Files

91 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""AvianVisitors - cut the cream ground off the generated illustrations.
Step 2 of the illustration pipeline (after pregen.py, before build_masks.py).
pregen.py renders each bird on a flat cream ground because the image model
can't cut a clean transparent background on its own (it leaves holes and
fringes). A flat known ground, by contrast, removes cleanly. This runs each
illustration through the BiRefNet matting model (via rembg), then crops to
the bird's bounding box with a small even margin, and saves an RGBA cutout
back in place.
Idempotent: an illustration that already has a transparent background is
skipped unless you pass --force.
Requires rembg + onnxruntime (see requirements.txt). The first run downloads
the BiRefNet model (~1 GB) to ~/.u2net/.
Usage:
python3 cutout.py # process every illustration
python3 cutout.py calypte-anna # one slug (both poses)
python3 cutout.py calypte-anna-2 --force
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def main() -> int:
here = Path(__file__).resolve().parents[1]
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("slugs", nargs="*",
help="Slugs to process (e.g. calypte-anna). Default: all.")
ap.add_argument("--dir", type=Path, default=here / "assets" / "illustrations",
help="Illustration directory (default: avian/assets/illustrations/)")
ap.add_argument("--model", default="birefnet-general",
help="rembg model name (default: birefnet-general)")
ap.add_argument("--margin", type=float, default=0.02,
help="Even margin around the bird, as a fraction of its "
"long side (default: 0.02)")
ap.add_argument("--force", action="store_true",
help="Re-cut illustrations that already have transparency")
args = ap.parse_args()
try:
from PIL import Image
from rembg import new_session, remove
except ImportError:
print("error: needs Pillow + rembg (pip install -r requirements.txt)",
file=sys.stderr)
return 2
if args.slugs:
paths = [args.dir / f"{s}.png" for s in args.slugs]
missing = [p for p in paths if not p.exists()]
if missing:
print("error: not found: " + ", ".join(p.name for p in missing), file=sys.stderr)
return 2
else:
paths = sorted(args.dir.glob("*.png"))
if not paths:
print("error: no illustrations found", file=sys.stderr)
return 1
session = new_session(args.model)
done = skipped = 0
for p in paths:
im = Image.open(p)
if not args.force and im.mode == "RGBA" and im.getchannel("A").getextrema()[0] == 0:
skipped += 1
continue
cut = remove(im.convert("RGB"), session=session) # RGBA, ground -> transparent
bbox = cut.getchannel("A").getbbox()
if bbox:
pad = round(args.margin * max(bbox[2] - bbox[0], bbox[3] - bbox[1]))
x0, y0 = max(0, bbox[0] - pad), max(0, bbox[1] - pad)
x1, y1 = min(cut.width, bbox[2] + pad), min(cut.height, bbox[3] + pad)
cut = cut.crop((x0, y0, x1, y1))
cut.save(p)
done += 1
print(f" [cut] {p.name} -> {cut.width}x{cut.height}")
print(f"\ncut {done} · skipped {skipped} (already transparent)")
return 0
if __name__ == "__main__":
sys.exit(main())