seavision-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. seavision_python-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,738 @@
1
+ """Postprocessing utilities and CSV writing for detections.
2
+
3
+ This module contains two main components:
4
+
5
+ 1. Postprocessing abstractions and built-in implementations that operate on
6
+ :class:`Detection` objects, either per-frame or per-video:
7
+
8
+ - FramePostprocessor: transforms detections for a single frame (e.g. label
9
+ filters, per-frame NMS)
10
+ - VideoPostprocessor: transforms detections for an entire video at once
11
+ (e.g. motion-based track pruning with full temporal context).
12
+
13
+ 2. CSV writers for persisting detections to disk (DetectionWriter).
14
+
15
+ The goal is to allow the SeaVision pipeline to be configured with an ordered
16
+ list of postprocessing stages, mixing frame-level and video-level operations
17
+ as needed for a given deployment.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import math
24
+ import os
25
+ from dataclasses import dataclass
26
+ from enum import Enum
27
+ from pathlib import Path
28
+ from typing import (
29
+ Callable,
30
+ Dict,
31
+ List,
32
+ Literal,
33
+ Optional,
34
+ Protocol,
35
+ Set,
36
+ TypedDict
37
+ )
38
+ import numpy as np
39
+
40
+ from .detectors import Detection
41
+ from .source import FrameContext, VideoMetadata
42
+
43
+ # ==============================================================================
44
+ # CSV Output Configuration
45
+ # ==============================================================================
46
+
47
+ class OutputMode(Enum):
48
+ """Output mode for detection results."""
49
+
50
+ SINGLE_FILE = "single" # All detections in one csv file
51
+ PER_VIDEO = "per_video" # Separate CSV file for each video
52
+
53
+
54
+ @dataclass
55
+ class CSVWriterConfig:
56
+ """Configuration for the detection CSV writer.
57
+
58
+ Attributes
59
+ ----------
60
+ output_dir:
61
+ Directory where the CSV files will be saved.
62
+ output_mode:
63
+ Whether to write all detections into a single CSV file or separate
64
+ CSV files per video.
65
+ single_file_name:
66
+ Filename for single mode (default: ``"detections.csv"``).
67
+ overwrite:
68
+ If True, overwrite existing files. If False, raise an error if the
69
+ output file already exists.
70
+ """
71
+
72
+ output_dir: str = "./output"
73
+ output_mode: OutputMode = OutputMode.PER_VIDEO
74
+ single_file_name: str = "detections.csv"
75
+ overwrite: bool = False
76
+
77
+
78
+ # ==============================================================================
79
+ # Postprocessor interfaces
80
+ # ==============================================================================
81
+
82
+
83
+ class FramePostprocessor(Protocol):
84
+ """
85
+ Operates on detections for a single frame, potentially with per-video state.
86
+
87
+ Typical examples:
88
+
89
+ - Confidence or size filters
90
+ - Label-based filters
91
+ - Per-frame non-maximum suppression (NMS)
92
+
93
+ Frame post processors are called once per frame during the pipeline.
94
+ """
95
+
96
+ def reset_for_video(self, metadata: VideoMetadata) -> None:
97
+ """Reset internal state for a new video."""
98
+
99
+ def process_frame(
100
+ self,
101
+ detections: List[Detection],
102
+ context: FrameContext,
103
+ ) -> List[Detection]:
104
+ """
105
+ Process detections for one frame and return the transformed list.
106
+
107
+ The caller is responsible for using the returned list; implementations
108
+ should not mutate the input list in place.
109
+ """
110
+
111
+ def finalize_video(self) -> None:
112
+ """Finalise any per-video state at the end of a video."""
113
+
114
+
115
+ class VideoPostprocessor(Protocol):
116
+ """Operate on detections for an entire video at once.
117
+
118
+ Frames and per-frame contexts are optional. Implementations that only
119
+ depend on detections and metadata can ignore them, which avoids the need
120
+ for the caller to buffer full-resolution frames in memory.
121
+ """
122
+
123
+ def process_video(
124
+ self,
125
+ detections_per_frame: List[List[Detection]],
126
+ metadata: VideoMetadata,
127
+ frames: Optional[List[np.ndarray]] = None,
128
+ contexts: Optional[List[FrameContext]] = None,
129
+ ) -> List[List[Detection]]:
130
+ """Process full-video detections and return new per-frame detections.
131
+
132
+ The returned list must have the same length as ``detections_per_frame``
133
+ and correspond to frames in the same order.
134
+ """
135
+
136
+ # ==============================================================================
137
+ # FramePostProcessors
138
+ # ==============================================================================
139
+
140
+
141
+ @dataclass
142
+ class LabelFilterConfig:
143
+ """
144
+ Simple label-based filtering configuration.
145
+
146
+ Attributes
147
+ ----------
148
+ keep_labels:
149
+ If not None, only detections whose `label` is in this set are kept.
150
+ drop_labels:
151
+ If not None, detections whose `label` is in this set are removed.
152
+
153
+ When both are set, a detection is kept only if:
154
+
155
+ (label in keep_labels or keep_labels is None)
156
+ and (label not in drop_labels or drop_labels is None).
157
+ """
158
+
159
+ keep_labels: Optional[Set[str]] = None
160
+ drop_labels: Optional[Set[str]] = None
161
+
162
+
163
+ class LabelFilter(FramePostprocessor):
164
+ """Filter detections based on their string labels."""
165
+
166
+ def __init__(self, config: Optional[LabelFilterConfig] = None) -> None:
167
+ self.config = config or LabelFilterConfig()
168
+
169
+ def reset_for_video(self, metadata: VideoMetadata) -> None:
170
+ # Stateless per video.
171
+ return None
172
+
173
+ def finalize_video(self) -> None:
174
+ # Stateless per video.
175
+ return None
176
+
177
+ def process_frame(
178
+ self,
179
+ detections: List[Detection],
180
+ context: FrameContext,
181
+ ) -> List[Detection]:
182
+ if not detections:
183
+ return []
184
+
185
+ keep = self.config.keep_labels
186
+ drop = self.config.drop_labels
187
+
188
+ # No filtering requested.
189
+ if keep is None and drop is None:
190
+ return detections
191
+
192
+ filtered: List[Detection] = []
193
+
194
+ for d in detections:
195
+ label = d.label
196
+
197
+ if keep is not None and label not in keep:
198
+ continue
199
+ if drop is not None and label in drop:
200
+ continue
201
+
202
+ filtered.append(d)
203
+
204
+ return filtered
205
+
206
+
207
+ @dataclass
208
+ class PerFrameNmsConfig:
209
+ """
210
+ Configuration for per-frame non-maximum suppression (NMS).
211
+
212
+ Attributes
213
+ ----------
214
+ iou_threshold:
215
+ IoU threshold above which a smaller box is suppressed.
216
+ class_agnostic:
217
+ If True, NMS is applied across all labels jointly.
218
+ If False, NMS is applied independently per label.
219
+ """
220
+
221
+ iou_threshold: float = 0.7
222
+ class_agnostic: bool = True
223
+
224
+
225
+ class PerFrameNmsPostprocessor(FramePostprocessor):
226
+ """
227
+ Apply greedy NMS to detections on a per-frame basis.
228
+
229
+ Detections are sorted by area (largest first) and overlapping boxes with
230
+ IoI greater than `iou_threshold` are supressed. When `class_agnostic` is
231
+ False, suppression is applied independently per label.
232
+ """
233
+
234
+ def __init__(self, config: Optional[PerFrameNmsConfig] = None) -> None:
235
+ self.config = config or PerFrameNmsConfig()
236
+
237
+ def reset_for_video(self, metadata: VideoMetadata) -> None:
238
+ # Stateless.
239
+ return None
240
+
241
+ def finalize_video(self) -> None:
242
+ # Stateless.
243
+ return None
244
+
245
+ def process_frame(
246
+ self,
247
+ detections: List[Detection],
248
+ context: FrameContext,
249
+ ) -> List[Detection]:
250
+ if not detections:
251
+ return []
252
+
253
+ # Group detections by label if class_agnostic is False.
254
+ if self.config.class_agnostic:
255
+ groups: Dict[Optional[str], List[Detection]] = {None: detections}
256
+ else:
257
+ groups = {}
258
+ for d in detections:
259
+ groups.setdefault(d.label, []).append(d)
260
+
261
+ kept_all: List[Detection] = []
262
+ for group in groups.values():
263
+ kept_all.extend(self._nms_group(group))
264
+
265
+ return kept_all
266
+
267
+ def _nms_group(self, dets: List[Detection]) -> List[Detection]:
268
+ if not dets:
269
+ return []
270
+
271
+ positives: List[Detection] = []
272
+ others: List[Detection] = []
273
+
274
+ # Separate detections with positive area from degenerate ones.
275
+ for d in dets:
276
+ if d.area > 0.0:
277
+ positives.append(d)
278
+ else:
279
+ others.append(d)
280
+
281
+ # Larger boxes first.
282
+ positives.sort(key=lambda d: d.area, reverse=True)
283
+
284
+ kept: List[Detection] = []
285
+
286
+ for det in positives:
287
+ if any(self._iou(det, k) > self.config.iou_threshold for k in kept):
288
+ continue
289
+ kept.append(det)
290
+
291
+ # Preserve degenerate-area detections unchanged.
292
+ kept.extend(others)
293
+ return kept
294
+
295
+ @staticmethod
296
+ def _iou(a: Detection, b: Detection) -> float:
297
+ ax1, ay1, ax2, ay2 = a.bbox
298
+ bx1, by1, bx2, by2 = b.bbox
299
+
300
+ inter_x1 = max(ax1, bx1)
301
+ inter_y1 = max(ay1, by1)
302
+ inter_x2 = min(ax2, bx2)
303
+ inter_y2 = min(ay2, by2)
304
+
305
+ inter_w = max(0.0, inter_x2 - inter_x1)
306
+ inter_h = max(0.0, inter_y2 - inter_y1)
307
+ inter_area = inter_w * inter_h
308
+
309
+ if inter_area <= 0.0:
310
+ return 0.0
311
+
312
+ area_a = max(0.0, (ax2 - ax1)) * max(0.0, (ay2 - ay1))
313
+ area_b = max(0.0, (bx2 - bx1)) * max(0.0, (by2 - by1))
314
+ denom = area_a + area_b - inter_area
315
+
316
+ return inter_area / denom if denom > 0.0 else 0.0
317
+
318
+
319
+ # ==============================================================================
320
+ # VideoPostProcessors
321
+ # ==============================================================================
322
+
323
+
324
+ @dataclass
325
+ class MotionTrackVideoConfig:
326
+ """
327
+ Configuration for full-video motion-based track filtering.
328
+
329
+ Attributes
330
+ ----------
331
+ window_seconds:
332
+ Time window over which motion is evaluated.
333
+ threshold_fraction:
334
+ Tracks whose displacement is less than this fraction of a
335
+ characteristic box size are dropped entirely.
336
+ """
337
+
338
+ window_seconds: float = 3.0
339
+ threshold_fraction: float = 0.4
340
+
341
+
342
+ class MotionTrackVideoPostprocessor(VideoPostprocessor):
343
+ """
344
+ Drop entire tracks that exhibit too little motion over a time window.
345
+
346
+ - For each track with enough detections (based on the fps and
347
+ window_seconds), measure the displacement of the box centre between the
348
+ first detection and a later detection at least window_seconds away.
349
+ - Compute a characteristic length scale from the mean of the first and
350
+ target box areas.
351
+ - If displacement < threshold_fraction * length_scale, the track is
352
+ considered stationary and removed entirely.
353
+ """
354
+
355
+ def __init__(self, config: Optional[MotionTrackVideoConfig] = None) -> None:
356
+ self.config = config or MotionTrackVideoConfig()
357
+
358
+ def process_video(
359
+ self,
360
+ detections_per_frame: List[List[Detection]],
361
+ metadata: VideoMetadata,
362
+ frames: Optional[List[np.ndarray]] = None,
363
+ contexts: Optional[List[FrameContext]] = None,
364
+ ) -> List[List[Detection]]:
365
+ fps = float(metadata.fps) if metadata.fps else 0.0
366
+ if fps <= 0.0:
367
+ return detections_per_frame
368
+
369
+ min_frames = int(self.config.window_seconds * fps)
370
+ if min_frames <= 1:
371
+ return detections_per_frame
372
+
373
+ # Build per-track history across the entire video.
374
+ tracks: Dict[int, List[Detection]] = {}
375
+ for frame_dets in detections_per_frame:
376
+ for d in frame_dets:
377
+ if d.track_id is None:
378
+ continue
379
+ tracks.setdefault(d.track_id, []).append(d)
380
+
381
+ drop_tracks: Set[int] = set()
382
+
383
+ for track_id, dets in tracks.items():
384
+ if not dets:
385
+ continue
386
+
387
+ dets_sorted = sorted(dets, key=lambda d: d.frame_number)
388
+ if len(dets_sorted) < min_frames:
389
+ continue
390
+
391
+ first = dets_sorted[0]
392
+ target = dets_sorted[-1]
393
+
394
+ # Prefer timestamp-based window when available.
395
+ if first.timestamp is not None:
396
+ for d in dets_sorted:
397
+ if d.timestamp is not None and d.timestamp - first.timestamp >= self.config.window_seconds:
398
+ target = d
399
+ break
400
+
401
+ # Characteristic box size from first and target detections.
402
+ area_first = max(first.area, 1.0)
403
+ area_last = max(target.area, 1.0)
404
+ area_mean = max((area_first + area_last) * 0.5, 1.0)
405
+
406
+ length_scale = math.sqrt(area_mean)
407
+ threshold = self.config.threshold_fraction * length_scale
408
+
409
+ dx = target.xc - first.xc
410
+ dy = target.yc - first.yc
411
+ displacement = math.hypot(dx, dy)
412
+
413
+ if displacement < threshold:
414
+ drop_tracks.add(track_id)
415
+
416
+ if not drop_tracks:
417
+ return detections_per_frame
418
+
419
+ # Filter out detections from tracks to drop.
420
+ filtered_per_frame: List[List[Detection]] = []
421
+ for frame_dets in detections_per_frame:
422
+ filtered_per_frame.append(
423
+ [d for d in frame_dets if d.track_id not in drop_tracks]
424
+ )
425
+
426
+ return filtered_per_frame
427
+
428
+
429
+ # ==============================================================================
430
+ # Stage registry and construction helper
431
+ # ==============================================================================
432
+
433
+ StageKind = Literal["frame", "video"]
434
+
435
+ @dataclass
436
+ class PostprocessStage:
437
+ """
438
+ A single postprocessing stage with a concrete implementation.
439
+
440
+ Attributes
441
+ ----------
442
+ kind:
443
+ "frame" or "video".
444
+ impl:
445
+ Instance of FramePostprocessor or VideoPostprocessor.
446
+ """
447
+
448
+ kind: StageKind
449
+ impl: object # FramePostprocessor | VideoPostprocessor
450
+
451
+
452
+ class PostprocessorFactory(TypedDict):
453
+ """Registry entry describing how to build a postprocessor stage."""
454
+
455
+ kind: StageKind
456
+ build: Callable[[dict], object] # returns FramePostprocessor or VideoPostprocessor
457
+
458
+
459
+ POSTPROCESSOR_REGISTRY: Dict[str, PostprocessorFactory] = {
460
+ "motion_track_video": {
461
+ "kind": "video",
462
+ "build": lambda cfg: MotionTrackVideoPostprocessor(
463
+ MotionTrackVideoConfig(
464
+ window_seconds=cfg.get("window_seconds", 3.0),
465
+ threshold_fraction=cfg.get("threshold_fraction", 0.4),
466
+ )
467
+ ),
468
+ },
469
+ "label_filter": {
470
+ "kind": "frame",
471
+ "build": lambda cfg: LabelFilter(
472
+ LabelFilterConfig(
473
+ keep_labels=set(cfg.get("keep_labels", [])) or None,
474
+ drop_labels=set(cfg.get("drop_labels", [])) or None,
475
+ )
476
+ ),
477
+ },
478
+ "nms": {
479
+ "kind": "frame",
480
+ "build": lambda cfg: PerFrameNmsPostprocessor(
481
+ PerFrameNmsConfig(
482
+ iou_threshold=cfg.get("iou_threshold", 0.7),
483
+ class_agnostic=cfg.get("class_agnostic", True),
484
+ )
485
+ ),
486
+ },
487
+ }
488
+
489
+
490
+ def build_postprocess_stages(config_dict: Optional[dict]) -> List[PostprocessStage]:
491
+ """
492
+ Build an ordered list of postprocessing stages from a configuration dict.
493
+
494
+ Expected config format (e.g. from YAML):
495
+
496
+ postprocess:
497
+ stages:
498
+ - type: motion_track_video
499
+ window_seconds: 3.0
500
+ threshold_fraction: 0.4
501
+ - type: label_filter
502
+ keep_labels: ["fish", "seal"]
503
+ - type: nms
504
+ iou_threshold: 0.7
505
+ class_agnostic: false
506
+
507
+ Parameters
508
+ ----------
509
+ config_dict:
510
+ Dictionary under the top-level 'postprocess' key, or None.
511
+
512
+ Returns
513
+ -------
514
+ List[PostprocessStage]
515
+ Stages in the order declared in the configuration.
516
+ """
517
+ if not config_dict:
518
+ return []
519
+
520
+ stages_cfg = config_dict.get("stages", [])
521
+ stages: List[PostprocessStage] = []
522
+
523
+ for stage_cfg in stages_cfg:
524
+ stage_type = stage_cfg.get("type")
525
+ if stage_type not in POSTPROCESSOR_REGISTRY:
526
+ # Unknown stage type – skip or raise, depending on policy.
527
+ continue
528
+
529
+ entry = POSTPROCESSOR_REGISTRY[stage_type]
530
+ kind: StageKind = entry["kind"]
531
+ impl = entry["build"](stage_cfg)
532
+ stages.append(PostprocessStage(kind=kind, impl=impl))
533
+
534
+ return stages
535
+
536
+
537
+ # =============================================================================
538
+ # CSV Writer
539
+ # =============================================================================
540
+
541
+
542
+ CSV_HEADERS = [
543
+ "source_file",
544
+ "timestamp",
545
+ "frame_number",
546
+ "xc",
547
+ "yc",
548
+ "width",
549
+ "height",
550
+ "confidence",
551
+ "label",
552
+ "track_id",
553
+ ]
554
+
555
+
556
+ class DetectionWriter:
557
+ """
558
+ Writes detection results to CSV files.
559
+
560
+ Supports two output modes:
561
+
562
+ - SINGLE_FILE: all detections written to one CSV file.
563
+ - PER_VIDEO: separate CSV file created for each source video.
564
+
565
+ Examples
566
+ --------
567
+ Single file mode:
568
+
569
+ config = CSVWriterConfig(
570
+ output_dir="./results",
571
+ output_mode=OutputMode.SINGLE_FILE,
572
+ overwrite=True,
573
+ )
574
+
575
+ with DetectionWriter(config) as writer:
576
+ for detection in detections:
577
+ writer.write(detection)
578
+
579
+ Per-video mode:
580
+
581
+ config = CSVWriterConfig(
582
+ output_dir="./results",
583
+ output_mode=OutputMode.PER_VIDEO,
584
+ overwrite=True,
585
+ )
586
+
587
+ with DetectionWriter(config) as writer:
588
+ for detection in all_detections:
589
+ writer.write(detection)
590
+ # Writer automatically creates new CSV when source_file changes.
591
+ """
592
+
593
+ def __init__(self, config: Optional[CSVWriterConfig] = None):
594
+ """
595
+ Initialise the detection writer.
596
+
597
+ Args:
598
+ config: CSVWriterConfig object with output settings.
599
+ """
600
+
601
+ self.config = config or CSVWriterConfig()
602
+
603
+ # Ensure output directory exists
604
+ os.makedirs(self.config.output_dir, exist_ok=True)
605
+
606
+ # State for file handling
607
+ self._current_file: Optional[Path] = None
608
+ self._current_writer: Optional[csv.DictWriter] = None
609
+ self._current_source: Optional[str] = None
610
+ self._detection_count: int = 0
611
+
612
+ def write(self, detection: Detection) -> None:
613
+ """
614
+ Write a detection to the appropriate CSV file.
615
+
616
+ Args:
617
+ detection: Detection object to write.
618
+
619
+ Raises:
620
+ FileExistsError: If the output file already exists and overwrite
621
+ is set to False.
622
+ """
623
+
624
+ # Handle file opening based on mode
625
+ if self.config.output_mode == OutputMode.SINGLE_FILE:
626
+ # Single file mode: open once on first write
627
+ if self._current_file is None:
628
+ self._open_single_file()
629
+
630
+ else:
631
+ # Per video mode: open a new file when source changed
632
+ if detection.source_file != self._current_source:
633
+ self._close_current_file()
634
+ self._open_per_video_file(detection.source_file)
635
+
636
+ # Write the detection
637
+ self._current_writer.writerow(detection.to_csv_row())
638
+ self._detection_count += 1
639
+
640
+ def write_batch(self, detections: List[Detection]) -> None:
641
+ """
642
+ Write a batch of detections.
643
+
644
+ Args:
645
+ detections: List of Detection objects to write.
646
+ """
647
+ for detection in detections:
648
+ self.write(detection)
649
+
650
+ def finalise_video(self, source_file: str) -> None:
651
+ """
652
+ Finalise ouptut for a video, ensuring a CSV is created even if no
653
+ detections were made.
654
+
655
+ Call this after processing each video in PER_VIDEO mode to ensure all
656
+ videos with zero detections still get an empty CSV file.
657
+
658
+ Args:
659
+ source_file: Source video file path.
660
+ """
661
+ if self.config.output_mode == OutputMode.PER_VIDEO:
662
+ # If we haven't written anything for this video, create empty file
663
+ if self._current_source != source_file:
664
+ self._close_current_file()
665
+ self._open_per_video_file(source_file)
666
+ # Close the file for this video
667
+ self._close_current_file()
668
+
669
+ def _open_file(self, filepath: Path) -> None:
670
+ """
671
+ Open a CSV file for writing, checking for overwrite permission.
672
+
673
+ Args:
674
+ filepath: Path to the CSV file to open.
675
+
676
+ Raises:
677
+ FileExistsError: If the file exists and overwrite is False.
678
+ """
679
+
680
+ # Check if file exists
681
+ if filepath.exists():
682
+ if not self.config.overwrite:
683
+ raise FileExistsError(
684
+ f"Output file already exists: {filepath}. "
685
+ f"Set overwrite=True to overwrite."
686
+ )
687
+ else:
688
+ print(f"WARNING: Overwriting existing file: {filepath}")
689
+
690
+ # Open file
691
+ self._current_file = open(filepath, mode="w", newline="", encoding="utf-8")
692
+ self._current_writer = csv.DictWriter(
693
+ self._current_file,
694
+ fieldnames=CSV_HEADERS
695
+ )
696
+ self._current_writer.writeheader()
697
+
698
+ def _open_single_file(self) -> None:
699
+ """Open the single output CSV file."""
700
+ filepath = Path(self.config.output_dir) / self.config.single_file_name
701
+ self._open_file(filepath)
702
+
703
+ def _open_per_video_file(self, source_file: str) -> None:
704
+ """Open a per-video output CSV file."""
705
+ # Generate output filename based on source video name
706
+ video_name = Path(source_file).stem
707
+ filename = f"{video_name}_detections.csv"
708
+ filepath = Path(self.config.output_dir) / filename
709
+
710
+ # Open file
711
+ self._open_file(filepath)
712
+ self._current_source = source_file
713
+
714
+ def _close_current_file(self) -> None:
715
+ """Close the current output CSV file, if open."""
716
+ if self._current_file is not None:
717
+ self._current_file.close()
718
+ self._current_file = None
719
+ self._current_writer = None
720
+ self._current_source = None
721
+
722
+ def close(self) -> None:
723
+ """Close any open files."""
724
+ self._close_current_file()
725
+
726
+ @property
727
+ def detection_count(self) -> int:
728
+ """Get the total number of detections written."""
729
+ return self._detection_count
730
+
731
+ def __enter__(self):
732
+ """Enter context manager."""
733
+ return self
734
+
735
+ def __exit__(self, exc_type, exc_value, exc_tb):
736
+ """Exit context manager, closing any open files."""
737
+ self.close()
738
+ return False