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