Skip to content

Commit fad464e

Browse files
authored
slice 14: Object-Multiplex batching — track N objects per frame (faithful) (#21)
Extend Sam3VideoSession to seed and track multiple objects together as the batch dimension B (Object Multiplex): each object carries its own memory bank + box prompt; per frame they run through one track_step over the shared, broadcast frame features. Reuses the slice-12 track_step batch axis rather than retrofitting the old lineage's bucket mux/demux — identical per-object semantics, simpler path. Injection-based multi-object tests (CI-safe per the mlx-cpu rule): N objects batch through one pass, stable ordered ids, per-object mask rows, determinism, and a guard rejecting mixed seed frames (per-object seed frames / mid-clip spawning is the association slice). Full regression 608 passed / 13 skipped.
1 parent 898e951 commit fad464e

2 files changed

Lines changed: 135 additions & 20 deletions

File tree

src/mlx_cv/models/sam3/real_video_streaming.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ class Sam3VideoFrameResult:
3131
object_score_logits: mx.array # [num_obj]
3232

3333

34-
def _box_to_corner_points(box: tuple[float, float, float, float]) -> tuple[mx.array, mx.array]:
35-
"""xyxy box (prompt-encoder input-image coords) -> SAM corner points (TL=2, BR=3)."""
34+
def _box_corner_points(box: tuple[float, float, float, float]) -> mx.array:
35+
"""xyxy box (prompt-encoder input-image coords) -> 2 SAM corner points ``[2, 2]``."""
3636
x0, y0, x1, y1 = (float(v) for v in box)
37-
coords = mx.array([[[x0, y0], [x1, y1]]]) # [1, 2, 2]
38-
labels = mx.array([[2.0, 3.0]]) # [1, 2]
39-
return coords, labels
37+
return mx.array([[x0, y0], [x1, y1]])
38+
39+
40+
_BOX_CORNER_LABELS = (2.0, 3.0) # SAM box corners: top-left=2, bottom-right=3
4041

4142

4243
@dataclass
@@ -53,7 +54,7 @@ def __init__(self, model: Sam3VideoModel, *, num_maskmem: int | None = None):
5354
self.model = model
5455
self.tracker = model.tracker_model
5556
self.num_maskmem = num_maskmem if num_maskmem is not None else self.tracker.num_maskmem
56-
self._prompt: _Prompt | None = None
57+
self._prompts: list[_Prompt] = []
5758

5859
@classmethod
5960
def from_tracker(cls, tracker: Sam3TrackerVideoModel, *, num_maskmem: int | None = None) -> "Sam3VideoSession":
@@ -62,11 +63,12 @@ def from_tracker(cls, tracker: Sam3TrackerVideoModel, *, num_maskmem: int | None
6263
session.model = None
6364
session.tracker = tracker
6465
session.num_maskmem = num_maskmem if num_maskmem is not None else tracker.num_maskmem
65-
session._prompt = None
66+
session._prompts = []
6667
return session
6768

6869
def add_box_prompt(self, frame_index: int, box, object_id: int) -> None:
69-
self._prompt = _Prompt(frame_index=int(frame_index), box=tuple(box), object_id=int(object_id))
70+
"""Seed an object with a box prompt. Multiple objects may be added (multiplex batch)."""
71+
self._prompts.append(_Prompt(frame_index=int(frame_index), box=tuple(box), object_id=int(object_id)))
7072

7173
def propagate(self, pixel_values_per_frame) -> list[Sam3VideoFrameResult]:
7274
"""Extract per-frame tracker features via the detector, then run the streaming loop."""
@@ -75,34 +77,60 @@ def propagate(self, pixel_values_per_frame) -> list[Sam3VideoFrameResult]:
7577
features = [self.model.extract_tracker_features(pv) for pv in pixel_values_per_frame]
7678
return self.run_from_features(features)
7779

80+
@staticmethod
81+
def _to_object_batch(x: mx.array, num_objects: int) -> mx.array:
82+
"""Broadcast a single shared-frame tensor ``[1, ...]`` to the object batch ``[B, ...]``."""
83+
if x.shape[0] == num_objects:
84+
return x
85+
if x.shape[0] != 1:
86+
raise ValueError(f"expected frame tensor with leading dim 1 or {num_objects}, got {x.shape[0]}")
87+
return mx.repeat(x, num_objects, axis=0)
88+
89+
def _seed_point_inputs(self, prompts: list[_Prompt]) -> tuple[mx.array, mx.array]:
90+
"""Batched box-corner prompts for the seed frame -> ``(coords [B,2,2], labels [B,2])``."""
91+
coords = mx.stack([_box_corner_points(p.box) for p in prompts], axis=0)
92+
labels = mx.broadcast_to(mx.array([_BOX_CORNER_LABELS]), (len(prompts), len(_BOX_CORNER_LABELS)))
93+
return coords, labels
94+
7895
def run_from_features(self, per_frame_features) -> list[Sam3VideoFrameResult]:
7996
"""Run the memory-propagation loop over pre-extracted per-frame features.
8097
81-
Each entry is ``(image_embeddings, image_positional_embeddings, high_res_features)``;
82-
``high_res_features`` is ``[4g-res, 2g-res]`` raw FPN levels (NHWC).
98+
Each entry is ``(image_embeddings, image_positional_embeddings, high_res_features)`` for a
99+
single frame (shared across objects); ``high_res_features`` is ``[4g-res, 2g-res]`` raw FPN
100+
levels (NHWC). All seeded objects are tracked together as the batch dimension ``B`` (Object
101+
Multiplex): each object carries its own memory + box prompt; per frame they run through one
102+
``track_step`` over the shared frame features.
83103
"""
84-
if self._prompt is None:
104+
if not self._prompts:
85105
raise ValueError("Sam3VideoSession requires a prompt; call add_box_prompt first")
86-
prompt = self._prompt
106+
seed_frames = {p.frame_index for p in self._prompts}
107+
if len(seed_frames) != 1:
108+
raise ValueError(
109+
"Sam3VideoSession batches objects that share one seed frame; per-object seed frames / "
110+
"mid-clip spawning is handled by the association layer (later slice)"
111+
)
112+
prompts = self._prompts
113+
seed_frame = prompts[0].frame_index
114+
object_ids = [p.object_id for p in prompts]
115+
num_objects = len(prompts)
87116
bank: list[Sam3TrackerStageOutput] = []
88117
results: list[Sam3VideoFrameResult] = []
89118
for frame_index, (vision_features, vision_pos, high_res_features) in enumerate(per_frame_features):
90-
is_init = frame_index == prompt.frame_index
91-
point_inputs = _box_to_corner_points(prompt.box) if is_init else None
119+
is_init = frame_index == seed_frame
92120
out = self.tracker.track_step(
93-
vision_features=vision_features,
94-
vision_pos=vision_pos,
95-
high_res_features=high_res_features,
121+
vision_features=self._to_object_batch(vision_features, num_objects),
122+
vision_pos=self._to_object_batch(vision_pos, num_objects),
123+
high_res_features=[self._to_object_batch(h, num_objects) for h in high_res_features],
96124
is_init_cond_frame=is_init,
97-
point_inputs=point_inputs,
125+
point_inputs=self._seed_point_inputs(prompts) if is_init else None,
98126
previous_frames=bank if bank else None,
99127
)
100128
bank.append(out)
101129
results.append(
102130
Sam3VideoFrameResult(
103131
frame_index=frame_index,
104-
object_ids=[prompt.object_id],
105-
masks=out.low_res_masks[:, :, :, 0] > 0,
132+
object_ids=list(object_ids),
133+
masks=out.low_res_masks[:, :, :, 0] > 0, # [B, h, w] (one row per object)
106134
object_score_logits=out.object_score_logits.reshape(-1),
107135
)
108136
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Slice 14: faithful SAM3 Object-Multiplex batching (multiple objects per frame).
2+
3+
Weight-free structural verification that several seeded objects track together as the
4+
batch dimension ``B`` through one ``track_step`` per frame — each object carries its own
5+
memory bank + box prompt over the shared frame features. Injection-based (no detector),
6+
so it runs on the Linux mlx[cpu] CI; numeric parity is the deferred out-of-sandbox gate.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import mlx.core as mx
12+
import pytest
13+
14+
from mlx_cv.models.sam3.real_tracker_decoder import Sam3TrackerPromptEncoderConfig
15+
from mlx_cv.models.sam3.real_video_config import Sam3TrackerVideoConfig
16+
from mlx_cv.models.sam3.real_video_model import Sam3TrackerVideoModel
17+
from mlx_cv.models.sam3.real_video_streaming import Sam3VideoSession
18+
19+
CHANNELS = 256
20+
21+
22+
def _tiny_tracker(g: int = 4) -> Sam3TrackerVideoModel:
23+
config = Sam3TrackerVideoConfig(
24+
prompt_encoder=Sam3TrackerPromptEncoderConfig(image_size=g * 16, patch_size=16),
25+
memory_attention_rope_feat_sizes=(g, g),
26+
)
27+
tracker = Sam3TrackerVideoModel(config)
28+
mx.eval(tracker.parameters())
29+
return tracker
30+
31+
32+
def _inject_features(g: int = 4, seed: int = 0):
33+
keys = mx.random.split(mx.random.key(seed), 4)
34+
return (
35+
mx.random.normal((1, g, g, CHANNELS), key=keys[0]),
36+
mx.random.normal((1, g, g, CHANNELS), key=keys[1]),
37+
[
38+
mx.random.normal((1, g * 4, g * 4, CHANNELS), key=keys[2]),
39+
mx.random.normal((1, g * 2, g * 2, CHANNELS), key=keys[3]),
40+
],
41+
)
42+
43+
44+
def test_multiplex_two_objects_batch_through_one_track_step():
45+
g = 4
46+
session = Sam3VideoSession.from_tracker(_tiny_tracker(g))
47+
session.add_box_prompt(0, [4, 4, 28, 28], object_id=5)
48+
session.add_box_prompt(0, [20, 20, 60, 60], object_id=9)
49+
results = session.run_from_features([_inject_features(g, seed=i) for i in range(3)])
50+
assert [r.frame_index for r in results] == [0, 1, 2]
51+
for r in results:
52+
assert r.object_ids == [5, 9] # stable, ordered
53+
assert r.masks.shape == (2, g * 4, g * 4) # one mask row per object
54+
assert r.object_score_logits.shape == (2,)
55+
assert bool(mx.all(mx.isfinite(r.object_score_logits)).item())
56+
57+
58+
def test_multiplex_scales_to_more_objects():
59+
g = 4
60+
session = Sam3VideoSession.from_tracker(_tiny_tracker(g))
61+
for i, oid in enumerate((3, 7, 11, 4)):
62+
session.add_box_prompt(0, [2 + i, 2 + i, 30 + i, 30 + i], object_id=oid)
63+
results = session.run_from_features([_inject_features(g, seed=i) for i in range(2)])
64+
for r in results:
65+
assert r.object_ids == [3, 7, 11, 4]
66+
assert r.masks.shape == (4, g * 4, g * 4)
67+
68+
69+
def test_multiplex_rejects_mixed_seed_frames():
70+
session = Sam3VideoSession.from_tracker(_tiny_tracker())
71+
session.add_box_prompt(0, [4, 4, 28, 28], object_id=1)
72+
session.add_box_prompt(1, [4, 4, 28, 28], object_id=2)
73+
with pytest.raises(ValueError, match="seed frame"):
74+
session.run_from_features([_inject_features() for _ in range(2)])
75+
76+
77+
def test_multiplex_is_deterministic():
78+
g = 4
79+
session = Sam3VideoSession.from_tracker(_tiny_tracker(g))
80+
session.add_box_prompt(0, [4, 4, 28, 28], object_id=1)
81+
session.add_box_prompt(0, [20, 20, 60, 60], object_id=2)
82+
frames = [_inject_features(g, seed=i) for i in range(3)]
83+
first = session.run_from_features(frames)
84+
second = session.run_from_features(frames)
85+
for a, b in zip(first, second):
86+
assert bool(mx.all(a.masks == b.masks).item())
87+
assert bool(mx.all(a.object_score_logits == b.object_score_logits).item())

0 commit comments

Comments
 (0)