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,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)