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,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive detection bounding box for the QGraphcsScene in the SeaVision
|
|
3
|
+
validation GUI.
|
|
4
|
+
|
|
5
|
+
Provides a draggable, resizeable rectangle that represents a single detection
|
|
6
|
+
overlay on the video frame. Emits geometry changes through a callback so the
|
|
7
|
+
validation model can be updated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from enum import Enum, auto
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import Qt, QRectF, QPointF
|
|
14
|
+
from PySide6.QtGui import QPen, QBrush, QColor, QCursor, QPainter
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QGraphicsRectItem,
|
|
17
|
+
QGraphicsItem,
|
|
18
|
+
QGraphicsSceneMouseEvent,
|
|
19
|
+
QGraphicsSceneHoverEvent,
|
|
20
|
+
QStyleOptionGraphicsItem,
|
|
21
|
+
QWidget,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Size of the resize handles in scene pixels. This is intentionally large - on a
|
|
27
|
+
# 640x480 display displayed at 2x, each handle is about 8 physical pixels, which
|
|
28
|
+
# is comformtable to grab with a mouse.
|
|
29
|
+
_HANDLE_SIZE = 3.0
|
|
30
|
+
|
|
31
|
+
# Minimum size of the box in frame pixels. Prevents the box from collapsing to zero or inverting
|
|
32
|
+
MIN_SIZE = 3.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _HandlePosition(Enum):
|
|
36
|
+
"""Which edge or corner a resize handle sits on."""
|
|
37
|
+
TOP_LEFT = auto()
|
|
38
|
+
TOP = auto()
|
|
39
|
+
TOP_RIGHT = auto()
|
|
40
|
+
RIGHT = auto()
|
|
41
|
+
BOTTOM_RIGHT = auto()
|
|
42
|
+
BOTTOM = auto()
|
|
43
|
+
BOTTOM_LEFT = auto()
|
|
44
|
+
LEFT = auto()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Map each handle to the cursor shape shown when hovering above it
|
|
48
|
+
_HANDLE_CURSORS = {
|
|
49
|
+
_HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor,
|
|
50
|
+
_HandlePosition.TOP: Qt.CursorShape.SizeVerCursor,
|
|
51
|
+
_HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor,
|
|
52
|
+
_HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor,
|
|
53
|
+
_HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor,
|
|
54
|
+
_HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor,
|
|
55
|
+
_HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor,
|
|
56
|
+
_HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DetectionRectItem(QGraphicsRectItem):
|
|
61
|
+
"""
|
|
62
|
+
An interactive bounding box for a single detection.
|
|
63
|
+
|
|
64
|
+
Supports:
|
|
65
|
+
- Click to select
|
|
66
|
+
- Drag to move
|
|
67
|
+
- Drag edges/corner handles to resize
|
|
68
|
+
- Cursor changes to indicate available interaction
|
|
69
|
+
- Geometry change callback for model updates
|
|
70
|
+
|
|
71
|
+
The item operates in frame pixel coordinates - its rect() and pos() values
|
|
72
|
+
correspond directly to pixel positions in the original video frame. The
|
|
73
|
+
QGraphicsView handles the scaling between frame coordinates and screen
|
|
74
|
+
pixels.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
x: Left edge in frame pixels
|
|
78
|
+
y: Top edge in frame pixels
|
|
79
|
+
width: Width in frame pixels
|
|
80
|
+
height: Height in frame pixels
|
|
81
|
+
detection_id: The unique ID from ValidationModel, used to route geometry
|
|
82
|
+
changes back to the correct detection.
|
|
83
|
+
colour: BGR tuple for the box outline (converted to RGB internally).
|
|
84
|
+
on_geometry_changed: Callback invoked when the user finishes dragging.
|
|
85
|
+
Receives (detection_id, xc, yc, width, height) in frame coordinates
|
|
86
|
+
using centre-format to match the Detection dataclass convention.
|
|
87
|
+
parent: Optional parent QGraphicsItem.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
x: float,
|
|
93
|
+
y: float,
|
|
94
|
+
width: float,
|
|
95
|
+
height: float,
|
|
96
|
+
detection_id: int,
|
|
97
|
+
colour: tuple[int, int, int] = (0, 0, 255),
|
|
98
|
+
on_geometry_changed: callable = None,
|
|
99
|
+
parent: QGraphicsItem | None = None,
|
|
100
|
+
):
|
|
101
|
+
|
|
102
|
+
super().__init__(x, y, width, height, parent)
|
|
103
|
+
|
|
104
|
+
self._detection_id = detection_id
|
|
105
|
+
self._on_geometry_changed = on_geometry_changed
|
|
106
|
+
|
|
107
|
+
# --- Interaction flags ---
|
|
108
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
|
|
109
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
|
|
110
|
+
self.setFlag(
|
|
111
|
+
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True
|
|
112
|
+
)
|
|
113
|
+
self.setAcceptHoverEvents(True)
|
|
114
|
+
|
|
115
|
+
# --- Visual style ---
|
|
116
|
+
# COnvert BGR (OpenCV format) to RGB (Qt format)
|
|
117
|
+
b, g, r = colour
|
|
118
|
+
pen_colour = QColor(r, g, b)
|
|
119
|
+
pen = QPen(pen_colour, 2.0)
|
|
120
|
+
pen.setCosmetic(True)
|
|
121
|
+
self.setPen(pen)
|
|
122
|
+
self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
123
|
+
|
|
124
|
+
self._default_pen_colour = pen_colour
|
|
125
|
+
|
|
126
|
+
# --- Resize state ---
|
|
127
|
+
self._active_handle: _HandlePosition | None = None
|
|
128
|
+
self._drag_start_rect: QRectF | None = None
|
|
129
|
+
self._drag_start_pos: QPointF | None = None
|
|
130
|
+
|
|
131
|
+
# --- Frame bounds (set externally to clamp movement) ---
|
|
132
|
+
self._frame_width: float = 0.0
|
|
133
|
+
self._frame_height: float = 0.0
|
|
134
|
+
|
|
135
|
+
def set_frame_bounds(self, width: float, height: float) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Set the frame dimensions for clamping box movement.
|
|
138
|
+
|
|
139
|
+
Called once when the item is created. Prevents the reviewer from
|
|
140
|
+
dragging a box outside the visible frame area.
|
|
141
|
+
"""
|
|
142
|
+
self._frame_width = width
|
|
143
|
+
self._frame_height = height
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def detection_id(self) -> int:
|
|
147
|
+
"""The unique ID of the detection this box represents."""
|
|
148
|
+
return self._detection_id
|
|
149
|
+
|
|
150
|
+
def _current_handle_size(self) -> float:
|
|
151
|
+
"""
|
|
152
|
+
Compute handle size in scene coordinates that maps to a consistent
|
|
153
|
+
screen size.
|
|
154
|
+
|
|
155
|
+
If no view is available (item not in a scene yet), falls back to
|
|
156
|
+
the default.
|
|
157
|
+
"""
|
|
158
|
+
if self.scene() is None:
|
|
159
|
+
return _HANDLE_SIZE
|
|
160
|
+
|
|
161
|
+
views = self.scene().views()
|
|
162
|
+
if not views:
|
|
163
|
+
return _HANDLE_SIZE
|
|
164
|
+
|
|
165
|
+
# Get the view's horizontal scale factor
|
|
166
|
+
transform = views[0].transform()
|
|
167
|
+
scale = transform.m11() # horizontal scale
|
|
168
|
+
|
|
169
|
+
if scale <= 0:
|
|
170
|
+
return _HANDLE_SIZE
|
|
171
|
+
|
|
172
|
+
# Target: 5 screen pixels per handle half-size
|
|
173
|
+
return 6.0 / scale
|
|
174
|
+
|
|
175
|
+
def _handle_rects(self) -> dict[_HandlePosition, QRectF]:
|
|
176
|
+
"""
|
|
177
|
+
Calculates the rectangles for all eight resize handles.
|
|
178
|
+
|
|
179
|
+
Handles are positioned at the four corners and the midpoints of the
|
|
180
|
+
four edges, each centered on the edge/corner point with a size of
|
|
181
|
+
_HANDLE_SIZE in each direction.
|
|
182
|
+
|
|
183
|
+
Returns coordinates in the item's local coordinate system.
|
|
184
|
+
"""
|
|
185
|
+
r = self.rect()
|
|
186
|
+
s = self._current_handle_size()
|
|
187
|
+
cx = r.x() + r.width() / 2
|
|
188
|
+
cy = r.y() + r.height() / 2
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
_HandlePosition.TOP_LEFT: QRectF(
|
|
192
|
+
r.x() - s, r.y() - s, 2 * s, 2 * s
|
|
193
|
+
),
|
|
194
|
+
_HandlePosition.TOP: QRectF(
|
|
195
|
+
cx - s, r.y() - s, 2 * s, 2 * s
|
|
196
|
+
),
|
|
197
|
+
_HandlePosition.TOP_RIGHT: QRectF(
|
|
198
|
+
r.right() - s, r.y() - s, 2 * s, 2 * s
|
|
199
|
+
),
|
|
200
|
+
_HandlePosition.RIGHT: QRectF(
|
|
201
|
+
r.right() - s, cy - s, 2 * s, 2 * s
|
|
202
|
+
),
|
|
203
|
+
_HandlePosition.BOTTOM_RIGHT: QRectF(
|
|
204
|
+
r.right() - s, r.bottom() - s, 2 * s, 2 * s
|
|
205
|
+
),
|
|
206
|
+
_HandlePosition.BOTTOM: QRectF(
|
|
207
|
+
cx - s, r.bottom() - s, 2 * s, 2 * s
|
|
208
|
+
),
|
|
209
|
+
_HandlePosition.BOTTOM_LEFT: QRectF(
|
|
210
|
+
r.x() - s, r.bottom() - s, 2 * s, 2 * s
|
|
211
|
+
),
|
|
212
|
+
_HandlePosition.LEFT: QRectF(
|
|
213
|
+
r.x() - s, cy - s, 2 * s, 2 * s
|
|
214
|
+
),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def _handle_at(self, pos: QPointF) -> _HandlePosition | None:
|
|
218
|
+
"""
|
|
219
|
+
Determine which handle (if any) is under the given local position.
|
|
220
|
+
|
|
221
|
+
Returns None if the position is inside the rect but not on a handle,
|
|
222
|
+
meaning a drag should move the whole box rather than resize it.
|
|
223
|
+
"""
|
|
224
|
+
for handle, rect in self._handle_rects().items():
|
|
225
|
+
if rect.contains(pos):
|
|
226
|
+
return handle
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- Hover events (cursor feedback) ---
|
|
231
|
+
def hoverMoveEvent(self, event: QGraphicsSceneHoverEvent):
|
|
232
|
+
"""Change cursor based on which handle the mouse is near."""
|
|
233
|
+
handle = self._handle_at(event.pos())
|
|
234
|
+
if handle is not None:
|
|
235
|
+
self.setCursor(QCursor(_HANDLE_CURSORS[handle]))
|
|
236
|
+
else:
|
|
237
|
+
self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
|
|
238
|
+
|
|
239
|
+
def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None:
|
|
240
|
+
"""reset cursor when the mouse leaves the item."""
|
|
241
|
+
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- Mouse events (drag and resize) ---
|
|
245
|
+
def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
|
|
246
|
+
"""
|
|
247
|
+
Start a drag operation.
|
|
248
|
+
|
|
249
|
+
If the press is on a handle, begin resizing. Otherwise, begin moving
|
|
250
|
+
the whole box (handled by Qt's built-in ItemIsMovable)
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
if event.button() != Qt.MouseButton.LeftButton:
|
|
254
|
+
super().mousePressEvent(event)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
self._active_handle = self._handle_at(event.pos())
|
|
258
|
+
|
|
259
|
+
if self._active_handle is not None:
|
|
260
|
+
# Resize - record the starting geometry to compute delatas during
|
|
261
|
+
# mouseMoveEvent.
|
|
262
|
+
self._drag_start_rect = QRectF(self.rect())
|
|
263
|
+
self._drag_start_pos = event.pos()
|
|
264
|
+
|
|
265
|
+
# Disable movement during the drag
|
|
266
|
+
self.setFlag(
|
|
267
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False
|
|
268
|
+
)
|
|
269
|
+
event.accept()
|
|
270
|
+
else:
|
|
271
|
+
# Normal move - let Qt handle it vaia ItemIsMovable
|
|
272
|
+
super().mousePressEvent(event)
|
|
273
|
+
|
|
274
|
+
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Handle a drag movement.
|
|
277
|
+
|
|
278
|
+
During resize: compute the new rect from the mouse delta and the active
|
|
279
|
+
handle.
|
|
280
|
+
During move: delegate to Qt's built in handling.
|
|
281
|
+
"""
|
|
282
|
+
if self._active_handle is not None and self._drag_start_rect is not None:
|
|
283
|
+
self.resize_to(event.pos())
|
|
284
|
+
event.accept()
|
|
285
|
+
else:
|
|
286
|
+
super().mouseMoveEvent(event)
|
|
287
|
+
|
|
288
|
+
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Finish a drag operation and notify the model.
|
|
291
|
+
|
|
292
|
+
Re-enables ItemIsMovable (which was disbaled during resize) and fires
|
|
293
|
+
the geometry changed callback with the new coordinates.
|
|
294
|
+
"""
|
|
295
|
+
was_resizing = self._active_handle is not None
|
|
296
|
+
|
|
297
|
+
self._active_handle = None
|
|
298
|
+
self._drag_start_rect = None
|
|
299
|
+
self._drag_start_pos = None
|
|
300
|
+
|
|
301
|
+
# Re-enable movement for future drags
|
|
302
|
+
self.setFlag(
|
|
303
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
super().mouseReleaseEvent(event)
|
|
307
|
+
self._notify_geometry_changed()
|
|
308
|
+
|
|
309
|
+
def resize_to(self, current_pos: QPointF) -> None:
|
|
310
|
+
"""
|
|
311
|
+
Update the rectangle during a resize drag.
|
|
312
|
+
|
|
313
|
+
Computes the new rect by adjusting the edge(s) corresponding to the
|
|
314
|
+
active handle by the mouse delta since the drag started.
|
|
315
|
+
Enforces a minimum size to prevent the box from collapsing to 0 or
|
|
316
|
+
inverting.
|
|
317
|
+
"""
|
|
318
|
+
if self._drag_start_rect is None or self._drag_start_pos is None:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
self.prepareGeometryChange()
|
|
322
|
+
|
|
323
|
+
dx = current_pos.x() - self._drag_start_pos.x()
|
|
324
|
+
dy = current_pos.y() - self._drag_start_pos.y()
|
|
325
|
+
|
|
326
|
+
r = QRectF(self._drag_start_rect)
|
|
327
|
+
handle = self._active_handle
|
|
328
|
+
|
|
329
|
+
# Adjust the appropiate edges based on which handle is active
|
|
330
|
+
if handle in (
|
|
331
|
+
_HandlePosition.TOP_LEFT,
|
|
332
|
+
_HandlePosition.TOP,
|
|
333
|
+
_HandlePosition.TOP_RIGHT,
|
|
334
|
+
):
|
|
335
|
+
new_top = r.top() + dy
|
|
336
|
+
max_top = r.bottom() - MIN_SIZE
|
|
337
|
+
r.setTop(min(new_top, max_top))
|
|
338
|
+
|
|
339
|
+
if handle in (
|
|
340
|
+
_HandlePosition.BOTTOM_LEFT,
|
|
341
|
+
_HandlePosition.BOTTOM,
|
|
342
|
+
_HandlePosition.BOTTOM_RIGHT,
|
|
343
|
+
):
|
|
344
|
+
new_bottom = r.bottom() + dy
|
|
345
|
+
min_bottom = r.top() + MIN_SIZE
|
|
346
|
+
r.setBottom(max(new_bottom, min_bottom))
|
|
347
|
+
|
|
348
|
+
if handle in (
|
|
349
|
+
_HandlePosition.TOP_LEFT,
|
|
350
|
+
_HandlePosition.LEFT,
|
|
351
|
+
_HandlePosition.BOTTOM_LEFT,
|
|
352
|
+
):
|
|
353
|
+
new_left = r.left() + dx
|
|
354
|
+
max_left = r.right() - MIN_SIZE
|
|
355
|
+
r.setLeft(min(new_left, max_left))
|
|
356
|
+
|
|
357
|
+
if handle in (
|
|
358
|
+
_HandlePosition.TOP_RIGHT,
|
|
359
|
+
_HandlePosition.RIGHT,
|
|
360
|
+
_HandlePosition.BOTTOM_RIGHT,
|
|
361
|
+
):
|
|
362
|
+
new_right = r.right() + dx
|
|
363
|
+
min_right = r.left() + MIN_SIZE
|
|
364
|
+
r.setRight(max(new_right, min_right))
|
|
365
|
+
|
|
366
|
+
self.setRect(r)
|
|
367
|
+
|
|
368
|
+
def _notify_geometry_changed(self) -> None:
|
|
369
|
+
"""
|
|
370
|
+
Convert the current item geometry to centre-format and invoke the
|
|
371
|
+
callback.
|
|
372
|
+
|
|
373
|
+
The callback signature matches ValidationModel.set_corrected_geometry:
|
|
374
|
+
(detection_id, xc, yc, width, height) - all in frame pixel coordinates.
|
|
375
|
+
"""
|
|
376
|
+
if self._on_geometry_changed is None:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# The item's scene position plus its local rect give the absolute frame
|
|
380
|
+
# coordinates. Since we set items at pos (0, 0) and use the rect for
|
|
381
|
+
# positioning, the rect coordinates ARE the frame coordinates.
|
|
382
|
+
r = self.rect()
|
|
383
|
+
pos = self.pos()
|
|
384
|
+
|
|
385
|
+
# Absolute frame coordinaates
|
|
386
|
+
abs_x = pos.x() + r.x()
|
|
387
|
+
abs_y = pos.y() + r.y()
|
|
388
|
+
w = r.width()
|
|
389
|
+
h = r.height()
|
|
390
|
+
|
|
391
|
+
# Convert corner-format to centre-format
|
|
392
|
+
xc = abs_x + w / 2
|
|
393
|
+
yc = abs_y + h / 2
|
|
394
|
+
|
|
395
|
+
self._on_geometry_changed(
|
|
396
|
+
self._detection_id, xc, yc, w, h
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def boundingRect(self) -> QRectF:
|
|
400
|
+
"""
|
|
401
|
+
Expand the bounding rect to include resize handles.
|
|
402
|
+
|
|
403
|
+
QGraphicsScene uses the bounding rect for hit testing and repaint
|
|
404
|
+
scheduling. If we don;t expand it, the handles (which extend beyond the
|
|
405
|
+
rectangles edges) won't recieve mouse events and won't be repainted
|
|
406
|
+
correctly.
|
|
407
|
+
"""
|
|
408
|
+
r = self.rect()
|
|
409
|
+
margin = self._current_handle_size() + 13
|
|
410
|
+
return r.adjusted(-margin, -margin, margin, margin)
|
|
411
|
+
|
|
412
|
+
def paint(
|
|
413
|
+
self,
|
|
414
|
+
painter: QPainter,
|
|
415
|
+
option: QStyleOptionGraphicsItem,
|
|
416
|
+
widget: QWidget | None = None,
|
|
417
|
+
) -> None:
|
|
418
|
+
"""
|
|
419
|
+
Draw the bounding box and, if selected, the resize handles.
|
|
420
|
+
|
|
421
|
+
The base rectangle is always drawn. When selected, the box colour
|
|
422
|
+
changes to cyan and resize handles appear at corners and edge
|
|
423
|
+
midpoints.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
# Use cyan pen when selected, default pen otherwise
|
|
427
|
+
if self.isSelected():
|
|
428
|
+
highlight_pen = QPen(QColor(0, 255, 255), 2.0)
|
|
429
|
+
highlight_pen.setCosmetic(True)
|
|
430
|
+
painter.setPen(highlight_pen)
|
|
431
|
+
else:
|
|
432
|
+
painter.setPen(self.pen())
|
|
433
|
+
|
|
434
|
+
painter.setBrush(self.brush())
|
|
435
|
+
painter.drawRect(self.rect())
|
|
436
|
+
|
|
437
|
+
# Draw resize handles when selected
|
|
438
|
+
if self.isSelected():
|
|
439
|
+
handle_pen = QPen(QColor(255, 255, 255), 1.0)
|
|
440
|
+
handle_pen.setCosmetic(True)
|
|
441
|
+
handle_brush = QBrush(QColor(0, 255, 255))
|
|
442
|
+
painter.setPen(handle_pen)
|
|
443
|
+
painter.setBrush(handle_brush)
|
|
444
|
+
|
|
445
|
+
for rect in self._handle_rects().values():
|
|
446
|
+
painter.drawRect(rect)
|
|
447
|
+
|
|
448
|
+
def itemChange(self, change, value):
|
|
449
|
+
"""
|
|
450
|
+
Clamp the item position to stay within frame bounds.
|
|
451
|
+
|
|
452
|
+
Called by Qt whenever the item's position changes (because we set the
|
|
453
|
+
ItemSendsGeometryChanges flag). We intercept position changes and adjust
|
|
454
|
+
them to keep the box within the frame.
|
|
455
|
+
"""
|
|
456
|
+
if (
|
|
457
|
+
change == QGraphicsItem.GraphicsItemChange.ItemPositionChange
|
|
458
|
+
and self._frame_width > 0
|
|
459
|
+
and self._frame_height > 0
|
|
460
|
+
):
|
|
461
|
+
self.prepareGeometryChange()
|
|
462
|
+
r = self.rect()
|
|
463
|
+
new_pos = QPointF(value)
|
|
464
|
+
|
|
465
|
+
# Clamp so the box stays within frame bounds
|
|
466
|
+
min_x = -r.x()
|
|
467
|
+
min_y = -r.y()
|
|
468
|
+
max_x = self._frame_width - r.x() - r.width()
|
|
469
|
+
max_y = self._frame_height - r.y() - r.height()
|
|
470
|
+
|
|
471
|
+
new_pos.setX(max(min_x, min(new_pos.x(), max_x)))
|
|
472
|
+
new_pos.setY(max(min_y, min(new_pos.y(), max_y)))
|
|
473
|
+
|
|
474
|
+
return new_pos
|
|
475
|
+
|
|
476
|
+
return super().itemChange(change, value)
|
|
477
|
+
|
|
478
|
+
def update_colour(self, bgr: tuple[int, int, int]) -> None:
|
|
479
|
+
"""Update the box colour (e.g. after status change)."""
|
|
480
|
+
b, g, r = bgr
|
|
481
|
+
colour = QColor(r, g, b)
|
|
482
|
+
pen = QPen(colour, 2.0)
|
|
483
|
+
pen.setCosmetic(True)
|
|
484
|
+
self.setPen(pen)
|
|
485
|
+
self._default_pen_colour = colour
|
|
486
|
+
self.update()
|