11from __future__ import annotations
22
33import logging
4+ import queue
5+ import threading
46import time
57from pathlib import Path
68
1416
1517log = logging .getLogger (__name__ )
1618
19+ _FRAME_SENTINEL = object ()
20+
1721
1822class 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