"""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()