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,563 @@
|
|
|
1
|
+
"""Detection table model and view for the validation workflow."""
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import (
|
|
4
|
+
QAbstractTableModel,
|
|
5
|
+
QModelIndex,
|
|
6
|
+
Qt,
|
|
7
|
+
Signal,
|
|
8
|
+
QSortFilterProxyModel,
|
|
9
|
+
)
|
|
10
|
+
from PySide6.QtGui import QBrush, QColor, QFont
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QAbstractItemView,
|
|
13
|
+
QHeaderView,
|
|
14
|
+
QMenu,
|
|
15
|
+
QTableView,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from seavision.engine.detectors.base import Detection
|
|
19
|
+
from seavision.gui.validation.validation_model import (
|
|
20
|
+
ValidatedDetection,
|
|
21
|
+
ValidationStatus,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Column definitions: (display_name, attribute_or_key, width_hint)
|
|
25
|
+
COLUMNS = [
|
|
26
|
+
("Frame", "frame_number", 60),
|
|
27
|
+
("Time", "time", 70),
|
|
28
|
+
("Confidence", "confidence", 80),
|
|
29
|
+
("Label", "label", 80),
|
|
30
|
+
("Track", "track_id", 50),
|
|
31
|
+
("Status", "status", 60),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_STATUS_SYMBOLS = {
|
|
35
|
+
ValidationStatus.PENDING: "·",
|
|
36
|
+
ValidationStatus.CONFIRMED: "✓",
|
|
37
|
+
ValidationStatus.REJECTED: "✗",
|
|
38
|
+
ValidationStatus.SKIPPED: "—",
|
|
39
|
+
ValidationStatus.CORRECTED: "✎",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# --- Status colours ---
|
|
43
|
+
_STATUS_FOREGROUND = {
|
|
44
|
+
ValidationStatus.CONFIRMED: QBrush(QColor("#2d8a4e")), # green
|
|
45
|
+
ValidationStatus.REJECTED: QBrush(QColor("#c0392b")), # red
|
|
46
|
+
ValidationStatus.SKIPPED: QBrush(QColor("#888888")), # grey
|
|
47
|
+
ValidationStatus.CORRECTED: QBrush(QColor("#2d6da8")), # blue
|
|
48
|
+
# PENDING: return None (default text colour)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_STATUS_BACKGROUND = {
|
|
52
|
+
ValidationStatus.CONFIRMED: QBrush(QColor(45, 138, 78, 25)), # light green
|
|
53
|
+
ValidationStatus.REJECTED: QBrush(QColor(192, 57, 43, 25)), # light red
|
|
54
|
+
# PENDING, SKIPPED, CORRECTED: return None (no background)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class DetectionTableModel(QAbstractTableModel):
|
|
58
|
+
"""
|
|
59
|
+
Table model backed by a list of detection objects.
|
|
60
|
+
|
|
61
|
+
Implements the required QAbstractTableModel interface so that any
|
|
62
|
+
QTableView can display detection data. The model is read-only for now -
|
|
63
|
+
development phase 4 will add the mutable Status column.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, parent=None):
|
|
67
|
+
super().__init__(parent)
|
|
68
|
+
self._detections: list[ValidatedDetection] = []
|
|
69
|
+
self._fps: float | None = None
|
|
70
|
+
self._selected_id: int | None = None
|
|
71
|
+
|
|
72
|
+
# --- Compulsory interface methods ---
|
|
73
|
+
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
|
74
|
+
"""Number of detections in the table."""
|
|
75
|
+
return len(self._detections)
|
|
76
|
+
|
|
77
|
+
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
|
78
|
+
"""Number of columns in the table."""
|
|
79
|
+
return len(COLUMNS)
|
|
80
|
+
|
|
81
|
+
# --- Main column display logic ---
|
|
82
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
83
|
+
"""
|
|
84
|
+
Return data for a cell.
|
|
85
|
+
|
|
86
|
+
The view calls this for every visible cell with different roles:
|
|
87
|
+
- DisplayRole: the text to show
|
|
88
|
+
- TextAlignmentRole: how to align the text
|
|
89
|
+
- Other roles: return None to use the view's defaults
|
|
90
|
+
"""
|
|
91
|
+
if not index.isValid() or index.row() >= len(self._detections):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
vd = self._detections[index.row()]
|
|
95
|
+
col = index.column()
|
|
96
|
+
|
|
97
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
98
|
+
return self._display_value(vd, col)
|
|
99
|
+
|
|
100
|
+
if role == Qt.ItemDataRole.ForegroundRole:
|
|
101
|
+
if col == 5:
|
|
102
|
+
return _STATUS_FOREGROUND.get(vd.status)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if role == Qt.ItemDataRole.BackgroundRole:
|
|
106
|
+
return _STATUS_BACKGROUND.get(vd.status)
|
|
107
|
+
|
|
108
|
+
if role == Qt.ItemDataRole.FontRole:
|
|
109
|
+
font = QFont()
|
|
110
|
+
changed = False
|
|
111
|
+
|
|
112
|
+
if vd.is_manual:
|
|
113
|
+
font.setItalic(True)
|
|
114
|
+
changed = True
|
|
115
|
+
|
|
116
|
+
if self._selected_id is not None and vd.id == self._selected_id:
|
|
117
|
+
font.setBold(True)
|
|
118
|
+
changed = True
|
|
119
|
+
|
|
120
|
+
if col == 5: # Status column — slightly larger
|
|
121
|
+
font.setPointSize(font.pointSize() + 2)
|
|
122
|
+
changed = True
|
|
123
|
+
|
|
124
|
+
return font if changed else None
|
|
125
|
+
|
|
126
|
+
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
127
|
+
# Centre align numeric columns (frame, Time, Confidence, Track)
|
|
128
|
+
if col in (0, 1, 2, 4):
|
|
129
|
+
return Qt.AlignmentFlag.AlignCenter
|
|
130
|
+
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
|
131
|
+
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def set_selected_id(self, detection_id: int | None) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Mark a detection as selected so its row renders in bold.
|
|
137
|
+
|
|
138
|
+
Emits dataChanged for both the previously selected row and the
|
|
139
|
+
newly selected row so their FontRole is re-queried.
|
|
140
|
+
"""
|
|
141
|
+
old_id = self._selected_id
|
|
142
|
+
self._selected_id = detection_id
|
|
143
|
+
|
|
144
|
+
# Repaint the old row (un-bold it)
|
|
145
|
+
if old_id is not None:
|
|
146
|
+
for row, vd in enumerate(self._detections):
|
|
147
|
+
if vd.id == old_id:
|
|
148
|
+
self.dataChanged.emit(
|
|
149
|
+
self.index(row, 0),
|
|
150
|
+
self.index(row, self.columnCount() - 1),
|
|
151
|
+
)
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
# Repaint the new row (bold it)
|
|
155
|
+
if detection_id is not None:
|
|
156
|
+
for row, vd in enumerate(self._detections):
|
|
157
|
+
if vd.id == detection_id:
|
|
158
|
+
self.dataChanged.emit(
|
|
159
|
+
self.index(row, 0),
|
|
160
|
+
self.index(row, self.columnCount() - 1),
|
|
161
|
+
)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
# --- Value display helper ---
|
|
165
|
+
def _display_value(
|
|
166
|
+
self, validated_det: ValidatedDetection, col: int
|
|
167
|
+
) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Format a detection field for display.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
validaed_det: The ValidatedDetection wrapping the raw Detection.
|
|
173
|
+
col: Column index.
|
|
174
|
+
"""
|
|
175
|
+
detection = validated_det.detection
|
|
176
|
+
|
|
177
|
+
if col == 0: # Frame
|
|
178
|
+
return str(detection.frame_number)
|
|
179
|
+
|
|
180
|
+
if col == 1: # Time
|
|
181
|
+
if self._fps is not None and self._fps > 0:
|
|
182
|
+
timestamp = detection.frame_number / self._fps
|
|
183
|
+
mins = int(timestamp // 60)
|
|
184
|
+
secs = timestamp % 60
|
|
185
|
+
return f"{mins}:{secs:04.1f}"
|
|
186
|
+
return "—"
|
|
187
|
+
|
|
188
|
+
if col == 2: # Confidence
|
|
189
|
+
if detection.confidence is not None:
|
|
190
|
+
return f"{detection.confidence:.2f}"
|
|
191
|
+
else:
|
|
192
|
+
return "—"
|
|
193
|
+
|
|
194
|
+
if col == 3: # Label
|
|
195
|
+
return detection.label if detection.label else "—"
|
|
196
|
+
|
|
197
|
+
if col == 4: # Track
|
|
198
|
+
if detection.track_id is not None:
|
|
199
|
+
return str(detection.track_id)
|
|
200
|
+
else:
|
|
201
|
+
return "—"
|
|
202
|
+
|
|
203
|
+
if col == 5: # Status
|
|
204
|
+
return _STATUS_SYMBOLS.get(validated_det.status, "·")
|
|
205
|
+
|
|
206
|
+
return ""
|
|
207
|
+
|
|
208
|
+
# --- Column header display ---
|
|
209
|
+
def headerData(
|
|
210
|
+
self,
|
|
211
|
+
section: int, # Column index for horizontal headers
|
|
212
|
+
orientation: Qt.Orientation,
|
|
213
|
+
role: int = Qt.ItemDataRole.DisplayRole
|
|
214
|
+
):
|
|
215
|
+
"""Return column header labels."""
|
|
216
|
+
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
|
217
|
+
if 0 <= section < len(COLUMNS):
|
|
218
|
+
return COLUMNS[section][0]
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- Set detection ---
|
|
223
|
+
def set_detections(
|
|
224
|
+
self,
|
|
225
|
+
detections: list[ValidatedDetection],
|
|
226
|
+
fps: float | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Replace the detection list and refresh all connected views.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
detections: New list of ValidatedDetection objects.
|
|
233
|
+
fps: Video frame rate, used for timestamp calculation. None if
|
|
234
|
+
unkown
|
|
235
|
+
"""
|
|
236
|
+
self.beginResetModel()
|
|
237
|
+
self._detections = list(detections)
|
|
238
|
+
self._fps = fps
|
|
239
|
+
self.endResetModel()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- Add/remove manual detections ---
|
|
243
|
+
def append_detection(self, vd: ValidatedDetection) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Add a single detection to the end of the table.
|
|
246
|
+
|
|
247
|
+
Uses beginInsertRows/endInsertRows for a surgical updated - the view
|
|
248
|
+
inserts one row without losing selection or scroll position
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
row = len(self._detections)
|
|
252
|
+
self.beginInsertRows(self.index(0, 0).parent(), row, row)
|
|
253
|
+
self._detections.append(vd)
|
|
254
|
+
self.endInsertRows()
|
|
255
|
+
|
|
256
|
+
def remove_detection_by_id(self, detection_id: int) -> None:
|
|
257
|
+
"""
|
|
258
|
+
Remove a detection by its unique ID.
|
|
259
|
+
|
|
260
|
+
Uses beginRemoveRows/endRemoveRows for a surgical update. Does nothing
|
|
261
|
+
if the ID is not found in the current list.
|
|
262
|
+
"""
|
|
263
|
+
for row, vd in enumerate(self._detections):
|
|
264
|
+
if vd.id == detection_id:
|
|
265
|
+
self.beginRemoveRows(
|
|
266
|
+
self.index(0, 0).parent(), row, row
|
|
267
|
+
)
|
|
268
|
+
self._detections.pop(row)
|
|
269
|
+
self.endRemoveRows()
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# --- Accessory methods ---
|
|
273
|
+
def detection_at(self, row: int) -> ValidatedDetection | None:
|
|
274
|
+
"""Return the Detection object at the given row index."""
|
|
275
|
+
if 0 <= row < len(self._detections):
|
|
276
|
+
return self._detections[row]
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def detection_count(self) -> int:
|
|
280
|
+
"""Return the total number of detections in the model."""
|
|
281
|
+
return len(self._detections)
|
|
282
|
+
|
|
283
|
+
def frame_numbers_sorted(self) -> list[int]:
|
|
284
|
+
"""
|
|
285
|
+
Sorted list of unique frame numbers with detections.
|
|
286
|
+
|
|
287
|
+
Used by the navigation logic to find previous/next detection frames with
|
|
288
|
+
bisect (In ValidationTab).
|
|
289
|
+
"""
|
|
290
|
+
seen = sorted(set(
|
|
291
|
+
d.detection.frame_number for d in self._detections
|
|
292
|
+
))
|
|
293
|
+
return seen
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class DetectionFilterProxy(QSortFilterProxyModel):
|
|
297
|
+
"""
|
|
298
|
+
Proxy model that filters detections by ValidationStatus or origin.
|
|
299
|
+
|
|
300
|
+
Set the filter with set_status_filter(). Pass None to show all detections.
|
|
301
|
+
Use set_manual_filter(True) to show onlymanually added detections.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def __init__(self, parent=None):
|
|
305
|
+
super().__init__(parent)
|
|
306
|
+
self._status_filter: ValidationStatus | None = None
|
|
307
|
+
self._manual_only: bool = False
|
|
308
|
+
self._class_filter: str | None = None
|
|
309
|
+
|
|
310
|
+
def sourceModel(self) -> DetectionTableModel | None:
|
|
311
|
+
"""Return the source model wiht correct type."""
|
|
312
|
+
model = super().sourceModel()
|
|
313
|
+
if isinstance(model, DetectionTableModel):
|
|
314
|
+
return model
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def set_status_filter(
|
|
318
|
+
self, status: ValidationStatus | None
|
|
319
|
+
) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Set which status to show. None means show all detections. Triggers an
|
|
322
|
+
immediate re-filter.
|
|
323
|
+
"""
|
|
324
|
+
self._status_filter = status
|
|
325
|
+
self._manual_only = False
|
|
326
|
+
self.invalidateFilter()
|
|
327
|
+
|
|
328
|
+
def set_manual_filter(self, enabled: bool) -> None:
|
|
329
|
+
"""Show only manually added detections."""
|
|
330
|
+
self._manual_only = enabled
|
|
331
|
+
if enabled:
|
|
332
|
+
self._status_filter = None
|
|
333
|
+
self.invalidateFilter()
|
|
334
|
+
|
|
335
|
+
def set_class_filter(self, class_name: str | None) -> None:
|
|
336
|
+
"""Show only detections of a certain class."""
|
|
337
|
+
self._class_filter = class_name
|
|
338
|
+
self.invalidateFilter()
|
|
339
|
+
|
|
340
|
+
def filterAcceptsRow(
|
|
341
|
+
self, source_row: int, source_parent
|
|
342
|
+
) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Return True is this row should be visible.
|
|
345
|
+
|
|
346
|
+
If no filter is set, all rows pass. Otherwise, only rows whose
|
|
347
|
+
ValidationStatus or origin matches the filter pass.
|
|
348
|
+
"""
|
|
349
|
+
source_model = self.sourceModel()
|
|
350
|
+
if source_model is None:
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
vd = source_model.detection_at(source_row)
|
|
354
|
+
if vd is None:
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
# Class filter applies regardless of other filters
|
|
358
|
+
if self._class_filter is not None:
|
|
359
|
+
if vd.detection.label != self._class_filter:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
# Manual filter
|
|
363
|
+
if self._manual_only:
|
|
364
|
+
return vd.is_manual
|
|
365
|
+
|
|
366
|
+
# Status filter
|
|
367
|
+
if self._status_filter is not None:
|
|
368
|
+
return vd.status == self._status_filter
|
|
369
|
+
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class DetectionTableView(QTableView):
|
|
375
|
+
"""
|
|
376
|
+
Table view for displaying detections with click-to-select.
|
|
377
|
+
|
|
378
|
+
Emits detection_selected when the user clixks a row. The signal carries both
|
|
379
|
+
row index (for looking up the Detection from the model) and the frame number
|
|
380
|
+
(for seeking the video).
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
detection_selected = Signal(int, int) # row index, frame number
|
|
384
|
+
context_action = Signal(str, int) # action_name, source_row
|
|
385
|
+
|
|
386
|
+
def __init__(self, parent=None):
|
|
387
|
+
super().__init__(parent)
|
|
388
|
+
self._proxy: DetectionFilterProxy | None = None
|
|
389
|
+
self._setup_appearance()
|
|
390
|
+
|
|
391
|
+
# --- Right click context menus ---
|
|
392
|
+
self.setContextMenuPolicy(
|
|
393
|
+
Qt.ContextMenuPolicy.CustomContextMenu
|
|
394
|
+
)
|
|
395
|
+
self.customContextMenuRequested.connect(
|
|
396
|
+
self._on_context_menu
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _setup_appearance(self) -> None:
|
|
400
|
+
"""Configure the table's visual behaiour."""
|
|
401
|
+
# Selection behaviour: clicking selects the whole row
|
|
402
|
+
self.setSelectionBehavior(
|
|
403
|
+
QAbstractItemView.SelectionBehavior.SelectRows
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Only one row can be selected at a time
|
|
407
|
+
self.setSelectionMode(
|
|
408
|
+
QAbstractItemView.SelectionMode.SingleSelection
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Subtle zebra striping for readability
|
|
412
|
+
self.setAlternatingRowColors(True)
|
|
413
|
+
|
|
414
|
+
# Column headers are clickable to sort
|
|
415
|
+
self.setSortingEnabled(True)
|
|
416
|
+
|
|
417
|
+
# Hide the vertical header (row numbers)
|
|
418
|
+
self.verticalHeader().setVisible(False)
|
|
419
|
+
|
|
420
|
+
# Last column stretches to fill remaining space
|
|
421
|
+
self.horizontalHeader().setStretchLastSection(True)
|
|
422
|
+
|
|
423
|
+
# Bold text for the selected row
|
|
424
|
+
self.setStyleSheet("""
|
|
425
|
+
QTableView::item:selected {
|
|
426
|
+
font-weight: bold;
|
|
427
|
+
}
|
|
428
|
+
""")
|
|
429
|
+
|
|
430
|
+
def setModel(self, model: DetectionTableModel) -> None:
|
|
431
|
+
"""
|
|
432
|
+
Set the model and connect selection handling.
|
|
433
|
+
|
|
434
|
+
Also applies the colum width hints from the COLUMNS definition.
|
|
435
|
+
"""
|
|
436
|
+
self._proxy = DetectionFilterProxy(self)
|
|
437
|
+
self._proxy.setSourceModel(model)
|
|
438
|
+
|
|
439
|
+
super().setModel(self._proxy)
|
|
440
|
+
|
|
441
|
+
# Apply column widths
|
|
442
|
+
for i, (_, _, width) in enumerate(COLUMNS):
|
|
443
|
+
self.setColumnWidth(i, width)
|
|
444
|
+
|
|
445
|
+
# Connect selection changes
|
|
446
|
+
self.selectionModel().currentRowChanged.connect(
|
|
447
|
+
self._on_row_changed
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _on_row_changed(
|
|
451
|
+
self, current: QModelIndex, _previous: QModelIndex
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Handle a new row being selected in the table."""
|
|
454
|
+
if not current.isValid():
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# Map through the sort proxy to get the source model's row
|
|
458
|
+
source_index = current
|
|
459
|
+
if hasattr(current.model(), "mapToSource"):
|
|
460
|
+
source_index = current.model().mapToSource(current)
|
|
461
|
+
|
|
462
|
+
row = source_index.row()
|
|
463
|
+
model = self._source_model()
|
|
464
|
+
|
|
465
|
+
if model is None:
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
vd = model.detection_at(row)
|
|
469
|
+
if vd is not None:
|
|
470
|
+
self.detection_selected.emit(row, vd.detection.frame_number)
|
|
471
|
+
|
|
472
|
+
def _source_model(self) -> DetectionTableModel | None:
|
|
473
|
+
"""Get the underlying DetectionTableModel, bypassing any proxy."""
|
|
474
|
+
model = self.model()
|
|
475
|
+
if model is None:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
# If sorting is enabled, Qt wraps in a QSortFilterProxyModel
|
|
479
|
+
if hasattr(model, "sourceModel"):
|
|
480
|
+
return model.sourceModel()
|
|
481
|
+
return model
|
|
482
|
+
|
|
483
|
+
def select_row(self, row: int) -> None:
|
|
484
|
+
"""
|
|
485
|
+
Programmatically select a row in the table.
|
|
486
|
+
|
|
487
|
+
Scrolls the table to make the row visible and triggers the selection
|
|
488
|
+
signal.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
row: Row index in the source model (not the proxy)
|
|
492
|
+
"""
|
|
493
|
+
model = self.model()
|
|
494
|
+
if model is None:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# If there's a sort proxy, map from source row to the proxy row index
|
|
498
|
+
if hasattr(model, "mapFromSource"):
|
|
499
|
+
source_model = model.sourceModel()
|
|
500
|
+
source_index = source_model.index(row, 0)
|
|
501
|
+
proxy_index = model.mapFromSource(source_index)
|
|
502
|
+
else:
|
|
503
|
+
proxy_index = model.index(row, 0)
|
|
504
|
+
|
|
505
|
+
if proxy_index.isValid():
|
|
506
|
+
self.setCurrentIndex(proxy_index)
|
|
507
|
+
self.scrollTo(
|
|
508
|
+
proxy_index
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _on_context_menu(self, pos) -> None:
|
|
512
|
+
"""Show a context menu for the right clicked row."""
|
|
513
|
+
index = self.indexAt(pos)
|
|
514
|
+
if not index.isValid():
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
# Map through the proxy to get the source row
|
|
518
|
+
source_index = self._proxy.mapToSource(index)
|
|
519
|
+
source_row = source_index.row()
|
|
520
|
+
|
|
521
|
+
menu = QMenu(self)
|
|
522
|
+
|
|
523
|
+
confirm = menu.addAction("Confirm")
|
|
524
|
+
reject = menu.addAction("Reject")
|
|
525
|
+
skip = menu.addAction("Skip")
|
|
526
|
+
|
|
527
|
+
# Check if this is a manual detection
|
|
528
|
+
model = self._proxy.sourceModel()
|
|
529
|
+
|
|
530
|
+
vd = model.detection_at(source_row)
|
|
531
|
+
remove = None
|
|
532
|
+
undo_correction = None
|
|
533
|
+
if vd and vd.is_manual:
|
|
534
|
+
menu.addSeparator()
|
|
535
|
+
remove = menu.addAction("Remove")
|
|
536
|
+
|
|
537
|
+
menu.addSeparator()
|
|
538
|
+
change_label = menu.addAction("Change Label")
|
|
539
|
+
if vd and vd.corrected_geometry is not None:
|
|
540
|
+
undo_correction = menu.addAction("Undo Correction")
|
|
541
|
+
rename_label = menu.addAction("Rename Label")
|
|
542
|
+
|
|
543
|
+
menu.addSeparator()
|
|
544
|
+
select = menu.addAction("Select")
|
|
545
|
+
|
|
546
|
+
action = menu.exec(self.viewport().mapToGlobal(pos))
|
|
547
|
+
|
|
548
|
+
if action == confirm:
|
|
549
|
+
self.context_action.emit("confirm", source_row)
|
|
550
|
+
elif action == reject:
|
|
551
|
+
self.context_action.emit("reject", source_row)
|
|
552
|
+
elif action == skip:
|
|
553
|
+
self.context_action.emit("skip", source_row)
|
|
554
|
+
elif action is remove:
|
|
555
|
+
self.context_action.emit("remove", source_row)
|
|
556
|
+
elif action == select:
|
|
557
|
+
self.context_action.emit("select", source_row)
|
|
558
|
+
elif action == change_label:
|
|
559
|
+
self.context_action.emit("change_label", source_row)
|
|
560
|
+
elif action == rename_label:
|
|
561
|
+
self.context_action.emit("rename_label", source_row)
|
|
562
|
+
elif action == undo_correction:
|
|
563
|
+
self.context_action.emit("undo_correction", source_row)
|