203 lines
10 KiB
Python
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()
|