1111class CameraEngine (QThread ):
1212 frame_ready = pyqtSignal (QImage )
1313 error_occurred = pyqtSignal (str )
14+ camera_ready = pyqtSignal () # Emitted after camera is warmed up and stable
15+
16+ # Number of warm-up frames to discard on camera open.
17+ # These initial frames are often black/corrupted while AE/AF settles.
18+ WARMUP_FRAMES = 10
1419
1520 def __init__ (self , settings : AppSettings ):
1621 super ().__init__ ()
@@ -59,36 +64,107 @@ def acknowledge_frame(self):
5964 with self .lock :
6065 self .ui_ready = True
6166
67+ def _init_camera (self , camera_index : int , width : int , height : int ):
68+ """
69+ Initialize camera with settings optimized to prevent auto-exposure
70+ hunting (flash blink) and minimize startup latency.
71+
72+ Returns an opened VideoCapture or None on failure.
73+ """
74+ cap = cv2 .VideoCapture (camera_index , cv2 .CAP_DSHOW )
75+ if not cap .isOpened ():
76+ return None
77+
78+ # 1. Force MJPG codec for maximum quality & faster decode from hardware
79+ cap .set (cv2 .CAP_PROP_FOURCC , cv2 .VideoWriter_fourcc (* 'MJPG' ))
80+
81+ # 2. Set resolution
82+ cap .set (cv2 .CAP_PROP_FRAME_WIDTH , width )
83+ cap .set (cv2 .CAP_PROP_FRAME_HEIGHT , height )
84+
85+ # 3. Minimize internal frame buffer to prevent stale frames on start
86+ # (default is often 4+ frames, causing perceived startup lag)
87+ cap .set (cv2 .CAP_PROP_BUFFERSIZE , 1 )
88+
89+ # 4. Suppress auto-exposure hunting that causes flash/LED blinking.
90+ # DirectShow convention: 0.25 = manual exposure, 0.75 = auto.
91+ # This is best-effort — not all cameras/drivers support it.
92+ cap .set (cv2 .CAP_PROP_AUTO_EXPOSURE , 0.25 )
93+ cap .set (cv2 .CAP_PROP_EXPOSURE , - 5 )
94+
95+ # 5. Disable autofocus to prevent mechanical hunting delays
96+ cap .set (cv2 .CAP_PROP_AUTOFOCUS , 0 )
97+
98+ # NOTE: We deliberately do NOT set CAP_PROP_FPS via DirectShow.
99+ # Setting FPS triggers a full camera pipeline re-negotiation which
100+ # causes additional auto-exposure/flash blinks and adds 1-2s startup
101+ # delay. Frame pacing is instead handled by pyvirtualcam's
102+ # sleep_until_next_frame().
103+
104+ return cap
105+
106+ def _warmup_camera (self , cap , num_frames : int = None ):
107+ """
108+ Discard initial frames that are typically black or have unstable
109+ exposure/white-balance. Uses grab() instead of read() for speed
110+ since we don't need to decode these frames.
111+ """
112+ if num_frames is None :
113+ num_frames = self .WARMUP_FRAMES
114+ for _ in range (num_frames ):
115+ if not self ._run_flag :
116+ break
117+ cap .grab ()
118+
119+ def _restore_auto_exposure (self , cap ):
120+ """
121+ Re-enable auto-exposure after warm-up so the camera adapts to
122+ changing lighting conditions during normal operation.
123+ """
124+ cap .set (cv2 .CAP_PROP_AUTO_EXPOSURE , 0.75 )
125+
62126 def run (self ):
63127 try :
64- # Check run flag before initializing
128+ # Snapshot settings under lock before slow hardware init
65129 with self .lock :
66130 if not self ._run_flag :
67131 return
68- # Hardware Initialization
69- self .cap = cv2 .VideoCapture (self .settings .camera_index , cv2 .CAP_DSHOW )
70- curr_w = self .cap .get (cv2 .CAP_PROP_FRAME_WIDTH )
71- curr_h = self .cap .get (cv2 .CAP_PROP_FRAME_HEIGHT )
72- curr_fps = self .cap .get (cv2 .CAP_PROP_FPS )
73-
74- if curr_w != self .settings .width :
75- self .cap .set (cv2 .CAP_PROP_FRAME_WIDTH , self .settings .width )
76- if curr_h != self .settings .height :
77- self .cap .set (cv2 .CAP_PROP_FRAME_HEIGHT , self .settings .height )
78- if curr_fps != self .settings .fps :
79- self .cap .set (cv2 .CAP_PROP_FPS , self .settings .fps )
80-
81- if not self .cap .isOpened ():
132+ camera_index = self .settings .camera_index
133+ width = self .settings .width
134+ height = self .settings .height
135+ fps = self .settings .fps
136+
137+ # --- Hardware Initialization (outside lock — this is slow I/O) ---
138+ cap = self ._init_camera (camera_index , width , height )
139+ if cap is None :
82140 self .error_occurred .emit (
83- f"Could not open camera { self . settings . camera_index } "
141+ f"Could not open camera { camera_index } "
84142 )
85143 return
86144
87- # Virtual Camera Initialization: Use defaults to safely pick up available driver (OBS, etc.)
145+ with self .lock :
146+ if not self ._run_flag :
147+ cap .release ()
148+ return
149+ self .cap = cap
150+
151+ # Discard warm-up frames (black/unstable exposure)
152+ self ._warmup_camera (cap )
153+
154+ if not self ._run_flag :
155+ return
156+
157+ # Re-enable auto-exposure now that initial frames are stable
158+ self ._restore_auto_exposure (cap )
159+
160+ # Notify UI that camera is ready and streaming
161+ self .camera_ready .emit ()
162+
163+ # --- Virtual Camera Initialization ---
88164 with pyvirtualcam .Camera (
89- width = self . settings . width ,
90- height = self . settings . height ,
91- fps = self . settings . fps ,
165+ width = width ,
166+ height = height ,
167+ fps = fps ,
92168 ) as cam :
93169 print (f"Virtual Camera Active: { cam .device } " )
94170
@@ -112,11 +188,11 @@ def run(self):
112188 reconnected = False
113189 while self ._run_flag and not reconnected :
114190 # Send a black placeholder frame to virtual camera to keep connection active
115- placeholder = np .zeros ((self . settings . height , self . settings . width , 3 ), dtype = np .uint8 )
191+ placeholder = np .zeros ((height , width , 3 ), dtype = np .uint8 )
116192 cv2 .putText (
117193 placeholder ,
118194 "Camera Disconnected - Retrying..." ,
119- (50 , self . settings . height // 2 ),
195+ (50 , height // 2 ),
120196 cv2 .FONT_HERSHEY_SIMPLEX ,
121197 0.8 ,
122198 (255 , 255 , 255 ),
@@ -136,25 +212,15 @@ def run(self):
136212 break
137213
138214 print ("[CameraEngine] Attempting reconnection..." )
139- new_cap = cv2 .VideoCapture (self .settings .camera_index , cv2 .CAP_DSHOW )
140- curr_w = new_cap .get (cv2 .CAP_PROP_FRAME_WIDTH )
141- curr_h = new_cap .get (cv2 .CAP_PROP_FRAME_HEIGHT )
142- curr_fps = new_cap .get (cv2 .CAP_PROP_FPS )
143-
144- if curr_w != self .settings .width :
145- new_cap .set (cv2 .CAP_PROP_FRAME_WIDTH , self .settings .width )
146- if curr_h != self .settings .height :
147- new_cap .set (cv2 .CAP_PROP_FRAME_HEIGHT , self .settings .height )
148- if curr_fps != self .settings .fps :
149- new_cap .set (cv2 .CAP_PROP_FPS , self .settings .fps )
150-
151- if new_cap .isOpened ():
215+ new_cap = self ._init_camera (camera_index , width , height )
216+
217+ if new_cap is not None :
218+ self ._warmup_camera (new_cap , num_frames = 5 )
219+ self ._restore_auto_exposure (new_cap )
152220 with self .lock :
153221 self .cap = new_cap
154222 reconnected = True
155223 print ("[CameraEngine] Reconnection successful!" )
156- else :
157- new_cap .release ()
158224
159225 if not reconnected :
160226 break
@@ -163,12 +229,14 @@ def run(self):
163229 # 1. Processing Pipeline (In-place optimizations applied inside)
164230 processed_frame = self ._process_frame (frame )
165231
166- # 2. Resolution Safety Check and Correction
232+ # 2. Resolution Safety Check — high quality interpolation
167233 h , w = processed_frame .shape [:2 ]
168- target_w , target_h = self . settings . width , self . settings . height
234+ target_w , target_h = width , height
169235 if w != target_w or h != target_h :
236+ # INTER_AREA for downscaling (anti-aliased), INTER_LANCZOS4 for upscaling
237+ interp = cv2 .INTER_AREA if (w > target_w or h > target_h ) else cv2 .INTER_LANCZOS4
170238 processed_frame = cv2 .resize (
171- processed_frame , (target_w , target_h ), interpolation = cv2 . INTER_LINEAR
239+ processed_frame , (target_w , target_h ), interpolation = interp
172240 )
173241
174242 # 3. Virtual Camera Output (Optimized buffer reuse)
@@ -188,8 +256,9 @@ def run(self):
188256 self .ui_ready = False
189257
190258 # Scale on background thread to offload UI thread
259+ # INTER_AREA produces smooth downscaled previews
191260 preview_frame = cv2 .resize (
192- processed_frame , preview_size , interpolation = cv2 .INTER_LINEAR
261+ processed_frame , preview_size , interpolation = cv2 .INTER_AREA
193262 )
194263 ph , pw , pch = preview_frame .shape
195264 q_img = QImage (
@@ -230,7 +299,8 @@ def _process_frame(self, frame):
230299 start_h = (h - new_h ) // 2
231300 start_w = (w - new_w ) // 2
232301 frame = frame [start_h : start_h + new_h , start_w : start_w + new_w ]
233- frame = cv2 .resize (frame , self .output_size , interpolation = cv2 .INTER_LINEAR )
302+ # INTER_LANCZOS4 for high quality zoom output
303+ frame = cv2 .resize (frame , self .output_size , interpolation = cv2 .INTER_LANCZOS4 )
234304
235305 # 2. Flips (In-place)
236306 if flip_horizontal and flip_vertical :
@@ -255,11 +325,15 @@ def _process_frame(self, frame):
255325 return frame
256326
257327 def stop (self ):
328+ """Signal the engine to stop. Releases camera to unblock any pending read().
329+
330+ This method is non-blocking — it signals the thread to stop and releases
331+ the camera device, but does NOT wait for the thread to finish. Use wait()
332+ after calling stop() if you need to block until the thread exits (e.g. on
333+ application close).
334+ """
258335 self ._run_flag = False
259336 with self .lock :
260337 if self .cap :
261338 self .cap .release ()
262- # Do not set to None here to prevent None reference errors in run loop
263- # since we check cap_device = self.cap, but it will exit loop when read fails
264- self .wait (2000 ) # Wait up to 2 seconds for worker thread to exit securely
265-
339+ self .cap = None
0 commit comments