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,891 @@
1
+ """Main application window."""
2
+
3
+ from os import path
4
+ from pathlib import Path
5
+
6
+ from PySide6.QtCore import QSettings
7
+ from PySide6.QtGui import QAction, QKeySequence, QDragEnterEvent, QDropEvent
8
+ from PySide6.QtWidgets import(
9
+ QApplication,
10
+ QDialog,
11
+ QFileDialog,
12
+ QMainWindow,
13
+ QMessageBox,
14
+ QTabWidget
15
+ )
16
+
17
+ from seavision.gui.validation.session import SessionManager
18
+ from seavision.gui.validation.tab import ValidationTab
19
+
20
+
21
+ class MainWindow(QMainWindow):
22
+ """
23
+ Top-level window for the SeaVision GUI application.
24
+
25
+ Contains a tab widget (currently just the validation tab), a menu bar with
26
+ file operations, and a status bar.
27
+ """
28
+
29
+ def __init__(self, parent=None):
30
+ super().__init__(parent)
31
+ self.setWindowTitle("SeaVision")
32
+ self.resize(1200, 800)
33
+
34
+ # --- Central widget ---
35
+ self._tabs = QTabWidget()
36
+ self.setCentralWidget(self._tabs)
37
+
38
+ # --- Validation tab ---
39
+ self._validation_tab = ValidationTab()
40
+ self._tabs.addTab(self._validation_tab, "Validation")
41
+
42
+ # --- Menu bar ---
43
+ self._build_menus()
44
+
45
+ # --- Status bar ---
46
+ self.statusBar().showMessage("Ready")
47
+
48
+ self._restore_settings()
49
+
50
+ # --- Drag & drop ---
51
+ self.setAcceptDrops(True)
52
+
53
+ def _build_menus(self):
54
+ """Create the File menu."""
55
+
56
+ # --- File menu ---
57
+ file_menu = self.menuBar().addMenu("&File")
58
+
59
+ # Open video
60
+ open_video = QAction("Open &Video...", self)
61
+ open_video.setShortcut(QKeySequence("Ctrl+O"))
62
+ open_video.triggered.connect(self._on_open_video)
63
+ file_menu.addAction(open_video)
64
+
65
+ file_menu.addSeparator()
66
+
67
+ # Open a new Session
68
+ open_session = QAction("&New Session...", self)
69
+ open_session.setShortcut(QKeySequence("Ctrl+N"))
70
+ open_session.triggered.connect(self._on_open_session)
71
+ file_menu.addAction(open_session)
72
+
73
+ file_menu.addSeparator()
74
+
75
+ # Open S3 Video
76
+ open_s3_video = QAction("Open Video from S&3...", self)
77
+ open_s3_video.triggered.connect(self._on_open_s3_video)
78
+ file_menu.addAction(open_s3_video)
79
+
80
+ # New S3 Session
81
+ open_s3_session = QAction("New Session from S3...", self)
82
+ open_s3_session.triggered.connect(self._on_open_s3_session)
83
+ file_menu.addAction(open_s3_session)
84
+
85
+ file_menu.addSeparator()
86
+
87
+ # Load session
88
+ load_session = QAction("&Load Session...", self)
89
+ load_session.setShortcut(QKeySequence("Ctrl+L"))
90
+ load_session.triggered.connect(self._on_load_session)
91
+ file_menu.addAction(load_session)
92
+
93
+ # Recent sessions
94
+ self._recent_menu = file_menu.addMenu("&Recent Sessions")
95
+ self._rebuild_recent_menu()
96
+
97
+ file_menu.addSeparator()
98
+
99
+ # Save session
100
+ save_session = QAction("&Save Session...", self)
101
+ save_session.setShortcut(QKeySequence("Ctrl+S"))
102
+ save_session.triggered.connect(self._on_save_session)
103
+ file_menu.addAction(save_session)
104
+
105
+ # Save session as
106
+ save_session_as = QAction("Save Session &As...", self)
107
+ save_session_as.setShortcut(QKeySequence("Ctrl+Shift+S"))
108
+ save_session_as.triggered.connect(
109
+ lambda: self._on_save_session(save_as=True)
110
+ )
111
+ file_menu.addAction(save_session_as)
112
+
113
+ file_menu.addSeparator()
114
+
115
+ # Export reviewed detections
116
+ export_action = QAction("&Export Validated Detections...", self)
117
+ export_action.setShortcut(QKeySequence("Ctrl+E"))
118
+ export_action.triggered.connect(self._on_export)
119
+ file_menu.addAction(export_action)
120
+
121
+ # Separator
122
+ file_menu.addSeparator()
123
+
124
+ # Exit
125
+ exit_action = QAction("E&xit", self)
126
+ exit_action.setShortcut(QKeySequence("Ctrl+Q"))
127
+ exit_action.triggered.connect(self.close)
128
+ file_menu.addAction(exit_action)
129
+
130
+ # --- Tools menu ---
131
+ tools_menu = self.menuBar().addMenu("&Tools")
132
+
133
+ set_cache_loc = QAction("Set Video Cache &Location...", self)
134
+ set_cache_loc.triggered.connect(
135
+ self._on_set_cache_location
136
+ )
137
+ tools_menu.addAction(set_cache_loc)
138
+
139
+ clear_cache = QAction("Clear Video &Cache...", self)
140
+ clear_cache.triggered.connect(self._on_clear_cache)
141
+ tools_menu.addAction(clear_cache)
142
+
143
+ # --- View menu ---
144
+ view_menu = self.menuBar().addMenu("&View")
145
+
146
+ self._toggle_overlays = QAction("Show &Detections", self)
147
+ self._toggle_overlays.setCheckable(True)
148
+ self._toggle_overlays.setChecked(True)
149
+ self._toggle_overlays.setShortcut(QKeySequence("Ctrl+D"))
150
+ self._toggle_overlays.toggled.connect(
151
+ self._validation_tab.set_overlays_visible
152
+ )
153
+ view_menu.addAction(self._toggle_overlays)
154
+
155
+ self._toggle_rejected = QAction(
156
+ "Show &Rejected Detections", self
157
+ )
158
+ self._toggle_rejected.setCheckable(True)
159
+ self._toggle_rejected.setChecked(False)
160
+ self._toggle_rejected.toggled.connect(
161
+ self._validation_tab.set_rejected_visible
162
+ )
163
+ view_menu.addAction(self._toggle_rejected)
164
+
165
+
166
+ # --- OPEN VIDEO/SESSION HANDLERS ---
167
+ def _unsaved_changes_warning(self) -> bool:
168
+ """
169
+ Check for unsaved changes when opening a new video/session.
170
+
171
+ Returns:
172
+ True if it's safe to proceed (no unsaved changes or user chose to
173
+ discard/save), False if the user cancelled.
174
+ """
175
+ tab = self._validation_tab
176
+ if tab is not None and tab._has_unsaved_changes:
177
+ reply = QMessageBox.question(
178
+ self,
179
+ "Unsaved Changes",
180
+ "You have unsaved review progress.\n\n"
181
+ "Do you want to save before opening a new session?",
182
+ (
183
+ QMessageBox.StandardButton.Save
184
+ | QMessageBox.StandardButton.Discard
185
+ | QMessageBox.StandardButton.Cancel
186
+ ),
187
+ QMessageBox.StandardButton.Save,
188
+ )
189
+
190
+ if reply == QMessageBox.StandardButton.Save:
191
+ self._on_save_session()
192
+ if tab._has_unsaved_changes:
193
+ return False # Save was cancelled or failed
194
+ elif reply == QMessageBox.StandardButton.Cancel:
195
+ return False
196
+
197
+ return True # No unsaved changes, or user saved/discarded
198
+
199
+ def _on_open_video(self) -> None:
200
+ """Show file dialog and open the selected video."""
201
+ # Check for any unsaved changes
202
+ if not self._unsaved_changes_warning():
203
+ return
204
+
205
+ filepath, _ = QFileDialog.getOpenFileName(
206
+ self,
207
+ "Open Video",
208
+ str(Path.home()),
209
+ "Video files (*.ts *.TS *.mp4 *.MP4 *.avi *.AVI *.mkv *.MKV *.mov *.MOV);;All files (*)",
210
+ )
211
+ if filepath:
212
+ self._validation_tab.open_video(filepath)
213
+ self.statusBar().showMessage(f"Opened: {filepath}")
214
+ self.update_title()
215
+
216
+ def _on_open_session(self) -> None:
217
+ """Show dialogs to open a detection CSV and video directory."""
218
+ # Check for any unsaved changes
219
+ if not self._unsaved_changes_warning():
220
+ return
221
+
222
+ # Step 1: Pick the CSV file
223
+ csv_path, _ = QFileDialog.getOpenFileName(
224
+ self,
225
+ "Open Detection CSV",
226
+ str(Path.home()),
227
+ "CSV files (*.csv);;All files (*.*)",
228
+ )
229
+ if not csv_path:
230
+ return
231
+
232
+ # Step 2: Peek at the CSV to determine if we need a video directory.
233
+ # If all sources are S3 URIs, we don't need to ask for a local directory
234
+ try:
235
+ from seavision.engine.visualiser import CSVDetectionLoader
236
+ loader = CSVDetectionLoader(csv_path)
237
+ except (FileNotFoundError, ValueError) as e:
238
+ QMessageBox.warning(
239
+ self, "CSV Error",
240
+ f"Failed to read detection CSV:\n\n{e}",
241
+ )
242
+ return
243
+
244
+ # Classify sources
245
+ local_sources = [
246
+ s for s in loader.sources_in_file
247
+ if not s.startswith("s3://")
248
+ ]
249
+ s3_sources = [
250
+ s for s in loader.sources_in_file
251
+ if s.startswith("s3://")
252
+ ]
253
+
254
+ # Step 3: Only ask for a video directory if there are local sources
255
+ video_dir = ""
256
+ if local_sources:
257
+ video_dir = QFileDialog.getExistingDirectory(
258
+ self,
259
+ "Select Video Directory",
260
+ str(Path.home()),
261
+ )
262
+ if not video_dir:
263
+ # User cancelled — but if there are S3 sources, we can
264
+ # still proceed with just those
265
+ if not s3_sources:
266
+ return
267
+ # Proceed with S3-only sources, no local directory
268
+ video_dir = ""
269
+
270
+ # Step 4: Pass both to the validation tab
271
+ self._validation_tab.open_session(csv_path, video_dir)
272
+
273
+ # Step 5: Update the status bar with session summary
274
+ self._update_session_status()
275
+
276
+ # Step 6: Update the window title
277
+ self.update_title()
278
+
279
+ def _on_open_s3_video(self) -> None:
280
+ """Open a single video file from S3 (no detections)."""
281
+ from seavision.gui.validation.s3_browser import S3BrowserMode
282
+
283
+ dialog = self._open_s3_browser(S3BrowserMode.VIDEO)
284
+ if dialog is None:
285
+ return
286
+
287
+ video_uri = dialog.selected_video_uri
288
+ profile = dialog.selected_profile
289
+ if not video_uri:
290
+ return
291
+
292
+ self._validation_tab._aws_profile = profile
293
+
294
+ local_path = self._s3_download_to_cache(video_uri, profile)
295
+ if local_path is None:
296
+ self.statusBar().showMessage("S3 video download failed")
297
+ return
298
+
299
+ self._validation_tab.open_video(local_path)
300
+ self.statusBar().showMessage(f"Opened S3 video: {video_uri}")
301
+
302
+
303
+
304
+ def _on_open_s3_session(self) -> None:
305
+ """Open a detection session from S3 (CSV + video directory)."""
306
+ from seavision.gui.validation.s3_browser import S3BrowserMode
307
+
308
+ dialog = self._open_s3_browser(S3BrowserMode.SESSION)
309
+ if dialog is None:
310
+ return
311
+
312
+ csv_uri = dialog.selected_csv_uri
313
+ video_prefix = dialog.selected_video_prefix
314
+ profile = dialog.selected_profile
315
+ if not csv_uri or not video_prefix:
316
+ return
317
+
318
+ self._validation_tab._aws_profile = profile
319
+
320
+ # Download the CSV to local cache to open_session can parse it
321
+ local_csv = self._s3_download_to_cache(csv_uri, profile)
322
+ if local_csv is None:
323
+ self.statusBar().showMessage("S3 CSV download failed")
324
+ return
325
+
326
+ # video_prefix is an S3 URI like "s3://bucket/videos/" —
327
+ # open_session → resolve_video_paths classifies every source
328
+ # from the CSV as an S3 source, and _download_s3_videos
329
+ # handles the caching.
330
+ self._validation_tab.open_session(
331
+ csv_path=local_csv,
332
+ video_dir=video_prefix,
333
+ )
334
+ self._update_session_status()
335
+
336
+ # --- OPEN FROM S3 DIALOGS ---
337
+ def _open_s3_browser(self, mode):
338
+ """
339
+ Open the S3 browser dialog in the given mode and return the dialog if
340
+ accepted, or None if cancelled.
341
+
342
+ Checks for AWS profiles first and shows an error if none are found.
343
+
344
+ Args:
345
+ mode: S3BrowserMode.VIDEO or S3BrowserMode.SESSION
346
+
347
+ Returns:
348
+ The accepted S3BrowserDialog, or None.
349
+ """
350
+ from seavision.gui.validation.s3_browser import S3BrowserDialog
351
+
352
+ profiles = self._validation_tab._get_aws_profiles()
353
+ if not profiles:
354
+ QMessageBox.warning(
355
+ self,
356
+ "No AWS Profiles",
357
+ "No AWS profiles are configured on this machine.\n\n"
358
+ "To set up a profile, run:\n"
359
+ " aws configure sso\n\n"
360
+ "See docs/AWS_SETUP.md for details.", #TODO: This should be a link toonline doce or a proper local help document
361
+ )
362
+ return None
363
+
364
+ dialog = S3BrowserDialog(profiles, mode, parent=self)
365
+ if dialog.exec() != QDialog.DialogCode.Accepted:
366
+ return None
367
+
368
+ return dialog
369
+
370
+ def _s3_download_to_cache(
371
+ self, uri: str, profile: str
372
+ ) -> str | None:
373
+ """
374
+ Download a single S3 URI to the local cache.
375
+
376
+ Shows a busy cursor during the download and an error dialog on failure.
377
+
378
+ Returns:
379
+ The local file path as a string, or None on failure.
380
+ """
381
+ from PySide6.QtCore import Qt
382
+ from seavision.gui.validation.video_cache import S3VideoCache
383
+
384
+ cache = S3VideoCache(
385
+ cache_dir = self._validation_tab._get_cache_dir()
386
+ )
387
+
388
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
389
+ try:
390
+ local_path = cache.get_or_download(
391
+ uri, profile_name=profile
392
+ )
393
+ return str(local_path)
394
+ except RuntimeError as e:
395
+ QMessageBox.warning(
396
+ self,
397
+ "Download Error",
398
+ f"Failed to download {uri}:\n\n{e}",
399
+ )
400
+ return None
401
+ finally:
402
+ QApplication.restoreOverrideCursor()
403
+
404
+ def _update_session_status(self) -> None:
405
+ """Update the status bar with session information."""
406
+ tab = self._validation_tab
407
+ if tab._csv_loader is None:
408
+ return
409
+
410
+ total_detections = sum(
411
+ len(tab._csv_loader.get_detections_for_frame(f))
412
+ for f in tab._csv_loader.get_frame_numbers_with_detections()
413
+ )
414
+ video_count = len(tab._csv_loader.sources_in_file)
415
+ resolved_count = len(tab._video_paths)
416
+
417
+ csv_name = Path(str(tab._csv_loader.csv_path)).name
418
+
419
+ self.statusBar().showMessage(
420
+ f"{csv_name} — {total_detections} detections across "
421
+ f"{video_count} videos ({resolved_count} found locally)"
422
+ )
423
+
424
+ def _on_save_session(self, save_as: bool = False) -> None:
425
+ """Save the current session state."""
426
+ tab = self._validation_tab
427
+ if tab._validation_model is None:
428
+ self.statusBar().showMessage("No session to save")
429
+ return
430
+
431
+ if tab._session_save_path is not None and not save_as:
432
+ path = tab._session_save_path
433
+ else:
434
+ path, _ = QFileDialog.getSaveFileName(
435
+ self,
436
+ "Save Session",
437
+ str(Path.home()),
438
+ "SeaVision Session (*.seavision-session)",
439
+ )
440
+ if not path:
441
+ return
442
+
443
+ try:
444
+ SessionManager.save_session(
445
+ path,
446
+ tab._validation_model,
447
+ tab._csv_path,
448
+ tab._video_dir,
449
+ aws_profile=tab._aws_profile,
450
+ cache_dir=str(tab._cache_dir) if tab._cache_dir else None,
451
+ )
452
+ tab._session_save_path = Path(path)
453
+ tab._has_unsaved_changes = False
454
+ self.statusBar().showMessage(f"Session saved: {path}")
455
+ except Exception as e:
456
+ QMessageBox.warning(
457
+ self, "Save Error",
458
+ f"Failed to save session:\n\n{e}",
459
+ )
460
+
461
+ self.update_title()
462
+ self._add_recent_session(path)
463
+
464
+ def _load_session_from_path(self, path: str) -> None:
465
+ """Load a previously saved session from a specific path."""
466
+ try:
467
+ session_data = SessionManager.load_session(path)
468
+ except (FileNotFoundError, ValueError) as e:
469
+ QMessageBox.warning(
470
+ self, "Load Error",
471
+ f"Failed to load session:\n\n{e}",
472
+ )
473
+ return
474
+
475
+ csv_path: str = session_data["csv_path"]
476
+ video_dir: str = session_data["video_dir"]
477
+
478
+ # Check that the csv still exists
479
+ if not Path(csv_path).exists():
480
+ csv_path, _ = QFileDialog.getOpenFileName(
481
+ self,
482
+ f"Locate CSV (was: {csv_path})",
483
+ str(Path.home()),
484
+ "CSV files (*.csv)",
485
+ )
486
+ if not csv_path:
487
+ return
488
+
489
+ # Check that the video directory still exists if local paths are present
490
+ has_local_sources = video_dir and video_dir.strip()
491
+ if has_local_sources and not Path(video_dir).is_dir():
492
+ video_dir = QFileDialog.getExistingDirectory(
493
+ self,
494
+ f"Locate Video Directory (was: {video_dir})",
495
+ str(Path.home()),
496
+ )
497
+ if not video_dir:
498
+ return
499
+
500
+ # Open the session from the CSV and video directory
501
+ tab = self._validation_tab
502
+ tab._aws_profile = session_data.get("aws_profile")
503
+
504
+ loaded_cache_dir = session_data.get("cache_dir")
505
+ if loaded_cache_dir and Path(loaded_cache_dir).is_dir():
506
+ tab._cache_dir = Path(loaded_cache_dir)
507
+ else:
508
+ tab._cache_dir = None
509
+
510
+ tab.open_session(csv_path, video_dir)
511
+
512
+ tab._session_save_path = Path(path)
513
+
514
+ # Apply saved state
515
+ if tab._validation_model is not None:
516
+ stats = SessionManager.apply_session_state(
517
+ tab._validation_model, session_data
518
+ )
519
+
520
+ # Refresh the table to show restored statuses
521
+ if tab._active_source_file:
522
+ video_dets = tab._validation_model.get_detections_for_video(
523
+ tab._active_source_file
524
+ )
525
+ fps = tab._metadata.fps if tab._metadata else None
526
+ tab._detection_model.set_detections(video_dets, fps)
527
+
528
+ # Refresh video list progress
529
+ for sf in tab._validation_model.get_all_source_files():
530
+ progress = tab._validation_model.get_progress(sf)
531
+ tab._video_list.update_progress(sf, progress)
532
+
533
+ tab._has_unsaved_changes = False
534
+ self.update_title()
535
+
536
+ msg = (
537
+ f"Session loaded: {stats['applied']} review actions restored."
538
+ )
539
+ if stats["manual_added"] > 0:
540
+ msg += f", {stats['manual_added']} manual detections"
541
+ if stats["skipped"] > 0:
542
+ msg += f" ({stats['skipped']} skipped — CSV may have changed)"
543
+ self.statusBar().showMessage(msg)
544
+
545
+ self._add_recent_session(path)
546
+
547
+ def _on_load_session(self) -> None:
548
+ """Load a previously saved session."""
549
+ path, _ = QFileDialog.getOpenFileName(
550
+ self,
551
+ "Load Session",
552
+ str(Path.home()),
553
+ "SeaVision Session (*.seavision-session)",
554
+ )
555
+ if not path:
556
+ return
557
+
558
+ self._load_session_from_path(path)
559
+
560
+ def _on_export(self) -> None:
561
+ """Export confirmed detections to CSV."""
562
+ tab = self._validation_tab
563
+ if tab._validation_model is None:
564
+ self.statusBar().showMessage("No session to export")
565
+ return
566
+
567
+ csv_name = (
568
+ Path(tab._csv_path).stem if tab._csv_path else "detections"
569
+ )
570
+ default_name = f"{csv_name}_validated.csv"
571
+
572
+ path, _ = QFileDialog.getSaveFileName(
573
+ self,
574
+ "Export Validated Detections",
575
+ str(Path.home() / default_name),
576
+ "CSV files (*.csv | *.CSV);;All files (*.*)",
577
+ )
578
+ if not path:
579
+ return
580
+
581
+ from seavision.gui.validation.session import SessionManager
582
+
583
+ try:
584
+ count = SessionManager.export_detections(
585
+ path, tab._validation_model
586
+ )
587
+ self.statusBar().showMessage(
588
+ f"Exported {count} confirmed detections to: {path}"
589
+ )
590
+ except Exception as e:
591
+ QMessageBox.warning(
592
+ self, "Export Error",
593
+ f"Failed to export detections:\n\n{e}",
594
+ )
595
+
596
+ # --- CACHE TOOL HANDLERS ---
597
+ def _on_clear_cache(self) -> None:
598
+ """Show cache size and offer to clear it."""
599
+ from seavision.gui.validation.video_cache import S3VideoCache
600
+
601
+ cache = S3VideoCache()
602
+ size = cache.cache_size()
603
+ size_mb = size / (1024 * 1024)
604
+
605
+ if size == 0:
606
+ QMessageBox.information(
607
+ self, "Video Cache",
608
+ "The video cache is empty.",
609
+ )
610
+ return
611
+
612
+ reply = QMessageBox.question(
613
+ self,
614
+ "Clear Video Cache",
615
+ f"The video cache contains {size_mb:.1f} MB of data.\n\n"
616
+ f"Location: {cache.cache_dir}\n\n"
617
+ f"Clear it? Downloaded videos will need to be "
618
+ f"re-downloaded next time.",
619
+ QMessageBox.StandardButton.Yes
620
+ | QMessageBox.StandardButton.No,
621
+ )
622
+
623
+ if reply == QMessageBox.StandardButton.Yes:
624
+ freed = cache.clear_cache()
625
+ freed_mb = freed / (1024 * 1024)
626
+ self.statusBar().showMessage(
627
+ f"Cache cleared: {freed_mb:.1f} MB freed"
628
+ )
629
+
630
+ def _on_set_cache_location(self) -> None:
631
+ """Let the user choose where S3 videos are cached."""
632
+ tab = self._validation_tab
633
+ current = tab._get_cache_dir()
634
+
635
+ new_dir = QFileDialog.getExistingDirectory(
636
+ self,
637
+ "Choose Video Cache Location",
638
+ str(current)
639
+ )
640
+ if not new_dir:
641
+ return
642
+
643
+ tab._set_cache_dir(Path(new_dir))
644
+ self.statusBar().showMessage(
645
+ f"Cache location set to: {new_dir}"
646
+ )
647
+
648
+ def update_title(self) -> None:
649
+ """
650
+ Update the window title to reflect the current state.
651
+
652
+ Format:
653
+ - No session: "SeaVision"
654
+ - Saved session: "SeaVision — session_name.seavision-session"
655
+ - Unsaved changes: "SeaVision — session_name.seavision-session *"
656
+ - CSV open but not saved: "SeaVision — detections.csv"
657
+ """
658
+ parts = ["SeaVision"]
659
+
660
+ tab = self._validation_tab
661
+ if tab._session_save_path is not None:
662
+ parts.append("—")
663
+ parts.append(tab._session_save_path.name)
664
+ elif tab._csv_path is not None:
665
+ parts.append("—")
666
+ parts.append(Path(tab._csv_path).name)
667
+
668
+ if tab._has_unsaved_changes:
669
+ parts.append("*")
670
+
671
+ self.setWindowTitle(" ".join(parts))
672
+
673
+ # --- DRAG & DROP HANDLER OVERRIDES ---
674
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
675
+ """Accept drag events for supported file types."""
676
+ if event.mimeData().hasUrls():
677
+ for url in event.mimeData().urls():
678
+ path = url.toLocalFile().lower()
679
+ if path.endswith(
680
+ (".csv", ".ts", ".mp4", ".avi", ".mkv",
681
+ ".seavision-session")
682
+ ):
683
+ event.acceptProposedAction()
684
+ return
685
+ event.ignore()
686
+
687
+ def dropEvent(self, event: QDropEvent) -> None:
688
+ """Handle dropped files."""
689
+ for url in event.mimeData().urls():
690
+ path = url.toLocalFile()
691
+ lower = path.lower()
692
+
693
+ if lower.endswith(".seavision-session"):
694
+ self._load_session_from_path(path)
695
+ return
696
+
697
+ if lower.endswith(".csv"):
698
+ # Trigger the session flow — need to ask for video dir
699
+ from seavision.engine.visualiser import CSVDetectionLoader
700
+
701
+ try:
702
+ loader = CSVDetectionLoader(path)
703
+ except (FileNotFoundError, ValueError) as e:
704
+ QMessageBox.warning(
705
+ self, "CSV Error",
706
+ f"Failed to read detection CSV:\n\n{e}",
707
+ )
708
+ return
709
+
710
+ # Check if we need a video directory
711
+ local_sources = [
712
+ s for s in loader.sources_in_file
713
+ if not s.startswith("s3://")
714
+ ]
715
+
716
+ video_dir = ""
717
+ if local_sources:
718
+ video_dir = QFileDialog.getExistingDirectory(
719
+ self,
720
+ "Select Video Directory",
721
+ str(Path.home()),
722
+ )
723
+ if not video_dir:
724
+ return
725
+
726
+ self._validation_tab.open_session(path, video_dir)
727
+ self._update_session_status()
728
+ return
729
+
730
+ if lower.endswith((".ts", ".mp4", ".avi", ".mkv")):
731
+ self._validation_tab.open_video(path)
732
+ return
733
+
734
+ # --- Save window state (size, splitter location etc) between sessions ---
735
+ def _save_settings(self) -> None:
736
+ """Persist window geometry and state."""
737
+ from PySide6.QtCore import QSettings
738
+
739
+ settings = QSettings("SeaVision", "SeaVision")
740
+ settings.setValue("geometry", self.saveGeometry())
741
+ settings.setValue("windowState", self.saveState())
742
+
743
+ # Save splitter positions from the validation tab
744
+ tab = self._validation_tab
745
+ if hasattr(tab, '_outer_splitter'):
746
+ settings.setValue(
747
+ "outerSplitter", tab._outer_splitter.saveState()
748
+ )
749
+ if hasattr(tab, '_right_splitter'):
750
+ settings.setValue(
751
+ "rightSplitter", tab._right_splitter.saveState()
752
+ )
753
+
754
+ # Save the playback speed
755
+ if hasattr(tab, '_transport'):
756
+ settings.setValue(
757
+ "playbackSpeed",
758
+ tab._transport._speed_combo.currentText()
759
+ )
760
+
761
+ # --- Restore saved settings from previous session ---
762
+ def _restore_settings(self) -> None:
763
+ """Restore window geometry and state from previous session."""
764
+ settings = QSettings("SeaVision", "SeaVision")
765
+
766
+ geometry = settings.value("geometry")
767
+ if geometry is not None:
768
+ self.restoreGeometry(geometry)
769
+
770
+ state = settings.value("windowState")
771
+ if state is not None:
772
+ self.restoreState(state)
773
+
774
+ tab = self._validation_tab
775
+ outer = settings.value("outerSplitter")
776
+ if outer is not None and hasattr(tab, '_outer_splitter'):
777
+ tab._outer_splitter.restoreState(outer)
778
+
779
+ right = settings.value("rightSplitter")
780
+ if right is not None and hasattr(tab, '_right_splitter'):
781
+ tab._right_splitter.restoreState(right)
782
+
783
+ speed = settings.value("playbackSpeed")
784
+ if speed is not None and hasattr(tab, '_transport'):
785
+ idx = tab._transport._speed_combo.findText(speed)
786
+ if idx >= 0:
787
+ tab._transport._speed_combo.setCurrentIndex(idx)
788
+
789
+ # --- Handle recent session cache ---
790
+ def _add_recent_session(self, path: str) -> None:
791
+ """Add a session path to the recent sessions list."""
792
+
793
+ settings = QSettings("SeaVision", "SeaVision")
794
+ recent = settings.value("recentSessions", [])
795
+ if not isinstance(recent, list):
796
+ recent = []
797
+
798
+ # Remove if already present (will re-add at top)
799
+ if path in recent:
800
+ recent.remove(path)
801
+
802
+ recent.insert(0, path)
803
+ recent = recent[:5] # Keep at most 5
804
+
805
+ settings.setValue("recentSessions", recent)
806
+ self._rebuild_recent_menu()
807
+
808
+ def _get_recent_sessions(self) -> list[str]:
809
+ """Get the list of recent session paths."""
810
+
811
+ settings = QSettings("SeaVision", "SeaVision")
812
+ recent = settings.value("recentSessions", [])
813
+ if not isinstance(recent, list):
814
+ return []
815
+ # Filter out paths that no longer exist
816
+ return [p for p in recent if Path(p).exists()]
817
+
818
+ def _rebuild_recent_menu(self) -> None:
819
+ """Rebuild the Recent Sessions submenu."""
820
+ self._recent_menu.clear()
821
+
822
+ recent = self._get_recent_sessions()
823
+ if not recent:
824
+ no_recent = QAction("(no recent sessions)", self)
825
+ no_recent.setEnabled(False)
826
+ self._recent_menu.addAction(no_recent)
827
+ return
828
+
829
+ for path in recent:
830
+ action = QAction(Path(path).name, self)
831
+ action.setToolTip(str(path))
832
+ action.setData(path)
833
+ action.triggered.connect(self._on_open_recent)
834
+ self._recent_menu.addAction(action)
835
+
836
+ self._recent_menu.addSeparator()
837
+ clear_action = QAction("Clear Recent Sessions", self)
838
+ clear_action.triggered.connect(self._on_clear_recent)
839
+ self._recent_menu.addAction(clear_action)
840
+
841
+ def _on_open_recent(self) -> None:
842
+ """Open a session from the recent sessions list."""
843
+ action = self.sender()
844
+ if action is None:
845
+ return
846
+ path = action.data()
847
+ if path and Path(path).exists():
848
+ self._load_session_from_path(path)
849
+ else:
850
+ QMessageBox.warning(
851
+ self, "File Not Found",
852
+ f"Session file no longer exists:\n{path}"
853
+ )
854
+ self._rebuild_recent_menu()
855
+
856
+ def _on_clear_recent(self) -> None:
857
+ """Clear the recent sessions list."""
858
+ settings = QSettings("SeaVision", "SeaVision")
859
+ settings.setValue("recentSessions", [])
860
+ self._rebuild_recent_menu()
861
+
862
+ def closeEvent(self, event):
863
+ self._save_settings()
864
+
865
+ tab = self._validation_tab
866
+
867
+ if tab._has_unsaved_changes:
868
+ reply = QMessageBox.question(
869
+ self,
870
+ "Unsaved Changes",
871
+ "You have unsaved review progress.\n\n"
872
+ "Do you want to save before closing?",
873
+ (
874
+ QMessageBox.StandardButton.Save
875
+ | QMessageBox.StandardButton.Discard
876
+ | QMessageBox.StandardButton.Cancel
877
+ ),
878
+ QMessageBox.StandardButton.Save,
879
+ )
880
+
881
+ if reply == QMessageBox.StandardButton.Save:
882
+ self._on_save_session()
883
+ if tab._has_unsaved_changes:
884
+ event.ignore()
885
+ return
886
+ elif reply == QMessageBox.StandardButton.Cancel:
887
+ event.ignore()
888
+ return
889
+
890
+ tab.shutdown()
891
+ event.accept()