chore(tools): key out white backgrounds in the ANSI art converter

This commit is contained in:
beefcarver committed 2026-11-03 18:44:52 -05:00
1 parent e14a349c2d
commit b3bd8c1e9f
1 file changed
+58 -4
+58 -4
View File
@@ -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)