245 lines
11 KiB
Python
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))
|