feat: imgage feature

This commit is contained in:
2026-04-01 12:31:23 +02:00
parent 175de893aa
commit 528d2d8519
11 changed files with 431 additions and 7 deletions

267
backend/uframe/imaging.py Normal file
View File

@@ -0,0 +1,267 @@
"""µFrame Image Converter — convert images to character art.
Supports braille, block, ascii, and halfblock rendering modes
with optional dithering and color output.
"""
from __future__ import annotations
import os
from pathlib import Path
from uframe.chars import BRAILLE_BASE, BRAILLE_LEFT, BRAILLE_RIGHT
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ASCII brightness ramp (light → dark)
ASCII_RAMP = " .:-=+*#%@"
# Block shade ramp (light → dark)
BLOCK_RAMP = " ░▒▓█"
def _load_image(path: str) -> "Image.Image":
"""Load an image from file path."""
if not HAS_PIL:
raise ImportError("Pillow is required for image conversion: pip install Pillow")
resolved = Path(path).expanduser()
if not resolved.is_file():
raise FileNotFoundError(f"Image not found: {path}")
return Image.open(str(resolved))
def _floyd_steinberg(pixels: list[list[float]], w: int, h: int, levels: int = 2) -> list[list[int]]:
"""Apply Floyd-Steinberg dithering to a grayscale pixel array.
Returns quantized values in range [0, levels-1].
"""
result = [[0] * w for _ in range(h)]
err = [row[:] for row in pixels] # copy
for y in range(h):
for x in range(w):
old = err[y][x]
new = round(old * (levels - 1)) / (levels - 1) if levels > 1 else (1.0 if old > 0.5 else 0.0)
result[y][x] = int(round(new * (levels - 1)))
quant_err = old - new
if x + 1 < w:
err[y][x + 1] += quant_err * 7 / 16
if y + 1 < h:
if x - 1 >= 0:
err[y + 1][x - 1] += quant_err * 3 / 16
err[y + 1][x] += quant_err * 5 / 16
if x + 1 < w:
err[y + 1][x + 1] += quant_err * 1 / 16
return result
def convert_braille(img: "Image.Image", width: int, dither: str = "floyd",
invert: bool = False) -> list[str]:
"""Convert image to braille character art.
Each character encodes a 2×4 pixel block. Resolution: 2x horizontal, 4x vertical.
"""
# Resize: each output char = 2 pixels wide × 4 pixels tall
pixel_w = width * 2
aspect = img.height / img.width
pixel_h = int(pixel_w * aspect / 2) # /2 for terminal cell aspect
pixel_h = max(pixel_h, 4)
# Round up to multiple of 4
pixel_h = ((pixel_h + 3) // 4) * 4
img_resized = img.resize((pixel_w, pixel_h)).convert("L")
# Get pixel data as 0.01.0 floats
pixels = []
for y in range(pixel_h):
row = []
for x in range(pixel_w):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
row.append(v)
pixels.append(row)
# Dither to binary
if dither == "floyd":
binary = _floyd_steinberg(pixels, pixel_w, pixel_h, levels=2)
else:
binary = [[1 if p > 0.5 else 0 for p in row] for row in pixels]
# Map 2×4 blocks to braille characters
lines: list[str] = []
for by in range(0, pixel_h, 4):
line = ""
for bx in range(0, pixel_w, 2):
code = BRAILLE_BASE
for row in range(4):
py = by + row
if py < pixel_h:
# Left column
px_l = bx
if px_l < pixel_w and binary[py][px_l]:
code |= BRAILLE_LEFT[row]
# Right column
px_r = bx + 1
if px_r < pixel_w and binary[py][px_r]:
code |= BRAILLE_RIGHT[row]
line += chr(code)
lines.append(line)
return lines
def convert_block(img: "Image.Image", width: int, dither: str = "none",
invert: bool = False) -> list[str]:
"""Convert image to block shade characters (░▒▓█)."""
aspect = img.height / img.width
height = max(1, int(width * aspect / 2)) # /2 for terminal cell aspect
img_resized = img.resize((width, height)).convert("L")
pixels = []
for y in range(height):
row = []
for x in range(width):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
row.append(v)
pixels.append(row)
if dither == "floyd":
quantized = _floyd_steinberg(pixels, width, height, levels=len(BLOCK_RAMP))
else:
quantized = [[int(p * (len(BLOCK_RAMP) - 1)) for p in row] for row in pixels]
lines: list[str] = []
for row in quantized:
line = "".join(BLOCK_RAMP[min(v, len(BLOCK_RAMP) - 1)] for v in row)
lines.append(line)
return lines
def convert_ascii(img: "Image.Image", width: int, dither: str = "none",
invert: bool = False) -> list[str]:
"""Convert image to classic ASCII art using brightness ramp."""
aspect = img.height / img.width
height = max(1, int(width * aspect / 2))
img_resized = img.resize((width, height)).convert("L")
lines: list[str] = []
for y in range(height):
line = ""
for x in range(width):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
idx = int(v * (len(ASCII_RAMP) - 1))
line += ASCII_RAMP[min(idx, len(ASCII_RAMP) - 1)]
lines.append(line)
return lines
def convert_halfblock(img: "Image.Image", width: int,
invert: bool = False, use_color: bool = False) -> list[tuple[str, str | None, str | None]]:
"""Convert image to half-block characters with optional color.
Uses ▄ with foreground (bottom pixel) and background (top pixel) colors.
Returns list of (line_text, fg_colors, bg_colors) tuples.
Each fg/bg color string has one 3-digit hex per character, or None for mono.
"""
aspect = img.height / img.width
height = max(2, int(width * aspect / 2))
# Round up to even
height = height + (height % 2)
img_resized = img.resize((width, height))
if use_color:
img_rgb = img_resized.convert("RGB")
img_gray = img_resized.convert("L")
lines: list[tuple[str, str | None, str | None]] = []
for y in range(0, height, 2):
chars = ""
fgs = "" if use_color else None
bgs = "" if use_color else None
for x in range(width):
top_v = img_gray.getpixel((x, y)) / 255.0
bot_v = img_gray.getpixel((x, y + 1)) / 255.0 if y + 1 < height else 0
if invert:
top_v = 1.0 - top_v
bot_v = 1.0 - bot_v
if use_color:
top_rgb = img_rgb.getpixel((x, y))
bot_rgb = img_rgb.getpixel((x, y + 1)) if y + 1 < height else (0, 0, 0)
# Quantize to 3-digit hex
fg_hex = f"{round(bot_rgb[0]*15/255):x}{round(bot_rgb[1]*15/255):x}{round(bot_rgb[2]*15/255):x}"
bg_hex = f"{round(top_rgb[0]*15/255):x}{round(top_rgb[1]*15/255):x}{round(top_rgb[2]*15/255):x}"
fgs += fg_hex
bgs += bg_hex
chars += ""
lines.append((chars, fgs, bgs))
return lines
def convert_image(path: str, mode: str = "braille", width: int = 30,
dither: str = "floyd", invert: bool = False,
use_color: bool = False) -> list[str]:
"""High-level image conversion — returns list of character art lines.
Args:
path: image file path
mode: "braille", "block", "ascii", "halfblock"
width: output width in characters
dither: "floyd", "threshold", "none"
invert: flip light/dark
use_color: preserve colors (halfblock only for now)
Returns:
List of strings, one per output line.
"""
img = _load_image(path)
if mode == "braille":
return convert_braille(img, width, dither, invert)
elif mode == "block":
return convert_block(img, width, dither, invert)
elif mode == "ascii":
return convert_ascii(img, width, dither, invert)
elif mode == "halfblock":
hb_lines = convert_halfblock(img, width, invert, use_color)
# For non-color mode, just return the character strings
return [line[0] for line in hb_lines]
else:
return convert_braille(img, width, dither, invert)
def get_image_height(path: str, mode: str = "braille", width: int = 30) -> int:
"""Calculate the output height for an image without full conversion."""
try:
img = _load_image(path)
except (ImportError, FileNotFoundError):
return 1
aspect = img.height / img.width
if mode == "braille":
pixel_h = int(width * 2 * aspect / 2)
return max(1, ((pixel_h + 3) // 4))
else:
return max(1, int(width * aspect / 2))