diff --git a/dlclivegui/gui/camera_config/preview.py b/dlclivegui/gui/camera_config/preview.py index bbd1aef0d..ef2c7570b 100644 --- a/dlclivegui/gui/camera_config/preview.py +++ b/dlclivegui/gui/camera_config/preview.py @@ -7,6 +7,7 @@ from PySide6.QtCore import QTimer +from ...services.camera_controller import SingleCameraWorker from ...services.multi_camera_controller import MultiCameraController if TYPE_CHECKING: @@ -56,7 +57,7 @@ class PreviewSession: def apply_rotation(frame, rotation): - return MultiCameraController.apply_rotation(frame, rotation) + return SingleCameraWorker.apply_rotation(frame, rotation) def apply_crop(frame, x0, y0, x1, y1): @@ -66,7 +67,7 @@ def apply_crop(frame, x0, y0, x1, y1): x1 = max(x0, min(x1, w)) y1 = max(y0, min(y1, h)) - return MultiCameraController.apply_crop(frame, (x0, y0, x1, y1)) + return SingleCameraWorker.apply_crop(frame, (x0, y0, x1, y1)) def resize_to_fit(frame, max_w=400, max_h=300): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index ed6e3ee7c..4af17d600 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -71,6 +71,7 @@ ) from ..services.dlc_processor import DLCLiveProcessor, PoseResult from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id +from ..services.recording_manager import RecordingManager from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore from ..utils.stats import WorkerTimingStats, format_dlc_stats @@ -80,7 +81,6 @@ from .misc import layouts as lyts from .misc.drag_spinbox import ScrubSpinBox from .misc.eliding_label import ElidingPathLabel -from .recording_manager import RecordingManager from .theme import LOGO, LOGO_ALPHA, AppStyle, apply_theme logger = logging.getLogger("DLCLiveGUI") @@ -812,7 +812,7 @@ def _connect_signals(self) -> None: # Multi-camera controller signals (used for both single and multi-camera modes) self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready) - self.multi_camera_controller.recording_frame_ready.connect(self._on_recording_frame_ready) + # self.multi_camera_controller.recording_frame_ready.connect(self._on_recording_frame_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) @@ -1621,6 +1621,7 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return + self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame) self.multi_camera_controller.set_recording_frame_do_emit(True) self._settings_store.set_session_name(session_name) @@ -1645,6 +1646,7 @@ def _stop_multi_camera_recording(self) -> None: # Stop frame emission immediately so no new frames enter recording pipeline. try: self.multi_camera_controller.set_recording_frame_do_emit(False) + self.multi_camera_controller.set_recording_sink(None) except Exception: logger.exception("Failed to disable recording frame emission") diff --git a/dlclivegui/services/camera_controller.py b/dlclivegui/services/camera_controller.py new file mode 100644 index 000000000..57173530f --- /dev/null +++ b/dlclivegui/services/camera_controller.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import copy +import logging +import time +from threading import Event, Lock + +import cv2 +import numpy as np +from PySide6.QtCore import QObject, Signal, Slot + +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend + +# from dlclivegui.config import CameraSettings +from dlclivegui.config import ( + SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, +) +from dlclivegui.utils.stats import WorkerTimingStats + +logger = logging.getLogger(__name__) + + +class SingleCameraWorker(QObject): + """Worker for a single camera in multi-camera mode.""" + + frame_captured = Signal(str, object, float, object) # camera_id, frame, timestamp, timestamp_metadata + error_occurred = Signal(str, str) # camera_id, error_message + runtime_info = Signal(str, object) # camera_id, dict of runtime info + started = Signal(str) # camera_id + stopped = Signal(str) # camera_id + + def __init__(self, camera_id: str, settings: CameraSettings): + super().__init__() + self._camera_id = camera_id + self._settings = copy.deepcopy(settings) + self._stop_event = Event() + self._backend: CameraBackend | None = None + self._max_consecutive_errors = 5 + self._retry_delay = 0.1 + self._trigger_timeout_delay = 0.05 + self._trigger_wait_log_interval = 2.0 + self._last_trigger_wait_log = 0.0 + self._trigger_wait_suppressed_count = 0 + + self._recording_sink = None + self._recording_enabled = False + self._recording_sink_lock = Lock() + + # Performance logs + self._timing = WorkerTimingStats( + camera_id, logger=logger, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING + ) + + def set_recording_sink(self, sink) -> None: + with self._recording_sink_lock: + self._recording_sink = sink + + def set_recording_enabled(self, enabled: bool) -> None: + with self._recording_sink_lock: + self._recording_enabled = bool(enabled) + + @Slot() + def run(self) -> None: + self._stop_event.clear() + + try: + logger.debug( + "[Worker %s] before create: backend=%s index=%s properties=%s", + self._camera_id, + self._settings.backend, + self._settings.index, + self._settings.properties, + ) + + self._backend = CameraFactory.create(self._settings) + + logger.debug( + "[Worker %s] after create: backend=%s index=%s properties=%s", + self._camera_id, + self._backend.settings.backend, + self._backend.settings.index, + self._backend.settings.properties, + ) + + self._backend.open() + self.runtime_info.emit( + self._camera_id, + { + "actual_fps": getattr(self._backend, "actual_fps", None), + "actual_resolution": getattr(self._backend, "actual_resolution", None), + "actual_pixel_format": getattr(self._backend, "actual_pixel_format", None), + "actual_output_format": getattr(self._backend, "actual_output_format", None), + }, + ) + except Exception as exc: + logger.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) + self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") + self.stopped.emit(self._camera_id) + return + + self.started.emit(self._camera_id) + consecutive_errors = 0 + + while not self._stop_event.is_set(): + try: + with self._timing.measure("Single.read"): + captured = self._backend.read() + frame = captured.frame + timestamp = captured.software_timestamp + timestamp_metadata = captured.timestamp_metadata + if frame is None or frame.size == 0: + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit( + self._camera_id, "Too many empty frames.\nWas the device disconnected ?" + ) + break + if self._stop_event.wait(self._retry_delay): + break + continue + + consecutive_errors = 0 + with self._timing.measure("Single.transforms"): + frame = self._apply_worker_transforms(frame) + + with self._recording_sink_lock: + recording_enabled = self._recording_enabled + recording_sink = self._recording_sink + + if recording_enabled and recording_sink is not None: + try: + with self._timing.measure("Single.recording_sink"): + recording_sink(self._camera_id, frame, timestamp, timestamp_metadata) + except Exception as exc: + logger.exception(f"Failed to write frame for camera {self._camera_id}: {exc}") + + with self._timing.measure("Single.emit"): + self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) + + self._timing.note_frame() + self._timing.maybe_log() + + except TimeoutError as exc: + self._timing.note_timeout() + self._timing.maybe_log() + if self._stop_event.is_set(): + break + + # In hardware-trigger mode, a timeout usually means: + # "no trigger pulse arrived during this poll interval". + # This is expected and should not count as a camera failure. + if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): + self._log_trigger_wait_throttled(exc) + consecutive_errors = 0 + + if self._stop_event.wait(self._trigger_timeout_delay): + break # Stop event set during wait + continue + + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") + break + if self._stop_event.wait(self._retry_delay): + break + continue + + except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() + consecutive_errors += 1 + if self._stop_event.is_set(): + break + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") + break + if self._stop_event.wait(self._retry_delay): + break + continue + + # Cleanup + if self._backend is not None: + try: + self._backend.close() + except Exception: + pass + self.stopped.emit(self._camera_id) + + def stop(self) -> None: + self._stop_event.set() + + @staticmethod + def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: + """Apply rotation to frame.""" + if degrees == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + elif degrees == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + elif degrees == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + @staticmethod + def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: + """Apply crop to frame.""" + x0, y0, x1, y1 = crop_region + height, width = frame.shape[:2] + + x0 = max(0, min(x0, width)) + y0 = max(0, min(y0, height)) + x1 = max(x0, min(x1, width)) if x1 > 0 else width + y1 = max(y0, min(y1, height)) if y1 > 0 else height + + if x0 < x1 and y0 < y1: + return frame[y0:y1, x0:x1] + return frame + + def _apply_worker_transforms(self, frame: np.ndarray) -> np.ndarray: + if self._settings.rotation: + frame = self.apply_rotation(frame, self._settings.rotation) + + crop_region = self._settings.get_crop_region() + if crop_region: + frame = self.apply_crop(frame, crop_region) + + return frame + + def _log_trigger_wait_throttled(self, exc: BaseException) -> None: + """Log hardware-trigger wait timeouts at a controlled rate. + + In trigger-waiting modes, read timeouts are expected polling misses. + Without throttling, the log can be flooded at ~10-20 messages/sec/camera. + """ + now = time.monotonic() + + if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: + self._trigger_wait_suppressed_count += 1 + return + + suppressed = self._trigger_wait_suppressed_count + self._trigger_wait_suppressed_count = 0 + self._last_trigger_wait_log = now + + if suppressed: + logger.debug( + "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", + self._camera_id, + exc, + suppressed, + ) + else: + logger.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 997d0d947..deeafdd6a 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -7,27 +7,26 @@ import time from dataclasses import dataclass from functools import partial -from threading import Event, Lock +from threading import Lock import cv2 import numpy as np -from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtGui import QImage, QPixmap -from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings from dlclivegui.config import ( GUI_MAX_DISPLAY_FPS, MULTI_CAMERA_WORKER_DO_LOG_TIMING, - SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings, CameraTriggerSettings, ) from dlclivegui.utils.stats import WorkerTimingStats +from .camera_controller import SingleCameraWorker + LOGGER = logging.getLogger(__name__) QUIT_WAIT_MS = 5000 # wait for cooperative quit (5s) @@ -45,181 +44,6 @@ class MultiFrameData: display_ids: dict[str, str] = None # camera_id -> display_id (for labeling) -class SingleCameraWorker(QObject): - """Worker for a single camera in multi-camera mode.""" - - frame_captured = Signal(str, object, float, object) # camera_id, frame, timestamp, timestamp_metadata - error_occurred = Signal(str, str) # camera_id, error_message - runtime_info = Signal(str, object) # camera_id, dict of runtime info - started = Signal(str) # camera_id - stopped = Signal(str) # camera_id - - def __init__(self, camera_id: str, settings: CameraSettings): - super().__init__() - self._camera_id = camera_id - self._settings = copy.deepcopy(settings) - self._stop_event = Event() - self._backend: CameraBackend | None = None - self._max_consecutive_errors = 5 - self._retry_delay = 0.1 - self._trigger_timeout_delay = 0.05 - - self._trigger_wait_log_interval = 2.0 - self._last_trigger_wait_log = 0.0 - self._trigger_wait_suppressed_count = 0 - - # Performance logs - self._timing = WorkerTimingStats( - camera_id, logger=LOGGER, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING - ) - - @Slot() - def run(self) -> None: - self._stop_event.clear() - - try: - LOGGER.debug( - "[Worker %s] before create: backend=%s index=%s properties=%s", - self._camera_id, - self._settings.backend, - self._settings.index, - self._settings.properties, - ) - - self._backend = CameraFactory.create(self._settings) - - LOGGER.debug( - "[Worker %s] after create: backend=%s index=%s properties=%s", - self._camera_id, - self._backend.settings.backend, - self._backend.settings.index, - self._backend.settings.properties, - ) - - self._backend.open() - self.runtime_info.emit( - self._camera_id, - { - "actual_fps": getattr(self._backend, "actual_fps", None), - "actual_resolution": getattr(self._backend, "actual_resolution", None), - "actual_pixel_format": getattr(self._backend, "actual_pixel_format", None), - "actual_output_format": getattr(self._backend, "actual_output_format", None), - }, - ) - except Exception as exc: - LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) - self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") - self.stopped.emit(self._camera_id) - return - - self.started.emit(self._camera_id) - consecutive_errors = 0 - - while not self._stop_event.is_set(): - try: - with self._timing.measure("Single.read"): - captured = self._backend.read() - frame = captured.frame - timestamp = captured.software_timestamp - timestamp_metadata = captured.timestamp_metadata - if frame is None or frame.size == 0: - consecutive_errors += 1 - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit( - self._camera_id, "Too many empty frames.\nWas the device disconnected ?" - ) - break - if self._stop_event.wait(self._retry_delay): - break - continue - - consecutive_errors = 0 - with self._timing.measure("Single.emit.frame_captured"): - self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) - - self._timing.note_frame() - self._timing.maybe_log() - - except TimeoutError as exc: - self._timing.note_timeout() - self._timing.maybe_log() - if self._stop_event.is_set(): - break - - # In hardware-trigger mode, a timeout usually means: - # "no trigger pulse arrived during this poll interval". - # This is expected and should not count as a camera failure. - if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): - self._log_trigger_wait_throttled(exc) - consecutive_errors = 0 - - if self._stop_event.wait(self._trigger_timeout_delay): - break # Stop event set during wait - continue - - consecutive_errors += 1 - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") - break - if self._stop_event.wait(self._retry_delay): - break - continue - - except Exception as exc: - self._timing.note_error() - self._timing.maybe_log() - consecutive_errors += 1 - if self._stop_event.is_set(): - break - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") - break - if self._stop_event.wait(self._retry_delay): - break - continue - - # Cleanup - if self._backend is not None: - try: - self._backend.close() - except Exception: - pass - self.stopped.emit(self._camera_id) - - def stop(self) -> None: - self._stop_event.set() - - def _log_trigger_wait_throttled(self, exc: BaseException) -> None: - """Log hardware-trigger wait timeouts at a controlled rate. - - In trigger-waiting modes, read timeouts are expected polling misses. - Without throttling, the log can be flooded at ~10-20 messages/sec/camera. - """ - now = time.monotonic() - - if now - self._last_trigger_wait_log < self._trigger_wait_log_interval: - self._trigger_wait_suppressed_count += 1 - return - - suppressed = self._trigger_wait_suppressed_count - self._trigger_wait_suppressed_count = 0 - self._last_trigger_wait_log = now - - if suppressed: - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s (suppressed %d repeated timeout logs)", - self._camera_id, - exc, - suppressed, - ) - else: - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s", - self._camera_id, - exc, - ) - - def get_display_id(settings: CameraSettings) -> str: """Return the human-friendly camera label used for GUI display. Intentionally different from get_camera_id(), which should return a stable @@ -286,9 +110,9 @@ class MultiCameraController(QObject): # Signals frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) - recording_frame_ready = Signal( - str, object, float, object - ) # camera_id, frame, timestamp, timestamp_metadata (full cam FPS; for recording) + # recording_frame_ready = Signal( + # str, object, float, object + # ) # camera_id, frame, timestamp, timestamp_metadata (full cam FPS; for recording) display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id @@ -312,6 +136,7 @@ def __init__(self): self._stopping = False self._all_stopped_emitted = False self._recording_frame_emission_enabled: bool = False + self._recording_sink = None self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._camera_display_order: list[str] = [] @@ -345,12 +170,9 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: return timing def set_recording_frame_do_emit(self, enabled: bool) -> None: - """Enable/disable the lightweight per-camera recording frame signal. - - This avoids sending recording-only traffic when the user is only previewing - or running DLC. - """ self._recording_frame_emission_enabled = bool(enabled) + for worker in list(self._workers.values()): + worker.set_recording_enabled(enabled) def _should_emit_display_ready(self) -> bool: """Return True when the UI/display path should be updated. @@ -456,6 +278,8 @@ def _start_camera(self, settings: CameraSettings) -> None: self._display_ids[cam_id] = display_id dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) + worker.set_recording_sink(self._recording_sink) + worker.set_recording_enabled(self._recording_frame_emission_enabled) thread = QThread() worker.moveToThread(thread) @@ -473,7 +297,13 @@ def _start_camera(self, settings: CameraSettings) -> None: worker.stopped.connect(thread.quit) thread.start() + def set_recording_sink(self, sink) -> None: + self._recording_sink = sink + for worker in list(self._workers.values()): + worker.set_recording_sink(sink) + def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: + # remove stored frame data with self._frame_lock: self._frames.pop(camera_id, None) self._timestamps.pop(camera_id, None) @@ -608,20 +438,20 @@ def _on_frame_captured( frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): - settings = self._settings.get(camera_id) + # self._settings.get(camera_id) - with timing.measure("Multi.apply_transforms"): - if settings and settings.rotation: - frame = MultiCameraController.apply_rotation(frame, settings.rotation) + # with timing.measure("Multi.apply_transforms"): + # if settings and settings.rotation: + # frame = MultiCameraController.apply_rotation(frame, settings.rotation) - if settings: - crop_region = settings.get_crop_region() - if crop_region: - frame = MultiCameraController.apply_crop(frame, crop_region) + # if settings: + # crop_region = settings.get_crop_region() + # if crop_region: + # frame = MultiCameraController.apply_crop(frame, crop_region) - if self._recording_frame_emission_enabled: - with timing.measure("Multi.emit.recording_frame_ready"): - self.recording_frame_ready.emit(camera_id, frame, timestamp, timestamp_metadata) + # if self._recording_frame_emission_enabled: + # with timing.measure("Multi.emit.recording_frame_ready"): + # self.recording_frame_ready.emit(camera_id, frame, timestamp) with self._frame_lock: with timing.measure("Multi.store_latest"): @@ -697,32 +527,6 @@ def actual_fps_by_camera_id(self) -> dict[str, float]: return out - @staticmethod - def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: - """Apply rotation to frame.""" - if degrees == 90: - return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) - elif degrees == 180: - return cv2.rotate(frame, cv2.ROTATE_180) - elif degrees == 270: - return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) - return frame - - @staticmethod - def apply_crop(frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: - """Apply crop to frame.""" - x0, y0, x1, y1 = crop_region - height, width = frame.shape[:2] - - x0 = max(0, min(x0, width)) - y0 = max(0, min(y0, height)) - x1 = max(x0, min(x1, width)) if x1 > 0 else width - y1 = max(y0, min(y1, height)) if y1 > 0 else height - - if x0 < x1 and y0 < y1: - return frame[y0:y1, x0:x1] - return frame - @staticmethod def apply_resize(frame: np.ndarray, max_w: int, max_h: int, allow_upscale: bool = False) -> np.ndarray: """Resize frame to fit within max dimensions while maintaining aspect ratio.""" diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/services/recording_manager.py similarity index 87% rename from dlclivegui/gui/recording_manager.py rename to dlclivegui/services/recording_manager.py index 37e008fdb..1cfa28632 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/services/recording_manager.py @@ -106,31 +106,28 @@ def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) def _start_dispatcher(self) -> None: - with self._lock: - if self._dispatch_thread is not None and self._dispatch_thread.is_alive(): - return + if self._dispatch_thread is not None and self._dispatch_thread.is_alive(): + return - self._dispatch_stop.clear() - self._frame_queue = queue.Queue(maxsize=4096) - self._dispatch_thread = threading.Thread( - target=self._dispatch_loop, - name="RecordingManagerDispatcher", - daemon=True, - ) - self._dispatch_thread.start() + self._dispatch_stop.clear() + self._frame_queue = queue.Queue(maxsize=4096) + self._dispatch_thread = threading.Thread( + target=self._dispatch_loop, + name="RecordingManagerDispatcher", + daemon=True, + ) + self._dispatch_thread.start() def _stop_dispatcher(self, timeout: float = 2.0) -> None: - self._dispatch_stop.set() - with self._lock: q = self._frame_queue t = self._dispatch_thread if q is not None: try: - q.put_nowait(_FRAME_SENTINEL) + q.put(_FRAME_SENTINEL, block=True, timeout=timeout) except queue.Full: - pass + log.warning("Recording frame queue full while stopping dispatcher; dispatcher may not stop promptly.") if t is not None: t.join(timeout=timeout) @@ -138,8 +135,9 @@ def _stop_dispatcher(self, timeout: float = 2.0) -> None: log.warning("Recording frame dispatcher did not stop within %.1fs", timeout) with self._lock: - self._dispatch_thread = None - self._frame_queue = None + if self._dispatch_thread is t: + self._dispatch_thread = None + self._frame_queue = None self._dispatch_stop.clear() def _dispatch_loop(self) -> None: @@ -149,18 +147,15 @@ def _dispatch_loop(self) -> None: if q is None: return - while not self._dispatch_stop.is_set(): - try: - item = q.get(timeout=0.1) - except queue.Empty: - continue + while True: + item = q.get() try: if item is _FRAME_SENTINEL: break - cam_id, frame, timestamp = item - self._write_frame_now(cam_id, frame, timestamp) + cam_id, frame, timestamp, timestamp_metadata = item + self._write_frame_now(cam_id, frame, timestamp, timestamp_metadata) finally: try: @@ -270,7 +265,6 @@ def start_all( self._run_dir = None return None - self._start_dispatcher() return run_dir def stop_all(self) -> None: @@ -326,13 +320,23 @@ def _write_frame_now( log.exception("Failed to stop recorder for %s after write error.", cam_id) def write_frame( - self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + self, + cam_id: str, + frame: np.ndarray, + timestamp: float | None = None, + timestamp_metadata: object | None = None, ) -> None: with self._lock: - q = self._frame_queue active = cam_id in self._recorders + if not active: + return + + if self._frame_queue is None or self._dispatch_thread is None or not self._dispatch_thread.is_alive(): + self._start_dispatcher() - if not active or q is None: + q = self._frame_queue + + if q is None: return try: @@ -345,6 +349,27 @@ def write_frame( getattr(frame, "dtype", None), ) + def flush(self, timeout: float = 2.0) -> bool: + """Wait until all currently queued recording frames have been dispatched. + + Returns True if the queue drained before timeout, False otherwise. + """ + with self._lock: + q = self._frame_queue + + if q is None: + return True + + done = threading.Event() + + def waiter() -> None: + q.join() + done.set() + + t = threading.Thread(target=waiter, name="RecordingManagerFlush", daemon=True) + t.start() + return done.wait(timeout) + def get_stats_summary(self) -> str: totals = { "enqueued": 0, diff --git a/tests/cameras/test_backend_discovery.py b/tests/cameras/test_backend_discovery.py index 610a90c62..0b86e9520 100644 --- a/tests/cameras/test_backend_discovery.py +++ b/tests/cameras/test_backend_discovery.py @@ -26,7 +26,7 @@ def _write_temp_backend_package(tmp_path: Path, pkg_name: str = "test_backends_p # A backend module which registers itself as "lazyfake" backend_code = textwrap.dedent( """ - from dlclivegui.cameras.base import register_backend, CameraBackend + from dlclivegui.cameras.base import register_backend, CameraBackend, CapturedFrame from dlclivegui.config import CameraSettings import numpy as np import time @@ -44,7 +44,7 @@ def open(self) -> None: def read(self): # Small deterministic frame + timestamp frame = np.zeros((2, 3, 3), dtype=np.uint8) - return frame, time.time() + return CapturedFrame(frame, time.time(), None) def close(self) -> None: self._opened = False diff --git a/tests/cameras/test_factory.py b/tests/cameras/test_factory.py index cc1d798de..43516b487 100644 --- a/tests/cameras/test_factory.py +++ b/tests/cameras/test_factory.py @@ -3,6 +3,7 @@ import pytest from dlclivegui.cameras import CameraFactory, DetectedCamera, base +from dlclivegui.cameras.base import CapturedFrame from dlclivegui.config import CameraSettings @@ -69,7 +70,7 @@ def open(self): raise AssertionError("Probing path should not open when rich discovery returns a list") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -112,7 +113,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -150,7 +151,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -182,7 +183,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -220,7 +221,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -252,7 +253,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -280,7 +281,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -311,7 +312,7 @@ def open(self): pass def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass @@ -341,7 +342,7 @@ def open(self): raise RuntimeError("no device") def read(self): - return None, 0.0 + return CapturedFrame(None, 0.0, None) def close(self): pass diff --git a/tests/cameras/test_fake_backend.py b/tests/cameras/test_fake_backend.py index d85616bcc..eac6e6015 100644 --- a/tests/cameras/test_fake_backend.py +++ b/tests/cameras/test_fake_backend.py @@ -26,7 +26,7 @@ def open(self): def read(self): assert self._opened img = np.zeros((10, 20, 3), dtype=np.uint8) - return img, 123.456 + return base.CapturedFrame(img, 123.456, None) def close(self): self._opened = False diff --git a/tests/conftest.py b/tests/conftest.py index 25c5567e2..04992894d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -154,6 +154,18 @@ def _factory(settings: CameraSettings): # --------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------- +class FakeRunner: + """Minimal fake DLCLive runner used by DLCLiveProcessor._process_frame.""" + + def __init__(self, parent): + self._parent = parent + self.device = "cpu" + self.model = None + self.net = None + + def get_pose(self, processed_frame): + self._parent.pose_calls += 1 + return np.ones((2, 3), dtype=float) class FakeDLCLive: @@ -163,14 +175,31 @@ def __init__(self, **opts): self.opts = opts self.init_called = False self.pose_calls = 0 + self.process_frame_calls = 0 + + self.processor = opts.get("processor") + self.cfg = {"fake": True} + self.runner = FakeRunner(self) + self.pose = None def init_inference(self, frame): self.init_called = True + def process_frame(self, frame): + self.process_frame_calls += 1 + return frame + def get_pose(self, frame, frame_time=None): + # Keep this for compatibility with older tests, but production code now + # uses self.runner.get_pose(...). self.pose_calls += 1 return np.ones((2, 3), dtype=float) + def _post_process_pose(self, processed_frame, frame_time=None): + if self.pose is None: + self.pose = self.runner.get_pose(processed_frame) + return self.pose + @pytest.fixture def fake_dlclive_factory(): @@ -325,7 +354,7 @@ def _fake_start_all(self, recording, active_cams, current_frames, **kwargs): run_dir.mkdir(parents=True, exist_ok=True) return run_dir - from dlclivegui.gui import recording_manager as rm_mod + from dlclivegui.services import recording_manager as rm_mod monkeypatch.setattr(rm_mod.RecordingManager, "start_all", _fake_start_all) return calls @@ -409,7 +438,7 @@ def recording_settings(app_config_two_cams): @pytest.fixture def patch_video_recorder(monkeypatch): - import dlclivegui.gui.recording_manager as rm_mod + import dlclivegui.services.recording_manager as rm_mod monkeypatch.setattr(rm_mod, "VideoRecorder", FakeVideoRecorder) return FakeVideoRecorder @@ -428,7 +457,7 @@ def _fake_write_frame(cam_id, frame, timestamp=None, timestamp_metadata=None): @pytest.fixture def patch_build_run_dir(monkeypatch, tmp_path): - import dlclivegui.gui.recording_manager as rm_mod + import dlclivegui.services.recording_manager as rm_mod spy = {"session_dir": None, "use_timestamp": None} run_dir = tmp_path / "videos" / "Sess_SANITIZED" / "run_TEST" diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index df1c357e8..9556efc72 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -8,7 +8,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QMessageBox -from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.base import CameraBackend, CapturedFrame from dlclivegui.cameras.factory import CameraFactory, DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings from dlclivegui.gui.camera_config.camera_config_dialog import CameraConfigDialog @@ -194,7 +194,7 @@ def close(self): self._opened = False def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + return CapturedFrame(np.zeros((30, 40, 3), dtype=np.uint8), 0.1, None) CountingBackend.opens = 0 monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) @@ -238,7 +238,7 @@ def close(self): self._opened = False def read(self): - return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + return CapturedFrame(np.zeros((30, 40, 3), dtype=np.uint8), 0.1, None) CountingBackend.opens = 0 monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda s: CountingBackend(s))) diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 511d44552..bef210b3c 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -9,6 +9,7 @@ def stop(self): @pytest.mark.gui @pytest.mark.timeout(10) +@pytest.mark.skip("Removed functionality.") def test_record_overlay_uses_identity_transform_for_per_camera_recording(window, draw_pose_stub): # Disable event timers to avoid GUI rendering pipelines interfering with test window._display_timer.stop() @@ -47,6 +48,7 @@ def test_record_overlay_uses_identity_transform_for_per_camera_recording(window, @pytest.mark.gui @pytest.mark.timeout(10) +@pytest.mark.skip("Removed functionality.") def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording_frame_spy, draw_pose_stub): # Disable event timers to avoid GUI rendering pipelines interfering with test window._display_timer.stop() diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 45576716c..01853a8bd 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -4,8 +4,8 @@ import pytest from dlclivegui.config import CameraSettings -from dlclivegui.gui.recording_manager import RecordingManager from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id +from dlclivegui.services.recording_manager import RecordingManager from dlclivegui.utils.stats import RecorderStats from dlclivegui.utils.timestamps import FrameTimestampMetadata @@ -43,37 +43,40 @@ def test_start_all_creates_recorders_and_returns_run_dir( spy, expected_run_dir = patch_build_run_dir mgr = RecordingManager() - run_dir = mgr.start_all( - recording_settings, - _active_cams_two, - current_frames, - session_name="Sess", - use_timestamp=True, - all_or_nothing=False, - ) - - assert run_dir == expected_run_dir - assert mgr.is_active is True - assert mgr.run_dir == expected_run_dir - assert mgr.session_dir is not None - assert len(mgr.recorders) == 2 - - # build_run_dir called with correct use_timestamp - assert spy["use_timestamp"] is True - assert spy["session_dir"] is not None + try: + run_dir = mgr.start_all( + recording_settings, + _active_cams_two, + current_frames, + session_name="Sess", + use_timestamp=True, + all_or_nothing=False, + ) - # Validate per-cam recorder construction - for cam in _active_cams_two: - cam_id = get_camera_id(cam) - rec = mgr.recorders[cam_id] - assert rec.codec == recording_settings.codec - assert rec.crf == recording_settings.crf - assert rec.frame_rate == float(cam.fps) - assert rec.is_running is True - # output file should be inside run dir - assert rec.output.parent == expected_run_dir - # filename should include backend + cam index - assert f"_{cam.backend}_cam{cam.index}" in rec.output.name + assert run_dir == expected_run_dir + assert mgr.is_active is True + assert mgr.run_dir == expected_run_dir + assert mgr.session_dir is not None + assert len(mgr.recorders) == 2 + + # build_run_dir called with correct use_timestamp + assert spy["use_timestamp"] is True + assert spy["session_dir"] is not None + + # Validate per-cam recorder construction + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + assert rec.codec == recording_settings.codec + assert rec.crf == recording_settings.crf + assert rec.frame_rate == float(cam.fps) + assert rec.is_running is True + # output file should be inside run dir + assert rec.output.parent == expected_run_dir + # filename should include backend + cam index + assert f"_{cam.backend}_cam{cam.index}" in rec.output.name + finally: + mgr.stop_all() @pytest.mark.unit @@ -83,8 +86,11 @@ def test_start_all_passes_use_timestamp_flag( spy, _expected_run_dir = patch_build_run_dir mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess", use_timestamp=False) - assert spy["use_timestamp"] is False + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess", use_timestamp=False) + assert spy["use_timestamp"] is False + finally: + mgr.stop_all() @pytest.mark.unit @@ -92,14 +98,18 @@ def test_frame_size_is_inferred_from_current_frames( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - # cam0 -> 480x640, cam1 -> 720x1280 - for cam in _active_cams_two: - cam_id = get_camera_id(cam) - rec = mgr.recorders[cam_id] - frame = current_frames[cam_id] - assert rec.frame_size == (frame.shape[0], frame.shape[1]) + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + # cam0 -> 480x640, cam1 -> 720x1280 + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + frame = current_frames[cam_id] + assert rec.frame_size == (frame.shape[0], frame.shape[1]) + finally: + mgr.stop_all() @pytest.mark.unit @@ -111,10 +121,14 @@ def test_missing_frame_results_in_none_frame_size( current_frames.pop(cam1_id) mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - rec1 = mgr.recorders[cam1_id] - assert rec1.frame_size is None + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + rec1 = mgr.recorders[cam1_id] + assert rec1.frame_size is None + finally: + mgr.stop_all() @pytest.mark.unit @@ -175,6 +189,7 @@ def start_with_failure(self): assert mgr.session_dir is None finally: patch_video_recorder.start = original_start + mgr.stop_all() @pytest.mark.unit @@ -197,14 +212,19 @@ def test_write_frame_uses_given_timestamp( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - frame = current_frames[cam0_id] - mgr.write_frame(cam0_id, frame, timestamp=123.0) + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - rec = mgr.recorders[cam0_id] - assert rec.write_calls[-1][1] == 123.0 + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + mgr.write_frame(cam0_id, frame, timestamp=123.0) + assert mgr.flush(timeout=2.0) + + rec = mgr.recorders[cam0_id] + assert rec.write_calls[-1][1] == 123.0 + finally: + mgr.stop_all() @pytest.mark.unit @@ -212,18 +232,23 @@ def test_write_frame_uses_time_when_timestamp_missing( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir, monkeypatch ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - import dlclivegui.gui.recording_manager as rm_mod # noqa: E402 + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) + import dlclivegui.services.recording_manager as rm_mod # noqa: E402 - cam0_id = get_camera_id(_active_cams_two[0]) - frame = current_frames[cam0_id] - mgr.write_frame(cam0_id, frame, timestamp=None) + monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) + + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + mgr.write_frame(cam0_id, frame, timestamp=None) + assert mgr.flush(timeout=2.0) - rec = mgr.recorders[cam0_id] - assert rec.write_calls[-1][1] == 999.0 + rec = mgr.recorders[cam0_id] + assert rec.write_calls[-1][1] == 999.0 + finally: + mgr.stop_all() @pytest.mark.unit @@ -231,14 +256,20 @@ def test_write_frame_removes_recorder_on_exception( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - rec = mgr.recorders[cam0_id] - rec.raise_on_write = True + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - mgr.write_frame(cam0_id, current_frames[cam0_id], timestamp=1.0) - assert cam0_id not in mgr.recorders + cam0_id = get_camera_id(_active_cams_two[0]) + rec = mgr.recorders[cam0_id] + rec.raise_on_write = True + + mgr.write_frame(cam0_id, current_frames[cam0_id], timestamp=1.0) + assert mgr.flush(timeout=2.0) + + assert cam0_id not in mgr.recorders + finally: + mgr.stop_all() @pytest.mark.unit @@ -246,17 +277,21 @@ def test_get_stats_summary_single_recorder_uses_formatter( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir, monkeypatch ): mgr = RecordingManager() - mgr.start_all(recording_settings, [_active_cams_two[0]], current_frames, session_name="Sess") - cam0_id = get_camera_id(_active_cams_two[0]) - mgr.recorders[cam0_id]._stats = RecorderStats(frames_written=10, frames_enqueued=12) + try: + mgr.start_all(recording_settings, [_active_cams_two[0]], current_frames, session_name="Sess") + + cam0_id = get_camera_id(_active_cams_two[0]) + mgr.recorders[cam0_id]._stats = RecorderStats(frames_written=10, frames_enqueued=12) - # Patch formatter to avoid depending on formatting implementation - import dlclivegui.utils.stats as stats_mod + # Patch formatter to avoid depending on formatting implementation + import dlclivegui.utils.stats as stats_mod - monkeypatch.setattr(stats_mod, "format_recorder_stats", lambda s: "OK_SINGLE") + monkeypatch.setattr(stats_mod, "format_recorder_stats", lambda s: "OK_SINGLE") - assert mgr.get_stats_summary() == "OK_SINGLE" + assert mgr.get_stats_summary() == "OK_SINGLE" + finally: + mgr.stop_all() @pytest.mark.unit @@ -264,39 +299,43 @@ def test_get_stats_summary_multi_aggregates( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - ids = [get_camera_id(c) for c in _active_cams_two] - - mgr.recorders[ids[0]]._stats = RecorderStats( - frames_enqueued=12, - frames_written=10, - dropped_frames=1, - queue_size=2, - buffer_size=10, - average_latency=0.01, - last_latency=0.02, - write_fps=25.0, - ) - mgr.recorders[ids[1]]._stats = RecorderStats( - frames_enqueued=24, - frames_written=20, - dropped_frames=3, - queue_size=4, - buffer_size=10, - average_latency=0.03, - last_latency=0.05, - write_fps=30.0, - ) - - summary = mgr.get_stats_summary() - - assert "2 cams" in summary - assert "30/36 frames" in summary - assert "writer 55.0 fps" in summary - assert "dropped 4" in summary - assert "queue 6/20" in summary - assert "backlog 6" in summary + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + ids = [get_camera_id(c) for c in _active_cams_two] + + mgr.recorders[ids[0]]._stats = RecorderStats( + frames_enqueued=12, + frames_written=10, + dropped_frames=1, + queue_size=2, + buffer_size=10, + average_latency=0.01, + last_latency=0.02, + write_fps=25.0, + ) + mgr.recorders[ids[1]]._stats = RecorderStats( + frames_enqueued=24, + frames_written=20, + dropped_frames=3, + queue_size=4, + buffer_size=10, + average_latency=0.03, + last_latency=0.05, + write_fps=30.0, + ) + + summary = mgr.get_stats_summary() + + assert "2 cams" in summary + assert "30/36 frames" in summary + assert "writer 55.0 fps" in summary + assert "dropped 4" in summary + assert "queue 6/20" in summary + assert "backlog 6" in summary + finally: + mgr.stop_all() @pytest.mark.unit @@ -307,51 +346,58 @@ def test_recording_manager_uses_stable_camera_id_not_display_id( ): mgr = RecordingManager() - cam = CameraSettings( - name="GenTL cam", - backend="gentl", - index=0, - fps=30.0, - enabled=True, - properties={ - "gentl": { - "device_id": "serial:SER0", - "serial_number": "SER0", - } - }, - ).apply_defaults() - - stable_id = get_camera_id(cam) - display_id = get_display_id(cam) - - assert stable_id == "gentl:serial:SER0" - assert display_id == "GenTL cam" - assert stable_id != display_id - - frame = np.zeros((480, 640, 3), dtype=np.uint8) - current_frames = {stable_id: frame} - - run_dir = mgr.start_all( - recording_settings, - [cam], - current_frames, - session_name="Sess", - ) + try: + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "GenTL cam" + assert stable_id != display_id + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + current_frames = {stable_id: frame} - assert run_dir is not None - assert stable_id in mgr.recorders - assert display_id not in mgr.recorders + run_dir = mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert run_dir is not None + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders - rec = mgr.recorders[stable_id] - assert rec.frame_size == (480, 640) + rec = mgr.recorders[stable_id] + assert rec.frame_size == (480, 640) + + mgr.write_frame(stable_id, frame, timestamp=123.0) + assert mgr.flush(timeout=2.0) + + assert len(rec.write_calls) == 1 + assert rec.write_calls[-1][1] == 123.0 - mgr.write_frame(stable_id, frame, timestamp=123.0) - assert len(rec.write_calls) == 1 - assert rec.write_calls[-1][1] == 123.0 + # Display ID is GUI-only and must not route frames internally. + mgr.write_frame(display_id, frame, timestamp=456.0) + assert mgr.flush(timeout=2.0) - # Display ID is GUI-only and must not route frames internally. - mgr.write_frame(display_id, frame, timestamp=456.0) - assert len(rec.write_calls) == 1 + assert len(rec.write_calls) == 1 + finally: + mgr.stop_all() @pytest.mark.unit @@ -362,41 +408,44 @@ def test_start_all_does_not_infer_frame_size_from_display_id( ): mgr = RecordingManager() - cam = CameraSettings( - name="GenTL cam", - backend="gentl", - index=0, - fps=30.0, - enabled=True, - properties={ - "gentl": { - "device_id": "serial:SER0", - "serial_number": "SER0", - } - }, - ).apply_defaults() - - stable_id = get_camera_id(cam) - display_id = get_display_id(cam) - - frame = np.zeros((480, 640, 3), dtype=np.uint8) - - # Simulate the buggy situation: frames are keyed by display ID. - current_frames = {display_id: frame} - - mgr.start_all( - recording_settings, - [cam], - current_frames, - session_name="Sess", - ) + try: + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + + # Simulate the buggy situation: frames are keyed by display ID. + current_frames = {display_id: frame} + + mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) - assert stable_id in mgr.recorders - assert display_id not in mgr.recorders + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders - # Since RecordingManager uses stable IDs internally, it should not find this frame. - rec = mgr.recorders[stable_id] - assert rec.frame_size is None + # Since RecordingManager uses stable IDs internally, it should not find this frame. + rec = mgr.recorders[stable_id] + assert rec.frame_size is None + finally: + mgr.stop_all() @pytest.mark.unit @@ -412,18 +461,22 @@ def test_start_all_passes_writegear_options( recording_settings.fast_encoding = True mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - for cam in _active_cams_two: - cam_id = get_camera_id(cam) - rec = mgr.recorders[cam_id] + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - opts_ovrr = rec.writer_options_overrides - assert opts_ovrr is not None - assert opts_ovrr["-vcodec"] == "libx264" - assert opts_ovrr["-crf"] == "23" - assert opts_ovrr["-preset"] == "ultrafast" - assert opts_ovrr["-tune"] == "zerolatency" + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + + opts_ovrr = rec.writer_options_overrides + assert opts_ovrr is not None + assert opts_ovrr["-vcodec"] == "libx264" + assert opts_ovrr["-crf"] == "23" + assert opts_ovrr["-preset"] == "ultrafast" + assert opts_ovrr["-tune"] == "zerolatency" + finally: + mgr.stop_all() class TestRecordingManagerTimestampMetadata: @@ -437,28 +490,33 @@ def test_write_frame_passes_timestamp_metadata( patch_build_run_dir, ): mgr = RecordingManager() - mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") - - cam0_id = get_camera_id(_active_cams_two[0]) - frame = current_frames[cam0_id] - - meta = FrameTimestampMetadata( - source="grab_result.GetTimeStamp", - backend="basler", - default_reported="seconds", - seconds=0.001, - raw_value=1_000_000, - raw_unit="ticks", - tick_frequency_hz=1_000_000_000.0, - kind="camera_clock", - ) - - mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta) - - rec = mgr.recorders[cam0_id] - assert len(rec.write_calls) == 1 - written_frame, written_timestamp, written_metadata = rec.write_calls[0] - assert written_frame is frame - assert written_timestamp == 123.0 - assert written_metadata is meta + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + cam0_id = get_camera_id(_active_cams_two[0]) + frame = current_frames[cam0_id] + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta) + assert mgr.flush(timeout=2.0) + + rec = mgr.recorders[cam0_id] + assert len(rec.write_calls) == 1 + + written_frame, written_timestamp, written_metadata = rec.write_calls[0] + assert written_frame is frame + assert written_timestamp == 123.0 + assert written_metadata is meta + finally: + mgr.stop_all() diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 783b02240..1f8d0f17a 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -504,7 +504,7 @@ def _create(settings): @pytest.mark.unit -def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): +def test_recording_sink_receives_frames_when_enabled(qtbot, patch_factory): mc = MultiCameraController() cam = CameraSettings( @@ -516,26 +516,25 @@ def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): ).apply_defaults() cam_id = get_camera_id(cam) - seen: list[tuple[str, tuple, float]] = [] + seen: list[tuple[str, tuple, float, object]] = [] - def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None): - seen.append((camera_id, frame.shape, timestamp)) - - mc.recording_frame_ready.connect(on_recording_frame) + def sink(camera_id, frame, timestamp, timestamp_metadata=None): + seen.append((camera_id, frame.shape, timestamp, timestamp_metadata)) try: with qtbot.waitSignal(mc.all_started, timeout=1500): mc.start([cam]) - # Disabled by default: should not emit recording frames. + # Disabled by default. qtbot.wait(300) assert seen == [] + mc.set_recording_sink(sink) mc.set_recording_frame_do_emit(True) qtbot.waitUntil(lambda: bool(seen), timeout=2000) - camera_id, shape, timestamp = seen[-1] + camera_id, shape, timestamp, timestamp_metadata = seen[-1] assert camera_id == cam_id assert isinstance(timestamp, float) assert len(shape) in (2, 3) @@ -551,48 +550,76 @@ def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None): mc.stop(wait=True) -class TestRecordingFrameTimestamps: - @pytest.mark.unit - def test_recording_frame_ready_forwards_timestamp_metadata(self, qtbot): - mc = MultiCameraController() - mc._running = True - mc._recording_frame_emission_enabled = True +@pytest.mark.unit +def test_recording_sink_forwards_timestamp_metadata(qtbot, monkeypatch): + from dlclivegui.cameras.base import CapturedFrame + from dlclivegui.cameras.factory import CameraFactory + + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.001, + raw_value=1_000_000, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + kind="camera_clock", + ) + + class TimestampBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + self._count = 0 + + def open(self): + pass + + def read(self): + self._count += 1 + return CapturedFrame( + frame=np.zeros((10, 10), dtype=np.uint8), + software_timestamp=123.0 + self._count, + timestamp_metadata=meta, + ) + + def close(self): + pass - cam_id = "basler:0815-0000" - mc._settings[cam_id] = CameraSettings( - name="C", - backend="basler", - index=0, - enabled=True, - ).apply_defaults() - mc._camera_display_order = [cam_id] - mc._display_ids[cam_id] = "C" + monkeypatch.setattr(CameraFactory, "create", staticmethod(lambda settings: TimestampBackend(settings))) - frame = np.zeros((10, 10), dtype=np.uint8) - meta = FrameTimestampMetadata( - source="grab_result.GetTimeStamp", - backend="basler", - default_reported="seconds", - seconds=0.001, - raw_value=1_000_000, - raw_unit="ticks", - tick_frequency_hz=1_000_000_000.0, - kind="camera_clock", - ) + mc = MultiCameraController() + cam = CameraSettings( + name="C", + backend="basler", + index=0, + enabled=True, + properties={"basler": {"device_id": "0815-0000"}}, + ).apply_defaults() - seen = [] + cam_id = get_camera_id(cam) + seen = [] - def on_recording_frame(camera_id, emitted_frame, timestamp, timestamp_metadata): - seen.append((camera_id, emitted_frame, timestamp, timestamp_metadata)) + def sink(camera_id, frame, timestamp, timestamp_metadata=None): + seen.append((camera_id, frame, timestamp, timestamp_metadata)) - mc.recording_frame_ready.connect(on_recording_frame) + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) - mc._on_frame_captured(cam_id, frame, 123.0, meta) + # Recording is disabled by start(); enable the new sink path after cameras are running. + mc.set_recording_sink(sink) + mc.set_recording_frame_do_emit(True) - assert len(seen) == 1 + qtbot.waitUntil(lambda: bool(seen), timeout=2000) - camera_id, emitted_frame, timestamp, timestamp_metadata = seen[0] + camera_id, frame, timestamp, timestamp_metadata = seen[-1] assert camera_id == cam_id - assert emitted_frame is frame - assert timestamp == 123.0 + assert frame.shape == (10, 10) + assert isinstance(timestamp, float) assert timestamp_metadata is meta + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/test_config.py b/tests/test_config.py index 7c2f64bf0..2a967a01d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -81,7 +81,7 @@ def test_recording_settings_writegear_options_default(): opts = settings.writegear_options(100.0) - assert opts["-input_framerate"] == "100.000000" + assert opts["-input_framerate"] == 100.0 assert opts["-vcodec"] == "libx264" assert opts["-crf"] == "23" assert "-preset" not in opts @@ -93,7 +93,7 @@ def test_recording_settings_writegear_options_fast_encoding_x264(): opts = settings.writegear_options(100.0) - assert opts["-input_framerate"] == "100.000000" + assert opts["-input_framerate"] == 100.0 assert opts["-vcodec"] == "libx264" assert opts["-crf"] == "23" assert opts["-preset"] == "ultrafast" @@ -115,4 +115,4 @@ def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): opts = settings.writegear_options(None) - assert opts["-input_framerate"] == "30.000000" + assert opts["-input_framerate"] == 30.0