Skip to content

Commit e715e4c

Browse files
committed
Async recording stop and queued frame dispatch
Move recording shutdown off the UI thread and finalize it via a Qt signal so stop actions no longer block the interface. RecordingManager now uses a lock-protected recorder map plus a bounded background frame-dispatch queue/thread to decouple frame intake from disk writes, drop frames under sustained backpressure, and cleanly stop/tear down recorders. The recording-with-overlays UI path is also disabled in this change.
1 parent 09226a3 commit e715e4c

2 files changed

Lines changed: 190 additions & 40 deletions

File tree

‎dlclivegui/gui/main_window.py‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
import json
77
import logging
88
import os
9+
import threading
910
import time
1011
from pathlib import Path
1112

1213
import cv2
1314
import numpy as np
14-
from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl
15+
from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl, Signal
1516
from PySide6.QtGui import (
1617
QAction,
1718
QActionGroup,
@@ -87,6 +88,8 @@
8788
class DLCLiveMainWindow(QMainWindow):
8889
"""Main application window."""
8990

91+
_recording_stopped_async = Signal()
92+
9093
def __init__(self, config: ApplicationSettings | None = None):
9194
super().__init__()
9295
self.setWindowTitle("DeepLabCut Live GUI")
@@ -178,6 +181,9 @@ def __init__(self, config: ApplicationSettings | None = None):
178181
self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y)
179182
# Display flag (decoupled from frame capture for performance)
180183
self._display_dirty: bool = False
184+
# Recording state
185+
self._recording_stopping = False
186+
self._recording_stopped_async.connect(self._on_recording_stopped_async)
181187

182188
self._load_icons()
183189
self._preview_pixmap = QPixmap(LOGO_ALPHA)
@@ -641,11 +647,11 @@ def _build_recording_group(self) -> QGroupBox:
641647
form.addRow(grid)
642648

643649
# Recording options
644-
self.record_with_overlays_checkbox = QCheckBox("Record video with overlays")
645-
self.record_with_overlays_checkbox.setToolTip(
646-
"Enable to include pose overlays in recorded video (keypoints & bounding boxes)"
647-
)
648-
self.record_with_overlays_checkbox.setChecked(False)
650+
# self.record_with_overlays_checkbox = QCheckBox("Record video with overlays")
651+
# self.record_with_overlays_checkbox.setToolTip(
652+
# "Enable to include pose overlays in recorded video (keypoints & bounding boxes)"
653+
# )
654+
# self.record_with_overlays_checkbox.setChecked(False)
649655

650656
self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters")
651657
self.fast_encoding_checkbox.setToolTip(
@@ -658,7 +664,7 @@ def _build_recording_group(self) -> QGroupBox:
658664
recording_options = QWidget()
659665
recording_options_layout = QHBoxLayout(recording_options)
660666
recording_options_layout.setContentsMargins(0, 0, 0, 0)
661-
recording_options_layout.addWidget(self.record_with_overlays_checkbox)
667+
# recording_options_layout.addWidget(self.record_with_overlays_checkbox)
662668
recording_options_layout.addWidget(self.fast_encoding_checkbox)
663669
recording_options_layout.addStretch(1)
664670

@@ -1452,8 +1458,8 @@ def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp
14521458
if not self._rec_manager.is_active:
14531459
return
14541460

1455-
if self.record_with_overlays_checkbox.isChecked():
1456-
frame = self._render_overlays_for_recording(camera_id, frame)
1461+
# if self.record_with_overlays_checkbox.isChecked():
1462+
# frame = self._render_overlays_for_recording(camera_id, frame)
14571463

14581464
self._rec_manager.write_frame(camera_id, frame, timestamp)
14591465

@@ -1609,9 +1615,35 @@ def _stop_multi_camera_recording(self) -> None:
16091615
if not self._rec_manager.is_active:
16101616
return
16111617

1612-
self.multi_camera_controller.set_recording_frame_do_emit(False)
1618+
if getattr(self, "_recording_stopping", False):
1619+
return
1620+
1621+
self._recording_stopping = True
16131622

1614-
self._rec_manager.stop_all()
1623+
self.start_record_button.setEnabled(False)
1624+
self.stop_record_button.setEnabled(False)
1625+
self.statusBar().showMessage("Stopping multi-camera recording…", 3000)
1626+
1627+
# Stop frame emission immediately so no new frames enter recording pipeline.
1628+
try:
1629+
self.multi_camera_controller.set_recording_frame_do_emit(False)
1630+
except Exception:
1631+
logger.exception("Failed to disable recording frame emission")
1632+
1633+
def worker():
1634+
try:
1635+
self._rec_manager.stop_all()
1636+
finally:
1637+
self._recording_stopped_async.emit()
1638+
1639+
threading.Thread(
1640+
target=worker,
1641+
name="StopRecordingWorker",
1642+
daemon=True,
1643+
).start()
1644+
1645+
def _on_recording_stopped_async(self) -> None:
1646+
self._recording_stopping = False
16151647
self.start_record_button.setEnabled(True)
16161648
self.stop_record_button.setEnabled(False)
16171649
self.statusBar().showMessage("Multi-camera recording stopped", 3000)

‎dlclivegui/gui/recording_manager.py‎

Lines changed: 147 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import logging
4+
import queue
5+
import threading
46
import time
57
from pathlib import Path
68

@@ -14,6 +16,8 @@
1416

1517
log = logging.getLogger(__name__)
1618

19+
_FRAME_SENTINEL = object()
20+
1721

1822
class RecordingManager:
1923
"""Handle multi-camera recording lifecycle and filenames."""
@@ -23,21 +27,30 @@ def __init__(self):
2327
self._session_dir: Path | None = None
2428
self._run_dir: Path | None = None
2529

30+
self._lock = threading.RLock()
31+
self._frame_queue: queue.Queue | None = None
32+
self._dispatch_thread: threading.Thread | None = None
33+
self._dispatch_stop = threading.Event()
34+
2635
@property
2736
def is_active(self) -> bool:
28-
return bool(self._recorders)
37+
with self._lock:
38+
return bool(self._recorders)
2939

3040
@property
3141
def recorders(self) -> dict[str, VideoRecorder]:
32-
return self._recorders
42+
with self._lock:
43+
return dict(self._recorders)
3344

3445
@property
3546
def session_dir(self) -> Path | None:
36-
return self._session_dir
47+
with self._lock:
48+
return self._session_dir
3749

3850
@property
3951
def run_dir(self) -> Path | None:
40-
return self._run_dir
52+
with self._lock:
53+
return self._run_dir
4154

4255
@staticmethod
4356
def _backend_ns(cam: CameraSettings) -> dict:
@@ -89,7 +102,71 @@ def _resolve_recording_fps(
89102
return None
90103

91104
def pop(self, cam_id: str, default=None) -> VideoRecorder | None:
92-
return self._recorders.pop(cam_id, default)
105+
with self._lock:
106+
return self._recorders.pop(cam_id, default)
107+
108+
def _start_dispatcher(self) -> None:
109+
with self._lock:
110+
if self._dispatch_thread is not None and self._dispatch_thread.is_alive():
111+
return
112+
113+
self._dispatch_stop.clear()
114+
self._frame_queue = queue.Queue(maxsize=4096)
115+
self._dispatch_thread = threading.Thread(
116+
target=self._dispatch_loop,
117+
name="RecordingManagerDispatcher",
118+
daemon=True,
119+
)
120+
self._dispatch_thread.start()
121+
122+
def _stop_dispatcher(self, timeout: float = 2.0) -> None:
123+
self._dispatch_stop.set()
124+
125+
with self._lock:
126+
q = self._frame_queue
127+
t = self._dispatch_thread
128+
129+
if q is not None:
130+
try:
131+
q.put_nowait(_FRAME_SENTINEL)
132+
except queue.Full:
133+
pass
134+
135+
if t is not None:
136+
t.join(timeout=timeout)
137+
if t.is_alive():
138+
log.warning("Recording frame dispatcher did not stop within %.1fs", timeout)
139+
140+
with self._lock:
141+
self._dispatch_thread = None
142+
self._frame_queue = None
143+
self._dispatch_stop.clear()
144+
145+
def _dispatch_loop(self) -> None:
146+
with self._lock:
147+
q = self._frame_queue
148+
149+
if q is None:
150+
return
151+
152+
while not self._dispatch_stop.is_set():
153+
try:
154+
item = q.get(timeout=0.1)
155+
except queue.Empty:
156+
continue
157+
158+
try:
159+
if item is _FRAME_SENTINEL:
160+
break
161+
162+
cam_id, frame, timestamp = item
163+
self._write_frame_now(cam_id, frame, timestamp)
164+
165+
finally:
166+
try:
167+
q.task_done()
168+
except ValueError:
169+
pass
93170

94171
def start_all(
95172
self,
@@ -117,8 +194,9 @@ def start_all(
117194
Returns:
118195
run_dir if at least one recorder started, else None.
119196
"""
120-
if self._recorders:
121-
return self._run_dir
197+
with self._lock:
198+
if self._recorders:
199+
return self._run_dir
122200

123201
if not active_cams:
124202
return None
@@ -135,8 +213,9 @@ def start_all(
135213
log.error("Failed to create run dir: %s", exc)
136214
return None
137215

138-
self._session_dir = session_dir
139-
self._run_dir = run_dir
216+
with self._lock:
217+
self._session_dir = session_dir
218+
self._run_dir = run_dir
140219

141220
started_any = False
142221

@@ -174,7 +253,8 @@ def start_all(
174253
)
175254
try:
176255
recorder.start()
177-
self._recorders[cam_id] = recorder
256+
with self._lock:
257+
self._recorders[cam_id] = recorder
178258
started_any = True
179259
log.info("Started recording %s -> %s", cam_id, cam_path)
180260
except Exception as exc:
@@ -184,44 +264,78 @@ def start_all(
184264
return None
185265

186266
if not started_any:
187-
self._recorders.clear()
188-
self._session_dir = None
189-
self._run_dir = None
267+
with self._lock:
268+
self._recorders.clear()
269+
self._session_dir = None
270+
self._run_dir = None
190271
return None
191272

273+
self._start_dispatcher()
192274
return run_dir
193275

194276
def stop_all(self) -> None:
195-
for cam_id, rec in self._recorders.items():
277+
self._stop_dispatcher()
278+
279+
with self._lock:
280+
recorders = list(self._recorders.items())
281+
self._recorders.clear()
282+
283+
for cam_id, rec in recorders:
196284
try:
197285
rec.stop()
198286
log.info("Stopped recording %s", cam_id)
199287
except Exception as exc:
200288
log.warning("Error stopping recorder for %s: %s", cam_id, exc)
201-
self._recorders.clear()
202-
self._session_dir = None
203-
self._run_dir = None
204289

205-
def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None:
206-
rec = self._recorders.get(cam_id)
290+
with self._lock:
291+
self._session_dir = None
292+
self._run_dir = None
293+
294+
def _write_frame_now(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None:
295+
with self._lock:
296+
rec = self._recorders.get(cam_id)
297+
207298
if not rec or not rec.is_running:
208299
return
300+
209301
try:
210302
rec.write(frame, timestamp=timestamp if timestamp is not None else time.time())
211303
except Exception as exc:
212304
log.warning(
213-
"Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s",
305+
"Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s. Removing recorder.",
214306
cam_id,
215307
type(exc).__name__,
216308
str(exc) or repr(exc),
217309
getattr(frame, "shape", None),
218310
getattr(frame, "dtype", None),
219311
)
220-
try:
221-
rec.stop()
222-
except Exception:
223-
log.exception("Failed to stop recorder for %s after write error.")
224-
self._recorders.pop(cam_id, None)
312+
313+
with self._lock:
314+
rec = self._recorders.pop(cam_id, None)
315+
316+
if rec is not None:
317+
try:
318+
rec.stop()
319+
except Exception:
320+
log.exception("Failed to stop recorder for %s after write error.", cam_id)
321+
322+
def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None:
323+
with self._lock:
324+
q = self._frame_queue
325+
active = cam_id in self._recorders
326+
327+
if not active or q is None:
328+
return
329+
330+
try:
331+
q.put_nowait((cam_id, frame, timestamp if timestamp is not None else time.time()))
332+
except queue.Full:
333+
log.warning(
334+
"Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s",
335+
cam_id,
336+
getattr(frame, "shape", None),
337+
getattr(frame, "dtype", None),
338+
)
225339

226340
def get_stats_summary(self) -> str:
227341
totals = {
@@ -235,7 +349,11 @@ def get_stats_summary(self) -> str:
235349
"max_latency": 0.0,
236350
"avg_latencies": [],
237351
}
238-
for rec in self._recorders.values():
352+
353+
with self._lock:
354+
recorders = list(self._recorders.values())
355+
356+
for rec in recorders:
239357
stats: RecorderStats | None = rec.get_stats()
240358
if not stats:
241359
continue
@@ -249,8 +367,8 @@ def get_stats_summary(self) -> str:
249367
totals["max_latency"] = max(totals["max_latency"], stats.last_latency)
250368
totals["avg_latencies"].append(stats.average_latency)
251369

252-
if len(self._recorders) == 1:
253-
rec = next(iter(self._recorders.values()))
370+
if len(recorders) == 1:
371+
rec = recorders[0]
254372
stats = rec.get_stats()
255373
if stats:
256374
from dlclivegui.utils.stats import format_recorder_stats
@@ -265,7 +383,7 @@ def get_stats_summary(self) -> str:
265383
fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0
266384

267385
return (
268-
f"{len(self._recorders)} cams | {totals['written']}/{totals['enqueued']} frames | "
386+
f"{len(recorders)} cams | {totals['written']}/{totals['enqueued']} frames | "
269387
f"writer {totals['write_fps']:.1f} fps | "
270388
f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | "
271389
f"queue {queue_text} ({fill_pct:.0f}%) | "

0 commit comments

Comments
 (0)