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
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
+ )