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,2503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation tab - assembles viewer, transport and worker and creates the tab
|
|
3
|
+
layout.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import (
|
|
10
|
+
QThread,
|
|
11
|
+
QTimer,
|
|
12
|
+
QObject,
|
|
13
|
+
QSettings,
|
|
14
|
+
Qt,
|
|
15
|
+
Signal,
|
|
16
|
+
Slot,
|
|
17
|
+
)
|
|
18
|
+
from PySide6.QtWidgets import (
|
|
19
|
+
QApplication,
|
|
20
|
+
QComboBox,
|
|
21
|
+
QDialog,
|
|
22
|
+
QFileDialog,
|
|
23
|
+
QHBoxLayout,
|
|
24
|
+
QInputDialog,
|
|
25
|
+
QLineEdit,
|
|
26
|
+
QMainWindow,
|
|
27
|
+
QMenu,
|
|
28
|
+
QMessageBox,
|
|
29
|
+
QProgressDialog,
|
|
30
|
+
QPushButton,
|
|
31
|
+
QSplitter,
|
|
32
|
+
QVBoxLayout,
|
|
33
|
+
QWidget,
|
|
34
|
+
)
|
|
35
|
+
from PySide6.QtGui import(
|
|
36
|
+
QKeySequence,
|
|
37
|
+
QShortcut,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from seavision.gui.shared.session_utils import resolve_video_paths
|
|
41
|
+
from seavision.gui.validation.button_styles import ReviewButtonStyles
|
|
42
|
+
from seavision.gui.validation.detection_detail import DetectionDetailPanel
|
|
43
|
+
from seavision.gui.validation.detection_table import(
|
|
44
|
+
DetectionFilterProxy,
|
|
45
|
+
DetectionTableModel,
|
|
46
|
+
DetectionTableView,
|
|
47
|
+
)
|
|
48
|
+
from seavision.gui.validation.seekable_source import (
|
|
49
|
+
_needs_preload,
|
|
50
|
+
_estimate_memory_mb,
|
|
51
|
+
_available_memory_mb,
|
|
52
|
+
SeekableVideoSource,
|
|
53
|
+
)
|
|
54
|
+
from seavision.gui.validation.transport_bar import TransportBar
|
|
55
|
+
from seavision.gui.validation.validation_model import (
|
|
56
|
+
ValidationModel,
|
|
57
|
+
ValidationStatus,
|
|
58
|
+
ValidatedDetection,
|
|
59
|
+
)
|
|
60
|
+
from seavision.gui.validation.video_cache import S3VideoCache
|
|
61
|
+
from seavision.gui.validation.video_list import VideoListWidget
|
|
62
|
+
from seavision.gui.validation.interactive_frame_view import (
|
|
63
|
+
InteractiveFrameView,
|
|
64
|
+
)
|
|
65
|
+
from seavision.gui.validation.video_worker import VideoDecoderWorker
|
|
66
|
+
from seavision.engine.visualiser import (
|
|
67
|
+
CSVDetectionLoader,
|
|
68
|
+
ListDetectionSource,
|
|
69
|
+
)
|
|
70
|
+
from seavision.engine.detectors import Detection
|
|
71
|
+
from seavision.engine.source.base import VideoMetadata
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_STATUS_FILTER_MAP = {
|
|
77
|
+
"Show: All": None,
|
|
78
|
+
"Show: Pending": ValidationStatus.PENDING,
|
|
79
|
+
"Show: Confirmed": ValidationStatus.CONFIRMED,
|
|
80
|
+
"Show: Rejected": ValidationStatus.REJECTED,
|
|
81
|
+
"Show: Skipped": ValidationStatus.SKIPPED,
|
|
82
|
+
"Show: Corrected": ValidationStatus.CORRECTED,
|
|
83
|
+
}
|
|
84
|
+
_PAN_STEP = 40 # pixels per keypress when zoomed in
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _S3DownloadWorker(QObject):
|
|
88
|
+
"""
|
|
89
|
+
Background worker for downloading S3 videos.
|
|
90
|
+
|
|
91
|
+
Reports progress per-file rather than per-byte — this avoids
|
|
92
|
+
reliability issues with boto3's Callback parameter across
|
|
93
|
+
versions and transfer strategies.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Emitted after each file: (completed_count, total_count)
|
|
97
|
+
progress = Signal(int, int)
|
|
98
|
+
|
|
99
|
+
# Emitted after each successful file: (s3_uri, local_path)
|
|
100
|
+
file_complete = Signal(str, str)
|
|
101
|
+
|
|
102
|
+
# Emitted when all downloads are done: (success_count, fail_count)
|
|
103
|
+
finished = Signal(int, int)
|
|
104
|
+
|
|
105
|
+
# Emitted on each failure: (s3_uri, error_message)
|
|
106
|
+
error = Signal(str, str)
|
|
107
|
+
|
|
108
|
+
# Trigger download - emitted from main thread
|
|
109
|
+
start_download = Signal(list, str) # List of S3 URIs, AWS profile name
|
|
110
|
+
|
|
111
|
+
def __init__(self, cache, parent=None):
|
|
112
|
+
super().__init__(parent)
|
|
113
|
+
self._cache: S3VideoCache = cache
|
|
114
|
+
self._cancelled = False
|
|
115
|
+
self.start_download.connect(self._do_download)
|
|
116
|
+
|
|
117
|
+
def cancel(self):
|
|
118
|
+
"""Request cancellation of remaining downloads."""
|
|
119
|
+
self._cancelled = True
|
|
120
|
+
|
|
121
|
+
@Slot(list, str)
|
|
122
|
+
def _do_download(self, s3_uris: list[str], profile_name: str) -> None:
|
|
123
|
+
"""Run download logic on the worker thread."""
|
|
124
|
+
self.download_batch(
|
|
125
|
+
s3_uris,
|
|
126
|
+
profile_name=profile_name if profile_name else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def download_batch(
|
|
130
|
+
self,
|
|
131
|
+
s3_uris: list[str],
|
|
132
|
+
profile_name: str | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Download a list of S3 URIs to the local cache.
|
|
136
|
+
|
|
137
|
+
Emits file_complete after each successful download, error
|
|
138
|
+
after each failure, progress after each attempt (success or
|
|
139
|
+
failure), and finished when all are done.
|
|
140
|
+
"""
|
|
141
|
+
total = len(s3_uris)
|
|
142
|
+
success = 0
|
|
143
|
+
failed = 0
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
for i, uri in enumerate(s3_uris):
|
|
147
|
+
if self._cancelled:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
local_path = self._cache.get_or_download(
|
|
152
|
+
uri, profile_name=profile_name,
|
|
153
|
+
)
|
|
154
|
+
self.file_complete.emit(uri, str(local_path))
|
|
155
|
+
success += 1
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
self.error.emit(uri, str(e))
|
|
159
|
+
failed += 1
|
|
160
|
+
|
|
161
|
+
self.progress.emit(i + 1, total)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
# Catch-all for anything that escapes the per-file handler
|
|
165
|
+
logger.error("S3 download batch failed: %s", e, exc_info=True)
|
|
166
|
+
self.error.emit("batch", str(e))
|
|
167
|
+
|
|
168
|
+
self.finished.emit(success, failed)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _StatusFilteredDetectionSource:
|
|
172
|
+
"""
|
|
173
|
+
Adapter between ValidationModel and the video worker.
|
|
174
|
+
|
|
175
|
+
Implements the DetectionSource interface (get_detections_for_frame) while
|
|
176
|
+
filtering by review status and attaching per-detection colour hints for the
|
|
177
|
+
annotator.
|
|
178
|
+
|
|
179
|
+
Thread safety: This object is read by the worker thread and written
|
|
180
|
+
(show_rejected toggle) by the main thread. The shared state is a sinle
|
|
181
|
+
boolean, which is atomic under the GIL. No lock is needed.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
# BGR colours for each status
|
|
185
|
+
_STATUS_COLOURS = {
|
|
186
|
+
ValidationStatus.PENDING: (0, 0, 255), # Red
|
|
187
|
+
ValidationStatus.CONFIRMED: (0, 200, 0), # Green
|
|
188
|
+
ValidationStatus.CORRECTED: (0, 200, 0), # Green
|
|
189
|
+
ValidationStatus.REJECTED: (128, 128, 128), # Grey
|
|
190
|
+
ValidationStatus.SKIPPED: (128, 128, 128), # Grey
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
model: ValidationModel,
|
|
196
|
+
source_file: str,
|
|
197
|
+
) -> None:
|
|
198
|
+
self._model = model
|
|
199
|
+
self._source_file = source_file
|
|
200
|
+
self._show_rejected = False
|
|
201
|
+
self._show_all = True # When False hide all overlays
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def show_rejected(self) -> bool:
|
|
205
|
+
return self._show_rejected
|
|
206
|
+
|
|
207
|
+
@show_rejected.setter
|
|
208
|
+
def show_rejected(self, value: bool) -> None:
|
|
209
|
+
self._show_rejected = value
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def show_all(self) -> bool:
|
|
213
|
+
return self._show_all
|
|
214
|
+
|
|
215
|
+
@show_all.setter
|
|
216
|
+
def show_all(self, value: bool) -> None:
|
|
217
|
+
self._show_all = value
|
|
218
|
+
|
|
219
|
+
def get_detections_for_frame(
|
|
220
|
+
self, frame_number: int
|
|
221
|
+
) -> list[Detection]:
|
|
222
|
+
"""
|
|
223
|
+
Return detections for a frame, filtered by status.
|
|
224
|
+
|
|
225
|
+
Each returned Detection has an additional `_render_colour` attribute
|
|
226
|
+
(a BGR tupple) set based on its review status. Rejected and skipped
|
|
227
|
+
detections are excluded unless show_rejected is True.
|
|
228
|
+
"""
|
|
229
|
+
if not self._show_all:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
validated = self._model.get_detections_for_frame(
|
|
233
|
+
self._source_file, frame_number
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
result = []
|
|
237
|
+
for vd in validated:
|
|
238
|
+
# Filter rejected and skipped unless toggled on
|
|
239
|
+
if vd.status in (
|
|
240
|
+
ValidationStatus.REJECTED,
|
|
241
|
+
ValidationStatus.SKIPPED,
|
|
242
|
+
) and not self._show_rejected:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
det = vd.detection
|
|
246
|
+
|
|
247
|
+
# Attach a render colour based on status
|
|
248
|
+
det._render_colour = self._STATUS_COLOURS.get(
|
|
249
|
+
vd.status, (0, 0, 255)
|
|
250
|
+
)
|
|
251
|
+
result.append(det)
|
|
252
|
+
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class ValidationTab(QWidget):
|
|
257
|
+
"""
|
|
258
|
+
Main validation interface - video viewer with transport controls.
|
|
259
|
+
|
|
260
|
+
Owns the video decoder worker thread and mediates between the transport bar
|
|
261
|
+
(user input) and the worker (video decoding).
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
# Private signals for sending commands to the worker thread. We emit these
|
|
265
|
+
# instead of calling worker methods directly because the worker lives on a
|
|
266
|
+
# different thread.
|
|
267
|
+
_request_open = Signal(str, bool) # filepath, preload
|
|
268
|
+
_request_frame = Signal(int)
|
|
269
|
+
_request_play = Signal(int)
|
|
270
|
+
_request_stop = Signal()
|
|
271
|
+
_request_close = Signal()
|
|
272
|
+
_set_detection_source = Signal(object)
|
|
273
|
+
_clear_detection_source = Signal()
|
|
274
|
+
_request_frame_immediate = Signal(int)
|
|
275
|
+
_set_highlight = Signal(object) # Detection object or None
|
|
276
|
+
_set_playback_speed = Signal(float) # Playback speed multiplier
|
|
277
|
+
_set_annotations_enabled = Signal(bool)
|
|
278
|
+
|
|
279
|
+
def __init__(self, parent=None):
|
|
280
|
+
super().__init__(parent)
|
|
281
|
+
|
|
282
|
+
# --- Create widgets ---
|
|
283
|
+
self._viewer = InteractiveFrameView()
|
|
284
|
+
self._transport = TransportBar()
|
|
285
|
+
self._detection_model = DetectionTableModel()
|
|
286
|
+
self._detection_table = DetectionTableView()
|
|
287
|
+
self._detection_table.setModel(self._detection_model)
|
|
288
|
+
self._detail_panel = DetectionDetailPanel()
|
|
289
|
+
|
|
290
|
+
# --- Action buttons ---
|
|
291
|
+
button_container = QVBoxLayout()
|
|
292
|
+
|
|
293
|
+
# Top row - review actions
|
|
294
|
+
review_row = QHBoxLayout()
|
|
295
|
+
self._confirm_btn = QPushButton("Confirm (C)")
|
|
296
|
+
self._confirm_btn.setStyleSheet(ReviewButtonStyles().confirm)
|
|
297
|
+
self._confirm_btn.setToolTip(
|
|
298
|
+
"Confirm selected detection (C)\n"
|
|
299
|
+
"Shift+C to confirm all on this frame"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
self._reject_btn = QPushButton("Reject (R)")
|
|
303
|
+
self._reject_btn.setStyleSheet(ReviewButtonStyles().reject)
|
|
304
|
+
self._reject_btn.setToolTip(
|
|
305
|
+
"Reject selected detection (R)\n"
|
|
306
|
+
"Shift+R to reject all on this frame"
|
|
307
|
+
)
|
|
308
|
+
self._reject_btn.setToolTip(
|
|
309
|
+
"Reject selected detection (R)\n"
|
|
310
|
+
"Shift+R to reject all on this frame"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self._skip_btn = QPushButton("Skip (S)")
|
|
314
|
+
self._skip_btn.setStyleSheet(ReviewButtonStyles().skip)
|
|
315
|
+
self._skip_btn.setToolTip(
|
|
316
|
+
"Skip selected detection (S)\n"
|
|
317
|
+
"Shift+S to skip all on this frame"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
review_row.addWidget(self._confirm_btn)
|
|
321
|
+
review_row.addWidget(self._reject_btn)
|
|
322
|
+
review_row.addWidget(self._skip_btn)
|
|
323
|
+
|
|
324
|
+
# Bottom row - manual add/remove
|
|
325
|
+
edit_row = QHBoxLayout()
|
|
326
|
+
|
|
327
|
+
self._add_btn = QPushButton("Add Detection (A)")
|
|
328
|
+
self._add_btn.setStyleSheet(ReviewButtonStyles().add)
|
|
329
|
+
self._add_btn.setToolTip(
|
|
330
|
+
"Add a new detection (A)\n"
|
|
331
|
+
"Shift+A to add detection at cursor position"
|
|
332
|
+
)
|
|
333
|
+
self._add_btn.setCheckable(True)
|
|
334
|
+
self._add_btn.setToolTip(
|
|
335
|
+
"Toggle 'add detection' mode. When enabled, click on frame to add a detection."
|
|
336
|
+
)
|
|
337
|
+
self._add_btn.toggled.connect(self._on_add_mode_toggled)
|
|
338
|
+
|
|
339
|
+
self._remove_btn = QPushButton("Remove")
|
|
340
|
+
self._remove_btn.setStyleSheet(ReviewButtonStyles().reject)
|
|
341
|
+
self._remove_btn.setToolTip(
|
|
342
|
+
"Remove a manually added detection (not available for "
|
|
343
|
+
"pipeline detections)"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
edit_row.addWidget(self._add_btn)
|
|
347
|
+
edit_row.addWidget(self._remove_btn)
|
|
348
|
+
|
|
349
|
+
# Layout in container
|
|
350
|
+
button_container.addLayout(review_row)
|
|
351
|
+
button_container.addLayout(edit_row)
|
|
352
|
+
|
|
353
|
+
self._confirm_btn.setEnabled(False)
|
|
354
|
+
self._reject_btn.setEnabled(False)
|
|
355
|
+
self._skip_btn.setEnabled(False)
|
|
356
|
+
self._remove_btn.setEnabled(False)
|
|
357
|
+
self._add_btn.setEnabled(False)
|
|
358
|
+
|
|
359
|
+
# --- Layout: three-zone splitter ---
|
|
360
|
+
# Outer splitter: left placeholder | centre viewer | right panel
|
|
361
|
+
self._outer_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
362
|
+
|
|
363
|
+
# --- Video list widget ---
|
|
364
|
+
self._video_list = VideoListWidget()
|
|
365
|
+
self._outer_splitter.addWidget(self._video_list)
|
|
366
|
+
|
|
367
|
+
# --- Video Viewer ---
|
|
368
|
+
self._outer_splitter.addWidget(self._viewer)
|
|
369
|
+
|
|
370
|
+
# --- Table + filter controls ---
|
|
371
|
+
table_container = QWidget()
|
|
372
|
+
table_layout = QVBoxLayout(table_container)
|
|
373
|
+
table_layout.setContentsMargins(0, 0, 0, 0)
|
|
374
|
+
|
|
375
|
+
# Add filtering row to layout
|
|
376
|
+
filter_row = QHBoxLayout()
|
|
377
|
+
|
|
378
|
+
# Status filter
|
|
379
|
+
self._status_filter_combo = QComboBox()
|
|
380
|
+
self._status_filter_combo.addItems([
|
|
381
|
+
"Show: All",
|
|
382
|
+
"Show: Pending",
|
|
383
|
+
"Show: Confirmed",
|
|
384
|
+
"Show: Rejected",
|
|
385
|
+
"Show: Skipped",
|
|
386
|
+
"Show: Corrected",
|
|
387
|
+
"Show: Manual",
|
|
388
|
+
])
|
|
389
|
+
self._status_filter_combo.currentTextChanged.connect(
|
|
390
|
+
self._on_status_filter_changed
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Class filter
|
|
394
|
+
self._class_filter_combo = QComboBox()
|
|
395
|
+
self._class_filter_combo.addItem("Class: All")
|
|
396
|
+
# Populated dynamically when session opens
|
|
397
|
+
self._class_filter_combo.currentTextChanged.connect(
|
|
398
|
+
self._on_class_filter_changed
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
filter_row.addWidget(self._status_filter_combo)
|
|
402
|
+
filter_row.addWidget(self._class_filter_combo)
|
|
403
|
+
|
|
404
|
+
table_layout.addLayout(filter_row)
|
|
405
|
+
table_layout.addWidget(self._detection_table, stretch = 1)
|
|
406
|
+
|
|
407
|
+
# Build detection detail widget
|
|
408
|
+
detail_container = QWidget()
|
|
409
|
+
detail_layout = QVBoxLayout(detail_container)
|
|
410
|
+
detail_layout.setContentsMargins(0, 0, 0, 0)
|
|
411
|
+
|
|
412
|
+
detail_layout.addWidget(self._detail_panel, stretch=1)
|
|
413
|
+
detail_layout.addLayout(button_container)
|
|
414
|
+
|
|
415
|
+
# Right: detection table (top) + detail panel (bottom)
|
|
416
|
+
self._right_splitter = QSplitter(Qt.Orientation.Vertical)
|
|
417
|
+
self._right_splitter.addWidget(table_container)
|
|
418
|
+
self._right_splitter.addWidget(detail_container)
|
|
419
|
+
self._right_splitter.setSizes([400, 220])
|
|
420
|
+
|
|
421
|
+
self._outer_splitter.addWidget(self._right_splitter)
|
|
422
|
+
|
|
423
|
+
# Set initial sizes: left 150px, centre 700px, right 350px
|
|
424
|
+
self._outer_splitter.setSizes([150, 700, 350])
|
|
425
|
+
|
|
426
|
+
# Main layout: splitter on top, transport bar on bottom
|
|
427
|
+
layout = QVBoxLayout(self)
|
|
428
|
+
layout.addWidget(self._outer_splitter, stretch=1)
|
|
429
|
+
layout.addWidget(self._transport, stretch=0)
|
|
430
|
+
|
|
431
|
+
# --- Worker thread ---
|
|
432
|
+
self._thread = QThread()
|
|
433
|
+
self._worker = VideoDecoderWorker()
|
|
434
|
+
self._worker.moveToThread(self._thread)
|
|
435
|
+
self._thread.start()
|
|
436
|
+
|
|
437
|
+
# --- State ---
|
|
438
|
+
# Playback state
|
|
439
|
+
self._current_frame = 0
|
|
440
|
+
self._is_playing = False
|
|
441
|
+
self._metadata = None
|
|
442
|
+
|
|
443
|
+
# Session state
|
|
444
|
+
self._csv_loader: CSVDetectionLoader | None = None
|
|
445
|
+
self._video_paths: dict[str, str] = {}
|
|
446
|
+
self._active_source_file: str | None = None
|
|
447
|
+
self._validation_model: ValidationModel | None = None
|
|
448
|
+
self._selected_detection: ValidatedDetection | None = None
|
|
449
|
+
self._csv_path : str | None = None
|
|
450
|
+
self._video_dir: str | None = None
|
|
451
|
+
self._session_save_path: Path | None = None
|
|
452
|
+
self._has_unsaved_changes: bool = False
|
|
453
|
+
self._aws_profile: str | None = None
|
|
454
|
+
self._cache_dir: str | None = None
|
|
455
|
+
self._active_detection_adapter: _StatusFilteredDetectionSource | None = None
|
|
456
|
+
self._video_is_preloaded: bool = False
|
|
457
|
+
self._s3_progress_dialog: QProgressDialog | None = None
|
|
458
|
+
self._s3_on_complete = None
|
|
459
|
+
self._overlays_visible: bool = True
|
|
460
|
+
self._selected_label: str | None = None
|
|
461
|
+
self._add_mode_auto_label: str | None = None
|
|
462
|
+
|
|
463
|
+
# --- Connect private signals to worker slots ---
|
|
464
|
+
self._request_open.connect(self._worker.open_video)
|
|
465
|
+
self._request_frame.connect(self._worker.request_frame)
|
|
466
|
+
self._request_play.connect(self._worker.start_playback)
|
|
467
|
+
self._request_stop.connect(self._worker.stop_playback)
|
|
468
|
+
self._request_close.connect(self._worker.close_video)
|
|
469
|
+
self._set_detection_source.connect(self._worker.set_detection_source)
|
|
470
|
+
self._clear_detection_source.connect(
|
|
471
|
+
self._worker.clear_detection_source
|
|
472
|
+
)
|
|
473
|
+
self._request_frame_immediate.connect(
|
|
474
|
+
self._worker.request_frame_immediate
|
|
475
|
+
)
|
|
476
|
+
self._set_highlight.connect(self._worker.set_highlight_detection)
|
|
477
|
+
self._set_playback_speed.connect(self._worker.set_speed)
|
|
478
|
+
self._set_annotations_enabled.connect(
|
|
479
|
+
self._worker.set_annotations_enabled
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# --- Connect worker signals to slots ---
|
|
483
|
+
self._worker.frame_ready.connect(self._viewer.update_frame)
|
|
484
|
+
self._worker.frame_ready.connect(self._on_frame_received)
|
|
485
|
+
self._worker.video_opened.connect(self._on_video_opened)
|
|
486
|
+
self._worker.playback_finished.connect(self._on_playback_finished)
|
|
487
|
+
self._worker.error_occurred.connect(self._on_error)
|
|
488
|
+
|
|
489
|
+
# --- Connect transport bar signals ---
|
|
490
|
+
self._transport.play_pause_clicked.connect(self._on_play_pause)
|
|
491
|
+
self._transport.next_frame_clicked.connect(self._on_next_frame)
|
|
492
|
+
self._transport.prev_frame_clicked.connect(self._on_prev_frame)
|
|
493
|
+
self._transport.seek_requested.connect(self._on_seek)
|
|
494
|
+
self._transport.seek_commited.connect(self._on_seek_committed)
|
|
495
|
+
self._transport.prev_detection_clicked.connect(
|
|
496
|
+
self._on_prev_detection
|
|
497
|
+
)
|
|
498
|
+
self._transport.next_detection_clicked.connect(
|
|
499
|
+
self._on_next_detection
|
|
500
|
+
)
|
|
501
|
+
self._transport.speed_changed.connect(self._set_playback_speed)
|
|
502
|
+
|
|
503
|
+
# --- Connect detection table signals ---
|
|
504
|
+
self._detection_table.detection_selected.connect(
|
|
505
|
+
self._on_detection_selected
|
|
506
|
+
)
|
|
507
|
+
self._detection_table.context_action.connect(
|
|
508
|
+
self._on_table_context_action
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# --- Connect action button clicks ---
|
|
512
|
+
self._confirm_btn.clicked.connect(self._on_confirm)
|
|
513
|
+
self._reject_btn.clicked.connect(self._on_reject)
|
|
514
|
+
self._skip_btn.clicked.connect(self._on_skip)
|
|
515
|
+
self._remove_btn.clicked.connect(self._on_remove)
|
|
516
|
+
|
|
517
|
+
# --- Connect detection addition signals ---
|
|
518
|
+
self._viewer.frame_clicked.connect(
|
|
519
|
+
self._on_frame_clicked_for_add
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# --- Conect video list signals ---
|
|
523
|
+
self._video_list.video_selected.connect(self._on_video_selected)
|
|
524
|
+
|
|
525
|
+
# --- Connect signals for interactive editing ---
|
|
526
|
+
self._viewer.scene.detection_geometry_changed.connect(
|
|
527
|
+
self._on_detection_geometry_changed
|
|
528
|
+
)
|
|
529
|
+
self._viewer.scene.detection_draw_complete.connect(
|
|
530
|
+
self._on_detection_drawn
|
|
531
|
+
)
|
|
532
|
+
self._viewer.scene.detection_rect_selected.connect(
|
|
533
|
+
self._on_scene_detection_selected
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# --- Debounce timer for seek requests ---
|
|
537
|
+
self._seek_timer = QTimer()
|
|
538
|
+
self._seek_timer.setSingleShot(True)
|
|
539
|
+
self._seek_timer.setInterval(50) # ms
|
|
540
|
+
self._seek_timer.timeout.connect(self._do_seek)
|
|
541
|
+
self._pending_seek: int | None = None
|
|
542
|
+
|
|
543
|
+
# --- Focus ---
|
|
544
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
545
|
+
|
|
546
|
+
# --- Keyboard shortcuts ---
|
|
547
|
+
self._setup_shortcuts()
|
|
548
|
+
|
|
549
|
+
# --- Context menus ---
|
|
550
|
+
self._viewer.setContextMenuPolicy(
|
|
551
|
+
Qt.ContextMenuPolicy.CustomContextMenu
|
|
552
|
+
)
|
|
553
|
+
self._viewer.customContextMenuRequested.connect(
|
|
554
|
+
self._on_frame_context_menu
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _show_status(self, message: str) -> None:
|
|
559
|
+
"""Show a message in the main window's status bar."""
|
|
560
|
+
main_window = self.window()
|
|
561
|
+
if isinstance(main_window, QMainWindow):
|
|
562
|
+
main_window.statusBar().showMessage(message)
|
|
563
|
+
|
|
564
|
+
def _clear_status(self) -> None:
|
|
565
|
+
"""Clear the status bar message."""
|
|
566
|
+
main_window = self.window()
|
|
567
|
+
if isinstance(main_window, QMainWindow):
|
|
568
|
+
main_window.statusBar().clearMessage()
|
|
569
|
+
|
|
570
|
+
def _setup_shortcuts(self) -> None:
|
|
571
|
+
"""Register keyboard shortcuts for the review workflow."""
|
|
572
|
+
|
|
573
|
+
# Store shortcut references
|
|
574
|
+
self._shortcuts = []
|
|
575
|
+
|
|
576
|
+
def _add(key: str, slot) -> None:
|
|
577
|
+
shortcut = QShortcut(QKeySequence(key), self)
|
|
578
|
+
shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
|
|
579
|
+
shortcut.activated.connect(slot)
|
|
580
|
+
self._shortcuts.append(shortcut)
|
|
581
|
+
|
|
582
|
+
_add("C", self._on_confirm)
|
|
583
|
+
_add("Shift+C", self._on_confirm_all_on_frame)
|
|
584
|
+
_add("1", self._on_confirm)
|
|
585
|
+
_add("R", self._on_reject)
|
|
586
|
+
_add("Shift+R", self._on_reject_all_on_frame)
|
|
587
|
+
_add("2", self._on_reject)
|
|
588
|
+
_add("S", self._on_skip)
|
|
589
|
+
_add("Shift+S", self._on_skip_all_on_frame)
|
|
590
|
+
_add("3", self._on_skip)
|
|
591
|
+
_add("N", self._advance_to_next)
|
|
592
|
+
_add("A", self._toggle_add_mode)
|
|
593
|
+
_add("L", self._toggle_add_mode_with_current_label)
|
|
594
|
+
_add("Space", self._on_play_pause)
|
|
595
|
+
_add("Left", self._on_prev_frame)
|
|
596
|
+
_add("Right", self._on_next_frame)
|
|
597
|
+
_add("Ctrl+Left", self._on_prev_detection)
|
|
598
|
+
_add("Ctrl+Right", self._on_next_detection)
|
|
599
|
+
_add("0", lambda: self._viewer.reset_zoom())
|
|
600
|
+
_add("F", lambda: self._viewer.reset_zoom())
|
|
601
|
+
_add("Ctrl+Z", self._undo_correction)
|
|
602
|
+
|
|
603
|
+
# Panning when zoomed (Shift+Arrow)
|
|
604
|
+
_add("Shift+Left", lambda: self._viewer.pan(-_PAN_STEP, 0))
|
|
605
|
+
_add("Shift+Right", lambda: self._viewer.pan(_PAN_STEP, 0))
|
|
606
|
+
_add("Shift+Up", lambda: self._viewer.pan(0, -_PAN_STEP))
|
|
607
|
+
_add("Shift+Down", lambda: self._viewer.pan(0, _PAN_STEP))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def open_session(
|
|
611
|
+
self, csv_path: str, video_dir: str
|
|
612
|
+
) -> None:
|
|
613
|
+
"""
|
|
614
|
+
Open a detection session from a CSV file and video directory.
|
|
615
|
+
|
|
616
|
+
Parses the CSV, resolves source videos against the directory,
|
|
617
|
+
and opens the first resolved video with detection overlays.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
csv_path: Path to the detection CSV file.
|
|
621
|
+
video_dir: Directory containing the source video files.
|
|
622
|
+
"""
|
|
623
|
+
# --- Parse the CSV ---
|
|
624
|
+
try:
|
|
625
|
+
loader = CSVDetectionLoader(csv_path)
|
|
626
|
+
except (FileNotFoundError, ValueError) as e:
|
|
627
|
+
QMessageBox.warning(
|
|
628
|
+
self,
|
|
629
|
+
"CSV Error",
|
|
630
|
+
f"Failed to load detection CSV:\n\n{e}",
|
|
631
|
+
)
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
# --- Resolve video files ---
|
|
635
|
+
resolved, s3_sources = resolve_video_paths(
|
|
636
|
+
loader.sources_in_file,
|
|
637
|
+
video_dir
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# --- Store session state ---
|
|
641
|
+
self._csv_loader = loader
|
|
642
|
+
self._video_paths = resolved
|
|
643
|
+
self._csv_path = csv_path
|
|
644
|
+
self._video_dir = video_dir
|
|
645
|
+
self._session_save_path = None
|
|
646
|
+
self._has_unsaved_changes = False
|
|
647
|
+
self._cache_dir = None
|
|
648
|
+
|
|
649
|
+
# --- Create validation model ---
|
|
650
|
+
self._validation_model = ValidationModel(loader, parent=self)
|
|
651
|
+
|
|
652
|
+
# --- Populate the class filter ---
|
|
653
|
+
self._populate_class_filter()
|
|
654
|
+
|
|
655
|
+
# --- Wire validation model signals ---
|
|
656
|
+
self._validation_model.detection_status_changed.connect(
|
|
657
|
+
self._on_detection_status_changed
|
|
658
|
+
)
|
|
659
|
+
self._validation_model.progress_changed.connect(
|
|
660
|
+
self._on_progress_changed
|
|
661
|
+
)
|
|
662
|
+
self._validation_model.detection_added.connect(
|
|
663
|
+
self._on_detection_added
|
|
664
|
+
)
|
|
665
|
+
self._validation_model.detection_removed.connect(
|
|
666
|
+
self._on_detection_removed
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# --- Handle S3 downloads if needed ---
|
|
670
|
+
if s3_sources:
|
|
671
|
+
self._pending_loader = loader
|
|
672
|
+
self._pending_csv_path = csv_path
|
|
673
|
+
self._download_s3_videos(
|
|
674
|
+
s3_sources,
|
|
675
|
+
on_complete=self._on_s3_downloads_complete,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# If there are also local videos, finish setup now
|
|
679
|
+
# with what we have — S3 videos get added when ready
|
|
680
|
+
if resolved:
|
|
681
|
+
self._finish_session_setup(csv_path, loader)
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
# --- No S3 sources — finish setup immediately ---
|
|
685
|
+
self._finish_session_setup(csv_path, loader)
|
|
686
|
+
|
|
687
|
+
logger.info(
|
|
688
|
+
"Session opened: %s — %d sources, %d resolved",
|
|
689
|
+
csv_path,
|
|
690
|
+
len(loader.sources_in_file),
|
|
691
|
+
len(resolved),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _finish_session_setup(self, csv_path: str, loader) -> None:
|
|
695
|
+
"""
|
|
696
|
+
Complete session setup after all video paths are resolved.
|
|
697
|
+
|
|
698
|
+
Called directly for local-only sessions, or after S3 downloads
|
|
699
|
+
complete for S3 sessions. The ValidationModel and its signal
|
|
700
|
+
connections are already set up by open_session.
|
|
701
|
+
"""
|
|
702
|
+
resolved = self._video_paths
|
|
703
|
+
|
|
704
|
+
# --- Check we found at least one video ---
|
|
705
|
+
if not resolved:
|
|
706
|
+
local_expected = [
|
|
707
|
+
Path(s).name for s in loader.sources_in_file
|
|
708
|
+
if not s.startswith("s3://")
|
|
709
|
+
]
|
|
710
|
+
s3_expected = [
|
|
711
|
+
s for s in loader.sources_in_file
|
|
712
|
+
if s.startswith("s3://")
|
|
713
|
+
]
|
|
714
|
+
|
|
715
|
+
message = "No video files could be resolved.\n\n"
|
|
716
|
+
if local_expected:
|
|
717
|
+
message += (
|
|
718
|
+
"Expected local files:\n"
|
|
719
|
+
+ "\n".join(f" • {n}" for n in local_expected[:10])
|
|
720
|
+
+ ("\n ..." if len(local_expected) > 10 else "")
|
|
721
|
+
+ "\n\n"
|
|
722
|
+
)
|
|
723
|
+
if s3_expected:
|
|
724
|
+
message += (
|
|
725
|
+
f"{len(s3_expected)} S3 video(s) failed to download."
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
QMessageBox.warning(self, "No Videos Found", message)
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
# --- Populate video list sidebar ---
|
|
732
|
+
video_info = []
|
|
733
|
+
for source_file in self._validation_model.get_all_source_files():
|
|
734
|
+
progress = self._validation_model.get_progress(source_file)
|
|
735
|
+
video_info.append({
|
|
736
|
+
"source_file": source_file,
|
|
737
|
+
"total": progress["total"],
|
|
738
|
+
"reviewed": progress["reviewed"],
|
|
739
|
+
"available": source_file in self._video_paths,
|
|
740
|
+
})
|
|
741
|
+
self._video_list.set_videos(video_info)
|
|
742
|
+
|
|
743
|
+
# --- Open the first available video ---
|
|
744
|
+
first_source = next(
|
|
745
|
+
(s for s in loader.sources_in_file if s in resolved),
|
|
746
|
+
None,
|
|
747
|
+
)
|
|
748
|
+
if first_source is not None:
|
|
749
|
+
self._open_video_for_source(first_source)
|
|
750
|
+
|
|
751
|
+
# Disbale worker side annotations so that all annotation is handled
|
|
752
|
+
# by the interactive frame viewer
|
|
753
|
+
self._set_annotations_enabled.emit(False)
|
|
754
|
+
|
|
755
|
+
logger.info(
|
|
756
|
+
"Session setup complete: %d videos available",
|
|
757
|
+
len(resolved),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def _on_s3_downloads_complete(self) -> None:
|
|
761
|
+
"""Called when background S3 downloads finish."""
|
|
762
|
+
loader = self._pending_loader
|
|
763
|
+
csv_path = self._pending_csv_path
|
|
764
|
+
self._pending_loader = None
|
|
765
|
+
self._pending_csv_path = None
|
|
766
|
+
|
|
767
|
+
# Finish setup — video list, open first video
|
|
768
|
+
self._finish_session_setup(csv_path, loader)
|
|
769
|
+
|
|
770
|
+
def _open_video_for_source(self, source_name: str) -> None:
|
|
771
|
+
"""
|
|
772
|
+
Open a specific source video with its detection overlay.
|
|
773
|
+
|
|
774
|
+
Queries the ValidationModel for this video's detections and populates
|
|
775
|
+
the table with ValidatedDetection objects.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
source_name: The source_file value from the CSV.
|
|
779
|
+
"""
|
|
780
|
+
# CLear existing status messages
|
|
781
|
+
self._clear_status()
|
|
782
|
+
|
|
783
|
+
local_path = self._video_paths.get(source_name)
|
|
784
|
+
if local_path is None:
|
|
785
|
+
logger.error(
|
|
786
|
+
"No local path for source: %s",
|
|
787
|
+
source_name
|
|
788
|
+
)
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
self._active_source_file = source_name
|
|
793
|
+
# --- Send status-aware detection source to the worker ---
|
|
794
|
+
self._active_detection_adapter = _StatusFilteredDetectionSource(
|
|
795
|
+
self._validation_model,
|
|
796
|
+
source_name,
|
|
797
|
+
)
|
|
798
|
+
self._set_detection_source.emit(self._active_detection_adapter)
|
|
799
|
+
|
|
800
|
+
# --- Populate the detection table from the ValidationModel ---
|
|
801
|
+
validated_dets = self._validation_model.get_detections_for_video(
|
|
802
|
+
source_name
|
|
803
|
+
)
|
|
804
|
+
fps = self._metadata.fps if self._metadata else None
|
|
805
|
+
self._detection_model.set_detections(validated_dets, fps=fps)
|
|
806
|
+
|
|
807
|
+
has_detections = len(validated_dets) > 0
|
|
808
|
+
self._transport.set_detection_nav_enabled(has_detections)
|
|
809
|
+
self._add_btn.setEnabled(self._validation_model is not None)
|
|
810
|
+
|
|
811
|
+
# --- Clear selection state ---
|
|
812
|
+
self._detail_panel.clear()
|
|
813
|
+
self._selected_detection = None
|
|
814
|
+
self._set_highlight.emit(None)
|
|
815
|
+
self._detection_model.set_selected_id(None)
|
|
816
|
+
|
|
817
|
+
# --- Open video
|
|
818
|
+
preload = self._should_preload(local_path)
|
|
819
|
+
self._request_open.emit(local_path, preload)
|
|
820
|
+
self._video_is_preloaded = preload
|
|
821
|
+
|
|
822
|
+
logger.info(
|
|
823
|
+
"Opened source '%s' (%s) — %d detection frames",
|
|
824
|
+
source_name,
|
|
825
|
+
local_path,
|
|
826
|
+
len(validated_dets),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def _on_video_selected(self, source_file: str) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Switch to a different video in the session.
|
|
832
|
+
|
|
833
|
+
Called when the user clicks a video in the sidebar. Delegates to
|
|
834
|
+
_open_video_for_source which handles the video-switching logic.
|
|
835
|
+
"""
|
|
836
|
+
if source_file == self._active_source_file:
|
|
837
|
+
return # Already viewing this video
|
|
838
|
+
|
|
839
|
+
if source_file not in self._video_paths:
|
|
840
|
+
QMessageBox.warning(
|
|
841
|
+
self,
|
|
842
|
+
"Video Not Available",
|
|
843
|
+
f"The video file could not be found:\n\n"
|
|
844
|
+
f" {source_file}\n\n"
|
|
845
|
+
f"It may have been moved or deleted.",
|
|
846
|
+
)
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
self._open_video_for_source(source_file)
|
|
850
|
+
|
|
851
|
+
def _refresh_worker_detection_source(self) -> None:
|
|
852
|
+
"""
|
|
853
|
+
Rebuild the worker's detection source from the ValidationModel.
|
|
854
|
+
|
|
855
|
+
Called after any change that affects which detections exist (manual add/
|
|
856
|
+
remove). This ensures the frameAnnotator sees manual detections
|
|
857
|
+
alongside pipeline detections.
|
|
858
|
+
"""
|
|
859
|
+
if self._validation_model is None:
|
|
860
|
+
return
|
|
861
|
+
if self._active_source_file is None:
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
# Get all detections for the current video (inclusing manual)
|
|
865
|
+
all_vds = self._validation_model.get_detections_for_video(
|
|
866
|
+
self._active_source_file
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# Extract the raw Detection objects for the annotator
|
|
870
|
+
raw_detections = [vd.detection for vd in all_vds]
|
|
871
|
+
|
|
872
|
+
# Build a new detection source and send it to the worker
|
|
873
|
+
source = ListDetectionSource(
|
|
874
|
+
raw_detections, self._active_source_file
|
|
875
|
+
)
|
|
876
|
+
self._set_detection_source.emit(source)
|
|
877
|
+
|
|
878
|
+
def open_video(self, filepath: str) -> None:
|
|
879
|
+
"""Open a video file without detections (called by Main Window)."""
|
|
880
|
+
# Clear any active session state
|
|
881
|
+
self._csv_loader = None
|
|
882
|
+
self._video_paths = {}
|
|
883
|
+
self._active_source_file = None
|
|
884
|
+
self._validation_model = None
|
|
885
|
+
self._detection_model.set_selected_id(None)
|
|
886
|
+
self._clear_detection_source.emit()
|
|
887
|
+
self._set_annotations_enabled.emit(True)
|
|
888
|
+
|
|
889
|
+
# Clear the detection table and detail panel
|
|
890
|
+
self._detection_model.set_detections([])
|
|
891
|
+
self._detail_panel.clear()
|
|
892
|
+
self._selected_detection = None
|
|
893
|
+
self._set_highlight.emit(None)
|
|
894
|
+
|
|
895
|
+
# Clear session saving state
|
|
896
|
+
self._csv_path = None
|
|
897
|
+
self._video_dir = None
|
|
898
|
+
self._session_save_path = None
|
|
899
|
+
self._has_unsaved_changes = False
|
|
900
|
+
self._video_list.clear()
|
|
901
|
+
|
|
902
|
+
# Disable detection navigation buttons
|
|
903
|
+
self._transport.set_detection_nav_enabled(False)
|
|
904
|
+
|
|
905
|
+
# Disable review buttons
|
|
906
|
+
self._confirm_btn.setEnabled(False)
|
|
907
|
+
self._reject_btn.setEnabled(False)
|
|
908
|
+
self._skip_btn.setEnabled(False)
|
|
909
|
+
self._remove_btn.setEnabled(False)
|
|
910
|
+
|
|
911
|
+
preload = self._should_preload(filepath)
|
|
912
|
+
self._request_open.emit(filepath, preload)
|
|
913
|
+
self._video_is_preloaded = preload
|
|
914
|
+
|
|
915
|
+
def _on_video_opened(self, metadata: VideoMetadata) -> None:
|
|
916
|
+
"""Worker has opened a video — configure the UI."""
|
|
917
|
+
self._metadata = metadata
|
|
918
|
+
self._current_frame = 0
|
|
919
|
+
self._is_playing = False
|
|
920
|
+
self._transport.set_video_info(metadata)
|
|
921
|
+
|
|
922
|
+
# Update table with accurate frame rate
|
|
923
|
+
if metadata.fps and metadata.fps > 0:
|
|
924
|
+
self._detection_model._fps = metadata.fps
|
|
925
|
+
self._detection_model.layoutChanged.emit()
|
|
926
|
+
|
|
927
|
+
# Auto-select the first unreviewed detection
|
|
928
|
+
if (self._validation_model is not None
|
|
929
|
+
and self._active_source_file is not None):
|
|
930
|
+
first = self._validation_model.get_next_unreviewed(
|
|
931
|
+
self._active_source_file
|
|
932
|
+
)
|
|
933
|
+
if first is not None:
|
|
934
|
+
row = self._find_row_for_detection(first)
|
|
935
|
+
if row is not None:
|
|
936
|
+
self._detection_table.select_row(row)
|
|
937
|
+
elif self._detection_model.detection_count() > 0:
|
|
938
|
+
# All reviewed — select the first detection anyway
|
|
939
|
+
self._detection_table.select_row(0)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
# --- s3 Download logic ---
|
|
943
|
+
@Slot(int, int)
|
|
944
|
+
def _on_s3_progress(self, completed: int, total: int) -> None:
|
|
945
|
+
"""Update the S3 download progress dialog from the main thread."""
|
|
946
|
+
if self._s3_progress_dialog is not None:
|
|
947
|
+
self._s3_progress_dialog.setValue(completed)
|
|
948
|
+
self._s3_progress_dialog.setLabelText(
|
|
949
|
+
f"Downloading video {completed}/{total}..."
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
@Slot(str, str)
|
|
953
|
+
def _on_s3_file_complete(self, uri: str, path: str) -> None:
|
|
954
|
+
"""Record a successfully downloaded S3 video path."""
|
|
955
|
+
self._video_paths[uri] = path
|
|
956
|
+
|
|
957
|
+
@Slot(str, str)
|
|
958
|
+
def _on_s3_error(self, uri: str, msg: str) -> None:
|
|
959
|
+
"""Log an S3 download failure."""
|
|
960
|
+
logger.error("Failed to download S3 video '%s': %s", uri, msg)
|
|
961
|
+
|
|
962
|
+
@Slot(int, int)
|
|
963
|
+
def _on_s3_finished(self, success: int, failed: int) -> None:
|
|
964
|
+
"""Handle completion of all S3 downloads - runs on the main thread."""
|
|
965
|
+
if self._s3_progress_dialog is not None:
|
|
966
|
+
self._s3_progress_dialog.close()
|
|
967
|
+
self._s3_progress_dialog = None
|
|
968
|
+
|
|
969
|
+
if hasattr(self, "_s3_thread") and self._s3_thread is not None:
|
|
970
|
+
self._s3_thread.quit()
|
|
971
|
+
|
|
972
|
+
if failed > 0:
|
|
973
|
+
QMessageBox.warning(
|
|
974
|
+
self, "Download Incomplete",
|
|
975
|
+
f"{failed} video(s) failed to download.\n"
|
|
976
|
+
f"They will be greyed out in the video list.",
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
if self._s3_on_complete is not None:
|
|
980
|
+
callback = self._s3_on_complete
|
|
981
|
+
self._s3_on_complete = None
|
|
982
|
+
callback()
|
|
983
|
+
|
|
984
|
+
def _download_s3_videos(
|
|
985
|
+
self,
|
|
986
|
+
s3_uris: list[str],
|
|
987
|
+
on_complete: callable = None,
|
|
988
|
+
) -> None:
|
|
989
|
+
"""Download S3 videos to local cache with progress dialog."""
|
|
990
|
+
cache_dir = self._cache_dir or self._get_cache_dir()
|
|
991
|
+
cache = S3VideoCache(cache_dir=Path(cache_dir))
|
|
992
|
+
|
|
993
|
+
# --- Check if any S3 videos still need downloading ---
|
|
994
|
+
uncached_uris = []
|
|
995
|
+
for uri in s3_uris:
|
|
996
|
+
cached_path = cache.get_cached_path(uri)
|
|
997
|
+
if cached_path is not None and Path(cached_path).exists():
|
|
998
|
+
self._video_paths[uri] = str(cached_path)
|
|
999
|
+
else:
|
|
1000
|
+
uncached_uris.append(uri)
|
|
1001
|
+
|
|
1002
|
+
if not uncached_uris:
|
|
1003
|
+
self._show_status("All S3 videos loaded from local cache.")
|
|
1004
|
+
return # All videos already cached, no need to ask or download
|
|
1005
|
+
|
|
1006
|
+
# Ask the user which AWS profile to use
|
|
1007
|
+
profile_name = self._ask_aws_profile()
|
|
1008
|
+
if profile_name is None:
|
|
1009
|
+
return
|
|
1010
|
+
self._aws_profile = profile_name
|
|
1011
|
+
|
|
1012
|
+
# Show busy cursor during size calcualtion
|
|
1013
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
1014
|
+
try:
|
|
1015
|
+
total_bytes, need_download = cache.calculate_download_size(
|
|
1016
|
+
uncached_uris, profile_name=self._aws_profile
|
|
1017
|
+
)
|
|
1018
|
+
except RuntimeError as e:
|
|
1019
|
+
QMessageBox.warning(
|
|
1020
|
+
self, "S3 Access Error",
|
|
1021
|
+
f"Cannot access S3 videos:\n\n{e}",
|
|
1022
|
+
)
|
|
1023
|
+
return
|
|
1024
|
+
finally:
|
|
1025
|
+
QApplication.restoreOverrideCursor()
|
|
1026
|
+
|
|
1027
|
+
if not need_download:
|
|
1028
|
+
# All already cached
|
|
1029
|
+
for uri in uncached_uris:
|
|
1030
|
+
local_path = cache.cache_dir / cache._cache_key(uri)
|
|
1031
|
+
self._video_paths[uri] = str(local_path)
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
size_mb = total_bytes / (1024 * 1024)
|
|
1035
|
+
|
|
1036
|
+
# --- Confirm s3 dowload and allow cache relocation ---
|
|
1037
|
+
msg = QMessageBox(self)
|
|
1038
|
+
msg.setIcon(QMessageBox.Icon.Question)
|
|
1039
|
+
msg.setWindowTitle("Download S3 Videos")
|
|
1040
|
+
msg.setText(
|
|
1041
|
+
f"This session requires downloading {len(need_download)} "
|
|
1042
|
+
f"video(s) (~{size_mb:.1f} MB) from S3.\n\n"
|
|
1043
|
+
f"Cache location: {cache.cache_dir}\n\n"
|
|
1044
|
+
f"Continue?"
|
|
1045
|
+
)
|
|
1046
|
+
download_btn = msg.addButton(
|
|
1047
|
+
"Download", QMessageBox.ButtonRole.AcceptRole
|
|
1048
|
+
)
|
|
1049
|
+
change_btn = msg.addButton(
|
|
1050
|
+
"Change Location…", QMessageBox.ButtonRole.ActionRole
|
|
1051
|
+
)
|
|
1052
|
+
cancel_btn = msg.addButton(
|
|
1053
|
+
"Cancel", QMessageBox.ButtonRole.RejectRole
|
|
1054
|
+
)
|
|
1055
|
+
msg.setDefaultButton(download_btn)
|
|
1056
|
+
msg.exec()
|
|
1057
|
+
|
|
1058
|
+
clicked = msg.clickedButton()
|
|
1059
|
+
|
|
1060
|
+
if clicked == change_btn:
|
|
1061
|
+
new_dir = QFileDialog.getExistingDirectory(
|
|
1062
|
+
self,
|
|
1063
|
+
"Choose Cache Location",
|
|
1064
|
+
str(cache.cache_dir),
|
|
1065
|
+
)
|
|
1066
|
+
if not new_dir:
|
|
1067
|
+
return # User cancelled the directory picker
|
|
1068
|
+
|
|
1069
|
+
# Persist the new location and rebuild the cache object
|
|
1070
|
+
self._set_cache_dir(Path(new_dir))
|
|
1071
|
+
self._cache_dir = new_dir
|
|
1072
|
+
cache = S3VideoCache(cache_dir=Path(new_dir))
|
|
1073
|
+
|
|
1074
|
+
# Re-check what's already cached in the new location
|
|
1075
|
+
need_download = []
|
|
1076
|
+
total_bytes = 0
|
|
1077
|
+
for uri in uncached_uris:
|
|
1078
|
+
cached_path = cache.get_cached_path(uri)
|
|
1079
|
+
if cached_path is not None and Path(cached_path).exists():
|
|
1080
|
+
self._video_paths[uri] = str(cached_path)
|
|
1081
|
+
else:
|
|
1082
|
+
need_download.append(uri)
|
|
1083
|
+
|
|
1084
|
+
if not need_download:
|
|
1085
|
+
self._show_status("All S3 videos loaded from cache.")
|
|
1086
|
+
if on_complete is not None:
|
|
1087
|
+
on_complete()
|
|
1088
|
+
return
|
|
1089
|
+
|
|
1090
|
+
# Recalculate size for remaining downloads
|
|
1091
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
1092
|
+
try:
|
|
1093
|
+
total_bytes, need_download = cache.calculate_download_size(
|
|
1094
|
+
need_download, profile_name=self._aws_profile
|
|
1095
|
+
)
|
|
1096
|
+
except RuntimeError as e:
|
|
1097
|
+
QMessageBox.warning(
|
|
1098
|
+
self, "S3 Access Error",
|
|
1099
|
+
f"Cannot access S3 videos:\n\n{e}",
|
|
1100
|
+
)
|
|
1101
|
+
return
|
|
1102
|
+
finally:
|
|
1103
|
+
QApplication.restoreOverrideCursor()
|
|
1104
|
+
|
|
1105
|
+
elif clicked == cancel_btn or clicked is None:
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
# If nothing left to download after cache relocation check
|
|
1109
|
+
if not need_download:
|
|
1110
|
+
self._show_status("All S3 videos loaded from cache.")
|
|
1111
|
+
if on_complete is not None:
|
|
1112
|
+
on_complete()
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
# Add already cached URIs
|
|
1116
|
+
for uri in uncached_uris:
|
|
1117
|
+
if uri not in need_download and cache.is_cached(uri):
|
|
1118
|
+
local_path = cache.cache_dir / cache._cache_key(uri)
|
|
1119
|
+
self._video_paths[uri] = str(local_path)
|
|
1120
|
+
|
|
1121
|
+
# Dowload remaining in background
|
|
1122
|
+
total_files = len(need_download)
|
|
1123
|
+
progress_dialog = QProgressDialog(
|
|
1124
|
+
f"Downloading video 0/{total_files}...",
|
|
1125
|
+
"Cancel", 0, total_files, self,
|
|
1126
|
+
)
|
|
1127
|
+
progress_dialog.setWindowTitle("S3 Download")
|
|
1128
|
+
progress_dialog.setMinimumDuration(0)
|
|
1129
|
+
progress_dialog.setAutoClose(False)
|
|
1130
|
+
progress_dialog.setAutoReset(False)
|
|
1131
|
+
|
|
1132
|
+
# Store dialog box so it can be accessed by @Slot methods
|
|
1133
|
+
self._s3_progress_dialog = progress_dialog
|
|
1134
|
+
self._s3_on_complete = on_complete
|
|
1135
|
+
|
|
1136
|
+
self._s3_thread = QThread()
|
|
1137
|
+
self._s3_worker = _S3DownloadWorker(cache)
|
|
1138
|
+
self._s3_worker.moveToThread(self._s3_thread)
|
|
1139
|
+
|
|
1140
|
+
# Connect to @Slot methods on self (a QObject on the main thread).
|
|
1141
|
+
# Qt auto-detects the thread boundary and uses QueuedConnection.
|
|
1142
|
+
self._s3_worker.progress.connect(self._on_s3_progress)
|
|
1143
|
+
self._s3_worker.file_complete.connect(self._on_s3_file_complete)
|
|
1144
|
+
self._s3_worker.error.connect(self._on_s3_error)
|
|
1145
|
+
self._s3_worker.finished.connect(self._on_s3_finished)
|
|
1146
|
+
|
|
1147
|
+
progress_dialog.canceled.connect(self._s3_worker.cancel)
|
|
1148
|
+
|
|
1149
|
+
self._s3_thread.started.connect(
|
|
1150
|
+
lambda: self._s3_worker.start_download.emit(
|
|
1151
|
+
need_download, profile_name or ""
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
self._s3_thread.start()
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _ask_aws_profile(self) -> str | None:
|
|
1158
|
+
"""
|
|
1159
|
+
Ask the user which AWS profile to use for S3 access.
|
|
1160
|
+
|
|
1161
|
+
Discovers profiles from ~/.aws/config and presents them in a dropdown.
|
|
1162
|
+
Returns the selected profile name, or None if cancelled.
|
|
1163
|
+
"""
|
|
1164
|
+
# Discover configured profiles
|
|
1165
|
+
profiles = self._get_aws_profiles()
|
|
1166
|
+
|
|
1167
|
+
if not profiles:
|
|
1168
|
+
QMessageBox.warning(
|
|
1169
|
+
self,
|
|
1170
|
+
"No AWS Profiles Found",
|
|
1171
|
+
"No AWS profiles are configured on this machine.\n\n"
|
|
1172
|
+
"To set up a profile, run:\n"
|
|
1173
|
+
" aws configure sso\n\n"
|
|
1174
|
+
"See the AWS setup documentation for details.",
|
|
1175
|
+
)
|
|
1176
|
+
return None
|
|
1177
|
+
|
|
1178
|
+
# Let the user pick which profile to use
|
|
1179
|
+
# TODO: Add an 'add profile' button which lets the users interactively
|
|
1180
|
+
# set up an AWS profile.
|
|
1181
|
+
profile, ok = QInputDialog.getItem(
|
|
1182
|
+
self,
|
|
1183
|
+
"AWS Profile",
|
|
1184
|
+
"Select the AWS profile to use for S3 access:",
|
|
1185
|
+
profiles,
|
|
1186
|
+
current=0,
|
|
1187
|
+
editable=False,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
if not ok:
|
|
1191
|
+
return None
|
|
1192
|
+
return profile
|
|
1193
|
+
|
|
1194
|
+
@staticmethod
|
|
1195
|
+
def _get_aws_profiles() -> list[str]:
|
|
1196
|
+
"""
|
|
1197
|
+
Read availble AWS profile names from ~/.aws/config.
|
|
1198
|
+
|
|
1199
|
+
Returns a list of profile names. The default profile (if it exists) is
|
|
1200
|
+
listed first.
|
|
1201
|
+
"""
|
|
1202
|
+
# TODO: It might be useful to have functionality here for the user to
|
|
1203
|
+
# adjust the search path depending on their machine's setup.
|
|
1204
|
+
config_path = Path.home() / ".aws" / "config"
|
|
1205
|
+
if not config_path.exists():
|
|
1206
|
+
return []
|
|
1207
|
+
|
|
1208
|
+
import configparser
|
|
1209
|
+
|
|
1210
|
+
config = configparser.ConfigParser()
|
|
1211
|
+
config.read(str(config_path))
|
|
1212
|
+
|
|
1213
|
+
profiles = []
|
|
1214
|
+
for section in config.sections():
|
|
1215
|
+
# AWS config sections are named [profile foo] or [default]
|
|
1216
|
+
if section == "default":
|
|
1217
|
+
profiles.insert(0, "default")
|
|
1218
|
+
elif section.startswith("profile "):
|
|
1219
|
+
profiles.append(section[len("profile "):])
|
|
1220
|
+
|
|
1221
|
+
return profiles
|
|
1222
|
+
|
|
1223
|
+
@staticmethod
|
|
1224
|
+
def _get_cache_dir() -> Path:
|
|
1225
|
+
"""
|
|
1226
|
+
Read the user's preferred cache directory from QSettings,
|
|
1227
|
+
falling back to the default ~/.seavision/cache/.
|
|
1228
|
+
"""
|
|
1229
|
+
settings = QSettings("SeaVision", "SeaVision")
|
|
1230
|
+
saved = settings.value("s3CacheDir")
|
|
1231
|
+
if saved and Path(saved).is_dir():
|
|
1232
|
+
return Path(saved)
|
|
1233
|
+
return Path.home() / ".seavision" / "cache"
|
|
1234
|
+
|
|
1235
|
+
@staticmethod
|
|
1236
|
+
def _set_cache_dir(path: Path) -> None:
|
|
1237
|
+
"""Persist the user's preferred cache directory to QSettings."""
|
|
1238
|
+
settings = QSettings("SeaVision", "SeaVision")
|
|
1239
|
+
settings.setValue("s3CacheDir", str(path))
|
|
1240
|
+
|
|
1241
|
+
def _refresh_video_list_availability(self) -> None:
|
|
1242
|
+
"""Re-populate the video list with updated availability."""
|
|
1243
|
+
if self._validation_model is None:
|
|
1244
|
+
return
|
|
1245
|
+
|
|
1246
|
+
video_info = []
|
|
1247
|
+
for source_file in self._validation_model.get_all_source_files():
|
|
1248
|
+
progress = self._validation_model.get_progress(source_file)
|
|
1249
|
+
video_info.append({
|
|
1250
|
+
"source_file": source_file,
|
|
1251
|
+
"total": progress["total"],
|
|
1252
|
+
"reviewed": progress["reviewed"],
|
|
1253
|
+
"available": source_file in self._video_paths,
|
|
1254
|
+
})
|
|
1255
|
+
self._video_list.set_videos(video_info)
|
|
1256
|
+
|
|
1257
|
+
# --- Video preload methods ---
|
|
1258
|
+
def _confirm_preload(self, filepath: str, metadata: VideoMetadata) -> bool:
|
|
1259
|
+
"""
|
|
1260
|
+
Ask the user whether to pre-load a video that requires it for accurate
|
|
1261
|
+
seeking. Returns True for pre-load, False for fallback.
|
|
1262
|
+
"""
|
|
1263
|
+
estimated_mb = _estimate_memory_mb(
|
|
1264
|
+
metadata.width, metadata.height, metadata.frame_count
|
|
1265
|
+
)
|
|
1266
|
+
available_mb = _available_memory_mb()
|
|
1267
|
+
|
|
1268
|
+
if available_mb is not None:
|
|
1269
|
+
memory_line = (
|
|
1270
|
+
f"Estimated memory usage: {estimated_mb:.1f} MB\n"
|
|
1271
|
+
f"Available RAM: {available_mb:.1f} MB"
|
|
1272
|
+
)
|
|
1273
|
+
else:
|
|
1274
|
+
memory_line = (
|
|
1275
|
+
f"Estimated memory usage: {estimated_mb:.1f} MB\n"
|
|
1276
|
+
f"Available RAM: NA"
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
msg = QMessageBox(self)
|
|
1280
|
+
msg.setIcon(QMessageBox.Icon.Information)
|
|
1281
|
+
msg.setWindowTitle("Video Format Notice")
|
|
1282
|
+
msg.setText(
|
|
1283
|
+
f"This video format ({Path(filepath).suffix}) does not support "
|
|
1284
|
+
f"frame-accurate seeking.\n\n"
|
|
1285
|
+
f"For accurate detection overlay alignment, the full video can be "
|
|
1286
|
+
f"loaded into memory. This is recomended for validation work.\n\n"
|
|
1287
|
+
f"{memory_line}\n"
|
|
1288
|
+
f"Alternatively, you can use standard seeking, but detection "
|
|
1289
|
+
f"overlays may not align accurately with the video frames."
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
preload_btn = msg.addButton(
|
|
1293
|
+
"Load into Memory (Recomended)", QMessageBox.ButtonRole.AcceptRole
|
|
1294
|
+
)
|
|
1295
|
+
_fallback_btn = msg.addButton(
|
|
1296
|
+
"Use Standard Seeking", QMessageBox.ButtonRole.RejectRole
|
|
1297
|
+
)
|
|
1298
|
+
msg.setDefaultButton(preload_btn)
|
|
1299
|
+
|
|
1300
|
+
msg.exec()
|
|
1301
|
+
return msg.clickedButton() == preload_btn
|
|
1302
|
+
|
|
1303
|
+
def _should_preload(self, filepath: str) -> bool:
|
|
1304
|
+
"""Determine whether to pre-load based on format and user choice."""
|
|
1305
|
+
if not _needs_preload(filepath):
|
|
1306
|
+
return False
|
|
1307
|
+
|
|
1308
|
+
# Read metadata to calculate memory
|
|
1309
|
+
metadata = SeekableVideoSource._read_metadata(filepath)
|
|
1310
|
+
return self._confirm_preload(filepath, metadata)
|
|
1311
|
+
|
|
1312
|
+
def _update_scene_detections(self) -> None:
|
|
1313
|
+
"""
|
|
1314
|
+
Synchronise the scene's detection overlays with the current frame.
|
|
1315
|
+
|
|
1316
|
+
Reads detections for the current frame from the ValidationModel, maps
|
|
1317
|
+
their status to colours, and passes them to the scene for rendering as
|
|
1318
|
+
iteractive rect items.
|
|
1319
|
+
"""
|
|
1320
|
+
if (
|
|
1321
|
+
self._validation_model is None
|
|
1322
|
+
or self._active_source_file is None
|
|
1323
|
+
or not self._overlays_visible
|
|
1324
|
+
):
|
|
1325
|
+
self._viewer.scene.set_detections([])
|
|
1326
|
+
return
|
|
1327
|
+
|
|
1328
|
+
validated_dets = self._validation_model.get_detections_for_frame(
|
|
1329
|
+
self._active_source_file, self._current_frame
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
# Status -> BGR colour mapping
|
|
1333
|
+
status_colours = {
|
|
1334
|
+
ValidationStatus.PENDING: (0, 0, 255), # Red
|
|
1335
|
+
ValidationStatus.CONFIRMED: (0, 200, 0), # Green
|
|
1336
|
+
ValidationStatus.CORRECTED: (0, 200, 0), # Green
|
|
1337
|
+
ValidationStatus.REJECTED: (128, 128, 128), # Grey
|
|
1338
|
+
ValidationStatus.SKIPPED: (128, 128, 128), # Grey
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
det_infos = []
|
|
1342
|
+
for vd in validated_dets:
|
|
1343
|
+
# Skip rejected/skipped unless toggled on
|
|
1344
|
+
if vd.status in (
|
|
1345
|
+
ValidationStatus.REJECTED,
|
|
1346
|
+
ValidationStatus.SKIPPED
|
|
1347
|
+
):
|
|
1348
|
+
if (
|
|
1349
|
+
self._active_detection_adapter is not None
|
|
1350
|
+
and not self._active_detection_adapter.show_rejected
|
|
1351
|
+
):
|
|
1352
|
+
continue
|
|
1353
|
+
|
|
1354
|
+
det = vd.detection
|
|
1355
|
+
|
|
1356
|
+
# Use corrected geometry if available
|
|
1357
|
+
if vd.corrected_geometry is not None:
|
|
1358
|
+
geom = vd.corrected_geometry
|
|
1359
|
+
xc = geom["xc"]
|
|
1360
|
+
yc = geom["yc"]
|
|
1361
|
+
w = geom["width"]
|
|
1362
|
+
h = geom["height"]
|
|
1363
|
+
else:
|
|
1364
|
+
xc = det.xc
|
|
1365
|
+
yc = det.yc
|
|
1366
|
+
w = det.width
|
|
1367
|
+
h = det.height
|
|
1368
|
+
|
|
1369
|
+
det_infos.append({
|
|
1370
|
+
"detection_id": vd.id,
|
|
1371
|
+
"x1": xc - w / 2,
|
|
1372
|
+
"y1": yc - h / 2,
|
|
1373
|
+
"width": w,
|
|
1374
|
+
"height": h,
|
|
1375
|
+
"colour": status_colours.get(
|
|
1376
|
+
vd.status, (0, 0, 255)
|
|
1377
|
+
),
|
|
1378
|
+
"editable": True,
|
|
1379
|
+
})
|
|
1380
|
+
|
|
1381
|
+
self._viewer.scene.set_detections(det_infos)
|
|
1382
|
+
|
|
1383
|
+
# Restore highlight if a detection is selected
|
|
1384
|
+
if self._selected_detection is not None:
|
|
1385
|
+
self._viewer.scene.highlight_detection(
|
|
1386
|
+
self._selected_detection.id
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
def _on_detection_selected(
|
|
1390
|
+
self, row: int, frame_number: int,
|
|
1391
|
+
) -> None:
|
|
1392
|
+
"""
|
|
1393
|
+
Handle a detection being selected in the table.
|
|
1394
|
+
|
|
1395
|
+
Seeks the video to the detection's frame, updates the detail panel, and
|
|
1396
|
+
requests a frame render with the selected detection highlighted.
|
|
1397
|
+
"""
|
|
1398
|
+
vd = self._detection_model.detection_at(row)
|
|
1399
|
+
if vd is None:
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
self._selected_detection = vd
|
|
1403
|
+
|
|
1404
|
+
# Tell the table model which row to bold
|
|
1405
|
+
self._detection_model.set_selected_id(vd.id)
|
|
1406
|
+
|
|
1407
|
+
# Update current class
|
|
1408
|
+
self._selected_label = vd.detection.label
|
|
1409
|
+
|
|
1410
|
+
# Update detection detail panel
|
|
1411
|
+
fps = self._metadata.fps if self._metadata else None
|
|
1412
|
+
self._detail_panel.set_detection(vd, fps=fps)
|
|
1413
|
+
|
|
1414
|
+
# Enable action buttons
|
|
1415
|
+
self._confirm_btn.setEnabled(True)
|
|
1416
|
+
self._reject_btn.setEnabled(True)
|
|
1417
|
+
self._skip_btn.setEnabled(True)
|
|
1418
|
+
self._remove_btn.setEnabled(
|
|
1419
|
+
vd.is_manual if vd else False
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
if self._is_playing:
|
|
1423
|
+
self._on_play_pause()
|
|
1424
|
+
|
|
1425
|
+
self._seek_to_frame_with_highlight(frame_number, vd)
|
|
1426
|
+
|
|
1427
|
+
def _seek_to_frame_with_highlight(
|
|
1428
|
+
self, frame_number: int, vd: ValidatedDetection | None
|
|
1429
|
+
) -> None:
|
|
1430
|
+
"""
|
|
1431
|
+
Seek to a frame and highlight a specific detection. Uses the scene
|
|
1432
|
+
viewer by default and falls back to on-frame detection rendering.
|
|
1433
|
+
"""
|
|
1434
|
+
if vd is None:
|
|
1435
|
+
self._viewer.scene.highlight_detection(None)
|
|
1436
|
+
self._set_highlight.emit(None)
|
|
1437
|
+
else:
|
|
1438
|
+
self._viewer.scene.highlight_detection(vd.id)
|
|
1439
|
+
self._set_highlight.emit(vd.detection)
|
|
1440
|
+
|
|
1441
|
+
self._request_frame.emit(frame_number)
|
|
1442
|
+
|
|
1443
|
+
def _on_frame_received(self, image, frame_number, timestamp) -> None:
|
|
1444
|
+
"""Worker has decoded a frame — update our bookkeeping."""
|
|
1445
|
+
self._current_frame = frame_number
|
|
1446
|
+
self._transport.update_position(frame_number, timestamp)
|
|
1447
|
+
self._update_scene_detections()
|
|
1448
|
+
|
|
1449
|
+
def _on_play_pause(self) -> None:
|
|
1450
|
+
"""User clicked play or pause."""
|
|
1451
|
+
if self._metadata is None:
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
if self._is_playing:
|
|
1455
|
+
self._request_stop.emit()
|
|
1456
|
+
self._is_playing = False
|
|
1457
|
+
else:
|
|
1458
|
+
self._request_play.emit(self._current_frame)
|
|
1459
|
+
self._is_playing = True
|
|
1460
|
+
|
|
1461
|
+
self._transport.set_playing(self._is_playing)
|
|
1462
|
+
|
|
1463
|
+
def _on_next_frame(self) -> None:
|
|
1464
|
+
"""Step forward one frame."""
|
|
1465
|
+
if self._metadata is None:
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
next_frame = min(self._current_frame + 1,
|
|
1469
|
+
self._metadata.frame_count - 1)
|
|
1470
|
+
self._request_frame.emit(next_frame)
|
|
1471
|
+
|
|
1472
|
+
def _on_prev_frame(self) -> None:
|
|
1473
|
+
"""Step backward one frame."""
|
|
1474
|
+
prev_frame = max(self._current_frame - 1, 0)
|
|
1475
|
+
self._request_frame.emit(prev_frame)
|
|
1476
|
+
|
|
1477
|
+
def _on_next_detection(self) -> None:
|
|
1478
|
+
"""
|
|
1479
|
+
Navigate to the next detection in the table
|
|
1480
|
+
|
|
1481
|
+
Steps through detection sequentially - all detections on the
|
|
1482
|
+
current frame before moving on to the next frame.
|
|
1483
|
+
"""
|
|
1484
|
+
if self._detection_model.detection_count() == 0:
|
|
1485
|
+
return
|
|
1486
|
+
|
|
1487
|
+
# If nothing is seletected, start with the first detection
|
|
1488
|
+
if self._selected_detection is None:
|
|
1489
|
+
self._detection_table.select_row(0)
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
# Find the current row by matching the selected detection's ID
|
|
1493
|
+
current_row = self._find_row_for_detection(self._selected_detection)
|
|
1494
|
+
if current_row is None:
|
|
1495
|
+
self._detection_table.select_row(0)
|
|
1496
|
+
return
|
|
1497
|
+
|
|
1498
|
+
next_row = current_row + 1
|
|
1499
|
+
if next_row >= self._detection_model.detection_count():
|
|
1500
|
+
return
|
|
1501
|
+
|
|
1502
|
+
self._detection_table.select_row(next_row)
|
|
1503
|
+
|
|
1504
|
+
def _on_prev_detection(self) -> None:
|
|
1505
|
+
"""
|
|
1506
|
+
Navigate to the previous detection in the table.
|
|
1507
|
+
"""
|
|
1508
|
+
if self._detection_model.detection_count() == 0:
|
|
1509
|
+
return
|
|
1510
|
+
if self._selected_detection is None:
|
|
1511
|
+
return
|
|
1512
|
+
|
|
1513
|
+
current_row = self._find_row_for_detection(self._selected_detection)
|
|
1514
|
+
if current_row is None:
|
|
1515
|
+
return
|
|
1516
|
+
|
|
1517
|
+
prev_row = current_row - 1
|
|
1518
|
+
if prev_row < 0:
|
|
1519
|
+
return
|
|
1520
|
+
|
|
1521
|
+
self._detection_table.select_row(prev_row)
|
|
1522
|
+
|
|
1523
|
+
def _find_row_for_detection(
|
|
1524
|
+
self, vd: ValidatedDetection
|
|
1525
|
+
) -> int | None:
|
|
1526
|
+
"""Find the source-model row index for a ValidatedDetection."""
|
|
1527
|
+
for row in range(self._detection_model.detection_count()):
|
|
1528
|
+
candidate = self._detection_model.detection_at(row)
|
|
1529
|
+
if candidate is not None and candidate.id == vd.id:
|
|
1530
|
+
return row
|
|
1531
|
+
|
|
1532
|
+
return None
|
|
1533
|
+
|
|
1534
|
+
def _on_seek(self, frame_number: int) -> None:
|
|
1535
|
+
"""
|
|
1536
|
+
User is dragging the slider — update the display.
|
|
1537
|
+
|
|
1538
|
+
For pre-loaded videos, send the frame request immediately
|
|
1539
|
+
(array lookup is O(1)). For non-preloaded videos, only update the
|
|
1540
|
+
transport bar labels - the actual seek happens on release via
|
|
1541
|
+
_on_seek_committed.
|
|
1542
|
+
"""
|
|
1543
|
+
if self._is_playing:
|
|
1544
|
+
self._request_stop.emit()
|
|
1545
|
+
self._is_playing = False
|
|
1546
|
+
self._transport.set_playing(False)
|
|
1547
|
+
|
|
1548
|
+
if self._video_is_preloaded:
|
|
1549
|
+
# Instant response for pre-loaded videos
|
|
1550
|
+
self._request_frame.emit(frame_number)
|
|
1551
|
+
else:
|
|
1552
|
+
# For non-preloaded: just update the position labels.
|
|
1553
|
+
# The actual frame seek happens on slider release
|
|
1554
|
+
if self._metadata and self._metadata.fps > 0:
|
|
1555
|
+
timestamp = frame_number / self._metadata.fps
|
|
1556
|
+
else:
|
|
1557
|
+
timestamp = 0.0
|
|
1558
|
+
self._transport.update_position(frame_number, timestamp)
|
|
1559
|
+
|
|
1560
|
+
def _on_seek_committed(self, frame_number: int) -> None:
|
|
1561
|
+
"""User released the slider - jump directly to the final frame."""
|
|
1562
|
+
# Cancel any pending debounced seeks
|
|
1563
|
+
self._seek_timer.stop()
|
|
1564
|
+
self._pending_seek = None
|
|
1565
|
+
|
|
1566
|
+
if self._is_playing:
|
|
1567
|
+
self._request_stop.emit()
|
|
1568
|
+
self._is_playing = False
|
|
1569
|
+
self._transport.set_playing(False)
|
|
1570
|
+
|
|
1571
|
+
# Set the skip flag directly on the worker — bypasses the signal
|
|
1572
|
+
# queue so it takes effect before queued seeks are processed.
|
|
1573
|
+
# Safe under Python's GIL for simple attribute assignment.
|
|
1574
|
+
self._worker._skip_pending_seeks = True
|
|
1575
|
+
|
|
1576
|
+
# This goes to the end of the queue, but uses the immediate
|
|
1577
|
+
# slot which ignores the skip flag and resets it.
|
|
1578
|
+
self._request_frame_immediate.emit(frame_number)
|
|
1579
|
+
|
|
1580
|
+
def _do_seek(self) -> None:
|
|
1581
|
+
"""Process the most recent seek request."""
|
|
1582
|
+
if self._pending_seek is not None:
|
|
1583
|
+
self._request_frame.emit(self._pending_seek)
|
|
1584
|
+
self._pending_seek = None
|
|
1585
|
+
|
|
1586
|
+
def _on_playback_finished(self) -> None:
|
|
1587
|
+
"""Video reached the end."""
|
|
1588
|
+
self._is_playing = False
|
|
1589
|
+
self._transport.set_playing(False)
|
|
1590
|
+
|
|
1591
|
+
# --- Detection status view update ---
|
|
1592
|
+
def set_overlays_visible(self, visible: bool) -> None:
|
|
1593
|
+
"""Toggle all detection overlays on or off."""
|
|
1594
|
+
self._overlays_visible = visible
|
|
1595
|
+
|
|
1596
|
+
if self._active_detection_adapter is not None:
|
|
1597
|
+
self._active_detection_adapter.show_all = visible
|
|
1598
|
+
|
|
1599
|
+
# Update scene-based overlays (interactive mode)
|
|
1600
|
+
if not visible:
|
|
1601
|
+
self._viewer.scene.set_detections([])
|
|
1602
|
+
else:
|
|
1603
|
+
self._update_scene_detections()
|
|
1604
|
+
|
|
1605
|
+
# Re-render for worker-annotated mode (plain video)
|
|
1606
|
+
if self._metadata is not None and not self._is_playing:
|
|
1607
|
+
self._request_frame.emit(self._current_frame)
|
|
1608
|
+
|
|
1609
|
+
def set_rejected_visible(self, visible: bool) -> None:
|
|
1610
|
+
"""Toggle visibility of rejected and skipped detections."""
|
|
1611
|
+
if self._active_detection_adapter is not None:
|
|
1612
|
+
self._active_detection_adapter.show_rejected = visible
|
|
1613
|
+
|
|
1614
|
+
# Refresh scene overlays (the filter logic in
|
|
1615
|
+
# _update_scene_detections reads show_rejected from the adapter)
|
|
1616
|
+
self._update_scene_detections()
|
|
1617
|
+
|
|
1618
|
+
# Re-render for worker-annotated mode (plain video)
|
|
1619
|
+
if self._metadata is not None and not self._is_playing:
|
|
1620
|
+
self._request_frame.emit(self._current_frame)
|
|
1621
|
+
|
|
1622
|
+
# --- Detection modification ---
|
|
1623
|
+
def _pick_label(self, current_label: str | None = None) -> str | None:
|
|
1624
|
+
"""
|
|
1625
|
+
Show a label picker dialog and return the chosen label.
|
|
1626
|
+
|
|
1627
|
+
Handles three scenarios:
|
|
1628
|
+
- If labels exist in the model: shows a dropdown with existing
|
|
1629
|
+
labels and an editable text field for new ones.
|
|
1630
|
+
- If no labels exist (e.g. plain video): shows a text input
|
|
1631
|
+
with greyed-out italic placeholder hint text.
|
|
1632
|
+
- If the label set gains a new entry, it's added to the model
|
|
1633
|
+
and the class filter is repopulated.
|
|
1634
|
+
|
|
1635
|
+
Args:
|
|
1636
|
+
current_label: Pre-select this label in the dropdown, or
|
|
1637
|
+
use as default text. None starts with first item / empty.
|
|
1638
|
+
|
|
1639
|
+
Returns:
|
|
1640
|
+
The chosen label string, or None if the user cancelled.
|
|
1641
|
+
"""
|
|
1642
|
+
labels = sorted(self._validation_model.labels)
|
|
1643
|
+
|
|
1644
|
+
if labels:
|
|
1645
|
+
# --- Dropdown with editable text ---
|
|
1646
|
+
current_index = 0
|
|
1647
|
+
if current_label and current_label in labels:
|
|
1648
|
+
current_index = labels.index(current_label)
|
|
1649
|
+
|
|
1650
|
+
label, ok = QInputDialog.getItem(
|
|
1651
|
+
self,
|
|
1652
|
+
"Detection Label",
|
|
1653
|
+
"Select a class for this detection:",
|
|
1654
|
+
labels,
|
|
1655
|
+
current=current_index,
|
|
1656
|
+
editable=True,
|
|
1657
|
+
)
|
|
1658
|
+
if not ok or not label:
|
|
1659
|
+
return None
|
|
1660
|
+
else:
|
|
1661
|
+
# --- No existing labels: show text input with placeholder ---
|
|
1662
|
+
# QInputDialog.getText doesn't support placeholder text via
|
|
1663
|
+
# its static method, so we create the dialog manually.
|
|
1664
|
+
dialog = QInputDialog(self)
|
|
1665
|
+
dialog.setWindowTitle("Detection Label")
|
|
1666
|
+
dialog.setLabelText("Enter a class label for this detection:")
|
|
1667
|
+
dialog.setInputMode(QInputDialog.InputMode.TextInput)
|
|
1668
|
+
dialog.setTextValue("")
|
|
1669
|
+
|
|
1670
|
+
# Access the internal QLineEdit to set placeholder text
|
|
1671
|
+
line_edit = dialog.findChild(QLineEdit)
|
|
1672
|
+
if line_edit is not None:
|
|
1673
|
+
line_edit.setPlaceholderText("Enter class label")
|
|
1674
|
+
# Style the placeholder — Qt uses palette for placeholder
|
|
1675
|
+
# text colour, but setStyleSheet is more reliable
|
|
1676
|
+
line_edit.setStyleSheet(
|
|
1677
|
+
"QLineEdit[text=''] { font-style: italic; }"
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
1681
|
+
return None
|
|
1682
|
+
|
|
1683
|
+
label = dialog.textValue().strip()
|
|
1684
|
+
if not label:
|
|
1685
|
+
return None
|
|
1686
|
+
|
|
1687
|
+
# Add new label to the model if needed
|
|
1688
|
+
if label not in self._validation_model.labels:
|
|
1689
|
+
self._validation_model.labels.add(label)
|
|
1690
|
+
self._populate_class_filter()
|
|
1691
|
+
|
|
1692
|
+
return label
|
|
1693
|
+
|
|
1694
|
+
def _on_detection_geometry_changed(
|
|
1695
|
+
self,
|
|
1696
|
+
detection_id: int,
|
|
1697
|
+
xc: float,
|
|
1698
|
+
yc: float,
|
|
1699
|
+
width: float,
|
|
1700
|
+
height: float,
|
|
1701
|
+
) -> None:
|
|
1702
|
+
"""
|
|
1703
|
+
Handle a detection box being dragged or resized in the scene.
|
|
1704
|
+
|
|
1705
|
+
Writes the new geometry to the ValidationModel. The model automatically
|
|
1706
|
+
sets the status to CORRECTED and emits the appropiate signals, which
|
|
1707
|
+
update the table, status bar, and video list.
|
|
1708
|
+
"""
|
|
1709
|
+
if self._validation_model is None:
|
|
1710
|
+
return
|
|
1711
|
+
|
|
1712
|
+
self._validation_model.set_corrected_geometry(
|
|
1713
|
+
detection_id, xc, yc, width, height
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
# Update the detail panel if this is the selected detection
|
|
1717
|
+
if (
|
|
1718
|
+
self._selected_detection is not None
|
|
1719
|
+
and self._selected_detection.id == detection_id
|
|
1720
|
+
):
|
|
1721
|
+
fps = self._metadata.fps if self._metadata else None
|
|
1722
|
+
self._detail_panel.set_detection(
|
|
1723
|
+
self._selected_detection, fps=fps
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
logger.debug(
|
|
1727
|
+
"Detection %d geometry corrected: "
|
|
1728
|
+
"(%.1f, %.1f, %.1f, %.1f)",
|
|
1729
|
+
detection_id, xc, yc, width, height,
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
def _on_detection_drawn(
|
|
1733
|
+
self,
|
|
1734
|
+
xc: float,
|
|
1735
|
+
yc: float,
|
|
1736
|
+
width: float,
|
|
1737
|
+
height: float,
|
|
1738
|
+
) -> None:
|
|
1739
|
+
"""
|
|
1740
|
+
Handle a new bounding box drawn by the user in draw-to-create mode.
|
|
1741
|
+
"""
|
|
1742
|
+
if not self._ensure_validation_model():
|
|
1743
|
+
return
|
|
1744
|
+
|
|
1745
|
+
# --- Determine class label ---
|
|
1746
|
+
if self._add_mode_auto_label is not None:
|
|
1747
|
+
label = self._add_mode_auto_label
|
|
1748
|
+
self._add_mode_auto_label = None
|
|
1749
|
+
else:
|
|
1750
|
+
label = self._pick_label()
|
|
1751
|
+
if label is None:
|
|
1752
|
+
self._add_btn.setChecked(False)
|
|
1753
|
+
return
|
|
1754
|
+
|
|
1755
|
+
# --- Add the detection to the model ---
|
|
1756
|
+
self._validation_model.add_detection(
|
|
1757
|
+
source_file=self._active_source_file,
|
|
1758
|
+
frame_number=self._current_frame,
|
|
1759
|
+
xc=xc,
|
|
1760
|
+
yc=yc,
|
|
1761
|
+
width=width,
|
|
1762
|
+
height=height,
|
|
1763
|
+
label=label,
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1766
|
+
# Uncheck the add mode button
|
|
1767
|
+
self._add_btn.setChecked(False)
|
|
1768
|
+
|
|
1769
|
+
def _undo_correction(self) -> None:
|
|
1770
|
+
"""
|
|
1771
|
+
Undo the geomemtry correction on the currently selected detection.
|
|
1772
|
+
"""
|
|
1773
|
+
if self._validation_model is None:
|
|
1774
|
+
return
|
|
1775
|
+
if self._selected_detection is None:
|
|
1776
|
+
return
|
|
1777
|
+
|
|
1778
|
+
reverted = self._validation_model.undo_correction(
|
|
1779
|
+
self._selected_detection.id
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
if reverted:
|
|
1783
|
+
# Refresh the detail panel with the original geometry
|
|
1784
|
+
fps = self._metadata.fps if self._metadata else None
|
|
1785
|
+
self._detail_panel.set_detection(
|
|
1786
|
+
self._selected_detection, fps=fps
|
|
1787
|
+
)
|
|
1788
|
+
self._update_scene_detections()
|
|
1789
|
+
self._show_status(
|
|
1790
|
+
"Correction undone — reverted to original geometry"
|
|
1791
|
+
)
|
|
1792
|
+
else:
|
|
1793
|
+
self._show_status("No correction to undo on this detection")
|
|
1794
|
+
|
|
1795
|
+
def _on_scene_detection_selected(self, detection_id: int) -> None:
|
|
1796
|
+
"""
|
|
1797
|
+
Handle a detection being clicked in the scene.
|
|
1798
|
+
|
|
1799
|
+
Finds the corresponding row in the detection table and selects it, which
|
|
1800
|
+
triggers the existing selection -> detail panel -> highlight logic.
|
|
1801
|
+
"""
|
|
1802
|
+
row = self._find_row_for_detection_id(detection_id)
|
|
1803
|
+
if row is not None:
|
|
1804
|
+
self._detection_table.select_row(row)
|
|
1805
|
+
|
|
1806
|
+
def _find_row_for_detection_id(self, detection_id: int) -> int | None:
|
|
1807
|
+
"""Find the source model row for a given detection ID."""
|
|
1808
|
+
for row in range(len(self._detection_model._detections)):
|
|
1809
|
+
if self._detection_model._detections[row].id == detection_id:
|
|
1810
|
+
return row
|
|
1811
|
+
return None
|
|
1812
|
+
|
|
1813
|
+
def _change_detection_label(self, vd: ValidatedDetection) -> None:
|
|
1814
|
+
"""
|
|
1815
|
+
Show a dialog to change a detection's label.
|
|
1816
|
+
|
|
1817
|
+
This allows the reviewer to correct mislabelled pipeline detections
|
|
1818
|
+
without needing to reject and re-add them.
|
|
1819
|
+
"""
|
|
1820
|
+
new_label = self._pick_label(
|
|
1821
|
+
current_label=vd.detection.label
|
|
1822
|
+
)
|
|
1823
|
+
if new_label is None:
|
|
1824
|
+
return
|
|
1825
|
+
|
|
1826
|
+
# Update the detection's label directly
|
|
1827
|
+
vd.detection.label = new_label
|
|
1828
|
+
|
|
1829
|
+
# Add the label to the set if it's already there
|
|
1830
|
+
if new_label not in self._validation_model.labels:
|
|
1831
|
+
self._validation_model.labels.add(new_label)
|
|
1832
|
+
self._populate_class_filter()
|
|
1833
|
+
|
|
1834
|
+
# Mark as having unsaved changes
|
|
1835
|
+
self._has_unsaved_changes = True
|
|
1836
|
+
main_window = self.window()
|
|
1837
|
+
if hasattr(main_window, "update_title"):
|
|
1838
|
+
main_window.update_title()
|
|
1839
|
+
|
|
1840
|
+
# Refresh the table and detail panel
|
|
1841
|
+
fps = self._metadata.fps if self._metadata else None
|
|
1842
|
+
self._detail_panel.set_detection(vd, fps=fps)
|
|
1843
|
+
self._update_scene_detections()
|
|
1844
|
+
|
|
1845
|
+
# Emit dataChanged so the table repaints the label column
|
|
1846
|
+
for row, table_vd in enumerate(
|
|
1847
|
+
self._detection_model._detections
|
|
1848
|
+
):
|
|
1849
|
+
if table_vd.id == vd.id:
|
|
1850
|
+
top_left = self._detection_model.index(row, 0)
|
|
1851
|
+
bottom_right = self._detection_model.index(
|
|
1852
|
+
row,
|
|
1853
|
+
self._detection_model.columnCount() - 1,
|
|
1854
|
+
)
|
|
1855
|
+
self._detection_model.dataChanged.emit(
|
|
1856
|
+
top_left, bottom_right
|
|
1857
|
+
)
|
|
1858
|
+
break
|
|
1859
|
+
|
|
1860
|
+
def _rename_label(self) -> None:
|
|
1861
|
+
"""
|
|
1862
|
+
Show a dialog to rename a label across all detections.
|
|
1863
|
+
|
|
1864
|
+
Prompts for the old label (dropdown) and the new label (text input).
|
|
1865
|
+
Updates all detections, the label set, and the class filter.
|
|
1866
|
+
"""
|
|
1867
|
+
if self._validation_model is None:
|
|
1868
|
+
return
|
|
1869
|
+
|
|
1870
|
+
labels = sorted(self._validation_model.labels)
|
|
1871
|
+
if not labels:
|
|
1872
|
+
return
|
|
1873
|
+
|
|
1874
|
+
old_label, ok = QInputDialog.getItem(
|
|
1875
|
+
self,
|
|
1876
|
+
"Rename Label — Select Label",
|
|
1877
|
+
"Which label do you want to rename?",
|
|
1878
|
+
labels,
|
|
1879
|
+
current=0,
|
|
1880
|
+
editable=False,
|
|
1881
|
+
)
|
|
1882
|
+
if not ok or not old_label:
|
|
1883
|
+
return
|
|
1884
|
+
|
|
1885
|
+
new_label, ok = QInputDialog.getText(
|
|
1886
|
+
self,
|
|
1887
|
+
"Rename Label — New Name",
|
|
1888
|
+
f"Rename '{old_label}' to:",
|
|
1889
|
+
)
|
|
1890
|
+
if not ok or not new_label or new_label == old_label:
|
|
1891
|
+
return
|
|
1892
|
+
|
|
1893
|
+
count = self._validation_model.rename_label(old_label, new_label)
|
|
1894
|
+
|
|
1895
|
+
# Refresh UI
|
|
1896
|
+
self._populate_class_filter()
|
|
1897
|
+
self._update_scene_detections()
|
|
1898
|
+
self._has_unsaved_changes = True
|
|
1899
|
+
|
|
1900
|
+
# Refresh the full table
|
|
1901
|
+
self._detection_model.dataChanged.emit(
|
|
1902
|
+
self._detection_model.index(0, 0),
|
|
1903
|
+
self._detection_model.index(
|
|
1904
|
+
self._detection_model.rowCount() - 1,
|
|
1905
|
+
self._detection_model.columnCount() - 1,
|
|
1906
|
+
),
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
if self._selected_detection is not None:
|
|
1910
|
+
fps = self._metadata.fps if self._metadata else None
|
|
1911
|
+
self._detail_panel.set_detection(
|
|
1912
|
+
self._selected_detection, fps=fps
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
main_window = self.window()
|
|
1916
|
+
if hasattr(main_window, "update_title"):
|
|
1917
|
+
main_window.update_title()
|
|
1918
|
+
|
|
1919
|
+
self._show_status(
|
|
1920
|
+
f"Renamed '{old_label}' → '{new_label}' "
|
|
1921
|
+
f"({count} detection{'s' if count != 1 else ''})"
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# --- Detection status ---
|
|
1925
|
+
def _on_detection_status_changed(
|
|
1926
|
+
self, detection_id: int, new_status: ValidationStatus
|
|
1927
|
+
) -> None:
|
|
1928
|
+
"""
|
|
1929
|
+
Handle a detection's status changing.
|
|
1930
|
+
|
|
1931
|
+
Finds the row in the table model and emits dataChanged so the view
|
|
1932
|
+
repaints that row with updated status and colours.
|
|
1933
|
+
"""
|
|
1934
|
+
self._has_unsaved_changes = True
|
|
1935
|
+
|
|
1936
|
+
main_window = self.window()
|
|
1937
|
+
if hasattr(main_window, 'update_title'):
|
|
1938
|
+
main_window.update_title()
|
|
1939
|
+
|
|
1940
|
+
# Find which row this detection is in the source model
|
|
1941
|
+
for row, vd in enumerate(self._detection_model._detections):
|
|
1942
|
+
if vd.id == detection_id:
|
|
1943
|
+
# Emit dataChanged so the entire row
|
|
1944
|
+
top_left = self._detection_model.index(row, 0)
|
|
1945
|
+
bottom_right = self._detection_model.index(
|
|
1946
|
+
row, self._detection_model.columnCount() - 1
|
|
1947
|
+
)
|
|
1948
|
+
self._detection_model.dataChanged.emit(
|
|
1949
|
+
top_left, bottom_right
|
|
1950
|
+
)
|
|
1951
|
+
break
|
|
1952
|
+
|
|
1953
|
+
# Update detection overlays to reflect the new status colour
|
|
1954
|
+
self._update_scene_detections()
|
|
1955
|
+
|
|
1956
|
+
def _on_progress_changed(self, progress: dict) -> None:
|
|
1957
|
+
"""Update the status bar with progress."""
|
|
1958
|
+
if self._active_source_file is None:
|
|
1959
|
+
return
|
|
1960
|
+
|
|
1961
|
+
# Update the video list sidebar
|
|
1962
|
+
self._video_list.update_progress(
|
|
1963
|
+
self._active_source_file, progress
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
filename = Path(self._active_source_file).name
|
|
1967
|
+
total = progress["total"]
|
|
1968
|
+
reviewed = progress["reviewed"]
|
|
1969
|
+
confirmed = progress["confirmed"]
|
|
1970
|
+
rejected = progress["rejected"]
|
|
1971
|
+
skipped = progress["skipped"]
|
|
1972
|
+
manual = progress["manual"]
|
|
1973
|
+
|
|
1974
|
+
message = (
|
|
1975
|
+
f"{filename} — {reviewed}/{total} reviewed — "
|
|
1976
|
+
f"{confirmed} confirmed, {rejected} rejected, "
|
|
1977
|
+
f"{skipped} skipped"
|
|
1978
|
+
|
|
1979
|
+
)
|
|
1980
|
+
if manual > 0:
|
|
1981
|
+
message += f", {manual} manually added"
|
|
1982
|
+
|
|
1983
|
+
self._show_status(message)
|
|
1984
|
+
|
|
1985
|
+
def _on_detection_added(self, vd: ValidatedDetection) -> None:
|
|
1986
|
+
"""Handle a manual detection being added."""
|
|
1987
|
+
self._has_unsaved_changes = True
|
|
1988
|
+
|
|
1989
|
+
if vd.detection.source_file == self._active_source_file:
|
|
1990
|
+
self._detection_model.append_detection(vd)
|
|
1991
|
+
self._select_validated_detection(vd)
|
|
1992
|
+
self._refresh_worker_detection_source()
|
|
1993
|
+
self._request_frame.emit(self._current_frame)
|
|
1994
|
+
|
|
1995
|
+
def _on_detection_removed(self, detection_id: int) -> None:
|
|
1996
|
+
"""Handle a manual detection being removed (implemented in Step 7)."""
|
|
1997
|
+
self._has_unsaved_changes = True
|
|
1998
|
+
self._detection_model.set_selected_id(None)
|
|
1999
|
+
|
|
2000
|
+
self._detection_model.remove_detection_by_id(detection_id)
|
|
2001
|
+
self._selected_detection = None
|
|
2002
|
+
self._confirm_btn.setEnabled(False)
|
|
2003
|
+
self._reject_btn.setEnabled(False)
|
|
2004
|
+
self._skip_btn.setEnabled(False)
|
|
2005
|
+
self._remove_btn.setEnabled(False)
|
|
2006
|
+
self._refresh_worker_detection_source()
|
|
2007
|
+
self._request_frame.emit(self._current_frame)
|
|
2008
|
+
|
|
2009
|
+
# --- Action button handlers ---
|
|
2010
|
+
def _on_confirm(self) -> None:
|
|
2011
|
+
"""Mark the selected detection as confirmed."""
|
|
2012
|
+
self._apply_status(ValidationStatus.CONFIRMED)
|
|
2013
|
+
|
|
2014
|
+
def _on_reject(self) -> None:
|
|
2015
|
+
"""Mark the selected detection as rejected."""
|
|
2016
|
+
self._apply_status(ValidationStatus.REJECTED)
|
|
2017
|
+
|
|
2018
|
+
def _on_skip(self) -> None:
|
|
2019
|
+
"""Mark the selected detection as skipped."""
|
|
2020
|
+
self._apply_status(ValidationStatus.SKIPPED)
|
|
2021
|
+
|
|
2022
|
+
def _on_remove(self) -> None:
|
|
2023
|
+
"""Remove the selected manual detection."""
|
|
2024
|
+
if self._validation_model is None:
|
|
2025
|
+
return
|
|
2026
|
+
|
|
2027
|
+
if self._selected_detection is None:
|
|
2028
|
+
return
|
|
2029
|
+
|
|
2030
|
+
self._validation_model.remove_detection(
|
|
2031
|
+
self._selected_detection.id
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
def _on_confirm_all_on_frame(self) -> None:
|
|
2035
|
+
"""Confirm all detections on the current frame."""
|
|
2036
|
+
self._apply_status_to_frame(ValidationStatus.CONFIRMED)
|
|
2037
|
+
|
|
2038
|
+
def _on_reject_all_on_frame(self) -> None:
|
|
2039
|
+
"""Reject all detections on the current frame."""
|
|
2040
|
+
self._apply_status_to_frame(ValidationStatus.REJECTED)
|
|
2041
|
+
|
|
2042
|
+
def _on_skip_all_on_frame(self) -> None:
|
|
2043
|
+
"""Skip all detections on the current frame."""
|
|
2044
|
+
self._apply_status_to_frame(ValidationStatus.SKIPPED)
|
|
2045
|
+
|
|
2046
|
+
def _apply_status_to_frame(self, status: ValidationStatus) -> None:
|
|
2047
|
+
"""
|
|
2048
|
+
Apply a review status to all unreviewed detections on the current frame.
|
|
2049
|
+
|
|
2050
|
+
Iterates all detections for the current video and frame number, skipping
|
|
2051
|
+
any that alreayd have a non-PENDING status. After updating all of them,
|
|
2052
|
+
advances to the next PENDING detection.
|
|
2053
|
+
"""
|
|
2054
|
+
if self._validation_model is None:
|
|
2055
|
+
return
|
|
2056
|
+
if self._active_source_file is None:
|
|
2057
|
+
return
|
|
2058
|
+
|
|
2059
|
+
frame_dets = self._validation_model.get_detections_for_frame(
|
|
2060
|
+
self._active_source_file, self._current_frame
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
if not frame_dets:
|
|
2064
|
+
return
|
|
2065
|
+
|
|
2066
|
+
changed = 0
|
|
2067
|
+
for vd in frame_dets:
|
|
2068
|
+
if vd.status == ValidationStatus.PENDING and vd.status != status:
|
|
2069
|
+
self._validation_model.set_status(vd.id, status)
|
|
2070
|
+
changed += 1
|
|
2071
|
+
|
|
2072
|
+
if changed > 0:
|
|
2073
|
+
status_name = status.name.lower()
|
|
2074
|
+
self._show_status(
|
|
2075
|
+
f"{changed} detection{'s' if changed != 1 else ''} "
|
|
2076
|
+
f"{status_name} on frame {self._current_frame}"
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
self._advance_to_next()
|
|
2080
|
+
|
|
2081
|
+
def _apply_status(self, status: ValidationStatus) -> None:
|
|
2082
|
+
"""
|
|
2083
|
+
Apply a review status to the currently selected detection, then advance
|
|
2084
|
+
to the next unreviewed detection.
|
|
2085
|
+
"""
|
|
2086
|
+
if self._validation_model is None:
|
|
2087
|
+
return
|
|
2088
|
+
|
|
2089
|
+
if self._selected_detection is None:
|
|
2090
|
+
return
|
|
2091
|
+
|
|
2092
|
+
if self._selected_detection.status == status:
|
|
2093
|
+
return
|
|
2094
|
+
|
|
2095
|
+
self._validation_model.set_status(
|
|
2096
|
+
self._selected_detection.id, status
|
|
2097
|
+
)
|
|
2098
|
+
self._advance_to_next()
|
|
2099
|
+
|
|
2100
|
+
def _advance_to_next(self) -> None:
|
|
2101
|
+
"""
|
|
2102
|
+
Advance to the next unreviewed detection.
|
|
2103
|
+
|
|
2104
|
+
Priority order:
|
|
2105
|
+
1. Next unreviewed detection on the CURRENT frame (after current ID)
|
|
2106
|
+
2. Any unreviewed detection on the CURRENT frame (before current ID)
|
|
2107
|
+
3. Next unreviewed detection on subsequent frames in this video
|
|
2108
|
+
4. Next video with unreviewed detections
|
|
2109
|
+
"""
|
|
2110
|
+
if self._validation_model is None:
|
|
2111
|
+
return
|
|
2112
|
+
if self._active_source_file is None:
|
|
2113
|
+
return
|
|
2114
|
+
|
|
2115
|
+
current_id = (
|
|
2116
|
+
self._selected_detection.id
|
|
2117
|
+
if self._selected_detection is not None
|
|
2118
|
+
else -1
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
# --- Priority 1 & 2: Check current frame first ---
|
|
2122
|
+
frame_dets = self._validation_model.get_detections_for_frame(
|
|
2123
|
+
self._active_source_file, self._current_frame
|
|
2124
|
+
)
|
|
2125
|
+
|
|
2126
|
+
# Search after current detection on this frame
|
|
2127
|
+
past_current = (current_id == -1)
|
|
2128
|
+
for vd in frame_dets:
|
|
2129
|
+
if not past_current:
|
|
2130
|
+
if vd.id == current_id:
|
|
2131
|
+
past_current = True
|
|
2132
|
+
continue
|
|
2133
|
+
if vd.status == ValidationStatus.PENDING:
|
|
2134
|
+
self._select_validated_detection(vd)
|
|
2135
|
+
return
|
|
2136
|
+
|
|
2137
|
+
# Wrap within frame: check detections before current
|
|
2138
|
+
for vd in frame_dets:
|
|
2139
|
+
if vd.id == current_id:
|
|
2140
|
+
break
|
|
2141
|
+
if vd.status == ValidationStatus.PENDING:
|
|
2142
|
+
self._select_validated_detection(vd)
|
|
2143
|
+
return
|
|
2144
|
+
|
|
2145
|
+
# --- Priority 3: Search globally (skips current frame) ---
|
|
2146
|
+
next_det = self._validation_model.get_next_unreviewed(
|
|
2147
|
+
self._active_source_file, after_id=current_id
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
if next_det is not None:
|
|
2151
|
+
self._select_validated_detection(next_det)
|
|
2152
|
+
else:
|
|
2153
|
+
# --- Priority 4: Next video ---
|
|
2154
|
+
advanced = self._advance_to_next_video()
|
|
2155
|
+
if not advanced:
|
|
2156
|
+
self._show_status("All detections reviewed.")
|
|
2157
|
+
|
|
2158
|
+
def _advance_to_next_video(self) -> bool:
|
|
2159
|
+
"""
|
|
2160
|
+
Advance to the next video that has unreviewed detections.
|
|
2161
|
+
|
|
2162
|
+
Searches all videos in the session (starting after the current one) for
|
|
2163
|
+
any with PENDING detections. If found, switches to that video and
|
|
2164
|
+
selects its first unreviewed detection.
|
|
2165
|
+
|
|
2166
|
+
Returns:
|
|
2167
|
+
True if video with unreviewed detections was found and loaded, False
|
|
2168
|
+
if all videos are fully reviewed.
|
|
2169
|
+
"""
|
|
2170
|
+
if self._validation_model is None:
|
|
2171
|
+
return False
|
|
2172
|
+
|
|
2173
|
+
all_sources = self._validation_model.get_all_source_files()
|
|
2174
|
+
if not all_sources:
|
|
2175
|
+
return False
|
|
2176
|
+
|
|
2177
|
+
# Find current video's position in the list
|
|
2178
|
+
try:
|
|
2179
|
+
current_idx = all_sources.index(self._active_source_file)
|
|
2180
|
+
except ValueError:
|
|
2181
|
+
current_idx = -1
|
|
2182
|
+
|
|
2183
|
+
# Search forward from the next video, wrapping around
|
|
2184
|
+
for offset in range(1, len(all_sources)):
|
|
2185
|
+
idx = (current_idx + offset) % len(all_sources)
|
|
2186
|
+
source = all_sources[idx]
|
|
2187
|
+
|
|
2188
|
+
# Skip videos that aren't available locally
|
|
2189
|
+
if source not in self._video_paths:
|
|
2190
|
+
continue
|
|
2191
|
+
|
|
2192
|
+
# Check if this video has unreviewed detections
|
|
2193
|
+
next_vd = self._validation_model.get_next_unreviewed(source)
|
|
2194
|
+
if next_vd is not None:
|
|
2195
|
+
# Switch to this video
|
|
2196
|
+
self._video_list.select_video(source)
|
|
2197
|
+
# The video_selected signal will fire, which calls
|
|
2198
|
+
# _on_video_selected -> _open_video_for_source.
|
|
2199
|
+
# The auto-select in Step 7 will pick the first
|
|
2200
|
+
# unreviewed detection.
|
|
2201
|
+
return True
|
|
2202
|
+
|
|
2203
|
+
return False
|
|
2204
|
+
|
|
2205
|
+
def _select_validated_detection(
|
|
2206
|
+
self, validated_det: ValidatedDetection
|
|
2207
|
+
) -> None:
|
|
2208
|
+
"""
|
|
2209
|
+
Select a ValidatedDetection in the table, which triggers click-to-seek
|
|
2210
|
+
via the existing selection change handler.
|
|
2211
|
+
"""
|
|
2212
|
+
# Find the source model row
|
|
2213
|
+
for row, vd in enumerate(self._detection_model._detections):
|
|
2214
|
+
if vd.id == validated_det.id:
|
|
2215
|
+
self._detection_table.select_row(row)
|
|
2216
|
+
return
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
# --- Table filtering helpers ---
|
|
2220
|
+
def _on_status_filter_changed(self, text: str) -> None:
|
|
2221
|
+
"""Update the table filter when the status combo changes."""
|
|
2222
|
+
proxy = self._detection_table.model()
|
|
2223
|
+
if not isinstance(proxy, DetectionFilterProxy):
|
|
2224
|
+
raise(RuntimeError("Expected detection table model to be a DetectionFilterProxy"))
|
|
2225
|
+
|
|
2226
|
+
if text == "Show: Manual":
|
|
2227
|
+
proxy.set_manual_filter(True)
|
|
2228
|
+
else:
|
|
2229
|
+
status = _STATUS_FILTER_MAP.get(text)
|
|
2230
|
+
proxy.set_status_filter(status)
|
|
2231
|
+
|
|
2232
|
+
def _on_class_filter_changed(self, text: str) -> None:
|
|
2233
|
+
"""Update the table filter when the class combo changes."""
|
|
2234
|
+
proxy = self._detection_table.model()
|
|
2235
|
+
if not isinstance(proxy, DetectionFilterProxy):
|
|
2236
|
+
return
|
|
2237
|
+
|
|
2238
|
+
if text == "Class: All":
|
|
2239
|
+
proxy.set_class_filter(None)
|
|
2240
|
+
else:
|
|
2241
|
+
proxy.set_class_filter(text)
|
|
2242
|
+
|
|
2243
|
+
# TODO: The below needs attention.
|
|
2244
|
+
def _populate_class_filter(self) -> None:
|
|
2245
|
+
"""
|
|
2246
|
+
Populate the class filter combo box from the ValidationModel's label
|
|
2247
|
+
set. Called once when a session is opened.
|
|
2248
|
+
TODO: Will need to be called again if the detection set is modified
|
|
2249
|
+
"""
|
|
2250
|
+
self._class_filter_combo.blockSignals(True)
|
|
2251
|
+
|
|
2252
|
+
self._class_filter_combo.clear()
|
|
2253
|
+
self._class_filter_combo.addItem("Class: All")
|
|
2254
|
+
|
|
2255
|
+
if self._validation_model is not None:
|
|
2256
|
+
for label in sorted(self._validation_model.labels):
|
|
2257
|
+
self._class_filter_combo.addItem(label)
|
|
2258
|
+
|
|
2259
|
+
self._class_filter_combo.blockSignals(False)
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
# --- Adding detections ---
|
|
2263
|
+
def _toggle_add_mode(self) -> None:
|
|
2264
|
+
"""Toggle 'add detection' mode on or off."""
|
|
2265
|
+
self._add_btn.setChecked(not self._add_btn.isChecked())
|
|
2266
|
+
|
|
2267
|
+
def _on_add_mode_toggled(self, checked: bool) -> None:
|
|
2268
|
+
"""Enter or exit 'add detection' mode."""
|
|
2269
|
+
self._add_mode = checked
|
|
2270
|
+
self._viewer.set_add_mode(checked)
|
|
2271
|
+
|
|
2272
|
+
if checked:
|
|
2273
|
+
if self._add_mode_auto_label:
|
|
2274
|
+
self._show_status(
|
|
2275
|
+
f"Add detection mode (label: "
|
|
2276
|
+
f"{self._add_mode_auto_label}): draw on frame"
|
|
2277
|
+
)
|
|
2278
|
+
else:
|
|
2279
|
+
self._show_status(
|
|
2280
|
+
"Add detection mode: draw on the video frame "
|
|
2281
|
+
"to place a detection."
|
|
2282
|
+
)
|
|
2283
|
+
else:
|
|
2284
|
+
self._add_mode_auto_label = None
|
|
2285
|
+
self._clear_status()
|
|
2286
|
+
|
|
2287
|
+
def _toggle_add_mode_with_current_label(self) -> None:
|
|
2288
|
+
"""
|
|
2289
|
+
Toggle add mode using the currently selected detection's label.
|
|
2290
|
+
|
|
2291
|
+
If a detection is selected, enter add mode and automatically apply
|
|
2292
|
+
its label to the next drawn detection (no label picker dialog).
|
|
2293
|
+
If no detection is selected, fall back to normal add mode with the
|
|
2294
|
+
dialog.
|
|
2295
|
+
"""
|
|
2296
|
+
if self._selected_label is not None:
|
|
2297
|
+
self._add_mode_auto_label = self._selected_label
|
|
2298
|
+
self._add_btn.setChecked(not self._add_btn.isChecked())
|
|
2299
|
+
else:
|
|
2300
|
+
self._show_status("Please select a detection wiht a valid label.")
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def _on_frame_clicked_for_add(
|
|
2304
|
+
self, frame_x: float, frame_y: float
|
|
2305
|
+
) -> None:
|
|
2306
|
+
"""Handle a click on the video frame in add mode."""
|
|
2307
|
+
if not self._ensure_validation_model():
|
|
2308
|
+
return
|
|
2309
|
+
|
|
2310
|
+
# --- Pick a class label ---
|
|
2311
|
+
label = self._pick_label()
|
|
2312
|
+
if label is None:
|
|
2313
|
+
self._add_btn.setChecked(False)
|
|
2314
|
+
return
|
|
2315
|
+
|
|
2316
|
+
# use the current frame number
|
|
2317
|
+
current_frame = self._current_frame
|
|
2318
|
+
|
|
2319
|
+
# Compute timestamp if metadata is available
|
|
2320
|
+
timestamp = 0.0
|
|
2321
|
+
if self._metadata and self._metadata.fps > 0:
|
|
2322
|
+
timestamp = current_frame / self._metadata.fps
|
|
2323
|
+
|
|
2324
|
+
# Default bounding box size - currently adding a detection simply adds a
|
|
2325
|
+
# default 60x60 box to verify that the system works. TODO: This will be
|
|
2326
|
+
# refined to include custom bounding box drawing with click and drag.
|
|
2327
|
+
default_width = 60.0
|
|
2328
|
+
default_height = 60.0
|
|
2329
|
+
|
|
2330
|
+
vd = self._validation_model.add_detection(
|
|
2331
|
+
source_file=self._active_source_file,
|
|
2332
|
+
frame_number=current_frame,
|
|
2333
|
+
xc=frame_x, yc=frame_y,
|
|
2334
|
+
width=default_width, height=default_height,
|
|
2335
|
+
timestamp=timestamp,
|
|
2336
|
+
label=label,
|
|
2337
|
+
)
|
|
2338
|
+
|
|
2339
|
+
# Uncheck the add button
|
|
2340
|
+
self._add_btn.setChecked(False)
|
|
2341
|
+
|
|
2342
|
+
# refresh the frame to show the added detection
|
|
2343
|
+
self._request_frame.emit(current_frame)
|
|
2344
|
+
|
|
2345
|
+
def _ensure_validation_model(self) -> bool:
|
|
2346
|
+
"""
|
|
2347
|
+
Ensure a ValidationModel exists, creating an empty one if needed.
|
|
2348
|
+
|
|
2349
|
+
When a plain video is open without a CSV, there is no model. This
|
|
2350
|
+
method creates a minimal empty model so that manual detection addition
|
|
2351
|
+
works on plain videos.
|
|
2352
|
+
|
|
2353
|
+
Returns:
|
|
2354
|
+
True if a ValidationModel exists or was created successfully, False
|
|
2355
|
+
if there was an error creating the model.
|
|
2356
|
+
"""
|
|
2357
|
+
if self._validation_model is not None:
|
|
2358
|
+
return True
|
|
2359
|
+
|
|
2360
|
+
if self._metadata is None:
|
|
2361
|
+
return False
|
|
2362
|
+
|
|
2363
|
+
# Determine the source file name from the worker or metadata
|
|
2364
|
+
source_file = self._metadata.source_file
|
|
2365
|
+
if not source_file:
|
|
2366
|
+
return False
|
|
2367
|
+
|
|
2368
|
+
self._active_source_file = source_file
|
|
2369
|
+
|
|
2370
|
+
# create a minimal detection source with no detections
|
|
2371
|
+
empty_source = ListDetectionSource([], source_file)
|
|
2372
|
+
|
|
2373
|
+
self._validation_model = ValidationModel(empty_source)
|
|
2374
|
+
|
|
2375
|
+
# Wire up the model's signals
|
|
2376
|
+
self._validation_model.detection_status_changed.connect(
|
|
2377
|
+
self._on_detection_status_changed
|
|
2378
|
+
)
|
|
2379
|
+
self._validation_model.progress_changed.connect(
|
|
2380
|
+
self._on_progress_changed
|
|
2381
|
+
)
|
|
2382
|
+
self._validation_model.detection_added.connect(
|
|
2383
|
+
self._on_detection_added
|
|
2384
|
+
)
|
|
2385
|
+
self._validation_model.detection_removed.connect(
|
|
2386
|
+
self._on_detection_removed
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
# Disable worker annotations so scene handles rendering
|
|
2390
|
+
self._set_annotations_enabled.emit(False)
|
|
2391
|
+
|
|
2392
|
+
return True
|
|
2393
|
+
|
|
2394
|
+
# --- Context menu handlers ---
|
|
2395
|
+
def _on_table_context_action(
|
|
2396
|
+
self, action_name: str, source_row: int
|
|
2397
|
+
) -> None:
|
|
2398
|
+
"""Handle a context menu action from the detection table."""
|
|
2399
|
+
vd = self._detection_model.detection_at(source_row)
|
|
2400
|
+
if vd is None:
|
|
2401
|
+
return
|
|
2402
|
+
|
|
2403
|
+
if action_name == "confirm":
|
|
2404
|
+
self._validation_model.set_status(
|
|
2405
|
+
vd.id, ValidationStatus.CONFIRMED
|
|
2406
|
+
)
|
|
2407
|
+
elif action_name == "reject":
|
|
2408
|
+
self._validation_model.set_status(
|
|
2409
|
+
vd.id, ValidationStatus.REJECTED
|
|
2410
|
+
)
|
|
2411
|
+
elif action_name == "skip":
|
|
2412
|
+
self._validation_model.set_status(
|
|
2413
|
+
vd.id, ValidationStatus.SKIPPED
|
|
2414
|
+
)
|
|
2415
|
+
elif action_name == "remove":
|
|
2416
|
+
self._validation_model.remove_detection(vd.id)
|
|
2417
|
+
elif action_name == "change_label":
|
|
2418
|
+
self._change_detection_label(vd)
|
|
2419
|
+
elif action_name == "select":
|
|
2420
|
+
self._select_validated_detection(vd)
|
|
2421
|
+
elif action_name == "rename_label":
|
|
2422
|
+
self._rename_label()
|
|
2423
|
+
elif action_name == "undo_correction":
|
|
2424
|
+
self._undo_correction()
|
|
2425
|
+
|
|
2426
|
+
def _on_frame_context_menu(self, pos) -> None:
|
|
2427
|
+
"""Show a context menu when right-clicking on the video frame."""
|
|
2428
|
+
menu = QMenu(self)
|
|
2429
|
+
|
|
2430
|
+
# Add detection
|
|
2431
|
+
add_action = menu.addAction("&Add Detection")
|
|
2432
|
+
add_action.triggered.connect(self._toggle_add_mode)
|
|
2433
|
+
|
|
2434
|
+
if self._validation_model is not None:
|
|
2435
|
+
rename_label = menu.addAction("Rename Label...")
|
|
2436
|
+
rename_label.triggered.connect(self._rename_label)
|
|
2437
|
+
|
|
2438
|
+
menu.addSeparator()
|
|
2439
|
+
|
|
2440
|
+
# Frame level batch actions
|
|
2441
|
+
if self._validation_model is not None:
|
|
2442
|
+
confirm_frame = menu.addAction("Confirm All on Frame")
|
|
2443
|
+
confirm_frame.triggered.connect(
|
|
2444
|
+
lambda: self._apply_status_to_frame(
|
|
2445
|
+
ValidationStatus.CONFIRMED
|
|
2446
|
+
)
|
|
2447
|
+
)
|
|
2448
|
+
|
|
2449
|
+
reject_frame = menu.addAction("Reject All on Frame")
|
|
2450
|
+
reject_frame.triggered.connect(
|
|
2451
|
+
lambda: self._apply_status_to_frame(
|
|
2452
|
+
ValidationStatus.REJECTED
|
|
2453
|
+
)
|
|
2454
|
+
)
|
|
2455
|
+
|
|
2456
|
+
menu.addSeparator()
|
|
2457
|
+
|
|
2458
|
+
# Detection editing (only if a detection is selected)
|
|
2459
|
+
if self._selected_detection is not None:
|
|
2460
|
+
vd = self._selected_detection
|
|
2461
|
+
|
|
2462
|
+
change_label = menu.addAction("Change Label...")
|
|
2463
|
+
change_label.triggered.connect(
|
|
2464
|
+
lambda: self._change_detection_label(vd)
|
|
2465
|
+
)
|
|
2466
|
+
|
|
2467
|
+
if vd.corrected_geometry is not None:
|
|
2468
|
+
undo_action = menu.addAction("Undo Correction")
|
|
2469
|
+
undo_action.triggered.connect(self._undo_correction)
|
|
2470
|
+
|
|
2471
|
+
if vd.is_manual:
|
|
2472
|
+
remove_action = menu.addAction("Remove Detection")
|
|
2473
|
+
remove_action.triggered.connect(
|
|
2474
|
+
lambda: self._on_remove()
|
|
2475
|
+
)
|
|
2476
|
+
|
|
2477
|
+
menu.exec(self._viewer.mapToGlobal(pos))
|
|
2478
|
+
|
|
2479
|
+
# --- Error handling and shutdown ---
|
|
2480
|
+
def _on_error(self, message: str) -> None:
|
|
2481
|
+
"""Worker reported an error."""
|
|
2482
|
+
logger.warning("Worker error: %s", message)
|
|
2483
|
+
self._show_status(f"⚠ {message}")
|
|
2484
|
+
QMessageBox.warning(self, "Video Error", message)
|
|
2485
|
+
|
|
2486
|
+
def shutdown(self) -> None:
|
|
2487
|
+
"""
|
|
2488
|
+
Stop playback, close video and shut down the worker thread.
|
|
2489
|
+
|
|
2490
|
+
Must be called before the application exits, otherwise the background
|
|
2491
|
+
thread may hang or print warnings.
|
|
2492
|
+
"""
|
|
2493
|
+
# Clean up S3 download thread if running
|
|
2494
|
+
if hasattr(self, '_s3_thread') and self._s3_thread.isRunning():
|
|
2495
|
+
self._s3_worker.cancel()
|
|
2496
|
+
self._s3_thread.quit()
|
|
2497
|
+
self._s3_thread.wait()
|
|
2498
|
+
|
|
2499
|
+
self._request_stop.emit()
|
|
2500
|
+
self._clear_detection_source.emit()
|
|
2501
|
+
self._request_close.emit()
|
|
2502
|
+
self._thread.quit()
|
|
2503
|
+
self._thread.wait()
|