120 lines
6.8 KiB
Python
120 lines
6.8 KiB
Python
"""Read-only Blender audit of the CampGuide SOMA trial; writes JSON diagnostics."""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import bpy
|
|
import numpy as np
|
|
|
|
|
|
def points(mesh, evaluated=False):
|
|
obj = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else mesh
|
|
data = obj.to_mesh() if evaluated else obj.data
|
|
result = np.array([obj.matrix_world @ v.co for v in data.vertices])
|
|
if evaluated:
|
|
obj.to_mesh_clear()
|
|
return result
|
|
|
|
|
|
def audit(path):
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
if path.suffix == '.fbx':
|
|
bpy.ops.import_scene.fbx(filepath=str(path))
|
|
elif path.suffix == '.bvh':
|
|
bpy.ops.import_anim.bvh(filepath=str(path), global_scale=0.01, rotate_mode='QUATERNION', update_scene_fps=True)
|
|
else:
|
|
bpy.context.scene.render.fps = 30
|
|
bpy.ops.import_scene.gltf(filepath=str(path))
|
|
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
|
|
result = {'path': str(path)}
|
|
if arm:
|
|
result['bones'] = {b.name: {'parent': b.parent.name if b.parent else None,
|
|
'head': list(arm.matrix_world @ b.head_local),
|
|
'tail': list(arm.matrix_world @ b.tail_local),
|
|
'world_basis': [list((arm.matrix_world @ b.matrix_local).to_3x3().col[i].normalized()) for i in range(3)],
|
|
'deform': b.use_deform} for b in arm.data.bones}
|
|
result['gaps'] = {b.name: (arm.matrix_world @ b.head_local - arm.matrix_world @ b.parent.tail_local).length
|
|
for b in arm.data.bones if b.parent}
|
|
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
|
|
if meshes:
|
|
mesh = max(meshes, key=lambda o: len(o.data.vertices))
|
|
rest = points(mesh)
|
|
lo, hi = rest.min(0), rest.max(0)
|
|
height = float(hi[2]-lo[2])
|
|
weights = [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-5}
|
|
for v in mesh.data.vertices]
|
|
groups = sorted({g for w in weights for g in w})
|
|
def region(mask):
|
|
ids = np.flatnonzero(mask)
|
|
return {'vertices': len(ids), 'weights': {g: round(sum(weights[i].get(g, 0) for i in ids)/max(1, len(ids)), 5)
|
|
for g in groups if any(weights[i].get(g, 0) > .01 for i in ids)}}
|
|
result['mesh'] = {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons),
|
|
'bounds': [lo.tolist(), hi.tolist()],
|
|
'head_region': region(rest[:, 2] > lo[2]+height*.78),
|
|
'outer_hands': region(np.abs(rest[:, 0]) > max(abs(lo[0]), abs(hi[0]))*.78),
|
|
'weight_sums': [min(sum(w.values()) for w in weights), max(sum(w.values()) for w in weights)]}
|
|
# Weld coincident UV/normal seam vertices only for connectivity statistics.
|
|
_, inverse = np.unique(np.round(rest, 5), axis=0, return_inverse=True)
|
|
parent = np.arange(inverse.max()+1)
|
|
def root(x):
|
|
while parent[x] != x:
|
|
parent[x] = parent[parent[x]]
|
|
x = parent[x]
|
|
return x
|
|
edges = np.array([e.vertices[:] for e in mesh.data.edges])
|
|
for a, b in inverse[edges]:
|
|
parent[root(a)] = root(b)
|
|
labels = np.array([root(v) for v in inverse])
|
|
result['mesh']['component_sizes'] = sorted(np.unique(labels, return_counts=True)[1].tolist(), reverse=True)
|
|
result['mesh']['components'] = [dict(region(labels == label), bounds=[rest[labels == label].min(0).tolist(), rest[labels == label].max(0).tolist()])
|
|
for label, count in zip(*np.unique(labels, return_counts=True)) if count >= 25]
|
|
lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1)
|
|
valid = lengths > height*.002
|
|
action = arm.animation_data.action if arm and arm.animation_data else None
|
|
if action:
|
|
first, last = map(int, action.frame_range)
|
|
result['animation'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'frames': []}
|
|
endpoints = []
|
|
for frame in range(first, last+1):
|
|
bpy.context.scene.frame_set(frame)
|
|
deformed = points(mesh, True)
|
|
ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12)
|
|
ratios[~valid] = 0
|
|
worst = np.argsort(ratios)[-5:][::-1]
|
|
hand_mask = valid & (np.abs(rest[edges[:, 0], 0]) > height*.255) & (rest[edges[:, 0], 2] > height*.55) & (rest[edges[:, 0], 2] < height*.67)
|
|
result['animation']['frames'].append({'frame': frame, 'bounds': [deformed.min(0).tolist(), deformed.max(0).tolist()],
|
|
'hips_location': list(arm.pose.bones['Hips'].location),
|
|
'hand_max_stretch': float(ratios[hand_mask].max()),
|
|
'edges_over_2x': int((ratios > 2).sum()), 'max_stretch': float(ratios.max()),
|
|
'worst': [{'ratio': float(ratios[e]), 'ids': edges[e].tolist(),
|
|
'rest': rest[edges[e]].tolist(), 'weights': [weights[v] for v in edges[e]]} for e in worst]})
|
|
if frame in (first, last):
|
|
endpoints.append({b.name: b.rotation_quaternion.copy() for b in arm.pose.bones})
|
|
if len(endpoints) == 2:
|
|
result['animation']['loop_rotation_gap_degrees'] = {n: float(endpoints[0][n].rotation_difference(endpoints[1][n]).angle*180/np.pi)
|
|
for n in endpoints[0]}
|
|
elif arm and arm.animation_data and arm.animation_data.action:
|
|
first, last = map(int, arm.animation_data.action.frame_range)
|
|
samples = []
|
|
for frame in range(first, last+1):
|
|
bpy.context.scene.frame_set(frame)
|
|
samples.append({n: {'location': list(arm.pose.bones[n].location),
|
|
'world_head': list(arm.matrix_world @ arm.pose.bones[n].head)}
|
|
for n in ('Root', 'Hips', 'LeftFoot', 'RightFoot')})
|
|
result['motion'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'samples': samples}
|
|
return result
|
|
|
|
|
|
args = sys.argv[sys.argv.index('--')+1:]
|
|
out = Path(args[0])
|
|
result = []
|
|
for filename in args[1:]:
|
|
item = audit(Path(filename))
|
|
result.append(item)
|
|
print('AUDIT', filename, 'bones', len(item.get('bones', {})), 'mesh', item.get('mesh'))
|
|
if 'animation' in item:
|
|
worst = max(item['animation']['frames'], key=lambda f: f['max_stretch'])
|
|
print('WORST', json.dumps(worst))
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|