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,728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* study-2d.mjs - Gate 0 of the known-character tracker (#500, plan todo 4).
|
|
4
|
+
* Measures, on existing data only, what the tracker has to fix and what it
|
|
5
|
+
* can reach:
|
|
6
|
+
*
|
|
7
|
+
* (a) stance slide: truth itself and run-492f Gbest, tools/track/metrics.mjs
|
|
8
|
+
* truthStanceSlideCmPerS (+ pelvis steps > 20 deg/frame)
|
|
9
|
+
* (b) IoU ceiling: every truth motion scored as the prediction through
|
|
10
|
+
* tools/bench/score.mjs (the same scorer and field the gate uses)
|
|
11
|
+
* (c) camera fixedness: per fal clip, the median background-pixel shift vs
|
|
12
|
+
* frame 0 over textured border-band tiles (10 % of the width; SSD block
|
|
13
|
+
* matching + Lucas-Kanade refinement), plus static truth renders as controls
|
|
14
|
+
* (d) 2D evidence: truth joints as the video shows them (the render's
|
|
15
|
+
* joints.json, verified to be the render of item.source) projected with
|
|
16
|
+
* camera.json vs the obs kp2d: per-joint pixel error split by box
|
|
17
|
+
* occlusion, L/R swap frames per bilateral group, and deltaPx
|
|
18
|
+
* (e) grey (skin) truth set: approved-skin.json and the provenance of its
|
|
19
|
+
* published observations
|
|
20
|
+
*
|
|
21
|
+
* node tools/track/study-2d.mjs [--stages 2d,stance,ceiling,camera,skin] [options]
|
|
22
|
+
*
|
|
23
|
+
* Each stage writes <out>/stages/<stage>.json keyed by `inputKey`, the sha256
|
|
24
|
+
* of everything it read (item list, input file hashes, obs root / baseline,
|
|
25
|
+
* and the analysis code). A stage named in --stages is always recomputed; any
|
|
26
|
+
* other stage is reused only when its stored inputKey equals the current one,
|
|
27
|
+
* recomputed when its inputs changed, and left out when it never ran.
|
|
28
|
+
* summary.json/summary.md merge the stages. Any item that is not ok (missing
|
|
29
|
+
* obs, failed scoring, bad provenance, ...) is listed and the exit code is 1.
|
|
30
|
+
* Truth is read here for measurement only; nothing here feeds a tracker.
|
|
31
|
+
*/
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join, resolve } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { readNpz } from "../kimodo/read-npz.mjs";
|
|
38
|
+
import { cameraFromJson, worldToPixel } from "../bench/obs/extrinsics.mjs";
|
|
39
|
+
import { pelvisStepsDeg, resampleMotion, truthStanceSlideCmPerS } from "./metrics.mjs";
|
|
40
|
+
|
|
41
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const ROOT = resolve(HERE, "../..");
|
|
43
|
+
const STAGES = ["2d", "stance", "ceiling", "camera", "skin"];
|
|
44
|
+
|
|
45
|
+
/** COCO-17 keypoint names (ViTPose order). */
|
|
46
|
+
export const COCO_NAMES = ["nose", "leftEye", "rightEye", "leftEar", "rightEar", "leftShoulder", "rightShoulder", "leftElbow", "rightElbow", "leftWrist", "rightWrist", "leftHip", "rightHip", "leftKnee", "rightKnee", "leftAnkle", "rightAnkle"];
|
|
47
|
+
/** COCO body keypoint -> rendered rig joint (joints.json name). Face keypoints have no rig joint and are not scored. */
|
|
48
|
+
export const COCO_TO_RIG = { 5: "LeftArm", 6: "RightArm", 7: "LeftForeArm", 8: "RightForeArm", 9: "LeftHand", 10: "RightHand", 11: "LeftUpLeg", 12: "RightUpLeg", 13: "LeftLeg", 14: "RightLeg", 15: "LeftFoot", 16: "RightFoot" };
|
|
49
|
+
export const BODY_KEYPOINTS = Object.keys(COCO_TO_RIG).map(Number);
|
|
50
|
+
/** Bilateral groups as [left, right] COCO pairs (the Viterbi's arms/legs states). */
|
|
51
|
+
export const BILATERAL = { arms: [[5, 6], [7, 8], [9, 10]], legs: [[11, 12], [13, 14], [15, 16]] };
|
|
52
|
+
/** A group is swapped when the swapped assignment beats identity by more than this fraction of the torso height. */
|
|
53
|
+
export const SWAP_MARGIN = 0.2;
|
|
54
|
+
/** Keypoints below this ViTPose confidence do not vote in the swap classifier. */
|
|
55
|
+
export const SWAP_CONF_MIN = 0.3;
|
|
56
|
+
/** A pair vote needs this many confident L/R pairs in the group. */
|
|
57
|
+
export const SWAP_MIN_PAIRS = 2;
|
|
58
|
+
|
|
59
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
60
|
+
const fileSha = (path) => sha256(readFileSync(path));
|
|
61
|
+
/** sha256 of a file, or "missing" (a missing input is part of the key too). */
|
|
62
|
+
const shaOrMissing = (path) => (path && existsSync(path) ? fileSha(path) : "missing");
|
|
63
|
+
/** The analysis code every stage depends on, plus stage-specific repo files. */
|
|
64
|
+
const CODE = ["tools/track/study-2d.mjs", "tools/track/metrics.mjs", "tools/bench/obs/extrinsics.mjs", "tools/kimodo/read-npz.mjs"];
|
|
65
|
+
export const codeSha = (extra = []) => sha256([...CODE, ...extra].map((p) => `${p}:${fileSha(join(ROOT, p))}`).join("\n"));
|
|
66
|
+
/** The cache key of a stage: sha256 of its JSON-described inputs. */
|
|
67
|
+
export const inputKey = (inputs) => sha256(JSON.stringify(inputs));
|
|
68
|
+
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
69
|
+
const putJson = (path, value) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, "\t")}\n`); };
|
|
70
|
+
const round = (v, d = 3) => (v === null || v === undefined || !Number.isFinite(v) ? v ?? null : Math.round(v * 10 ** d) / 10 ** d);
|
|
71
|
+
const mean = (xs) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null);
|
|
72
|
+
|
|
73
|
+
/** numpy-style linear-interpolated percentile of an unsorted list (null when empty). */
|
|
74
|
+
export function percentile(values, p) {
|
|
75
|
+
if (!values.length) return null;
|
|
76
|
+
const s = Float64Array.from(values).sort();
|
|
77
|
+
const x = (p / 100) * (s.length - 1), lo = Math.floor(x), hi = Math.ceil(x);
|
|
78
|
+
return s[lo] + (s[hi] - s[lo]) * (x - lo);
|
|
79
|
+
}
|
|
80
|
+
const stats = (xs) => ({ n: xs.length, medianPx: round(percentile(xs, 50), 2), p95Px: round(percentile(xs, 95), 2), meanPx: round(mean(xs), 2) });
|
|
81
|
+
|
|
82
|
+
/** Frame numbers -> "a-b" runs. */
|
|
83
|
+
export function frameRuns(frames) {
|
|
84
|
+
const runs = [];
|
|
85
|
+
for (const f of [...frames].sort((a, b) => a - b)) {
|
|
86
|
+
const last = runs.at(-1);
|
|
87
|
+
if (last && f === last[1] + 1) last[1] = f; else runs.push([f, f]);
|
|
88
|
+
}
|
|
89
|
+
return runs.map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------- the scene
|
|
93
|
+
|
|
94
|
+
/** scene.json (or a bench box) -> oriented box { centre, half, yaw (rad) }. */
|
|
95
|
+
export function normalizeBox(box) {
|
|
96
|
+
if (Array.isArray(box?.min) && Array.isArray(box?.max) && !box.halfExtents) {
|
|
97
|
+
return { centre: box.min.map((v, i) => (v + box.max[i]) / 2), half: box.min.map((v, i) => (box.max[i] - v) / 2), yaw: 0 };
|
|
98
|
+
}
|
|
99
|
+
const centre = box?.centre ?? box?.center, half = box?.halfExtents;
|
|
100
|
+
if (!Array.isArray(centre) || !Array.isArray(half)) throw new Error("box needs {min,max} or {centre, halfExtents, yawDeg}");
|
|
101
|
+
const yaw = Number.isFinite(box.yawDeg) ? (box.yawDeg * Math.PI) / 180 : Number(box.yaw ?? 0);
|
|
102
|
+
return { centre, half, yaw };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True when the open segment camera -> point passes through the box, i.e. the
|
|
107
|
+
* point is hidden by it (a point inside the box counts as hidden). The box
|
|
108
|
+
* yaw is about +Y (Three's rotation.y).
|
|
109
|
+
*/
|
|
110
|
+
export function segmentHitsBox(origin, point, rawBox) {
|
|
111
|
+
const { centre, half, yaw } = normalizeBox(rawBox);
|
|
112
|
+
const c = Math.cos(yaw), s = Math.sin(yaw);
|
|
113
|
+
const local = (p) => { const x = p[0] - centre[0], y = p[1] - centre[1], z = p[2] - centre[2]; return [c * x - s * z, y, s * x + c * z]; };
|
|
114
|
+
const o = local(origin), q = local(point);
|
|
115
|
+
let enter = -Infinity, exit = Infinity;
|
|
116
|
+
for (let a = 0; a < 3; a += 1) {
|
|
117
|
+
const d = q[a] - o[a];
|
|
118
|
+
if (Math.abs(d) < 1e-12) {
|
|
119
|
+
if (Math.abs(o[a]) > half[a]) return false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const t1 = (-half[a] - o[a]) / d, t2 = (half[a] - o[a]) / d;
|
|
123
|
+
enter = Math.max(enter, Math.min(t1, t2));
|
|
124
|
+
exit = Math.min(exit, Math.max(t1, t2));
|
|
125
|
+
}
|
|
126
|
+
return enter <= exit && exit > 1e-9 && enter < 1 - 1e-9;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const jointOccluded = (cameraCentre, point, boxes) => boxes.some((box) => segmentHitsBox(cameraCentre, point, box));
|
|
130
|
+
|
|
131
|
+
// ------------------------------------------------------------ the 2D study
|
|
132
|
+
|
|
133
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* L/R swap test for one bilateral group in one frame. kp: COCO-17 [x, y, conf];
|
|
137
|
+
* truthPx: COCO index -> [u, v]. Returns { swapped: true|false|null (too few
|
|
138
|
+
* confident pairs), identityPx, swappedPx, pairs }.
|
|
139
|
+
*/
|
|
140
|
+
export function classifySwap(kp, truthPx, pairs, torsoPx, { confMin = SWAP_CONF_MIN, margin = SWAP_MARGIN, minPairs = SWAP_MIN_PAIRS } = {}) {
|
|
141
|
+
let identity = 0, swapped = 0, used = 0;
|
|
142
|
+
for (const [l, r] of pairs) {
|
|
143
|
+
if (!(kp[l][2] >= confMin && kp[r][2] >= confMin)) continue;
|
|
144
|
+
identity += dist(kp[l], truthPx[l]) + dist(kp[r], truthPx[r]);
|
|
145
|
+
swapped += dist(kp[l], truthPx[r]) + dist(kp[r], truthPx[l]);
|
|
146
|
+
used += 1;
|
|
147
|
+
}
|
|
148
|
+
if (used < minPairs) return { swapped: null, identityPx: null, swappedPx: null, pairs: used };
|
|
149
|
+
identity /= 2 * used; swapped /= 2 * used;
|
|
150
|
+
return { swapped: identity - swapped > margin * torsoPx, identityPx: identity, swappedPx: swapped, pairs: used };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The pure 2D analysis of one clip. kp2d: frames x 17 x 3 (flat); truthWorld:
|
|
155
|
+
* COCO index -> [T][3] world points; cam: cameraFromJson(camera.json); boxes:
|
|
156
|
+
* scene boxes. Returns per-joint visible/occluded errors, swap frames and the
|
|
157
|
+
* raw error lists (visibleBest = error under the per-frame truth-chosen L/R
|
|
158
|
+
* assignment, the Viterbi's noise scale).
|
|
159
|
+
*/
|
|
160
|
+
export function analyzeClip({ kp2d, frames, truthWorld, cam, boxes = [] }) {
|
|
161
|
+
const centre = cam.t_c2w;
|
|
162
|
+
const perJoint = Object.fromEntries(BODY_KEYPOINTS.map((k) => [COCO_NAMES[k], { visible: [], occluded: [] }]));
|
|
163
|
+
const visibleBest = [], occludedBest = [], visibleIdentity = [], occludedIdentity = [];
|
|
164
|
+
const swaps = { arms: [], legs: [] }, scored = { arms: 0, legs: 0 };
|
|
165
|
+
const occludedPairs = [];
|
|
166
|
+
for (let t = 0; t < frames; t += 1) {
|
|
167
|
+
const kp = Array.from({ length: 17 }, (_, k) => [kp2d[(t * 17 + k) * 3], kp2d[(t * 17 + k) * 3 + 1], kp2d[(t * 17 + k) * 3 + 2]]);
|
|
168
|
+
const px = {}, hidden = {};
|
|
169
|
+
for (const k of BODY_KEYPOINTS) {
|
|
170
|
+
px[k] = worldToPixel(truthWorld[k][t], cam);
|
|
171
|
+
hidden[k] = jointOccluded(centre, truthWorld[k][t], boxes);
|
|
172
|
+
if (hidden[k]) occludedPairs.push([t, COCO_NAMES[k]]);
|
|
173
|
+
}
|
|
174
|
+
const mid = (a, b) => [(px[a][0] + px[b][0]) / 2, (px[a][1] + px[b][1]) / 2];
|
|
175
|
+
const torso = dist(mid(5, 6), mid(11, 12));
|
|
176
|
+
const chosen = {};
|
|
177
|
+
for (const [group, pairs] of Object.entries(BILATERAL)) {
|
|
178
|
+
const verdict = classifySwap(kp, px, pairs, torso);
|
|
179
|
+
if (verdict.swapped !== null) scored[group] += 1;
|
|
180
|
+
if (verdict.swapped) swaps[group].push(t);
|
|
181
|
+
for (const [l, r] of pairs) { chosen[l] = verdict.swapped ? r : l; chosen[r] = verdict.swapped ? l : r; }
|
|
182
|
+
}
|
|
183
|
+
for (const k of BODY_KEYPOINTS) {
|
|
184
|
+
const identity = dist(kp[k], px[k]), best = dist(kp[k], px[chosen[k]]);
|
|
185
|
+
perJoint[COCO_NAMES[k]][hidden[k] ? "occluded" : "visible"].push(identity);
|
|
186
|
+
(hidden[k] ? occludedBest : visibleBest).push(best);
|
|
187
|
+
(hidden[k] ? occludedIdentity : visibleIdentity).push(identity);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const both = swaps.arms.filter((t) => swaps.legs.includes(t));
|
|
191
|
+
const any = [...new Set([...swaps.arms, ...swaps.legs])].sort((a, b) => a - b);
|
|
192
|
+
return {
|
|
193
|
+
frames,
|
|
194
|
+
perJoint: Object.fromEntries(Object.entries(perJoint).map(([name, v]) => [name, { visible: stats(v.visible), occluded: stats(v.occluded) }])),
|
|
195
|
+
visible: stats(visibleIdentity), occluded: stats(occludedIdentity), visibleBest: stats(visibleBest), occludedBest: stats(occludedBest),
|
|
196
|
+
swaps: {
|
|
197
|
+
counts: { arms: swaps.arms.length, legs: swaps.legs.length, both: both.length, any: any.length },
|
|
198
|
+
scoredFrames: scored,
|
|
199
|
+
runs: { arms: frameRuns(swaps.arms), legs: frameRuns(swaps.legs) },
|
|
200
|
+
frames: { arms: swaps.arms, legs: swaps.legs },
|
|
201
|
+
},
|
|
202
|
+
occludedJointFrames: occludedPairs.length,
|
|
203
|
+
raw: { visibleBest, visibleIdentity },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Load one truth item and its obs and run analyzeClip. Missing obs -> status "missing-obs" (never throws for it). */
|
|
208
|
+
export function studyItem(item, obsRoot) {
|
|
209
|
+
const label = `${item.set}/${item.name}`;
|
|
210
|
+
const obsPath = join(obsRoot, item.set, item.name, "g5", "obs.npz");
|
|
211
|
+
if (!existsSync(obsPath)) return { item: label, variant: item.variant, status: "missing-obs", obsPath };
|
|
212
|
+
const files = join(item.dir, item.variant);
|
|
213
|
+
const camera = readJson(join(files, "camera.json")), joints = readJson(join(files, "joints.json")), meta = readJson(join(files, "meta.json"));
|
|
214
|
+
if (meta.source?.path !== item.source || meta.source?.sha256 !== fileSha(item.source)) throw new Error(`${label}: ${files} is not the render of ${item.source}`);
|
|
215
|
+
const cam = cameraFromJson(camera);
|
|
216
|
+
const index = Object.fromEntries(joints.joints.map((j, i) => [j.name, i]));
|
|
217
|
+
const truthWorld = Object.fromEntries(BODY_KEYPOINTS.map((k) => {
|
|
218
|
+
const i = index[COCO_TO_RIG[k]];
|
|
219
|
+
if (i === undefined) throw new Error(`${label}: joints.json has no ${COCO_TO_RIG[k]}`);
|
|
220
|
+
return [k, joints.world.map((frame) => frame[i])];
|
|
221
|
+
}));
|
|
222
|
+
// The projection must be the render's own (joints.json uv).
|
|
223
|
+
let projectionCheckPx = 0;
|
|
224
|
+
for (let t = 0; t < joints.frames; t += 1) for (const k of BODY_KEYPOINTS) projectionCheckPx = Math.max(projectionCheckPx, dist(worldToPixel(truthWorld[k][t], cam), joints.uv[t][index[COCO_TO_RIG[k]]]));
|
|
225
|
+
if (!(projectionCheckPx < 0.01)) throw new Error(`${label}: camera.json projection disagrees with joints.json uv by ${projectionCheckPx} px`);
|
|
226
|
+
const obs = readNpz(obsPath);
|
|
227
|
+
const [obsFrames] = obs.kp2d.shape;
|
|
228
|
+
const Kgap = Math.max(...camera.K.flat().map((v, i) => Math.abs(v - obs.K.data[i])));
|
|
229
|
+
const boxes = item.scene ? [readJson(item.scene)] : [];
|
|
230
|
+
const frames = Math.min(obsFrames, joints.frames);
|
|
231
|
+
const result = analyzeClip({ kp2d: obs.kp2d.data, frames, truthWorld, cam, boxes });
|
|
232
|
+
return { item: label, variant: item.variant, status: "ok", obsPath, obsSha256: fileSha(obsPath), obsFrames, truthFrames: joints.frames, obsKVsCameraMax: Kgap, projectionCheckPx, boxes: boxes.length, ...result };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// -------------------------------------------------------------- (a) stance
|
|
236
|
+
|
|
237
|
+
function motionOf(path) {
|
|
238
|
+
const npz = readNpz(path);
|
|
239
|
+
return { frames: npz.posed_joints.shape[0], fps: npz.fps.data[0], posedJoints: npz.posed_joints.data, rotMats: npz.local_rot_mats.data };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function stanceItem(item, baselineRun) {
|
|
243
|
+
const label = `${item.set}/${item.name}`;
|
|
244
|
+
const truth = motionOf(item.source);
|
|
245
|
+
const gbestPath = join(baselineRun, item.set, item.name, "Gbest", "motion.npz");
|
|
246
|
+
if (!existsSync(gbestPath)) return { item: label, status: "missing-baseline", gbestPath };
|
|
247
|
+
const gbest = motionOf(gbestPath);
|
|
248
|
+
// The truth is measured as a prediction on the same timeline as Gbest (its fps and frame count).
|
|
249
|
+
const t = truthStanceSlideCmPerS(resampleMotion(truth, gbest.fps, gbest.frames), truth), g = truthStanceSlideCmPerS(gbest, truth);
|
|
250
|
+
const steps = (m) => { const s = pelvisStepsDeg(m.rotMats, m.frames); return { over20: s.filter((v) => v > 20).length, maxDeg: round(Math.max(...s), 2) }; };
|
|
251
|
+
return {
|
|
252
|
+
item: label, status: "ok", gbestPath, gbestSha256: fileSha(gbestPath),
|
|
253
|
+
truth: { cmPerS: round(t.meanCmPerS), stanceSamples: t.stanceSamples, fps: truth.fps, frames: truth.frames, pelvisSteps: steps(truth) },
|
|
254
|
+
gbest: { cmPerS: round(g.meanCmPerS), stanceSamples: g.stanceSamples, fps: gbest.fps, frames: gbest.frames, pelvisSteps: steps(gbest) },
|
|
255
|
+
rule: g.rule,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ------------------------------------------------------------ (b) ceiling
|
|
260
|
+
|
|
261
|
+
/** Score the truth motion as the prediction with score.mjs exactly as obs-bench does for gt/cube items. */
|
|
262
|
+
/** What a truth-as-prediction score depends on (score.json is reused only while this is unchanged). */
|
|
263
|
+
export function ceilingInputs(item) {
|
|
264
|
+
return {
|
|
265
|
+
item, source: shaOrMissing(item.source), scene: shaOrMissing(item.scene),
|
|
266
|
+
gt: ["camera.json", "joints.json"].map((f) => shaOrMissing(join(item.dir, item.variant, f))),
|
|
267
|
+
scorer: ["tools/bench/score.mjs", "tools/gt-render/render.mjs"].map((p) => shaOrMissing(join(ROOT, p))),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function ceilingItem(item, outDir, { port, cdpPort, force }) {
|
|
272
|
+
const label = `${item.set}/${item.name}`, out = join(outDir, "gt-self", item.set, item.name);
|
|
273
|
+
const scorePath = join(out, "score.json"), keyPath = join(out, "inputs.json"), key = inputKey(ceilingInputs(item));
|
|
274
|
+
let reused = true;
|
|
275
|
+
if (force || !existsSync(scorePath) || !existsSync(keyPath) || readJson(keyPath).inputKey !== key) {
|
|
276
|
+
reused = false;
|
|
277
|
+
mkdirSync(out, { recursive: true });
|
|
278
|
+
const args = [join(ROOT, "tools/bench/score.mjs"), "--gt", join(item.dir, item.variant), "--pred", item.source, "--out", out, "--port", String(port), "--cdp-port", String(cdpPort)];
|
|
279
|
+
if (item.scene) { const s = readJson(item.scene); args.push("--box", JSON.stringify({ min: s.min, max: s.max }), "--gt-npz", item.source); }
|
|
280
|
+
const fd = openSync(join(out, "score.log"), "a");
|
|
281
|
+
try {
|
|
282
|
+
const run = spawnSync(process.execPath, args, { cwd: ROOT, stdio: ["ignore", fd, fd], timeout: 20 * 60000 });
|
|
283
|
+
if (run.status !== 0) return { item: label, status: "score-failed", code: run.status ?? run.signal, log: join(out, "score.log") };
|
|
284
|
+
} finally { closeSync(fd); }
|
|
285
|
+
putJson(keyPath, { inputKey: key, inputs: ceilingInputs(item) });
|
|
286
|
+
}
|
|
287
|
+
const s = readJson(scorePath);
|
|
288
|
+
return {
|
|
289
|
+
item: label, variant: item.variant, status: "ok", reused, score: scorePath,
|
|
290
|
+
maskIoURawMean: s.overlap.maskIoURawMean, maskIoUAlignedMean: s.overlap.maskIoUAlignedMean,
|
|
291
|
+
paMpjpeM: s.pose.paMpjpeM, rootErrorRawRmseM: s.trajectory.rootErrorRawM.rmse, ateAlignedRmseM: s.trajectory.ateAlignedM.rmse,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -------------------------------------------------------------- (c) camera
|
|
296
|
+
|
|
297
|
+
const inBand = (x, y, w, h, b) => x < b || y < b || x >= w - b || y >= h - b;
|
|
298
|
+
|
|
299
|
+
function halfRes(img, w, h) {
|
|
300
|
+
const W = w >> 1, H = h >> 1, out = new Float32Array(W * H);
|
|
301
|
+
for (let y = 0; y < H; y += 1) for (let x = 0; x < W; x += 1) out[y * W + x] = (img[2 * y * w + 2 * x] + img[2 * y * w + 2 * x + 1] + img[(2 * y + 1) * w + 2 * x] + img[(2 * y + 1) * w + 2 * x + 1]) / 4;
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Textured tiles of the border band of the reference frame: tile x tile
|
|
307
|
+
* blocks lying entirely within bandFrac x width of an image edge whose
|
|
308
|
+
* structure tensor's smaller eigenvalue (per pixel, grey levels^2) is at least
|
|
309
|
+
* minTexture, so flat sky/floor and single edges (aperture problem) do not vote.
|
|
310
|
+
*/
|
|
311
|
+
export function bandTiles(ref, w, h, { bandFrac = 0.1, tile = 32, margin = 14, minTexture = 2 } = {}) {
|
|
312
|
+
const band = Math.round(bandFrac * w), tiles = [];
|
|
313
|
+
for (let y = margin; y + tile <= h - margin; y += tile) for (let x = margin; x + tile <= w - margin; x += tile) {
|
|
314
|
+
const corners = [[x, y], [x + tile - 1, y], [x, y + tile - 1], [x + tile - 1, y + tile - 1]];
|
|
315
|
+
if (!corners.every(([cx, cy]) => inBand(cx, cy, w, h, band))) continue;
|
|
316
|
+
let sxx = 0, syy = 0, sxy = 0;
|
|
317
|
+
for (let yy = y; yy < y + tile; yy += 1) for (let xx = x; xx < x + tile; xx += 1) {
|
|
318
|
+
const gx = (ref[yy * w + xx + 1] - ref[yy * w + xx - 1]) / 2, gy = (ref[(yy + 1) * w + xx] - ref[(yy - 1) * w + xx]) / 2;
|
|
319
|
+
sxx += gx * gx; syy += gy * gy; sxy += gx * gy;
|
|
320
|
+
}
|
|
321
|
+
const n = tile * tile, a = sxx / n, c = syy / n, b = sxy / n;
|
|
322
|
+
const minEig = (a + c) / 2 - Math.sqrt(((a - c) / 2) ** 2 + b * b);
|
|
323
|
+
if (minEig >= minTexture) tiles.push({ x, y, minEig });
|
|
324
|
+
}
|
|
325
|
+
return tiles;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ssd(ref, cur, w, x, y, size, dx, dy) {
|
|
329
|
+
let s = 0;
|
|
330
|
+
for (let yy = y; yy < y + size; yy += 1) {
|
|
331
|
+
const r = yy * w, c = (yy + dy) * w + dx;
|
|
332
|
+
for (let xx = x; xx < x + size; xx += 1) { const d = ref[r + xx] - cur[c + xx]; s += d * d; }
|
|
333
|
+
}
|
|
334
|
+
return s;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parabola = (m, c0, p) => { const den = m - 2 * c0 + p; return den > 0 ? (0.5 * (m - p)) / den : 0; };
|
|
338
|
+
|
|
339
|
+
function bilinear(img, w, x, y) {
|
|
340
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0, i = y0 * w + x0;
|
|
341
|
+
return img[i] * (1 - fx) * (1 - fy) + img[i + 1] * fx * (1 - fy) + img[i + w] * (1 - fx) * fy + img[i + w + 1] * fx * fy;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Lucas-Kanade translation refinement of one tile from (dx, dy); the parabola alone is biased up to ~0.25 px. */
|
|
345
|
+
function refineLk(ref, cur, w, h, x, y, size, dx, dy, iterations = 6) {
|
|
346
|
+
for (let it = 0; it < iterations; it += 1) {
|
|
347
|
+
if (x + dx < 2 || y + dy < 2 || x + dx + size > w - 3 || y + dy + size > h - 3) break;
|
|
348
|
+
let a = 0, b = 0, c = 0, gx0 = 0, gy0 = 0;
|
|
349
|
+
for (let yy = y; yy < y + size; yy += 1) for (let xx = x; xx < x + size; xx += 1) {
|
|
350
|
+
const u = xx + dx, v = yy + dy;
|
|
351
|
+
const gx = (bilinear(cur, w, u + 1, v) - bilinear(cur, w, u - 1, v)) / 2, gy = (bilinear(cur, w, u, v + 1) - bilinear(cur, w, u, v - 1)) / 2;
|
|
352
|
+
const r = bilinear(cur, w, u, v) - ref[yy * w + xx];
|
|
353
|
+
a += gx * gx; b += gx * gy; c += gy * gy; gx0 += gx * r; gy0 += gy * r;
|
|
354
|
+
}
|
|
355
|
+
const det = a * c - b * b;
|
|
356
|
+
if (!(det > 1e-9)) break;
|
|
357
|
+
const sx = -(c * gx0 - b * gy0) / det, sy = -(a * gy0 - b * gx0) / det;
|
|
358
|
+
if (!(Math.abs(sx) < 1.5 && Math.abs(sy) < 1.5)) break;
|
|
359
|
+
dx += sx; dy += sy;
|
|
360
|
+
if (Math.hypot(sx, sy) < 1e-3) break;
|
|
361
|
+
}
|
|
362
|
+
return [dx, dy];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Translation of every tile between ref and cur (SSD block matching: +-radius
|
|
367
|
+
* px on the half-resolution frames, then +-2 px at full resolution with a
|
|
368
|
+
* parabolic sub-pixel fit). Returns [{ x, y, dx, dy, shift, clipped }] where
|
|
369
|
+
* clipped = the best match sits on the search bound (a shift >= radius).
|
|
370
|
+
*/
|
|
371
|
+
export function tileShifts(ref, cur, w, h, tiles, { tile = 32, radius = 12, refHalf = null, curHalf = null } = {}) {
|
|
372
|
+
const rh = refHalf ?? halfRes(ref, w, h), ch = curHalf ?? halfRes(cur, w, h), hw = w >> 1, hh = h >> 1, R = Math.ceil(radius / 2), ht = tile >> 1;
|
|
373
|
+
return tiles.map(({ x, y }) => {
|
|
374
|
+
const hx = x >> 1, hy = y >> 1;
|
|
375
|
+
let best = Infinity, bx = 0, by = 0;
|
|
376
|
+
for (let dy = -R; dy <= R; dy += 1) for (let dx = -R; dx <= R; dx += 1) {
|
|
377
|
+
if (hx + dx < 0 || hy + dy < 0 || hx + dx + ht > hw || hy + dy + ht > hh) continue;
|
|
378
|
+
const s = ssd(rh, ch, hw, hx, hy, ht, dx, dy);
|
|
379
|
+
if (s < best) { best = s; bx = dx; by = dy; }
|
|
380
|
+
}
|
|
381
|
+
let fbest = Infinity, fx = 2 * bx, fy = 2 * by;
|
|
382
|
+
const cands = [];
|
|
383
|
+
for (let dy = 2 * by - 2; dy <= 2 * by + 2; dy += 1) for (let dx = 2 * bx - 2; dx <= 2 * bx + 2; dx += 1) {
|
|
384
|
+
if (x + dx < 0 || y + dy < 0 || x + dx + tile > w || y + dy + tile > h) continue;
|
|
385
|
+
const s = ssd(ref, cur, w, x, y, tile, dx, dy);
|
|
386
|
+
cands.push([dx, dy, s]);
|
|
387
|
+
if (s < fbest) { fbest = s; fx = dx; fy = dy; }
|
|
388
|
+
}
|
|
389
|
+
const at = (dx, dy) => cands.find((c) => c[0] === dx && c[1] === dy)?.[2];
|
|
390
|
+
const sx = [at(fx - 1, fy), at(fx + 1, fy)], sy = [at(fx, fy - 1), at(fx, fy + 1)];
|
|
391
|
+
const subX = sx.every(Number.isFinite) ? parabola(sx[0], fbest, sx[1]) : 0, subY = sy.every(Number.isFinite) ? parabola(sy[0], fbest, sy[1]) : 0;
|
|
392
|
+
const [dx, dy] = refineLk(ref, cur, w, h, x, y, tile, fx + subX, fy + subY);
|
|
393
|
+
return { x, y, dx, dy, shift: Math.hypot(dx, dy), clipped: Math.abs(bx) === R || Math.abs(by) === R };
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function decodeGrey(video, width, height) {
|
|
398
|
+
const run = spawnSync("ffmpeg", ["-v", "error", "-i", video, "-vf", `scale=${width}:${height}`, "-f", "rawvideo", "-pix_fmt", "gray", "pipe:1"], { maxBuffer: 2 ** 31 });
|
|
399
|
+
if (run.status !== 0) throw new Error(`ffmpeg ${video}: ${run.stderr}`);
|
|
400
|
+
const size = width * height, frames = run.stdout.length / size;
|
|
401
|
+
if (!Number.isInteger(frames) || frames < 2) throw new Error(`${video}: decoded ${run.stdout.length} bytes`);
|
|
402
|
+
return Array.from({ length: frames }, (_, i) => Float32Array.from(run.stdout.subarray(i * size, (i + 1) * size)));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Median background-pixel shift vs frame 0 on the border band, per frame and summarised per clip. */
|
|
406
|
+
export function cameraProbe(label, video, width, height, log = () => {}) {
|
|
407
|
+
const frames = decodeGrey(video, width, height), tiles = bandTiles(frames[0], width, height);
|
|
408
|
+
if (tiles.length < 8) throw new Error(`only ${tiles.length} textured band tiles in frame 0`);
|
|
409
|
+
const refHalf = halfRes(frames[0], width, height), perFrame = [];
|
|
410
|
+
for (let t = 1; t < frames.length; t += 1) {
|
|
411
|
+
const shifts = tileShifts(frames[0], frames[t], width, height, tiles, { refHalf });
|
|
412
|
+
perFrame.push({ medianPx: percentile(shifts.map((s) => s.shift), 50), movedFraction: shifts.filter((s) => s.shift > 1).length / shifts.length, clipped: shifts.filter((s) => s.clipped).length, dx: percentile(shifts.map((s) => s.dx), 50), dy: percentile(shifts.map((s) => s.dy), 50) });
|
|
413
|
+
if (t % 40 === 0) log(`${label}: frame ${t}/${frames.length - 1}`);
|
|
414
|
+
}
|
|
415
|
+
const med = perFrame.map((f) => f.medianPx), last = perFrame.at(-1);
|
|
416
|
+
return {
|
|
417
|
+
clip: label, video, videoSha256: fileSha(video), frames: frames.length, tiles: tiles.length,
|
|
418
|
+
medianShiftPx: round(percentile(med, 50), 3), p95ShiftPx: round(percentile(med, 95), 3), maxShiftPx: round(Math.max(...med), 3),
|
|
419
|
+
framesOver1Px: med.filter((v) => v >= 1).length, maxMovedTileFraction: round(Math.max(...perFrame.map((f) => f.movedFraction)), 3),
|
|
420
|
+
lastFrame: { medianPx: round(last.medianPx, 3), dx: round(last.dx, 3), dy: round(last.dy, 3), movedTileFraction: round(last.movedFraction, 3) },
|
|
421
|
+
perFrameMedianPx: med.map((v) => round(v, 3)),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------- (e) skin
|
|
426
|
+
|
|
427
|
+
const SKIN_SET = { gt: "gt-skin", cube: "cube-skin" };
|
|
428
|
+
|
|
429
|
+
/** approved.json -> approved-skin.json: the truth items as variant skin under distinct set names. Deterministic. */
|
|
430
|
+
export function skinApprovedFrom(approved, approvedPath, approvedSha256) {
|
|
431
|
+
return {
|
|
432
|
+
derivedFrom: { path: approvedPath, sha256: approvedSha256 },
|
|
433
|
+
note: "Grey (skin) truth set: the approved gt/cube items with variant skin under distinct set names so the obs cache and run dirs (keyed by set/name) never reuse the shaded observations. Written by tools/track/study-2d.mjs skinApprovedFrom().",
|
|
434
|
+
items: approved.items.filter((i) => SKIN_SET[i.set]).map((i) => ({ set: SKIN_SET[i.set], name: i.name, variant: "skin", dir: i.dir, source: i.source, scene: i.scene, scoring: i.scoring })),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Provenance of one published skin obs: manifest video sha / detector, and separation from the shaded obs. */
|
|
439
|
+
export function skinProvenance(item, obsRoot, shadedSet) {
|
|
440
|
+
const g5 = join(obsRoot, item.set, item.name, "g5"), manifestPath = join(g5, "manifest.json"), obsPath = join(g5, "obs.npz");
|
|
441
|
+
const label = `${item.set}/${item.name}`;
|
|
442
|
+
if (!existsSync(obsPath) || !existsSync(manifestPath)) return { item: label, status: "missing-obs", g5 };
|
|
443
|
+
const manifest = readJson(manifestPath), video = join(item.dir, "skin", "video.mp4"), shadedVideo = join(item.dir, "shaded", "video.mp4");
|
|
444
|
+
const shadedObs = join(obsRoot, shadedSet, item.name, "g5", "obs.npz");
|
|
445
|
+
const videoSha256 = fileSha(video), obsSha256 = fileSha(obsPath);
|
|
446
|
+
const checks = {
|
|
447
|
+
manifestVideoSha256MatchesSkinVideo: manifest.videoSha256 === videoSha256,
|
|
448
|
+
manifestVideoIsSkin: resolve(manifest.video) === resolve(video),
|
|
449
|
+
detectorYolo: manifest.detector === "yolo",
|
|
450
|
+
keypointsVitpose: manifest.keypoints === "vitpose",
|
|
451
|
+
obsShaMatchesManifest: manifest.obsSha256 === obsSha256,
|
|
452
|
+
skinVideoDiffersFromShaded: videoSha256 !== fileSha(shadedVideo),
|
|
453
|
+
skinObsDiffersFromShadedObs: existsSync(shadedObs) ? obsSha256 !== fileSha(shadedObs) : null,
|
|
454
|
+
};
|
|
455
|
+
return { item: label, status: Object.values(checks).every((v) => v !== false) ? "ok" : "bad-provenance", g5, videoSha256, obsSha256, detector: manifest.detector, checks };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The no-extract handoff run: every item read its mannequin obs from the obs root and G5 scored. */
|
|
459
|
+
export function handoffCheck(item, runDir, obsRoot) {
|
|
460
|
+
const dir = join(runDir, item.set, item.name), log = join(dir, "bench.log"), label = `${item.set}/${item.name}`;
|
|
461
|
+
if (!existsSync(log)) return { item: label, status: "missing-log" };
|
|
462
|
+
const text = readFileSync(log, "utf8");
|
|
463
|
+
const manifest = existsSync(join(dir, "obs-mannequin", "manifest.json")) ? readJson(join(dir, "obs-mannequin", "manifest.json")) : null;
|
|
464
|
+
const result = existsSync(join(dir, "G5", "result.json")) ? readJson(join(dir, "G5", "result.json")) : null;
|
|
465
|
+
const expected = join(obsRoot, item.set, item.name, "g5", "obs.npz");
|
|
466
|
+
const checks = {
|
|
467
|
+
fromObsRootLine: text.includes("obs-mannequin: from obs root"),
|
|
468
|
+
noExtraction: !text.includes("extracting on"),
|
|
469
|
+
manifestSourceIsPublished: resolve(manifest?.source ?? "") === resolve(expected),
|
|
470
|
+
g5Ok: result?.ok === true,
|
|
471
|
+
g5ScoreFileParses: result?.ok ? existsSync(join(dir, "G5", "score", "score.json")) && Number.isFinite(readJson(join(dir, "G5", "score", "score.json")).overlap?.maskIoURawMean) : false,
|
|
472
|
+
};
|
|
473
|
+
return { item: label, status: Object.values(checks).every(Boolean) ? "ok" : "failed", checks, g5: result?.score ? { paMpjpeM: result.score.paMpjpeM, iouRaw: result.score.iouRaw, rootErrorRawRmseM: result.score.rootErrorRawRmseM } : null };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------- summary
|
|
477
|
+
|
|
478
|
+
function markdown(summary) {
|
|
479
|
+
const L = [], f = (v, d = 2) => (v === null || v === undefined ? "-" : Number(v).toFixed(d));
|
|
480
|
+
L.push("# Gate 0: 2D evidence, headroom ceiling, camera fixedness (#500 todo 4)", "", `Generated ${summary.createdAt} by tools/track/study-2d.mjs (commit ${summary.commit}). Numbers below are copied from summary.json.`, "");
|
|
481
|
+
const a = summary.stance;
|
|
482
|
+
L.push("## (a) Stance slide on truth-stance frames (truthStanceSlideCmPerS)", "");
|
|
483
|
+
if (a) {
|
|
484
|
+
L.push(`Rule: ${a.rule}`, "", "| item | truth cm/s | Gbest cm/s | stance samples | Gbest pelvis steps >20 deg | Gbest max step deg |", "| --- | --- | --- | --- | --- | --- |");
|
|
485
|
+
for (const r of a.items) L.push(r.status === "ok" ? `| ${r.item} | ${f(r.truth.cmPerS)} | ${f(r.gbest.cmPerS)} | ${r.gbest.stanceSamples} | ${r.gbest.pelvisSteps.over20} | ${f(r.gbest.pelvisSteps.maxDeg, 1)} |` : `| ${r.item} | ${r.status} | | | | |`);
|
|
486
|
+
L.push(`| **mean** | **${f(a.mean.truthCmPerS)}** | **${f(a.mean.gbestCmPerS)}** | | | |`, "");
|
|
487
|
+
}
|
|
488
|
+
const b = summary.iouCeiling;
|
|
489
|
+
L.push("## (b) Truth-as-prediction IoU ceiling (overlap.maskIoURawMean)", "");
|
|
490
|
+
if (b) {
|
|
491
|
+
L.push("| set | items | mean IoU raw | min | IS-1 truth threshold (0.9 x) |", "| --- | --- | --- | --- | --- |");
|
|
492
|
+
for (const [set, v] of Object.entries(b.perSet)) L.push(`| ${set} | ${v.items} | ${f(v.meanIoURaw, 4)} | ${f(v.minIoURaw, 4)} | ${f(v.is1Threshold, 4)} |`);
|
|
493
|
+
L.push("", "| item | IoU raw | IoU aligned | PA-MPJPE m | root raw m |", "| --- | --- | --- | --- | --- |");
|
|
494
|
+
for (const r of b.items) L.push(r.status === "ok" ? `| ${r.item} | ${f(r.maskIoURawMean, 4)} | ${f(r.maskIoUAlignedMean, 4)} | ${f(r.paMpjpeM, 4)} | ${f(r.rootErrorRawRmseM, 4)} |` : `| ${r.item} | ${r.status} | | | |`);
|
|
495
|
+
L.push("", b.skinNote, "");
|
|
496
|
+
}
|
|
497
|
+
const c = summary.camera;
|
|
498
|
+
L.push("## (c) Camera fixedness (fal clips)", "");
|
|
499
|
+
if (c) {
|
|
500
|
+
L.push(`Method: ${c.method}`, "", `**cameraFixed: ${c.cameraFixed}** (rule: median shift < ${c.thresholdPx} px on every fal clip; worst clip ${c.worst?.clip ?? "-"} ${f(c.worst?.medianShiftPx, 3)} px)`, "");
|
|
501
|
+
L.push("| clip | median shift px | p95 px | max px | frames >= 1 px | textured tiles | last-frame dx, dy |", "| --- | --- | --- | --- | --- | --- | --- |");
|
|
502
|
+
for (const r of [...c.clips, ...c.controls.map((x) => ({ ...x, clip: `control ${x.clip}` }))]) L.push(r.status !== "ok" ? `| ${r.clip ?? r.item} | ${r.status}: ${r.error} | | | | |` : `| ${r.clip} | ${f(r.medianShiftPx, 3)} | ${f(r.p95ShiftPx, 3)} | ${f(r.maxShiftPx, 3)} | ${r.framesOver1Px} | ${r.tiles} | ${f(r.lastFrame.dx)}, ${f(r.lastFrame.dy)} |`);
|
|
503
|
+
L.push("");
|
|
504
|
+
}
|
|
505
|
+
const d = summary.study2d;
|
|
506
|
+
L.push("## (d) 2D evidence: truth projected vs obs kp2d", "");
|
|
507
|
+
if (d) {
|
|
508
|
+
L.push(`deltaPx (median visible error under the truth-chosen L/R assignment, shaded set): **${f(summary.deltaPx)} px**. ${d.deltaNote}`, "");
|
|
509
|
+
for (const [variant, v] of Object.entries(d.perVariant)) L.push(`- ${variant}: deltaPx ${f(v.deltaPx)} px, identity-assignment visible median ${f(v.visibleIdentityMedianPx)} px, items ok ${v.itemsOk}/${v.items}`);
|
|
510
|
+
L.push("", "| item | variant | status | frames | arm swaps | leg swaps | swap runs (arms / legs) | visible median / p95 px (identity) | occluded median / p95 px (n) |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |");
|
|
511
|
+
for (const r of d.items) {
|
|
512
|
+
if (r.status !== "ok") { L.push(`| ${r.item} | ${r.variant} | ${r.status} | | | | | | |`); continue; }
|
|
513
|
+
L.push(`| ${r.item} | ${r.variant} | ok | ${r.frames} | ${r.swaps.counts.arms} | ${r.swaps.counts.legs} | ${r.swaps.runs.arms.join(",") || "-"} / ${r.swaps.runs.legs.join(",") || "-"} | ${f(r.visible.medianPx)} / ${f(r.visible.p95Px)} | ${f(r.occluded.medianPx)} / ${f(r.occluded.p95Px)} (${r.occluded.n}) |`);
|
|
514
|
+
}
|
|
515
|
+
L.push("", "Per-joint median px (visible | occluded), shaded set pooled:", "", "| joint | visible median | visible p95 | occluded median | occluded p95 | occluded n |", "| --- | --- | --- | --- | --- | --- |");
|
|
516
|
+
for (const [joint, v] of Object.entries(d.pooledPerJoint.shaded ?? {})) L.push(`| ${joint} | ${f(v.visible.medianPx)} | ${f(v.visible.p95Px)} | ${f(v.occluded.medianPx)} | ${f(v.occluded.p95Px)} | ${v.occluded.n} |`);
|
|
517
|
+
if (d.walkFlipWindow) L.push("", `gt/walk known flip window f18-51: swap frames inside it ${d.walkFlipWindow.inside}, outside ${d.walkFlipWindow.outside} (overlap: ${d.walkFlipWindow.overlaps}).`);
|
|
518
|
+
L.push("");
|
|
519
|
+
}
|
|
520
|
+
const e = summary.skin;
|
|
521
|
+
L.push("## (e) Grey (skin) truth set", "");
|
|
522
|
+
if (e) {
|
|
523
|
+
L.push(`approved-skin.json: ${e.approvedSkin.items} items, sets ${e.approvedSkin.sets.join(", ")}; selectItems ok: ${e.approvedSkin.selectItemsOk}; equals skinApprovedFrom(approved.json): ${e.approvedSkin.matchesGenerator}.`, "");
|
|
524
|
+
L.push("| item | provenance | video sha256 (skin) | detector | skin obs != shaded obs | handoff |", "| --- | --- | --- | --- | --- | --- |");
|
|
525
|
+
for (const r of e.provenance) {
|
|
526
|
+
const h = e.handoff?.find((x) => x.item === r.item);
|
|
527
|
+
L.push(`| ${r.item} | ${r.status} | ${r.videoSha256?.slice(0, 12) ?? "-"} | ${r.detector ?? "-"} | ${r.checks?.skinObsDiffersFromShadedObs ?? "-"} | ${h ? h.status : "not run"} |`);
|
|
528
|
+
}
|
|
529
|
+
L.push("");
|
|
530
|
+
}
|
|
531
|
+
return `${L.join("\n")}\n`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// -------------------------------------------------------------------- main
|
|
535
|
+
|
|
536
|
+
export const USAGE = `usage: node tools/track/study-2d.mjs [options]
|
|
537
|
+
|
|
538
|
+
--stages 2d,stance,ceiling,camera,skin stages to recompute (default all); other stages
|
|
539
|
+
are reused only while their inputs are unchanged, else recomputed
|
|
540
|
+
--approved <json> shaded truth + fal items (default evidence/obs/approved.json)
|
|
541
|
+
--approved-skin <json> grey truth items (default evidence/obs/approved-skin.json)
|
|
542
|
+
--write-approved-skin write --approved-skin from --approved (refuses to replace a different file)
|
|
543
|
+
--obs-root <dir> obs cache <dir>/<set>/<name>/g5/obs.npz (default evidence/obs/cache)
|
|
544
|
+
--baseline <run> Gbest baseline run (default evidence/obs/run-492f)
|
|
545
|
+
--handoff <run> a no-extract obs-bench run of approved-skin.json to verify
|
|
546
|
+
--items a,b limit to these set/name items
|
|
547
|
+
--out <dir> default evidence/obs/study-2d
|
|
548
|
+
--port 5504 --cdp-port 9504 scorer ports for the ceiling stage
|
|
549
|
+
--force re-score the ceiling even when score.json matches its inputs
|
|
550
|
+
|
|
551
|
+
Exit code 1 when any item is not ok (missing obs, failed scoring, bad provenance, failed handoff).`;
|
|
552
|
+
|
|
553
|
+
export function parseArgs(argv) {
|
|
554
|
+
const o = { stages: STAGES, approved: "evidence/obs/approved.json", approvedSkin: "evidence/obs/approved-skin.json", obsRoot: "evidence/obs/cache", baseline: "evidence/obs/run-492f", out: "evidence/obs/study-2d", port: 5504, cdpPort: 9504, force: false, writeApprovedSkin: false };
|
|
555
|
+
const names = { "--stages": "stages", "--approved": "approved", "--approved-skin": "approvedSkin", "--obs-root": "obsRoot", "--baseline": "baseline", "--handoff": "handoff", "--items": "items", "--out": "out", "--port": "port", "--cdp-port": "cdpPort" };
|
|
556
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
557
|
+
const flag = argv[i];
|
|
558
|
+
if (flag === "--help" || flag === "-h") { o.help = true; continue; }
|
|
559
|
+
if (flag === "--force") { o.force = true; continue; }
|
|
560
|
+
if (flag === "--write-approved-skin") { o.writeApprovedSkin = true; continue; }
|
|
561
|
+
if (!names[flag]) throw new Error(`unknown option ${flag}`);
|
|
562
|
+
const value = argv[++i];
|
|
563
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${flag} needs a value`);
|
|
564
|
+
o[names[flag]] = value;
|
|
565
|
+
}
|
|
566
|
+
if (typeof o.stages === "string") o.stages = o.stages.split(",").map((s) => s.trim()).filter(Boolean);
|
|
567
|
+
const bad = o.stages.filter((s) => !STAGES.includes(s));
|
|
568
|
+
if (bad.length) throw new Error(`unknown stages ${bad.join(",")}; known ${STAGES.join(",")}`);
|
|
569
|
+
if (typeof o.items === "string") o.items = o.items.split(",").map((s) => s.trim()).filter(Boolean);
|
|
570
|
+
for (const key of ["port", "cdpPort"]) { o[key] = Number(o[key]); if (!Number.isInteger(o[key])) throw new Error(`--${key} must be an integer`); }
|
|
571
|
+
for (const key of ["approved", "approvedSkin", "obsRoot", "baseline", "out", "handoff"]) if (o[key]) o[key] = resolve(o[key]);
|
|
572
|
+
return o;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function commitId() {
|
|
576
|
+
const head = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: ROOT, encoding: "utf8" }).stdout.trim();
|
|
577
|
+
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: ROOT, encoding: "utf8" }).stdout.trim();
|
|
578
|
+
return `${head}${dirty ? "-dirty" : ""}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
582
|
+
const o = parseArgs(argv);
|
|
583
|
+
if (o.help) { console.log(USAGE); return; }
|
|
584
|
+
const approvedBytes = readFileSync(o.approved), approved = JSON.parse(approvedBytes);
|
|
585
|
+
if (!Array.isArray(approved?.items)) throw new Error(`${o.approved}: no items array`);
|
|
586
|
+
const generated = skinApprovedFrom(approved, o.approved, sha256(approvedBytes));
|
|
587
|
+
if (o.writeApprovedSkin) {
|
|
588
|
+
const text = `${JSON.stringify(generated, null, "\t")}\n`;
|
|
589
|
+
if (existsSync(o.approvedSkin) && readFileSync(o.approvedSkin, "utf8") !== text) throw new Error(`${o.approvedSkin} exists and differs; not replacing it`);
|
|
590
|
+
writeFileSync(o.approvedSkin, text);
|
|
591
|
+
console.log(`study-2d: wrote ${o.approvedSkin} (${generated.items.length} items)`);
|
|
592
|
+
}
|
|
593
|
+
const pick = (items) => (o.items ? items.filter((i) => o.items.includes(`${i.set}/${i.name}`)) : items);
|
|
594
|
+
const truthItems = pick(approved.items.filter((i) => i.set === "gt" || i.set === "cube"));
|
|
595
|
+
const falItems = pick(approved.items.filter((i) => i.set === "fal"));
|
|
596
|
+
const skinApproved = existsSync(o.approvedSkin) ? readJson(o.approvedSkin) : null;
|
|
597
|
+
const skinItems = skinApproved ? pick(skinApproved.items) : [];
|
|
598
|
+
const stageDir = join(o.out, "stages"), log = (line) => console.log(`[study-2d] ${line}`);
|
|
599
|
+
/** Run, reuse or skip one stage by its input key (see the header). */
|
|
600
|
+
const stage = async (name, inputs, fn) => {
|
|
601
|
+
const path = join(stageDir, `${name}.json`), key = inputKey(inputs);
|
|
602
|
+
const cached = existsSync(path) ? readJson(path) : null;
|
|
603
|
+
if (!o.stages.includes(name)) {
|
|
604
|
+
if (!cached) return null;
|
|
605
|
+
if (cached.inputKey === key) { log(`stage ${name}: reused (inputs unchanged since ${cached.createdAt})`); return cached; }
|
|
606
|
+
log(`stage ${name}: inputs changed since ${cached.createdAt}; recomputing`);
|
|
607
|
+
} else log(`stage ${name}`);
|
|
608
|
+
const value = { createdAt: new Date().toISOString(), commit: commitId(), inputKey: key, ...(await fn()) };
|
|
609
|
+
putJson(path, value);
|
|
610
|
+
return value;
|
|
611
|
+
};
|
|
612
|
+
const obsPathOf = (i) => join(o.obsRoot, i.set, i.name, "g5", "obs.npz");
|
|
613
|
+
const truthFiles = (i) => ({ item: i, source: shaOrMissing(i.source), scene: shaOrMissing(i.scene), ...Object.fromEntries(["camera.json", "joints.json", "meta.json"].map((f) => [f, shaOrMissing(join(i.dir, i.variant, f))])) });
|
|
614
|
+
const guard = (label, fn) => { try { return fn(); } catch (error) { log(`${label}: FAILED ${error.message}`); return { item: label, status: "failed", error: error.message }; } };
|
|
615
|
+
|
|
616
|
+
const study2d = await stage("2d", { code: codeSha(), obsRoot: o.obsRoot, items: [...truthItems, ...skinItems].map((i) => ({ ...truthFiles(i), obs: shaOrMissing(obsPathOf(i)) })) }, () => {
|
|
617
|
+
const items = [...truthItems, ...skinItems].map((item) => guard(`${item.set}/${item.name}`, () => { const r = studyItem(item, o.obsRoot); log(`2d ${item.set}/${item.name} (${item.variant}): ${r.status}${r.status === "ok" ? ` visible median ${r.visible.medianPx} px, swaps arms ${r.swaps.counts.arms} legs ${r.swaps.counts.legs}` : ` ${r.obsPath}`}`); return r; }));
|
|
618
|
+
const perVariant = {}, pooledPerJoint = {};
|
|
619
|
+
for (const variant of ["shaded", "skin"]) {
|
|
620
|
+
const ok = items.filter((r) => r.variant === variant && r.status === "ok");
|
|
621
|
+
const all = items.filter((r) => r.variant === variant);
|
|
622
|
+
perVariant[variant] = { items: all.length, itemsOk: ok.length, deltaPx: round(percentile(ok.flatMap((r) => r.raw.visibleBest), 50), 3), visibleIdentityMedianPx: round(percentile(ok.flatMap((r) => r.raw.visibleIdentity), 50), 3) };
|
|
623
|
+
pooledPerJoint[variant] = Object.fromEntries(COCO_NAMES.filter((_, k) => BODY_KEYPOINTS.includes(k)).map((name) => {
|
|
624
|
+
// Pooled as the median over items of each item's median / p95 (items weigh equally).
|
|
625
|
+
const vis = ok.map((r) => r.perJoint[name].visible.medianPx).filter(Number.isFinite), occ = ok.map((r) => r.perJoint[name].occluded.medianPx).filter(Number.isFinite);
|
|
626
|
+
const visP = ok.map((r) => r.perJoint[name].visible.p95Px).filter(Number.isFinite), occP = ok.map((r) => r.perJoint[name].occluded.p95Px).filter(Number.isFinite);
|
|
627
|
+
return [name, { visible: { medianPx: round(percentile(vis, 50), 2), p95Px: round(percentile(visP, 50), 2), items: vis.length }, occluded: { medianPx: round(percentile(occ, 50), 2), p95Px: round(percentile(occP, 50), 2), n: ok.reduce((s, r) => s + r.perJoint[name].occluded.n, 0) } }];
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
const walk = items.find((r) => r.item === "gt/walk" && r.status === "ok");
|
|
631
|
+
const walkFrames = walk ? [...new Set([...walk.swaps.frames.arms, ...walk.swaps.frames.legs])] : [];
|
|
632
|
+
for (const r of items) delete r.raw;
|
|
633
|
+
return {
|
|
634
|
+
items, perVariant, pooledPerJoint,
|
|
635
|
+
deltaNote: "Error of each visible body keypoint (COCO shoulders..ankles vs the rendered rig's Arm/ForeArm/Hand/UpLeg/Leg/Foot) under the per-frame, per-group L/R assignment the truth prefers; occluded = the truth joint lies behind a scene box along the camera ray. Face keypoints have no rig joint and are not scored. Per-joint pooled values are the median over items of each item's median / p95.",
|
|
636
|
+
walkFlipWindow: walk ? { window: [18, 51], inside: walkFrames.filter((t) => t >= 18 && t <= 51).length, outside: walkFrames.filter((t) => t < 18 || t > 51).length, overlaps: walkFrames.some((t) => t >= 18 && t <= 51) } : null,
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const stance = await stage("stance", { code: codeSha(), baseline: o.baseline, items: truthItems.map((i) => ({ item: i, source: shaOrMissing(i.source), gbest: shaOrMissing(join(o.baseline, i.set, i.name, "Gbest", "motion.npz")) })) }, () => {
|
|
641
|
+
const items = truthItems.map((item) => guard(`${item.set}/${item.name}`, () => { const r = stanceItem(item, o.baseline); log(`stance ${r.item}: ${r.status} truth ${r.truth?.cmPerS} Gbest ${r.gbest?.cmPerS} cm/s`); return r; }));
|
|
642
|
+
const ok = items.filter((r) => r.status === "ok");
|
|
643
|
+
return { baseline: o.baseline, rule: ok[0]?.rule ?? null, items, mean: { truthCmPerS: round(mean(ok.map((r) => r.truth.cmPerS))), gbestCmPerS: round(mean(ok.map((r) => r.gbest.cmPerS))), items: ok.length } };
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const ceiling = await stage("ceiling", { code: codeSha(), out: o.out, items: truthItems.map((i) => ({ ...ceilingInputs(i), skin: ["camera.json", "joints.json"].map((f) => shaOrMissing(join(i.dir, "skin", f))) })) }, () => {
|
|
647
|
+
const items = truthItems.map((item) => guard(`${item.set}/${item.name}`, () => { log(`ceiling ${item.set}/${item.name}: scoring truth as prediction`); const r = ceilingItem(item, o.out, o); log(`ceiling ${r.item}: ${r.status} IoU raw ${r.maskIoURawMean}`); return r; }));
|
|
648
|
+
// The skin renders use a byte-identical camera.json/joints.json and the shared mask/, so the score of the truth is the same: verified, not assumed.
|
|
649
|
+
const skinSame = truthItems.map((item) => ({ item: `${item.set}/${item.name}`, same: ["camera.json", "joints.json"].every((f) => fileSha(join(item.dir, "shaded", f)) === fileSha(join(item.dir, "skin", f))) }));
|
|
650
|
+
const perSet = {};
|
|
651
|
+
for (const set of ["gt", "cube"]) {
|
|
652
|
+
const ok = items.filter((r) => r.status === "ok" && r.item.startsWith(`${set}/`));
|
|
653
|
+
const ious = ok.map((r) => r.maskIoURawMean);
|
|
654
|
+
perSet[set] = { items: ok.length, meanIoURaw: round(mean(ious), 4), minIoURaw: round(Math.min(...ious), 4), is1Threshold: round(0.9 * mean(ious), 4) };
|
|
655
|
+
if (skinSame.filter((s) => s.item.startsWith(`${set}/`)).every((s) => s.same)) perSet[`${SKIN_SET[set]}`] = { ...perSet[set], derivedFrom: set };
|
|
656
|
+
}
|
|
657
|
+
const exp3 = items.filter((r) => r.item.startsWith("cube/")).map((r) => {
|
|
658
|
+
const path = join(dirname(dirname(truthItems.find((i) => `${i.set}/${i.name}` === r.item).dir)), "gt-path", "gt-self", "shaded", r.item.split("/")[1], "score.json");
|
|
659
|
+
return existsSync(path) ? { item: r.item, exp3GtSelf: path, maskIoURawMean: readJson(path).overlap.maskIoURawMean } : { item: r.item, exp3GtSelf: null };
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
items, perSet, skinSame, exp3CrossCheck: exp3,
|
|
663
|
+
skinNote: `Skin ceiling: camera.json and joints.json are byte-identical between shaded and skin for ${skinSame.filter((s) => s.same).length}/${skinSame.length} items and both score against the shared mask/, so the truth-as-prediction score of the skin variant equals the shaded one (gt-skin/cube-skin rows are derived, not re-rendered).`,
|
|
664
|
+
};
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const cameraVideos = [...falItems.map((i) => [i.video, join(i.dir, i.variant, "camera.json")]), ...truthItems.map((i) => [join(i.dir, i.variant, "video.mp4"), join(i.dir, i.variant, "camera.json")])];
|
|
668
|
+
const camera = await stage("camera", { code: codeSha(), fal: falItems.map((i) => i.name), controls: truthItems.map((i) => `${i.set}/${i.name}`), files: cameraVideos.map(([v, c]) => [v, shaOrMissing(v), shaOrMissing(c)]) }, () => {
|
|
669
|
+
const probe = (label, video, width, height) => guard(label, () => { const t0 = Date.now(); const r = cameraProbe(label, video, width, height, log); log(`camera ${label}: median ${r.medianShiftPx} px, max ${r.maxShiftPx} px (${((Date.now() - t0) / 1000).toFixed(1)} s)`); return { status: "ok", ...r }; });
|
|
670
|
+
const clips = falItems.map((item) => { const cam = readJson(join(item.dir, item.variant, "camera.json")); return probe(item.name, item.video, cam.width, cam.height); });
|
|
671
|
+
const controls = truthItems.filter((i) => ["gt/walk", "cube/bump"].includes(`${i.set}/${i.name}`)).map((item) => { const cam = readJson(join(item.dir, item.variant, "camera.json")); return probe(`${item.set}/${item.name} (${item.variant} truth render, static camera)`, join(item.dir, item.variant, "video.mp4"), cam.width, cam.height); });
|
|
672
|
+
const ok = clips.filter((c) => c.status === "ok"), thresholdPx = 1;
|
|
673
|
+
const worst = ok.reduce((w, c) => (!w || c.medianShiftPx > w.medianShiftPx ? c : w), null);
|
|
674
|
+
return {
|
|
675
|
+
method: "frame 0 border band (pixels within 10 % of the width of any image edge; the person is mostly inside) split into 32 px tiles, keeping tiles whose structure-tensor min eigenvalue >= 2 grey^2 (no flat sky/floor, no single edges); per frame t each tile's translation vs frame 0 by SSD block matching (+-12 px, half-res coarse then full-res +-2 px with parabolic sub-pixel fit) refined by Lucas-Kanade; the per-frame shift is the median tile shift magnitude (the moving person is a minority of the tiles). Per clip: median / p95 / max over frames 1..T-1. Controls are truth renders with a known static camera.",
|
|
676
|
+
thresholdPx, cameraFixed: ok.length === falItems.length && ok.length > 0 && ok.every((c) => c.medianShiftPx < thresholdPx), clipsOk: ok.length, clipsTotal: falItems.length,
|
|
677
|
+
worst: worst ? { clip: worst.clip, medianShiftPx: worst.medianShiftPx } : null, clips, controls,
|
|
678
|
+
};
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const shadedSetOf = { "gt-skin": "gt", "cube-skin": "cube" };
|
|
682
|
+
const skinInputs = {
|
|
683
|
+
code: codeSha(["tools/bench/obs-bench.mjs"]), approvedSkin: shaOrMissing(o.approvedSkin), approved: sha256(approvedBytes), obsRoot: o.obsRoot,
|
|
684
|
+
items: skinItems.map((i) => ({ item: i, ...Object.fromEntries(["obs.npz", "manifest.json"].map((f) => [f, shaOrMissing(join(o.obsRoot, i.set, i.name, "g5", f))])), skinVideo: shaOrMissing(join(i.dir, "skin", "video.mp4")), shadedVideo: shaOrMissing(join(i.dir, "shaded", "video.mp4")), shadedObs: shaOrMissing(join(o.obsRoot, shadedSetOf[i.set] ?? "-", i.name, "g5", "obs.npz")) })),
|
|
685
|
+
handoff: o.handoff ? { dir: o.handoff, files: skinItems.map((i) => ["bench.log", "obs-mannequin/manifest.json", "G5/result.json", "G5/score/score.json"].map((f) => shaOrMissing(join(o.handoff, i.set, i.name, f)))) } : null,
|
|
686
|
+
};
|
|
687
|
+
// (e) imports obs-bench (three/FBXLoader) only when the stage runs.
|
|
688
|
+
const skinSummary = await stage("skin", skinInputs, async () => {
|
|
689
|
+
if (!skinApproved) throw new Error(`${o.approvedSkin} does not exist (use --write-approved-skin)`);
|
|
690
|
+
const { selectItems, itemInputs } = await import("../bench/obs-bench.mjs");
|
|
691
|
+
const selected = selectItems(skinApproved);
|
|
692
|
+
const result = {
|
|
693
|
+
approvedSkin: {
|
|
694
|
+
path: o.approvedSkin, items: selected.length, sets: [...new Set(selected.map((i) => i.set))],
|
|
695
|
+
selectItemsOk: selected.every((i) => itemInputs(i, "/unused").detector === "yolo" && itemInputs(i, "/unused").video === join(i.dir, "skin", "video.mp4")),
|
|
696
|
+
matchesGenerator: JSON.stringify(skinApproved.items) === JSON.stringify(generated.items),
|
|
697
|
+
},
|
|
698
|
+
provenance: skinItems.map((item) => guard(`${item.set}/${item.name}`, () => skinProvenance(item, o.obsRoot, shadedSetOf[item.set]))),
|
|
699
|
+
handoff: o.handoff ? skinItems.map((item) => guard(`${item.set}/${item.name}`, () => handoffCheck(item, o.handoff, o.obsRoot))) : null,
|
|
700
|
+
handoffRun: o.handoff ?? null,
|
|
701
|
+
};
|
|
702
|
+
for (const r of result.provenance) log(`skin provenance ${r.item}: ${r.status}`);
|
|
703
|
+
for (const r of result.handoff ?? []) log(`skin handoff ${r.item}: ${r.status}`);
|
|
704
|
+
return result;
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const summary = {
|
|
708
|
+
tool: "tools/track/study-2d.mjs", createdAt: new Date().toISOString(), commit: commitId(),
|
|
709
|
+
inputs: { approved: o.approved, approvedSkin: o.approvedSkin, obsRoot: o.obsRoot, baseline: o.baseline },
|
|
710
|
+
deltaPx: study2d?.perVariant?.shaded?.deltaPx ?? null,
|
|
711
|
+
cameraFixed: camera?.cameraFixed ?? null,
|
|
712
|
+
study2d, stance, iouCeiling: ceiling, camera, skin: skinSummary,
|
|
713
|
+
};
|
|
714
|
+
putJson(join(o.out, "summary.json"), summary);
|
|
715
|
+
writeFileSync(join(o.out, "summary.md"), markdown(summary));
|
|
716
|
+
log(`wrote ${join(o.out, "summary.json")} and summary.md`);
|
|
717
|
+
const failed = [
|
|
718
|
+
...[["2d", study2d?.items], ["stance", stance?.items], ["ceiling", ceiling?.items], ["camera", camera?.clips], ["camera", camera?.controls], ["skin-provenance", skinSummary?.provenance], ["skin-handoff", skinSummary?.handoff]]
|
|
719
|
+
.flatMap(([name, rows]) => (rows ?? []).filter((r) => r.status !== "ok").map((r) => ({ stage: name, item: r.item ?? r.clip, status: r.status }))),
|
|
720
|
+
...(skinSummary && !(skinSummary.approvedSkin?.selectItemsOk && skinSummary.approvedSkin?.matchesGenerator) ? [{ stage: "skin", item: o.approvedSkin, status: "bad-approved-skin" }] : []),
|
|
721
|
+
];
|
|
722
|
+
if (failed.length) console.error(`[study-2d] ${failed.length} item(s) not ok: ${failed.map((r) => `${r.stage}:${r.item}=${r.status}`).join(", ")}`);
|
|
723
|
+
return { summary, failed };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
727
|
+
main().then((result) => { if (result?.failed.length) process.exitCode = 1; }, (error) => { console.error(`study-2d: ${error.stack ?? error}`); process.exitCode = 1; });
|
|
728
|
+
}
|