forked from xiangwang25/whale-town-end-v2
294 lines
11 KiB
Python
294 lines
11 KiB
Python
#!/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:]))
|