22
33import logging
44import subprocess
5+ import threading
6+ import time
57from typing import Optional
68
79import cv2
1113
1214class CameraReader :
1315 """
14- Camera reader using OpenCV with V4L2 backend.
16+ Camera reader using OpenCV with a V4L2 backend.
17+
18+ Capture runs in a BACKGROUND THREAD that continuously pulls the newest frame
19+ and keeps only the latest one. ``read_frame()`` then returns that latest
20+ frame without blocking. This mirrors what ``v4l2-ctl --stream-mmap`` does
21+ (drain continuously) and avoids the classic OpenCV problem where a
22+ synchronous ``cap.read()`` in the main loop serializes capture with
23+ processing and throttles FPS far below what the sensor can deliver.
1524 """
1625
1726 def __init__ (
@@ -21,36 +30,37 @@ def __init__(
2130 height : int ,
2231 fps : int ,
2332 rotate_90_cw : bool = False ,
33+ threaded : bool = True ,
2434 ):
25- """
26- Initialize the camera reader with the specified device and settings.
27-
28- Parameters
29- ----------
30- device : str
31- Video device path (e.g., '/dev/video0').
32- width : int
33- Desired frame width.
34- height : int
35- Desired frame height.
36- fps : int
37- Desired frames per second.
38- rotate_90_cw : bool
39- Whether to rotate frames 90 degrees clockwise.
40- """
4135 self .device = device
4236 self .width = width
4337 self .height = height
4438 self .fps = fps
4539 self .rotate_90_cw = rotate_90_cw
40+ self .threaded = threaded
4641
4742 self .cap : Optional [cv2 .VideoCapture ] = None
43+
44+ # Latest-frame handoff between the grab thread and read_frame().
45+ self ._latest = None
46+ self ._latest_lock = threading .Lock ()
47+ self ._frame_ready = threading .Event ()
48+ self ._stop = threading .Event ()
49+ self ._grab_thread : Optional [threading .Thread ] = None
50+
4851 self .open_camera ()
4952
53+ if self .threaded :
54+ self ._grab_thread = threading .Thread (
55+ target = self ._grab_loop , name = "camera-grab" , daemon = True
56+ )
57+ self ._grab_thread .start ()
58+ # Give the thread a moment to land the first frame so the first
59+ # read_frame() isn't None.
60+ self ._frame_ready .wait (timeout = 2.0 )
61+
5062 def open_camera (self ):
51- """
52- Open the camera using the specified device and settings.
53- """
63+ """Open the camera using the specified device and settings."""
5464 self .open_capture (self .device , self .width , self .height , int (self .fps ))
5565 if self .cap is None or not self .cap .isOpened ():
5666 raise RuntimeError (f"Failed to open camera on device { self .device } " )
@@ -79,7 +89,6 @@ def open_capture(
7989 self .cap .set (cv2 .CAP_PROP_FPS , fps )
8090 self .cap .set (cv2 .CAP_PROP_BUFFERSIZE , 1 )
8191 self .cap .set (cv2 .CAP_PROP_AUTO_EXPOSURE , 3 )
82-
8392 try :
8493 subprocess .run (
8594 [
@@ -104,10 +113,8 @@ def open_capture(
104113 device ,
105114 (e .stderr or e .stdout or "" ).strip (),
106115 )
107-
108116 if not self .cap or not self .cap .isOpened ():
109117 raise RuntimeError (f"Failed to open camera device { device } " )
110-
111118 actual_width = int (self .cap .get (cv2 .CAP_PROP_FRAME_WIDTH ))
112119 actual_height = int (self .cap .get (cv2 .CAP_PROP_FRAME_HEIGHT ))
113120 actual_fps = float (self .cap .get (cv2 .CAP_PROP_FPS )) or float (fps )
@@ -119,11 +126,41 @@ def open_capture(
119126 actual_fps ,
120127 )
121128 self .width , self .height , self .fps = actual_width , actual_height , actual_fps
122-
123129 except Exception as e :
124130 logging .error ("Error opening camera %s: %s" , device , e )
125131 self .cap = None
126132
133+ def _grab_loop (self ):
134+ """Continuously read the newest frame; keep only the latest."""
135+ fail = 0
136+ while not self ._stop .is_set ():
137+ if self .cap is None or not self .cap .isOpened ():
138+ time .sleep (0.05 )
139+ try :
140+ self .cap = None
141+ self .open_capture (
142+ self .device , self .width , self .height , int (self .fps or 30 )
143+ )
144+ except Exception :
145+ pass
146+ continue
147+
148+ ret , frame = self .cap .read ()
149+ if not ret or frame is None :
150+ fail += 1
151+ if fail % 50 == 0 :
152+ logging .warning ("camera grab failing (%d)" , fail )
153+ time .sleep (0.005 )
154+ continue
155+ fail = 0
156+
157+ if self .rotate_90_cw :
158+ frame = cv2 .rotate (frame , cv2 .ROTATE_90_CLOCKWISE )
159+
160+ with self ._latest_lock :
161+ self ._latest = frame
162+ self ._frame_ready .set ()
163+
127164 def is_opened (self ) -> bool :
128165 """
129166 Check if the camera is opened.
@@ -136,21 +173,25 @@ def is_opened(self) -> bool:
136173
137174 def read_frame (self ):
138175 """
139- Read a frame from the camera .
176+ Return the most recent frame .
140177
141178 Returns
142179 -------
143- The captured frame as a cv2.Mat object, or None if reading failed.
180+ Threaded mode: non-blocking, returns the latest grabbed frame (or None
181+ until the first one arrives). Non-threaded mode: the original synchronous
182+ read, kept for compatibility.
144183 """
184+ if self .threaded :
185+ with self ._latest_lock :
186+ return None if self ._latest is None else self ._latest .copy ()
187+
145188 if not self .is_opened ():
146189 logging .warning ("Camera is not opened. Reopening..." )
147190 self .open_capture (self .device , self .width , self .height , int (self .fps or 30 ))
148-
149191 ret , frame = self .cap .read () if self .cap else (False , None )
150192 if not ret :
151193 logging .warning ("Failed to read frame from camera." )
152194 return None
153-
154195 if self .rotate_90_cw and frame is not None :
155196 frame = cv2 .rotate (frame , cv2 .ROTATE_90_CLOCKWISE )
156197 return frame
@@ -159,6 +200,9 @@ def release(self) -> None:
159200 """
160201 Release the camera resource.
161202 """
203+ self ._stop .set ()
204+ if self ._grab_thread and self ._grab_thread .is_alive ():
205+ self ._grab_thread .join (timeout = 1.0 )
162206 if self .cap :
163207 self .cap .release ()
164208 self .cap = None
0 commit comments