195 lines
14 KiB
Python
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()
|