seavision-python 0.1.1__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.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- seavision_python-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SAM3 detector implementation using Ultralytics integration.
|
|
3
|
+
|
|
4
|
+
This module wraps SAM3's Ultralytics integration to fit the SeaVision
|
|
5
|
+
DetectorBase interface, enabling use in the standard detection pipeline via
|
|
6
|
+
the usual configuration process.
|
|
7
|
+
|
|
8
|
+
SAM3 (Segment Anything Model 3) provides promptable segmentation capabilities
|
|
9
|
+
with support for:
|
|
10
|
+
- Text prompts: Natural language concepts like "fish", "coral reef"
|
|
11
|
+
- Box prompts: Bounding box exemplars to find similar objects
|
|
12
|
+
- Point prompts: Click-based prompts (requires base SAM3Predictor)
|
|
13
|
+
- Hybrid mode: Using another detector to generate prompts for SAM3
|
|
14
|
+
|
|
15
|
+
Two inference modes are available:
|
|
16
|
+
- Image mode: Frame-by-frame segmentation without tracking
|
|
17
|
+
- Video mode: Segmentation with tracking and temporal context
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from typing import Dict, Iterator, List, Optional, Tuple
|
|
23
|
+
import numpy as np
|
|
24
|
+
import torch
|
|
25
|
+
|
|
26
|
+
from ...source.base import FrameContext
|
|
27
|
+
from ..base import Detection, DetectorBase
|
|
28
|
+
from .config import (
|
|
29
|
+
SAM3DetectorConfig,
|
|
30
|
+
PromptConfig,
|
|
31
|
+
PromptType,
|
|
32
|
+
PrompterConfig,
|
|
33
|
+
HybridStrategy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Check for Ultralytics availability
|
|
39
|
+
try:
|
|
40
|
+
from ultralytics.models.sam import (
|
|
41
|
+
SAM3Predictor, SAM3SemanticPredictor, SAM3VideoSemanticPredictor
|
|
42
|
+
)
|
|
43
|
+
ULTRALYTICS_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
ULTRALYTICS_AVAILABLE = False
|
|
46
|
+
SAM3Predictor = None
|
|
47
|
+
SAM3SemanticPredictor = None
|
|
48
|
+
SAM3VideoSemanticPredictor = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SAM3Detector(DetectorBase):
|
|
52
|
+
"""
|
|
53
|
+
Promptable concept segmentation detector using Meta's SAM3.
|
|
54
|
+
|
|
55
|
+
This detector implements the DetectorBase interface for seamless integration
|
|
56
|
+
with the SeaVision detection pipeline. It leverages SAM3's advanced
|
|
57
|
+
segmentation capabilities to detect objects based on various prompt types.
|
|
58
|
+
|
|
59
|
+
Two inference modes are available:
|
|
60
|
+
- Image mode (video_mode=False): Frame-by-frame segmentation without
|
|
61
|
+
tracking. Each frame is processed independently.
|
|
62
|
+
- Video mode (video_mode=True): Segmentation with tracking and temporal
|
|
63
|
+
context. Maintains consistent object IDs across frames.
|
|
64
|
+
|
|
65
|
+
Supported prompting modes:
|
|
66
|
+
- TEXT: Natural language concepts (e.g., "fish", "coral reef"). Uses
|
|
67
|
+
SAM3's semantic understanding to segment matching objects.
|
|
68
|
+
- BOX: Bounding box exemplars. Finds and segments objects similar to
|
|
69
|
+
the provided box regions.
|
|
70
|
+
- POINT: Point prompts (SAM2 style). Note: Requires base SAM3Predictor,
|
|
71
|
+
not the semantic predictor. Limited support in current implementation.
|
|
72
|
+
- DETECTOR: Hybrid mode using another detector (e.g., motion detector)
|
|
73
|
+
to generate prompts that SAM3 then refines or classifies.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
config (SAM3DetectorConfig): Configuration for the detector.
|
|
77
|
+
uri (str): Not used, inherited from base class pattern.
|
|
78
|
+
|
|
79
|
+
Example (text prompts - image mode):
|
|
80
|
+
>>> config = SAM3DetectorConfig(
|
|
81
|
+
... prompt_config=PromptConfig(
|
|
82
|
+
... prompt_type=PromptType.TEXT,
|
|
83
|
+
... text_prompts=["fish", "coral"]
|
|
84
|
+
... )
|
|
85
|
+
... )
|
|
86
|
+
>>> with SAM3Detector(config) as detector:
|
|
87
|
+
... for frame, context in source.iter_frames():
|
|
88
|
+
... for detection in detector.process_frame(frame, context):
|
|
89
|
+
... print(f"Found {detection.label} at ({detection.xc}, {detection.yc})")
|
|
90
|
+
|
|
91
|
+
Example (video mode with tracking):
|
|
92
|
+
>>> config = SAM3DetectorConfig(
|
|
93
|
+
... prompt_config=PromptConfig(
|
|
94
|
+
... prompt_type=PromptType.TEXT,
|
|
95
|
+
... text_prompts=["fish"]
|
|
96
|
+
... ),
|
|
97
|
+
... video_mode=True,
|
|
98
|
+
... )
|
|
99
|
+
>>> with SAM3Detector(config) as detector:
|
|
100
|
+
... for frame, context in source.iter_frames():
|
|
101
|
+
... for detection in detector.process_frame(frame, context):
|
|
102
|
+
... print(f"Track {detection.track_id}: {detection.label}")
|
|
103
|
+
|
|
104
|
+
Example (hybrid mode - motion detector provides prompts):
|
|
105
|
+
>>> config = SAM3DetectorConfig(
|
|
106
|
+
... prompt_config=PromptConfig(
|
|
107
|
+
... prompt_type=PromptType.DETECTOR,
|
|
108
|
+
... prompter=PrompterConfig(
|
|
109
|
+
... detector_type="motion",
|
|
110
|
+
... strategy=HybridStrategy.CLASSIFY_REGIONS,
|
|
111
|
+
... text_prompts=["fish", "debris"],
|
|
112
|
+
... ),
|
|
113
|
+
... ),
|
|
114
|
+
... )
|
|
115
|
+
|
|
116
|
+
Example (interactive refinement with exemplars):
|
|
117
|
+
>>> detector = SAM3Detector(config)
|
|
118
|
+
>>> # Add positive exemplar - find similar objects
|
|
119
|
+
>>> detector.add_exemplar(box=[100, 100, 200, 200], positive=True)
|
|
120
|
+
>>> # Add negative exemplar - exclude similar regions
|
|
121
|
+
>>> detector.add_exemplar(box=[300, 300, 350, 350], positive=False)
|
|
122
|
+
>>> for detection in detector.process_frame(frame, context):
|
|
123
|
+
... print(detection)
|
|
124
|
+
|
|
125
|
+
Note:
|
|
126
|
+
- Requires ultralytics>=8.3.237 and the sam3.pt checkpoint.
|
|
127
|
+
- GPU (CUDA) strongly recommended for reasonable performance.
|
|
128
|
+
- The checkpoint file should be placed at ./models/sam3.pt or the path
|
|
129
|
+
specified via the checkpoint parameter.
|
|
130
|
+
|
|
131
|
+
See Also:
|
|
132
|
+
- SAM3DetectorConfig: Configuration options for this detector.
|
|
133
|
+
- PromptConfig: Configuration for different prompt types.
|
|
134
|
+
- Detection: The output detection format.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, config: Optional[SAM3DetectorConfig] = None):
|
|
138
|
+
"""
|
|
139
|
+
Initialise the SAM3 detector.
|
|
140
|
+
|
|
141
|
+
Sets up the detector with the provided configuration, validates prompt
|
|
142
|
+
settings, and prepares internal state for processing. The actual model
|
|
143
|
+
loading is deferred until first use (lazy initialisation).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
config: Configuration for the detector. If None, defaults are used
|
|
147
|
+
but text prompts will be required before processing can begin.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ImportError: If ultralytics>=8.3.237 is not installed.
|
|
151
|
+
ValueError: If prompt configuration is invalid (e.g., TEXT mode
|
|
152
|
+
without any text prompts specified).
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> # Basic initialisation with text prompts
|
|
156
|
+
>>> config = SAM3DetectorConfig(
|
|
157
|
+
... prompt_config=PromptConfig(
|
|
158
|
+
... prompt_type=PromptType.TEXT,
|
|
159
|
+
... text_prompts=["fish", "shark"]
|
|
160
|
+
... )
|
|
161
|
+
... )
|
|
162
|
+
>>> detector = SAM3Detector(config)
|
|
163
|
+
|
|
164
|
+
>>> # Initialisation with video mode for tracking
|
|
165
|
+
>>> config = SAM3DetectorConfig(
|
|
166
|
+
... prompt_config=PromptConfig(
|
|
167
|
+
... prompt_type=PromptType.TEXT,
|
|
168
|
+
... text_prompts=["fish"]
|
|
169
|
+
... ),
|
|
170
|
+
... video_mode=True,
|
|
171
|
+
... confidence_threshold=0.5,
|
|
172
|
+
... )
|
|
173
|
+
>>> detector = SAM3Detector(config)
|
|
174
|
+
"""
|
|
175
|
+
if not ULTRALYTICS_AVAILABLE:
|
|
176
|
+
raise ImportError(
|
|
177
|
+
"ultralytics>=8.3.237 is required for SAM 3 support. "
|
|
178
|
+
"Install with: pip install ultralytics>=8.3.237"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self.config = config or SAM3DetectorConfig()
|
|
182
|
+
|
|
183
|
+
# Validate prompts
|
|
184
|
+
self._validate_prompt_config()
|
|
185
|
+
|
|
186
|
+
# Lazily initialised predictors
|
|
187
|
+
self._predictor = None
|
|
188
|
+
self._is_video_mode = self.config.video_mode
|
|
189
|
+
|
|
190
|
+
# Prompter detector for hybrid mode
|
|
191
|
+
self._prompter_detector: Optional[DetectorBase] = None
|
|
192
|
+
|
|
193
|
+
# Video mode state
|
|
194
|
+
self._video_initialised = False
|
|
195
|
+
self._current_source_file = None
|
|
196
|
+
|
|
197
|
+
# Frame counter
|
|
198
|
+
self._frame_count: int = 0
|
|
199
|
+
|
|
200
|
+
# Track IDs for consistent mapping
|
|
201
|
+
self._current_track_ids: Dict[int, int] = {}
|
|
202
|
+
self._next_track_id: int = 0
|
|
203
|
+
|
|
204
|
+
# Interactive exemplars (for box/point refinement)
|
|
205
|
+
self._positive_boxes: List[List[float]] = []
|
|
206
|
+
self._negative_boxes: List[List[float]] = []
|
|
207
|
+
self._positive_points: List[List[float]] = []
|
|
208
|
+
self._negative_points: List[List[float]] = []
|
|
209
|
+
|
|
210
|
+
logger.info(f"SAM3Detector initialised")
|
|
211
|
+
logger.info(f" Mode: {'video (tracking)' if self._is_video_mode else 'image (independent)'}")
|
|
212
|
+
logger.info(f" Checkpoint: {self.config.checkpoint}")
|
|
213
|
+
if self.config.prompt_config:
|
|
214
|
+
logger.info(f" Prompt type: {self.config.prompt_config.prompt_type}")
|
|
215
|
+
if self.config.prompt_config.text_prompts:
|
|
216
|
+
logger.info(f" Text prompts: {self.config.prompt_config.text_prompts}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _validate_prompt_config(self) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Validate the prompt configuration.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If prompt configuration is missing or invalid.
|
|
225
|
+
"""
|
|
226
|
+
prompt_config = self.config.prompt_config
|
|
227
|
+
|
|
228
|
+
if prompt_config is None:
|
|
229
|
+
raise ValueError(
|
|
230
|
+
"No prompts specified. Provide prompts like: "
|
|
231
|
+
"SAM3DetectorConfig(prompts=['fish', 'coral']) or use "
|
|
232
|
+
"prompt_config for advanced options."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if prompt_config.prompt_type == PromptType.TEXT and not prompt_config.text_prompts:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"No text prompts specified for TEXT mode. Provide prompts like: "
|
|
238
|
+
"SAM3DetectorConfig(prompts=['fish', 'coral'])"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ensure_predictor_loaded(self) -> None:
|
|
243
|
+
"""
|
|
244
|
+
Lazily load the appropriate SAM3 predictor.
|
|
245
|
+
|
|
246
|
+
This method handles the deferred loading of the SAM3 model, which is
|
|
247
|
+
beneficial for memory management and startup time. The predictor is
|
|
248
|
+
loaded on first use rather than at initialisation.
|
|
249
|
+
|
|
250
|
+
The method selects between SAM3VideoSemanticPredictor (for video mode
|
|
251
|
+
with tracking) and SAM3SemanticPredictor (for image mode) based on
|
|
252
|
+
the configuration.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
FileNotFoundError: If the SAM3 checkpoint file is not found at
|
|
256
|
+
the configured path.
|
|
257
|
+
RuntimeError: If the model fails to load for any other reason.
|
|
258
|
+
|
|
259
|
+
Note:
|
|
260
|
+
This method is idempotent - calling it multiple times has no
|
|
261
|
+
additional effect after the predictor is loaded.
|
|
262
|
+
"""
|
|
263
|
+
if self._predictor is not None:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Check if checkpoint exists
|
|
267
|
+
if not os.path.exists(self.config.checkpoint):
|
|
268
|
+
raise FileNotFoundError(
|
|
269
|
+
f"SAM 3 checkpoint not found: {self.config.checkpoint}\n"
|
|
270
|
+
f"Expected location: {os.path.abspath(self.config.checkpoint)}\n"
|
|
271
|
+
f"Download from: https://huggingface.co/facebook/sam3\n"
|
|
272
|
+
f"Then place in ./models/sam3.pt or specify path via checkpoint parameter."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
logger.info(f"Loading SAM3 model from checkpoint: {self.config.checkpoint}")
|
|
276
|
+
|
|
277
|
+
# Build overrides dict
|
|
278
|
+
overrides = {
|
|
279
|
+
"conf": self.config.confidence_threshold,
|
|
280
|
+
"task": "segment",
|
|
281
|
+
"mode": "predict",
|
|
282
|
+
"model": self.config.checkpoint,
|
|
283
|
+
"half": self.config.half,
|
|
284
|
+
"imgsz": self.config.imgsz,
|
|
285
|
+
"device": self.config.device,
|
|
286
|
+
"verbose": False,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
if self._is_video_mode:
|
|
291
|
+
self._predictor = SAM3VideoSemanticPredictor(overrides=overrides)
|
|
292
|
+
logger.info("SAM3 Video predictor loaded (tracking enabled)")
|
|
293
|
+
else:
|
|
294
|
+
self._predictor = SAM3SemanticPredictor(overrides=overrides)
|
|
295
|
+
logger.info("SAM3 Image predictor loaded (independent frames)")
|
|
296
|
+
|
|
297
|
+
logger.info(f"SAM3 {'Video' if self._is_video_mode else 'Image'} predictor loaded")
|
|
298
|
+
|
|
299
|
+
except Exception as e:
|
|
300
|
+
raise RuntimeError(f"Failed to load SAM 3 model: {e}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _ensure_prompter_detector(self) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Lazily initialise the prompt-generating detector for hybrid mode.
|
|
306
|
+
|
|
307
|
+
In hybrid mode (PromptType.DETECTOR), another detector is used to
|
|
308
|
+
generate candidate regions which SAM3 then refines or classifies.
|
|
309
|
+
This method initialises that prompter detector based on the
|
|
310
|
+
configuration.
|
|
311
|
+
|
|
312
|
+
Supported prompter detector types:
|
|
313
|
+
- "motion": Uses the built-in MotionDetector
|
|
314
|
+
- Custom types: Looked up in the pipeline's detector registry
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
ValueError: If the prompter configuration is missing when required,
|
|
318
|
+
or if the specified detector type is not recognised.
|
|
319
|
+
|
|
320
|
+
Note:
|
|
321
|
+
This method is only called when prompt_type is DETECTOR.
|
|
322
|
+
It is idempotent - multiple calls have no additional effect.
|
|
323
|
+
"""
|
|
324
|
+
if self._prompter_detector is not None:
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
if self.config.prompt_config.prompt_type != PromptType.DETECTOR:
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
prompter_cfg = self.config.prompt_config.prompter
|
|
331
|
+
if prompter_cfg is None:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
"Prompter configuration is required for DETECTOR prompt type."
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
detector_type = prompter_cfg.detector_type
|
|
337
|
+
logger.info(f"Initialising prompter detector: {detector_type}")
|
|
338
|
+
|
|
339
|
+
# Built in detector types
|
|
340
|
+
if detector_type == "motion":
|
|
341
|
+
from ..motion import MotionDetector, MotionDetectorConfig
|
|
342
|
+
|
|
343
|
+
# Parse config
|
|
344
|
+
motion_cfg = MotionDetectorConfig(
|
|
345
|
+
stabilisation_enabled=prompter_cfg.detector_config.get(
|
|
346
|
+
"stabilisation_enabled", True
|
|
347
|
+
),
|
|
348
|
+
min_area=prompter_cfg.detector_config.get("min_area", 500),
|
|
349
|
+
max_area=prompter_cfg.detector_config.get("max_area", 50000),
|
|
350
|
+
morph_kernel_size=prompter_cfg.detector_config.get(
|
|
351
|
+
"morph_kernel_size", 5
|
|
352
|
+
),
|
|
353
|
+
morph_iterations=prompter_cfg.detector_config.get(
|
|
354
|
+
"morph_iterations", 2
|
|
355
|
+
),
|
|
356
|
+
persistence_enabled=prompter_cfg.detector_config.get(
|
|
357
|
+
"persistence_enabled", True
|
|
358
|
+
),
|
|
359
|
+
min_persistence=prompter_cfg.detector_config.get(
|
|
360
|
+
"min_persistence", 3
|
|
361
|
+
),
|
|
362
|
+
max_frames_missing=prompter_cfg.detector_config.get(
|
|
363
|
+
"max_frames_missing", 5
|
|
364
|
+
),
|
|
365
|
+
iou_threshold=prompter_cfg.detector_config.get(
|
|
366
|
+
"iou_threshold", 0.3
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
self._prompter_detector = MotionDetector(motion_cfg)
|
|
370
|
+
|
|
371
|
+
# TODO: YOLO detector
|
|
372
|
+
# elif detector_type == "yolo":
|
|
373
|
+
# from ..detectors.yolo import YOLODetector, YOLODetectorConfig
|
|
374
|
+
# ...
|
|
375
|
+
|
|
376
|
+
# Try to get from pipeline registry
|
|
377
|
+
else:
|
|
378
|
+
try:
|
|
379
|
+
from ....pipeline import DETECTOR_REGISTRY
|
|
380
|
+
if detector_type in DETECTOR_REGISTRY:
|
|
381
|
+
det_class, config_parser = DETECTOR_REGISTRY[detector_type]
|
|
382
|
+
det_config = config_parser(prompter_cfg.detector_config)
|
|
383
|
+
self._prompter_detector = det_class(det_config)
|
|
384
|
+
else:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"Unknown prompter detector type: {detector_type}. "
|
|
387
|
+
f"Available: motion, or register custom detector."
|
|
388
|
+
)
|
|
389
|
+
except ImportError:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"Unknown prompter detector type: {detector_type}. "
|
|
392
|
+
f"Built-in types: motion"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
logger.debug(f"Prompter detector initialised: {type(self._prompter_detector)}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def process_frame(
|
|
399
|
+
self,
|
|
400
|
+
frame: np.ndarray,
|
|
401
|
+
context: FrameContext
|
|
402
|
+
) -> Iterator[Detection]:
|
|
403
|
+
"""
|
|
404
|
+
Process a single video frame and yield detections.
|
|
405
|
+
|
|
406
|
+
This is the main entry point for detection. It handles:
|
|
407
|
+
1. Lazy loading of the SAM3 model
|
|
408
|
+
2. Detection of video source changes (for state reset)
|
|
409
|
+
3. Routing to the appropriate processing method based on prompt type
|
|
410
|
+
4. Yielding Detection objects for each segmented instance
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
frame: BGR image as a numpy array with shape (H, W, 3).
|
|
414
|
+
The frame should be in OpenCV's default BGR colour format.
|
|
415
|
+
context: FrameContext containing metadata about the current frame,
|
|
416
|
+
including source file path, frame number, timestamp, and FPS.
|
|
417
|
+
|
|
418
|
+
Yields:
|
|
419
|
+
Detection objects for each segmented instance found in the frame.
|
|
420
|
+
Each Detection includes:
|
|
421
|
+
- Bounding box (xc, yc, width, height)
|
|
422
|
+
- Confidence score (if available)
|
|
423
|
+
- Label (if output_labels is enabled and text prompts provided)
|
|
424
|
+
- Track ID (in video mode only)
|
|
425
|
+
- Mask (if output_masks is enabled)
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
ValueError: If an unsupported prompt type is configured.
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
>>> for frame, context in video_source.iter_frames():
|
|
432
|
+
... for detection in detector.process_frame(frame, context):
|
|
433
|
+
... print(f"Frame {context.frame_number}: "
|
|
434
|
+
... f"{detection.label} at ({detection.xc:.0f}, {detection.yc:.0f})")
|
|
435
|
+
|
|
436
|
+
Note:
|
|
437
|
+
In video mode, the detector maintains state across frames. When the
|
|
438
|
+
source file changes, internal state is automatically reset.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
self._ensure_predictor_loaded()
|
|
442
|
+
|
|
443
|
+
# Check for video change (reset state if source file changes)
|
|
444
|
+
if context.source_file != self._current_source_file:
|
|
445
|
+
self._on_video_change(context)
|
|
446
|
+
|
|
447
|
+
prompt_config = self.config.prompt_config
|
|
448
|
+
|
|
449
|
+
# Route to appropriate processing method based on prompt type
|
|
450
|
+
if prompt_config.prompt_type == PromptType.DETECTOR:
|
|
451
|
+
yield from self._process_hybrid_mode(frame, context)
|
|
452
|
+
elif prompt_config.prompt_type == PromptType.TEXT:
|
|
453
|
+
yield from self._process_text_mode(frame, context)
|
|
454
|
+
elif prompt_config.prompt_type == PromptType.BOX:
|
|
455
|
+
yield from self._process_box_mode(frame, context)
|
|
456
|
+
elif prompt_config.prompt_type == PromptType.POINT:
|
|
457
|
+
yield from self._process_point_mode(frame, context)
|
|
458
|
+
else:
|
|
459
|
+
raise ValueError(f"Unsupported prompt type: {prompt_config.prompt_type}")
|
|
460
|
+
|
|
461
|
+
self._frame_count += 1
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _on_video_change(self, context: FrameContext) -> None:
|
|
465
|
+
"""
|
|
466
|
+
Handle transition to a new video file.
|
|
467
|
+
|
|
468
|
+
Called when the source file in the FrameContext differs from the
|
|
469
|
+
previously processed source. This resets all video-specific state
|
|
470
|
+
to ensure clean processing of the new video.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
context: FrameContext for the first frame of the new video.
|
|
474
|
+
|
|
475
|
+
Note:
|
|
476
|
+
This method resets:
|
|
477
|
+
- Video initialisation flag
|
|
478
|
+
- Track ID mappings
|
|
479
|
+
- Prompter detector state (if in hybrid mode)
|
|
480
|
+
"""
|
|
481
|
+
if self._current_source_file is not None:
|
|
482
|
+
logger.debug(
|
|
483
|
+
f"Video changed from {self._current_source_file} "
|
|
484
|
+
f"to {context.source_file}"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
self._current_source_file = context.source_file
|
|
488
|
+
self._video_initialised = False
|
|
489
|
+
|
|
490
|
+
# Reset track ID mapping for new video
|
|
491
|
+
self._current_track_ids.clear()
|
|
492
|
+
self._next_track_id = 0
|
|
493
|
+
|
|
494
|
+
# Reset prompter detector if in hybrid mode
|
|
495
|
+
if self._prompter_detector is not None:
|
|
496
|
+
self._prompter_detector.reset()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _process_text_mode(
|
|
500
|
+
self,
|
|
501
|
+
frame: np.ndarray,
|
|
502
|
+
context: FrameContext
|
|
503
|
+
) -> Iterator[Detection]:
|
|
504
|
+
"""
|
|
505
|
+
Process a single frame using text prompts.
|
|
506
|
+
|
|
507
|
+
Uses SAM3's semantic understanding to segment objects matching the
|
|
508
|
+
configured text prompts (e.g., "fish", "coral reef").
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
512
|
+
context: FrameContext with frame metadata.
|
|
513
|
+
|
|
514
|
+
Yields:
|
|
515
|
+
Detection objects for each instance matching the text prompts.
|
|
516
|
+
|
|
517
|
+
Note:
|
|
518
|
+
The text prompts are configured via prompt_config.text_prompts.
|
|
519
|
+
Each detected instance will have a label corresponding to the
|
|
520
|
+
matched text prompt (if output_labels is enabled).
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
prompt_config = self.config.prompt_config
|
|
524
|
+
text_prompts = prompt_config.text_prompts
|
|
525
|
+
|
|
526
|
+
if not text_prompts:
|
|
527
|
+
logger.warning("No text prompts provided, skipping frame")
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
# Optional: combine text prompts with any interactive box exemplars.
|
|
531
|
+
# This mirrors the Hugging Face API where text prompts can be
|
|
532
|
+
# accompanied by positive (label=1) and negative (label=0) boxes.
|
|
533
|
+
bboxes = None
|
|
534
|
+
labels = None
|
|
535
|
+
|
|
536
|
+
if self._positive_boxes or self._negative_boxes:
|
|
537
|
+
all_boxes = list(self._positive_boxes) + list(self._negative_boxes)
|
|
538
|
+
labels_list = [1] * len(self._positive_boxes) + [0] * len(self._negative_boxes)
|
|
539
|
+
|
|
540
|
+
bboxes = np.array(all_boxes, dtype=np.float32)
|
|
541
|
+
labels = np.array(labels_list, dtype=np.int32)
|
|
542
|
+
|
|
543
|
+
if self._is_video_mode:
|
|
544
|
+
yield from self._process_video_frame(
|
|
545
|
+
frame,
|
|
546
|
+
context,
|
|
547
|
+
text=text_prompts,
|
|
548
|
+
bboxes=bboxes,
|
|
549
|
+
labels=labels,
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
yield from self._process_image_frame(
|
|
553
|
+
frame,
|
|
554
|
+
context,
|
|
555
|
+
text=text_prompts,
|
|
556
|
+
bboxes=bboxes,
|
|
557
|
+
labels=labels,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _process_box_mode(
|
|
562
|
+
self,
|
|
563
|
+
frame: np.ndarray,
|
|
564
|
+
context: FrameContext
|
|
565
|
+
) -> Iterator[Detection]:
|
|
566
|
+
"""
|
|
567
|
+
Process a single frame using box prompts.
|
|
568
|
+
|
|
569
|
+
Uses bounding box exemplars to find and segment similar objects.
|
|
570
|
+
Both configured box prompts and interactive exemplars (added via
|
|
571
|
+
add_exemplar) are used.
|
|
572
|
+
|
|
573
|
+
Positive boxes indicate "find objects like this region".
|
|
574
|
+
Negative boxes indicate "exclude regions like this".
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
578
|
+
context: FrameContext with frame metadata.
|
|
579
|
+
|
|
580
|
+
Yields:
|
|
581
|
+
Detection objects for each segmented instance.
|
|
582
|
+
|
|
583
|
+
Note:
|
|
584
|
+
Box prompts should be in [x1, y1, x2, y2] format (xyxy).
|
|
585
|
+
Interactive exemplars can be added at runtime using add_exemplar().
|
|
586
|
+
"""
|
|
587
|
+
prompt_config = self.config.prompt_config
|
|
588
|
+
|
|
589
|
+
# Combine configured boxes with interactive exemplars
|
|
590
|
+
positive_boxes = list(prompt_config.box_prompts) + self._positive_boxes
|
|
591
|
+
negative_boxes = list(self._negative_boxes)
|
|
592
|
+
|
|
593
|
+
if not positive_boxes and not negative_boxes:
|
|
594
|
+
logger.warning("No box prompts provided, skipping frame")
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
# Build combined boxes and labels arrays
|
|
598
|
+
# Label 1 = positive (foreground), Label 0 = negative (background)
|
|
599
|
+
all_boxes = positive_boxes + negative_boxes
|
|
600
|
+
labels = [1] * len(positive_boxes) + [0] * len(negative_boxes)
|
|
601
|
+
|
|
602
|
+
# Convert to numpy arrays for predictor
|
|
603
|
+
bboxes = np.array(all_boxes, dtype=np.float32)
|
|
604
|
+
labels = np.array(labels, dtype=np.int32)
|
|
605
|
+
|
|
606
|
+
if self._is_video_mode:
|
|
607
|
+
yield from self._process_video_frame(
|
|
608
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
yield from self._process_image_frame(
|
|
612
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _process_point_mode(
|
|
617
|
+
self,
|
|
618
|
+
frame: np.ndarray,
|
|
619
|
+
context: FrameContext
|
|
620
|
+
) -> Iterator[Detection]:
|
|
621
|
+
"""
|
|
622
|
+
Process a single frame using point prompts.
|
|
623
|
+
|
|
624
|
+
Point prompts provide click-based interaction similar to SAM2.
|
|
625
|
+
Both configured points and interactive exemplars are used.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
629
|
+
context: FrameContext with frame metadata.
|
|
630
|
+
|
|
631
|
+
Yields:
|
|
632
|
+
Detection objects for each segmented instance.
|
|
633
|
+
|
|
634
|
+
Warning:
|
|
635
|
+
Point prompts require SAM3Predictor, not SAM3SemanticPredictor.
|
|
636
|
+
The current implementation uses SAM3SemanticPredictor which has
|
|
637
|
+
limited point support. Consider using BOX mode for similar
|
|
638
|
+
functionality with better support.
|
|
639
|
+
|
|
640
|
+
Note:
|
|
641
|
+
Points should be in [x, y] format (pixel coordinates).
|
|
642
|
+
Labels: 1 = positive (foreground), 0 = negative (background).
|
|
643
|
+
"""
|
|
644
|
+
prompt_config = self.config.prompt_config
|
|
645
|
+
|
|
646
|
+
# Combine configured points with interactive exemplars
|
|
647
|
+
positive_points = list(prompt_config.point_prompts) + self._positive_points
|
|
648
|
+
negative_points = list(self._negative_points)
|
|
649
|
+
|
|
650
|
+
# Build labels list
|
|
651
|
+
positive_labels = list(prompt_config.point_labels) if prompt_config.point_labels else []
|
|
652
|
+
# Ensure we have labels for all configured points
|
|
653
|
+
while len(positive_labels) < len(prompt_config.point_prompts):
|
|
654
|
+
positive_labels.append(1)
|
|
655
|
+
# Add labels for interactive positive points
|
|
656
|
+
positive_labels.extend([1] * len(self._positive_points))
|
|
657
|
+
|
|
658
|
+
# Combine with negative points
|
|
659
|
+
all_points = positive_points + negative_points
|
|
660
|
+
all_labels = positive_labels + [0] * len(negative_points)
|
|
661
|
+
|
|
662
|
+
if not all_points:
|
|
663
|
+
logger.warning("No point prompts provided, skipping frame")
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# Convert to numpy arrays
|
|
667
|
+
points = np.array(all_points, dtype=np.float32)
|
|
668
|
+
labels = np.array(all_labels, dtype=np.int32)
|
|
669
|
+
|
|
670
|
+
# Point prompts have limited support in SAM3SemanticPredictor
|
|
671
|
+
# Log a warning but attempt to proceed
|
|
672
|
+
logger.warning(
|
|
673
|
+
"Point prompts have limited support in SAM3SemanticPredictor. "
|
|
674
|
+
"Consider using BOX mode or SAM3Predictor for full point support. "
|
|
675
|
+
"Converting points to small boxes as a workaround."
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Convert points to small boxes (workaround for semantic predictor)
|
|
679
|
+
# Create a small box around each point
|
|
680
|
+
box_size = 10 # pixels
|
|
681
|
+
bboxes = []
|
|
682
|
+
for point in all_points:
|
|
683
|
+
x, y = point
|
|
684
|
+
bboxes.append([
|
|
685
|
+
x - box_size / 2,
|
|
686
|
+
y - box_size / 2,
|
|
687
|
+
x + box_size / 2,
|
|
688
|
+
y + box_size / 2
|
|
689
|
+
])
|
|
690
|
+
|
|
691
|
+
bboxes = np.array(bboxes, dtype=np.float32)
|
|
692
|
+
|
|
693
|
+
if self._is_video_mode:
|
|
694
|
+
yield from self._process_video_frame(
|
|
695
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
696
|
+
)
|
|
697
|
+
else:
|
|
698
|
+
yield from self._process_image_frame(
|
|
699
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _process_hybrid_mode(
|
|
704
|
+
self,
|
|
705
|
+
frame: np.ndarray,
|
|
706
|
+
context: FrameContext
|
|
707
|
+
) -> Iterator[Detection]:
|
|
708
|
+
"""
|
|
709
|
+
Process a single frame using another detector to generate prompts.
|
|
710
|
+
|
|
711
|
+
In hybrid mode, a prompter detector (e.g., motion detector) generates
|
|
712
|
+
candidate regions which SAM3 then processes according to the configured
|
|
713
|
+
strategy:
|
|
714
|
+
|
|
715
|
+
- BOX_REFINEMENT: Use detected boxes directly as SAM3 prompts to
|
|
716
|
+
generate refined segmentation masks.
|
|
717
|
+
- CLASSIFY_REGIONS: Use text prompts to classify/label the detected
|
|
718
|
+
regions (requires text_prompts in prompter config).
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
722
|
+
context: FrameContext with frame metadata.
|
|
723
|
+
|
|
724
|
+
Yields:
|
|
725
|
+
Detection objects for each processed region.
|
|
726
|
+
|
|
727
|
+
Note:
|
|
728
|
+
The prompter detector is configured via prompt_config.prompter.
|
|
729
|
+
Its reset() method is called automatically when the video changes.
|
|
730
|
+
"""
|
|
731
|
+
self._ensure_prompter_detector()
|
|
732
|
+
|
|
733
|
+
prompter_cfg = self.config.prompt_config.prompter
|
|
734
|
+
|
|
735
|
+
# Get candidate detections from prompter
|
|
736
|
+
candidates = list(self._prompter_detector.process_frame(frame, context))
|
|
737
|
+
|
|
738
|
+
if not candidates:
|
|
739
|
+
logger.debug(f"Frame {context.frame_number}: No candidates from prompter")
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
logger.debug(
|
|
743
|
+
f"Frame {context.frame_number}: {len(candidates)} candidates from prompter"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Convert candidates to boxes (xyxy format)
|
|
747
|
+
candidate_boxes = []
|
|
748
|
+
for det in candidates:
|
|
749
|
+
x1 = det.xc - det.width / 2
|
|
750
|
+
y1 = det.yc - det.height / 2
|
|
751
|
+
x2 = det.xc + det.width / 2
|
|
752
|
+
y2 = det.yc + det.height / 2
|
|
753
|
+
candidate_boxes.append([x1, y1, x2, y2])
|
|
754
|
+
|
|
755
|
+
bboxes = np.array(candidate_boxes, dtype=np.float32)
|
|
756
|
+
labels = np.ones(len(bboxes), dtype=np.int32)
|
|
757
|
+
|
|
758
|
+
# Apply SAM3 based on strategy
|
|
759
|
+
if prompter_cfg.strategy == HybridStrategy.BOX_REFINEMENT:
|
|
760
|
+
# Use boxes directly as SAM3 prompts (refine into masks)
|
|
761
|
+
if self._is_video_mode:
|
|
762
|
+
yield from self._process_video_frame(
|
|
763
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
yield from self._process_image_frame(
|
|
767
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
elif prompter_cfg.strategy == HybridStrategy.CLASSIFY_REGIONS:
|
|
771
|
+
# Use text prompts to classify the detected regions
|
|
772
|
+
text_prompts = prompter_cfg.text_prompts
|
|
773
|
+
if text_prompts:
|
|
774
|
+
if self._is_video_mode:
|
|
775
|
+
yield from self._process_video_frame(
|
|
776
|
+
frame, context, text=text_prompts, bboxes=bboxes, labels=labels
|
|
777
|
+
)
|
|
778
|
+
else:
|
|
779
|
+
yield from self._process_image_frame(
|
|
780
|
+
frame, context, text=text_prompts, bboxes=bboxes, labels=labels
|
|
781
|
+
)
|
|
782
|
+
else:
|
|
783
|
+
# Fall back to box refinement if no text prompts
|
|
784
|
+
logger.warning(
|
|
785
|
+
"CLASSIFY_REGIONS strategy requires text_prompts. "
|
|
786
|
+
"Falling back to BOX_REFINEMENT."
|
|
787
|
+
)
|
|
788
|
+
if self._is_video_mode:
|
|
789
|
+
yield from self._process_video_frame(
|
|
790
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
791
|
+
)
|
|
792
|
+
else:
|
|
793
|
+
yield from self._process_image_frame(
|
|
794
|
+
frame, context, bboxes=bboxes, labels=labels
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _process_image_frame(
|
|
799
|
+
self,
|
|
800
|
+
frame: np.ndarray,
|
|
801
|
+
context: FrameContext,
|
|
802
|
+
text: Optional[List[str]] = None,
|
|
803
|
+
bboxes: Optional[np.ndarray] = None,
|
|
804
|
+
labels: Optional[np.ndarray] = None,
|
|
805
|
+
points: Optional[np.ndarray] = None,
|
|
806
|
+
) -> Iterator[Detection]:
|
|
807
|
+
"""
|
|
808
|
+
Process a single frame in image mode (no tracking).
|
|
809
|
+
|
|
810
|
+
Each frame is processed independently without temporal context or
|
|
811
|
+
object tracking between frames.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
815
|
+
context: FrameContext with frame metadata.
|
|
816
|
+
text: Optional list of text prompts for semantic segmentation.
|
|
817
|
+
bboxes: Optional array of bounding boxes, shape (N, 4) in xyxy format.
|
|
818
|
+
labels: Optional array of labels for boxes, shape (N,).
|
|
819
|
+
1 = positive/foreground, 0 = negative/background.
|
|
820
|
+
|
|
821
|
+
Yields:
|
|
822
|
+
Detection objects for each segmented instance.
|
|
823
|
+
"""
|
|
824
|
+
# Set the image
|
|
825
|
+
self._predictor.set_image(frame)
|
|
826
|
+
|
|
827
|
+
# Run inference with available prompts
|
|
828
|
+
results = self._predictor(text=text, bboxes=bboxes, labels=labels)
|
|
829
|
+
|
|
830
|
+
# Reset for next frame
|
|
831
|
+
self._predictor.reset_image()
|
|
832
|
+
|
|
833
|
+
# Parse results
|
|
834
|
+
yield from self._parse_image_results(results, context, text)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _process_video_frame(
|
|
838
|
+
self,
|
|
839
|
+
frame: np.ndarray,
|
|
840
|
+
context: FrameContext,
|
|
841
|
+
text: Optional[List[str]] = None,
|
|
842
|
+
bboxes: Optional[np.ndarray] = None,
|
|
843
|
+
labels: Optional[np.ndarray] = None,
|
|
844
|
+
points: Optional[np.ndarray] = None,
|
|
845
|
+
) -> Iterator[Detection]:
|
|
846
|
+
"""
|
|
847
|
+
Process a single frame in video mode (with tracking).
|
|
848
|
+
|
|
849
|
+
Maintains temporal context and consistent object IDs across frames.
|
|
850
|
+
The first frame initialises prompts; subsequent frames use the
|
|
851
|
+
established memory for tracking.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
frame: BGR image as a numpy array (H, W, 3).
|
|
855
|
+
context: FrameContext with frame metadata.
|
|
856
|
+
text: Optional list of text prompts for semantic segmentation.
|
|
857
|
+
bboxes: Optional array of bounding boxes, shape (N, 4) in xyxy format.
|
|
858
|
+
labels: Optional array of labels for boxes, shape (N,).
|
|
859
|
+
|
|
860
|
+
Yields:
|
|
861
|
+
Detection objects for each tracked instance, including track_id.
|
|
862
|
+
"""
|
|
863
|
+
# First frame: initialize with prompts
|
|
864
|
+
if not self._video_initialised:
|
|
865
|
+
# Set up the image for the predictor
|
|
866
|
+
self._predictor.set_image(frame)
|
|
867
|
+
|
|
868
|
+
# Manually initialise the video inference state to mirror
|
|
869
|
+
# Ultralytics' expected structure when using stream_inference.
|
|
870
|
+
preprocessed = self._predictor.preprocess([frame])
|
|
871
|
+
self._predictor.batch = (None, [frame], None)
|
|
872
|
+
|
|
873
|
+
# Ensure per-frame buffers are large enough for this frame index.
|
|
874
|
+
num_frames = max(context.frame_number + 1, 1)
|
|
875
|
+
self._predictor.inference_state = {
|
|
876
|
+
"num_frames": num_frames,
|
|
877
|
+
"tracker_inference_states": [],
|
|
878
|
+
"tracker_metadata": {},
|
|
879
|
+
"text_prompt": None,
|
|
880
|
+
"per_frame_geometric_prompt": [None] * num_frames,
|
|
881
|
+
"im": preprocessed,
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
# Add initial prompts (sets text_ids and per-frame prompt entries)
|
|
885
|
+
frame_idx, out = self._predictor.add_prompt(
|
|
886
|
+
frame_idx=context.frame_number,
|
|
887
|
+
text=text,
|
|
888
|
+
bboxes=bboxes,
|
|
889
|
+
labels=labels,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
self._video_initialised = True
|
|
893
|
+
|
|
894
|
+
# Parse initial frame output
|
|
895
|
+
yield from self._parse_video_output(out, context, text)
|
|
896
|
+
else:
|
|
897
|
+
# Grow per-frame buffers if the video is longer than initial estimate
|
|
898
|
+
frame_idx = context.frame_number
|
|
899
|
+
prompt_buf = self._predictor.inference_state.get("per_frame_geometric_prompt")
|
|
900
|
+
if prompt_buf is not None and frame_idx >= len(prompt_buf):
|
|
901
|
+
extend_by = frame_idx - len(prompt_buf) + 1
|
|
902
|
+
prompt_buf.extend([None] * extend_by)
|
|
903
|
+
self._predictor.inference_state["per_frame_geometric_prompt"] = prompt_buf
|
|
904
|
+
# Keep num_frames in sync with the extended buffer
|
|
905
|
+
self._predictor.inference_state["num_frames"] = len(prompt_buf)
|
|
906
|
+
|
|
907
|
+
# Update predictor with current frame
|
|
908
|
+
self._predictor.inference_state["im"] = self._predictor.preprocess([frame])
|
|
909
|
+
|
|
910
|
+
# Run inference on subsequent frames
|
|
911
|
+
with torch.inference_mode():
|
|
912
|
+
out = self._predictor._run_single_frame_inference(
|
|
913
|
+
frame_idx=frame_idx,
|
|
914
|
+
reverse=False,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
yield from self._parse_video_output(out, context, text)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _parse_image_results(
|
|
921
|
+
self,
|
|
922
|
+
results,
|
|
923
|
+
context: FrameContext,
|
|
924
|
+
text_prompts: Optional[List[str]] = None,
|
|
925
|
+
) -> Iterator[Detection]:
|
|
926
|
+
"""
|
|
927
|
+
Parse SAM3SemanticPredictor results into Detection objects.
|
|
928
|
+
|
|
929
|
+
Converts the Ultralytics Results format into SeaVision Detection objects,
|
|
930
|
+
applying configured filters (confidence, size) and extracting labels
|
|
931
|
+
from text prompts.
|
|
932
|
+
|
|
933
|
+
Args:
|
|
934
|
+
results: Results from SAM3SemanticPredictor. This is a List[Results]
|
|
935
|
+
where the list length equals batch size (always 1 for SAM).
|
|
936
|
+
Each Results object contains ALL detections for that image.
|
|
937
|
+
context: Frame context with metadata.
|
|
938
|
+
text_prompts: Optional text prompts for labelling detections.
|
|
939
|
+
|
|
940
|
+
Yields:
|
|
941
|
+
Detection objects meeting filter criteria.
|
|
942
|
+
|
|
943
|
+
Note:
|
|
944
|
+
The Results object structure:
|
|
945
|
+
- results.boxes: Bounding boxes with xyxy, conf, cls attributes
|
|
946
|
+
- results.masks: Segmentation masks
|
|
947
|
+
"""
|
|
948
|
+
if results is None:
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
# Handle list of Results - SAM uses batch=1, so we get a list with one element
|
|
952
|
+
# The single Results object contains all N detections for the image
|
|
953
|
+
if isinstance(results, list):
|
|
954
|
+
if not results:
|
|
955
|
+
return
|
|
956
|
+
# Get the single Results object (contains all detections)
|
|
957
|
+
results = results[0]
|
|
958
|
+
|
|
959
|
+
if results is None:
|
|
960
|
+
return
|
|
961
|
+
|
|
962
|
+
# Get boxes and masks from the Results object
|
|
963
|
+
boxes = results.boxes
|
|
964
|
+
if boxes is None or len(boxes) == 0:
|
|
965
|
+
return
|
|
966
|
+
|
|
967
|
+
masks = results.masks
|
|
968
|
+
|
|
969
|
+
# Iterate over all detections in this frame
|
|
970
|
+
for i in range(len(boxes)):
|
|
971
|
+
box = boxes[i]
|
|
972
|
+
|
|
973
|
+
# Get bounding box coordinates (xyxy format)
|
|
974
|
+
xyxy = box.xyxy[0].cpu().numpy()
|
|
975
|
+
x1, y1, x2, y2 = xyxy
|
|
976
|
+
|
|
977
|
+
# Calculate center format
|
|
978
|
+
xc = (x1 + x2) / 2
|
|
979
|
+
yc = (y1 + y2) / 2
|
|
980
|
+
width = x2 - x1
|
|
981
|
+
height = y2 - y1
|
|
982
|
+
|
|
983
|
+
# Filter by size
|
|
984
|
+
area = width * height
|
|
985
|
+
if area < self.config.min_mask_area:
|
|
986
|
+
continue
|
|
987
|
+
if area > self.config.max_mask_area:
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
# Get confidence
|
|
991
|
+
confidence = None
|
|
992
|
+
if box.conf is not None and len(box.conf) > 0:
|
|
993
|
+
confidence = float(box.conf[0])
|
|
994
|
+
|
|
995
|
+
# Filter by confidence
|
|
996
|
+
if confidence < self.config.confidence_threshold:
|
|
997
|
+
continue
|
|
998
|
+
|
|
999
|
+
# Get label
|
|
1000
|
+
label = None
|
|
1001
|
+
if self.config.output_labels:
|
|
1002
|
+
if box.cls is not None and len(box.cls) > 0:
|
|
1003
|
+
cls_id = int(box.cls[0])
|
|
1004
|
+
# Map class ID to text prompt if available
|
|
1005
|
+
if text_prompts and cls_id < len(text_prompts):
|
|
1006
|
+
label = text_prompts[cls_id]
|
|
1007
|
+
else:
|
|
1008
|
+
label = str(cls_id)
|
|
1009
|
+
|
|
1010
|
+
# Get mask
|
|
1011
|
+
mask = None
|
|
1012
|
+
if self.config.output_masks and masks is not None:
|
|
1013
|
+
if i < len(masks):
|
|
1014
|
+
mask_data = masks[i].data.cpu().numpy()
|
|
1015
|
+
mask = (mask_data[0] * 255).astype(np.uint8)
|
|
1016
|
+
|
|
1017
|
+
yield Detection(
|
|
1018
|
+
source_file=context.source_file,
|
|
1019
|
+
timestamp=context.timestamp,
|
|
1020
|
+
frame_number=context.frame_number,
|
|
1021
|
+
xc=float(xc),
|
|
1022
|
+
yc=float(yc),
|
|
1023
|
+
width=float(width),
|
|
1024
|
+
height=float(height),
|
|
1025
|
+
confidence=confidence,
|
|
1026
|
+
label=label,
|
|
1027
|
+
track_id=None, # No tracking in image mode
|
|
1028
|
+
mask=mask,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _parse_video_output(
|
|
1033
|
+
self,
|
|
1034
|
+
out: dict,
|
|
1035
|
+
context: FrameContext,
|
|
1036
|
+
text_prompts: Optional[List[str]] = None,
|
|
1037
|
+
) -> Iterator[Detection]:
|
|
1038
|
+
"""
|
|
1039
|
+
Parse SAM3VideoSemanticPredictor output into Detection objects.
|
|
1040
|
+
|
|
1041
|
+
Converts the video predictor's output dictionary into SeaVision Detection
|
|
1042
|
+
objects with consistent track IDs across frames.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
out: Output dict from video predictor containing:
|
|
1046
|
+
- obj_id_to_mask: dict mapping object IDs to mask tensors
|
|
1047
|
+
- obj_id_to_score: dict mapping object IDs to confidence scores
|
|
1048
|
+
- obj_id_to_cls: dict mapping object IDs to class indices
|
|
1049
|
+
context: Frame context with metadata.
|
|
1050
|
+
text_prompts: Optional text prompts for labelling detections.
|
|
1051
|
+
|
|
1052
|
+
Yields:
|
|
1053
|
+
Detection objects with track_id for consistent tracking.
|
|
1054
|
+
|
|
1055
|
+
Note:
|
|
1056
|
+
SAM3's internal object IDs are mapped to sequential track IDs
|
|
1057
|
+
(0, 1, 2, ...) for cleaner output. The mapping is maintained
|
|
1058
|
+
in self._current_track_ids.
|
|
1059
|
+
"""
|
|
1060
|
+
if out is None:
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
obj_id_to_mask = out.get("obj_id_to_mask", {})
|
|
1064
|
+
obj_id_to_score = out.get("obj_id_to_score", {})
|
|
1065
|
+
obj_id_to_cls = out.get("obj_id_to_cls", {})
|
|
1066
|
+
|
|
1067
|
+
for obj_id, mask_tensor in obj_id_to_mask.items():
|
|
1068
|
+
# Convert mask tensor to numpy
|
|
1069
|
+
# .cpu() works for both GPU and CPU tensors
|
|
1070
|
+
mask_np = mask_tensor.squeeze().cpu().numpy()
|
|
1071
|
+
binary_mask = mask_np > 0
|
|
1072
|
+
|
|
1073
|
+
# Skip empty masks
|
|
1074
|
+
if not binary_mask.any():
|
|
1075
|
+
continue
|
|
1076
|
+
|
|
1077
|
+
# Calculate bounding box from mask
|
|
1078
|
+
rows = np.any(binary_mask, axis=1)
|
|
1079
|
+
cols = np.any(binary_mask, axis=0)
|
|
1080
|
+
|
|
1081
|
+
if not rows.any() or not cols.any():
|
|
1082
|
+
continue
|
|
1083
|
+
|
|
1084
|
+
y_indices = np.where(rows)[0]
|
|
1085
|
+
x_indices = np.where(cols)[0]
|
|
1086
|
+
y1, y2 = y_indices[0], y_indices[-1]
|
|
1087
|
+
x1, x2 = x_indices[0], x_indices[-1]
|
|
1088
|
+
|
|
1089
|
+
# Calculate center format
|
|
1090
|
+
xc = (x1 + x2) / 2
|
|
1091
|
+
yc = (y1 + y2) / 2
|
|
1092
|
+
width = x2 - x1
|
|
1093
|
+
height = y2 - y1
|
|
1094
|
+
|
|
1095
|
+
# Filter by size
|
|
1096
|
+
area = width * height
|
|
1097
|
+
if area < self.config.min_mask_area:
|
|
1098
|
+
continue
|
|
1099
|
+
if area > self.config.max_mask_area:
|
|
1100
|
+
continue
|
|
1101
|
+
|
|
1102
|
+
# Get confidence
|
|
1103
|
+
confidence = obj_id_to_score.get(obj_id)
|
|
1104
|
+
if confidence is not None and confidence < self.config.confidence_threshold:
|
|
1105
|
+
continue
|
|
1106
|
+
|
|
1107
|
+
# Get label from class ID
|
|
1108
|
+
label = None
|
|
1109
|
+
if self.config.output_labels:
|
|
1110
|
+
cls_id = obj_id_to_cls.get(obj_id)
|
|
1111
|
+
if cls_id is not None:
|
|
1112
|
+
cls_id_int = int(cls_id)
|
|
1113
|
+
if text_prompts and cls_id_int < len(text_prompts):
|
|
1114
|
+
label = text_prompts[cls_id_int]
|
|
1115
|
+
else:
|
|
1116
|
+
label = str(cls_id_int)
|
|
1117
|
+
|
|
1118
|
+
# Map SAM3's object ID to our consistent track ID
|
|
1119
|
+
if obj_id not in self._current_track_ids:
|
|
1120
|
+
self._current_track_ids[obj_id] = self._next_track_id
|
|
1121
|
+
self._next_track_id += 1
|
|
1122
|
+
track_id = self._current_track_ids[obj_id]
|
|
1123
|
+
|
|
1124
|
+
# Get mask
|
|
1125
|
+
mask = None
|
|
1126
|
+
if self.config.output_masks:
|
|
1127
|
+
mask = (binary_mask * 255).astype(np.uint8)
|
|
1128
|
+
|
|
1129
|
+
yield Detection(
|
|
1130
|
+
source_file=context.source_file,
|
|
1131
|
+
timestamp=context.timestamp,
|
|
1132
|
+
frame_number=context.frame_number,
|
|
1133
|
+
xc=float(xc),
|
|
1134
|
+
yc=float(yc),
|
|
1135
|
+
width=float(width),
|
|
1136
|
+
height=float(height),
|
|
1137
|
+
confidence=float(confidence) if confidence is not None else None,
|
|
1138
|
+
label=label,
|
|
1139
|
+
track_id=track_id,
|
|
1140
|
+
mask=mask,
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def add_exemplar(
|
|
1145
|
+
self,
|
|
1146
|
+
box: Optional[List[float]] = None,
|
|
1147
|
+
point: Optional[List[float]] = None,
|
|
1148
|
+
positive: bool = False
|
|
1149
|
+
) -> None:
|
|
1150
|
+
"""
|
|
1151
|
+
Add an interactive exemplar for refinement.
|
|
1152
|
+
|
|
1153
|
+
Use this to dynamically add positive or negative examples during
|
|
1154
|
+
processing. Positive exemplars help find similar objects; negative
|
|
1155
|
+
exemplars help exclude similar regions.
|
|
1156
|
+
|
|
1157
|
+
This is useful for interactive workflows where user feedback can
|
|
1158
|
+
improve detection quality by providing additional context.
|
|
1159
|
+
|
|
1160
|
+
Args:
|
|
1161
|
+
box: Bounding box as [x1, y1, x2, y2] in xyxy format.
|
|
1162
|
+
Mutually exclusive with point.
|
|
1163
|
+
point: Point as [x, y] in pixel coordinates.
|
|
1164
|
+
Mutually exclusive with box.
|
|
1165
|
+
positive: If True, find similar objects (foreground).
|
|
1166
|
+
If False, exclude similar regions (background).
|
|
1167
|
+
|
|
1168
|
+
Raises:
|
|
1169
|
+
ValueError: If neither box nor point is provided, or if both
|
|
1170
|
+
are provided simultaneously.
|
|
1171
|
+
|
|
1172
|
+
Example:
|
|
1173
|
+
>>> detector = SAM3Detector(config)
|
|
1174
|
+
>>>
|
|
1175
|
+
>>> # Add positive box exemplar - "find objects like this"
|
|
1176
|
+
>>> detector.add_exemplar(box=[100, 100, 200, 200], positive=True)
|
|
1177
|
+
>>>
|
|
1178
|
+
>>> # Add negative box exemplar - "exclude regions like this"
|
|
1179
|
+
>>> detector.add_exemplar(box=[300, 300, 350, 350], positive=False)
|
|
1180
|
+
>>>
|
|
1181
|
+
>>> # Add positive point exemplar
|
|
1182
|
+
>>> detector.add_exemplar(point=[150, 150], positive=True)
|
|
1183
|
+
>>>
|
|
1184
|
+
>>> # Process frames with these exemplars
|
|
1185
|
+
>>> for detection in detector.process_frame(frame, context):
|
|
1186
|
+
... print(detection)
|
|
1187
|
+
|
|
1188
|
+
Note:
|
|
1189
|
+
Exemplars persist until clear_exemplars() is called or the
|
|
1190
|
+
detector is reset. They are combined with configured prompts
|
|
1191
|
+
during processing.
|
|
1192
|
+
"""
|
|
1193
|
+
if box is None and point is None:
|
|
1194
|
+
raise ValueError("Either box or point must be provided")
|
|
1195
|
+
if box is not None and point is not None:
|
|
1196
|
+
raise ValueError("Cannot provide both box and point")
|
|
1197
|
+
|
|
1198
|
+
if box is not None:
|
|
1199
|
+
if positive:
|
|
1200
|
+
self._positive_boxes.append(box)
|
|
1201
|
+
logger.info(f"Added positive box exemplar: {box}")
|
|
1202
|
+
else:
|
|
1203
|
+
self._negative_boxes.append(box)
|
|
1204
|
+
logger.info(f"Added negative box exemplar: {box}")
|
|
1205
|
+
|
|
1206
|
+
if point is not None:
|
|
1207
|
+
if positive:
|
|
1208
|
+
self._positive_points.append(point)
|
|
1209
|
+
logger.info(f"Added positive point exemplar: {point}")
|
|
1210
|
+
else:
|
|
1211
|
+
self._negative_points.append(point)
|
|
1212
|
+
logger.info(f"Added negative point exemplar: {point}")
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def clear_exemplars(self) -> None:
|
|
1216
|
+
"""
|
|
1217
|
+
Clear all interactive exemplars.
|
|
1218
|
+
|
|
1219
|
+
Removes all positive and negative exemplars (both boxes and points)
|
|
1220
|
+
that were added via add_exemplar(). Configured prompts from the
|
|
1221
|
+
config are not affected.
|
|
1222
|
+
|
|
1223
|
+
Example:
|
|
1224
|
+
>>> detector.add_exemplar(box=[100, 100, 200, 200], positive=True)
|
|
1225
|
+
>>> detector.add_exemplar(box=[300, 300, 350, 350], positive=False)
|
|
1226
|
+
>>> detector.clear_exemplars() # Removes both exemplars
|
|
1227
|
+
"""
|
|
1228
|
+
self._positive_boxes.clear()
|
|
1229
|
+
self._negative_boxes.clear()
|
|
1230
|
+
self._positive_points.clear()
|
|
1231
|
+
self._negative_points.clear()
|
|
1232
|
+
logger.info("Cleared all exemplars")
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def reset(self) -> None:
|
|
1236
|
+
"""
|
|
1237
|
+
Reset detector state for a new video.
|
|
1238
|
+
|
|
1239
|
+
Clears all internal state including:
|
|
1240
|
+
- Frame counter
|
|
1241
|
+
- Video initialisation flag
|
|
1242
|
+
- Current source file tracking
|
|
1243
|
+
- Track ID mappings
|
|
1244
|
+
- Prompter detector state (if in hybrid mode)
|
|
1245
|
+
- Interactive exemplars
|
|
1246
|
+
- Predictor internal state
|
|
1247
|
+
|
|
1248
|
+
This is called automatically when processing a new video source,
|
|
1249
|
+
but can also be called manually to force a state reset.
|
|
1250
|
+
|
|
1251
|
+
Example:
|
|
1252
|
+
>>> detector.reset() # Force reset before processing new content
|
|
1253
|
+
"""
|
|
1254
|
+
self._frame_count = 0
|
|
1255
|
+
self._video_initialised = False
|
|
1256
|
+
self._current_source_file = None
|
|
1257
|
+
self._current_track_ids.clear()
|
|
1258
|
+
self._next_track_id = 0
|
|
1259
|
+
|
|
1260
|
+
if self._prompter_detector is not None:
|
|
1261
|
+
self._prompter_detector.reset()
|
|
1262
|
+
|
|
1263
|
+
# TODO: Add functionality so that when multiple similar videos are
|
|
1264
|
+
# processed sequentially, we can choose to keep exemplars from one video
|
|
1265
|
+
# to the next. For now, we clear all exemplars on reset.
|
|
1266
|
+
self.clear_exemplars()
|
|
1267
|
+
|
|
1268
|
+
# Reset predictor state
|
|
1269
|
+
if self._predictor is not None:
|
|
1270
|
+
if self._is_video_mode:
|
|
1271
|
+
self._predictor.inference_state = {}
|
|
1272
|
+
else:
|
|
1273
|
+
try:
|
|
1274
|
+
self._predictor.reset_image()
|
|
1275
|
+
self._predictor.reset_prompts()
|
|
1276
|
+
except (AttributeError, TypeError):
|
|
1277
|
+
pass
|
|
1278
|
+
|
|
1279
|
+
logger.debug("SAM3Detector state reset")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def __enter__(self):
|
|
1283
|
+
"""
|
|
1284
|
+
Context manager entry.
|
|
1285
|
+
|
|
1286
|
+
Allows the detector to be used with Python's 'with' statement for
|
|
1287
|
+
automatic resource cleanup.
|
|
1288
|
+
|
|
1289
|
+
Returns:
|
|
1290
|
+
Self for use in the with block.
|
|
1291
|
+
|
|
1292
|
+
Example:
|
|
1293
|
+
>>> with SAM3Detector(config) as detector:
|
|
1294
|
+
... for frame, context in source.iter_frames():
|
|
1295
|
+
... for detection in detector.process_frame(frame, context):
|
|
1296
|
+
... print(detection)
|
|
1297
|
+
"""
|
|
1298
|
+
return self
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1302
|
+
"""
|
|
1303
|
+
Context manager exit.
|
|
1304
|
+
|
|
1305
|
+
Performs cleanup by resetting the detector state.
|
|
1306
|
+
|
|
1307
|
+
Args:
|
|
1308
|
+
exc_type: Exception type if an exception was raised.
|
|
1309
|
+
exc_val: Exception value if an exception was raised.
|
|
1310
|
+
exc_tb: Exception traceback if an exception was raised.
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
False to propagate any exceptions.
|
|
1314
|
+
"""
|
|
1315
|
+
self.reset()
|
|
1316
|
+
return False
|