734 lines
29 KiB
Python
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()
|