493 lines
23 KiB
Python
493 lines
23 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
|
|
|
|
# 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
|
|
|
|
|
|
# ── 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))
|