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
seavision/pipeline.py
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""Pipeline orchestration for buoy detection system."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Dict, List, Optional, Tuple, Type, Union
|
|
8
|
+
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
|
|
11
|
+
from .engine import (
|
|
12
|
+
VideoSource,
|
|
13
|
+
DetectionWriter,
|
|
14
|
+
CSVWriterConfig,
|
|
15
|
+
OutputMode,
|
|
16
|
+
DetectorBase,
|
|
17
|
+
PostprocessStage,
|
|
18
|
+
build_postprocess_stages,
|
|
19
|
+
)
|
|
20
|
+
from .engine.detectors.motion import (
|
|
21
|
+
MotionDetectorConfig,
|
|
22
|
+
MotionDetector,
|
|
23
|
+
StabiliserConfig,
|
|
24
|
+
BackgroundConfig
|
|
25
|
+
)
|
|
26
|
+
from .engine.visualiser import VisualiserConfig, LiveVisualiser
|
|
27
|
+
|
|
28
|
+
# Module logger
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# ==============================================================================
|
|
32
|
+
# DETECTOR REGISTRY
|
|
33
|
+
# ==============================================================================
|
|
34
|
+
|
|
35
|
+
# Type alias for detector classes
|
|
36
|
+
DetectorClass = Type[DetectorBase]
|
|
37
|
+
ConfigParser = Callable[[dict], object]
|
|
38
|
+
|
|
39
|
+
# Registry mapping detector type names to (DetectorClass, config_parser) tuples
|
|
40
|
+
# Config parser takes a dict and returns the appropiate config object
|
|
41
|
+
DETECTOR_REGISTRY: Dict[str, Tuple[DetectorClass, ConfigParser]] = {}
|
|
42
|
+
|
|
43
|
+
def register_detector(
|
|
44
|
+
name: str,
|
|
45
|
+
detector_class: DetectorClass,
|
|
46
|
+
config_parser: ConfigParser
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Register a detector type for use with YAML configuration.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
name: Name used in config files (e.g., "motion", "yolo").
|
|
53
|
+
detector_class: The detector class.
|
|
54
|
+
config_parser: Function that parses a config dict into the appropriate
|
|
55
|
+
config object for this detector.
|
|
56
|
+
"""
|
|
57
|
+
DETECTOR_REGISTRY[name] = (detector_class, config_parser)
|
|
58
|
+
logger.debug(f"Registered detector '{name}'.")
|
|
59
|
+
|
|
60
|
+
def _parse_motion_config(data:dict) -> MotionDetectorConfig:
|
|
61
|
+
"""
|
|
62
|
+
Parse a dictionary into MotionDetectorConfig.
|
|
63
|
+
"""
|
|
64
|
+
# Stabiliser sub-config
|
|
65
|
+
stabiliser_data = data.get("stabiliser", {})
|
|
66
|
+
stabiliser = StabiliserConfig(
|
|
67
|
+
feature_detector=stabiliser_data.get("feature_detector", "ORB"),
|
|
68
|
+
max_features=stabiliser_data.get("max_features", 500),
|
|
69
|
+
match_ratio=stabiliser_data.get("match_ratio", 0.75),
|
|
70
|
+
min_matches=stabiliser_data.get("min_matches", 10),
|
|
71
|
+
ransac_threshold=stabiliser_data.get("ransac_threshold", 5.0),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Background sub-config
|
|
75
|
+
background_data = data.get("background", {})
|
|
76
|
+
background = BackgroundConfig(
|
|
77
|
+
history=background_data.get("history", 600),
|
|
78
|
+
var_threshold=background_data.get("var_threshold", 16),
|
|
79
|
+
detect_shadows=background_data.get("detect_shadows", True),
|
|
80
|
+
learning_rate=background_data.get("learning_rate", -1)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Main motion detector config
|
|
84
|
+
return MotionDetectorConfig(
|
|
85
|
+
stabiliser=stabiliser,
|
|
86
|
+
background=background,
|
|
87
|
+
stabilisation_enabled=data.get("stabilisation_enabled", True),
|
|
88
|
+
min_area=data.get("min_area", 500),
|
|
89
|
+
max_area=data.get("max_area", 5000),
|
|
90
|
+
morph_kernel_size=data.get("morph_kernel_size", 5),
|
|
91
|
+
morph_iterations=data.get("morph_iterations", 2),
|
|
92
|
+
# Persistence filtering
|
|
93
|
+
persistence_enabled=data.get("persistence_enabled", True),
|
|
94
|
+
min_persistence=data.get("min_persistence", 3),
|
|
95
|
+
max_frames_missing=data.get("max_frames_missing", 5),
|
|
96
|
+
iou_threshold=data.get("iou_threshold", 0.3),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Register built-in detectors
|
|
100
|
+
register_detector(
|
|
101
|
+
"motion",
|
|
102
|
+
MotionDetector,
|
|
103
|
+
_parse_motion_config
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _parse_sam3_config(data: dict) -> "SAM3DetectorConfig":
|
|
107
|
+
"""Parse a dictionary into SAM3DetectorConfig."""
|
|
108
|
+
from .engine.detectors.sam3 import (
|
|
109
|
+
SAM3DetectorConfig,
|
|
110
|
+
PromptConfig,
|
|
111
|
+
PromptType,
|
|
112
|
+
PrompterConfig,
|
|
113
|
+
HybridStrategy,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Handle simple prompts shorthand
|
|
117
|
+
prompts = data.get("prompts", [])
|
|
118
|
+
|
|
119
|
+
# Handle advanced prompt_config
|
|
120
|
+
prompt_config = None
|
|
121
|
+
if "prompt_config" in data:
|
|
122
|
+
pc_data = data["prompt_config"]
|
|
123
|
+
|
|
124
|
+
# Parse prompt type
|
|
125
|
+
prompt_type_str = pc_data.get("prompt_type", "TEXT").upper()
|
|
126
|
+
prompt_type = PromptType[prompt_type_str]
|
|
127
|
+
|
|
128
|
+
# Parse prompter config for DETECTOR mode
|
|
129
|
+
prompter = None
|
|
130
|
+
if "prompter" in pc_data:
|
|
131
|
+
pr_data = pc_data["prompter"]
|
|
132
|
+
strategy_str = pr_data.get("strategy", "BOX_REFINEMENT").upper()
|
|
133
|
+
strategy = HybridStrategy[strategy_str]
|
|
134
|
+
|
|
135
|
+
prompter = PrompterConfig(
|
|
136
|
+
detector_type=pr_data.get("detector_type", "motion"),
|
|
137
|
+
detector_config=pr_data.get("detector_config", {}),
|
|
138
|
+
strategy=strategy,
|
|
139
|
+
text_prompts=pr_data.get("text_prompts", []),
|
|
140
|
+
min_iou_with_prompt=pr_data.get("min_iou_with_prompt", 0.0),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
prompt_config = PromptConfig(
|
|
144
|
+
prompt_type=prompt_type,
|
|
145
|
+
text_prompts=pc_data.get("text_prompts", []),
|
|
146
|
+
box_prompts=pc_data.get("box_prompts", []),
|
|
147
|
+
box_labels=pc_data.get("box_labels", []),
|
|
148
|
+
point_prompts=pc_data.get("point_prompts", []),
|
|
149
|
+
point_labels=pc_data.get("point_labels", []),
|
|
150
|
+
prompter=prompter,
|
|
151
|
+
reprompt_interval=pc_data.get("reprompt_interval", 0),
|
|
152
|
+
reprompt_on_lost=pc_data.get("reprompt_on_lost", True),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return SAM3DetectorConfig(
|
|
156
|
+
checkpoint=data.get("checkpoint", "sam3.pt"),
|
|
157
|
+
device=data.get("device", "cuda"),
|
|
158
|
+
half=data.get("half", True),
|
|
159
|
+
prompts=prompts,
|
|
160
|
+
prompt_config=prompt_config,
|
|
161
|
+
confidence_threshold=data.get("confidence_threshold", 0.25),
|
|
162
|
+
min_mask_area=data.get("min_mask_area", 100),
|
|
163
|
+
max_mask_area=data.get("max_mask_area", 1000000),
|
|
164
|
+
video_mode=data.get("video_mode", True),
|
|
165
|
+
output_masks=data.get("output_masks", False),
|
|
166
|
+
output_labels=data.get("output_labels", True),
|
|
167
|
+
imgsz=data.get("imgsz", 1024),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Register SAM 3 detector (conditional on availability)
|
|
172
|
+
try:
|
|
173
|
+
from .engine.detectors.sam3 import SAM3Detector
|
|
174
|
+
register_detector("sam3", SAM3Detector, _parse_sam3_config)
|
|
175
|
+
except ImportError:
|
|
176
|
+
logger.debug("SAM 3 detector not available (ultralytics not installed)")
|
|
177
|
+
|
|
178
|
+
# ==============================================================================
|
|
179
|
+
# CONFIGURATION DATACLASSES
|
|
180
|
+
# ==============================================================================
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class InputConfig:
|
|
184
|
+
"""
|
|
185
|
+
Configuration for pipeline input processing.
|
|
186
|
+
|
|
187
|
+
Note: Video discovery (paths, patterns) is handled externally via
|
|
188
|
+
discover_local_videos() or discover_s3_videos(). This config only
|
|
189
|
+
contains processing options.
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
frame_skip: Process every Nth frame (1 = all frames, 5 = every 5th).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
frame_skip: int = 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class DetectorConfig:
|
|
200
|
+
"""
|
|
201
|
+
Configuration for detector selection and settings.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
type: Detector type name (must be registered in DETECTOR_REGISTRY).
|
|
205
|
+
config: Dictionary of detector-specific configuration options.
|
|
206
|
+
"""
|
|
207
|
+
type: str = "motion"
|
|
208
|
+
config: dict = field(default_factory=dict)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class PipelineConfig:
|
|
213
|
+
"""
|
|
214
|
+
Configuration for the pipeline input.
|
|
215
|
+
|
|
216
|
+
Attributes:
|
|
217
|
+
input: Input file/directory configuration.
|
|
218
|
+
output: Output/postprocessing configuration.
|
|
219
|
+
detector: Detector configuration
|
|
220
|
+
resume: If True, skip videos that already have output files.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
input: InputConfig = field(default_factory=InputConfig)
|
|
224
|
+
output: CSVWriterConfig = field(default_factory=CSVWriterConfig)
|
|
225
|
+
detector: DetectorConfig = field(default_factory=DetectorConfig)
|
|
226
|
+
resume: bool = True
|
|
227
|
+
visualiser: Optional[VisualiserConfig] = None
|
|
228
|
+
# Raw dictionary used to build postprocessing stages via build_postprocess_stages
|
|
229
|
+
postprocess: Optional[dict] = None
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_dict(cls, data: dict) -> "PipelineConfig":
|
|
233
|
+
"""
|
|
234
|
+
Create a PipelineConfig from a dictionary (e.g. loaded from YAML).
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
data: Dictionary containing configuration data.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
PipelineConfig instance.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
# Parse input config
|
|
244
|
+
input_data = data.get("input", {})
|
|
245
|
+
input_config = InputConfig(
|
|
246
|
+
frame_skip=input_data.get("frame_skip", 1),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Parse output config
|
|
250
|
+
output_data = data.get("output", {})
|
|
251
|
+
output_mode_str = output_data.get("output_mode", "per_video")
|
|
252
|
+
output_mode = (
|
|
253
|
+
OutputMode.SINGLE_FILE if output_mode_str == "single"
|
|
254
|
+
else OutputMode.PER_VIDEO
|
|
255
|
+
)
|
|
256
|
+
output_config = CSVWriterConfig(
|
|
257
|
+
output_dir=output_data.get("directory", "./output"),
|
|
258
|
+
output_mode=output_mode,
|
|
259
|
+
single_file_name=output_data.get("single_file_name", "detections.csv"),
|
|
260
|
+
overwrite=output_data.get("overwrite", False)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Parse detector config (Generic)
|
|
264
|
+
detector_data = data.get("detector", {})
|
|
265
|
+
detector_config = DetectorConfig(
|
|
266
|
+
type=detector_data.get("type", "motion"),
|
|
267
|
+
config=detector_data.get("config", {})
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Top level options
|
|
271
|
+
resume = data.get("resume", False)
|
|
272
|
+
|
|
273
|
+
# Optional postprocessing configuration (raw dict interpreted by
|
|
274
|
+
# build_postprocess_stages). If absent or empty, no postprocessing is
|
|
275
|
+
# applied.
|
|
276
|
+
postprocess_config: Optional[dict] = None
|
|
277
|
+
post_data = data.get("postprocess")
|
|
278
|
+
if isinstance(post_data, dict) and post_data:
|
|
279
|
+
postprocess_config = post_data
|
|
280
|
+
|
|
281
|
+
return cls(
|
|
282
|
+
input=input_config,
|
|
283
|
+
output=output_config,
|
|
284
|
+
detector=detector_config,
|
|
285
|
+
resume=resume,
|
|
286
|
+
visualiser=None, # will be set by caller if needed
|
|
287
|
+
postprocess=postprocess_config,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# ==============================================================================
|
|
291
|
+
# RESULT CLASSES
|
|
292
|
+
# ==============================================================================
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class DryRunResult:
|
|
296
|
+
"""
|
|
297
|
+
Results from a dry run.
|
|
298
|
+
|
|
299
|
+
Attributes:
|
|
300
|
+
videos_found: Number of video files found.
|
|
301
|
+
videos_to_process: Number of videos that would be processed.
|
|
302
|
+
videos_skipped: Number of videos that would be skipped (already have output).
|
|
303
|
+
total_frames: Total number of frames across all videos.
|
|
304
|
+
total_duration: Total duration of all videos (seconds).
|
|
305
|
+
output_mode: Output mode that would be used.
|
|
306
|
+
output_dir: Directory where output would be saved.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
videos_found: int = 0
|
|
310
|
+
videos_to_process: int = 0
|
|
311
|
+
videos_skipped: int = 0
|
|
312
|
+
total_frames: int = 0
|
|
313
|
+
total_duration: float = 0.0
|
|
314
|
+
output_mode: str = ""
|
|
315
|
+
output_dir: str = ""
|
|
316
|
+
|
|
317
|
+
def summary(self) -> str:
|
|
318
|
+
"""Generate a human readable summary."""
|
|
319
|
+
|
|
320
|
+
hours = int(self.total_duration // 3600)
|
|
321
|
+
minutes = int((self.total_duration % 3600) // 60)
|
|
322
|
+
|
|
323
|
+
lines = [
|
|
324
|
+
"Dry run complete:",
|
|
325
|
+
f" Videos found: {self.videos_found}",
|
|
326
|
+
f" Videos to process: {self.videos_to_process}"
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
if self.videos_skipped > 0:
|
|
330
|
+
lines.append(f" Videos to skip (resume): {self.videos_skipped}")
|
|
331
|
+
|
|
332
|
+
lines.extend([
|
|
333
|
+
f" Total frames: {self.total_frames}",
|
|
334
|
+
f" Total duration: {hours}h {minutes}m",
|
|
335
|
+
f" Output mode: {self.output_mode}",
|
|
336
|
+
f" Output directory: {self.output_dir}"
|
|
337
|
+
])
|
|
338
|
+
|
|
339
|
+
return "\n".join(lines)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class PipelineResult:
|
|
344
|
+
"""
|
|
345
|
+
Results from a pipeline run.
|
|
346
|
+
|
|
347
|
+
Attributes:
|
|
348
|
+
videos_processed: Number of videos successfuly processed.
|
|
349
|
+
videos_skipped: Number of videos skipped (resume mode).
|
|
350
|
+
videos_failed: Number of videos that failed processing.
|
|
351
|
+
total_detections: Total number of detections across all videos.
|
|
352
|
+
elapsed_time: Total elapsed time (seconds).
|
|
353
|
+
detections_per_video: DIctionary mapping video path to number of detections.
|
|
354
|
+
failed_videos: Dictionary mapping video path to error message.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
videos_processed: int = 0
|
|
358
|
+
videos_skipped: int = 0
|
|
359
|
+
videos_failed: int = 0
|
|
360
|
+
total_detections: int = 0
|
|
361
|
+
elapsed_time: float = 0.0
|
|
362
|
+
detections_per_video: Dict = field(default_factory=dict)
|
|
363
|
+
failed_videos: Dict = field(default_factory=dict)
|
|
364
|
+
|
|
365
|
+
def summary(self) -> str:
|
|
366
|
+
"""Generate a human readable summary."""
|
|
367
|
+
|
|
368
|
+
lines = [
|
|
369
|
+
"Pipeline Results:",
|
|
370
|
+
f" Videos processed: {self.videos_processed}",
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
if self.videos_skipped > 0:
|
|
374
|
+
lines.append(f" Videos skipped (resume): {self.videos_skipped}")
|
|
375
|
+
|
|
376
|
+
lines.extend([
|
|
377
|
+
f" Videos failed: {self.videos_failed}",
|
|
378
|
+
f" Total detections: {self.total_detections}",
|
|
379
|
+
f" Elapsed time: {self.elapsed_time:.1f} seconds"
|
|
380
|
+
])
|
|
381
|
+
|
|
382
|
+
if self.failed_videos:
|
|
383
|
+
lines.append(" Failed videos:")
|
|
384
|
+
for video, error in self.failed_videos.items():
|
|
385
|
+
lines.append(f" - {Path(video).name}: {error}")
|
|
386
|
+
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ==============================================================================
|
|
391
|
+
# PIPELINE CLASS
|
|
392
|
+
# ==============================================================================
|
|
393
|
+
|
|
394
|
+
class DetectionPipeline:
|
|
395
|
+
"""
|
|
396
|
+
Main pipeline for processing videos and detecting objects.
|
|
397
|
+
|
|
398
|
+
Ochestrates video loading, detection, and output writing.
|
|
399
|
+
Supports pluggable detectors via factory function or a registry.
|
|
400
|
+
|
|
401
|
+
Example (default motion detector):
|
|
402
|
+
config = PipelineConfig(
|
|
403
|
+
input=InputConfig(path="./footage/"),
|
|
404
|
+
output=CSVWriterConfig(output_dir="./results"),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
pipeline = Pipeline(config)
|
|
408
|
+
result = pipeline.run()
|
|
409
|
+
print(result.summary())
|
|
410
|
+
|
|
411
|
+
Example (custom detector via factory):
|
|
412
|
+
pipeline = Pipeline(
|
|
413
|
+
config,
|
|
414
|
+
detector_factory=lambda: MotionDetector(
|
|
415
|
+
MotionDetectorConfig(min_area=500)
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
result = pipeline.run()
|
|
419
|
+
|
|
420
|
+
Example (future ML detector):
|
|
421
|
+
pipeline = Pipeline(
|
|
422
|
+
config,
|
|
423
|
+
detector_factory=lambda: YOLODetector(model_path="weights/fish.pt")
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
Example (via YAML config):
|
|
427
|
+
# config.yaml:
|
|
428
|
+
# detector:
|
|
429
|
+
# type: motion
|
|
430
|
+
# config:
|
|
431
|
+
# min_area: 500
|
|
432
|
+
|
|
433
|
+
config = PipelineConfig.from_dict(yaml.safe_load(open("config.yaml")))
|
|
434
|
+
pipeline = Pipeline(config)
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(
|
|
438
|
+
self,
|
|
439
|
+
config: Optional[PipelineConfig] = None,
|
|
440
|
+
detector_factory: Optional[Callable[[], DetectorBase]] = None
|
|
441
|
+
):
|
|
442
|
+
"""
|
|
443
|
+
Initialise the detection pipeline.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
config: Pipeline configuration. If None, defaults are used.
|
|
447
|
+
detector_factory: Optional callable that returns a new detector
|
|
448
|
+
instance. If provided, overrides the detector config from
|
|
449
|
+
PipelineConfig. Use this for maximum flexibility when configuring
|
|
450
|
+
detectors programmatically.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
self.config = config or PipelineConfig()
|
|
454
|
+
self._detector_factory = detector_factory or self._create_detector_from_config
|
|
455
|
+
|
|
456
|
+
# Optional ordered detection postprocessing stages (frame and/or video)
|
|
457
|
+
self._postprocess_stages: List[PostprocessStage] = build_postprocess_stages(
|
|
458
|
+
self.config.postprocess
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Create visualiser if configured
|
|
462
|
+
self._visualiser: Optional[LiveVisualiser] = None
|
|
463
|
+
if self.config.visualiser is not None:
|
|
464
|
+
self._visualiser = LiveVisualiser(self.config.visualiser)
|
|
465
|
+
|
|
466
|
+
logger.info("DetectionPipeline initialised.")
|
|
467
|
+
logger.debug(f"Output directory: {self.config.output.output_dir}")
|
|
468
|
+
logger.debug(f"Detector type: {self.config.detector.type}")
|
|
469
|
+
|
|
470
|
+
if self.config.input.frame_skip > 1:
|
|
471
|
+
logger.info(
|
|
472
|
+
f"Frame skipping enabled: processing every "
|
|
473
|
+
f"{self.config.input.frame_skip} frame(s)."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if self.config.resume:
|
|
477
|
+
logger.info("Resume mode enabled: skipping videos with exisiting outputs.")
|
|
478
|
+
|
|
479
|
+
if self._visualiser is not None:
|
|
480
|
+
logger.info("Live visualiser enabled.")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _create_detector_from_config(self) -> DetectorBase:
|
|
484
|
+
"""
|
|
485
|
+
Creates a detector instance based on the pipeline config.
|
|
486
|
+
|
|
487
|
+
Uses the detector registry to look up the appropiate class and config
|
|
488
|
+
parser.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
A new detector instance.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
ValueError: If the specified detector type is not registered.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
detector_type = self.config.detector.type
|
|
498
|
+
|
|
499
|
+
if detector_type not in DETECTOR_REGISTRY:
|
|
500
|
+
available = list(DETECTOR_REGISTRY.keys())
|
|
501
|
+
raise ValueError(
|
|
502
|
+
f"Unknown detector type '{detector_type}'. "
|
|
503
|
+
f"Available types: {available}"
|
|
504
|
+
f"Add new detectors via register_detector()."
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
detector_class, config_parser = DETECTOR_REGISTRY[detector_type]
|
|
508
|
+
detector_config = config_parser(self.config.detector.config)
|
|
509
|
+
|
|
510
|
+
return detector_class(detector_config)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def process_sources(
|
|
514
|
+
self,
|
|
515
|
+
sources: List[VideoSource],
|
|
516
|
+
dry_run: bool = False,
|
|
517
|
+
) -> Union[PipelineResult, DryRunResult]:
|
|
518
|
+
"""
|
|
519
|
+
Process a list of video sources.
|
|
520
|
+
|
|
521
|
+
This is the main entry point. Sources can come from any discovery
|
|
522
|
+
function (discover_local_videos, discover_s3_videos, etc.).
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
sources: List of VideoSource instances to process.
|
|
526
|
+
dry_run: If True, gather statistics without actually processing.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
PipelineResult with processing statistics, or DryRunResult if
|
|
530
|
+
dry_run is True.
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
from engine import discover_local_videos
|
|
534
|
+
|
|
535
|
+
sources = discover_local_videos("./footage/", "*.ts")
|
|
536
|
+
result = pipeline.process_sources(sources)
|
|
537
|
+
"""
|
|
538
|
+
if not sources:
|
|
539
|
+
logger.warning("No video sources provided.")
|
|
540
|
+
if dry_run:
|
|
541
|
+
return DryRunResult()
|
|
542
|
+
return PipelineResult()
|
|
543
|
+
|
|
544
|
+
logger.info(f"Processing {len(sources)} video source(s).")
|
|
545
|
+
|
|
546
|
+
if dry_run:
|
|
547
|
+
return self._dry_run(sources)
|
|
548
|
+
else:
|
|
549
|
+
return self._process_sources(sources)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _get_source_identifier(self, source: VideoSource) -> str:
|
|
553
|
+
"""
|
|
554
|
+
Get a unique identifier for a video source.
|
|
555
|
+
|
|
556
|
+
Uses the source_file from metadata, which is set during source
|
|
557
|
+
creation.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
source: VideoSource instance.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
String identifier (typically the file path or URI).
|
|
564
|
+
"""
|
|
565
|
+
metadata = source.get_metadata()
|
|
566
|
+
return metadata.source_file
|
|
567
|
+
|
|
568
|
+
def _get_output_path(self, source_identifier: str) -> Path:
|
|
569
|
+
"""
|
|
570
|
+
Get the output CSV file path for a given source.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
source_identifier: Unique identifier for the source.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Path to the output CSV file.
|
|
577
|
+
"""
|
|
578
|
+
if self.config.output.output_mode == OutputMode.SINGLE_FILE:
|
|
579
|
+
return (
|
|
580
|
+
Path(self.config.output.output_dir)
|
|
581
|
+
/ self.config.output.single_file_name
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
# Extract stem from identifier (works for paths and URIs)
|
|
585
|
+
name = Path(source_identifier).stem
|
|
586
|
+
return (
|
|
587
|
+
Path(self.config.output.output_dir)
|
|
588
|
+
/ f"{name}_detections.csv"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _should_skip_source(self, source: VideoSource) -> bool:
|
|
593
|
+
"""
|
|
594
|
+
Determine whether to skip processing a source based on resume mode.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
source: VideoSource instance.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
True if the source should be skipped, False otherwise.
|
|
601
|
+
"""
|
|
602
|
+
if not self.config.resume:
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
# Can't resume in single file mode (would need to parse existing CSV)
|
|
606
|
+
if self.config.output.output_mode == OutputMode.SINGLE_FILE:
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
source_id = self._get_source_identifier(source)
|
|
610
|
+
output_path = self._get_output_path(source_id)
|
|
611
|
+
return output_path.exists()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _dry_run(self, sources: List[VideoSource]) -> DryRunResult:
|
|
615
|
+
"""
|
|
616
|
+
Perform a dry run - gather statistics without processing.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
sources: List of VideoSource instances to scan.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
DryRunResult with gathered statistics.
|
|
623
|
+
"""
|
|
624
|
+
result = DryRunResult(
|
|
625
|
+
videos_found=len(sources),
|
|
626
|
+
output_mode=self.config.output.output_mode.value,
|
|
627
|
+
output_dir=self.config.output.output_dir,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
sources_to_process = []
|
|
631
|
+
|
|
632
|
+
for source in tqdm(sources, desc="Scanning videos", unit="video"):
|
|
633
|
+
if self._should_skip_source(source):
|
|
634
|
+
result.videos_skipped += 1
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
sources_to_process.append(source)
|
|
638
|
+
|
|
639
|
+
# Get video metadata
|
|
640
|
+
try:
|
|
641
|
+
metadata = source.get_metadata()
|
|
642
|
+
result.total_frames += metadata.frame_count
|
|
643
|
+
result.total_duration += metadata.duration
|
|
644
|
+
except Exception as e:
|
|
645
|
+
source_id = self._get_source_identifier(source)
|
|
646
|
+
logger.warning(
|
|
647
|
+
f"Could not read metadata from {source_id}: {e}"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
result.videos_to_process = len(sources_to_process)
|
|
651
|
+
|
|
652
|
+
# Adjust frame count for frame skipping
|
|
653
|
+
if self.config.input.frame_skip > 1:
|
|
654
|
+
result.total_frames = result.total_frames // self.config.input.frame_skip
|
|
655
|
+
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
def _process_sources(self, sources: List[VideoSource]) -> PipelineResult:
|
|
659
|
+
"""
|
|
660
|
+
Process a list of video sources.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
sources: List of VideoSource instances to process.
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
PipelineResult with processing statistics.
|
|
667
|
+
"""
|
|
668
|
+
result = PipelineResult()
|
|
669
|
+
start_time = time.time()
|
|
670
|
+
|
|
671
|
+
with DetectionWriter(self.config.output) as writer:
|
|
672
|
+
for source in tqdm(sources, desc="Processing videos", unit="video"):
|
|
673
|
+
# Get source identifier for logging and output
|
|
674
|
+
source_id = self._get_source_identifier(source)
|
|
675
|
+
source_name = Path(source_id).name
|
|
676
|
+
|
|
677
|
+
# Check if we should skip this source (resume mode)
|
|
678
|
+
if self._should_skip_source(source):
|
|
679
|
+
logger.debug(f"Skipping (resume): {source_name}")
|
|
680
|
+
result.videos_skipped += 1
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
detection_count = self._process_single_source(source, writer)
|
|
685
|
+
result.detections_per_video[source_id] = detection_count
|
|
686
|
+
result.videos_processed += 1
|
|
687
|
+
|
|
688
|
+
# Finalise to handle empty videos
|
|
689
|
+
writer.finalise_video(source_id)
|
|
690
|
+
|
|
691
|
+
except Exception as e:
|
|
692
|
+
logger.error(f"Failed to process {source_name}: {e}")
|
|
693
|
+
result.failed_videos[source_id] = str(e)
|
|
694
|
+
result.videos_failed += 1
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
result.total_detections = writer.detection_count
|
|
698
|
+
result.elapsed_time = time.time() - start_time
|
|
699
|
+
|
|
700
|
+
# Log summary
|
|
701
|
+
logger.info(
|
|
702
|
+
f"Pipeline complete: {result.videos_processed} processed, "
|
|
703
|
+
f"{result.videos_skipped} skipped, "
|
|
704
|
+
f"{result.videos_failed} failed, "
|
|
705
|
+
f"{result.total_detections} detections, "
|
|
706
|
+
f"{result.elapsed_time:.1f}s elapsed."
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
if result.failed_videos:
|
|
710
|
+
logger.warning(
|
|
711
|
+
f"Failed videos: "
|
|
712
|
+
f"{[Path(v).name for v in result.failed_videos.keys()]}"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return result
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _process_single_source(
|
|
719
|
+
self,
|
|
720
|
+
source: VideoSource,
|
|
721
|
+
writer: DetectionWriter,
|
|
722
|
+
) -> int:
|
|
723
|
+
"""
|
|
724
|
+
Process a single video source.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
source: VideoSource instance to process.
|
|
728
|
+
writer: DetectionWriter instance for outputting detections.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
Number of detections made in this video.
|
|
732
|
+
"""
|
|
733
|
+
source_id = self._get_source_identifier(source)
|
|
734
|
+
source_name = Path(source_id).name
|
|
735
|
+
logger.debug(f"Processing: {source_name}")
|
|
736
|
+
|
|
737
|
+
detection_count = 0
|
|
738
|
+
frame_skip = self.config.input.frame_skip
|
|
739
|
+
|
|
740
|
+
metadata = source.get_metadata()
|
|
741
|
+
logger.debug(
|
|
742
|
+
f"Video: {metadata.frame_count} frames, "
|
|
743
|
+
f"{metadata.duration:.1f}s, {metadata.fps:.1f} FPS"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Start visualisation for this source
|
|
747
|
+
if self._visualiser is not None:
|
|
748
|
+
self._visualiser.start_video(metadata)
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
with self._detector_factory() as detector:
|
|
752
|
+
# Progress bar for frames
|
|
753
|
+
frame_iter = tqdm(
|
|
754
|
+
source.iter_frames(),
|
|
755
|
+
desc=f" {source_name}",
|
|
756
|
+
total=metadata.frame_count,
|
|
757
|
+
unit="frame",
|
|
758
|
+
leave=False,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
# If no postprocessing stages are configured, stream detections
|
|
762
|
+
# directly to the writer/visualiser as before.
|
|
763
|
+
if not self._postprocess_stages:
|
|
764
|
+
for frame_idx, (frame, context) in enumerate(frame_iter):
|
|
765
|
+
# Skip frames if configured
|
|
766
|
+
if frame_skip > 1 and frame_idx % frame_skip != 0:
|
|
767
|
+
continue
|
|
768
|
+
|
|
769
|
+
frame_detections = list(detector.process_frame(frame, context))
|
|
770
|
+
|
|
771
|
+
# Write to CSV
|
|
772
|
+
for detection in frame_detections:
|
|
773
|
+
writer.write(detection)
|
|
774
|
+
detection_count += 1
|
|
775
|
+
|
|
776
|
+
# Visualise frame with detections
|
|
777
|
+
if self._visualiser is not None:
|
|
778
|
+
self._visualiser.process_frame(frame, frame_detections, context)
|
|
779
|
+
|
|
780
|
+
else:
|
|
781
|
+
# When postprocessing stages are configured (including any
|
|
782
|
+
# video-level stages), buffer detections for the whole
|
|
783
|
+
# video, then apply stages in the configured order.
|
|
784
|
+
frames: List[object] = [] # store frames only if needed for visualisation
|
|
785
|
+
contexts: List[object] = []
|
|
786
|
+
detections_per_frame: List[List[object]] = []
|
|
787
|
+
|
|
788
|
+
for frame_idx, (frame, context) in enumerate(frame_iter):
|
|
789
|
+
if frame_skip > 1 and frame_idx % frame_skip != 0:
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
frame_detections = list(detector.process_frame(frame, context))
|
|
793
|
+
|
|
794
|
+
# Buffer for later processing
|
|
795
|
+
if self._visualiser is not None:
|
|
796
|
+
frames.append(frame)
|
|
797
|
+
contexts.append(context)
|
|
798
|
+
detections_per_frame.append(frame_detections)
|
|
799
|
+
|
|
800
|
+
# Apply stages in order on the buffered detections.
|
|
801
|
+
for stage in self._postprocess_stages:
|
|
802
|
+
if stage.kind == "video":
|
|
803
|
+
# Frames/contexts are optional; pass only detections
|
|
804
|
+
# and metadata to avoid unnecessary buffering
|
|
805
|
+
detections_per_frame = stage.impl.process_video(
|
|
806
|
+
detections_per_frame,
|
|
807
|
+
metadata,
|
|
808
|
+
)
|
|
809
|
+
else: # "frame"
|
|
810
|
+
impl = stage.impl
|
|
811
|
+
impl.reset_for_video(metadata)
|
|
812
|
+
for i, (ctx, dets) in enumerate(
|
|
813
|
+
zip(contexts, detections_per_frame)
|
|
814
|
+
):
|
|
815
|
+
detections_per_frame[i] = impl.process_frame(dets, ctx)
|
|
816
|
+
impl.finalize_video()
|
|
817
|
+
|
|
818
|
+
# After all stages, write and visualise using the final detections.
|
|
819
|
+
for idx, (context, frame_detections) in enumerate(
|
|
820
|
+
zip(contexts, detections_per_frame)
|
|
821
|
+
):
|
|
822
|
+
frame = frames[idx] if self._visualiser is not None else None
|
|
823
|
+
|
|
824
|
+
for detection in frame_detections:
|
|
825
|
+
writer.write(detection)
|
|
826
|
+
detection_count += 1
|
|
827
|
+
|
|
828
|
+
if self._visualiser is not None and frame is not None:
|
|
829
|
+
self._visualiser.process_frame(frame, frame_detections, context)
|
|
830
|
+
finally:
|
|
831
|
+
# End visualisation for this source
|
|
832
|
+
if self._visualiser is not None:
|
|
833
|
+
vis_result = self._visualiser.end_video()
|
|
834
|
+
logger.debug(f"Visualisation: {vis_result.summary()}")
|
|
835
|
+
|
|
836
|
+
logger.debug(f"Completed {source_name}: {detection_count} detections.")
|
|
837
|
+
return detection_count
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ==============================================================================
|
|
841
|
+
# UTILITY FUNCTIONS
|
|
842
|
+
# ==============================================================================
|
|
843
|
+
|
|
844
|
+
def setup_logging(level: int = logging.INFO) -> None:
|
|
845
|
+
"""
|
|
846
|
+
Set up basic logging configuration.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
level: Logging level (e.g., logging.INFO, logging.DEBUG).
|
|
850
|
+
"""
|
|
851
|
+
|
|
852
|
+
logging.basicConfig(
|
|
853
|
+
level=level,
|
|
854
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
855
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
856
|
+
)
|