Files
healer-man/scripts/runewaker-pipeline/blender/audit-animated-actor-poses.py
T
2026-08-14 15:56:39 -04:00

734 lines
29 KiB
Python

"""Audit evaluated skinned poses in an uncompressed animated RuneWaker GLB.
Run through Blender so the same armature and modifier evaluation used by the
diagnostic renderer is exercised::
blender --background --factory-startup --python audit-animated-actor-poses.py -- \
input.animated.raw.glb output.pose-audit.json [options]
The default samples the bind pose plus the first, middle, and last frame of
every action. The resulting JSON is deterministic: it contains no timestamps
and all actions, meshes, components, issues, and rendered filenames are sorted.
Options:
--sample-mode all|families Audit every action, or one per semantic family.
--render-mode none|flagged|families|all
--render-dir DIRECTORY Directory for compact 256px review frames.
--contact-sheet FILE Combine rendered frames into one PNG.
--render-limit INTEGER Maximum non-bind review frames (default 24).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import sys
from pathlib import Path
from typing import Iterable
import bpy
from mathutils import Vector
TOOL_VERSION = "1.0.0"
EPSILON = 1.0e-9
THRESHOLDS = {
"minimumDiagonalRatio": 0.2,
"maximumDiagonalRatio": 5.0,
"minimumRadiusRatio": 0.2,
"maximumRadiusRatio": 5.0,
"maximumCentroidShiftRatio": 2.0,
"maximumAxisExtentRatio": 8.0,
"minimumAxisExtentRatio": 0.05,
"maximumComponentCenterGlobalRatio": 0.75,
"maximumComponentCenterLocalRatio": 12.0,
"maximumComponentDiagonalRatio": 8.0,
"minimumComponentDiagonalRatio": 0.05,
"disconnectedEnvelopeRadiusRatio": 4.0,
}
def parse_arguments() -> argparse.Namespace:
values = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input_glb", type=Path)
parser.add_argument("output_json", type=Path)
parser.add_argument(
"--source-file",
type=Path,
help="Original shipping GLB when input_glb is a temporary decompressed copy.",
)
parser.add_argument(
"--source-sha256",
help="Precomputed SHA-256 for --source-file (avoids reading it again in Blender).",
)
parser.add_argument(
"--variant-id",
help="Stable packaged-variant identifier, normally its path below public assets/creatures.",
)
parser.add_argument("--sample-mode", choices=("all", "families"), default="all")
parser.add_argument(
"--render-mode",
choices=("none", "flagged", "families", "all"),
default="none",
)
parser.add_argument("--render-dir", type=Path)
parser.add_argument("--contact-sheet", type=Path)
parser.add_argument("--render-limit", type=int, default=24)
result = parser.parse_args(values)
if result.render_limit < 0:
parser.error("--render-limit cannot be negative")
if result.render_mode != "none" and result.render_dir is None:
parser.error("--render-dir is required when review frames are enabled")
if result.contact_sheet is not None and result.render_dir is None:
parser.error("--render-dir is required with --contact-sheet")
return result
def rounded(value: float) -> float:
return round(float(value), 6)
def vector_json(value: Vector) -> list[float]:
return [rounded(value.x), rounded(value.y), rounded(value.z)]
def ratio(numerator: float, denominator: float, default: float = 1.0) -> float:
return numerator / denominator if abs(denominator) > EPSILON else default
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def actor_id(path: Path) -> str:
suffix = ".animated.raw.glb"
return path.name[: -len(suffix)] if path.name.endswith(suffix) else path.stem
def semantic_family(name: str) -> str:
value = re.sub(r"[^a-z0-9]+", " ", name.casefold())
families = (
("death", ("death", "dead", "die")),
("attack", ("attack", "strike", "melee", "shoot")),
("idle", ("idle", "stand")),
("run", ("run", "sprint")),
("walk", ("walk", "move")),
("hit", ("hit", "hurt", "wound", "damage")),
("cast", ("cast", "spell", "magic")),
("spawn", ("spawn", "birth", "emerge")),
("special", ("skill", "special", "roar", "stun", "knock")),
)
words = set(value.split())
for family, needles in families:
if any(needle in words or needle in value for needle in needles):
return family
return "other"
class UnionFind:
def __init__(self, size: int) -> None:
self.parent = list(range(size))
self.rank = [0] * size
def find(self, value: int) -> int:
root = value
while self.parent[root] != root:
root = self.parent[root]
while self.parent[value] != value:
next_value = self.parent[value]
self.parent[value] = root
value = next_value
return root
def union(self, left: int, right: int) -> None:
left_root = self.find(left)
right_root = self.find(right)
if left_root == right_root:
return
if self.rank[left_root] < self.rank[right_root]:
left_root, right_root = right_root, left_root
self.parent[right_root] = left_root
if self.rank[left_root] == self.rank[right_root]:
self.rank[left_root] += 1
def mesh_components(mesh_object: bpy.types.Object) -> list[list[int]]:
mesh = mesh_object.data
union_find = UnionFind(len(mesh.vertices))
for edge in mesh.edges:
union_find.union(edge.vertices[0], edge.vertices[1])
grouped: dict[int, list[int]] = {}
for index in range(len(mesh.vertices)):
grouped.setdefault(union_find.find(index), []).append(index)
return sorted(grouped.values(), key=lambda indices: (indices[0], len(indices)))
def bounds_metrics(points: Iterable[Vector]) -> dict[str, object]:
point_list = list(points)
finite = [point for point in point_list if all(math.isfinite(axis) for axis in point)]
non_finite = len(point_list) - len(finite)
if not finite:
return {
"vertexCount": len(point_list),
"finiteVertices": 0,
"nonFiniteVertices": non_finite,
"minimum": None,
"maximum": None,
"centroid": None,
"axisExtents": None,
"diagonal": None,
"maximumRadius": None,
"rmsRadius": None,
}
minimum = Vector((min(p.x for p in finite), min(p.y for p in finite), min(p.z for p in finite)))
maximum = Vector((max(p.x for p in finite), max(p.y for p in finite), max(p.z for p in finite)))
centroid = sum(finite, Vector()) / len(finite)
squared_distances = [(point - centroid).length_squared for point in finite]
extents = maximum - minimum
return {
"vertexCount": len(point_list),
"finiteVertices": len(finite),
"nonFiniteVertices": non_finite,
"minimum": vector_json(minimum),
"maximum": vector_json(maximum),
"centroid": vector_json(centroid),
"axisExtents": vector_json(extents),
"diagonal": rounded(extents.length),
"maximumRadius": rounded(math.sqrt(max(squared_distances))),
"rmsRadius": rounded(math.sqrt(sum(squared_distances) / len(squared_distances))),
}
def evaluate_vertices(
mesh_objects: list[bpy.types.Object],
) -> tuple[dict[str, list[Vector]], list[str]]:
dependency_graph = bpy.context.evaluated_depsgraph_get()
result: dict[str, list[Vector]] = {}
warnings: list[str] = []
for mesh_object in mesh_objects:
evaluated_object = mesh_object.evaluated_get(dependency_graph)
evaluated_mesh = evaluated_object.to_mesh()
try:
points = [evaluated_object.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices]
result[mesh_object.name] = points
if len(points) != len(mesh_object.data.vertices):
warnings.append(
f"{mesh_object.name}: evaluated vertex count {len(points)} differs from source "
f"count {len(mesh_object.data.vertices)}; component metrics omitted"
)
finally:
evaluated_object.to_mesh_clear()
return result, warnings
def set_bind_pose(scene: bpy.types.Scene, armatures: list[bpy.types.Object]) -> None:
for armature in armatures:
if armature.animation_data is not None:
armature.animation_data.action = None
armature.data.pose_position = "REST"
scene.frame_set(0)
bpy.context.view_layer.update()
def set_action_pose(
scene: bpy.types.Scene,
armatures: list[bpy.types.Object],
action: bpy.types.Action,
frame: float,
) -> None:
for index, armature in enumerate(armatures):
armature.data.pose_position = "POSE"
if armature.animation_data is None:
armature.animation_data_create()
armature.animation_data.action = action if index == 0 else None
integer_frame = math.floor(frame)
scene.frame_set(integer_frame, subframe=frame - integer_frame)
bpy.context.view_layer.update()
def component_metrics(
vertices_by_mesh: dict[str, list[Vector]],
topology: dict[str, list[list[int]]],
) -> dict[str, dict[str, object]]:
result: dict[str, dict[str, object]] = {}
for mesh_name in sorted(topology, key=str.casefold):
vertices = vertices_by_mesh.get(mesh_name, [])
components = topology[mesh_name]
expected_vertices = sum(len(component) for component in components)
if len(vertices) != expected_vertices:
continue
for index, vertex_indices in enumerate(components):
key = f"{mesh_name}#{index:04d}"
result[key] = bounds_metrics(vertices[vertex_index] for vertex_index in vertex_indices)
return result
def flatten_vertices(vertices_by_mesh: dict[str, list[Vector]]) -> list[Vector]:
return [
vertex
for mesh_name in sorted(vertices_by_mesh, key=str.casefold)
for vertex in vertices_by_mesh[mesh_name]
]
def compare_pose(
pose: dict[str, object],
bind: dict[str, object],
posed_components: dict[str, dict[str, object]],
bind_components: dict[str, dict[str, object]],
) -> tuple[dict[str, object], list[dict[str, object]]]:
comparisons: dict[str, object] = {
"diagonalRatio": None,
"maximumRadiusRatio": None,
"rmsRadiusRatio": None,
"centroidShiftRatio": None,
"axisExtentRatios": None,
"componentCount": len(posed_components),
"extremeComponentCount": 0,
"disconnectedComponentCount": 0,
"componentOutliers": [],
}
issues: list[dict[str, object]] = []
non_finite = int(pose["nonFiniteVertices"])
if non_finite:
issues.append({"severity": "error", "code": "non-finite-vertices", "value": non_finite})
if pose["diagonal"] is None or bind["diagonal"] is None:
issues.append({"severity": "error", "code": "missing-finite-bounds"})
return comparisons, issues
diagonal_ratio = ratio(float(pose["diagonal"]), float(bind["diagonal"]))
maximum_radius_ratio = ratio(float(pose["maximumRadius"]), float(bind["maximumRadius"]))
rms_radius_ratio = ratio(float(pose["rmsRadius"]), float(bind["rmsRadius"]))
centroid = Vector(pose["centroid"])
bind_centroid = Vector(bind["centroid"])
centroid_shift_ratio = ratio((centroid - bind_centroid).length, float(bind["diagonal"]), 0.0)
pose_extents = pose["axisExtents"]
bind_extents = bind["axisExtents"]
axis_ratios = [ratio(float(pose_extents[i]), float(bind_extents[i])) for i in range(3)]
comparisons.update(
{
"diagonalRatio": rounded(diagonal_ratio),
"maximumRadiusRatio": rounded(maximum_radius_ratio),
"rmsRadiusRatio": rounded(rms_radius_ratio),
"centroidShiftRatio": rounded(centroid_shift_ratio),
"axisExtentRatios": [rounded(value) for value in axis_ratios],
}
)
def ratio_issue(code: str, value: float, minimum: float, maximum: float) -> None:
if value < minimum or value > maximum:
issues.append({"severity": "warning", "code": code, "value": rounded(value)})
ratio_issue(
"extreme-aabb-diagonal",
diagonal_ratio,
THRESHOLDS["minimumDiagonalRatio"],
THRESHOLDS["maximumDiagonalRatio"],
)
ratio_issue(
"extreme-maximum-radius",
maximum_radius_ratio,
THRESHOLDS["minimumRadiusRatio"],
THRESHOLDS["maximumRadiusRatio"],
)
if centroid_shift_ratio > THRESHOLDS["maximumCentroidShiftRatio"]:
issues.append(
{"severity": "warning", "code": "extreme-centroid-shift", "value": rounded(centroid_shift_ratio)}
)
if any(
value < THRESHOLDS["minimumAxisExtentRatio"]
or value > THRESHOLDS["maximumAxisExtentRatio"]
for value in axis_ratios
):
issues.append(
{
"severity": "warning",
"code": "extreme-axis-extent",
"value": [rounded(value) for value in axis_ratios],
}
)
outliers: list[dict[str, object]] = []
disconnected = 0
for key in sorted(set(posed_components) & set(bind_components), key=str.casefold):
current = posed_components[key]
reference = bind_components[key]
if current["centroid"] is None or reference["centroid"] is None:
continue
current_center = Vector(current["centroid"])
reference_center = Vector(reference["centroid"])
center_shift = (current_center - reference_center).length
global_ratio = ratio(center_shift, float(bind["diagonal"]), 0.0)
local_denominator = max(float(reference["diagonal"] or 0.0), float(bind["diagonal"]) * 0.01)
local_ratio = ratio(center_shift, local_denominator, 0.0)
component_diagonal_ratio = ratio(
float(current["diagonal"] or 0.0), float(reference["diagonal"] or 0.0)
)
outside_ratio = ratio((current_center - bind_centroid).length, float(bind["maximumRadius"]), 0.0)
is_extreme = (
global_ratio > THRESHOLDS["maximumComponentCenterGlobalRatio"]
and local_ratio > THRESHOLDS["maximumComponentCenterLocalRatio"]
) or not (
THRESHOLDS["minimumComponentDiagonalRatio"]
<= component_diagonal_ratio
<= THRESHOLDS["maximumComponentDiagonalRatio"]
)
is_disconnected = outside_ratio > THRESHOLDS["disconnectedEnvelopeRadiusRatio"]
if is_disconnected:
disconnected += 1
if is_extreme or is_disconnected:
outliers.append(
{
"component": key,
"vertexCount": current["vertexCount"],
"centerShiftGlobalRatio": rounded(global_ratio),
"centerShiftLocalRatio": rounded(local_ratio),
"diagonalRatio": rounded(component_diagonal_ratio),
"envelopeRadiusRatio": rounded(outside_ratio),
"disconnected": is_disconnected,
}
)
outliers.sort(
key=lambda item: (
-max(
float(item["centerShiftGlobalRatio"]),
float(item["envelopeRadiusRatio"]),
abs(math.log(max(float(item["diagonalRatio"]), EPSILON))),
),
str(item["component"]).casefold(),
)
)
comparisons["extremeComponentCount"] = len(outliers)
comparisons["disconnectedComponentCount"] = disconnected
comparisons["componentOutliers"] = outliers[:12]
if outliers:
issues.append({"severity": "warning", "code": "extreme-components", "value": len(outliers)})
if disconnected:
issues.append(
{"severity": "warning", "code": "disconnected-components", "value": disconnected}
)
return comparisons, issues
def unique_sample_frames(action: bpy.types.Action) -> list[tuple[str, float]]:
first, last = (float(value) for value in action.frame_range)
candidates = (("start", first), ("middle", (first + last) * 0.5), ("end", last))
result: list[tuple[str, float]] = []
seen: set[float] = set()
for label, frame in candidates:
key = round(frame, 6)
if key not in seen:
seen.add(key)
result.append((label, frame))
return result
def look_at(obj: bpy.types.Object, point: Vector) -> None:
obj.rotation_euler = (point - obj.location).to_track_quat("-Z", "Y").to_euler()
def configure_review_scene(scene: bpy.types.Scene, bind: dict[str, object]) -> None:
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 256
scene.render.resolution_y = 256
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.world.color = (0.025, 0.03, 0.04)
minimum = Vector(bind["minimum"])
maximum = Vector(bind["maximum"])
center = (minimum + maximum) * 0.5
size = max(*(maximum - minimum), 1.0)
camera_data = bpy.data.cameras.new("PoseAuditCamera")
camera = bpy.data.objects.new("PoseAuditCamera", camera_data)
scene.collection.objects.link(camera)
scene.camera = camera
camera_data.lens = 55
camera.location = center + Vector((size * 1.7, -size * 2.7, size * 0.45))
look_at(camera, center + Vector((0.0, 0.0, size * 0.05)))
for name, energy, size_factor, offset in (
("PoseAuditKey", 900, 2.0, (1.8, -1.5, 2.0)),
("PoseAuditFill", 500, 1.5, (-1.6, -0.5, 0.7)),
):
light_data = bpy.data.lights.new(name, type="AREA")
light_data.energy = energy
light_data.shape = "DISK"
light_data.size = size * size_factor
light = bpy.data.objects.new(name, light_data)
scene.collection.objects.link(light)
light.location = center + Vector(tuple(size * value for value in offset))
look_at(light, center)
def safe_filename(value: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-") or "unnamed"
def make_contact_sheet(frame_paths: list[Path], output_path: Path) -> None:
if not frame_paths:
return
try:
import numpy as np
except ImportError as error:
raise RuntimeError("Blender's NumPy module is required for contact sheets") from error
images = [bpy.data.images.load(str(path), check_existing=False) for path in frame_paths]
try:
width = max(image.size[0] for image in images)
height = max(image.size[1] for image in images)
columns = min(4, len(images))
rows = math.ceil(len(images) / columns)
canvas_pixels = np.zeros((rows * height, columns * width, 4), dtype=np.float32)
canvas_pixels[:, :, 3] = 1.0
for index, image in enumerate(images):
pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape(
(image.size[1], image.size[0], 4)
)
row, column = divmod(index, columns)
canvas_pixels[
row * height : row * height + image.size[1],
column * width : column * width + image.size[0],
:,
] = pixels
canvas = bpy.data.images.new(
"PoseAuditContactSheet",
width=columns * width,
height=rows * height,
alpha=True,
)
try:
canvas.pixels.foreach_set(canvas_pixels.ravel())
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas.filepath_raw = str(output_path)
canvas.file_format = "PNG"
canvas.save()
finally:
bpy.data.images.remove(canvas)
finally:
for image in images:
bpy.data.images.remove(image)
def main() -> None:
args = parse_arguments()
input_glb = args.input_glb.resolve()
output_json = args.output_json.resolve()
source_file = args.source_file.resolve() if args.source_file is not None else input_glb
if not input_glb.is_file():
raise RuntimeError(f"Input GLB does not exist: {input_glb}")
if not source_file.is_file():
raise RuntimeError(f"Source GLB does not exist: {source_file}")
output_json.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
result = bpy.ops.import_scene.gltf(filepath=str(input_glb))
if "FINISHED" not in result:
raise RuntimeError(f"GLB import failed: {result}")
scene = bpy.context.scene
mesh_objects = sorted(
(obj for obj in scene.objects if obj.type == "MESH"), key=lambda obj: obj.name.casefold()
)
armatures = sorted(
(obj for obj in scene.objects if obj.type == "ARMATURE"), key=lambda obj: obj.name.casefold()
)
if not mesh_objects:
raise RuntimeError("The imported GLB must contain at least one mesh")
topology = {mesh.name: mesh_components(mesh) for mesh in mesh_objects}
set_bind_pose(scene, armatures)
bind_vertices, bind_warnings = evaluate_vertices(mesh_objects)
bind = bounds_metrics(flatten_vertices(bind_vertices))
bind_component_metrics = component_metrics(bind_vertices, topology)
bind["meshObjects"] = len(mesh_objects)
bind["topologyComponents"] = len(bind_component_metrics)
asset_issues: list[dict[str, object]] = []
if not armatures:
asset_issues.append(
{
"severity": "warning",
"code": "pose-only-no-armature",
"detail": "The packaged actor contains static posed meshes and no skeletal armature.",
}
)
if bind["nonFiniteVertices"]:
asset_issues.append(
{
"severity": "error",
"code": "bind-non-finite-vertices",
"value": bind["nonFiniteVertices"],
}
)
if bind["diagonal"] is None:
asset_issues.append({"severity": "error", "code": "bind-missing-finite-bounds"})
actions = sorted(bpy.data.actions, key=lambda action: action.name.casefold())
if args.sample_mode == "families":
family_actions: dict[str, bpy.types.Action] = {}
for action in actions:
family_actions.setdefault(semantic_family(action.name), action)
actions = [family_actions[name] for name in sorted(family_actions)]
action_plan = [
{
"actionIndex": action_index,
"action": action.name,
"semanticFamily": semantic_family(action.name),
"frameRange": [rounded(value) for value in action.frame_range],
"frames": [
{"label": label, "frame": rounded(frame)}
for label, frame in unique_sample_frames(action)
],
}
for action_index, action in enumerate(actions)
]
samples: list[dict[str, object]] = []
evaluation_warnings = set(bind_warnings)
for action_index, action in enumerate(actions):
family = semantic_family(action.name)
for label, frame in unique_sample_frames(action):
set_action_pose(scene, armatures, action, frame)
vertices, warnings = evaluate_vertices(mesh_objects)
evaluation_warnings.update(warnings)
pose = bounds_metrics(flatten_vertices(vertices))
components = component_metrics(vertices, topology)
comparison, issues = compare_pose(pose, bind, components, bind_component_metrics)
samples.append(
{
"sampleId": f"action-{action_index:04d}-{label}",
"action": action.name,
"semanticFamily": family,
"frameLabel": label,
"frame": rounded(frame),
"metrics": pose,
"bindComparison": comparison,
"issues": issues,
}
)
rendered: list[dict[str, str]] = []
if args.render_mode != "none":
args.render_dir.mkdir(parents=True, exist_ok=True)
configure_review_scene(scene, bind)
candidates: list[dict[str, object]]
if args.render_mode == "all":
candidates = samples
elif args.render_mode == "flagged":
candidates = [sample for sample in samples if sample["issues"]]
else:
by_family: dict[str, dict[str, object]] = {}
for sample in samples:
if sample["frameLabel"] == "middle":
by_family.setdefault(str(sample["semanticFamily"]), sample)
candidates = [by_family[family] for family in sorted(by_family)]
candidates = candidates[: args.render_limit]
set_bind_pose(scene, armatures)
bind_path = args.render_dir / "0000-Bind-Pose.png"
scene.render.filepath = str(bind_path)
bpy.ops.render.render(write_still=True)
rendered.append({"sampleId": "bind", "file": bind_path.name})
actions_by_name = {action.name: action for action in bpy.data.actions}
for render_index, sample in enumerate(candidates, start=1):
action = actions_by_name[str(sample["action"])]
set_action_pose(scene, armatures, action, float(sample["frame"]))
frame_path = args.render_dir / (
f"{render_index:04d}-{safe_filename(str(sample['action']))}-"
f"{sample['frameLabel']}.png"
)
scene.render.filepath = str(frame_path)
bpy.ops.render.render(write_still=True)
rendered.append({"sampleId": str(sample["sampleId"]), "file": frame_path.name})
if args.contact_sheet is not None:
make_contact_sheet([args.render_dir / item["file"] for item in rendered], args.contact_sheet)
issue_samples = [sample for sample in samples if sample["issues"]]
errors = sum(1 for issue in asset_issues if issue["severity"] == "error") + sum(
1
for sample in samples
for issue in sample["issues"]
if issue["severity"] == "error"
)
warnings = sum(1 for issue in asset_issues if issue["severity"] == "warning") + sum(
1
for sample in samples
for issue in sample["issues"]
if issue["severity"] == "warning"
)
status = "error" if errors else "warning" if warnings else "pass"
report = {
"schemaVersion": 1,
"tool": {"name": "RuneWaker evaluated-pose audit", "version": TOOL_VERSION},
"actorId": actor_id(source_file),
"variantId": args.variant_id or actor_id(source_file),
"source": {
"file": source_file.name,
"sha256": args.source_sha256 or sha256_file(source_file),
"auditInput": input_glb.name,
"auditInputDecompressed": source_file != input_glb,
},
"sampling": {
"mode": args.sample_mode,
"rig": "skeletal" if armatures else "static-pose-only",
"bindPose": True,
"framesPerAction": ["start", "middle", "end"],
"actions": len(actions),
"samples": len(samples),
"actionPlan": action_plan,
},
"thresholds": THRESHOLDS,
"bindPose": bind,
"assetIssues": asset_issues,
"samples": samples,
"summary": {
"status": status,
"sampledActions": len(actions),
"sampledPoses": len(samples) + 1,
"flaggedSamples": len(issue_samples),
"errors": errors,
"warnings": warnings,
"evaluationWarnings": sorted(evaluation_warnings, key=str.casefold),
"assetIssues": len(asset_issues),
},
"review": {
"mode": args.render_mode,
"frames": rendered,
"contactSheet": args.contact_sheet.name if args.contact_sheet is not None else None,
},
"limitations": [
"Thresholds detect gross numeric deformation; they cannot prove that an animation is semantically correct.",
"Fast motion, projectiles, weapon trails, and deliberately detached body parts can produce legitimate outliers.",
"Only start, middle, and end frames are sampled; a defect isolated between those frames may be missed.",
"Component metrics require modifiers to preserve vertex ordering and count.",
],
}
output_json.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8")
print(
"POSE_AUDIT "
+ json.dumps(
{
"actorId": report["actorId"],
"status": status,
"actions": len(actions),
"poses": len(samples) + 1,
"flaggedSamples": len(issue_samples),
"report": str(output_json),
},
sort_keys=True,
)
)
if __name__ == "__main__":
main()