268 lines
8.6 KiB
Python
268 lines
8.6 KiB
Python
"""µ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))
|