tamserve 0.2.0__py3-none-any.whl

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.
tamserve/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Continuous batching for SAM 2-family video trackers: many cameras, one GPU."""
2
+ from tamserve.engine import Box, Clicks, Engine, prepare
3
+ from tamserve.models import MODELS, build_video_predictor
4
+ from tamserve.router import Router
5
+ from tamserve.wire import Mask, Update
6
+
7
+ __all__ = ["Engine", "Router", "Update", "Mask", "Box", "Clicks", "prepare", "build_video_predictor", "MODELS"]
tamserve/compare.py ADDED
@@ -0,0 +1,47 @@
1
+ """How far one run's masks are from a reference run's, per object."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+
6
+ from tamserve.reference import Masks
7
+
8
+
9
+ def only(masks: Masks, obj: int) -> Masks:
10
+ """One object's masks out of a multi-object run."""
11
+ return {f: {obj: m[obj]} for f, m in masks.items() if obj in m}
12
+
13
+
14
+ def compare(ref: Masks, cand: Masks) -> dict:
15
+ """Mask IoU per object per frame, plus objects lost or invented.
16
+
17
+ lost: the reference has the object on a frame and the candidate does not.
18
+ phantom: the reference has nothing there and the candidate does.
19
+ missing / extra: (frame, object) keys on one side only, whatever their masks.
20
+ Frames where both are empty agree and carry no IoU, so check object_frames.
21
+ """
22
+ ref_keys = {(f, o) for f, objs in ref.items() for o in objs}
23
+ cand_keys = {(f, o) for f, objs in cand.items() for o in objs}
24
+ ious, lost, phantom = [], 0, 0
25
+ for f, objs in ref.items():
26
+ for o, r in objs.items():
27
+ c = cand.get(f, {}).get(o)
28
+ if c is None:
29
+ c = torch.zeros_like(r)
30
+ r_any, c_any = bool(r.any()), bool(c.any())
31
+ lost += int(r_any and not c_any)
32
+ phantom += int(c_any and not r_any)
33
+ if r_any or c_any:
34
+ ious.append(float((r & c).sum() / (r | c).sum()))
35
+ for f, o in cand_keys - ref_keys:
36
+ phantom += int(bool(cand[f][o].any()))
37
+ t = torch.tensor(ious) if ious else torch.ones(1)
38
+ return {
39
+ "missing": len(ref_keys - cand_keys),
40
+ "extra": len(cand_keys - ref_keys),
41
+ "object_frames": len(ious),
42
+ "mean_iou": float(t.mean()),
43
+ "p1_iou": float(t.quantile(0.01)),
44
+ "min_iou": float(t.min()),
45
+ "lost": lost,
46
+ "phantom": phantom,
47
+ }
tamserve/engine.py ADDED
@@ -0,0 +1,622 @@
1
+ """Continuous batching for SAM 2-family trackers (EdgeTAM, SAM 2.1). Cameras and
2
+ the objects followed on them come and go independently, and each step is one
3
+ batched pass over every object on every camera with a frame waiting.
4
+
5
+ An object keeps only the memory the model reads: its seed frame, the last 6
6
+ frames' memory and the last 15 frames' object pointers, so it can be followed
7
+ indefinitely. The model code is stock. The engine calls the same encoder, memory
8
+ attention, mask decoder and memory encoder as SAM2VideoPredictor, and builds each
9
+ object's memory exactly as _prepare_memory_conditioned_features does. Objects
10
+ whose memories have the same shape share one memory-attention call; once they
11
+ are 16 frames in, that is all of them.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import gc
16
+ from collections import deque
17
+ from dataclasses import dataclass
18
+ from typing import NamedTuple
19
+
20
+ import numpy as np
21
+ import torch
22
+ import torch.nn.functional as F
23
+ from PIL import Image
24
+
25
+ from tamserve.reference import precision
26
+
27
+ _MEAN = (0.485, 0.456, 0.406)
28
+ _STD = (0.229, 0.224, 0.225)
29
+ # Image-encoder calls: up to _ENCODE_WHOLE frames go in one, more are cut into pieces
30
+ # of _ENCODE_BATCH. EdgeTAM's encoder does the least GPU work per frame at 4 frames
31
+ # (5% faster at 64 cameras on an RTX 4090; SAM 2.1's is within 3% either way), and its
32
+ # activations stay small. Up to 8 frames a second call costs more to launch than it saves.
33
+ _ENCODE_WHOLE, _ENCODE_BATCH = 8, 4
34
+ # Objects per pass through memory attention, the decoder and the memory encoder, whose
35
+ # buffers grow with the object count (SAM 2.1 attends over about 29,000 memory tokens
36
+ # per object). 16 is as fast as 64 on an RTX 4090, and leaves five sizes to compile
37
+ # instead of seven.
38
+ _OBJECT_BATCH = 16
39
+ # Rows up to which a compiled call is replayed as a CUDA graph: one launch instead of
40
+ # hundreds. With one or two rows launching is the limit (220 against 130 frames/s at
41
+ # one camera on an RTX 4090); with more the GPU is, and replaying costs it 4 to 8%.
42
+ _GRAPH_ROWS = 2
43
+ _GRAPH_MODES = ("reduce-overhead", "max-autotune") # the torch.compile modes that use CUDA graphs
44
+ _RESIZE_BATCH = 16 # raw frames resized together: 16 at 1080p take 0.4 GB while they are floats
45
+
46
+
47
+ class Box(NamedTuple):
48
+ """A seed: a box around the object, in the frame's own pixels."""
49
+ x0: float
50
+ y0: float
51
+ x1: float
52
+ y1: float
53
+
54
+
55
+ class Clicks(NamedTuple):
56
+ """A seed: points in the frame's own pixels, (x, y) each. labels: 1 = on the
57
+ object, 0 = not on it; all 1 when left out."""
58
+ xy: list
59
+ labels: list | None = None
60
+
61
+
62
+ def prepare(image, size: int = 1024, device=None):
63
+ """A frame resized on the CPU exactly as stock load_video_frames resizes it, kept
64
+ as uint8, optionally uploaded to `device` so the step does not pay for it. For
65
+ masks that must match stock's bit for bit; otherwise hand step() the frame as it
66
+ is and the engine resizes it on the GPU, which is many times cheaper.
67
+ `image` is a PIL image, an H x W x 3 uint8 RGB array (what video decoders
68
+ give; OpenCV's are BGR and need converting), or the path of an image file.
69
+ Returns (3 x size x size tensor, (height, width))."""
70
+ if isinstance(image, np.ndarray):
71
+ image = Image.fromarray(image)
72
+ if not isinstance(image, Image.Image):
73
+ with Image.open(image) as f:
74
+ return prepare(f.convert("RGB"), size, device)
75
+ pixels = torch.from_numpy(np.array(image.convert("RGB").resize((size, size))))
76
+ pixels = pixels.permute(2, 0, 1).contiguous()
77
+ return (pixels if device is None else pixels.to(device)), (image.height, image.width)
78
+
79
+
80
+ def _resize(x, size):
81
+ """N x H x W x 3 uint8 frames as N x 3 x size x size, resized as PIL resizes
82
+ them: bicubic, width then height, rounded in between. Pixels match prepare()'s
83
+ or differ by 1."""
84
+ x = x.permute(0, 3, 1, 2).float()
85
+ for h in (x.shape[2], size):
86
+ x = F.interpolate(x, size=(h, size), mode="bicubic", antialias=True, align_corners=False)
87
+ x = x.round_().clamp_(0, 255)
88
+ return x.to(torch.uint8)
89
+
90
+
91
+ def _cat_backbone(parts):
92
+ """forward_image outputs for consecutive chunks of frames, joined along the batch."""
93
+ cat = lambda xs: torch.cat(xs) if torch.is_tensor(xs[0]) else [cat(list(x)) for x in zip(*xs)]
94
+ return {k: cat([p[k] for p in parts]) for k in parts[0]}
95
+
96
+
97
+ def _each(fn, x):
98
+ """fn applied to every tensor inside nested lists, tuples and dicts."""
99
+ if torch.is_tensor(x):
100
+ return fn(x)
101
+ if isinstance(x, dict):
102
+ return {k: _each(fn, v) for k, v in x.items()}
103
+ return type(x)(_each(fn, v) for v in x) if isinstance(x, (list, tuple)) else x
104
+
105
+
106
+ def _version(t):
107
+ """Counts in-place writes to `t`; None for inference tensors, which have no count."""
108
+ return None if t.is_inference() else t._version
109
+
110
+
111
+ def _tokens(x):
112
+ """Memory as (objects, tokens, channels). EdgeTAM's perceiver already gives
113
+ tokens; SAM 2.1 gives (objects, channels, H, W) maps, flattened as stock does."""
114
+ return x.flatten(2).permute(0, 2, 1) if x.ndim == 4 else x
115
+
116
+
117
+ @dataclass
118
+ class _Track:
119
+ """One object's memory, one row in each tensor."""
120
+ cond_mem: torch.Tensor # its seed frame
121
+ cond_ptr: torch.Tensor
122
+ mem: deque # recent frames, oldest first
123
+ ptr: deque
124
+ t: int = 0 # frames followed since the seed frame
125
+
126
+
127
+ class Engine:
128
+ """Follow objects on many cameras at once.
129
+
130
+ step() takes one frame per camera and returns every followed object's mask
131
+ on it. Objects start through `seeds`, on any step, and stop through drop();
132
+ the others on their camera carry on undisturbed.
133
+
134
+ exact: seed each object on its own and follow a step's objects in one pass,
135
+ as stock does, so one camera matches stock bit for bit. Otherwise objects
136
+ that start together are seeded together and objects go 16 to a pass,
137
+ which differs from stock only by GPU rounding.
138
+ compile: compile the modules that follow objects with torch.compile: True for
139
+ the fastest setup measured (tuned kernels, plus CUDA graphs for calls of one
140
+ or two rows), or one torch.compile mode's name for every call ("default" is
141
+ ready soonest). Every batch is then cut into power-of-two pieces, so each
142
+ module sees only a few sizes, and all of them are compiled here so that no
143
+ later step stalls: about 20 minutes the first time on a machine for True,
144
+ under 3 for "default", and 1 to 2 once PyTorch has them cached on disk.
145
+ Seeding stays uncompiled.
146
+ gpu_masks: return masks on the GPU instead of copying them to the CPU.
147
+ """
148
+
149
+ def __init__(self, predictor, *, bf16: bool = True, exact: bool = False, compile: bool | str = False,
150
+ gpu_masks: bool = False):
151
+ m = predictor
152
+ # The memory layout below mirrors stock for this configuration only.
153
+ if not (m.num_maskmem > 0 and m.use_obj_ptrs_in_encoder and m.memory_temporal_stride_for_eval == 1
154
+ and m.use_mask_input_as_output_without_sam and not m.non_overlap_masks
155
+ and not m.non_overlap_masks_for_mem_enc and not m.training):
156
+ raise ValueError("unsupported predictor configuration; use build_video_predictor")
157
+ self.model = m
158
+ self.bf16 = bf16
159
+ self.exact = exact
160
+ self.gpu_masks = gpu_masks
161
+ self.device = m.device
162
+ self.tracks: dict = {} # (camera id, object id) -> _Track, in the order objects started
163
+ self._pos = None # memory positional encoding: the same for every frame and object
164
+ self._last = None # the previous step's frames and their features
165
+ self._stage: dict = {} # frame shape -> page-locked buffer raw frames are uploaded from
166
+ # Stock divides by 255 in float64 on the CPU, then normalises in float32 on
167
+ # the device. A 256-entry table reproduces the first step exactly; CUDA
168
+ # divides by a scalar through its reciprocal, which rounds differently.
169
+ self._unit = (torch.arange(256, dtype=torch.float64) / 255.0).float().to(self.device)
170
+ self._mean = torch.tensor(_MEAN, device=self.device).view(3, 1, 1)
171
+ self._std = torch.tensor(_STD, device=self.device).view(3, 1, 1)
172
+ self._blank = None # the prompt encoder's output when there is no prompt
173
+ # module -> the forward to follow objects with, in place of stock's
174
+ self._fast: dict = {m.sam_prompt_encoder: self._no_prompt,
175
+ m.image_encoder.neck.position_encoding: self._one_pos}
176
+ # ...and the forward each had on the instance (usually none), to put back.
177
+ self._was: dict = {mod: vars(mod).get("forward") for mod in self._fast}
178
+ self._attention = None # stock's attention module, once compiled
179
+ self.compiled = bool(compile)
180
+ if compile:
181
+ self._compile(compile)
182
+
183
+ def _compile(self, mode):
184
+ from tamserve_sam2.modeling.sam import transformer
185
+
186
+ self._warm(2) # uncompiled first: fills position encodings the modules cache
187
+ # Stock pins its attention kernel through a context manager the compiler cannot
188
+ # trace, which cuts the mask decoder into 18 graphs and memory attention into 8.
189
+ # With stock's own fallback switch, PyTorch picks the kernel and each is one graph.
190
+ # _use() sets the switch while the compiled modules run and puts it back after.
191
+ self._attention, self._pinned = transformer, transformer.ALLOW_ALL_KERNELS
192
+ torch._dynamo.config.cache_size_limit = 64
193
+ # The mode for calls of up to _GRAPH_ROWS rows, and for larger ones.
194
+ modes = ("max-autotune", "max-autotune-no-cudagraphs") if mode is True else (mode, mode)
195
+
196
+ def wrap(fn, dynamic, rows):
197
+ few = torch.compile(fn, mode=modes[0], dynamic=dynamic)
198
+ many = few if modes[0] == modes[1] else torch.compile(fn, mode=modes[1], dynamic=dynamic)
199
+
200
+ def run(*a, **k):
201
+ which = rows(a, k) > _GRAPH_ROWS
202
+ # Compact copies of the inputs: a view's layout depends on how the batch
203
+ # was cut, and the compiler would compile again for each layout.
204
+ out = (few, many)[which](*_each(torch.Tensor.contiguous, a), **_each(torch.Tensor.contiguous, k))
205
+ # A CUDA graph reuses its output buffers on the next call.
206
+ return _each(torch.Tensor.clone, out) if modes[which] in _GRAPH_MODES else out
207
+ return run
208
+
209
+ # How many rows a call has: the first argument's, unless the module is called by keyword.
210
+ rows = {"memory_attention": lambda a, k: k["memory"].shape[1],
211
+ "sam_mask_decoder": lambda a, k: k["image_embeddings"].shape[0]}
212
+ for name in ("image_encoder", "spatial_perceiver", "memory_attention", "sam_mask_decoder", "memory_encoder"):
213
+ mod = getattr(self.model, name, None)
214
+ if mod is not None:
215
+ self._was[mod] = vars(mod).get("forward")
216
+ # Memory length changes while an object is young; batch sizes are the pieces'.
217
+ self._fast[mod] = wrap(mod.forward, name == "memory_attention",
218
+ rows.get(name, lambda a, k: a[0].shape[0]))
219
+ k = 1
220
+ while k <= _OBJECT_BATCH:
221
+ self._warm(k)
222
+ k *= 2
223
+ torch.cuda.empty_cache() # hand back what following 64 objects at once reserved
224
+
225
+ def _warm(self, k):
226
+ """Follow one object on each of k cameras of noise until their memories are full
227
+ (so every memory length is seen at this size), then forget them."""
228
+ n = self.model.image_size
229
+ seed = torch.zeros(n, n, dtype=torch.bool)
230
+ seed[n // 4:n // 2, n // 4:n // 2] = True
231
+ frames = lambda: {i: (torch.randint(0, 256, (3, n, n), dtype=torch.uint8, device=self.device), (n, n))
232
+ for i in range(k)}
233
+ keep, self.tracks = self.tracks, {}
234
+ self.step(frames(), {i: {0: seed} for i in range(k)})
235
+ for _ in range(self.model.max_obj_ptrs_in_encoder):
236
+ self.step(frames())
237
+ self.tracks, self._last = keep, None
238
+
239
+ def _use(self, fast):
240
+ """Point the modules at the forward objects are followed with, or back at what
241
+ they had: the predictor is shared with stock."""
242
+ for mod, fn in self._fast.items():
243
+ if fast:
244
+ mod.forward = fn
245
+ elif self._was[mod] is None:
246
+ vars(mod).pop("forward", None)
247
+ else:
248
+ mod.forward = self._was[mod]
249
+ if self._attention is not None:
250
+ self._attention.ALLOW_ALL_KERNELS = fast or self._pinned
251
+
252
+ def _no_prompt(self, points, boxes, masks):
253
+ """The prompt encoder's output for objects followed without a prompt: the same
254
+ for each, so worked out once. Stock redoes it on every frame with masked
255
+ writes, and each one makes the CPU wait for the GPU to finish its queue."""
256
+ assert boxes is None and masks is None, "only objects followed without a prompt come through here"
257
+ if self._blank is None:
258
+ enc = self.model.sam_prompt_encoder
259
+ self._blank = type(enc).forward(enc, (points[0][:1], points[1][:1]), None, None)
260
+ sparse, dense = self._blank
261
+ n = points[0].shape[0]
262
+ return sparse.expand(n, -1, -1), dense.expand(n, -1, -1, -1)
263
+
264
+ def _one_pos(self, x):
265
+ """The image encoder's position encoding for one frame instead of one per
266
+ frame. They are all the same, 88 MB a frame at 1024 x 1024, and one row is read."""
267
+ enc = self.model.image_encoder.neck.position_encoding
268
+ return type(enc).forward(enc, x[:1])
269
+
270
+ def _index(self, values):
271
+ """`values` as a tensor on the device. A plain copy to the GPU waits for
272
+ everything queued on it; from pinned memory it does not."""
273
+ idx = torch.tensor(values)
274
+ if self.device.type != "cuda":
275
+ return idx.to(self.device)
276
+ return idx.pin_memory().to(self.device, non_blocking=True)
277
+
278
+ def _pieces(self, n, cap):
279
+ """Slices covering n rows, at most `cap` each; powers of two when compiled."""
280
+ i = 0
281
+ while i < n:
282
+ size = min(cap, n - i)
283
+ if self.compiled:
284
+ size = 1 << (size.bit_length() - 1)
285
+ yield slice(i, i + size)
286
+ i += size
287
+
288
+ def drop(self, cam, obj=None) -> None:
289
+ """Stop following an object; with no object, every object on the camera."""
290
+ for key in [k for k in self.tracks if k[0] == cam and obj in (None, k[1])]:
291
+ del self.tracks[key]
292
+ if not self.tracks:
293
+ self._last = None # free the cached features
294
+
295
+ def step(self, frames: dict, seeds: dict | None = None) -> dict:
296
+ """One batched pass: every object on every camera here advances one frame.
297
+
298
+ frames: camera id -> the frame as a decoder gives it (an H x W x 3 uint8
299
+ RGB array), which the engine uploads and resizes on its device, or
300
+ prepare() output.
301
+ seeds: camera id -> {object id: bool mask, Box or Clicks}: objects that
302
+ start on this frame, or start again if they are already followed.
303
+ Returns camera id -> {object id: bool mask at the frame's own size}.
304
+ """
305
+ seeds = seeds or {}
306
+ new = {(c, o) for c in seeds for o in seeds[c]}
307
+ return self._run(frames, seeds, [k for k in self.tracks if k[0] in frames and k not in new], frames, False)
308
+
309
+ def add(self, frames: dict, seeds: dict) -> dict:
310
+ """Start objects (or start them again) on frames step() has already been
311
+ given, without advancing the others: for prompts decided after seeing a
312
+ step's masks. Frames from prepare() that step() was just given are not
313
+ encoded again; raw frames are.
314
+ Returns the started objects' masks."""
315
+ return self._run(frames, seeds, [], seeds, True)
316
+
317
+ @torch.inference_mode()
318
+ def _run(self, frames, seeds, following, cams, reuse):
319
+ frameless = [c for c in seeds if c not in frames]
320
+ if frameless:
321
+ raise KeyError(f"seeds need the frame they apply to: {frameless}")
322
+ starting = [(c, o) for c in seeds for o in seeds[c]]
323
+ out = {c: {} for c in cams}
324
+ live = {c for c, _ in starting + following}
325
+ busy = [c for c in frames if c in live]
326
+ if not busy:
327
+ return out # nothing to follow: the frames are not even read
328
+ # Objects of one age side by side, so each memory-attention call is as large as it can be.
329
+ following.sort(key=lambda k: (len(self.tracks[k].mem), len(self.tracks[k].ptr)))
330
+ frames = self._prepared(frames, busy)
331
+ hw = {c: tuple(frames[c][1]) for c in busy}
332
+ # Stock takes all of a video's objects in one pass, whatever their number.
333
+ cap = len(starting) + len(following) if self.exact else _OBJECT_BATCH
334
+ # Python's garbage collector, run in the middle of a step, can free something that
335
+ # touches the GPU while compiled code is being recorded as a CUDA graph, and CUDA then
336
+ # abandons the recording. It waits until the step is over.
337
+ collecting = gc.isenabled()
338
+ gc.disable()
339
+ try:
340
+ with precision(self.device.type, self.bf16):
341
+ feats, pos, sizes, cols = self._encode([frames[c][0] for c in busy], reuse)
342
+ col, low = dict(zip(busy, cols)), {}
343
+ self._use(False)
344
+ for i in range(0, len(starting), cap):
345
+ low.update(self._seed(starting[i:i + cap], col, feats, sizes, seeds, hw))
346
+ self._use(True)
347
+ for i in range(0, len(following), cap):
348
+ low.update(self._track(following[i:i + cap], col, feats, pos, sizes))
349
+ return self._outputs(low, hw, out)
350
+ finally:
351
+ self._use(False) # the predictor is shared: leave it as stock has it
352
+ if collecting:
353
+ gc.enable()
354
+
355
+ def _prepared(self, frames, busy):
356
+ """The busy cameras' frames as prepare() gives them. Raw frames are resized
357
+ here, on the engine's device: frames of one size are uploaded together and
358
+ resized as PIL does (_resize), so pixels match stock's or differ by 1."""
359
+ out, sizes = {}, {}
360
+ for c in busy:
361
+ out[c] = frames[c]
362
+ if not isinstance(out[c], tuple):
363
+ out[c] = np.asarray(out[c])
364
+ if out[c].dtype != np.uint8 or out[c].ndim != 3 or out[c].shape[2] != 3:
365
+ raise ValueError(f"camera {c!r}: a frame is an H x W x 3 uint8 array or prepare() output")
366
+ sizes.setdefault(out[c].shape, []).append(c)
367
+ for shape, cams in sizes.items():
368
+ for k in range(0, len(cams), _RESIZE_BATCH):
369
+ chunk = cams[k:k + _RESIZE_BATCH]
370
+ x = _resize(self._upload([out[c] for c in chunk]), self.model.image_size)
371
+ for j, c in enumerate(chunk):
372
+ out[c] = (x[j], shape[:2])
373
+ return out
374
+
375
+ def _upload(self, arrays):
376
+ """Frames of one shape on the device, in one upload. On CUDA they go through a
377
+ buffer kept for that shape in page-locked memory (memory the system will not
378
+ move, so the GPU reads it directly)."""
379
+ if self.device.type != "cuda":
380
+ return torch.from_numpy(np.stack(arrays)).to(self.device)
381
+ shape = arrays[0].shape
382
+ buf = self._stage.get(shape)
383
+ if buf is None or len(buf) < len(arrays):
384
+ buf = self._stage[shape] = torch.empty((len(arrays), *shape), dtype=torch.uint8).pin_memory()
385
+ host = buf.numpy()
386
+ for i, a in enumerate(arrays):
387
+ host[i] = a
388
+ return buf[:len(arrays)].to(self.device) # waits for the copy, so the buffer can be refilled
389
+
390
+ def _normalize(self, images):
391
+ x = torch.stack(images).to(self.device, non_blocking=True)
392
+ return (self._unit[x.int()] - self._mean) / self._std
393
+
394
+ def _encode(self, images, reuse):
395
+ """Features for `images` and each image's column in them. With `reuse`
396
+ (add() after step()), frames the last call encoded are not encoded again;
397
+ the version check catches a buffer torch saw being refilled since."""
398
+ if reuse and self._last is not None:
399
+ cols = [next((i for i, (t, v) in enumerate(self._last[0])
400
+ if t is im and v is not None and v == _version(im)), None)
401
+ for im in images]
402
+ if None not in cols:
403
+ return (*self._last[1:], cols)
404
+ self._last = None # free the previous step's features before making this one's
405
+ self._use(True)
406
+ cap = len(images) if len(images) <= _ENCODE_WHOLE else _ENCODE_BATCH
407
+ parts = [self.model.forward_image(self._normalize(images[p])) for p in self._pieces(len(images), cap)]
408
+ backbone = parts[0] if len(parts) == 1 else _cat_backbone(parts)
409
+ _, feats, pos, sizes = self.model._prepare_backbone_features(backbone)
410
+ # Position encodings depend only on the frame size, so every frame's are the
411
+ # same (_one_pos): keep one copy of the level memory attention reads.
412
+ pos = pos[-1][:, :1].clone()
413
+ self._last = ([(im, _version(im)) for im in images], feats, pos, sizes)
414
+ return feats, pos, sizes, list(range(len(images)))
415
+
416
+ def _rows(self, xs, keys, col):
417
+ """Each object's camera's column of (tokens, frames, channels) features,
418
+ every object's tokens kept together as stock lays them out: the GPU rounds
419
+ differently on other layouts."""
420
+ idx = self._index([col[c] for c, _ in keys])
421
+ return [x.permute(1, 0, 2)[idx].permute(1, 0, 2) for x in xs]
422
+
423
+ @staticmethod
424
+ def _high_res(feats, sizes):
425
+ return [x.permute(1, 2, 0).view(x.size(1), x.size(2), *s)
426
+ for x, s in zip(feats[:-1], sizes[:-1])]
427
+
428
+ def _fill_holes(self, low):
429
+ if self.model.fill_hole_area > 0:
430
+ from tamserve_sam2.utils.misc import fill_holes_in_mask_scores
431
+ return fill_holes_in_mask_scores(low, self.model.fill_hole_area)
432
+ return low
433
+
434
+ def _remember_pos(self, mem_pos):
435
+ if self._pos is None:
436
+ self._pos = _tokens(mem_pos[-1][0:1]).clone()
437
+
438
+ def _outputs(self, low, hw, out):
439
+ """Fill `out` with each object's mask at its camera's frame size. Objects on
440
+ frames of one size are resized, and copied off the GPU, together."""
441
+ groups: dict = {}
442
+ for key in low:
443
+ groups.setdefault(hw[key[0]], []).append(key)
444
+ for size, keys in groups.items():
445
+ for i in range(0, len(keys), _OBJECT_BATCH):
446
+ run = keys[i:i + _OBJECT_BATCH]
447
+ x = low[run[0]] if len(run) == 1 else torch.cat([low[k] for k in run])
448
+ if tuple(x.shape[-2:]) != size:
449
+ x = F.interpolate(x, size=size, mode="bilinear", align_corners=False)
450
+ masks = x[:, 0] > 0 if self.gpu_masks else (x[:, 0] > 0).cpu()
451
+ for j, (c, o) in enumerate(run):
452
+ out[c][o] = masks[j]
453
+ return out
454
+
455
+ def _prompt(self, seed, size):
456
+ """A seed as stock's add_new_mask / add_new_points_or_box prepares it: a
457
+ 1 x 1 x n x n mask, or (point coordinates, point labels) at the model's size."""
458
+ n = self.model.image_size
459
+ if isinstance(seed, (Box, Clicks)):
460
+ if isinstance(seed, Box):
461
+ xy, labels = torch.tensor(seed, dtype=torch.float32).reshape(1, 2, 2), [2, 3] # corners
462
+ else:
463
+ xy = torch.tensor(seed.xy, dtype=torch.float32).reshape(1, -1, 2)
464
+ labels = seed.labels if seed.labels is not None else [1] * xy.shape[1]
465
+ xy = xy / torch.tensor([size[1], size[0]]) * n
466
+ return xy.to(self.device), torch.tensor(labels, dtype=torch.int32, device=self.device).reshape(1, -1)
467
+ x = torch.as_tensor(seed)[None, None].float().to(self.device)
468
+ if tuple(x.shape[-2:]) != (n, n):
469
+ x = F.interpolate(x, size=(n, n), align_corners=False, mode="bilinear", antialias=True)
470
+ x = (x >= 0.5).float()
471
+ return x
472
+
473
+ def _seed(self, keys, col, feats, sizes, seeds, hw):
474
+ """Stock add_new_mask / add_new_points_or_box + the first memory encoding,
475
+ for each starting object.
476
+
477
+ Stock seeds each object on its own (batch 1), then encodes the seed memory
478
+ of a video's objects together. GPU rounding depends on batch size, so with
479
+ exact=True the engine keeps those batch sizes; otherwise objects prompted
480
+ the same way go through each call together.
481
+ """
482
+ m = self.model
483
+ n = m.image_size
484
+ k = len(keys)
485
+ vf = self._rows(feats, keys, col)
486
+ hrf = self._high_res(vf, sizes)
487
+ prompts = [self._prompt(seeds[c][o], hw[c]) for c, o in keys]
488
+
489
+ # Masks together, and points by how many there are (a box is two).
490
+ groups: dict = {}
491
+ for i, p in enumerate(prompts):
492
+ kind = i if self.exact else ("mask" if torch.is_tensor(p) else p[0].shape[1])
493
+ groups.setdefault(kind, []).append(i)
494
+ heads = [None] * k
495
+ for idx in groups.values():
496
+ run = idx == list(range(idx[0], idx[-1] + 1)) # a slice keeps stock's memory layout
497
+ sel = slice(idx[0], idx[-1] + 1) if run else self._index(idx)
498
+ high_res = [h[sel] for h in hrf]
499
+ if torch.is_tensor(prompts[idx[0]]):
500
+ pix = vf[-1][:, sel].permute(1, 2, 0).view(len(idx), m.hidden_dim, *sizes[-1])
501
+ out = m._use_mask_as_output(pix, high_res, torch.cat([prompts[i] for i in idx]))
502
+ else:
503
+ pts = {"point_coords": torch.cat([prompts[i][0] for i in idx]),
504
+ "point_labels": torch.cat([prompts[i][1] for i in idx])}
505
+ pix = (vf[-1][:, sel] + m.no_mem_embed).permute(1, 2, 0).view(len(idx), m.hidden_dim, *sizes[-1])
506
+ out = m._forward_sam_heads(backbone_features=pix, point_inputs=pts, mask_inputs=None,
507
+ high_res_features=high_res,
508
+ multimask_output=m._use_multimask(True, pts))
509
+ for j, i in enumerate(idx):
510
+ heads[i] = (out[3][j:j + 1], out[5][j:j + 1], out[6][j:j + 1])
511
+ low = self._fill_holes(torch.cat([h[0] for h in heads]))
512
+ obj_ptr = torch.cat([h[1] for h in heads])
513
+ score = torch.cat([h[2] for h in heads])
514
+ high = F.interpolate(low, size=(n, n), mode="bilinear", align_corners=False)
515
+ spans, i = [slice(0, k)], 0
516
+ if self.exact: # one camera's objects at a time
517
+ spans = []
518
+ while i < k:
519
+ j = next((j for j in range(i, k) if keys[j][0] != keys[i][0]), k)
520
+ spans.append(slice(i, j))
521
+ i = j
522
+ mem = []
523
+ for p in spans:
524
+ mm, mem_pos = m._encode_new_memory(
525
+ current_vision_feats=[x[:, p] for x in vf], feat_sizes=sizes,
526
+ pred_masks_high_res=high[p], object_score_logits=score[p], is_mask_from_pts=True)
527
+ self._remember_pos(mem_pos)
528
+ mem.append(mm)
529
+ mem = _tokens(torch.cat(mem).to(torch.bfloat16))
530
+ for i, key in enumerate(keys):
531
+ # Stock consolidates seed pointers into a float32 buffer.
532
+ self.tracks[key] = _Track(cond_mem=mem[i:i + 1], cond_ptr=obj_ptr[i:i + 1].float(),
533
+ mem=deque(maxlen=m.num_maskmem - 1),
534
+ ptr=deque(maxlen=m.max_obj_ptrs_in_encoder - 1))
535
+ return {key: low[i:i + 1] for i, key in enumerate(keys)}
536
+
537
+ def _track(self, keys, col, feats, pos, sizes):
538
+ """Stock track_step for every followed object: memory attention per memory
539
+ shape, then the mask decoder and the memory encoder over all of them."""
540
+ m = self.model
541
+ buckets: dict = {}
542
+ for key in keys:
543
+ st = self.tracks[key]
544
+ buckets.setdefault((len(st.mem), len(st.ptr)), []).append(key)
545
+ order = [key for b in sorted(buckets) for key in buckets[b]]
546
+ vf, vp = self._rows(feats, order, col), pos.expand(-1, len(order), -1)
547
+
548
+ pix, start = [], 0
549
+ for b in sorted(buckets):
550
+ group = buckets[b]
551
+ pix.append(self._attend(group, vf[-1][:, start:start + len(group)],
552
+ vp[:, start:start + len(group)], sizes[-1]))
553
+ start += len(group)
554
+ pix, hrf, parts = torch.cat(pix), self._high_res(vf, sizes), []
555
+ for p in self._pieces(len(order), len(order)):
556
+ _, _, _, low, high, obj_ptr, score = m._forward_sam_heads(
557
+ backbone_features=pix[p], point_inputs=None, mask_inputs=None,
558
+ high_res_features=[h[p] for h in hrf],
559
+ multimask_output=m._use_multimask(False, None))
560
+ mem, mem_pos = m._encode_new_memory(
561
+ current_vision_feats=[x[:, p] for x in vf], feat_sizes=sizes, pred_masks_high_res=high,
562
+ object_score_logits=score, is_mask_from_pts=False)
563
+ parts.append((low, obj_ptr, mem))
564
+ low, obj_ptr, mem = parts[0] if len(parts) == 1 else (torch.cat(x) for x in zip(*parts))
565
+ mem = _tokens(mem.to(torch.bfloat16))
566
+ low = self._fill_holes(low)
567
+ for i, key in enumerate(order):
568
+ st = self.tracks[key]
569
+ st.t += 1
570
+ st.mem.append(mem[i:i + 1])
571
+ st.ptr.append(obj_ptr[i:i + 1])
572
+ return {key: low[i:i + 1] for i, key in enumerate(order)}
573
+
574
+ def _attend(self, group, curr, curr_pos, size):
575
+ """_prepare_memory_conditioned_features for objects whose memories have the
576
+ same shape. Pointer distances, which depend on an object's age, only matter
577
+ when a model enables them; EdgeTAM does not (add_tpos_enc_to_obj_ptrs)."""
578
+ from tamserve_sam2.modeling.sam2_utils import get_1d_sine_pe
579
+
580
+ m = self.model
581
+ sts = [self.tracks[key] for key in group]
582
+ B, C = curr.shape[1], m.hidden_dim
583
+ n_mem, n_ptr = len(sts[0].mem), len(sts[0].ptr)
584
+
585
+ # Spatial memory: seed frame (t_pos 0), then recent frames oldest first;
586
+ # frame u-k has t_pos num_maskmem-k.
587
+ slots = [torch.cat([st.cond_mem for st in sts])]
588
+ slots += [torch.cat([st.mem[j] for st in sts]) for j in range(n_mem)]
589
+ t_pos = [0] + [m.num_maskmem - n_mem + j for j in range(n_mem)]
590
+ base = self._pos.expand(B, -1, -1).permute(1, 0, 2)
591
+ memory = [x.permute(1, 0, 2) for x in slots]
592
+ memory_pos = [base + m.maskmem_tpos_enc[m.num_maskmem - tp - 1] for tp in t_pos]
593
+
594
+ # Object pointers: seed frame first, then frames u-1, u-2, ... newest first.
595
+ ptrs = [torch.cat([st.cond_ptr for st in sts])]
596
+ ptrs += [torch.cat([st.ptr[-d] for st in sts]) for d in range(1, n_ptr + 1)]
597
+ obj_ptrs = torch.stack(ptrs)
598
+ if m.add_tpos_enc_to_obj_ptrs:
599
+ # A camera has no last frame, so stock's min(num_frames, max) is the max.
600
+ t_diff_max = m.max_obj_ptrs_in_encoder - 1
601
+ tpos_dim = C if m.proj_tpos_enc_in_obj_ptrs else m.mem_dim
602
+ # One projection per age, shared by the objects of that age, as stock
603
+ # shares it across a video's objects: GPU matmul rounding depends on the row count.
604
+ ages: dict = {}
605
+ for st in sts:
606
+ if st.t not in ages:
607
+ dist = self._index([st.t + 1] + list(range(1, n_ptr + 1)))
608
+ ages[st.t] = m.obj_ptr_tpos_proj(get_1d_sine_pe(dist / t_diff_max, dim=tpos_dim)).unsqueeze(1)
609
+ obj_pos = torch.cat([ages[st.t] for st in sts], dim=1)
610
+ else:
611
+ obj_pos = obj_ptrs.new_zeros(len(ptrs), B, m.mem_dim)
612
+ if m.mem_dim < C:
613
+ obj_ptrs = obj_ptrs.reshape(-1, B, C // m.mem_dim, m.mem_dim)
614
+ obj_ptrs = obj_ptrs.permute(0, 2, 1, 3).flatten(0, 1)
615
+ obj_pos = obj_pos.repeat_interleave(C // m.mem_dim, dim=0)
616
+
617
+ memory, memory_pos = torch.cat(memory + [obj_ptrs]), torch.cat(memory_pos + [obj_pos])
618
+ parts = [m.memory_attention(
619
+ curr=[curr[:, p]], curr_pos=[curr_pos[:, p]], memory=memory[:, p], memory_pos=memory_pos[:, p],
620
+ num_obj_ptr_tokens=obj_ptrs.shape[0], num_spatial_mem=len(slots)) for p in self._pieces(B, B)]
621
+ out = parts[0] if len(parts) == 1 else torch.cat(parts, dim=1)
622
+ return out.permute(1, 2, 0).view(B, C, *size)