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