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,16 @@
|
|
|
1
|
+
"""Video source handling."""
|
|
2
|
+
|
|
3
|
+
from .base import FrameContext, VideoMetadata, VideoSource
|
|
4
|
+
from .local import LocalVideoSource
|
|
5
|
+
from .s3 import S3VideoSource
|
|
6
|
+
from .discovery import discover_local_videos, discover_s3_videos
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"FrameContext",
|
|
10
|
+
"VideoMetadata",
|
|
11
|
+
"VideoSource",
|
|
12
|
+
"LocalVideoSource",
|
|
13
|
+
"S3VideoSource",
|
|
14
|
+
"discover_local_videos",
|
|
15
|
+
"discover_s3_videos",
|
|
16
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Base classes for video source handling."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterator, Tuple
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class VideoMetadata:
|
|
11
|
+
"""
|
|
12
|
+
Metadata for a video source.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
source_file: Path of the original source file.
|
|
16
|
+
fps: Frames per second of the video.
|
|
17
|
+
frame_count: Total number of frames in the video.
|
|
18
|
+
width: Width of the video frames (pixels).
|
|
19
|
+
height: Height of the video frames (pixels).
|
|
20
|
+
duration: Duration of the video (seconds).
|
|
21
|
+
"""
|
|
22
|
+
source_file: str
|
|
23
|
+
fps: float
|
|
24
|
+
frame_count: int
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
duration: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FrameContext:
|
|
32
|
+
"""Context information passed with each frame to detectors.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
source_file: Path or identifier of the source video.
|
|
36
|
+
frame_number: Frame index within the video.
|
|
37
|
+
timestamp: Timestamp within the video (seconds from start).
|
|
38
|
+
fps: Frames per second of the source video.
|
|
39
|
+
"""
|
|
40
|
+
source_file: str
|
|
41
|
+
frame_number: int
|
|
42
|
+
timestamp: float
|
|
43
|
+
fps: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VideoSource(ABC):
|
|
47
|
+
"""Abstract base class for video sources.
|
|
48
|
+
|
|
49
|
+
Provides a common interface for loading video frames from different
|
|
50
|
+
backends (local files, S3, etc.).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def iter_frames(self) -> Iterator[Tuple[np.ndarray, FrameContext]]:
|
|
55
|
+
"""Iterate over frames in the video.
|
|
56
|
+
|
|
57
|
+
Yields:
|
|
58
|
+
Tuple of (frame, context) where frame is a BGR numpy array
|
|
59
|
+
and context contains metadata about the frame.
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_metadata(self) -> VideoMetadata:
|
|
65
|
+
"""Get metadata about the video source.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
VideoMetadata object with file information.
|
|
69
|
+
"""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def __enter__(self):
|
|
73
|
+
"""Context manager entry."""
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
77
|
+
"""Context manager exit - performs cleanup."""
|
|
78
|
+
self.close()
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
"""Release any resources held by the source.
|
|
83
|
+
|
|
84
|
+
Override this method if your source needs cleanup
|
|
85
|
+
(e.g., closing file handles, network connections).
|
|
86
|
+
"""
|
|
87
|
+
pass
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Video source discovery functions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .base import VideoSource
|
|
8
|
+
from .local import LocalVideoSource
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_local_videos(
|
|
14
|
+
path: str,
|
|
15
|
+
pattern: str = "*.ts"
|
|
16
|
+
) -> List[VideoSource]:
|
|
17
|
+
"""
|
|
18
|
+
Discover video files on the local file system.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Path to a video file or directory containing videos.
|
|
22
|
+
pattern: Glob pattern to match video files in directories.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of LocalVideoSource instances (unopened - will opened when iterated
|
|
26
|
+
in the pipeline).
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
FileNotFoundError: If the specified path does not exist.
|
|
30
|
+
ValueError: If no video files are found in the specified directory.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
sources = discover_local_videos("./footage/", "*.ts")
|
|
34
|
+
for source in sources:
|
|
35
|
+
with source:
|
|
36
|
+
for frame, context in source.iter_frames():
|
|
37
|
+
# process frame
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
input_path = Path(path)
|
|
41
|
+
|
|
42
|
+
if input_path.is_file():
|
|
43
|
+
logger.debug(f"Discovered single video: {input_path}")
|
|
44
|
+
return [LocalVideoSource(str(input_path))]
|
|
45
|
+
|
|
46
|
+
elif input_path.is_dir():
|
|
47
|
+
video_paths = list(input_path.glob(pattern))
|
|
48
|
+
|
|
49
|
+
if not video_paths:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"No files matching '{pattern}' found in {input_path}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger.debug(f"Discovered {len(video_paths)} videos in: {input_path}")
|
|
55
|
+
return [LocalVideoSource(str(p)) for p in video_paths]
|
|
56
|
+
|
|
57
|
+
else:
|
|
58
|
+
raise FileNotFoundError(f"Path not found: {input_path}")
|
|
59
|
+
|
|
60
|
+
def discover_s3_videos(
|
|
61
|
+
bucket: str,
|
|
62
|
+
prefix: str = "",
|
|
63
|
+
pattern: str = "*.ts",
|
|
64
|
+
region_name: Optional[str] = None,
|
|
65
|
+
profile_name: Optional[str] = None,
|
|
66
|
+
endpoint_url: Optional[str] = None,
|
|
67
|
+
) -> List[VideoSource]:
|
|
68
|
+
"""
|
|
69
|
+
Discover video files in an S3 bucket.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
bucket: S3 bucket name.
|
|
73
|
+
prefix: Key prefix to filter objects (e.g., "footage/2025/").
|
|
74
|
+
pattern: Glob pattern for matching video filenames (e.g. "*.ts").
|
|
75
|
+
region_name: AWS region name, if None uses the default from config.
|
|
76
|
+
profile_name: AWS profile name for SSO authentication. If None,
|
|
77
|
+
uses default credentials chain.
|
|
78
|
+
endpoint_url: Custom S3-compatible endpoint URL (e.g. Wasabi).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
List of S3VideoSource instances (not yet connected).
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ImportError: If boto3 is not installed.
|
|
85
|
+
ValueError: If no matching video files are found.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
# Using SSO profile
|
|
89
|
+
sources = discover_s3_videos(
|
|
90
|
+
bucket="my-buoy-footage",
|
|
91
|
+
prefix="2025/10/",
|
|
92
|
+
pattern="*.ts",
|
|
93
|
+
profile_name="sso-profile-name"
|
|
94
|
+
)
|
|
95
|
+
for source in sources:
|
|
96
|
+
with source:
|
|
97
|
+
for frame, context in source.iter_frames():
|
|
98
|
+
# process frame
|
|
99
|
+
pass
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
import boto3
|
|
103
|
+
except ImportError:
|
|
104
|
+
raise ImportError(
|
|
105
|
+
"boto3 is required for S3 support. "
|
|
106
|
+
"Install with `pip install boto3`."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
from fnmatch import fnmatch
|
|
110
|
+
from .s3 import S3VideoSource
|
|
111
|
+
|
|
112
|
+
# Create S3 client via session (supports SSO profiles and custom endpoints)
|
|
113
|
+
session = boto3.Session(
|
|
114
|
+
profile_name=profile_name,
|
|
115
|
+
region_name=region_name,
|
|
116
|
+
)
|
|
117
|
+
s3_client = session.client("s3", endpoint_url=endpoint_url)
|
|
118
|
+
paginator = s3_client.get_paginator("list_objects_v2")
|
|
119
|
+
|
|
120
|
+
matching_keys = []
|
|
121
|
+
|
|
122
|
+
# Paginate through all objects in the bucket with the given prefix
|
|
123
|
+
page_iterator = paginator.paginate(Bucket=bucket, Prefix=prefix)
|
|
124
|
+
|
|
125
|
+
for page in page_iterator:
|
|
126
|
+
if "Contents" not in page:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
for obj in page["Contents"]:
|
|
130
|
+
key = obj["Key"]
|
|
131
|
+
filename = key.split("/")[-1]
|
|
132
|
+
|
|
133
|
+
if fnmatch(filename, pattern):
|
|
134
|
+
matching_keys.append(key)
|
|
135
|
+
|
|
136
|
+
if not matching_keys:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"No files matching '{pattern}' found in s3://{bucket}/{prefix}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Sort for deterministic order
|
|
142
|
+
matching_keys.sort()
|
|
143
|
+
|
|
144
|
+
logger.debug(f"Discovered {len(matching_keys)} videos in s3://{bucket}/{prefix}")
|
|
145
|
+
|
|
146
|
+
return [
|
|
147
|
+
S3VideoSource(
|
|
148
|
+
f"s3://{bucket}/{key}",
|
|
149
|
+
region_name=region_name,
|
|
150
|
+
profile_name=profile_name,
|
|
151
|
+
endpoint_url=endpoint_url,
|
|
152
|
+
)
|
|
153
|
+
for key in matching_keys
|
|
154
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Loading a locally stored video file as a video source."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterator, Tuple
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from .base import FrameContext, VideoMetadata, VideoSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalVideoSource(VideoSource):
|
|
13
|
+
"""
|
|
14
|
+
Loads video frames from a local file using OpenCV.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
file_path: Path to the local video file.
|
|
18
|
+
|
|
19
|
+
Methods:
|
|
20
|
+
get_metadata: Get metadata about the video file.
|
|
21
|
+
iter_frames: Iterate over frames in the video file.
|
|
22
|
+
process_gopro: Process GoPro video to strip audio stream.
|
|
23
|
+
close: Release the video capture resource.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
with LocalVideoSource("footage/clip_001.ts") as source:
|
|
27
|
+
metadata = source.get_metadata()
|
|
28
|
+
print(f"Processing {metadata.duration:.1f}s video")
|
|
29
|
+
|
|
30
|
+
for frame, context in source.iter_frames():
|
|
31
|
+
# process frame
|
|
32
|
+
pass
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, filepath: str):
|
|
36
|
+
"""
|
|
37
|
+
Initialise the video source.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
filepath: Path to the local video file.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If the video file cannot be opened
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
self.filepath = filepath
|
|
47
|
+
self._cap = cv2.VideoCapture(filepath)
|
|
48
|
+
|
|
49
|
+
if not self._cap.isOpened():
|
|
50
|
+
raise ValueError(f"Cannot open video file: {filepath}")
|
|
51
|
+
|
|
52
|
+
def get_metadata(self) -> VideoMetadata:
|
|
53
|
+
"""
|
|
54
|
+
Get metadata about the video file.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
VideoMetadata object with file propoerties.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
fps = self._cap.get(cv2.CAP_PROP_FPS)
|
|
61
|
+
frame_count = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
62
|
+
width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
63
|
+
height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
64
|
+
duration = frame_count / fps if fps > 0 else 0.0
|
|
65
|
+
|
|
66
|
+
return VideoMetadata(
|
|
67
|
+
source_file=self.filepath,
|
|
68
|
+
fps=fps,
|
|
69
|
+
frame_count=frame_count,
|
|
70
|
+
width=width,
|
|
71
|
+
height=height,
|
|
72
|
+
duration=duration
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def iter_frames(self) -> Iterator[Tuple[np.ndarray, FrameContext]]:
|
|
76
|
+
"""
|
|
77
|
+
Iterate over frames in the video file.
|
|
78
|
+
|
|
79
|
+
Yields:
|
|
80
|
+
Tuple of (frame, context) where frame is a BGR numpy array
|
|
81
|
+
and context contains metadata about the frame.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
fps = self._cap.get(cv2.CAP_PROP_FPS)
|
|
85
|
+
frame_number = 0
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
ret, frame = self._cap.read()
|
|
89
|
+
|
|
90
|
+
if not ret:
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
timestamp = frame_number / fps if fps > 0 else 0.0
|
|
94
|
+
context = FrameContext(
|
|
95
|
+
source_file=self.filepath,
|
|
96
|
+
frame_number=frame_number,
|
|
97
|
+
timestamp=timestamp,
|
|
98
|
+
fps=fps
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
yield frame, context
|
|
102
|
+
frame_number += 1
|
|
103
|
+
|
|
104
|
+
def process_gopro(self, processed_path: Path, overwrite: bool = False) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Method for processing local GoPro video to strip audio stream. This
|
|
107
|
+
method uses ffmpeg to strip the audio stream from the video and save
|
|
108
|
+
the processed video to the specified path.
|
|
109
|
+
|
|
110
|
+
The LocalVideoSource object will point to the updated processed file
|
|
111
|
+
after this method is called.
|
|
112
|
+
|
|
113
|
+
An ffmpeg installation is required for this method to work.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
processed_path: Path at which the processed output should be
|
|
117
|
+
saved.
|
|
118
|
+
overwrite: Whether to overwrite an existing processed file.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
FileExistsError: If the processed file already exists and
|
|
122
|
+
overwrite is False.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
# Ensure output path exists
|
|
126
|
+
processed_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
# Check if the file already exists
|
|
129
|
+
if processed_path.exists() and not overwrite:
|
|
130
|
+
raise FileExistsError(f"Processed file already exists: {processed_path}")
|
|
131
|
+
|
|
132
|
+
cmd = [
|
|
133
|
+
"ffmpeg",
|
|
134
|
+
"-i", str(self.filepath),
|
|
135
|
+
"-y", # Overwrite output files without asking
|
|
136
|
+
"-map", "0:v", # Map only the video stream
|
|
137
|
+
"-c", "copy",
|
|
138
|
+
str(processed_path)
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
subprocess.run(cmd, check=True)
|
|
142
|
+
|
|
143
|
+
# Update path
|
|
144
|
+
self.filepath = str(processed_path)
|
|
145
|
+
|
|
146
|
+
# Update capture object
|
|
147
|
+
self._cap.release()
|
|
148
|
+
self._cap = cv2.VideoCapture(self.filepath)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
"""Release the video capture resource."""
|
|
153
|
+
if self._cap is not None and self._cap.isOpened():
|
|
154
|
+
self._cap.release()
|
|
155
|
+
self._cap = None
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Loading video files from AWS S3 as a video source."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Iterator, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import boto3
|
|
12
|
+
from botocore.exceptions import ClientError
|
|
13
|
+
BOTO3_AVAILABLE = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
BOTO3_AVAILABLE = False
|
|
16
|
+
|
|
17
|
+
from .base import FrameContext, VideoMetadata, VideoSource
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_s3_uri(uri: str) -> Tuple[str, str]:
|
|
23
|
+
"""
|
|
24
|
+
Parse an S3 URI into bucket and key components.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
uri: S3 URI (e.g., "s3://bucket-name/path/to/.file.ts").
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (bucket_name, key).
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If the URI is not a valid S3 URI.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
match = re.match(r"^s3://([^/]+)/(.+)$", uri)
|
|
37
|
+
if not match:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Invalid S3 URI: {uri}. Expected format: s3://bucket/key"
|
|
40
|
+
)
|
|
41
|
+
return match.group(1), match.group(2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class S3VideoSource(VideoSource):
|
|
45
|
+
"""
|
|
46
|
+
Loads video frames from an AWS S3 bucket using pre-signed URLs.
|
|
47
|
+
|
|
48
|
+
Uses OpenCV's HTTP streaming capability to read video directly from S3
|
|
49
|
+
without downloading to a temporary file.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
uri: S3 URI to the video file (e.g., "s3://bucket-name/path/to/.file.ts").
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
with S3VideoSource("s3://my-bucket/footage/clip_001.ts") as source:
|
|
56
|
+
metadata = source.get_metadata()
|
|
57
|
+
print(f"Processing {metadata.duration:.1f}s video")
|
|
58
|
+
|
|
59
|
+
for frame, context in source.iter_frames():
|
|
60
|
+
# process frame
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
Note:
|
|
64
|
+
Requires boto3 to be installed and AWS credentials configured.
|
|
65
|
+
Credentials are read from environment variables, ~/.aws/credentials,
|
|
66
|
+
IAM role (if running on AWS infrastructure), or SSO profile.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
uri: str,
|
|
72
|
+
presigned_url_expiry: int = 14400,
|
|
73
|
+
region_name: Optional[str] = None,
|
|
74
|
+
profile_name: Optional[str] = None,
|
|
75
|
+
endpoint_url: Optional[str] = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialise the S3 video source.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
uri: S3 URI to the video file (e.g., "s3://bucket/path/video.ts").
|
|
82
|
+
presigned_url_expiry: Expiry time in seconds for the presigned URL.
|
|
83
|
+
Default is 14400 (4 hours).
|
|
84
|
+
region_name: AWS region name. If None, uses default region from
|
|
85
|
+
config.
|
|
86
|
+
profile_name: AWS profile name for SSO authentication. If None,
|
|
87
|
+
uses default credentials chain.
|
|
88
|
+
endpoint_url: Custom S3-compatible endpoint URL (e.g. Wasabi).
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ImportError: If boto3 is not installed.
|
|
92
|
+
ValueError: If the URI is invalid.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
if not BOTO3_AVAILABLE:
|
|
96
|
+
raise ImportError(
|
|
97
|
+
"boto3 is required for S3 support. "
|
|
98
|
+
"Install with `pip install boto3`."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.uri = uri
|
|
102
|
+
self.presigned_url_expiry = presigned_url_expiry
|
|
103
|
+
self.bucket, self.key = parse_s3_uri(uri)
|
|
104
|
+
|
|
105
|
+
# Create S3 client via session (supports SSO profiles and custom endpoints)
|
|
106
|
+
session = boto3.Session(
|
|
107
|
+
profile_name=profile_name,
|
|
108
|
+
region_name=region_name,
|
|
109
|
+
)
|
|
110
|
+
self._s3_client = session.client("s3", endpoint_url=endpoint_url)
|
|
111
|
+
|
|
112
|
+
# State - lazy initialisation
|
|
113
|
+
self._presigned_url: Optional[str] = None
|
|
114
|
+
self._cap: Optional[cv2.VideoCapture] = None
|
|
115
|
+
self._metadata: Optional[VideoMetadata] = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ensure_connected(self) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Ensure the video capture is connected and ready.
|
|
121
|
+
|
|
122
|
+
Generates pre-signed URL and open VideoCapture on first call.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
RuntimeError: If unable to open video stream.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
if self._cap is not None:
|
|
129
|
+
return # Already connected
|
|
130
|
+
|
|
131
|
+
# Generate pre-signed URL
|
|
132
|
+
try:
|
|
133
|
+
self._presigned_url = self._s3_client.generate_presigned_url(
|
|
134
|
+
"get_object",
|
|
135
|
+
Params={"Bucket": self.bucket, "Key": self.key},
|
|
136
|
+
ExpiresIn=self.presigned_url_expiry,
|
|
137
|
+
)
|
|
138
|
+
logger.debug(f"Generated pre-signed URL for {self.uri}")
|
|
139
|
+
except ClientError as e:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
f"Failed to generate pre-signed URL for {self.uri}: {e}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Open video capture
|
|
145
|
+
self._cap = cv2.VideoCapture(self._presigned_url)
|
|
146
|
+
|
|
147
|
+
if not self._cap.isOpened():
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"Cannot open video stream from S3: {self.uri}. "
|
|
150
|
+
"OpenCV may not support streaming this format via HTTP."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Validate we can read metadata
|
|
154
|
+
frame_count = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
155
|
+
if frame_count <= 0:
|
|
156
|
+
logger.warning(
|
|
157
|
+
f"Frame count not available for {self.uri}. "
|
|
158
|
+
"Progress tracking may be inaccurate."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_metadata(self) -> VideoMetadata:
|
|
163
|
+
"""
|
|
164
|
+
Get metadata about the video file.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
VideoMetadata object with file properties.
|
|
168
|
+
"""
|
|
169
|
+
self._ensure_connected()
|
|
170
|
+
|
|
171
|
+
if self._metadata is not None:
|
|
172
|
+
return self._metadata
|
|
173
|
+
|
|
174
|
+
fps = self._cap.get(cv2.CAP_PROP_FPS)
|
|
175
|
+
frame_count = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
176
|
+
width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
177
|
+
height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
178
|
+
duration = frame_count / fps if fps > 0 else 0.0
|
|
179
|
+
|
|
180
|
+
self._metadata = VideoMetadata(
|
|
181
|
+
source_file=self.uri,
|
|
182
|
+
fps=fps,
|
|
183
|
+
frame_count=frame_count,
|
|
184
|
+
width=width,
|
|
185
|
+
height=height,
|
|
186
|
+
duration=duration,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return self._metadata
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def iter_frames(self) -> Iterator[Tuple[np.ndarray, FrameContext]]:
|
|
193
|
+
"""
|
|
194
|
+
Iterate over frames in the video file.
|
|
195
|
+
|
|
196
|
+
Yields:
|
|
197
|
+
Tuple of (frame, context) where frame is a BGR numpy array
|
|
198
|
+
and context contains metadata about the frame.
|
|
199
|
+
"""
|
|
200
|
+
self._ensure_connected()
|
|
201
|
+
|
|
202
|
+
fps = self._cap.get(cv2.CAP_PROP_FPS)
|
|
203
|
+
frame_number = 0
|
|
204
|
+
|
|
205
|
+
while True:
|
|
206
|
+
ret, frame = self._cap.read()
|
|
207
|
+
|
|
208
|
+
if not ret:
|
|
209
|
+
break # End of video
|
|
210
|
+
|
|
211
|
+
timestamp = frame_number / fps if fps > 0 else 0.0
|
|
212
|
+
|
|
213
|
+
context = FrameContext(
|
|
214
|
+
source_file=self.uri,
|
|
215
|
+
frame_number=frame_number,
|
|
216
|
+
timestamp=timestamp,
|
|
217
|
+
fps=fps,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
yield frame, context
|
|
221
|
+
frame_number += 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
"""Release the video capture resource."""
|
|
227
|
+
if self._cap is not None and self._cap.isOpened():
|
|
228
|
+
self._cap.release()
|
|
229
|
+
self._cap = None
|
|
230
|
+
self._presigned_url = None
|
|
231
|
+
self._metadata = None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Visualiser module for detection overlay on video."""
|
|
2
|
+
|
|
3
|
+
from .config import (
|
|
4
|
+
OutputMode,
|
|
5
|
+
LabelPosition,
|
|
6
|
+
BoundingBoxStyle,
|
|
7
|
+
LabelStyle,
|
|
8
|
+
OverlayStyle,
|
|
9
|
+
VideoOutputConfig,
|
|
10
|
+
VisualiserConfig
|
|
11
|
+
)
|
|
12
|
+
from .annotator import FrameAnnotator
|
|
13
|
+
from .writer import (
|
|
14
|
+
VideoWriterHandle,
|
|
15
|
+
OutputNameFunction,
|
|
16
|
+
extract_output_stem,
|
|
17
|
+
sanitise_filename
|
|
18
|
+
)
|
|
19
|
+
from .loader import (
|
|
20
|
+
DetectionSource,
|
|
21
|
+
FrameDetections,
|
|
22
|
+
CSVDetectionLoader,
|
|
23
|
+
IteratorDetectionSource,
|
|
24
|
+
ListDetectionSource,
|
|
25
|
+
load_detections_from_csv,
|
|
26
|
+
)
|
|
27
|
+
from .visualiser import (
|
|
28
|
+
AnnotatedFrame,
|
|
29
|
+
VisualisationResult,
|
|
30
|
+
LiveVisualiser,
|
|
31
|
+
PostHocVisualiser
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Config
|
|
36
|
+
"OutputMode",
|
|
37
|
+
"LabelPosition",
|
|
38
|
+
"BoundingBoxStyle",
|
|
39
|
+
"LabelStyle",
|
|
40
|
+
"OverlayStyle",
|
|
41
|
+
"VideoOutputConfig",
|
|
42
|
+
"VisualiserConfig",
|
|
43
|
+
# Writer utilities
|
|
44
|
+
"VideoWriterHandle",
|
|
45
|
+
"OutputNameFunction",
|
|
46
|
+
"extract_output_stem",
|
|
47
|
+
"sanitise_filename",
|
|
48
|
+
# Annotator
|
|
49
|
+
"FrameAnnotator",
|
|
50
|
+
# Loaders
|
|
51
|
+
"DetectionSource",
|
|
52
|
+
"FrameDetections",
|
|
53
|
+
"CSVDetectionLoader",
|
|
54
|
+
"IteratorDetectionSource",
|
|
55
|
+
"ListDetectionSource",
|
|
56
|
+
"load_detections_from_csv",
|
|
57
|
+
# Visualisers
|
|
58
|
+
"AnnotatedFrame",
|
|
59
|
+
"VisualisationResult",
|
|
60
|
+
"LiveVisualiser",
|
|
61
|
+
"PostHocVisualiser",
|
|
62
|
+
]
|