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,422 @@
1
+ """Background video decoder - runs on a QThread"""
2
+
3
+ import logging
4
+ import time
5
+ from typing import Optional
6
+ import cv2
7
+
8
+ from PySide6.QtCore import QObject, QTimer, Signal, Slot
9
+
10
+ from seavision.gui.shared.conversion import numpy_bgr_to_qimage
11
+ from seavision.gui.validation.seekable_source import SeekableVideoSource
12
+ from seavision.engine.detectors import Detection
13
+ from seavision.engine.source import FrameContext
14
+ from seavision.engine.visualiser import FrameAnnotator
15
+ from seavision.engine.visualiser.loader import DetectionSource
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class VideoDecoderWorker(QObject):
21
+ """
22
+ Decodes video frames on a background thread.
23
+
24
+ All communication is through signals - the main thread never calls methods
25
+ on this object directly. Instead, it emits signals that Qt delivers to this
26
+ worker's slots on the worker thread.
27
+ """
28
+
29
+ # Emitted after each frame is decoded and converted
30
+ frame_ready = Signal(object, int, float) # QImage, frame number, timestamp
31
+
32
+ # Emited after a video is successfully opened
33
+ video_opened = Signal(object) # VideoMetadata
34
+
35
+ # Emitted when an error occurs
36
+ error_occurred = Signal(str) # Error message
37
+
38
+ # Emmitted when playback reaches the end of the video
39
+ playback_finished = Signal()
40
+
41
+ def __init__(self, parent=None):
42
+ super().__init__(parent)
43
+
44
+ # Video source
45
+ self._source: SeekableVideoSource | None = None
46
+
47
+ # Playback state
48
+ self._playing = False
49
+
50
+ # Frame annotator instance - stateless, set once for all videos.
51
+ self._annotator = FrameAnnotator()
52
+
53
+ # Detection source for annotations (set externally by the main thread)
54
+ self._detection_source: DetectionSource | None = None
55
+
56
+ # Flag to skip pending seek requests
57
+ self._skip_pending_seeks = False
58
+
59
+ # Selected detection for highlighting (set externally by the
60
+ # main thread)
61
+ self._highlight_detection: Detection | None = None
62
+
63
+ # Playback speed multiplier
64
+ self._speed_multiplier: float = 1.0
65
+
66
+ # Toggle annotation drawing
67
+ self._annotations_enabled: bool = True
68
+
69
+
70
+ # --- PLAYBACK SLOT & METHODS ---
71
+ @Slot(str, bool)
72
+ def open_video(self, filepath: str, preload: bool = False) -> None:
73
+ """
74
+ Open a video file and emit metadata with the specified loading strategy.
75
+ """
76
+ try:
77
+ self.close_video()
78
+ self._source = SeekableVideoSource(filepath, preload=preload)
79
+ self.video_opened.emit(
80
+ self._source.metadata,
81
+ )
82
+ # Show the first frame immediately
83
+ self.request_frame(0)
84
+ except Exception as e:
85
+ self.error_occurred.emit(str(e))
86
+
87
+ @Slot(int)
88
+ def request_frame(self, frame_number: int) -> None:
89
+ """Seek to a frame, decode it, and emit the result."""
90
+ if self._skip_pending_seeks:
91
+ return # return immediately to skip pending seeks.
92
+
93
+ if self._source is None:
94
+ return
95
+
96
+ self._playing = False # Stop playback if active
97
+
98
+ try:
99
+ result = self._source.read_at(frame_number)
100
+ if result is None:
101
+ return
102
+
103
+ frame, ctx = result
104
+ if self._annotations_enabled:
105
+ frame = self._annotate_frame(frame, ctx)
106
+ image = numpy_bgr_to_qimage(frame)
107
+ self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
108
+ except Exception as e:
109
+ logger.warning(
110
+ "Frame decode error at frame %d: %s",
111
+ frame_number, e
112
+ )
113
+ self.error_occurred.emit(
114
+ f"Frame decode error at frame {frame_number}: {e}"
115
+ )
116
+
117
+ @Slot(int)
118
+ def request_frame_immediate(self, frame_number: int) -> None:
119
+ """
120
+ Seek to a frame unconditionally - never skipped.
121
+
122
+ Clears the skip flag so that future preview seeks (from the next drag)
123
+ will be processed normally.
124
+ """
125
+ self._skip_pending_seeks = False
126
+ self._playing = False
127
+
128
+ if self._source is None:
129
+ return
130
+
131
+ try:
132
+ result = self._source.read_at(frame_number)
133
+ if result is None:
134
+ return
135
+
136
+ frame, ctx = result
137
+ if self._annotations_enabled:
138
+ frame = self._annotate_frame(frame, ctx)
139
+ image = numpy_bgr_to_qimage(frame)
140
+ self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
141
+ except Exception as e:
142
+ logger.warning(
143
+ "Frame decode error at frame %d: %s",
144
+ frame_number, e
145
+ )
146
+ self.error_occurred.emit(
147
+ f"Frame decode error at frame {frame_number}: {e}"
148
+ )
149
+
150
+ @Slot(int)
151
+ def start_playback(self, from_frame: int) -> None:
152
+ """Begin continuous playback from the given frame."""
153
+ if self._source is None:
154
+ return
155
+
156
+ self._playing = True
157
+ self._source.seek(from_frame)
158
+ self._playback_tick()
159
+
160
+ @Slot()
161
+ def stop_playback(self) -> None:
162
+ """Stop continuous playback."""
163
+ self._playing = False
164
+
165
+ @Slot(float)
166
+ def set_speed(self, multiplier: float) -> None:
167
+ """Set the playback speed multiplier."""
168
+ self._speed_multiplier = max(0.1, multiplier)
169
+
170
+ def _playback_tick(self) -> None:
171
+ """
172
+ Decode and emit one frame, then schedule the next tick.
173
+
174
+ Uses QTimer.singleShot to yield control back to the event loop between
175
+ frames. This allows the worker to process other signals (like
176
+ stop_playback or request_frame) between ticks.
177
+ """
178
+ if not self._playing or self._source is None:
179
+ return
180
+
181
+ tick_start = time.perf_counter()
182
+
183
+ try:
184
+ result = self._source.read()
185
+ if result is None:
186
+ self._playing = False
187
+ self.playback_finished.emit()
188
+ return
189
+
190
+ frame, ctx = result
191
+ if self._annotations_enabled:
192
+ frame = self._annotate_frame(frame, ctx)
193
+ image = numpy_bgr_to_qimage(frame)
194
+ self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
195
+ except Exception as e:
196
+ logger.warning(
197
+ "Playback decode error at frame %d: %s",
198
+ self._source.current_frame, e
199
+ )
200
+ self.error_occurred.emit(
201
+ f"Playback decode error at frame {self._source.current_frame}: {e}"
202
+ )
203
+ self._playing = False
204
+ return
205
+
206
+ # Subtract the time already spent from the target interval
207
+ elapsed_ms = (time.perf_counter() - tick_start) * 1000
208
+ target_ms = 1000 / (self._source.metadata.fps * self._speed_multiplier)
209
+ remaining_ms = max(1, int(target_ms - elapsed_ms))
210
+
211
+ QTimer.singleShot(remaining_ms, self._playback_tick)
212
+
213
+ # --- ANNOTATION SLOTS & METHODS ---
214
+ @Slot(bool)
215
+ def set_annotations_enabled(self, enabled: bool) -> None:
216
+ """
217
+ Toggle worker-side annotation rendering.
218
+
219
+ When disabled, the worker emits raw frames without any detection
220
+ overlays. The interactive scene handles detection rendering instead.
221
+ """
222
+ self._annotations_enabled = enabled
223
+
224
+ @Slot(object)
225
+ def set_detection_source(self, source: Optional[DetectionSource]) -> None:
226
+ """
227
+ Set the detection source for overlay rendering.
228
+
229
+ Called when a session is opened or when switching videos within a
230
+ session. Pass None to clear detections (e.g. when returning to plain
231
+ video-only mode).
232
+ """
233
+ self._detection_source = source
234
+ logger.debug(
235
+ "Detection source set: %s",
236
+ type(source).__name__ if source else "None"
237
+ )
238
+
239
+ @Slot(object)
240
+ def set_highlight_detection(self, detection: Detection | None) -> None:
241
+ """
242
+ Set which detection to highlight with a distinct colour.
243
+
244
+ Args:
245
+ detection: The detection object to highlight, or None to clear the
246
+ highlight.
247
+ """
248
+ self._highlight_detection = detection
249
+
250
+ def _annotate_frame(self, frame, context: FrameContext):
251
+ """
252
+ Annotate a frame with detection overlays and optional highlight.
253
+
254
+ Detections are draw individually, allowing each to have its own colour
255
+ based on review status. The _StatusFilterDetectionSource adapter
256
+ attaches a `_render_colour` attribute to each detection.
257
+
258
+ The highlight is drawn on top of everything, so it always stands out
259
+ regardless of the underlying detection colour.
260
+ """
261
+
262
+ if self._detection_source is None:
263
+ return frame
264
+
265
+ detections = self._detection_source.get_detections_for_frame(
266
+ context.frame_number
267
+ )
268
+
269
+ if not detections:
270
+ return frame
271
+
272
+ frame = frame.copy()
273
+
274
+ for det in detections:
275
+ colour = getattr(det, "_render_colour", None)
276
+ self._draw_detection_box(frame, det, colour=colour)
277
+
278
+ # Highlight the selected detection if it is on this frame
279
+ if self._highlight_detection is not None:
280
+ hl = self._highlight_detection
281
+ highlighted = False
282
+
283
+ # First, try exact match (same frame, same coords)
284
+ if hl.frame_number == context.frame_number:
285
+ for det in detections:
286
+ if (det.xc == hl.xc and det.yc == hl.yc
287
+ and det.width == hl.width
288
+ and det.height == hl.height):
289
+ self._draw_highlight(frame, det)
290
+ highlighted = True
291
+ break
292
+
293
+ # If the detection wasn't in the filtered list (e.g. rejected/
294
+ # skipped with "show rejected off"), draw it anyway
295
+ if not highlighted:
296
+ self._draw_detection_box(frame, hl, colour=(128, 128, 128))
297
+ self._draw_highlight(frame, hl)
298
+ highlighted = True
299
+
300
+ # If not on the exact frame, follow by track_id
301
+ if not highlighted and hl.track_id is not None:
302
+ for det in detections:
303
+ if det.track_id == hl.track_id:
304
+ self._draw_highlight(frame, det)
305
+ break
306
+
307
+ return frame
308
+
309
+ @staticmethod
310
+ def _draw_detection_box(
311
+ frame,
312
+ detection: Detection,
313
+ colour: tuple[int, int, int] | None = None
314
+ ) -> None:
315
+ """
316
+ Draw a single detection bounding box with an optional label.
317
+
318
+ This replaces the FrameAnnotator for in-GUI rendering, giving us control
319
+ pver per-detection colours and a cleaner visual style that is better
320
+ suited to interactive review than the pipeline annotator's output.
321
+
322
+ Args:
323
+ frame: BGR numpy array, modified in place.
324
+ detection: The detection to draw.
325
+ colour: BGR tuple. If None, defaults to red (0, 0, 255).
326
+ """
327
+ if colour is None:
328
+ colour = (0, 0, 255)
329
+
330
+ # Convert from centre format to corner format
331
+ x1 = int(detection.xc - detection.width / 2)
332
+ y1 = int(detection.yc - detection.height / 2)
333
+ x2 = int(detection.xc + detection.width / 2)
334
+ y2 = int(detection.yc + detection.height / 2)
335
+
336
+ # Draw the bounding box - 2px for a clean look
337
+ cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2)
338
+
339
+ # Build a compact label
340
+ parts = []
341
+ if detection.label:
342
+ parts.append(detection.label)
343
+ if detection.confidence is not None:
344
+ parts.append(f"{detection.confidence:.2f}")
345
+
346
+ if not parts:
347
+ return
348
+
349
+ label_text = " | ".join(parts)
350
+ font = cv2.FONT_HERSHEY_SIMPLEX
351
+ font_scale = 0.4
352
+ font_thickness = 1
353
+ (text_w, text_h), baseline = cv2.getTextSize(
354
+ label_text, font, font_scale, font_thickness
355
+ )
356
+
357
+ # Position the label above the box, falling inside if at the top edge of
358
+ # the frame
359
+ label_y = y1 - 4
360
+ if label_y - text_h < 0:
361
+ label_y = y1 + text_h + 4
362
+
363
+ label_x = x1
364
+
365
+ # Semi-transparent background for readability
366
+ bg_x1 = max(0, label_x - 2)
367
+ bg_y1 = max(0, label_y - text_h - 2)
368
+ bg_x2 = min(frame.shape[1], label_x + text_w + 2)
369
+ bg_y2 = min(frame.shape[0], label_y + baseline + 2)
370
+
371
+ # Draw filled rectangle with alpha blending
372
+ overlay = frame.copy()
373
+ cv2.rectangle(
374
+ overlay, (bg_x1, bg_y1), (bg_x2, bg_y2), (0, 0, 0), -1
375
+ )
376
+ cv2.addWeighted(overlay, 0.5, frame, 0.5, 0, frame)
377
+
378
+ # Draw the text in white for contrast
379
+ cv2.putText(
380
+ frame, label_text,
381
+ (label_x, label_y),
382
+ font, font_scale, (255, 255, 255), font_thickness,
383
+ cv2.LINE_AA,
384
+ )
385
+
386
+ @staticmethod
387
+ def _draw_highlight(frame, detection: Detection):
388
+ """
389
+ Draw a highlight rectangle on the selected detection.
390
+
391
+ Uses cyan (BGR: 255, 255, 0) with a thicker line to
392
+ distinguish it from the standard annotation boxes.
393
+ """
394
+
395
+ # Convert from centre format to corner format
396
+ x1 = int(detection.xc - detection.width / 2)
397
+ y1 = int(detection.yc - detection.height / 2)
398
+ x2 = int(detection.xc + detection.width / 2)
399
+ y2 = int(detection.yc + detection.height / 2)
400
+
401
+ # Outer glow — wider, semi-transparent cyan
402
+ overlay = frame.copy()
403
+ # cv2.rectangle(overlay, (x1 - 2, y1 - 2), (x2 + 2, y2 + 2),
404
+ # (255, 255, 0), 4)
405
+ # cv2.addWeighted(overlay, 0.4, frame, 0.6, 0, frame)
406
+
407
+ # Inner highlight — bright, solid cyan
408
+ cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 255, 0), 2)
409
+
410
+ # --- CLEANUP ---
411
+ @Slot()
412
+ def close_video(self) -> None:
413
+ """Release the current video."""
414
+ self._playing = False
415
+ if self._source is not None:
416
+ self._source.close()
417
+ self._source = None
418
+
419
+ @Slot()
420
+ def clear_detection_source(self) -> None:
421
+ """Remove the detection source (returns to plain video mode)."""
422
+ self._detection_source = None