| """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()
|
|
|