File size: 9,096 Bytes
ce06a8c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 | """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)
# Background threshold for the base render ("white background" style tag):
# a pixel is background if every channel exceeds this.
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")
# point() per band, then AND: background = all three channels bright
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) # invert: figure pixels white
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
# ββ Backup originals once; always re-frame FROM the backup (idempotent) ββ
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)
# ββ Measure: face (mask, base coords) + figure (base render, base coords) ββ
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 alpha bbox of the ORIGINAL framed sprites (sprite coords) ββ
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
# ββ Recover the old bake's scale (base px β sprite px) ββ
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
# ββ New transform (same spec as cast_pipeline._frame_upper_body) ββ
H0 = sH # keep cast-uniform height
face_px = H0 * FACE_TARGET_FRAC / UI_SPRITE_FRAC # target face on canvas
rescale = face_px / face_h_sprite
# Min-figure-height floor (anti-"midget"): boost short figures up to the
# MIN_FIGURE_CANVAS_FRAC crown floor β identical to cast_pipeline._frame_upper_body.
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) # widen to fit, never clip
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()
|