| """Re-apply the face-normalized ZOOM framing to an already-baked (curated) cast. |
| |
| The curated sprites under static/sprites/curated/<key>/ are FRAMED composites |
| (the old bake: figure scaled, feet on the canvas bottom edge) β not raw mattes β |
| so this script first RECOVERS the old bake's scale, then re-frames to the |
| current spec in cast_pipeline: |
| |
| β’ every face = FACE_TARGET_FRAC of the SCENE height (all equal) |
| β’ feet sit FEET_DROP_FACES face-heights BELOW the canvas bottom (leg crop) |
| β’ canvas WIDTH grows to fit the figure (no side clipping); heights uniform |
| |
| Scale recovery: `<key>_base.png` is the raw RGB render on a near-white |
| background; thresholding it gives the figure bbox in base coordinates, and the |
| framed sprite's alpha bbox gives the same figure in sprite coordinates β the |
| height ratio is the old scale, which maps the face-gate mask's face height |
| into sprite pixels. |
| |
| Originals are backed up to <char_dir>/_preframe/ on first run and ALWAYS |
| re-read from there, so the script is idempotent β tweak the env knobs |
| (ARS_FABULA_FACE_TARGET / ARS_FABULA_FEET_DROP) and re-run freely. |
| |
| Usage: |
| python tools/reframe_curated.py # all chars under curated/ |
| python tools/reframe_curated.py --char yuki # one character |
| python tools/reframe_curated.py --dir static/sprites/curated |
| python tools/reframe_curated.py --restore # put _preframe originals back |
| """ |
| from __future__ import annotations |
| import argparse |
| import os |
| import shutil |
| import sys |
|
|
| from PIL import Image |
|
|
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| from cast_pipeline import (FACE_TARGET_FRAC, FEET_DROP_FACES, UI_SPRITE_FRAC, |
| MIN_FIGURE_CANVAS_FRAC, STANDARD_EXPRESSIONS) |
|
|
| |
| |
| BG_THRESHOLD = 235 |
|
|
|
|
| def _figure_bbox_on_white(img: Image.Image) -> tuple[int, int, int, int] | None: |
| """Bbox of the non-background figure in an RGB-on-white base render.""" |
| rgb = img.convert("RGB") |
| |
| masks = [band.point(lambda v: 255 if v > BG_THRESHOLD else 0) |
| for band in rgb.split()] |
| from PIL import ImageChops |
| bg = ImageChops.multiply(ImageChops.multiply(masks[0], masks[1]), masks[2]) |
| fig = bg.point(lambda v: 0 if v else 255) |
| return fig.getbbox() |
|
|
|
|
| def reframe_char(char_dir: str, key: str) -> bool: |
| mask_path = os.path.join(char_dir, "_facegate_mask.png") |
| base_path = os.path.join(char_dir, f"{key}_base.png") |
| if not os.path.exists(mask_path) or not os.path.exists(base_path): |
| print(f" [{key}] SKIP β needs _facegate_mask.png + {key}_base.png") |
| return False |
|
|
| expr_files = [f"{key}_{e}.png" for e in STANDARD_EXPRESSIONS] |
| expr_files = [f for f in expr_files if os.path.exists(os.path.join(char_dir, f))] |
| if not expr_files: |
| print(f" [{key}] SKIP β no expression sprites") |
| return False |
|
|
| |
| pre_dir = os.path.join(char_dir, "_preframe") |
| os.makedirs(pre_dir, exist_ok=True) |
| for f in expr_files: |
| dst = os.path.join(pre_dir, f) |
| if not os.path.exists(dst): |
| shutil.copy2(os.path.join(char_dir, f), dst) |
|
|
| |
| with Image.open(mask_path) as m: |
| fb = m.convert("L").getbbox() |
| if not fb: |
| print(f" [{key}] SKIP β empty face mask") |
| return False |
| face_h_base = fb[3] - fb[1] |
|
|
| with Image.open(base_path) as b: |
| base_fig = _figure_bbox_on_white(b) |
| bW, bH = b.size |
| if not base_fig or (base_fig[2] - base_fig[0]) >= bW - 2: |
| print(f" [{key}] SKIP β couldn't isolate the figure in {key}_base.png " |
| f"(background not near-white?)") |
| return False |
|
|
| |
| union = None |
| sW = sH = 0 |
| for f in expr_files: |
| with Image.open(os.path.join(pre_dir, f)) as img: |
| rgba = img.convert("RGBA") |
| sW, sH = rgba.size |
| bb = rgba.getchannel("A").getbbox() |
| if bb: |
| union = bb if union is None else ( |
| min(union[0], bb[0]), min(union[1], bb[1]), |
| max(union[2], bb[2]), max(union[3], bb[3])) |
| if union is None: |
| print(f" [{key}] SKIP β sprites have no alpha content") |
| return False |
|
|
| |
| s_h = (union[3] - union[1]) / (base_fig[3] - base_fig[1]) |
| s_w = (union[2] - union[0]) / (base_fig[2] - base_fig[0]) |
| if abs(s_h - s_w) / max(s_h, s_w) > 0.05: |
| print(f" [{key}] WARNING: scale recovery disagrees " |
| f"(h={s_h:.3f} vs w={s_w:.3f}) β sprite may already be re-framed " |
| f"or side-clipped; using height") |
| face_h_sprite = face_h_base * s_h |
|
|
| |
| H0 = sH |
| face_px = H0 * FACE_TARGET_FRAC / UI_SPRITE_FRAC |
| rescale = face_px / face_h_sprite |
| |
| |
| fig_h_sprite = union[3] - union[1] |
| crown_frac = (fig_h_sprite * rescale - FEET_DROP_FACES * face_px) / H0 |
| if crown_frac < MIN_FIGURE_CANVAS_FRAC and fig_h_sprite > 1: |
| rescale = (MIN_FIGURE_CANVAS_FRAC * H0 + FEET_DROP_FACES * face_px) / fig_h_sprite |
| print(f" [{key}] short figure ({crown_frac:.0%} crown) boosted to " |
| f"the {MIN_FIGURE_CANVAS_FRAC:.0%} min-height floor") |
| pad = 12 |
| fig_w = (union[2] - union[0]) * rescale |
| W0 = max(sW, int(fig_w) + 1 + 2 * pad) |
| cx = (union[0] + union[2]) / 2.0 |
| off_x = int(round(W0 / 2.0 - cx * rescale)) |
| off_y = int(round(H0 + FEET_DROP_FACES * face_px - union[3] * rescale)) |
| nw, nh = max(1, int(round(sW * rescale))), max(1, int(round(sH * rescale))) |
|
|
| for f in expr_files: |
| with Image.open(os.path.join(pre_dir, f)) as img: |
| resized = img.convert("RGBA").resize((nw, nh), Image.LANCZOS) |
| canvas = Image.new("RGBA", (W0, H0), (0, 0, 0, 0)) |
| canvas.paste(resized, (off_x, off_y), resized) |
| canvas.save(os.path.join(char_dir, f)) |
|
|
| crown_scene = (H0 - (off_y + union[1] * rescale)) / H0 * UI_SPRITE_FRAC |
| print(f" [{key}] reframed {len(expr_files)} sprites: face {FACE_TARGET_FRAC:.0%} " |
| f"of scene, feet at -{FEET_DROP_FACES:g} faces, crown at {crown_scene:.0%} " |
| f"of scene, canvas {W0}x{H0} (old scale {s_h:.3f}, zoom Γ{rescale:.2f})") |
| if crown_scene > UI_SPRITE_FRAC: |
| print(f" [{key}] WARNING: crown overflows the canvas top β lower " |
| f"ARS_FABULA_FACE_TARGET or ARS_FABULA_FEET_DROP and re-run") |
| return True |
|
|
|
|
| def restore_char(char_dir: str, key: str) -> bool: |
| pre_dir = os.path.join(char_dir, "_preframe") |
| if not os.path.isdir(pre_dir): |
| print(f" [{key}] nothing to restore") |
| return False |
| n = 0 |
| for f in os.listdir(pre_dir): |
| if f.endswith(".png"): |
| shutil.copy2(os.path.join(pre_dir, f), os.path.join(char_dir, f)) |
| n += 1 |
| print(f" [{key}] restored {n} original sprites from _preframe/") |
| return True |
|
|
|
|
| def main() -> None: |
| ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) |
| ap.add_argument("--dir", default=os.path.join("static", "sprites", "curated"), |
| help="cast directory (default: static/sprites/curated)") |
| ap.add_argument("--char", action="append", default=None, |
| help="character key (repeatable; default: all)") |
| ap.add_argument("--restore", action="store_true", |
| help="restore the _preframe originals instead of reframing") |
| args = ap.parse_args() |
|
|
| root = args.dir |
| if not os.path.isdir(root): |
| sys.exit(f"not a directory: {root}") |
| keys = args.char or sorted( |
| d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))) |
|
|
| print(f"{'Restoring' if args.restore else 'Reframing'} cast in {root} " |
| f"(face={FACE_TARGET_FRAC:.0%} of scene, feet at -{FEET_DROP_FACES:g} faces)") |
| for key in keys: |
| char_dir = os.path.join(root, key) |
| if not os.path.isdir(char_dir): |
| print(f" [{key}] SKIP β no such folder") |
| continue |
| (restore_char if args.restore else reframe_char)(char_dir, key) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|