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 +7 -0
- tamserve/compare.py +47 -0
- tamserve/engine.py +622 -0
- tamserve/models.py +196 -0
- tamserve/reference.py +47 -0
- tamserve/router.py +554 -0
- tamserve/wire.py +182 -0
- tamserve/worker.py +517 -0
- tamserve-0.2.0.dist-info/METADATA +464 -0
- tamserve-0.2.0.dist-info/RECORD +13 -0
- tamserve-0.2.0.dist-info/WHEEL +4 -0
- tamserve-0.2.0.dist-info/entry_points.txt +2 -0
- tamserve-0.2.0.dist-info/licenses/LICENSE +201 -0
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)
|