chore(tools): key out white backgrounds in the ANSI art converter
This commit is contained in:
1 parent
e14a349c2d
commit
b3bd8c1e9f
1 file changed
+58
-4
+58
-4
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user