cozyclay 1.9.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 (286) hide show
  1. package/CHANGELOG.md +171 -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-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,61 @@
1
+ # Cube-contact validation (#454)
2
+
3
+ Run from this checkout. Requires Node, ffmpeg/ffprobe, Chrome, and installed npm dependencies. Live extraction additionally requires the existing GVHMR installation over SSH. Never run two GPU extractions concurrently.
4
+
5
+ ```sh
6
+ E=/Users/yun/ccFalToMocap/evidence/exp3
7
+ node tools/bench/exp3.mjs --root "$E" --stage gt
8
+ node tools/bench/exp3.mjs --root "$E" --stage gt-path --host yun@ubuntu-baremetal
9
+ node tools/bench/exp3.mjs --root "$E" --stage fal
10
+ node tools/bench/exp3.mjs --root "$E" --stage summary
11
+ ```
12
+
13
+ Defaults are Vite 5194, CDP 9234, and `--variants shaded,skin`. `--motions sit` selects a scenario; `--variants skin` selects only the grey/YOLO path. `--stage all` runs all stages serially. Inputs: `gt-motions/{bump,handon,sit,stepup}.npz` and `gen-log*.json` containing the action prompts. A failed stage exits nonzero; it never substitutes a guessed motion or passes a failed extraction into fitting. Completed extraction/scoring artifacts can be reused; use a fresh evidence directory after changing inputs or derivation code (the driver is not a content-addressed cache).
14
+
15
+ ## Renderer
16
+
17
+ ```sh
18
+ node tools/gt-render/render.mjs --out /tmp/example --port 5194 --cdp-port 9234 \
19
+ --azimuth 30 --elevation 5 --f-mm 35 --keep-frames \
20
+ --scene-box '{"x":0,"z":1,"rot":0,"sx":0.5,"sy":0.4,"sz":0.5}' motion.npz
21
+ ```
22
+
23
+ `scaleX/scaleY/scaleZ` aliases are accepted. Studio scale/position bounds are validated at the CLI boundary (sizes 0.1..100 m), and yaw is normalized. Sizes are metres; the library cube is 1 m on each axis. Base is on Y=0, footprint centred on X/Z, rotation is yaw degrees about +Y. The single QA-hook line exposes only the existing Studio place and update operations. A scene prop's committed scale is checked before capture; clamped unsupported values fail instead of recording inaccurate dimensions. `scene.json` is written at the motion root and beside each `camera.json`. Rotated boxes record centre/half-extents/yaw and corners; axis-aligned boxes also record min/max. The existing `--box` contact scorer and F5 accept **axis-aligned boxes only**; this experiment derives yaw-zero boxes.
24
+
25
+ The auto-camera includes both character support over the full clip and all box corners. Masks redraw only the character magenta: the neutral cube remains an occluder but is not included in the character mask. `score.mjs` now reads `scene.json` and places the same visible box in prediction/GT mask renders, including calibrated placements. Without a scene file its old behavior is unchanged.
26
+
27
+ `--export-vertices --no-video` exports `vertices.f32` (little-endian float32, frame/vertex/XYZ order) and `vertices.json` plus the usual joints/masks. Vertices are world-space CPU-skinned points, not native NPZ joint estimates. `<variant>/contact-sheet.png` shows up to four sampled poses with the eight cube corners/twelve edges projected using the saved camera. `contact-sheet.json` identifies frames.
28
+
29
+ ## Derivation and interpretation
30
+
31
+ Every span is 0.5 s at the Studio's 24 fps. Sit chooses the lowest sustained hips after the first third; hand-on chooses the lowest sustained mean wrist height in the latter half that permits a skin-safe palm-height table in front of the body (the absolute lowest span can have hands beside the thighs); step-up uses the terminal raised foot; bump uses maximal skin reach along the dominant horizontal travel axis (not necessarily a torso impact). A finite grid of footprint translations around the intended contact is considered, with 0.5 m seat, 0.9 m table, 0.4 m step. For horizontal supports, the top equals the **lowest actual skin vertex within that footprint across the span**. Thus a witness touches exactly and no sampled skin penetrates in that span; vertices lower than the intended contact are never silently discarded. Anatomically poor candidates are reported as warnings, not asserted to be valid motion. A static box cannot force every frame of an imperfect motion to touch. `contact-baseline.json` reports maximum gap as well as minimum gap and penetration; its pass criterion checks every declared contact frame (gap <=3 cm, penetration <=2 cm).
32
+
33
+ `scenario.json` preserves witness, target height, span, box, prompt, method and uncertainties. Inspect the contact sheet; a small distance alone does not certify sitting, stepping, or palm support. Thin triangle intersections without an enclosed vertex are outside the existing skinned-vertex metric.
34
+
35
+ GT path runs T2 prod/prod+fmm on shaded and native yolo-vitpose/yolo-vitpose+fmm on skin, fits F0..F5 with the scenario box and matching `--base-condition`, and runs `score.mjs --box` for each step plus GT-as-pred. `gt-path/summary.md` has a variant column (48 rows). Fits, scores and GT-self checks are stored under `gt-path/{fit,score,gt-self}/<variant>/<scenario>/`. It uses RAW box contact (scene safety must not benefit from evaluation-only alignment), aligned root-relative MPJPE, aligned last-frame mean joint error, and aligned IoU. F4 uses only native NPZ endpoint poses, not intermediate GT; rig retargeting/anchoring means these pins are not exact rendered-rig endpoints. F5 is skeletal collision resolution, not skinned-contact optimization.
36
+
37
+ ## fal
38
+
39
+ ```sh
40
+ node tools/bench/fal-generate.mjs --scenario "$E/gt/sit" --out "$E/fal/clips" --variant skin --runs 3 --dry-run
41
+ ```
42
+
43
+ Live key defaults to `~/ccFalToMocap/.fal-key`, or `--key <file>`. Missing key automatically produces dry-run request records and A/B stills; it never waits for the key. The key is not saved or logged. A and B are first/last decoded GT frames of `--variant shaded|skin` (default shaded), cube included. Clip names include scenario, variant and independent run number. The experiment driver plans 4 scenarios x 2 variants x 3 runs = 24 clips. Requests import the repo's `buildH3LockedPrompt` contract and use `minimax/h3-max-turbo/image-to-video`, 480P, 5 s, disabled prompt expansion. No undocumented seed field is sent: three independent requests, explicitly recorded. The queue polling has no overall deadline, logs every status, and bounds individual HTTP requests. Each clip saves request ID, request metadata, all queue responses, MP4, and provider-reported cost fields. Absent cost is unknown, not zero. Mock tests validate the client contract, not live provider availability.
44
+
45
+ `--stage fal` generates then extracts and fits available clips. It runs prod/prod+fmm for shaded and yolo-vitpose/yolo-vitpose+fmm for skin, supplying true F0/F1 baselines and detector-matched in-camera evidence. Video is normalized to the A-still's 832x480 and 24 fps without temporal stretching. A changed aspect ratio (>1%) fails; unchanged framing is an explicit unverified H3 assumption. F4 pins the original A/B GT poses to the fal clip endpoints; F5 receives the box. **No intermediate fal motion GT exists**, so no MPJPE/trajectory-GT claims are made. Each F step is evaluated for first/last world joint error against A/B, whole-clip skin distance/penetration (contact timing unknown), and same-camera unaligned mask IoU.
46
+
47
+ Palette segmentation is a coloured-pixel proxy: max(R,G,B)-min(R,G,B) >=35; HSV saturation >=0.25; value >=0.18, on decoded RGB bytes. Neutral cube/floor/shadows are excluded; saturated hallucinations are included and desaturated body pixels can be lost. It is not semantic segmentation. Threshold boundary tests are deterministic.
48
+
49
+ For skin clips, segmentation is **background difference**, not colour: compare each decoded RGB frame against the GT's `plate.png` (same fixed A-still camera, cube present, character hidden). A pixel is foreground if any channel changes by >=30/255. Moving shadows, lighting drift, compression and generated set changes can contaminate this proxy; no semantic or camera-drift correction is claimed. Shaded and skin IoUs use different proxies and should not be compared as identical measures.
50
+
51
+ `qa-pack/<scenario>-<variant>-<run>.mp4`: fal | F5 Studio render with cube | 50% blend, same camera/time. `qa-pack/ratings.csv` has empty human rating columns (`prompt_adherence_0_2,contact_natural_0_2,usable_without_fix_YN,notes`), and resumed runs preserve existing ratings. No pack video is fabricated when generation is pending.
52
+
53
+ ## Validation
54
+
55
+ ```sh
56
+ node test/verify-cube-contact.mjs
57
+ npm run build
58
+ node tools/run-tests.mjs
59
+ ```
60
+
61
+ Pure tests cover support witness/nonpenetration, sustained spans, all scenario derivations, rotated box coordinates, independent Three camera projection, segmentation thresholds, request fields, and mocked queue completion/errors. No fixed sleeps or live network calls occur in these tests.
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """Issue #432: add camera evidence around the UNMODIFIED production runner.
3
+
4
+ python cclay_bench_extract_incam.py <runner.py> <video> <out.npz>
5
+ --static-cam --f-mm 35 --out-root /tmp/cclay-fit-.../cache
6
+ --detector palette --keypoints hybrid --smooth-sigma 3
7
+
8
+ Import-only reuse: runner.main, FastRuntime and trajectory_job. The two repo
9
+ worker modules are deployed alongside this file, never into the GVHMR tree.
10
+ No GT poses/joints are inputs. The output contains the runner's members plus:
11
+ incam_raw_{global_orient,transl,body_pose,betas}: SMPL-X model parameters
12
+ incam_global_orient / incam_pelvis: production-smoothed camera-space root
13
+ ayfz_to_camera[T,4,4]: per-frame pelvis/orientation relation, column vectors
14
+ ay_to_ayfz[4,4]: exact runner normalization including floor/heading offset
15
+ K_fullimg[T,3,3], ay_global_orient, ay_transl: raw prediction diagnostics
16
+
17
+ SMPL model transl is NOT its regressed pelvis. We evaluate the same SMPL-X ->
18
+ SMPL mesh regressor as production to get camera pelvis, then smooth with the
19
+ runner's own function. ayfz_to_camera is framewise, not a claim that noisy
20
+ monocular camera and integrated world trajectories agree on one static SE(3).
21
+ The JS fit uses one initial registration to preserve F1's trajectory.
22
+ """
23
+ import importlib.util
24
+ from pathlib import Path
25
+ import sys
26
+
27
+
28
+ def main():
29
+ runner_path = Path(sys.argv[1]).resolve()
30
+ arguments = sys.argv[2:]
31
+ direct = "--bench-direct" in arguments
32
+ if direct:
33
+ arguments.remove("--bench-direct")
34
+ if "--static-cam" not in arguments or "--f-mm" not in arguments:
35
+ raise ValueError("bench camera evidence requires --static-cam and --f-mm")
36
+ sys.path.insert(0, str(runner_path.parent))
37
+ spec = importlib.util.spec_from_file_location("cozyclay_fit_runner", runner_path)
38
+ runner = importlib.util.module_from_spec(spec)
39
+ spec.loader.exec_module(runner)
40
+ import numpy as np
41
+ import torch
42
+
43
+ capture = {}
44
+ detach = runner.detach_to_cpu
45
+ normalize = runner.compute_T_ayfz2ay
46
+
47
+ def save_prediction(value):
48
+ result = detach(value)
49
+ capture["prediction"] = result
50
+ return result
51
+
52
+ def save_normalization(joints, *args, **kwargs):
53
+ result = normalize(joints, *args, **kwargs)
54
+ capture["heading"] = result.detach().cpu()
55
+ return result
56
+
57
+ runner.detach_to_cpu = save_prediction
58
+ runner.compute_T_ayfz2ay = save_normalization
59
+ sys.argv = [str(runner_path), *arguments]
60
+ video, output = Path(arguments[0]), Path(arguments[1])
61
+ root = Path(arguments[arguments.index("--out-root") + 1])
62
+ sigma = float(arguments[arguments.index("--smooth-sigma") + 1]) if "--smooth-sigma" in arguments else 1.2
63
+ # Match the T2 condition's execution path as well as its detector flags.
64
+ # YOLO/direct must NOT gain production's trajectory correction or palette
65
+ # fast path merely because this launcher also captures camera evidence.
66
+ if direct:
67
+ runner.main()
68
+ else:
69
+ from gvhmr_fastpath import FastRuntime
70
+ from gvhmr_trajectory import trajectory_job
71
+ runtime = FastRuntime(runner, enabled=True)
72
+ with runtime.job(), trajectory_job(runner, video, root):
73
+ runner.main()
74
+ pred = capture["prediction"]
75
+ cp, gp = pred["smpl_params_incam"], pred["smpl_params_global"]
76
+ with np.load(output) as archive:
77
+ arrays = {key: archive[key] for key in archive.files}
78
+
79
+ # Re-evaluate regressed pelvis in BOTH frames. The SMPL parameter transl
80
+ # omits the shaped rest-pelvis offset; using it directly shifts placement.
81
+ with torch.no_grad():
82
+ model = runner.make_smplx("supermotion").cuda()
83
+ mapping = torch.load("hmr4d/utils/body_model/smplx2smpl_sparse.pt").cuda()
84
+ regressor = torch.load("hmr4d/utils/body_model/smpl_neutral_J_regressor.pt").cuda()
85
+
86
+ def pelvis(params):
87
+ mesh = model(**runner.to_cuda(params)).vertices
88
+ verts = torch.stack([torch.matmul(mapping, v) for v in mesh])
89
+ return torch.einsum("v,tvc->tc", regressor[0], verts).cpu()
90
+
91
+ camera_pelvis = pelvis(cp)
92
+ world_pelvis = pelvis(gp)
93
+ del model, mapping, regressor
94
+ runner._release_gpu()
95
+ # The prediction packs 21 axis-angle vectors into 63 scalars; the
96
+ # runner smoother requires a final axis of 3, as in its own main().
97
+ orient, _, camera_pelvis = runner.smooth_motion_params(
98
+ cp["global_orient"], cp["body_pose"].reshape(-1, 21, 3), camera_pelvis, sigma=sigma)
99
+ # Reconstruct normalization translation from production's unsmoothed
100
+ # mesh-regressed pelvis. Its first root is saved in legacy positions.
101
+ heading = capture["heading"][0]
102
+ rotation = heading[:3, :3]
103
+ norm = heading.clone()
104
+ norm[:3, 3] = torch.from_numpy(arrays["positions"][0, 0]) - rotation @ world_pelvis[0]
105
+ camera_r = runner.axis_angle_to_matrix(orient)
106
+ ayfz_r = runner.axis_angle_to_matrix(torch.from_numpy(arrays["smpl_global_orient"]))
107
+ r = camera_r @ ayfz_r.transpose(-1, -2)
108
+ relation = torch.eye(4).repeat(len(orient), 1, 1)
109
+ relation[:, :3, :3] = r
110
+ relation[:, :3, 3] = camera_pelvis - (r @ torch.from_numpy(arrays["smpl_transl"])[..., None])[..., 0]
111
+
112
+ c32 = lambda value: np.ascontiguousarray(np.asarray(value, dtype=np.float32))
113
+ arrays.update({"incam_raw_" + key: c32(value) for key, value in cp.items()})
114
+ arrays.update(incam_global_orient=c32(orient), incam_pelvis=c32(camera_pelvis),
115
+ ayfz_to_camera=c32(relation), ay_to_ayfz=c32(norm),
116
+ ay_global_orient=c32(gp["global_orient"]), ay_transl=c32(gp["transl"]),
117
+ K_fullimg=c32(pred["K_fullimg"]))
118
+ if not all(np.isfinite(value).all() for value in arrays.values()):
119
+ raise ValueError("nonfinite camera evidence")
120
+ np.savez(output, **arrays)
121
+ print(f"[fit] wrote camera parameters and transforms: {output}", flush=True)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Extract the OBS NPZ contract from one GVHMR forward pass.
3
+
4
+ Units and frames: image coordinates are pixels; translations and joints are
5
+ metres. ``incam_*`` uses OpenCV camera coordinates (+X right, +Y down, +Z
6
+ forward). ``global_*`` uses GVHMR gravity-view ``ay`` coordinates (+Y up).
7
+ Joints are the first 22 SMPL joints obtained by SMPL-X FK/regression using the
8
+ runner's ``make_smplx('supermotion')`` body model. K is float64 and is copied
9
+ exactly from --K-json.
10
+ """
11
+ import argparse
12
+ import importlib.util
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+
20
+ def load_runner(path):
21
+ path = Path(path).resolve()
22
+ sys.path.insert(0, str(path.parent))
23
+ spec = importlib.util.spec_from_file_location("cclay_obs_runner", path)
24
+ runner = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(runner)
26
+ return runner
27
+
28
+
29
+ def fk22(runner, params, model, sparse, regressor):
30
+ import torch
31
+ # Pipeline params are batched (1, T, ...); SMPL-X expects (T, ...), the
32
+ # same per-frame layout the runner feeds it via pred["smpl_params_global"].
33
+ flat = {key: (value[0] if value.dim() >= 2 and value.shape[0] == 1 else value) for key, value in params.items()}
34
+ with torch.no_grad():
35
+ body = model(**runner.to_cuda(flat))
36
+ verts = torch.stack([torch.matmul(sparse, v) for v in body.vertices])
37
+ joints = torch.einsum("jv,tvi->tji", regressor, verts)
38
+ return joints[:, :22]
39
+
40
+
41
+ def parameterized_pp(outputs, endecoder, cp_thr, clamp):
42
+ """Exact pp_static_joint_cam, exposing its two correction limits."""
43
+ import torch
44
+ from hmr4d.utils.geo_transform import apply_T_on_points, transform_mat
45
+ from hmr4d.utils.net_utils import gaussian_smooth
46
+ from pytorch3d.transforms import axis_angle_to_matrix
47
+
48
+ incam = outputs["pred_smpl_params_incam"].copy()
49
+ global_params = outputs["pred_smpl_params_global"]
50
+ logits = outputs["static_conf_logits"].clone()[:, :-1]
51
+ joint_ids = [7, 10, 8, 11, 20, 21]
52
+ batch, length = incam["transl"].shape[:2]
53
+ assert batch == 1
54
+
55
+ pred_w = endecoder.fk_v2(**global_params)
56
+ incam["transl"] = gaussian_smooth(incam["transl"], sigma=5, dim=-2)
57
+ pred_c = endecoder.fk_v2(**incam)
58
+ r_gv = axis_angle_to_matrix(global_params["global_orient"][:, 0])
59
+ r_c = axis_angle_to_matrix(incam["global_orient"][:, 0])
60
+ r_c2w = r_gv @ r_c.mT
61
+ t_c2w = pred_w[:, 0, 0] - torch.einsum("bij,bj->bi", r_c2w, pred_c[:, 0, 0])
62
+ t_c2w = transform_mat(r_c2w, t_c2w)
63
+ pred_c_in_w = apply_T_on_points(pred_c, t_c2w[:, None])
64
+
65
+ post_transl = global_params["transl"].clone()
66
+ post_joints = pred_w.clone()
67
+ threshold = torch.as_tensor([cp_thr] * 3, device=post_joints.device, dtype=post_joints.dtype)
68
+ for i in range(1, length):
69
+ diff = post_joints[:, i, 0] - pred_c_in_w[:, i, 0]
70
+ diff = diff * ~((diff > -threshold) * (diff < threshold))
71
+ diff = torch.clamp(diff, -clamp, clamp)
72
+ post_transl[:, i:] -= diff
73
+ post_joints[:, i:] -= diff[:, None, None]
74
+
75
+ static = logits.sigmoid() > 0.8
76
+ count = static.sum(-1, keepdim=True).clamp_min(1)
77
+ disp = (post_joints[:, 1:, joint_ids] - post_joints[:, :-1, joint_ids])
78
+ disp = (disp * static[..., None]).sum(-2) / count
79
+ disp[:, :, 1] = 0
80
+ for i in range(1, length):
81
+ post_transl[:, i:] -= disp[:, [i - 1]]
82
+ post_joints[:, i:] -= disp[:, [i - 1], None]
83
+ ground_y = post_joints[..., 1].flatten(-2).min(dim=-1)[0]
84
+ post_transl[..., 1] -= ground_y
85
+ return post_transl
86
+
87
+
88
+ def as_np(value, dtype=np.float32):
89
+ if hasattr(value, "detach"):
90
+ value = value.detach().cpu().numpy()
91
+ return np.ascontiguousarray(value, dtype=dtype)
92
+
93
+
94
+ def main():
95
+ ap = argparse.ArgumentParser()
96
+ ap.add_argument("runner")
97
+ ap.add_argument("video")
98
+ ap.add_argument("output")
99
+ ap.add_argument("--out-root", default="outputs/cclay-obs")
100
+ ap.add_argument("--K-json", required=True)
101
+ ap.add_argument("--detector", choices=("palette", "yolo"), default="palette")
102
+ ap.add_argument("--keypoints", choices=("vitpose", "hybrid"), default="vitpose")
103
+ ap.add_argument("--betas-json")
104
+ ap.add_argument("--pp-thr", type=float, default=0.0)
105
+ ap.add_argument("--pp-clamp", type=float, default=1.0)
106
+ ap.add_argument("--check-pp", action="store_true")
107
+ args = ap.parse_args()
108
+
109
+ runner = load_runner(args.runner)
110
+ import torch
111
+ from hmr4d.model.gvhmr.utils.postprocess import pp_static_joint_cam, process_ik
112
+ from hmr4d.utils.geo.hmr_cam import normalize_kp2d
113
+
114
+ with open(args.K_json, encoding="utf-8") as stream:
115
+ K = np.asarray(json.load(stream), dtype=np.float64)
116
+ if K.shape != (3, 3):
117
+ raise ValueError("--K-json must contain a 3x3 array")
118
+
119
+ video = Path(args.video)
120
+ length, width, height = runner.get_video_lwh(str(video))
121
+ cfg = runner.build_cfg(video, True, None, Path(args.out_root))
122
+ data = runner.preprocess(cfg, video, args.detector, args.keypoints)
123
+ data["K_fullimg"] = torch.as_tensor(K, dtype=data["K_fullimg"].dtype,
124
+ device=data["K_fullimg"].device).repeat(length, 1, 1)
125
+ model = runner.hydra.utils.instantiate(cfg.model, _recursive_=False)
126
+ model.load_pretrained_model(cfg.ckpt_path)
127
+ model = model.eval().cuda()
128
+ batch = {
129
+ "length": data["length"][None],
130
+ "obs": normalize_kp2d(data["kp2d"], data["bbx_xys"])[None],
131
+ "bbx_xys": data["bbx_xys"][None], "K_fullimg": data["K_fullimg"][None],
132
+ "cam_angvel": data["cam_angvel"][None], "f_imgseq": data["f_imgseq"][None],
133
+ }
134
+ batch = {key: value.cuda() for key, value in batch.items()}
135
+ with torch.no_grad():
136
+ raw = model.pipeline.forward(batch, train=False, postproc=False, static_cam=True)
137
+
138
+ pred = {key: {name: value.clone() for name, value in raw[key].items()}
139
+ for key in ("pred_smpl_params_incam", "pred_smpl_params_global")}
140
+ pred["static_conf_logits"] = raw["static_conf_logits"].clone()
141
+ if args.betas_json:
142
+ with open(args.betas_json, encoding="utf-8") as stream:
143
+ loaded = json.load(stream)
144
+ # Accept a bare list or tools/bench/obs/mannequin-betas.json ({"betas": [...], ...}).
145
+ betas_used = np.asarray(loaded["betas"] if isinstance(loaded, dict) else loaded, dtype=np.float32)
146
+ if betas_used.shape != (10,):
147
+ raise ValueError("--betas-json must contain 10 floats")
148
+ betas = torch.as_tensor(betas_used, device=pred["pred_smpl_params_incam"]["betas"].device,
149
+ dtype=pred["pred_smpl_params_incam"]["betas"].dtype)
150
+ for params in (pred["pred_smpl_params_incam"], pred["pred_smpl_params_global"]):
151
+ params["betas"] = betas[None, None].expand_as(params["betas"])
152
+ else:
153
+ betas_used = as_np(pred["pred_smpl_params_incam"]["betas"][0].mean(0))
154
+
155
+ default_transl = pp_static_joint_cam(pred, model.pipeline.endecoder)
156
+ relaxed_transl = parameterized_pp(pred, model.pipeline.endecoder, args.pp_thr, args.pp_clamp)
157
+ if args.check_pp:
158
+ exact = parameterized_pp(pred, model.pipeline.endecoder, 0.25, 0.02)
159
+ pp_diff = float((exact - default_transl).abs().max().item())
160
+ print(f"pp_default vs GVHMR postproc max abs diff: {pp_diff:.9g}", flush=True)
161
+ if pp_diff > 1e-5:
162
+ raise AssertionError(f"pp_default mismatch: {pp_diff}")
163
+
164
+ variants = {}
165
+ for name, transl in (("pp_default", default_transl), ("pp_relaxed", relaxed_transl)):
166
+ params = {key: value.clone() for key, value in pred["pred_smpl_params_global"].items()}
167
+ params["transl"] = transl
168
+ ik_input = {"pred_smpl_params_global": params,
169
+ "pred_smpl_params_incam": pred["pred_smpl_params_incam"],
170
+ "static_conf_logits": pred["static_conf_logits"]}
171
+ params["body_pose"] = process_ik(ik_input, model.pipeline.endecoder)
172
+ variants[name] = params
173
+
174
+ smplx = runner.make_smplx("supermotion").cuda()
175
+ sparse = torch.load("hmr4d/utils/body_model/smplx2smpl_sparse.pt").cuda()
176
+ regressor = torch.load("hmr4d/utils/body_model/smpl_neutral_J_regressor.pt").cuda()
177
+ incam = pred["pred_smpl_params_incam"]
178
+ incam_joints = fk22(runner, incam, smplx, sparse, regressor)
179
+ output = {
180
+ "fps": np.int32(runner.video_fps(video)), "K": K,
181
+ "bbx_xys": as_np(data["bbx_xys"]), "kp2d": as_np(data["kp2d"]),
182
+ "static_conf_logits": as_np(pred["static_conf_logits"][0]),
183
+ "betas_pred": as_np(raw["pred_smpl_params_incam"]["betas"][0]),
184
+ "betas_used": as_np(betas_used),
185
+ "incam_global_orient": as_np(incam["global_orient"][0]),
186
+ "incam_body_pose": as_np(incam["body_pose"][0].reshape(length, 21, 3)),
187
+ "incam_transl": as_np(incam["transl"][0]), "incam_joints": as_np(incam_joints),
188
+ "incam_pelvis": as_np(incam_joints[:, 0]),
189
+ }
190
+ for name, params in variants.items():
191
+ joints = fk22(runner, params, smplx, sparse, regressor)
192
+ output.update({f"global_{name}_global_orient": as_np(params["global_orient"][0]),
193
+ f"global_{name}_body_pose": as_np(params["body_pose"][0].reshape(length, 21, 3)),
194
+ f"global_{name}_transl": as_np(params["transl"][0]),
195
+ f"global_{name}_joints": as_np(joints)})
196
+ if not all(np.isfinite(value).all() for value in output.values()):
197
+ raise ValueError("nonfinite OBS output")
198
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
199
+ np.savez(args.output, **output)
200
+ print(f"wrote OBS NPZ: {args.output} fields={len(output)}", flush=True)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env python3
2
+ """Bench launcher for cclay_gvhmr_extract.py (issue #430), YOLO runs only.
3
+
4
+ python cclay_bench_runner.py <runner.py> <runner args...>
5
+
6
+ Runs the runner's own main() with exactly the given argv, in the same way
7
+ tools/ardy/cclay_gvhmr_worker.py loads it, and adds one observation: the
8
+ runner logs how many frames its YOLO track detected only on the raw-detection
9
+ fallback, so the tracker path gets a "[bench] yolo track: N/M frames" line
10
+ here. The wrapped method's inputs and return value are passed through
11
+ untouched; the runner's decisions do not change.
12
+ """
13
+ import importlib.util
14
+ from pathlib import Path
15
+ import sys
16
+
17
+
18
+ def main():
19
+ runner_path = Path(sys.argv[1]).resolve()
20
+ sys.path.insert(0, str(runner_path.parent))
21
+ from hmr4d.utils.preproc.tracker import Tracker
22
+
23
+ original = Tracker.sort_track_length
24
+
25
+ def counted(track_history, video_path):
26
+ result = original(track_history, video_path)
27
+ id_to_frame_ids, _, id_sorted = result
28
+ if id_sorted:
29
+ print(f"[bench] yolo track: {len(id_to_frame_ids[id_sorted[0]])}/{len(track_history)} frames",
30
+ file=sys.stderr, flush=True)
31
+ return result
32
+
33
+ Tracker.sort_track_length = staticmethod(counted)
34
+ spec = importlib.util.spec_from_file_location("cozyclay_gvhmr_bench", runner_path)
35
+ runner = importlib.util.module_from_spec(spec)
36
+ sys.argv = [str(runner_path), *sys.argv[2:]]
37
+ spec.loader.exec_module(runner)
38
+ runner.main()
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,162 @@
1
+ import { sceneBoxRecord, projectBox, BOX_EDGES } from "../gt-render/scene-box.mjs";
2
+
3
+ /** Lowest sustained window, never a one-frame outlier. Ties choose the later span. */
4
+ export function lowestWindow(values, length, start = 0) {
5
+ if (!values.length || !values.every(Number.isFinite) || length < 1 || length > values.length - start) throw new Error("invalid sustained window");
6
+ let best = start, score = Infinity;
7
+ for (let i = start; i <= values.length - length; i++) {
8
+ const mean = values.slice(i, i + length).reduce((a, b) => a + b, 0) / length;
9
+ if (mean <= score) { score = mean; best = i; }
10
+ }
11
+ return Array.from({ length }, (_, i) => best + i);
12
+ }
13
+ const mean = a => a.reduce((s, v) => s + v, 0) / a.length;
14
+ const xyz = (v, i) => [v[i], v[i + 1], v[i + 2]];
15
+
16
+ /** Axis-aligned horizontal support: the highest nonpenetrating top beneath
17
+ * ALL sampled skin in this footprint. No joint radius or guessed skin offset.
18
+ * Returns the witness vertex, making exact contact independently checkable. */
19
+ export function supportTop(frames, vertices, { x, z, sx, sz }) {
20
+ let height = Infinity, witness = null;
21
+ for (const frame of frames) {
22
+ const v = vertices(frame);
23
+ for (let i = 0; i < v.length; i += 3) {
24
+ if (Math.abs(v[i] - x) <= sx / 2 && Math.abs(v[i + 2] - z) <= sz / 2 && v[i + 1] < height) {
25
+ height = v[i + 1]; witness = { frame, vertex: i / 3, point: xyz(v, i) };
26
+ }
27
+ }
28
+ }
29
+ return { height, witness };
30
+ }
31
+
32
+ export function deriveBox(scenario, joints, vertices) {
33
+ const index = name => { const i = joints.joints.findIndex(j => j.name === name); if (i < 0) throw new Error(`missing joint ${name}`); return i; };
34
+ const at = (f, name) => joints.world[f][index(name)];
35
+ const n = joints.frames, length = Math.min(n, Math.round(joints.fps / 2));
36
+ const heights = name => joints.world.map((_, f) => at(f, name)[1]);
37
+ const warnings = [];
38
+ let frames, box, witness, targetHeight;
39
+ if (scenario === "bump") {
40
+ const first = at(0, "Hips"), travel = joints.world.map((_, f) => at(f, "Hips").map((v, a) => v - first[a]));
41
+ const extent = a => Math.max(...travel.map(v => Math.abs(v[a])));
42
+ const axis = extent(0) > extent(2) ? 0 : 2;
43
+ const far = travel.reduce((a, b) => Math.abs(b[axis]) > Math.abs(a[axis]) ? b : a), sign = Math.sign(far[axis]) || 1;
44
+ let reach = -Infinity, peak = 0;
45
+ for (let f = 0; f < n; f++) {
46
+ const v = vertices(f);
47
+ for (let i = 0; i < v.length; i += 3) if (v[i + 1] > 0.15 && v[i + 1] < 1.5 && sign * v[i + axis] > reach) {
48
+ reach = sign * v[i + axis]; peak = f; witness = { frame: f, vertex: i / 3, point: xyz(v, i) };
49
+ }
50
+ }
51
+ frames = Array.from({ length: Math.min(length, n) }, (_, i) => Math.max(0, Math.min(n - length, peak - Math.floor(length / 2))) + i);
52
+ const centre = at(peak, "Hips").slice(), side = axis === 0 ? 2 : 0;
53
+ centre[axis] = sign * (reach + 0.2);
54
+ box = { x: centre[0], z: centre[2], rot: 0, sx: axis === 0 ? 0.4 : 1.5, sy: 1.5, sz: axis === 2 ? 0.4 : 1.5 };
55
+ // Centre width on the actual contact witness, not a drifting pelvis.
56
+ box[side === 0 ? "x" : "z"] = witness.point[side];
57
+ warnings.push("Bump frame is maximal skin reach along the dominant travel axis; this may be an extended hand rather than torso impact.");
58
+ } else {
59
+ let centres;
60
+ if (scenario === "sit") {
61
+ frames = lowestWindow(heights("Hips"), length, Math.floor(n / 3));
62
+ centres = frames.map(f => at(f, "Hips"));
63
+ targetHeight = mean(centres.map(p => p[1])) - 0.12;
64
+ if (Math.max(...heights("Hips")) - mean(centres.map(p => p[1])) < 0.15) warnings.push("No clear seated pelvis drop; using the lowest available sustained span.");
65
+ } else if (scenario === "handon") {
66
+ const travel = [at(n - 1, "Hips")[0] - at(0, "Hips")[0], at(n - 1, "Hips")[2] - at(0, "Hips")[2]];
67
+ const norm = Math.hypot(...travel), forward = norm > 1e-6 ? travel.map(v => v / norm) : [0, 1];
68
+ const windows = Array.from({ length: n - length - Math.floor(n / 2) + 1 }, (_, i) => {
69
+ const span = Array.from({ length }, (_, k) => Math.floor(n / 2) + i + k);
70
+ const palms = span.flatMap(f => [at(f, "LeftHand"), at(f, "RightHand")]);
71
+ return { span, palms, height: mean(palms.map(p => p[1])) };
72
+ }).sort((a, b) => a.height - b.height);
73
+ // The lowest hand span can still be beside the thighs. Select the
74
+ // lowest span that permits a palm-height table IN FRONT, not a box
75
+ // shifted sideways to touch a leg. Keep the failed-motion caveat.
76
+ for (const { span, palms } of windows) {
77
+ let best = Infinity;
78
+ const target = Math.min(...palms.map(p => p[1])) - 0.025;
79
+ for (let step = 14; step <= 36; step++) {
80
+ const distance = step * 0.025;
81
+ const candidate = { x: mean(palms.map(p => p[0])) + forward[0] * distance, z: mean(palms.map(p => p[2])) + forward[1] * distance, sx: 0.9, sz: 0.9 };
82
+ const support = supportTop(span, vertices, candidate);
83
+ if (support.height < Math.max(0.1, target - 0.1) || support.height > target + 0.1) continue;
84
+ const loss = Math.abs(support.height - target) + 0.02 * distance;
85
+ if (loss < best) { best = loss; box = { ...candidate, sy: support.height, rot: 0 }; witness = support.witness; frames = span; centres = palms; targetHeight = target; }
86
+ }
87
+ if (box) break;
88
+ }
89
+ if (!box) throw new Error("handon: no skin-safe palm-height table in front of the body in any sustained latter-half span");
90
+ warnings.push("The absolute lowest hands are beside the thighs, not in a table-support pose. Selected the lowest sustained span permitting a skin-safe palm-height table in front. Both-palms natural contact is not established.");
91
+ } else if (scenario === "stepup") {
92
+ frames = Array.from({ length }, (_, i) => n - length + i);
93
+ const foot = mean(frames.map(f => at(f, "LeftFoot")[1])) > mean(frames.map(f => at(f, "RightFoot")[1])) ? "LeftFoot" : "RightFoot";
94
+ centres = frames.map(f => at(f, foot));
95
+ targetHeight = Math.min(...centres.map(p => p[1])) - 0.04;
96
+ if (targetHeight < 0.12) warnings.push("No clearly raised terminal stance foot; best available terminal span used, not a fabricated 0.3 m step.");
97
+ } else throw new Error(`unknown scenario ${scenario}`);
98
+ if (!box) {
99
+ const x = mean(centres.map(p => p[0])), z = mean(centres.map(p => p[2]));
100
+ const size = scenario === "sit" ? 0.5 : 0.4;
101
+ let best = Infinity;
102
+ // A footprint enclosing the feet is rejected by its near-floor
103
+ // support top, not repaired by ignoring those vertices.
104
+ const offsets = [-0.3, -0.2, -0.1, 0, 0.1, 0.2, 0.3];
105
+ for (const dx of offsets) for (const dz of offsets) {
106
+ const candidate = { x: x + dx, z: z + dz, sx: size, sz: size };
107
+ const support = supportTop(frames, vertices, candidate);
108
+ if (!Number.isFinite(support.height) || support.height < 0.1 || support.height > targetHeight + 0.1) continue;
109
+ const loss = Math.abs(support.height - targetHeight) + 0.3 * Math.hypot(dx, dz);
110
+ if (loss < best) { best = loss; box = { ...candidate, sy: support.height, rot: 0 }; witness = support.witness; }
111
+ }
112
+ if (!box) throw new Error(`${scenario}: no Studio-supported (>=0.1 m) support footprint near the intended contact`);
113
+ }
114
+ if (scenario === "stepup" && box.sy > 0.45) warnings.push(`Kimodo's terminal sole is ${box.sy.toFixed(3)} m high; retained the motion-derived height rather than inventing a 0.3-0.4 m step.`);
115
+ if (Math.abs(box.sy - targetHeight) > 0.1) warnings.push(`Skin-safe top ${box.sy.toFixed(3)} m differs from anatomical target ${targetHeight.toFixed(3)} m: motion/contact is not a clean instance of the requested action.`);
116
+ }
117
+ return { scenario, box, scene: sceneBoxRecord(box), contactFrames: frames, contactSeconds: [frames[0] / joints.fps, frames.at(-1) / joints.fps], witness, targetHeight, warnings, method: "Static box from rendered skin in a declared 0.5 s contact span; no intermediate GT is used in fitting. A support top equals the lowest skin vertex within its footprint across that span." };
118
+ }
119
+
120
+ /** Chroma-only proxy: RGB range >= 35, HSV S >= .25, V >= .18;
121
+ * includes all saturated palette colours, excludes neutral cube/floor/shadows.
122
+ * It is NOT semantic segmentation and includes similarly coloured hallucinations. */
123
+ export const PALETTE_THRESHOLDS = Object.freeze({ minRange: 35, minSaturation: 0.25, minValue: 0.18 });
124
+ export function paletteMask(rgb, thresholds = PALETTE_THRESHOLDS) {
125
+ if (rgb.length % 3) throw new Error("RGB byte count must be divisible by three");
126
+ const mask = Buffer.alloc(rgb.length / 3);
127
+ for (let p = 0; p < mask.length; p++) {
128
+ const r = rgb[p * 3], g = rgb[p * 3 + 1], b = rgb[p * 3 + 2], hi = Math.max(r, g, b), lo = Math.min(r, g, b);
129
+ mask[p] = hi - lo >= thresholds.minRange && (hi - lo) / hi >= thresholds.minSaturation && hi / 255 >= thresholds.minValue ? 255 : 0;
130
+ }
131
+ return mask;
132
+ }
133
+
134
+ /** Grey-body proxy against the same camera's empty Studio plate (cube stays).
135
+ * Any RGB channel changing by >=30 counts as foreground. Lighting drift,
136
+ * moving shadows and generated background changes are intentionally NOT
137
+ * corrected: they are uncertainties of this proxy, not motion ground truth. */
138
+ export function backgroundDifferenceMask(rgb, plate, threshold = 30) {
139
+ if (!plate.length || plate.length % 3 || rgb.length % plate.length || !Number.isFinite(threshold) || threshold <= 0 || threshold > 255) throw new Error("invalid RGB background-difference inputs");
140
+ const mask = Buffer.alloc(rgb.length / 3);
141
+ for (let p = 0; p < mask.length; p++) {
142
+ const i = p * 3, q = i % plate.length;
143
+ mask[p] = Math.max(Math.abs(rgb[i] - plate[q]), Math.abs(rgb[i + 1] - plate[q + 1]), Math.abs(rgb[i + 2] - plate[q + 2])) >= threshold ? 255 : 0;
144
+ }
145
+ return mask;
146
+ }
147
+
148
+ /** Draw all 8 projected corners and 12 edges directly into RGB (no image dependency). */
149
+ export function drawProjectedBox(rgb, camera, box) {
150
+ const out = Buffer.from(rgb), points = projectBox(box, camera), { width, height } = camera;
151
+ const dot = (x, y, radius) => { for (let dx = -radius; dx <= radius; dx++) for (let dy = -radius; dy <= radius; dy++) {
152
+ const u = Math.round(x) + dx, v = Math.round(y) + dy;
153
+ if (u >= 0 && u < width && v >= 0 && v < height) { const p = (v * width + u) * 3; out[p] = 255; out[p + 1] = 25; out[p + 2] = 25; }
154
+ } };
155
+ for (const [a, b] of BOX_EDGES) {
156
+ const p = points[a], q = points[b]; if (p[2] <= 0 || q[2] <= 0) continue;
157
+ const steps = Math.ceil(Math.max(Math.abs(p[0] - q[0]), Math.abs(p[1] - q[1])));
158
+ for (let i = 0; i <= steps; i++) { const t = steps ? i / steps : 0; dot(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, 0); }
159
+ }
160
+ for (const [u, v, d] of points) if (d > 0) dot(u, v, 3);
161
+ return out;
162
+ }