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.
- seavision/__init__.py +3 -0
- seavision/defaults.py +38 -0
- seavision/edge/__init__.py +38 -0
- seavision/edge/bundle.py +189 -0
- seavision/edge/capture.py +107 -0
- seavision/edge/config.py +219 -0
- seavision/edge/postprocess.py +185 -0
- seavision/edge/runtime.py +416 -0
- seavision/edge/writer.py +167 -0
- seavision/engine/__init__.py +52 -0
- seavision/engine/detectors/__init__.py +90 -0
- seavision/engine/detectors/base.py +179 -0
- seavision/engine/detectors/motion/__init__.py +14 -0
- seavision/engine/detectors/motion/background.py +92 -0
- seavision/engine/detectors/motion/detector.py +218 -0
- seavision/engine/detectors/motion/stabiliser.py +174 -0
- seavision/engine/detectors/motion/tracker.py +174 -0
- seavision/engine/detectors/sam3/__init__.py +23 -0
- seavision/engine/detectors/sam3/config.py +309 -0
- seavision/engine/detectors/sam3/detector.py +1316 -0
- seavision/engine/detectors/sam3/native.py +490 -0
- seavision/engine/detectors/yolo/__init__.py +12 -0
- seavision/engine/detectors/yolo/config.py +39 -0
- seavision/engine/detectors/yolo/detector.py +236 -0
- seavision/engine/export/__init__.py +24 -0
- seavision/engine/export/config.py +160 -0
- seavision/engine/export/exporter.py +286 -0
- seavision/engine/export/validation.py +314 -0
- seavision/engine/postprocessor.py +738 -0
- seavision/engine/source/__init__.py +16 -0
- seavision/engine/source/base.py +87 -0
- seavision/engine/source/discovery.py +154 -0
- seavision/engine/source/local.py +155 -0
- seavision/engine/source/s3.py +231 -0
- seavision/engine/visualiser/__init__.py +62 -0
- seavision/engine/visualiser/annotator.py +382 -0
- seavision/engine/visualiser/config.py +127 -0
- seavision/engine/visualiser/loader.py +558 -0
- seavision/engine/visualiser/visualiser.py +402 -0
- seavision/engine/visualiser/writer.py +245 -0
- seavision/gui/__init__.py +0 -0
- seavision/gui/app.py +30 -0
- seavision/gui/main_window.py +891 -0
- seavision/gui/shared/__init__.py +0 -0
- seavision/gui/shared/conversion.py +55 -0
- seavision/gui/shared/session_utils.py +37 -0
- seavision/gui/validation/__init__.py +0 -0
- seavision/gui/validation/button_styles.py +144 -0
- seavision/gui/validation/detection_detail.py +164 -0
- seavision/gui/validation/detection_rect_item.py +486 -0
- seavision/gui/validation/detection_table.py +563 -0
- seavision/gui/validation/interactive_frame_scene.py +340 -0
- seavision/gui/validation/interactive_frame_view.py +252 -0
- seavision/gui/validation/s3_browser.py +645 -0
- seavision/gui/validation/seekable_source.py +262 -0
- seavision/gui/validation/session.py +376 -0
- seavision/gui/validation/tab.py +2503 -0
- seavision/gui/validation/transport_bar.py +172 -0
- seavision/gui/validation/validation_model.py +482 -0
- seavision/gui/validation/video_cache.py +215 -0
- seavision/gui/validation/video_list.py +140 -0
- seavision/gui/validation/video_viewer.py +150 -0
- seavision/gui/validation/video_worker.py +422 -0
- seavision/pipeline.py +856 -0
- seavision/resources/default.yaml +139 -0
- seavision/run_edge.py +96 -0
- seavision/run_export.py +176 -0
- seavision/run_pipeline.py +388 -0
- seavision_python-0.1.1.dist-info/METADATA +286 -0
- seavision_python-0.1.1.dist-info/RECORD +73 -0
- seavision_python-0.1.1.dist-info/WHEEL +5 -0
- seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
- seavision_python-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""High level visualiser orchestrators."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Iterator, List, Optional
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..source.base import VideoSource, VideoMetadata, FrameContext
|
|
9
|
+
from ..detectors.base import Detection
|
|
10
|
+
from .config import VisualiserConfig, OutputMode
|
|
11
|
+
from .annotator import FrameAnnotator
|
|
12
|
+
from .writer import VideoWriterHandle, OutputNameFunction
|
|
13
|
+
from .loader import DetectionSource, CSVDetectionLoader
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AnnotatedFrame:
|
|
18
|
+
"""Container for an annotated frame (for streaming output)."""
|
|
19
|
+
frame: np.ndarray
|
|
20
|
+
frame_number: int
|
|
21
|
+
timestamp: float
|
|
22
|
+
detections: List[Detection]
|
|
23
|
+
source_file: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class VisualisationResult:
|
|
28
|
+
"""Result statistics from visualisation."""
|
|
29
|
+
source_file: str
|
|
30
|
+
output_path: Optional[str]
|
|
31
|
+
total_frames: int
|
|
32
|
+
frames_with_detections: int
|
|
33
|
+
total_detections: int
|
|
34
|
+
|
|
35
|
+
def summary(self) -> str:
|
|
36
|
+
"""Create a human readable output of validation results."""
|
|
37
|
+
return (
|
|
38
|
+
f"Visualised: {Path(self.source_file).name}\n"
|
|
39
|
+
f" Output: {self.output_path or '(stream only)'}\n"
|
|
40
|
+
f" Frames: {self.total_frames} "
|
|
41
|
+
f"({self.frames_with_detections} with detections)\n"
|
|
42
|
+
f" Detections: {self.total_detections}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LiveVisualiser:
|
|
47
|
+
"""
|
|
48
|
+
Visualiser for integration with the detection pipeline.
|
|
49
|
+
|
|
50
|
+
Designed to be called frame-by-frame as detections are generated,
|
|
51
|
+
rather than loading detections from CSV post-hoc.
|
|
52
|
+
|
|
53
|
+
Lifecycle:
|
|
54
|
+
1. Create LiveVisualiser with config
|
|
55
|
+
2. Call start_video() when beginning a new video
|
|
56
|
+
3. Call process_frame() for each frame + its detections
|
|
57
|
+
4. Call end_video() when finished with that video
|
|
58
|
+
|
|
59
|
+
Example (pipeline integration):
|
|
60
|
+
visualiser = LiveVisualiser(config)
|
|
61
|
+
|
|
62
|
+
for source in sources:
|
|
63
|
+
visualiser.start_video(source.get_metadata())
|
|
64
|
+
|
|
65
|
+
for frame, context in source.iter_frames():
|
|
66
|
+
detections = list(detector.process_frame(frame, context))
|
|
67
|
+
annotated = visualiser.process_frame(frame, detections, context)
|
|
68
|
+
|
|
69
|
+
result = visualiser.end_video()
|
|
70
|
+
print(result.summary())
|
|
71
|
+
|
|
72
|
+
Example (custom naming):
|
|
73
|
+
def device_date_namer(meta: VideoMetadata) -> str:
|
|
74
|
+
# Parse S3 URI to extract device and date
|
|
75
|
+
parts = meta.source_file.split('/')
|
|
76
|
+
device = parts[-4] # e.g., "cam-3-0"
|
|
77
|
+
date = parts[-2] # e.g., "2025-01-15"
|
|
78
|
+
stem = Path(meta.source_file).stem
|
|
79
|
+
return f"{device}_{date}_{stem}"
|
|
80
|
+
|
|
81
|
+
visualiser = LiveVisualiser(config, name_function=device_date_namer)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
config: Optional[VisualiserConfig] = None,
|
|
87
|
+
name_function: Optional[OutputNameFunction] = None
|
|
88
|
+
):
|
|
89
|
+
"""
|
|
90
|
+
Initialise the live visualiser.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config: Visualisation configuration. If None, defaults are used.
|
|
94
|
+
name_function: Optional custom function to generate output filenames.
|
|
95
|
+
Takes VideoMetadata, returns the filename stem (without suffix
|
|
96
|
+
or extension). If None, uses default naming based on source path.
|
|
97
|
+
"""
|
|
98
|
+
self._config = config or VisualiserConfig()
|
|
99
|
+
self._name_function = name_function
|
|
100
|
+
self._annotator = FrameAnnotator(
|
|
101
|
+
self._config.bbox_style,
|
|
102
|
+
self._config.label_style,
|
|
103
|
+
self._config.overlay_style,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Per-video state
|
|
107
|
+
self._writer: Optional[VideoWriterHandle] = None
|
|
108
|
+
self._metadata: Optional[VideoMetadata] = None
|
|
109
|
+
self._frames_processed: int = 0
|
|
110
|
+
self._frames_with_detections: int = 0
|
|
111
|
+
self._total_detections: int = 0
|
|
112
|
+
|
|
113
|
+
def start_video(self, metadata: VideoMetadata) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Begin visualisation for a new video.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
metadata: Metadata for the video being processed.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
RuntimeError: If called while another video is in progress.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
if self._writer is not None:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
"Previous video not ended. Call end_video() first."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self._metadata = metadata
|
|
130
|
+
self._frames_processed = 0
|
|
131
|
+
self._frames_with_detections = 0
|
|
132
|
+
self._total_detections = 0
|
|
133
|
+
|
|
134
|
+
# Open video writer if outputting to file
|
|
135
|
+
if self._config.output_mode in (OutputMode.FILE, OutputMode.BOTH):
|
|
136
|
+
self._writer = VideoWriterHandle(
|
|
137
|
+
self._config.video_output,
|
|
138
|
+
metadata,
|
|
139
|
+
name_function=self._name_function
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def process_frame(
|
|
143
|
+
self, frame: np.ndarray,
|
|
144
|
+
detections: List[Detection],
|
|
145
|
+
context: FrameContext
|
|
146
|
+
) -> Optional[AnnotatedFrame]:
|
|
147
|
+
"""
|
|
148
|
+
Process a single frame with its detections.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
frame: BGR image as numpy array
|
|
152
|
+
detections: List of detections for this frame
|
|
153
|
+
context: Frame context with metadata
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
AnnotatedFrame if output_mode includes STREAM, else None.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: If called when no video is in progress.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if self._metadata is None:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
"No video in progress. Call start_video() first."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Apply confidence filter
|
|
168
|
+
if self._config.min_confidence is not None:
|
|
169
|
+
detections = [
|
|
170
|
+
d for d in detections
|
|
171
|
+
if d.confidence is None
|
|
172
|
+
or d.confidence >= self._config.min_confidence
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# Skip frames without detections if configured
|
|
176
|
+
if self._config.only_frames_with_detections and not detections:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# Update statistics
|
|
180
|
+
self._frames_processed += 1
|
|
181
|
+
self._total_detections += len(detections)
|
|
182
|
+
if detections:
|
|
183
|
+
self._frames_with_detections += 1
|
|
184
|
+
|
|
185
|
+
# Annotate frame
|
|
186
|
+
annotated = self._annotator.annotate_frame(
|
|
187
|
+
frame, detections, context, copy=True
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Write to video file if configured
|
|
191
|
+
if self._writer is not None:
|
|
192
|
+
self._writer.write(annotated)
|
|
193
|
+
|
|
194
|
+
# Return for streaming if configured
|
|
195
|
+
if self._config.output_mode in (OutputMode.STREAM, OutputMode.BOTH):
|
|
196
|
+
return AnnotatedFrame(
|
|
197
|
+
frame=annotated,
|
|
198
|
+
frame_number=context.frame_number,
|
|
199
|
+
timestamp=context.timestamp,
|
|
200
|
+
detections=detections,
|
|
201
|
+
source_file=self._metadata.source_file
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def end_video(self) -> VisualisationResult:
|
|
207
|
+
"""
|
|
208
|
+
Finish visualisation for the current video.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
VisualisationResult with summary statistics.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
RuntimeError: If called when no video is in progress.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
if self._metadata is None:
|
|
218
|
+
raise RuntimeError("No video in progress")
|
|
219
|
+
|
|
220
|
+
# Close video writer
|
|
221
|
+
output_path = None
|
|
222
|
+
if self._writer is not None:
|
|
223
|
+
output_path = str(self._writer.output_path)
|
|
224
|
+
self._writer.close()
|
|
225
|
+
self._writer = None
|
|
226
|
+
|
|
227
|
+
result = VisualisationResult(
|
|
228
|
+
source_file=self._metadata.source_file,
|
|
229
|
+
output_path=output_path,
|
|
230
|
+
total_frames=self._frames_processed,
|
|
231
|
+
frames_with_detections=self._frames_with_detections,
|
|
232
|
+
total_detections=self._total_detections,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Reset state
|
|
236
|
+
self._metadata = None
|
|
237
|
+
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def __enter__(self) -> "LiveVisualiser":
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
246
|
+
# Clean up if video was not properly ended
|
|
247
|
+
if self._writer is not None:
|
|
248
|
+
self._writer.close()
|
|
249
|
+
self._writer = None
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class PostHocVisualiser:
|
|
254
|
+
"""
|
|
255
|
+
Visualiser for post-hoc processing from CSV detection files.
|
|
256
|
+
|
|
257
|
+
Reads detections from CSV and overlays them on the source video.
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
visualiser = PostHocVisualiser(config)
|
|
261
|
+
|
|
262
|
+
with LocalVideoSource("footage/video.ts") as source:
|
|
263
|
+
result = visualiser.visualise(source, "results/video_detections.csv")
|
|
264
|
+
print(result.summary())
|
|
265
|
+
|
|
266
|
+
Example (custom naming):
|
|
267
|
+
def custom_namer(meta: VideoMetadata) -> str:
|
|
268
|
+
return f"reviewed_{Path(meta.source_file).stem}"
|
|
269
|
+
|
|
270
|
+
visualiser = PostHocVisualiser(config, name_function=custom_namer)
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
config: Optional[VisualiserConfig] = None,
|
|
276
|
+
name_function: Optional[OutputNameFunction] = None
|
|
277
|
+
):
|
|
278
|
+
self._config = config or VisualiserConfig()
|
|
279
|
+
self._name_function = name_function
|
|
280
|
+
self._annotator = FrameAnnotator(
|
|
281
|
+
self._config.bbox_style,
|
|
282
|
+
self._config.label_style,
|
|
283
|
+
self._config.overlay_style,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def visualise(
|
|
288
|
+
self,
|
|
289
|
+
video_source: VideoSource,
|
|
290
|
+
csv_path: str,
|
|
291
|
+
) -> Iterator[AnnotatedFrame]:
|
|
292
|
+
"""
|
|
293
|
+
Visualise a video using detections from a CSV file.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
video_source: VideoSource to read frames from.
|
|
297
|
+
csv_path: Path to CSV file with detections.
|
|
298
|
+
|
|
299
|
+
Yields:
|
|
300
|
+
AnnotatedFrame objects if output_mode includes STREAM.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
detection_source = CSVDetectionLoader(
|
|
304
|
+
csv_path,
|
|
305
|
+
source_file=video_source.get_metadata().source_file
|
|
306
|
+
)
|
|
307
|
+
yield from self._visualise(video_source, detection_source)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def visualise_from_source(
|
|
311
|
+
self,
|
|
312
|
+
video_source: VideoSource,
|
|
313
|
+
detection_source: DetectionSource,
|
|
314
|
+
) -> Iterator[AnnotatedFrame]:
|
|
315
|
+
"""
|
|
316
|
+
Visualise a video using a detection source.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
video_source: VideoSource to read frames from.
|
|
320
|
+
detection_source: DetectionSource to provide detections.
|
|
321
|
+
|
|
322
|
+
Yields:
|
|
323
|
+
AnnotatedFrame objects if output_mode includes STREAM.
|
|
324
|
+
"""
|
|
325
|
+
yield from self._visualise(video_source, detection_source)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _visualise(
|
|
329
|
+
self,
|
|
330
|
+
video_source: VideoSource,
|
|
331
|
+
detection_source: DetectionSource,
|
|
332
|
+
) -> Iterator[AnnotatedFrame]:
|
|
333
|
+
"""Core visualisation loop for post-hoc mode."""
|
|
334
|
+
|
|
335
|
+
metadata = video_source.get_metadata()
|
|
336
|
+
writer = None
|
|
337
|
+
|
|
338
|
+
# Statistics
|
|
339
|
+
frames_processed = 0
|
|
340
|
+
frames_with_detections = 0
|
|
341
|
+
total_detections = 0
|
|
342
|
+
|
|
343
|
+
if self._config.output_mode in (OutputMode.FILE, OutputMode.BOTH):
|
|
344
|
+
writer = VideoWriterHandle(
|
|
345
|
+
self._config.video_output,
|
|
346
|
+
metadata,
|
|
347
|
+
name_function=self._name_function
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
for frame, context in video_source.iter_frames():
|
|
352
|
+
# Frame skipping
|
|
353
|
+
if context.frame_number % self._config.frame_skip != 0:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# Get detections for this frame
|
|
357
|
+
detections = detection_source.get_detections_for_frame(
|
|
358
|
+
context.frame_number
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Apply confidence filter
|
|
362
|
+
if self._config.min_confidence is not None:
|
|
363
|
+
detections = [
|
|
364
|
+
d for d in detections
|
|
365
|
+
if d.confidence is None
|
|
366
|
+
or d.confidence >= self._config.min_confidence
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
# Skip frames without detections if configured
|
|
370
|
+
if self._config.only_frames_with_detections and not detections:
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Update statistics
|
|
374
|
+
frames_processed += 1
|
|
375
|
+
if detections:
|
|
376
|
+
frames_with_detections += 1
|
|
377
|
+
total_detections += len(detections)
|
|
378
|
+
|
|
379
|
+
# Annotate frame
|
|
380
|
+
annotated = self._annotator.annotate_frame(
|
|
381
|
+
frame, detections, context
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Write to file
|
|
385
|
+
if writer is not None:
|
|
386
|
+
writer.write(annotated)
|
|
387
|
+
|
|
388
|
+
# Yield for streaming
|
|
389
|
+
if self._config.output_mode in (OutputMode.STREAM, OutputMode.BOTH):
|
|
390
|
+
yield AnnotatedFrame(
|
|
391
|
+
frame=annotated,
|
|
392
|
+
frame_number=context.frame_number,
|
|
393
|
+
timestamp=context.timestamp,
|
|
394
|
+
detections=detections,
|
|
395
|
+
source_file=metadata.source_file
|
|
396
|
+
)
|
|
397
|
+
finally:
|
|
398
|
+
if writer is not None:
|
|
399
|
+
writer.close()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Video file writing management."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
import re
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..source.base import VideoMetadata
|
|
11
|
+
from .config import VideoOutputConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Type alias for custom naming functions
|
|
15
|
+
# Takes VideoMatadata, returns the output filename stem
|
|
16
|
+
# (without suffix/extension)
|
|
17
|
+
OutputNameFunction = Callable[[VideoMetadata], str]
|
|
18
|
+
|
|
19
|
+
def extract_output_stem(
|
|
20
|
+
source_file: str,
|
|
21
|
+
include_parents: int = 2
|
|
22
|
+
) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Extract a suitable output filename stem from a source file path or URI.
|
|
25
|
+
|
|
26
|
+
Handles both local paths and S3 URIs, including enough path components to
|
|
27
|
+
avoid collisions when multiple videos have the same filename.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
source_file: Source file path or S3 URI.
|
|
31
|
+
include_parents: Number of parent directories to include in the output
|
|
32
|
+
stem to help avoid name collisions. Default is 2.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A sanitised string suitable for use as a filename stem.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
# Local path
|
|
39
|
+
extract_output_stem("/home/user/footage/2025-01-15/video_001.ts")
|
|
40
|
+
# -> "2025-01-15_video_001"
|
|
41
|
+
|
|
42
|
+
# S3 URI
|
|
43
|
+
extract_output_stem("s3://bucket/device1/cam1/2025-01-15/video_001.ts")
|
|
44
|
+
# -> "cam1_2025-01-15_video_001"
|
|
45
|
+
|
|
46
|
+
# S3 URI with more context
|
|
47
|
+
extract_output_stem("s3://bucket/device1/cam1/2025-01-15/video_001.ts", include_parents=3)
|
|
48
|
+
# -> "device1_cam1_2025-01-15_video_001"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# Handle S3 URIs - extract the key portion
|
|
52
|
+
s3_match = re.match(r"^s3://[^/]+/(.+)$", source_file)
|
|
53
|
+
if s3_match:
|
|
54
|
+
# Use PurePosixPath for S3 keys (always forward slashes)
|
|
55
|
+
path = PurePosixPath(s3_match.group(1))
|
|
56
|
+
else:
|
|
57
|
+
# Local path
|
|
58
|
+
path = Path(source_file)
|
|
59
|
+
|
|
60
|
+
# Get the filename stem
|
|
61
|
+
stem = path.stem
|
|
62
|
+
|
|
63
|
+
# Build output stem with parent directories
|
|
64
|
+
parts = []
|
|
65
|
+
for i in range(min(include_parents, len(list(path.parents)) - 1)):
|
|
66
|
+
parent_name = path.parents[i].name
|
|
67
|
+
if parent_name: # Skip empty names (root)
|
|
68
|
+
parts.insert(0, parent_name)
|
|
69
|
+
parts.append(stem)
|
|
70
|
+
|
|
71
|
+
# Join with underscores and sanitise
|
|
72
|
+
output_stem = "_".join(parts)
|
|
73
|
+
|
|
74
|
+
# Remove any characters that might cause filesystem issues
|
|
75
|
+
output_stem = re.sub(r'[<>:"/\\|?*]', '_', output_stem)
|
|
76
|
+
|
|
77
|
+
return output_stem
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sanitise_filename(name: str) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Sanitise a string to be safe for use as a filename.
|
|
83
|
+
|
|
84
|
+
Replaces or removes characters that are invalid in filenames across
|
|
85
|
+
common operating systems.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
name: Input string to sanitize.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Sanitized filename string.
|
|
92
|
+
"""
|
|
93
|
+
# Replace problematic characters with underscores
|
|
94
|
+
sanitised = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name)
|
|
95
|
+
|
|
96
|
+
# Collapse multiple underscores
|
|
97
|
+
sanitised = re.sub(r'_+', '_', sanitised)
|
|
98
|
+
|
|
99
|
+
# Strip leading/trailing underscores and whitespace
|
|
100
|
+
sanitised = sanitised.strip('_ ')
|
|
101
|
+
|
|
102
|
+
# Ensure we have something left
|
|
103
|
+
if not sanitised:
|
|
104
|
+
sanitised = "output"
|
|
105
|
+
|
|
106
|
+
return sanitised
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class VideoWriterHandle:
|
|
111
|
+
"""
|
|
112
|
+
Manages the lifecycle of a video file writer.
|
|
113
|
+
|
|
114
|
+
Handles opening, writing frames, and closing video files.
|
|
115
|
+
Uses context manager pattern for safe resource cleanup.
|
|
116
|
+
|
|
117
|
+
Output naming priority:
|
|
118
|
+
1. Custom naming function (if provided)
|
|
119
|
+
2. Default naming using extact_output_stem()
|
|
120
|
+
|
|
121
|
+
Example with custom naming:
|
|
122
|
+
def my_namer(meta: VideoMetadata) -> str:
|
|
123
|
+
# Extract device and date from S3 path
|
|
124
|
+
parts = meta.source_file.split('/')
|
|
125
|
+
return f"{parts[-3]}_{parts[-2]}_{Path(meta.source_file).stem}"
|
|
126
|
+
|
|
127
|
+
with VideoWriterHandle(config, metadata, name_function=my_namer) as writer:
|
|
128
|
+
...
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
config: VideoOutputConfig
|
|
132
|
+
metadata: VideoMetadata
|
|
133
|
+
name_function: Optional[OutputNameFunction] = None
|
|
134
|
+
_writer: Optional[cv2.VideoWriter] = field(default=None, init=False)
|
|
135
|
+
_output_path: Optional[Path] = field(default=None, init=False)
|
|
136
|
+
_frame_count: int = field(default=0, init=False)
|
|
137
|
+
|
|
138
|
+
def __post_init__(self):
|
|
139
|
+
"""Initialise and open the video writer."""
|
|
140
|
+
self._output_path = self._build_output_path()
|
|
141
|
+
self._ensure_output_dir()
|
|
142
|
+
self._check_overwrite()
|
|
143
|
+
self._open_writer()
|
|
144
|
+
|
|
145
|
+
def _build_output_path(self) -> Path:
|
|
146
|
+
"""
|
|
147
|
+
Build the output file path from config and metadata.
|
|
148
|
+
|
|
149
|
+
Uses custom name function if provided, otherwise falls back to
|
|
150
|
+
extract_output_stem() with configured parent directory depth.
|
|
151
|
+
"""
|
|
152
|
+
output_dir = Path(self.config.output_dir)
|
|
153
|
+
|
|
154
|
+
# Determine the output stem
|
|
155
|
+
if self.name_function is not None:
|
|
156
|
+
# Use custom naming function
|
|
157
|
+
stem = self.name_function(self.metadata)
|
|
158
|
+
stem = sanitise_filename(stem)
|
|
159
|
+
else:
|
|
160
|
+
# Default naming with parent directory context
|
|
161
|
+
stem = extract_output_stem(
|
|
162
|
+
self.metadata.source_file,
|
|
163
|
+
include_parents=self.config.include_parent_dirs
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
output_name = stem + self.config.filename_suffix + self.config.format
|
|
167
|
+
return output_dir / output_name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _ensure_output_dir(self) -> None:
|
|
171
|
+
"""Create output directory if it doesn't exist."""
|
|
172
|
+
self._output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_overwrite(self) -> None:
|
|
176
|
+
"""Check if output file exists and handle based on config."""
|
|
177
|
+
if self._output_path.exists() and not self.config.overwrite:
|
|
178
|
+
raise FileExistsError(
|
|
179
|
+
f"Output file already exists: {self._output_path}. "
|
|
180
|
+
"Set overwrite=True to replace."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _open_writer(self) -> None:
|
|
185
|
+
"""Open the cv2 VideoWriter with the specified configuration."""
|
|
186
|
+
|
|
187
|
+
fourcc = cv2.VideoWriter_fourcc(*self.config.codec)
|
|
188
|
+
fps = self.config.fps or self.metadata.fps
|
|
189
|
+
|
|
190
|
+
self._writer = cv2.VideoWriter(
|
|
191
|
+
str(self._output_path),
|
|
192
|
+
fourcc,
|
|
193
|
+
fps,
|
|
194
|
+
(self.metadata.width, self.metadata.height)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if not self._writer.isOpened():
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"Failed to open video writer for {self._output_path}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def write(self, frame: np.ndarray) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Write a frame to the video file.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
frame: BGR image as a numpy array.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
RuntimeError: If the writer is not opened.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
if self._writer is None:
|
|
215
|
+
raise RuntimeError("Video writer is not opened.")
|
|
216
|
+
|
|
217
|
+
self._writer.write(frame)
|
|
218
|
+
self._frame_count += 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
"""Release the video writer resource."""
|
|
223
|
+
if self._writer is not None:
|
|
224
|
+
self._writer.release()
|
|
225
|
+
self._writer = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def frame_count(self) -> int:
|
|
230
|
+
"""Get the number of frames written."""
|
|
231
|
+
return self._frame_count
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def output_path(self) -> Optional[Path]:
|
|
236
|
+
"""Get the output file path."""
|
|
237
|
+
return self._output_path
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def __enter__(self) -> "VideoWriterHandle":
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
244
|
+
self.close()
|
|
245
|
+
return False
|
|
File without changes
|
seavision/gui/app.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SeaVision GUI application entry point."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
"""Launch the SeaVision GUI."""
|
|
7
|
+
|
|
8
|
+
# Import PySide6 with error handling if not available
|
|
9
|
+
try:
|
|
10
|
+
from PySide6.QtWidgets import QApplication
|
|
11
|
+
except ImportError:
|
|
12
|
+
print(
|
|
13
|
+
"PySide6 is required for the GUI. "
|
|
14
|
+
"Install with: pip install seavision[gui]"
|
|
15
|
+
)
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
app = QApplication(sys.argv)
|
|
19
|
+
app.setApplicationName("SeaVision")
|
|
20
|
+
|
|
21
|
+
from seavision.gui.main_window import MainWindow
|
|
22
|
+
|
|
23
|
+
window = MainWindow()
|
|
24
|
+
window.show()
|
|
25
|
+
|
|
26
|
+
sys.exit(app.exec())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|