Files
micronomicon/backend/uframe/imaging.py
2026-04-01 12:31:23 +02:00

268 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""µ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))