Files
beefy-player/tools/ansi_art.py
T

265 lines
9.4 KiB
Python

#!/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
#: 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.
"""
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())