seavision-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- seavision_python-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|