"""µ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.0–1.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))