Files
AIGame/Tools/model_tools/bl_diagnose_lmgirl_gait.py

195 lines
14 KiB
Python

"""Measure bone trajectories independently of skinning for the shared walk."""
import json
import sys
import math
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix,Vector,Quaternion
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
SOURCE=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
def quaternion(q):return Quaternion((q[3],*q[:3]))
def sample(data,frame):
source_indices={b['name']:i for i,b in enumerate(SOURCE['bones'])}
values=[SOURCE['clips'][0]['samples'][frame][source_indices[b['name']]] for b in data['bones']]
source_rot=[];mapped=[];world=[]
for i,(b,v) in enumerate(zip(data['bones'],values)):
parent=b['parent'];q=quaternion(v[3:7]);source_rot.append(source_rot[parent]@q if parent>=0 else q)
mapped.append(source_rot[i]@quaternion(data['bindRotationCorrections'][i]) if data.get('bindRotationCorrections') else source_rot[i])
q=mapped[parent].inverted()@mapped[i] if parent>=0 else mapped[i]
p=Vector(v[:3])
if data.get('translationOffsets'):
p*=data['translationScale']
if data.get('translationDeltaRotations'):p=quaternion(data['translationDeltaRotations'][i])@p
p+=Vector(data['translationOffsets'][i])
local=Matrix.LocRotScale(p,q,Vector(v[7:10]));world.append(world[parent]@local if parent>=0 else local)
return {b['name'].split(':')[-1]:world[i] for i,b in enumerate(data['bones'])}
def audit():
results={}
for label,data in [('boy',SOURCE),('girl_tokenrig',json.loads((FOLDER/'unity_review_data.json').read_text())),
('girl_mixamo',json.loads((FOLDER/'website/unity_review_data.json').read_text()))]:
samples=[]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
for f in range(32):
pose=sample(data,f);hips=pose['Hips'].translation
lateral=(pose['LeftUpLeg'].translation-pose['RightUpLeg'].translation).normalized()
feet={side:list(pose[side+'Foot'].translation-hips) for side in ['Left','Right']}
gap=(pose['LeftFoot'].translation-pose['RightFoot'].translation).dot(lateral)
knees=(pose['LeftLeg'].translation-pose['RightLeg'].translation).dot(lateral)
samples.append({'frame':f+1,'footSignedGap':gap,'kneeSignedGap':knees,'feetRelativeHips':feet})
results[label]={'restHipWidth':(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length,
'restAnkleWidth':(rest['LeftFoot'].translation-rest['RightFoot'].translation).length,
'leftRestLegVector':list(rest['LeftFoot'].translation-rest['LeftUpLeg'].translation),
'minimumFootSignedGap':min(s['footSignedGap'] for s in samples),'minimumKneeSignedGap':min(s['kneeSignedGap'] for s in samples),
'crossedFrames':[s['frame'] for s in samples if s['footSignedGap']<0],
'samples':samples}
(FOLDER/'gait_diagnosis_before.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='samples'} for k,d in results.items()},indent=2))
def corrected_motion(data,label):
names=[b['name'].split(':')[-1] for b in data['bones']];indices={n:i for i,n in enumerate(names)}
parents=[b['parent'] for b in data['bones']]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
source_rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in SOURCE['bones']}
target_width=(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length
source_width=(source_rest['LeftUpLeg'].translation-source_rest['RightUpLeg'].translation).length
scale=Vector((target_width/source_width,data['translationScale'],data['translationScale']))
samples=[];tracks=[];previous={}
for frame in range(32):
source=sample(SOURCE,frame);initial=sample(data,frame)
local=[]
for i,n in enumerate(names):
m=initial[names[parents[i]]].inverted()@initial[n] if parents[i]>=0 else initial[n]
p,q,s=m.decompose();local.append([p.copy(),q.copy(),s.copy()])
def fk():
result=[]
for i,(p,q,s) in enumerate(local):
m=Matrix.LocRotScale(p,q,s);result.append(result[parents[i]]@m if parents[i]>=0 else m)
return result
world=fk();target_hips=world[indices['Hips']].translation.copy();source_hips=source['Hips'].translation.copy()
goals={};errors={}
for side in ['Left','Right']:
upper=indices[side+'UpLeg'];lower=indices[side+'Leg'];foot=indices[side+'Foot']
source_foot=source[side+'Foot'].translation-source_hips;source_knee=source[side+'Leg'].translation-source_hips
goal=target_hips+Vector(tuple(source_foot[i]*scale[i] for i in range(3)))
hint=target_hips+Vector(tuple(source_knee[i]*scale[i] for i in range(3)))
h=world[upper].translation.copy();k=world[lower].translation.copy();f=world[foot].translation.copy()
foot_q=world[foot].to_quaternion().copy();a=(k-h).length;b=(f-k).length
direction=goal-h;wanted_distance=direction.length;direction.normalize()
distance=max(abs(a-b)+1e-7,min(wanted_distance,a+b-1e-7))
reachable=h+direction*distance
bend=hint-h-direction*(hint-h).dot(direction)
if bend.length<1e-7:bend=k-h-direction*(k-h).dot(direction)
if bend.length<1e-7:bend=Vector((0,0,-1))-direction*direction.dot(Vector((0,0,-1)))
bend.normalize();along=(a*a+distance*distance-b*b)/(2*distance)
desired_knee=h+direction*along+bend*math.sqrt(max(0,a*a-along*along))
upper_q=(k-h).rotation_difference(desired_knee-h)@world[upper].to_quaternion()
local[upper][1]=world[parents[upper]].to_quaternion().inverted()@upper_q
world=fk()
lower_q=(world[foot].translation-world[lower].translation).rotation_difference(reachable-world[lower].translation)@world[lower].to_quaternion()
local[lower][1]=world[parents[lower]].to_quaternion().inverted()@lower_q
world=fk();local[foot][1]=world[parents[foot]].to_quaternion().inverted()@foot_q;world=fk()
goals[side]=list(goal);errors[side]=(world[foot].translation-goal).length
values=[]
for i,(p,q,s) in enumerate(local):
if i in previous and previous[i].dot(q)<0:q.negate()
previous[i]=q.copy();values.append(list(p)+[q.x,q.y,q.z,q.w]+list(s))
samples.append(values)
lateral=(world[indices['LeftUpLeg']].translation-world[indices['RightUpLeg']].translation).normalized()
gap=(world[indices['LeftFoot']].translation-world[indices['RightFoot']].translation).dot(lateral)
tracks.append({'frame':frame+1,'footSignedGap':gap,'goalErrors':errors,'goals':goals,
'positions':{n:list(world[i].translation) for n,i in indices.items() if n in ['Hips','LeftUpLeg','RightUpLeg','LeftLeg','RightLeg','LeftFoot','RightFoot']}})
return {'name':'Walking_LmGirl'+('Mixamo33' if label=='mixamo' else '41'),'fps':30,'samples':samples}, {
'footMotionScale':list(scale),'minimumFootSignedGap':min(t['footSignedGap'] for t in tracks),
'crossedFrames':[t['frame'] for t in tracks if t['footSignedGap']<0],
'maxUnreachableGoalError':max(max(t['goalErrors'].values()) for t in tracks),'tracks':tracks}
def correct():
result={'method':'Pelvis-relative source ankle paths scaled by target hip width and hip-to-ankle span; analytic two-bone IK with source knee hints, fixed segment lengths and preserved mapped foot world rotations.',
'sourceClipUnmodified':True,'sourceClips':'CampGuide native Walking','motions':{},'reports':{}}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
result['motions'][label],result['reports'][label]=corrected_motion(json.loads(path.read_text()),label)
result['deformation']={}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
data=json.loads(path.read_text());motion=result['motions'][label];parents=[b['parent'] for b in data['bones']]
for variant in data['variants']:
points=np.array(variant['vertices']);p4=np.column_stack((points,np.ones(len(points))))
weights=np.zeros((len(points),len(parents)))
for i,row in enumerate(variant['weights']):
for j,w in row:weights[i,j]=w
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);lengths=edges[:,2];masks=edges[:,3].astype(int)
maxima={name:0 for name in ['all','hands','neck']};over=maxima.copy();floors=[]
for values in motion['samples']:
world=[]
for i,v in enumerate(values):
local=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@local if parents[i]>=0 else local)
deformed=np.zeros_like(points)
for i,b in enumerate(data['bones']):deformed+=weights[:,i,None]*(p4@np.array(world[i]@Matrix(b['bindpose'])).T)[:,:3]
ratios=np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/lengths;floors.append(float(deformed[:,1].min()))
for name,mask in [('all',1),('hands',2),('neck',4)]:
region=ratios[(masks&mask)!=0];maxima[name]=max(maxima[name],float(region.max()));over[name]=max(over[name],int((region>2).sum()))
result['deformation'][variant['name']]={'maxStretch':maxima,'maxEdgesOver2x':over,'floorMinRange':[min(floors),max(floors)]}
(FOLDER/'gait_correction.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='tracks'} for k,d in result['reports'].items()},indent=2))
def export_corrected():
result=json.loads((FOLDER/'gait_correction.json').read_text());C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
bpy.ops.wm.open_mainfile(filepath=str(FOLDER/('lmgirl_'+tag+'.blend')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');arm.animation_data_clear();arm.data.pose_position='POSE'
names=[b.name for b in arm.data.bones];data=json.loads((FOLDER/'unity_review_data.json').read_text());parents=[b['parent'] for b in data['bones']]
for b in arm.pose.bones:b.rotation_mode='QUATERNION'
for f,values in enumerate(result['motions']['tokenrig']['samples'],1):
bpy.context.scene.frame_set(f);world=[]
for i,v in enumerate(values):
m=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@m if parents[i]>=0 else m)
for i,n in enumerate(names):
b=arm.pose.bones[n];b.matrix=arm.matrix_world.inverted()@C@world[i]@C;bpy.context.view_layer.update()
for attr in ['location','rotation_quaternion','scale']:b.keyframe_insert(attr,frame=f)
bpy.context.scene.frame_start=1;bpy.context.scene.frame_end=32;bpy.context.scene.render.fps=30
path=FOLDER/('Walking_'+tag+'_corrected.glb')
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
receipts.append({'file':str(path),'frames':[1,32],'source':result['motions']['tokenrig']['name']})
(FOLDER/'gait_export_manifest.json').write_text(json.dumps(receipts,indent=2),encoding='utf-8')
def verify_exports():
from mathutils.kdtree import KDTree
data=json.loads((FOLDER/'unity_review_data.json').read_text());result=json.loads((FOLDER/'gait_correction.json').read_text());receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
variant=next(v for v in data['variants'] if v['name']==tag)
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(FOLDER/('Walking_'+tag+'_corrected.glb')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
# Mesh raw geometry is Blender Z-up; convert to native review Y-up.
points=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices]);points=points[:,[0,2,1]]
tree=KDTree(len(points))
for i,p in enumerate(points):tree.insert(Vector(p),i)
tree.balance();mapping=[]
for p in variant['vertices']:
_,index,distance=tree.find(Vector(p));assert distance<1e-5;mapping.append(index)
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);max_stretch=0;min_gap=100
for frame in range(1,33):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
obj=mesh.evaluated_get(bpy.context.evaluated_depsgraph_get());geometry=obj.to_mesh()
deformed=np.array([obj.matrix_world@v.co for v in geometry.vertices])[mapping];obj.to_mesh_clear()
max_stretch=max(max_stretch,float((np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/edges[:,2]).max()))
p={n:arm.matrix_world@arm.pose.bones['mixamorig:'+n].head for n in ['LeftUpLeg','RightUpLeg','LeftFoot','RightFoot']}
lateral=(p['LeftUpLeg']-p['RightUpLeg']).normalized();min_gap=min(min_gap,(p['LeftFoot']-p['RightFoot']).dot(lateral))
error=abs(max_stretch-result['deformation'][tag]['maxStretch']['all']);assert error<.003,(tag,error)
assert min_gap>.08,(tag,min_gap)
receipts.append({'variant':tag,'maxStretch':max_stretch,'error':error,'minimumFootSignedGap':min_gap})
(FOLDER/'gait_export_verification.json').write_text(json.dumps({'passed':True,'results':receipts},indent=2),encoding='utf-8')
print('GAIT_EXPORTS_VERIFIED',len(receipts))
if __name__=='__main__':
if '--correct' in sys.argv:correct()
elif '--export' in sys.argv:export_corrected()
elif '--verify-exports' in sys.argv:verify_exports()
else:audit()