#!/usr/bin/env python3 """Create clean transparent game-asset cutouts with BiRefNet. This tool is intended for Novamailio-generated WhaleTown assets that come back on a plain matte background. It uses BiRefNet for the alpha mask, then fills transparent/semitransparent edge RGB from confident foreground pixels so Godot texture filtering cannot reveal the original matte color. """ from __future__ import annotations import argparse from collections import deque import os from pathlib import Path import sys from typing import Iterable, Tuple import numpy as np from PIL import Image, ImageDraw DEFAULT_MODEL = "ZhengPeng7/BiRefNet" DEFAULT_HF_ENDPOINT = "https://hf-mirror.com" DEFAULT_SIZE = 1024 def _die(message: str, code: int = 1) -> None: print(f"Error: {message}", file=sys.stderr) raise SystemExit(code) def _warn(message: str) -> None: print(f"Warning: {message}", file=sys.stderr) def _import_ml_deps() -> Tuple[object, object, object]: try: import torch from torchvision import transforms from transformers import AutoModelForImageSegmentation except ImportError as exc: _die( "Missing BiRefNet dependencies. Install them with:\n" " python3 -m pip install torch torchvision transformers timm einops kornia scipy\n" f"Original import error: {exc}" ) return torch, transforms, AutoModelForImageSegmentation def _select_device(torch: object, requested: str) -> str: if requested != "auto": return requested if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): return "mps" if torch.cuda.is_available(): return "cuda" return "cpu" def _load_birefnet(model_name: str, device: str, torch: object, auto_model: object) -> object: model = auto_model.from_pretrained(model_name, trust_remote_code=True) model.to(device) model.eval() return model def _run_birefnet( image: Image.Image, *, model_name: str, device: str, input_size: int, ) -> Image.Image: torch, transforms, auto_model = _import_ml_deps() selected_device = _select_device(torch, device) print(f"BiRefNet device: {selected_device}", file=sys.stderr) print(f"BiRefNet model: {model_name}", file=sys.stderr) model = _load_birefnet(model_name, selected_device, torch, auto_model) transform = transforms.Compose( [ transforms.Resize( (input_size, input_size), interpolation=transforms.InterpolationMode.BILINEAR, ), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ] ) input_tensor = transform(image).unsqueeze(0).to(selected_device) with torch.no_grad(): output = model(input_tensor) if isinstance(output, (list, tuple)): prediction = output[-1] elif hasattr(output, "logits"): prediction = output.logits else: prediction = output prediction = prediction.sigmoid().detach().float().cpu()[0] if prediction.ndim == 3: prediction = prediction.squeeze(0) mask = transforms.ToPILImage()(prediction) return mask.resize(image.size, Image.Resampling.LANCZOS).convert("L") def _stabilize_mask( mask: Image.Image, *, low_cut: float, high_span: float, hard_low: float, hard_high: float, ) -> Image.Image: mask_arr = np.asarray(mask).astype(np.float32) / 255.0 mask_arr = np.clip((mask_arr - low_cut) / high_span, 0.0, 1.0) mask_arr = np.where(mask_arr > hard_high, 1.0, mask_arr) mask_arr = np.where(mask_arr < hard_low, 0.0, mask_arr) return Image.fromarray((mask_arr * 255).astype(np.uint8)) def _connected_matte_mask(image: Image.Image, threshold: float) -> np.ndarray: """Fallback mask for comparing or rescuing BiRefNet failures.""" rgb = np.asarray(image.convert("RGB")) arr = rgb.astype(np.int32) h, w = arr.shape[:2] strips = np.concatenate( [ arr[:80, :, :].reshape(-1, 3), arr[max(0, h - 80) : h, :, :].reshape(-1, 3), arr[:, :80, :].reshape(-1, 3), arr[:, max(0, w - 80) : w, :].reshape(-1, 3), ], axis=0, ) background = np.median(strips, axis=0).astype(np.int32) color_dist = np.sqrt(((arr - background) ** 2).sum(axis=2)) candidate = color_dist <= threshold visited = np.zeros((h, w), dtype=bool) queue: deque[Tuple[int, int]] = deque() for x in range(w): for y in (0, h - 1): if candidate[y, x] and not visited[y, x]: visited[y, x] = True queue.append((y, x)) for y in range(h): for x in (0, w - 1): if candidate[y, x] and not visited[y, x]: visited[y, x] = True queue.append((y, x)) while queue: y, x = queue.popleft() for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)): if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and candidate[ny, nx]: visited[ny, nx] = True queue.append((ny, nx)) return ~visited def _decontaminate_edge_rgb(rgb: np.ndarray, alpha: np.ndarray, confidence: int) -> np.ndarray: foreground = alpha > confidence if not foreground.any(): _warn("Mask has no confident foreground pixels; edge decontamination skipped.") return rgb try: from scipy import ndimage except ImportError: _warn("scipy is missing; install scipy for edge RGB decontamination.") return rgb _, indices = ndimage.distance_transform_edt(~foreground, return_indices=True) nearest_rgb = rgb[indices[0], indices[1]] alpha_f = alpha.astype(np.float32) / 255.0 edge_mix = np.clip((0.98 - alpha_f) / 0.98, 0.0, 1.0)[..., None] replace_strength = np.where(alpha_f[..., None] < 0.98, edge_mix, 0.0) cleaned = rgb * (1.0 - replace_strength) + nearest_rgb * replace_strength return np.clip(cleaned, 0, 255).astype(np.uint8) def _compose_cutout(image: Image.Image, mask: Image.Image, confidence: int) -> Image.Image: rgb = np.asarray(image.convert("RGB")).astype(np.float32) alpha = np.asarray(mask).astype(np.uint8) cleaned_rgb = _decontaminate_edge_rgb(rgb, alpha, confidence) return Image.fromarray(np.dstack([cleaned_rgb, alpha])) def _make_preview(cutout: Image.Image, output: Path, scale: float) -> None: w, h = cutout.size small = cutout.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS) canvas = Image.new("RGB", (small.width * 2 + 48, small.height + 48), (238, 238, 238)) magenta_plate = Image.new("RGB", small.size, (198, 76, 190)) magenta_plate.paste(small, (0, 0), small) checker = Image.new("RGB", small.size, (230, 230, 230)) draw = ImageDraw.Draw(checker) step = 32 for y in range(0, small.height, step): for x in range(0, small.width, step): if ((x // step) + (y // step)) % 2 == 0: draw.rectangle([x, y, x + step - 1, y + step - 1], fill=(190, 206, 224)) checker.paste(small, (0, 0), small) canvas.paste(magenta_plate, (16, 32)) canvas.paste(checker, (small.width + 32, 32)) output.parent.mkdir(parents=True, exist_ok=True) canvas.save(output) def _magenta_stats(image: Image.Image) -> Tuple[int, int, int]: arr = np.asarray(image.convert("RGBA")).astype(np.int16) rgb = arr[:, :, :3] alpha = arr[:, :, 3] semi = (alpha > 0) & (alpha < 255) magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 60) & (rgb[:, :, 2] > 90) semi_magenta = semi & magenta return int(semi.sum()), int(magenta.sum()), int(semi_magenta.sum()) def parse_args(argv: Iterable[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", required=True, help="Source RGB/RGBA image with matte background.") parser.add_argument("--output", required=True, help="Transparent PNG output path.") parser.add_argument("--mask-out", help="Optional alpha mask output path.") parser.add_argument("--preview-out", help="Optional preview sheet output path.") parser.add_argument("--model", default=DEFAULT_MODEL, help=f"HF model id. Default: {DEFAULT_MODEL}") parser.add_argument("--hf-endpoint", default=DEFAULT_HF_ENDPOINT, help="Hugging Face endpoint/mirror.") parser.add_argument("--device", choices=["auto", "cpu", "mps", "cuda"], default="auto") parser.add_argument("--input-size", type=int, default=DEFAULT_SIZE, help="Square BiRefNet input size.") parser.add_argument("--low-cut", type=float, default=0.025, help="Low alpha normalization cut.") parser.add_argument("--high-span", type=float, default=0.94, help="Alpha normalization span.") parser.add_argument("--hard-low", type=float, default=0.015, help="Values below this become transparent.") parser.add_argument("--hard-high", type=float, default=0.985, help="Values above this become opaque.") parser.add_argument("--edge-confidence", type=int, default=245, help="Confident foreground alpha for RGB fill.") parser.add_argument("--preview-scale", type=float, default=0.52) parser.add_argument("--fallback-connected-matte", action="store_true", help="Use simple connected matte mask instead of BiRefNet.") parser.add_argument("--fallback-threshold", type=float, default=28.0) return parser.parse_args(list(argv)) def main(argv: Iterable[str]) -> int: args = parse_args(argv) input_path = Path(args.input) output_path = Path(args.output) if not input_path.exists(): _die(f"Input image not found: {input_path}") os.environ.setdefault("HF_ENDPOINT", args.hf_endpoint) image = Image.open(input_path).convert("RGB") if args.fallback_connected_matte: foreground = _connected_matte_mask(image, args.fallback_threshold) mask = Image.fromarray(foreground.astype(np.uint8) * 255) else: mask = _run_birefnet( image, model_name=args.model, device=args.device, input_size=args.input_size, ) mask = _stabilize_mask( mask, low_cut=args.low_cut, high_span=args.high_span, hard_low=args.hard_low, hard_high=args.hard_high, ) cutout = _compose_cutout(image, mask, args.edge_confidence) output_path.parent.mkdir(parents=True, exist_ok=True) cutout.save(output_path) if args.mask_out: mask_path = Path(args.mask_out) mask_path.parent.mkdir(parents=True, exist_ok=True) mask.save(mask_path) if args.preview_out: _make_preview(cutout, Path(args.preview_out), args.preview_scale) semi, magenta, semi_magenta = _magenta_stats(cutout) print(f"wrote {output_path}") if args.mask_out: print(f"wrote {args.mask_out}") if args.preview_out: print(f"wrote {args.preview_out}") print(f"alpha_bbox={cutout.getchannel('A').getbbox()}") print(f"semi_transparent_pixels={semi}") print(f"visible_magenta_like_pixels={magenta}") print(f"semi_magenta_like_pixels={semi_magenta}") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))