#!/usr/bin/env python3 """Turn a logo into half-block ANSI art for the header hero. The header hero is a handful of text rows, so an image has to survive a brutal downsample: two pixels per cell (upper/lower half block), one ANSI colour per half. Colours are quantised to the stock 16-colour ANSI palette on purpose — the rest of the UI never emits RGB, and a hero built from palette names still reads as the station's art in any terminal theme. Usage: python tools/ansi_art.py logo.png --cols 22 --rows 7 python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png \\ --cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py ``--preview out.png`` draws what the cells will look like, which is much easier to judge than a wall of ▀ and ▄. """ from __future__ import annotations import argparse import sys from pathlib import Path from PIL import Image, ImageDraw #: The stock xterm colours the names below resolve to in a terminal. ANSI16: dict[str, tuple[int, int, int]] = { "black": (0, 0, 0), "red": (170, 0, 0), "green": (0, 170, 0), "yellow": (170, 85, 0), "blue": (0, 0, 170), "magenta": (170, 0, 170), "cyan": (0, 170, 170), "white": (170, 170, 170), "bright_black": (85, 85, 85), "bright_red": (255, 85, 85), "bright_green": (85, 255, 85), "bright_yellow": (255, 255, 85), "bright_blue": (85, 85, 255), "bright_magenta": (255, 85, 255), "bright_cyan": (85, 255, 255), "bright_white": (255, 255, 255), } #: Below this alpha a half-cell counts as empty (terminal background). ALPHA_CUTOFF = 110 def nearest_colour(rgb: tuple[int, int, int]) -> str: best, best_distance = "white", float("inf") for name, reference in ANSI16.items(): distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True)) if distance < best_distance: best, best_distance = name, distance return best def load_image(path: Path | None, url: str | None) -> Image.Image: if path is None and url: import httpx response = httpx.get(url, follow_redirects=True, timeout=30.0) response.raise_for_status() from io import BytesIO image = Image.open(BytesIO(response.content)) elif path is not None: image = Image.open(path) else: # pragma: no cover - argparse enforces one of the two raise SystemExit("pass an image path or --url") image = image.convert("RGBA") # Trim the transparent margin: the art should fill the box it is given. opaque = image.getchannel("A").point(lambda value: 255 if value > 24 else 0) box = opaque.getbbox() return image.crop(box) if box else image def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]: """(glyph, foreground, background) per cell, two source pixels per cell.""" small = image.resize((cols, rows * 2), Image.LANCZOS) pixels = small.load() grid: list[list[tuple[str, str, str]]] = [] for row in range(rows): cells: list[tuple[str, str, str]] = [] for column in range(cols): top = pixels[column, row * 2] bottom = pixels[column, row * 2 + 1] top_ink = top[3] >= ALPHA_CUTOFF bottom_ink = bottom[3] >= ALPHA_CUTOFF if not top_ink and not bottom_ink: cells.append((" ", "", "")) elif top_ink and bottom_ink: cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3]))) elif top_ink: cells.append(("▀", nearest_colour(top[:3]), "")) else: cells.append(("▄", nearest_colour(bottom[:3]), "")) grid.append(cells) return grid def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]: """One rich-markup string per row, run-length encoded by style.""" lines: list[str] = [] for cells in grid: line: list[str] = [] index = 0 while index < len(cells): glyph, foreground, background = cells[index] if not background and glyph == " ": line.append(" ") index += 1 continue run = [glyph] index += 1 while index < len(cells) and cells[index] == (glyph, foreground, background): run.append(cells[index][0]) index += 1 style = " ".join( part for part in (foreground, f"on {background}" if background else "") if part ) body = "".join(run) line.append(f"[{style}]{body}[/]" if style else body) lines.append("".join(line).rstrip()) return lines def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None: width, height = cell image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24)) draw = ImageDraw.Draw(image) for row, cells in enumerate(grid): for column, (glyph, foreground, background) in enumerate(cells): x, y = column * width, row * height if background: draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background]) if glyph == " ": continue colour = ANSI16.get(foreground, (140, 140, 140)) if glyph == "▀": box = [x, y, x + width - 1, y + height // 2 - 1] else: box = [x, y + height // 2, x + width - 1, y + height - 1] draw.rectangle(box, fill=colour) image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path) MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand. {provenance} Regenerate with:: {command} Each row is rich markup; ``▀`` is a half block whose foreground is the top pixel and whose background is the pixel below it. Colours are stock ANSI palette names, so the art keeps the terminal's own palette. """ from __future__ import annotations HERO: tuple[str, ...] = ( {rows} ) ''' def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("image", nargs="?", type=Path, help="local image file") parser.add_argument("--url", help="fetch the image from this URL instead") parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)") parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)") parser.add_argument("--module", type=Path, help="write a Python module with HERO") parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells") parser.add_argument("--provenance", default="", help="source note for the module header") args = parser.parse_args(argv) image = load_image(args.image, args.url) grid = art_rows(image, args.cols, args.rows) rows = markup(grid) if args.preview: preview(grid, args.preview, (10, 20)) if args.module: source = args.provenance or args.url or str(args.image) command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:]) body = "\n".join(f" {row!r}," for row in rows) args.module.write_text( MODULE_TEMPLATE.format( provenance=f"Source: {source}", command=command, rows=body, ), encoding="utf-8", ) print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)") else: print("\n".join(rows)) return 0 if __name__ == "__main__": raise SystemExit(main())