From b3bd8c1e9f68a42313237c164d35444c34724f91 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Tue, 3 Nov 2026 18:44:52 -0500 Subject: [PATCH] chore(tools): key out white backgrounds in the ANSI art converter --- tools/ansi_art.py | 62 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/tools/ansi_art.py b/tools/ansi_art.py index ec97c30..4a40a1c 100644 --- a/tools/ansi_art.py +++ b/tools/ansi_art.py @@ -48,6 +48,9 @@ ANSI16: dict[str, tuple[int, int, int]] = { #: 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") @@ -58,7 +61,51 @@ def nearest_colour(rgb: tuple[int, int, int]) -> str: return best -def load_image(path: Path | None, url: str | None) -> Image.Image: +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 @@ -73,15 +120,17 @@ def load_image(path: Path | None, url: str | None) -> Image.Image: 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 > 24 else 0) + 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 = image.resize((cols, rows * 2), Image.LANCZOS) + small = resize_premultiplied(image, (cols, rows * 2)) pixels = small.load() grid: list[list[tuple[str, str, str]]] = [] for row in range(rows): @@ -179,9 +228,14 @@ def main(argv: list[str] | None = None) -> int: 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) + image = load_image(args.image, args.url, args.key_white) grid = art_rows(image, args.cols, args.rows) rows = markup(grid)