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,558 @@
1
+ """Detection loading from various sources for post-hoc visualisation."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Dict, Iterator, List, Optional
7
+ import csv
8
+ import logging
9
+
10
+ from ..detectors.base import Detection
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DetectionSource(ABC):
16
+ """
17
+ Abstract base class for loading detection.
18
+
19
+ Provides a common interface for different detection sources (CSV files,
20
+ iterators, databases, etc) to be used with the visualiser.
21
+ """
22
+
23
+ @abstractmethod
24
+ def get_detections_for_frame(self, frame_number: int) -> List[Detection]:
25
+ """
26
+ Get all detections for a specific frame number.
27
+
28
+ Args:
29
+ frame_number: The frame index to retrieve detections for.
30
+
31
+ Returns:
32
+ List of Detection objects for that frame (empty list if none).
33
+ """
34
+ pass
35
+
36
+
37
+ @abstractmethod
38
+ def get_source_file(self) -> str:
39
+ """
40
+ Get the source video file path these detections belong to.
41
+
42
+ Returns:
43
+ String path or URI of the source video.
44
+ """
45
+ pass
46
+
47
+
48
+ @abstractmethod
49
+ def get_frame_numbers_with_detections(self) -> List[int]:
50
+ """
51
+ Get list of frame numbers that have detections.
52
+
53
+ Returns:
54
+ Sorted list of frame numbers containing at least one detection.
55
+ """
56
+ pass
57
+
58
+
59
+ def get_total_detection_count(self) -> int:
60
+ """
61
+ Get the total number of detections across all frames.
62
+
63
+ Returns:
64
+ Total detection count.
65
+ """
66
+ return sum(
67
+ len(self.get_detections_for_frame(f))
68
+ for f in self.get_frame_numbers_with_detections()
69
+ )
70
+
71
+
72
+ def get_frame_count_with_detections(self) -> int:
73
+ """
74
+ Get the number of frames that have at least one detection.
75
+
76
+ Returns:
77
+ Number of frames with detections.
78
+ """
79
+ return len(self.get_frame_numbers_with_detections())
80
+
81
+
82
+ @dataclass
83
+ class FrameDetections:
84
+ """
85
+ Container for detections grouped by frame number.
86
+
87
+ A simple data structure that can be used directly or as a base for more
88
+ complex detection sources.
89
+
90
+ Attributes:
91
+ source_file: Path or URI of the source file
92
+ detections_by_frame: Dictionary mapping frame numbers to lists of
93
+ Detection objects.
94
+
95
+ Example:
96
+ detections = FrameDetections(
97
+ source_file="s3://my-bucket/video.ts",
98
+ detections_by_frame={
99
+ 10: [Detection(...), Detection(...)],
100
+ 15: [Detection(...)]
101
+ }
102
+ )
103
+ frame_10_dets = detections.get_detections_for_frame(10)
104
+ """
105
+ source_file: str
106
+ detections_by_frame: Dict[int, List[Detection]] = field(default_factory=dict)
107
+
108
+
109
+ def get_detections_for_frame(self, frame_number: int) -> List[Detection]:
110
+ """Get detections for a specific frame."""
111
+ return self.detections_by_frame.get(frame_number, [])
112
+
113
+
114
+ def get_frame_numbers_with_detections(self) -> List[int]:
115
+ """Get sorted list of frame numbers with detections."""
116
+ return sorted(self.detections_by_frame.keys())
117
+
118
+
119
+ def add_detection(self, detection: Detection) -> None:
120
+ """
121
+ Add a detection to the appropriate frame.
122
+
123
+ Args:
124
+ detection: Detection to add.
125
+ """
126
+ frame_num = detection.frame_number
127
+ if frame_num not in self.detections_by_frame:
128
+ self.detections_by_frame[frame_num] = []
129
+ self.detections_by_frame[frame_num].append(detection)
130
+
131
+
132
+ def get_total_detection_count(self) -> int:
133
+ """Get total number of detections."""
134
+ return sum(len(dets) for dets in self.detections_by_frame.values())
135
+
136
+
137
+ class CSVDetectionLoader(DetectionSource):
138
+ """
139
+ Load detections from a CSV file.
140
+
141
+ Expects CSV format matching Detection.to_csv_row() output:
142
+ source_file, timestamp, frame_number, xc, yc, width, height, confidence
143
+
144
+ Handles:
145
+ - CSV files with headers
146
+ - Multiple source files in one CSV (filterable)
147
+ - Missing or empty confidence values
148
+ - Whitespace in values
149
+
150
+ Attributes:
151
+ csv_path: Path to the CSV file.
152
+ source_file_filter: If set, only load detections for this source.
153
+
154
+ Example:
155
+ # Load all detections from CSV
156
+ loader = CSVDetectionLoader("results/detections.csv")
157
+
158
+ # Load only detections for a specific video
159
+ loader = CSVDetectionLoader(
160
+ "results/all_detections.csv",
161
+ source_file="footage/video_001.ts"
162
+ )
163
+
164
+ # Use with visualiser
165
+ for frame_num in loader.get_frame_numbers_with_detections():
166
+ detections = loader.get_detections_for_frame(frame_num)
167
+ """
168
+
169
+ # Expected CSV columns
170
+ REQUIRED_COLUMNS = {
171
+ "source_file",
172
+ "timestamp",
173
+ "frame_number",
174
+ "xc",
175
+ "yc",
176
+ "width",
177
+ "height",
178
+ }
179
+ OPTIONAL_COLUMNS = {
180
+ "confidence",
181
+ "label",
182
+ "track_id",
183
+ }
184
+
185
+
186
+ def __init__(
187
+ self,
188
+ csv_path: str,
189
+ source_file: Optional[str] = None
190
+ ):
191
+ """
192
+ Load detections from CSV.
193
+
194
+ Args:
195
+ csv_path: Path to CSV file with detections.
196
+ source_file: If provided, only load detections matching this source.
197
+ Useful when a single CSV contains detections from multiple videos.
198
+ If None and CSV contains multiple sources, uses the first one found.
199
+
200
+ Raises:
201
+ FileNotFoundError: If CSV file does not exist.
202
+ ValueError: If CSV is missing required columns.
203
+ """
204
+
205
+ self._csv_path = Path(csv_path)
206
+ self._source_file_filter = source_file
207
+ self._detections_by_frame: Dict[int, List[Detection]] = {}
208
+ self._source_file: str = ""
209
+ self._sources_in_file: List[str] = []
210
+
211
+ self._load()
212
+
213
+
214
+ def _load(self) -> None:
215
+ """
216
+ Load and index detections from a CSV.
217
+ """
218
+ if not self._csv_path.exists():
219
+ raise FileNotFoundError(f"CSV file not found: {self._csv_path}")
220
+
221
+ logger.debug(f"Loading detections from CSV: {self._csv_path}")
222
+
223
+ with open(self._csv_path, "r", newline="", encoding="utf-8-sig") as f:
224
+ reader = csv.DictReader(f)
225
+
226
+ # Validate columns
227
+ if reader.fieldnames is None:
228
+ raise ValueError(
229
+ f"CSV file is empty or has no header: {self._csv_path}"
230
+ )
231
+
232
+ columns = set(reader.fieldnames)
233
+ missing = self.REQUIRED_COLUMNS - columns
234
+ if missing:
235
+ raise ValueError(
236
+ f"CSV file is missing required columns: {missing}. "
237
+ f"Found: {columns}"
238
+ )
239
+
240
+ has_confidence = "confidence" in columns
241
+ sources_seen: set = set()
242
+
243
+ for row_num, row in enumerate(reader, start = 2): # Start at 2 (1-indexed + header)
244
+ try:
245
+ detection = self._parse_row(row, has_confidence)
246
+ except (ValueError, KeyError) as e:
247
+ logger.warning(
248
+ f"Skipping invalid row {row_num} in {self._csv_path}: {e}"
249
+ )
250
+ continue
251
+
252
+ # Track sources seen
253
+ sources_seen.add(detection.source_file)
254
+
255
+ # Apply source filter
256
+ if self._source_file_filter is not None:
257
+ if not self._matches_source(detection.source_file):
258
+ continue
259
+
260
+ # Set source file from first matching detection
261
+ if not self._source_file:
262
+ self._source_file = detection.source_file
263
+
264
+ # Index by frame number
265
+ frame_num = detection.frame_number
266
+ if frame_num not in self._detections_by_frame:
267
+ self._detections_by_frame[frame_num] = []
268
+ self._detections_by_frame[frame_num].append(detection)
269
+
270
+ self._sources_in_file = sorted(sources_seen)
271
+
272
+ # Logger summary
273
+ total_detections = sum(len(d) for d in self._detections_by_frame.values())
274
+ frame_count = len(self._detections_by_frame)
275
+
276
+ logger.info(
277
+ f"Loaded {total_detections} detections across {frame_count} frames "
278
+ f"from: {self._csv_path}"
279
+ )
280
+
281
+ if len(self._sources_in_file) > 1:
282
+ logger.debug(
283
+ f"CSV contains {len(self._sources_in_file)} source files: "
284
+ f"{self._sources_in_file}"
285
+ )
286
+
287
+
288
+ def _parse_row(
289
+ self,
290
+ row: Dict[str, str],
291
+ has_confidence: bool
292
+ ) -> Detection:
293
+ """
294
+ Parse a CSV row into a Detection object.
295
+
296
+ Args:
297
+ row: Dictionary from csv.DictReader.
298
+ has_confidence: Whether the CSV has a confidence column.
299
+
300
+ Returns:
301
+ Detection object.
302
+
303
+ Raises:
304
+ ValueError: If required fields are missing or invalid.
305
+ """
306
+ # Parse confidence (may be empty string or missing)
307
+ confidence = None
308
+ if has_confidence:
309
+ conf_str = row.get("confidence", "").strip()
310
+ if conf_str:
311
+ confidence = float(conf_str)
312
+
313
+ # Parse label (may be empty string or missing)
314
+ label = None
315
+ label_str = row.get("label", "").strip()
316
+ if label_str:
317
+ label = label_str
318
+
319
+ # Parse the track ID (may be empty string or missing)
320
+ track_id = None
321
+ track_id_str = row.get("track_id", "").strip()
322
+ if track_id_str:
323
+ track_id = track_id_str
324
+
325
+ return Detection(
326
+ source_file=row["source_file"].strip(),
327
+ timestamp=float(row["timestamp"].strip()),
328
+ frame_number=int(row["frame_number"].strip()),
329
+ xc=float(row["xc"].strip()),
330
+ yc=float(row["yc"].strip()),
331
+ width=float(row["width"].strip()),
332
+ height=float(row["height"].strip()),
333
+ confidence=confidence,
334
+ label=label,
335
+ track_id=track_id
336
+ )
337
+
338
+
339
+ def _matches_source(self, source_file: str) -> bool:
340
+ """
341
+ Check if a source file matches the filter.
342
+
343
+ Handles potential differences in path formatting (e.g., relative vs
344
+ absolute, different separators).
345
+
346
+ Args:
347
+ source_file: Source file path from detection.
348
+
349
+ Returns:
350
+ True if it matches the filter (or no filter set).
351
+ """
352
+ if self._source_file_filter is None:
353
+ return True
354
+
355
+ # Exact match
356
+ if source_file == self._source_file_filter:
357
+ return True
358
+
359
+ # Try normalised path comparison for local files
360
+ try:
361
+ source_path = Path(source_file)
362
+ filter_path = Path(self._source_file_filter)
363
+
364
+ # Compare resolved paths if both are local files
365
+ if source_path.exists() and filter_path.exists():
366
+ return source_path.resolve() == filter_path.resolve()
367
+
368
+ # Compare just the filename as fallback
369
+ return source_path.name == filter_path.name
370
+ except (OSError, ValueError):
371
+ # Path comparison failed, stick with exact match (already False)
372
+ return False
373
+
374
+
375
+ def get_detections_for_frame(self, frame_number: int) -> List[Detection]:
376
+ """Get all detections for a specific frame number."""
377
+ return self._detections_by_frame.get(frame_number, [])
378
+
379
+
380
+ def get_source_file(self) -> str:
381
+ """Get the source video file path."""
382
+ return self._source_file
383
+
384
+
385
+ def get_frame_numbers_with_detections(self) -> List[int]:
386
+ """Get sorted list of frame numbers with detections."""
387
+ return sorted(self._detections_by_frame.keys())
388
+
389
+
390
+ @property
391
+ def csv_path(self) -> Path:
392
+ """Get the path to the CSV file."""
393
+ return self._csv_path
394
+
395
+
396
+ @property
397
+ def sources_in_file(self) -> List[str]:
398
+ """Get list of all source files found in the CSV."""
399
+ return self._sources_in_file
400
+
401
+
402
+ class IteratorDetectionSource(DetectionSource):
403
+ """
404
+ Wrap an iterator of detections for use with visualiser.
405
+
406
+ Buffers all detections into a frame-indexed structure for random access.
407
+ Useful for streaming visualisation directly from detector output.
408
+
409
+ Note:
410
+ This class buffers ALL detections in memory. For very long videos
411
+ with many detections, consider using CSVDetectionLoader instead
412
+ (write to CSV first, then load).
413
+
414
+ Attributes:
415
+ source_file: Path or URI of the source video.
416
+
417
+ Example:
418
+ # From detector output
419
+ detections = list(detector.process_frame(frame, context))
420
+ source = IteratorDetectionSource(iter(detections), "video.ts")
421
+
422
+ # From a generator
423
+ def detection_generator():
424
+ for frame_num in range(100):
425
+ yield Detection(...)
426
+
427
+ source = IteratorDetectionSource(detection_generator(), "video.ts")
428
+ """
429
+ def __init__(
430
+ self,
431
+ detections: Iterator[Detection],
432
+ source_file: str,
433
+ ):
434
+ """
435
+ Create detection source from iterator.
436
+
437
+ Args:
438
+ detections: Iterator yielding Detection objects.
439
+ source_file: Source video file path/URI (for metadata).
440
+
441
+ Note:
442
+ The iterator is fully consumed during initialisation.
443
+ """
444
+ self._source_file = source_file
445
+ self._detections_by_frame: Dict[int, List[Detection]] = {}
446
+ self._buffer(detections)
447
+
448
+
449
+ def _buffer(self, detections: Iterator[Detection]) -> None:
450
+ """Buffer all detections into frame index."""
451
+ count = 0
452
+ for det in detections:
453
+ frame_num = det.frame_number
454
+ if frame_num not in self._detections_by_frame:
455
+ self._detections_by_frame[frame_num] = []
456
+ self._detections_by_frame[frame_num].append(det)
457
+ count += 1
458
+
459
+ logger.debug(
460
+ f"Buffered {count} detections across "
461
+ f"{len(self._detections_by_frame)} frames"
462
+ )
463
+
464
+
465
+ def get_detections_for_frame(self, frame_number: int) -> List[Detection]:
466
+ """Get all detections for a specific frame number."""
467
+ return self._detections_by_frame.get(frame_number, [])
468
+
469
+
470
+ def get_source_file(self) -> str:
471
+ """Get the source video file path."""
472
+ return self._source_file
473
+
474
+
475
+ def get_frame_numbers_with_detections(self) -> List[int]:
476
+ """Get sorted list of frame numbers with detections."""
477
+ return sorted(self._detections_by_frame.keys())
478
+
479
+
480
+ class ListDetectionSource(DetectionSource):
481
+ """
482
+ Create detection source from a list of detections.
483
+
484
+ Simpler alternative to IteratorDetectionSource when detections
485
+ are already in a list.
486
+
487
+ Example:
488
+ detections = [det1, det2, det3]
489
+ source = ListDetectionSource(detections, "video.ts")
490
+ """
491
+
492
+ def __init__(
493
+ self,
494
+ detections: List[Detection],
495
+ source_file: str,
496
+ ):
497
+ """
498
+ Create detection source from list.
499
+
500
+ Args:
501
+ detections: List of Detection objects.
502
+ source_file: Source video file path/URI (for metadata).
503
+ """
504
+ self._source_file = source_file
505
+ self._detections_by_frame: Dict[int, List[Detection]] = {}
506
+
507
+ for det in detections:
508
+ frame_num = det.frame_number
509
+ if frame_num not in self._detections_by_frame:
510
+ self._detections_by_frame[frame_num] = []
511
+ self._detections_by_frame[frame_num].append(det)
512
+
513
+
514
+ def get_detections_for_frame(self, frame_number: int) -> List[Detection]:
515
+ """Get all detections for a specific frame number."""
516
+ return self._detections_by_frame.get(frame_number, [])
517
+
518
+
519
+ def get_source_file(self) -> str:
520
+ """Get the source video file path."""
521
+ return self._source_file
522
+
523
+
524
+ def get_frame_numbers_with_detections(self) -> List[int]:
525
+ """Get sorted list of frame numbers with detections."""
526
+ return sorted(self._detections_by_frame.keys())
527
+
528
+
529
+ def load_detections_from_csv(
530
+ csv_path: str,
531
+ source_file: Optional[str] = None,
532
+ ) -> FrameDetections:
533
+ """
534
+ Convenience function to load detections from CSV into a FrameDetections object.
535
+
536
+ Args:
537
+ csv_path: Path to CSV file.
538
+ source_file: Optional source file filter.
539
+
540
+ Returns:
541
+ FrameDetections container with loaded detections.
542
+
543
+ Example:
544
+ detections = load_detections_from_csv("results.csv")
545
+ for frame_num in detections.get_frame_numbers_with_detections():
546
+ frame_dets = detections.get_detections_for_frame(frame_num)
547
+ """
548
+ loader = CSVDetectionLoader(csv_path, source_file)
549
+
550
+ frame_detections = FrameDetections(
551
+ source_file=loader.get_source_file(),
552
+ detections_by_frame={
553
+ frame_num: loader.get_detections_for_frame(frame_num)
554
+ for frame_num in loader.get_frame_numbers_with_detections()
555
+ }
556
+ )
557
+
558
+ return frame_detections