ascii-art/render.py

528 lines
25 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 PIL import Image, 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
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."""
# 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")
# Monospace chars in browser: ~0.6 width-to-height ratio at font-size 8px, line-height 1.05
# So each char cell is roughly 4.8px wide x 8.4px tall → ratio 0.57
char_aspect = 0.55
aspect = img.height / img.width
height = max(1, int(width * aspect * char_aspect))
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
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 + " "
color = f"{r},{g},{b}"
if color == prev_color:
run_chars.append(html_escape(char))
else:
if run_chars:
line_parts.append(f'<span style="color:rgb({prev_color})">{"".join(run_chars)}</span>')
run_chars = [html_escape(char)]
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)
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
# ── 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 generate_pattern(
pattern_type: str = "plasma",
width: int = 200,
height: int = 80,
palette_name: str = "wingdings",
output_format: str = "html",
seed: int | None = None,
) -> 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)
# 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)
phase2 = rng.uniform(0, math.tau)
phase3 = rng.uniform(0, math.tau)
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":
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</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 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))