Skip to content

Commit aa4d085

Browse files
committed
slice 12: Faithful SAM3 tracker per-frame track_step (memory-propagation step)
Give the faithful Sam3TrackerVideoModel a real SAM2-style per-frame step built on the ported slice 8-10 subsystems: memory-attention conditioning (no-memory first-frame path + 4-layer transformer over stacked maskmem + object-pointer tokens with temporal PE), prompt encode, mask decode, occlusion-gated object pointer, and memory encode. Additive enablers (strictly non-breaking, existing taps unchanged): - Sam3TrackerMaskDecoder returns the SAM mask token (object-pointer source). - Sam3TrackerPromptEncoder gains the sparse point/box path + get_dense_pe(). Weight-free component test covers shapes/finiteness/determinism on a tiny grid for both the init and memory-conditioned paths. Numeric parity stays the deferred out-of-sandbox gate (parity-status sam3_video). First slice of the phased video streaming port (slices 12-16).
1 parent 0a5d716 commit aa4d085

3 files changed

Lines changed: 309 additions & 2 deletions

File tree

src/mlx_cv/models/sam3/real_tracker_decoder.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,32 @@ def __call__(self, input_masks: mx.array | None, batch_size: int = 1) -> mx.arra
9999
dense = self.no_mask_embed.weight.reshape(1, 1, 1, -1)
100100
return mx.broadcast_to(dense, (batch_size, height, width, self.hidden_size))
101101

102+
def get_dense_pe(self) -> mx.array:
103+
"""Positional encoding over the image-embedding grid -> ``[1, H, W, hidden]`` (NHWC)."""
104+
height, width = self.image_embedding_size
105+
ys = (mx.arange(height).astype(mx.float32) + 0.5) / height
106+
xs = (mx.arange(width).astype(mx.float32) + 0.5) / width
107+
grid = mx.stack(
108+
[mx.broadcast_to(xs[None, :], (height, width)), mx.broadcast_to(ys[:, None], (height, width))],
109+
axis=-1,
110+
) # [H, W, 2], already normalized -> shared_embedding treats it as such (input_shape=None)
111+
return self.shared_embedding(grid)[None]
112+
113+
def encode_sparse(self, points: mx.array, labels: mx.array) -> mx.array:
114+
"""Embed point / box-corner prompts into sparse embeddings ``[B, N, hidden]``.
115+
116+
``labels``: -1 padding (not-a-point), 0/1 negative/positive point, 2/3 box top-left/
117+
bottom-right corner. Mirrors the SAM2 prompt encoder's point path.
118+
"""
119+
coords = points + 0.5 # shift to pixel centers
120+
embeddings = self.shared_embedding(coords, input_shape=(self.input_image_size, self.input_image_size))
121+
labels_e = labels[..., None]
122+
point_embed = self.point_embed.weight # [num_point_embeddings, hidden]
123+
embeddings = mx.where(labels_e == -1, mx.zeros_like(embeddings) + self.not_a_point_embed.weight, embeddings)
124+
for label_value in range(point_embed.shape[0]):
125+
embeddings = mx.where(labels_e == label_value, embeddings + point_embed[label_value], embeddings)
126+
return embeddings
127+
102128

103129
class Sam3TrackerAttention(nn.Module):
104130
"""Attention with optional internal downsampling (q/k/v -> internal, o -> hidden)."""
@@ -194,6 +220,7 @@ class Sam3TrackerMaskDecoderOutput:
194220
masks: mx.array
195221
iou_pred: mx.array
196222
object_score_logits: mx.array
223+
sam_tokens_out: mx.array # [B, point_batch, num_masks, C] — mask-token outputs (obj-pointer source)
197224

198225

199226
class Sam3TrackerMaskDecoder(nn.Module):
@@ -271,4 +298,10 @@ def __call__(
271298
mask_slice = slice(1, None) if multimask_output else slice(0, 1)
272299
masks = masks[:, :, mask_slice]
273300
iou_pred = iou_pred[:, :, mask_slice]
274-
return Sam3TrackerMaskDecoderOutput(masks=masks, iou_pred=iou_pred, object_score_logits=object_score_logits)
301+
sam_tokens_out = mask_tokens_out[:, :, mask_slice]
302+
return Sam3TrackerMaskDecoderOutput(
303+
masks=masks,
304+
iou_pred=iou_pred,
305+
object_score_logits=object_score_logits,
306+
sam_tokens_out=sam_tokens_out,
307+
)

src/mlx_cv/models/sam3/real_video_model.py

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515

1616
from __future__ import annotations
1717

18+
import math
19+
from dataclasses import dataclass, field
20+
from typing import Any
21+
1822
import mlx.core as mx
1923
import mlx.nn as nn
2024

@@ -25,7 +29,41 @@
2529
from .real_video_config import Sam3TrackerVideoConfig
2630
from .real_vision import Sam3VisionConfig, Sam3VisionNeck
2731

28-
__all__ = ["Sam3TrackerVideoModel", "Sam3VideoModel", "build_sam3_video_real"]
32+
__all__ = [
33+
"Sam3TrackerStageOutput",
34+
"Sam3TrackerVideoModel",
35+
"Sam3VideoModel",
36+
"build_sam3_video_real",
37+
]
38+
39+
40+
def _upsample_nhwc(x: mx.array, factor: int) -> mx.array:
41+
"""Nearest-neighbour integer upsample of an NHWC tensor along H and W."""
42+
if factor == 1:
43+
return x
44+
return mx.repeat(mx.repeat(x, factor, axis=1), factor, axis=2)
45+
46+
47+
def _get_1d_sine_pe(positions: mx.array, dim: int, temperature: float = 10000.0) -> mx.array:
48+
"""1D sinusoidal positional encoding of ``positions`` -> ``[..., dim]`` (SAM2 ``get_1d_sine_pe``)."""
49+
half = dim // 2
50+
dim_t = temperature ** (2.0 * (mx.arange(half).astype(mx.float32) // 1) / dim)
51+
angles = positions[..., None] / dim_t
52+
return mx.concatenate([mx.sin(angles), mx.cos(angles)], axis=-1)
53+
54+
55+
@dataclass
56+
class Sam3TrackerStageOutput:
57+
"""One frame's faithful tracker output (object batch ``B`` along axis 0)."""
58+
59+
low_res_masks: mx.array # NHWC [B, 4g, 4g, 1]
60+
high_res_masks: mx.array # NHWC [B, 16g, 16g, 1]
61+
iou_pred: mx.array # [B, 1, 1]
62+
object_score_logits: mx.array # [B, 1, 1]
63+
obj_ptr: mx.array # [B, hidden_dim]
64+
maskmem_features: mx.array | None = None # NHWC [B, g, g, mem_dim]
65+
maskmem_pos_enc: mx.array | None = None # NHWC [B, g, g, mem_dim]
66+
extra: dict[str, Any] = field(default_factory=dict)
2967

3068

3169
class Sam3TrackerVideoModel(nn.Module):
@@ -50,6 +88,138 @@ def __init__(self, config: Sam3TrackerVideoConfig, hidden_dim: int = 256, mem_di
5088
self.no_object_pointer = mx.zeros((1, hidden_dim))
5189
self.occlusion_spatial_embedding_parameter = mx.zeros((1, mem_dim))
5290

91+
self.hidden_dim = hidden_dim
92+
self.mem_dim = mem_dim
93+
self.num_maskmem = config.num_maskmem
94+
95+
# ---- faithful per-frame streaming step (slice 12) ------------------------
96+
97+
def _assemble_memory(
98+
self, previous_frames: list[Sam3TrackerStageOutput], batch: int
99+
) -> tuple[mx.array, mx.array, int]:
100+
"""Stack prior-frame spatial memory + object pointers -> ``([mem_seq,B,mem_dim], pos, num_ptr_tokens)``.
101+
102+
Spatial memory carries the learned ``memory_temporal_positional_encoding`` per slot; object
103+
pointers are split into ``hidden_dim // mem_dim`` tokens (appended last so memory-attention can
104+
exclude them from RoPE) with a sine temporal encoding projected to ``mem_dim``.
105+
"""
106+
recent = previous_frames[-(self.num_maskmem - 1) :]
107+
spatial_mem: list[mx.array] = []
108+
spatial_pos: list[mx.array] = []
109+
for offset, frame in enumerate(reversed(recent)):
110+
t_pos = offset + 1 # 1 == most recent prior frame
111+
feats = frame.maskmem_features
112+
b, h, w, c = feats.shape
113+
spatial_mem.append(feats.reshape(b, h * w, c).transpose(1, 0, 2))
114+
pos = frame.maskmem_pos_enc.reshape(b, h * w, c).transpose(1, 0, 2)
115+
spatial_pos.append(pos + self.memory_temporal_positional_encoding[self.num_maskmem - t_pos - 1])
116+
117+
obj_tokens: list[mx.array] = []
118+
obj_token_pos: list[mx.array] = []
119+
num_obj_ptr_tokens = 0
120+
if recent:
121+
stacked = mx.stack([frame.obj_ptr for frame in recent], axis=0) # [n, B, hidden_dim]
122+
n = stacked.shape[0]
123+
tokens_per_ptr = self.hidden_dim // self.mem_dim
124+
stacked = stacked.reshape(n, batch, tokens_per_ptr, self.mem_dim)
125+
stacked = stacked.transpose(0, 2, 1, 3).reshape(n * tokens_per_ptr, batch, self.mem_dim)
126+
rel = mx.arange(n).astype(mx.float32)
127+
sine = self.temporal_positional_encoding_projection_layer(_get_1d_sine_pe(rel, self.hidden_dim))
128+
sine = mx.repeat(sine, tokens_per_ptr, axis=0)
129+
obj_tokens.append(stacked)
130+
obj_token_pos.append(mx.broadcast_to(sine[:, None, :], (n * tokens_per_ptr, batch, self.mem_dim)))
131+
num_obj_ptr_tokens = n * tokens_per_ptr
132+
133+
memory = mx.concatenate(spatial_mem + obj_tokens, axis=0)
134+
memory_pos = mx.concatenate(spatial_pos + obj_token_pos, axis=0)
135+
return memory, memory_pos, num_obj_ptr_tokens
136+
137+
def _condition_on_memory(
138+
self,
139+
vision_features: mx.array,
140+
vision_pos: mx.array,
141+
previous_frames: list[Sam3TrackerStageOutput] | None,
142+
is_init_cond_frame: bool,
143+
) -> mx.array:
144+
"""Fuse current NHWC features with the memory bank -> NHWC ``[B, g, g, C]``."""
145+
batch, height, width, channels = vision_features.shape
146+
if is_init_cond_frame or not previous_frames:
147+
# First frame: directly add the no-memory embedding (no transformer pass).
148+
return vision_features + self.no_memory_embedding.reshape(1, 1, 1, channels)
149+
150+
memory, memory_pos, num_obj_ptr_tokens = self._assemble_memory(previous_frames, batch)
151+
current = vision_features.reshape(batch, height * width, channels).transpose(1, 0, 2)
152+
current_pos = vision_pos.reshape(batch, height * width, channels).transpose(1, 0, 2)
153+
conditioned = self.memory_attention(
154+
current_vision_features=current,
155+
memory=memory,
156+
current_vision_position_embeddings=current_pos,
157+
memory_position_embeddings=memory_pos,
158+
num_object_pointer_tokens=num_obj_ptr_tokens,
159+
) # [1, B, seq, C]
160+
return conditioned.reshape(batch, height, width, channels)
161+
162+
def track_step(
163+
self,
164+
*,
165+
vision_features: mx.array, # NHWC [B, g, g, C]
166+
vision_pos: mx.array, # NHWC [B, g, g, C]
167+
high_res_features: list[mx.array] | tuple[mx.array, mx.array], # [4g-res, 2g-res] raw FPN, NHWC
168+
is_init_cond_frame: bool = False,
169+
point_inputs: tuple[mx.array, mx.array] | None = None, # (coords [B,N,2], labels [B,N])
170+
mask_inputs: mx.array | None = None, # NHWC [B, Hm, Wm, 1]
171+
previous_frames: list[Sam3TrackerStageOutput] | None = None,
172+
run_mem_encoder: bool = True,
173+
) -> Sam3TrackerStageOutput:
174+
"""One faithful SAM2-style tracker step over a single frame (object batch ``B``)."""
175+
batch, grid_h, grid_w, channels = vision_features.shape
176+
177+
conditioned = self._condition_on_memory(vision_features, vision_pos, previous_frames, is_init_cond_frame)
178+
179+
# High-res guides for mask upscaling (conv_s0 / conv_s1 own the channel projections).
180+
feat_s0 = self.mask_decoder.conv_s0(high_res_features[0])
181+
feat_s1 = self.mask_decoder.conv_s1(high_res_features[1])
182+
183+
# Sparse prompt: explicit points/box, else a single padding point (label -1).
184+
if point_inputs is not None:
185+
coords, labels = point_inputs
186+
else:
187+
coords = mx.zeros((batch, 1, 2))
188+
labels = -mx.ones((batch, 1))
189+
sparse = self.prompt_encoder.encode_sparse(coords, labels)[:, None] # [B, 1, N, C]
190+
191+
# Dense prompt + dense positional encoding.
192+
dense = self.prompt_encoder(mask_inputs, batch_size=batch) # NHWC [B, g, g, C]
193+
image_pe = mx.broadcast_to(self.prompt_encoder.get_dense_pe(), (batch, grid_h, grid_w, channels))
194+
195+
decoded = self.mask_decoder(conditioned, image_pe, sparse, dense, [feat_s0, feat_s1], multimask_output=False)
196+
low_res_masks = decoded.masks[:, 0, 0][..., None] # NHWC [B, 4g, 4g, 1]
197+
high_res_masks = _upsample_nhwc(low_res_masks, 4) # NHWC [B, 16g, 16g, 1] for the memory encoder
198+
199+
# Object pointer from the SAM token, gated by object presence (occlusion handling).
200+
sam_token = decoded.sam_tokens_out[:, 0, 0] # [B, C]
201+
obj_ptr = self.object_pointer_proj(sam_token)
202+
is_obj = (decoded.object_score_logits[:, 0] > 0).astype(obj_ptr.dtype) # [B, 1]
203+
obj_ptr = is_obj * obj_ptr + (1.0 - is_obj) * self.no_object_pointer
204+
205+
maskmem_features = maskmem_pos_enc = None
206+
if run_mem_encoder:
207+
mask_for_mem = mx.sigmoid(high_res_masks)
208+
mask_for_mem = mask_for_mem * self.config.sigmoid_scale_for_mem_enc + self.config.sigmoid_bias_for_mem_enc
209+
mem = self.memory_encoder(conditioned, mask_for_mem)
210+
maskmem_features = mem.vision_features
211+
maskmem_pos_enc = mem.vision_pos_enc
212+
213+
return Sam3TrackerStageOutput(
214+
low_res_masks=low_res_masks,
215+
high_res_masks=high_res_masks,
216+
iou_pred=decoded.iou_pred,
217+
object_score_logits=decoded.object_score_logits,
218+
obj_ptr=obj_ptr,
219+
maskmem_features=maskmem_features,
220+
maskmem_pos_enc=maskmem_pos_enc,
221+
)
222+
53223

54224
class Sam3VideoModel(nn.Module):
55225
"""Faithful SAM 3 video model: detector + tracker + tracker neck (1797 tensors)."""
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Slice 12: faithful SAM3 tracker per-frame ``track_step`` (single object).
2+
3+
Weight-free structural verification of the per-frame memory-propagation step that
4+
``Sam3TrackerVideoModel`` now exposes (mirrors the SAM2-style tracker loop with the
5+
faithful slice 8-10 subsystems):
6+
7+
- the init/no-memory path (seed box prompt) produces coherent mask / pointer / memory
8+
shapes and finite values;
9+
- the memory-conditioned path (a prior frame's memory + object pointer fed back through
10+
the 4-layer memory-attention transformer) runs and stays finite;
11+
- the step is deterministic (identical inputs -> identical outputs).
12+
13+
Numeric parity vs the upstream reference is the deferred, out-of-sandbox gate
14+
(see parity-status ``sam3_video``); no synthetic pass and no real weights here.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import mlx.core as mx
20+
import pytest
21+
22+
from mlx_cv.models.sam3.real_tracker_decoder import Sam3TrackerPromptEncoderConfig
23+
from mlx_cv.models.sam3.real_video_config import Sam3TrackerVideoConfig
24+
from mlx_cv.models.sam3.real_video_model import Sam3TrackerStageOutput, Sam3TrackerVideoModel
25+
26+
GRID = 4 # tracker feature grid (g); image-embedding + RoPE feat sizes matched to it
27+
CHANNELS = 256
28+
MEM_DIM = 64
29+
30+
31+
def _tiny_tracker() -> Sam3TrackerVideoModel:
32+
"""A faithful tracker on a tiny grid: prompt-encoder grid and RoPE feat sizes == GRID."""
33+
config = Sam3TrackerVideoConfig(
34+
prompt_encoder=Sam3TrackerPromptEncoderConfig(image_size=GRID * 16, patch_size=16),
35+
memory_attention_rope_feat_sizes=(GRID, GRID),
36+
)
37+
model = Sam3TrackerVideoModel(config)
38+
mx.eval(model.parameters())
39+
return model
40+
41+
42+
def _frame_features(seed: int) -> dict:
43+
"""Random per-frame inputs: top-level features + pos enc + two raw high-res FPN levels."""
44+
key = mx.random.key(seed)
45+
k0, k1, k2, k3 = mx.random.split(key, 4)
46+
return {
47+
"vision_features": mx.random.normal((1, GRID, GRID, CHANNELS), key=k0),
48+
"vision_pos": mx.random.normal((1, GRID, GRID, CHANNELS), key=k1),
49+
"high_res_features": [
50+
mx.random.normal((1, GRID * 4, GRID * 4, CHANNELS), key=k2), # 4g-res
51+
mx.random.normal((1, GRID * 2, GRID * 2, CHANNELS), key=k3), # 2g-res
52+
],
53+
}
54+
55+
56+
def _box_prompt() -> tuple[mx.array, mx.array]:
57+
# SAM box encoding: top-left (label 2) + bottom-right (label 3), in input-image pixels.
58+
coords = mx.array([[[8.0, 8.0], [40.0, 40.0]]]) # [1, 2, 2]
59+
labels = mx.array([[2.0, 3.0]]) # [1, 2]
60+
return coords, labels
61+
62+
63+
def _assert_stage_shapes(out: Sam3TrackerStageOutput) -> None:
64+
assert out.low_res_masks.shape == (1, GRID * 4, GRID * 4, 1)
65+
assert out.high_res_masks.shape == (1, GRID * 16, GRID * 16, 1)
66+
assert out.iou_pred.shape == (1, 1, 1)
67+
assert out.object_score_logits.shape == (1, 1, 1)
68+
assert out.obj_ptr.shape == (1, CHANNELS)
69+
assert out.maskmem_features.shape == (1, GRID, GRID, MEM_DIM)
70+
assert out.maskmem_pos_enc.shape == (1, GRID, GRID, MEM_DIM)
71+
for value in (out.low_res_masks, out.high_res_masks, out.obj_ptr, out.maskmem_features):
72+
assert bool(mx.all(mx.isfinite(value)).item())
73+
74+
75+
def test_init_frame_box_prompt_produces_coherent_shapes():
76+
model = _tiny_tracker()
77+
out = model.track_step(is_init_cond_frame=True, point_inputs=_box_prompt(), **_frame_features(0))
78+
_assert_stage_shapes(out)
79+
80+
81+
def test_memory_conditioned_frame_runs_and_is_finite():
82+
model = _tiny_tracker()
83+
init = model.track_step(is_init_cond_frame=True, point_inputs=_box_prompt(), **_frame_features(0))
84+
tracked = model.track_step(is_init_cond_frame=False, previous_frames=[init], **_frame_features(1))
85+
_assert_stage_shapes(tracked)
86+
87+
88+
def test_track_step_is_deterministic():
89+
model = _tiny_tracker()
90+
features = _frame_features(0)
91+
first = model.track_step(is_init_cond_frame=True, point_inputs=_box_prompt(), **features)
92+
second = model.track_step(is_init_cond_frame=True, point_inputs=_box_prompt(), **features)
93+
assert bool(mx.all(first.low_res_masks == second.low_res_masks).item())
94+
assert bool(mx.all(first.obj_ptr == second.obj_ptr).item())
95+
assert bool(mx.all(first.maskmem_features == second.maskmem_features).item())
96+
97+
98+
def test_decoder_exposes_sam_token_for_object_pointer():
99+
"""The additive mask-token return must align with the (single) selected mask."""
100+
model = _tiny_tracker()
101+
out = model.track_step(is_init_cond_frame=True, point_inputs=_box_prompt(), **_frame_features(2))
102+
# obj_ptr is finite and shaped from the SAM token via object_pointer_proj.
103+
assert out.obj_ptr.shape == (1, CHANNELS)
104+
assert bool(mx.all(mx.isfinite(out.obj_ptr)).item())

0 commit comments

Comments
 (0)