Files

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')