KAZIK/tools/generators/pack_character_cast.py

203 lines
10 KiB
Python

"""Offline normalization of the ten shared, identity-locked visitor sheets."""
from pathlib import Path
import json
import struct
import cv2
import numpy as np
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[2]
ART = ROOT / 'assets/sprites/characters'
OUT = ROOT / 'assets/animations/characters'
GENERATED = Path.home()/'.codex/generated_images/019e0a0d-b269-7211-8574-91b6289060a2'
SOURCES = [
'9d00967e-d22f-43e4-95eb-703f2483d9de', '2ffe5ab3-4c2d-4eaf-beee-7e0c61d55391',
'870dca7b-988c-4cba-8555-ecdb4874fd50', 'd2bc0f10-dd34-4dfe-a9be-41593724845d',
'a1e8be10-a51c-4c00-b509-993d98fd893a', 'b2fb75d3-169f-479e-b2f6-c8ade1d332b9',
'28ab7bb4-a834-48a8-b348-9242957ff8d0', '4dd13bb9-5536-4a1c-99fd-ff24419e3977',
'7d44d86f-8047-467f-9321-cb7d2c9980f6', '850f855a-e2d6-469c-844b-afe8756ed291',
]
DESCRIPTIONS = [
'Black-haired man, navy blazer, ivory shirt, burgundy trousers, brown loafers',
'Auburn-bob woman, emerald trouser suit, ivory blouse, black flats',
'Older stocky bald man, moustache, burgundy waistcoat, white rolled sleeves',
'Silver-bob older woman, plum cardigan, teal skirt, pearl necklace',
'Dark-skinned bearded man, gray shirt, cobalt trousers, dark sneakers',
'Dark-braided woman, coral blouse, charcoal trousers, black loafers',
'Young wavy-haired man, glasses, mustard sweater, navy chinos, white sneakers',
'Black-pixie woman, gray jacket, burgundy top, navy trousers, black flats',
'Silver-haired bearded man, olive blazer, burgundy turtleneck, charcoal trousers',
'Curly-haired woman, cobalt belted jumpsuit, white sneakers',
]
W,H,STRIDE = 192,224,100
ACTIONS = {'idle':(0,4), 'talk':(4,4), 'drink':(8,4), 'seat':(12,4),
'seat_talk':(16,4), 'seat_drink':(20,4), 'walk_front':(24,8),
'walk_back':(32,8), 'walk_right':(40,8), 'walk_left':(48,8),
'sit_down':(56,8), 'pick_up':(64,8), 'throw':(72,24), 'cheer':(96,4)}
def isolate_rows(source):
a = np.asarray(source.convert('RGBA')).copy()
mask = (a[:,:,3] >= 220).astype(np.uint8)
projection = mask.sum(axis=1)
bounds = [0]
for i in range(1,4):
middle = a.shape[0]*i//4
radius = a.shape[0]//24
choices = np.arange(middle-radius,middle+radius+1)
cost = projection[choices]+abs(choices-middle)*.15
bounds.append(int(choices[np.argmin(cost)]))
bounds.append(a.shape[0])
poses, boxes = [],[]
for row,(top,bottom) in enumerate(zip(bounds,bounds[1:])):
band = mask[top:bottom]
_,labels,stats,_ = cv2.connectedComponentsWithStats(band)
parts = [(i,s) for i,s in enumerate(stats[1:],1)
if s[cv2.CC_STAT_AREA] > 650 and s[cv2.CC_STAT_HEIGHT] > (bottom-top)*.35]
parts.sort(key=lambda p:p[1][0]+p[1][2]/2)
if row == 3 and len(parts) > 8:
walking = parts[:-2]
parts = [walking[round(i*(len(walking)-1)/5)] for i in range(6)]+parts[-2:]
assert len(parts) == 8,(row,len(parts),stats.tolist())
for label,s in parts:
x,y,w,h,_ = map(int,s)
crop = a[top+y:top+y+h,x:x+w].copy()
crop[:,:,3] = np.where(labels[y:y+h,x:x+w] == label,255,0)
poses.append(Image.fromarray(crop))
boxes.append([x,top+y,w,h])
reference_height = float(np.median([poses[i].height for i in [0,*range(8,14),*range(16,22)]]))
scale = min(202/reference_height,184/max(p.width for p in poses))
result = []
for pose in poses:
size = tuple(max(1,round(v*scale)) for v in pose.size)
pose = pose.resize(size,Image.Resampling.LANCZOS)
frame = Image.new('RGBA',(W,H))
frame.alpha_composite(pose,((W-size[0])//2,220-size[1]))
result.append(frame)
return result,{'row_boundaries':bounds,'boxes':boxes,'scale':scale,'foot_anchor':[W//2,220]}
def sequence(poses,keys,count,loop=False):
frames = []
arrays = [np.asarray(poses[k]) for k in keys]
xy = np.mgrid[:H,:W][::-1].transpose(1,2,0).astype(np.float32)
pairs = list(zip(arrays,arrays[1:]+arrays[:1])) if loop else list(zip(arrays,arrays[1:]))
flows = []
for a,b in pairs:
ga,gb = cv2.cvtColor(a,cv2.COLOR_RGBA2GRAY),cv2.cvtColor(b,cv2.COLOR_RGBA2GRAY)
fa = cv2.calcOpticalFlowFarneback(ga,gb,None,.5,3,19,3,5,1.2,0)
fb = cv2.calcOpticalFlowFarneback(gb,ga,None,.5,3,19,3,5,1.2,0)
# The support plane is invariant; flow must not drag shoes off the canvas.
vertical = np.clip((H-12-xy[:,:,1])/36,0,1)
horizontal = np.clip(np.minimum(xy[:,:,0],W-1-xy[:,:,0])/12,0,1)
for flow in (fa,fb):
flow[:,:,1] *= vertical
flow[:,:,0] *= horizontal
flows.append((fa,fb))
for i in range(count):
position = i*len(pairs)/(count if loop else count-1)
at = min(int(position),len(pairs)-1)
t = min(position-at,1)
a,b = pairs[at]
if t < .001:
frames.append(Image.fromarray(a)); continue
if t > .999:
frames.append(Image.fromarray(b)); continue
fa,fb = flows[at]
wa = cv2.remap(a,xy-fa*t,None,cv2.INTER_LINEAR,borderMode=cv2.BORDER_CONSTANT)
wb = cv2.remap(b,xy-fb*(1-t),None,cv2.INTER_LINEAR,borderMode=cv2.BORDER_CONSTANT)
# Warp one intact pose: crossfading disjoint hands creates extra ghost arms.
mix = (wa if t < .5 else wb).copy()
mix[:,:,3] = np.where(mix[:,:,3] >= 128,255,0)
frames.append(Image.fromarray(mix))
return frames
def walking_poses(source, standing_height):
"""Crop the replacement contact/passing sheets, sharing one scale per sheet."""
a = np.asarray(source.convert('RGBA')).copy()
mask = (a[:,:,3] >= 220).astype(np.uint8)
projection = mask.sum(axis=1)
bounds = [0]
for row in (1,2):
middle = a.shape[0]*row//3
choices = np.arange(middle-a.shape[0]//30,middle+a.shape[0]//30+1)
bounds.append(int(choices[np.argmin(projection[choices]+abs(choices-middle)*.2)]))
bounds.append(a.shape[0])
crops, boxes = [], []
for top,bottom in zip(bounds,bounds[1:]):
_,labels,stats,_ = cv2.connectedComponentsWithStats(mask[top:bottom])
parts = [(i,s) for i,s in enumerate(stats[1:],1)
if s[cv2.CC_STAT_AREA] > 650 and s[cv2.CC_STAT_HEIGHT] > (bottom-top)*.5]
parts.sort(key=lambda p:p[1][0]+p[1][2]/2)
assert len(parts) == 4, ('walking row',top,len(parts))
for label,s in parts:
x,y,w,h,_ = map(int,s)
crop = a[top+y:top+y+h,x:x+w].copy()
crop[:,:,3] = np.where(labels[y:y+h,x:x+w] == label,255,0)
crops.append(Image.fromarray(crop))
boxes.append([x,top+y,w,h])
scale = min(standing_height/float(np.median([p.height for p in crops])),
184/max(p.width for p in crops),214/max(p.height for p in crops))
poses = []
for crop in crops:
size = tuple(max(1,round(v*scale)) for v in crop.size)
crop = crop.resize(size,Image.Resampling.LANCZOS)
frame = Image.new('RGBA',(W,H))
frame.alpha_composite(crop,((W-size[0])//2,220-size[1]))
poses.append(frame)
return poses, {'row_boundaries':bounds,'boxes':boxes,'scale':scale}
def main():
ART.mkdir(parents=True,exist_ok=True)
OUT.mkdir(parents=True,exist_ok=True)
metadata,roster = [],Image.new('RGBA',(W*10,H),(22,27,31,255))
with (OUT/'cast-v1.bin').open('wb') as target:
target.write(struct.pack('<4s4I',b'ANM1',W,H,10*STRIDE,10*STRIDE*W*H*4))
for identity,source_id in enumerate(SOURCES):
path = ART/f'person-{identity:02}-source-v1.png'
if not path.exists():
path.write_bytes((GENERATED/f'exec-{source_id}.png').read_bytes())
poses,info = isolate_rows(Image.open(path))
frames = [poses[0]]*4
for keys in ([0,1,0],[0,2,0]):
frames += sequence(poses,keys,4)
frames += [poses[3]]*4
for keys in ([3,4,3],[3,5,3]):
frames += sequence(poses,keys,4)
walk_path = ART/f'person-{identity:02}-walk-source-v2.png'
walk,walk_info = walking_poses(Image.open(walk_path),poses[0].getbbox()[3]-poses[0].getbbox()[1])
info['walking_v2'] = {'source':walk_path.name,**walk_info}
front = sequence(walk,list(range(4)),8,True)
back = sequence(walk,list(range(4,8)),8,True)
right = sequence(walk,list(range(8,12)),8,True)
walk_contact = Image.new('RGBA',(W*8,H*4),(22,27,31,255))
for index,frame in enumerate(front+back+right+[ImageOps.mirror(f) for f in right]):
walk_contact.alpha_composite(frame,((index%8)*W,(index//8)*H))
walk_contact.save(ART/f'person-{identity:02}-walk-normalized-v2.png')
frames += front+back+right+[ImageOps.mirror(f) for f in right]
frames += sequence(poses,[0,6,3],8)
frames += sequence(poses,[0,7,22],8)
frames += sequence(poses,[22,23,30,31],24)
frames += sequence(poses,[0,15,0],4)
assert len(frames) == STRIDE
for frame in frames:
target.write(frame.tobytes('raw','BGRA'))
roster.alpha_composite(poses[0],(identity*W,0))
contact = Image.new('RGBA',(W*8,H*4),(22,27,31,255))
for i,frame in enumerate(poses):
contact.alpha_composite(frame,((i%8)*W,(i//8)*H))
contact.save(ART/f'person-{identity:02}-normalized-v1.png')
motion = front+back+right+frames[56:96]
motion[0].save(ART/f'person-{identity:02}-motion-v1.gif',save_all=True,
append_images=motion[1:],duration=100,loop=0,disposal=2)
metadata.append({'id':identity,'description':DESCRIPTIONS[identity],**info})
print('Packed shared character',identity,flush=True)
roster.save(ART/'roster-v1.png')
(ART/'cast-v1.json').write_text(json.dumps({'dimensions':[W,H],'characters':metadata,
'stride':STRIDE,'actions':ACTIONS,'generation':'built-in imagegen; one identity-locked full sheet per person'},indent=2)+'\n')
if __name__ == '__main__':
main()