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,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
|
+
|