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,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graphics scene for interactive frame display with editable bounding boxes.
|
|
3
|
+
|
|
4
|
+
Manages the video frame pixmap and the set of DetectionRectItem overlays.
|
|
5
|
+
Provides methods to update the frame, synchronise detection overlays with the
|
|
6
|
+
ValidationModel, and handle draw-to-create for manual detections.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import Signal, Qt, QRectF, QPointF
|
|
13
|
+
from PySide6.QtGui import QPixmap, QImage, QPen, QColor, QBrush
|
|
14
|
+
from PySide6.QtWidgets import (
|
|
15
|
+
QGraphicsScene,
|
|
16
|
+
QGraphicsPixmapItem,
|
|
17
|
+
QGraphicsSceneMouseEvent,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from seavision.gui.validation.detection_rect_item import (
|
|
21
|
+
DetectionRectItem,
|
|
22
|
+
MIN_SIZE
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InteractiveFrameScene(QGraphicsScene):
|
|
29
|
+
"""
|
|
30
|
+
Scene containing a video frame pixmap and interactive detection overlays.
|
|
31
|
+
|
|
32
|
+
Reponsibilities:
|
|
33
|
+
- Display the current video frame as a background pixmap.
|
|
34
|
+
- Create and amange DetectionRectItem instances for each visibile detection.
|
|
35
|
+
- Handle the draw-to-create interaction for manual detection addition.
|
|
36
|
+
- Emit signals when detections are modified or created.
|
|
37
|
+
|
|
38
|
+
The scene operates in frame pixel coordinates. The top left corner of the
|
|
39
|
+
frame is (0, 0) and the bottom right corner is (frame_width, frame_height).
|
|
40
|
+
The QGraphicsView handles scaling these to screen pixels.
|
|
41
|
+
|
|
42
|
+
Signals:
|
|
43
|
+
detection_geometry_changed(int, float, float, float, float):
|
|
44
|
+
Emitted when a detection box is dragged or resized.
|
|
45
|
+
Args: detection_id, xc, yc, width, height (centre format, frame pixels)
|
|
46
|
+
|
|
47
|
+
detection_draw_complete(float, float, float, float):
|
|
48
|
+
Emitted when the user finishes drawing a new bounding box.
|
|
49
|
+
Args: xc, yc, width, height (centre format, frame pixels)
|
|
50
|
+
|
|
51
|
+
detection_rect_selected(int):
|
|
52
|
+
Emitted when a detection rect is clicked in the scene.
|
|
53
|
+
Args: detection_id
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
detection_geometry_changed = Signal(int, float, float, float, float)
|
|
57
|
+
detection_draw_complete = Signal(float, float, float, float)
|
|
58
|
+
detection_rect_selected = Signal(int)
|
|
59
|
+
|
|
60
|
+
def __init__(self, parent=None):
|
|
61
|
+
super().__init__(parent)
|
|
62
|
+
|
|
63
|
+
# --- Frame pixmap ---
|
|
64
|
+
self._pixmap_item: QGraphicsPixmapItem | None = None
|
|
65
|
+
self._frame_width: int = 0
|
|
66
|
+
self._frame_height: int = 0
|
|
67
|
+
|
|
68
|
+
# --- Detection overlays ---
|
|
69
|
+
# Map from detection_id to the rect item, for efficient lookup when we
|
|
70
|
+
# need to update or remove a specific detection.
|
|
71
|
+
self._detection_items: dict[int, DetectionRectItem] = {}
|
|
72
|
+
|
|
73
|
+
# --- Draw-to-create state ---
|
|
74
|
+
self._draw_mode: bool = False
|
|
75
|
+
self._draw_origin: QPointF | None = None
|
|
76
|
+
self._draw_rubber_band: DetectionRectItem | None = None
|
|
77
|
+
|
|
78
|
+
# --- Highlighted detection ---
|
|
79
|
+
self._highlighted_id: int | None = None
|
|
80
|
+
|
|
81
|
+
# Scene background
|
|
82
|
+
self.setBackgroundBrush(QBrush(QColor(30, 30, 30)))
|
|
83
|
+
|
|
84
|
+
# Selection handling
|
|
85
|
+
self._setup_selection_handling()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def update_frame(self, image: QImage) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Display a new video frame.
|
|
91
|
+
|
|
92
|
+
Replaces the background pixmap. Does NOT clear detection overlays -
|
|
93
|
+
those are managed separately by set_detections() so they persist across
|
|
94
|
+
frame updates during playback.
|
|
95
|
+
"""
|
|
96
|
+
pixmap = QPixmap.fromImage(image)
|
|
97
|
+
self._frame_width = pixmap.width()
|
|
98
|
+
self._frame_height = pixmap.height()
|
|
99
|
+
|
|
100
|
+
if self._pixmap_item is None:
|
|
101
|
+
self._pixmap_item = self.addPixmap(pixmap)
|
|
102
|
+
self._pixmap_item.setZValue(-1) # Behind everything
|
|
103
|
+
self.setSceneRect(0, 0, self._frame_width, self._frame_height)
|
|
104
|
+
else:
|
|
105
|
+
self._pixmap_item.setPixmap(pixmap)
|
|
106
|
+
|
|
107
|
+
# Update scene rect if frame dimensions changed
|
|
108
|
+
current = self.sceneRect()
|
|
109
|
+
if (
|
|
110
|
+
current.width() != self._frame_width
|
|
111
|
+
or current.height() != self._frame_height
|
|
112
|
+
):
|
|
113
|
+
self.setSceneRect(0, 0, self._frame_width, self._frame_height)
|
|
114
|
+
|
|
115
|
+
def set_detections(
|
|
116
|
+
self,
|
|
117
|
+
detections: list[dict],
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Synchronise detection overlays with the current state.
|
|
121
|
+
|
|
122
|
+
Clears all existing detection items and creates new ones from the
|
|
123
|
+
provided list. Each dict must contain:
|
|
124
|
+
detection_id (int), x1 (float), y1 (float),
|
|
125
|
+
width (float), height (float),
|
|
126
|
+
colour (tuple[int,int,int] — BGR),
|
|
127
|
+
editable (bool)
|
|
128
|
+
|
|
129
|
+
This is called whenever the frame changes (during seeking) or when
|
|
130
|
+
a detection's status changes (needing a colour update).
|
|
131
|
+
"""
|
|
132
|
+
self._clear_detection_items()
|
|
133
|
+
|
|
134
|
+
for det_info in detections:
|
|
135
|
+
item = DetectionRectItem(
|
|
136
|
+
x=det_info["x1"],
|
|
137
|
+
y=det_info["y1"],
|
|
138
|
+
width=det_info["width"],
|
|
139
|
+
height=det_info["height"],
|
|
140
|
+
detection_id=det_info["detection_id"],
|
|
141
|
+
colour=det_info["colour"],
|
|
142
|
+
on_geometry_changed=self._on_item_geometry_changed,
|
|
143
|
+
)
|
|
144
|
+
item.set_frame_bounds(self._frame_width, self._frame_height)
|
|
145
|
+
|
|
146
|
+
# Only allow editing on selected/editable detections
|
|
147
|
+
if not det_info.get("editable", True):
|
|
148
|
+
item.setFlag(
|
|
149
|
+
DetectionRectItem.GraphicsItemFlag.ItemIsMovable, False
|
|
150
|
+
)
|
|
151
|
+
item.setFlag(
|
|
152
|
+
DetectionRectItem.GraphicsItemFlag.ItemIsSelectable, False
|
|
153
|
+
)
|
|
154
|
+
item.setAcceptHoverEvents(False)
|
|
155
|
+
|
|
156
|
+
self.addItem(item)
|
|
157
|
+
self._detection_items[det_info["detection_id"]] = item
|
|
158
|
+
|
|
159
|
+
def highlight_detection(self, detection_id: int | None) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Visually highlight a detection (e.g. the currently selected one).
|
|
162
|
+
|
|
163
|
+
Selects the item in the scene's selection system, which triggers the
|
|
164
|
+
resize handles to appear via DetectionRectItem.paint().
|
|
165
|
+
"""
|
|
166
|
+
# Deselect previous
|
|
167
|
+
if self._highlighted_id is not None:
|
|
168
|
+
prev = self._detection_items.get(self._highlighted_id)
|
|
169
|
+
if prev is not None:
|
|
170
|
+
prev.setSelected(False)
|
|
171
|
+
|
|
172
|
+
self._highlighted_id = detection_id
|
|
173
|
+
|
|
174
|
+
if detection_id is not None:
|
|
175
|
+
item = self._detection_items.get(detection_id)
|
|
176
|
+
if item is not None:
|
|
177
|
+
item.setSelected(True)
|
|
178
|
+
|
|
179
|
+
def _clear_detection_items(self) -> None:
|
|
180
|
+
"""Remove all detection rect items from the scene."""
|
|
181
|
+
for item in self._detection_items.values():
|
|
182
|
+
self.removeItem(item)
|
|
183
|
+
self._detection_items.clear()
|
|
184
|
+
|
|
185
|
+
def _on_item_geometry_changed(
|
|
186
|
+
self,
|
|
187
|
+
detection_id: int,
|
|
188
|
+
xc: float,
|
|
189
|
+
yc: float,
|
|
190
|
+
width: float,
|
|
191
|
+
height: float,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Route a geometry change from a DetectionrectItem to the scene signal.
|
|
195
|
+
|
|
196
|
+
This is callback passed to each item at contruction time. It just
|
|
197
|
+
re-emits the change as a scene-level signal so the tab can pick it up.
|
|
198
|
+
"""
|
|
199
|
+
self.detection_geometry_changed.emit(
|
|
200
|
+
detection_id, xc, yc, width, height
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- Draw-to-create mode ---
|
|
205
|
+
def set_draw_mode(self, enabled: bool) -> None:
|
|
206
|
+
"""
|
|
207
|
+
Toggle draw_to_create mode,
|
|
208
|
+
|
|
209
|
+
When enable, clicking and draggin on th eframe creates a new bounding
|
|
210
|
+
box. The rubber band rectangle follows the mouse during the grag. On
|
|
211
|
+
release, detection_draw_complete is emitted with the drawn
|
|
212
|
+
rectangle's coordinates.
|
|
213
|
+
"""
|
|
214
|
+
self._draw_mode = enabled
|
|
215
|
+
if not enabled and self._draw_rubber_band is not None:
|
|
216
|
+
self.removeItem(self._draw_rubber_band)
|
|
217
|
+
self._draw_rubber_band = None
|
|
218
|
+
self._draw_origin = None
|
|
219
|
+
|
|
220
|
+
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
221
|
+
"""
|
|
222
|
+
Handle mouse press - start drawing if in draw mode.
|
|
223
|
+
|
|
224
|
+
In draw mode, we intercept the press to start the rubber band rectangle.
|
|
225
|
+
Otherwise, we delegate to the base class so normal item selection and
|
|
226
|
+
dragging work.
|
|
227
|
+
"""
|
|
228
|
+
if (
|
|
229
|
+
self._draw_mode
|
|
230
|
+
and event.button() == Qt.MouseButton.LeftButton
|
|
231
|
+
):
|
|
232
|
+
pos = event.scenePos()
|
|
233
|
+
|
|
234
|
+
# Only start drawing if the click is within the frame
|
|
235
|
+
if (
|
|
236
|
+
0 <= pos.x() <= self._frame_width
|
|
237
|
+
and 0 <= pos.y() <= self._frame_height
|
|
238
|
+
):
|
|
239
|
+
self._draw_origin = pos
|
|
240
|
+
|
|
241
|
+
# Create a zero-size rect at the click point
|
|
242
|
+
self._draw_rubber_band = DetectionRectItem(
|
|
243
|
+
x=pos.x(),
|
|
244
|
+
y=pos.y(),
|
|
245
|
+
width=0,
|
|
246
|
+
height=0,
|
|
247
|
+
detection_id=-1, # Temporary ID for the rubber band
|
|
248
|
+
colour=(0, 200, 200), # Cyan for drawing feedback
|
|
249
|
+
on_geometry_changed=None, # No need to track geometry changes
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Disable interaction on the rubber band
|
|
253
|
+
self._draw_rubber_band.setFlag(
|
|
254
|
+
DetectionRectItem.GraphicsItemFlag.ItemIsMovable, False
|
|
255
|
+
)
|
|
256
|
+
self._draw_rubber_band.setFlag(
|
|
257
|
+
DetectionRectItem.GraphicsItemFlag.ItemIsSelectable, False
|
|
258
|
+
)
|
|
259
|
+
self._draw_rubber_band.setAcceptHoverEvents(False)
|
|
260
|
+
self.addItem(self._draw_rubber_band)
|
|
261
|
+
event.accept()
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
super().mousePressEvent(event)
|
|
265
|
+
|
|
266
|
+
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
267
|
+
"""Update the rubber band rectangle during draw-to-create drag."""
|
|
268
|
+
if (
|
|
269
|
+
self._draw_mode
|
|
270
|
+
and self._draw_origin is not None
|
|
271
|
+
and self._draw_rubber_band is not None
|
|
272
|
+
):
|
|
273
|
+
pos = event.scenePos()
|
|
274
|
+
|
|
275
|
+
# Clamp to frame bounds
|
|
276
|
+
x = max(0, min(pos.x(), self._frame_width))
|
|
277
|
+
y = max(0, min(pos.y(), self._frame_height))
|
|
278
|
+
|
|
279
|
+
# Calculate the rect fromthe origin to current position, handling
|
|
280
|
+
# any drag direction (top-left to bottom-right, bottom-right to
|
|
281
|
+
# top-left, etc.)
|
|
282
|
+
x1 = min(self._draw_origin.x(), x)
|
|
283
|
+
y1 = min(self._draw_origin.y(), y)
|
|
284
|
+
x2 = max(self._draw_origin.x(), x)
|
|
285
|
+
y2 = max(self._draw_origin.y(), y)
|
|
286
|
+
|
|
287
|
+
self._draw_rubber_band.prepareGeometryChange()
|
|
288
|
+
self._draw_rubber_band.setRect(QRectF(x1, y1, x2 - x1, y2 - y1))
|
|
289
|
+
event.accept()
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
super().mouseMoveEvent(event)
|
|
293
|
+
|
|
294
|
+
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Finish drawing and emit the new detection's coordinates.
|
|
297
|
+
|
|
298
|
+
Enforces a minimum size - very small accidental drags are treated as
|
|
299
|
+
click and ignored. The rubber band item is removed; the tab will create
|
|
300
|
+
a proper detection via the ValidationModel and a subsequent
|
|
301
|
+
set_detections() call will add the real item.
|
|
302
|
+
"""
|
|
303
|
+
if (
|
|
304
|
+
self._draw_mode
|
|
305
|
+
and self._draw_origin is not None
|
|
306
|
+
and self._draw_rubber_band is not None
|
|
307
|
+
):
|
|
308
|
+
r = self._draw_rubber_band.rect()
|
|
309
|
+
|
|
310
|
+
# Remove the temporary rubber band
|
|
311
|
+
self.removeItem(self._draw_rubber_band)
|
|
312
|
+
self._draw_rubber_band = None
|
|
313
|
+
self._draw_origin = None
|
|
314
|
+
|
|
315
|
+
# Ignore very small draws (below minimum size defined in
|
|
316
|
+
# detection_rect_item.py)
|
|
317
|
+
if r.width() >= MIN_SIZE and r.height() >= MIN_SIZE:
|
|
318
|
+
# Convert to centre format for the signal
|
|
319
|
+
xc = r.x() + r.width() / 2
|
|
320
|
+
yc = r.y() + r.height() / 2
|
|
321
|
+
self.detection_draw_complete.emit(
|
|
322
|
+
xc, yc, r.width(), r.height()
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Exit draw mode after one draw
|
|
326
|
+
self._draw_mode = False
|
|
327
|
+
event.accept()
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
super().mouseReleaseEvent(event)
|
|
331
|
+
|
|
332
|
+
def _setup_selection_handling(self) -> None:
|
|
333
|
+
"""Connect the scene's selection change to our handler."""
|
|
334
|
+
self.selectionChanged.connect(self._on_selection_changed)
|
|
335
|
+
|
|
336
|
+
def _on_selection_changed(self) -> None:
|
|
337
|
+
"""Emit detection_rect_selected when a detection item is selected."""
|
|
338
|
+
selected = self.selectedItems()
|
|
339
|
+
if len(selected) == 1 and isinstance(selected[0], DetectionRectItem):
|
|
340
|
+
self.detection_rect_selected.emit(selected[0].detection_id)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QGraphicsView-based frame viewer with interactive detection overlays.
|
|
3
|
+
|
|
4
|
+
Provides external interface for modifying display (e.g. set_add_mode,
|
|
5
|
+
update_frame etc.) and supports interactive bounding box editing.)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import Signal, Qt
|
|
11
|
+
from PySide6.QtGui import QImage, QPainter, QWheelEvent, QKeyEvent
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QGraphicsView,
|
|
14
|
+
QSizePolicy,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from seavision.gui.validation.interactive_frame_scene import (
|
|
18
|
+
InteractiveFrameScene,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
SCROLL_BAR_STYLE = """
|
|
24
|
+
QGraphicsView {
|
|
25
|
+
border: none;
|
|
26
|
+
}
|
|
27
|
+
QScrollBar:horizontal {
|
|
28
|
+
height: 8px;
|
|
29
|
+
background: transparent;
|
|
30
|
+
margin: 0;
|
|
31
|
+
}
|
|
32
|
+
QScrollBar::handle:horizontal {
|
|
33
|
+
background: rgba(128, 128, 128, 120);
|
|
34
|
+
border-radius: 4px;
|
|
35
|
+
min-width: 30px;
|
|
36
|
+
}
|
|
37
|
+
QScrollBar::handle:horizontal:hover {
|
|
38
|
+
background: rgba(128, 128, 128, 180);
|
|
39
|
+
}
|
|
40
|
+
QScrollBar::add-line:horizontal,
|
|
41
|
+
QScrollBar::sub-line:horizontal,
|
|
42
|
+
QScrollBar::add-page:horizontal,
|
|
43
|
+
QScrollBar::sub-page:horizontal {
|
|
44
|
+
background: transparent;
|
|
45
|
+
width: 0;
|
|
46
|
+
height: 0;
|
|
47
|
+
}
|
|
48
|
+
QScrollBar:vertical {
|
|
49
|
+
width: 8px;
|
|
50
|
+
background: transparent;
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
QScrollBar::handle:vertical {
|
|
54
|
+
background: rgba(128, 128, 128, 120);
|
|
55
|
+
border-radius: 4px;
|
|
56
|
+
min-height: 30px;
|
|
57
|
+
}
|
|
58
|
+
QScrollBar::handle:vertical:hover {
|
|
59
|
+
background: rgba(128, 128, 128, 180);
|
|
60
|
+
}
|
|
61
|
+
QScrollBar::add-line:vertical,
|
|
62
|
+
QScrollBar::sub-line:vertical,
|
|
63
|
+
QScrollBar::add-page:vertical,
|
|
64
|
+
QScrollBar::sub-page:vertical {
|
|
65
|
+
background: transparent;
|
|
66
|
+
width: 0;
|
|
67
|
+
height: 0;
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
class InteractiveFrameView(QGraphicsView):
|
|
72
|
+
"""
|
|
73
|
+
Video frame display with iteractive detection editing.
|
|
74
|
+
|
|
75
|
+
External interface:
|
|
76
|
+
- update_frame(image, frame_number, timestamp)
|
|
77
|
+
- set_add_mode(enabled)
|
|
78
|
+
- frame_clicked signal (emitted for backward compat; draw mode
|
|
79
|
+
now uses detection_draw_complete on the scene)
|
|
80
|
+
- scene: the InteractiveFrameScene (access detection management)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Backward compatibility for previous click-to-add mode - the draw-to-create
|
|
84
|
+
# method not uses scene.detection_draw_complete signal instead.
|
|
85
|
+
frame_clicked = Signal(int, float)
|
|
86
|
+
|
|
87
|
+
def __init__(self, parent=None):
|
|
88
|
+
super().__init__(parent)
|
|
89
|
+
self._scene = InteractiveFrameScene(self)
|
|
90
|
+
self.setScene(self._scene)
|
|
91
|
+
|
|
92
|
+
# --- Display settings ---
|
|
93
|
+
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
94
|
+
self.setSizePolicy(
|
|
95
|
+
QSizePolicy.Policy.Expanding,
|
|
96
|
+
QSizePolicy.Policy.Expanding,
|
|
97
|
+
)
|
|
98
|
+
self.setMinimumSize(320, 240)
|
|
99
|
+
|
|
100
|
+
# Enable scroll bars when zoomed in
|
|
101
|
+
self.setHorizontalScrollBarPolicy(
|
|
102
|
+
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
|
103
|
+
)
|
|
104
|
+
self.setVerticalScrollBarPolicy(
|
|
105
|
+
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Smooth rendering
|
|
109
|
+
self.setRenderHints(
|
|
110
|
+
self.renderHints()
|
|
111
|
+
| QPainter.RenderHint.Antialiasing
|
|
112
|
+
| QPainter.RenderHint.SmoothPixmapTransform
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Zoom configuration
|
|
116
|
+
self.setTransformationAnchor(
|
|
117
|
+
QGraphicsView.ViewportAnchor.AnchorUnderMouse
|
|
118
|
+
)
|
|
119
|
+
self.setResizeAnchor(
|
|
120
|
+
QGraphicsView.ViewportAnchor.AnchorViewCenter
|
|
121
|
+
)
|
|
122
|
+
self._zoom_factor = 1.0
|
|
123
|
+
self._min_zoom = 1.0
|
|
124
|
+
self._max_zoom = 20.0
|
|
125
|
+
|
|
126
|
+
# Dark background
|
|
127
|
+
self.setStyleSheet("background-color: #1e1e1e; border: none;")
|
|
128
|
+
self.setStyleSheet(SCROLL_BAR_STYLE)
|
|
129
|
+
# Anchor zoom/fit transforms on the centre of the view
|
|
130
|
+
self.setTransformationAnchor(
|
|
131
|
+
QGraphicsView.ViewportAnchor.AnchorViewCenter
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self._frame_loaded = False
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def scene(self) -> InteractiveFrameScene:
|
|
138
|
+
"""Access the underlying scene for detection management."""
|
|
139
|
+
return self._scene
|
|
140
|
+
|
|
141
|
+
def update_frame(
|
|
142
|
+
self,
|
|
143
|
+
image: QImage,
|
|
144
|
+
frame_number: int,
|
|
145
|
+
timestamp: float,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Display a new frame from the video worker.
|
|
149
|
+
"""
|
|
150
|
+
self._scene.update_frame(image)
|
|
151
|
+
if self._zoom_factor == 1.0:
|
|
152
|
+
self._fit_frame()
|
|
153
|
+
self._frame_loaded = True
|
|
154
|
+
|
|
155
|
+
def _fit_frame(self) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Scale the scene to fit the view while maintaining aspect ratio.
|
|
158
|
+
|
|
159
|
+
Scales the view's transformation matrix so that the entire scene rect
|
|
160
|
+
fits within the view's viewport. The advantage is that all items
|
|
161
|
+
(pixmap, detection rects, handles) scale together with no additional
|
|
162
|
+
work.
|
|
163
|
+
"""
|
|
164
|
+
scene_rect = self._scene.sceneRect()
|
|
165
|
+
if scene_rect.isEmpty():
|
|
166
|
+
return # No frame loaded yet
|
|
167
|
+
self.fitInView(scene_rect, Qt.AspectRatioMode.KeepAspectRatio)
|
|
168
|
+
|
|
169
|
+
def resizeEvent(self, event) -> None:
|
|
170
|
+
"""Re-fit the frame when the widget is resized."""
|
|
171
|
+
if self._zoom_factor == 1.0:
|
|
172
|
+
self._fit_frame()
|
|
173
|
+
super().resizeEvent(event)
|
|
174
|
+
|
|
175
|
+
def set_add_mode(self, enabled: bool) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Toggle draw-to-create mode.
|
|
178
|
+
|
|
179
|
+
When enabled, the cursor changes to crosshair and clicking+dragging
|
|
180
|
+
on the frame draws a new bounding box.
|
|
181
|
+
"""
|
|
182
|
+
self._scene.set_draw_mode(enabled)
|
|
183
|
+
if enabled:
|
|
184
|
+
self.setCursor(Qt.CursorShape.CrossCursor)
|
|
185
|
+
else:
|
|
186
|
+
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
187
|
+
|
|
188
|
+
def wheelEvent(self, event: QWheelEvent) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Zoom in/out with the scroll wheel.
|
|
191
|
+
|
|
192
|
+
Zooms towards the cursor position. The zoom factor is clamped to prevent
|
|
193
|
+
excessive zoom in either direction.
|
|
194
|
+
"""
|
|
195
|
+
zoom_in_factor = 1.15
|
|
196
|
+
zoom_out_factor = 1.0 / zoom_in_factor
|
|
197
|
+
|
|
198
|
+
if event.angleDelta().y() > 0:
|
|
199
|
+
factor = zoom_in_factor
|
|
200
|
+
else:
|
|
201
|
+
factor = zoom_out_factor
|
|
202
|
+
|
|
203
|
+
new_zoom = self._zoom_factor * factor
|
|
204
|
+
if new_zoom < self._min_zoom or new_zoom > self._max_zoom:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
self._zoom_factor = new_zoom
|
|
208
|
+
self.scale(factor, factor)
|
|
209
|
+
|
|
210
|
+
def pan(self, dx: int, dy: int) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Pan the viewport by the given pixel amounts.
|
|
213
|
+
|
|
214
|
+
Only effective when zoomed in. Does nothing at default zoom since the
|
|
215
|
+
entire frame is already visible.
|
|
216
|
+
"""
|
|
217
|
+
if self._zoom_factor <= 1.0:
|
|
218
|
+
return
|
|
219
|
+
h_bar = self.horizontalScrollBar()
|
|
220
|
+
v_bar = self.verticalScrollBar()
|
|
221
|
+
h_bar.setValue(h_bar.value() + dx)
|
|
222
|
+
v_bar.setValue(v_bar.value() + dy)
|
|
223
|
+
|
|
224
|
+
def keyPressEvent(self, event) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Suppress default arrow key scrolling.
|
|
227
|
+
|
|
228
|
+
QGraphicsView scrolls on arrow keys by default. We block this
|
|
229
|
+
because arrow key behaviour is handled entirely by tab-level
|
|
230
|
+
shortcuts (plain arrows for frame stepping, Shift+arrows for
|
|
231
|
+
panning).
|
|
232
|
+
"""
|
|
233
|
+
if event.key() in (
|
|
234
|
+
Qt.Key.Key_Left, Qt.Key.Key_Right,
|
|
235
|
+
Qt.Key.Key_Up, Qt.Key.Key_Down,
|
|
236
|
+
):
|
|
237
|
+
event.ignore()
|
|
238
|
+
return
|
|
239
|
+
super().keyPressEvent(event)
|
|
240
|
+
|
|
241
|
+
def reset_zoom(self) -> None:
|
|
242
|
+
"""Reset zoom to fit the entire frame in the view."""
|
|
243
|
+
self._zoom_factor = 1.0
|
|
244
|
+
self._fit_frame()
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def frame_width(self) -> int:
|
|
248
|
+
return self._scene._frame_width
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def frame_height(self) -> int:
|
|
252
|
+
return self._scene._frame_height
|