ascii-art/render.py

923 lines
40 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
import math
import random
from html import escape as html_escape
from io import BytesIO
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps, 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": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全",
# ── Exotic palettes ──
"thai": " กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ",
"arabic": " ﺎﺏﺕﺙﺝﺡﺥﺩﺫﺭﺯﺱﺵﺹﺽﻁﻅﻉﻍﻑﻕﻙﻝﻡﻥﻩﻭﻱ",
"devanagari": " ँंःअआइईउऊऋएऐओऔकखगघचछजझटठडढणतथदधनपफबभमयरलवशषसह",
"ethiopic": " ሀሁሂሃሄህሆለሉሊላሌልሎሐሑሒሓሔሕሖመሙሚማሜምሞሰሱሲሳሴስሶረሩሪራሬርሮሸሹሺሻሼሽሾቀቁቂቃቄቅቆበቡቢባቤብቦተቱቲታቴትቶ",
"georgian": " აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ",
"tibetan": " ༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗༘༙༚༛༜༝༞༟༠༡༢༣༤༥༦༧༨༩",
"hieroglyph": " 𓀀𓀁𓀂𓀃𓀄𓀅𓀆𓀇𓀈𓀉𓀊𓀋𓀌𓀍𓀎𓀏𓀐𓀑𓀒𓀓𓀔𓀕𓀖𓀗𓀘𓀙𓀚𓀛𓀜𓀝𓀞𓀟𓀠𓀡𓀢𓀣𓀤𓀥𓀦𓀧𓀨𓀩𓀪𓀫𓀬𓀭𓀮𓀯𓀰",
"cuneiform": " 𒀀𒀁𒀂𒀃𒀄𒀅𒀆𒀇𒀈𒀉𒀊𒀋𒀌𒀍𒀎𒀏𒀐𒀑𒀒𒀓𒀔𒀕𒀖𒀗𒀘𒀙𒀚𒀛𒀜𒀝𒀞𒀟𒀠𒀡𒀢𒀣𒀤𒀥𒀦𒀧𒀨𒀩𒀪𒀫𒀬𒀭",
"alchemical": " 🜀🜁🜂🜃🜄🜅🜆🜇🜈🜉🜊🜋🜌🜍🜎🜏🜐🜑🜒🜓🜔🜕🜖🜗🜘🜙🜚🜛🜜🜝🜞🜟🜠🜡🜢🜣🜤🜥🜦🜧🜨🜩🜪🜫🜬🜭🜮🜯🜰🜱🜲🜳🜴🜵🜶🜷🜸🜹🜺🜻🜼🜽🜾🜿",
"dominos": " 🁣🁤🁥🁦🁧🁨🁩🁪🁫🁬🁭🁮🁯🁰🁱🁲🁳🁴🁵🁶🁷🁸🁹🁺🁻🁼🁽🁾🁿🂀🂁🂂🂃🂄🂅🂆🂇🂈🂉🂊🂋🂌🂍🂎🂏🂐🂑🂒🂓",
"mahjong": " 🀀🀁🀂🀃🀄🀅🀆🀇🀈🀉🀊🀋🀌🀍🀎🀏🀐🀑🀒🀓🀔🀕🀖🀗🀘🀙🀚🀛🀜🀝🀞🀟🀠🀡",
"dingbats": " ✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❘❙❚❛❜❝❞",
"playing": " 🂡🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂬🂭🂮🂱🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂼🂽🂾🃁🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃌🃍🃎🃑🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃜🃝🃞",
"yijing": " ☰☱☲☳☴☵☶☷䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿",
"box": " ─│┌┐└┘├┤┬┴┼╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳",
"math": " ∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅",
"flora": " 🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓",
"weather": " 🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠",
}
MAX_GIF_FRAMES = 60
# Palettes with double-width characters (emoji, CJK supplementary, etc.)
WIDE_PALETTES = {
"emoji", "flora", "weather", "dominos", "mahjong", "playing",
"hieroglyph", "cuneiform", "alchemical",
}
# Palettes whose glyphs aren't reliably monospaced in JetBrains Mono.
# These need fixed-width cells in HTML output to maintain grid alignment.
VARIABLE_WIDTH_PALETTES = WIDE_PALETTES | {
"wingdings", "zodiac", "chess", "arrows", "music", "cosmic", "mystic",
"runes", "geometric", "kanji", "thai", "arabic", "devanagari", "ethiopic",
"georgian", "tibetan", "hieroglyph", "cuneiform", "alchemical", "dingbats",
"yijing", "math", "flora", "weather",
}
def is_wide_palette(name: str) -> bool:
"""Check if a palette uses double-width (emoji/supplementary) characters."""
return name in WIDE_PALETTES
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, wide_chars: bool = False) -> Image.Image:
"""Composite alpha onto background and resize for rendering."""
# Apply EXIF orientation (phone photos come rotated without this)
img = ImageOps.exif_transpose(img)
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")
if wide_chars:
# Emoji/wide chars are ~2x the width of normal monospace chars
# so halve the render width to maintain correct proportions,
# and use a squarer aspect since each char cell is nearly square
render_width = max(1, width // 2)
char_aspect = 1.0
else:
render_width = width
# Monospace chars: ~0.55 width-to-height ratio at 5px font, line-height 1.0
char_aspect = 0.55
aspect = img.height / img.width
height = max(1, int(render_width * aspect * char_aspect))
return img.resize((render_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",
fixed_width_cells: bool = False,
) -> str:
"""Render a single prepared (resized RGB) frame to art string."""
width, height = img.size
num_chars = len(chars)
pixels = img.load()
# Build luminance grid using fast pixel access
lum_grid = [[0.0] * width for _ in range(height)]
for y in range(height):
for x in range(width):
r, g, b = pixels[x, y]
lum_grid[y][x] = 0.299 * r + 0.587 * g + 0.114 * b
if dither:
lum_grid = _floyd_steinberg_dither(lum_grid, width, height, num_chars)
if output_format == "html":
# Optimized HTML: use CSS classes for color grouping
# Build line strings directly to reduce object creation
parts = []
for y in range(height):
line_parts = []
prev_color = None
run_chars = []
for x in range(width):
r, g, b = pixels[x, y]
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
escaped = html_escape(char)
if fixed_width_cells:
# Wrap each char in a fixed-width cell so non-monospace
# glyphs don't break the grid alignment
escaped = f'<span class="c">{escaped}</span>'
color = f"{r},{g},{b}"
if color == prev_color:
run_chars.append(escaped)
else:
if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
run_chars = [escaped]
prev_color = color
if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
parts.append("".join(line_parts))
return "\n".join(parts)
elif output_format == "ansi":
lines = []
for y in range(height):
line = []
for x in range(width):
r, g, b = pixels[x, y]
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
lines.append("".join(line))
return "\n".join(lines)
else: # plain
lines = []
for y in range(height):
line = []
for x in range(width):
lum = max(0.0, min(255.0, lum_grid[y][x]))
idx = min(int(lum / 256 * num_chars), num_chars - 1)
char = chars[idx]
if double_width:
char = char + " "
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)
wide = is_wide_palette(palette_name)
img = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
return _render_frame(img, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
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)
wide = is_wide_palette(palette_name)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
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, wide_chars=wide)
art = _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
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."""
wide = is_wide_palette(palette_name)
prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
return _render_frame(prepared, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
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
# ── Pattern Generators ──────────────────────────────────────────────────
PATTERN_TYPES = [
"plasma", "mandelbrot", "spiral", "waves", "nebula",
"kaleidoscope", "aurora", "lava", "crystals", "fractal_tree",
]
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
"""HSV (0-1 range) to RGB (0-255)."""
if s == 0:
c = int(v * 255)
return c, c, c
h6 = h * 6.0
i = int(h6)
f = h6 - i
p = int(v * (1 - s) * 255)
q = int(v * (1 - s * f) * 255)
t = int(v * (1 - s * (1 - f)) * 255)
vi = int(v * 255)
if i == 0: return vi, t, p
if i == 1: return q, vi, p
if i == 2: return p, vi, t
if i == 3: return p, q, vi
if i == 4: return t, p, vi
return vi, p, q
def _rgb_to_hsv(r: int, g: int, b: int) -> tuple[float, float, float]:
"""RGB (0-255) to HSV (0-1 range)."""
r1, g1, b1 = r / 255.0, g / 255.0, b / 255.0
mx = max(r1, g1, b1)
mn = min(r1, g1, b1)
diff = mx - mn
if diff == 0:
h = 0.0
elif mx == r1:
h = ((g1 - b1) / diff) % 6 / 6.0
elif mx == g1:
h = ((b1 - r1) / diff + 2) / 6.0
else:
h = ((r1 - g1) / diff + 4) / 6.0
s = 0.0 if mx == 0 else diff / mx
return h, s, mx
# ── Image Animation Effects ───────────────────────────────────────────
EFFECT_NAMES = ["color_cycle", "wave", "typewriter", "pulse", "glitch"]
def _effect_color_cycle(img: Image.Image, t: float) -> Image.Image:
"""Rotate hue by t * 360 degrees."""
pixels = img.load()
w, h = img.size
out = img.copy()
out_px = out.load()
shift = t % 1.0
for y in range(h):
for x in range(w):
r, g, b = pixels[x, y]
hue, sat, val = _rgb_to_hsv(r, g, b)
hue = (hue + shift) % 1.0
out_px[x, y] = _hsv_to_rgb(hue, sat, val)
return out
def _effect_wave(img: Image.Image, t: float) -> Image.Image:
"""Sine-wave horizontal row displacement."""
w, h = img.size
out = Image.new("RGB", (w, h), (0, 0, 0))
pixels = img.load()
out_px = out.load()
for y in range(h):
shift = int(math.sin(y * 0.3 + t * math.tau) * 3)
for x in range(w):
sx = (x + shift) % w
out_px[x, y] = pixels[sx, y]
return out
def _effect_typewriter(img: Image.Image, t: float) -> Image.Image:
"""Progressive row reveal."""
w, h = img.size
out = Image.new("RGB", (w, h), (0, 0, 0))
visible_rows = max(1, int(t * h))
out.paste(img.crop((0, 0, w, min(visible_rows, h))), (0, 0))
return out
def _effect_pulse(img: Image.Image, t: float) -> Image.Image:
"""Brightness oscillation."""
factor = 0.5 + 0.5 * math.sin(t * math.tau)
factor = 0.3 + factor * 0.9 # range 0.3 to 1.2
enhancer = ImageEnhance.Brightness(img)
return enhancer.enhance(factor)
def _effect_glitch(img: Image.Image, t: float) -> Image.Image:
"""Seeded random row shifts + channel swaps."""
w, h = img.size
rng = random.Random(int(t * 1000))
out = img.copy()
pixels = img.load()
out_px = out.load()
# Random row shifts
for _ in range(max(1, h // 8)):
y = rng.randint(0, h - 1)
shift = rng.randint(-w // 4, w // 4)
block_h = rng.randint(1, max(1, h // 10))
for dy in range(block_h):
yy = y + dy
if yy >= h:
break
for x in range(w):
sx = (x + shift) % w
r, g, b = pixels[sx, yy]
# Occasional channel swap
if rng.random() < 0.3:
r, g, b = g, b, r
out_px[x, yy] = (r, g, b)
return out
_EFFECTS = {
"color_cycle": _effect_color_cycle,
"wave": _effect_wave,
"typewriter": _effect_typewriter,
"pulse": _effect_pulse,
"glitch": _effect_glitch,
}
def animate_image(
image_path: str,
width: int = 80,
palette_name: str = "wingdings",
bg: str = "dark",
double_width: bool = False,
dither: bool = False,
output_format: str = "html",
effect: str = "color_cycle",
num_frames: int = 20,
frame_duration: int = 100,
return_pil: bool = False,
) -> list[dict]:
"""Apply an animation effect to a static image, returning frame dicts."""
img = Image.open(image_path)
wide = is_wide_palette(palette_name)
prepared = _prepare_frame(img, width, bg, wide_chars=wide)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
effect_fn = _EFFECTS.get(effect, _effect_color_cycle)
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
frames = []
for i in range(num_frames):
t = i / num_frames
effected = effect_fn(prepared, t)
art = _render_frame(effected, chars, double_width, dither, output_format, fixed_width_cells=fix_cells)
frame = {"art": art, "duration": frame_duration}
if return_pil:
frame["pil_frame"] = effected
frames.append(frame)
return frames
# ── Pattern Animation ─────────────────────────────────────────────────
def generate_pattern(
pattern_type: str = "plasma",
width: int = 200,
height: int = 80,
palette_name: str = "wingdings",
output_format: str = "html",
seed: int | None = None,
t_offset: float = 0.0,
) -> str:
"""Generate a colorful pattern as Unicode art."""
if seed is None:
seed = random.randint(0, 2**31)
rng = random.Random(seed)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
num_chars = len(chars)
fix_cells = palette_name in VARIABLE_WIDTH_PALETTES and output_format == "html"
# Random parameters for variety
freq1 = rng.uniform(0.02, 0.12)
freq2 = rng.uniform(0.01, 0.08)
freq3 = rng.uniform(0.03, 0.15)
phase1 = rng.uniform(0, math.tau) + t_offset * math.tau
phase2 = rng.uniform(0, math.tau) + t_offset * math.tau * 0.7
phase3 = rng.uniform(0, math.tau) + t_offset * math.tau * 1.3
hue_offset = rng.uniform(0, 1)
hue_speed = rng.uniform(0.002, 0.02)
cx = width * rng.uniform(0.3, 0.7)
cy = height * rng.uniform(0.3, 0.7)
spirals = rng.randint(2, 8)
zoom = rng.uniform(0.5, 3.0)
lines = []
for y in range(height):
line = []
for x in range(width):
# Compute value (0-1) and hue based on pattern type
if pattern_type == "plasma":
v1 = math.sin(x * freq1 + phase1)
v2 = math.sin(y * freq2 + phase2)
v3 = math.sin((x + y) * freq3 + phase3)
v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1)
val = (v1 + v2 + v3 + v4) / 4.0
val = (val + 1) / 2 # normalize to 0-1
hue = (val * 3 + hue_offset + x * hue_speed) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.4 + 0.6 * val
elif pattern_type == "mandelbrot":
# Map to complex plane
re = (x - width * 0.65) / (width * 0.3) * zoom
im = (y - height * 0.5) / (height * 0.5) * zoom
c = complex(re, im)
z = complex(0, 0)
iteration = 0
max_iter = 80
while abs(z) < 4 and iteration < max_iter:
z = z * z + c
iteration += 1
if iteration == max_iter:
val = 0
hue = 0
sat = 0
bri = 0.05
else:
val = iteration / max_iter
hue = (val * 5 + hue_offset) % 1.0
sat = 0.9
bri = 0.3 + 0.7 * val
elif pattern_type == "spiral":
dx = x - cx
dy = (y - cy) * 2.2 # aspect correction
dist = math.sqrt(dx**2 + dy**2)
angle = math.atan2(dy, dx)
val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2
hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0
sat = 0.8 + 0.2 * math.sin(dist * 0.1)
bri = 0.3 + 0.7 * val
elif pattern_type == "waves":
v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1)
v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2)
v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1)
val = (v1 + v2 + v3 + 3) / 6
hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0
sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2))
bri = 0.3 + 0.7 * val
elif pattern_type == "nebula":
# Layered noise-like patterns
v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2)
v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3)
v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2)
val = (v1 + v2 + v3 + v4 + 4) / 8
hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.9 * val ** 0.7
elif pattern_type == "kaleidoscope":
# Mirror and rotate
dx = x - cx
dy = (y - cy) * 2.2
angle = math.atan2(dy, dx)
dist = math.sqrt(dx**2 + dy**2)
# Fold angle into segments
segments = spirals
angle = abs(((angle % (math.tau / segments)) - math.pi / segments))
nx = dist * math.cos(angle)
ny = dist * math.sin(angle)
v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2)
v2 = math.sin(dist * freq3 + phase3)
val = (v1 + v2 + 2) / 4
hue = (dist * hue_speed + val + hue_offset) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.2 + 0.8 * val
elif pattern_type == "aurora":
wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8
dist_from_wave = abs(y - height * 0.4 - wave)
val = max(0, 1 - dist_from_wave / (height * 0.4))
val = val ** 0.5
hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0
sat = 0.6 + 0.4 * val
bri = 0.05 + 0.95 * val
elif pattern_type == "lava":
v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1)
v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3)
val = (v1 * v2 + v3 + 2) / 4
val = val ** 1.5 # push towards extremes
hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 # reds to oranges
sat = 0.8 + 0.2 * val
bri = 0.15 + 0.85 * val
elif pattern_type == "crystals":
# Voronoi-like
min_dist = float('inf')
min_dist2 = float('inf')
# Use fixed seed points
for i in range(12):
px = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width
py = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height
d = math.sqrt((x - px)**2 + ((y - py) * 2.2)**2)
if d < min_dist:
min_dist2 = min_dist
min_dist = d
elif d < min_dist2:
min_dist2 = d
# Reset rng state
rng = random.Random(seed)
_ = [rng.uniform(0, 1) for _ in range(20)] # burn values to resync
edge = min_dist2 - min_dist
val = min(1, edge / 20)
hue = (min_dist * hue_speed + hue_offset) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.2 + 0.8 * val
elif pattern_type == "fractal_tree":
# Barnsley fern inspired
nx = (x - cx) / (width * 0.3)
ny = (height - y) / (height * 0.8)
v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2)
v2 = math.sin((nx * ny) * 5 + phase3)
v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50)
val = (v1 + v2 + v3 + 3) / 6
hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0 # greens
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8))
else:
val = 0.5
hue = 0.5
sat = 0.5
bri = 0.5
r, g, b = _hsv_to_rgb(hue, sat, bri)
lum = get_luminance(r, g, b)
idx = int(lum / 256 * num_chars)
idx = min(idx, num_chars - 1)
char = chars[idx]
if output_format == "html":
escaped = html_escape(char)
if fix_cells:
escaped = f'<span class="c">{escaped}</span>'
line.append(f'<span style="color:rgb({r},{g},{b})">{escaped}</span>')
elif output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
else:
line.append(char)
lines.append("".join(line))
return "\n".join(lines)
def _generate_pattern_pil(
pattern_type: str = "plasma",
width: int = 200,
height: int = 80,
seed: int | None = None,
t_offset: float = 0.0,
) -> Image.Image:
"""Generate a pattern as a PIL Image (shares math with generate_pattern)."""
if seed is None:
seed = random.randint(0, 2**31)
rng = random.Random(seed)
freq1 = rng.uniform(0.02, 0.12)
freq2 = rng.uniform(0.01, 0.08)
freq3 = rng.uniform(0.03, 0.15)
phase1 = rng.uniform(0, math.tau) + t_offset * math.tau
phase2 = rng.uniform(0, math.tau) + t_offset * math.tau * 0.7
phase3 = rng.uniform(0, math.tau) + t_offset * math.tau * 1.3
hue_offset = rng.uniform(0, 1)
hue_speed = rng.uniform(0.002, 0.02)
cx = width * rng.uniform(0.3, 0.7)
cy = height * rng.uniform(0.3, 0.7)
spirals = rng.randint(2, 8)
zoom = rng.uniform(0.5, 3.0)
img = Image.new("RGB", (width, height))
px = img.load()
for y in range(height):
for x in range(width):
if pattern_type == "plasma":
v1 = math.sin(x * freq1 + phase1)
v2 = math.sin(y * freq2 + phase2)
v3 = math.sin((x + y) * freq3 + phase3)
v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1)
val = (v1 + v2 + v3 + v4) / 4.0
val = (val + 1) / 2
hue = (val * 3 + hue_offset + x * hue_speed) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.4 + 0.6 * val
elif pattern_type == "mandelbrot":
re = (x - width * 0.65) / (width * 0.3) * zoom
im = (y - height * 0.5) / (height * 0.5) * zoom
c = complex(re, im)
z = complex(0, 0)
iteration = 0
max_iter = 80
while abs(z) < 4 and iteration < max_iter:
z = z * z + c
iteration += 1
if iteration == max_iter:
val, hue, sat, bri = 0, 0, 0, 0.05
else:
val = iteration / max_iter
hue = (val * 5 + hue_offset) % 1.0
sat = 0.9
bri = 0.3 + 0.7 * val
elif pattern_type == "spiral":
dx = x - cx
dy = (y - cy) * 2.2
dist = math.sqrt(dx**2 + dy**2)
angle = math.atan2(dy, dx)
val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2
hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0
sat = 0.8 + 0.2 * math.sin(dist * 0.1)
bri = 0.3 + 0.7 * val
elif pattern_type == "waves":
v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1)
v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2)
v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1)
val = (v1 + v2 + v3 + 3) / 6
hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0
sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2))
bri = 0.3 + 0.7 * val
elif pattern_type == "nebula":
v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2)
v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3)
v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2)
val = (v1 + v2 + v3 + v4 + 4) / 8
hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.9 * val ** 0.7
elif pattern_type == "kaleidoscope":
dx = x - cx
dy = (y - cy) * 2.2
angle = math.atan2(dy, dx)
dist = math.sqrt(dx**2 + dy**2)
segments = spirals
angle = abs(((angle % (math.tau / segments)) - math.pi / segments))
nx = dist * math.cos(angle)
ny = dist * math.sin(angle)
v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2)
v2 = math.sin(dist * freq3 + phase3)
val = (v1 + v2 + 2) / 4
hue = (dist * hue_speed + val + hue_offset) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.2 + 0.8 * val
elif pattern_type == "aurora":
wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8
dist_from_wave = abs(y - height * 0.4 - wave)
val = max(0, 1 - dist_from_wave / (height * 0.4))
val = val ** 0.5
hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0
sat = 0.6 + 0.4 * val
bri = 0.05 + 0.95 * val
elif pattern_type == "lava":
v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1)
v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3)
val = (v1 * v2 + v3 + 2) / 4
val = val ** 1.5
hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0
sat = 0.8 + 0.2 * val
bri = 0.15 + 0.85 * val
elif pattern_type == "crystals":
min_dist = float('inf')
min_dist2 = float('inf')
for i in range(12):
ppx = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width
ppy = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height
d = math.sqrt((x - ppx)**2 + ((y - ppy) * 2.2)**2)
if d < min_dist:
min_dist2 = min_dist
min_dist = d
elif d < min_dist2:
min_dist2 = d
rng = random.Random(seed)
_ = [rng.uniform(0, 1) for _ in range(20)]
edge = min_dist2 - min_dist
val = min(1, edge / 20)
hue = (min_dist * hue_speed + hue_offset) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.2 + 0.8 * val
elif pattern_type == "fractal_tree":
nx = (x - cx) / (width * 0.3)
ny = (height - y) / (height * 0.8)
v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2)
v2 = math.sin((nx * ny) * 5 + phase3)
v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50)
val = (v1 + v2 + v3 + 3) / 6
hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8))
else:
val, hue, sat, bri = 0.5, 0.5, 0.5, 0.5
px[x, y] = _hsv_to_rgb(hue, sat, bri)
return img
def animate_pattern(
pattern_type: str = "plasma",
width: int = 200,
height: int = 80,
palette_name: str = "wingdings",
output_format: str = "html",
seed: int | None = None,
num_frames: int = 20,
frame_duration: int = 100,
return_pil: bool = False,
) -> list[dict]:
"""Generate animated pattern frames by advancing t_offset."""
if seed is None:
seed = random.randint(0, 2**31)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
frames = []
for i in range(num_frames):
t = i / num_frames
art = generate_pattern(
pattern_type=pattern_type, width=width, height=height,
palette_name=palette_name, output_format=output_format,
seed=seed, t_offset=t,
)
frame = {"art": art, "duration": frame_duration}
if return_pil:
frame["pil_frame"] = _generate_pattern_pil(
pattern_type=pattern_type, width=width, height=height,
seed=seed, t_offset=t,
)
frames.append(frame)
return frames
def frames_to_gif(pil_frames: list[Image.Image], durations: list[int]) -> bytes:
"""Assemble PIL frames into an animated GIF, return bytes."""
buf = BytesIO()
# Convert to P mode (palette) for GIF compatibility
converted = []
for f in pil_frames:
# Ensure RGB, then quantize
rgb = f.convert("RGB")
converted.append(rgb.quantize(colors=256, method=Image.Quantize.MEDIANCUT))
converted[0].save(
buf,
format="GIF",
save_all=True,
append_images=converted[1:],
duration=durations,
loop=0,
optimize=False,
)
return buf.getvalue()
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))