ascii-art/render.py

245 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Colorful Unicode/Wingdings ASCII Art Renderer
Converts images and GIFs to terminal art using various character sets
with true-color ANSI output, Floyd-Steinberg dithering, and multiple output formats.
"""
import argparse
from html import escape as html_escape
from PIL import Image, ImageSequence
# ── Character palettes sorted dark → light ──────────────────────────────
PALETTES = {
"wingdings": "♠♣♦♥✦✧◆◇○●◐◑▲△▼▽★☆✪✫✿❀❁❃❋✾✽❖☀☁☂☃✈♛♚♞♜⚡⚛⚙",
"zodiac": "♈♉♊♋♌♍♎♏♐♑♒♓⛎☉☽☿♀♁♂♃♄♅♆⚳⚴⚵⚶⚷",
"chess": "♔♕♖♗♘♙♚♛♜♝♞♟⬛⬜◼◻▪▫",
"arrows": "↖↗↘↙⇐⇑⇒⇓⟵⟶⟷↺↻⤴⤵↯↮↭↬↫",
"music": "♩♪♫♬♭♮♯𝄞𝄡𝄢𝅗𝅥𝅘𝅥𝅘𝅥𝅮𝅘𝅥𝅯𝅘𝅥𝅰",
"braille": "⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⣀⣠⣤⣴⣶⣾⣿",
"blocks": " ░▒▓█▀▄▌▐▖▗▘▙▚▛▜▝▞▟",
"emoji": "🌑🌒🌓🌔🌕✨💫⭐🌟💎🔮🔥💧🌊🌿🍀🌸🌺🌻🎭🎪",
"cosmic": "·∙∘○◌◯◎●◉⊙⊚⊛⊜⊝◐◑◒◓◔◕⦿✪★✦✧❂☀☼",
"mystic": "·‥…∴∵∶∷∸∹∺⊹✧✦★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋",
"runes": "ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯᚰᚱᚲᚳᚴᚵᚶᚷᚸᚹᚺᚻᚼᚽᚾᚿᛀᛁᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋᛌᛍ",
"dense": " .·:;+*#%@█",
"classic": " .:-=+*#%@",
# ── High-resolution palettes (70+ chars for fine gradation) ──
"hires": " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
"ultra": " .·:;'\"^~-_+<>!?|/\\()[]{}1iltfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$█",
"dots": "⠀⠁⠈⠐⠠⡀⢀⠃⠅⠆⠉⠊⠌⠑⠒⠔⠘⠡⠢⠤⠨⠰⡁⡂⡄⡈⡐⡠⢁⢂⢄⢈⢐⢠⣀⠇⠋⠍⠎⠓⠕⠖⠙⠚⠜⠣⠥⠦⠩⠪⠬⠱⠲⠴⠸⡃⡅⡆⡉⡊⡌⡑⡒⡔⡘⡡⡢⡤⡨⡰⢃⢅⢆⢉⢊⢌⢑⢒⢔⢘⢡⢢⢤⢨⢰⣁⣂⣄⣈⣐⣠⠏⠗⠛⠝⠞⠧⠫⠭⠮⠳⠵⠶⠹⠺⠼⡇⡋⡍⡎⡓⡕⡖⡙⡚⡜⡣⡥⡦⡩⡪⡬⡱⡲⡴⡸⢇⢋⢍⢎⢓⢕⢖⢙⢚⢜⢣⢥⢦⢩⢪⢬⢱⢲⢴⢸⣃⣅⣆⣉⣊⣌⣑⣒⣔⣘⣡⣢⣤⣨⣰⠟⠯⠷⠻⠽⠾⡏⡗⡛⡝⡞⡧⡫⡭⡮⡳⡵⡶⡹⡺⡼⢏⢗⢛⢝⢞⢧⢫⢭⢮⢳⢵⢶⢹⢺⢼⣇⣋⣍⣎⣓⣕⣖⣙⣚⣜⣣⣥⣦⣩⣪⣬⣱⣲⣴⣸⠿⡟⡯⡷⡻⡽⡾⢟⢯⢷⢻⢽⢾⣏⣗⣛⣝⣞⣧⣫⣭⣮⣳⣵⣶⣹⣺⣼⡿⢿⣟⣯⣷⣻⣽⣾⣿",
"shades": " ░░▒▒▓▓██",
"geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋",
"kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全",
}
MAX_GIF_FRAMES = 60
def get_luminance(r: int, g: int, b: int) -> float:
"""Perceived luminance (0-255)."""
return 0.299 * r + 0.587 * g + 0.114 * b
def _prepare_frame(img: Image.Image, width: int, bg: str) -> Image.Image:
"""Composite alpha onto background and resize for rendering."""
img = img.convert("RGBA")
bg_color = (0, 0, 0, 255) if bg == "dark" else (255, 255, 255, 255)
background = Image.new("RGBA", img.size, bg_color)
background.paste(img, mask=img.split()[3])
img = background.convert("RGB")
aspect = img.height / img.width
height = max(1, int(width * aspect * 0.45))
return img.resize((width, height), Image.LANCZOS)
def _floyd_steinberg_dither(pixels: list[list[float]], width: int, height: int, levels: int) -> list[list[float]]:
"""Apply Floyd-Steinberg dithering to a 2D luminance array."""
step = 255.0 / max(levels - 1, 1)
for y in range(height):
for x in range(width):
old = pixels[y][x]
new = round(old / step) * step
new = max(0.0, min(255.0, new))
pixels[y][x] = new
err = old - new
if x + 1 < width:
pixels[y][x + 1] += err * 7 / 16
if y + 1 < height:
if x - 1 >= 0:
pixels[y + 1][x - 1] += err * 3 / 16
pixels[y + 1][x] += err * 5 / 16
if x + 1 < width:
pixels[y + 1][x + 1] += err * 1 / 16
return pixels
def _render_frame(
img: Image.Image,
chars: str,
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Render a single prepared (resized RGB) frame to art string."""
width, height = img.size
# Build luminance grid
lum_grid = []
for y in range(height):
row = []
for x in range(width):
r, g, b = img.getpixel((x, y))
row.append(get_luminance(r, g, b))
lum_grid.append(row)
if dither:
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, len(chars))
lines = []
for y in range(height):
line = []
for x in range(width):
r, g, b = img.getpixel((x, y))
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = int(lum / 256 * len(chars))
idx = min(idx, len(chars) - 1)
char = chars[idx]
if double_width:
char = char + " "
if output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
elif output_format == "html":
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>')
else: # plain
line.append(char)
lines.append("".join(line))
return "\n".join(lines)
def image_to_art(
image_path: str,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Convert a static image to colorful Unicode art."""
img = Image.open(image_path)
img = _prepare_frame(img, width, bg)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(img, chars, double_width, dither, output_format)
def gif_to_art(
image_path: str,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> list[dict]:
"""Convert an animated GIF to a list of frame dicts with art and duration."""
img = Image.open(image_path)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
frames = []
frame_list = list(ImageSequence.Iterator(img))
# Sample evenly if too many frames
if len(frame_list) > MAX_GIF_FRAMES:
step = len(frame_list) / MAX_GIF_FRAMES
indices = [int(i * step) for i in range(MAX_GIF_FRAMES)]
frame_list = [frame_list[i] for i in indices]
for frame in frame_list:
duration = frame.info.get("duration", 100)
prepared = _prepare_frame(frame.copy(), width, bg)
art = _render_frame(prepared, chars, double_width, dither, output_format)
frames.append({"art": art, "duration": max(duration, 20)})
return frames
def render_from_pil(
img: Image.Image,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "ansi",
) -> str:
"""Render from a PIL Image object directly."""
prepared = _prepare_frame(img, width, bg)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
return _render_frame(prepared, chars, double_width, dither, output_format)
def is_animated_gif(image_path: str) -> bool:
"""Check if a file is an animated GIF."""
try:
img = Image.open(image_path)
return getattr(img, "is_animated", False)
except Exception:
return False
def render_demo(image_path: str, width: int = 70):
"""Render the same image in multiple palettes for comparison."""
from rich.console import Console
from rich.panel import Panel
console = Console(force_terminal=True, color_system="truecolor", width=width + 10)
demos = ["wingdings", "cosmic", "braille", "runes", "mystic", "blocks"]
for pal in demos:
art = image_to_art(image_path, width=width, palette_name=pal)
console.print(Panel(
art,
title=f"[bold bright_cyan]{pal}[/]",
border_style="bright_blue",
width=width + 4,
))
console.print()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Image to colorful Unicode art")
parser.add_argument("image", help="Path to image file")
parser.add_argument("-w", "--width", type=int, default=80, help="Output width in chars")
parser.add_argument("-p", "--palette", default="wingdings", choices=list(PALETTES.keys()),
help="Character palette to use")
parser.add_argument("--bg", default="dark", choices=["dark", "light"],
help="Background color assumption")
parser.add_argument("--double", action="store_true", help="Double-width characters")
parser.add_argument("--dither", action="store_true", help="Enable Floyd-Steinberg dithering")
parser.add_argument("--format", default="ansi", choices=["ansi", "html", "plain"],
help="Output format")
parser.add_argument("--demo", action="store_true", help="Render in all palettes")
args = parser.parse_args()
if args.demo:
render_demo(args.image, args.width)
elif is_animated_gif(args.image):
frames = gif_to_art(args.image, args.width, args.palette, args.bg,
args.double, args.dither, args.format)
import time
try:
while True:
for frame in frames:
print("\033[H\033[J" + frame["art"], flush=True)
time.sleep(frame["duration"] / 1000)
except KeyboardInterrupt:
pass
else:
print(image_to_art(args.image, args.width, args.palette, args.bg,
args.double, args.dither, args.format))