seavision-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. seavision_python-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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()