Files
Dansori_Characters/_tools/reactions_layout_render.py
2026-07-04 10:34:46 +09:00

97 lines
5.1 KiB
Python

#!/usr/bin/env python
"""
reactions_layout_render.py — 반응 시퀀서용 레이아웃 사전계산 + 오프라인 합성 검증(캐릭터 무관).
사용: python reactions_layout_render.py <Profile_dir> <char_prefix>
출력: <Profile>/06_Reactions/_layout.json, _reaction_preview.png
브라우저 런타임(reactions.html)이 _layout.json 을 로드해 동일 합성.
스케일 기준: 바디는 '상단 25% 최대너비(=어깨/소매 span) -> TARGET_SHOULDER'(캐릭터별 네이티브 크기 차 흡수),
목 부착점(상단 opaque 중앙)을 무대 NECK에 고정. 머리는 'bbox 너비 -> HEAD_TARGET_W', 목(하단중앙)을 NECK에 정합·회전.
"""
import sys, os, json, math
import numpy as np
from PIL import Image
TH = 32
NECK_X, NECK_Y = 260, 250
TARGET_SHOULDER = 230 # 바디 상단 25% 최대너비를 이 값으로
HEAD_TARGET_W = 150 # 머리 bbox 너비를 이 값으로
OVERLAP = 6
def bbox(a):
ys, xs = np.nonzero(a > TH); return xs.min(), ys.min(), xs.max(), ys.max()
def main(profile, cpfx):
lib = os.path.join(profile, "03_Assets", "Library"); rdir = os.path.join(profile, "06_Reactions")
STAGE_W, STAGE_H = 520, 900
layout = {"stage": {"w": STAGE_W, "h": STAGE_H}, "neck": [NECK_X, NECK_Y], "overlap": OVERLAP,
"headTargetW": HEAD_TARGET_W, "bodies": {}, "heads": {}}
for root, _, files in os.walk(os.path.join(lib, "BakedPoses")):
for fn in files:
if not fn.endswith(".png"): continue
a = np.asarray(Image.open(os.path.join(root, fn)).convert("RGBA"))[:, :, 3]
x0, y0, x1, y1 = bbox(a); bh = y1 - y0 + 1
row0 = np.nonzero(a[y0] > TH)[0]; tcx = float((row0.min() + row0.max()) / 2)
shoulderW = 0 # 상단 25% 최대 행너비 = 어깨/소매 span
for yy in range(y0, y0 + max(3, int(bh * 0.25))):
cols = np.nonzero(a[yy] > TH)[0]
if len(cols): shoulderW = max(shoulderW, int(cols.max() - cols.min() + 1))
if shoulderW == 0: continue
scale = TARGET_SHOULDER / shoulderW
if not (0.1 <= scale <= 3.0): continue
layout["bodies"][fn[:-4]] = {"scale": round(scale, 4),
"ox": round(NECK_X - tcx * scale, 1), "oy": round(NECK_Y - y0 * scale, 1)}
for fn in os.listdir(os.path.join(lib, "Heads")):
if not fn.endswith(".png"): continue
im = Image.open(os.path.join(lib, "Heads", fn)).convert("RGBA"); a = np.asarray(im)[:, :, 3]
x0, y0, x1, y1 = bbox(a)
layout["heads"][fn[:-4]] = {"w": int(x1 - x0 + 1),
"neckNorm": [round(((x0 + x1) / 2) / im.width, 4), round(y1 / im.height, 4)]}
os.makedirs(rdir, exist_ok=True)
json.dump(layout, open(os.path.join(rdir, "_layout.json"), "w"), ensure_ascii=False, indent=1)
print(f"layout: {len(layout['bodies'])} bodies, {len(layout['heads'])} heads -> _layout.json")
# ---- offline verify (character-agnostic: pick by suffix) ----
T = lambda x, y: np.array([[1,0,x],[0,1,y],[0,0,1]], float)
def R(d):
r=math.radians(d);c,s=math.cos(r),math.sin(r);return np.array([[c,-s,0],[s,c,0],[0,0,1]],float)
Sc = lambda k: np.array([[k,0,0],[0,k,0],[0,0,1]], float)
def draw(base, img, M):
Mi=np.linalg.inv(M); a,c,e=Mi[0]; b,d,f=Mi[1]
return Image.alpha_composite(base, img.transform((STAGE_W,STAGE_H),Image.AFFINE,(a,c,e,b,d,f),resample=Image.BICUBIC))
def pick_body(suf):
for k in layout["bodies"]:
if k.endswith("_"+suf): return k
return None
def pick_head(expr):
for k in layout["heads"]:
if k.endswith("_"+expr): return k
return None
def bpath(k):
for root,_,files in os.walk(os.path.join(lib,"BakedPoses")):
if k+".png" in files: return os.path.join(root, k+".png")
def compose(bk, hk, rot):
bl=layout["bodies"][bk]; hl=layout["heads"][hk]
body=Image.open(bpath(bk)).convert("RGBA"); head=Image.open(os.path.join(lib,"Heads",hk+".png")).convert("RGBA")
fr=Image.new("RGBA",(STAGE_W,STAGE_H),(0,0,0,0))
fr=draw(fr, body, T(bl["ox"],bl["oy"]) @ Sc(bl["scale"]))
hs=HEAD_TARGET_W/hl["w"]; ax=hl["neckNorm"][0]*head.width; ay=hl["neckNorm"][1]*head.height
fr=draw(fr, head, T(NECK_X,NECK_Y+OVERLAP) @ R(rot) @ Sc(hs) @ T(-ax,-ay))
return fr
reactions=[("no","armscross","negative",7),("heart","heart","love",-4),("focus","listen","sleepy",4)]
sc=0.46; fw,fh=int(STAGE_W*sc),int(STAGE_H*sc)
mont=Image.new("RGBA",(fw*len(reactions),fh),(28,32,40,255))
for i,(nm,bsuf,expr,rot) in enumerate(reactions):
bk=pick_body(bsuf); hk=pick_head(expr)
if not bk or not hk: print(f" skip {nm}: body={bk} head={hk}"); continue
try:
mont.alpha_composite(compose(bk,hk,rot).resize((fw,fh),Image.BICUBIC),(fw*i,0)); print(f" {nm}: {bk} + {hk} rot={rot}")
except Exception as ex: print(f" FAIL {nm}: {ex}")
out=os.path.join(rdir,"_reaction_preview.png"); mont.convert("RGB").save(out); print("saved:", out)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])