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