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,172 @@
1
+ """Playback transport controls."""
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtWidgets import (
5
+ QComboBox,
6
+ QHBoxLayout,
7
+ QLabel,
8
+ QPushButton,
9
+ QSlider,
10
+ QWidget,
11
+ )
12
+
13
+ from seavision.engine.source.base import VideoMetadata
14
+ from seavision.gui.validation.button_styles import TransportButtonStyles
15
+
16
+
17
+ class TransportBar(QWidget):
18
+ """
19
+ Playback controls: prev/next frame, play/pause, seek slider, and
20
+ frame/time display.
21
+
22
+ All user actions are exposed as signals - this widget does not know about
23
+ videos or workers.
24
+ """
25
+
26
+ play_pause_clicked = Signal()
27
+ next_frame_clicked = Signal()
28
+ prev_frame_clicked = Signal()
29
+ seek_requested = Signal(int) # Frame number, fires during drag
30
+ seek_commited = Signal(int) # Frame number, fires on slider release
31
+
32
+ prev_detection_clicked = Signal()
33
+ next_detection_clicked = Signal()
34
+
35
+ speed_changed = Signal(float) # Playback speed multiplier
36
+
37
+ def __init__(self, parent=None):
38
+ """
39
+ Build the layout and connect signals.
40
+ """
41
+ super().__init__(parent)
42
+
43
+ layout = QHBoxLayout(self)
44
+ layout.setContentsMargins(4, 2, 4, 2)
45
+
46
+ # --- Buttons ---
47
+ self._prev_det_btn = QPushButton("⟪")
48
+ self._prev_det_btn.setToolTip("Previous detection (Ctrl+Left)")
49
+ self._prev_det_btn.setFixedWidth(36)
50
+ self._prev_det_btn.setStyleSheet(TransportButtonStyles().transport)
51
+
52
+ self._prev_btn = QPushButton("|◀")
53
+ self._prev_btn.setToolTip("Previous frame (Left)")
54
+ self._prev_btn.setFixedWidth(36)
55
+ self._prev_btn.setStyleSheet(TransportButtonStyles().transport)
56
+
57
+ self._play_btn = QPushButton("▶")
58
+ self._play_btn.setFixedWidth(50)
59
+ self._play_btn.setStyleSheet(TransportButtonStyles().transport)
60
+
61
+ self._next_btn = QPushButton("▶|")
62
+ self._next_btn.setToolTip("Next frame (Right)")
63
+ self._next_btn.setFixedWidth(36)
64
+ self._next_btn.setStyleSheet(TransportButtonStyles().transport)
65
+
66
+ self._next_det_btn = QPushButton("⟫")
67
+ self._next_det_btn.setToolTip("Next detection (Ctrl+Right)")
68
+ self._next_det_btn.setFixedWidth(36)
69
+ self._next_det_btn.setStyleSheet(TransportButtonStyles().transport)
70
+
71
+ # --- Seek slider ---
72
+ self._slider = QSlider(Qt.Orientation.Horizontal)
73
+ self._slider.setRange(0, 0)
74
+
75
+ # --- Labels ---
76
+ self._frame_label = QLabel("Frame: - / -")
77
+ self._time_label = QLabel("Time: - / -")
78
+ self._frame_label.setFixedWidth(140)
79
+ self._time_label.setFixedWidth(100)
80
+
81
+ # --- Speed selector ---
82
+ self._speed_combo = QComboBox()
83
+ self._speed_combo.addItems(
84
+ ["0.25x", "0.5x", "1x", "2x", "5x"]
85
+ )
86
+ self._speed_combo.setCurrentIndex(2) # Default 1x
87
+ self._speed_combo.setFixedWidth(65)
88
+ self._speed_combo.setToolTip("Playback speed")
89
+ self._speed_combo.currentTextChanged.connect(
90
+ self._on_speed_changed
91
+ )
92
+
93
+ # --- Assemble ---
94
+ layout.addWidget(self._prev_det_btn)
95
+ layout.addWidget(self._prev_btn)
96
+ layout.addWidget(self._play_btn)
97
+ layout.addWidget(self._next_btn)
98
+ layout.addWidget(self._next_det_btn)
99
+ layout.addWidget(self._slider)
100
+ layout.addWidget(self._frame_label)
101
+ layout.addWidget(self._time_label)
102
+ layout.addWidget(self._speed_combo)
103
+
104
+ # --- Internal wiring ---
105
+ self._prev_det_btn.clicked.connect(self.prev_detection_clicked)
106
+ self._prev_btn.clicked.connect(self.prev_frame_clicked)
107
+ self._play_btn.clicked.connect(self.play_pause_clicked)
108
+ self._next_btn.clicked.connect(self.next_frame_clicked)
109
+ self._next_det_btn.clicked.connect(self.next_detection_clicked)
110
+ self._slider.sliderMoved.connect(self.seek_requested)
111
+ self._slider.sliderReleased.connect(
112
+ self._on_slider_released
113
+ )
114
+
115
+ # Disable until a video is loaded
116
+ self.setEnabled(False)
117
+
118
+ # Store total frame count for labels
119
+ self._total_frames = 0
120
+ self._total_duration = 0.0
121
+
122
+ def set_video_info(self, metadata: VideoMetadata) -> None:
123
+ """Configure controls for a newly opened video."""
124
+ self._total_frames = metadata.frame_count
125
+ self._total_duration = metadata.duration
126
+ self._slider.setRange(0, max(0, metadata.frame_count - 1))
127
+ self.update_position(0, 0.0)
128
+ self.setEnabled(True)
129
+
130
+ def update_position(self, frame_number: int, timestamp: float) -> None:
131
+ """Update the display to reflect the current playback position."""
132
+ # Block signals to prevent the programmatic setValue from triggering
133
+ # sliderMoved -> seek_requested -> infinite loop
134
+ self._slider.blockSignals(True)
135
+ self._slider.setValue(frame_number)
136
+ self._slider.blockSignals(False)
137
+
138
+ self._frame_label.setText(
139
+ f"Frame: {frame_number} / {self._total_frames}"
140
+ )
141
+ self._time_label.setText(
142
+ f"{self._format_time(timestamp)} / "
143
+ f"{self._format_time(self._total_duration)}"
144
+ )
145
+
146
+ def set_playing(self, is_playing: bool) -> None:
147
+ """Update the play/pause button text."""
148
+ self._play_btn.setText("⏸" if is_playing else "▶")
149
+
150
+ def _on_slider_released(self) -> None:
151
+ """Slider was released - emit the final position."""
152
+ self.seek_commited.emit(self._slider.value())
153
+
154
+ def _on_speed_changed(self, text: str) -> None:
155
+ """Parse the speed text and emit the multiplier."""
156
+ try:
157
+ multiplier = float(text.rstrip("x"))
158
+ self.speed_changed.emit(multiplier)
159
+ except ValueError:
160
+ pass
161
+
162
+ def set_detection_nav_enabled(self, enabled: bool) -> None:
163
+ """Enable or disable the detection navigation buttons."""
164
+ self._prev_det_btn.setEnabled(enabled)
165
+ self._next_det_btn.setEnabled(enabled)
166
+
167
+ @staticmethod
168
+ def _format_time(seconds: float) -> str:
169
+ """Format seconds as MM:SS."""
170
+ mins = int(seconds // 60)
171
+ secs = seconds % 60
172
+ return f"{mins}:{secs:04.1f}"
@@ -0,0 +1,482 @@
1
+ """
2
+ Validation model for the SeaVision review workflow.
3
+
4
+ Wraps raw Detection objects with mutable review status, providing the core data
5
+ layer for confirm/reject/skip operations and manual data addition in the review
6
+ UI.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum, auto
11
+ from typing import Optional
12
+
13
+ from PySide6.QtCore import QObject, Signal
14
+
15
+ from seavision.engine.detectors.base import Detection
16
+ from seavision.engine.visualiser import CSVDetectionLoader
17
+
18
+ class ValidationStatus(Enum):
19
+ """Review status for a detection."""
20
+ PENDING = auto()
21
+ CONFIRMED = auto()
22
+ REJECTED = auto()
23
+ SKIPPED = auto()
24
+ CORRECTED = auto()
25
+
26
+
27
+ @dataclass
28
+ class ValidatedDetection:
29
+ """
30
+ A detection with mutable review status.
31
+
32
+ Wraps an immutable engine Detection with the fields the GUI needs to
33
+ track: a unique ID, a review status, an origin flag, and optional
34
+ geometry corrections.
35
+
36
+ Attributes:
37
+ detection: The original Detection from the pipeline, or a synthetic
38
+ Detection for manually added detections.
39
+ status: Current review status (starts as PENDING for pipeline
40
+ detections, CORRECTED for manual additions).
41
+ id: Unique integer ID, assigned sequentially when the model is
42
+ constructed or when a detection is added. Stable for the
43
+ lifetime of the session.
44
+ is_manual: True if this detection was added by the reviewer reather
45
+ than loaded from the pipeline CSV. Affects export behaviour,
46
+ visual styling and performance evalutation.
47
+ corrected_geometry: If the reviewer adjusts the bounding box the
48
+ new geometry is stored here as:
49
+ {"xc": float, "yc": float, "width": float, "height": float}.
50
+ None means the original geometry is unchanged.
51
+ """
52
+
53
+ detection: Detection
54
+ status: ValidationStatus = ValidationStatus.PENDING
55
+ id: int = 0
56
+ is_manual: bool = False
57
+ corrected_geometry: Optional[dict] = field(default=None, repr=False)
58
+
59
+
60
+ class ValidationModel(QObject):
61
+ """
62
+ Central data model for the review workflow.
63
+
64
+ Wraps a detection source (typically a CSVDetectionLoader) with mutable
65
+ review status. Provides methods to query and update detection statuses, add
66
+ new detections, and emits signals when state changes so that UI components
67
+ can react.
68
+
69
+ The model is constructed once per session and lives for the session's
70
+ duration. It does not own the video source or any display widgets - it is
71
+ purely data and logic.
72
+ """
73
+
74
+ # Emitted when a single detection's status changes.
75
+ # Args: (detection_id: int, new_status: ValidationStatus)
76
+ detection_status_changed = Signal(int, object)
77
+
78
+ # Emitted after any status change with updated progress counts.
79
+ # Args: (progress_dict: dict) - keys are status names, values are counts.
80
+ progress_changed = Signal(object)
81
+
82
+ # Emitted when a new detection is added manually.
83
+ # Args: (validated_detection: ValidatedDetection)
84
+ detection_added = Signal(object)
85
+
86
+ # Emitted when a detection is removed (undo of a manual add).
87
+ # Args: (detection_id: int)
88
+ detection_removed = Signal(int)
89
+
90
+ def __init__(self, detection_source: CSVDetectionLoader, parent=None):
91
+ """
92
+ Build the validation model from a detection source.
93
+
94
+ Args:
95
+ detection_source: A CSVDetectionLoader (or any object with
96
+ get_detections_for_frame() and sources_in_file).
97
+ parent: Optional QObject parent for memory management.
98
+ """
99
+ super().__init__(parent)
100
+
101
+ self._source = detection_source
102
+ self._detections: list[ValidatedDetection] = []
103
+ self._by_id: dict[int, ValidatedDetection] = {}
104
+ self._by_video: dict[str, list[ValidatedDetection]] = {}
105
+ self._by_frame: dict[tuple[str, int], list[ValidatedDetection]] = {}
106
+ self._labels: set[str] = set()
107
+ self._next_id: int = 0
108
+
109
+ self._build_from_source()
110
+
111
+ # --- Create the validation model from a source ---
112
+ def _build_from_source(self) -> None:
113
+ """
114
+ Flatten all detections from the source into ValidatedDetection
115
+ objects with sequential IDs and build lookup indexes.
116
+ """
117
+ for frame_num in self._source.get_frame_numbers_with_detections():
118
+ for det in self._source.get_detections_for_frame(frame_num):
119
+ vd = ValidatedDetection(
120
+ detection=det,
121
+ status=ValidationStatus.PENDING,
122
+ id=self._next_id,
123
+ is_manual=False,
124
+ )
125
+ self._index_detection(vd)
126
+ self._next_id += 1
127
+
128
+ if det.label:
129
+ self._labels.add(det.label)
130
+
131
+ def _index_detection(self, vd: ValidatedDetection) -> None:
132
+ """Add a ValidatedDetection to all internal indexes."""
133
+ self._detections.append(vd)
134
+ self._by_id[vd.id] = vd
135
+
136
+ source_file = vd.detection.source_file
137
+ self._by_video.setdefault(source_file, []).append(vd)
138
+
139
+ key = (source_file, vd.detection.frame_number)
140
+ self._by_frame.setdefault(key, []).append(vd)
141
+
142
+ # --- Reading methods ---
143
+ def get_detections_for_video(
144
+ self, source_file: str
145
+ ) -> list[ValidatedDetection]:
146
+ """Return all detections for a given source video."""
147
+ return self._by_video.get(source_file, [])
148
+
149
+ def get_detections_for_frame(
150
+ self, source_file: str, frame_number: int
151
+ ) -> list[ValidatedDetection]:
152
+ """Return all detections for a specific frame of a specified video."""
153
+ return self._by_frame.get((source_file, frame_number), [])
154
+
155
+ def get_all_source_files(self) -> list[str]:
156
+ """Return all unique source file paths, in insertion order."""
157
+ return list(self._by_video.keys())
158
+
159
+ def get_progress(self, source_file: str) -> dict:
160
+ """
161
+ Return review progress counts for a specific video.
162
+
163
+ Returns:
164
+ Dict with keys: "confirmed", "rejected", "skipped", "corrected",
165
+ "pending", "manual", "total", "reviewed"
166
+ """
167
+
168
+ detections = self.get_detections_for_video(source_file)
169
+ counts = {
170
+ "confirmed": 0,
171
+ "rejected": 0,
172
+ "skipped": 0,
173
+ "corrected": 0,
174
+ "pending": 0,
175
+ "manual": 0,
176
+ }
177
+ for vd in detections:
178
+ counts[vd.status.name.lower()] += 1
179
+ if vd.is_manual:
180
+ counts["manual"] += 1
181
+
182
+ counts["total"] = len(detections)
183
+ counts["reviewed"] = counts["total"] - counts["pending"]
184
+ return counts
185
+
186
+ # --- Set detection status ---
187
+ def set_status(
188
+ self, detection_id: int, new_status: ValidationStatus
189
+ ) -> None:
190
+ """
191
+ Update the review status of a detection.
192
+
193
+ Emits detection_status_changed and progress_changed signals.
194
+
195
+ Args:
196
+ detection_id: The unique ID of the detection to update.
197
+ new_status: The new ValidationStatus to assign.
198
+
199
+ Raises:
200
+ KeyError: If detection_id is not found.
201
+ """
202
+ if detection_id not in self._by_id:
203
+ raise KeyError("detection_id not found")
204
+
205
+ vd = self._by_id.get(detection_id)
206
+ vd.status = new_status
207
+
208
+ self.detection_status_changed.emit(detection_id, new_status)
209
+
210
+ source_file = vd.detection.source_file
211
+ progress = self.get_progress(source_file)
212
+ self.progress_changed.emit(progress)
213
+
214
+ # --- Add/remove detections ---
215
+ def add_detection(
216
+ self,
217
+ source_file: str,
218
+ frame_number: int,
219
+ xc: float,
220
+ yc: float,
221
+ width: float,
222
+ height: float,
223
+ label: str,
224
+ timestamp: float = 0.0,
225
+ ) -> ValidatedDetection:
226
+ """
227
+ Add a new detection manually.
228
+
229
+ Creates a synthetic Detection object and wraps it in a
230
+ ValidatedDetection with CONFIRMED status and is_manual=True. The
231
+ detection is indexed into all lookup structures and the detection_added
232
+ signal is emitted so the table can insert a row.
233
+
234
+ Args:
235
+ source_file: The video this detection belongs to.
236
+ frame_number: The frame number where the detection is.
237
+ xc, yc: Centre coordinates of the bounding box (in frame
238
+ pixel coordinates).
239
+ width, height: Bounding box dimensions in pixels.
240
+ label: Class label - must be one of self._labels.
241
+ timestamp: Timestamp in seconds. If the caller has access
242
+ to the video metadata, pass frame_number / fps.
243
+ Otherwise defaults to 0.0.
244
+
245
+ Returns:
246
+ The newly created ValidatedDetection.
247
+ """
248
+ if label not in self._labels:
249
+ raise ValueError(
250
+ f"Unknown label: {label}. "
251
+ f"Valid labels are: {sorted(self._labels)}"
252
+ )
253
+
254
+
255
+ det = Detection(
256
+ source_file=source_file,
257
+ frame_number=frame_number,
258
+ xc=xc,
259
+ yc=yc,
260
+ width=width,
261
+ height=height,
262
+ timestamp=timestamp,
263
+ confidence=None,
264
+ label=label,
265
+ track_id=None,
266
+ )
267
+
268
+ vd = ValidatedDetection(
269
+ detection=det,
270
+ status=ValidationStatus.CONFIRMED,
271
+ id=self._next_id,
272
+ is_manual=True,
273
+ )
274
+
275
+ self._index_detection(vd)
276
+ self._next_id += 1
277
+
278
+ self.detection_added.emit(vd)
279
+
280
+ progress = self.get_progress(source_file)
281
+ self.progress_changed.emit(progress)
282
+
283
+ return vd
284
+
285
+ def remove_detection(self, detection_id: int) -> bool:
286
+ """
287
+ Remove manually added detection entirely.
288
+
289
+ Only works on detections where is_manual is True. Pipeline detections
290
+ cannot be removed - they should be rejected instead because the
291
+ rejection itself is useful to inform downstream systems that the
292
+ detector produced a false positive at this location.
293
+
294
+ Args:
295
+ detection_id: The unique ID of the detection to remove.
296
+
297
+ Returns:
298
+ True if the detection was removed, False if it was a pipeline
299
+ detection that cannot be removed.
300
+
301
+ Raises:
302
+ KeyError: If detection_id is not found.
303
+ """
304
+ vd = self._by_id[detection_id]
305
+
306
+ if not vd.is_manual:
307
+ return False
308
+
309
+ # Remove from all indexes
310
+ self._detections.remove(vd)
311
+ del self._by_id[detection_id]
312
+
313
+ source_file = vd.detection.source_file
314
+ self._by_video[source_file].remove(vd)
315
+
316
+ key = (source_file, vd.detection.frame_number)
317
+ self._by_frame[key].remove(vd)
318
+ if not self._by_frame[key]: # Clean up empty frame entry
319
+ del self._by_frame[key]
320
+
321
+ self.detection_removed.emit(detection_id)
322
+
323
+ progress = self.get_progress(source_file)
324
+ self.progress_changed.emit(progress)
325
+
326
+ return True
327
+
328
+ def rename_label(self, old_label: str, new_label: str) -> int:
329
+ """
330
+ Rename a label aross all detections.
331
+
332
+ Every detection (Pipeline and manual) that has 'old_label' gets
333
+ 'new_label'. The label set is updated accordingly.
334
+
335
+ Args:
336
+ old_label: The label to replace.
337
+ new_label: The new label name.
338
+
339
+ Returns:
340
+ The number of detections that were updated.
341
+
342
+ Raises:
343
+ ValueError: If old_label doesn't exist in the label set
344
+ """
345
+ if old_label not in self._labels:
346
+ raise ValueError(f"Label '{old_label}' not found")
347
+
348
+ count = 0
349
+ for vd in self._detections:
350
+ if vd.detection.label == old_label:
351
+ vd.detection.label = new_label
352
+ count += 1
353
+
354
+ # Update the label set
355
+ self._labels.discard(old_label)
356
+ self._labels.add(new_label)
357
+
358
+ return count
359
+
360
+ def get_next_unreviewed(
361
+ self, source_file: str, after_id: int = -1
362
+ ) -> ValidatedDetection | None:
363
+ """
364
+ Fien the next unreviewed detection for a video, after a given ID.
365
+
366
+ "Unreviewed means status is PENDING. The search is linear through the
367
+ video's detections in frame order.
368
+
369
+ Args:
370
+ source_file: The video to search within.
371
+ after_id: STart searching after this detectio ID. Use -1 to search
372
+ from the beginning.
373
+
374
+ Returns:
375
+ The next PENDING ValidatedDetection, or None if all are reviewed.
376
+ """
377
+
378
+ detections = self.get_detections_for_video(source_file)
379
+
380
+ past_start = (after_id == -1)
381
+
382
+ for vd in detections:
383
+ if not past_start:
384
+ if vd.id == after_id:
385
+ past_start = True
386
+ continue
387
+
388
+ if vd.status == ValidationStatus.PENDING:
389
+ return vd
390
+
391
+ # Wrap around: check from the begininng up to after_id
392
+ if after_id != -1:
393
+ for vd in detections:
394
+ if vd.id == after_id:
395
+ break
396
+
397
+ if vd.status == ValidationStatus.PENDING:
398
+ return vd
399
+
400
+ return None
401
+
402
+ def set_corrected_geometry(
403
+ self,
404
+ detection_id: int,
405
+ xc: float,
406
+ yc: float,
407
+ width: float,
408
+ height: float,
409
+ ) -> None:
410
+ """
411
+ Store corrected bounding box geometry for a detection.
412
+
413
+ Automatically sets the status to CORRECTED. This method is called by the
414
+ bounding box editing UI.
415
+ """
416
+ vd = self._by_id[detection_id]
417
+ vd.corrected_geometry = {
418
+ "xc": xc,
419
+ "yc": yc,
420
+ "width": width,
421
+ "height": height,
422
+ }
423
+ vd.status = ValidationStatus.CORRECTED
424
+
425
+ self.detection_status_changed.emit(detection_id, vd.status)
426
+
427
+ source_file = vd.detection.source_file
428
+ progress = self.get_progress(source_file)
429
+ self.progress_changed.emit(progress)
430
+
431
+ def undo_correction(self, detection_id: int) -> bool:
432
+ """
433
+ Revert a corrected detection to its original pipeline geometry.
434
+
435
+ Clears the corrected_geometry dict and resets the status to PENDING
436
+ (the reviewer hasn't confirmed or rejected the original geometry).
437
+
438
+ Args:
439
+ detection_id: The unique ID of the detection to revert.
440
+
441
+ Returns:
442
+ True if the detection had a correction that was rverted.
443
+ False if there was nothing to undo.
444
+ """
445
+ vd = self._by_id.get(detection_id)
446
+
447
+ if vd is None:
448
+ return False
449
+
450
+ if vd.corrected_geometry is None:
451
+ return False
452
+
453
+ vd.corrected_geometry = None
454
+ vd.status = ValidationStatus.PENDING
455
+
456
+ self.detection_status_changed.emit(detection_id, vd.status)
457
+
458
+ source_file = vd.detection.source_file
459
+ self.progress_changed.emit(self.get_progress(source_file))
460
+
461
+ return True
462
+
463
+ # --- Convenience propoerties ---
464
+ @property
465
+ def all_detections(self) -> list[ValidatedDetection]:
466
+ """Return a flat list of all validated detections."""
467
+ return self._detections
468
+
469
+ @property
470
+ def labels(self) -> set[str]:
471
+ """
472
+ All unique detection labels discovered in the datset.
473
+
474
+ This is the authoritative set of valid labels for the session. Populated
475
+ once during construction from the CSV. Manual detections must use a
476
+ label from this set (TODO: Add functionality to modify the set).
477
+ """
478
+ return self._labels
479
+
480
+ def get_by_id(self, detection_id: int) -> ValidatedDetection | None:
481
+ """Look up a detection by its' uniwue ID."""
482
+ return self._by_id.get(detection_id)