cozyclay 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/live-hub.mjs +35 -93
  40. package/mcp/mesh-file.mjs +48 -0
  41. package/mcp/server.mjs +6 -77
  42. package/mcp/tool-handlers.mjs +219 -376
  43. package/package.json +2 -1
  44. package/src/App.jsx +1249 -9076
  45. package/src/analytics.js +393 -9
  46. package/src/app-context.js +211 -0
  47. package/src/app-stage.jsx +25 -24
  48. package/src/ardy/auto-fix-panel.css +108 -0
  49. package/src/ardy/fix-collisions.js +10 -66
  50. package/src/ardy/ik-drag.js +101 -0
  51. package/src/ardy/ik-key-json.js +43 -0
  52. package/src/ardy/ik.js +97 -67
  53. package/src/ardy/physics-panel.css +2 -0
  54. package/src/ardy/physics-panel.jsx +17 -5
  55. package/src/ardy/platform-fit-panel.css +64 -0
  56. package/src/ardy/platform-fit-panel.jsx +40 -0
  57. package/src/ardy/platform-fit.js +326 -0
  58. package/src/ardy/playback.js +166 -20
  59. package/src/ardy/range-pin.js +299 -0
  60. package/src/ardy/timeline-coordinates.js +26 -0
  61. package/src/ardy/timeline.css +1199 -0
  62. package/src/ardy/timeline.jsx +149 -41
  63. package/src/ardy/waypoints.js +2 -2
  64. package/src/asset-pane.css +462 -0
  65. package/src/asset-pane.jsx +461 -232
  66. package/src/command-bus.js +349 -0
  67. package/src/commands/ai.js +31 -0
  68. package/src/commands/cast.js +140 -0
  69. package/src/commands/elements/character.js +13 -0
  70. package/src/commands/elements/motion.js +14 -0
  71. package/src/commands/elements/object.js +22 -0
  72. package/src/commands/elements/scene.js +16 -0
  73. package/src/commands/elements/shot.js +9 -0
  74. package/src/commands/elements/stage.js +11 -0
  75. package/src/commands/elements.js +155 -0
  76. package/src/commands/export.js +20 -0
  77. package/src/commands/index.js +40 -0
  78. package/src/commands/motion.js +231 -0
  79. package/src/commands/objects.js +174 -0
  80. package/src/commands/project.js +53 -0
  81. package/src/commands/scene.js +81 -0
  82. package/src/commands/shared.js +19 -0
  83. package/src/commands/shot.js +103 -0
  84. package/src/commands/stage.js +17 -0
  85. package/src/commands/view.js +42 -0
  86. package/src/document-store.js +144 -0
  87. package/src/domains/cast.js +1007 -0
  88. package/src/domains/motion.js +3219 -0
  89. package/src/domains/objects.js +932 -0
  90. package/src/domains/scenes.js +817 -0
  91. package/src/domains/shots.js +404 -0
  92. package/src/domains/stage.js +93 -0
  93. package/src/dualview.jsx +113 -57
  94. package/src/facing-marks.js +3 -0
  95. package/src/first-success-guide.jsx +14 -14
  96. package/src/grid-view.js +18 -5
  97. package/src/hierarchy-panel.css +386 -0
  98. package/src/hierarchy-panel.jsx +88 -19
  99. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  100. package/src/i2v-motion-studio.jsx +180 -0
  101. package/src/ik-camera.js +3 -0
  102. package/src/main.jsx +6 -0
  103. package/src/motion/generation.js +105 -0
  104. package/src/motion-readiness-ui.jsx +4 -4
  105. package/src/motion-readiness.js +11 -0
  106. package/src/motion-trail.js +366 -9
  107. package/src/object-gizmo.jsx +7 -1
  108. package/src/panels/CameraPanel.jsx +60 -0
  109. package/src/panels/CharacterTransformPanel.jsx +48 -0
  110. package/src/panels/EnvironmentPanel.jsx +40 -0
  111. package/src/panels/Foldout.jsx +32 -0
  112. package/src/panels/LightPanel.jsx +24 -0
  113. package/src/panels/ObjectTransformPanel.jsx +554 -0
  114. package/src/panels/PosePanel.jsx +97 -0
  115. package/src/panels/ProjectPanel.jsx +33 -0
  116. package/src/panels/PromptBlocksPanel.jsx +442 -0
  117. package/src/panels/PropsPanel.jsx +70 -0
  118. package/src/panels/ReferenceImageField.jsx +91 -0
  119. package/src/panels/RigControlPanel.jsx +78 -0
  120. package/src/panels/RigPanel.jsx +37 -0
  121. package/src/panels/SubjectBox.jsx +47 -0
  122. package/src/panels/SubjectsPanel.jsx +39 -0
  123. package/src/panels/VideoCapturePanel.jsx +180 -0
  124. package/src/panels/details.css +1171 -0
  125. package/src/panels/motion.css +197 -0
  126. package/src/panels/pose.css +39 -0
  127. package/src/planview.jsx +60 -31
  128. package/src/posestudio.jsx +59 -4
  129. package/src/project-browser.css +1073 -0
  130. package/src/project-browser.jsx +284 -123
  131. package/src/range-pin-object-transform.js +19 -0
  132. package/src/range-pin-panel.css +518 -0
  133. package/src/range-pin-panel.jsx +344 -0
  134. package/src/result-modal.jsx +5 -5
  135. package/src/room.jsx +79 -51
  136. package/src/scenes.js +5 -0
  137. package/src/semantic-edit.js +2 -0
  138. package/src/settings-menu.jsx +39 -105
  139. package/src/shell/BottomDock.jsx +450 -0
  140. package/src/shell/DetailsSlot.jsx +513 -0
  141. package/src/shell/LibrarySlot.jsx +86 -0
  142. package/src/shell/MenuBar.jsx +601 -0
  143. package/src/shell/OutlinerSlot.jsx +50 -0
  144. package/src/shell/PreferencesDialog.jsx +538 -0
  145. package/src/shell/PreferencesSlot.jsx +40 -0
  146. package/src/shell/StatusBar.jsx +125 -0
  147. package/src/shell/StudioShell.jsx +92 -0
  148. package/src/shell/TopBar.jsx +129 -0
  149. package/src/shell/ViewportToolbar.jsx +656 -0
  150. package/src/shell/agent-glass.css +296 -0
  151. package/src/shell/dock.css +93 -0
  152. package/src/shell/glass-regions.css +985 -0
  153. package/src/shell/glass.css +687 -0
  154. package/src/shell/log-store.js +52 -0
  155. package/src/shell/mode.css +9 -0
  156. package/src/shell/preferences.css +651 -0
  157. package/src/shell/shell.css +282 -0
  158. package/src/shell/studio-shell-context.js +19 -0
  159. package/src/shell/topbar.css +435 -0
  160. package/src/shell/viewport.css +451 -0
  161. package/src/store/authored-intent.js +22 -0
  162. package/src/store/runtime-adapters.js +92 -0
  163. package/src/store/scene-stage.js +21 -0
  164. package/src/store/use-document-store.js +14 -0
  165. package/src/studio-actions.js +210 -0
  166. package/src/studio-agent-commands.js +38 -268
  167. package/src/studio-agent-context.js +38 -9
  168. package/src/studio-agent-motion.js +122 -378
  169. package/src/studio-agent-protocol.js +66 -30
  170. package/src/studio-app-binding.js +379 -0
  171. package/src/studio-contact-sheet.js +73 -0
  172. package/src/studio-elements.js +58 -51
  173. package/src/styles/themes.css +200 -0
  174. package/src/styles/tokens.css +69 -0
  175. package/src/styles.css +443 -1753
  176. package/src/theme.js +44 -0
  177. package/src/timeline-extent.js +16 -0
  178. package/src/trail-key-conflicts.js +39 -0
  179. package/src/trail-pick.js +59 -0
  180. package/src/ui.jsx +8 -8
  181. package/src/use-case-question.jsx +66 -0
  182. package/src/workflow/AgentPanel.jsx +82 -69
  183. package/src/workflow/agent-client.js +12 -2
  184. package/src/workflow/agent-panel.css +34 -32
  185. package/src/workflow/cozy-scene-node.css +2 -0
  186. package/src/workflow/workflow.css +2 -0
  187. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  188. package/tools/ardy/bridge.mjs +164 -146
  189. package/tools/ardy/visual-qa.mjs +5 -5
  190. package/tools/bench/EXP3.md +61 -0
  191. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  192. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  193. package/tools/bench/cclay_bench_runner.py +42 -0
  194. package/tools/bench/cube-contact.mjs +162 -0
  195. package/tools/bench/exp3.mjs +152 -0
  196. package/tools/bench/extract-bench-lib.mjs +216 -0
  197. package/tools/bench/extract-bench.mjs +302 -0
  198. package/tools/bench/fal-generate.mjs +88 -0
  199. package/tools/bench/fit/README.md +189 -0
  200. package/tools/bench/fit/camera.mjs +59 -0
  201. package/tools/bench/fit/contact.mjs +419 -0
  202. package/tools/bench/fit/footlock.mjs +234 -0
  203. package/tools/bench/fit/motion.mjs +85 -0
  204. package/tools/bench/fit/pin.mjs +27 -0
  205. package/tools/bench/fit/remote.mjs +105 -0
  206. package/tools/bench/fit-bench.mjs +126 -0
  207. package/tools/bench/fit-sanity.mjs +45 -0
  208. package/tools/bench/metrics.mjs +313 -0
  209. package/tools/bench/obs/depth.mjs +258 -0
  210. package/tools/bench/obs/extrinsics.mjs +175 -0
  211. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  212. package/tools/bench/obs/ground.mjs +294 -0
  213. package/tools/bench/obs/heading.mjs +151 -0
  214. package/tools/bench/obs/ladder.mjs +555 -0
  215. package/tools/bench/obs/mannequin-betas.json +124 -0
  216. package/tools/bench/obs/remote.mjs +83 -0
  217. package/tools/bench/obs/rest_joints.py +52 -0
  218. package/tools/bench/obs/ybot-targets.mjs +49 -0
  219. package/tools/bench/obs-bench.mjs +520 -0
  220. package/tools/bench/score.mjs +343 -0
  221. package/tools/bench/summarize.mjs +86 -0
  222. package/tools/dev/pages/privacy.html +13 -8
  223. package/tools/gt-render/browser.mjs +159 -0
  224. package/tools/gt-render/camera-math.mjs +276 -0
  225. package/tools/gt-render/page.mjs +267 -0
  226. package/tools/gt-render/render.mjs +514 -0
  227. package/tools/gt-render/scene-box.mjs +31 -0
  228. package/tools/gt-render/take-transform.mjs +87 -0
  229. package/tools/qa/css-rule-usage.mjs +283 -0
  230. package/tools/qa/studio-control-count.mjs +70 -32
  231. package/tools/run-tests.mjs +58 -3
  232. package/tools/track/DESIGN.md +513 -0
  233. package/tools/track/backfill-provenance.mjs +116 -0
  234. package/tools/track/budget.mjs +8 -0
  235. package/tools/track/check-rig.mjs +209 -0
  236. package/tools/track/diagnostics.schema.json +60 -0
  237. package/tools/track/export-rig.mjs +256 -0
  238. package/tools/track/fallback.mjs +7 -0
  239. package/tools/track/fk-parity-fixture.mjs +161 -0
  240. package/tools/track/gate.mjs +349 -0
  241. package/tools/track/masks.mjs +318 -0
  242. package/tools/track/metrics.mjs +201 -0
  243. package/tools/track/publish-obs.mjs +108 -0
  244. package/tools/track/py/check_env.py +61 -0
  245. package/tools/track/py/eval_lr.py +229 -0
  246. package/tools/track/py/lr_viterbi.py +294 -0
  247. package/tools/track/py/masks.py +405 -0
  248. package/tools/track/py/objective.py +382 -0
  249. package/tools/track/py/rig.py +508 -0
  250. package/tools/track/py/scene.py +326 -0
  251. package/tools/track/py/test_joint_indices.py +123 -0
  252. package/tools/track/py/test_lr_viterbi.py +195 -0
  253. package/tools/track/py/test_masks.py +229 -0
  254. package/tools/track/py/test_rig.py +288 -0
  255. package/tools/track/py/test_scene.py +354 -0
  256. package/tools/track/py/test_track.py +328 -0
  257. package/tools/track/py/track.py +411 -0
  258. package/tools/track/remote.mjs +140 -0
  259. package/tools/track/rig-dump.mjs +227 -0
  260. package/tools/track/run-box-tests.mjs +14 -0
  261. package/tools/track/run-box.mjs +168 -0
  262. package/tools/track/setup-box.sh +59 -0
  263. package/tools/track/study-2d.mjs +728 -0
  264. package/dist/assets/analytics-B1hnH66c.js +0 -1
  265. package/dist/assets/app-Cu1cexZR.css +0 -1
  266. package/dist/assets/app-Dxdh8kds.js +0 -4861
  267. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  268. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  269. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  270. package/dist/assets/workflow-DsudxKHF.css +0 -1
  271. package/src/fal-motion-studio.jsx +0 -180
  272. package/src/scene-history.js +0 -129
@@ -0,0 +1,326 @@
1
+ """Scene terms for the known-character tracker: boxes, floor, stance, occlusion.
2
+
3
+ World frame: Studio / three.js, metres, +Y up, floor plane y = floor_y (0).
4
+ Boxes are oriented about +Y only (props stay upright, `src/ardy/collision-blockers.js`):
5
+ a box point is `center + Ry(yaw) @ local` with `Ry` the three.js rotation
6
+ (`tools/gt-render/scene-box.mjs` boxCorners), `|local| <= halfExtents`.
7
+
8
+ Accepted box records (`parse_scene`):
9
+ - today's bench scene.json: `{min, max}` (plus optional `centre`, `halfExtents`, `yawDeg`)
10
+ - shot context: `{center|centre, halfExtents, yaw (radians) | yawDeg, perFrame?}` where
11
+ `perFrame` is a list of per-frame centres `[x, y, z]` or `{center|centre, yaw|yawDeg}`
12
+ for props that travel or turn.
13
+ - a list of such records, or `{boxes: [...]}`.
14
+
15
+ Everything that feeds a loss takes plain torch tensors (vertices or joints) and is
16
+ differentiable; the HMM and ray tests are discrete and run without gradients.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import math
22
+ from pathlib import Path
23
+ from typing import NamedTuple
24
+
25
+ import numpy as np
26
+ import torch
27
+
28
+ # cskel27 indices: LeftFoot, LeftToeBase / RightFoot, RightToeBase (src/ardy/cskel27.js).
29
+ FOOT_NAMES = ("left", "right")
30
+ FOOT_JOINTS = ((25, 26), (21, 22))
31
+
32
+
33
+ class SceneError(ValueError):
34
+ """Malformed scene input. `code` is the machine-readable reason."""
35
+
36
+ def __init__(self, code: str, detail: str):
37
+ super().__init__(f"{code}: {detail}")
38
+ self.code = code
39
+
40
+
41
+ class Box(NamedTuple):
42
+ center: np.ndarray # (3,)
43
+ half_extents: np.ndarray # (3,)
44
+ yaw: float # radians about +Y
45
+ per_frame_center: np.ndarray | None # (T, 3)
46
+ per_frame_yaw: np.ndarray | None # (T,)
47
+
48
+
49
+ class SceneBoxes(NamedTuple):
50
+ centers: torch.Tensor # (B, T, 3)
51
+ half: torch.Tensor # (B, 3)
52
+ yaw: torch.Tensor # (B, T)
53
+
54
+
55
+ def _vec3(value, what: str) -> np.ndarray:
56
+ try:
57
+ out = np.asarray(value, dtype=np.float64)
58
+ except (TypeError, ValueError):
59
+ raise SceneError("bad-box", f"{what} must be 3 numbers, got {value!r}") from None
60
+ if out.shape != (3,) or not np.all(np.isfinite(out)):
61
+ raise SceneError("bad-box", f"{what} must be 3 finite numbers, got {value!r}")
62
+ return out
63
+
64
+
65
+ def _yaw(record: dict, default: float, what: str) -> float:
66
+ if "yaw" in record:
67
+ value = record["yaw"]
68
+ elif "yawDeg" in record:
69
+ value = record["yawDeg"] * math.pi / 180 if isinstance(record["yawDeg"], (int, float)) else record["yawDeg"]
70
+ else:
71
+ return default
72
+ if not isinstance(value, (int, float)) or not math.isfinite(value):
73
+ raise SceneError("bad-box", f"{what} yaw must be a finite number, got {value!r}")
74
+ return float(value)
75
+
76
+
77
+ def parse_box(record, index: int = 0) -> Box:
78
+ what = f"box {index}"
79
+ if not isinstance(record, dict):
80
+ raise SceneError("bad-box", f"{what} must be an object, got {type(record).__name__}")
81
+ center = record.get("center", record.get("centre"))
82
+ if center is not None or "halfExtents" in record:
83
+ if center is None or "halfExtents" not in record:
84
+ raise SceneError("bad-box", f"{what} needs both center and halfExtents")
85
+ center, half = _vec3(center, f"{what} center"), _vec3(record["halfExtents"], f"{what} halfExtents")
86
+ elif "min" in record and "max" in record:
87
+ lo, hi = _vec3(record["min"], f"{what} min"), _vec3(record["max"], f"{what} max")
88
+ center, half = (lo + hi) / 2, (hi - lo) / 2
89
+ else:
90
+ raise SceneError("bad-box", f"{what} needs {{center, halfExtents}} or {{min, max}}")
91
+ if not np.all(half > 0):
92
+ raise SceneError("degenerate-box", f"{what} has non-positive size {(2 * half).tolist()}")
93
+ yaw = _yaw(record, 0.0, what)
94
+ per_center = per_yaw = None
95
+ frames = record.get("perFrame")
96
+ if frames is not None:
97
+ if not isinstance(frames, list) or not frames:
98
+ raise SceneError("bad-box", f"{what} perFrame must be a non-empty list")
99
+ per_center, per_yaw = np.empty((len(frames), 3)), np.empty(len(frames))
100
+ for t, entry in enumerate(frames):
101
+ if isinstance(entry, dict):
102
+ per_center[t] = _vec3(entry.get("center", entry.get("centre")), f"{what} perFrame[{t}] center")
103
+ per_yaw[t] = _yaw(entry, yaw, f"{what} perFrame[{t}]")
104
+ else:
105
+ per_center[t], per_yaw[t] = _vec3(entry, f"{what} perFrame[{t}]"), yaw
106
+ return Box(center, half, yaw, per_center, per_yaw)
107
+
108
+
109
+ def parse_scene(scene) -> list[Box]:
110
+ """Boxes from a scene.json / shot-context value (None -> no boxes)."""
111
+ if scene is None:
112
+ return []
113
+ if isinstance(scene, dict) and "boxes" in scene:
114
+ scene = scene["boxes"]
115
+ records = scene if isinstance(scene, list) else [scene]
116
+ return [parse_box(record, i) for i, record in enumerate(records)]
117
+
118
+
119
+ def load_scene(path) -> list[Box]:
120
+ return parse_scene(json.loads(Path(path).read_text()))
121
+
122
+
123
+ def box_tensors(boxes: list[Box], frames: int, *, device=None, dtype=torch.float32) -> SceneBoxes:
124
+ """Per-frame box tensors; static boxes are repeated over `frames`."""
125
+ centers, yaws = [], []
126
+ for i, box in enumerate(boxes):
127
+ if box.per_frame_center is not None:
128
+ if len(box.per_frame_center) != frames:
129
+ raise SceneError("box-frames-mismatch", f"box {i} has {len(box.per_frame_center)} perFrame entries for {frames} frames")
130
+ centers.append(box.per_frame_center)
131
+ yaws.append(box.per_frame_yaw)
132
+ else:
133
+ centers.append(np.broadcast_to(box.center, (frames, 3)))
134
+ yaws.append(np.full(frames, box.yaw))
135
+ as_t = lambda a, shape: torch.as_tensor(np.asarray(a, dtype=np.float64).reshape(shape), dtype=dtype, device=device)
136
+ b = len(boxes)
137
+ return SceneBoxes(
138
+ centers=as_t(centers, (b, frames, 3)),
139
+ half=as_t([box.half_extents for box in boxes], (b, 3)),
140
+ yaw=as_t(yaws, (b, frames)),
141
+ )
142
+
143
+
144
+ def _boxes_like(boxes: SceneBoxes, points: torch.Tensor) -> SceneBoxes:
145
+ """Boxes on the points' device and dtype (differentiable; a no-op when they already match)."""
146
+ return SceneBoxes(*(v.to(device=points.device, dtype=points.dtype) for v in boxes))
147
+
148
+
149
+ def _cpu64(value) -> torch.Tensor:
150
+ """Detached float64 CPU tensor from a tensor on any device, an array or a list."""
151
+ if isinstance(value, torch.Tensor):
152
+ return value.detach().to(device="cpu", dtype=torch.float64)
153
+ return torch.as_tensor(np.asarray(value, dtype=np.float64))
154
+
155
+
156
+ def to_box_local(points: torch.Tensor, boxes: SceneBoxes) -> torch.Tensor:
157
+ """points (T, N, 3) -> box-local coordinates (T, N, B, 3), on the points' device."""
158
+ boxes = _boxes_like(boxes, points)
159
+ d = points[:, :, None, :] - boxes.centers.transpose(0, 1)[:, None, :, :]
160
+ c, s = torch.cos(boxes.yaw).T[:, None, :], torch.sin(boxes.yaw).T[:, None, :]
161
+ dx, dy, dz = d.unbind(-1)
162
+ return torch.stack((c * dx - s * dz, dy, s * dx + c * dz), dim=-1)
163
+
164
+
165
+ def box_sdf(points: torch.Tensor, boxes: SceneBoxes) -> torch.Tensor:
166
+ """Signed distance (T, N, B) of points (T, N, 3) to each box; positive outside."""
167
+ q = to_box_local(points, boxes).abs() - boxes.half[None, None].to(device=points.device, dtype=points.dtype)
168
+ sq = torch.relu(q).square().sum(-1)
169
+ # sqrt has an infinite derivative at 0: route inside points through a constant.
170
+ outside = torch.where(sq > 0, torch.sqrt(torch.where(sq > 0, sq, torch.ones_like(sq))), torch.zeros_like(sq))
171
+ return outside + torch.clamp(q.max(-1).values, max=0)
172
+
173
+
174
+ def floor_sdf(points: torch.Tensor, floor_y: float = 0.0) -> torch.Tensor:
175
+ return points[..., 1] - floor_y
176
+
177
+
178
+ def penetration_loss(points: torch.Tensor, boxes: SceneBoxes, *, floor_y: float = 0.0, margin: float = 0.0) -> dict[str, torch.Tensor]:
179
+ """{"box", "floor"}: sum relu(margin - sd)^2 over points (T, N, 3) and every box / the floor."""
180
+ return {
181
+ "box": torch.relu(margin - box_sdf(points, boxes)).square().sum(),
182
+ "floor": torch.relu(margin - floor_sdf(points, floor_y)).square().sum(),
183
+ }
184
+
185
+
186
+ @torch.no_grad()
187
+ def penetration_stats(points: torch.Tensor, boxes: SceneBoxes, *, floor_y: float = 0.0) -> dict:
188
+ """Diagnostics: deepest box / floor penetration in cm and the frames with any penetration."""
189
+ box_depth = torch.relu(-box_sdf(points, boxes)).amax(dim=(1, 2)) if boxes.half.shape[0] else points.new_zeros(points.shape[0])
190
+ floor_depth = torch.relu(-floor_sdf(points, floor_y)).amax(dim=1)
191
+ worst = torch.maximum(box_depth, floor_depth)
192
+ return {
193
+ "maxBoxCm": float(box_depth.max()) * 100 if box_depth.numel() else 0.0,
194
+ "maxFloorCm": float(floor_depth.max()) * 100 if floor_depth.numel() else 0.0,
195
+ "frames": [int(t) for t in torch.nonzero(worst > 0).flatten()],
196
+ }
197
+
198
+
199
+ @torch.no_grad()
200
+ def support_height(points: torch.Tensor, boxes: SceneBoxes, *, floor_y: float = 0.0, step_tol: float = 0.1) -> torch.Tensor:
201
+ """(T, N) height of the surface under each point: the floor or the top of a box whose
202
+ footprint contains the point and whose top is at most `step_tol` above it."""
203
+ support = torch.full(points.shape[:2], float(floor_y), dtype=points.dtype, device=points.device)
204
+ if not boxes.half.shape[0]:
205
+ return support
206
+ boxes = _boxes_like(boxes, points)
207
+ local = to_box_local(points, boxes)
208
+ half = boxes.half[None, None]
209
+ over = (local[..., 0].abs() <= half[..., 0]) & (local[..., 2].abs() <= half[..., 2])
210
+ top = (boxes.centers[..., 1] + boxes.half[:, 1:2]).T[:, None, :] # (T, 1, B)
211
+ eligible = over & (top <= points[..., 1:2] + step_tol)
212
+ tops = torch.where(eligible, top.expand_as(eligible), torch.full_like(eligible, -math.inf, dtype=points.dtype))
213
+ return torch.maximum(support, tops.amax(-1))
214
+
215
+
216
+ def foot_points(joints: torch.Tensor) -> torch.Tensor:
217
+ """cskel27 joints (T, 27, 3) -> (T, 2, 2, 3): feet (left, right) x (ankle, toe)."""
218
+ return joints[:, torch.as_tensor(FOOT_JOINTS, device=joints.device)]
219
+
220
+
221
+ @torch.no_grad()
222
+ def stance_hmm(
223
+ feet,
224
+ fps: float,
225
+ boxes: SceneBoxes | None = None,
226
+ *,
227
+ floor_y: float = 0.0,
228
+ contact_height=None,
229
+ stay: float = 0.95,
230
+ height_scale: float = 0.05,
231
+ speed_scale: float = 0.3,
232
+ sharpness: float = 4.0,
233
+ step_tol: float = 0.1,
234
+ ) -> np.ndarray:
235
+ """Per-foot 2-state (swing/contact) Viterbi. Returns bool (T, F), True = contact.
236
+
237
+ feet: (T, F, 3) or (T, F, K, 3) world positions of the current estimate (K contact
238
+ joints per foot, e.g. `foot_points`). Per joint, h = height above its support minus
239
+ its planted height (`contact_height`, broadcastable to (F, K); default: the joint's
240
+ 5th-percentile height over the clip), v = horizontal speed. Normalised evidence
241
+ z = min_k sqrt((max(h, 0) / height_scale)^2 + (v / speed_scale)^2) (any planted joint
242
+ means contact); p(contact) = sigmoid(sharpness * (1 - z)); stay probability `stay`.
243
+ """
244
+ # Discrete pass: everything (feet, boxes, contact_height) crosses to float64 CPU here.
245
+ x = _cpu64(feet)
246
+ if x.ndim == 3:
247
+ x = x[:, :, None, :]
248
+ if x.ndim != 4 or x.shape[-1] != 3:
249
+ raise SceneError("bad-feet", f"feet must be (T, F, 3) or (T, F, K, 3), got {tuple(x.shape)}")
250
+ if not torch.isfinite(x).all():
251
+ raise SceneError("non-finite", "foot positions contain NaN or inf")
252
+ t_len, f_len, k_len, _ = x.shape
253
+ flat = x.reshape(t_len, f_len * k_len, 3)
254
+ if boxes is None:
255
+ support = torch.full(flat.shape[:2], float(floor_y), dtype=torch.float64)
256
+ else:
257
+ support = support_height(flat, SceneBoxes(*(_cpu64(v) for v in boxes)), floor_y=floor_y, step_tol=step_tol)
258
+ height = (flat[..., 1] - support).reshape(t_len, f_len, k_len).numpy()
259
+ if contact_height is None:
260
+ ref = np.percentile(height, 5, axis=0)
261
+ else:
262
+ ref = np.broadcast_to(_cpu64(contact_height).numpy(), (f_len, k_len))
263
+ xz = x[..., [0, 2]].numpy()
264
+ speed = np.linalg.norm(np.gradient(xz, axis=0), axis=-1) * fps if t_len > 1 else np.zeros((t_len, f_len, k_len))
265
+ z = np.sqrt((np.maximum(height - ref, 0) / height_scale) ** 2 + (speed / speed_scale) ** 2).min(-1) # (T, F)
266
+ logit = sharpness * (1 - z)
267
+ # -log sigmoid(l) = softplus(-l); cost[..., 0] swing, cost[..., 1] contact.
268
+ cost = np.stack((np.logaddexp(0, logit), np.logaddexp(0, -logit)), axis=-1)
269
+ switch = -math.log(1 - stay) + math.log(stay)
270
+ out = np.zeros((t_len, f_len), dtype=bool)
271
+ for f in range(f_len):
272
+ acc, back = cost[0, f].copy(), np.zeros((t_len, 2), dtype=np.int64)
273
+ for t in range(1, t_len):
274
+ nxt = np.empty(2)
275
+ for s in range(2):
276
+ via = acc + np.where(np.arange(2) == s, 0.0, switch)
277
+ back[t, s] = int(np.argmin(via))
278
+ nxt[s] = via[back[t, s]] + cost[t, f, s]
279
+ acc = nxt
280
+ state = int(np.argmin(acc))
281
+ for t in range(t_len - 1, -1, -1):
282
+ out[t, f] = state == 1
283
+ state = back[t, state]
284
+ return out
285
+
286
+
287
+ def skate_loss(feet: torch.Tensor, stance, fps: float) -> torch.Tensor:
288
+ """Sum of squared horizontal velocity (m/s) of foot joints between consecutive
289
+ frames that are both stance. feet (T, F, 3) or (T, F, K, 3), stance bool (T, F)."""
290
+ if feet.ndim == 3:
291
+ feet = feet[:, :, None, :]
292
+ stance = torch.as_tensor(stance, dtype=torch.bool, device=feet.device)
293
+ both = (stance[1:] & stance[:-1])[..., None] # (T-1, F, 1)
294
+ vel = (feet[1:, ..., [0, 2]] - feet[:-1, ..., [0, 2]]) * fps
295
+ return (vel.square().sum(-1) * both).sum()
296
+
297
+
298
+ def camera_center(camera) -> np.ndarray:
299
+ """World camera position from camera.json (`worldToCamera`, OpenCV 4x4) or a 4x4 array."""
300
+ matrix = _cpu64(camera["worldToCamera"] if isinstance(camera, dict) else camera).numpy()
301
+ if matrix.shape != (4, 4) or not np.all(np.isfinite(matrix)):
302
+ raise SceneError("bad-camera", "worldToCamera must be a finite 4x4 matrix")
303
+ return -matrix[:3, :3].T @ matrix[:3, 3]
304
+
305
+
306
+ @torch.no_grad()
307
+ def ray_occlusion(points: torch.Tensor, camera_pos, boxes: SceneBoxes, *, eps: float = 1e-6) -> torch.Tensor:
308
+ """bool (T, N): the segment camera -> point passes through a box interior (the point is
309
+ hidden behind a box, or inside one). A point on a box's camera-facing face is visible."""
310
+ t_len, n = points.shape[:2]
311
+ if not boxes.half.shape[0]:
312
+ return torch.zeros((t_len, n), dtype=torch.bool, device=points.device)
313
+ boxes = _boxes_like(boxes, points)
314
+ cam = _cpu64(camera_pos).to(device=points.device, dtype=points.dtype)
315
+ origin = to_box_local(cam.expand(t_len, 1, 3), boxes) # (T, 1, B, 3)
316
+ d = to_box_local(points, boxes) - origin # (T, N, B, 3)
317
+ half = boxes.half[None, None].to(points.dtype)
318
+ flat = d.abs() < 1e-12
319
+ safe = torch.where(flat, torch.ones_like(d), d)
320
+ t0, t1 = (-half - origin) / safe, (half - origin) / safe
321
+ inside_slab = origin.abs() < half
322
+ lo = torch.where(flat, torch.where(inside_slab, -math.inf, math.inf), torch.minimum(t0, t1))
323
+ hi = torch.where(flat, torch.where(inside_slab, math.inf, -math.inf), torch.maximum(t0, t1))
324
+ near = torch.clamp(lo.amax(-1), min=0)
325
+ far = torch.clamp(hi.amin(-1), max=1)
326
+ return (far - near > eps).any(-1)
@@ -0,0 +1,123 @@
1
+ """Independent name contracts for cskel27 versus COCO index spaces.
2
+
3
+ The synthetic recovery fixture imports the objective's map, so it cannot by
4
+ itself catch a shared off-by-one. These assertions use exported rig names and
5
+ an independent anatomical correspondence instead of regenerating that map.
6
+ """
7
+ import json
8
+ from pathlib import Path
9
+ import warnings
10
+
11
+ import numpy as np
12
+ import pytest
13
+ import torch
14
+
15
+ from lr_viterbi import ARM_PAIRS, COCO_NAMES, FACE_PAIRS, LEG_PAIRS, state_permutation
16
+ from masks import ANKLES, EXTREMITIES, TORSO_PAIRS, UPPER
17
+ from objective import (ARM_TRUST_JOINTS, COCO_JOINTS, JOINT_CAPS_DEG,
18
+ articulation_reliability, trust_region_loss)
19
+ from rig import State
20
+ from scene import FOOT_JOINTS, FOOT_NAMES, foot_points
21
+
22
+
23
+ @pytest.fixture(scope='module')
24
+ def joint_names():
25
+ path = Path.home() / 'cclay-ingest/cozyfit/testdata/rig-y-bot-tpose.npz'
26
+ with np.load(path, allow_pickle=False) as rig:
27
+ meta = json.loads(bytes(rig['meta_json'].astype(np.uint8)))
28
+ names = meta['cskel27Joints']
29
+ assert len(names) == 27 and names[0] == 'Hips'
30
+ return names
31
+
32
+
33
+ def test_coco_correspondence_and_visibility_indices_by_name(joint_names):
34
+ expected = ['LeftArm', 'RightArm', 'LeftForeArm', 'RightForeArm', 'LeftHand', 'RightHand',
35
+ 'LeftUpLeg', 'RightUpLeg', 'LeftLeg', 'RightLeg', 'LeftFoot', 'RightFoot']
36
+ assert [joint_names[j] for j in COCO_JOINTS[5:]] == expected
37
+ # Visibility is anatomical: selecting it by this map must hide the matching
38
+ # detector-side body landmark, not the next joint along the right arm.
39
+ for coco, name in enumerate(expected, start=5):
40
+ hidden = torch.zeros(1, 27, dtype=torch.bool)
41
+ hidden[0, joint_names.index(name)] = True
42
+ assert hidden[:, COCO_JOINTS][0, 5:].nonzero().flatten().tolist() == [coco - 5]
43
+
44
+
45
+ def test_trust_weighting_by_exported_bilateral_joint_names(joint_names):
46
+ strong = {side + part for side in ('Left', 'Right') for part in ('Shoulder', 'Arm', 'ForeArm', 'Hand')}
47
+ assert {joint_names[j] for j in ARM_TRUST_JOINTS} == strong
48
+ state = State.identity(1)
49
+ initial = state.local_rot_mats()
50
+ angle = 0.4
51
+ c, s = np.cos(angle), np.sin(angle)
52
+ turn = torch.tensor([[c, 0, s], [0, 1, 0], [-s, 0, c]], dtype=torch.float32)
53
+ leg = initial.clone(); leg[:, joint_names.index('RightUpLeg')] = turn
54
+ unit = trust_region_loss(state.transl, leg, state.transl, initial).item()
55
+ assert unit > 0
56
+ for joint, name in enumerate(joint_names):
57
+ rotations = initial.clone(); rotations[:, joint] = turn
58
+ actual = trust_region_loss(state.transl, rotations, state.transl, initial).item()
59
+ multiplier = 2 if name == 'Hips' else 4 if name in strong else 1
60
+ assert actual == pytest.approx(multiplier * unit), name
61
+
62
+
63
+ def test_joint_limit_caps_are_anatomically_symmetric(joint_names):
64
+ assert len(JOINT_CAPS_DEG) == len(joint_names)
65
+ caps = dict(zip(joint_names, JOINT_CAPS_DEG))
66
+ expected = {'Shoulder': 90, 'Arm': 175, 'ForeArm': 175, 'Hand': 110,
67
+ 'HandEnd': 150, 'HandThumb1': 180, 'UpLeg': 160, 'Leg': 175, 'Foot': 100, 'ToeBase': 80}
68
+ for part, degrees in expected.items():
69
+ assert caps['Left' + part] == caps['Right' + part] == degrees
70
+
71
+
72
+ def test_foot_and_lr_selections_use_their_own_index_spaces(joint_names):
73
+ assert FOOT_NAMES == ('left', 'right')
74
+ expected_feet = [['LeftFoot', 'LeftToeBase'], ['RightFoot', 'RightToeBase']]
75
+ assert [[joint_names[j] for j in foot] for foot in FOOT_JOINTS] == expected_feet
76
+ tagged = torch.arange(27)[None, :, None].expand(1, -1, 3)
77
+ selected = foot_points(tagged)[0, :, :, 0].tolist()
78
+ assert [[joint_names[j] for j in foot] for foot in selected] == expected_feet
79
+ for pairs, parts in [(ARM_PAIRS, ['Shoulder', 'Elbow', 'Wrist']),
80
+ (LEG_PAIRS, ['Hip', 'Knee', 'Ankle']), (FACE_PAIRS, ['Eye', 'Ear'])]:
81
+ assert [(COCO_NAMES[a], COCO_NAMES[b]) for a, b in pairs] == [('left' + p, 'right' + p) for p in parts]
82
+ full = state_permutation('full_swap')
83
+ assert all(full[a] == b and full[b] == a for a, b in ARM_PAIRS + LEG_PAIRS + FACE_PAIRS)
84
+
85
+
86
+ def test_mask_prompt_indices_by_coco_name():
87
+ assert [[COCO_NAMES[j] for j in pair] for pair in TORSO_PAIRS] == [
88
+ ['leftShoulder', 'rightShoulder'], ['leftHip', 'rightHip']]
89
+ assert [COCO_NAMES[j] for j in EXTREMITIES] == ['nose', 'leftWrist', 'rightWrist', 'leftAnkle', 'rightAnkle']
90
+ assert [COCO_NAMES[j] for j in UPPER] == ['nose', 'leftWrist', 'rightWrist']
91
+ assert [COCO_NAMES[j] for j in ANKLES] == ['leftAnkle', 'rightAnkle']
92
+
93
+
94
+ def test_reliability_lower_median_strict_cuda_determinism():
95
+ assert torch.cuda.is_available(), 'Box acceptance requires CUDA'
96
+ projected = np.arange(4 * 17 * 2, dtype=np.float32).reshape(4, 17, 2) / 3
97
+ assigned = np.zeros((4, 17, 3), np.float32)
98
+ assigned[..., 2] = 1
99
+ assigned[:, :5, 2] = 0
100
+ assigned[1, 6::2, 2] = 0.2
101
+ assigned[2, 10:, 2] = 0
102
+ assigned[3, :, 2] = 0
103
+ seen = np.ones((4, 17), bool)
104
+ seen[2, 5] = False
105
+ expected = []
106
+ for uv, obs, visible in zip(projected, assigned, seen):
107
+ valid = visible[5:] & (obs[5:, 2] >= 0.5)
108
+ body = (uv - obs[:, :2])[5:][valid]
109
+ center = np.sort(body, axis=0)[(len(body) - 1) // 2] if len(body) else np.zeros(2)
110
+ excess = np.maximum(np.linalg.norm(uv - obs[:, :2] - center, axis=-1) / 3 - 1, 0) / 2
111
+ expected.append(obs[:, 2] * visible / (1 + excess ** 2) ** 2)
112
+ enabled = torch.are_deterministic_algorithms_enabled()
113
+ warn_only = torch.is_deterministic_algorithms_warn_only_enabled()
114
+ try:
115
+ torch.use_deterministic_algorithms(True)
116
+ with warnings.catch_warnings():
117
+ warnings.simplefilter('error', UserWarning)
118
+ actual = articulation_reliability(torch.tensor(projected, device='cuda'),
119
+ torch.tensor(assigned, device='cuda'),
120
+ torch.tensor(seen, device='cuda'), 3)
121
+ np.testing.assert_allclose(actual.cpu().numpy(), np.asarray(expected), atol=1e-6, rtol=1e-5)
122
+ finally:
123
+ torch.use_deterministic_algorithms(enabled, warn_only=warn_only)
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from eval_lr import evaluate_item, main as eval_main, truth_reference_error
10
+ from lr_viterbi import (
11
+ ARM_PAIRS,
12
+ BODY_INDICES,
13
+ LEG_PAIRS,
14
+ STATE_NAMES,
15
+ solve_lr_viterbi,
16
+ state_permutation,
17
+ )
18
+
19
+
20
+ def synthetic_clip(frames: int = 64, seed: int = 17):
21
+ rng = np.random.default_rng(seed)
22
+ base = np.zeros((frames, 17, 2), dtype=np.float64)
23
+ # A moving torso plus distinct, smooth limbs. The labels are deliberately
24
+ # geometric only; no colour or appearance signal is present.
25
+ for t in range(frames):
26
+ phase = 0.13 * t
27
+ base[t, 0] = [120 + 1.5 * t, 70]
28
+ base[t, 1:5] = [[119, 63], [121, 63], [117, 66], [123, 66]]
29
+ base[t, 5] = [100 + 2 * np.sin(phase), 120 + 0.3 * t]
30
+ base[t, 6] = [140 - 2 * np.sin(phase), 120 + 0.3 * t]
31
+ base[t, 7] = [82 + 3 * np.sin(phase), 155 + 2 * np.cos(phase)]
32
+ base[t, 8] = [158 - 3 * np.sin(phase), 155 + 2 * np.cos(phase)]
33
+ base[t, 9] = [70 + 4 * np.sin(phase), 190 + 2 * np.cos(phase)]
34
+ base[t, 10] = [170 - 4 * np.sin(phase), 190 + 2 * np.cos(phase)]
35
+ base[t, 11] = [108 + 1.5 * np.sin(phase), 220 + 0.4 * t]
36
+ base[t, 12] = [132 - 1.5 * np.sin(phase), 220 + 0.4 * t]
37
+ base[t, 13] = [102 + 2 * np.sin(phase), 270 + 1.5 * np.cos(phase)]
38
+ base[t, 14] = [138 - 2 * np.sin(phase), 270 + 1.5 * np.cos(phase)]
39
+ base[t, 15] = [96 + 2 * np.sin(phase), 320 + 1.2 * np.cos(phase)]
40
+ base[t, 16] = [144 - 2 * np.sin(phase), 320 + 1.2 * np.cos(phase)]
41
+ kp = np.concatenate([base, np.ones((frames, 17, 1), dtype=np.float64)], axis=2)
42
+ kp[..., :2] += rng.normal(0, 0.15, kp[..., :2].shape)
43
+ return base, kp
44
+
45
+
46
+ def swap_observations(kp, groups, start, end):
47
+ out = kp.copy()
48
+ for t in range(start, end + 1):
49
+ for left, right in groups:
50
+ out[t, [left, right]] = out[t, [right, left]]
51
+ return out
52
+
53
+
54
+ def assert_corrected(result, projected, observed, start, end, groups):
55
+ assignments = result["assignments"]
56
+ for t in range(projected.shape[0]):
57
+ for left, right in groups:
58
+ expected = right if start <= t <= end else left
59
+ assert assignments[t, left] == expected
60
+ expected = left if start <= t <= end else right
61
+ assert assignments[t, right] == expected
62
+ corrected = np.take_along_axis(observed[..., :2], assignments[:, :, None], axis=1)
63
+ raw_error = np.linalg.norm(observed[..., :2] - projected, axis=2)[:, list(BODY_INDICES)].mean()
64
+ corrected_error = np.linalg.norm(corrected - projected, axis=2)[:, list(BODY_INDICES)].mean()
65
+ # The detector noise remains after assignment correction; the swap error
66
+ # should nevertheless be removed rather than requiring a denoiser here.
67
+ assert corrected_error < raw_error * 0.4
68
+
69
+
70
+ def test_one_three_seven_frame_swap_runs_are_recovered():
71
+ projected, clean = synthetic_clip()
72
+ # Use separate clips so each expected state is unambiguous.
73
+ for length, state_groups in ((1, ARM_PAIRS), (3, LEG_PAIRS), (7, ARM_PAIRS)):
74
+ start = 20
75
+ observed = swap_observations(clean, state_groups, start, start + length - 1)
76
+ result = solve_lr_viterbi(observed, projected, deltaPx=2.0)
77
+ assert_corrected(result, projected, observed, start, start + length - 1, state_groups)
78
+ expected_name = "arms_swap" if state_groups is ARM_PAIRS else "legs_swap"
79
+ assert all(result["state_names"][s] == expected_name for s in result["path"][start : start + length])
80
+
81
+
82
+ def test_iid_noise_has_no_switches():
83
+ projected, clean = synthetic_clip(seed=21)
84
+ rng = np.random.default_rng(99)
85
+ noisy = clean.copy()
86
+ noisy[..., :2] += rng.normal(0, 1.0, noisy[..., :2].shape)
87
+ result = solve_lr_viterbi(noisy, projected, deltaPx=3.0)
88
+ assert result["switches"] == 0
89
+ assert np.all(result["path"] == STATE_NAMES.index("identity"))
90
+
91
+
92
+ def test_slow_180_degree_turn_is_not_a_swap():
93
+ projected, _ = synthetic_clip(frames=80)
94
+ fixture = next((parent / "test/fixtures/heading-orient.json" for parent in Path(__file__).resolve().parents if (parent / "test/fixtures/heading-orient.json").exists()), None)
95
+ if fixture is not None:
96
+ raw = json.loads(fixture.read_text())["items"]["gt/turn"]
97
+ angles = []
98
+ for x, y, z in raw:
99
+ theta = np.linalg.norm([x, y, z])
100
+ qw = np.cos(theta / 2)
101
+ qy = (y / theta) * np.sin(theta / 2) if theta > 1e-12 else y / 2
102
+ angles.append(2 * np.arctan2(qy, qw))
103
+ angles = np.unwrap(np.asarray(angles))
104
+ projected = np.resize(projected, (len(angles), 17, 2))
105
+ else:
106
+ # The box runner receives only tools/track/py, so retain a deterministic
107
+ # equivalent when the repository fixture is not uploaded.
108
+ angles = np.linspace(0, np.pi, projected.shape[0])
109
+ turned = projected.copy()
110
+ for t, angle in enumerate(angles):
111
+ c, s = np.cos(angle), np.sin(angle)
112
+ xy = projected[t] - [125, 210]
113
+ turned[t] = xy @ np.array([[c, s], [-s, c]]) + [125, 210]
114
+ kp = np.concatenate([turned, np.ones((turned.shape[0], 17, 1))], axis=2)
115
+ kp[..., :2] += np.random.default_rng(123).normal(0, 0.2, kp[..., :2].shape)
116
+ result = solve_lr_viterbi(kp, turned, deltaPx=2.0)
117
+ assert result["switches"] == 0
118
+ assert np.all(result["path"] == STATE_NAMES.index("identity"))
119
+
120
+
121
+ def test_malformed_and_no_evidence_inputs_are_safe():
122
+ projected = np.zeros((4, 17, 2), dtype=np.float64)
123
+ kp = np.zeros((4, 17, 3), dtype=np.float64)
124
+ result = solve_lr_viterbi(kp, projected, deltaPx=2.0)
125
+ assert np.all(result["path"] == 0)
126
+ assert np.all(result["ambiguous"])
127
+ assert np.all(result["margins"] == 0)
128
+
129
+ nan_kp = kp.copy()
130
+ nan_kp[..., :2] = np.nan
131
+ nan_result = solve_lr_viterbi(nan_kp, projected, deltaPx=2.0)
132
+ assert np.all(nan_result["path"] == 0)
133
+ empty = solve_lr_viterbi(np.empty((0, 17, 3)), np.empty((0, 17, 2)), deltaPx=2.0)
134
+ assert empty["path"].size == 0
135
+
136
+
137
+ def test_permutations_are_restricted_and_deterministic():
138
+ assert np.array_equal(state_permutation("identity"), np.arange(17))
139
+ assert state_permutation("arms_swap")[11] == 11
140
+ assert state_permutation("legs_swap")[5] == 5
141
+ assert state_permutation("full_swap")[1] == 2
142
+ assert len(set(tuple(state_permutation(name)) for name in STATE_NAMES)) == 4
143
+
144
+
145
+ def test_visibility_is_anatomical_not_permuted_with_detector_labels():
146
+ projected = np.zeros((1, 17, 2), dtype=np.float64)
147
+ observed = np.zeros((1, 17, 3), dtype=np.float64)
148
+ visibility = np.zeros((1, 17), dtype=bool)
149
+ # Only anatomical left shoulder is visible. Detector left is wrong by 100px;
150
+ # detector right is exactly at the visible anatomical projection.
151
+ projected[0, 5] = [0, 0]
152
+ observed[0, 5] = [100, 0, 1]
153
+ observed[0, 6] = [0, 0, 1]
154
+ visibility[0, 5] = True
155
+ paths = []
156
+ for hidden_right_x in (0, 10000):
157
+ projected[0, 6] = [hidden_right_x, 0]
158
+ result = solve_lr_viterbi(observed, projected, visibility=visibility, deltaPx=2.0)
159
+ paths.append(int(result["path"][0]))
160
+ assert int(np.argmin(result["emission"][0])) == STATE_NAMES.index("arms_swap")
161
+ assert paths == [STATE_NAMES.index("arms_swap")] * 2
162
+
163
+
164
+ def test_hysteresis_keeps_clear_swap_when_unrelated_states_tie():
165
+ projected = np.zeros((5, 17, 2), dtype=np.float64)
166
+ observed = np.zeros((5, 17, 3), dtype=np.float64)
167
+ for left, right in ARM_PAIRS:
168
+ projected[:, right, 0] = 100
169
+ observed[:, left, 2] = observed[:, right, 2] = 1
170
+ observed[:, :, :2] = projected
171
+ for left, right in ARM_PAIRS:
172
+ observed[2, [left, right]] = observed[2, [right, left]]
173
+ result = solve_lr_viterbi(observed, projected, deltaPx=2.0, hysteresis=0.1)
174
+ assert result["raw_path"][2] in (STATE_NAMES.index("full_swap"), STATE_NAMES.index("arms_swap"))
175
+ assert result["assignments"][2, 5] == 6
176
+ assert result["assignments"][2, 6] == 5
177
+ assert result["margins"][2] > 0.1
178
+
179
+
180
+ def test_eval_rejects_missing_truth_assignment_reference(tmp_path, monkeypatch):
181
+ item = {"set": "gt", "name": "walk", "variant": "shaded", "dir": str(tmp_path), "source": "none"}
182
+ assert truth_reference_error({}, "gt/walk") is not None
183
+ row = evaluate_item(item, {}, tmp_path, 2.0)
184
+ assert row["status"] == "missing-truth-reference"
185
+
186
+ summary = tmp_path / "summary.json"
187
+ approved = tmp_path / "approved.json"
188
+ out = tmp_path / "result.json"
189
+ summary.write_text(json.dumps({"deltaPx": 2.0}))
190
+ approved.write_text(json.dumps({"items": [item]}))
191
+ monkeypatch.setattr(sys, "argv", ["eval_lr.py", "--summary", str(summary), "--approved", str(approved), "--obs-root", str(tmp_path), "--out", str(out)])
192
+ assert eval_main() == 1
193
+ result = json.loads(out.read_text())
194
+ assert result["pass"] is False
195
+ assert result["items"][0]["status"] == "missing-truth-reference"