Files
AvianVisitors/avian/scripts/build_masks.py
T

121 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""AvianVisitors - rebuild the collage silhouette masks from the cutouts.
Step 3 of the illustration pipeline (after pregen.py and cutout.py).
The collage packs birds by their actual silhouette, not bounding boxes,
so the frontend ships a tiny 1-bit mask per illustration inlined in
apt.js. This reads every cutout in avian/assets/illustrations/ and
rewrites the DIMS and MASKS tables in avian/frontend/apt.js:
DIMS[slug] = [w, h] aspect, scaled so the long side is 560
MASKS[slug] = {w, h, bits} silhouette downscaled to <=93px, 1-bit
packed MSB-first row-major, base64. A bit is 1 where
the cutout is opaque (alpha > 127). This is exactly
what loadMask() in apt.js decodes.
Run after changing the illustration set, then bump SKETCH_VERSION and
IMG_VERSION in apt.js so browsers drop their cached copies.
Usage:
python3 build_masks.py # rewrite apt.js in place
python3 build_masks.py --check # report only, don't write
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
from pathlib import Path
DIM_MAX = 560 # long side of the stored aspect
MASK_MAX = 93 # long side of the stored silhouette
ALPHA_ON = 127 # opaque above this -> silhouette bit set
def build_tables(illus_dir: Path):
"""Return (dims, masks) dicts keyed by slug, in sorted order."""
from PIL import Image
dims, masks = {}, {}
pngs = sorted(p for p in illus_dir.glob("*.png")
if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", p.stem))
for p in pngs:
slug = p.stem
im = Image.open(p).convert("RGBA")
w, h = im.size
scale = DIM_MAX / max(w, h)
dims[slug] = [round(w * scale), round(h * scale)]
ms = MASK_MAX / max(w, h)
mw, mh = max(1, round(w * ms)), max(1, round(h * ms))
alpha = im.getchannel("A").resize((mw, mh), Image.LANCZOS)
px = alpha.load()
bits = bytearray((mw * mh + 7) // 8)
for y in range(mh):
for x in range(mw):
if px[x, y] > ALPHA_ON:
i = y * mw + x
bits[i >> 3] |= 1 << (7 - (i & 7))
masks[slug] = {"w": mw, "h": mh, "bits": base64.b64encode(bytes(bits)).decode()}
return dims, masks
def replace_decl(src: str, name: str, value: str) -> str:
"""Replace `var <name> = {...};` (single line) with the new value."""
pat = re.compile(r" var " + name + r" = \{.*?\};")
repl = f" var {name} = {value};"
new, n = pat.subn(lambda _m: repl, src, count=1)
if n != 1:
raise SystemExit(f"error: could not find `var {name} = {{...}};` in apt.js")
return new
def main() -> int:
here = Path(__file__).resolve().parents[1]
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--illustrations", type=Path, default=here / "assets" / "illustrations",
help="Cutout directory (default: avian/assets/illustrations/)")
ap.add_argument("--apt", type=Path, default=here / "frontend" / "apt.js",
help="Frontend file to patch (default: avian/frontend/apt.js)")
ap.add_argument("--check", action="store_true",
help="Report counts and don't write apt.js")
args = ap.parse_args()
dims, masks = build_tables(args.illustrations)
perched = sum(1 for k in dims if not k.endswith("-2"))
flight = sum(1 for k in dims if k.endswith("-2"))
print(f"built {len(dims)} masks ({perched} perched + {flight} flight) "
f"from {args.illustrations}")
if not dims:
print("error: no cutouts found", file=sys.stderr)
return 1
dims_json = json.dumps(dims, separators=(",", ":"))
masks_json = json.dumps(masks, separators=(",", ":"))
if args.check:
src = args.apt.read_text()
cur = json.loads(re.search(r"var DIMS = (\{.*?\});", src).group(1))
added = sorted(set(dims) - set(cur))
removed = sorted(set(cur) - set(dims))
print(f"apt.js currently has {len(cur)} entries; "
f"+{len(added)} new, -{len(removed)} removed")
if added:
print(" new:", ", ".join(added[:8]) + (" ..." if len(added) > 8 else ""))
if removed:
print(" gone:", ", ".join(removed[:8]) + (" ..." if len(removed) > 8 else ""))
return 0
src = args.apt.read_text()
src = replace_decl(src, "DIMS", dims_json)
src = replace_decl(src, "MASKS", masks_json)
args.apt.write_text(src)
print(f"patched {args.apt}\nremember to bump SKETCH_VERSION + IMG_VERSION in apt.js")
return 0
if __name__ == "__main__":
sys.exit(main())