Files
AIGame/Tools/model_tools/bl_compare_mixamo_soma.py

244 lines
14 KiB
Python

"""Offline skin/motion comparison. Never modifies the source FBXs or Unity assets.
Mixamo clips are skeleton-only. Their evaluated world bone matrices are applied
to the same-name bones of the original skinned model, preserving its bind pose.
This explicitly handles the differing bind/rest poses; it is not Unity Humanoid.
"""
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
REFERENCE_POINTS = None
GEOMETRY_CHECKS = {}
def coords(obj, evaluated=False):
item = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else obj
data = item.to_mesh() if evaluated else item.data
result = np.array([item.matrix_world @ v.co for v in data.vertices], dtype=np.float64)
if evaluated:
item.to_mesh_clear()
return result
def imported(path):
before = set(bpy.data.objects)
if path.suffix.lower() == '.blend':
bpy.ops.wm.open_mainfile(filepath=str(path))
elif path.suffix.lower() == '.fbx':
bpy.ops.import_scene.fbx(filepath=str(path))
else:
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
return [o for o in bpy.data.objects if o not in before]
def configure_render(height, center):
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 16
scene.render.resolution_x, scene.render.resolution_y = 512, 640
scene.render.resolution_percentage = 100
scene.view_settings.view_transform = 'AgX'
world = bpy.data.worlds.new('AuditWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.35, .38, .42, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .7
scene.world = world
camera = bpy.data.objects.new('AuditCamera', bpy.data.cameras.new('AuditCamera'))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = 'ORTHO'
camera.data.ortho_scale = height*1.3
for direction, power in [((2, -3, 4), 180), ((-3, 1, 2), 120)]:
lamp = bpy.data.objects.new('AuditLight', bpy.data.lights.new('AuditLight', 'AREA'))
scene.collection.objects.link(lamp)
lamp.location = center + Vector(direction)*height
lamp.rotation_euler = (center-lamp.location).to_track_quat('-Z', 'Y').to_euler()
lamp.data.energy, lamp.data.size = power*height**2, height*3
return camera
def audit(label, mesh_path, clip_path, out):
global REFERENCE_POINTS
bpy.ops.wm.read_factory_settings(use_empty=True)
objects = imported(mesh_path)
arm = next(o for o in objects if o.type == 'ARMATURE')
mesh = max((o for o in objects if o.type == 'MESH'), key=lambda o: len(o.data.vertices))
rest = coords(mesh)
up = int(np.argmax(np.ptp(rest, axis=0)))
normalized = rest[:, [0, 2, 1]].copy() if up == 1 else rest.copy()
if up == 1:
normalized[:, 1] *= -1
lo, hi = normalized.min(0), normalized.max(0)
height = float(hi[2]-lo[2])
if REFERENCE_POINTS is None:
REFERENCE_POINTS = normalized.copy()
if str(mesh_path) not in GEOMETRY_CHECKS:
matches=[]
for sign in (1,-1):
candidate=normalized.copy()
candidate[:,:2] *= sign
distances=[]
for start in range(0,len(candidate),128):
distance2=((candidate[start:start+128,None,:]-REFERENCE_POINTS[None,:,:])**2).sum(2)
distances.extend(np.sqrt(distance2.min(1)).tolist())
matches.append({'yaw_degrees': 0 if sign==1 else 180, 'max_nearest_vertex_distance_m': max(distances),
'mean_nearest_vertex_distance_m': float(np.mean(distances))})
GEOMETRY_CHECKS[str(mesh_path)] = min(matches,key=lambda m:m['mean_nearest_vertex_distance_m'])
groups = [g.name for g in mesh.vertex_groups]
weights = np.zeros((len(rest), len(groups)))
for v in mesh.data.vertices:
for g in v.groups:
weights[v.index, g.group] = g.weight
edges = np.array([e.vertices[:] for e in mesh.data.edges])
lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1)
valid = lengths > height*.002
z = (normalized[:, 2]-lo[2])/height
hand = (np.abs(normalized[:, 0]) > height*.255) & (z > .55) & (z < .67)
neck = (z > .67) & (z < .78) & (np.abs(normalized[:, 0]) < height*.12)
head = z > .78
regions = {'all': np.ones(len(rest), dtype=bool), 'hands': hand, 'neck': neck, 'head': head}
region_edges = {n: valid & mask[edges[:, 0]] & mask[edges[:, 1]] for n, mask in regions.items()}
foot_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Foot', 'Toe', 'Shin', 'UpLeg')) or n.endswith('Leg')]
head_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Head', 'Neck'))]
def region_summary(mask):
w = weights[mask]
return {'vertices': int(mask.sum()), 'average_weights': {n: float(w[:,i].mean()) for i,n in enumerate(groups) if w[:,i].max() > .01},
'max_foot_influence': float(w[:,foot_groups].sum(1).max()),
'max_head_neck_influence': float(w[:,head_groups].sum(1).max())}
def norm_point(p):
return [p.x, -p.z, p.y] if up == 1 else list(p)
bones = {b.name: {'head': norm_point(arm.matrix_world @ b.head_local), 'tail': norm_point(arm.matrix_world @ b.tail_local),
'parent': b.parent.name if b.parent else None} for b in arm.data.bones}
result = {'label': label, 'mesh_source': str(mesh_path), 'clip_source': str(clip_path) if clip_path else None,
'geometry_match_to_first_mixamo': GEOMETRY_CHECKS[str(mesh_path)],
'mesh': {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons), 'height_m': height,
'bounds_normalized_z_up': [lo.tolist(), hi.tolist()], 'hands': region_summary(hand), 'head': region_summary(head),
'max_influences_over_0_00001': int((weights > .00001).sum(1).max()),
'max_influences_over_0_01': int((weights > .01).sum(1).max()),
'max_weight_discarded_if_top4': float(np.sort(weights,axis=1)[:,:-4].sum(1).max()),
'weight_sum_range': [float(weights.sum(1).min()), float(weights.sum(1).max())],
'rest_edge_lengths_sha256': hashlib.sha256(np.round(np.sort(lengths), 5).tobytes()).hexdigest()},
'bones': bones}
# Motion-independent LBS stress test: head yaw 30 degrees and both upper arms
# down 70 degrees, in normalized world coordinates. Same angles for both rigs.
# Descendants inherit their parent's deformation; joint pivots remain those
# of each delivered rig, so this tests the combined bind placement + weights.
canonical = Matrix.Rotation(math.pi/2,4,'X') if up == 1 else Matrix.Identity(4)
bind = {b.name: canonical @ arm.matrix_world @ b.matrix_local for b in arm.data.bones}
def stress(arm_down, head_yaw):
deform = {}
for b in arm.data.bones:
inherited = deform[b.parent.name] if b.parent else Matrix.Identity(4)
posed = inherited @ bind[b.name]
suffix = b.name.split(':')[-1]
angle, axis = 0, 'Z'
if suffix == 'Head':
angle = head_yaw
elif suffix in ('LeftArm','RightArm'):
angle, axis = arm_down*(1 if bind[b.name].translation.x > 0 else -1), 'Y'
if angle:
pivot = posed.translation
posed = Matrix.Translation(pivot) @ Matrix.Rotation(math.radians(angle),4,axis) @ Matrix.Translation(-pivot) @ posed
deform[b.name] = posed @ bind[b.name].inverted()
points4 = np.column_stack((normalized,np.ones(len(normalized))))
deformed = np.zeros((len(rest),3))
for i,name in enumerate(groups):
deformed += weights[:,i,None]*(points4 @ np.array(deform[name]).T)[:,:3]
ratios = np.linalg.norm(deformed[edges[:,1]]-deformed[edges[:,0]],axis=1)/np.maximum(lengths,1e-12)
return {n: {'max_edge_stretch': float(ratios[mask].max()), 'edges_over_2x': int((ratios[mask] > 2).sum())}
for n,mask in region_edges.items()}
result['controlled_lbs_stress'] = {'neutral': stress(0,0), 'arms_down_70_head_yaw_30': stress(70,30)}
source = None
if clip_path:
objects = imported(clip_path)
source = next(o for o in objects if o.type == 'ARMATURE')
assert set(arm.pose.bones.keys()) == set(source.pose.bones.keys()), 'skeleton names differ'
action_arm = source or arm
action = action_arm.animation_data.action if action_arm.animation_data else None
if not action:
return result
first, last = map(int, action.frame_range)
fps = bpy.context.scene.render.fps/bpy.context.scene.render.fps_base
samples, endposes, worldposes = [], [], []
named = lambda suffix: next((b.name for b in action_arm.pose.bones if b.name == suffix or b.name.endswith(':'+suffix)), None)
hips_name = named('Hips')
tracks = {s: named(s) for s in ['Hips','Head','LeftFoot','RightFoot','LeftToeBase','RightToeBase']}
for frame in range(first, last+1):
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
if source:
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
deformed = coords(mesh, True)
ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12)
worst = int(np.argmax(np.where(valid, ratios, 0)))
values = {n: {'max': float(ratios[mask].max()) if mask.any() else None,
'p99': float(np.percentile(ratios[mask],99)) if mask.any() else None,
'over_2x': int((ratios[mask] > 2).sum())} for n,mask in region_edges.items()}
feet = {s: [float(deformed[weights[:,i] > .25,2].min()) for i,n in enumerate(groups) if s+'Foot' in n and (weights[:,i] > .25).any()]
for s in ['Left','Right']}
samples.append({'frame': frame, 'regions': values,
'floor_min_z': float(deformed[:,2].min()), 'feet_min_z': feet,
'hips_world': list(action_arm.matrix_world @ action_arm.pose.bones[hips_name].head),
'worst_edge': {'ids': edges[worst].tolist(), 'rest': normalized[edges[worst]].tolist(),
'ratio': float(ratios[worst]), 'weights': [{n: float(weights[v,i]) for i,n in enumerate(groups) if weights[v,i]>.00001} for v in edges[worst]]}})
poses = {b.name: b.matrix_basis.to_quaternion().copy() for b in action_arm.pose.bones}
world = {n: list(action_arm.matrix_world @ action_arm.pose.bones[b].head) for n,b in tracks.items() if b}
worldposes.append(world)
if frame in (first, last):
endposes.append(poses)
seam = {n: min(float(endposes[0][n].rotation_difference(endposes[-1][n]).angle),2*math.pi-float(endposes[0][n].rotation_difference(endposes[-1][n]).angle))*180/math.pi for n in endposes[0]}
result['animation'] = {'range': [first,last], 'fps': fps, 'duration_between_endpoints_s': (last-first)/fps,
'samples': samples, 'joint_tracks': worldposes, 'loop_local_rotation_gap_degrees': seam,
'summary': {n: {'max_edge_stretch': max(s['regions'][n]['max'] for s in samples if s['regions'][n]['max'] is not None),
'max_p99_edge_stretch': max(s['regions'][n]['p99'] for s in samples if s['regions'][n]['p99'] is not None),
'max_edges_over_2x': max(s['regions'][n]['over_2x'] for s in samples)} for n in regions}}
print('METRICS', label, result['animation']['summary'], 'SEAM', sorted(seam.items(),key=lambda x:x[1],reverse=True)[:4])
if label.startswith(('Mixamo','TokenRig41','TokenRigLocal')) and '--metrics-only' not in sys.argv:
mid = first+(last-first)//3
# Re-evaluate one representative source frame before rendering.
bpy.context.scene.frame_set(mid)
bpy.context.view_layer.update()
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
points = coords(mesh, True)
center = Vector(((points[:,0].min()+points[:,0].max())/2, (points[:,1].min()+points[:,1].max())/2, (points[:,2].min()+points[:,2].max())/2))
camera = configure_render(height, center)
for view, direction in [('front',(0,-3,.12)), ('side',(3,0,.12)), ('back',(0,3,.12))]:
camera.location = center+Vector(direction)*height
camera.rotation_euler = (center-camera.location).to_track_quat('-Z','Y').to_euler()
bpy.context.scene.render.filepath = str(out/(label.replace(' ','_')+'_'+view+'.png'))
bpy.ops.render.render(write_still=True)
return result
def main():
root, out = map(Path, sys.argv[sys.argv.index('--')+1:][:2])
out.mkdir(parents=True, exist_ok=True)
base = root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
soma = root/'Tools/output/campguide_studio/rig_soma'
jobs = [('Mixamo '+name, base/'CampGuideStudio.fbx', base/('CampGuideStudio@'+name+'.fbx')) for name in ['Walking','Breathing Idle','Happy Idle']]
jobs += [('SOMA '+name, soma/'kimodo'/name/'baked.glb',None) for name in ['Idle','Walk']]
results=[]
for label,mesh,clip in jobs:
result=audit(label,mesh,clip,out)
results.append(result)
(out/'comparison.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('AUDIT_DONE',out/'comparison.json')
if __name__ == '__main__':
main()