File size: 16,788 Bytes
0bdbf4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
"""Tracking tools (Phase-A: Mock implementation, opt-in real SAM2.1).

A real :class:`SAM2Tracker` is provided that uses
``sam2.SAM2ImagePredictor`` with the SAM2.1-Hiera-Large checkpoint to
predict object masks frame by frame. Tracking is a simple bbox-prompt
chain (re-prompt each frame with the previous frame's predicted bbox);
sufficient for "where did this object go" queries on streaming tasks.

Activation: set ``STREAMO_TRACKER_BACKEND=sam2`` (else falls back to
:class:`MockTracker`). Optional env vars:

* ``STREAMO_SAM2_CKPT`` (default
  ``checkpoints/sam2.1_hiera_large.pt``)
* ``STREAMO_SAM2_CONFIG`` (default ``configs/sam2.1/sam2.1_hiera_l.yaml``)
* ``STREAMO_SAM2_DEVICE`` (default ``cuda`` if available else ``cpu``)
"""
from __future__ import annotations

import os
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

from .base import Tool, ToolContext, ToolResult

try:
    from PIL import Image
except Exception:  # pragma: no cover
    Image = None  # type: ignore


# ---------------------------------------------------------------------------
# Tracker adapter interface
# ---------------------------------------------------------------------------
class TrackerAdapter:
    """Minimal interface every tracker (mock or SAM2) must satisfy."""

    def init(self, frame_path: str, bbox: List[float],
             text_prompt: Optional[str] = None) -> Dict[str, Any]:
        raise NotImplementedError

    def query(self, session: Dict[str, Any],
              frame_path: str) -> Dict[str, Any]:
        raise NotImplementedError

    def end(self, session: Dict[str, Any]) -> Dict[str, Any]:
        return {"finalized": True}


@dataclass
class MockTracker(TrackerAdapter):
    decay_per_step: float = 0.04
    cutoff: float = 0.2

    def init(self, frame_path, bbox, text_prompt=None):
        if Image is None or not os.path.exists(frame_path):
            w = h = 0
        else:
            try:
                w, h = Image.open(frame_path).size
            except Exception:
                w = h = 0
        return {
            "init_frame": frame_path,
            "init_bbox": list(bbox),
            "prompt": text_prompt,
            "frame_size": [w, h],
            "step": 0,
            "score": 1.0,
        }

    def query(self, session, frame_path):
        session["step"] = int(session.get("step", 0)) + 1
        score = max(0.0, 1.0 - self.decay_per_step * session["step"])
        session["score"] = score
        in_view = score >= self.cutoff
        return {
            "bbox": session["init_bbox"] if in_view else None,
            "score": round(score, 3),
            "in_view": in_view,
        }


# ---------------------------------------------------------------------------
# SAM2-backed adapter (opt-in)
# ---------------------------------------------------------------------------
class SAM2Tracker(TrackerAdapter):
    """SAM2.1 image-predictor based tracker.

    Strategy (deliberately simple):
      * On ``init``: build SAM2 predictor lazily; run on the init frame
        with the user-provided bbox prompt; remember the resulting mask
        bbox as ``last_bbox``.
      * On each ``query(t)``: re-set image to frame_t, prompt with
        ``last_bbox`` (treated as a box prompt → SAM2 returns a refined
        mask); update ``last_bbox`` for the next call.
      * Confidence is taken from SAM2's IoU prediction.
      * If the predicted mask is empty / very small, mark as out of view.

    Note: this is **not** the full video propagation predictor (which
    requires all frames preloaded). For our agent's "look forward 2-3
    seconds, did the object leave the frame" use case it's accurate
    enough and runs on a single frame at a time.
    """

    def __init__(self, ckpt_path: Optional[str] = None,
                 config_file: Optional[str] = None,
                 device: Optional[str] = None) -> None:
        self.ckpt_path = ckpt_path or os.environ.get(
            "STREAMO_SAM2_CKPT",
            "checkpoints/sam2.1_hiera_large.pt",
        )
        self.config_file = config_file or os.environ.get(
            "STREAMO_SAM2_CONFIG",
            "configs/sam2.1/sam2.1_hiera_l.yaml",
        )
        self.device = device or os.environ.get("STREAMO_SAM2_DEVICE")
        self._predictor = None
        self._lock = threading.Lock()

    # -- helpers ------------------------------------------------------
    def _resolve_device(self) -> str:
        if self.device:
            return self.device
        try:
            import torch
            return "cuda" if torch.cuda.is_available() else "cpu"
        except Exception:
            return "cpu"

    def _ensure_predictor(self):
        if self._predictor is None:
            with self._lock:
                if self._predictor is None:
                    from sam2.build_sam import build_sam2
                    from sam2.sam2_image_predictor import SAM2ImagePredictor
                    device = self._resolve_device()
                    model = build_sam2(
                        config_file=self.config_file,
                        ckpt_path=self.ckpt_path,
                        device=device,
                    )
                    self._predictor = SAM2ImagePredictor(model)
                    self._device = device
        return self._predictor

    @staticmethod
    def _mask_to_bbox(mask) -> Optional[List[float]]:
        import numpy as np
        if mask is None:
            return None
        m = mask.astype(bool) if hasattr(mask, "astype") else mask
        ys, xs = np.where(m)
        if len(xs) == 0:
            return None
        x0, x1 = int(xs.min()), int(xs.max())
        y0, y1 = int(ys.min()), int(ys.max())
        return [float(x0), float(y0), float(x1), float(y1)]

    def _predict(self, frame_path: str, box: List[float]
                 ) -> Tuple[Optional[List[float]], float, Optional[List[int]]]:
        import numpy as np
        from PIL import Image as _PIL
        predictor = self._ensure_predictor()
        img = np.array(_PIL.open(frame_path).convert("RGB"))
        h, w = img.shape[:2]
        # Clamp / sanitize box
        x0, y0, x1, y1 = box
        x0 = max(0, min(int(x0), w - 1))
        x1 = max(0, min(int(x1), w))
        y0 = max(0, min(int(y0), h - 1))
        y1 = max(0, min(int(y1), h))
        if x1 <= x0 or y1 <= y0:
            return None, 0.0, [w, h]
        try:
            import torch
            ctx_mgr = (torch.inference_mode() if torch is not None
                       else _NullCtx())
        except Exception:
            ctx_mgr = _NullCtx()
        with ctx_mgr:
            predictor.set_image(img)
            masks, ious, _ = predictor.predict(
                box=np.array([x0, y0, x1, y1]),
                multimask_output=False,
            )
        if masks is None or len(masks) == 0:
            return None, 0.0, [w, h]
        mask = masks[0]
        iou = float(ious[0]) if ious is not None and len(ious) else 0.0
        bbox = self._mask_to_bbox(mask)
        return bbox, iou, [w, h]

    # -- adapter methods ---------------------------------------------
    def init(self, frame_path, bbox, text_prompt=None):
        bbox = [float(v) for v in (bbox or [0, 0, 0, 0])]
        try:
            new_bbox, score, size = self._predict(frame_path, bbox)
            sess = {
                "init_frame": frame_path,
                "init_bbox": list(bbox),
                "last_bbox": new_bbox or list(bbox),
                "prompt": text_prompt,
                "frame_size": size or [0, 0],
                "step": 0,
                "score": score,
                "lost": new_bbox is None,
            }
        except Exception as e:  # noqa: BLE001
            # Fall back to mock-style session so caller stays alive.
            sess = {
                "init_frame": frame_path,
                "init_bbox": list(bbox),
                "last_bbox": list(bbox),
                "prompt": text_prompt,
                "frame_size": [0, 0],
                "step": 0,
                "score": 1.0,
                "lost": False,
                "error": f"sam2 init: {type(e).__name__}: {e}",
            }
        return sess

    def query(self, session, frame_path):
        session["step"] = int(session.get("step", 0)) + 1
        prev = session.get("last_bbox") or session.get("init_bbox")
        if not prev or session.get("lost"):
            return {"bbox": None, "score": 0.0, "in_view": False}
        try:
            new_bbox, score, _ = self._predict(frame_path, prev)
        except Exception as e:  # noqa: BLE001
            return {"bbox": None, "score": 0.0, "in_view": False,
                    "error": f"sam2 query: {type(e).__name__}: {e}"}
        if new_bbox is None or score < 0.3:
            session["lost"] = True
            return {"bbox": None, "score": round(score, 3), "in_view": False}
        session["last_bbox"] = new_bbox
        session["score"] = score
        return {"bbox": new_bbox, "score": round(float(score), 3),
                "in_view": True}


class _NullCtx:
    def __enter__(self): return self
    def __exit__(self, *a): return False


def _build_default_tracker() -> TrackerAdapter:
    backend = os.environ.get("STREAMO_TRACKER_BACKEND", "mock").lower()
    if backend == "sam2":
        try:
            return SAM2Tracker()
        except Exception:
            return MockTracker()
    return MockTracker()


DEFAULT_TRACKER: TrackerAdapter = _build_default_tracker()


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
def _ensure_tracks(ctx: ToolContext) -> Dict[str, Any]:
    if ctx.tracks is None:
        ctx.tracks = {}
    return ctx.tracks


def _new_track_id(ctx: ToolContext) -> str:
    n = len(_ensure_tracks(ctx)) + 1
    return f"trk{n:02d}"


class TrackInitTool(Tool):
    name = "track_init"
    description = (
        "Start a SAM3 tracking session on the frame at time `t`. Provide "
        "either an explicit `bbox` (pixel or normalized) or a "
        "`text_prompt` describing the target. Returns a `track_id` you "
        "must pass to subsequent `track_query` / `track_end` calls."
    )
    parameters = {
        "type": "object",
        "properties": {
            "t": {"type": "integer"},
            "bbox": {"type": "array",
                      "items": {"type": "number"},
                      "minItems": 4, "maxItems": 4},
            "text_prompt": {"type": "string"},
        },
        "required": ["t"],
    }

    def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
        try:
            t = int(args["t"])
        except (KeyError, TypeError, ValueError) as e:
            return ToolResult(self.name, False, error=f"bad t: {e}")
        if not (0 <= t < len(ctx.frame_paths)):
            return ToolResult(self.name, False,
                              error=f"t out of range: {t}")
        bbox = args.get("bbox")
        prompt = args.get("text_prompt")
        if bbox is None and not prompt:
            return ToolResult(self.name, False,
                              error="bbox or text_prompt required")
        try:
            sess = DEFAULT_TRACKER.init(
                ctx.frame_paths[t],
                list(bbox) if bbox is not None else [0, 0, 0, 0],
                prompt,
            )
        except Exception as e:  # noqa: BLE001
            return ToolResult(self.name, False, error=f"tracker init: {e}")
        tid = _new_track_id(ctx)
        ctx.tracks[tid] = {
            "session": sess, "start_t": t, "prompt": prompt,
            "init_bbox": list(bbox) if bbox is not None else None,
            "traj": [], "status": "active",
        }
        if ctx.memory is not None:
            ctx.memory.upsert_track(tid, start_t=t, prompt=prompt,
                                     init_bbox=ctx.tracks[tid]["init_bbox"])
            ctx.memory.write_event(
                t, "tool",
                f"track_init {tid} prompt={prompt or 'bbox'}",
                ref_frame=t, salience=1.2,
            )
        return ToolResult(self.name, True,
                          data={"track_id": tid, "start_t": t})


class TrackQueryTool(Tool):
    name = "track_query"
    description = (
        "Query an active SAM3 track at time `t`. Returns the predicted "
        "bbox, confidence, and whether the target is still in view."
    )
    parameters = {
        "type": "object",
        "properties": {
            "track_id": {"type": "string"},
            "t": {"type": "integer"},
        },
        "required": ["track_id", "t"],
    }

    def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
        tid = args.get("track_id")
        if not tid or ctx.tracks is None or tid not in ctx.tracks:
            return ToolResult(self.name, False,
                              error=f"unknown track_id: {tid}")
        try:
            t = int(args["t"])
        except (KeyError, TypeError, ValueError) as e:
            return ToolResult(self.name, False, error=f"bad t: {e}")
        if not (0 <= t < len(ctx.frame_paths)):
            return ToolResult(self.name, False,
                              error=f"t out of range: {t}")
        tr = ctx.tracks[tid]
        if tr.get("status") != "active":
            return ToolResult(self.name, False,
                              error=f"track {tid} not active")
        out = DEFAULT_TRACKER.query(tr["session"], ctx.frame_paths[t])
        tr["traj"].append({"t": t, **out})
        if ctx.memory is not None:
            ctx.memory.upsert_track(tid, traj_append=[{"t": t, **out}])
            # Also write a retrievable working-layer event so the bbox /
            # visibility survives past the current physical round and can be
            # surfaced by memory_search / snapshot. Without this, the per-
            # frame track_query result only lives in the tracks side-index
            # (which retrieve()/search() never scan) and evaporates once the
            # streaming clock ticks — forcing the model to re-query the same
            # target repeatedly. Mirror the auto_track event format.
            bbox = out.get("bbox")
            in_view = out.get("in_view")
            if in_view and bbox:
                ctx.memory.write_event(
                    t, "tool",
                    f"track_query {tid} bbox={[int(v) for v in bbox]} "
                    f"score={out.get('score')}",
                    ref_frame=t, salience=1.0,
                )
            elif not in_view:
                ctx.memory.write_event(
                    t, "tool",
                    f"track_query {tid} lost (score={out.get('score')})",
                    ref_frame=t, salience=1.1,
                )
        return ToolResult(self.name, True, data={"track_id": tid, **out})


class TrackEndTool(Tool):
    name = "track_end"
    description = (
        "End a SAM3 tracking session and obtain its summary "
        "(duration, sample count, max confidence drop)."
    )
    parameters = {
        "type": "object",
        "properties": {"track_id": {"type": "string"}},
        "required": ["track_id"],
    }

    def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
        tid = args.get("track_id")
        if not tid or ctx.tracks is None or tid not in ctx.tracks:
            return ToolResult(self.name, False,
                              error=f"unknown track_id: {tid}")
        tr = ctx.tracks[tid]
        if tr.get("status") != "active":
            return ToolResult(self.name, False,
                              error=f"track {tid} already ended")
        DEFAULT_TRACKER.end(tr["session"])
        traj = tr["traj"]
        duration = (traj[-1]["t"] - tr["start_t"]) if traj else 0
        scores = [s.get("score", 0.0) for s in traj]
        summary = {
            "duration_s": duration,
            "samples": len(traj),
            "score_min": round(min(scores), 3) if scores else None,
            "score_max": round(max(scores), 3) if scores else None,
            "lost_at": next((s["t"] for s in traj if not s.get("in_view")), None),
        }
        tr["status"] = "ended"
        tr["summary"] = summary
        if ctx.memory is not None:
            ctx.memory.upsert_track(tid, status="ended", summary=summary)
            ctx.memory.write_event(
                tr["start_t"] + duration, "tool",
                f"track_end {tid} dur={duration}s lost@{summary['lost_at']}",
                salience=1.2,
            )
        return ToolResult(self.name, True,
                          data={"track_id": tid, "summary": summary})