From 29d2b752097b43fcaf8c829b7cafc2ad22b58e3f Mon Sep 17 00:00:00 2001 From: beefcarver Date: Sun, 15 Nov 2026 09:07:51 -0500 Subject: [PATCH] chore(tools): drop the unused ansi_art generator --- tools/ansi_art.py | 275 ---------------------------------------------- 1 file changed, 275 deletions(-) delete mode 100644 tools/ansi_art.py diff --git a/tools/ansi_art.py b/tools/ansi_art.py deleted file mode 100644 index afdeba8..0000000 --- a/tools/ansi_art.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/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. - -Shipped catalogues no longer use image art: each one renders a wordmark from -beefy_player/providers/wordmark.py, which the header repaints with the active -palette gradient (RadioProvider.hero_gradient, on by default). Art from this -tool carries colours of its own, so a provider that uses it must set -hero_gradient = False — otherwise the header repaints the art with the gradient -and the palette names below are wasted. - -Usage: - - python tools/ansi_art.py logo.png --cols 22 --rows 7 - python tools/ansi_art.py --url https://example.org/logo.png \\ - --cols 22 --rows 7 --module beefy_player/providers//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 - -#: Ink weaker than this is treated as fringe when cropping the image. -CROP_CUTOFF = 96 - - -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 key_white(image: Image.Image, tolerance: int) -> Image.Image: - """Make near-white pixels transparent. - - A logo exported on a white background (or one whose anti-aliasing bleeds - white) otherwise survives the downsample as a glaring box. - """ - image = image.copy() - pixels = image.load() - width, height = image.size - threshold = 255 - max(0, tolerance) - for y in range(height): - for x in range(width): - red, green, blue, alpha = pixels[x, y] - if alpha and min(red, green, blue) >= threshold: - pixels[x, y] = (red, green, blue, 0) - return image - - -def resize_premultiplied(image: Image.Image, size: tuple[int, int]) -> Image.Image: - """Resize in premultiplied alpha, so transparent pixels cannot bleed in. - - Plain ``Image.resize`` averages the RGB of fully transparent pixels (often - white) with the surrounding ink, which is how a clean logo turns into a - grey haze at 20x14. - """ - import numpy as np - - data = np.asarray(image.convert("RGBA"), dtype=np.float32) - alpha = data[..., 3:4] / 255.0 - premultiplied = np.concatenate([data[..., :3] * alpha, data[..., 3:4]], axis=-1) - small = Image.fromarray(premultiplied.astype(np.uint8), "RGBA").resize( - size, Image.LANCZOS - ) - out = np.asarray(small, dtype=np.float32) - out_alpha = out[..., 3:4] / 255.0 - rgb = out[..., :3] / np.maximum(out_alpha, 1e-6) - return Image.fromarray( - np.concatenate([np.clip(rgb, 0, 255), out[..., 3:4]], axis=-1).astype(np.uint8), - "RGBA", - ) - - -def load_image( - path: Path | None, url: str | None, key_white_background: bool = False -) -> 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") - if key_white_background: - image = key_white(image, 24) - # Trim the transparent margin: the art should fill the box it is given. - opaque = image.getchannel("A").point(lambda value: 255 if value > CROP_CUTOFF 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 = resize_premultiplied(image, (cols, rows * 2)) - 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. - -The header paints every hero with the active palette gradient unless the -provider opts out, so the class using this art must set -``hero_gradient = False``. -""" - -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") - parser.add_argument( - "--key-white", - action="store_true", - help="treat near-white pixels as transparent (logos on white)", - ) - args = parser.parse_args(argv) - - image = load_image(args.image, args.url, args.key_white) - 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())