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,262 @@
1
+ """Random access video source for the GUI viewer."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from pathlib import Path
6
+ import logging
7
+
8
+ from seavision.engine.source.base import FrameContext, VideoMetadata
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Container formats where cv2.VideoCapture seeking is unreliable.
13
+ # These use byte-offset estimation rather than a frame index table,
14
+ # causing decoder state corruption on seek-back.
15
+ _PRELOAD_EXTENSIONS = frozenset({
16
+ ".ts", ".mts", ".m2ts", # MPEG transport streams
17
+ })
18
+
19
+
20
+ def _needs_preload(filepath: str) -> bool:
21
+ """Check whether a file's container format requires pre-loading."""
22
+ return Path(filepath).suffix.lower() in _PRELOAD_EXTENSIONS
23
+
24
+
25
+ def _estimate_memory_mb(width: int, height: int, frame_count: int) -> float:
26
+ """Estimate memory usage for pre-loading all frames, in MB."""
27
+ bytes_per_frame = width * height * 3 # BGR uint8
28
+ return (bytes_per_frame * frame_count) / (1024 * 1024)
29
+
30
+
31
+ def _available_memory_mb() -> float | None:
32
+ """
33
+ Return available system RAM in MB, or None if it can't be determined.
34
+
35
+ Uses psutil if available. This is an optional dependency — the GUI
36
+ works without it, the dialog just won't show available RAM.
37
+ """
38
+ try:
39
+ import psutil
40
+ return psutil.virtual_memory().available / (1024 * 1024)
41
+ except ImportError:
42
+ return None
43
+
44
+
45
+ class SeekableVideoSource:
46
+ """
47
+ Random-access wrapper around cv2.VideoCapture.
48
+
49
+ Unlike LocalVideoSource (which iterates sequentially), this class supports
50
+ seeking to arbitrary frame numbers. Designed for the GUI viewer where the
51
+ user may jump around in the video timeline.
52
+
53
+ Compatible only with local video files, not live network streams.
54
+
55
+ Supports two modes:
56
+ 1. preload=True: all frames decoded sequentially into memory on
57
+ construction. Every access is an 0(1) array lookup. Frame-accurate
58
+ for all container formats. Uses (width * height * 3 * frames)
59
+ bytes of RAM.
60
+ 2. preload=False: uses cv2.VideoCapture seeking directly. Fast and
61
+ memory-efficient, but seeking accuracy depends on the container
62
+ format.
63
+
64
+ Usage:
65
+ with SeekableVideoSource("video.ts") as src:
66
+ frame, ctx = src.read_at(47)
67
+ frame, ctx = src.read_at(183)
68
+ """
69
+
70
+ def __init__(self, filepath: str, preload: bool = False) -> None:
71
+ self._filepath = filepath
72
+ self._preload = preload
73
+
74
+ if not Path(self._filepath).exists():
75
+ raise ValueError(f"Video file not found: {self._filepath}")
76
+
77
+ self._metadata = self._read_metadata(filepath)
78
+ self._frame_counter = 0
79
+ self._frames: list[np.ndarray] = []
80
+ self._cap: cv2.VideoCapture | None = None
81
+
82
+ if preload:
83
+ self._frames = self._preload_frames(filepath)
84
+ # Correct frame count to match actual decoded frames
85
+ if len(self._frames) != self._metadata.frame_count:
86
+ self._metadata = VideoMetadata(
87
+ source_file=self._metadata.source_file,
88
+ fps=self._metadata.fps,
89
+ frame_count=len(self._frames),
90
+ width=self._metadata.width,
91
+ height=self._metadata.height,
92
+ duration=len(self._frames) / self._metadata.fps
93
+ if self._metadata.fps > 0 else 0.0,
94
+ )
95
+ else:
96
+ self._cap = cv2.VideoCapture(self._filepath)
97
+ if not self._cap.isOpened():
98
+ raise ValueError(f"Failed to open video: {self._filepath}")
99
+
100
+
101
+ @staticmethod
102
+ def _read_metadata(filepath:str) -> VideoMetadata:
103
+ """Read video metadata without disturbing the main decode."""
104
+ cap = cv2.VideoCapture(filepath)
105
+ if not cap.isOpened():
106
+ raise ValueError(f"Failed to open video file: {filepath}")
107
+
108
+ try:
109
+ fps = cap.get(cv2.CAP_PROP_FPS)
110
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
111
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
112
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
113
+ duration = frame_count / fps if fps > 0 else 0.0
114
+
115
+ return VideoMetadata(
116
+ source_file=filepath,
117
+ fps=fps,
118
+ frame_count=frame_count,
119
+ width=width,
120
+ height=height,
121
+ duration=duration,
122
+ )
123
+ finally:
124
+ cap.release()
125
+
126
+ @staticmethod
127
+ def _preload_frames(filepath: str) -> list[np.ndarray]:
128
+ """
129
+ Sequentially read every frame into memory.
130
+
131
+ Uses a fresh capture with no prior seeking — the exact same
132
+ decode path as LocalVideoSource.iter_frames(). This guarantees
133
+ frame numbering matches the pipeline that generated detections.
134
+ """
135
+ cap = cv2.VideoCapture(filepath)
136
+ if not cap.isOpened():
137
+ raise ValueError(f"Failed to open video file: {filepath}")
138
+
139
+ frames: list[np.ndarray] = []
140
+ try:
141
+ while True:
142
+ ret, frame = cap.read()
143
+ if not ret:
144
+ break
145
+ frames.append(frame)
146
+ finally:
147
+ cap.release()
148
+
149
+ size_mb = sum(f.nbytes for f in frames) / (1024 * 1024)
150
+ logger.info(
151
+ "Pre-loaded %d frames from %s (%.1f MB)",
152
+ len(frames),
153
+ Path(filepath).name,
154
+ size_mb,
155
+ )
156
+
157
+ if not frames:
158
+ raise ValueError(f"No frames could be read from: {filepath}")
159
+
160
+ return frames
161
+
162
+ @property
163
+ def metadata(self) -> VideoMetadata:
164
+ """Cached video metadata."""
165
+ return self._metadata
166
+
167
+ def _make_context(self, frame_number: int) -> FrameContext:
168
+ """Build a FrameContext for the given frame number."""
169
+ fps = self._metadata.fps
170
+ return FrameContext(
171
+ frame_number=frame_number,
172
+ timestamp=frame_number / fps if fps > 0 else 0.0,
173
+ source_file=self._filepath,
174
+ fps=fps,
175
+ )
176
+
177
+ def _clamp(self, frame_number: int) -> int:
178
+ """Clamp a frame number to the valid range."""
179
+ if self._preload:
180
+ max_frame = len(self._frames) - 1
181
+ else:
182
+ max_frame = self._metadata.frame_count - 1
183
+ return max(0, min(frame_number, max_frame))
184
+
185
+ def seek(self, frame_number: int) -> None:
186
+ """
187
+ Seek to a specific frame number.
188
+
189
+ The frame number is clamped to the valid range. This prevents invalid
190
+ frames being read causing hidden failures.
191
+
192
+ In preload mode, the internal counter is updated. Otherwise, the
193
+ underlying capture is seeked using CAP_PROP_POS_FRAMES.
194
+ """
195
+ clamped = self._clamp(frame_number)
196
+
197
+ if self._preload:
198
+ self._frame_counter = clamped
199
+ else:
200
+ if self._cap is None:
201
+ return
202
+
203
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, clamped)
204
+ self._frame_counter = clamped
205
+
206
+ def read(self) -> tuple[np.ndarray, FrameContext] | None:
207
+ """
208
+ Read the current frame and advance the position.
209
+
210
+ returns:
211
+ Tuple of (frame, context) or None if at the end of the video or the
212
+ frame could not be decoded.
213
+ """
214
+ if self._preload:
215
+ if self._frame_counter >= len(self._frames):
216
+ return None
217
+ frame = self._frames[self._frame_counter].copy()
218
+ else:
219
+ if self._cap is None:
220
+ return None
221
+ ret, frame = self._cap.read()
222
+ if not ret:
223
+ return None
224
+
225
+ ctx = self._make_context(self._frame_counter)
226
+ self._frame_counter += 1
227
+ return frame, ctx
228
+
229
+ def read_at(self, frame_number: int) -> tuple[np.ndarray, FrameContext] | None:
230
+ """
231
+ Seek to a frame and read it in one call.
232
+ """
233
+ if self._preload:
234
+ if not self._frames:
235
+ return None
236
+ clamped = self._clamp(frame_number)
237
+ frame = self._frames[clamped].copy()
238
+ else:
239
+ if self._cap is None:
240
+ return None
241
+ clamped = self._clamp(frame_number)
242
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, clamped)
243
+ ret, frame = self._cap.read()
244
+ if not ret:
245
+ return None
246
+
247
+ ctx = self._make_context(clamped)
248
+ self._frame_counter = clamped + 1
249
+ return frame, ctx
250
+
251
+ def close(self) -> None:
252
+ """Release the video capture."""
253
+ self._frames = []
254
+ if self._cap is not None:
255
+ self._cap.release()
256
+ self._cap = None
257
+
258
+ def __enter__(self) -> "SeekableVideoSource":
259
+ return self
260
+
261
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
262
+ self.close()
@@ -0,0 +1,376 @@
1
+ """
2
+ Session manager for save/load/export of validation state.
3
+
4
+ This module is intentionally free of Qt dependcies. It operates on the
5
+ ValidationModel's data and produces/consumes files. The Qt-side integration
6
+ (file dialogs, status bar updates) lives in tab.py and main_window.py.
7
+ """
8
+
9
+ import csv
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from seavision.gui.validation.validation_model import (
17
+ ValidatedDetection,
18
+ ValidationModel,
19
+ ValidationStatus,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _serialise_manual_detection(
26
+ vd: ValidatedDetection,
27
+ ) -> dict[str, Any]:
28
+ """
29
+ Serialise a manually added detection to a JSON-friendly dict.
30
+
31
+ Manual detections need their full geometry and label stored because they
32
+ don't exist in the CSV. Pipeline detections only need their status stored
33
+ because the CSV provides the rest.
34
+ """
35
+ det = vd.detection
36
+ return {
37
+ "source_file": det.source_file,
38
+ "frame_number": det.frame_number,
39
+ "timestamp": det.timestamp,
40
+ "xc": det.xc,
41
+ "yc": det.yc,
42
+ "width": det.width,
43
+ "height": det.height,
44
+ "label": det.label or "",
45
+ "status": vd.status.name,
46
+ "corrected_geometry": vd.corrected_geometry,
47
+ }
48
+
49
+
50
+ class SessionManager:
51
+ """
52
+ Handles save, load, and export operations for validation sessions.
53
+
54
+ All methods are stateless class-level operations - the manager does not hold
55
+ a reference to the model or remember previous save paths. State tracking
56
+ (current save path, unsaved changes flag) lives on the ValidationTab.
57
+
58
+ File format:
59
+ Session files use the .seavision-session extension and contain JSON with
60
+ this structure:
61
+ {
62
+ "version": 1,
63
+ "csv_path": "/path/to/detections.csv",
64
+ "video_dir": "/path/to/videos/",
65
+ "timestamp": "2025-06-15T14:30:00Z",
66
+ "decisions": {
67
+ "video.ts::47::0": {
68
+ "status": "CONFIRMED",
69
+ "corrected_geometry": null,
70
+ "is_manual": false
71
+ },
72
+ ...
73
+ },
74
+ "manual_detections": [
75
+ {
76
+ "source_file": "video.ts",
77
+ "frame_number": 52,
78
+ "xc": 320.0, "yc": 240.0,
79
+ "width": 60.0, "height": 60.0,
80
+ "label": "seal",
81
+ "status": "CONFIRMED"
82
+ },
83
+ ...
84
+ ]
85
+ }
86
+
87
+ Decision keys use the format "{source_file}::{frame_number}::{index}" where
88
+ index is the detection's position within its frame. This is deterministic
89
+ for a given CSV because CSVDetectionLoader loads in file order.
90
+ """
91
+
92
+ @staticmethod
93
+ def save_session(
94
+ path: Path | str,
95
+ model: ValidationModel,
96
+ csv_path: str,
97
+ video_dir: str,
98
+ aws_profile: str | None,
99
+ cache_dir: str | None = None,
100
+ ) -> None:
101
+ """
102
+ Save the current validation state to a .seavision-session JSON file.
103
+
104
+ Args:
105
+ path: Destination file path for the session file. Ends with
106
+ .seavision-session.
107
+ model: The ValidationModel containing all detection states.
108
+ csv_path: Path to the detection CSV (stored for reload).
109
+ video_dir: Path to the video directory (stored for reload).
110
+ aws_profile: The AWS profile name to use for S3 operations.
111
+ cache_dir: Path to the cache directory (stored for reload).
112
+ """
113
+ path = Path(path)
114
+
115
+ decisions: dict[str, dict[str, Any]] = {}
116
+ manual_detections: list[dict[str, Any]] = []
117
+
118
+ for source_file in model.get_all_source_files():
119
+ detections = model.get_detections_for_video(source_file)
120
+ frame_counters: dict[int, int] = {}
121
+
122
+ for vd in detections:
123
+ if vd.is_manual:
124
+ manual_detections.append(
125
+ _serialise_manual_detection(vd),
126
+ )
127
+ continue
128
+
129
+ frame_num = vd.detection.frame_number
130
+ idx = frame_counters.get(frame_num, 0)
131
+ frame_counters[frame_num] = idx + 1
132
+
133
+ # Skip saving PENDING detectons - these can be read from CSV
134
+ # on load
135
+ if vd.status == ValidationStatus.PENDING:
136
+ continue
137
+
138
+ key = f"{source_file}::{frame_num}::{idx}"
139
+ decisions[key] = {
140
+ "status": vd.status.name,
141
+ "corrected_geometry": vd.corrected_geometry,
142
+ "is_manual": False,
143
+ }
144
+
145
+ session_data = {
146
+ "version": 1,
147
+ "csv_path": str(csv_path),
148
+ "video_dir": str(video_dir),
149
+ "aws_profile": aws_profile,
150
+ "cache_dir": cache_dir,
151
+ "timestamp": datetime.now(timezone.utc).isoformat(),
152
+ "decisions": decisions,
153
+ "manual_detections": manual_detections,
154
+ }
155
+
156
+ with open(path, "w", encoding="utf-8") as f:
157
+ json.dump(session_data, f, indent=2)
158
+
159
+ logger.info("Session saved to %s (%d decisions, %d manual)",
160
+ path, len(decisions), len(manual_detections))
161
+
162
+
163
+ @staticmethod
164
+ def load_session(path: Path | str) -> dict[str, Any]:
165
+ """
166
+ Read and validate a session file.
167
+
168
+ Returns the parsed JSON as a dict. Does NOT apply the state to a model -
169
+ that is the caller's responsibility.
170
+
171
+ Raises:
172
+ FileNotFoundError: If the file doesn't exist.
173
+ ValueError: If the file is not valid.
174
+ """
175
+
176
+ path = Path(path)
177
+
178
+ # TODO: Add check that file extension is .seavision-session?
179
+
180
+ with open(path, "r", encoding="utf-8") as f:
181
+ try:
182
+ data: dict = json.load(f)
183
+ except json.JSONDecodeError as e:
184
+ raise ValueError(
185
+ f"Session file is not valid JSON: {e}"
186
+ ) from e
187
+
188
+ required = {"version", "csv_path", "video_dir", "decisions"}
189
+ missing = required - set(data.keys())
190
+ if missing:
191
+ raise ValueError(
192
+ f"Session file missing required keys: {missing}"
193
+ )
194
+
195
+ if data["version"] != 1:
196
+ raise ValueError(
197
+ f"Unsupported session version: {data['version']}. "
198
+ f"This tool supports version 1."
199
+ )
200
+
201
+ data.setdefault("manual_detections", [])
202
+ data.setdefault("aws_profile", None)
203
+ data.setdefault("cache_dir", None)
204
+
205
+ logger.info(
206
+ "Loaded session from %s (%d decisions, %d manual)",
207
+ path, len(data["decisions"]),
208
+ len(data["manual_detections"]),
209
+ )
210
+ return data
211
+
212
+
213
+ @staticmethod
214
+ def apply_session_state(
215
+ model: ValidationModel,
216
+ session_data: dict[str, Any],
217
+ ) -> dict[str, str]:
218
+ """
219
+ Apply saved decisions and manual detections to a live model.
220
+
221
+ Called after the CSV has been re-loaded and the ValidationModel
222
+ constructed with all detections in PENDING state.
223
+
224
+ Returns:
225
+ States dict: {"applied", "skipped", "manual_added"}
226
+ """
227
+ stats = {"applied": 0, "skipped": 0, "manual_added": 0}
228
+
229
+ # build lookup keyed the same way as the session file
230
+ model_keys: dict[str, ValidatedDetection] = {}
231
+ for source_file in model.get_all_source_files():
232
+ detections = model.get_detections_for_video(source_file)
233
+ frame_counters: dict[int, int] = {}
234
+ for vd in detections:
235
+ if vd.is_manual:
236
+ continue
237
+ frame_num = vd.detection.frame_number
238
+ idx = frame_counters.get(frame_num, 0)
239
+ frame_counters[frame_num] = idx + 1
240
+ key = f"{source_file}::{frame_num}::{idx}"
241
+ model_keys[key] = vd
242
+
243
+ for key, decision in session_data["decisions"].items():
244
+ vd = model_keys.get(key)
245
+ if vd is None:
246
+ logger.warning("Session key not found in model: %s", key)
247
+ stats["skipped"] += 1
248
+ continue
249
+
250
+ try:
251
+ status = ValidationStatus[decision["status"]]
252
+ except KeyError:
253
+ logger.warning(
254
+ "Unknown status '%s' for key %s",
255
+ decision["status"], key,
256
+ )
257
+ stats["skipped"] += 1
258
+ continue
259
+
260
+ model.set_status(vd.id, status)
261
+
262
+ if decision.get("corrected_geometry") is not None:
263
+ geom = decision["corrected_geometry"]
264
+ model.set_corrected_geometry(
265
+ vd.id, geom["xc"], geom["yc"],
266
+ geom["width"], geom["height"],
267
+ )
268
+
269
+ stats["applied"] += 1
270
+
271
+ # restore manual detections
272
+ for manual in session_data.get("manual_detections", []):
273
+ try:
274
+ model.add_detection(
275
+ source_file=manual["source_file"],
276
+ frame_number=manual["frame_number"],
277
+ xc=manual["xc"], yc=manual["yc"],
278
+ width=manual["width"], height=manual["height"],
279
+ label=manual["label"],
280
+ timestamp=manual.get("timestamp", 0.0),
281
+ )
282
+
283
+ saved_status = ValidationStatus[manual.get(
284
+ "status", "CONFIRMED"
285
+ )]
286
+ if saved_status != ValidationStatus.CONFIRMED:
287
+ all_dets = model.get_detections_for_video(
288
+ manual["source_file"]
289
+ )
290
+ last_manual = all_dets[-1]
291
+ model.set_status(last_manual.id, saved_status)
292
+
293
+ stats["manual_added"] += 1
294
+
295
+ except (ValueError, KeyError) as e:
296
+ logger.warning(
297
+ "Failed to restore manual detection: %s", e
298
+ )
299
+ stats["skipped"] += 1
300
+
301
+ logger.info(
302
+ "Applied session state: %d applied, %d skipped, "
303
+ "%d manual restored",
304
+ stats["applied"], stats["skipped"], stats["manual_added"],
305
+ )
306
+ return stats
307
+
308
+
309
+ @staticmethod
310
+ def export_detections(
311
+ path: Path | str,
312
+ model: ValidationModel,
313
+ ) -> int:
314
+ """
315
+ Export confirmed and corrected detections to a CSV file.
316
+
317
+ Only CONFIRMED and CORRECTED detections are included. Adds
318
+ 'status' and 'source' columns to distinguish pipeline from
319
+ manual detections. Uses corrected geometry where available.
320
+
321
+ Returns:
322
+ Number of detections written.
323
+ """
324
+ path = Path(path)
325
+ exportable = {
326
+ ValidationStatus.CONFIRMED, ValidationStatus.CORRECTED
327
+ }
328
+
329
+ fieldnames = [
330
+ "source_file", "frame_number", "timestamp",
331
+ "xc", "yc", "width", "height",
332
+ "confidence", "label", "track_id",
333
+ "status", "source",
334
+ ]
335
+
336
+ count = 0
337
+ with open(path, "w", newline="", encoding="utf-8") as f:
338
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
339
+ writer.writeheader()
340
+
341
+ for source_file in model.get_all_source_files():
342
+ for vd in model.get_detections_for_video(source_file):
343
+ if vd.status not in exportable:
344
+ continue
345
+
346
+ det = vd.detection
347
+ if vd.corrected_geometry is not None:
348
+ geom = vd.corrected_geometry
349
+ xc, yc = geom["xc"], geom["yc"]
350
+ width, height = geom["width"], geom["height"]
351
+ else:
352
+ xc, yc = det.xc, det.yc
353
+ width, height = det.width, det.height
354
+
355
+ writer.writerow({
356
+ "source_file": det.source_file,
357
+ "frame_number": det.frame_number,
358
+ "timestamp": det.timestamp,
359
+ "xc": xc, "yc": yc,
360
+ "width": width, "height": height,
361
+ "confidence": (
362
+ det.confidence if det.confidence is not None
363
+ else ""
364
+ ),
365
+ "label": det.label or "",
366
+ "track_id": (
367
+ det.track_id if det.track_id is not None
368
+ else ""
369
+ ),
370
+ "status": vd.status.name,
371
+ "source": "manual" if vd.is_manual else "pipeline",
372
+ })
373
+ count += 1
374
+
375
+ logger.info("Exported %d detections to %s", count, path)
376
+ return count