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,486 @@
1
+ """
2
+ Interactive detection bounding box for the QGraphcsScene in the SeaVision
3
+ validation GUI.
4
+
5
+ Provides a draggable, resizeable rectangle that represents a single detection
6
+ overlay on the video frame. Emits geometry changes through a callback so the
7
+ validation model can be updated.
8
+ """
9
+
10
+ import logging
11
+ from enum import Enum, auto
12
+
13
+ from PySide6.QtCore import Qt, QRectF, QPointF
14
+ from PySide6.QtGui import QPen, QBrush, QColor, QCursor, QPainter
15
+ from PySide6.QtWidgets import (
16
+ QGraphicsRectItem,
17
+ QGraphicsItem,
18
+ QGraphicsSceneMouseEvent,
19
+ QGraphicsSceneHoverEvent,
20
+ QStyleOptionGraphicsItem,
21
+ QWidget,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Size of the resize handles in scene pixels. This is intentionally large - on a
27
+ # 640x480 display displayed at 2x, each handle is about 8 physical pixels, which
28
+ # is comformtable to grab with a mouse.
29
+ _HANDLE_SIZE = 3.0
30
+
31
+ # Minimum size of the box in frame pixels. Prevents the box from collapsing to zero or inverting
32
+ MIN_SIZE = 3.0
33
+
34
+
35
+ class _HandlePosition(Enum):
36
+ """Which edge or corner a resize handle sits on."""
37
+ TOP_LEFT = auto()
38
+ TOP = auto()
39
+ TOP_RIGHT = auto()
40
+ RIGHT = auto()
41
+ BOTTOM_RIGHT = auto()
42
+ BOTTOM = auto()
43
+ BOTTOM_LEFT = auto()
44
+ LEFT = auto()
45
+
46
+
47
+ # Map each handle to the cursor shape shown when hovering above it
48
+ _HANDLE_CURSORS = {
49
+ _HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor,
50
+ _HandlePosition.TOP: Qt.CursorShape.SizeVerCursor,
51
+ _HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor,
52
+ _HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor,
53
+ _HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor,
54
+ _HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor,
55
+ _HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor,
56
+ _HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor,
57
+ }
58
+
59
+
60
+ class DetectionRectItem(QGraphicsRectItem):
61
+ """
62
+ An interactive bounding box for a single detection.
63
+
64
+ Supports:
65
+ - Click to select
66
+ - Drag to move
67
+ - Drag edges/corner handles to resize
68
+ - Cursor changes to indicate available interaction
69
+ - Geometry change callback for model updates
70
+
71
+ The item operates in frame pixel coordinates - its rect() and pos() values
72
+ correspond directly to pixel positions in the original video frame. The
73
+ QGraphicsView handles the scaling between frame coordinates and screen
74
+ pixels.
75
+
76
+ Args:
77
+ x: Left edge in frame pixels
78
+ y: Top edge in frame pixels
79
+ width: Width in frame pixels
80
+ height: Height in frame pixels
81
+ detection_id: The unique ID from ValidationModel, used to route geometry
82
+ changes back to the correct detection.
83
+ colour: BGR tuple for the box outline (converted to RGB internally).
84
+ on_geometry_changed: Callback invoked when the user finishes dragging.
85
+ Receives (detection_id, xc, yc, width, height) in frame coordinates
86
+ using centre-format to match the Detection dataclass convention.
87
+ parent: Optional parent QGraphicsItem.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ x: float,
93
+ y: float,
94
+ width: float,
95
+ height: float,
96
+ detection_id: int,
97
+ colour: tuple[int, int, int] = (0, 0, 255),
98
+ on_geometry_changed: callable = None,
99
+ parent: QGraphicsItem | None = None,
100
+ ):
101
+
102
+ super().__init__(x, y, width, height, parent)
103
+
104
+ self._detection_id = detection_id
105
+ self._on_geometry_changed = on_geometry_changed
106
+
107
+ # --- Interaction flags ---
108
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
109
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
110
+ self.setFlag(
111
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True
112
+ )
113
+ self.setAcceptHoverEvents(True)
114
+
115
+ # --- Visual style ---
116
+ # COnvert BGR (OpenCV format) to RGB (Qt format)
117
+ b, g, r = colour
118
+ pen_colour = QColor(r, g, b)
119
+ pen = QPen(pen_colour, 2.0)
120
+ pen.setCosmetic(True)
121
+ self.setPen(pen)
122
+ self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
123
+
124
+ self._default_pen_colour = pen_colour
125
+
126
+ # --- Resize state ---
127
+ self._active_handle: _HandlePosition | None = None
128
+ self._drag_start_rect: QRectF | None = None
129
+ self._drag_start_pos: QPointF | None = None
130
+
131
+ # --- Frame bounds (set externally to clamp movement) ---
132
+ self._frame_width: float = 0.0
133
+ self._frame_height: float = 0.0
134
+
135
+ def set_frame_bounds(self, width: float, height: float) -> None:
136
+ """
137
+ Set the frame dimensions for clamping box movement.
138
+
139
+ Called once when the item is created. Prevents the reviewer from
140
+ dragging a box outside the visible frame area.
141
+ """
142
+ self._frame_width = width
143
+ self._frame_height = height
144
+
145
+ @property
146
+ def detection_id(self) -> int:
147
+ """The unique ID of the detection this box represents."""
148
+ return self._detection_id
149
+
150
+ def _current_handle_size(self) -> float:
151
+ """
152
+ Compute handle size in scene coordinates that maps to a consistent
153
+ screen size.
154
+
155
+ If no view is available (item not in a scene yet), falls back to
156
+ the default.
157
+ """
158
+ if self.scene() is None:
159
+ return _HANDLE_SIZE
160
+
161
+ views = self.scene().views()
162
+ if not views:
163
+ return _HANDLE_SIZE
164
+
165
+ # Get the view's horizontal scale factor
166
+ transform = views[0].transform()
167
+ scale = transform.m11() # horizontal scale
168
+
169
+ if scale <= 0:
170
+ return _HANDLE_SIZE
171
+
172
+ # Target: 5 screen pixels per handle half-size
173
+ return 6.0 / scale
174
+
175
+ def _handle_rects(self) -> dict[_HandlePosition, QRectF]:
176
+ """
177
+ Calculates the rectangles for all eight resize handles.
178
+
179
+ Handles are positioned at the four corners and the midpoints of the
180
+ four edges, each centered on the edge/corner point with a size of
181
+ _HANDLE_SIZE in each direction.
182
+
183
+ Returns coordinates in the item's local coordinate system.
184
+ """
185
+ r = self.rect()
186
+ s = self._current_handle_size()
187
+ cx = r.x() + r.width() / 2
188
+ cy = r.y() + r.height() / 2
189
+
190
+ return {
191
+ _HandlePosition.TOP_LEFT: QRectF(
192
+ r.x() - s, r.y() - s, 2 * s, 2 * s
193
+ ),
194
+ _HandlePosition.TOP: QRectF(
195
+ cx - s, r.y() - s, 2 * s, 2 * s
196
+ ),
197
+ _HandlePosition.TOP_RIGHT: QRectF(
198
+ r.right() - s, r.y() - s, 2 * s, 2 * s
199
+ ),
200
+ _HandlePosition.RIGHT: QRectF(
201
+ r.right() - s, cy - s, 2 * s, 2 * s
202
+ ),
203
+ _HandlePosition.BOTTOM_RIGHT: QRectF(
204
+ r.right() - s, r.bottom() - s, 2 * s, 2 * s
205
+ ),
206
+ _HandlePosition.BOTTOM: QRectF(
207
+ cx - s, r.bottom() - s, 2 * s, 2 * s
208
+ ),
209
+ _HandlePosition.BOTTOM_LEFT: QRectF(
210
+ r.x() - s, r.bottom() - s, 2 * s, 2 * s
211
+ ),
212
+ _HandlePosition.LEFT: QRectF(
213
+ r.x() - s, cy - s, 2 * s, 2 * s
214
+ ),
215
+ }
216
+
217
+ def _handle_at(self, pos: QPointF) -> _HandlePosition | None:
218
+ """
219
+ Determine which handle (if any) is under the given local position.
220
+
221
+ Returns None if the position is inside the rect but not on a handle,
222
+ meaning a drag should move the whole box rather than resize it.
223
+ """
224
+ for handle, rect in self._handle_rects().items():
225
+ if rect.contains(pos):
226
+ return handle
227
+ return None
228
+
229
+
230
+ # --- Hover events (cursor feedback) ---
231
+ def hoverMoveEvent(self, event: QGraphicsSceneHoverEvent):
232
+ """Change cursor based on which handle the mouse is near."""
233
+ handle = self._handle_at(event.pos())
234
+ if handle is not None:
235
+ self.setCursor(QCursor(_HANDLE_CURSORS[handle]))
236
+ else:
237
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
238
+
239
+ def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None:
240
+ """reset cursor when the mouse leaves the item."""
241
+ self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
242
+
243
+
244
+ # --- Mouse events (drag and resize) ---
245
+ def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
246
+ """
247
+ Start a drag operation.
248
+
249
+ If the press is on a handle, begin resizing. Otherwise, begin moving
250
+ the whole box (handled by Qt's built-in ItemIsMovable)
251
+ """
252
+
253
+ if event.button() != Qt.MouseButton.LeftButton:
254
+ super().mousePressEvent(event)
255
+ return
256
+
257
+ self._active_handle = self._handle_at(event.pos())
258
+
259
+ if self._active_handle is not None:
260
+ # Resize - record the starting geometry to compute delatas during
261
+ # mouseMoveEvent.
262
+ self._drag_start_rect = QRectF(self.rect())
263
+ self._drag_start_pos = event.pos()
264
+
265
+ # Disable movement during the drag
266
+ self.setFlag(
267
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False
268
+ )
269
+ event.accept()
270
+ else:
271
+ # Normal move - let Qt handle it vaia ItemIsMovable
272
+ super().mousePressEvent(event)
273
+
274
+ def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
275
+ """
276
+ Handle a drag movement.
277
+
278
+ During resize: compute the new rect from the mouse delta and the active
279
+ handle.
280
+ During move: delegate to Qt's built in handling.
281
+ """
282
+ if self._active_handle is not None and self._drag_start_rect is not None:
283
+ self.resize_to(event.pos())
284
+ event.accept()
285
+ else:
286
+ super().mouseMoveEvent(event)
287
+
288
+ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
289
+ """
290
+ Finish a drag operation and notify the model.
291
+
292
+ Re-enables ItemIsMovable (which was disbaled during resize) and fires
293
+ the geometry changed callback with the new coordinates.
294
+ """
295
+ was_resizing = self._active_handle is not None
296
+
297
+ self._active_handle = None
298
+ self._drag_start_rect = None
299
+ self._drag_start_pos = None
300
+
301
+ # Re-enable movement for future drags
302
+ self.setFlag(
303
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True
304
+ )
305
+
306
+ super().mouseReleaseEvent(event)
307
+ self._notify_geometry_changed()
308
+
309
+ def resize_to(self, current_pos: QPointF) -> None:
310
+ """
311
+ Update the rectangle during a resize drag.
312
+
313
+ Computes the new rect by adjusting the edge(s) corresponding to the
314
+ active handle by the mouse delta since the drag started.
315
+ Enforces a minimum size to prevent the box from collapsing to 0 or
316
+ inverting.
317
+ """
318
+ if self._drag_start_rect is None or self._drag_start_pos is None:
319
+ return
320
+
321
+ self.prepareGeometryChange()
322
+
323
+ dx = current_pos.x() - self._drag_start_pos.x()
324
+ dy = current_pos.y() - self._drag_start_pos.y()
325
+
326
+ r = QRectF(self._drag_start_rect)
327
+ handle = self._active_handle
328
+
329
+ # Adjust the appropiate edges based on which handle is active
330
+ if handle in (
331
+ _HandlePosition.TOP_LEFT,
332
+ _HandlePosition.TOP,
333
+ _HandlePosition.TOP_RIGHT,
334
+ ):
335
+ new_top = r.top() + dy
336
+ max_top = r.bottom() - MIN_SIZE
337
+ r.setTop(min(new_top, max_top))
338
+
339
+ if handle in (
340
+ _HandlePosition.BOTTOM_LEFT,
341
+ _HandlePosition.BOTTOM,
342
+ _HandlePosition.BOTTOM_RIGHT,
343
+ ):
344
+ new_bottom = r.bottom() + dy
345
+ min_bottom = r.top() + MIN_SIZE
346
+ r.setBottom(max(new_bottom, min_bottom))
347
+
348
+ if handle in (
349
+ _HandlePosition.TOP_LEFT,
350
+ _HandlePosition.LEFT,
351
+ _HandlePosition.BOTTOM_LEFT,
352
+ ):
353
+ new_left = r.left() + dx
354
+ max_left = r.right() - MIN_SIZE
355
+ r.setLeft(min(new_left, max_left))
356
+
357
+ if handle in (
358
+ _HandlePosition.TOP_RIGHT,
359
+ _HandlePosition.RIGHT,
360
+ _HandlePosition.BOTTOM_RIGHT,
361
+ ):
362
+ new_right = r.right() + dx
363
+ min_right = r.left() + MIN_SIZE
364
+ r.setRight(max(new_right, min_right))
365
+
366
+ self.setRect(r)
367
+
368
+ def _notify_geometry_changed(self) -> None:
369
+ """
370
+ Convert the current item geometry to centre-format and invoke the
371
+ callback.
372
+
373
+ The callback signature matches ValidationModel.set_corrected_geometry:
374
+ (detection_id, xc, yc, width, height) - all in frame pixel coordinates.
375
+ """
376
+ if self._on_geometry_changed is None:
377
+ return
378
+
379
+ # The item's scene position plus its local rect give the absolute frame
380
+ # coordinates. Since we set items at pos (0, 0) and use the rect for
381
+ # positioning, the rect coordinates ARE the frame coordinates.
382
+ r = self.rect()
383
+ pos = self.pos()
384
+
385
+ # Absolute frame coordinaates
386
+ abs_x = pos.x() + r.x()
387
+ abs_y = pos.y() + r.y()
388
+ w = r.width()
389
+ h = r.height()
390
+
391
+ # Convert corner-format to centre-format
392
+ xc = abs_x + w / 2
393
+ yc = abs_y + h / 2
394
+
395
+ self._on_geometry_changed(
396
+ self._detection_id, xc, yc, w, h
397
+ )
398
+
399
+ def boundingRect(self) -> QRectF:
400
+ """
401
+ Expand the bounding rect to include resize handles.
402
+
403
+ QGraphicsScene uses the bounding rect for hit testing and repaint
404
+ scheduling. If we don;t expand it, the handles (which extend beyond the
405
+ rectangles edges) won't recieve mouse events and won't be repainted
406
+ correctly.
407
+ """
408
+ r = self.rect()
409
+ margin = self._current_handle_size() + 13
410
+ return r.adjusted(-margin, -margin, margin, margin)
411
+
412
+ def paint(
413
+ self,
414
+ painter: QPainter,
415
+ option: QStyleOptionGraphicsItem,
416
+ widget: QWidget | None = None,
417
+ ) -> None:
418
+ """
419
+ Draw the bounding box and, if selected, the resize handles.
420
+
421
+ The base rectangle is always drawn. When selected, the box colour
422
+ changes to cyan and resize handles appear at corners and edge
423
+ midpoints.
424
+ """
425
+
426
+ # Use cyan pen when selected, default pen otherwise
427
+ if self.isSelected():
428
+ highlight_pen = QPen(QColor(0, 255, 255), 2.0)
429
+ highlight_pen.setCosmetic(True)
430
+ painter.setPen(highlight_pen)
431
+ else:
432
+ painter.setPen(self.pen())
433
+
434
+ painter.setBrush(self.brush())
435
+ painter.drawRect(self.rect())
436
+
437
+ # Draw resize handles when selected
438
+ if self.isSelected():
439
+ handle_pen = QPen(QColor(255, 255, 255), 1.0)
440
+ handle_pen.setCosmetic(True)
441
+ handle_brush = QBrush(QColor(0, 255, 255))
442
+ painter.setPen(handle_pen)
443
+ painter.setBrush(handle_brush)
444
+
445
+ for rect in self._handle_rects().values():
446
+ painter.drawRect(rect)
447
+
448
+ def itemChange(self, change, value):
449
+ """
450
+ Clamp the item position to stay within frame bounds.
451
+
452
+ Called by Qt whenever the item's position changes (because we set the
453
+ ItemSendsGeometryChanges flag). We intercept position changes and adjust
454
+ them to keep the box within the frame.
455
+ """
456
+ if (
457
+ change == QGraphicsItem.GraphicsItemChange.ItemPositionChange
458
+ and self._frame_width > 0
459
+ and self._frame_height > 0
460
+ ):
461
+ self.prepareGeometryChange()
462
+ r = self.rect()
463
+ new_pos = QPointF(value)
464
+
465
+ # Clamp so the box stays within frame bounds
466
+ min_x = -r.x()
467
+ min_y = -r.y()
468
+ max_x = self._frame_width - r.x() - r.width()
469
+ max_y = self._frame_height - r.y() - r.height()
470
+
471
+ new_pos.setX(max(min_x, min(new_pos.x(), max_x)))
472
+ new_pos.setY(max(min_y, min(new_pos.y(), max_y)))
473
+
474
+ return new_pos
475
+
476
+ return super().itemChange(change, value)
477
+
478
+ def update_colour(self, bgr: tuple[int, int, int]) -> None:
479
+ """Update the box colour (e.g. after status change)."""
480
+ b, g, r = bgr
481
+ colour = QColor(r, g, b)
482
+ pen = QPen(colour, 2.0)
483
+ pen.setCosmetic(True)
484
+ self.setPen(pen)
485
+ self._default_pen_colour = colour
486
+ self.update()