seavision-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. seavision/__init__.py +3 -0
  2. seavision/defaults.py +38 -0
  3. seavision/edge/__init__.py +38 -0
  4. seavision/edge/bundle.py +189 -0
  5. seavision/edge/capture.py +107 -0
  6. seavision/edge/config.py +219 -0
  7. seavision/edge/postprocess.py +185 -0
  8. seavision/edge/runtime.py +416 -0
  9. seavision/edge/writer.py +167 -0
  10. seavision/engine/__init__.py +52 -0
  11. seavision/engine/detectors/__init__.py +90 -0
  12. seavision/engine/detectors/base.py +179 -0
  13. seavision/engine/detectors/motion/__init__.py +14 -0
  14. seavision/engine/detectors/motion/background.py +92 -0
  15. seavision/engine/detectors/motion/detector.py +218 -0
  16. seavision/engine/detectors/motion/stabiliser.py +174 -0
  17. seavision/engine/detectors/motion/tracker.py +174 -0
  18. seavision/engine/detectors/sam3/__init__.py +23 -0
  19. seavision/engine/detectors/sam3/config.py +309 -0
  20. seavision/engine/detectors/sam3/detector.py +1316 -0
  21. seavision/engine/detectors/sam3/native.py +490 -0
  22. seavision/engine/detectors/yolo/__init__.py +12 -0
  23. seavision/engine/detectors/yolo/config.py +39 -0
  24. seavision/engine/detectors/yolo/detector.py +236 -0
  25. seavision/engine/export/__init__.py +24 -0
  26. seavision/engine/export/config.py +160 -0
  27. seavision/engine/export/exporter.py +286 -0
  28. seavision/engine/export/validation.py +314 -0
  29. seavision/engine/postprocessor.py +738 -0
  30. seavision/engine/source/__init__.py +16 -0
  31. seavision/engine/source/base.py +87 -0
  32. seavision/engine/source/discovery.py +154 -0
  33. seavision/engine/source/local.py +155 -0
  34. seavision/engine/source/s3.py +231 -0
  35. seavision/engine/visualiser/__init__.py +62 -0
  36. seavision/engine/visualiser/annotator.py +382 -0
  37. seavision/engine/visualiser/config.py +127 -0
  38. seavision/engine/visualiser/loader.py +558 -0
  39. seavision/engine/visualiser/visualiser.py +402 -0
  40. seavision/engine/visualiser/writer.py +245 -0
  41. seavision/gui/__init__.py +0 -0
  42. seavision/gui/app.py +30 -0
  43. seavision/gui/main_window.py +891 -0
  44. seavision/gui/shared/__init__.py +0 -0
  45. seavision/gui/shared/conversion.py +55 -0
  46. seavision/gui/shared/session_utils.py +37 -0
  47. seavision/gui/validation/__init__.py +0 -0
  48. seavision/gui/validation/button_styles.py +144 -0
  49. seavision/gui/validation/detection_detail.py +164 -0
  50. seavision/gui/validation/detection_rect_item.py +486 -0
  51. seavision/gui/validation/detection_table.py +563 -0
  52. seavision/gui/validation/interactive_frame_scene.py +340 -0
  53. seavision/gui/validation/interactive_frame_view.py +252 -0
  54. seavision/gui/validation/s3_browser.py +645 -0
  55. seavision/gui/validation/seekable_source.py +262 -0
  56. seavision/gui/validation/session.py +376 -0
  57. seavision/gui/validation/tab.py +2503 -0
  58. seavision/gui/validation/transport_bar.py +172 -0
  59. seavision/gui/validation/validation_model.py +482 -0
  60. seavision/gui/validation/video_cache.py +215 -0
  61. seavision/gui/validation/video_list.py +140 -0
  62. seavision/gui/validation/video_viewer.py +150 -0
  63. seavision/gui/validation/video_worker.py +422 -0
  64. seavision/pipeline.py +856 -0
  65. seavision/resources/default.yaml +139 -0
  66. seavision/run_edge.py +96 -0
  67. seavision/run_export.py +176 -0
  68. seavision/run_pipeline.py +388 -0
  69. seavision_python-0.1.1.dist-info/METADATA +286 -0
  70. seavision_python-0.1.1.dist-info/RECORD +73 -0
  71. seavision_python-0.1.1.dist-info/WHEEL +5 -0
  72. seavision_python-0.1.1.dist-info/entry_points.txt +5 -0
  73. seavision_python-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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