211 lines
7.5 KiB
Python
211 lines
7.5 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
|
|
|
|
|
|
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())
|