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,215 @@
1
+ """
2
+ Local file cache for remote-sourced videos (e.g. from S3)
3
+
4
+ Downloads videos from the remote source to a local cache directory so that
5
+ SeekableVideoSource can open them with random access seeking. The cache
6
+ persists between sessions.
7
+
8
+ Supported sources:
9
+ - S3 (e.g. s3://my-bucket/path/to/video.mp4)
10
+
11
+ This module currently depends on boto3 but has no Qt dependency.
12
+ """
13
+
14
+ import hashlib
15
+ import logging
16
+ import shutil
17
+ from pathlib import Path
18
+
19
+ from seavision.engine.source.s3 import parse_s3_uri
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ try:
24
+ import boto3
25
+ from botocore.exceptions import ClientError, NoCredentialsError
26
+ BOTO3_AVAILABLE = True
27
+ except ImportError:
28
+ BOTO3_AVAILABLE = False
29
+
30
+
31
+ _DEFAULT_CACHE_DIR = Path.home() / ".seavision" / "cache"
32
+
33
+
34
+ class S3VideoCache:
35
+ """
36
+ Local file cache for S3 videos.
37
+
38
+ Downloads videos from S3 on demand and stored them in a flat directory keyed
39
+ by a hash of the S3 URI.
40
+ """
41
+
42
+ def __init__(self, cache_dir: Path | None = None):
43
+ self.cache_dir = cache_dir or _DEFAULT_CACHE_DIR
44
+
45
+ def _ensure_cache_dir(self):
46
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ @staticmethod
49
+ def _cache_key(s3_uri: str) -> str:
50
+ """
51
+ Derive a filesystem-safe cache ky from an S3 URI.
52
+
53
+ Uses SHA-256 truncated to 16 hex chars plus the original filename for
54
+ readability.
55
+ """
56
+ uri_hash = hashlib.sha256(s3_uri.encode()).hexdigest()[:16]
57
+ filename = Path(s3_uri.split("/")[-1]).name
58
+ return f"{uri_hash}_{filename}"
59
+
60
+ def get_cached_path(self, s3_uri: str) -> str | None:
61
+ """
62
+ Return the local cached path for an S3 URI if it exists.
63
+
64
+ Does not attempt to download. Returns None if the file is not in the
65
+ cache.
66
+ """
67
+ cache_key = self._cache_key(s3_uri)
68
+ cache_path = self.cache_dir / cache_key
69
+ if cache_path.exists():
70
+ return str(cache_path)
71
+ return None
72
+
73
+ def get_or_download(
74
+ self,
75
+ s3_uri: str,
76
+ profile_name: str | None = None
77
+ ) -> Path:
78
+ """
79
+ Return the local path for an S3 video, downloading if needed.
80
+
81
+ Downloads to a .downloading temp file first, then renames automatically
82
+ to prevent partial files being treated as complete.
83
+ """
84
+ if not BOTO3_AVAILABLE:
85
+ raise ImportError(
86
+ "boto3 is required for S3 video downloads. "
87
+ "Install with pip install boto3"
88
+ )
89
+
90
+ self._ensure_cache_dir()
91
+ cached = self.get_cached_path(s3_uri)
92
+
93
+ if cached is not None:
94
+ logger.debug("Cache hit for %s", s3_uri)
95
+ return Path(cached)
96
+
97
+ # Not cached - need to download
98
+ local_path = self.cache_dir / self._cache_key(s3_uri)
99
+
100
+ logger.info("Downloading %s to cache...", s3_uri)
101
+ bucket, key = parse_s3_uri(s3_uri)
102
+ tmp_path = local_path.with_suffix(".downloading")
103
+
104
+ try:
105
+ session = boto3.Session(profile_name=profile_name)
106
+ s3_client = session.client("s3")
107
+
108
+ s3_client.download_file(bucket, key, str(tmp_path))
109
+ tmp_path.rename(local_path)
110
+ logger.info("Cached %s to %s", s3_uri, local_path)
111
+ return local_path
112
+
113
+ except NoCredentialsError:
114
+ if tmp_path.exists():
115
+ tmp_path.unlink()
116
+ raise RuntimeError(
117
+ "AWS credentials not found. "
118
+ "Run 'aws sso login' before opening S3 sessions."
119
+ )
120
+
121
+ except ClientError as e:
122
+ if tmp_path.exists():
123
+ tmp_path.unlink()
124
+ error_code = e.response.get("Error", {}).get("Code", "")
125
+ if error_code == "ExpiredToken":
126
+ raise RuntimeError(
127
+ "AWS credentials have expired. "
128
+ "Run 'aws sso login' to refresh them."
129
+ )
130
+ raise RuntimeError(
131
+ f"Failed to download {s3_uri}:\n\n{e}"
132
+ ) from e
133
+
134
+ except Exception as e:
135
+ if tmp_path.exists():
136
+ tmp_path.unlink()
137
+ raise RuntimeError(
138
+ f"Failed to download {s3_uri}:\n\n{e}"
139
+ ) from e
140
+
141
+
142
+ def calculate_download_size(
143
+ self,
144
+ s3_uris: list[str],
145
+ profile_name: str | None = None,
146
+ ) -> tuple[int, list[str]]:
147
+ """
148
+ Calculate total download size for uncached S3 URIs using HEAD requests.
149
+
150
+ Returns:
151
+ Tuple of (total_bytes, uris_needing_downloading)
152
+ """
153
+ if not BOTO3_AVAILABLE:
154
+ raise ImportError(
155
+ "boto3 is required for S3 video downloads. "
156
+ "Install with pip install boto3"
157
+ )
158
+
159
+ session = boto3.Session(profile_name=profile_name)
160
+ s3_client = session.client("s3")
161
+
162
+ total_bytes = 0
163
+ need_download: list[str] = []
164
+
165
+ for uri in s3_uris:
166
+ if (self.cache_dir / self._cache_key(uri)).exists():
167
+ continue
168
+
169
+ bucket, key = parse_s3_uri(uri)
170
+ try:
171
+ response = s3_client.head_object(
172
+ Bucket=bucket,
173
+ Key=key
174
+ )
175
+ total_bytes += response["ContentLength"]
176
+ need_download.append(uri)
177
+
178
+ except (ClientError, NoCredentialsError) as e:
179
+ raise RuntimeError(
180
+ f"Cannot determine size of {uri}: {e}"
181
+ ) from e
182
+
183
+ except Exception as e:
184
+ # Catches connection errors, DNS failures, timeouts,
185
+ # and any other network-level issue from boto3/urllib3
186
+ raise RuntimeError(
187
+ f"Cannot connect to S3 for {uri}:\n\n{e}"
188
+ ) from e
189
+
190
+ return total_bytes, need_download
191
+
192
+
193
+ def cache_size(self) -> int:
194
+ """Return total size of cached files in bytes."""
195
+ if not self.cache_dir.exists():
196
+ return 0
197
+ return sum(
198
+ f.stat().st_size
199
+ for f in self.cache_dir.iterdir()
200
+ if f.is_file() and not f.name.endswith(".downloading")
201
+ )
202
+
203
+ def clear_cache(self) -> int:
204
+ """Delete all cached files. Returns bytes freed."""
205
+ freed = self.cache_size()
206
+ if self.cache_dir.exists():
207
+ shutil.rmtree(self.cache_dir)
208
+ logger.info(
209
+ "Cleared cache: %.1f MB freed", freed / (1024**2)
210
+ )
211
+ return freed
212
+
213
+ def is_cached(self, s3_uri: str) -> bool:
214
+ """Check whether a specific URI is already cached."""
215
+ return (self.cache_dir / self._cache_key(s3_uri)).exists()
@@ -0,0 +1,140 @@
1
+ """
2
+ Video list sidebar widget.
3
+
4
+ Shows all videos in the current session with detection counts and review
5
+ progress. Clicking a video switched the viewer and detection table to that
6
+ video.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from PySide6.QtCore import Signal, Qt
12
+ from PySide6.QtGui import QBrush, QColor
13
+ from PySide6.QtWidgets import (
14
+ QLabel,
15
+ QListWidget,
16
+ QListWidgetItem,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+ _COLOUR_NOT_STARTED = QColor(150, 150, 150) # GREY
22
+ _COLOUR_IN_PROGRESS = QColor("#CC7A00") # AMBER/ORANGE
23
+ _COLOUR_COMPLETE = QColor(60, 160, 60) # GREEN
24
+ _COLOUR_UNAVAILABLE = QColor(180, 180, 180) # LIGHT GREY for videos that can't be loaded (e.g. missing S3 creds)
25
+
26
+
27
+ class VideoListWidget(QWidget):
28
+ """
29
+ Sidebar showing all videos in the current session.
30
+
31
+ Signals:
32
+ video_selected: Emitted when the users clicks a video. Carries the
33
+ source_file string.
34
+ """
35
+
36
+ video_selected = Signal(str)
37
+
38
+ def __init__(self, parent=None):
39
+ super().__init__(parent)
40
+
41
+ # --- Main layout ---
42
+ layout = QVBoxLayout(self)
43
+ layout.setContentsMargins(0, 4, 0, 0)
44
+
45
+ # --- Header ---
46
+ self._header = QLabel("Videos:")
47
+ self._header.setStyleSheet("font-weight: bold; padding: 4px;")
48
+ layout.addWidget(self._header)
49
+
50
+ # --- Video list ---
51
+ self._list = QListWidget()
52
+ self._list.setAlternatingRowColors(True)
53
+ self._list.currentItemChanged.connect(self._on_item_changed)
54
+ layout.addWidget(self._list)
55
+
56
+ def set_videos(self, video_info: list[dict]) -> None:
57
+ """
58
+ Populate the list with video entries.
59
+
60
+ Args:
61
+ video_info: List of dicts with keys:
62
+ source_file, total, rviewed, available.
63
+ """
64
+ self._list.blockSignals(True)
65
+ self._list.clear()
66
+
67
+ for info in video_info:
68
+ source_file = info["source_file"]
69
+ filename = Path(source_file).name
70
+ total = info["total"]
71
+ reviewed = info["reviewed"]
72
+ available = info.get("available", True)
73
+
74
+ text = f"{filename} ({reviewed}/{total})"
75
+ item = QListWidgetItem(text)
76
+ item.setToolTip(source_file)
77
+ item.setData(Qt.ItemDataRole.UserRole, source_file)
78
+
79
+ if not available:
80
+ item.setForeground(QBrush(_COLOUR_UNAVAILABLE))
81
+ item.setFlags(
82
+ item.flags() & ~Qt.ItemFlag.ItemIsEnabled
83
+ )
84
+ item.setToolTip(f"{source_file} (not found)")
85
+ elif reviewed == 0:
86
+ item.setForeground(QBrush(_COLOUR_NOT_STARTED))
87
+ elif reviewed >= total:
88
+ item.setForeground(QBrush(_COLOUR_COMPLETE))
89
+ else:
90
+ item.setForeground(QBrush(_COLOUR_IN_PROGRESS))
91
+
92
+ self._list.addItem(item)
93
+
94
+ self._list.blockSignals(False)
95
+ self._header.setText(f"Videos ({len(video_info)}):")
96
+
97
+ def update_progress(
98
+ self, source_file: str, progress: dict
99
+ ) -> None:
100
+ """Update the progress display for a single video."""
101
+ for row in range(self._list.count()):
102
+ item = self._list.item(row)
103
+ if item.data(Qt.ItemDataRole.UserRole) != source_file:
104
+ continue
105
+
106
+ total = progress["total"]
107
+ reviewed = progress["reviewed"]
108
+ filename = Path(source_file).name
109
+ item.setText(f"{filename} ({reviewed}/{total})")
110
+
111
+ if reviewed == 0:
112
+ item.setForeground(QBrush(_COLOUR_NOT_STARTED))
113
+ elif reviewed >= total:
114
+ item.setForeground(QBrush(_COLOUR_COMPLETE))
115
+ else:
116
+ item.setForeground(QBrush(_COLOUR_IN_PROGRESS))
117
+ break
118
+
119
+ def _on_item_changed(
120
+ self,
121
+ current: QListWidgetItem,
122
+ _previous: QListWidgetItem,
123
+ ) -> None:
124
+ if current is None:
125
+ return
126
+ source_file = current.data(Qt.ItemDataRole.UserRole)
127
+ if source_file:
128
+ self.video_selected.emit(source_file)
129
+
130
+ def select_video(self, source_file: str) -> None:
131
+ """Programmatically select a video in the list."""
132
+ for row in range(self._list.count()):
133
+ item = self._list.item(row)
134
+ if item.data(Qt.ItemDataRole.UserRole) == source_file:
135
+ self._list.setCurrentItem(item)
136
+ return
137
+
138
+ def clear(self) -> None:
139
+ self._list.clear()
140
+ self._header.setText("Videos:")
@@ -0,0 +1,150 @@
1
+ """Video frame display widget."""
2
+
3
+ from PySide6.QtCore import Signal, Qt, QPoint
4
+ from PySide6.QtGui import QCursor, QImage, QPixmap
5
+ from PySide6.QtWidgets import QLabel, QSizePolicy
6
+
7
+ class FrameDisplay(QLabel):
8
+ """
9
+ Displays video frames sclaed to fit the available space.
10
+
11
+ Recieves QImage objects (from the video worker), converts them to QPixmap,
12
+ and renders them centered with correct aspect ratio. Handles window resizing
13
+ by re-scaling the last displayed frame.
14
+ """
15
+
16
+ # Emitted when the user clicks the frame in 'add' mode.
17
+ frame_clicked = Signal(float, float) # x, y in frame's pixel space
18
+
19
+ def __init__(self, parent=None):
20
+ super().__init__(parent)
21
+
22
+ # Detection adding
23
+ self._add_mode = False
24
+ self._frame_width: int = 0
25
+ self._frame_height: int = 0
26
+
27
+ # Set alignment policies
28
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
29
+ self.setSizePolicy(
30
+ QSizePolicy.Policy.Expanding,
31
+ QSizePolicy.Policy.Expanding
32
+ )
33
+ self.setMinimumSize(320, 240)
34
+
35
+ # Placeholder appearance
36
+ self.setStyleSheet("background-color: #1e1e1e; color: #888888;")
37
+ self.setText(
38
+ "Open a video or session to begin\n\n"
39
+ "File → New Session (Ctrl+N)\n"
40
+ "File → Open Video (Ctrl+O)\n\n"
41
+ "Or drag a CSV, video or .seavision-session file here"
42
+ )
43
+
44
+ # Placeholder for the unscaled original frame
45
+ self._original_pixmap: QPixmap | None = None
46
+
47
+ def update_frame(self, image: QImage, frame_number: int,
48
+ timestamp: float) -> None:
49
+ """
50
+ Display a new frame from the video worker.
51
+
52
+ Args:
53
+ image: The frame as a QImage.
54
+ frame_number: Frame index (not used here, but part of the signal
55
+ signature - other slots use it).
56
+ timestamp: Time in seconds (same)
57
+ """
58
+ # Store frame dimensions
59
+ self._frame_width = image.width()
60
+ self._frame_height = image.height()
61
+
62
+ self._original_pixmap = QPixmap.fromImage(image)
63
+ self._scale_and_display()
64
+
65
+ def _scale_and_display(self) -> None:
66
+ """Scale the stored pixmap to fit the current widget size."""
67
+ if self._original_pixmap is None:
68
+ return
69
+
70
+ scaled = self._original_pixmap.scaled(
71
+ self.size(),
72
+ Qt.AspectRatioMode.KeepAspectRatio,
73
+ Qt.TransformationMode.SmoothTransformation
74
+ )
75
+ self.setPixmap(scaled)
76
+
77
+
78
+ # --- Handle adding detection ---
79
+ def _widget_to_frame_coords(
80
+ self, widget_x: int, widget_y: int
81
+ ) -> tuple[float, float] | None:
82
+ """
83
+ Convert widget pixel coordinates to original frame coordinates.
84
+
85
+ The label displays the frame scaled to fit with aspect ratio preserved.
86
+ This method reverses that transformation.
87
+
88
+ Returns:
89
+ (frame_x, frame_y) in the original frame's pixel space, or None if
90
+ the click is outside the displayed frame area (i.e. on the
91
+ letterbox area).
92
+ """
93
+ pixmap = self.pixmap()
94
+ if pixmap is None or pixmap.isNull():
95
+ return None
96
+
97
+ if self._frame_width == 0 or self._frame_height == 0:
98
+ return None
99
+
100
+ # The displayed pixmap size (after scaling to fit the label)
101
+ display_w = pixmap.width()
102
+ display_h = pixmap.height()
103
+
104
+ # Calculate the label ofest
105
+ offset_x = (self.width() - display_w) / 2
106
+ offset_y = (self.height() - display_h) / 2
107
+
108
+ # Position relative to the displayed image
109
+ rel_x = widget_x - offset_x
110
+ rel_y = widget_y - offset_y
111
+
112
+ # Check bounds to see if click is in letterbox area
113
+ if rel_x < 0 or rel_y < 0:
114
+ return None
115
+ if rel_x > display_w or rel_y > display_h:
116
+ return None
117
+
118
+ # Scale back to original frame coordinates
119
+ frame_x = rel_x * (self._frame_width / display_w)
120
+ frame_y = rel_y * (self._frame_height / display_h)
121
+
122
+ return frame_x, frame_y
123
+
124
+ def set_add_mode(self, enabled: bool) -> None:
125
+ """Toggle add-detection mode. Changes the cursor as a visual cue."""
126
+ self._add_mode = enabled
127
+ if enabled:
128
+ self.setCursor(Qt.CursorShape.CrossCursor)
129
+ else:
130
+ self.setCursor(Qt.CursorShape.ArrowCursor)
131
+
132
+ def mousePressEvent(self, event) -> None:
133
+ """Handle clicks - in add mode, emit the frame coordinates."""
134
+ if self._add_mode and event.button() == Qt.MouseButton.LeftButton:
135
+ coords = self._widget_to_frame_coords(
136
+ event.position().x(), event.position().y()
137
+ )
138
+ if coords is not None:
139
+ frame_x, frame_y = coords
140
+ self.frame_clicked.emit(frame_x, frame_y)
141
+ # Exit add mode after one click
142
+ self.set_add_mode(False)
143
+ else:
144
+ super().mousePressEvent(event)
145
+
146
+ def resizeEvent(self, event) -> None:
147
+ """Re-scale the frame when the widget is resized."""
148
+ self._scale_and_display()
149
+ super().resizeEvent(event)
150
+