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.
- package/CHANGELOG.md +137 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +219 -376
- package/package.json +2 -1
- package/src/App.jsx +1249 -9076
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +97 -67
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +88 -19
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +38 -268
- package/src/studio-agent-context.js +38 -9
- package/src/studio-agent-motion.js +122 -378
- package/src/studio-agent-protocol.js +66 -30
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +443 -1753
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +58 -3
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-Cu1cexZR.css +0 -1
- package/dist/assets/app-Dxdh8kds.js +0 -4861
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
- package/dist/assets/workflow-CJd-PDtV.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- 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"
|