Skip to content

Commit 2793385

Browse files
committed
Refactor camera worker and recording pipeline
Extracts `SingleCameraWorker` into a new `services/camera_controller.py` module and moves per-frame rotation/cropping plus recording-sink writes into the worker thread. `MultiCameraController` now injects and updates a shared recording sink on workers, and recording enable/disable is propagated directly to active workers. The previous recording-frame emission path and transform handling in the multi-camera slot are removed/commented out to avoid duplicate processing and keep the controller focused on frame aggregation. Also relocates `recording_manager.py` from `gui` to `services` with a file rename.
1 parent 1487cfd commit 2793385

3 files changed

Lines changed: 280 additions & 222 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
import logging
5+
import time
6+
from threading import Event, Lock
7+
8+
import cv2
9+
import numpy as np
10+
from PySide6.QtCore import QObject, Signal, Slot
11+
12+
from dlclivegui.cameras import CameraFactory
13+
from dlclivegui.cameras.base import CameraBackend
14+
15+
# from dlclivegui.config import CameraSettings
16+
from dlclivegui.config import (
17+
SINGLE_CAMERA_WORKER_DO_LOG_TIMING,
18+
CameraSettings,
19+
)
20+
from dlclivegui.utils.stats import WorkerTimingStats
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class SingleCameraWorker(QObject):
26+
"""Worker for a single camera in multi-camera mode."""
27+
28+
frame_captured = Signal(str, object, float) # camera_id, frame, timestamp
29+
error_occurred = Signal(str, str) # camera_id, error_message
30+
runtime_info = Signal(str, object) # camera_id, dict of runtime info
31+
started = Signal(str) # camera_id
32+
stopped = Signal(str) # camera_id
33+
34+
def __init__(self, camera_id: str, settings: CameraSettings):
35+
super().__init__()
36+
self._camera_id = camera_id
37+
self._settings = copy.deepcopy(settings)
38+
self._stop_event = Event()
39+
self._backend: CameraBackend | None = None
40+
self._max_consecutive_errors = 5
41+
self._retry_delay = 0.1
42+
self._trigger_timeout_delay = 0.05
43+
self._trigger_wait_log_interval = 2.0
44+
self._last_trigger_wait_log = 0.0
45+
self._trigger_wait_suppressed_count = 0
46+
47+
self._recording_sink = None
48+
self._recording_enabled = False
49+
self._recording_sink_lock = Lock()
50+
51+
# Performance logs
52+
self._timing = WorkerTimingStats(
53+
camera_id, logger=logger, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING
54+
)
55+
56+
def set_recording_sink(self, sink) -> None:
57+
with self._recording_sink_lock:
58+
self._recording_sink = sink
59+
60+
def set_recording_enabled(self, enabled: bool) -> None:
61+
with self._recording_sink_lock:
62+
self._recording_enabled = bool(enabled)
63+
64+
@Slot()
65+
def run(self) -> None:
66+
self._stop_event.clear()
67+
68+
try:
69+
logger.debug(
70+
"[Worker %s] before create: backend=%s index=%s properties=%s",
71+
self._camera_id,
72+
self._settings.backend,
73+
self._settings.index,
74+
self._settings.properties,
75+
)
76+
77+
self._backend = CameraFactory.create(self._settings)
78+
79+
logger.debug(
80+
"[Worker %s] after create: backend=%s index=%s properties=%s",
81+
self._camera_id,
82+
self._backend.settings.backend,
83+
self._backend.settings.index,
84+
self._backend.settings.properties,
85+
)
86+
87+
self._backend.open()
88+
self.runtime_info.emit(
89+
self._camera_id,
90+
{
91+
"actual_fps": getattr(self._backend, "actual_fps", None),
92+
"actual_resolution": getattr(self._backend, "actual_resolution", None),
93+
"actual_pixel_format": getattr(self._backend, "actual_pixel_format", None),
94+
"actual_output_format": getattr(self._backend, "actual_output_format", None),
95+
},
96+
)
97+
except Exception as exc:
98+
logger.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc)
99+
self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}")
100+
self.stopped.emit(self._camera_id)
101+
return
102+
103+
self.started.emit(self._camera_id)
104+
consecutive_errors = 0
105+
106+
while not self._stop_event.is_set():
107+
try:
108+
with self._timing.measure("Single.read"):
109+
frame, timestamp = self._backend.read()
110+
if frame is None or frame.size == 0:
111+
consecutive_errors += 1
112+
if consecutive_errors >= self._max_consecutive_errors:
113+
self.error_occurred.emit(
114+
self._camera_id, "Too many empty frames.\nWas the device disconnected ?"
115+
)
116+
break
117+
if self._stop_event.wait(self._retry_delay):
118+
break
119+
continue
120+
121+
consecutive_errors = 0
122+
with self._timing.measure("Single.transforms"):
123+
frame = self._apply_worker_transforms(frame)
124+
125+
with self._recording_sink_lock:
126+
recording_enabled = self._recording_enabled
127+
recording_sink = self._recording_sink
128+
129+
if recording_enabled and recording_sink is not None:
130+
try:
131+
with self._timing.measure("Single.recording_sink"):
132+
recording_sink(self._camera_id, frame, timestamp)
133+
except Exception as exc:
134+
logger.exception(f"Failed to write frame for camera {self._camera_id}: {exc}")
135+
136+
with self._timing.measure("Single.emit"):
137+
self.frame_captured.emit(self._camera_id, frame, timestamp)
138+
139+
self._timing.note_frame()
140+
self._timing.maybe_log()
141+
142+
except TimeoutError as exc:
143+
self._timing.note_timeout()
144+
self._timing.maybe_log()
145+
if self._stop_event.is_set():
146+
break
147+
148+
# In hardware-trigger mode, a timeout usually means:
149+
# "no trigger pulse arrived during this poll interval".
150+
# This is expected and should not count as a camera failure.
151+
if bool(getattr(self._backend, "waits_for_hardware_trigger", False)):
152+
self._log_trigger_wait_throttled(exc)
153+
consecutive_errors = 0
154+
155+
if self._stop_event.wait(self._trigger_timeout_delay):
156+
break # Stop event set during wait
157+
continue
158+
159+
consecutive_errors += 1
160+
if consecutive_errors >= self._max_consecutive_errors:
161+
self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}")
162+
break
163+
if self._stop_event.wait(self._retry_delay):
164+
break
165+
continue
166+
167+
except Exception as exc:
168+
self._timing.note_error()
169+
self._timing.maybe_log()
170+
consecutive_errors += 1
171+
if self._stop_event.is_set():
172+
break
173+
if consecutive_errors >= self._max_consecutive_errors:
174+
self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}")
175+
break
176+
if self._stop_event.wait(self._retry_delay):
177+
break
178+
continue
179+
180+
# Cleanup
181+
if self._backend is not None:
182+
try:
183+
self._backend.close()
184+
except Exception:
185+
pass
186+
self.stopped.emit(self._camera_id)
187+
188+
def stop(self) -> None:
189+
self._stop_event.set()
190+
191+
@staticmethod
192+
def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray:
193+
"""Apply rotation to frame."""
194+
if degrees == 90:
195+
return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
196+
elif degrees == 180:
197+
return cv2.rotate(frame, cv2.ROTATE_180)
198+
elif degrees == 270:
199+
return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
200+
return frame
201+
202+
@staticmethod
203+
def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray:
204+
"""Apply crop to frame."""
205+
x0, y0, x1, y1 = crop_region
206+
height, width = frame.shape[:2]
207+
208+
x0 = max(0, min(x0, width))
209+
y0 = max(0, min(y0, height))
210+
x1 = max(x0, min(x1, width)) if x1 > 0 else width
211+
y1 = max(y0, min(y1, height)) if y1 > 0 else height
212+
213+
if x0 < x1 and y0 < y1:
214+
return frame[y0:y1, x0:x1]
215+
return frame
216+
217+
def _apply_worker_transforms(self, frame: np.ndarray) -> np.ndarray:
218+
if self._settings.rotation:
219+
frame = self.apply_rotation(frame, self._settings.rotation)
220+
221+
crop_region = self._settings.get_crop_region()
222+
if crop_region:
223+
frame = self.apply_crop(frame, crop_region)
224+
225+
return frame
226+
227+
def _log_trigger_wait_throttled(self, exc: BaseException) -> None:
228+
"""Log hardware-trigger wait timeouts at a controlled rate.
229+
230+
In trigger-waiting modes, read timeouts are expected polling misses.
231+
Without throttling, the log can be flooded at ~10-20 messages/sec/camera.
232+
"""
233+
now = time.monotonic()
234+
235+
if now - self._last_trigger_wait_log < self._trigger_wait_log_interval:
236+
self._trigger_wait_suppressed_count += 1
237+
return
238+
239+
suppressed = self._trigger_wait_suppressed_count
240+
self._trigger_wait_suppressed_count = 0
241+
self._last_trigger_wait_log = now
242+
243+
if suppressed:
244+
logger.debug(
245+
"[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)",
246+
self._camera_id,
247+
exc,
248+
suppressed,
249+
)
250+
else:
251+
logger.debug(
252+
"[Worker %s] waiting for hardware trigger: %s",
253+
self._camera_id,
254+
exc,
255+
)

0 commit comments

Comments
 (0)