Skip to content

Commit da64058

Browse files
authored
Merge pull request #4 from DilshanX09/feature/dilshan
fix: optimize camera start, resolve auto-exposure blinking, fix UI fr…
2 parents 2803e08 + 83511cd commit da64058

2 files changed

Lines changed: 157 additions & 50 deletions

File tree

src/camera_engine.py

Lines changed: 120 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
class 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

src/ui_main.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,14 @@ def _build_floating_panels(self):
364364

365365
self._populate_cameras()
366366

367+
self.panel_camera.body_layout.addWidget(_divider())
368+
self._toggle_fast_start = self._add_toggle_row(
369+
self.panel_camera,
370+
"Fast Camera Start",
371+
self.settings.fast_start,
372+
self._on_fast_start_changed,
373+
)
374+
367375
# ---- Transformations -----------------------------------------------
368376
self.panel_transform = FloatingPanel(self.central, "Transformations", width=280)
369377
self._toggle_flip_h = self._add_toggle_row(
@@ -777,7 +785,13 @@ def _start_engine(self):
777785
return
778786

779787
self._cam_active = True
780-
self.video_label.setStyleSheet("background: #000000;")
788+
# Show loading state while camera warms up
789+
self.video_label.setPixmap(QPixmap())
790+
self.video_label.setText("Starting camera...")
791+
self.video_label.setStyleSheet(
792+
f"background: #000000; color: {CLR_TEXT_LIGHT}; "
793+
f"font-family: '{_FONT_FAMILY}'; font-size: 14px;"
794+
)
781795

782796
# Set icon to ON and keep background transparent/white
783797
self.btn_cam.setStyleSheet(self._circle_btn_style(40))
@@ -799,13 +813,15 @@ def _start_engine(self):
799813
self.engine.update_preview_size(
800814
self.video_label.width(), self.video_label.height()
801815
)
802-
# Connect the settings changed signal to the engine's thread-safe update slot
816+
# QueuedConnection ensures the slot runs on the engine's thread,
817+
# preventing UI freeze from cross-thread lock contention
803818
self.settings_changed.connect(
804-
self.engine.update_setting, Qt.ConnectionType.DirectConnection
819+
self.engine.update_setting, Qt.ConnectionType.QueuedConnection
805820
)
806821
self.engine.frame_ready.connect(self._on_frame_ready)
807822
self.engine.error_occurred.connect(self._on_engine_error)
808823
self.engine.finished.connect(self._on_engine_finished)
824+
self.engine.camera_ready.connect(self._on_camera_ready)
809825
self.engine.start()
810826

811827
def _stop_engine(self):
@@ -846,8 +862,16 @@ def _on_engine_error(self, err: str):
846862
print(f"[StreamLens] Camera error: {err}")
847863
self._stop_engine()
848864

865+
@pyqtSlot()
866+
def _on_camera_ready(self):
867+
"""Called when camera has finished warming up and is producing stable frames."""
868+
self.video_label.setText("")
869+
self.video_label.setStyleSheet("background: #000000;")
870+
849871
def closeEvent(self, event):
850-
self._stop_engine()
872+
if self.engine:
873+
self.engine.stop()
874+
self.engine.wait(3000) # Block on exit to ensure clean shutdown
851875
if self.state_manager:
852876
self.state_manager.flush()
853877
super().closeEvent(event)
@@ -863,6 +887,14 @@ def _on_camera_selected(self, idx: int, name: str):
863887
self._stop_engine()
864888
# The UI flow allows user to manually restart it or you can call self._start_engine() here
865889

890+
def _on_fast_start_changed(self):
891+
new_val = not self.settings.fast_start
892+
if self.state_manager:
893+
self.state_manager.update_setting("fast_start", new_val)
894+
else:
895+
self.settings.fast_start = new_val
896+
self.settings_changed.emit("fast_start", new_val)
897+
866898
def _on_flip_h_changed(self):
867899
new_val = not self.settings.flip_horizontal
868900
if self.state_manager:
@@ -921,6 +953,7 @@ class _FallbackSettings:
921953
width = 1280
922954
height = 720
923955
fps = 60
956+
fast_start = True
924957

925958

926959
if __name__ == "__main__":

0 commit comments

Comments
 (0)