cozyclay 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,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())