fighting-konoha/guide/tools/measure_head_scale.py

192 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cek skala SEMUA gerakan terhadap idle lewat ukuran kepala/helm — dijalankan di gambar ASLI generator.
Kenapa kepala: pose aksi (serang, lompat, skill) mengubah tinggi & siluet (lutut ditekuk, lengan & senjata
terangkat), jadi tinggi/siluet tidak bisa menilai skala. Kepala/helm bentuknya tetap.
Kenapa di gambar asli: di atlas kepala hanya ~20 px (selisih 1 px = 5 %); di gambar asli ~6× lebih besar.
Kenapa tepi (edge), bukan warna: model menggambar tiap strip dengan pencahayaan berbeda → pencocokan warna
gagal; garis kontur helm tetap sama.
Pakai:
python measure_head_scale.py <char_dir> --poses idle:4,walk:6,jump:4,attack1:4 \
[--ref idle:0] [--head-box x0,y0,x1,y1] [--rel-json rel.json] [--target 0.5] [--key ff00ff] [--landmark white]
char_dir folder karakter berisi raw-original/<state>.png
--head-box kotak kepala di pose acuan, koordinat relatif crop pose (kolom pose, baris dari atas gambar).
Kosongkan untuk otomatis (pita 10–30 % teratas siluet) — lalu CEK tile TEMPLATE di head-check.png.
--rel-json {"walk": [1.045], "idle": [1,0.95,1,0.987]} = --rel yang SEKARANG dipakai rebuild_raw.py
--landmark white: cari kepala di dekat gumpalan putih (bulu helm / rambut putih) — mencegah tertukar dengan bahu
Keluaran: rasio ukuran kepala di atlas vs idle per frame, median per gerakan, saran --rel baru, dan
<char_dir>/head-check.png: setiap kepala yang ditemukan DIKEMBALIKAN ke ukuran template memakai skala terbaca.
Kalau skalanya benar, semua kepala di lembar itu tampak sama besar dengan TEMPLATE. Kotak yang tidak berisi
kepala (tertutup lengan / menghadap depan) = angka frame itu tidak valid → abaikan, pakai median gerakannya.
"""
import argparse
import json
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from sprite_gen.frames.extract import estimate_pixel_grid_runlen
WHITE_MIN = 215
MIN_SCORE = 0.30 # skor NCC tepi di bawah ini = kepala tidak tertangkap dengan baik
def corr(a, b):
H, W = a.shape; h, w = b.shape
fa = np.fft.rfft2(a, (H + h, W + w)); fb = np.fft.rfft2(b[::-1, ::-1], (H + h, W + w))
return np.fft.irfft2(fa * fb, (H + h, W + w))[h - 1:H, w - 1:W]
def edges(img, key):
a = np.asarray(img).astype(float)
fg = np.sqrt(((a - key) ** 2).sum(-1)) > 110
g = a.mean(2) * fg
gy, gx = np.gradient(g)
return np.hypot(gx, gy), fg, a
def white_center(a, fg):
ys, xs = np.nonzero(fg & (a.min(2) > WHITE_MIN))
if len(xs) < 50:
return None
rows = np.nonzero(fg.any(1))[0]
sel = ys < rows.min() + 0.45 * (rows.max() - rows.min())
return (float(np.median(xs[sel])), float(np.median(ys[sel]))) if sel.any() else None
def split_poses(img, n, key):
a = np.array(img).astype(int)
cols = (np.sqrt(((a - key) ** 2).sum(-1)) > 110).sum(0) > 2
runs, x = [], 0
while x < len(cols):
if cols[x]:
s = x
while x < len(cols) and cols[x]:
x += 1
runs.append([s, x])
else:
x += 1
merged = [runs[0]] if runs else []
for r in runs[1:]:
if r[0] - merged[-1][1] < 12:
merged[-1][1] = r[1]
else:
merged.append(r)
merged = [r for r in merged if r[1] - r[0] > 20]
if len(merged) != n: # pose saling menempel → pakai slot sama lebar
merged = [[round(k * img.width / n), round((k + 1) * img.width / n)] for k in range(n)]
return merged
def main():
ap = argparse.ArgumentParser()
ap.add_argument("char_dir")
ap.add_argument("--poses", required=True, help="state:jumlah,... mis. idle:4,walk:6,attack1:4")
ap.add_argument("--ref", default="idle:0")
ap.add_argument("--head-box", default="")
ap.add_argument("--rel-json", default="")
ap.add_argument("--target", type=float, default=0.5)
ap.add_argument("--key", default="ff00ff")
ap.add_argument("--landmark", default="", choices=["", "white"])
args = ap.parse_args()
root = Path(args.char_dir)
key = np.array([int(args.key[i:i + 2], 16) for i in (0, 2, 4)])
poses = [(p.split(":")[0], int(p.split(":")[1])) for p in args.poses.split(",")]
rel = json.loads(Path(args.rel_json).read_text()) if args.rel_json else {}
def load(state, n):
im = Image.open(root / "raw-original" / f"{state}.png").convert("RGB")
px, py = estimate_pixel_grid_runlen(im.convert("RGBA"))
return im, split_poses(im, n, key), args.target / ((px + py) / 2)
def rel_of(state, i, n):
r = rel.get(state, [1.0])
return r[0] if len(r) == 1 else r[i]
rs, ri = args.ref.split(":"); ri = int(ri)
nref = dict(poses)[rs]
im, runs, base_ref = load(rs, nref)
x0, x1 = runs[ri]
pose = im.crop((x0, 0, x1, im.height))
if args.head_box:
hb = [int(v) for v in args.head_box.split(",")]
else:
_, fg, _ = edges(pose, key)
rows = np.nonzero(fg.any(1))[0]; top, h = rows.min(), rows.max() - rows.min()
y0, y1 = top + int(h * 0.10), top + int(h * 0.30)
cols = np.nonzero(fg[y0:y1].any(0))[0]
hb = [int(cols.min()), int(y0), int(cols.max() + 1), int(y1)]
tmpl = pose.crop(hb)
tE, tfg, ta = edges(tmpl, key)
tmask = (tfg & ~(ta.min(2) > WHITE_MIN)).astype(np.float32)
ref_scale_atlas = base_ref * rel_of(rs, ri, nref)
offset = None
if args.landmark == "white":
_, pfg, pa = edges(pose, key)
c = white_center(pa, pfg)
if c:
offset = ((hb[0] + hb[2]) / 2 - c[0], (hb[1] + hb[3]) / 2 - c[1])
def find(img):
E, fg, a = edges(img, key)
c = white_center(a, fg) if offset else None
best = (-9.0, 1.0, 0, 0)
for s in np.arange(0.70, 1.401, 0.01):
tw, th = round(tmpl.width * s), round(tmpl.height * s)
if tw >= img.width or th >= img.height:
continue
T = np.array(Image.fromarray(tE.astype(np.float32)).resize((tw, th), Image.BILINEAR)).astype(float)
M = (np.array(Image.fromarray((tmask * 255).astype(np.uint8)).resize((tw, th), Image.BILINEAR)) > 127).astype(float)
n = M.sum()
tz = (T - (T * M).sum() / n) * M
sc = corr(E, tz) / np.sqrt(np.maximum(corr(E * E, M) - corr(E, M) ** 2 / n, 1e-9) * (tz * tz).sum())
if c is not None:
yy, xx = np.mgrid[0:sc.shape[0], 0:sc.shape[1]]
ex, ey = c[0] + offset[0] * s, c[1] + offset[1] * s
sc = np.where((abs(xx + tw / 2 - ex) < 90) & (abs(yy + th / 2 - ey) < 90), sc, -9)
k = np.unravel_index(np.argmax(sc), sc.shape)
if sc[k] > best[0]:
best = (float(sc[k]), float(s), int(k[1]), int(k[0]))
return best
tiles = [("TEMPLATE", tmpl)]
print(f"template kepala: {tmpl.width}x{tmpl.height}px dari {args.ref} (box {hb})")
suggestions = {}
for state, n in poses:
im, runs, base = load(state, n)
ratios = []
for i, (a0, a1) in enumerate(runs):
p = im.crop((a0, 0, a1, im.height))
score, s, bx, by = find(p)
ratio = s * base * rel_of(state, i, n) / ref_scale_atlas
ok = score >= MIN_SCORE
if ok:
ratios.append(ratio)
tw, th = round(tmpl.width * s), round(tmpl.height * s)
tiles.append((f"{state}{i} {ratio:.2f}{'' if ok else ' ?'}",
p.crop((bx, by, bx + tw, by + th)).resize(tmpl.size, Image.LANCZOS)))
print(f"{state:>10} {i}: kepala di atlas = {ratio:.3f} × idle (skor {score:.2f}){'' if ok else ' TIDAK VALID'}")
if ratios:
med = float(np.median(ratios))
cur = rel.get(state, [1.0])
new = [round(v / med, 3) for v in cur]
suggestions[state] = new
flag = "OK" if abs(med - 1) <= 0.03 else "PERLU KOREKSI"
print(f"{'':>10} median {med:.3f} → {flag}; --rel baru {','.join(map(str, new))}")
W, H = tmpl.size; cols = 9
sheet = Image.new("RGB", (W * cols, (H + 14) * ((len(tiles) + cols - 1) // cols)), (0, 0, 0))
for k, (name, t) in enumerate(tiles):
x, y = (k % cols) * W, (k // cols) * (H + 14)
sheet.paste(t, (x, y + 14)); ImageDraw.Draw(sheet).text((x + 2, y + 1), name, fill=(255, 255, 0))
sheet.save(root / "head-check.png")
print(f"\nLIHAT {root / 'head-check.png'}: semua kepala harus sama besar dengan TEMPLATE.")
print("saran --rel (salin ke rel.json bila setuju):", json.dumps(suggestions))
if __name__ == "__main__":
main()