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,490 @@
|
|
|
1
|
+
"""Native SAM3 detector using the official Transformers API.
|
|
2
|
+
|
|
3
|
+
This module provides a DetectorBase-compatible wrapper around Meta's
|
|
4
|
+
"facebook/sam3" image segmentation model exposed via the Hugging Face
|
|
5
|
+
Transformers library. It is intended as an alternative to the
|
|
6
|
+
Ultralytics-based SAM3 integration used by :class:`SAM3Detector`.
|
|
7
|
+
|
|
8
|
+
Key characteristics:
|
|
9
|
+
|
|
10
|
+
- Uses :class:`transformers.Sam3Model` and :class:`transformers.Sam3Processor`
|
|
11
|
+
directly ("native" API), mirroring the behaviour of the official
|
|
12
|
+
SAM3 demo and documentation.
|
|
13
|
+
- Operates on video streams frame-by-frame, but treats each frame as an
|
|
14
|
+
independent image segmentation problem and then assigns simple track IDs
|
|
15
|
+
across frames using IoU-based matching.
|
|
16
|
+
- Supports TEXT prompts only ("promptable concept segmentation"), using the
|
|
17
|
+
same :class:`SAM3DetectorConfig` and :class:`PromptConfig` as the existing
|
|
18
|
+
Ultralytics-backed detector.
|
|
19
|
+
- Emits :class:`engine.detectors.base.Detection` objects, so it integrates
|
|
20
|
+
seamlessly with the rest of the SeaVision pipeline (writers, visualisers
|
|
21
|
+
and post-processors).
|
|
22
|
+
|
|
23
|
+
Notes
|
|
24
|
+
-----
|
|
25
|
+
- This implementation focuses on correctness and API parity rather than
|
|
26
|
+
maximum performance. For each frame, it runs the SAM3 model once per
|
|
27
|
+
text prompt, similar to how the official demo handles single-text queries.
|
|
28
|
+
- The tracking implementation is deliberately simple (greedy IoU matching)
|
|
29
|
+
and is intended to provide stable track IDs for downstream consumers
|
|
30
|
+
(e.g. motion-based filters), rather than fully replicating SAM3's
|
|
31
|
+
internal video-tracking behaviour.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Dict, Iterator, List, Optional
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
import torch
|
|
41
|
+
from PIL import Image
|
|
42
|
+
|
|
43
|
+
from ...source import FrameContext
|
|
44
|
+
from ..base import Detection, DetectorBase
|
|
45
|
+
from .config import SAM3DetectorConfig, PromptConfig, PromptType
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
try: # Optional dependency: Transformers with SAM3 support
|
|
51
|
+
from transformers import Sam3Model, Sam3Processor # type: ignore
|
|
52
|
+
|
|
53
|
+
HF_SAM3_AVAILABLE = True
|
|
54
|
+
except ImportError: # pragma: no cover - optional dependency guard
|
|
55
|
+
Sam3Model = None # type: ignore[assignment]
|
|
56
|
+
Sam3Processor = None # type: ignore[assignment]
|
|
57
|
+
HF_SAM3_AVAILABLE = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SAM3NativeDetector(DetectorBase):
|
|
61
|
+
"""Native SAM3 detector using the Hugging Face Transformers API.
|
|
62
|
+
|
|
63
|
+
This detector provides promptable concept segmentation for video frames
|
|
64
|
+
using the official ``facebook/sam3`` checkpoint. It accepts the same
|
|
65
|
+
:class:`SAM3DetectorConfig` as the Ultralytics-based :class:`SAM3Detector`
|
|
66
|
+
and produces :class:`Detection` objects in the same format so it can be
|
|
67
|
+
dropped into existing SeaVision pipelines.
|
|
68
|
+
|
|
69
|
+
Differences vs :class:`SAM3Detector` (Ultralytics backend):
|
|
70
|
+
|
|
71
|
+
- Uses :class:`Sam3Model` + :class:`Sam3Processor` (image PCS) rather
|
|
72
|
+
than Ultralytics' ``SAM3VideoSemanticPredictor``.
|
|
73
|
+
- Currently supports **TEXT prompts only** (one or more concepts), using
|
|
74
|
+
per-frame image segmentation rather than the SAM3 video model.
|
|
75
|
+
- Implements a simple IoU-based tracker to maintain ``track_id``
|
|
76
|
+
continuity across frames, instead of relying on SAM3's native video
|
|
77
|
+
tracking state.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
config:
|
|
82
|
+
:class:`SAM3DetectorConfig` instance. The following fields are used:
|
|
83
|
+
|
|
84
|
+
- ``prompts`` / ``prompt_config.text_prompts``: list of text
|
|
85
|
+
concepts, e.g. ``["fish", "seal"]``.
|
|
86
|
+
- ``confidence_threshold``: minimum score for keeping a mask.
|
|
87
|
+
- ``min_mask_area`` / ``max_mask_area``: area filters (in pixels).
|
|
88
|
+
- ``device``: ``"cuda"``, ``"cpu"``, etc.
|
|
89
|
+
- ``output_masks`` / ``output_labels``: control Detection outputs.
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
ImportError
|
|
94
|
+
If the ``transformers`` package with SAM3 support is not installed.
|
|
95
|
+
ValueError
|
|
96
|
+
If the prompt configuration is missing or uses a non-TEXT mode.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# IoU threshold for associating detections with existing tracks
|
|
100
|
+
_TRACK_IOU_THRESHOLD: float = 0.5
|
|
101
|
+
# Maximum age (in frames) for keeping tracks without updates
|
|
102
|
+
_MAX_TRACK_AGE_FRAMES: int = 30
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: Optional[SAM3DetectorConfig] = None):
|
|
105
|
+
if not HF_SAM3_AVAILABLE:
|
|
106
|
+
raise ImportError(
|
|
107
|
+
"SAM3NativeDetector requires the 'transformers' package with "
|
|
108
|
+
"SAM3 support. Install with: pip install 'transformers>=4.47' "
|
|
109
|
+
"and ensure the facebook/sam3 checkpoint is available."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self.config: SAM3DetectorConfig = config or SAM3DetectorConfig()
|
|
113
|
+
|
|
114
|
+
# Ensure we have a TEXT prompt configuration
|
|
115
|
+
prompt_cfg: Optional[PromptConfig] = self.config.prompt_config
|
|
116
|
+
if prompt_cfg is None:
|
|
117
|
+
if self.config.prompts:
|
|
118
|
+
prompt_cfg = PromptConfig(
|
|
119
|
+
prompt_type=PromptType.TEXT,
|
|
120
|
+
text_prompts=self.config.prompts,
|
|
121
|
+
)
|
|
122
|
+
self.config.prompt_config = prompt_cfg
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"SAM3NativeDetector requires text prompts. Provide "
|
|
126
|
+
"SAM3DetectorConfig(prompts=[...]) or a PromptConfig "
|
|
127
|
+
"with prompt_type=TEXT."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if prompt_cfg.prompt_type != PromptType.TEXT:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"SAM3NativeDetector currently supports only TEXT prompts. "
|
|
133
|
+
f"Got prompt_type={prompt_cfg.prompt_type!r}."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if not prompt_cfg.text_prompts:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"No text prompts specified. Provide text_prompts in the "
|
|
139
|
+
"PromptConfig or use the 'prompts' shorthand in "
|
|
140
|
+
"SAM3DetectorConfig."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._device = torch.device(self.config.device)
|
|
144
|
+
|
|
145
|
+
# Lazily loaded native SAM3 components
|
|
146
|
+
self._model: Optional[Sam3Model] = None # type: ignore[assignment]
|
|
147
|
+
self._processor: Optional[Sam3Processor] = None # type: ignore[assignment]
|
|
148
|
+
|
|
149
|
+
# Video / tracking state
|
|
150
|
+
self._current_source_file: Optional[str] = None
|
|
151
|
+
self._frame_count: int = 0
|
|
152
|
+
|
|
153
|
+
# Simple track state: track_id -> {"bbox": (x1,y1,x2,y2), "label": str, "last_frame": int}
|
|
154
|
+
self._tracks: Dict[int, Dict] = {}
|
|
155
|
+
self._next_track_id: int = 0
|
|
156
|
+
|
|
157
|
+
logger.info("SAM3NativeDetector initialised")
|
|
158
|
+
logger.info(" Backend : Hugging Face Sam3Model (image PCS)")
|
|
159
|
+
logger.info(" Device : %s", self._device)
|
|
160
|
+
logger.info(" Prompts : %s", prompt_cfg.text_prompts)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Model loading and video state management
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
def _ensure_model_loaded(self) -> None:
|
|
166
|
+
"""Lazily load the native SAM3 model and processor.
|
|
167
|
+
|
|
168
|
+
By default this loads the official ``facebook/sam3`` checkpoint from
|
|
169
|
+
Hugging Face. You can override the model identifier or use a fully
|
|
170
|
+
offline local path by setting ``SAM3DetectorConfig.checkpoint`` to
|
|
171
|
+
either a local directory or a different model ID.
|
|
172
|
+
|
|
173
|
+
If the environment variable ``HF_TOKEN`` is set, it will be passed to
|
|
174
|
+
``from_pretrained`` to authenticate against gated repositories.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
if self._model is not None and self._processor is not None:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Allow overriding the model via config.checkpoint. This can be either
|
|
181
|
+
# a local path (for offline use) or a Hugging Face model ID.
|
|
182
|
+
model_name_or_path = getattr(self.config, "checkpoint", None) or "facebook/sam3"
|
|
183
|
+
|
|
184
|
+
# Optional token for gated repos
|
|
185
|
+
import os
|
|
186
|
+
|
|
187
|
+
hf_token = os.environ.get("HF_TOKEN")
|
|
188
|
+
|
|
189
|
+
logger.info(
|
|
190
|
+
"Loading native SAM3 model '%s' on %s (token: %s)",
|
|
191
|
+
model_name_or_path,
|
|
192
|
+
self._device,
|
|
193
|
+
"set" if hf_token else "not set",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
model_kwargs = {"token": hf_token} if hf_token else {}
|
|
197
|
+
|
|
198
|
+
# Use default dtype from the checkpoint; mixed precision can be
|
|
199
|
+
# controlled externally if desired.
|
|
200
|
+
self._model = Sam3Model.from_pretrained( # type: ignore[call-arg]
|
|
201
|
+
model_name_or_path,
|
|
202
|
+
**model_kwargs,
|
|
203
|
+
).to(self._device)
|
|
204
|
+
self._processor = Sam3Processor.from_pretrained( # type: ignore[call-arg]
|
|
205
|
+
model_name_or_path,
|
|
206
|
+
**model_kwargs,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._model.eval()
|
|
210
|
+
logger.info("Native SAM3 model loaded successfully")
|
|
211
|
+
|
|
212
|
+
def _on_video_change(self, context: FrameContext) -> None:
|
|
213
|
+
"""Reset tracking state when the video source changes.
|
|
214
|
+
|
|
215
|
+
This is called automatically when ``context.source_file`` differs
|
|
216
|
+
from the previously seen source. It clears the internal track
|
|
217
|
+
dictionary and frame counter, ensuring that detections from
|
|
218
|
+
different videos are not linked together.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
if self._current_source_file is not None:
|
|
222
|
+
logger.debug(
|
|
223
|
+
"Video changed from %s to %s",
|
|
224
|
+
self._current_source_file,
|
|
225
|
+
context.source_file,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self._current_source_file = context.source_file
|
|
229
|
+
self._frame_count = 0
|
|
230
|
+
self._tracks.clear()
|
|
231
|
+
self._next_track_id = 0
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Core DetectorBase implementation
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
def process_frame(
|
|
237
|
+
self,
|
|
238
|
+
frame: np.ndarray,
|
|
239
|
+
context: FrameContext,
|
|
240
|
+
) -> Iterator[Detection]:
|
|
241
|
+
"""Process a single video frame and yield detections.
|
|
242
|
+
|
|
243
|
+
For each configured text prompt, this method runs the native SAM3
|
|
244
|
+
image model to obtain segmentation masks for matching concepts.
|
|
245
|
+
Masks are converted into :class:`Detection` objects, filtered by
|
|
246
|
+
area and confidence, and then assigned simple track IDs based on
|
|
247
|
+
IoU with tracks from previous frames.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
self._ensure_model_loaded()
|
|
251
|
+
|
|
252
|
+
# Reset state when the video source changes
|
|
253
|
+
if context.source_file != self._current_source_file:
|
|
254
|
+
self._on_video_change(context)
|
|
255
|
+
|
|
256
|
+
prompt_cfg = self.config.prompt_config
|
|
257
|
+
assert prompt_cfg is not None # validated in __init__
|
|
258
|
+
text_prompts = prompt_cfg.text_prompts
|
|
259
|
+
|
|
260
|
+
all_detections: List[Detection] = []
|
|
261
|
+
|
|
262
|
+
# Run SAM3 once per text concept, similar to the HF demo's usage.
|
|
263
|
+
for text in text_prompts:
|
|
264
|
+
detections_for_prompt = self._run_single_prompt(frame, context, text)
|
|
265
|
+
all_detections.extend(detections_for_prompt)
|
|
266
|
+
|
|
267
|
+
# Assign / update track IDs based on IoU with existing tracks
|
|
268
|
+
self._assign_tracks(all_detections, context.frame_number)
|
|
269
|
+
|
|
270
|
+
# Yield detections for this frame
|
|
271
|
+
for det in all_detections:
|
|
272
|
+
yield det
|
|
273
|
+
|
|
274
|
+
self._frame_count += 1
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
# Prompt processing helpers
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
def _run_single_prompt(
|
|
280
|
+
self,
|
|
281
|
+
frame: np.ndarray,
|
|
282
|
+
context: FrameContext,
|
|
283
|
+
text_prompt: str,
|
|
284
|
+
) -> List[Detection]:
|
|
285
|
+
"""Run SAM3 for a single text prompt on the given frame.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
frame:
|
|
290
|
+
BGR image as a ``numpy.ndarray`` with shape ``(H, W, 3)``.
|
|
291
|
+
context:
|
|
292
|
+
Frame metadata (timestamp, frame_number, source_file, etc.).
|
|
293
|
+
text_prompt:
|
|
294
|
+
Single natural-language concept to segment (e.g. ``"fish"``).
|
|
295
|
+
|
|
296
|
+
Returns
|
|
297
|
+
-------
|
|
298
|
+
list of :class:`Detection`
|
|
299
|
+
One Detection per accepted mask instance for this prompt.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
assert self._processor is not None
|
|
303
|
+
assert self._model is not None
|
|
304
|
+
|
|
305
|
+
# Convert BGR (OpenCV-style) to RGB for the processor
|
|
306
|
+
rgb = frame[:, :, ::-1]
|
|
307
|
+
pil_image = Image.fromarray(rgb)
|
|
308
|
+
|
|
309
|
+
# Build model inputs using the native processor
|
|
310
|
+
model_inputs = self._processor(
|
|
311
|
+
images=pil_image,
|
|
312
|
+
text=text_prompt,
|
|
313
|
+
return_tensors="pt",
|
|
314
|
+
).to(self._device)
|
|
315
|
+
|
|
316
|
+
with torch.no_grad():
|
|
317
|
+
outputs = self._model(**model_inputs)
|
|
318
|
+
|
|
319
|
+
# Post-process to obtain per-instance masks and scores
|
|
320
|
+
processed = self._processor.post_process_instance_segmentation(
|
|
321
|
+
outputs,
|
|
322
|
+
threshold=self.config.confidence_threshold,
|
|
323
|
+
mask_threshold=0.5,
|
|
324
|
+
target_sizes=model_inputs.get("original_sizes").tolist(),
|
|
325
|
+
)[0]
|
|
326
|
+
|
|
327
|
+
masks = processed.get("masks")
|
|
328
|
+
scores = processed.get("scores")
|
|
329
|
+
|
|
330
|
+
if masks is None or scores is None:
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
# Convert to numpy for geometric processing
|
|
334
|
+
masks_np = masks.cpu().numpy()
|
|
335
|
+
scores_np = scores.cpu().numpy()
|
|
336
|
+
|
|
337
|
+
detections: List[Detection] = []
|
|
338
|
+
|
|
339
|
+
for mask_arr, score in zip(masks_np, scores_np):
|
|
340
|
+
conf = float(score)
|
|
341
|
+
if conf < self.config.confidence_threshold:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
# Binary mask (True where object present)
|
|
345
|
+
binary_mask = mask_arr > 0.5
|
|
346
|
+
if not binary_mask.any():
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
# Bounding box from mask extents
|
|
350
|
+
rows = np.any(binary_mask, axis=1)
|
|
351
|
+
cols = np.any(binary_mask, axis=0)
|
|
352
|
+
|
|
353
|
+
if not rows.any() or not cols.any():
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
y_indices = np.where(rows)[0]
|
|
357
|
+
x_indices = np.where(cols)[0]
|
|
358
|
+
y1, y2 = y_indices[0], y_indices[-1]
|
|
359
|
+
x1, x2 = x_indices[0], x_indices[-1]
|
|
360
|
+
|
|
361
|
+
width = x2 - x1
|
|
362
|
+
height = y2 - y1
|
|
363
|
+
area = width * height
|
|
364
|
+
|
|
365
|
+
if area < self.config.min_mask_area:
|
|
366
|
+
continue
|
|
367
|
+
if area > self.config.max_mask_area:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# Optionally include the raw mask in the Detection
|
|
371
|
+
mask_out = None
|
|
372
|
+
if self.config.output_masks:
|
|
373
|
+
mask_out = (binary_mask.astype(np.uint8) * 255)
|
|
374
|
+
|
|
375
|
+
det = Detection.from_bbox(
|
|
376
|
+
source_file=context.source_file,
|
|
377
|
+
timestamp=context.timestamp,
|
|
378
|
+
frame_number=context.frame_number,
|
|
379
|
+
x1=float(x1),
|
|
380
|
+
y1=float(y1),
|
|
381
|
+
x2=float(x2),
|
|
382
|
+
y2=float(y2),
|
|
383
|
+
confidence=conf,
|
|
384
|
+
label=text_prompt if self.config.output_labels else None,
|
|
385
|
+
mask=mask_out,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
detections.append(det)
|
|
389
|
+
|
|
390
|
+
return detections
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Tracking helpers
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _bbox_iou(bbox_a: tuple, bbox_b: tuple) -> float:
|
|
397
|
+
"""Compute IoU between two ``(x1, y1, x2, y2)`` boxes."""
|
|
398
|
+
|
|
399
|
+
ax1, ay1, ax2, ay2 = bbox_a
|
|
400
|
+
bx1, by1, bx2, by2 = bbox_b
|
|
401
|
+
|
|
402
|
+
inter_x1 = max(ax1, bx1)
|
|
403
|
+
inter_y1 = max(ay1, by1)
|
|
404
|
+
inter_x2 = min(ax2, bx2)
|
|
405
|
+
inter_y2 = min(ay2, by2)
|
|
406
|
+
|
|
407
|
+
if inter_x2 <= inter_x1 or inter_y2 <= inter_y1:
|
|
408
|
+
return 0.0
|
|
409
|
+
|
|
410
|
+
inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1)
|
|
411
|
+
area_a = max((ax2 - ax1) * (ay2 - ay1), 0.0)
|
|
412
|
+
area_b = max((bx2 - bx1) * (by2 - by1), 0.0)
|
|
413
|
+
|
|
414
|
+
union = area_a + area_b - inter_area
|
|
415
|
+
if union <= 0.0:
|
|
416
|
+
return 0.0
|
|
417
|
+
|
|
418
|
+
return float(inter_area / union)
|
|
419
|
+
|
|
420
|
+
def _assign_tracks(self, detections: List[Detection], frame_number: int) -> None:
|
|
421
|
+
"""Assign or update track IDs for the detections in this frame.
|
|
422
|
+
|
|
423
|
+
The algorithm is a simple greedy IoU-based tracker:
|
|
424
|
+
|
|
425
|
+
- For each detection, find the best matching existing track with the
|
|
426
|
+
same label and IoU above :attr:`_TRACK_IOU_THRESHOLD`.
|
|
427
|
+
- If a match is found, reuse the track ID and update its state.
|
|
428
|
+
- Otherwise, create a new track ID for the detection.
|
|
429
|
+
- Tracks that have not been updated for more than
|
|
430
|
+
:attr:`_MAX_TRACK_AGE_FRAMES` frames are pruned.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
if not detections and not self._tracks:
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
# Prune very old tracks
|
|
437
|
+
for track_id in list(self._tracks.keys()):
|
|
438
|
+
last_frame = self._tracks[track_id]["last_frame"]
|
|
439
|
+
if frame_number - last_frame > self._MAX_TRACK_AGE_FRAMES:
|
|
440
|
+
del self._tracks[track_id]
|
|
441
|
+
|
|
442
|
+
for det in detections:
|
|
443
|
+
best_track_id: Optional[int] = None
|
|
444
|
+
best_iou = 0.0
|
|
445
|
+
|
|
446
|
+
det_bbox = det.bbox
|
|
447
|
+
det_label = det.label
|
|
448
|
+
|
|
449
|
+
for track_id, track_state in self._tracks.items():
|
|
450
|
+
if det_label is not None and track_state["label"] != det_label:
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
iou = self._bbox_iou(det_bbox, track_state["bbox"])
|
|
454
|
+
if iou > best_iou:
|
|
455
|
+
best_iou = iou
|
|
456
|
+
best_track_id = track_id
|
|
457
|
+
|
|
458
|
+
if best_track_id is not None and best_iou >= self._TRACK_IOU_THRESHOLD:
|
|
459
|
+
# Reuse existing track
|
|
460
|
+
det.track_id = best_track_id
|
|
461
|
+
self._tracks[best_track_id]["bbox"] = det_bbox
|
|
462
|
+
self._tracks[best_track_id]["last_frame"] = frame_number
|
|
463
|
+
else:
|
|
464
|
+
# Start a new track
|
|
465
|
+
new_id = self._next_track_id
|
|
466
|
+
self._next_track_id += 1
|
|
467
|
+
|
|
468
|
+
det.track_id = new_id
|
|
469
|
+
self._tracks[new_id] = {
|
|
470
|
+
"bbox": det_bbox,
|
|
471
|
+
"label": det_label,
|
|
472
|
+
"last_frame": frame_number,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
# Lifecycle helpers
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
def reset(self) -> None:
|
|
479
|
+
"""Reset detector and tracking state.
|
|
480
|
+
|
|
481
|
+
This clears the current video context, frame counter and internal
|
|
482
|
+
track dictionary. The underlying SAM3 model and processor remain
|
|
483
|
+
loaded so that subsequent videos do not pay the model load cost
|
|
484
|
+
again.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
self._current_source_file = None
|
|
488
|
+
self._frame_count = 0
|
|
489
|
+
self._tracks.clear()
|
|
490
|
+
self._next_track_id = 0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YOLO-based object detector for SeaVision.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- YOLODetectorConfig: configuration for the YOLO detector.
|
|
6
|
+
- YOLODetector: DetectorBase-compatible wrapper around Ultralytics YOLO.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .config import YOLODetectorConfig
|
|
10
|
+
from .detector import YOLODetector
|
|
11
|
+
|
|
12
|
+
__all__ = ["YOLODetector", "YOLODetectorConfig"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration dataclass for the YOLO detector."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class YOLODetectorConfig:
|
|
8
|
+
"""
|
|
9
|
+
Configuration for the YOLO detector.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
model_path: Path to the YOLO model weights.
|
|
13
|
+
device: Device to run the model on (e.g., 'cpu', 'cuda', or 'mps').
|
|
14
|
+
imgsz: Inference image size (e.g., 640), passed to the YOLO model.
|
|
15
|
+
conf_threshold: Minimum confidence score for detections.
|
|
16
|
+
iou_threshold: IoU threshold for non-max suppression.
|
|
17
|
+
max_detections: Maximum number of detections per image.
|
|
18
|
+
classes: Optional list of class indices to keep. If None, keep all
|
|
19
|
+
classes.
|
|
20
|
+
output_labels: Whether to map class indicies to human-readable labels
|
|
21
|
+
using the model's built-in names.
|
|
22
|
+
half: Whether to use half precision (float16) for inference. Only
|
|
23
|
+
applicable on compatible devices (e.g., CUDA).
|
|
24
|
+
verbose: Whether to print detailed logs during inference (default True).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_path: str = ""
|
|
28
|
+
device: str = "cpu"
|
|
29
|
+
imgsz: int = 640
|
|
30
|
+
|
|
31
|
+
conf_threshold: float = 0.25
|
|
32
|
+
iou_threshold: float = 0.45
|
|
33
|
+
max_detections: int = 300
|
|
34
|
+
|
|
35
|
+
classes: Optional[List[int]] = None
|
|
36
|
+
output_labels: bool = True
|
|
37
|
+
|
|
38
|
+
half: bool = False
|
|
39
|
+
verbose: bool = True
|