seavision-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- seavision_python-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|