cozyclay 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -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-PROTOCOL.md +1 -1
- 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 +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- 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/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- 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-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- 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/otio.js +2 -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/scene-objects.js +37 -1
- 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 +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- 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 +460 -1742
- 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/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- 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-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.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,405 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SAM2 video masks of the one person in a bench clip (tracker silhouette evidence).
|
|
3
|
+
|
|
4
|
+
Inputs are what a real user has: the video and GVHMR's obs NPZ (``bbx_xys`` =
|
|
5
|
+
centre x, centre y, square size in image pixels; ``kp2d`` ViTPose confidences).
|
|
6
|
+
No plate, truth mask or colour cue is read here.
|
|
7
|
+
|
|
8
|
+
Prompting:
|
|
9
|
+
* anchor = first frame whose obs box is valid and trusted (mean ViTPose
|
|
10
|
+
confidence >= TRUST_CONF; frame 0 on every clean clip). If no frame is
|
|
11
|
+
trusted the most confident valid frame is used; no valid box at all raises
|
|
12
|
+
``no-person-prompt``. The anchor gets the obs box plus positive points at the
|
|
13
|
+
confident ViTPose shoulder/hip midpoints, nose, wrists and (at >= ANKLE_TRUST)
|
|
14
|
+
ankles inside the box (``--prompt joints``); frames before it are tracked
|
|
15
|
+
backwards from it. Measured on the 11 shaded truth clips (median of per-item
|
|
16
|
+
median IoU): box + centre point 0.000 (the centre of GVHMR's loose square box
|
|
17
|
+
lands on the floor between the legs), box only 0.000, torso midpoints 0.889,
|
|
18
|
+
joints 0.930, all 17 joints 0.899; without ankles (``upper``) the grey skin
|
|
19
|
+
set falls to 0.886. The part-coloured mannequin needs points on the head and
|
|
20
|
+
limbs or SAM2 returns a sub-part.
|
|
21
|
+
* re-prompt: when the mask area changes by more than REPROMPT_CHANGE relative to
|
|
22
|
+
the previous frame in tracking order, that frame is re-prompted from its own
|
|
23
|
+
obs box (if trusted), at most once per frame, and tracking resumes from it.
|
|
24
|
+
* chunked propagation: at most ``--chunk-frames`` frames are loaded per SAM2
|
|
25
|
+
state (frames offloaded to CPU); the next chunk is seeded with the previous
|
|
26
|
+
chunk's boundary mask. This bounds GPU memory for 15 s (362 frame) clips.
|
|
27
|
+
|
|
28
|
+
Output ``<out>/masks.npz``: ``prob`` float16 T x H/2 x W/2 (sigmoid of the SAM2
|
|
29
|
+
logits, area-downsampled), ``reprompted`` int32 frame list, ``area`` int32 full
|
|
30
|
+
resolution foreground pixels per frame, ``anchor`` int32, ``chunks`` int32
|
|
31
|
+
(direction, start, end) rows with direction 1 forward / -1 backward. A summary
|
|
32
|
+
is written to ``<out>/masks.json``.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import contextlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
REPROMPT_CHANGE = 0.4
|
|
47
|
+
TRUST_CONF = 0.5
|
|
48
|
+
DEFAULT_CHUNK_FRAMES = 180
|
|
49
|
+
DEFAULT_MAX_RESERVED_MIB = 5632
|
|
50
|
+
CONFIG = "configs/sam2.1/sam2.1_hiera_s.yaml"
|
|
51
|
+
CHECKPOINT = os.path.expanduser("~/cclay-ingest/cozyfit/checkpoints/sam2.1_hiera_small.pt")
|
|
52
|
+
OBJ_ID = 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NoPersonPrompt(RuntimeError):
|
|
56
|
+
"""The obs carries no usable person box to prompt SAM2 with."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, detail: str):
|
|
59
|
+
super().__init__(f"no-person-prompt: {detail}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def box_from_xys(xys, width: int, height: int):
|
|
63
|
+
"""obs ``bbx_xys`` row (centre x, centre y, size) -> clipped xyxy box, or None."""
|
|
64
|
+
xys = np.asarray(xys, dtype=np.float64)
|
|
65
|
+
if xys.shape != (3,) or not np.all(np.isfinite(xys)) or xys[2] <= 1.0:
|
|
66
|
+
return None
|
|
67
|
+
cx, cy, size = xys
|
|
68
|
+
half = size / 2.0
|
|
69
|
+
x0, x1 = np.clip([cx - half, cx + half], 0.0, width - 1.0)
|
|
70
|
+
y0, y1 = np.clip([cy - half, cy + half], 0.0, height - 1.0)
|
|
71
|
+
if x1 - x0 < 2.0 or y1 - y0 < 2.0:
|
|
72
|
+
return None
|
|
73
|
+
return np.array([x0, y0, x1, y1], dtype=np.float32)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def box_centre(box) -> np.ndarray:
|
|
77
|
+
return np.array([[(box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0]], dtype=np.float32)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
PROMPT_MODES = ("centre", "box", "torso", "upper", "joints", "all")
|
|
81
|
+
DEFAULT_PROMPT = "joints"
|
|
82
|
+
TORSO_PAIRS = ((5, 6), (11, 12)) # COCO shoulders, hips
|
|
83
|
+
EXTREMITIES = (0, 9, 10, 15, 16) # COCO nose, wrists, ankles
|
|
84
|
+
UPPER = (0, 9, 10) # COCO nose, wrists
|
|
85
|
+
ANKLES = (15, 16)
|
|
86
|
+
# Floor-standing props hide the feet first; a hidden ankle still scores 0.46-0.71
|
|
87
|
+
# (fal stepup/handon) against >= 0.76 when visible, and a click on the prop pulls
|
|
88
|
+
# the prop into the person mask.
|
|
89
|
+
ANKLE_TRUST = 0.75
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def prompt_points(mode: str, box, kp=None, trust: float = TRUST_CONF):
|
|
93
|
+
"""Positive points accompanying the box prompt of one frame (None = box only).
|
|
94
|
+
|
|
95
|
+
centre: the box centre; torso: shoulder and hip midpoints whose two ViTPose
|
|
96
|
+
joints are both confident and inside the box.
|
|
97
|
+
"""
|
|
98
|
+
if mode not in PROMPT_MODES:
|
|
99
|
+
raise ValueError(f"unknown prompt mode {mode}")
|
|
100
|
+
if box is None or mode == "box":
|
|
101
|
+
return None
|
|
102
|
+
if mode == "centre":
|
|
103
|
+
return box_centre(box)
|
|
104
|
+
if kp is None:
|
|
105
|
+
return None
|
|
106
|
+
kp = np.asarray(kp, dtype=np.float64)
|
|
107
|
+
candidates = []
|
|
108
|
+
for a, b in TORSO_PAIRS:
|
|
109
|
+
if kp[a, 2] >= trust and kp[b, 2] >= trust:
|
|
110
|
+
candidates.append((kp[a, :2] + kp[b, :2]) / 2.0)
|
|
111
|
+
extra = {"upper": UPPER, "joints": EXTREMITIES, "all": range(len(kp))}.get(mode, ())
|
|
112
|
+
candidates += [kp[j, :2] for j in extra if kp[j, 2] >= (max(trust, ANKLE_TRUST) if j in ANKLES else trust)]
|
|
113
|
+
points = [(x, y) for x, y in candidates if box[0] <= x <= box[2] and box[1] <= y <= box[3]]
|
|
114
|
+
return np.array(points, dtype=np.float32) if points else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def frame_confidence(kp2d, frames: int) -> np.ndarray:
|
|
118
|
+
"""Mean ViTPose confidence per frame; all ones when the obs has no kp2d."""
|
|
119
|
+
if kp2d is None:
|
|
120
|
+
return np.ones(frames, dtype=np.float64)
|
|
121
|
+
kp2d = np.asarray(kp2d, dtype=np.float64)
|
|
122
|
+
if kp2d.ndim != 3 or kp2d.shape[0] != frames or kp2d.shape[2] < 3:
|
|
123
|
+
raise ValueError(f"bad-obs: kp2d shape {kp2d.shape} for {frames} frames")
|
|
124
|
+
conf = kp2d[:, :, 2].mean(axis=1)
|
|
125
|
+
return np.where(np.isfinite(conf), conf, 0.0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def prompt_boxes(bbx_xys, width: int, height: int):
|
|
129
|
+
"""Validated per-frame prompt boxes; raises no-person-prompt when none exists."""
|
|
130
|
+
if bbx_xys is None:
|
|
131
|
+
raise NoPersonPrompt("obs has no bbx_xys")
|
|
132
|
+
bbx = np.asarray(bbx_xys, dtype=np.float64)
|
|
133
|
+
if bbx.ndim != 2 or bbx.shape[0] == 0 or bbx.shape[1] != 3:
|
|
134
|
+
raise NoPersonPrompt(f"obs bbx_xys is empty or malformed (shape {bbx.shape})")
|
|
135
|
+
boxes = [box_from_xys(row, width, height) for row in bbx]
|
|
136
|
+
if all(box is None for box in boxes):
|
|
137
|
+
raise NoPersonPrompt(f"none of {len(boxes)} obs boxes is a valid person box")
|
|
138
|
+
return boxes
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def choose_anchor(boxes, conf, trust: float = TRUST_CONF) -> int:
|
|
142
|
+
valid = np.array([box is not None for box in boxes])
|
|
143
|
+
if not valid.any():
|
|
144
|
+
raise NoPersonPrompt("no valid obs box")
|
|
145
|
+
trusted = np.flatnonzero(valid & (np.asarray(conf) >= trust))
|
|
146
|
+
if trusted.size:
|
|
147
|
+
return int(trusted[0])
|
|
148
|
+
return int(np.flatnonzero(valid)[np.argmax(np.asarray(conf)[valid])])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def should_reprompt(prev_area: int, area: int, threshold: float = REPROMPT_CHANGE) -> bool:
|
|
152
|
+
"""Re-prompt when the mask area jumps by more than ``threshold`` of the previous frame's."""
|
|
153
|
+
return prev_area > 0 and abs(area - prev_area) > threshold * prev_area
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def reprompt_frames(areas, eligible=None, threshold: float = REPROMPT_CHANGE):
|
|
157
|
+
"""Frames the rule fires on for a fixed area sequence (tracking order = index order)."""
|
|
158
|
+
areas = [int(a) for a in areas]
|
|
159
|
+
eligible = [True] * len(areas) if eligible is None else list(eligible)
|
|
160
|
+
return [t for t in range(1, len(areas)) if eligible[t] and should_reprompt(areas[t - 1], areas[t], threshold)]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def plan_chunks(frames: int, anchor: int, chunk_frames: int):
|
|
164
|
+
"""(direction, start, end) inclusive ranges in processing order.
|
|
165
|
+
|
|
166
|
+
Forward chunks walk anchor..T-1, backward chunks anchor..0; neighbouring
|
|
167
|
+
chunks share their boundary frame, which seeds the later chunk.
|
|
168
|
+
"""
|
|
169
|
+
if chunk_frames < 2:
|
|
170
|
+
raise ValueError("chunk-frames must be >= 2")
|
|
171
|
+
if not 0 <= anchor < frames:
|
|
172
|
+
raise ValueError(f"anchor {anchor} outside 0..{frames - 1}")
|
|
173
|
+
chunks = []
|
|
174
|
+
start = anchor
|
|
175
|
+
while True:
|
|
176
|
+
end = min(start + chunk_frames - 1, frames - 1)
|
|
177
|
+
chunks.append((1, start, end))
|
|
178
|
+
if end == frames - 1:
|
|
179
|
+
break
|
|
180
|
+
start = end
|
|
181
|
+
end = anchor
|
|
182
|
+
while end > 0:
|
|
183
|
+
start = max(end - chunk_frames + 1, 0)
|
|
184
|
+
chunks.append((-1, start, end))
|
|
185
|
+
end = start
|
|
186
|
+
return chunks
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_video(path: str):
|
|
190
|
+
import cv2
|
|
191
|
+
|
|
192
|
+
capture = cv2.VideoCapture(path)
|
|
193
|
+
if not capture.isOpened():
|
|
194
|
+
raise RuntimeError(f"cannot open video {path}")
|
|
195
|
+
frames = []
|
|
196
|
+
while True:
|
|
197
|
+
ok, frame = capture.read()
|
|
198
|
+
if not ok:
|
|
199
|
+
break
|
|
200
|
+
frames.append(frame)
|
|
201
|
+
capture.release()
|
|
202
|
+
if not frames:
|
|
203
|
+
raise RuntimeError(f"no frames decoded from {path}")
|
|
204
|
+
return frames
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def log(message: str) -> None:
|
|
208
|
+
print(f"[masks] {message}", flush=True)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def track_clip(predictor, open_chunk, count: int, prob_hw, boxes, trusted, anchor: int, chunks,
|
|
212
|
+
threshold: float = REPROMPT_CHANGE, points=None):
|
|
213
|
+
"""Propagate one object over ``chunks`` with the online re-prompt rule.
|
|
214
|
+
|
|
215
|
+
``predictor`` follows the SAM2 video predictor API; ``open_chunk(start, end)``
|
|
216
|
+
is a context manager yielding an inference state over frames start..end.
|
|
217
|
+
Returns (prob float16 T x h x w, area int64 T, reprompted frame list).
|
|
218
|
+
"""
|
|
219
|
+
import torch
|
|
220
|
+
import torch.nn.functional as F
|
|
221
|
+
|
|
222
|
+
ph, pw = prob_hw
|
|
223
|
+
prob = np.zeros((count, ph, pw), dtype=np.float16)
|
|
224
|
+
area = np.full(count, -1, dtype=np.int64)
|
|
225
|
+
reprompted: list[int] = []
|
|
226
|
+
boundary: dict = {} # full-res bool masks: chunk boundary frames and the anchor
|
|
227
|
+
done = 0
|
|
228
|
+
|
|
229
|
+
def record(t: int, logits) -> int:
|
|
230
|
+
nonlocal done
|
|
231
|
+
mask = logits[0, 0] > 0
|
|
232
|
+
small = F.interpolate(torch.sigmoid(logits.float()), size=(ph, pw), mode="area")[0, 0]
|
|
233
|
+
prob[t] = small.float().cpu().numpy().astype(np.float16)
|
|
234
|
+
if area[t] < 0:
|
|
235
|
+
done += 1
|
|
236
|
+
if done % 20 == 0 or done == count:
|
|
237
|
+
log(f"{done} / {count}")
|
|
238
|
+
area[t] = int(mask.sum().item())
|
|
239
|
+
boundary[t] = mask.cpu()
|
|
240
|
+
return int(area[t])
|
|
241
|
+
|
|
242
|
+
def prompt_box(state, local: int, t: int):
|
|
243
|
+
positive = None if points is None else points[t]
|
|
244
|
+
if positive is None:
|
|
245
|
+
predictor.add_new_points_or_box(state, frame_idx=local, obj_id=OBJ_ID, box=boxes[t])
|
|
246
|
+
else:
|
|
247
|
+
predictor.add_new_points_or_box(state, frame_idx=local, obj_id=OBJ_ID, box=boxes[t], points=positive,
|
|
248
|
+
labels=np.ones(len(positive), dtype=np.int32))
|
|
249
|
+
|
|
250
|
+
for index, (direction, start, end) in enumerate(chunks):
|
|
251
|
+
reverse = direction < 0
|
|
252
|
+
seed = end if reverse else start
|
|
253
|
+
log(f"chunk {index + 1}/{len(chunks)} {'backward' if reverse else 'forward'} {start}-{end} seed={seed}")
|
|
254
|
+
with open_chunk(start, end) as state:
|
|
255
|
+
seed_local = seed - start
|
|
256
|
+
if seed in boundary and bool(boundary[seed].any()):
|
|
257
|
+
predictor.add_new_mask(state, frame_idx=seed_local, obj_id=OBJ_ID, mask=boundary[seed])
|
|
258
|
+
elif boxes[seed] is not None:
|
|
259
|
+
if seed != anchor:
|
|
260
|
+
reprompted.append(seed)
|
|
261
|
+
log(f"reprompt f{seed}: empty seed mask")
|
|
262
|
+
prompt_box(state, seed_local, seed)
|
|
263
|
+
else:
|
|
264
|
+
predictor.add_new_mask(state, frame_idx=seed_local, obj_id=OBJ_ID, mask=boundary[seed])
|
|
265
|
+
resume = seed_local
|
|
266
|
+
while True:
|
|
267
|
+
restart = None
|
|
268
|
+
for local, _, logits in predictor.propagate_in_video(state, start_frame_idx=resume, reverse=reverse):
|
|
269
|
+
t = start + local
|
|
270
|
+
if t == seed and area[t] >= 0:
|
|
271
|
+
continue # boundary frame already recorded by the previous chunk
|
|
272
|
+
new_area = record(t, logits)
|
|
273
|
+
prev = t + 1 if reverse else t - 1
|
|
274
|
+
if (t != seed and 0 <= prev < count and area[prev] >= 0 and t not in reprompted
|
|
275
|
+
and trusted[t] and boxes[t] is not None
|
|
276
|
+
and should_reprompt(int(area[prev]), new_area, threshold)):
|
|
277
|
+
log(f"reprompt f{t}: area {int(area[prev])} -> {new_area}")
|
|
278
|
+
reprompted.append(t)
|
|
279
|
+
restart = local
|
|
280
|
+
break
|
|
281
|
+
if restart is None:
|
|
282
|
+
break
|
|
283
|
+
# Forget the tracked output so the obs box is an initial conditioning
|
|
284
|
+
# prompt (fresh segmentation), not a correction of the drifted mask.
|
|
285
|
+
state["output_dict_per_obj"][0]["non_cond_frame_outputs"].pop(restart, None)
|
|
286
|
+
state["frames_tracked_per_obj"][0].pop(restart, None)
|
|
287
|
+
area[start + restart] = -1
|
|
288
|
+
done -= 1
|
|
289
|
+
prompt_box(state, restart, start + restart)
|
|
290
|
+
resume = restart
|
|
291
|
+
for t in list(boundary):
|
|
292
|
+
if t not in (start, end, anchor):
|
|
293
|
+
del boundary[t]
|
|
294
|
+
if (area < 0).any():
|
|
295
|
+
raise RuntimeError(f"frames without a mask: {np.flatnonzero(area < 0).tolist()[:10]}")
|
|
296
|
+
return prob, area, sorted(reprompted)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def segment(video: str, obs: str, chunk_frames: int = DEFAULT_CHUNK_FRAMES, checkpoint: str = CHECKPOINT,
|
|
300
|
+
max_reserved_mib: int = DEFAULT_MAX_RESERVED_MIB, threshold: float = REPROMPT_CHANGE,
|
|
301
|
+
prompt: str = DEFAULT_PROMPT):
|
|
302
|
+
"""Run SAM2 over the clip; returns (arrays, summary)."""
|
|
303
|
+
if prompt not in PROMPT_MODES:
|
|
304
|
+
raise ValueError(f"unknown prompt mode {prompt}")
|
|
305
|
+
data = np.load(obs)
|
|
306
|
+
bbx = data["bbx_xys"] if "bbx_xys" in data.files else None
|
|
307
|
+
kp2d = data["kp2d"] if "kp2d" in data.files else None
|
|
308
|
+
# Validate the prompt before decoding video or touching the GPU.
|
|
309
|
+
if bbx is None or np.asarray(bbx).ndim != 2 or np.asarray(bbx).shape[0] == 0:
|
|
310
|
+
prompt_boxes(bbx, 1, 1)
|
|
311
|
+
started = time.time()
|
|
312
|
+
frames = read_video(video)
|
|
313
|
+
count = len(frames)
|
|
314
|
+
height, width = frames[0].shape[:2]
|
|
315
|
+
if len(bbx) != count:
|
|
316
|
+
raise ValueError(f"frame-count-mismatch: video {count} frames, obs {len(bbx)}")
|
|
317
|
+
boxes = prompt_boxes(bbx, width, height)
|
|
318
|
+
conf = frame_confidence(kp2d, count)
|
|
319
|
+
anchor = choose_anchor(boxes, conf)
|
|
320
|
+
chunks = plan_chunks(count, anchor, chunk_frames)
|
|
321
|
+
points = [prompt_points(prompt, boxes[t], None if kp2d is None else kp2d[t]) for t in range(count)]
|
|
322
|
+
anchor_points = None if points[anchor] is None else points[anchor].round(1).tolist()
|
|
323
|
+
log(f"video {width}x{height} frames={count} anchor={anchor} conf={conf[anchor]:.2f} box={boxes[anchor].round(1).tolist()} "
|
|
324
|
+
f"prompt={prompt} points={anchor_points} chunks={len(chunks)}")
|
|
325
|
+
|
|
326
|
+
import cv2
|
|
327
|
+
import torch
|
|
328
|
+
from sam2.build_sam import build_sam2_video_predictor
|
|
329
|
+
|
|
330
|
+
if not torch.cuda.is_available():
|
|
331
|
+
raise RuntimeError("CUDA is unavailable")
|
|
332
|
+
total_mib = torch.cuda.get_device_properties(0).total_memory / 2**20
|
|
333
|
+
# Hard cap: an allocation past the budget raises OOM instead of silently exceeding it.
|
|
334
|
+
torch.cuda.set_per_process_memory_fraction(min(1.0, max_reserved_mib / total_mib), 0)
|
|
335
|
+
torch.cuda.reset_peak_memory_stats()
|
|
336
|
+
torch.backends.cuda.matmul.allow_tf32 = True
|
|
337
|
+
torch.backends.cudnn.allow_tf32 = True
|
|
338
|
+
predictor = build_sam2_video_predictor(CONFIG, checkpoint, device="cuda")
|
|
339
|
+
load_s = time.time() - started
|
|
340
|
+
|
|
341
|
+
@contextlib.contextmanager
|
|
342
|
+
def jpeg_chunk(start: int, end: int):
|
|
343
|
+
with tempfile.TemporaryDirectory(prefix="cozyfit-masks-") as tmp:
|
|
344
|
+
for local, t in enumerate(range(start, end + 1)):
|
|
345
|
+
cv2.imwrite(os.path.join(tmp, f"{local:05d}.jpg"), frames[t], [cv2.IMWRITE_JPEG_QUALITY, 95])
|
|
346
|
+
state = predictor.init_state(tmp, offload_video_to_cpu=True)
|
|
347
|
+
try:
|
|
348
|
+
yield state
|
|
349
|
+
finally:
|
|
350
|
+
del state
|
|
351
|
+
torch.cuda.empty_cache()
|
|
352
|
+
|
|
353
|
+
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16):
|
|
354
|
+
prob, area, reprompted = track_clip(predictor, jpeg_chunk, count, (height // 2, width // 2),
|
|
355
|
+
boxes, conf >= TRUST_CONF, anchor, chunks, threshold, points)
|
|
356
|
+
ph, pw = prob.shape[1:]
|
|
357
|
+
torch.cuda.synchronize()
|
|
358
|
+
peak = torch.cuda.max_memory_reserved() / 2**20
|
|
359
|
+
seconds = time.time() - started
|
|
360
|
+
arrays = {
|
|
361
|
+
"prob": prob,
|
|
362
|
+
"reprompted": np.array(sorted(reprompted), dtype=np.int32),
|
|
363
|
+
"area": area.astype(np.int32),
|
|
364
|
+
"anchor": np.array(anchor, dtype=np.int32),
|
|
365
|
+
"chunks": np.array(chunks, dtype=np.int32).reshape(-1, 3),
|
|
366
|
+
}
|
|
367
|
+
summary = {
|
|
368
|
+
"frames": count, "width": width, "height": height, "probShape": [count, ph, pw],
|
|
369
|
+
"anchor": anchor, "anchorConfidence": float(conf[anchor]), "chunks": [list(c) for c in chunks],
|
|
370
|
+
"chunkFrames": chunk_frames, "reprompted": sorted(reprompted), "repromptChange": threshold,
|
|
371
|
+
"trustConfidence": TRUST_CONF, "prompt": prompt, "emptyFrames": int((area == 0).sum()),
|
|
372
|
+
"seconds": round(seconds, 3), "modelLoadSeconds": round(load_s, 3), "peakReservedMiB": round(peak, 1),
|
|
373
|
+
"maxReservedMiB": max_reserved_mib, "config": CONFIG, "checkpoint": os.path.basename(checkpoint),
|
|
374
|
+
"torch": torch.__version__,
|
|
375
|
+
}
|
|
376
|
+
return arrays, summary
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def main(argv=None) -> int:
|
|
380
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
381
|
+
parser.add_argument("--video", required=True)
|
|
382
|
+
parser.add_argument("--obs", required=True)
|
|
383
|
+
parser.add_argument("--out", required=True)
|
|
384
|
+
parser.add_argument("--chunk-frames", type=int, default=DEFAULT_CHUNK_FRAMES)
|
|
385
|
+
parser.add_argument("--checkpoint", default=CHECKPOINT)
|
|
386
|
+
parser.add_argument("--max-reserved-mib", type=int, default=DEFAULT_MAX_RESERVED_MIB)
|
|
387
|
+
parser.add_argument("--prompt", choices=PROMPT_MODES, default=DEFAULT_PROMPT)
|
|
388
|
+
args = parser.parse_args(argv)
|
|
389
|
+
try:
|
|
390
|
+
arrays, summary = segment(args.video, args.obs, args.chunk_frames, args.checkpoint, args.max_reserved_mib,
|
|
391
|
+
prompt=args.prompt)
|
|
392
|
+
except NoPersonPrompt as error:
|
|
393
|
+
print(str(error), file=sys.stderr, flush=True)
|
|
394
|
+
return 3
|
|
395
|
+
os.makedirs(args.out, exist_ok=True)
|
|
396
|
+
np.savez_compressed(os.path.join(args.out, "masks.npz"), **arrays)
|
|
397
|
+
with open(os.path.join(args.out, "masks.json"), "w") as handle:
|
|
398
|
+
json.dump(summary, handle, indent=1)
|
|
399
|
+
log(f"done frames={summary['frames']} seconds={summary['seconds']} peakReservedMiB={summary['peakReservedMiB']} "
|
|
400
|
+
f"reprompted={len(summary['reprompted'])} emptyFrames={summary['emptyFrames']}")
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
raise SystemExit(main())
|