Initial Dansori character workspace
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
#!/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])
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
rig_pivots_render.py — 풀캔버스 리그 파츠 검증 + 관절 피벗 자동 산출 + 배경춤 프리뷰 렌더.
|
||||
|
||||
사용법:
|
||||
python rig_pivots_render.py <Profile_dir> <prefix>
|
||||
예:
|
||||
python rig_pivots_render.py "D:\\...\\Noeul_Profile" noeul_part_
|
||||
|
||||
하는 일:
|
||||
1) <Profile>/03_Assets/Parts/Images/<prefix><bone>.png (마스터+16파츠) 검증
|
||||
- 전부 520x900 / 32-bit alpha / 16파츠 스택 == 마스터(alpha union) missed/extra 출력
|
||||
2) 관절 피벗 자동 산출: pivot(bone) = centroid(opaque(bone) & opaque(parent))
|
||||
3) <Profile>/05_Animation/dance_idle.json 로 배경춤 프레임 렌더(풀캔버스 FK)
|
||||
출력: <Profile>/04_Rig/_pivots.json (rig.json에 반영), <Profile>/04_Rig/_dance_preview.png
|
||||
|
||||
주의: PowerShell은 대소문자 무시 변수 충돌이 잦으니 리그 계산은 이 Python 도구를 쓸 것.
|
||||
"""
|
||||
import sys, os, json, math
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
CW, CH, TH = 520, 900, 32
|
||||
BONES = [ # (name, parent, z)
|
||||
("pelvis",None,6),("chest","pelvis",8),("neck","chest",9),("head","neck",10),
|
||||
("upperarm_r","chest",5),("forearm_r","upperarm_r",5),("hand_r","forearm_r",5),
|
||||
("upperarm_l","chest",12),("forearm_l","upperarm_l",12),("hand_l","forearm_l",13),
|
||||
("thigh_r","pelvis",4),("shin_r","thigh_r",3),("foot_r","shin_r",2),
|
||||
("thigh_l","pelvis",4),("shin_l","thigh_l",3),("foot_l","shin_l",2)]
|
||||
|
||||
def main(profile, prefix):
|
||||
pdir = os.path.join(profile, "03_Assets", "Parts", "Images")
|
||||
anim_path = os.path.join(profile, "05_Animation", "dance_idle.json")
|
||||
rigdir = os.path.join(profile, "04_Rig")
|
||||
names = ["master_apose"] + [n for n,_,_ in BONES]
|
||||
for nm in names:
|
||||
p = os.path.join(pdir, f"{prefix}{nm}.png")
|
||||
assert os.path.exists(p), f"MISSING {p}"
|
||||
assert Image.open(p).size == (CW, CH), f"size!=520x900: {nm}"
|
||||
print("all 17 present, all 520x900: OK")
|
||||
|
||||
img = {n: Image.open(os.path.join(pdir, f"{prefix}{n}.png")).convert("RGBA") for n,_,_ in BONES}
|
||||
alpha = {n: (np.asarray(img[n])[:,:,3] > TH) for n,_,_ in BONES}
|
||||
master = np.asarray(Image.open(os.path.join(pdir, f"{prefix}master_apose.png")).convert("RGBA"))[:,:,3] > TH
|
||||
|
||||
pivot = {}
|
||||
for n,p,_ in BONES:
|
||||
par = p if p else "chest"
|
||||
ys,xs = np.nonzero(alpha[n] & alpha[par])
|
||||
if len(xs): pivot[n] = (round(float(xs.mean()),1), round(float(ys.mean()),1))
|
||||
else:
|
||||
ys2,xs2 = np.nonzero(alpha[n]); pivot[n] = (round(float(xs2.mean()),1), round(float(ys2.min()),1))
|
||||
print("== pivots (paste into rig.json) ==")
|
||||
for n,_,_ in BONES: print(f" {n:12s} {pivot[n]}")
|
||||
|
||||
union = np.zeros_like(master)
|
||||
for n,_,_ in BONES: union |= alpha[n]
|
||||
mo = int(master.sum())
|
||||
print(f"stack union vs master: opaque={mo} missed={int((master & ~union).sum())} extra={int((union & ~master).sum())}")
|
||||
|
||||
os.makedirs(rigdir, exist_ok=True)
|
||||
json.dump({n:list(pivot[n]) for n,_,_ in BONES}, open(os.path.join(rigdir,"_pivots.json"),"w"), ensure_ascii=False, indent=2)
|
||||
|
||||
anim = json.load(open(anim_path, encoding="utf-8"))
|
||||
ease = lambda u: -(math.cos(math.pi*u)-1)/2
|
||||
def samp(keys,t):
|
||||
if not keys: return 0.0
|
||||
if t<=keys[0]["t"]: return float(keys[0]["v"])
|
||||
if t>=keys[-1]["t"]: return float(keys[-1]["v"])
|
||||
for i in range(len(keys)-1):
|
||||
a,b=keys[i],keys[i+1]
|
||||
if a["t"]<=t<=b["t"]:
|
||||
sp=b["t"]-a["t"]
|
||||
return float(a["v"]) if sp<=0 else float(a["v"])+(float(b["v"])-float(a["v"]))*ease((t-a["t"])/sp)
|
||||
return 0.0
|
||||
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)
|
||||
def render(t):
|
||||
W={}
|
||||
for n,p,_ in BONES:
|
||||
tr=anim["tracks"].get(n,{}); rot=samp(tr.get("rot"),t);tx=samp(tr.get("tx"),t);ty=samp(tr.get("ty"),t)
|
||||
jx,jy=pivot[n]; ml=T(tx,ty)@T(jx,jy)@R(rot)@T(-jx,-jy); W[n]=(W[p]@ml) if p else ml
|
||||
fr=Image.new("RGBA",(CW,CH),(0,0,0,0))
|
||||
for n,_,_ in sorted(BONES,key=lambda b:b[2]):
|
||||
Mi=np.linalg.inv(W[n]); a,c,e=Mi[0]; b,d,f=Mi[1]
|
||||
fr=Image.alpha_composite(fr,img[n].transform((CW,CH),Image.AFFINE,(a,c,e,b,d,f),resample=Image.BICUBIC))
|
||||
return fr
|
||||
times=[0.0,0.5,1.0,1.5]; sc=0.42; fw,fh=int(CW*sc),int(CH*sc)
|
||||
mont=Image.new("RGBA",(fw*len(times),fh),(28,32,40,255))
|
||||
for i,t in enumerate(times): mont.alpha_composite(render(t).resize((fw,fh),Image.BICUBIC),(fw*i,0))
|
||||
out=os.path.join(rigdir,"_dance_preview.png"); mont.convert("RGB").save(out)
|
||||
print("saved preview:", out)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__); sys.exit(1)
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
Reference in New Issue
Block a user