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.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. 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