#!/usr/bin/env python """ reactions_layout_render.py — 반응 시퀀서용 레이아웃 사전계산 + 오프라인 합성 검증(캐릭터 무관). 사용: python reactions_layout_render.py 출력: /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])