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,422 @@
|
|
|
1
|
+
"""Background video decoder - runs on a QThread"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import cv2
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QObject, QTimer, Signal, Slot
|
|
9
|
+
|
|
10
|
+
from seavision.gui.shared.conversion import numpy_bgr_to_qimage
|
|
11
|
+
from seavision.gui.validation.seekable_source import SeekableVideoSource
|
|
12
|
+
from seavision.engine.detectors import Detection
|
|
13
|
+
from seavision.engine.source import FrameContext
|
|
14
|
+
from seavision.engine.visualiser import FrameAnnotator
|
|
15
|
+
from seavision.engine.visualiser.loader import DetectionSource
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VideoDecoderWorker(QObject):
|
|
21
|
+
"""
|
|
22
|
+
Decodes video frames on a background thread.
|
|
23
|
+
|
|
24
|
+
All communication is through signals - the main thread never calls methods
|
|
25
|
+
on this object directly. Instead, it emits signals that Qt delivers to this
|
|
26
|
+
worker's slots on the worker thread.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Emitted after each frame is decoded and converted
|
|
30
|
+
frame_ready = Signal(object, int, float) # QImage, frame number, timestamp
|
|
31
|
+
|
|
32
|
+
# Emited after a video is successfully opened
|
|
33
|
+
video_opened = Signal(object) # VideoMetadata
|
|
34
|
+
|
|
35
|
+
# Emitted when an error occurs
|
|
36
|
+
error_occurred = Signal(str) # Error message
|
|
37
|
+
|
|
38
|
+
# Emmitted when playback reaches the end of the video
|
|
39
|
+
playback_finished = Signal()
|
|
40
|
+
|
|
41
|
+
def __init__(self, parent=None):
|
|
42
|
+
super().__init__(parent)
|
|
43
|
+
|
|
44
|
+
# Video source
|
|
45
|
+
self._source: SeekableVideoSource | None = None
|
|
46
|
+
|
|
47
|
+
# Playback state
|
|
48
|
+
self._playing = False
|
|
49
|
+
|
|
50
|
+
# Frame annotator instance - stateless, set once for all videos.
|
|
51
|
+
self._annotator = FrameAnnotator()
|
|
52
|
+
|
|
53
|
+
# Detection source for annotations (set externally by the main thread)
|
|
54
|
+
self._detection_source: DetectionSource | None = None
|
|
55
|
+
|
|
56
|
+
# Flag to skip pending seek requests
|
|
57
|
+
self._skip_pending_seeks = False
|
|
58
|
+
|
|
59
|
+
# Selected detection for highlighting (set externally by the
|
|
60
|
+
# main thread)
|
|
61
|
+
self._highlight_detection: Detection | None = None
|
|
62
|
+
|
|
63
|
+
# Playback speed multiplier
|
|
64
|
+
self._speed_multiplier: float = 1.0
|
|
65
|
+
|
|
66
|
+
# Toggle annotation drawing
|
|
67
|
+
self._annotations_enabled: bool = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- PLAYBACK SLOT & METHODS ---
|
|
71
|
+
@Slot(str, bool)
|
|
72
|
+
def open_video(self, filepath: str, preload: bool = False) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Open a video file and emit metadata with the specified loading strategy.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
self.close_video()
|
|
78
|
+
self._source = SeekableVideoSource(filepath, preload=preload)
|
|
79
|
+
self.video_opened.emit(
|
|
80
|
+
self._source.metadata,
|
|
81
|
+
)
|
|
82
|
+
# Show the first frame immediately
|
|
83
|
+
self.request_frame(0)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.error_occurred.emit(str(e))
|
|
86
|
+
|
|
87
|
+
@Slot(int)
|
|
88
|
+
def request_frame(self, frame_number: int) -> None:
|
|
89
|
+
"""Seek to a frame, decode it, and emit the result."""
|
|
90
|
+
if self._skip_pending_seeks:
|
|
91
|
+
return # return immediately to skip pending seeks.
|
|
92
|
+
|
|
93
|
+
if self._source is None:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
self._playing = False # Stop playback if active
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
result = self._source.read_at(frame_number)
|
|
100
|
+
if result is None:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
frame, ctx = result
|
|
104
|
+
if self._annotations_enabled:
|
|
105
|
+
frame = self._annotate_frame(frame, ctx)
|
|
106
|
+
image = numpy_bgr_to_qimage(frame)
|
|
107
|
+
self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.warning(
|
|
110
|
+
"Frame decode error at frame %d: %s",
|
|
111
|
+
frame_number, e
|
|
112
|
+
)
|
|
113
|
+
self.error_occurred.emit(
|
|
114
|
+
f"Frame decode error at frame {frame_number}: {e}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@Slot(int)
|
|
118
|
+
def request_frame_immediate(self, frame_number: int) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Seek to a frame unconditionally - never skipped.
|
|
121
|
+
|
|
122
|
+
Clears the skip flag so that future preview seeks (from the next drag)
|
|
123
|
+
will be processed normally.
|
|
124
|
+
"""
|
|
125
|
+
self._skip_pending_seeks = False
|
|
126
|
+
self._playing = False
|
|
127
|
+
|
|
128
|
+
if self._source is None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
result = self._source.read_at(frame_number)
|
|
133
|
+
if result is None:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
frame, ctx = result
|
|
137
|
+
if self._annotations_enabled:
|
|
138
|
+
frame = self._annotate_frame(frame, ctx)
|
|
139
|
+
image = numpy_bgr_to_qimage(frame)
|
|
140
|
+
self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Frame decode error at frame %d: %s",
|
|
144
|
+
frame_number, e
|
|
145
|
+
)
|
|
146
|
+
self.error_occurred.emit(
|
|
147
|
+
f"Frame decode error at frame {frame_number}: {e}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@Slot(int)
|
|
151
|
+
def start_playback(self, from_frame: int) -> None:
|
|
152
|
+
"""Begin continuous playback from the given frame."""
|
|
153
|
+
if self._source is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
self._playing = True
|
|
157
|
+
self._source.seek(from_frame)
|
|
158
|
+
self._playback_tick()
|
|
159
|
+
|
|
160
|
+
@Slot()
|
|
161
|
+
def stop_playback(self) -> None:
|
|
162
|
+
"""Stop continuous playback."""
|
|
163
|
+
self._playing = False
|
|
164
|
+
|
|
165
|
+
@Slot(float)
|
|
166
|
+
def set_speed(self, multiplier: float) -> None:
|
|
167
|
+
"""Set the playback speed multiplier."""
|
|
168
|
+
self._speed_multiplier = max(0.1, multiplier)
|
|
169
|
+
|
|
170
|
+
def _playback_tick(self) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Decode and emit one frame, then schedule the next tick.
|
|
173
|
+
|
|
174
|
+
Uses QTimer.singleShot to yield control back to the event loop between
|
|
175
|
+
frames. This allows the worker to process other signals (like
|
|
176
|
+
stop_playback or request_frame) between ticks.
|
|
177
|
+
"""
|
|
178
|
+
if not self._playing or self._source is None:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
tick_start = time.perf_counter()
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
result = self._source.read()
|
|
185
|
+
if result is None:
|
|
186
|
+
self._playing = False
|
|
187
|
+
self.playback_finished.emit()
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
frame, ctx = result
|
|
191
|
+
if self._annotations_enabled:
|
|
192
|
+
frame = self._annotate_frame(frame, ctx)
|
|
193
|
+
image = numpy_bgr_to_qimage(frame)
|
|
194
|
+
self.frame_ready.emit(image, ctx.frame_number, ctx.timestamp)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.warning(
|
|
197
|
+
"Playback decode error at frame %d: %s",
|
|
198
|
+
self._source.current_frame, e
|
|
199
|
+
)
|
|
200
|
+
self.error_occurred.emit(
|
|
201
|
+
f"Playback decode error at frame {self._source.current_frame}: {e}"
|
|
202
|
+
)
|
|
203
|
+
self._playing = False
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Subtract the time already spent from the target interval
|
|
207
|
+
elapsed_ms = (time.perf_counter() - tick_start) * 1000
|
|
208
|
+
target_ms = 1000 / (self._source.metadata.fps * self._speed_multiplier)
|
|
209
|
+
remaining_ms = max(1, int(target_ms - elapsed_ms))
|
|
210
|
+
|
|
211
|
+
QTimer.singleShot(remaining_ms, self._playback_tick)
|
|
212
|
+
|
|
213
|
+
# --- ANNOTATION SLOTS & METHODS ---
|
|
214
|
+
@Slot(bool)
|
|
215
|
+
def set_annotations_enabled(self, enabled: bool) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Toggle worker-side annotation rendering.
|
|
218
|
+
|
|
219
|
+
When disabled, the worker emits raw frames without any detection
|
|
220
|
+
overlays. The interactive scene handles detection rendering instead.
|
|
221
|
+
"""
|
|
222
|
+
self._annotations_enabled = enabled
|
|
223
|
+
|
|
224
|
+
@Slot(object)
|
|
225
|
+
def set_detection_source(self, source: Optional[DetectionSource]) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Set the detection source for overlay rendering.
|
|
228
|
+
|
|
229
|
+
Called when a session is opened or when switching videos within a
|
|
230
|
+
session. Pass None to clear detections (e.g. when returning to plain
|
|
231
|
+
video-only mode).
|
|
232
|
+
"""
|
|
233
|
+
self._detection_source = source
|
|
234
|
+
logger.debug(
|
|
235
|
+
"Detection source set: %s",
|
|
236
|
+
type(source).__name__ if source else "None"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@Slot(object)
|
|
240
|
+
def set_highlight_detection(self, detection: Detection | None) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Set which detection to highlight with a distinct colour.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
detection: The detection object to highlight, or None to clear the
|
|
246
|
+
highlight.
|
|
247
|
+
"""
|
|
248
|
+
self._highlight_detection = detection
|
|
249
|
+
|
|
250
|
+
def _annotate_frame(self, frame, context: FrameContext):
|
|
251
|
+
"""
|
|
252
|
+
Annotate a frame with detection overlays and optional highlight.
|
|
253
|
+
|
|
254
|
+
Detections are draw individually, allowing each to have its own colour
|
|
255
|
+
based on review status. The _StatusFilterDetectionSource adapter
|
|
256
|
+
attaches a `_render_colour` attribute to each detection.
|
|
257
|
+
|
|
258
|
+
The highlight is drawn on top of everything, so it always stands out
|
|
259
|
+
regardless of the underlying detection colour.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
if self._detection_source is None:
|
|
263
|
+
return frame
|
|
264
|
+
|
|
265
|
+
detections = self._detection_source.get_detections_for_frame(
|
|
266
|
+
context.frame_number
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if not detections:
|
|
270
|
+
return frame
|
|
271
|
+
|
|
272
|
+
frame = frame.copy()
|
|
273
|
+
|
|
274
|
+
for det in detections:
|
|
275
|
+
colour = getattr(det, "_render_colour", None)
|
|
276
|
+
self._draw_detection_box(frame, det, colour=colour)
|
|
277
|
+
|
|
278
|
+
# Highlight the selected detection if it is on this frame
|
|
279
|
+
if self._highlight_detection is not None:
|
|
280
|
+
hl = self._highlight_detection
|
|
281
|
+
highlighted = False
|
|
282
|
+
|
|
283
|
+
# First, try exact match (same frame, same coords)
|
|
284
|
+
if hl.frame_number == context.frame_number:
|
|
285
|
+
for det in detections:
|
|
286
|
+
if (det.xc == hl.xc and det.yc == hl.yc
|
|
287
|
+
and det.width == hl.width
|
|
288
|
+
and det.height == hl.height):
|
|
289
|
+
self._draw_highlight(frame, det)
|
|
290
|
+
highlighted = True
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
# If the detection wasn't in the filtered list (e.g. rejected/
|
|
294
|
+
# skipped with "show rejected off"), draw it anyway
|
|
295
|
+
if not highlighted:
|
|
296
|
+
self._draw_detection_box(frame, hl, colour=(128, 128, 128))
|
|
297
|
+
self._draw_highlight(frame, hl)
|
|
298
|
+
highlighted = True
|
|
299
|
+
|
|
300
|
+
# If not on the exact frame, follow by track_id
|
|
301
|
+
if not highlighted and hl.track_id is not None:
|
|
302
|
+
for det in detections:
|
|
303
|
+
if det.track_id == hl.track_id:
|
|
304
|
+
self._draw_highlight(frame, det)
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
return frame
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _draw_detection_box(
|
|
311
|
+
frame,
|
|
312
|
+
detection: Detection,
|
|
313
|
+
colour: tuple[int, int, int] | None = None
|
|
314
|
+
) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Draw a single detection bounding box with an optional label.
|
|
317
|
+
|
|
318
|
+
This replaces the FrameAnnotator for in-GUI rendering, giving us control
|
|
319
|
+
pver per-detection colours and a cleaner visual style that is better
|
|
320
|
+
suited to interactive review than the pipeline annotator's output.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
frame: BGR numpy array, modified in place.
|
|
324
|
+
detection: The detection to draw.
|
|
325
|
+
colour: BGR tuple. If None, defaults to red (0, 0, 255).
|
|
326
|
+
"""
|
|
327
|
+
if colour is None:
|
|
328
|
+
colour = (0, 0, 255)
|
|
329
|
+
|
|
330
|
+
# Convert from centre format to corner format
|
|
331
|
+
x1 = int(detection.xc - detection.width / 2)
|
|
332
|
+
y1 = int(detection.yc - detection.height / 2)
|
|
333
|
+
x2 = int(detection.xc + detection.width / 2)
|
|
334
|
+
y2 = int(detection.yc + detection.height / 2)
|
|
335
|
+
|
|
336
|
+
# Draw the bounding box - 2px for a clean look
|
|
337
|
+
cv2.rectangle(frame, (x1, y1), (x2, y2), colour, 2)
|
|
338
|
+
|
|
339
|
+
# Build a compact label
|
|
340
|
+
parts = []
|
|
341
|
+
if detection.label:
|
|
342
|
+
parts.append(detection.label)
|
|
343
|
+
if detection.confidence is not None:
|
|
344
|
+
parts.append(f"{detection.confidence:.2f}")
|
|
345
|
+
|
|
346
|
+
if not parts:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
label_text = " | ".join(parts)
|
|
350
|
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
351
|
+
font_scale = 0.4
|
|
352
|
+
font_thickness = 1
|
|
353
|
+
(text_w, text_h), baseline = cv2.getTextSize(
|
|
354
|
+
label_text, font, font_scale, font_thickness
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Position the label above the box, falling inside if at the top edge of
|
|
358
|
+
# the frame
|
|
359
|
+
label_y = y1 - 4
|
|
360
|
+
if label_y - text_h < 0:
|
|
361
|
+
label_y = y1 + text_h + 4
|
|
362
|
+
|
|
363
|
+
label_x = x1
|
|
364
|
+
|
|
365
|
+
# Semi-transparent background for readability
|
|
366
|
+
bg_x1 = max(0, label_x - 2)
|
|
367
|
+
bg_y1 = max(0, label_y - text_h - 2)
|
|
368
|
+
bg_x2 = min(frame.shape[1], label_x + text_w + 2)
|
|
369
|
+
bg_y2 = min(frame.shape[0], label_y + baseline + 2)
|
|
370
|
+
|
|
371
|
+
# Draw filled rectangle with alpha blending
|
|
372
|
+
overlay = frame.copy()
|
|
373
|
+
cv2.rectangle(
|
|
374
|
+
overlay, (bg_x1, bg_y1), (bg_x2, bg_y2), (0, 0, 0), -1
|
|
375
|
+
)
|
|
376
|
+
cv2.addWeighted(overlay, 0.5, frame, 0.5, 0, frame)
|
|
377
|
+
|
|
378
|
+
# Draw the text in white for contrast
|
|
379
|
+
cv2.putText(
|
|
380
|
+
frame, label_text,
|
|
381
|
+
(label_x, label_y),
|
|
382
|
+
font, font_scale, (255, 255, 255), font_thickness,
|
|
383
|
+
cv2.LINE_AA,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _draw_highlight(frame, detection: Detection):
|
|
388
|
+
"""
|
|
389
|
+
Draw a highlight rectangle on the selected detection.
|
|
390
|
+
|
|
391
|
+
Uses cyan (BGR: 255, 255, 0) with a thicker line to
|
|
392
|
+
distinguish it from the standard annotation boxes.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
# Convert from centre format to corner format
|
|
396
|
+
x1 = int(detection.xc - detection.width / 2)
|
|
397
|
+
y1 = int(detection.yc - detection.height / 2)
|
|
398
|
+
x2 = int(detection.xc + detection.width / 2)
|
|
399
|
+
y2 = int(detection.yc + detection.height / 2)
|
|
400
|
+
|
|
401
|
+
# Outer glow — wider, semi-transparent cyan
|
|
402
|
+
overlay = frame.copy()
|
|
403
|
+
# cv2.rectangle(overlay, (x1 - 2, y1 - 2), (x2 + 2, y2 + 2),
|
|
404
|
+
# (255, 255, 0), 4)
|
|
405
|
+
# cv2.addWeighted(overlay, 0.4, frame, 0.6, 0, frame)
|
|
406
|
+
|
|
407
|
+
# Inner highlight — bright, solid cyan
|
|
408
|
+
cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 255, 0), 2)
|
|
409
|
+
|
|
410
|
+
# --- CLEANUP ---
|
|
411
|
+
@Slot()
|
|
412
|
+
def close_video(self) -> None:
|
|
413
|
+
"""Release the current video."""
|
|
414
|
+
self._playing = False
|
|
415
|
+
if self._source is not None:
|
|
416
|
+
self._source.close()
|
|
417
|
+
self._source = None
|
|
418
|
+
|
|
419
|
+
@Slot()
|
|
420
|
+
def clear_detection_source(self) -> None:
|
|
421
|
+
"""Remove the detection source (returns to plain video mode)."""
|
|
422
|
+
self._detection_source = None
|