From c0099ae6348a8625c7a8410eaefb16c662f703b2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 14:25:03 -0500 Subject: [PATCH 001/194] Move RecorderStats to utils.stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the RecorderStats dataclass from dlclivegui/services/video_recorder.py into dlclivegui/utils/stats.py and add the needed dataclasses import. Update imports to reflect the new location in dlclivegui/gui/recording_manager.py, dlclivegui/services/video_recorder.py, and tests/tests/gui/test_rec_manager.py. Also add imports for REC_DO_LOG_TIMING and WorkerTimingStats in video_recorder.py. No behavioral changes intended—this is a refactor to centralize recorder-related stats. --- dlclivegui/gui/recording_manager.py | 3 ++- dlclivegui/services/video_recorder.py | 17 ++--------------- dlclivegui/utils/stats.py | 16 +++++++++++++++- tests/gui/test_rec_manager.py | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 49ac9934b..daf6f7d5d 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -8,7 +8,8 @@ from dlclivegui.config import CameraSettings, RecordingSettings from dlclivegui.services.multi_camera_controller import get_camera_id -from dlclivegui.services.video_recorder import RecorderStats, VideoRecorder +from dlclivegui.services.video_recorder import VideoRecorder +from dlclivegui.utils.stats import RecorderStats from dlclivegui.utils.utils import build_run_dir, sanitize_name log = logging.getLogger(__name__) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index e2ae15c9e..358af5645 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -9,12 +9,13 @@ import threading import time from collections import deque -from dataclasses import dataclass from pathlib import Path from typing import Any import numpy as np +from dlclivegui.utils.stats import RecorderStats + try: from vidgear.gears import WriteGear except ImportError: # pragma: no cover - handled at runtime @@ -26,20 +27,6 @@ STOP_JOIN_TIMEOUT = 5.0 # seconds -@dataclass -class RecorderStats: - """Snapshot of recorder throughput metrics.""" - - frames_enqueued: int = 0 - frames_written: int = 0 - dropped_frames: int = 0 - queue_size: int = 0 - average_latency: float = 0.0 - last_latency: float = 0.0 - write_fps: float = 0.0 - buffer_seconds: float = 0.0 - - _SENTINEL = object() diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 38e3798b7..acc83a9a4 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -3,9 +3,23 @@ import logging import time +from dataclasses import dataclass from dlclivegui.services.dlc_processor import ProcessorStats -from dlclivegui.services.video_recorder import RecorderStats + + +@dataclass +class RecorderStats: + """Snapshot of recorder throughput metrics.""" + + frames_enqueued: int = 0 + frames_written: int = 0 + dropped_frames: int = 0 + queue_size: int = 0 + average_latency: float = 0.0 + last_latency: float = 0.0 + write_fps: float = 0.0 + buffer_seconds: float = 0.0 class WorkerTimingStats: diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index c789078b0..aa8b187c5 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -6,7 +6,7 @@ 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.video_recorder import RecorderStats +from dlclivegui.utils.stats import RecorderStats @pytest.fixture From f31b57d3c45917ac82cf76c0b21418535bd209b4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 14:26:16 -0500 Subject: [PATCH 002/194] Preserve mono frames & add recorder timing Basler backend: add preserve_mono support and configure the pypylon converter to output Mono8 when the camera source PixelFormat is Mono* and preserve_mono is enabled; fall back to BGR8 otherwise. Log the first decoded frame and expose per-backend timing flag BASLER_DO_LOG_TIMING. Update defaults for global timing flags and add REC_DO_LOG_TIMING. Camera settings: add preserve_mono flag and include it in __repr__. RecordingManager: pass convert_grayscale_to_rgb based on the camera preserve_mono setting. VideoRecorder: add convert_grayscale_to_rgb option, avoid unnecessary grayscale->RGB expansion when disabled, forward pixel-format/size hints to WriteGear, add WorkerTimingStats for recorder processing and writer, instrument preprocessing/queue/write steps, log the first frame, and improve frame-size mismatch handling and error reporting. These changes reduce memory/CPU overhead for mono cameras and add better timing/diagnostics for recording. --- dlclivegui/cameras/backends/basler_backend.py | 64 +++++++++- dlclivegui/config.py | 5 + dlclivegui/gui/recording_manager.py | 1 + dlclivegui/services/video_recorder.py | 118 +++++++++++++----- 4 files changed, 149 insertions(+), 39 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 41ab4bb8a..a54590f7b 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,7 @@ import numpy as np -from ...config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings +from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend @@ -46,6 +46,12 @@ def __init__(self, settings): super().__init__(settings) self._props: dict = settings.properties if isinstance(settings.properties, dict) else {} + self._preserve_mono: bool = bool( + getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False) + ) + self._output_is_mono: bool = False + self._source_pixel_format: str | None = None + self._logged_first_frame: bool = False # Optional fast-start hint for probe workers # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) @@ -117,7 +123,7 @@ def __init__(self, settings): timing_id, logger=LOG, log_interval=1.0, - enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + enabled=BASLER_DO_LOG_TIMING, ) @property @@ -463,6 +469,42 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None + def _configure_converter(self) -> None: + """Configure pypylon image converter. + + Default behavior remains BGR8 for compatibility. + + If properties.basler.preserve_mono=true and the source PixelFormat is Mono*, + return Mono8 frames as 2D arrays to avoid 3x BGR expansion in the grab thread. + """ + if self._camera is None: + return + + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + self._source_pixel_format = str(pixel_format or "") + + self._converter = pylon.ImageFormatConverter() + self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + is_mono_source = self._source_pixel_format.startswith("Mono") + + if self._preserve_mono and is_mono_source: + self._converter.OutputPixelFormat = pylon.PixelType_Mono8 + self._output_is_mono = True + LOG.info( + "[Basler] Converter configured for Mono8 output (source PixelFormat=%s preserve_mono=%s)", + self._source_pixel_format, + self._preserve_mono, + ) + else: + self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed + self._output_is_mono = False + LOG.info( + "[Basler] Converter configured for BGR8 output (source PixelFormat=%s preserve_mono=%s)", + self._source_pixel_format, + self._preserve_mono, + ) + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -560,9 +602,7 @@ def open(self) -> None: pass # Converter BEFORE StartGrabbing - self._converter = pylon.ImageFormatConverter() - self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + self._configure_converter() # Force stream configuration reset try: @@ -638,6 +678,20 @@ def read(self) -> tuple[np.ndarray, float]: with self._timing.measure("Basler.get_array"): frame = image.GetArray() + if not self._logged_first_frame: + self._logged_first_frame = True + LOG.info( + "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " + "source_pixel_format=%s output_is_mono=%s, preserve_mono=%s", + self._device_id, + frame.shape, + frame.dtype, + frame.nbytes / (1024 * 1024), + self._source_pixel_format, + self._output_is_mono, + self._preserve_mono, + ) + with self._timing.measure("Basler.release"): grab_result.Release() grab_result = None diff --git a/dlclivegui/config.py b/dlclivegui/config.py index dedb1d44c..0956f121b 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,7 +26,10 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False +#### Backends +BASLER_DO_LOG_TIMING: bool = True class CameraSettings(BaseModel): @@ -42,6 +45,7 @@ class CameraSettings(BaseModel): exposure: int = 0 # 0=auto else µs gain: float = 0.0 # 0.0=auto else value + preserve_mono: bool = False # if True, preserve mono images as mono (not BGR) when reading crop_x0: int = 0 crop_y0: int = 0 @@ -65,6 +69,7 @@ def pretty(self) -> str: f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, " f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n" f" rotation={self.rotation}, crop={crop}\n" + f" preserve_mono={self.preserve_mono}, max_devices={self.max_devices}\n" f"]" ) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index daf6f7d5d..51f79552a 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -104,6 +104,7 @@ def start_all( frame_rate=float(cam.fps), codec=recording.codec, crf=recording.crf, + convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), ) try: recorder.start() diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 358af5645..c92eb23cf 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -14,7 +14,8 @@ import numpy as np -from dlclivegui.utils.stats import RecorderStats +from dlclivegui.config import REC_DO_LOG_TIMING +from dlclivegui.utils.stats import RecorderStats, WorkerTimingStats try: from vidgear.gears import WriteGear @@ -41,6 +42,7 @@ def __init__( codec: str = "libx264", crf: int = 23, buffer_size: int = 240, + convert_grayscale_to_rgb: bool = True, ): # Config self._output = Path(output) @@ -50,6 +52,7 @@ def __init__( self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) + self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -67,6 +70,14 @@ def __init__( self._encode_error: Exception | None = None self._last_log_time = 0.0 self._frame_timestamps: list[float] = [] + # Timing + self._process_timing = WorkerTimingStats( + f"RecorderProcess[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._writer_timing = WorkerTimingStats( + f"RecorderWriter[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING + ) + self._logged_first_frame = False @property def is_running(self) -> bool: @@ -107,7 +118,15 @@ def start(self) -> None: "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), } - # TODO deal with pixel format + if not self._convert_grayscale_to_rgb: + writer_kwargs.update( + { + "-pix_fmt": "yuv420p", + } + ) + if self._frame_size is not None: + h, w = self._frame_size + writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) @@ -147,41 +166,57 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: if timestamp is None: timestamp = time.time() - # Convert frame to uint8 if needed - if frame.dtype != np.uint8: - frame_float = frame.astype(np.float32, copy=False) - max_val = float(frame_float.max()) if frame_float.size else 0.0 - scale = 1.0 - if max_val > 0: - scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) - frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) - - # Convert grayscale to RGB if needed - if frame.ndim == 2: - frame = np.repeat(frame[:, :, None], 3, axis=2) - - # Ensure contiguous array - frame = np.ascontiguousarray(frame) - - # Check if frame size matches expected size - if self._frame_size is not None: - expected_h, expected_w = self._frame_size - actual_h, actual_w = frame.shape[:2] - if (actual_h, actual_w) != (expected_h, expected_w): - logger.warning( - f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " - f"got (h={actual_h}, w={actual_w}). " - "Stopping recorder to prevent encoding errors." + with self._process_timing.measure("Recorder.preprocess"): + # Convert frame to uint8 if needed + if frame.dtype != np.uint8: + frame_float = frame.astype(np.float32, copy=False) + max_val = float(frame_float.max()) if frame_float.size else 0.0 + scale = 1.0 + if max_val > 0: + scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) + frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) + + # Convert grayscale to RGB if needed + if self._convert_grayscale_to_rgb and frame.ndim == 2: + frame = np.repeat(frame[:, :, None], 3, axis=2) + + # Ensure contiguous array + frame = np.ascontiguousarray(frame) + + if not self._logged_first_frame: + self._logged_first_frame = True + logger.info( + "Recorder %s first frame: shape=%s dtype=%s " + "contiguous=%s nbytes=%.2f MB convert_grayscale_to_rgb=%s", + self._output.name, + frame.shape, + frame.dtype, + frame.flags.c_contiguous, + frame.nbytes / (1024 * 1024), + self._convert_grayscale_to_rgb, ) - # Set error to stop recording gracefully - with self._stats_lock: - self._encode_error = ValueError( - f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + + # Check if frame size matches expected size + if self._frame_size is not None: + expected_h, expected_w = self._frame_size + actual_h, actual_w = frame.shape[:2] + if (actual_h, actual_w) != (expected_h, expected_w): + logger.warning( + f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " + f"got (h={actual_h}, w={actual_w}). " + "Stopping recorder to prevent encoding errors." ) - return False + with self._stats_lock: + self._encode_error = ValueError( + f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + ) + self._process_timing.note_error() + self._process_timing.maybe_log() + return False try: - q.put((frame, timestamp), block=False) + with self._process_timing.measure("Recorder.queue_put"): + q.put((frame, timestamp), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 @@ -191,9 +226,16 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: queue_size, self._buffer_size, ) + self._process_timing.note_error() + self._process_timing.maybe_log() return False + with self._stats_lock: self._frames_enqueued += 1 + + self._process_timing.note_frame() + self._process_timing.maybe_log() + return True def stop(self) -> None: @@ -315,12 +357,17 @@ def _writer_loop(self) -> None: writer = self._writer if writer is None: raise RuntimeError("WriteGear writer is not initialized") - writer.write(frame) + + with self._writer_timing.measure("Recorder.writer_write"): + writer.write(frame) + except Exception as exc: with self._stats_lock: self._encode_error = exc logger.exception("Video encoding failed while writing frame", exc_info=exc) self._stop_event.set() + self._process_timing.note_error() + self._process_timing.maybe_log() break else: elapsed = time.perf_counter() - start @@ -335,6 +382,9 @@ def _writer_loop(self) -> None: self._compute_write_fps_locked() self._last_log_time = now + self._writer_timing.note_frame() + self._writer_timing.maybe_log() + finally: # Ensure queue accounting is correct for every item pulled from q try: From 6b1a9912520d75cb34a9a6c8fcbb7e25c9683939 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:00:15 -0500 Subject: [PATCH 003/194] Add preserve_mono camera option and UI support Introduce a preserve_mono capability and related properties to CameraBackend (actual_pixel_format, recommended_preserve_mono). Add a Preserve Mono checkbox to the camera config UI, persist/load its value, include it in probe detection logic (detect pixel format and apply recommended preserve_mono when supported), and treat changes to preserve_mono as restart-triggering. Update Basler static capabilities test to advertise preserve_mono support and add VideoRecorder tests to verify grayscale frames are preserved when requested and expanded by default. This enables preserving single-channel camera output to reduce bandwidth/overhead for monochrome cameras. --- dlclivegui/cameras/base.py | 9 ++++ .../gui/camera_config/camera_config_dialog.py | 33 ++++++++++++- dlclivegui/gui/camera_config/ui_blocks.py | 9 +++- tests/cameras/backends/test_basler_backend.py | 3 +- tests/services/test_video_recorder.py | 46 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index fefedd1d5..f86f3d14b 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -68,6 +68,7 @@ class SupportLevel(str, Enum): "set_fps": SupportLevel.UNSUPPORTED, "set_exposure": SupportLevel.UNSUPPORTED, "set_gain": SupportLevel.UNSUPPORTED, + "preserve_mono": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, "hardware_trigger": SupportLevel.UNSUPPORTED, @@ -98,6 +99,14 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: """Return a dict describing supported features for UI purposes.""" return DEFAULT_CAPABILITIES + @property + def actual_pixel_format(self) -> str | None: + return None + + @property + def recommended_preserve_mono(self) -> bool | None: + return None + @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index f259d3ef0..0d2a5ad09 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -359,6 +359,7 @@ def _mark_dirty(*_args): self.cam_rotation.currentIndexChanged.connect(lambda *_: _mark_dirty()) self.cam_enabled_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) + self.cam_preserve_mono_checkbox.stateChanged.connect(lambda *_: _mark_dirty()) # ------------------------------- # UI state updates @@ -529,6 +530,9 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True): apply(self.cam_exposure, "set_exposure", "Exposure") apply(self.cam_gain, "set_gain", "Gain") + # Output format / preserve mono + apply(self.cam_preserve_mono_checkbox, "preserve_mono", "Preserve mono output") + # Hardware trigger / sync apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") @@ -943,6 +947,7 @@ def _build_model_from_form(self, base: CameraSettings) -> CameraSettings: "crop_y0": int(self.cam_crop_y0.value()), "crop_x1": int(self.cam_crop_x1.value()), "crop_y1": int(self.cam_crop_y1.value()), + "preserve_mono": bool(self.cam_preserve_mono_checkbox.isChecked()), } ) # Validate and coerce; if invalid, Pydantic will raise @@ -961,6 +966,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, + self.cam_preserve_mono_checkbox, ] for widget in block: if hasattr(widget, "blockSignals"): @@ -975,6 +981,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_index_label.setText(str(cam.index)) self.cam_backend_label.setText(cam.backend) self._update_controls_for_backend(cam.backend) + self.cam_preserve_mono_checkbox.setChecked(bool(getattr(cam, "preserve_mono", False))) self.cam_width.setValue(cam.width) self.cam_height.setValue(cam.height) self.cam_fps.setValue(cam.fps) @@ -1009,6 +1016,7 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.crop_y0 = int(self.cam_crop_y0.value()) cam.crop_x1 = int(self.cam_crop_x1.value()) cam.crop_y1 = int(self.cam_crop_y1.value()) + cam.preserve_mono = bool(self.cam_preserve_mono_checkbox.isChecked()) def _commit_pending_edits(self, *, reason: str = "") -> bool: """ @@ -1179,6 +1187,7 @@ def _clear_settings_form(self) -> None: self.cam_crop_y0.setValue(0) self.cam_crop_x1.setValue(0) self.cam_crop_y1.setValue(0) + self.cam_preserve_mono_checkbox.setChecked(False) self.apply_settings_btn.setEnabled(False) self.reset_settings_btn.setEnabled(False) @@ -1377,6 +1386,8 @@ def _on_probe_success(self, payload) -> None: actual_res = getattr(be, "actual_resolution", None) actual_fps = getattr(be, "actual_fps", None) + actual_pixel_format = getattr(be, "actual_pixel_format", None) + recommended_preserve_mono = getattr(be, "recommended_preserve_mono", None) try: be.close() @@ -1404,7 +1415,23 @@ def _on_probe_success(self, payload) -> None: if isinstance(actual_fps, (int, float)) and float(actual_fps) > 0: ns["detected_fps"] = float(actual_fps) - self._append_status(f"[Probe] actual_res={actual_res}, actual_fps={actual_fps}") + + if actual_pixel_format: + ns["detected_pixel_format"] = str(actual_pixel_format) + self._append_status(f"[Probe] PixelFormat={actual_pixel_format}") + + if recommended_preserve_mono is not None: + ns["recommended_preserve_mono"] = bool(recommended_preserve_mono) + + # ---- Generic capability-driven recommendation ---- + caps = CameraFactory.backend_capabilities(backend) + preserve_mono_cap = caps.get("preserve_mono") + preserve_mono_supported = preserve_mono_cap is not None and preserve_mono_cap.value != "unsupported" + + if preserve_mono_supported and recommended_preserve_mono is True: + if not bool(getattr(c, "preserve_mono", False)): + c.preserve_mono = True + self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: @@ -1433,7 +1460,9 @@ def _on_probe_success(self, payload) -> None: # Always refresh detected labels if currently selected if self._current_edit_index == i: + self._load_camera_to_form(c) self._set_detected_labels(c) + break except Exception as exc: @@ -1661,7 +1690,7 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b Backend-agnostic for now (no OpenCV special casing). """ # Restart on these changes - for key in ("width", "height", "fps", "exposure", "gain"): + for key in ("width", "height", "fps", "exposure", "gain", "preserve_mono"): try: if getattr(old, key, None) != getattr(new, key, None): return True diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 07a8025e3..90d17d025 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -276,7 +276,14 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: ) dlg.settings_form.addRow(detected_row) - # --- Requested resolution controls (Auto = 0) --- + # --- Requested resolution/output format controls (Auto = 0) --- + dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve Mono output") + dlg.cam_preserve_mono_checkbox.setToolTip( + "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " + "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." + ) + dlg.settings_form.addRow(dlg.cam_preserve_mono_checkbox) + dlg.cam_width = QSpinBox() dlg.cam_width.setRange(0, 10000) dlg.cam_width.setValue(0) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index a88db91d6..41511d133 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -232,7 +232,7 @@ def test_basler_exposure_gain_fps_are_applied_when_nonzero( # --------------------------------------------------------------------- -def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( +def test_basler_static_capabilities_advertises_hardware_trigger_best_effort_and_mono( patch_basler_sdk, ): import dlclivegui.cameras.backends.basler_backend as bb @@ -240,6 +240,7 @@ def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( caps = bb.BaslerCameraBackend.static_capabilities() assert caps["hardware_trigger"] == SupportLevel.BEST_EFFORT + assert caps["preserve_mono"] == SupportLevel.SUPPORTED def test_basler_default_trigger_is_off_and_free_runs( diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index 28bb85646..efde6e2b9 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -372,3 +372,49 @@ def test_stop_timeout_marks_abandoned_and_prevents_restart( assert rec._abandoned is False rec.start() rec.stop() + + +def test_video_recorder_preserves_gray_when_requested(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder( + tmp_path / "out.mp4", + frame_size=(10, 20), + frame_rate=100, + convert_grayscale_to_rgb=False, + ) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written + assert written[0].shape == (10, 20) + + +def test_video_recorder_expands_gray_by_default(monkeypatch, tmp_path): + written = [] + + class FakeWriter: + def write(self, frame): + written.append(frame) + + def close(self): + pass + + monkeypatch.setattr("dlclivegui.services.video_recorder.WriteGear", lambda *a, **k: FakeWriter()) + + rec = vr_mod.VideoRecorder(tmp_path / "out.mp4", frame_size=(10, 20), frame_rate=100) + rec.start() + rec.write(np.zeros((10, 20), dtype=np.uint8)) + rec.stop() + + assert written[0].shape == (10, 20, 3) From ae06ee55c333f18fca7058b6395135d0f7c84d53 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:00:44 -0500 Subject: [PATCH 004/194] Add actual_pixel_format and preserve_mono support Expose the camera source pixel format via actual_pixel_format and add recommended_preserve_mono to suggest preserving mono images when the source format starts with "Mono". Add "preserve_mono" to reported capability levels. Implement _read_source_pixel_format to centralize reading the PixelFormat feature and call it from _configure_converter when needed so the backend always knows the source format before configuring conversion. --- dlclivegui/cameras/backends/basler_backend.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index a54590f7b..2d445ccb7 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -144,6 +144,16 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + return self._source_pixel_format + + @property + def recommended_preserve_mono(self) -> bool | None: + if not self._source_pixel_format: + return None + return self._source_pixel_format.startswith("Mono") + @classmethod def is_available(cls) -> bool: return pylon is not None @@ -160,6 +170,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "device_discovery": SupportLevel.BEST_EFFORT, "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, + "preserve_mono": SupportLevel.SUPPORTED, } ) return caps @@ -469,6 +480,10 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None + def _read_source_pixel_format(self) -> str: + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + return str(pixel_format or "") + def _configure_converter(self) -> None: """Configure pypylon image converter. @@ -480,6 +495,9 @@ def _configure_converter(self) -> None: if self._camera is None: return + if not self._source_pixel_format: + self._read_source_pixel_format() + pixel_format = self._feature_value(self._feature("PixelFormat"), "") self._source_pixel_format = str(pixel_format or "") From 41ab0a9b8a2b093c33c58eb31cc9ba35a5fc8fc2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:13:45 -0500 Subject: [PATCH 005/194] Basler: track camera pixel format and mono output Rename internal source pixel format to _camera_pixel_format and centralize pixel-format handling. Add actual_pixel_format and actual_output_format properties, plus helpers (_read_camera_pixel_format, _is_camera_mono, _should_output_mono) to determine if the camera is mono and whether the backend should output mono frames. Update _configure_converter to use these helpers (emit Mono8 when preserve_mono + mono camera), remove the _output_is_mono flag and the old _read_source_pixel_format, and improve log messages. Also ensure the camera pixel format is read during startup after gain detection. --- dlclivegui/cameras/backends/basler_backend.py | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 2d445ccb7..802876f04 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -49,8 +49,7 @@ def __init__(self, settings): self._preserve_mono: bool = bool( getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False) ) - self._output_is_mono: bool = False - self._source_pixel_format: str | None = None + self._camera_pixel_format: str | None = None self._logged_first_frame: bool = False # Optional fast-start hint for probe workers @@ -146,13 +145,21 @@ def actual_gain(self) -> float | None: @property def actual_pixel_format(self) -> str | None: - return self._source_pixel_format + """Camera/native pixel format reported by Basler, e.g. 'Mono8'.""" + return self._camera_pixel_format + + @property + def actual_output_format(self) -> str | None: + """Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'.""" + if not self._camera_pixel_format: + return None + return "Mono8" if self._should_output_mono() else "BGR8" @property def recommended_preserve_mono(self) -> bool | None: - if not self._source_pixel_format: + if not self._camera_pixel_format: return None - return self._source_pixel_format.startswith("Mono") + return self._is_camera_mono() @classmethod def is_available(cls) -> bool: @@ -197,6 +204,17 @@ def _ensure_mutable_ns(self) -> dict: self.settings.properties[self.OPTIONS_KEY] = ns return ns + def _read_camera_pixel_format(self) -> str: + pixel_format = self._feature_value(self._feature("PixelFormat"), "") + self._camera_pixel_format = str(pixel_format or "") + return self._camera_pixel_format + + def _is_camera_mono(self) -> bool: + return bool(self._camera_pixel_format and self._camera_pixel_format.startswith("Mono")) + + def _should_output_mono(self) -> bool: + return bool(self._preserve_mono and self._is_camera_mono()) + @classmethod def _enumerate_devices_cls(cls): """Enumerate DeviceInfo entries (unit-testable via monkeypatch).""" @@ -480,46 +498,34 @@ def _configure_frame_rate(self) -> None: except Exception: self._actual_fps = None - def _read_source_pixel_format(self) -> str: - pixel_format = self._feature_value(self._feature("PixelFormat"), "") - return str(pixel_format or "") - def _configure_converter(self) -> None: """Configure pypylon image converter. Default behavior remains BGR8 for compatibility. - If properties.basler.preserve_mono=true and the source PixelFormat is Mono*, - return Mono8 frames as 2D arrays to avoid 3x BGR expansion in the grab thread. + If preserve_mono=True and the camera PixelFormat is Mono*, + return Mono8 frames as 2D arrays to avoid 3x BGR expansion. """ if self._camera is None: return - if not self._source_pixel_format: - self._read_source_pixel_format() - - pixel_format = self._feature_value(self._feature("PixelFormat"), "") - self._source_pixel_format = str(pixel_format or "") + camera_pixel_format = self._camera_pixel_format or self._read_camera_pixel_format() self._converter = pylon.ImageFormatConverter() self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned - is_mono_source = self._source_pixel_format.startswith("Mono") - - if self._preserve_mono and is_mono_source: + if self._should_output_mono(): self._converter.OutputPixelFormat = pylon.PixelType_Mono8 - self._output_is_mono = True LOG.info( - "[Basler] Converter configured for Mono8 output (source PixelFormat=%s preserve_mono=%s)", - self._source_pixel_format, + "[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, self._preserve_mono, ) else: self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self._output_is_mono = False LOG.info( - "[Basler] Converter configured for BGR8 output (source PixelFormat=%s preserve_mono=%s)", - self._source_pixel_format, + "[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)", + camera_pixel_format, self._preserve_mono, ) @@ -608,6 +614,8 @@ def open(self) -> None: except Exception: self._actual_gain = None + self._read_camera_pixel_format() + # ---------------------------- # Start acquisition (skip for fast probe) # ---------------------------- @@ -700,13 +708,13 @@ def read(self) -> tuple[np.ndarray, float]: self._logged_first_frame = True LOG.info( "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " - "source_pixel_format=%s output_is_mono=%s, preserve_mono=%s", + "camera_pixel_format=%s output_format=%s preserve_mono=%s", self._device_id, frame.shape, frame.dtype, frame.nbytes / (1024 * 1024), - self._source_pixel_format, - self._output_is_mono, + self._camera_pixel_format, + self.actual_output_format, self._preserve_mono, ) From 6c2ab07ce728e8c2e8ffae390d6e122ae9fa72b4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 15:28:52 -0500 Subject: [PATCH 006/194] Show detected output format & mono option Add UI and backend support for reporting the camera backend's detected output format and pixel format. Introduce a detected output label with tooltip and move/rename the "preserve mono" checkbox into an Output row. Store/clear detected_output_format and detected_pixel_format in camera props, read actual_output_format from backends during probing, and set detected_output_format when pixel format indicates Mono. Add a mono indicator to camera list entries and update probe early-return logic to require both resolution and output format before skipping probing. --- .../gui/camera_config/camera_config_dialog.py | 41 ++++++++++++++++--- dlclivegui/gui/camera_config/ui_blocks.py | 31 ++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 0d2a5ad09..b0ca42afd 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -424,6 +424,8 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: det_res = ns.get("detected_resolution") det_fps = ns.get("detected_fps") + det_output_format = ns.get("detected_output_format") + det_pixel_format = ns.get("detected_pixel_format") if isinstance(det_res, (list, tuple)) and len(det_res) == 2: try: @@ -439,6 +441,18 @@ def _set_detected_labels(self, cam: CameraSettings) -> None: else: self.detected_fps_label.setText("—") + self.detected_output_format_label.setText(str(det_output_format) if det_output_format else "—") + + tooltip_parts = [] + if det_output_format: + tooltip_parts.append(f"Backend output: {det_output_format}") + if det_pixel_format: + tooltip_parts.append(f"Camera PixelFormat: {det_pixel_format}") + + self.detected_output_format_label.setToolTip( + "\n".join(tooltip_parts) if tooltip_parts else "Backend-reported output frame format emitted to the app." + ) + def _refresh_camera_labels(self) -> None: cam_list = getattr(self, "active_cameras_list", None) if not cam_list: @@ -467,11 +481,12 @@ def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{(cam.backend or '').lower()}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" + mono_indicator = " [Mono]" if getattr(cam, "preserve_mono", False) else "" trigger_role = self._trigger_role_for_label(cam) trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}" + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}{mono_indicator}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -1177,6 +1192,8 @@ def _clear_settings_form(self) -> None: self.cam_backend_label.setText("") self.detected_resolution_label.setText("—") self.detected_fps_label.setText("—") + self.detected_output_format_label.setText("—") + self.detected_output_format_label.setToolTip("Backend-reported output frame format emitted to the app.") self.cam_width.setValue(0) self.cam_height.setValue(0) self.cam_fps.setValue(0.0) @@ -1284,6 +1301,8 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: else: ns.pop("detected_resolution", None) ns.pop("detected_fps", None) + ns.pop("detected_pixel_format", None) + ns.pop("detected_output_format", None) ns.pop("last_applied_resolution", None) # Update UI immediately to show "Auto" while probing @@ -1355,12 +1374,16 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo ns = props.get(backend, {}) if isinstance(props.get(backend, None), dict) else {} if not apply_to_requested: det_res = ns.get("detected_resolution") + det_output = ns.get("detected_output_format") + has_res = False if isinstance(det_res, (list, tuple)) and len(det_res) == 2: try: - if int(det_res[0]) > 0 and int(det_res[1]) > 0: - return + has_res = int(det_res[0]) > 0 and int(det_res[1]) > 0 except Exception: - pass + has_res = False + + if has_res and det_output: + return # Start probe worker (settings will be opened in GUI thread for safety) self._probe_worker = CameraProbeWorker(cam, self) @@ -1387,6 +1410,7 @@ def _on_probe_success(self, payload) -> None: actual_res = getattr(be, "actual_resolution", None) actual_fps = getattr(be, "actual_fps", None) actual_pixel_format = getattr(be, "actual_pixel_format", None) + actual_output_format = getattr(be, "actual_output_format", None) recommended_preserve_mono = getattr(be, "recommended_preserve_mono", None) try: @@ -1410,8 +1434,6 @@ def _on_probe_success(self, payload) -> None: # Store regardless of "set_*" support. This is just "what device reports". if actual_res and isinstance(actual_res, (list, tuple)) and len(actual_res) == 2: ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] - elif actual_res and isinstance(actual_res, tuple) and len(actual_res) == 2: - ns["detected_resolution"] = [int(actual_res[0]), int(actual_res[1])] if isinstance(actual_fps, (int, float)) and float(actual_fps) > 0: ns["detected_fps"] = float(actual_fps) @@ -1420,6 +1442,10 @@ def _on_probe_success(self, payload) -> None: ns["detected_pixel_format"] = str(actual_pixel_format) self._append_status(f"[Probe] PixelFormat={actual_pixel_format}") + if actual_output_format: + ns["detected_output_format"] = str(actual_output_format) + self._append_status(f"[Probe] OutputFormat={actual_output_format}") + if recommended_preserve_mono is not None: ns["recommended_preserve_mono"] = bool(recommended_preserve_mono) @@ -1433,6 +1459,9 @@ def _on_probe_success(self, payload) -> None: c.preserve_mono = True self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") + if actual_pixel_format and str(actual_pixel_format).startswith("Mono"): + ns["detected_output_format"] = "Mono8" + # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: # Only apply resolution if we actually got it diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 90d17d025..9c8d40dd2 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -277,13 +277,6 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow(detected_row) # --- Requested resolution/output format controls (Auto = 0) --- - dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve Mono output") - dlg.cam_preserve_mono_checkbox.setToolTip( - "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " - "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." - ) - dlg.settings_form.addRow(dlg.cam_preserve_mono_checkbox) - dlg.cam_width = QSpinBox() dlg.cam_width.setRange(0, 10000) dlg.cam_width.setValue(0) @@ -297,6 +290,30 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: res_row = make_two_field_row("W", dlg.cam_width, "H", dlg.cam_height, key_width=30) dlg.settings_form.addRow("Resolution:", res_row) + # --- Output format controls --- + dlg.cam_preserve_mono_checkbox = QCheckBox("Preserve mono output") + dlg.cam_preserve_mono_checkbox.setToolTip( + "For monochrome cameras, keep frames as single-channel Mono8 instead of converting to BGR. " + "This reduces memory bandwidth and recording overhead. Display/overlay/DLC may convert later if needed." + ) + + dlg.detected_output_format_label = QLabel("—") + dlg.detected_output_format_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + dlg.detected_output_format_label.setToolTip( + "Backend-reported output frame format emitted to the app, for example Mono8 or BGR8." + ) + + output_widget = QWidget() + output_layout = QHBoxLayout(output_widget) + output_layout.setContentsMargins(0, 0, 0, 0) + output_layout.setSpacing(8) + output_layout.addWidget(dlg.cam_preserve_mono_checkbox) + output_layout.addStretch(1) + output_layout.addWidget(QLabel("Detected output:")) + output_layout.addWidget(dlg.detected_output_format_label) + + dlg.settings_form.addRow("Output:", output_widget) + # --- FPS + Rotation grouped --- dlg.cam_fps = QDoubleSpinBox() dlg.cam_fps.setRange(0.0, 240.0) From 35ca5c24d108ea95814d2ed0f54a4fe945e108ba Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:30:25 -0500 Subject: [PATCH 007/194] Track actual pixel/output formats in camera backends Expose actual_pixel_format and actual_output_format across backends and track the camera-reported formats for UI/telemetry. Aravis: add actual_pixel_format/actual_output_format properties and record _camera_pixel_format when setting pixel format. GenTL: initialize _camera_pixel_format/_actual_output_format, add _output_format_for_frame to infer output format from numpy frames, populate _actual_output_format on read, and record detected camera pixel format in several places. OpenCV: add actual_pixel_format (None) and actual_output_format (BGR8). These changes provide a consistent way to report native and emitted pixel formats to callers. --- dlclivegui/cameras/backends/aravis_backend.py | 12 ++++++ dlclivegui/cameras/backends/gentl_backend.py | 38 +++++++++++++++++++ dlclivegui/cameras/backends/opencv_backend.py | 10 +++++ 3 files changed, 60 insertions(+) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index b437c3c3f..a8ee67c13 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -69,6 +69,16 @@ def actual_fps(self) -> float | None: """Return the actual frame rate of the camera after opening.""" return self._actual_fps + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format requested/reported for Aravis.""" + return self._camera_pixel_format or self._pixel_format + + @property + def actual_output_format(self) -> str | None: + """Current Aravis backend emits BGR uint8 frames.""" + return self._actual_output_format or "BGR8" + @classmethod def is_available(cls) -> bool: """Check if Aravis is available on this system.""" @@ -615,10 +625,12 @@ def _configure_pixel_format(self) -> None: if self._pixel_format in format_map: self._camera.set_pixel_format(format_map[self._pixel_format]) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}'") else: # Try setting as string self._camera.set_pixel_format_from_string(self._pixel_format) + self._camera_pixel_format = self._pixel_format LOG.info(f"Pixel format set to '{self._pixel_format}' (from string)") except Exception as e: LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ad29ec1ee..7a2479c1d 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -99,6 +99,9 @@ def __init__(self, settings): self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto") self._pixel_format = str(self._pixel_format).strip() + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None + self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360 self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop"))) @@ -169,6 +172,16 @@ def actual_exposure(self) -> float | None: def actual_gain(self) -> float | None: return self._actual_gain + @property + def actual_pixel_format(self) -> str | None: + """Camera/native pixel format selected on the GenICam PixelFormat node.""" + return self._camera_pixel_format or (self._pixel_format if self._pixel_format != "auto" else None) + + @property + def actual_output_format(self) -> str | None: + """Current GenTL backend emits OpenCV-native BGR uint8 frames.""" + return self._actual_output_format or "BGR8" + @classmethod def is_available(cls) -> bool: return Harvester is not None @@ -593,6 +606,21 @@ def waits_for_hardware_trigger(self) -> bool: role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() return role in {"external", "follower"} + @staticmethod + def _output_format_for_frame(frame: np.ndarray) -> str: + if frame.ndim == 2: + if frame.dtype == np.uint8: + return "Mono8" + return f"Mono{frame.dtype}" + if frame.ndim == 3: + channels = frame.shape[2] + if channels == 3 and frame.dtype == np.uint8: + return "BGR8" + if channels == 4 and frame.dtype == np.uint8: + return "BGRA8" + return f"{channels}ch-{frame.dtype}" + return str(frame.dtype) + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -631,6 +659,7 @@ def read(self) -> tuple[np.ndarray, float]: self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: pass + self._actual_output_format = self._output_format_for_frame(frame) return frame, timestamp @@ -1340,11 +1369,14 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) + self._actual_pixel_format = self._pixel_format LOG.debug("GenTL pixel format selected: %s", self._pixel_format) except Exception as e: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) + if self._pixel_format and self._pixel_format.lower() != "auto": + self._camera_pixel_format = self._pixel_format def _configure_trigger(self, node_map) -> None: cfg = self._trigger @@ -1812,7 +1844,13 @@ def _read_telemetry(self, node_map) -> None: pixel_format = self._node_str(node_map, "PixelFormat") if pixel_format is not None: + self._camera_pixel_format = pixel_format ns["actual_pixel_format"] = pixel_format + ns["detected_pixel_format"] = pixel_format + + output_format = self.actual_output_format + if output_format is not None: + ns["actual_output_format"] = output_format except Exception: pass diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 74fdede98..869dde448 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -254,6 +254,16 @@ def actual_gain(self) -> None: """Not supported by OpenCV backend.""" return None + @property + def actual_pixel_format(self) -> str | None: + """OpenCV does not reliably expose native camera pixel format.""" + return None + + @property + def actual_output_format(self) -> str | None: + """OpenCV VideoCapture returns BGR frames in this backend.""" + return "BGR8" + # ---------------------------- # Internal helpers # ---------------------------- From 75da2360cdaea58af24065efb78a804cd58cad77 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:35:04 -0500 Subject: [PATCH 008/194] Use make_two_field_row for Output row Replace the manual QWidget/QHBoxLayout construction for the "Output" settings row with the reusable make_two_field_row helper. This simplifies and standardizes the layout while preserving the same widgets (cam_preserve_mono_checkbox and detected_output_format_label) and applies key_width=60 and gap=40 before adding the row to dlg.settings_form. --- dlclivegui/gui/camera_config/ui_blocks.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 9c8d40dd2..4c912753b 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -303,16 +303,10 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: "Backend-reported output frame format emitted to the app, for example Mono8 or BGR8." ) - output_widget = QWidget() - output_layout = QHBoxLayout(output_widget) - output_layout.setContentsMargins(0, 0, 0, 0) - output_layout.setSpacing(8) - output_layout.addWidget(dlg.cam_preserve_mono_checkbox) - output_layout.addStretch(1) - output_layout.addWidget(QLabel("Detected output:")) - output_layout.addWidget(dlg.detected_output_format_label) - - dlg.settings_form.addRow("Output:", output_widget) + output_row = make_two_field_row( + None, dlg.cam_preserve_mono_checkbox, "Detected:", dlg.detected_output_format_label, key_width=60, gap=40 + ) + dlg.settings_form.addRow("Output:", output_row) # --- FPS + Rotation grouped --- dlg.cam_fps = QDoubleSpinBox() From 2f03dfd9bbeac9ea25dc265d9246fdced813527f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:35:27 -0500 Subject: [PATCH 009/194] Use display labels for multi-camera GUI Add human-friendly display IDs for multi-camera support. Introduce get_display_id(settings) which prefers settings.name, then properties[backend].device_name, and falls back to backend:index. Main window now stores per-camera display IDs, clears them on stop, passes labels to create_tiled_frame, and uses the display label when building the compact camera status lines. This separates internal camera IDs from user-facing labels for clearer UI. --- dlclivegui/gui/main_window.py | 14 ++++++++++---- dlclivegui/services/multi_camera_controller.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 998c0fd67..9b2d7dea3 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -66,7 +66,7 @@ scan_processor_package, ) from ..services.dlc_processor import DLCLiveProcessor, PoseResult -from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id +from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id 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 format_dlc_stats @@ -165,6 +165,7 @@ def __init__(self, config: ApplicationSettings | None = None): # Multi-camera state self._multi_camera_mode = False self._multi_camera_frames: dict[str, np.ndarray] = {} + self._multi_camera_display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) # DLC pose rendering info for tiled view self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) @@ -1383,6 +1384,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: 2. Recording (queued writes, non-blocking) """ self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1443,6 +1445,7 @@ def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. """ self._multi_camera_frames = frame_data.frames + self._multi_camera_display_ids = frame_data.display_ids or {} self._display_dirty = True def _on_multi_camera_started(self) -> None: @@ -1463,6 +1466,7 @@ def _on_multi_camera_stopped(self) -> None: self.stop_preview_button.setEnabled(False) self._current_frame = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) @@ -1597,6 +1601,7 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._multi_camera_frames.clear() + self._multi_camera_display_ids.clear() self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1739,7 +1744,7 @@ def _update_display_from_pending(self) -> None: self._display_dirty = False # Create tiled frame on demand (moved from camera thread for performance) - tiled = create_tiled_frame(self._multi_camera_frames) + tiled = create_tiled_frame(self._multi_camera_frames, labels=self._multi_camera_display_ids) if tiled is not None: self._current_frame = tiled self._update_video_display(tiled) @@ -1755,10 +1760,11 @@ def _update_metrics(self) -> None: active_cams = self._config.multi_camera.get_active_cameras() lines = [] for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) fps = self._fps_tracker.fps(cam_id) # Make a compact label: name [backend:index] @ fps - label = f"{cam.name or cam_id} [{cam.backend}:{cam.index}]" + label = f"{display_id} [{cam.backend}:{cam.index}]" if fps > 0: lines.append(f"{label} @ {fps:.1f} fps") else: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c81b62f8f..4e7587404 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -222,6 +222,22 @@ def _log_trigger_wait_throttled(self, exc: BaseException) -> None: 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 + internal, reliable and unambiguous identity and may contain serials or machine paths. + """ + name = str(getattr(settings, "name", "") or "").strip() + if name: + return name + + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + device_name = str(ns.get("device_name", "") or "").strip() + if device_name: + return device_name + return f"{settings.backend}:{settings.index}" From 1401da5549a8dc999f1ae0ab562f69afe94014dc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:36:15 -0500 Subject: [PATCH 010/194] Update tests: display_id naming and fallback Adjust tests to reflect new human-friendly display_id values and add a fallback case. Updated expectations in tests to assert display_id equals "GenTL cam" / "GenTL Cam" / "C1" where applicable, and added a unit test to verify get_display_id falls back to the backend index (e.g. "gentl:3") when camera name is empty. Also added assertions in the controller test to ensure the stable camera id is present in frames and correctly mapped to the display id. Files changed: tests/gui/test_rec_manager.py, tests/services/test_multicam_controller.py. --- tests/gui/test_rec_manager.py | 2 +- tests/services/test_multicam_controller.py | 92 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index aa8b187c5..b3654a231 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -306,7 +306,7 @@ def test_recording_manager_uses_stable_camera_id_not_display_id( display_id = get_display_id(cam) assert stable_id == "gentl:serial:SER0" - assert display_id == "gentl:0" + assert display_id == "GenTL cam" assert stable_id != display_id frame = np.zeros((480, 640, 3), dtype=np.uint8) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 7dcde8908..ceeea6c70 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -204,6 +204,25 @@ def test_get_camera_id_falls_back_to_index_without_stable_identity(): assert get_camera_id(cam) == "opencv:index:2" +@pytest.mark.unit +def test_get_display_id_is_human_index_label(): + cam = CameraSettings( + name="GenTL Cam", + backend="gentl", + index=3, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + assert get_display_id(cam) == "GenTL Cam" + assert get_camera_id(cam) != get_display_id(cam) + + @pytest.mark.unit @pytest.mark.parametrize( ("role", "expected"), @@ -236,6 +255,23 @@ def test_trigger_role_from_settings_aliases(role, expected): assert _trigger_role_from_settings(cam) == expected +@pytest.mark.unit +def test_get_display_id_falls_back_to_backend_index_without_name(): + cam = CameraSettings( + name="", + backend="gentl", + index=3, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_display_id(cam) == "gentl:3" + + @pytest.mark.unit def test_camera_start_priority_orders_trigger_roles(): external = CameraSettings( @@ -351,6 +387,62 @@ def on_ready(mfd): mc.stop(wait=True) +@pytest.mark.unit +def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C1", + 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 == "C1" + assert stable_id != display_id + seen = [] + + def on_ready(mfd): + seen.append(mfd) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + mfd = seen[-1] + + assert stable_id in mfd.frames + assert mfd.display_ids[stable_id] == "C1" + assert mfd.source_camera_id == stable_id + assert stable_id in mfd.frames + assert stable_id in mfd.timestamps + + assert display_id not in mfd.frames + assert display_id not in mfd.timestamps + + assert mfd.display_ids is not None + assert mfd.display_ids[stable_id] == display_id + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + @pytest.mark.unit def test_display_order_is_cleared_on_stop(qtbot, patch_factory): mc = MultiCameraController() From bd104489f161395ccc179af91481084b530329a5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 16:36:32 -0500 Subject: [PATCH 011/194] Remove tiled frame generation and accessors Delete the internal _create_tiled_frame implementation and the public frame accessors (get_frame, get_all_frames, get_tiled_frame) from MultiCameraController. This removes the tiled canvas construction logic and convenience getters for retrieving camera frames; update any callers to use the controller's new/alternate APIs or access frames via the updated code paths. --- .../services/multi_camera_controller.py | 109 ------------------ 1 file changed, 109 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 4e7587404..7d24444a2 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -727,98 +727,6 @@ def to_display_pixmap(frame: np.ndarray) -> QPixmap: q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() return QPixmap.fromImage(q_img) - def _create_tiled_frame(self) -> np.ndarray: - """Create a tiled frame from all camera frames. - - The tiled frame is scaled to fit within a maximum canvas size - while maintaining aspect ratio of individual camera frames. - """ - if not self._frames: - return np.zeros((480, 640, 3), dtype=np.uint8) - - frames_list = [self._frames[idx] for idx in sorted(self._frames.keys())] - num_frames = len(frames_list) - - if num_frames == 0: - return np.zeros((480, 640, 3), dtype=np.uint8) - - # Determine grid layout - if num_frames == 1: - rows, cols = 1, 1 - elif num_frames == 2: - rows, cols = 1, 2 - elif num_frames <= 4: - rows, cols = 2, 2 - else: - rows, cols = 2, 2 # Limit to 4 - - # Maximum canvas size to fit on screen (leaving room for UI elements) - max_canvas_width = 1200 - max_canvas_height = 800 - - # Calculate tile size based on frame aspect ratio and available space - first_frame = frames_list[0] - frame_h, frame_w = first_frame.shape[:2] - frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 - - # Calculate tile dimensions that fit within the canvas - tile_w = max_canvas_width // cols - tile_h = max_canvas_height // rows - - # Maintain aspect ratio of original frames - tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 - - if frame_aspect > tile_aspect: - # Frame is wider than tile slot - constrain by width - tile_h = int(tile_w / frame_aspect) - else: - # Frame is taller than tile slot - constrain by height - tile_w = int(tile_h * frame_aspect) - - # Ensure minimum size - tile_w = max(160, tile_w) - tile_h = max(120, tile_h) - - # Create canvas - canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) - - # Get sorted camera IDs for consistent ordering - cam_ids = sorted(self._frames.keys()) - frames_list = [self._frames[cam_id] for cam_id in cam_ids] - - # Place each frame in the grid - for idx, frame in enumerate(frames_list[: rows * cols]): - row = idx // cols - col = idx % cols - - # Ensure frame is 3-channel - frame = MultiCameraController.ensure_color_bgr(frame) - - # Resize to tile size - resized = MultiCameraController.apply_resize(frame, tile_w, tile_h, allow_upscale=True) - - # Add camera ID label - if idx < len(cam_ids): - label = cam_ids[idx] - cv2.putText( - resized, - label, - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 255, 0), - 2, - ) - - # Place in canvas - y_start = row * tile_h - y_end = y_start + tile_h - x_start = col * tile_w - x_end = x_start + tile_w - canvas[y_start:y_end, x_start:x_end] = resized - - return canvas - def _on_camera_started(self, camera_id: str) -> None: """Handle camera start event.""" self._started_cameras.add(camera_id) @@ -875,20 +783,3 @@ def _on_camera_error(self, camera_id: str, message: str) -> None: if camera_id not in self._started_cameras: self._failed_cameras[camera_id] = message self.camera_error.emit(camera_id, message) - - def get_frame(self, camera_id: str) -> np.ndarray | None: - """Get the latest frame from a specific camera.""" - with self._frame_lock: - return self._frames.get(camera_id) - - def get_all_frames(self) -> dict[str, np.ndarray]: - """Get the latest frames from all cameras.""" - with self._frame_lock: - return dict(self._frames) - - def get_tiled_frame(self) -> np.ndarray | None: - """Get a tiled view of all camera frames.""" - with self._frame_lock: - if self._frames: - return self._create_tiled_frame() - return None From 10ba91813a907bad57d8df5a7c6b9eb8222e7bc2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:20:34 -0500 Subject: [PATCH 012/194] Use runtime FPS for recording; add fallbacks Collect and propagate runtime camera info to improve recorder FPS selection and logging. - Disabled some verbose timing flags in config (REC_DO_LOG_TIMING, BASLER_DO_LOG_TIMING). - MultiCameraController: added runtime_info signal, stores per-camera runtime info, logs it, and exposes actual_fps_by_camera_id(). Workers emit backend runtime properties on open. - MainWindow: pass actual_fps_by_camera to RecordingManager when starting recordings. - RecordingManager: added backend namespace helper and _resolve_recording_fps(cam, cam_id, frame_rates) to prefer measured FPS, then backend-detected FPS, then requested cam.fps (or auto). Use resolved recorder_fps when creating VideoRecorder and log chosen values. - VideoRecorder: if frame_rate is missing/zero, fall back to 30 FPS and emit a warning; added startup info log; removed/commented the old pix_fmt/output_dimensions branch. These changes make recording frame rates more accurate by preferring runtime-measured FPS and provide clearer logging and safe fallbacks when FPS is unknown. --- dlclivegui/config.py | 4 +- dlclivegui/gui/main_window.py | 2 + dlclivegui/gui/recording_manager.py | 63 ++++++++++++++++++- .../services/multi_camera_controller.py | 42 +++++++++++++ dlclivegui/services/video_recorder.py | 41 +++++++++--- 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 0956f121b..ea029fcba 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,10 +26,10 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False -REC_DO_LOG_TIMING: bool = True +REC_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = True +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 9b2d7dea3..e8df56fa5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1504,11 +1504,13 @@ def _start_multi_camera_recording(self) -> None: session_name = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True + actual_fps_by_camera = self.multi_camera_controller.actual_fps_by_camera_id() run_dir = self._rec_manager.start_all( recording, active_cams, self._multi_camera_frames, + frame_rates=actual_fps_by_camera, session_name=session_name, use_timestamp=use_ts, all_or_nothing=False, diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 51f79552a..d12b19ed3 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -39,6 +39,55 @@ def session_dir(self) -> Path | None: def run_dir(self) -> Path | None: return self._run_dir + @staticmethod + def _backend_ns(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) + return ns if isinstance(ns, dict) else {} + + @classmethod + def _resolve_recording_fps( + cls, + cam: CameraSettings, + cam_id: str, + frame_rates: dict[str, float] | None, + ) -> float | None: + """Resolve writer FPS. + + Prefer runtime measured FPS, then backend-probed detected_fps, + then explicit requested cam.fps. Auto/unknown returns None. + """ + measured_fps = 0.0 + if frame_rates: + try: + measured_fps = float(frame_rates.get(cam_id, 0.0) or 0.0) + except Exception: + measured_fps = 0.0 + + if measured_fps > 0.0: + return measured_fps + + ns = cls._backend_ns(cam) + + try: + detected_fps = float(ns.get("detected_fps", 0.0) or 0.0) + except Exception: + detected_fps = 0.0 + + if detected_fps > 0.0: + return detected_fps + + try: + requested_fps = float(getattr(cam, "fps", 0.0) or 0.0) + except Exception: + requested_fps = 0.0 + + if requested_fps > 0.0: + return requested_fps + + return None + def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) @@ -48,6 +97,7 @@ def start_all( active_cams: list[CameraSettings], current_frames: dict[str, np.ndarray], *, + frame_rates: dict[str, float] | None = None, session_name: str = "session", use_timestamp: bool = True, all_or_nothing: bool = False, @@ -97,11 +147,22 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None + recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + + log.debug( + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s recorder_fps=%s", + cam_id, + cam_path, + frame_size, + getattr(cam, "fps", None), + self._backend_ns(cam).get("detected_fps"), + f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + ) recorder = VideoRecorder( cam_path, frame_size=frame_size, - frame_rate=float(cam.fps), + frame_rate=recorder_fps, codec=recording.codec, crf=recording.crf, convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 7d24444a2..b4740d0d1 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -50,6 +50,7 @@ class SingleCameraWorker(QObject): frame_captured = Signal(str, object, float) # camera_id, frame, timestamp 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 @@ -110,6 +111,15 @@ def run(self) -> None: self.stopped.emit(self._camera_id) return + 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}") @@ -302,6 +312,7 @@ def __init__(self): self._workers: dict[str, SingleCameraWorker] = {} self._threads: dict[str, QThread] = {} self._settings: dict[str, CameraSettings] = {} + self._runtime_info: dict[str, dict] = {} self._frames: dict[str, np.ndarray] = {} self._timestamps: dict[str, float] = {} self._frame_lock = Lock() @@ -455,6 +466,7 @@ def _start_camera(self, settings: CameraSettings) -> None: # Connections unchanged thread.started.connect(worker.run) + worker.runtime_info.connect(self._on_camera_runtime_info) worker.frame_captured.connect(self._on_frame_captured) worker.started.connect(self._on_camera_started) worker.stopped.connect(self._on_camera_stopped) @@ -655,6 +667,36 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float timing.note_frame() timing.maybe_log() + def _on_camera_runtime_info(self, camera_id: str, info: object) -> None: + if not isinstance(info, dict): + return + + self._runtime_info[camera_id] = dict(info) + + actual_fps = info.get("actual_fps") + LOGGER.info( + "Camera %s runtime info: actual_fps=%s actual_resolution=%s pixel_format=%s output_format=%s", + camera_id, + actual_fps, + info.get("actual_resolution"), + info.get("actual_pixel_format"), + info.get("actual_output_format"), + ) + + def actual_fps_by_camera_id(self) -> dict[str, float]: + out: dict[str, float] = {} + + for camera_id, info in self._runtime_info.items(): + try: + fps = float(info.get("actual_fps") or 0.0) + except Exception: + fps = 0.0 + + if fps > 0.0: + out[camera_id] = fps + + return out + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index c92eb23cf..37ffe3dd4 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -109,7 +109,28 @@ def start(self) -> None: self._queue = None self._writer_thread = None - fps_value = float(self._frame_rate) if self._frame_rate else 30.0 + if self._frame_rate and float(self._frame_rate) > 0.0: + fps_value = float(self._frame_rate) + else: + fps_value = 30.0 + logger.warning( + "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " + "Video playback duration may not match capture timestamps.", + self._output.name, + fps_value, + ) + + logger.info( + "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s", + self._output, + self._frame_size, + fps_value, + self._codec, + self._crf, + self._buffer_size, + self._convert_grayscale_to_rgb, + ) writer_kwargs: dict[str, Any] = { "compression_mode": True, @@ -118,15 +139,15 @@ def start(self) -> None: "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), } - if not self._convert_grayscale_to_rgb: - writer_kwargs.update( - { - "-pix_fmt": "yuv420p", - } - ) - if self._frame_size is not None: - h, w = self._frame_size - writer_kwargs["-output_dimensions"] = (int(w), int(h)) + # if not self._convert_grayscale_to_rgb: + # writer_kwargs.update( + # { + # "-pix_fmt": "yuv420p", + # } + # ) + # if self._frame_size is not None: + # h, w = self._frame_size + # writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) From 9d4657dbb6f709d6e1e4e814e26a028390240d50 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:20:48 -0500 Subject: [PATCH 013/194] Track encoding errors with writer_timing Fix the video encoding error path in VideoRecorder by replacing _process_timing with _writer_timing so encoding failures are recorded and logged against the correct timing object. This ensures error timing and maybe_log are invoked on the writer timing tracker rather than the wrong object. --- dlclivegui/services/video_recorder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 37ffe3dd4..76bfc1d16 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -387,8 +387,8 @@ def _writer_loop(self) -> None: self._encode_error = exc logger.exception("Video encoding failed while writing frame", exc_info=exc) self._stop_event.set() - self._process_timing.note_error() - self._process_timing.maybe_log() + self._writer_timing.note_error() + self._writer_timing.maybe_log() break else: elapsed = time.perf_counter() - start From a69715013a03a686ea6ff772d97c1a98a310b368 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:22:32 -0500 Subject: [PATCH 014/194] Use _camera_pixel_format in camera backends Add _camera_pixel_format and _actual_output_format attributes to the Aravis backend and update the GenTL backend to set _camera_pixel_format (replacing the previous _actual_pixel_format assignment). This unifies pixel-format state handling across camera backends and prepares for explicit output format tracking. --- dlclivegui/cameras/backends/aravis_backend.py | 2 ++ dlclivegui/cameras/backends/gentl_backend.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index a8ee67c13..60059c464 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -52,6 +52,8 @@ def __init__(self, settings): self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None + self._camera_pixel_format: str | None = None + self._actual_output_format: str | None = None self._camera = None self._stream = None diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 7a2479c1d..5d0e69bed 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1369,7 +1369,7 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) - self._actual_pixel_format = self._pixel_format + self._camera_pixel_format = self._pixel_format LOG.debug("GenTL pixel format selected: %s", self._pixel_format) From 5c86aed8b403d1bac987cf59e23414d92ea69ff9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 24 Jun 2026 17:22:46 -0500 Subject: [PATCH 015/194] Add TYPE_CHECKING in stats --- dlclivegui/utils/stats.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index acc83a9a4..3a00c02c6 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -4,8 +4,10 @@ import logging import time from dataclasses import dataclass +from typing import TYPE_CHECKING -from dlclivegui.services.dlc_processor import ProcessorStats +if TYPE_CHECKING: + from dlclivegui.services.dlc_processor import ProcessorStats @dataclass From f72fcdd2c2b2af05073a6b87541930fe80a429c5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 14 Jul 2026 11:36:12 +0200 Subject: [PATCH 016/194] test: remove duplicate stable camera ID test --- tests/services/test_multicam_controller.py | 65 +--------------------- 1 file changed, 2 insertions(+), 63 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index ceeea6c70..c0b074c24 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -121,61 +121,6 @@ def _create(_settings): mc.start([cam]) -@pytest.mark.unit -def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): - mc = MultiCameraController() - - cam = CameraSettings( - name="C1", - 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:0" - assert stable_id != display_id - - seen = [] - - def on_ready(mfd): - seen.append(mfd) - - mc.frame_ready.connect(on_ready) - - try: - with qtbot.waitSignal(mc.all_started, timeout=1500): - mc.start([cam]) - - qtbot.waitUntil(lambda: bool(seen), timeout=2000) - - mfd = seen[-1] - - assert mfd.source_camera_id == stable_id - assert stable_id in mfd.frames - assert stable_id in mfd.timestamps - - assert display_id not in mfd.frames - assert display_id not in mfd.timestamps - - assert mfd.display_ids is not None - assert mfd.display_ids[stable_id] == display_id - - finally: - with qtbot.waitSignal(mc.all_stopped, timeout=2000): - mc.stop(wait=True) - - @pytest.mark.unit def test_get_camera_id_prefers_stable_device_id(): cam = CameraSettings( @@ -411,12 +356,9 @@ def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): assert stable_id == "gentl:serial:SER0" assert display_id == "C1" assert stable_id != display_id - seen = [] - - def on_ready(mfd): - seen.append(mfd) - mc.frame_ready.connect(on_ready) + seen = [] + mc.frame_ready.connect(seen.append) try: with qtbot.waitSignal(mc.all_started, timeout=1500): @@ -426,12 +368,9 @@ def on_ready(mfd): mfd = seen[-1] - assert stable_id in mfd.frames - assert mfd.display_ids[stable_id] == "C1" assert mfd.source_camera_id == stable_id assert stable_id in mfd.frames assert stable_id in mfd.timestamps - assert display_id not in mfd.frames assert display_id not in mfd.timestamps From 9a17e7cfd2054e7c7ed7e823a9ddfd63545748ab Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 11:08:16 +0200 Subject: [PATCH 017/194] Fix camera ID fallback and stop state reset Use the normalized backend value and integer camera index when building display IDs, ensuring consistent fallback identifiers. Also remove incorrectly rebased `_recording_frame_emission_enabled` as it is no longer needed. --- dlclivegui/services/multi_camera_controller.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index b4740d0d1..9ab8805dd 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -248,7 +248,7 @@ def get_display_id(settings: CameraSettings) -> str: if device_name: return device_name - return f"{settings.backend}:{settings.index}" + return f"{backend}:{int(settings.index)}" def get_camera_id(settings: CameraSettings) -> str: @@ -514,7 +514,6 @@ def _maybe_finalize_stop(self) -> None: return self._running = False - self._recording_frame_emission_enabled = False self._timing_per_cam.clear() self._gui_display_last_emit = 0.0 From d58fc225eed43aba43404b7bea3d4a6cec7ddc1f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 11:08:32 +0200 Subject: [PATCH 018/194] Clear camera runtime info during shutdown Ensure per-camera runtime metadata is fully reset across lifecycle transitions in `MultiCameraController`. This clears `_runtime_info` when starting a new camera set, removes entries when an individual camera is torn down, and clears all runtime info during final stop cleanup. It also removes the obsolete FIXME/commented-out clear call. --- dlclivegui/services/multi_camera_controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 9ab8805dd..0c45a2480 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -434,6 +434,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() + self._runtime_info.clear() self._expected_cameras = len(active_settings) for settings in active_settings: @@ -487,6 +488,7 @@ def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: thread = self._threads.pop(camera_id, None) self._settings.pop(camera_id, None) self._display_ids.pop(camera_id, None) + self._runtime_info.pop(camera_id, None) self._started_cameras.discard(camera_id) if worker is not None: @@ -499,7 +501,6 @@ def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: def _maybe_finalize_stop(self) -> None: """Finalize shutdown after every owned camera thread has finished.""" - # FUTURE FIXME: clear runtime info if not self._stopping: return @@ -519,7 +520,7 @@ def _maybe_finalize_stop(self) -> None: self._workers.clear() self._settings.clear() - # self._runtime_info.clear() + self._runtime_info.clear() self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() From 00afdcc3f88a58559096593ba5f1708f269bb18e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 11:08:46 +0200 Subject: [PATCH 019/194] Harden frame rate handling in video recorder Initialize FPS with a safe default, parse `frame_rate` defensively, and only warn/fallback when the configured value is missing or non-positive. This avoids startup failures from invalid frame-rate values while preserving expected logging. The commit also removes stale commented-out writer configuration code. --- dlclivegui/services/video_recorder.py | 33 ++++++++++++--------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 76bfc1d16..3f8f76bb3 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -109,16 +109,20 @@ def start(self) -> None: self._queue = None self._writer_thread = None - if self._frame_rate and float(self._frame_rate) > 0.0: - fps_value = float(self._frame_rate) - else: - fps_value = 30.0 - logger.warning( - "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " - "Video playback duration may not match capture timestamps.", - self._output.name, - fps_value, - ) + fps_value = 30.0 + if self._frame_rate is not None: + try: + fps_value = float(self._frame_rate) + except Exception: + fps_value = 0.0 + if fps_value <= 0.0: + fps_value = 30.0 + logger.warning( + "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " + "Video playback duration may not match capture timestamps.", + self._output.name, + fps_value, + ) logger.info( "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " @@ -139,15 +143,6 @@ def start(self) -> None: "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), } - # if not self._convert_grayscale_to_rgb: - # writer_kwargs.update( - # { - # "-pix_fmt": "yuv420p", - # } - # ) - # if self._frame_size is not None: - # h, w = self._frame_size - # writer_kwargs["-output_dimensions"] = (int(w), int(h)) self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) From 34024fea920e4896533aa895037d25996fab6703 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 11:12:09 +0200 Subject: [PATCH 020/194] Suggest mono preserve instead of auto-enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera probing no longer silently turns on `preserve_mono` when a mono pixel format is detected. Instead, it records `recommended_preserve_mono` and shows a status message explaining why enabling “Preserve mono frames” is recommended for performance, keeping configuration changes explicit and user-controlled. --- .../gui/camera_config/camera_config_dialog.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index b0ca42afd..b60d1c77b 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1454,14 +1454,17 @@ def _on_probe_success(self, payload) -> None: preserve_mono_cap = caps.get("preserve_mono") preserve_mono_supported = preserve_mono_cap is not None and preserve_mono_cap.value != "unsupported" - if preserve_mono_supported and recommended_preserve_mono is True: - if not bool(getattr(c, "preserve_mono", False)): - c.preserve_mono = True - self._append_status("[Probe] Mono pixel format detected; enabled Preserve mono frames.") - - if actual_pixel_format and str(actual_pixel_format).startswith("Mono"): - ns["detected_output_format"] = "Mono8" - + mono_recommended = ( + preserve_mono_supported + and recommended_preserve_mono is True + and not bool(getattr(c, "preserve_mono", False)) + ) + if mono_recommended: + ns["recommended_preserve_mono"] = True + self._append_status( + "[Probe] Mono pixel format detected. " + "Enable 'Preserve mono frames' to avoid expanding frames to color (for performance)." + ) # ---- Apply detected -> requested (Reset behavior) ---- if self._probe_apply_to_requested and self._probe_target_row == i: # Only apply resolution if we actually got it From efd05ac4ed92e72917999c290d34539a06425e19 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 15:17:35 +0200 Subject: [PATCH 021/194] Stabilize preview rendering GUI test Refactors `test_preview_renders_frames` to remove real camera timing dependencies by monkeypatching controller lifecycle methods and emitting synthetic `MultiFrameData` via `QTimer`. The test now explicitly verifies preview start/stop state changes, frame assignment, and that the displayed pixmap actually updates (via cache key), making the functional GUI test faster and less flaky. --- tests/gui/test_main.py | 81 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 1d08dbe06..df320bce3 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -1,8 +1,10 @@ +import numpy as np import pytest -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QImage from dlclivegui.services.dlc_processor import DLCLiveProcessor, Engine +from dlclivegui.services.multi_camera_controller import MultiFrameData def pixmap_bytes(label) -> bytes: @@ -16,30 +18,81 @@ def pixmap_bytes(label) -> bytes: @pytest.mark.gui @pytest.mark.functional -def test_preview_renders_frames(qtbot, window, multi_camera_controller): - """ - Validate that: - - Preview starts (`preview_button` clicked) - - Camera controller emits all_started - - GUI receives and renders frames to video_label.pixmap() - - Preview stops cleanly - """ - +def test_preview_renders_frames( + qtbot, + window, + multi_camera_controller, + monkeypatch, +): + """Verify preview controls and frame rendering without camera timing.""" w = window ctrl = multi_camera_controller + running = False + + camera_id = "fake:index:0" + frame = np.full((120, 160, 3), 127, dtype=np.uint8) + frame_data = MultiFrameData( + frames={camera_id: frame}, + timestamps={camera_id: 123.0}, + source_camera_id=camera_id, + display_ids={camera_id: "Test camera"}, + ) - with qtbot.waitSignal(ctrl.all_started, timeout=4000): + initial_pixmap = w.video_label.pixmap() + initial_cache_key = initial_pixmap.cacheKey() if initial_pixmap is not None else None + + def fake_is_running(): + return running + + def fake_get_active_count(): + return 1 if running else 0 + + def fake_start(_camera_settings): + nonlocal running + running = True + + def finish_start(): + ctrl.all_started.emit() + ctrl.frame_ready.emit(frame_data) + ctrl.display_ready.emit(frame_data) + + QTimer.singleShot(0, finish_start) + + def fake_stop(*, wait=True): + nonlocal running + running = False + QTimer.singleShot(0, ctrl.all_stopped.emit) + + monkeypatch.setattr(ctrl, "is_running", fake_is_running) + monkeypatch.setattr( + ctrl, + "get_active_count", + fake_get_active_count, + ) + monkeypatch.setattr(ctrl, "start", fake_start) + monkeypatch.setattr(ctrl, "stop", fake_stop) + + with qtbot.waitSignal(ctrl.all_started, timeout=1000): qtbot.mouseClick(w.preview_button, Qt.LeftButton) qtbot.waitUntil( - lambda: w.video_label.pixmap() is not None and not w.video_label.pixmap().isNull(), - timeout=6000, + lambda: ( + w._current_frame is not None + and w.video_label.pixmap() is not None + and not w.video_label.pixmap().isNull() + and w.video_label.pixmap().cacheKey() != initial_cache_key + ), + timeout=1000, ) - with qtbot.waitSignal(ctrl.all_stopped, timeout=4000): + assert w._current_frame is not None + assert w.stop_preview_button.isEnabled() + + with qtbot.waitSignal(ctrl.all_stopped, timeout=1000): qtbot.mouseClick(w.stop_preview_button, Qt.LeftButton) assert not ctrl.is_running() + assert w._current_frame is None @pytest.mark.gui From eeb7583eda3ccfde8bbcf1bd05f04ce1b05190e1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 17:57:10 +0200 Subject: [PATCH 022/194] Refactor WriteGear option handling Centralize WriteGear option building and validation in a shared utility, including strict checks for FPS/codec/CRF and fallback to a default recording FPS. Recording settings now support `fast_encoding`, which injects ultrafast low-latency FFmpeg overrides for x264/x265, and the recording manager passes these overrides through to `VideoRecorder`. --- dlclivegui/config.py | 30 +++++---- dlclivegui/gui/recording_manager.py | 1 + dlclivegui/services/video_recorder.py | 93 ++++++++++++++++++++------- dlclivegui/utils/__init__.py | 0 dlclivegui/utils/writegear_options.py | 42 ++++++++++++ 5 files changed, 131 insertions(+), 35 deletions(-) create mode 100644 dlclivegui/utils/__init__.py create mode 100644 dlclivegui/utils/writegear_options.py diff --git a/dlclivegui/config.py b/dlclivegui/config.py index ea029fcba..0d6e22fb3 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -4,10 +4,13 @@ import json from enum import Enum from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator +if TYPE_CHECKING: + from dlclivegui.utils.writegear_options import WriteGearOptions + Rotation = Literal[0, 90, 180, 270] TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] @@ -20,7 +23,8 @@ # Global settings ## GUI GUI_MAX_DISPLAY_FPS: float = 30.0 - +## Recording +DEFAULT_RECORDING_FPS: float = 30.0 ## Debug ### Timing logs @@ -505,6 +509,7 @@ class RecordingSettings(BaseModel): container: Literal["mp4", "avi", "mov"] = "mp4" codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + fast_encoding: bool = False def output_path(self) -> Path: """Return the absolute output path for recordings.""" @@ -518,17 +523,20 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename - def writegear_options(self, fps: float) -> dict[str, Any]: + @field_validator("codec", mode="before") + @classmethod + def _normalize_codec(cls, v) -> str: + return str(v or "").strip() or "libx264" + + def writegear_overrides(self) -> WriteGearOptions: """Return compression parameters for WriteGear.""" - fps_value = float(fps) if fps else 30.0 - codec_value = (self.codec or "libx264").strip() or "libx264" - crf_value = int(self.crf) if self.crf is not None else 23 - return { - "-input_framerate": f"{fps_value:.6f}", - "-vcodec": codec_value, - "-crf": str(crf_value), - } + if self.fast_encoding and self.codec in ("libx264", "libx265"): + return { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + return {} class ApplicationSettings(BaseModel): diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index d12b19ed3..6cdab5880 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -166,6 +166,7 @@ def start_all( codec=recording.codec, crf=recording.crf, convert_grayscale_to_rgb=not bool(getattr(cam, "preserve_mono", False)), + writer_options_overrides=recording.writegear_overrides() or None, ) try: recorder.start() diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 3f8f76bb3..df6b43cef 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -14,8 +14,9 @@ import numpy as np -from dlclivegui.config import REC_DO_LOG_TIMING +from dlclivegui.config import DEFAULT_RECORDING_FPS, REC_DO_LOG_TIMING from dlclivegui.utils.stats import RecorderStats, WorkerTimingStats +from dlclivegui.utils.writegear_options import WriteGearOptionOverrides, WriteGearOptions, normalize_writegear_options try: from vidgear.gears import WriteGear @@ -31,6 +32,46 @@ _SENTINEL = object() +def build_writegear_options( + *, + frame_rate: float | None, + codec: str, + crf: int, + overrides: WriteGearOptionOverrides | None = None, +) -> WriteGearOptions: + """Build and validate the final WriteGear/FFmpeg options.""" + try: + fps = float(frame_rate or 0.0) + except (TypeError, ValueError): + fps = 0.0 + + if fps <= 0: + fps = DEFAULT_RECORDING_FPS + + codec_value = str(codec or "").strip() + if not codec_value: + codec_value = "libx264" + + try: + crf_value = int(crf) + except (TypeError, ValueError) as exc: + raise ValueError("Recording CRF must be an integer.") from exc + + if not 0 <= crf_value <= 51: + raise ValueError("Recording CRF must be between 0 and 51.") + + options: WriteGearOptions = { + "-input_framerate": fps, + "-vcodec": codec_value, + "-crf": crf_value, + } + + if overrides: + options.update(overrides) + + return normalize_writegear_options(options) + + class VideoRecorder: """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" @@ -43,6 +84,7 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, + writer_options_overrides: WriteGearOptionOverrides | None = None, ): # Config self._output = Path(output) @@ -53,6 +95,7 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) + self._writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides else {} # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -109,20 +152,30 @@ def start(self) -> None: self._queue = None self._writer_thread = None - fps_value = 30.0 - if self._frame_rate is not None: - try: - fps_value = float(self._frame_rate) - except Exception: - fps_value = 0.0 - if fps_value <= 0.0: - fps_value = 30.0 - logger.warning( - "VideoRecorder frame_rate missing/zero for %s; falling back to %.3f FPS. " - "Video playback duration may not match capture timestamps.", - self._output.name, - fps_value, - ) + requested_fps = self._frame_rate + + writer_kwargs = build_writegear_options( + frame_rate=requested_fps, + codec=self._codec, + crf=self._crf, + overrides=self._writer_options_overrides, + ) + + fps_value = float(writer_kwargs["-input_framerate"]) + + try: + requested_fps_value = float(requested_fps or 0.0) + except (TypeError, ValueError): + requested_fps_value = 0.0 + + if requested_fps_value <= 0: + logger.warning( + "VideoRecorder frame_rate missing/zero for %s; " + "falling back to %.3f FPS. Video playback duration " + "may not match capture timestamps.", + self._output.name, + fps_value, + ) logger.info( "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " @@ -136,16 +189,8 @@ def start(self) -> None: self._convert_grayscale_to_rgb, ) - writer_kwargs: dict[str, Any] = { - "compression_mode": True, - "logging": False, - "-input_framerate": fps_value, - "-vcodec": (self._codec or "libx264").strip() or "libx264", - "-crf": int(self._crf), - } - self._output.parent.mkdir(parents=True, exist_ok=True) - self._writer = WriteGear(output=str(self._output), **writer_kwargs) + self._writer = WriteGear(output=str(self._output), compression_mode=True, logging=False, **writer_kwargs) self._queue = queue.Queue(maxsize=self._buffer_size) self._frames_enqueued = 0 self._frames_written = 0 diff --git a/dlclivegui/utils/__init__.py b/dlclivegui/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dlclivegui/utils/writegear_options.py b/dlclivegui/utils/writegear_options.py new file mode 100644 index 000000000..b23341d68 --- /dev/null +++ b/dlclivegui/utils/writegear_options.py @@ -0,0 +1,42 @@ +from collections.abc import Mapping +from typing import TypeAlias + +WriteGearOptionValue: TypeAlias = str | int | float | bool | None +WriteGearOptions: TypeAlias = dict[ + str, + WriteGearOptionValue, +] +WriteGearOptionOverrides: TypeAlias = Mapping[ + str, + WriteGearOptionValue, +] + + +def normalize_writegear_options( + options: WriteGearOptionOverrides, +) -> WriteGearOptions: + """Normalize known options while retaining supported extensions.""" + normalized = dict(options) + + try: + normalized["-input_framerate"] = float(normalized["-input_framerate"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-input_framerate' must be numeric.") from exc + + if normalized["-input_framerate"] <= 0: + raise ValueError("WriteGear option '-input_framerate' must be positive.") + + try: + normalized["-crf"] = int(normalized["-crf"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-crf' must be an integer.") from exc + + if not 0 <= normalized["-crf"] <= 51: + raise ValueError("WriteGear option '-crf' must be between 0 and 51.") + + codec = str(normalized.get("-vcodec") or "").strip() + if not codec: + raise ValueError("WriteGear option '-vcodec' must be a non-empty string.") + + normalized["-vcodec"] = codec + return normalized From 2b6294c0d54bdebfcaa393618984d1e7be53a23c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 13 Aug 2026 11:51:46 +0200 Subject: [PATCH 023/194] Clarify docs: Route camera open/probe requests to UI thread Documents that camera config workers only prepare settings and emit requests, while actual backend open/probe/close are handled in the GUI thread to avoid concurrent device opens. This renames `CameraProbeWorker.success` to `probe_requested`, updates the dialog signal hookup, refreshes worker messaging/docs, and removes unused backend cleanup logic from `CameraLoadWorker`. --- .../gui/camera_config/camera_config_dialog.py | 2 +- dlclivegui/gui/camera_config/loaders.py | 38 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index b60d1c77b..8b919ba89 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1388,7 +1388,7 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo # Start probe worker (settings will be opened in GUI thread for safety) self._probe_worker = CameraProbeWorker(cam, self) self._probe_worker.progress.connect(self._append_status) - self._probe_worker.success.connect(self._on_probe_success) + self._probe_worker.probe_requested.connect(self._on_probe_success) self._probe_worker.error.connect(self._on_probe_error) self._probe_worker.finished.connect(self._on_probe_finished) self._probe_worker.start() diff --git a/dlclivegui/gui/camera_config/loaders.py b/dlclivegui/gui/camera_config/loaders.py index e77edf471..319bb5b13 100644 --- a/dlclivegui/gui/camera_config/loaders.py +++ b/dlclivegui/gui/camera_config/loaders.py @@ -6,17 +6,13 @@ import copy import logging from enum import Enum, auto -from typing import TYPE_CHECKING from PySide6.QtCore import QThread, Signal from PySide6.QtWidgets import QWidget -from ...cameras.factory import CameraBackend, CameraFactory +from ...cameras.factory import CameraFactory from ...config import CameraSettings -if TYPE_CHECKING: - pass # only for typing - LOGGER = logging.getLogger(__name__) @@ -74,10 +70,16 @@ def run(self) -> None: class CameraProbeWorker(QThread): - """Request a quick device probe (open/close) without starting preview.""" + """Prepare and dispatch a camera probe request. + + This worker prepares a copy of the camera settings and emits ``success``. + The connected GUI thread handler performs the actual backend creation, + device open, probing, and close. Keeping those operations in one thread + prevents overlapping opens of the same device. + """ progress = Signal(str) - success = Signal(object) # emits CameraSettings + probe_requested = Signal(object) # emits CameraSettings error = Signal(str) def __init__(self, cam: CameraSettings, parent: QWidget | None = None): @@ -99,17 +101,23 @@ def run(self) -> None: self.progress.emit("Probing device defaults…") if self._cancel: return - self.success.emit(self._cam) + self.probe_requested.emit(self._cam) except Exception as exc: self.error.emit(f"{type(exc).__name__}: {exc}") # QThread.finished will fire automatically. # ------------------------------- -# Singleton camera preview loader worker +# Worker preparing cam preview loading # ------------------------------- class CameraLoadWorker(QThread): - """Open/configure a camera backend off the UI thread with progress and cancel support.""" + """Prepare and dispatch a camera-open request. + + This worker validates cancellation state, prepares a copy of the camera + settings, and emits ``success``. The connected GUI thread handler performs + the actual backend creation, device open, and configuration to avoid + opening the same device concurrently from multiple threads. + """ progress = Signal(str) success = Signal(object) # emits CameraSettings for GUI-thread open @@ -120,7 +128,6 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) self._cam = copy.deepcopy(cam) self._cancel = False - self._backend: CameraBackend | None = None # Ensure preview open never uses fast_start probe mode if isinstance(self._cam.properties, dict): @@ -139,22 +146,17 @@ def _check_cancel(self) -> bool: def run(self) -> None: try: - self.progress.emit("Creating backend…") + self.progress.emit("Preparing cam settings…") if self._check_cancel(): self.canceled.emit() return LOGGER.debug("Preparing camera open for %s:%d", self._cam.backend, self._cam.index) - self.progress.emit("Opening device…") + self.progress.emit("Requesting camera open…") # Open only in GUI thread to avoid simultaneous opens self.success.emit(self._cam) except Exception as exc: msg = f"{type(exc).__name__}: {exc}" - try: - if self._backend: - self._backend.close() - except Exception: - pass self.error.emit(msg) From bdc7265c022b2a558a8b4bdafbc9d20350424963 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 14:39:38 +0200 Subject: [PATCH 024/194] Only check output format once in read --- dlclivegui/cameras/backends/gentl_backend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 5d0e69bed..3bd9f20de 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -659,7 +659,8 @@ def read(self) -> tuple[np.ndarray, float]: self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: pass - self._actual_output_format = self._output_format_for_frame(frame) + if self._actual_output_format is None: + self._actual_output_format = self._output_format_for_frame(frame) return frame, timestamp From a8bfa3159b80ff69e91e583f4fcba59b2af2daae Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 14:45:35 +0200 Subject: [PATCH 025/194] Type hint fix --- dlclivegui/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 0d6e22fb3..e6b468ca6 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -528,7 +528,7 @@ def output_path(self) -> Path: def _normalize_codec(cls, v) -> str: return str(v or "").strip() or "libx264" - def writegear_overrides(self) -> WriteGearOptions: + def writegear_overrides(self) -> WriteGearOptions | None: """Return compression parameters for WriteGear.""" if self.fast_encoding and self.codec in ("libx264", "libx265"): From 55c4dac35f5c67d19fae44c89680ed38049e56c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 14:50:14 +0200 Subject: [PATCH 026/194] Harden recording FPS resolution logic Refactor `RecordingManager._resolve_recording_fps` to use a new `_valid_fps` helper that accepts only finite, positive numeric values. This removes broad exception handling, centralizes FPS validation for measured/detected/requested sources, and correctly treats invalid, missing, `NaN`, or infinite values as unset. --- dlclivegui/gui/recording_manager.py | 40 +++++++++++------------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 6cdab5880..ae02388b5 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math import time from pathlib import Path @@ -46,6 +47,15 @@ def _backend_ns(cam: CameraSettings) -> dict: ns = props.get(backend, {}) return ns if isinstance(ns, dict) else {} + @staticmethod + def _valid_fps(value) -> float | None: + try: + fps = float(value) + except (TypeError, ValueError): + return None + + return fps if math.isfinite(fps) and fps > 0.0 else None + @classmethod def _resolve_recording_fps( cls, @@ -58,35 +68,15 @@ def _resolve_recording_fps( Prefer runtime measured FPS, then backend-probed detected_fps, then explicit requested cam.fps. Auto/unknown returns None. """ - measured_fps = 0.0 - if frame_rates: - try: - measured_fps = float(frame_rates.get(cam_id, 0.0) or 0.0) - except Exception: - measured_fps = 0.0 - - if measured_fps > 0.0: + measured_fps = cls._valid_fps(frame_rates.get(cam_id) if frame_rates is not None else None) + if measured_fps is not None: return measured_fps - ns = cls._backend_ns(cam) - - try: - detected_fps = float(ns.get("detected_fps", 0.0) or 0.0) - except Exception: - detected_fps = 0.0 - - if detected_fps > 0.0: + detected_fps = cls._valid_fps(cls._backend_ns(cam).get("detected_fps")) + if detected_fps is not None: return detected_fps - try: - requested_fps = float(getattr(cam, "fps", 0.0) or 0.0) - except Exception: - requested_fps = 0.0 - - if requested_fps > 0.0: - return requested_fps - - return None + return cls._valid_fps(cam.fps) def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) From d8656ed3a5fc4e8fecd7bc850494bb4ee3524223 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 14:55:53 +0200 Subject: [PATCH 027/194] Filter None WriteGear option values Update `normalize_writegear_options` to drop keys with `None` values before normalizing known fields. This prevents unset overrides from being propagated as invalid WriteGear/ffmpeg options while preserving explicitly provided settings. --- dlclivegui/utils/writegear_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/utils/writegear_options.py b/dlclivegui/utils/writegear_options.py index b23341d68..432670087 100644 --- a/dlclivegui/utils/writegear_options.py +++ b/dlclivegui/utils/writegear_options.py @@ -16,7 +16,7 @@ def normalize_writegear_options( options: WriteGearOptionOverrides, ) -> WriteGearOptions: """Normalize known options while retaining supported extensions.""" - normalized = dict(options) + normalized = {key: value for key, value in options.items() if value is not None} try: normalized["-input_framerate"] = float(normalized["-input_framerate"]) From 9a5b01d1607113168f2c03ae11dbad469f621fb0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:12:34 +0200 Subject: [PATCH 028/194] Centralize WriteGear option validation Move final option validation responsibility into `normalize_writegear_options` and simplify `build_writegear_options` to only assemble defaults. `build_writegear_options` now keeps `crf` as provided, defaults codec inline, and only applies override entries with non-`None` values. The normalizer now works from a direct copy of the final options and performs normalization/validation there. --- dlclivegui/services/video_recorder.py | 20 ++++---------------- dlclivegui/utils/writegear_options.py | 4 ++-- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index df6b43cef..d067dbc55 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -39,7 +39,7 @@ def build_writegear_options( crf: int, overrides: WriteGearOptionOverrides | None = None, ) -> WriteGearOptions: - """Build and validate the final WriteGear/FFmpeg options.""" + """Build the final WriteGear/FFmpeg options.""" try: fps = float(frame_rate or 0.0) except (TypeError, ValueError): @@ -48,26 +48,14 @@ def build_writegear_options( if fps <= 0: fps = DEFAULT_RECORDING_FPS - codec_value = str(codec or "").strip() - if not codec_value: - codec_value = "libx264" - - try: - crf_value = int(crf) - except (TypeError, ValueError) as exc: - raise ValueError("Recording CRF must be an integer.") from exc - - if not 0 <= crf_value <= 51: - raise ValueError("Recording CRF must be between 0 and 51.") - options: WriteGearOptions = { "-input_framerate": fps, - "-vcodec": codec_value, - "-crf": crf_value, + "-vcodec": str(codec or "").strip() or "libx264", + "-crf": crf, } if overrides: - options.update(overrides) + options.update(key_value for key_value in overrides.items() if key_value[1] is not None) return normalize_writegear_options(options) diff --git a/dlclivegui/utils/writegear_options.py b/dlclivegui/utils/writegear_options.py index 432670087..1ae586fe1 100644 --- a/dlclivegui/utils/writegear_options.py +++ b/dlclivegui/utils/writegear_options.py @@ -15,8 +15,8 @@ def normalize_writegear_options( options: WriteGearOptionOverrides, ) -> WriteGearOptions: - """Normalize known options while retaining supported extensions.""" - normalized = {key: value for key, value in options.items() if value is not None} + """Normalize and validate final WriteGear options.""" + normalized = dict(options) try: normalized["-input_framerate"] = float(normalized["-input_framerate"]) From a9f91d114f55ccb6710b3bde69f00c28af188a62 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:28 -0500 Subject: [PATCH 029/194] Use one-by-one Basler grab strategy Switch Basler camera startup to `pylon.GrabStrategy_OneByOne` instead of `LatestImageOnly`, and update the nearby identity-persistence comment for clarity. --- dlclivegui/cameras/backends/basler_backend.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 802876f04..8960c5877 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -638,7 +638,8 @@ def open(self) -> None: pass self._camera.StartGrabbing( - pylon.GrabStrategy_LatestImageOnly, + # pylon.GrabStrategy_LatestImageOnly, + pylon.GrabStrategy_OneByOne, ) LOG.info( "[Basler] grabbing=%s max_buffers=%s", @@ -661,7 +662,7 @@ def open(self) -> None: ) # ---------------------------- - # Persist stable identity into namespace (migration-safe) + # Persist stable identity into namespace # ---------------------------- try: serial = device.GetSerialNumber() From 512ecadd72d12479b27194353f9e4db1317a4951 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:42 -0500 Subject: [PATCH 030/194] Ignore profiling output artifacts Add ignore patterns for generated profiling files (`profile*.svg`, `scalene*.json`, and `scalene*.html`) so local performance analysis outputs are not accidentally committed. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 7c5b18d80..1782ab32c 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ venv.bak/ !dlclivegui/config.py # uv package files uv.lock + +# profiling +profile*.svg +scalene*.json +scalene*.html From dca0fc9e6dcc4d1ccf89d384038e90fa0796f8d6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:21:55 -0500 Subject: [PATCH 031/194] Add profiling extra with Scalene Introduce a new `profiling` optional dependency group in `pyproject.toml` and include `scalene` so profiling tools can be installed independently from test and framework extras. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 265d9530c..da48c7820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ test = [ "tox", "tox-gh-actions", ] +profiling = [ + "scalene", +] tf = [ "deeplabcut-live[tf]>=1.1", ] From 4cec3fc090fc260ed4bbe61e553c2814d37c3302 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:24:16 -0500 Subject: [PATCH 032/194] Optimize recording pipeline and encoder options Adds a dedicated full-rate `recording_frame_ready` signal path so recording is decoupled from the inference/display frame flow, reducing processing overhead during capture. The GUI now exposes a fast-encoding toggle and persists it into recording settings, and recorder startup passes codec-specific writer options through to `VideoRecorder`. Recording telemetry was expanded to report enqueued vs written frames, writer FPS, queue fill against buffer size, backlog, and drops, improving visibility into recording throughput and pressure. --- dlclivegui/gui/main_window.py | 66 ++++++++++++++++--- dlclivegui/gui/recording_manager.py | 26 +++++++- .../services/multi_camera_controller.py | 17 ++++- dlclivegui/services/video_recorder.py | 53 ++++++++++++++- 4 files changed, 146 insertions(+), 16 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e8df56fa5..b43e9bfaf 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -633,13 +633,29 @@ def _build_recording_group(self) -> QGroupBox: form.addRow(grid) - # Record with overlays + # Recording options self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") self.record_with_overlays_checkbox.setToolTip( "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" ) self.record_with_overlays_checkbox.setChecked(False) - form.addRow(self.record_with_overlays_checkbox) + + self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters") + self.fast_encoding_checkbox.setToolTip( + "Use faster FFmpeg parameters for supported codecs.\n" + "For libx264/libx265 this uses preset=ultrafast and tune=zerolatency.\n" + "This can improve recording throughput but may increase file size." + ) + self.fast_encoding_checkbox.setChecked(False) + + recording_options = QWidget() + recording_options_layout = QHBoxLayout(recording_options) + recording_options_layout.setContentsMargins(0, 0, 0, 0) + recording_options_layout.addWidget(self.record_with_overlays_checkbox) + recording_options_layout.addWidget(self.fast_encoding_checkbox) + recording_options_layout.addStretch(1) + + form.addRow(recording_options) # Wrap recording buttons in a widget to prevent shifting recording_button_widget = QWidget() @@ -772,6 +788,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.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) @@ -822,6 +839,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.codec_combo.addItem(recording.codec) self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) self.crf_spin.setValue(int(recording.crf)) + + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): if not self.session_name_edit.text().strip(): @@ -934,6 +955,9 @@ def _recording_settings_from_ui(self) -> RecordingSettings: container=self.container_combo.currentText().strip() or "mp4", codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), + fast_encoding=bool( + getattr(self, "fast_encoding_checkbox", None) and self.fast_encoding_checkbox.isChecked() + ), ) def _bbox_settings_from_ui(self) -> BoundingBoxSettings: @@ -1376,6 +1400,24 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output + def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + """Handle full-rate per-camera frames for recording only. + + Intentionally lean: + - no MultiFrameData processing + - no DLC routing + - no display state updates + - no FPS tracker + - optional overlays only if user requested recording overlays + """ + if not self._rec_manager.is_active: + return + + if self.record_with_overlays_checkbox.isChecked(): + frame = self._render_overlays_for_recording(camera_id, frame) + + self._rec_manager.write_frame(camera_id, frame, timestamp) + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. @@ -1429,15 +1471,15 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._dlc.enqueue_frame(frame, timestamp) # PRIORITY 2: Recording (queued, non-blocking) - if self._rec_manager.is_active and src_id in frame_data.frames: - frame = frame_data.frames[src_id] + # if self._rec_manager.is_active and src_id in frame_data.frames: + # frame = frame_data.frames[src_id] - if self.record_with_overlays_checkbox.isChecked(): - # Draw overlays for recording - frame = self._render_overlays_for_recording(src_id, frame) + # if self.record_with_overlays_checkbox.isChecked(): + # # Draw overlays for recording + # frame = self._render_overlays_for_recording(src_id, frame) - ts = frame_data.timestamps.get(src_id, time.time()) - self._rec_manager.write_frame(src_id, frame, ts) + # ts = frame_data.timestamps.get(src_id, time.time()) + # self._rec_manager.write_frame(src_id, frame, ts) def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. @@ -1518,6 +1560,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_frame_do_emit(True) self._settings_store.set_session_name(session_name) self.start_record_button.setEnabled(False) @@ -1528,6 +1571,9 @@ def _start_multi_camera_recording(self) -> None: def _stop_multi_camera_recording(self) -> None: if not self._rec_manager.is_active: return + + self.multi_camera_controller.set_recording_frame_do_emit(False) + self._rec_manager.stop_all() self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) @@ -1719,6 +1765,8 @@ def _update_camera_controls_enabled(self) -> None: recording_editable = not multi_cam_recording self.codec_combo.setEnabled(recording_editable) self.crf_spin.setEnabled(recording_editable) + if hasattr(self, "fast_encoding_checkbox"): + self.fast_encoding_checkbox.setEnabled(recording_editable) # Config cameras button should be available when not in preview/recording self.config_cameras_button.setEnabled(allow_changes) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index ae02388b5..eeb6fd7b5 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -138,15 +138,19 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) + writer_options = recording.writegear_options(recorder_fps) log.debug( - "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s recorder_fps=%s", + "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " + "recorder_fps=%s fast_encoding=%s writer_options=%s", cam_id, cam_path, frame_size, getattr(cam, "fps", None), self._backend_ns(cam).get("detected_fps"), f"{recorder_fps:.3f}" if recorder_fps else "auto/fallback", + bool(getattr(recording, "fast_encoding", False)), + writer_options, ) recorder = VideoRecorder( @@ -204,9 +208,13 @@ def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = def get_stats_summary(self) -> str: totals = { + "enqueued": 0, "written": 0, "dropped": 0, "queue": 0, + "buffer": 0, + "backlog": 0, + "write_fps": 0.0, "max_latency": 0.0, "avg_latencies": [], } @@ -214,9 +222,13 @@ def get_stats_summary(self) -> str: stats: RecorderStats | None = rec.get_stats() if not stats: continue + totals["enqueued"] += stats.frames_enqueued totals["written"] += stats.frames_written totals["dropped"] += stats.dropped_frames totals["queue"] += stats.queue_size + totals["buffer"] += stats.buffer_size + totals["backlog"] += stats.backlog_frames + totals["write_fps"] += stats.write_fps totals["max_latency"] = max(totals["max_latency"], stats.last_latency) totals["avg_latencies"].append(stats.average_latency) @@ -230,8 +242,16 @@ def get_stats_summary(self) -> str: return "Recording..." else: avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 + + buffer = totals["buffer"] + queue_text = f"{totals['queue']}/{buffer}" if buffer > 0 else str(totals["queue"]) + fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0 + return ( - f"{len(self._recorders)} cams | {totals['written']} frames | " + f"{len(self._recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " + f"writer {totals['write_fps']:.1f} fps | " f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " - f"queue {totals['queue']} | dropped {totals['dropped']}" + f"queue {queue_text} ({fill_pct:.0f}%) | " + f"backlog {totals['backlog']} | " + f"dropped {totals['dropped']}" ) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 0c45a2480..027b79d8c 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -296,7 +296,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) + recording_frame_ready = Signal(str, object, float) # camera_id, frame, timestamp (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 @@ -319,6 +320,7 @@ def __init__(self): self._running = False self._stopping = False self._all_stopped_emitted = False + self._recording_frame_emission_enabled: bool = False self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._camera_display_order: list[str] = [] @@ -359,6 +361,14 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing 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) + def _should_emit_display_ready(self) -> bool: """Return True when the UI/display path should be updated. @@ -429,6 +439,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: self._running = True self._stopping = False self._all_stopped_emitted = False + self._recording_frame_emission_enabled = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -623,6 +634,10 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float 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) + with self._frame_lock: with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index d067dbc55..9ae0ea58d 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -61,7 +61,52 @@ def build_writegear_options( class VideoRecorder: - """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" + """Asynchronous video recorder backed by VidGear/FFmpeg. + + `VideoRecorder` wraps VidGear's `WriteGear` writer with a bounded in-memory + queue and a dedicated writer thread. Calls to `write()` perform minimal frame + validation/preprocessing, enqueue accepted frames without blocking, and return + immediately. The writer thread consumes queued frames and writes them to disk, + while also recording timestamps for successfully written frames. + + The recorder is intended for high-throughput camera pipelines where frame + acquisition should not block on video encoding. If the internal queue fills, + incoming frames are dropped and counted in recorder statistics. Timestamp + sidecar files are written on `stop()` for frames that were actually written. + + Args: + output: Output video path. + frame_size: Expected frame size as `(height, width)`. If provided, + incoming frames with different dimensions are rejected and the + recorder enters an error state. + frame_rate: Output video frame rate. If missing or non-positive, the + recorder falls back to 30 FPS and logs a warning. + codec: FFmpeg video codec name passed to WriteGear, for example + `"libx264"`. + crf: Constant Rate Factor passed to compatible FFmpeg encoders. Lower + values generally increase quality and file size. + buffer_size: Maximum number of frames that may wait in the recorder + queue before new frames are dropped. + convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded + to 3-channel RGB before writing. Set to `False` to preserve mono + frames when supported by the chosen writer/codec path. + fast_encoding: Whether to apply faster FFmpeg encoder settings when + supported by the selected codec. This can improve throughput at the + cost of larger files and/or reduced compression efficiency. + + Attributes: + is_running: Whether the writer thread is currently alive. + + Raises: + RuntimeError: If VidGear is unavailable, if the recorder is abandoned + after a failed stop, or if a previous encoding error is detected + during `write()`. + + Notes: + This class does not guarantee that every submitted frame is written. + Frames may be dropped when the queue is full, and timestamps are only + saved for frames successfully consumed by the writer thread. + """ def __init__( self, @@ -167,7 +212,7 @@ def start(self) -> None: logger.info( "Starting VideoRecorder output=%s frame_size=%s frame_rate=%.3f " - "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s", + "codec=%s crf=%s buffer_size=%s convert_grayscale_to_rgb=%s writer_options=%s", self._output, self._frame_size, fps_value, @@ -175,6 +220,7 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, + self._writer_options, ) self._output.parent.mkdir(parents=True, exist_ok=True) @@ -360,12 +406,13 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 + buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, dropped_frames=dropped, queue_size=queue_size, + buffer_size=self._buffer_size, average_latency=avg_latency, last_latency=last_latency, write_fps=write_fps, From 7a0c166fd41f14a4901a09feed36e4b7fed0e47f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:25:31 -0500 Subject: [PATCH 033/194] Enable timing logs and enrich recorder stats Turns on timing logging for multi-camera worker, recorder, and Basler backend diagnostics. Recording settings now include a `fast_encoding` flag, with `writegear_options` made more robust for missing/invalid FPS and optional low-latency FFmpeg options (`ultrafast` + `zerolatency`) for x264/x265. Recorder stats were expanded with buffer capacity awareness (`buffer_size`), derived backlog/fill-ratio properties, and richer formatted output showing queue fill and backlog. --- dlclivegui/config.py | 6 +++--- dlclivegui/gui/recording_manager.py | 2 +- dlclivegui/services/video_recorder.py | 2 +- dlclivegui/utils/stats.py | 23 ++++++++++++++++++++++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index e6b468ca6..1fbc6b6b0 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -29,11 +29,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False -REC_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +REC_DO_LOG_TIMING: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = False +BASLER_DO_LOG_TIMING: bool = True class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index eeb6fd7b5..95ed44299 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -138,7 +138,7 @@ def start_all( frame = current_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder_fps = self._resolve_recording_fps(cam, cam_id, frame_rates) - writer_options = recording.writegear_options(recorder_fps) + writer_options = recording.writegear_overrides() or None log.debug( "Starting recorder %s -> %s frame_size=%s requested_fps=%s detected_fps=%s " diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 9ae0ea58d..30f3a3063 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -220,7 +220,7 @@ def start(self) -> None: self._crf, self._buffer_size, self._convert_grayscale_to_rgb, - self._writer_options, + self._writer_options_overrides, ) self._output.parent.mkdir(parents=True, exist_ok=True) diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 3a00c02c6..1edbf7890 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -18,11 +18,24 @@ class RecorderStats: frames_written: int = 0 dropped_frames: int = 0 queue_size: int = 0 + buffer_size: int = 0 average_latency: float = 0.0 last_latency: float = 0.0 write_fps: float = 0.0 buffer_seconds: float = 0.0 + @property + def backlog_frames(self) -> int: + """Frames accepted by recorder but not yet written.""" + return max(0, self.frames_enqueued - self.frames_written) + + @property + def queue_fill_ratio(self) -> float: + """Queue fill ratio in [0, 1], or 0 when capacity is unknown.""" + if self.buffer_size <= 0: + return 0.0 + return min(1.0, max(0.0, self.queue_size / self.buffer_size)) + class WorkerTimingStats: """Tiny timing accumulator for camera worker performance diagnostics. @@ -128,11 +141,19 @@ def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 buffer_ms = stats.buffer_seconds * 1000.0 + + if stats.buffer_size > 0: + fill_pct = stats.queue_fill_ratio * 100.0 + queue_text = f"{stats.queue_size}/{stats.buffer_size} ({fill_pct:.0f}%, ~{buffer_ms:.0f} ms)" + else: + queue_text = f"{stats.queue_size} (~{buffer_ms:.0f} ms)" + return ( f"{stats.frames_written}/{stats.frames_enqueued} frames | " f"write {stats.write_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " - f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | " + f"queue {queue_text} | " + f"backlog {stats.backlog_frames} | " f"dropped {stats.dropped_frames}" ) From b1bc23579a4c7d26cdc932a73da98f9cef15339c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 26 Jun 2026 15:26:34 -0500 Subject: [PATCH 034/194] Expand recording behavior test coverage Updates test fixtures and unit tests around recording flow changes: FakeVideoRecorder now mirrors new constructor/runtime fields, Basler fake includes one-by-one grab strategy, and GUI/controller tests validate recording-frame emission gating plus overlay recording via `_on_recording_frame_ready`. Tests also cover `RecordingSettings.writegear_options` (including fast x264 options and FPS fallback), RecordingManager writer option wiring, and richer recorder stats formatting/aggregation with backlog and queue capacity output. --- dlclivegui/cameras/backends/basler_backend.py | 5 ++ tests/cameras/backends/conftest.py | 1 + tests/conftest.py | 19 +++++- tests/gui/test_pose_overlay.py | 13 +--- tests/gui/test_rec_manager.py | 54 +++++++++++++++-- tests/services/test_multicam_controller.py | 48 +++++++++++++++ tests/test_config.py | 43 +++++++++++++ tests/utils/test_stats.py | 60 ++++++++++++------- 8 files changed, 204 insertions(+), 39 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 8960c5877..56661522e 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -31,6 +31,8 @@ genicam = None # type: ignore[assignment] pylon = None # type: ignore[assignment] +DEBUG_TRIGGER_LOGS = False + @register_backend("basler") class BaslerCameraBackend(CameraBackend): @@ -959,6 +961,9 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo return False def _debug_trigger_nodes(self, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: + return + names = ( "TriggerSelector", "TriggerMode", diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 798b2b80b..011aa72c1 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -390,6 +390,7 @@ class FakePylon: """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" GrabStrategy_LatestImageOnly = 1 + GrabStrategy_OneByOne = 2 TimeoutHandling_ThrowException = 1 PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 diff --git a/tests/conftest.py b/tests/conftest.py index 7d12a70a1..49cd1c66e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -349,12 +349,28 @@ def fake_processor(): class FakeVideoRecorder: """Lightweight test double for VideoRecorder (no threads/ffmpeg).""" - def __init__(self, output, frame_size=None, frame_rate=None, codec="libx264", crf=23, **kwargs): + def __init__( + self, + output, + frame_size=None, + frame_rate=None, + codec="libx264", + crf=23, + buffer_size=240, + convert_grayscale_to_rgb=True, + writer_options=None, + **kwargs, + ): self.output = Path(output) self.frame_size = frame_size self.frame_rate = frame_rate self.codec = codec self.crf = crf + self.buffer_size = buffer_size + self.convert_grayscale_to_rgb = convert_grayscale_to_rgb + self.writer_options = dict(writer_options) if writer_options is not None else None + self.extra_kwargs = dict(kwargs) + self.started = False self.stopped = False self.write_calls = [] @@ -370,6 +386,7 @@ def start(self): if self.raise_on_start: raise RuntimeError("start failed") self.started = True + self.stopped = False def stop(self): self.stopped = True diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 3af35308f..511d44552 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,18 +65,9 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # Provide a frame raw = np.zeros((100, 100, 3), dtype=np.uint8) - # Build minimal frame_data to call _on_multi_frame_processing_ready - from dlclivegui.services.multi_camera_controller import MultiFrameData - - frame_data = MultiFrameData( - frames={cam_id: raw}, - timestamps={cam_id: 1.0}, - source_camera_id=cam_id, - ) - # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 1.0) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +75,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 2) toggle ON: should record overlay frame (different) window.record_with_overlays_checkbox.setChecked(True) - window._on_multi_frame_processing_ready(frame_data) + window._on_recording_frame_ready(cam_id, raw, 2.0) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index b3654a231..f97c43a57 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -266,18 +266,36 @@ def test_get_stats_summary_multi_aggregates( 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_written=10, dropped_frames=1, queue_size=2, average_latency=0.01, last_latency=0.02 + 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_written=20, dropped_frames=3, queue_size=4, average_latency=0.03, last_latency=0.05 + 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 frames" in summary # 10 + 20 - assert "dropped 4" in summary # 1 + 3 - assert "queue 6" in summary # 2 + 4 + 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 @pytest.mark.unit @@ -378,3 +396,29 @@ def test_start_all_does_not_infer_frame_size_from_display_id( # Since RecordingManager uses stable IDs internally, it should not find this frame. rec = mgr.recorders[stable_id] assert rec.frame_size is None + + +@pytest.mark.unit +def test_start_all_passes_writegear_options( + recording_settings, + _active_cams_two, + current_frames, + patch_video_recorder, + patch_build_run_dir, +): + recording_settings.codec = "libx264" + recording_settings.crf = 23 + 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] + + assert rec.writer_options is not None + assert rec.writer_options["-vcodec"] == "libx264" + assert rec.writer_options["-crf"] == "23" + assert rec.writer_options["-preset"] == "ultrafast" + assert rec.writer_options["-tune"] == "zerolatency" diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index c0b074c24..9abb34b8f 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -499,3 +499,51 @@ def _create(settings): if mc.is_running(): with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +@pytest.mark.unit +def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + cam_id = get_camera_id(cam) + seen: list[tuple[str, tuple, float]] = [] + + def on_recording_frame(camera_id, frame, timestamp): + seen.append((camera_id, frame.shape, timestamp)) + + mc.recording_frame_ready.connect(on_recording_frame) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Disabled by default: should not emit recording frames. + qtbot.wait(300) + assert seen == [] + + mc.set_recording_frame_do_emit(True) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + camera_id, shape, timestamp = seen[-1] + assert camera_id == cam_id + assert isinstance(timestamp, float) + assert len(shape) in (2, 3) + + mc.set_recording_frame_do_emit(False) + count_after_disable = len(seen) + + qtbot.wait(300) + assert len(seen) == count_after_disable + + 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 b7b4ceb15..7c2f64bf0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ CameraSettings, CameraTriggerSettings, MultiCameraSettings, + RecordingSettings, ) @@ -73,3 +74,45 @@ def test_trigger_source_defaults_to_auto(): trigger = CameraTriggerSettings() assert trigger.source == "auto" + + +def test_recording_settings_writegear_options_default(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_fast_encoding_x264(): + settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-input_framerate"] == "100.000000" + assert opts["-vcodec"] == "libx264" + assert opts["-crf"] == "23" + assert opts["-preset"] == "ultrafast" + assert opts["-tune"] == "zerolatency" + + +def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): + settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) + + opts = settings.writegear_options(100.0) + + assert opts["-vcodec"] == "h264_nvenc" + assert "-preset" not in opts + assert "-tune" not in opts + + +def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): + settings = RecordingSettings(codec="libx264", crf=23) + + opts = settings.writegear_options(None) + + assert opts["-input_framerate"] == "30.000000" diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index 1fa12400f..bd207cf9e 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,6 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st +from dlclivegui.gui.recording_manager import RecorderStats from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit @@ -14,19 +15,20 @@ def test_format_recorder_stats_exact(): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=10, frames_enqueued=12, write_fps=29.94, - last_latency=0.01234, # 12.34 ms -> 12.3 - average_latency=0.05678, # 56.78 ms -> 56.8 - buffer_seconds=0.4321, # 432.1 ms -> 432 + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, queue_size=3, + buffer_size=0, dropped_frames=2, ) assert format_recorder_stats(stats) == ( - "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | dropped 2" + "10/12 frames | write 29.9 fps | latency 12.3 ms (avg 56.8 ms) | queue 3 (~432 ms) | backlog 2 | dropped 2" ) @@ -115,6 +117,7 @@ def _fmt0(x: float) -> str: average_latency=finite_seconds_small, buffer_seconds=finite_seconds, queue_size=queue_size_int, + buffer_size=queue_size_int, dropped_frames=nonneg_int, ) def test_format_recorder_stats_properties( @@ -125,9 +128,10 @@ def test_format_recorder_stats_properties( average_latency, buffer_seconds, queue_size, + buffer_size, dropped_frames, ): - stats = SimpleNamespace( + stats = RecorderStats( frames_written=frames_written, frames_enqueued=frames_enqueued, write_fps=write_fps, @@ -135,28 +139,17 @@ def test_format_recorder_stats_properties( average_latency=average_latency, buffer_seconds=buffer_seconds, queue_size=queue_size, + buffer_size=buffer_size, dropped_frames=dropped_frames, ) s = format_recorder_stats(stats) - # Required structural tokens - assert " frames | write " in s - assert " fps | latency " in s - assert " ms (avg " in s - assert " ms) | queue " in s - assert " (~" in s - assert " ms) | dropped " in s - - # Exact numeric formatting expectations (substrings) - latency_ms = last_latency * 1000.0 - avg_ms = average_latency * 1000.0 - buffer_ms = buffer_seconds * 1000.0 - assert f"{frames_written}/{frames_enqueued} frames" in s - assert f"write {_fmt1(write_fps)} fps" in s - assert f"latency {_fmt1(latency_ms)} ms (avg {_fmt1(avg_ms)} ms)" in s - assert f"queue {queue_size} (~{_fmt0(buffer_ms)} ms)" in s + assert "write " in s + assert "latency " in s + assert "queue " in s + assert "backlog " in s assert f"dropped {dropped_frames}" in s @@ -251,3 +244,26 @@ def test_format_dlc_stats_profile_properties(stats): assert f"(GPU:{_fmt1(gpu_ms)}ms+proc:{_fmt1(proc_ms)}ms)" in s else: assert "GPU:" not in s + + +def test_format_recorder_stats_exact_with_buffer_capacity(): + stats = RecorderStats( + frames_written=10, + frames_enqueued=12, + write_fps=29.94, + last_latency=0.01234, + average_latency=0.05678, + buffer_seconds=0.4321, + queue_size=3, + buffer_size=10, + dropped_frames=2, + ) + + assert format_recorder_stats(stats) == ( + "10/12 frames | " + "write 29.9 fps | " + "latency 12.3 ms (avg 56.8 ms) | " + "queue 3/10 (30%, ~432 ms) | " + "backlog 2 | " + "dropped 2" + ) From 574c960258f01cac640fd829173b435cd0523af9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:23:21 +0200 Subject: [PATCH 035/194] Persist fast encoding setting in GUI Wire the fast encoding checkbox to QSettings so its value is restored on startup and saved when toggled. This adds typed get/set helpers for `recording/fast_encoding` in `DLCLiveGUISettingsStore`, updates `main_window` to prefer persisted values over config defaults, and includes a roundtrip unit test for the new setting. It also removes an obsolete commented-out recording block in the frame processing path. --- dlclivegui/gui/main_window.py | 18 ++++++------------ dlclivegui/utils/settings_store.py | 9 +++++++++ tests/utils/test_settings_store.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index b43e9bfaf..39d949029 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -813,6 +813,7 @@ def _connect_signals(self) -> None: self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) if hasattr(self, "container_combo"): self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ # Config @@ -841,7 +842,8 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.crf_spin.setValue(int(recording.crf)) if hasattr(self, "fast_encoding_checkbox"): - self.fast_encoding_checkbox.setChecked(bool(getattr(recording, "fast_encoding", False))) + config_fast_encoding = bool(getattr(recording, "fast_encoding", False)) + self.fast_encoding_checkbox.setChecked(self._settings_store.get_fast_encoding(default=config_fast_encoding)) ## Restore persisted session name if empty if hasattr(self, "session_name_edit"): @@ -1217,6 +1219,9 @@ def _on_use_timestamp_changed(self, _state: int) -> None: self._settings_store.set_use_timestamp(self.use_timestamp_checkbox.isChecked()) self._update_recording_path_preview() + def _on_fast_encoding_changed(self, _state: int) -> None: + self._settings_store.set_fast_encoding(self.fast_encoding_checkbox.isChecked()) + def _on_colormap_changed(self, _index: int) -> None: self._colormap = color_ui.get_cmap_name_from_combo(self.cmap_combo, fallback=self._colormap) if self._current_frame is not None: @@ -1470,17 +1475,6 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) self._dlc.enqueue_frame(frame, timestamp) - # PRIORITY 2: Recording (queued, non-blocking) - # if self._rec_manager.is_active and src_id in frame_data.frames: - # frame = frame_data.frames[src_id] - - # if self.record_with_overlays_checkbox.isChecked(): - # # Draw overlays for recording - # frame = self._render_overlays_for_recording(src_id, frame) - - # ts = frame_data.timestamps.get(src_id, time.time()) - # self._rec_manager.write_frame(src_id, frame, ts) - def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index fcf36fdd3..a0c5677f4 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -51,6 +51,15 @@ def get_use_timestamp(self, default: bool = True) -> bool: def set_use_timestamp(self, value: bool) -> None: self._s.setValue("recording/use_timestamp", bool(value)) + def get_fast_encoding(self, default: bool = False) -> bool: + value = self._s.value("recording/fast_encoding", default) + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue("recording/fast_encoding", bool(enabled)) + # --- optional: snapshot full config as JSON in QSettings --- def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: self._s.setValue("app/config_json", cfg.model_dump_json()) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 7eba56aef..318dc49cd 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -95,6 +95,17 @@ def model_validate_json(raw: str): assert settstore.load_full_config_snapshot() is None +def test_qt_settings_store_fast_encoding_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + settstore.set_fast_encoding(True) + assert settstore.get_fast_encoding(default=False) is True + + settstore.set_fast_encoding(False) + assert settstore.get_fast_encoding(default=True) is False + + # ----------------------------- # ModelPathStore helpers # ----------------------------- From 37ae506e27f97794ebce18e3935c1ba015c9008e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:24:09 +0200 Subject: [PATCH 036/194] Always wire recording setting signal handlers Remove defensive `hasattr` checks when connecting recording settings signals in `DLCLiveMainWindow`. The widgets are expected to exist, so connecting unconditionally avoids silently skipping persistence and recording path preview updates if an expected widget is missing or renamed. --- dlclivegui/gui/main_window.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 39d949029..5718bf805 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -803,16 +803,11 @@ def _connect_signals(self) -> None: # Recording settings ## Session name persistence + preview updates - if hasattr(self, "session_name_edit"): - self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) - if hasattr(self, "use_timestamp_checkbox"): - self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) - if hasattr(self, "output_directory_edit"): - self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "filename_edit"): - self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - if hasattr(self, "container_combo"): - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) + self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ From e4462bbef404d038a97d1384df93449d001b89ff Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:20 +0200 Subject: [PATCH 037/194] Disable default timing logs and fix stats import Turns off multi-camera, recorder, and Basler timing logs by default to reduce debug noise in normal runs. Also cleans up stale priority wording in main window comments, updates VideoRecorder docstrings to reflect `writer_options`, and fixes stats tests to import `RecorderStats` from `dlclivegui.utils.stats`. --- dlclivegui/config.py | 6 +++--- dlclivegui/gui/main_window.py | 5 ++--- dlclivegui/services/video_recorder.py | 5 ++--- tests/utils/test_stats.py | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 1fbc6b6b0..e6b468ca6 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -29,11 +29,11 @@ ## Debug ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -REC_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False +REC_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends -BASLER_DO_LOG_TIMING: bool = True +BASLER_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 5718bf805..790ebaf72 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1422,8 +1422,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. Priority: - 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) - 2. Recording (queued writes, non-blocking) + - DLC processing (highest priority - enqueue immediately, only for DLC camera) """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} @@ -1464,7 +1463,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._raw_frame = frame self._dlc_tile_offset, self._dlc_tile_scale = compute_tile_info(dlc_cam_id, frame, frame_data.frames) - # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! + # PRIORITY: DLC processing - only enqueue when DLC camera frame arrives! if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 30f3a3063..ef964521a 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -90,9 +90,8 @@ class VideoRecorder: convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded to 3-channel RGB before writing. Set to `False` to preserve mono frames when supported by the chosen writer/codec path. - fast_encoding: Whether to apply faster FFmpeg encoder settings when - supported by the selected codec. This can improve throughput at the - cost of larger files and/or reduced compression efficiency. + writer_options: Optional dictionary of additional keyword arguments passed + to `WriteGear`. If provided, this overrides the default options. Attributes: is_running: Whether the writer thread is currently alive. diff --git a/tests/utils/test_stats.py b/tests/utils/test_stats.py index bd207cf9e..bc1ae31f2 100644 --- a/tests/utils/test_stats.py +++ b/tests/utils/test_stats.py @@ -4,8 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from dlclivegui.gui.recording_manager import RecorderStats -from dlclivegui.utils.stats import format_dlc_stats, format_recorder_stats +from dlclivegui.utils.stats import RecorderStats, format_dlc_stats, format_recorder_stats pytestmark = pytest.mark.unit From cbe5391e12b0b46bfe2668c4bb90c77cc65bf967 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:33:53 +0200 Subject: [PATCH 038/194] Fix writer defaults and buffer time fallback Ensure ffmpeg writer defaults (`-input_framerate`, `-vcodec`, `-crf`) are always applied, even when custom writer options are provided, while still allowing overrides via `writer_options`. Also improve recorder stats by estimating `buffer_seconds` from average or last frame latency when write FPS is unavailable, avoiding zero/underreported buffer duration. --- dlclivegui/services/video_recorder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index ef964521a..b0a37c9f4 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -405,7 +405,15 @@ def get_stats(self) -> RecorderStats | None: avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() - buffer_seconds = queue_size / write_fps if write_fps > 0 else 0.0 + + if write_fps > 0: + buffer_seconds = queue_size / write_fps + elif avg_latency > 0: + buffer_seconds = queue_size * avg_latency + elif last_latency > 0: + buffer_seconds = queue_size * last_latency + else: + buffer_seconds = 0.0 return RecorderStats( frames_enqueued=frames_enqueued, frames_written=frames_written, From 5b162a4adf02a2961209ec46d84d0e93db89f931 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 14:33:22 +0200 Subject: [PATCH 039/194] Defer recording until preview frames are ready Adds a pending-recording flow in the main window so "Start recording" while preview is stopped first starts preview, then begins recording only after all active cameras have produced frames. The pending state is cleared on stop/error/init failure to avoid stale triggers and duplicate starts. Adds GUI tests covering deferred start, waiting for all camera frames, frame-ready trigger behavior, and no double-starts. --- dlclivegui/gui/main_window.py | 33 ++++++ tests/gui/test_recording_gui.py | 173 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/gui/test_recording_gui.py diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 790ebaf72..c9b70cd06 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -138,6 +138,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False + self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" @@ -1426,6 +1427,7 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """ self._multi_camera_frames = frame_data.frames self._multi_camera_display_ids = frame_data.display_ids or {} + self._try_start_pending_recording() src_id = frame_data.source_camera_id if src_id: self._fps_tracker.note_frame(src_id) # Track FPS @@ -1491,6 +1493,7 @@ def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" # Stop all multi-camera recorders self._stop_multi_camera_recording() + self._pending_recording_after_preview = False self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) @@ -1505,6 +1508,7 @@ def _on_multi_camera_stopped(self) -> None: def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" + self._pending_recording_after_preview = False self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") self._refresh_dlc_camera_list_running() if self.dlc_camera_combo.count() <= 1: @@ -1513,6 +1517,7 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: def _on_multi_camera_initialization_failed(self, failures: list) -> None: """Handle complete failure to initialize cameras.""" + self._pending_recording_after_preview = False # Build error message with details for each failed camera error_lines = ["Failed to initialize camera(s):"] for camera_id, error_msg in failures: @@ -1673,6 +1678,7 @@ def _stop_preview(self) -> None: self._stop_multi_camera_recording() self.multi_camera_controller.stop(wait=True) + self._pending_recording_after_preview = False self._stop_inference(show_message=False) self._fps_tracker.clear() self._last_display_time = 0.0 @@ -1956,6 +1962,7 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording # The recording will start after preview is confirmed running @@ -1967,6 +1974,32 @@ def _start_recording(self) -> None: # Preview already running, start recording immediately self._start_multi_camera_recording() + def _try_start_pending_recording(self) -> None: + if not self._pending_recording_after_preview: + return + + if self._rec_manager.is_active: + self._pending_recording_after_preview = False + return + + if not self.multi_camera_controller.is_running(): + return + + active_cams = self._config.multi_camera.get_active_cameras() + expected_ids = {get_camera_id(cam) for cam in active_cams} + + if not expected_ids: + self._pending_recording_after_preview = False + return + + available_ids = set(self._multi_camera_frames.keys()) + + if not expected_ids.issubset(available_ids): + return + + self._pending_recording_after_preview = False + self._start_multi_camera_recording() + def _stop_recording(self) -> None: """Stop recording from all cameras.""" self._stop_multi_camera_recording() diff --git a/tests/gui/test_recording_gui.py b/tests/gui/test_recording_gui.py new file mode 100644 index 000000000..9ef4c4c32 --- /dev/null +++ b/tests/gui/test_recording_gui.py @@ -0,0 +1,173 @@ +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import MultiFrameData, get_camera_id + + +@pytest.mark.gui +class TestPendingRecordingAfterPreview: + def test_start_recording_when_preview_stopped_defers_until_preview_frames( + self, + window, + monkeypatch, + ): + calls = { + "start_preview": 0, + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: False, + ) + + def fake_start_preview(): + calls["start_preview"] += 1 + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_preview", fake_start_preview) + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = False + + window._start_recording() + + assert calls["start_preview"] == 1 + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + def test_pending_recording_waits_until_all_active_cameras_have_frames( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + + assert calls["start_recording"] == 0 + assert window._pending_recording_after_preview is True + + window._multi_camera_frames[cam1_id] = np.zeros((10, 10, 3), dtype=np.uint8) + + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_is_triggered_from_multi_frame_processing_ready( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + + frame0 = np.zeros((10, 10, 3), dtype=np.uint8) + frame1 = np.zeros((10, 10, 3), dtype=np.uint8) + + frame_data = MultiFrameData( + frames={ + cam0_id: frame0, + cam1_id: frame1, + }, + timestamps={ + cam0_id: 1.0, + cam1_id: 1.0, + }, + source_camera_id=cam0_id, + display_ids={ + cam0_id: "Cam0", + cam1_id: "Cam1", + }, + ) + + window._on_multi_frame_processing_ready(frame_data) + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False + + def test_pending_recording_does_not_start_twice( + self, + window, + monkeypatch, + ): + active_cams = window._config.multi_camera.get_active_cameras() + assert len(active_cams) >= 2 + + cam0_id = get_camera_id(active_cams[0]) + cam1_id = get_camera_id(active_cams[1]) + + calls = { + "start_recording": 0, + } + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: True, + ) + + def fake_start_multi_camera_recording(): + calls["start_recording"] += 1 + + monkeypatch.setattr(window, "_start_multi_camera_recording", fake_start_multi_camera_recording) + + window._pending_recording_after_preview = True + window._multi_camera_frames = { + cam0_id: np.zeros((10, 10, 3), dtype=np.uint8), + cam1_id: np.zeros((10, 10, 3), dtype=np.uint8), + } + + window._try_start_pending_recording() + window._try_start_pending_recording() + + assert calls["start_recording"] == 1 + assert window._pending_recording_after_preview is False From fa33aaf5a40af735191cfb3e017377c5b6157e6c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:53 +0200 Subject: [PATCH 040/194] Disable delayed auto-start of recording In the multi-camera recording flow, the `QTimer.singleShot` call that automatically triggered `_start_multi_camera_recording` after starting preview is commented out. This stops the delayed automatic recording start when preview is not yet running. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c9b70cd06..d7323fec1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1968,7 +1968,7 @@ def _start_recording(self) -> None: # The recording will start after preview is confirmed running self.statusBar().showMessage("Starting preview before recording...", 3000) # Use a single-shot timer to start recording after preview starts - QTimer.singleShot(500, self._start_multi_camera_recording) + # QTimer.singleShot(500, self._start_multi_camera_recording) return # Preview already running, start recording immediately From 4a5ea489d34a4c3d42b2e46d5c27578939f030d3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:15:59 +0200 Subject: [PATCH 041/194] Update main_window.py --- dlclivegui/gui/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index d7323fec1..f96733dde 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -801,6 +801,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) + self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) # Recording settings ## Session name persistence + preview updates @@ -1488,6 +1490,7 @@ def _on_multi_camera_started(self) -> None: self.statusBar().showMessage(f"Multi-camera preview started: {active_count} camera(s)", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_stopped(self) -> None: """Handle all cameras stopped event.""" @@ -1505,6 +1508,7 @@ def _on_multi_camera_stopped(self) -> None: self.statusBar().showMessage("Multi-camera preview stopped", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" From f7c4e5ebfe03c6b0088f7fcde42ba4e621f9a389 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:28:12 +0200 Subject: [PATCH 042/194] Centralize trigger debug logging flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `DEBUG_TRIGGER_LOGS` into `config.py` so camera backends share a single toggle for trigger diagnostics. Basler now imports the shared flag instead of defining a local constant, and GenTL’s trigger-node dump is now guarded by both DEBUG log level and the config flag to avoid noisy logs unless explicitly enabled. --- dlclivegui/cameras/backends/basler_backend.py | 4 +--- dlclivegui/cameras/backends/gentl_backend.py | 5 ++++- dlclivegui/config.py | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 56661522e..3edde055a 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,7 @@ import numpy as np -from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings +from ...config import BASLER_DO_LOG_TIMING, DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend @@ -31,8 +31,6 @@ genicam = None # type: ignore[assignment] pylon = None # type: ignore[assignment] -DEBUG_TRIGGER_LOGS = False - @register_backend("basler") class BaslerCameraBackend(CameraBackend): diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 3bd9f20de..ea7538c72 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -12,7 +12,7 @@ import cv2 import numpy as np -from ...config import CameraTriggerSettings +from ...config import DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -199,6 +199,9 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: } def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: + return + names = ( "TriggerMode", "TriggerSelector", diff --git a/dlclivegui/config.py b/dlclivegui/config.py index e6b468ca6..c24a0e085 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -31,6 +31,8 @@ SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False REC_DO_LOG_TIMING: bool = False +### Trigger debug logging +DEBUG_TRIGGER_LOGS = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends BASLER_DO_LOG_TIMING: bool = False From 382b8a88e9a4485c7900a66b155cd9856e0dcb07 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 12:05:13 +0200 Subject: [PATCH 043/194] Normalize WriteGear options and rename overrides Refactors recorder option handling to use `writer_options_overrides` instead of replacing all defaults, and updates the recording manager call site accordingly. Recorder creation now normalizes and validates key FFmpeg/WriteGear options (`-input_framerate`, `-crf`, `-vcodec`) to enforce numeric/string types before initializing `WriteGear`. Recording settings were also updated to emit native numeric values for framerate and CRF, aligning config output with the new normalization path. --- dlclivegui/services/video_recorder.py | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index b0a37c9f4..c225d1762 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -90,8 +90,9 @@ class VideoRecorder: convert_grayscale_to_rgb: Whether 2D grayscale frames should be expanded to 3-channel RGB before writing. Set to `False` to preserve mono frames when supported by the chosen writer/codec path. - writer_options: Optional dictionary of additional keyword arguments passed - to `WriteGear`. If provided, this overrides the default options. + writer_options_overrides: Optional WriteGear/FFmpeg option overrides. + These values are merged over the recorder defaults. + Known numeric options are validated and normalized before WriteGear is created. Attributes: is_running: Whether the writer thread is currently alive. @@ -116,7 +117,11 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, +<<<<<<< HEAD writer_options_overrides: WriteGearOptionOverrides | None = None, +======= + writer_options_overrides: dict[str, Any] | None = None, +>>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) ): # Config self._output = Path(output) @@ -127,7 +132,13 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) +<<<<<<< HEAD self._writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides else {} +======= + self._writer_options_overrides = ( + dict(writer_options_overrides) if writer_options_overrides is not None else None + ) +>>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None @@ -509,6 +520,29 @@ def _finalize_writer(self) -> None: except Exception: logger.exception("Failed to close WriteGear during finalisation") + @staticmethod + def _normalize_writer_options( + options: dict[str, Any], + ) -> dict[str, Any]: + normalized = dict(options) + + try: + normalized["-input_framerate"] = float(normalized["-input_framerate"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-input_framerate' must be numeric.") from exc + + try: + normalized["-crf"] = int(normalized["-crf"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("WriteGear option '-crf' must be an integer.") from exc + + codec = str(normalized.get("-vcodec") or "").strip() + if not codec: + raise ValueError("WriteGear option '-vcodec' must be a non-empty string.") + + normalized["-vcodec"] = codec + return normalized + def _compute_write_fps_locked(self) -> float: if len(self._written_times) < 2: return 0.0 From b18acf7fab579e34643001f64c20762e8f8fe7a4 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 11:22:42 +0200 Subject: [PATCH 044/194] Align tests with writer options override rename Update test fixtures and GUI recorder-manager assertions to use `writer_options_overrides` instead of `writer_options`. This keeps the fake recorder interface consistent with the current recorder API and ensures writegear option checks target the renamed field. --- tests/conftest.py | 4 ++-- tests/gui/test_rec_manager.py | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 49cd1c66e..dcc9c7930 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -358,7 +358,7 @@ def __init__( crf=23, buffer_size=240, convert_grayscale_to_rgb=True, - writer_options=None, + writer_options_overrides=None, **kwargs, ): self.output = Path(output) @@ -368,7 +368,7 @@ def __init__( self.crf = crf self.buffer_size = buffer_size self.convert_grayscale_to_rgb = convert_grayscale_to_rgb - self.writer_options = dict(writer_options) if writer_options is not None else None + self.writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides is not None else None self.extra_kwargs = dict(kwargs) self.started = False diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index f97c43a57..635eb9488 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -417,8 +417,9 @@ def test_start_all_passes_writegear_options( cam_id = get_camera_id(cam) rec = mgr.recorders[cam_id] - assert rec.writer_options is not None - assert rec.writer_options["-vcodec"] == "libx264" - assert rec.writer_options["-crf"] == "23" - assert rec.writer_options["-preset"] == "ultrafast" - assert rec.writer_options["-tune"] == "zerolatency" + 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" From 97086dee40840e89300efb1c91aa0fe0964c3e57 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 18:24:16 +0200 Subject: [PATCH 045/194] Fix merge conflict --- dlclivegui/services/video_recorder.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index c225d1762..3086bfa93 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -117,11 +117,7 @@ def __init__( crf: int = 23, buffer_size: int = 240, convert_grayscale_to_rgb: bool = True, -<<<<<<< HEAD writer_options_overrides: WriteGearOptionOverrides | None = None, -======= - writer_options_overrides: dict[str, Any] | None = None, ->>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) ): # Config self._output = Path(output) @@ -132,13 +128,7 @@ def __init__( self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) self._convert_grayscale_to_rgb = bool(convert_grayscale_to_rgb) -<<<<<<< HEAD self._writer_options_overrides = dict(writer_options_overrides) if writer_options_overrides else {} -======= - self._writer_options_overrides = ( - dict(writer_options_overrides) if writer_options_overrides is not None else None - ) ->>>>>>> 45da9a1 (Normalize WriteGear options and rename overrides) # Worker state self._queue: queue.Queue[Any] | None = None self._writer_thread: threading.Thread | None = None From eba46f6c33efea2cffd3460506f5d7ba3a143745 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:31:55 +0200 Subject: [PATCH 046/194] Guard recording start when no cameras configured Prevent starting recording from triggering preview when no active cameras are configured. `start_recording` now checks for active cameras first, shows a clear error message directing users to "Configure Cameras...", and exits early instead of proceeding with pending recording setup. --- dlclivegui/gui/main_window.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index f96733dde..ef83fe0e5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1966,6 +1966,11 @@ def _start_recording(self) -> None: """Start recording from all active cameras.""" # Auto-start preview if not running if not self.multi_camera_controller.is_running(): + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + self._show_error("No cameras configured. Use 'Configure Cameras...' to add cameras.") + return + self._pending_recording_after_preview = True self._start_preview() # Wait a moment for cameras to initialize before recording From 1ff2629d6d9e5809b8197508f03548412797d4fd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:32:24 +0200 Subject: [PATCH 047/194] Update tests for writegear option builder Refactors recording option tests to target `build_writegear_options` directly instead of `RecordingSettings.writegear_options`, and aligns expectations with the newer option types/shape (numeric fps/crf values and explicit fast-encoding overrides). Also updates recording manager assertions to validate recorder codec/crf fields and narrowed writer overrides for x264 fast encoding. --- tests/gui/test_rec_manager.py | 19 +++++--- tests/test_config.py | 87 ++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 635eb9488..6e4e9f0ef 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -411,15 +411,20 @@ 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") + 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] - 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" + assert rec.codec == "libx264" + assert rec.crf == 23 + assert rec.writer_options_overrides == { + "-preset": "ultrafast", + "-tune": "zerolatency", + } diff --git a/tests/test_config.py b/tests/test_config.py index 7c2f64bf0..1f89fbf5e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,14 @@ import pytest from dlclivegui.config import ( + DEFAULT_RECORDING_FPS, ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings, RecordingSettings, ) +from dlclivegui.services.video_recorder import build_writegear_options @pytest.mark.unit @@ -76,43 +78,82 @@ def test_trigger_source_defaults_to_auto(): assert trigger.source == "auto" -def test_recording_settings_writegear_options_default(): - settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=False) +def test_build_writegear_options_default(): + settings = RecordingSettings( + codec="libx264", + crf=23, + fast_encoding=False, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "100.000000" - assert opts["-vcodec"] == "libx264" - assert opts["-crf"] == "23" - assert "-preset" not in opts - assert "-tune" not in opts + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + } -def test_recording_settings_writegear_options_fast_encoding_x264(): - settings = RecordingSettings(codec="libx264", crf=23, fast_encoding=True) +def test_build_writegear_options_fast_encoding_x264(): + settings = RecordingSettings( + codec="libx264", + crf=23, + fast_encoding=True, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "100.000000" - assert opts["-vcodec"] == "libx264" - assert opts["-crf"] == "23" - assert opts["-preset"] == "ultrafast" - assert opts["-tune"] == "zerolatency" + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + "-preset": "ultrafast", + "-tune": "zerolatency", + } -def test_recording_settings_writegear_options_fast_encoding_nvenc_no_x264_options(): - settings = RecordingSettings(codec="h264_nvenc", crf=23, fast_encoding=True) +def test_build_writegear_options_fast_encoding_nvenc(): + settings = RecordingSettings( + codec="h264_nvenc", + crf=23, + fast_encoding=True, + ) - opts = settings.writegear_options(100.0) + opts = build_writegear_options( + frame_rate=100.0, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) + assert opts["-input_framerate"] == 100.0 assert opts["-vcodec"] == "h264_nvenc" + assert opts["-crf"] == 23 assert "-preset" not in opts assert "-tune" not in opts -def test_recording_settings_writegear_options_invalid_fps_falls_back_to_30(): - settings = RecordingSettings(codec="libx264", crf=23) +def test_build_writegear_options_invalid_fps_uses_default(): + settings = RecordingSettings( + codec="libx264", + crf=23, + ) - opts = settings.writegear_options(None) + opts = build_writegear_options( + frame_rate=None, + codec=settings.codec, + crf=settings.crf, + overrides=settings.writegear_overrides(), + ) - assert opts["-input_framerate"] == "30.000000" + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS From 609d7ea9dcb45f9b8effb34441196b353cb9c3be Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 19 Aug 2026 10:58:14 +0200 Subject: [PATCH 048/194] Add Basler acquisition stats debug logging Adds rate-limited debug logging of Basler acquisition and stream-grabber buffer statistics to help diagnose frame retrieval issues. The backend now logs these stats after successful frame reads and after frame read errors, with new helper methods to collect camera and stream-grabber counters. --- dlclivegui/cameras/backends/basler_backend.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 3edde055a..1a4933c74 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -51,6 +51,7 @@ def __init__(self, settings): ) self._camera_pixel_format: str | None = None self._logged_first_frame: bool = False + self._debug_last_acquis_stats_log: float = 0.0 # Optional fast-start hint for probe workers # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) @@ -723,6 +724,8 @@ def read(self) -> tuple[np.ndarray, float]: grab_result.Release() grab_result = None + self._debug_log_acquisition_stats(context="after frame read") + if self._actual_width is None or self._actual_height is None: h, w = frame.shape[:2] self._actual_width = int(w) @@ -747,6 +750,8 @@ def read(self) -> tuple[np.ndarray, float]: self._timing.note_error() self._timing.maybe_log() + self._debug_log_acquisition_stats(context=f"after frame read error: {type(exc).__name__}") + raise RuntimeError("Failed to retrieve image from Basler camera.") from exc def close(self) -> None: @@ -958,6 +963,53 @@ def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> boo LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) return False + def _debug_log_acquisition_stats( + self, + *, + context: str, + force: bool = False, + ) -> None: + if not BASLER_DO_LOG_TIMING or not LOG.isEnabledFor(logging.DEBUG): + return + + now = time.monotonic() + if not force and now - self._debug_last_acquisition_stats_log < 1.0: + return + + self._debug_last_acquisition_stats_log = now + stats = self._debug_read_acquisition_stats() + if stats: + LOG.debug( + "[Basler] acquisition stats context=%s values=%s", + context, + stats, + ) + + def _debug_read_acquisition_stats(self) -> dict[str, int]: + cam = self._camera + if cam is None or not cam.IsGrabbing(): + return {} + + stats: dict[str, int] = {} + for name in ("NumReadyBuffers", "NumQueuedBuffers", "MaxNumBuffer"): + try: + stats[name] = int(getattr(cam, name).GetValue()) + except Exception: + pass + + try: + sg = cam.StreamGrabber + for name in ( + "Statistic_Buffer_Underrun_Count", + "Statistic_Missed_Frame_Count", + "Statistic_Failed_Buffer_Count", + ): + stats[name] = int(getattr(sg, name).GetValue()) + except Exception: + pass + + return stats + def _debug_trigger_nodes(self, *, context: str = "") -> None: if not LOG.isEnabledFor(logging.DEBUG) or not DEBUG_TRIGGER_LOGS: return From 6a9ae409283bc9fcb3b36831951264957a664a58 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:18:53 +0200 Subject: [PATCH 049/194] Add frame timestamp metadata helper class Introduce `FrameTimestampMetadata` in `dlclivegui/utils/timestamps.py` to standardize optional backend/hardware timestamp data for captured frames. The dataclass captures source/backend fields, converted and raw timestamp values, conversion metadata, and backend extras, and adds helper methods to serialize source-level data, per-frame values, full dictionaries, and the configured default reported timestamp. --- dlclivegui/utils/timestamps.py | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 dlclivegui/utils/timestamps.py diff --git a/dlclivegui/utils/timestamps.py b/dlclivegui/utils/timestamps.py new file mode 100644 index 000000000..dea14ed2d --- /dev/null +++ b/dlclivegui/utils/timestamps.py @@ -0,0 +1,82 @@ +# dlclivegui/utils/timestamps.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class FrameTimestampMetadata: + """Optional backend-provided timestamp metadata for a captured frame. + + This supplements, but does not replace, the software timestamp. + + Notes: + - `seconds` is in the backend/hardware timebase. + - `wall_clock_time` should only be set if the backend can confidently + provide Unix/wall-clock seconds. + - `raw_value` preserves the original device-specific timestamp. + """ + + source: str + backend: str + + # Which value should downstream consumers use by default, if any. + # Expected values: "seconds", "wall_clock_time", or "raw_value". + default_reported: str | None = None + + # Device/hardware timebase value, if convertible to seconds + seconds: float | None = None + + # True Unix/wall-clock timestamp, if available + wall_clock_time: float | None = None + + # Raw backend value, e.g. device clock ticks + raw_value: int | float | str | None = None + raw_unit: str | None = None + + # Conversion metadata. + tick_frequency_hz: float | None = None + timebase: str | None = None + + # e.g. "camera_clock", "ptp_camera_clock", "hardware_wall_clock", + # "frame_counter", "unknown" + kind: str = "unknown" + + # Backend-specific extras. + extra: dict[str, Any] | None = None + + def to_source_dict(self) -> dict[str, Any]: + """Return metadata that should be written once per recording stream.""" + return { + "source": self.source, + "backend": self.backend, + "default_reported": self.default_reported, + "raw_unit": self.raw_unit, + "tick_frequency_hz": self.tick_frequency_hz, + "timebase": self.timebase, + "kind": self.kind, + "extra": self.extra or {}, + } + + def to_frame_dict(self) -> dict[str, Any]: + """Return defined per-frame timestamp values only.""" + ts = {} + for k in ["seconds", "wall_clock_time", "raw_value"]: + v = getattr(self, k) + if v is not None: + ts[k] = v + return ts + + def to_dict(self) -> dict[str, Any]: + """Return full representation, useful for logging/debugging.""" + return { + **self.to_source_dict(), + **self.to_frame_dict(), + } + + def get_default_reported(self) -> int | float | str | None: + """Return the value selected by `default_reported`, if configured.""" + if not self.default_reported: + return None + return self.to_frame_dict().get(self.default_reported) From df226811813d00ec4e1e62b87d25e961d74837e9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:22 +0200 Subject: [PATCH 050/194] Propagate hardware timestamp metadata Adds end-to-end support for optional per-frame hardware timestamp metadata. Camera backends now return a `CapturedFrame` object (while preserving tuple unpacking), multi-camera signals and recording paths carry timestamp metadata, and `VideoRecorder` persists richer timestamp records. The timestamp JSON output is upgraded to schema v2 with backward-compatible software `timestamps` plus source metadata and per-frame hardware timestamp fields. --- dlclivegui/cameras/base.py | 24 +++++- dlclivegui/gui/main_window.py | 6 +- dlclivegui/gui/recording_manager.py | 10 ++- .../services/multi_camera_controller.py | 19 +++-- dlclivegui/services/video_recorder.py | 80 +++++++++++++++---- 5 files changed, 112 insertions(+), 27 deletions(-) diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index f86f3d14b..9217ad8e1 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -3,6 +3,7 @@ import logging from abc import ABC, abstractmethod +from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar @@ -11,6 +12,7 @@ from ..config import CameraSettings if TYPE_CHECKING: + from ..utils.timestamps import FrameTimestampMetadata from .factory import DetectedCamera _BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {} @@ -72,9 +74,24 @@ class SupportLevel(str, Enum): "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, "hardware_trigger": SupportLevel.UNSUPPORTED, + "hardware_frame_timestamps": SupportLevel.UNSUPPORTED, } +@dataclass(frozen=True) +class CapturedFrame: + """Frame plus software timestamp and optional backend timestamp metadata.""" + + frame: np.ndarray | None + software_timestamp: float + timestamp_metadata: FrameTimestampMetadata | None = None + + def __iter__(self): + """Backwards-compatible unpacking: frame, software_timestamp = backend.read()""" + yield self.frame + yield self.software_timestamp + + class CameraBackend(ABC): """Abstract base class for camera backends.""" @@ -107,6 +124,11 @@ def actual_pixel_format(self) -> str | None: def recommended_preserve_mono(self) -> bool | None: return None + @property + def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: + """Return backend-provided timestamp metadata for the last read frame.""" + return None + @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" @@ -171,7 +193,7 @@ def open(self) -> None: raise NotImplementedError @abstractmethod - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame and return the image with a timestamp.""" raise NotImplementedError diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index ef83fe0e5..f06dc8b0a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1403,7 +1403,9 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + def _on_recording_frame_ready( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: """Handle full-rate per-camera frames for recording only. Intentionally lean: @@ -1419,7 +1421,7 @@ def _on_recording_frame_ready(self, camera_id: str, frame: np.ndarray, timestamp if self.record_with_overlays_checkbox.isChecked(): frame = self._render_overlays_for_recording(camera_id, frame) - self._rec_manager.write_frame(camera_id, frame, timestamp) + self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 95ed44299..c3584dd95 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -192,12 +192,18 @@ def stop_all(self) -> None: self._session_dir = None self._run_dir = None - def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None: + def write_frame( + self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> None: rec = self._recorders.get(cam_id) if not rec or not rec.is_running: return try: - rec.write(frame, timestamp=timestamp if timestamp is not None else time.time()) + rec.write( + frame, + timestamp=timestamp if timestamp is not None else time.time(), + timestamp_metadata=timestamp_metadata, + ) except Exception as exc: log.warning("Failed to write frame for %s: %s", cam_id, exc) try: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 027b79d8c..f5b011150 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -48,7 +48,7 @@ class MultiFrameData: class SingleCameraWorker(QObject): """Worker for a single camera in multi-camera mode.""" - frame_captured = Signal(str, object, float) # camera_id, frame, timestamp + 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 @@ -132,7 +132,10 @@ def run(self) -> None: while not self._stop_event.is_set(): try: with self._timing.measure("Single.read"): - frame, timestamp = self._backend.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: @@ -146,7 +149,7 @@ def run(self) -> None: consecutive_errors = 0 with self._timing.measure("Single.emit.frame_captured"): - self.frame_captured.emit(self._camera_id, frame, timestamp) + self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) self._timing.note_frame() self._timing.maybe_log() @@ -297,7 +300,9 @@ class MultiCameraController(QObject): # Signals frame_ready = Signal(object) # MultiFrameData (full cam FPS; inference only) - recording_frame_ready = Signal(str, object, float) # camera_id, frame, timestamp (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 @@ -617,7 +622,9 @@ def stop(self, wait: bool = True) -> None: self._maybe_finalize_stop() - def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: + def _on_frame_captured( + self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None + ) -> None: """Handle a frame from one camera.""" timing = self._timing_for_camera(camera_id) frame_data: MultiFrameData | None = None @@ -636,7 +643,7 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float if self._recording_frame_emission_enabled: with timing.measure("Multi.emit.recording_frame_ready"): - self.recording_frame_ready.emit(camera_id, frame, timestamp) + self.recording_frame_ready.emit(camera_id, frame, timestamp, timestamp_metadata) with self._frame_lock: with timing.measure("Multi.store_latest"): diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 3086bfa93..8588086d0 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -124,6 +124,7 @@ def __init__( self._writer: Any | None = None self._frame_size = frame_size self._frame_rate = frame_rate + self._hardware_timestamp_source: dict[str, Any] | None = None self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) @@ -145,7 +146,7 @@ def __init__( self._written_times: deque[float] = deque(maxlen=600) self._encode_error: Exception | None = None self._last_log_time = 0.0 - self._frame_timestamps: list[float] = [] + self._frame_timestamps: list[dict[str, Any]] = [] # Timing self._process_timing = WorkerTimingStats( f"RecorderProcess[{self._output.name}]", logger=logger, log_interval=1.0, enabled=REC_DO_LOG_TIMING @@ -233,6 +234,7 @@ def start(self) -> None: self._last_latency = 0.0 self._written_times.clear() self._frame_timestamps.clear() + self._hardware_timestamp_source = None self._encode_error = None self._stop_event.clear() self._writer_thread = threading.Thread( @@ -246,7 +248,9 @@ def configure_stream(self, frame_size: tuple[int, int], frame_rate: float | None self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: + def write( + self, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> bool: error = self._current_error() if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error @@ -311,7 +315,7 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: try: with self._process_timing.measure("Recorder.queue_put"): - q.put((frame, timestamp), block=False) + q.put((frame, timestamp, timestamp_metadata), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 @@ -454,7 +458,7 @@ def _writer_loop(self) -> None: if item is _SENTINEL: break else: - frame, timestamp = item + frame, timestamp, timestamp_metadata = item start = time.perf_counter() try: @@ -465,6 +469,30 @@ def _writer_loop(self) -> None: with self._writer_timing.measure("Recorder.writer_write"): writer.write(frame) + record: dict[str, Any] = { + "frame_index": self._frames_written, + "software_timestamp": float(timestamp), + } + + if timestamp_metadata is not None: + if ( + hasattr(timestamp_metadata, "to_source_dict") + and self._hardware_timestamp_source is None + ): + self._hardware_timestamp_source = timestamp_metadata.to_source_dict() + + if hasattr(timestamp_metadata, "to_frame_dict"): + record["hardware_timestamp"] = timestamp_metadata.to_frame_dict() + default_value = timestamp_metadata.get_default_reported() + if default_value is not None: + record["hardware_timestamp_default"] = default_value + elif isinstance(timestamp_metadata, dict): + record["hardware_timestamp"] = dict(timestamp_metadata) + else: + record["hardware_timestamp"] = repr(timestamp_metadata) + + self._frame_timestamps.append(record) + except Exception as exc: with self._stats_lock: self._encode_error = exc @@ -481,7 +509,6 @@ def _writer_loop(self) -> None: self._total_latency += elapsed self._last_latency = elapsed self._written_times.append(now) - self._frame_timestamps.append(timestamp) if now - self._last_log_time >= 1.0: self._compute_write_fps_locked() self._last_log_time = now @@ -551,27 +578,48 @@ def _save_timestamps(self) -> None: logger.info("No timestamps to save") return - # Create timestamps file path timestamp_file = self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") try: with self._stats_lock: - timestamps = self._frame_timestamps.copy() + frame_timestamps = self._frame_timestamps.copy() + hardware_timestamp_source = ( + dict(self._hardware_timestamp_source) if self._hardware_timestamp_source is not None else None + ) + + software_timestamps = [ + float(rec["software_timestamp"]) for rec in frame_timestamps if "software_timestamp" in rec + ] - # Prepare metadata data = { + "schema_version": 2, "video_file": str(self._output.name), - "num_frames": len(timestamps), - "timestamps": timestamps, - "start_time": timestamps[0] if timestamps else None, - "end_time": timestamps[-1] if timestamps else None, - "duration_seconds": timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0, + "num_frames": len(frame_timestamps), + # Backward-compatible host/software timestamp list. + "timestamps": software_timestamps, + # New descriptive schema. + "timestamp_sources": { + "software_timestamp": { + "source": "host_time.time", + "backend": "host", + "kind": "software_wall_clock", + "timebase": "Unix epoch", + "unit": "seconds", + "description": "Host-side software timestamp captured during acquisition.", + }, + "hardware_timestamp": hardware_timestamp_source, + }, + "hardware_frame_timestamps": frame_timestamps, + "start_time": software_timestamps[0] if software_timestamps else None, + "end_time": software_timestamps[-1] if software_timestamps else None, + "duration_seconds": ( + software_timestamps[-1] - software_timestamps[0] if len(software_timestamps) > 1 else 0.0 + ), } - # Write to JSON with open(timestamp_file, "w") as f: json.dump(data, f, indent=2) - logger.info(f"Saved {len(timestamps)} frame timestamps to {timestamp_file}") + logger.info("Saved %d frame timestamps to %s", len(frame_timestamps), timestamp_file) except Exception as exc: - logger.exception(f"Failed to save timestamps to {timestamp_file}: {exc}") + logger.exception("Failed to save timestamps to %s: %s", timestamp_file, exc) From 7d3d9f18d6a31e526a6ff8bb0eacc752aad58a28 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:33 +0200 Subject: [PATCH 051/194] Update aravis_backend.py --- dlclivegui/cameras/backends/aravis_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py index 60059c464..d1dd24ca1 100644 --- a/dlclivegui/cameras/backends/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -11,7 +11,7 @@ import numpy as np from ...config import CameraSettings -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera LOG = logging.getLogger(__name__) @@ -372,7 +372,7 @@ def open(self) -> None: self._camera.start_acquisition() - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: """Read a frame from the camera.""" if self._camera is None or self._stream is None: raise RuntimeError("Aravis camera not initialized") @@ -430,7 +430,7 @@ def read(self) -> tuple[np.ndarray, float]: # Always push buffer back to stream self._stream.push_buffer(buffer) - return frame, timestamp + return CapturedFrame(frame=frame, software_timestamp=timestamp, timestamp_metadata=None) def stop(self) -> None: """Stop camera acquisition.""" From 74c605d994c37233ccef14abe4ea969a46dfe666 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:40 +0200 Subject: [PATCH 052/194] Update basler_backend.py --- dlclivegui/cameras/backends/basler_backend.py | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 1a4933c74..427f1d239 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -11,7 +11,8 @@ from ...config import BASLER_DO_LOG_TIMING, DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ...utils.stats import WorkerTimingStats -from ..base import CameraBackend, SupportLevel, register_backend +from ...utils.timestamps import FrameTimestampMetadata +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend LOG = logging.getLogger(__name__) @@ -57,6 +58,8 @@ def __init__(self, settings): # (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture) self._fast_start: bool = bool(self.ns.get("fast_start", False)) self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings + self._timestamp_tick_frequency_hz: float | None = None + self._last_frame_timestamp_metadata: FrameTimestampMetadata | None = None # ---- Trigger settings ---- raw_trigger = self.ns.get("trigger", self._props.get("trigger")) @@ -156,6 +159,10 @@ def actual_output_format(self) -> str | None: return None return "Mono8" if self._should_output_mono() else "BGR8" + @property + def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: + return self._last_frame_timestamp_metadata + @property def recommended_preserve_mono(self) -> bool | None: if not self._camera_pixel_format: @@ -179,6 +186,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, "preserve_mono": SupportLevel.SUPPORTED, + "hardware_frame_timestamps": SupportLevel.SUPPORTED, } ) return caps @@ -482,6 +490,7 @@ def _configure_frame_rate(self) -> None: "BslResultingAcquisitionFrameRate", "ExposureAuto", "ExposureTime", + "ExposureTimeAbs", "Width", "Height", "PixelFormat", @@ -551,7 +560,10 @@ def open(self) -> None: try: if hasattr(self._camera, "ExposureAuto"): self._camera.ExposureAuto.SetValue("Off") - self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + if hasattr(self._camera, "ExposureTime"): + self._camera.ExposureTime.SetValue(float(self.settings.exposure)) + if hasattr(self._camera, "ExposureTimeAbs"): + self._camera.ExposureTimeAbs.SetValue(float(self.settings.exposure)) LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) except Exception as exc: LOG.warning("[Basler] Failed to set exposure: %s", exc) @@ -662,9 +674,16 @@ def open(self) -> None: getattr(self.settings, "gain", None), ) - # ---------------------------- + # Get hardware tick frequency for timestamp conversion + try: + node = getattr(self._camera, "GevTimestampTickFrequency", None) + if node is not None and node.IsReadable(): + self._timestamp_tick_frequency_hz = float(node.GetValue()) + LOG.info("[Basler] timestamp tick frequency: %.3f Hz", self._timestamp_tick_frequency_hz) + except Exception: + LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True) + # Persist stable identity into namespace - # ---------------------------- try: serial = device.GetSerialNumber() if serial: @@ -677,7 +696,29 @@ def open(self) -> None: except Exception: pass - def read(self) -> tuple[np.ndarray, float]: + def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None: + try: + ticks = int(grab_result.GetTimeStamp()) + except Exception: + return None + + freq = getattr(self, "_timestamp_tick_frequency_hz", None) + seconds = ticks / freq if freq and freq > 0 else None + + return FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds" if seconds is not None else "raw_value", + seconds=seconds, + wall_clock_time=None, + raw_value=ticks, + raw_unit="ticks", + tick_frequency_hz=freq, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + def read(self) -> CapturedFrame: if self._camera is None: raise RuntimeError("Basler camera not opened") if self._converter is None: @@ -706,6 +747,11 @@ def read(self) -> tuple[np.ndarray, float]: with self._timing.measure("Basler.get_array"): frame = image.GetArray() + with self._timing.measure("Basler.timestamp"): + software_timestamp = time.time() + timestamp_metadata = self._make_timestamp_metadata(grab_result) + self._last_frame_timestamp_metadata = timestamp_metadata + if not self._logged_first_frame: self._logged_first_frame = True LOG.info( @@ -734,7 +780,11 @@ def read(self) -> tuple[np.ndarray, float]: self._timing.note_frame() self._timing.maybe_log() - return frame, time.time() + return CapturedFrame( + frame=frame, + software_timestamp=software_timestamp, + timestamp_metadata=timestamp_metadata, + ) except Exception as exc: if grab_result is not None: From 03e459b6b7a732d47d060482bf7cda33925a9360 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:43 +0200 Subject: [PATCH 053/194] Update gentl_backend.py --- dlclivegui/cameras/backends/gentl_backend.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ea7538c72..e43f4c809 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -13,7 +13,7 @@ import numpy as np from ...config import DEBUG_TRIGGER_LOGS, CameraTriggerSettings -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -624,7 +624,7 @@ def _output_format_for_frame(frame: np.ndarray) -> str: return f"{channels}ch-{frame.dtype}" return str(frame.dtype) - def read(self) -> tuple[np.ndarray, float]: + def read(self) -> CapturedFrame: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -665,7 +665,11 @@ def read(self) -> tuple[np.ndarray, float]: if self._actual_output_format is None: self._actual_output_format = self._output_format_for_frame(frame) - return frame, timestamp + return CapturedFrame( + frame=frame, + software_timestamp=timestamp, + timestamp_metadata=None, + ) def stop(self) -> None: if self._acquirer is not None: From 6e7b18bbec83a5ce49630480eb40240468385e02 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:19:51 +0200 Subject: [PATCH 054/194] Update opencv_backend.py --- dlclivegui/cameras/backends/opencv_backend.py | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 869dde448..1201749b7 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -10,10 +10,9 @@ from typing import TYPE_CHECKING, Literal import cv2 -import numpy as np from pydantic import BaseModel, Field, model_validator -from ..base import CameraBackend, SupportLevel, register_backend +from ..base import CameraBackend, CapturedFrame, SupportLevel, register_backend from ..factory import DetectedCamera from .utils.opencv_discovery import ( ModeRequest, @@ -199,21 +198,45 @@ def open(self) -> None: self._configure_capture() - def read(self) -> tuple[np.ndarray | None, float]: - """Robust frame read: return (None, ts) on transient failures; never raises.""" + def read(self) -> CapturedFrame: + """Robust frame read: return CapturedFrame(frame=None, ...) on transient failures; never raises.""" if self._capture is None: logger.warning("OpenCVCameraBackend.read() called before open()") - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + try: if not self._capture.grab(): - return None, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + success, frame = self._capture.retrieve() if not success or frame is None or frame.size == 0: - return None, time.time() - return frame, time.time() + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + + return CapturedFrame( + frame=frame, + software_timestamp=time.time(), + timestamp_metadata=None, + ) + except Exception as exc: - logger.debug(f"OpenCV read transient error: {exc}") - return None, time.time() + logger.debug("OpenCV read transient error: %s", exc) + return CapturedFrame( + frame=None, + software_timestamp=time.time(), + timestamp_metadata=None, + ) def close(self) -> None: self._release_capture() From 29944337946d917c72ecfb16986cf7b6de92375f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:20:51 +0200 Subject: [PATCH 055/194] Update tests for CapturedFrame read API Refactors backend tests to use the new `read()` return payload (`CapturedFrame`) instead of tuple unpacking, including frame/timestamp access updates and minor unused-variable cleanup. Test fixtures were also aligned with timestamp metadata support by returning `CapturedFrame` in the fake backend and extending fake recorder/frame callback signatures to accept `timestamp_metadata`. --- tests/cameras/backends/test_aravis_backend.py | 28 +++++++++---------- tests/cameras/backends/test_basler_backend.py | 11 +++++--- tests/cameras/backends/test_gentl_backend.py | 12 ++++---- tests/cameras/backends/test_gentl_trigger.py | 2 +- tests/cameras/backends/test_opencv_backend.py | 9 ++++-- tests/conftest.py | 9 +++--- tests/services/test_multicam_controller.py | 2 +- 7 files changed, 40 insertions(+), 33 deletions(-) diff --git a/tests/cameras/backends/test_aravis_backend.py b/tests/cameras/backends/test_aravis_backend.py index 797fd11cb..4f7ac55e8 100644 --- a/tests/cameras/backends/test_aravis_backend.py +++ b/tests/cameras/backends/test_aravis_backend.py @@ -243,7 +243,7 @@ def make_backend(settings, buffers): @pytest.mark.unit def test_device_name(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) assert be.device_name() == "FakeVendor FakeModel (12345)" @@ -253,9 +253,9 @@ def test_read_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, ts = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert frame.dtype == np.uint8 # Ensure grayscale expanded to 3 channels @@ -272,9 +272,9 @@ def test_read_rgb8_converts_to_bgr(): data = np.array([255, 0, 0, 0, 255, 0], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_RGB_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) # BGR conversion: red → [0,0,255], green → [0,255,0] assert (frame[0, 0] == np.array([0, 0, 255])).all() @@ -288,9 +288,9 @@ def test_read_bgr8_passthrough(): data = np.array([10, 20, 30, 40, 50, 60], dtype=np.uint8).tobytes() buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_BGR_8_PACKED) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 2, 3) assert (frame.flatten() == np.array([10, 20, 30, 40, 50, 60])).all() assert s.pushed >= 1 @@ -302,9 +302,9 @@ def test_read_mono16_scaling(): raw = np.array([0, 32768, 65535], dtype=np.uint16) buf = FakeAravis.Buffer(raw.tobytes(), w, h, FakeAravis.PIXEL_FORMAT_MONO_16) - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (1, 3, 3) # scaling: 0 → 0, max → 255, mid → ~128 @@ -320,9 +320,9 @@ def test_read_unknown_format_fallback_to_mono8(): data = (np.arange(w * h) % 256).astype(np.uint8).tobytes() # Unknown token buf = FakeAravis.Buffer(data, w, h, "SOME_UNKNOWN_FMT") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (h, w, 3) assert np.all(frame[..., 0] == frame[..., 1]) assert np.all(frame[..., 1] == frame[..., 2]) @@ -331,7 +331,7 @@ def test_read_unknown_format_fallback_to_mono8(): @pytest.mark.unit def test_read_timeout_raises(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) with pytest.raises(TimeoutError): be.read() @@ -341,7 +341,7 @@ def test_read_status_error_raises_and_pushes_back(): w, h = 1, 1 data = b"\x00" buf = FakeAravis.Buffer(data, w, h, FakeAravis.PIXEL_FORMAT_MONO_8, status="ERROR") - be, cam, s = make_backend(Settings(), [buf]) + be, _cam, s = make_backend(Settings(), [buf]) with pytest.raises(TimeoutError): be.read() @@ -350,7 +350,7 @@ def test_read_status_error_raises_and_pushes_back(): @pytest.mark.unit def test_close_is_idempotent(): - be, cam, s = make_backend(Settings(), []) + be, _cam, s = make_backend(Settings(), []) be.close() be.close() # should not raise diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 41511d133..f21cdc27b 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -21,7 +21,8 @@ def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, ba assert be._camera.IsGrabbing() assert be._converter is not None - frame, ts = be.read() + payload = be.read() + frame, ts = payload.frame, payload.software_timestamp assert isinstance(ts, float) assert isinstance(frame, np.ndarray) assert frame.shape == (10, 10, 3) @@ -257,7 +258,8 @@ def test_basler_default_trigger_is_off_and_free_runs( assert be._camera.TriggerMode.GetValue() == "Off" assert be.waits_for_hardware_trigger is False - frame, _ = be.read() + payload = be.read() + frame = payload.frame assert frame.shape == (10, 10, 3) be.close() @@ -356,7 +358,8 @@ def test_basler_follower_non_strict_invalid_source_disables_trigger( assert be._camera.TriggerMode.GetValue() == "Off" assert be.waits_for_hardware_trigger is False - frame, _ = be.read() + payload = be.read() + frame = payload.frame assert frame.shape == (10, 10, 3) be.close() @@ -430,7 +433,7 @@ def test_basler_software_trigger_requires_trigger_once_before_read( be.trigger_once() assert be._camera.software_trigger_calls == 1 - frame, _ = be.read() + frame = be.read().frame assert frame.shape == (10, 10, 3) be.close() diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index 3ffdab204..3cb7d9ea2 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -54,12 +54,12 @@ def test_open_starts_stream_and_read_returns_frame(patch_gentl_sdk, gentl_settin assert be._acquirer is not None # Strict model validated via behavior: read must succeed after normal open() - frame, ts = be.read() - assert isinstance(ts, float) - assert isinstance(frame, np.ndarray) - assert frame.size > 0 + captured = be.read() + assert isinstance(captured.software_timestamp, float) + assert isinstance(captured.frame, np.ndarray) + assert captured.frame.size > 0 # Backend converts to BGR; ensure 3-channel output - assert frame.ndim == 3 and frame.shape[2] == 3 + assert captured.frame.ndim == 3 and captured.frame.shape[2] == 3 be.close() assert be._harvester is None @@ -422,7 +422,7 @@ def test_pixel_format_unavailable_does_not_crash_open_and_streams(patch_gentl_sd be.open() # No fake-internal checks; just verify it can read - frame, _ = be.read() + frame = be.read().frame assert frame is not None and frame.size > 0 be.close() diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index 57339a103..b445f4ea8 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -289,7 +289,7 @@ def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( assert be._timeout == pytest.approx(expected_fetch_timeout) # Fake acquisition is started, so read should pass and record the capped timeout. - frame, _ = be.read() + frame = be.read().frame assert frame is not None assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 2f1557824..5fff09910 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -124,7 +124,8 @@ def test_read_returns_none_on_grab_failure(fake_capture_factory): cap.grab_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -135,7 +136,8 @@ def test_read_returns_none_on_retrieve_failure(fake_capture_factory): cap.retrieve_ok = False backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) @@ -150,7 +152,8 @@ def boom(): cap.grab = boom backend._capture = cap - frame, ts = backend.read() + payload = backend.read() + frame, ts = payload.frame, payload.software_timestamp assert frame is None assert isinstance(ts, float) diff --git a/tests/conftest.py b/tests/conftest.py index dcc9c7930..25c5567e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import ( CameraBackend, + CapturedFrame, SupportLevel, register_backend_direct, unregister_backend, @@ -86,7 +87,7 @@ def read(self): raise RuntimeError("not opened") self._counter += 1 frame = np.zeros(frame_shape, dtype=np.uint8) - return frame, float(timestamp_fn()) + return CapturedFrame(frame=frame, software_timestamp=float(timestamp_fn()), timestamp_metadata=None) _TestBackend.__name__ = f"TestBackend_{name}" return _TestBackend @@ -391,10 +392,10 @@ def start(self): def stop(self): self.stopped = True - def write(self, frame, timestamp=None): + def write(self, frame, timestamp=None, timestamp_metadata=None): if self.raise_on_write: raise RuntimeError("write failed") - self.write_calls.append((frame, timestamp)) + self.write_calls.append((frame, timestamp, timestamp_metadata)) return True def get_stats(self): @@ -418,7 +419,7 @@ def patch_video_recorder(monkeypatch): def recording_frame_spy(monkeypatch, window): captured = {} - def _fake_write_frame(cam_id, frame, timestamp=None): + def _fake_write_frame(cam_id, frame, timestamp=None, timestamp_metadata=None): captured[cam_id] = frame.copy() monkeypatch.setattr(window._rec_manager, "write_frame", _fake_write_frame) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 9abb34b8f..1b9b830d9 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -516,7 +516,7 @@ def test_recording_frame_ready_only_emits_when_enabled(qtbot, patch_factory): cam_id = get_camera_id(cam) seen: list[tuple[str, tuple, float]] = [] - def on_recording_frame(camera_id, frame, timestamp): + 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) From 66dad93a63efc107adf84af7088f43c29208ecb7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:38:52 +0200 Subject: [PATCH 056/194] Rename metadata key to frame_timestamps Updates the recording metadata schema in `video_recorder.py` by renaming `hardware_frame_timestamps` to `frame_timestamps`. This aligns timestamp data with a more general key name while preserving the same underlying values. --- dlclivegui/services/video_recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 8588086d0..e85819556 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -609,7 +609,7 @@ def _save_timestamps(self) -> None: }, "hardware_timestamp": hardware_timestamp_source, }, - "hardware_frame_timestamps": frame_timestamps, + "frame_timestamps": frame_timestamps, "start_time": software_timestamps[0] if software_timestamps else None, "end_time": software_timestamps[-1] if software_timestamps else None, "duration_seconds": ( From b573949dffa53e299024a61bea5ad770eddfaf46 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 14:39:09 +0200 Subject: [PATCH 057/194] Add timestamp metadata coverage across tests Expands test coverage for frame timestamp metadata end-to-end: Basler backend reads now validate hardware timestamp extraction, controller/recording manager tests assert metadata forwarding, and video recorder tests verify schema_version 2 sidecar output for both software-only and hardware-backed timestamps. Also adds focused unit tests for `FrameTimestampMetadata` source/frame field splitting and default-reported value behavior. --- tests/cameras/backends/conftest.py | 4 + tests/cameras/backends/test_basler_backend.py | 43 +++++++ tests/gui/test_rec_manager.py | 39 ++++++ tests/services/test_multicam_controller.py | 49 ++++++++ tests/services/test_video_recorder.py | 117 +++++++++++++++++- tests/utils/test_timestamps.py | 63 ++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/utils/test_timestamps.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 011aa72c1..4c09d26f0 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -526,6 +526,9 @@ def GrabSucceeded(self): def Release(self): self.released = True + def GetTimeStamp(self): + return 123456789 + class InstantCamera: def __init__(self, device): self._device = device @@ -550,6 +553,7 @@ def __init__(self, device): self.AcquisitionFrameRateEnable = FakePylon._Feature(False) self.AcquisitionFrameRate = FakePylon._Feature(30.0) + self.GevTimestampTickFrequency = FakePylon._Feature(1_000_000_000.0) self.MaxNumBuffer = FakePylon._Feature(10) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index f21cdc27b..e17a305fd 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -3,6 +3,9 @@ import numpy as np import pytest +from dlclivegui.cameras.base import CapturedFrame +from dlclivegui.utils.timestamps import FrameTimestampMetadata + # --------------------------------------------------------------------- # Core lifecycle # --------------------------------------------------------------------- @@ -509,3 +512,43 @@ def raise_timeout(*_args, **_kwargs): finally: backend.close() +class TestBaslerFrameTimestamps: + @pytest.mark.unit + def test_read_returns_captured_frame_with_hardware_timestamp_metadata( + self, + patch_basler_sdk, + basler_settings_factory, + ): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + be.open() + + captured = be.read() + + assert isinstance(captured, CapturedFrame) + assert captured.frame is not None + assert isinstance(captured.software_timestamp, float) + + meta = captured.timestamp_metadata + assert isinstance(meta, FrameTimestampMetadata) + + assert meta.backend == "basler" + assert meta.source == "grab_result.GetTimeStamp" + assert meta.kind == "camera_clock" + assert meta.raw_unit == "ticks" + assert meta.raw_value == 123456789 + assert meta.tick_frequency_hz == pytest.approx(1_000_000_000.0) + assert meta.seconds == pytest.approx(0.123456789) + assert meta.default_reported == "seconds" + + source_dict = meta.to_source_dict() + assert source_dict["backend"] == "basler" + assert source_dict["source"] == "grab_result.GetTimeStamp" + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + + be.close() diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 6e4e9f0ef..5ba5e2dca 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -7,6 +7,7 @@ from dlclivegui.gui.recording_manager import RecordingManager from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id from dlclivegui.utils.stats import RecorderStats +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.fixture @@ -428,3 +429,41 @@ def test_start_all_passes_writegear_options( "-preset": "ultrafast", "-tune": "zerolatency", } + + +class TestRecordingManagerTimestampMetadata: + @pytest.mark.unit + def test_write_frame_passes_timestamp_metadata( + self, + 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] + + 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 diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 1b9b830d9..783b02240 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -1,4 +1,5 @@ # tests/services/test_multicam_controller.py +import numpy as np import pytest from dlclivegui.cameras.factory import CameraFactory @@ -12,6 +13,7 @@ get_camera_id, get_display_id, ) +from dlclivegui.utils.timestamps import FrameTimestampMetadata @pytest.mark.unit @@ -547,3 +549,50 @@ def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None): finally: with qtbot.waitSignal(mc.all_stopped, timeout=2000): 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 + + 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" + + 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", + ) + + seen = [] + + def on_recording_frame(camera_id, emitted_frame, timestamp, timestamp_metadata): + seen.append((camera_id, emitted_frame, timestamp, timestamp_metadata)) + + mc.recording_frame_ready.connect(on_recording_frame) + + mc._on_frame_captured(cam_id, frame, 123.0, meta) + + assert len(seen) == 1 + + camera_id, emitted_frame, timestamp, timestamp_metadata = seen[0] + assert camera_id == cam_id + assert emitted_frame is frame + assert timestamp == 123.0 + assert timestamp_metadata is meta diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index efde6e2b9..ff09c1e93 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -9,6 +9,7 @@ import pytest import dlclivegui.services.video_recorder as vr_mod +from dlclivegui.utils.timestamps import FrameTimestampMetadata # ---------------------------- # Helpers @@ -228,10 +229,14 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f data = json.loads(ts_path.read_text()) assert data["video_file"] == output_path.name assert data["num_frames"] == 2 - assert data["timestamps"] == [10.0, 12.0] assert data["start_time"] == 10.0 assert data["end_time"] == 12.0 assert data["duration_seconds"] == 2.0 + assert data["schema_version"] == 2 + assert data["timestamps"] == [10.0, 12.0] + assert data["timestamp_sources"]["hardware_timestamp"] is None + assert data["frame_timestamps"][0]["software_timestamp"] == 10.0 + assert data["frame_timestamps"][1]["software_timestamp"] == 12.0 def test_encoder_write_error_sets_encode_error_and_future_writes_raise(patch_writegear, output_path, rgb_frame): @@ -418,3 +423,113 @@ def close(self): rec.stop() assert written[0].shape == (10, 20, 3) + + +class TestVideoRecorderTimestampSidecar: + def test_stop_writes_software_only_timestamp_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + rec.write(rgb_frame, timestamp=10.0) + rec.write(rgb_frame, timestamp=12.0) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 2) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 2 + + # Backward-compatible list. + assert data["timestamps"] == [10.0, 12.0] + + assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock" + assert data["timestamp_sources"]["hardware_timestamp"] is None + + assert data["frame_timestamps"] == [ + { + "frame_index": 0, + "software_timestamp": 10.0, + }, + { + "frame_index": 1, + "software_timestamp": 12.0, + }, + ] + + def test_stop_writes_hardware_timestamp_metadata_sidecar_json( + self, + patch_writegear, + output_path, + rgb_frame, + ): + rec = vr_mod.VideoRecorder(output_path, buffer_size=10) + rec.start() + + 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, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + rec.write(rgb_frame, timestamp=10.0, timestamp_metadata=meta) + + wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 1) + rec.stop() + + ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json") + assert ts_path.exists() + + data = json.loads(ts_path.read_text()) + + assert data["schema_version"] == 2 + assert data["video_file"] == output_path.name + assert data["num_frames"] == 1 + + # Backward-compatible software timestamp list. + assert data["timestamps"] == [10.0] + assert data["start_time"] == 10.0 + assert data["end_time"] == 10.0 + assert data["duration_seconds"] == 0.0 + + # Static hardware source metadata is written once. + hw_source = data["timestamp_sources"]["hardware_timestamp"] + assert hw_source == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + # Per-frame records contain only per-frame values. + frame_ts = data["frame_timestamps"] + assert len(frame_ts) == 1 + + rec0 = frame_ts[0] + assert rec0["frame_index"] == 0 + assert rec0["software_timestamp"] == 10.0 + assert rec0["hardware_timestamp"] == { + "seconds": 0.001, + "raw_value": 1_000_000, + } + assert rec0["hardware_timestamp_default"] == 0.001 diff --git a/tests/utils/test_timestamps.py b/tests/utils/test_timestamps.py new file mode 100644 index 000000000..560872930 --- /dev/null +++ b/tests/utils/test_timestamps.py @@ -0,0 +1,63 @@ +import pytest + +from dlclivegui.utils.timestamps import FrameTimestampMetadata + + +class TestFrameTimestampMetadata: + def test_splits_source_and_frame_values(self): + meta = FrameTimestampMetadata( + source="grab_result.GetTimeStamp", + backend="basler", + default_reported="seconds", + seconds=0.123456789, + wall_clock_time=None, + raw_value=123456789, + raw_unit="ticks", + tick_frequency_hz=1_000_000_000.0, + timebase="Basler camera timestamp counter", + kind="camera_clock", + ) + + assert meta.to_source_dict() == { + "source": "grab_result.GetTimeStamp", + "backend": "basler", + "default_reported": "seconds", + "raw_unit": "ticks", + "tick_frequency_hz": 1_000_000_000.0, + "timebase": "Basler camera timestamp counter", + "kind": "camera_clock", + "extra": {}, + } + + frame_dict = meta.to_frame_dict() + assert frame_dict["seconds"] == pytest.approx(0.123456789) + assert frame_dict["raw_value"] == 123456789 + assert "wall_clock_time" not in frame_dict + + assert meta.get_default_reported() == pytest.approx(0.123456789) + + def test_default_reported_raw_value(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="raw_value", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() == 42 + + def test_unknown_default_field_returns_none(self): + meta = FrameTimestampMetadata( + source="device_counter", + backend="some_backend", + default_reported="seconds", + raw_value=42, + raw_unit="frames", + kind="frame_counter", + ) + + assert meta.to_frame_dict() == {"raw_value": 42} + assert meta.get_default_reported() is None From 3f2cae78712a6229d942106d28f92213ff2d7fe0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 15:10:41 +0200 Subject: [PATCH 058/194] Harden Basler timestamp metadata handling Improve Basler hardware timestamp robustness by treating support as best-effort, recording the tick-frequency source, falling back to an assumed 1 GHz clock when frequency is unavailable, and ignoring zero-value camera timestamps as missing data. The frame timestamp metadata now includes the frequency source in `extra`, and unused last-frame timestamp state was removed. Video recorder metadata also drops the legacy top-level `timestamps` field in favor of the structured `timestamp_sources` schema. --- dlclivegui/cameras/backends/basler_backend.py | 30 ++++++++++++++----- dlclivegui/services/video_recorder.py | 4 +-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 427f1d239..0b529a804 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -59,7 +59,7 @@ def __init__(self, settings): self._fast_start: bool = bool(self.ns.get("fast_start", False)) self._retrieve_timeout_ms: int = 100 # default; may be overridden by trigger settings self._timestamp_tick_frequency_hz: float | None = None - self._last_frame_timestamp_metadata: FrameTimestampMetadata | None = None + self._timestamp_tick_frequency_source: str | None = None # ---- Trigger settings ---- raw_trigger = self.ns.get("trigger", self._props.get("trigger")) @@ -159,10 +159,6 @@ def actual_output_format(self) -> str | None: return None return "Mono8" if self._should_output_mono() else "BGR8" - @property - def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: - return self._last_frame_timestamp_metadata - @property def recommended_preserve_mono(self) -> bool | None: if not self._camera_pixel_format: @@ -186,7 +182,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, "preserve_mono": SupportLevel.SUPPORTED, - "hardware_frame_timestamps": SupportLevel.SUPPORTED, + "hardware_frame_timestamps": SupportLevel.BEST_EFFORT, } ) return caps @@ -679,10 +675,22 @@ def open(self) -> None: node = getattr(self._camera, "GevTimestampTickFrequency", None) if node is not None and node.IsReadable(): self._timestamp_tick_frequency_hz = float(node.GetValue()) - LOG.info("[Basler] timestamp tick frequency: %.3f Hz", self._timestamp_tick_frequency_hz) + self._timestamp_tick_frequency_source = "GevTimestampTickFrequency" + LOG.info( + "[Basler] timestamp tick frequency: %.3f Hz from GevTimestampTickFrequency", + self._timestamp_tick_frequency_hz, + ) except Exception: LOG.debug("[Basler] Could not read GevTimestampTickFrequency", exc_info=True) + if not self._timestamp_tick_frequency_hz or self._timestamp_tick_frequency_hz <= 0: + self._timestamp_tick_frequency_hz = 1_000_000_000.0 + self._timestamp_tick_frequency_source = "assumed_default_1ghz" + LOG.info( + "[Basler] timestamp tick frequency unavailable; assuming %.3f Hz", + self._timestamp_tick_frequency_hz, + ) + # Persist stable identity into namespace try: serial = device.GetSerialNumber() @@ -702,6 +710,10 @@ def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None except Exception: return None + if ticks == 0: + # Basler returns 0 if the timestamp is not available (e.g. for some GigE cameras) + return None + freq = getattr(self, "_timestamp_tick_frequency_hz", None) seconds = ticks / freq if freq and freq > 0 else None @@ -716,6 +728,9 @@ def _make_timestamp_metadata(self, grab_result) -> FrameTimestampMetadata | None tick_frequency_hz=freq, timebase="Basler camera timestamp counter", kind="camera_clock", + extra={ + "tick_frequency_source": self._timestamp_tick_frequency_source, + }, ) def read(self) -> CapturedFrame: @@ -750,7 +765,6 @@ def read(self) -> CapturedFrame: with self._timing.measure("Basler.timestamp"): software_timestamp = time.time() timestamp_metadata = self._make_timestamp_metadata(grab_result) - self._last_frame_timestamp_metadata = timestamp_metadata if not self._logged_first_frame: self._logged_first_frame = True diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index e85819556..63602b02f 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -595,9 +595,7 @@ def _save_timestamps(self) -> None: "schema_version": 2, "video_file": str(self._output.name), "num_frames": len(frame_timestamps), - # Backward-compatible host/software timestamp list. - "timestamps": software_timestamps, - # New descriptive schema. + # "timestamps": software_timestamps, "timestamp_sources": { "software_timestamp": { "source": "host_time.time", From 5150e5c890cc883d63486ac3051301802076c456 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 15:52:56 +0200 Subject: [PATCH 059/194] Drop legacy timestamps assertions in tests Updates `test_video_recorder.py` to stop asserting the top-level `timestamps` list in sidecar JSON fixtures. The tests now focus on schema v2 fields that remain authoritative (`frame_timestamps`, `start_time`, `end_time`, `duration_seconds`, and timestamp source metadata), aligning expectations with current sidecar output. --- tests/services/test_video_recorder.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index ff09c1e93..8389fbbb4 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -233,7 +233,6 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f assert data["end_time"] == 12.0 assert data["duration_seconds"] == 2.0 assert data["schema_version"] == 2 - assert data["timestamps"] == [10.0, 12.0] assert data["timestamp_sources"]["hardware_timestamp"] is None assert data["frame_timestamps"][0]["software_timestamp"] == 10.0 assert data["frame_timestamps"][1]["software_timestamp"] == 12.0 @@ -450,9 +449,6 @@ def test_stop_writes_software_only_timestamp_sidecar_json( assert data["video_file"] == output_path.name assert data["num_frames"] == 2 - # Backward-compatible list. - assert data["timestamps"] == [10.0, 12.0] - assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock" assert data["timestamp_sources"]["hardware_timestamp"] is None @@ -503,7 +499,6 @@ def test_stop_writes_hardware_timestamp_metadata_sidecar_json( assert data["num_frames"] == 1 # Backward-compatible software timestamp list. - assert data["timestamps"] == [10.0] assert data["start_time"] == 10.0 assert data["end_time"] == 10.0 assert data["duration_seconds"] == 0.0 From b040cc2cc5388650ade07fe452e139c018f28197 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 16:34:31 +0200 Subject: [PATCH 060/194] Guard default timestamp field Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/services/video_recorder.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 63602b02f..f57f60c5f 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -483,9 +483,10 @@ def _writer_loop(self) -> None: if hasattr(timestamp_metadata, "to_frame_dict"): record["hardware_timestamp"] = timestamp_metadata.to_frame_dict() - default_value = timestamp_metadata.get_default_reported() - if default_value is not None: - record["hardware_timestamp_default"] = default_value + if hasattr(timestamp_metadata, "get_default_reported"): + default_value = timestamp_metadata.get_default_reported() + if default_value is not None: + record["hardware_timestamp_default"] = default_value elif isinstance(timestamp_metadata, dict): record["hardware_timestamp"] = dict(timestamp_metadata) else: From 0cfcfb95ef73c0e8331cc99ebc97bbbe88c84cef Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 09:01:36 +0200 Subject: [PATCH 061/194] Run pre-commit --- dlclivegui/cameras/backends/basler_backend.py | 2 -- tests/cameras/backends/test_basler_backend.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 0b529a804..09d23c7bb 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -7,8 +7,6 @@ import time from typing import ClassVar -import numpy as np - from ...config import BASLER_DO_LOG_TIMING, DEBUG_TRIGGER_LOGS, CameraTriggerSettings from ...utils.stats import WorkerTimingStats from ...utils.timestamps import FrameTimestampMetadata diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index e17a305fd..81b136a17 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -512,6 +512,8 @@ def raise_timeout(*_args, **_kwargs): finally: backend.close() + + class TestBaslerFrameTimestamps: @pytest.mark.unit def test_read_returns_captured_frame_with_hardware_timestamp_metadata( From 0f5bf1b6421d41e0f9f832ba266aa76c4b18b9ca Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 10:10:10 +0200 Subject: [PATCH 062/194] Refactor Basler test fixture injection Rename `patch_basler_sdk` to `patched_basler_sdk` and have it return the patched Basler backend module directly. Tests now consume that module from the fixture instead of re-importing it, and the backend patcher mapping was updated accordingly. The fixture also patches `genicam` with `raising=False` for more robust monkeypatching. --- tests/cameras/backends/conftest.py | 10 +- tests/cameras/backends/test_basler_backend.py | 92 +++++++++---------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 4c09d26f0..946be4598 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -669,15 +669,15 @@ def fake_pylon_module(): @pytest.fixture() -def patch_basler_sdk(monkeypatch, fake_pylon_module): +def patched_basler_sdk(monkeypatch, fake_pylon_module): """Patch Basler backend to use FakePylon.""" import dlclivegui.cameras.backends.basler_backend as bb fake_genicam = SimpleNamespace(TimeoutException=FakePylonTimeoutException) monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False) - monkeypatch.setattr(bb, "genicam", fake_genicam) - return fake_pylon_module + monkeypatch.setattr(bb, "genicam", fake_genicam, raising=False) + return bb @pytest.fixture() @@ -1304,7 +1304,7 @@ def _make( # Generic patcher mapping fixture for test_generic_contracts.py # ----------------------------------------------------------------------------- @pytest.fixture() -def backend_sdk_patchers(patch_aravis_sdk, patch_basler_sdk, patch_gentl_sdk): +def backend_sdk_patchers(patch_aravis_sdk, patched_basler_sdk, patch_gentl_sdk): """ Mapping from backend name -> patcher callable (best-effort SDK stubs). @@ -1315,7 +1315,7 @@ def backend_sdk_patchers(patch_aravis_sdk, patch_basler_sdk, patch_gentl_sdk): return { # Calling it is harmless; patching already applied by fixture injection. "aravis": (lambda: patch_aravis_sdk), - "basler": (lambda: patch_basler_sdk), + "basler": (lambda: patched_basler_sdk), "gentl": (lambda: patch_gentl_sdk), # No patch needed: OpenCV is assumed present # "opencv": None, diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 81b136a17..886d60846 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -11,8 +11,8 @@ # --------------------------------------------------------------------- -def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, basler_settings_factory): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_open_starts_grabbing_and_read_returns_frame(patched_basler_sdk, basler_settings_factory): + bb = patched_basler_sdk settings = basler_settings_factory() be = bb.BaslerCameraBackend(settings) @@ -36,10 +36,10 @@ def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, ba def test_basler_fast_start_does_not_start_grabbing_and_read_raises( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory(properties={"basler": {"fast_start": True}}) be = bb.BaslerCameraBackend(settings) @@ -57,8 +57,8 @@ def test_basler_fast_start_does_not_start_grabbing_and_read_raises( be.close() -def test_basler_close_is_idempotent(patch_basler_sdk, basler_settings_factory): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_close_is_idempotent(patched_basler_sdk, basler_settings_factory): + bb = patched_basler_sdk be = bb.BaslerCameraBackend(basler_settings_factory()) be.open() @@ -66,8 +66,8 @@ def test_basler_close_is_idempotent(patch_basler_sdk, basler_settings_factory): be.close() -def test_basler_stop_before_open_and_after_close_is_safe(patch_basler_sdk, basler_settings_factory): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_stop_before_open_and_after_close_is_safe(patched_basler_sdk, basler_settings_factory): + bb = patched_basler_sdk be = bb.BaslerCameraBackend(basler_settings_factory()) @@ -83,8 +83,8 @@ def test_basler_stop_before_open_and_after_close_is_safe(patch_basler_sdk, basle be.stop() -def test_basler_read_before_open_raises_runtimeerror(patch_basler_sdk, basler_settings_factory): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_read_before_open_raises_runtimeerror(patched_basler_sdk, basler_settings_factory): + bb = patched_basler_sdk be = bb.BaslerCameraBackend(basler_settings_factory()) @@ -98,9 +98,9 @@ def test_basler_read_before_open_raises_runtimeerror(patch_basler_sdk, basler_se def test_basler_discover_devices_returns_serial_identity_and_label( - patch_basler_sdk, + patched_basler_sdk, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk cams = bb.BaslerCameraBackend.discover_devices(max_devices=10) @@ -111,8 +111,8 @@ def test_basler_discover_devices_returns_serial_identity_and_label( assert cams[0].path -def test_basler_quick_ping_true_for_existing_false_for_missing(patch_basler_sdk): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_quick_ping_true_for_existing_false_for_missing(patched_basler_sdk): + bb = patched_basler_sdk assert bb.BaslerCameraBackend.quick_ping(0) is True assert bb.BaslerCameraBackend.quick_ping(1) is True @@ -120,10 +120,10 @@ def test_basler_quick_ping_true_for_existing_false_for_missing(patch_basler_sdk) def test_basler_rebind_settings_uses_serial_device_id( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( index=0, @@ -139,10 +139,10 @@ def test_basler_rebind_settings_uses_serial_device_id( def test_basler_open_selects_device_id_and_persists_identity( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( index=0, @@ -159,8 +159,8 @@ def test_basler_open_selects_device_id_and_persists_identity( be.close() -def test_basler_open_index_out_of_range_raises(patch_basler_sdk, basler_settings_factory): - import dlclivegui.cameras.backends.basler_backend as bb +def test_basler_open_index_out_of_range_raises(patched_basler_sdk, basler_settings_factory): + bb = patched_basler_sdk settings = basler_settings_factory(index=99) be = bb.BaslerCameraBackend(settings) @@ -175,10 +175,10 @@ def test_basler_open_index_out_of_range_raises(patch_basler_sdk, basler_settings def test_basler_resolution_auto_does_not_modify_dimensions( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory(width=0, height=0) be = bb.BaslerCameraBackend(settings) @@ -193,10 +193,10 @@ def test_basler_resolution_auto_does_not_modify_dimensions( def test_basler_resolution_request_snaps_to_increment( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory(width=641, height=481) be = bb.BaslerCameraBackend(settings) @@ -211,10 +211,10 @@ def test_basler_resolution_request_snaps_to_increment( def test_basler_exposure_gain_fps_are_applied_when_nonzero( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory(exposure=20000, gain=2.5, fps=50.0) be = bb.BaslerCameraBackend(settings) @@ -237,9 +237,9 @@ def test_basler_exposure_gain_fps_are_applied_when_nonzero( def test_basler_static_capabilities_advertises_hardware_trigger_best_effort_and_mono( - patch_basler_sdk, + patched_basler_sdk, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk from dlclivegui.cameras.base import SupportLevel caps = bb.BaslerCameraBackend.static_capabilities() @@ -248,10 +248,10 @@ def test_basler_static_capabilities_advertises_hardware_trigger_best_effort_and_ def test_basler_default_trigger_is_off_and_free_runs( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory() be = bb.BaslerCameraBackend(settings) @@ -269,10 +269,10 @@ def test_basler_default_trigger_is_off_and_free_runs( def test_basler_follower_auto_selects_line1_and_times_out_waiting_for_trigger( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -312,10 +312,10 @@ def test_basler_follower_auto_selects_line1_and_times_out_waiting_for_trigger( def test_basler_follower_strict_invalid_source_raises( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -338,10 +338,10 @@ def test_basler_follower_strict_invalid_source_raises( def test_basler_follower_non_strict_invalid_source_disables_trigger( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -369,10 +369,10 @@ def test_basler_follower_non_strict_invalid_source_disables_trigger( def test_basler_master_configures_generic_line_output_and_restores_on_close( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -405,10 +405,10 @@ def test_basler_master_configures_generic_line_output_and_restores_on_close( @pytest.mark.xfail(reason="Software trigger support is not implemented yet.") def test_basler_software_trigger_requires_trigger_once_before_read( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -443,10 +443,10 @@ def test_basler_software_trigger_requires_trigger_once_before_read( def test_basler_close_turns_input_trigger_off( - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory( properties={ @@ -473,10 +473,10 @@ def test_basler_close_turns_input_trigger_off( def test_basler_hardware_trigger_maps_pylon_timeout_to_timeout_error( monkeypatch, - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk class FakePylonTimeout(Exception): pass @@ -518,10 +518,10 @@ class TestBaslerFrameTimestamps: @pytest.mark.unit def test_read_returns_captured_frame_with_hardware_timestamp_metadata( self, - patch_basler_sdk, + patched_basler_sdk, basler_settings_factory, ): - import dlclivegui.cameras.backends.basler_backend as bb + bb = patched_basler_sdk settings = basler_settings_factory() be = bb.BaslerCameraBackend(settings) From 81390973bcdf6865ee5113187b61ef4e128241b9 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 10:14:40 +0200 Subject: [PATCH 063/194] Add Basler timestamp metadata edge-case tests Extend Basler backend tests to cover `_make_timestamp_metadata` behavior when the SDK timestamp is zero and when tick frequency is missing or invalid. The new cases assert that zero ticks return `None` and invalid frequencies fall back to reporting raw tick values. --- tests/cameras/backends/test_basler_backend.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 886d60846..553a44b79 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -554,3 +554,48 @@ def test_read_returns_captured_frame_with_hardware_timestamp_metadata( assert frame_dict["raw_value"] == 123456789 be.close() + + @pytest.mark.unit + def test_make_timestamp_metadata_returns_none_for_zero_ticks( + self, + patched_basler_sdk, + basler_settings_factory, + ): + bb = patched_basler_sdk + backend = bb.BaslerCameraBackend(basler_settings_factory()) + + class GrabResult: + @staticmethod + def GetTimeStamp(): + return 0 + + metadata = backend._make_timestamp_metadata(GrabResult()) + + assert metadata is None + + import pytest + + @pytest.mark.parametrize("frequency", [None, 0.0, -1.0]) + def test_make_timestamp_metadata_uses_raw_ticks_for_invalid_frequency( + self, + patched_basler_sdk, + basler_settings_factory, + frequency, + ): + bb = patched_basler_sdk + backend = bb.BaslerCameraBackend(basler_settings_factory()) + backend._timestamp_tick_frequency_hz = frequency + backend._timestamp_tick_frequency_source = None + + class GrabResult: + @staticmethod + def GetTimeStamp(): + return 12_345 + + metadata = backend._make_timestamp_metadata(GrabResult()) + + assert metadata is not None + assert metadata.default_reported == "raw_value" + assert metadata.seconds is None + assert metadata.raw_value == 12_345 + assert metadata.tick_frequency_hz == frequency From fc98e3c32fa8ef3e4927f9376bc4ac5142e8c68b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 10:14:55 +0200 Subject: [PATCH 064/194] Remove unused last_frame_timestamp_metadata --- dlclivegui/cameras/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 9217ad8e1..9233d72b8 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -124,11 +124,6 @@ def actual_pixel_format(self) -> str | None: def recommended_preserve_mono(self) -> bool | None: return None - @property - def last_frame_timestamp_metadata(self) -> FrameTimestampMetadata | None: - """Return backend-provided timestamp metadata for the last read frame.""" - return None - @classmethod def options_key(cls) -> str: """Return the key used to store this backend's options in CameraSettings.""" From a524fde13bb9c51f5a3acdc36a8aabbcdbf37bcb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:40:43 +0200 Subject: [PATCH 065/194] Lock processor settings during DLC inference Disable all DLC and processor configuration widgets consistently while inference is active, including the processor-control checkbox. Refactor processor discovery into shared helpers that detect direct and indirect `dlclive.Processor` subclasses, standardize metadata extraction, and reuse the same fallback logic for package scans and file-based loading. --- dlclivegui/gui/main_window.py | 10 ++- dlclivegui/processors/processor_utils.py | 96 +++++++++++++++--------- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index f06dc8b0a..cc6570198 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1734,24 +1734,28 @@ def _update_inference_buttons(self) -> None: def _update_dlc_controls_enabled(self) -> None: """Enable/disable DLC settings based on inference state.""" allow_changes = not self._dlc_active - processor_controls = allow_changes and self._processor_control_enabled() widgets = [ self.model_path_edit, self.browse_model_button, self.dlc_camera_combo, - # self.additional_options_edit, ] + processor_widgets = [ self.processor_folder_edit, self.browse_processor_folder_button, self.refresh_processors_button, self.processor_combo, ] + for widget in widgets: widget.setEnabled(allow_changes) + for widget in processor_widgets: - widget.setEnabled(processor_controls) + widget.setEnabled(allow_changes) + + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: multi_cam_recording = self._rec_manager.is_active diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index b32445c38..58b48f415 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -17,6 +17,64 @@ def default_processors_dir() -> str: return str(path) +def _processor_base_class(): + from dlclive import Processor + + return Processor + + +def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: + """Return True for dlclive.Processor subclasses, including indirect subclasses.""" + if not inspect.isclass(obj): + return False + + try: + processor_base = _processor_base_class() + except Exception: + logger.exception("Could not import dlclive.Processor") + return False + + try: + if obj is processor_base: + return bool(include_base) + return issubclass(obj, processor_base) + except TypeError: + return False + + +def _processor_info_from_class(cls, fallback_name: str) -> dict: + return { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", fallback_name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + + +def discover_processor_classes(module, *, only_defined_in_module: bool = True) -> dict[str, dict]: + """Discover dlclive.Processor subclasses in a module. + + Includes indirect subclasses of Processor. + + Args: + module: Imported Python module. + only_defined_in_module: If True, ignore Processor subclasses imported + from other modules to avoid duplicate registry entries. + """ + processors: dict[str, dict] = {} + + for name, obj in inspect.getmembers(module, inspect.isclass): + if only_defined_in_module and getattr(obj, "__module__", None) != module.__name__: + continue + + if not _is_processor_subclass(obj): + continue + + processors[name] = _processor_info_from_class(obj, name) + + return processors + + def scan_processor_folder(folder_path): all_processors = {} folder = Path(folder_path) @@ -65,22 +123,7 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - from dlclive import Processor - - processors = {} - for attr_name in dir(mod): - obj = getattr(mod, attr_name) - try: - if isinstance(obj, type) and obj is not Processor and issubclass(obj, Processor): - processors[attr_name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", attr_name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Non-class or weird metaclass; ignore - pass + processors = discover_processor_classes(mod) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -131,26 +174,7 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - from dlclive import Processor - - processors: dict[str, dict] = {} - for name, obj in inspect.getmembers(module, inspect.isclass): - if obj is Processor: - continue - # Guard: module might define other classes; only include Processor subclasses - try: - if issubclass(obj, Processor): - processors[name] = { - "class": obj, - "name": getattr(obj, "PROCESSOR_NAME", name), - "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}), - } - except Exception: - # Some "classes" can fail issubclass checks; ignore safely - continue - - return processors + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From e1ec3b169eac9d560f46b31f0a7b7ae9d3e08e3c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 09:50:45 +0200 Subject: [PATCH 066/194] Improve processor discovery and logging Expand processor class discovery to include re-exported classes by disabling module-only filtering in package/file scans. Also broaden subclass-check error handling to catch unexpected exceptions and log full context when discovery encounters problematic objects. --- dlclivegui/processors/processor_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 58b48f415..8f606d8b5 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -38,7 +38,8 @@ def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: if obj is processor_base: return bool(include_base) return issubclass(obj, processor_base) - except TypeError: + except Exception: + logger.exception(f"Error checking if {obj} is a subclass of dlclive.Processor") return False @@ -123,7 +124,7 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - processors = discover_processor_classes(mod) + processors = discover_processor_classes(mod, only_defined_in_module=False) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -174,7 +175,8 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module) + # here module only is disabled to allow classes re-exported in other modules to be discovered + return discover_processor_classes(module, only_defined_in_module=False) except Exception: # Full traceback helps a ton when a plugin fails to import From feff4249d5da85f712389b37d3b41bf6a9096c2e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:06:38 +0200 Subject: [PATCH 067/194] Add processors package exports Create `dlclivegui/processors/__init__.py` to re-export `register_processor`, `BaseProcessorSocket`, and `PROCESSOR_REGISTRY` from `dlc_processor_socket`, making these APIs available via package-level imports. --- dlclivegui/processors/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 dlclivegui/processors/__init__.py diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py new file mode 100644 index 000000000..ee94194dd --- /dev/null +++ b/dlclivegui/processors/__init__.py @@ -0,0 +1,3 @@ +from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor + +__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] From daeed1e6e8489ab3636a417ace82bc6566947219 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:08:11 +0200 Subject: [PATCH 068/194] Move example socket processors to examples module Refactors `dlc_processor_socket.py` by removing the in-file example processors and `OneEuroFilter`, and adds them to a new `dlclivegui/processors/examples.py` module. This separates demonstration/experiment-specific logic from the core socket processor implementation, improving maintainability while preserving existing example processor behavior. --- dlclivegui/processors/dlc_processor_socket.py | 377 ----------------- dlclivegui/processors/examples.py | 387 ++++++++++++++++++ 2 files changed, 387 insertions(+), 377 deletions(-) create mode 100644 dlclivegui/processors/examples.py diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 8ded01069..b4f786f44 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -7,7 +7,6 @@ import sys import time from collections import deque -from math import acos, atan2, copysign, degrees, pi, sqrt from multiprocessing.connection import Client, Listener from pathlib import Path from threading import Event, Thread @@ -39,45 +38,6 @@ def register_processor(cls): return cls -class OneEuroFilter: # pragma: no cover - def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): - self.min_cutoff = min_cutoff - self.beta = beta - self.d_cutoff = d_cutoff - self.x_prev = x0 - if dx0 is None: - dx0 = np.zeros_like(x0) - self.dx_prev = dx0 - self.t_prev = t0 - - @staticmethod - def smoothing_factor(t_e, cutoff): - r = 2 * pi * cutoff * t_e - return r / (r + 1) - - @staticmethod - def exponential_smoothing(alpha, x, x_prev): - return alpha * x + (1 - alpha) * x_prev - - def __call__(self, t, x): - t_e = t - self.t_prev - if t_e <= 0: - return x - a_d = self.smoothing_factor(t_e, self.d_cutoff) - dx = (x - self.x_prev) / t_e - dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) - - cutoff = self.min_cutoff + self.beta * abs(dx_hat) - a = self.smoothing_factor(t_e, cutoff) - x_hat = self.exponential_smoothing(a, x, self.x_prev) - - self.x_prev = x_hat - self.dx_prev = dx_hat - self.t_prev = t - - return x_hat - - # pragma: cover class BaseProcessorSocket(Processor): """ @@ -476,343 +436,6 @@ def get_data(self): return save_dict -@register_processor -class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover - """ - DLC Processor with pose calculations (center, heading, head angle) and optional filtering. - - Calculates: - - center: Weighted average of head keypoints - - heading: Body orientation (degrees) - - head_angle: Head rotation relative to body (radians) - - Broadcasts: [timestamp, center_x, center_y, heading, head_angle] - """ - - PROCESSOR_NAME = "Example Experiment Pose Processor" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": False, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=False, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] - head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] - center = np.average(head_xy, axis=0, weights=head_conf) - - # Calculate body axis (tail_base -> neck) - body_axis = xy[7] - xy[13] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - xy[7] - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - - # Raw values (heading unwrapped for filtering) - vals = [center[0], center[1], heading, head_angle] - - # Apply filtering if enabled - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - -@register_processor -class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover - PROCESSOR_NAME = "Mouse Pose with less keypoints" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": ("127.0.0.1", 6000), - "description": "Server address (host, port)", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for clients", - }, - "use_perf_counter": { - "type": "bool", - "default": False, - "description": "Use time.perf_counter() instead of time.time()", - }, - "use_filter": { - "type": "bool", - "default": False, - "description": "Apply One-Euro filter to calculated values", - }, - "filter_kwargs": { - "type": "dict", - "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", - }, - "save_original": { - "type": "bool", - "default": True, - "description": "Save raw pose arrays for analysis", - }, - } - - def __init__( - self, - bind=("127.0.0.1", 6000), - authkey=b"secret password", - use_perf_counter=False, - use_filter=False, - filter_kwargs: dict | None = None, - save_original=True, - p_cutoff=0.4, - ): - super().__init__( - bind=bind, - authkey=authkey, - use_perf_counter=use_perf_counter, - save_original=save_original, - ) - - self.center_x = deque() - self.center_y = deque() - self.heading_direction = deque() - self.head_angle = deque() - - self.p_cutoff = p_cutoff - - self.use_filter = use_filter - self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} - self.filters = None - - def _clear_data_queues(self): - super()._clear_data_queues() - self.center_x.clear() - self.center_y.clear() - self.heading_direction.clear() - self.head_angle.clear() - - def _initialize_filters(self, vals): - t0 = self.timing_func() - self.filters = { - "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), - "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), - "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), - "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), - } - logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") - - def process(self, pose, **kwargs): - # Extract keypoints and confidence - xy = pose[:, :2] - conf = pose[:, 2] - - # Calculate weighted center from head keypoints - head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] - head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] - # set low confidence keypoints to zero weight - head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) - try: - center = np.average(head_xy, axis=0, weights=head_conf) - except ZeroDivisionError: - # If all keypoints have zero weight, return without processing - return pose - - neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) - - # Calculate body axis (tail_base -> neck) - body_axis = neck - xy[9] - body_axis /= sqrt(np.sum(body_axis**2)) - - # Calculate head axis (neck -> nose) - head_axis = xy[0] - neck - head_axis /= sqrt(np.sum(head_axis**2)) - - # Calculate head angle relative to body - cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] - sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) - try: - head_angle = acos(body_axis @ head_axis) * sign - except ValueError: - head_angle = 0 - - # Calculate heading (body orientation) - heading = degrees(atan2(body_axis[1], body_axis[0])) - vals = [center[0], center[1], heading, head_angle] - - curr_time = self.timing_func() - if self.use_filter: - if self.filters is None: - self._initialize_filters(vals) - - vals = [ - self.filters["center_x"](curr_time, vals[0]), - self.filters["center_y"](curr_time, vals[1]), - self.filters["heading"](curr_time, vals[2]), - self.filters["head_angle"](curr_time, vals[3]), - ] - - # Wrap heading to [0, 360) after filtering - vals[2] = vals[2] % 360 - # Update step counter - self.curr_step = self.curr_step + 1 - - # Store processed data (only if recording) - if self.recording: - if self.save_original and self.original_pose is not None: - self.original_pose.append(pose.copy()) - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - - payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] - self.broadcast(payload) - return pose - - def get_data(self): - save_dict = super().get_data() - save_dict["x_pos"] = np.array(self.center_x) - save_dict["y_pos"] = np.array(self.center_y) - save_dict["heading_direction"] = np.array(self.heading_direction) - save_dict["head_angle"] = np.array(self.head_angle) - save_dict["use_filter"] = self.use_filter - save_dict["filter_kwargs"] = self.filter_kwargs - return save_dict - - def get_available_processors(): """ Get list of available processor classes. diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py new file mode 100644 index 000000000..feb6ac3c9 --- /dev/null +++ b/dlclivegui/processors/examples.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import logging +from collections import deque +from math import acos, atan2, copysign, degrees, pi, sqrt + +import numpy as np + +from dlclivegui.processors import BaseProcessorSocket, register_processor + +logger = logging.getLogger(__name__) + + +class OneEuroFilter: # pragma: no cover + def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): + self.min_cutoff = min_cutoff + self.beta = beta + self.d_cutoff = d_cutoff + self.x_prev = x0 + if dx0 is None: + dx0 = np.zeros_like(x0) + self.dx_prev = dx0 + self.t_prev = t0 + + @staticmethod + def smoothing_factor(t_e, cutoff): + r = 2 * pi * cutoff * t_e + return r / (r + 1) + + @staticmethod + def exponential_smoothing(alpha, x, x_prev): + return alpha * x + (1 - alpha) * x_prev + + def __call__(self, t, x): + t_e = t - self.t_prev + if t_e <= 0: + return x + a_d = self.smoothing_factor(t_e, self.d_cutoff) + dx = (x - self.x_prev) / t_e + dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) + + cutoff = self.min_cutoff + self.beta * abs(dx_hat) + a = self.smoothing_factor(t_e, cutoff) + x_hat = self.exponential_smoothing(a, x, self.x_prev) + + self.x_prev = x_hat + self.dx_prev = dx_hat + self.t_prev = t + + return x_hat + + +@register_processor +class ExampleProcessorSocketCalculateMousePose(BaseProcessorSocket): # pragma: no cover + """ + DLC Processor with pose calculations (center, heading, head angle) and optional filtering. + + Calculates: + - center: Weighted average of head keypoints + - heading: Body orientation (degrees) + - head_angle: Head rotation relative to body (radians) + + Broadcasts: [timestamp, center_x, center_y, heading, head_angle] + """ + + PROCESSOR_NAME = "Example Experiment Pose Processor" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=False, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] + head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] + center = np.average(head_xy, axis=0, weights=head_conf) + + # Calculate body axis (tail_base -> neck) + body_axis = xy[7] - xy[13] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - xy[7] + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + sign = copysign(1, cross) + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + + # Raw values (heading unwrapped for filtering) + vals = [center[0], center[1], heading, head_angle] + + # Apply filtering if enabled + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict + + +@register_processor +class ExampleProcessorSocketFilterKeypoints(BaseProcessorSocket): # pragma: no cover + PROCESSOR_NAME = "Mouse Pose with less keypoints" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("127.0.0.1", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": True, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("127.0.0.1", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs: dict | None = None, + save_original=True, + p_cutoff=0.4, + ): + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + self.p_cutoff = p_cutoff + + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs if filter_kwargs is not None else {} + self.filters = None + + def _clear_data_queues(self): + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + logger.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] + head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] + # set low confidence keypoints to zero weight + head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + # If all keypoints have zero weight, return without processing + return pose + + neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) + + # Calculate body axis (tail_base -> neck) + body_axis = neck - xy[9] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - neck + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + sign = copysign(1, cross) + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = degrees(atan2(body_axis[1], body_axis[0])) + vals = [center[0], center[1], heading, head_angle] + + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + if self.save_original and self.original_pose is not None: + self.original_pose.append(pose.copy()) + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + return pose + + def get_data(self): + save_dict = super().get_data() + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + return save_dict From 70879fe628abc55cc2c00a554ea7f990466209c3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:11:43 +0200 Subject: [PATCH 069/194] Update plugin docs for processor examples Refines `PLUGIN_SYSTEM.md` to reflect the current processor structure: it now points to `examples.py` for sample implementations and keeps `dlc_processor_socket.py` focused on the socket base class. The registration example was also updated to import `register_processor` and `PROCESSOR_REGISTRY` from `dlclivegui.processors` instead of redefining them inline. --- dlclivegui/processors/PLUGIN_SYSTEM.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index 9e975e01c..e6a143626 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -16,7 +16,8 @@ Processors are Python classes (typically subclasses of `dlclive.Processor`) that ### Useful files -- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class + examples +- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class +- `dlclivegui/processors/examples.py` — Example processor implementations (e.g., One-Euro filter) - `dlclivegui/processors/processor_utils.py` — Scanning + instantiation helpers used by the GUI --- @@ -204,12 +205,7 @@ The built-in `BaseProcessorSocket` (in `dlc_processor_socket.py`) demonstrates a ```python from dlclive import Processor - -PROCESSOR_REGISTRY = {} - -def register_processor(cls): - PROCESSOR_REGISTRY[getattr(cls, "PROCESSOR_ID", cls.__name__)] = cls - return cls +from dlclivegui.processors import register_processor, PROCESSOR_REGISTRY @register_processor class MyNewProcessor(Processor): From 96391e8aa464b51782df86ee1d41b69e47a77e3e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:12:22 +0200 Subject: [PATCH 070/194] Skip socket base module in processor scan Update processor package discovery to ignore `dlc_processor_socket` during namespace scanning, since it only provides the base class/registry and should not be listed as an available processor source. The package fallback scan now uses default class discovery behavior, and related outdated comments/docstring lines were cleaned up. --- dlclivegui/processors/processor_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 8f606d8b5..948f21a4c 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -101,8 +101,6 @@ def scan_processor_folder(folder_path): def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str | dict]: """ Discover and load processor classes from a package namespace. - Returns a dict keyed as 'module.py::ClassName' with the same - structure you use today. """ all_processors: dict[str, dict] = {} @@ -118,13 +116,16 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ continue try: mod = import_module(mod_name) + # Skip dlc_processor_socket.py as it's the base class and registry + if mod.__name__.endswith("dlc_processor_socket"): + continue # Prefer module-level registry function if present if hasattr(mod, "get_available_processors"): processors = mod.get_available_processors() else: # Fallback: scan for dlclive.Processor subclasses - processors = discover_processor_classes(mod, only_defined_in_module=False) + processors = discover_processor_classes(mod) # Normalize into your “file::class” shape module_file = mod.__name__.split(".")[-1] + ".py" @@ -175,7 +176,6 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - # here module only is disabled to allow classes re-exported in other modules to be discovered return discover_processor_classes(module, only_defined_in_module=False) except Exception: From 620e4048642fd6ab5cae0a11e4f5a1b9bd0f12d4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:13:13 +0200 Subject: [PATCH 071/194] Update processor_utils.py --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 948f21a4c..0692d77f6 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -176,7 +176,7 @@ def load_processors_from_file(file_path: str | Path): return processors # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module, only_defined_in_module=False) + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From 4e8ace0f6d628bfb9d661f2b26bf10cfb59c6489 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:21:40 +0200 Subject: [PATCH 072/194] Warn on duplicate processor registration Change `register_processor` to log a warning instead of raising on duplicate `PROCESSOR_ID` keys, allowing later registrations to override earlier ones without import-time failures. Update subclass save tests to load processor classes from `dlclivegui.processors.examples` via a dedicated fixture, so the parametrized tests validate the concrete example processors against the correct module data path. --- dlclivegui/processors/dlc_processor_socket.py | 3 ++- .../custom_processors/test_base_processor.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index b4f786f44..ca9808f9d 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -30,10 +30,11 @@ def register_processor(cls): registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) if registry_key in PROCESSOR_REGISTRY: - raise ValueError( + msg = ( f"Duplicate processor registration key '{registry_key}': " f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" ) + logger.warning(msg) PROCESSOR_REGISTRY[registry_key] = cls return cls diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index d38749b34..e881607f4 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -37,6 +37,19 @@ def socket_mod(monkeypatch): return importlib.import_module(mod_name) +@pytest.fixture +def example_processor_mod(monkeypatch): + """ + Import the example processor module with dlclive mocked. + Adjust module name if your file lives elsewhere. + """ + _mock_dlclive(monkeypatch) + mod_name = "dlclivegui.processors.examples" + if mod_name in sys.modules: + del sys.modules[mod_name] + return importlib.import_module(mod_name) + + def _module_data_dir(socket_mod) -> Path: """Compute the data/ directory where save() writes artifacts.""" return Path(socket_mod.__file__).parent.parent.parent / "data" @@ -233,12 +246,14 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): ("ExampleProcessorSocketFilterKeypoints", 10), ], ) -def test_subclass_save_ignores_pre_recording_original_pose_frames(socket_mod, class_name, n_keypoints): +def test_subclass_save_ignores_pre_recording_original_pose_frames( + socket_mod, example_processor_mod, class_name, n_keypoints +): """ Concrete processors must keep original_pose aligned with recorded metadata even when process() is called before recording starts. """ - processor_class = getattr(socket_mod, class_name) + processor_class = getattr(example_processor_mod, class_name) proc = processor_class(bind=("127.0.0.1", 0), save_original=True) try: From 8af314898d5428895d162fe06871f4e115273be4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:23:40 +0200 Subject: [PATCH 073/194] Update examples.py --- dlclivegui/processors/examples.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index feb6ac3c9..177adb390 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -161,7 +161,7 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) + try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: @@ -331,7 +331,7 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - sign = copysign(1, cross) + try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: From 6fce7fa9f967db44a7e1130c8af78369102ab4cd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:27:22 +0200 Subject: [PATCH 074/194] Refine processor package scan typing Updates `scan_processor_package` to use a more precise return type annotation (`dict[str, dict]` --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 0692d77f6..90b95dad6 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -98,7 +98,7 @@ def scan_processor_folder(folder_path): return all_processors -def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str | dict]: +def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[str, dict]: """ Discover and load processor classes from a package namespace. """ From 45cc56dee7ff57b70ae1ecec5319e56626f808c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:35:29 +0200 Subject: [PATCH 075/194] Update examples.py --- dlclivegui/processors/examples.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 177adb390..d8fab0d2b 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -148,7 +148,10 @@ def process(self, pose, **kwargs): # Calculate weighted center from head keypoints head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] - center = np.average(head_xy, axis=0, weights=head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + center = np.zeros(2) # Calculate body axis (tail_base -> neck) body_axis = xy[7] - xy[13] From 002bd20e4e3d384d418c87efa8206d831b354afe Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:40:46 +0200 Subject: [PATCH 076/194] Fix dlclive Processor import paths Update processor imports to use `from dlclive.processor import Processor` in runtime code to avoid torch import side effects --- dlclivegui/processors/dlc_processor_socket.py | 2 +- dlclivegui/processors/processor_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index ca9808f9d..c649422ef 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from dlclive import Processor # type: ignore +from dlclive.processor import Processor # type: ignore logger = logging.getLogger("dlc_processor_socket") diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 90b95dad6..467792b03 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -18,7 +18,7 @@ def default_processors_dir() -> str: def _processor_base_class(): - from dlclive import Processor + from dlclive.processor import Processor return Processor From ac90f5e9b47b7461e37c4459bbb70e0496b945ee Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:49:10 +0200 Subject: [PATCH 077/194] Extract processor registry into new module Moves processor registration and discovery helpers out of `dlc_processor_socket.py` into a new `registry.py` module so registry access no longer depends on importing socket logic. `dlc_processor_socket.py` now imports the shared registry helpers and adds a safe fallback when `dlclive` is unavailable, reducing import-time failures in environments without that dependency. Package exports were updated to expose registry APIs from the new module. --- dlclivegui/processors/__init__.py | 4 +- dlclivegui/processors/dlc_processor_socket.py | 56 ++----------------- dlclivegui/processors/examples.py | 3 +- dlclivegui/processors/registry.py | 53 ++++++++++++++++++ 4 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 dlclivegui/processors/registry.py diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index ee94194dd..8e7717155 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor +from .registry import PROCESSOR_REGISTRY, register_processor -__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "PROCESSOR_REGISTRY"] diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index c649422ef..594512c24 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -13,7 +13,11 @@ import numpy as np import pandas as pd -from dlclive.processor import Processor # type: ignore + +try: + from dlclive.processor import Processor # type: ignore +except ImportError: + Processor = object # Fallback for type checking if dlclive is not installed logger = logging.getLogger("dlc_processor_socket") @@ -23,21 +27,6 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} - - -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - msg = ( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" - ) - logger.warning(msg) - PROCESSOR_REGISTRY[registry_key] = cls - return cls - # pragma: cover class BaseProcessorSocket(Processor): @@ -435,38 +424,3 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict - - -def get_available_processors(): - """ - Get list of available processor classes. - - Returns: - dict: Dictionary mapping registry keys to processor info. - """ - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } - - -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs - - Raises: - ValueError: If class_name is not in registry - """ - if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index d8fab0d2b..7ed769198 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,7 +6,8 @@ import numpy as np -from dlclivegui.processors import BaseProcessorSocket, register_processor +from dlclivegui.processors import register_processor +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket logger = logging.getLogger(__name__) diff --git a/dlclivegui/processors/registry.py b/dlclivegui/processors/registry.py new file mode 100644 index 000000000..28892975e --- /dev/null +++ b/dlclivegui/processors/registry.py @@ -0,0 +1,53 @@ +import logging + +logger = logging.getLogger(__name__) + +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +def register_processor(cls): + registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) + if registry_key in PROCESSOR_REGISTRY: + msg = ( + f"Duplicate processor registration key '{registry_key}': " + f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + ) + logger.warning(msg) + PROCESSOR_REGISTRY[registry_key] = cls + return cls + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping registry keys to processor info. + """ + return { + name: { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + for name, cls in PROCESSOR_REGISTRY.items() + } + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Registry key (e.g., "MyProcessorSocket") + **kwargs: Constructor kwargs + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) From 45afb83ad8cc5641cbabe32bcb6e0ad575149512 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:50:10 +0200 Subject: [PATCH 078/194] Fix dlclive mock structure in processor tests Update the base processor test helper to better mirror the real dlclive package layout by mocking both `dlclive` and `dlclive.processor`, and add a no-op `process` method on the dummy `Processor`. This prevents import/behavior mismatches in tests that rely on the processor interface. --- tests/custom_processors/test_base_processor.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index e881607f4..94dabab89 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -13,15 +13,21 @@ def _mock_dlclive(monkeypatch): - """Provide a dummy dlclive.Processor so the module can import in tests.""" - fake = types.ModuleType("dlclive") - class Processor: def __init__(self, *args, **kwargs): pass - fake.Processor = Processor - monkeypatch.setitem(sys.modules, "dlclive", fake) + def process(self, pose, **kwargs): + return pose + + dlclive_mod = types.ModuleType("dlclive") + processor_mod = types.ModuleType("dlclive.processor") + + dlclive_mod.Processor = Processor + processor_mod.Processor = Processor + + monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) + monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) @pytest.fixture From c71ca2b363728e33c41522e5ab5233b59a10296f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 16:41:25 +0200 Subject: [PATCH 079/194] Make Engine a str enum and normalize model_type Update `Engine` to inherit from `str, Enum` so enum members behave like strings where needed. Also harden `from_model_type` by coercing non-string inputs (including enum-like values with `.value`) before lowercasing, and raise a clear `ValueError` when conversion is not possible. --- dlclivegui/temp/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/temp/engine.py b/dlclivegui/temp/engine.py index 22138ede9..e75701783 100644 --- a/dlclivegui/temp/engine.py +++ b/dlclivegui/temp/engine.py @@ -6,7 +6,7 @@ # or if we update dlclive.Engine to have these methods and use that instead of a separate enum here. # The latter would be more cohesive but also creates a dependency from utils to dlclive, # pending release of dlclive -class Engine(Enum): +class Engine(str, Enum): TENSORFLOW = "tensorflow" PYTORCH = "pytorch" From 00a36740cb34d055200781c8c6d9274a90a563f7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 16:44:46 +0200 Subject: [PATCH 080/194] Persist custom processor folder in settings Remember the processor folder across sessions and use it when initializing the main window. The folder is now saved when browsing, during refresh (after resolving a valid directory), and on close. Processor refresh messaging was updated to show whether processors came from the selected folder or the built-in package. Settings store gained processor-folder get/set helpers that validate and normalize paths, with safe fallback to defaults when paths are missing or invalid. --- dlclivegui/gui/main_window.py | 23 +++++++++++++------ dlclivegui/utils/settings_store.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index cc6570198..224d9fdf5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -446,7 +446,7 @@ def _build_dlc_group(self) -> QGroupBox: # Processor selection processor_path_layout = QHBoxLayout() self.processor_folder_edit = QLineEdit() - self.processor_folder_edit.setText(default_processors_dir()) + self.processor_folder_edit.setText(self._settings_store.get_processor_folder(default=default_processors_dir())) processor_path_layout.addWidget(self.processor_folder_edit) self.browse_processor_folder_button = QPushButton("Browse...") @@ -1085,10 +1085,11 @@ def _action_browse_directory(self) -> None: def _action_browse_processor_folder(self) -> None: """Browse for processor folder.""" - current_path = self.processor_folder_edit.text() or default_processors_dir() + current_path = self.processor_folder_edit.text().strip() or default_processors_dir() directory = QFileDialog.getExistingDirectory(self, "Select processor folder", current_path) if directory: self.processor_folder_edit.setText(directory) + self._settings_store.set_processor_folder(directory) self._refresh_processors() def _action_open_recording_folder(self) -> None: @@ -1142,10 +1143,17 @@ def _refresh_processors(self) -> None: self.processor_combo.addItem("No Processor", None) selected_folder = self.processor_folder_edit.text().strip() - if Path(selected_folder).exists(): - self._scanned_processors = scan_processor_folder(selected_folder) + selected_path = Path(selected_folder).expanduser() if selected_folder else None + + if selected_path is not None and selected_path.is_dir(): + resolved_folder = str(selected_path.resolve()) + self._settings_store.set_processor_folder(resolved_folder) + self._scanned_processors = scan_processor_folder(resolved_folder) + source_text = resolved_folder else: self._scanned_processors = scan_processor_package("dlclivegui.processors") + source_text = "package dlclivegui.processors" + self._processor_keys = list(self._scanned_processors.keys()) for key in self._processor_keys: @@ -1154,9 +1162,7 @@ def _refresh_processors(self) -> None: self.processor_combo.addItem(display_name, key) self.processor_combo.update_shrink_width() - self.statusBar().showMessage( - f"Found {len(self._processor_keys)} processor(s) in package dlclivegui.processors", 3000 - ) + self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) # ------------------------------------------------------------------ # Recording path preview and session name persistence @@ -2166,6 +2172,9 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit + if hasattr(self, "processor_folder_edit"): + self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) # Close the window super().closeEvent(event) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index a0c5677f4..0107afb1c 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -57,6 +57,42 @@ def get_fast_encoding(self, default: bool = False) -> bool: return value return str(value).strip().lower() in {"1", "true", "yes", "on"} + def get_processor_folder(self, default: str = "") -> str: + """ + Return the persisted processor folder if it still exists and is a directory. + Otherwise return default. + """ + value = self._s.value("dlc/processor_folder", default) + value = str(value).strip() if value is not None else "" + + if not value: + return default + + try: + path = Path(value).expanduser() + if path.is_dir(): + return str(path.resolve()) + except Exception: + logger.debug("Persisted processor folder is invalid: %s", value, exc_info=True) + + return default + + def set_processor_folder(self, folder: str) -> None: + """ + Persist processor folder only if it exists and is a directory. + Invalid folders are ignored. + """ + folder = str(folder).strip() if folder is not None else "" + if not folder: + return + + try: + path = Path(folder).expanduser() + if path.is_dir(): + self._s.setValue("dlc/processor_folder", str(path.resolve())) + except Exception: + logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) + def set_fast_encoding(self, enabled: bool) -> None: self._s.setValue("recording/fast_encoding", bool(enabled)) From 9e02c9b453789695f779ea6b384988e5c2f5232d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 1 Jul 2026 18:11:37 +0200 Subject: [PATCH 081/194] Improve recorder error logging and handling Enhance error reporting and handling for video recording. recording_manager now logs exception type, message, and frame shape/dtype when a write fails. VideoRecorder adds detailed messages for frame-size mismatches, queue retrieval errors, and encoding failures (including frame description, expected size, frames_written/frames_enqueued/dropped, and queue_size) and stops the recorder to avoid FFmpeg pipe errors. Introduced _describe_frame to summarize frames and _set_encode_error to centralize creation of a RuntimeError (preserving original exception as __cause__) and set _encode_error under the stats lock. Minor test file newline fix. --- dlclivegui/gui/recording_manager.py | 9 +++- dlclivegui/services/video_recorder.py | 73 ++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index c3584dd95..9c7a523aa 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -205,7 +205,14 @@ def write_frame( timestamp_metadata=timestamp_metadata, ) except Exception as exc: - log.warning("Failed to write frame for %s: %s", cam_id, exc) + log.warning( + "Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s", + cam_id, + type(exc).__name__, + str(exc) or repr(exc), + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) try: rec.stop() except Exception: diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index f57f60c5f..3a3c40b2a 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -300,15 +300,16 @@ def write( expected_h, expected_w = self._frame_size actual_h, actual_w = frame.shape[:2] if (actual_h, actual_w) != (expected_h, expected_w): - logger.warning( - f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " - f"got (h={actual_h}, w={actual_w}). " - "Stopping recorder to prevent encoding errors." + message = ( + f"Frame size mismatch for recorder {self._output.name}: " + f"expected_hw=({expected_h}, {expected_w}) " + f"actual_hw=({actual_h}, {actual_w}) " + f"{self._describe_frame(frame)}. " + "Stopping recorder to prevent FFmpeg pipe errors." ) - with self._stats_lock: - self._encode_error = ValueError( - f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" - ) + + logger.warning(message) + self._set_encode_error(message) self._process_timing.note_error() self._process_timing.maybe_log() return False @@ -448,9 +449,12 @@ def _writer_loop(self) -> None: break continue except Exception as exc: - with self._stats_lock: - self._encode_error = exc - logger.exception("Could not retrieve item from queue", exc_info=exc) + message = ( + f"Could not retrieve frame from recorder queue for {self._output.name}: " + f"{type(exc).__name__}: {exc!s}" + ) + self._set_encode_error(message, exc) + logger.exception(message) self._stop_event.set() break @@ -495,9 +499,28 @@ def _writer_loop(self) -> None: self._frame_timestamps.append(record) except Exception as exc: + queue_size = q.qsize() if q is not None else -1 + with self._stats_lock: - self._encode_error = exc - logger.exception("Video encoding failed while writing frame", exc_info=exc) + frames_enqueued = self._frames_enqueued + frames_written = self._frames_written + dropped_frames = self._dropped_frames + + message = ( + f"Video encoding failed for recorder {self._output.name}: " + f"{type(exc).__name__}: {exc!s}. " + f"{self._describe_frame(frame)} " + f"expected_frame_size={self._frame_size} " + f"frames_written={frames_written} " + f"frames_enqueued={frames_enqueued} " + f"dropped={dropped_frames} " + f"queue_size={queue_size}. " + "The FFmpeg/WriteGear pipe is no longer usable; stopping this recorder." + ) + + self._set_encode_error(message, exc) + + logger.exception(message) self._stop_event.set() self._writer_timing.note_error() self._writer_timing.maybe_log() @@ -569,10 +592,34 @@ def _compute_write_fps_locked(self) -> float: return 0.0 return (len(self._written_times) - 1) / duration + def _describe_frame(self, frame: np.ndarray | None) -> str: + if frame is None: + return "frame=None" + + try: + return ( + f"shape={frame.shape} " + f"dtype={frame.dtype} " + f"contiguous={frame.flags.c_contiguous} " + f"nbytes={frame.nbytes / (1024 * 1024):.2f}MB" + ) + except Exception: + return f"frame=" + def _current_error(self) -> Exception | None: with self._stats_lock: return self._encode_error + def _set_encode_error(self, message: str, exc: Exception | None = None) -> Exception: + error = RuntimeError(message) + if exc is not None: + error.__cause__ = exc + + with self._stats_lock: + self._encode_error = error + + return error + def _save_timestamps(self) -> None: """Save frame timestamps to a JSON file alongside the video.""" if not self._frame_timestamps: From 9e2745435583eca7abd5cd47db67504437d04e82 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 10:56:56 +0200 Subject: [PATCH 082/194] Hide base socket processor from discovery Mark `BaseProcessorSocket` as non-discoverable and update processor subclass filtering to respect a class-level `PROCESSOR_DISCOVERABLE = False` flag only when set on the class itself. This keeps abstract/base classes out of selectable processor lists while allowing concrete subclasses to remain discoverable by default. Also improves the subclass-check docstring and exception logging message formatting. --- dlclivegui/processors/dlc_processor_socket.py | 1 + dlclivegui/processors/processor_utils.py | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 594512c24..0cab02063 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -40,6 +40,7 @@ class BaseProcessorSocket(Processor): PROCESSOR_NAME = "Base Socket Processor" PROCESSOR_DESCRIPTION = "Base class for socket-based processors with multi-client support" PROCESSOR_PARAMS = {} + PROCESSOR_DISCOVERABLE = False # base class, not intended to be an example processor def __init__( self, diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 467792b03..babd96eaf 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -23,8 +23,12 @@ def _processor_base_class(): return Processor -def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: - """Return True for dlclive.Processor subclasses, including indirect subclasses.""" +def _is_processor_subclass( + obj, + *, + include_base: bool = False, +) -> bool: + """Return whether obj is a selectable Processor subclass.""" if not inspect.isclass(obj): return False @@ -37,9 +41,22 @@ def _is_processor_subclass(obj, *, include_base: bool = False) -> bool: try: if obj is processor_base: return bool(include_base) - return issubclass(obj, processor_base) + + if not issubclass(obj, processor_base): + return False + + # Check only the class itself, not inherited values. This lets concrete + # subclasses of a non-discoverable base remain discoverable by default. + # getattr would return the inherited value. + if obj.__dict__.get("PROCESSOR_DISCOVERABLE", True) is False: + return False + + return True except Exception: - logger.exception(f"Error checking if {obj} is a subclass of dlclive.Processor") + logger.exception( + "Error checking whether %r is a Processor subclass", + obj, + ) return False From 9beed886e8419cde517866dac7f9021e0ecef451 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 10:57:36 +0200 Subject: [PATCH 083/194] Unify processor discovery and scan metadata Refactors processor scanning to consistently discover classes via `discover_processor_classes` for both package and file scans, removing the `get_available_processors` special-case path. Adds `_add_processor_results` to centralize normalization of scan entries (`file`, `class_name`, `file_path`) and avoid duplicated mutation logic. Also tightens function signatures with explicit type hints for scan/load/instantiate helpers. --- dlclivegui/processors/processor_utils.py | 73 ++++++++++++------------ 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index babd96eaf..e47dbe2f8 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -60,6 +60,27 @@ def _is_processor_subclass( return False +def _add_processor_results( + target: dict[str, dict], + processors: dict[str, dict], + *, + file_name: str, + file_path: str, +) -> None: + """Normalize discovered processors and add them to a scan result.""" + for class_name, processor_info in processors.items(): + key = f"{file_name}::{class_name}" + info = dict(processor_info) + info.update( + { + "file": file_name, + "class_name": class_name, + "file_path": file_path, + } + ) + target[key] = info + + def _processor_info_from_class(cls, fallback_name: str) -> dict: return { "class": cls, @@ -93,7 +114,7 @@ def discover_processor_classes(module, *, only_defined_in_module: bool = True) - return processors -def scan_processor_folder(folder_path): +def scan_processor_folder(folder_path: str | Path) -> dict[str, dict]: all_processors = {} folder = Path(folder_path) @@ -103,12 +124,12 @@ def scan_processor_folder(folder_path): try: processors = load_processors_from_file(py_file) - for class_or_id, processor_info in processors.items(): - key = f"{py_file.name}::{class_or_id}" - processor_info["file"] = py_file.name - processor_info["class_name"] = class_or_id - processor_info["file_path"] = str(py_file) - all_processors[key] = processor_info + _add_processor_results( + all_processors, + processors, + file_name=py_file.name, + file_path=str(py_file), + ) except Exception: logger.exception(f"Error loading {py_file}") @@ -133,26 +154,13 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ continue try: mod = import_module(mod_name) - # Skip dlc_processor_socket.py as it's the base class and registry - if mod.__name__.endswith("dlc_processor_socket"): - continue - - # Prefer module-level registry function if present - if hasattr(mod, "get_available_processors"): - processors = mod.get_available_processors() - else: - # Fallback: scan for dlclive.Processor subclasses - processors = discover_processor_classes(mod) - - # Normalize into your “file::class” shape - module_file = mod.__name__.split(".")[-1] + ".py" - for class_name, info in processors.items(): - key = f"{module_file}::{class_name}" - info = dict(info) # copy - info["file"] = module_file - info["class_name"] = class_name - info["file_path"] = mod.__file__ or "" - all_processors[key] = info + processors = discover_processor_classes(mod) + _add_processor_results( + all_processors, + processors, + file_name=mod_name.split(".")[-1] + ".py", + file_path=getattr(mod, "__file__", ""), + ) except Exception: logger.exception(f"Error importing processor module '{mod_name}'") @@ -160,7 +168,7 @@ def scan_processor_package(package_name: str = "dlclivegui.processors") -> dict[ return all_processors -def load_processors_from_file(file_path: str | Path): +def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: """ Load all processor classes from a Python file. @@ -185,13 +193,6 @@ def load_processors_from_file(file_path: str | Path): sys.modules[module_name] = module # Make visible during import for intra-module imports spec.loader.exec_module(module) - # Preferred path: the module exposes get_available_processors() - if hasattr(module, "get_available_processors"): - processors = module.get_available_processors() - if not isinstance(processors, dict): - raise TypeError(f"{file_path}: get_available_processors() must return a dict, got {type(processors)}") - return processors - # Fallback path: discover subclasses of dlclive.Processor return discover_processor_classes(module) @@ -201,7 +202,7 @@ def load_processors_from_file(file_path: str | Path): return {} -def instantiate_from_scan(processors_dict, processor_key, **kwargs): +def instantiate_from_scan(processors_dict: dict[str, dict], processor_key: str, **kwargs): """ Instantiate a processor from scan_processor_folder results. From ae2159053e296a1850c46c4eadca325af29b8615 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 11:05:39 +0200 Subject: [PATCH 084/194] Deprecate legacy processor registry API Marks the decorator-based processor registry as legacy and adds DeprecationWarning notices to registration, listing, and instantiation helpers. It also adds type hints and clearer docstrings, improves duplicate-key logging, and updates unknown-processor errors to better reflect legacy usage. --- dlclivegui/processors/registry.py | 86 ++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/dlclivegui/processors/registry.py b/dlclivegui/processors/registry.py index 28892975e..38a11e4d7 100644 --- a/dlclivegui/processors/registry.py +++ b/dlclivegui/processors/registry.py @@ -1,53 +1,89 @@ +from __future__ import annotations + import logging +import warnings logger = logging.getLogger(__name__) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} +# Legacy compatibility registry. +# GUI discovery no longer depends on this registry. +PROCESSOR_REGISTRY: dict[str, type] = {} def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - msg = ( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + """Register a processor for backward compatibility. + + New processor modules do not need this decorator. Processor discovery now + finds eligible dlclive.Processor subclasses directly. + """ + warnings.warn( + "@register_processor is deprecated and no longer required for GUI " + "discovery. Define a discoverable Processor subclass instead.", + DeprecationWarning, + stacklevel=2, + ) + + registry_key = str(getattr(cls, "PROCESSOR_ID", cls.__name__)) + + existing = PROCESSOR_REGISTRY.get(registry_key) + if existing is not None and existing is not cls: + logger.warning( + "Duplicate legacy processor registration key %r: %s vs %s", + registry_key, + existing.__name__, + cls.__name__, ) - logger.warning(msg) + PROCESSOR_REGISTRY[registry_key] = cls return cls -def get_available_processors(): - """ - Get list of available processor classes. +def get_available_processors() -> dict[str, dict]: + """Return processors registered through the legacy decorator. - Returns: - dict: Dictionary mapping registry keys to processor info. + Deprecated: + GUI discovery now inspects Processor subclasses directly. """ + warnings.warn( + "get_available_processors() is deprecated. Use " + "discover_processor_classes(), scan_processor_package(), or " + "scan_processor_folder() instead.", + DeprecationWarning, + stacklevel=2, + ) + return { name: { "class": cls, "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "description": getattr( + cls, + "PROCESSOR_DESCRIPTION", + "", + ), "params": getattr(cls, "PROCESSOR_PARAMS", {}), } for name, cls in PROCESSOR_REGISTRY.items() } -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs +def instantiate_processor( + class_name: str, + **kwargs, +): + """Instantiate a processor from the legacy registry. - Raises: - ValueError: If class_name is not in registry + Deprecated: + Use instantiate_from_scan() with scanner output instead. """ + warnings.warn( + "instantiate_processor() is deprecated. Use instantiate_from_scan() instead.", + DeprecationWarning, + stacklevel=2, + ) + if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + available = ", ".join(sorted(PROCESSOR_REGISTRY)) + raise ValueError(f"Unknown processor {class_name!r}. Available legacy registrations: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) From 1f6637469ab11255aa94720aa48ef106eec50536 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 11:58:32 +0200 Subject: [PATCH 085/194] Update processor discovery tests Refactors test helper modules to define real `Processor` subclasses instead of exposing `get_available_processors`, aligning tests with subclass-based discovery behavior. Renames the file-loading test accordingly, simplifies assertions to match the new discovery path, and adds a regression test ensuring legacy `@register_processor` usage remains import-compatible while emitting a `DeprecationWarning`. --- .../test_builtin_discovery_utils.py | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/tests/custom_processors/test_builtin_discovery_utils.py b/tests/custom_processors/test_builtin_discovery_utils.py index d91caae0a..f52489dcb 100644 --- a/tests/custom_processors/test_builtin_discovery_utils.py +++ b/tests/custom_processors/test_builtin_discovery_utils.py @@ -2,7 +2,6 @@ from __future__ import annotations import importlib -import uuid from pathlib import Path import pytest @@ -21,40 +20,41 @@ # --------------------------------------------------------------------------- -def _write_temp_processor_file(tmp_path: Path, stem: str | None = None) -> Path: - """ - Create a temporary processor module that exposes get_available_processors() - so we don't depend on dlclive.Processor being importable. - - The dummy processor has safe __init__ and no side-effects. - """ - stem = stem or f"tmp_proc_{uuid.uuid4().hex}" +def _write_temp_processor_file( + tmp_path: Path, + *, + stem: str = "dummy_proc", +) -> Path: py_file = tmp_path / f"{stem}.py" - py_file.write_text( - # Use get_available_processors to bypass dlclive import in loader. """ -class DummyProc: +from dlclive.processor import Processor + + +class DummyProc(Processor): PROCESSOR_NAME = "Dummy Processor" - PROCESSOR_DESCRIPTION = "A safe, dummy processor for tests" + PROCESSOR_DESCRIPTION = "Test processor" PROCESSOR_PARAMS = { - "foo": {"type": "int", "default": 1, "description": "dummy param"} + "foo": { + "type": "int", + "default": 0, + "description": "Test integer parameter", + }, + "bar": { + "type": "str", + "default": "", + "description": "Test string parameter", + }, } def __init__(self, **kwargs): - self.kwargs = kwargs - -def get_available_processors(): - # Return the normalized mapping the loader expects - return { - "DummyProc": { - "class": DummyProc, - "name": DummyProc.PROCESSOR_NAME, - "description": DummyProc.PROCESSOR_DESCRIPTION, - "params": DummyProc.PROCESSOR_PARAMS, - } - } -""" + super().__init__() + self.kwargs = dict(kwargs) + + def process(self, pose, **kwargs): + return pose +""", + encoding="utf-8", ) return py_file @@ -109,16 +109,14 @@ def test_scan_processor_package_populates_and_has_valid_shape(): # --------------------------------------------------------------------------- -def test_load_processors_from_file_prefers_registry(tmp_path: Path): +def test_load_processors_from_file_discovers_subclass(tmp_path: Path): py_file = _write_temp_processor_file(tmp_path) result = load_processors_from_file(py_file) assert isinstance(result, dict) assert "DummyProc" in result info = result["DummyProc"] - # For load_processors_from_file (registry path), the minimal fields are present: assert "class" in info and info["class"].__name__ == "DummyProc" assert info["name"] == "Dummy Processor" - assert "params" in info and "foo" in info["params"] def test_scan_processor_folder_discovers_files_and_normalizes_shape(tmp_path: Path): @@ -165,3 +163,23 @@ def test_display_processor_info_prints(capsys, tmp_path: Path): assert "Dummy Processor" in captured assert "Parameters:" in captured assert "- foo (int)" in captured or "foo" in captured # depends on your formatter + + +def test_legacy_register_processor_remains_import_compatible(): + from dlclive.processor import Processor + + from dlclivegui.processors import ( + PROCESSOR_REGISTRY, + register_processor, + ) + + PROCESSOR_REGISTRY.pop("LegacyProc", None) + + with pytest.warns(DeprecationWarning): + + @register_processor + class LegacyProc(Processor): + def process(self, pose, **kwargs): + return pose + + assert PROCESSOR_REGISTRY["LegacyProc"] is LegacyProc From 98c2f55437515f1e6173e264839340a26defc649 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 12:11:28 +0200 Subject: [PATCH 086/194] Require dlclive in socket processor tests Remove the runtime fallback that replaced `dlclive.processor.Processor` with `object` and import `Processor` directly in `dlc_processor_socket`. Update custom processor tests to stop mocking `dlclive`; they now use `pytest.importorskip` and import real modules only when DLCLive is installed. --- dlclivegui/processors/dlc_processor_socket.py | 6 +-- .../custom_processors/test_base_processor.py | 48 ++++--------------- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 0cab02063..6a91ef1a3 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -13,11 +13,7 @@ import numpy as np import pandas as pd - -try: - from dlclive.processor import Processor # type: ignore -except ImportError: - Processor = object # Fallback for type checking if dlclive is not installed +from dlclive.processor import Processor # type: ignore logger = logging.getLogger("dlc_processor_socket") diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index 94dabab89..8711eec11 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -3,8 +3,6 @@ import importlib import pickle -import sys -import types from pathlib import Path import numpy as np @@ -12,48 +10,20 @@ import pytest -def _mock_dlclive(monkeypatch): - class Processor: - def __init__(self, *args, **kwargs): - pass - - def process(self, pose, **kwargs): - return pose - - dlclive_mod = types.ModuleType("dlclive") - processor_mod = types.ModuleType("dlclive.processor") - - dlclive_mod.Processor = Processor - processor_mod.Processor = Processor +@pytest.fixture +def socket_mod(): + """Import the socket processor using the installed DLCLive package.""" + pytest.importorskip("dlclive.processor") - monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) - monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) + return importlib.import_module("dlclivegui.processors.dlc_processor_socket") @pytest.fixture -def socket_mod(monkeypatch): - """ - Import the processor module with dlclive mocked. - Adjust module name if your file lives elsewhere. - """ - _mock_dlclive(monkeypatch) - mod_name = "dlclivegui.processors.dlc_processor_socket" - if mod_name in sys.modules: - del sys.modules[mod_name] - return importlib.import_module(mod_name) - +def example_processor_mod(): + """Import the built-in example processors normally.""" + pytest.importorskip("dlclive.processor") -@pytest.fixture -def example_processor_mod(monkeypatch): - """ - Import the example processor module with dlclive mocked. - Adjust module name if your file lives elsewhere. - """ - _mock_dlclive(monkeypatch) - mod_name = "dlclivegui.processors.examples" - if mod_name in sys.modules: - del sys.modules[mod_name] - return importlib.import_module(mod_name) + return importlib.import_module("dlclivegui.processors.examples") def _module_data_dir(socket_mod) -> Path: From 9f71adf36376848a126f8562b447627efbdf0f41 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 12:12:00 +0200 Subject: [PATCH 087/194] Add discovery tests for built-in processors Expand built-in discovery coverage by importing and using `discover_processor_classes` and `_is_processor_subclass` in processor utility tests. The new tests ensure the `dlclivegui.processors.examples` module exposes discoverable `Processor` subclasses and specifically verify that `ExampleProcessorSocketCalculateMousePose` remains selectable under the discovery rules. --- .../test_builtin_discovery_utils.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/custom_processors/test_builtin_discovery_utils.py b/tests/custom_processors/test_builtin_discovery_utils.py index f52489dcb..f5041f7d9 100644 --- a/tests/custom_processors/test_builtin_discovery_utils.py +++ b/tests/custom_processors/test_builtin_discovery_utils.py @@ -7,7 +7,9 @@ import pytest from dlclivegui.processors.processor_utils import ( + _is_processor_subclass, default_processors_dir, + discover_processor_classes, display_processor_info, instantiate_from_scan, load_processors_from_file, @@ -87,6 +89,35 @@ def test_default_processors_dir_exists(): # --------------------------------------------------------------------------- +def test_builtin_examples_module_has_discoverable_processors(): + from dlclivegui.processors import examples + + processors = discover_processor_classes(examples) + + assert processors, "No discoverable Processor subclasses found in dlclivegui.processors.examples" + + +def test_builtin_example_processor_is_selectable(): + from dlclive.processor import Processor + + from dlclivegui.processors.examples import ( + ExampleProcessorSocketCalculateMousePose, + ) + + cls = ExampleProcessorSocketCalculateMousePose + + assert issubclass(cls, Processor) + assert cls.__module__ == "dlclivegui.processors.examples" + assert ( + cls.__dict__.get( + "PROCESSOR_DISCOVERABLE", + True, + ) + is not False + ) + assert _is_processor_subclass(cls) + + @pytest.mark.skipif( importlib.util.find_spec("dlclivegui.processors") is None, reason="dlclivegui.processors package not importable in this test environment", From 5edf7fe2a0c109a8970298c78e99462702d9e439 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 13:51:23 +0200 Subject: [PATCH 088/194] Update processor plugin system docs Revise PLUGIN_SYSTEM.md to reflect class-based processor discovery (including PROCESSOR_DISCOVERABLE behavior) instead of registry-driven discovery, and document legacy registration compatibility. Clarify GUI control-gating semantics, socket processor expectations, and modern instructions for creating and configuring custom processors. --- dlclivegui/processors/PLUGIN_SYSTEM.md | 271 ++++++++++++------------- 1 file changed, 132 insertions(+), 139 deletions(-) diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index e6a143626..6d48c843c 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -1,61 +1,64 @@ -# DeepLabCut Live GUI — Processor Plugin System +# DeepLabCut Live GUI: Processor Plugin System This repository includes a **plugin-style processor system** that lets the GUI discover and instantiate **DLCLive processors** dynamically. -Processors are Python classes (typically subclasses of `dlclive.Processor`) that can optionally: +Processors are Python classes that subclass `dlclive.processor.Processor`, directly or indirectly, and can optionally: -- receive pose estimates during inference (via `process(pose, **kwargs)`), -- broadcast pose-derived data to external clients (e.g., for experiment control), -- expose metadata so the GUI can list them and (optionally) build simple parameter UIs. +- Receive pose estimates during inference through `process(pose, **kwargs)` +- Broadcast pose-derived data, for example for experiment control +- Expose metadata so the GUI can list them and support processor configuration -> **Security / control note:** The GUI should treat processors as **optional, user-controlled extensions**. In our current design, the GUI exposes an opt-in toggle (recommended label: **“Allow processor control”**) that gates whether processor plugins are instantiated and whether the GUI reads/acts on processor state. - ---- +> The GUI should treat processors as **optional, user-controlled extensions**. +> In our current design, the GUI exposes an opt-in toggle, **Allow processor-based control**, that controls whether processor plugins are instantiated and whether the GUI reads or acts on processor state. ## Overview ### Useful files -- `dlclivegui/processors/dlc_processor_socket.py` — Example socket-based processor base class -- `dlclivegui/processors/examples.py` — Example processor implementations (e.g., One-Euro filter) -- `dlclivegui/processors/processor_utils.py` — Scanning + instantiation helpers used by the GUI - ---- +- `dlclivegui/processors/dlc_processor_socket.py`: Example socket-based processor base class +- `dlclivegui/processors/examples.py`: Example processor implementations, such as One-Euro filtering +- `dlclivegui/processors/processor_utils.py`: Scanning and instantiation helpers used by the GUI ## Architecture -### 1) Processor registry (module-level) +### 1) Processor class discovery -A typical processor module defines a registry and a decorator. The decorator registers classes into `PROCESSOR_REGISTRY` using either `PROCESSOR_ID` (if present) or the class name. +A processor module defines one or more classes that subclass `dlclive.processor.Processor`, directly or indirectly. -```python -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} +The GUI discovers eligible processor classes by inspecting the imported module. -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - PROCESSOR_REGISTRY[registry_key] = cls - return cls -``` +```python +from dlclive.processor import Processor -Register processors by decorating the class: -```python -@register_processor -class ExampleProcessor(BaseProcessorSocket): +class ExampleProcessor(Processor): PROCESSOR_NAME = "Example Processor" PROCESSOR_DESCRIPTION = "Example description" PROCESSOR_PARAMS = {} + + def process(self, pose, **kwargs): + return pose ``` +Only processor classes defined in the scanned module are included. Processor classes imported from another module are ignored to avoid duplicate entries. + +Reusable base classes that should not appear in the GUI can explicitly opt out: + +```python +class BaseProcessorSocket(Processor): + PROCESSOR_DISCOVERABLE = False +``` + +Concrete subclasses of a non-discoverable base class remain discoverable by default. + ### 2) Processor metadata Each processor class should define metadata attributes to help GUI discovery: ```python class MyProcessorSocket(BaseProcessorSocket): - PROCESSOR_NAME = "Mouse Pose Processor" # Human-readable - PROCESSOR_DESCRIPTION = "Broadcasts processed pose values" + PROCESSOR_NAME = "Use Pose Processor" # Human-readable + PROCESSOR_DESCRIPTION = "BBroadcasts processed pose values" PROCESSOR_PARAMS = { "bind": { "type": "tuple", @@ -77,64 +80,46 @@ class MyProcessorSocket(BaseProcessorSocket): > **Recommendation:** For security, prefer binding to `127.0.0.1` unless you explicitly want LAN exposure. -### 3) Module-level discovery helpers (optional) -Processor modules can expose: - -- `get_available_processors()` — returns a dictionary of available processors and metadata - -Example: - -```python -def get_available_processors(): - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } -``` - ---- - -## Discovery & instantiation (current utilities) +## Discovery & instantiation The GUI uses utilities from `dlclivegui/processors/processor_utils.py`: -- `scan_processor_folder(folder_path)` — discover processors from `*.py` files in a folder -- `scan_processor_package(package_name="dlclivegui.processors")` — discover processors from a package namespace -- `instantiate_from_scan(processors_dict, processor_key, **kwargs)` — instantiate a processor from scan output +- `discover_processor_classes(module)`: discover eligible processor classes in an imported module +- `scan_processor_folder(folder_path)`: discover processors from `*.py` files in a folder +- `scan_processor_package(package_name="dlclivegui.processors")`: discover processors from a package namespace +- `instantiate_from_scan(processors_dict, processor_key, **kwargs)`: instantiate a processor from scan output + +Package and folder scanning use different module-loading mechanisms, but both use the same class-based processor discovery. ### Key format Scan results are dictionaries keyed like: -``` -"some_file.py::SomeProcessorClassOrId" +```text +some_file.py::SomeProcessorClass ``` -Each entry contains (at least): +Each entry contains at least: - `class`: the processor class object - `name`: display name - `description`: description text - `params`: parameter schema - `file`: module filename -- `class_name`: class/registry key +- `class_name`: processor class name - `file_path`: full path to the module file ### Example: scanning and instantiating ```python from dlclivegui.processors.processor_utils import ( - scan_processor_package, - scan_processor_folder, instantiate_from_scan, + scan_processor_folder, + scan_processor_package, ) + # Built-in processors processors = scan_processor_package("dlclivegui.processors") @@ -143,121 +128,129 @@ processors = scan_processor_package("dlclivegui.processors") # List for key, info in processors.items(): - print(f"{info['name']} ({key}) — {info['description']}") + print(f"{info['name']} ({key}): {info['description']}") # Instantiate -selected_key = next(iter(processors.keys())) -proc = instantiate_from_scan(processors, selected_key, bind=("127.0.0.1", 6000)) +selected_key = next(iter(processors)) +proc = instantiate_from_scan( + processors, + selected_key, + bind=("127.0.0.1", 6000), +) ``` ---- +### Legacy registration compatibility -## GUI integration & the “Allow processor control” gate +Earlier processor modules may still import and use: -### Recommended behavior +```python +from dlclivegui.processors import PROCESSOR_REGISTRY, register_processor +``` -To keep processor behavior explicit and opt-in, the GUI provides a toggle (**Allow processor-based control**) with these semantics: +The registry and decorator remain temporarily available for compatibility with existing processor modules. However: -- **Disabled (default):** - - the GUI does **not instantiate** any processor plugin; - - the GUI does **not read or act** on processor state (connections, recording flags, remote commands); - - inference runs with `processor=None`. - - *processor code may be imported by the discovery process* +- GUI discovery does not use `PROCESSOR_REGISTRY` +- GUI discovery does not call `get_available_processors()` +- Decorating a class is not required for discovery +- An existing decorated class remains discoverable because the decorator returns the original class -- **Enabled:** - - the GUI may instantiate the selected processor and (optionally) reflect processor state in the UI. - - the processor will be used by the `DLCLive` instance during inference. +New processor modules should rely on subclass discovery instead of defining a registry or discovery function. -This lets users decide whether they want to run processor plugins and whether those plugins may influence UI/recording behavior. +## GUI integration & enabling custom processors -> We recommend users to follow this design patter when designing their own processors -> to help ensure predictable behavior and clear user control over processor-based features.
-> **We are not responsible for any unexpected behavior caused by custom processors,** -> **and the examples are provided as-is with no guarantees.** +### Recommended behavior ---- +To keep processor behavior explicit and opt-in, the GUI provides an **Allow processor-based control** toggle with these effects: -## Socket-based processors (example base class) +- **Disabled by default:** + - The GUI does **not instantiate** any processor plugin + - The GUI does **not read or act** on processor state, such as connections, recording flags, or remote commands + - Inference runs with `processor=None` + - Processor code may still be imported by the discovery process -The built-in `BaseProcessorSocket` (in `dlc_processor_socket.py`) demonstrates a simple approach for: +- **Enabled:** + - The GUI may instantiate the selected processor and reflect processor state in the UI + - The processor is used by the `DLCLive` instance during inference -- accepting multiple clients, -- receiving control messages (e.g., start/stop recording), -- broadcasting payloads to connected clients, -- cleaning up reliably on shutdown. +This lets users decide whether they want to run processor plugins and whether those plugins may influence UI or recording behavior. -### Key points +> We recommend that users follow this design pattern when creating processors to help ensure predictable behavior and clear user control over processor-based features. +> **We are not responsible for unexpected behavior caused by custom processors, and the examples are provided as-is with no guarantees.** -- Socket server is optional: `BaseProcessorSocket` supports `start_server(...)`. -- Connections are tracked in `self.conns`. -- `broadcast(payload)` sends to all clients; failing clients are dropped. -- `stop()` closes clients and listener, joins threads, and attempts to wake `accept()` during shutdown. +## Socket-based processors -> **Tip:** If you publish processors for others to use, keep module import side-effect free (define classes/functions only). +The built-in `BaseProcessorSocket` in `dlc_processor_socket.py` demonstrates a simple approach for: ---- +- Accepting multiple clients +- Receiving control messages, such as start and stop recording, +- Broadcasting payloads to connected clients, +- Cleaning up reliably on shutdown. -## Adding a new processor +`BaseProcessorSocket` is a reusable base class and is not shown as a selectable processor in the GUI: -1) Create a new module file in a processor folder (or inside `dlclivegui/processors/`). +```python +PROCESSOR_DISCOVERABLE = False +``` -2) Define a processor class and metadata: +Concrete subclasses defined in processor modules are discovered normally. -```python -from dlclive import Processor -from dlclivegui.processors import register_processor, PROCESSOR_REGISTRY +### Key points -@register_processor -class MyNewProcessor(Processor): - PROCESSOR_NAME = "My New Processor" - PROCESSOR_DESCRIPTION = "Does something cool" - PROCESSOR_PARAMS = { - "my_param": {"type": "bool", "default": True, "description": "Enable cool feature"} - } +- The socket server is optional: `BaseProcessorSocket` supports `start_server(...)`. +- Connections are tracked in `self.conns`. +- `broadcast(payload)` sends to all clients, and failing clients are dropped. +- `stop()` closes clients and the listener, joins threads, and attempts to wake `accept()` during shutdown. - def process(self, pose, **kwargs): - # Do something with pose - return pose +> **Tip:** If you publish processors for others to use, keep module imports side-effect free where possible. Define classes and functions during import, and initialize sockets, hardware, or other resources when the processor is instantiated. +## Adding a new processor -def get_available_processors(): - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), +1. Create a new module file in a processor folder or inside `dlclivegui/processors/`. + +2. Define a processor class and metadata: + ```python + from dlclive.processor import Processor + + class MyNewProcessor(Processor): + PROCESSOR_NAME = "My New Processor" + PROCESSOR_DESCRIPTION = "Does something useful" + PROCESSOR_PARAMS = { + "my_param": { + "type": "bool", + "default": True, + "description": "Enable optional behavior", + } } - for name, cls in PROCESSOR_REGISTRY.items() - } -``` -3) Refresh processors in the GUI, select your processor, and start inference (with processor control enabled if required). + def __init__(self, my_param: bool = True): + super().__init__() + self.my_param = my_param + + def process(self, pose, **kwargs): + # Do something with pose + return pose + ``` + No registration decorator, module-level registry, or `get_available_processors()` function is required. ---- +3. Refresh processors in the GUI, select your processor, and start inference with processor control enabled if required. ## Parameter schema types Supported `PROCESSOR_PARAMS` types: -- `"bool"` — checkbox -- `"int"` — integer input -- `"float"` — float input -- `"str"` — string input -- `"bytes"` — string that gets encoded to bytes -- `"tuple"` — tuple (e.g., `(host, port)`) -- `"dict"` — dictionary -- `"list"` — list +- `"bool"`: checkbox +- `"int"`: integer input +- `"float"`: float input +- `"str"`: string input +- `"bytes"`: string that gets encoded to bytes +- `"tuple"`: tuple, for example `(host, port)` +- `"dict"`: dictionary +- `"list"`: list ---- +The processor constructor remains the base definition of accepted arguments and values. ## Notes on external processors -External processors are arbitrary Python code. Only load processors you trust. - - - -## License +External processors are arbitrary Python code and are imported during discovery. Only load processors you trust. -This project is distributed under its project license. -See `LICENSE` in the repository. +Where possible, processor modules should avoid import-time side effects and initialize files, sockets, hardware, or other resources only when the processor is instantiated. From 633afa95fbd390364870d4ced58af2bb1726ccbf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 15:34:22 +0200 Subject: [PATCH 089/194] Refine custom processor UI controls Renames and repurposes processor control UI to a custom-processor toggle tied to processor selection, including a combined status/toggle row that only appears when a processor is chosen. Processor instantiation and status updates are now gated by the new `_custom_processor_enabled` logic, and disabled selections are explicitly reported without loading a plugin. Updated the recording paths UI test to use the new checkbox. --- dlclivegui/gui/main_window.py | 107 ++++++++++++++++----------- tests/gui/test_recording_paths_ui.py | 2 +- 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 224d9fdf5..858a74afd 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -481,13 +481,34 @@ def _build_dlc_group(self) -> QGroupBox: processing_sttgs = lyts.make_two_field_row( "Inference camera", self.dlc_camera_combo, - "Processor", + "Custom processor", self.processor_combo, key_width=None, ) self.dlc_camera_combo.update_shrink_width() form.addRow(processing_sttgs) + self.processor_status_label = QLabel("Processor: No clients | Recording: No") + self.processor_status_label.setWordWrap(True) + # form.addRow("Processor Status", self.processor_status_label) + self.use_custom_proc_checkbox = QCheckBox("Use custom processor") + self.use_custom_proc_checkbox.setChecked(False) + self.use_custom_proc_checkbox.setToolTip( + "If enabled, the GUI will load and interact with the selected processor plugin.\n" + ) + self.processor_toggle_row = lyts.make_two_field_row( + "Processor status", + self.processor_status_label, + None, + self.use_custom_proc_checkbox, + key_width=None, + left_stretch=0, + right_stretch=0, + style_values=False, + ) + self.processor_toggle_row.setVisible(False) # Hide until a processor is selected + form.addRow(self.processor_toggle_row) + # Wrap inference buttons in a widget to prevent shifting inference_button_widget = QWidget() inference_buttons = QHBoxLayout(inference_button_widget) @@ -508,17 +529,6 @@ def _build_dlc_group(self) -> QGroupBox: # self.show_predictions_checkbox.setChecked(True) # form.addRow(self.show_predictions_checkbox) - self.allow_processor_ctrl_checkbox = QCheckBox("Allow processor-based control") - self.allow_processor_ctrl_checkbox.setChecked(False) - self.allow_processor_ctrl_checkbox.setToolTip( - "If enabled, the GUI will load and interact with the selected processor plugin.\n" - ) - form.addRow(self.allow_processor_ctrl_checkbox) - - self.processor_status_label = QLabel("Processor: No clients | Recording: No") - self.processor_status_label.setWordWrap(True) - form.addRow("Processor Status", self.processor_status_label) - return group def _build_recording_group(self) -> QGroupBox: @@ -801,8 +811,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) + self.processor_combo.currentIndexChanged.connect(self._on_processor_selection_changed) + self.use_custom_proc_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) # Recording settings ## Session name persistence + preview updates @@ -1133,9 +1143,11 @@ def _action_open_recording_folder(self) -> None: logger.error(f"Failed to open folder: {exc}") self.statusBar().showMessage("Could not open recording folder.", 5000) - def _processor_control_enabled(self) -> bool: + def _custom_processor_enabled(self) -> bool: return bool( - getattr(self, "allow_processor_ctrl_checkbox", None) and self.allow_processor_ctrl_checkbox.isChecked() + getattr(self, "use_custom_proc_checkbox", None) + and self.use_custom_proc_checkbox.isChecked() + and self.processor_combo.currentData() is not None ) def _refresh_processors(self) -> None: @@ -1710,23 +1722,20 @@ def _configure_dlc(self) -> bool: # Instantiate processor if selected processor = None - if self._processor_control_enabled(): - selected_key = self.processor_combo.currentData() - if selected_key is not None and self._scanned_processors: - try: - # For now, instantiate with no parameters - processor = instantiate_from_scan(self._scanned_processors, selected_key) - processor_name = self._scanned_processors[selected_key]["name"] - self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) - except Exception as e: - error_msg = f"Failed to instantiate processor: {e}" - self._show_error(error_msg) - logger.error(error_msg) - return False - else: - selected_key = self.processor_combo.currentData() - if selected_key is not None: - self.statusBar().showMessage(f"Processor selection ignored (control disabled): {selected_key}", 3000) + selected_key = self.processor_combo.currentData() + if self._custom_processor_enabled(): + try: + # For now, instantiate with no parameters + processor = instantiate_from_scan(self._scanned_processors, selected_key) + processor_name = self._scanned_processors[selected_key]["name"] + self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) + except Exception as e: + error_msg = f"Failed to instantiate processor: {e}" + self._show_error(error_msg) + logger.error(error_msg) + return False + elif selected_key is not None: + self.statusBar().showMessage(f"Custom processor disabled: {selected_key}", 3000) self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) @@ -1760,8 +1769,8 @@ def _update_dlc_controls_enabled(self) -> None: for widget in processor_widgets: widget.setEnabled(allow_changes) - if hasattr(self, "allow_processor_ctrl_checkbox"): - self.allow_processor_ctrl_checkbox.setEnabled(allow_changes) + if hasattr(self, "use_custom_proc_checkbox"): + self.use_custom_proc_checkbox.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: multi_cam_recording = self._rec_manager.is_active @@ -1851,7 +1860,7 @@ def _update_metrics(self) -> None: self.dlc_stats_label.setText("DLC processor idle") # Update processor status (connection and recording state) - if hasattr(self, "processor_status_label") and self._processor_control_enabled(): + if hasattr(self, "processor_status_label") and self._custom_processor_enabled(): self._update_processor_status() # --- Recorder stats --- @@ -1863,26 +1872,40 @@ def _update_metrics(self) -> None: else: self.recording_stats_label.setText(self._last_recorder_summary) + def _on_processor_selection_changed( + self, + _index: int, + ) -> None: + """Enable custom processing when a processor is selected.""" + has_selection = self.processor_combo.currentData() is not None + self.processor_toggle_row.setVisible(has_selection) + + self.use_custom_proc_checkbox.blockSignals(True) + self.use_custom_proc_checkbox.setChecked(has_selection) + self.use_custom_proc_checkbox.blockSignals(False) + + self._update_processor_status() + def _update_processor_status(self) -> None: """Update processor connection and recording status, handle auto-recording.""" - if not self._processor_control_enabled(): - self.processor_status_label.setText("Processor control disabled") + if not self._custom_processor_enabled(): + self.processor_status_label.setText("Disabled") return if not self._dlc_active or not self._dlc_initialized: - self.processor_status_label.setText("Processor: Not active") + self.processor_status_label.setText("Not active") return # Get processor instance from _dlc processor = self._dlc._processor if processor is None: - self.processor_status_label.setText("Processor: None loaded") + self.processor_status_label.setText("None loaded") return # Check if processor has the required attributes (socket-based processors) if not hasattr(processor, "conns") or not hasattr(processor, "_recording"): - self.processor_status_label.setText("Processor: No status info") + self.processor_status_label.setText("No status info") return # Get connection count and recording state @@ -1895,7 +1918,7 @@ def _update_processor_status(self) -> None: self.processor_status_label.setText(f"Clients: {client_str} | Recording: {recording_str}") # Handle auto-recording based on processor's video recording flag - if hasattr(processor, "_vid_recording") and self.allow_processor_ctrl_checkbox.isChecked(): + if hasattr(processor, "_vid_recording") and self.use_custom_proc_checkbox.isChecked(): current_vid_recording = processor.video_recording # Check if video recording state changed diff --git a/tests/gui/test_recording_paths_ui.py b/tests/gui/test_recording_paths_ui.py index 234c3133a..7ccdff072 100644 --- a/tests/gui/test_recording_paths_ui.py +++ b/tests/gui/test_recording_paths_ui.py @@ -139,7 +139,7 @@ def test_processor_overrides_session_name_and_persists(window, start_all_spy, mo # Arrange window state so processor status logic runs window._dlc_active = True window._dlc_initialized = True - window.allow_processor_ctrl_checkbox.setChecked(True) + window.use_custom_proc_checkbox.setChecked(True) # Patch start_recording to avoid preview start/timers monkeypatch.setattr(window, "_start_recording", lambda: window._start_multi_camera_recording()) From 6e56be6a2a7a0abc078b743c1cbd2ffc58d8e3b5 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 16:09:42 +0200 Subject: [PATCH 090/194] Improve processor session override GUI test Refactor `test_processor_overrides_session_name_and_persists` for clarity and reliability by setting up the processor combo selection explicitly, keeping DLC state initialization together, and formatting monkeypatch/setup logic more readably. The assertions still verify that processor-generated session names update the UI and are passed into recording start kwargs. --- tests/gui/test_recording_paths_ui.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/gui/test_recording_paths_ui.py b/tests/gui/test_recording_paths_ui.py index 7ccdff072..c6561d698 100644 --- a/tests/gui/test_recording_paths_ui.py +++ b/tests/gui/test_recording_paths_ui.py @@ -135,26 +135,35 @@ def test_start_recording_passes_session_and_timestamp(window, start_all_spy, qtb assert recording.filename == window.filename_edit.text() -def test_processor_overrides_session_name_and_persists(window, start_all_spy, monkeypatch, fake_processor): - # Arrange window state so processor status logic runs +def test_processor_overrides_session_name_and_persists( + window, + start_all_spy, + monkeypatch, + fake_processor, +): window._dlc_active = True window._dlc_initialized = True + + window.processor_combo.addItem( + "Fake Processor", + "fake_processor", + ) + window.processor_combo.setCurrentIndex(window.processor_combo.count() - 1) window.use_custom_proc_checkbox.setChecked(True) # Patch start_recording to avoid preview start/timers - monkeypatch.setattr(window, "_start_recording", lambda: window._start_multi_camera_recording()) + monkeypatch.setattr( + window, + "_start_recording", + lambda: window._start_multi_camera_recording(), + ) - # Install fake processor window._dlc._processor = fake_processor - window._last_processor_vid_recording = False # ensure it sees a "change" + window._last_processor_vid_recording = False - # Act window._update_processor_status() - # Assert UI updated assert window.session_name_edit.text() == "auto_ABC" assert window.filename_edit.text() == "auto_ABC" - - # Assert recording call used overridden session name kwargs = start_all_spy["kwargs"] assert kwargs["session_name"] == "auto_ABC" From 29923dc6e6cfc05fa4daa598e517b5b7c0763d45 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 16:22:39 +0200 Subject: [PATCH 091/194] Add test for processor control re-enable Adds a GUI regression test to ensure processor-related controls are disabled while inference is active and properly re-enabled when it stops. Also updates plugin system docs by fixing a description typo and aligning the toggle name to "Use custom processor". --- dlclivegui/processors/PLUGIN_SYSTEM.md | 4 ++-- tests/gui/test_main.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index 6d48c843c..5c3b2e320 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -58,7 +58,7 @@ Each processor class should define metadata attributes to help GUI discovery: ```python class MyProcessorSocket(BaseProcessorSocket): PROCESSOR_NAME = "Use Pose Processor" # Human-readable - PROCESSOR_DESCRIPTION = "BBroadcasts processed pose values" + PROCESSOR_DESCRIPTION = "Broadcasts processed pose values" PROCESSOR_PARAMS = { "bind": { "type": "tuple", @@ -160,7 +160,7 @@ New processor modules should rely on subclass discovery instead of defining a re ### Recommended behavior -To keep processor behavior explicit and opt-in, the GUI provides an **Allow processor-based control** toggle with these effects: +To keep processor behavior explicit and opt-in, the GUI provides an **Use custom processor** toggle with these effects: - **Disabled by default:** - The GUI does **not instantiate** any processor plugin diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index df320bce3..ca177149f 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -175,3 +175,25 @@ def test_dlc_settings_from_ui_validates_detected_model_type( assert settings.model_type == "pytorch" assert isinstance(settings.model_type, str) + + +def test_processor_controls_reenabled_after_inference_stops( + window, +): + window._dlc_active = True + window._update_dlc_controls_enabled() + + assert not window.processor_folder_edit.isEnabled() + assert not window.browse_processor_folder_button.isEnabled() + assert not window.refresh_processors_button.isEnabled() + assert not window.processor_combo.isEnabled() + assert not window.use_custom_proc_checkbox.isEnabled() + + window._dlc_active = False + window._update_dlc_controls_enabled() + + assert window.processor_folder_edit.isEnabled() + assert window.browse_processor_folder_button.isEnabled() + assert window.refresh_processors_button.isEnabled() + assert window.processor_combo.isEnabled() + assert window.use_custom_proc_checkbox.isEnabled() From e5b9e41157e9a25fa730192db828cf88234ca272 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 09:50:45 +0200 Subject: [PATCH 092/194] Improve processor discovery and logging Expand processor class discovery to include re-exported classes by disabling module-only filtering in package/file scans. Also broaden subclass-check error handling to catch unexpected exceptions and log full context when discovery encounters problematic objects. --- dlclivegui/processors/processor_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e47dbe2f8..c33858a44 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,8 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module) + # here module only is disabled to allow classes re-exported in other modules to be discovered + return discover_processor_classes(module, only_defined_in_module=False) except Exception: # Full traceback helps a ton when a plugin fails to import From cd658b652925e78f263dfc63b1689239db78d9a2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:06:38 +0200 Subject: [PATCH 093/194] Add processors package exports Create `dlclivegui/processors/__init__.py` to re-export `register_processor`, `BaseProcessorSocket`, and `PROCESSOR_REGISTRY` from `dlc_processor_socket`, making these APIs available via package-level imports. --- dlclivegui/processors/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index 8e7717155..ee94194dd 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .registry import PROCESSOR_REGISTRY, register_processor +from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor -__all__ = ["register_processor", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] From ebaff411e3e0cb30b1cf7a4256c3a6d830088441 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:08:11 +0200 Subject: [PATCH 094/194] Move example socket processors to examples module Refactors `dlc_processor_socket.py` by removing the in-file example processors and `OneEuroFilter`, and adds them to a new `dlclivegui/processors/examples.py` module. This separates demonstration/experiment-specific logic from the core socket processor implementation, improving maintainability while preserving existing example processor behavior. --- dlclivegui/processors/dlc_processor_socket.py | 49 +++++++++++++++++++ dlclivegui/processors/examples.py | 5 +- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 6a91ef1a3..2c120c35c 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -23,6 +23,20 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +def register_processor(cls): + registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) + if registry_key in PROCESSOR_REGISTRY: + raise ValueError( + f"Duplicate processor registration key '{registry_key}': " + f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + ) + PROCESSOR_REGISTRY[registry_key] = cls + return cls + # pragma: cover class BaseProcessorSocket(Processor): @@ -421,3 +435,38 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping registry keys to processor info. + """ + return { + name: { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + for name, cls in PROCESSOR_REGISTRY.items() + } + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Registry key (e.g., "MyProcessorSocket") + **kwargs: Constructor kwargs + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 7ed769198..60c5f8421 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,8 +6,7 @@ import numpy as np -from dlclivegui.processors import register_processor -from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket +from dlclivegui.processors import BaseProcessorSocket, register_processor logger = logging.getLogger(__name__) @@ -165,7 +164,6 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: @@ -335,7 +333,6 @@ def process(self, pose, **kwargs): # Calculate head angle relative to body cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] sign = copysign(1, cross) # Positive when looking left - try: head_angle = acos(body_axis @ head_axis) * sign except ValueError: From 6ab74f77b79a0018fc9667cdf68b4824cecff949 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:12:22 +0200 Subject: [PATCH 095/194] Skip socket base module in processor scan Update processor package discovery to ignore `dlc_processor_socket` during namespace scanning, since it only provides the base class/registry and should not be listed as an available processor source. The package fallback scan now uses default class discovery behavior, and related outdated comments/docstring lines were cleaned up. --- dlclivegui/processors/processor_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index c33858a44..70653b0d7 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,6 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - # here module only is disabled to allow classes re-exported in other modules to be discovered return discover_processor_classes(module, only_defined_in_module=False) except Exception: From 005ea5139147774362c5a25db4eedf361f68b753 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:13:13 +0200 Subject: [PATCH 096/194] Update processor_utils.py --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 70653b0d7..e47dbe2f8 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,7 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module, only_defined_in_module=False) + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From fd69c6980c7fc12220e83c253426fba2a7797fd3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:21:40 +0200 Subject: [PATCH 097/194] Warn on duplicate processor registration Change `register_processor` to log a warning instead of raising on duplicate `PROCESSOR_ID` keys, allowing later registrations to override earlier ones without import-time failures. Update subclass save tests to load processor classes from `dlclivegui.processors.examples` via a dedicated fixture, so the parametrized tests validate the concrete example processors against the correct module data path. --- dlclivegui/processors/dlc_processor_socket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 2c120c35c..80fa72291 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -30,10 +30,11 @@ def register_processor(cls): registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) if registry_key in PROCESSOR_REGISTRY: - raise ValueError( + msg = ( f"Duplicate processor registration key '{registry_key}': " f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" ) + logger.warning(msg) PROCESSOR_REGISTRY[registry_key] = cls return cls From b85d3dc36c896271a0eb395a716c4dc26e352dd7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:49:10 +0200 Subject: [PATCH 098/194] Extract processor registry into new module Moves processor registration and discovery helpers out of `dlc_processor_socket.py` into a new `registry.py` module so registry access no longer depends on importing socket logic. `dlc_processor_socket.py` now imports the shared registry helpers and adds a safe fallback when `dlclive` is unavailable, reducing import-time failures in environments without that dependency. Package exports were updated to expose registry APIs from the new module. --- dlclivegui/processors/__init__.py | 4 +- dlclivegui/processors/dlc_processor_socket.py | 50 ------------------- dlclivegui/processors/examples.py | 3 +- 3 files changed, 4 insertions(+), 53 deletions(-) diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index ee94194dd..8e7717155 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor +from .registry import PROCESSOR_REGISTRY, register_processor -__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "PROCESSOR_REGISTRY"] diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 80fa72291..6a91ef1a3 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -23,21 +23,6 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} - - -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - msg = ( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" - ) - logger.warning(msg) - PROCESSOR_REGISTRY[registry_key] = cls - return cls - # pragma: cover class BaseProcessorSocket(Processor): @@ -436,38 +421,3 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict - - -def get_available_processors(): - """ - Get list of available processor classes. - - Returns: - dict: Dictionary mapping registry keys to processor info. - """ - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } - - -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs - - Raises: - ValueError: If class_name is not in registry - """ - if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 60c5f8421..7e96fc068 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,7 +6,8 @@ import numpy as np -from dlclivegui.processors import BaseProcessorSocket, register_processor +from dlclivegui.processors import register_processor +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket logger = logging.getLogger(__name__) From 8d9f51a69ff6d2655dc7a138c5888a4777c6c2b9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:01:59 +0200 Subject: [PATCH 099/194] Normalize recording container handling Centralized recording container configuration by introducing shared allowed/default container constants and using them across settings, UI, and utilities. The recording UI now populates container options from config and keeps the filename extension aligned with the selected container when switching between known video formats, so saved settings and path previews stay consistent. --- dlclivegui/config.py | 5 ++- dlclivegui/gui/main_window.py | 81 ++++++++++++++++++++++++----------- dlclivegui/utils/utils.py | 4 +- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c24a0e085..85a48210c 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -25,6 +25,9 @@ GUI_MAX_DISPLAY_FPS: float = 30.0 ## Recording DEFAULT_RECORDING_FPS: float = 30.0 +ALLOWED_VIDEO_CONTAINERS: set[str] = {"mp4", "avi", "mov"} +DEFAULT_RECORDING_CONTAINER: str = "mp4" + ## Debug ### Timing logs @@ -508,7 +511,7 @@ class RecordingSettings(BaseModel): enabled: bool = False directory: str = Field(default_factory=lambda: str(Path.home() / "Videos" / "deeplabcut-live")) filename: str = "session.mp4" - container: Literal["mp4", "avi", "mov"] = "mp4" + container: Literal["mp4", "avi", "mov"] = DEFAULT_RECORDING_CONTAINER codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) fast_encoding: bool = False diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 858a74afd..13fdb1518 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -48,8 +48,10 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( + ALLOWED_VIDEO_CONTAINERS, DEFAULT_CONFIG, GUI_MAX_DISPLAY_FPS, + DEFAULT_RECORDING_CONTAINER, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -604,7 +606,7 @@ def _build_recording_group(self) -> QGroupBox: self.container_combo.setToolTip("Select the video container/format") self.container_combo.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) self.container_combo.setEditable(True) - self.container_combo.addItems(["mp4", "avi", "mov"]) + self.container_combo.addItems(sorted(ALLOWED_VIDEO_CONTAINERS)) # Ensure it never becomes unreadable: self.container_combo.setMinimumContentsLength(8) self.container_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) @@ -820,7 +822,7 @@ def _connect_signals(self) -> None: self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) - self.container_combo.currentTextChanged.connect(lambda _t: self._update_recording_path_preview()) + self.container_combo.currentTextChanged.connect(self._on_container_changed) self.fast_encoding_checkbox.stateChanged.connect(self._on_fast_encoding_changed) # ------------------------------------------------------------------ @@ -958,11 +960,13 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor return DLCProcessorSettings.model_validate(updated_dlc) def _recording_settings_from_ui(self) -> RecordingSettings: + container = self.container_combo.currentText().strip() or DEFAULT_RECORDING_CONTAINER + filename = self._filename_matching_container(self.filename_edit.text().strip(), container) return RecordingSettings( enabled=True, # Always enabled - recording controlled by button directory=self.output_directory_edit.text().strip(), - filename=self.filename_edit.text().strip() or "session.mp4", - container=self.container_combo.currentText().strip() or "mp4", + filename=filename, + container=container, codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), fast_encoding=bool( @@ -1178,31 +1182,52 @@ def _refresh_processors(self) -> None: # ------------------------------------------------------------------ # Recording path preview and session name persistence + def _known_recording_extensions(self) -> set[str]: + """Return known recording container extensions without leading dots.""" + known = ALLOWED_VIDEO_CONTAINERS.copy() + if hasattr(self, "container_combo"): + known.update( + self.container_combo.itemText(i).strip().lower().lstrip(".") + for i in range(self.container_combo.count()) + if self.container_combo.itemText(i).strip() + ) + return known + + def _filename_matching_container(self, filename: str, container: str) -> str: + """ + Adjust filename extension to match selected container, but only when + the existing extension is another known recording container. + """ + name = filename.strip() or "recording" + selected_ext = container.strip().lower().lstrip(".") + suffix = Path(name).suffix + + if not suffix or not selected_ext: + return name + + current_ext = suffix.lower().lstrip(".") + if current_ext in self._known_recording_extensions() and current_ext != selected_ext: + return str(Path(name).with_suffix(f".{selected_ext}")) + + return name + + def _on_container_changed(self, text: str) -> None: + """Keep filename extension aligned with selected container when safe.""" + if hasattr(self, "filename_edit"): + current = self.filename_edit.text() + updated = self._filename_matching_container(current, text) + if updated != current: + self.filename_edit.blockSignals(True) + self.filename_edit.setText(updated) + self.filename_edit.blockSignals(False) + + self._update_recording_path_preview() + def _on_session_name_editing_finished(self) -> None: name = self.session_name_edit.text().strip() self._settings_store.set_session_name(name) self._update_recording_path_preview() - # def _update_recording_path_preview(self) -> None: - # """Update the label showing where files will go (best-effort).""" - # if not hasattr(self, "recording_path_preview"): - # return - # out_dir = self.output_directory_edit.text().strip() - # sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - # base = self.filename_edit.text().strip() - # container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" - # use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True - - # # Preview is approximate (since run index/time is decided at start). - # sess_safe = sess.strip() or "session" - # run_hint = "run_" if use_ts else "run_" - # stem_hint = Path(base).stem if base.strip() else "recording" # shows user-provided stem or default - # full_hint = str(Path(out_dir).expanduser() / sess_safe / run_hint / f"{stem_hint}_.{container}") - # self.recording_path_preview.setText(f"{full_hint}") - # self.recording_path_preview.setToolTip( - # f"Click to copy to clipboard :
{full_hint.replace('', '*')}" - # ) - def _update_recording_path_preview(self) -> None: """Update the label showing where files will go (best-effort).""" if not hasattr(self, "recording_path_preview"): @@ -1210,8 +1235,12 @@ def _update_recording_path_preview(self) -> None: out_dir = self.output_directory_edit.text().strip() sess = self.session_name_edit.text().strip() if hasattr(self, "session_name_edit") else "" - base = self.filename_edit.text().strip() - container = self.container_combo.currentText().strip() if hasattr(self, "container_combo") else "mp4" + container = ( + self.container_combo.currentText().strip() + if hasattr(self, "container_combo") + else DEFAULT_RECORDING_CONTAINER + ) + base = self._filename_matching_container(self.filename_edit.text(), container) use_ts = self.use_timestamp_checkbox.isChecked() if hasattr(self, "use_timestamp_checkbox") else True # Preview is approximate (since run index/time is decided at start). diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py index 6af003dad..534e5732f 100644 --- a/dlclivegui/utils/utils.py +++ b/dlclivegui/utils/utils.py @@ -8,6 +8,8 @@ from datetime import datetime from pathlib import Path +from dlclivegui.config import DEFAULT_RECORDING_CONTAINER + _INVALID_CHARS = re.compile(r"[^A-Za-z0-9._-]+") @@ -36,7 +38,7 @@ def split_stem_ext(base_filename: str, container: str) -> tuple[str, str]: If user typed an extension, keep it. Else use container. """ base = (base_filename or "").strip() - container = (container or "mp4").strip().lstrip(".") or "mp4" + container = (container or DEFAULT_RECORDING_CONTAINER).strip().lstrip(".") or DEFAULT_RECORDING_CONTAINER if not base: base = "recording" From 2a17c1a34f5bd21c658fa9be193911cd3c6a3709 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:50:25 +0200 Subject: [PATCH 100/194] Add DLC timing instrumentation in GUI path Introduce a new `DLC_DO_LOG_TIMING` config flag and wire `WorkerTimingStats` into `DLCLiveMainWindow` for DLC enqueue and pose-ready callback timing. The pose callback now logs camera-to-GUI latency in debug mode, marks display state dirty instead of forcing an immediate redraw, and emits periodic timing stats via `maybe_log()`. --- dlclivegui/config.py | 1 + dlclivegui/gui/main_window.py | 34 ++++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 85a48210c..8f80464e3 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -34,6 +34,7 @@ SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False REC_DO_LOG_TIMING: bool = False +DLC_DO_LOG_TIMING: bool = True ### Trigger debug logging DEBUG_TRIGGER_LOGS = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 13fdb1518..aa6cfde5d 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -52,6 +52,7 @@ DEFAULT_CONFIG, GUI_MAX_DISPLAY_FPS, DEFAULT_RECORDING_CONTAINER, + DLC_DO_LOG_TIMING, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -71,7 +72,7 @@ from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id, get_display_id 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 format_dlc_stats +from ..utils.stats import WorkerTimingStats, format_dlc_stats from ..utils.utils import FPSTracker from .camera_config.camera_config_dialog import CameraConfigDialog from .misc import color_dropdowns as color_ui @@ -132,6 +133,10 @@ def __init__(self, config: ApplicationSettings | None = None): self._rec_manager = RecordingManager() self._dlc = DLCLiveProcessor() self.multi_camera_controller = MultiCameraController() + ### Time debug + self._dlc_timing = WorkerTimingStats( + "GUI - DLC Worker", logger=logger, log_interval=2.0, enabled=DLC_DO_LOG_TIMING + ) self._config = config self._inference_camera_id: str | None = None # Camera ID used for inference @@ -1520,7 +1525,11 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) - self._dlc.enqueue_frame(frame, timestamp) + with self._dlc_timing.measure("enqueue_frame"): + self._dlc.enqueue_frame(frame, timestamp) + + self._dlc_timing.note_frame() + self._dlc_timing.maybe_log() def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: """Throttled UI/display path. @@ -2080,10 +2089,23 @@ def _stop_recording(self) -> None: def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return - self._last_pose = result - # logger.debug(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") - if self._current_frame is not None: - self._display_frame(self._current_frame, force=True) + + with self._dlc_timing.measure("DLC.pose_ready_callback"): + self._last_pose = result + + try: + latency_ms = (time.time() - float(result.timestamp)) * 1000.0 + if logger.isEnabledFor(logging.DEBUG): + logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) + except Exception: + pass + + if self._current_frame is not None: + self._display_dirty = True + # with self._dlc_timing.measure("DLC.display_after_pose"): + # self._display_frame(self._current_frame, force=True) + + self._dlc_timing.maybe_log() def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) From de4249b6c6420864555b02f81d8c792f12959bdb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:54:05 +0200 Subject: [PATCH 101/194] Prioritize latest frame when queue is full Update `_enqueue_frame` to keep enqueueing the newest frame by removing one queued item when `put_nowait` hits `queue.Full`, instead of dropping the incoming frame. This makes processing more real-time under load and keeps enqueue/drop stats consistent, including safe `task_done()` handling. --- dlclivegui/services/dlc_processor.py | 29 +++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index b4476e116..0ecf8abca 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -258,13 +258,28 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: if q is None: return - try: - q.put_nowait((frame_c, timestamp, enq_time)) - with self._stats_lock: - self._frames_enqueued += 1 - except queue.Full: - with self._stats_lock: - self._frames_dropped += 1 + item = (frame_c, timestamp, enq_time) + + while True: + try: + q.put_nowait(item) + with self._stats_lock: + self._frames_enqueued += 1 + return + + except queue.Full: + try: + q.get_nowait() + try: + q.task_done() + except ValueError: + pass + + with self._stats_lock: + self._frames_dropped += 1 + + except queue.Empty: + continue def get_stats(self) -> ProcessorStats: """Get current processing statistics.""" From 3fa34b6bbe058b20d71ee6daa8f3da218c07a380 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 11:16:52 +0200 Subject: [PATCH 102/194] Lower camera backend logs to debug Demoted multiple verbose runtime messages from INFO to DEBUG in Basler and GenTL backends. This keeps normal logs cleaner by moving routine configuration/readback details (FPS setup, converter mode, exposure/gain settings, trigger configuration, and startup/close diagnostics) out of INFO-level output while preserving the diagnostics when DEBUG is enabled. --- dlclivegui/cameras/backends/basler_backend.py | 26 +++++++++---------- dlclivegui/cameras/backends/gentl_backend.py | 18 ++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 09d23c7bb..319f5e5f7 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -447,7 +447,7 @@ def _configure_frame_rate(self) -> None: fps = self._positive_float(getattr(self.settings, "fps", 0.0)) if fps is None: - LOG.info("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") + LOG.debug("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") return enable = self._feature("AcquisitionFrameRateEnable") @@ -464,7 +464,7 @@ def _configure_frame_rate(self) -> None: try: min_v = rate.GetMin() max_v = rate.GetMax() - LOG.info("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) + LOG.debug("[Basler] AcquisitionFrameRate range: min=%s max=%s requested=%s", min_v, max_v, fps) except Exception: pass @@ -495,7 +495,7 @@ def _configure_frame_rate(self) -> None: if feature is not None: readbacks[name] = self._feature_value(feature, None) - LOG.info("[Basler] FPS readback requested=%s values=%s", fps, readbacks) + LOG.debug("[Basler] Readback requested=%s values=%s", fps, readbacks) try: self._actual_fps = float(readbacks.get("AcquisitionFrameRate")) @@ -520,14 +520,14 @@ def _configure_converter(self) -> None: if self._should_output_mono(): self._converter.OutputPixelFormat = pylon.PixelType_Mono8 - LOG.info( + LOG.debug( "[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)", camera_pixel_format, self._preserve_mono, ) else: self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed - LOG.info( + LOG.debug( "[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)", camera_pixel_format, self._preserve_mono, @@ -558,7 +558,7 @@ def open(self) -> None: self._camera.ExposureTime.SetValue(float(self.settings.exposure)) if hasattr(self._camera, "ExposureTimeAbs"): self._camera.ExposureTimeAbs.SetValue(float(self.settings.exposure)) - LOG.info("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) + LOG.debug("[Basler] Exposure set to %s us (auto off)", self.settings.exposure) except Exception as exc: LOG.warning("[Basler] Failed to set exposure: %s", exc) @@ -568,7 +568,7 @@ def open(self) -> None: if hasattr(self._camera, "GainAuto"): self._camera.GainAuto.SetValue("Off") self._camera.Gain.SetValue(float(self.settings.gain)) - LOG.info("[Basler] Gain set to %s dB (auto off)", self.settings.gain) + LOG.debug("[Basler] Gain set to %s dB (auto off)", self.settings.gain) except Exception as exc: LOG.warning("[Basler] Failed to set gain: %s", exc) @@ -648,7 +648,7 @@ def open(self) -> None: # pylon.GrabStrategy_LatestImageOnly, pylon.GrabStrategy_OneByOne, ) - LOG.info( + LOG.debug( "[Basler] grabbing=%s max_buffers=%s", self._camera.IsGrabbing(), self._camera.MaxNumBuffer.GetValue() if hasattr(self._camera, "MaxNumBuffer") else "N/A", @@ -656,7 +656,7 @@ def open(self) -> None: else: LOG.debug("Fast-start probe: skipping StartGrabbing and converter") - LOG.info( + LOG.debug( "[Basler] open device_id=%s index=%s fast_start=%s requested=(%sx%s @ %s fps exp=%s gain=%s)", getattr(self, "_device_id", None), getattr(self.settings, "index", None), @@ -766,7 +766,7 @@ def read(self) -> CapturedFrame: if not self._logged_first_frame: self._logged_first_frame = True - LOG.info( + LOG.debug( "[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " "camera_pixel_format=%s output_format=%s preserve_mono=%s", self._device_id, @@ -817,7 +817,7 @@ def read(self) -> CapturedFrame: raise RuntimeError("Failed to retrieve image from Basler camera.") from exc def close(self) -> None: - LOG.info( + LOG.debug( "[Basler] close called camera_exists=%s grabbing=%s open=%s", self._camera is not None, bool(self._camera and self._camera.IsGrabbing()), @@ -1226,7 +1226,7 @@ def _configure_trigger_input(self, cfg, *, strict: bool = False) -> None: self._trigger = CameraTriggerSettings() return - LOG.info( + LOG.debug( "Basler trigger input configured: role=%s selector=%s source=%s activation=%s " "selector_ok=%s source_ok=%s activation_ok=%s", role, @@ -1291,7 +1291,7 @@ def _configure_trigger_master(self, cfg, *, strict: bool = False) -> None: source_ok = self._set_enum_feature("LineSource", output_source, strict=strict) if mode_ok and source_ok: - LOG.info( + LOG.debug( "Basler trigger master configured via Line*: output_line=%s output_source=%s", output_line, output_source, diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index e43f4c809..63b8a8943 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1290,7 +1290,7 @@ def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> if requested.lower() == "auto": for candidate in ("Line0", "Line1", "Line2", "Any"): if candidate in available: - LOG.info( + LOG.debug( "GenTL TriggerSource auto-selected '%s'. Available: %s", candidate, available, @@ -1479,7 +1479,7 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._trigger = CameraTriggerSettings() return - LOG.info( + LOG.debug( "GenTL trigger input configured: role=%s selector=%s source_requested=%s " "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", role, @@ -1540,7 +1540,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N node = self._node(node_map, "StrobeDuration") if node is not None: node.value = int(strobe_duration) - LOG.info("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + LOG.debug("Configured GenTL StrobeDuration=%s", int(strobe_duration)) except Exception as exc: if strict: raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc @@ -1551,7 +1551,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N node = self._node(node_map, "StrobeDelay") if node is not None: node.value = int(strobe_delay) - LOG.info("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + LOG.debug("Configured GenTL StrobeDelay=%s", int(strobe_delay)) except Exception as exc: if strict: raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc @@ -1565,7 +1565,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N ) if enable_ok: - LOG.info( + LOG.debug( "GenTL trigger master configured via Strobe*: " "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " "StrobeOperation=%s operation_ok=%s", @@ -1605,7 +1605,7 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) if mode_ok and source_ok: - LOG.info( + LOG.debug( "GenTL trigger master configured via Line*: output_line=%s output_source=%s", output_line, output_source, @@ -1725,7 +1725,7 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) - LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target) + LOG.debug("Configuring GenTL frame rate: requested %.3f FPS", target) for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: @@ -1733,7 +1733,7 @@ def _configure_frame_rate(self, node_map) -> None: before = getattr(node, "value", None) node.value = True after = getattr(node, "value", None) - LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after) + LOG.debug("Enabled GenTL %s: before=%r after=%r", attr, before, after) break except Exception: pass @@ -1745,7 +1745,7 @@ def _configure_frame_rate(self, node_map) -> None: node.value = target after = getattr(node, "value", None) - LOG.info( + LOG.debug( "Set GenTL %s: before=%r requested=%.3f after=%r", attr, before, From 78bf8a160e55a915593a1b7bf575290dd9834a57 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:30:00 +0200 Subject: [PATCH 103/194] Harden DLC worker startup and timing logs Adds fine-grained `WorkerTimingStats` instrumentation across enqueue, initialization, inference, and emit paths, with error/frame accounting and optional timing logging. It also makes worker startup/stop behavior safer by deferring queue creation until RUNNING, blocking enqueue during STARTING, normalizing input frames before inference, and adding richer debug diagnostics (CUDA/runner state and thread stack dumps on stuck shutdown). --- dlclivegui/services/dlc_processor.py | 286 ++++++++++++++++++++++----- dlclivegui/utils/stats.py | 5 +- dlclivegui/utils/utils.py | 16 ++ 3 files changed, 256 insertions(+), 51 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 0ecf8abca..4d4df7584 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -16,9 +16,11 @@ import numpy as np from PySide6.QtCore import QObject, Signal -from dlclivegui.config import DLCProcessorSettings, ModelType +from dlclivegui.config import DLC_DO_LOG_TIMING, DLCProcessorSettings, ModelType from dlclivegui.processors.processor_utils import instantiate_from_scan from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released +from dlclivegui.utils.stats import WorkerTimingStats +from dlclivegui.utils.utils import format_thread_stack logger = logging.getLogger(__name__) STOP_WORKER_TIMEOUT = 10.0 # # seconds to wait in STOPPING state before scheduling background reaping @@ -181,6 +183,13 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) + self._timing = WorkerTimingStats( + "DLCLiveProcessor", + logger=logger, + log_interval=1.0, + enabled=bool(DLC_DO_LOG_TIMING or ENABLE_PROFILING), + ) + @staticmethod def get_model_backend(model_path: str) -> Engine: return Engine.from_model_path(model_path) @@ -232,24 +241,40 @@ def shutdown(self) -> None: def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: # Keep lifecycle lock held only for quick state checks and snapshots. with self._lifecycle_lock: - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread q = self._queue should_start = t is None or not t.is_alive() - frame_c = frame.copy() + with self._timing.measure("DLC.enqueue.copy_frame"): + frame_c = frame.copy() enq_time = time.perf_counter() if should_start: # Re-acquire the lifecycle lock to safely (re)start the worker if needed. with self._lifecycle_lock: # Re-check state in case it changed while we were copying the frame. - if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set(): + if ( + self._state in (WorkerState.STOPPING, WorkerState.FAULTED, WorkerState.STARTING) + or self._stop_event.is_set() + ): return t = self._worker_thread if t is None or not t.is_alive(): - # _start_worker_locked expects the lifecycle lock to be held. + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Starting DLC worker from first frame: " + "shape=%s dtype=%s contiguous=%s strides=%s timestamp=%.6f", + frame_c.shape, + frame_c.dtype, + frame_c.flags["C_CONTIGUOUS"], + frame_c.strides, + timestamp, + ) self._start_worker_locked(frame_c, timestamp) return # Worker is now running; refresh queue snapshot. @@ -262,18 +287,20 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: while True: try: - q.put_nowait(item) + with self._timing.measure("DLC.enqueue.put"): + q.put_nowait(item) with self._stats_lock: self._frames_enqueued += 1 return except queue.Full: try: - q.get_nowait() - try: - q.task_done() - except ValueError: - pass + with self._timing.measure("DLC.enqueue.drop_stale"): + q.get_nowait() + try: + q.task_done() + except ValueError: + pass with self._stats_lock: self._frames_dropped += 1 @@ -332,11 +359,91 @@ def get_stats(self) -> ProcessorStats: avg_processor_overhead=avg_proc_overhead, ) + def _debug_log_dlc_runner_device(self) -> None: + if not logger.isEnabledFor(logging.DEBUG): + return + + try: + import torch + + logger.debug( + "Torch CUDA state: available=%s built=%s device_count=%s current_device=%s device_name=%s " + "allocated=%.2fMB reserved=%.2fMB", + torch.cuda.is_available(), + torch.backends.cuda.is_built(), + torch.cuda.device_count(), + torch.cuda.current_device() if torch.cuda.is_available() else None, + torch.cuda.get_device_name(0) if torch.cuda.is_available() and torch.cuda.device_count() else None, + torch.cuda.memory_allocated(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + torch.cuda.memory_reserved(0) / (1024 * 1024) if torch.cuda.is_available() else 0.0, + ) + except Exception: + logger.debug("Could not query torch CUDA state", exc_info=True) + + dlc = self._dlc + runner = getattr(dlc, "runner", None) + + logger.debug( + "DLCLive runner: type=%s runner.device=%r runner.model=%r runner.net=%r", + type(runner).__name__ if runner is not None else None, + getattr(runner, "device", None), + type(getattr(runner, "model", None)).__name__ if getattr(runner, "model", None) is not None else None, + type(getattr(runner, "net", None)).__name__ if getattr(runner, "net", None) is not None else None, + ) + + seen: set[int] = set() + + def walk(obj, path: str, depth: int = 0) -> None: + if obj is None or depth > 7: + return + + oid = id(obj) + if oid in seen: + return + seen.add(oid) + + try: + params = getattr(obj, "parameters", None) + if callable(params): + first_param = next(iter(params()), None) + if first_param is not None: + logger.debug( + "Torch module at %s: parameter device=%s is_cuda=%s dtype=%s shape=%s", + path, + first_param.device, + first_param.is_cuda, + first_param.dtype, + tuple(first_param.shape), + ) + except Exception: + pass + + for name in ( + "runner", + "model", + "net", + "pose_model", + "dlc_model", + "module", + "engine", + "predictor", + "detector", + "backbone", + ): + try: + child = getattr(obj, name, None) + except Exception: + child = None + if child is not None: + walk(child, f"{path}.{name}", depth + 1) + + walk(self._dlc, "self._dlc") + def _start_worker_locked(self, init_frame: np.ndarray, init_timestamp: float) -> None: # lifecycle_lock must already be held if self._worker_thread is not None and self._worker_thread.is_alive(): return - self._queue = queue.Queue(maxsize=1) + self._queue = None self._stop_event.clear() self._state = WorkerState.STARTING self._worker_thread = threading.Thread( @@ -364,7 +471,7 @@ def _stop_worker(self) -> bool: t.join(timeout=STOP_WORKER_TIMEOUT) if t.is_alive(): qsize = self._queue.qsize() if self._queue is not None else -1 - logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)", qsize) + logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)\n%s", qsize, format_thread_stack(t)) self._schedule_reap(t) return False @@ -427,7 +534,8 @@ def _timed_processor(self): def timed_process(pose, _op=original, _holder=holder, **kwargs): start = time.perf_counter() try: - return _op(pose, **kwargs) + with self._timing.measure("DLC.processor.process"): + return _op(pose, **kwargs) finally: _holder[0] = time.perf_counter() - start @@ -438,6 +546,24 @@ def timed_process(pose, _op=original, _holder=holder, **kwargs): # Restore even if inference/errors occur self._processor.process = original + @staticmethod + def _prepare_input_frame(frame: np.ndarray) -> np.ndarray: + """Normalize camera frames for DLCLive inference.""" + arr = np.asarray(frame) + + if arr.ndim == 2: + # Mono8 / grayscale -> 3-channel + arr = np.repeat(arr[:, :, None], 3, axis=2) + elif arr.ndim == 3 and arr.shape[2] == 4: + arr = arr[:, :, :3] + elif arr.ndim != 3 or arr.shape[2] != 3: + raise ValueError(f"Unsupported DLCLive input frame shape: {arr.shape}") + + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8, copy=False) + + return np.ascontiguousarray(arr) + def _process_frame( self, frame: np.ndarray, @@ -453,11 +579,23 @@ def _process_frame( if self._dlc is None: raise RuntimeError("DLCLive instance is not initialized.") # Time GPU inference (and processor overhead when present) + with self._timing.measure("DLC.prepare_frame"): + frame = self._prepare_input_frame(frame) with self._timed_processor() as proc_holder: inference_start = time.perf_counter() - raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) + + with self._timing.measure("DLC.process_frame"): + processed_frame = self._dlc.process_frame(frame) + + with self._timing.measure("DLC.runner.get_pose"): + self._dlc.pose = self._dlc.runner.get_pose(processed_frame) + + with self._timing.measure("DLC.post_process_pose"): + raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start - pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) + with self._timing.measure("DLC.validate_pose"): + pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, @@ -475,7 +613,8 @@ def _process_frame( # Emit pose (measure signal overhead) signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) + with self._timing.measure("DLC.emit.pose_ready"): + self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) signal_time = time.perf_counter() - signal_start end_ts = time.perf_counter() @@ -496,6 +635,8 @@ def _process_frame( self._gpu_inference_times.append(gpu_inference_time) self._processor_overhead_times.append(processor_overhead) + self._timing.note_frame() + self._timing.maybe_log() self.frame_processed.emit() def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: @@ -504,60 +645,98 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") - init_start = time.perf_counter() - dyn = self._settings.dynamic - if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: - try: - dyn = dyn.to_tuple() - except Exception as e: - raise RuntimeError("Invalid dynamic crop settings format.") from e - enabled, margin, max_missing = dyn - - options = { - "model_path": self._settings.model_path, - "model_type": self._settings.model_type, - "processor": self._processor, - "dynamic": [enabled, margin, max_missing], - "resize": self._settings.resize, - "precision": self._settings.precision, - "single_animal": self._settings.single_animal, - } - if self._settings.device is not None: - options["device"] = self._settings.device + with self._timing.measure("DLC.build_options"): + dyn = self._settings.dynamic + if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: + try: + dyn = dyn.to_tuple() + except Exception as e: + raise RuntimeError("Invalid dynamic crop settings format.") from e + enabled, margin, max_missing = dyn + + options = { + "model_path": self._settings.model_path, + "model_type": self._settings.model_type, + "processor": self._processor, + "dynamic": [enabled, margin, max_missing], + "resize": self._settings.resize, + "precision": self._settings.precision, + "single_animal": self._settings.single_animal, + } + if self._settings.device is not None: + options["device"] = self._settings.device + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "DLC worker starting: model_path=%s model_type=%s device=%s " + "init_frame_shape=%s dtype=%s contiguous=%s", + self._settings.model_path, + self._settings.model_type, + self._settings.device, + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) try: if DLCLive is None: raise RuntimeError( "DLCLive class is not available. Ensure the dlclive package is installed and can be imported." ) - self._dlc = DLCLive(**options) + with self._timing.measure("DLC.construct"): + self._dlc = DLCLive(**options) + self._timing.maybe_log() except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() with self._lifecycle_lock: self._state = WorkerState.FAULTED raise RuntimeError( f"Failed to initialize DLCLive with model '{self._settings.model_path}': {exc}" ) from exc + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested during construction; exiting before init_inference.") + return + + with self._timing.measure("DLC.prepare_init_frame"): + init_frame = self._prepare_input_frame(init_frame) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calling DLCLive.init_inference with frame shape=%s dtype=%s contiguous=%s", + init_frame.shape, + init_frame.dtype, + init_frame.flags["C_CONTIGUOUS"], + ) # First inference to initialize - init_inference_start = time.perf_counter() - self._dlc.init_inference(init_frame) - init_inference_time = time.perf_counter() - init_inference_start + with self._timing.measure("DLC.init_inference"): + self._dlc.init_inference(init_frame) + + self._debug_log_dlc_runner_device() + self._timing.note_frame() + self._timing.maybe_log() + + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested after init_inference; exiting before RUNNING state.") + return # Pass DLCLive cfg to processor if available if hasattr(self._dlc, "processor") and hasattr(self._dlc.processor, "set_dlc_cfg"): - self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) + with self._timing.measure("DLC.processor.set_dlc_cfg"): + self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) self._initialized = True self.initialized.emit(True) with self._lifecycle_lock: + if self._stop_event.is_set(): + logger.debug("DLC worker stop requested before RUNNING state; exiting.") + return + + self._queue = queue.Queue(maxsize=1) self._state = WorkerState.RUNNING - total_init_time = time.perf_counter() - init_start - logger.info( - "DLCLive model initialized successfully (total: %.3fs, init_inference: %.3fs)", - total_init_time, - init_inference_time, - ) + logger.info("DLCLive model initialized successfully") # Emit pose for init frame & update stats (not dequeued) self._process_frame(init_frame, init_timestamp, time.perf_counter(), queue_wait_time=0.0) @@ -598,6 +777,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: self._process_frame(frame, ts, enq, queue_wait_time=0.0) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -610,11 +791,14 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # Normal operation: timed get try: wait_start = time.perf_counter() - item = q.get(timeout=0.05) + with self._timing.measure("DLC.queue_get"): + item = q.get(timeout=0.05) queue_wait_time = time.perf_counter() - wait_start except queue.Empty: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Error getting item from queue", exc_info=exc) with self._lifecycle_lock: self._state = WorkerState.FAULTED @@ -625,6 +809,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: frame, ts, enq = item self._process_frame(frame, ts, enq, queue_wait_time=queue_wait_time) except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -635,6 +821,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: logger.info("DLC worker thread exiting") + self._timing.maybe_log() + class DLCService: """Wrap DLCLiveProcessor lifecycle & configuration.""" diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 1edbf7890..0ef0528e2 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -70,6 +70,7 @@ def __init__(self, parent: WorkerTimingStats, name: str): self.parent = parent self.name = name self.t0 = 0.0 + self.elapsed = 0.0 def __enter__(self): if self.parent.enabled: @@ -80,8 +81,8 @@ def __exit__(self, exc_type, exc, tb): if not self.parent.enabled: return False - dt = time.perf_counter() - self.t0 - self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + dt + self.elapsed = time.perf_counter() - self.t0 + self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + self.elapsed self.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 return False diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py index 534e5732f..bd72958cd 100644 --- a/dlclivegui/utils/utils.py +++ b/dlclivegui/utils/utils.py @@ -1,7 +1,10 @@ from __future__ import annotations import re +import sys +import threading import time +import traceback from collections import deque from collections.abc import Iterable from dataclasses import dataclass @@ -87,6 +90,19 @@ def build_run_dir(session_dir: Path, *, use_timestamp: bool) -> Path: return run_dir +def format_thread_stack(thread: threading.Thread) -> str: + ident = thread.ident + if ident is None: + return f"Thread {thread.name!r} has no ident." + + frame = sys._current_frames().get(ident) + if frame is None: + return f"No Python stack frame found for thread {thread.name!r} ident={ident}." + + stack = "".join(traceback.format_stack(frame)) + return f"Stack for thread {thread.name!r} ident={ident}:\n{stack}" + + @dataclass(frozen=True) class RecordingPlan: session_dir: Path From aa5f3178753bfd6a6e5423ff9a917b080c161a1a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:30:37 +0200 Subject: [PATCH 104/194] Disable pose latency debug logging Comment out the camera-to-GUI pose latency calculation and debug log in `pose_ready_callback`. This removes the try/except-wrapped timing log path while leaving pose handling and display update behavior unchanged. --- dlclivegui/gui/main_window.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index aa6cfde5d..cde83ecfd 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2093,12 +2093,12 @@ def _on_pose_ready(self, result: PoseResult) -> None: with self._dlc_timing.measure("DLC.pose_ready_callback"): self._last_pose = result - try: - latency_ms = (time.time() - float(result.timestamp)) * 1000.0 - if logger.isEnabledFor(logging.DEBUG): - logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) - except Exception: - pass + # try: + # latency_ms = (time.time() - float(result.timestamp)) * 1000.0 + # if logger.isEnabledFor(logging.DEBUG): + # logger.debug("DLC pose latency camera_timestamp_to_gui=%.2f ms", latency_ms) + # except Exception: + # pass if self._current_frame is not None: self._display_dirty = True From 4f1b1e9cdddf51fff0bf2f359f0ea55fa50fd20d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 13:31:01 +0200 Subject: [PATCH 105/194] Fix shutdown order in camera preview stop Reorders preview teardown so inference is stopped before stopping the multi-camera controller. This avoids stopping the controller while inference is still active and keeps shutdown state cleanup consistent. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index cde83ecfd..cf95cb968 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1739,9 +1739,9 @@ def _stop_preview(self) -> None: # Stop any active recording first self._stop_multi_camera_recording() - self.multi_camera_controller.stop(wait=True) self._pending_recording_after_preview = False self._stop_inference(show_message=False) + self.multi_camera_controller.stop() self._fps_tracker.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): From c5fa7a3f57f4df7841d26dfe6c8ec2d92d913319 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 10:15:13 +0200 Subject: [PATCH 106/194] Add mono-preserving output mode to GenTL Adds a `preserve_mono` option to the GenTL camera backend so Mono pixel formats can remain 2D Mono8 output instead of always converting to BGR. The backend now reports preserve-mono capability, exposes recommended/actual output format based on camera format, and persists detected pixel/output format metadata in settings. It also logs first-frame format details to make runtime format behavior easier to inspect. --- dlclivegui/cameras/backends/gentl_backend.py | 54 +++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 63b8a8943..067a70a13 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -90,6 +90,8 @@ def __init__(self, settings): ns = {} self._fast_start: bool = bool(ns.get("fast_start", False)) + self._preserve_mono: bool = bool(getattr(settings, "preserve_mono", False) or ns.get("preserve_mono", False)) + self._logged_first_frame: bool = False raw_device_id = ns.get("device_id") or props.get("device_id") legacy_serial = ns.get("serial_number") or ns.get("serial") or props.get("serial_number") or props.get("serial") @@ -177,10 +179,20 @@ def actual_pixel_format(self) -> str | None: """Camera/native pixel format selected on the GenICam PixelFormat node.""" return self._camera_pixel_format or (self._pixel_format if self._pixel_format != "auto" else None) + @property + def recommended_preserve_mono(self) -> bool | None: + if not self._camera_pixel_format: + return None + return self._is_camera_mono() + @property def actual_output_format(self) -> str | None: - """Current GenTL backend emits OpenCV-native BGR uint8 frames.""" - return self._actual_output_format or "BGR8" + """Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'.""" + if self._actual_output_format: + return self._actual_output_format + if not self._camera_pixel_format: + return None + return "Mono8" if self._should_output_mono() else "BGR8" @classmethod def is_available(cls) -> bool: @@ -196,6 +208,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "device_discovery": SupportLevel.SUPPORTED, "stable_identity": SupportLevel.SUPPORTED, "hardware_trigger": SupportLevel.BEST_EFFORT, + "preserve_mono": SupportLevel.SUPPORTED, } def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: @@ -609,6 +622,13 @@ def waits_for_hardware_trigger(self) -> bool: role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() return role in {"external", "follower"} + def _is_camera_mono(self) -> bool: + fmt = str(self._camera_pixel_format or self._pixel_format or "").strip() + return fmt.startswith("Mono") + + def _should_output_mono(self) -> bool: + return bool(self._preserve_mono and self._is_camera_mono()) + @staticmethod def _output_format_for_frame(frame: np.ndarray) -> str: if frame.ndim == 2: @@ -664,6 +684,25 @@ def read(self) -> CapturedFrame: pass if self._actual_output_format is None: self._actual_output_format = self._output_format_for_frame(frame) + try: + ns = self._ensure_settings_ns() + ns["actual_output_format"] = self._actual_output_format + ns["preserve_mono"] = self._preserve_mono + except Exception: + pass + if not self._logged_first_frame: + self._logged_first_frame = True + LOG.info( + "[GenTL] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB " + "camera_pixel_format=%s output_format=%s preserve_mono=%s", + self._device_id, + frame.shape, + frame.dtype, + frame.nbytes / (1024 * 1024), + self._camera_pixel_format, + self.actual_output_format, + self._preserve_mono, + ) return CapturedFrame( frame=frame, @@ -1378,6 +1417,14 @@ def _configure_pixel_format(self, node_map) -> None: pixel_format_node.value = selected self._pixel_format = str(pixel_format_node.value) self._camera_pixel_format = self._pixel_format + try: + ns = self._ensure_settings_ns() + ns["actual_pixel_format"] = self._camera_pixel_format + ns["detected_pixel_format"] = self._camera_pixel_format + ns["actual_output_format"] = self.actual_output_format + ns["preserve_mono"] = self._preserve_mono + except Exception: + pass LOG.debug("GenTL pixel format selected: %s", self._pixel_format) @@ -1884,6 +1931,9 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: frame = cv2.cvtColor(frame, cv2.COLOR_BayerGR2BGR) elif fmt == "BayerBG8": frame = cv2.cvtColor(frame, cv2.COLOR_BayerBG2BGR) + elif self._should_output_mono(): + # Keep Mono* cameras as 2D uint8 frames when explicitly requested. + pass else: frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) From 4b7a3b751fc08e42deb1f38ae1886d38aa1c8479 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 15:18:25 +0200 Subject: [PATCH 107/194] Async recording stop and queued frame dispatch Move recording shutdown off the UI thread and finalize it via a Qt signal so stop actions no longer block the interface. RecordingManager now uses a lock-protected recorder map plus a bounded background frame-dispatch queue/thread to decouple frame intake from disk writes, drop frames under sustained backpressure, and cleanly stop/tear down recorders. The recording-with-overlays UI path is also disabled in this change. --- dlclivegui/gui/main_window.py | 54 +++++++-- dlclivegui/gui/recording_manager.py | 180 +++++++++++++++++++++++----- 2 files changed, 191 insertions(+), 43 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index cf95cb968..16c3449ec 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -6,12 +6,13 @@ import json import logging import os +import threading import time from pathlib import Path import cv2 import numpy as np -from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl +from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl, Signal from PySide6.QtGui import ( QAction, QActionGroup, @@ -88,6 +89,8 @@ class DLCLiveMainWindow(QMainWindow): """Main application window.""" + _recording_stopped_async = Signal() + def __init__(self, config: ApplicationSettings | None = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") @@ -179,6 +182,9 @@ def __init__(self, config: ApplicationSettings | None = None): self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) # Display flag (decoupled from frame capture for performance) self._display_dirty: bool = False + # Recording state + self._recording_stopping = False + self._recording_stopped_async.connect(self._on_recording_stopped_async) self._load_icons() self._preview_pixmap = QPixmap(LOGO_ALPHA) @@ -652,11 +658,11 @@ def _build_recording_group(self) -> QGroupBox: form.addRow(grid) # Recording options - self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") - self.record_with_overlays_checkbox.setToolTip( - "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" - ) - self.record_with_overlays_checkbox.setChecked(False) + # self.record_with_overlays_checkbox = QCheckBox("Record video with overlays") + # self.record_with_overlays_checkbox.setToolTip( + # "Enable to include pose overlays in recorded video (keypoints & bounding boxes)" + # ) + # self.record_with_overlays_checkbox.setChecked(False) self.fast_encoding_checkbox = QCheckBox("Use faster encoding parameters") self.fast_encoding_checkbox.setToolTip( @@ -669,7 +675,7 @@ def _build_recording_group(self) -> QGroupBox: recording_options = QWidget() recording_options_layout = QHBoxLayout(recording_options) recording_options_layout.setContentsMargins(0, 0, 0, 0) - recording_options_layout.addWidget(self.record_with_overlays_checkbox) + # recording_options_layout.addWidget(self.record_with_overlays_checkbox) recording_options_layout.addWidget(self.fast_encoding_checkbox) recording_options_layout.addStretch(1) @@ -1470,8 +1476,8 @@ def _on_recording_frame_ready( if not self._rec_manager.is_active: return - if self.record_with_overlays_checkbox.isChecked(): - frame = self._render_overlays_for_recording(camera_id, frame) + # if self.record_with_overlays_checkbox.isChecked(): + # frame = self._render_overlays_for_recording(camera_id, frame) self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) @@ -1627,9 +1633,35 @@ def _stop_multi_camera_recording(self) -> None: if not self._rec_manager.is_active: return - self.multi_camera_controller.set_recording_frame_do_emit(False) + if getattr(self, "_recording_stopping", False): + return + + self._recording_stopping = True - self._rec_manager.stop_all() + self.start_record_button.setEnabled(False) + self.stop_record_button.setEnabled(False) + self.statusBar().showMessage("Stopping multi-camera recording…", 3000) + + # Stop frame emission immediately so no new frames enter recording pipeline. + try: + self.multi_camera_controller.set_recording_frame_do_emit(False) + except Exception: + logger.exception("Failed to disable recording frame emission") + + def worker(): + try: + self._rec_manager.stop_all() + finally: + self._recording_stopped_async.emit() + + threading.Thread( + target=worker, + name="StopRecordingWorker", + daemon=True, + ).start() + + def _on_recording_stopped_async(self) -> None: + self._recording_stopping = False self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) self.statusBar().showMessage("Multi-camera recording stopped", 3000) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 9c7a523aa..6f5f4f43a 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -2,6 +2,8 @@ import logging import math +import queue +import threading import time from pathlib import Path @@ -15,6 +17,8 @@ log = logging.getLogger(__name__) +_FRAME_SENTINEL = object() + class RecordingManager: """Handle multi-camera recording lifecycle and filenames.""" @@ -24,21 +28,30 @@ def __init__(self): self._session_dir: Path | None = None self._run_dir: Path | None = None + self._lock = threading.RLock() + self._frame_queue: queue.Queue | None = None + self._dispatch_thread: threading.Thread | None = None + self._dispatch_stop = threading.Event() + @property def is_active(self) -> bool: - return bool(self._recorders) + with self._lock: + return bool(self._recorders) @property def recorders(self) -> dict[str, VideoRecorder]: - return self._recorders + with self._lock: + return dict(self._recorders) @property def session_dir(self) -> Path | None: - return self._session_dir + with self._lock: + return self._session_dir @property def run_dir(self) -> Path | None: - return self._run_dir + with self._lock: + return self._run_dir @staticmethod def _backend_ns(cam: CameraSettings) -> dict: @@ -79,7 +92,71 @@ def _resolve_recording_fps( return cls._valid_fps(cam.fps) def pop(self, cam_id: str, default=None) -> VideoRecorder | None: - return self._recorders.pop(cam_id, default) + with self._lock: + 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 + + 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) + except queue.Full: + pass + + if t is not None: + t.join(timeout=timeout) + if t.is_alive(): + log.warning("Recording frame dispatcher did not stop within %.1fs", timeout) + + with self._lock: + self._dispatch_thread = None + self._frame_queue = None + self._dispatch_stop.clear() + + def _dispatch_loop(self) -> None: + with self._lock: + q = self._frame_queue + + if q is None: + return + + while not self._dispatch_stop.is_set(): + try: + item = q.get(timeout=0.1) + except queue.Empty: + continue + + try: + if item is _FRAME_SENTINEL: + break + + cam_id, frame, timestamp = item + self._write_frame_now(cam_id, frame, timestamp) + + finally: + try: + q.task_done() + except ValueError: + pass def start_all( self, @@ -107,8 +184,9 @@ def start_all( Returns: run_dir if at least one recorder started, else None. """ - if self._recorders: - return self._run_dir + with self._lock: + if self._recorders: + return self._run_dir if not active_cams: return None @@ -125,8 +203,9 @@ def start_all( log.error("Failed to create run dir: %s", exc) return None - self._session_dir = session_dir - self._run_dir = run_dir + with self._lock: + self._session_dir = session_dir + self._run_dir = run_dir started_any = False @@ -164,7 +243,8 @@ def start_all( ) try: recorder.start() - self._recorders[cam_id] = recorder + with self._lock: + self._recorders[cam_id] = recorder started_any = True log.info("Started recording %s -> %s", cam_id, cam_path) except Exception as exc: @@ -174,30 +254,40 @@ def start_all( return None if not started_any: - self._recorders.clear() - self._session_dir = None - self._run_dir = None + with self._lock: + self._recorders.clear() + self._session_dir = None + self._run_dir = None return None + self._start_dispatcher() return run_dir def stop_all(self) -> None: - for cam_id, rec in self._recorders.items(): + self._stop_dispatcher() + + with self._lock: + recorders = list(self._recorders.items()) + self._recorders.clear() + + for cam_id, rec in recorders: try: rec.stop() log.info("Stopped recording %s", cam_id) except Exception as exc: log.warning("Error stopping recorder for %s: %s", cam_id, exc) - self._recorders.clear() - self._session_dir = None - self._run_dir = None - - def write_frame( - self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None - ) -> None: - rec = self._recorders.get(cam_id) + + with self._lock: + self._session_dir = None + self._run_dir = None + + def _write_frame_now(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None) -> None: + with self._lock: + rec = self._recorders.get(cam_id) + if not rec or not rec.is_running: return + try: rec.write( frame, @@ -206,18 +296,40 @@ def write_frame( ) except Exception as exc: log.warning( - "Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s", + "Failed to write frame for %s: %s: %s frame_shape=%s dtype=%s. Removing recorder.", cam_id, type(exc).__name__, str(exc) or repr(exc), getattr(frame, "shape", None), getattr(frame, "dtype", None), ) - try: - rec.stop() - except Exception: - log.exception("Failed to stop recorder for %s after write error.") - self._recorders.pop(cam_id, None) + + with self._lock: + rec = self._recorders.pop(cam_id, None) + + if rec is not None: + try: + rec.stop() + except Exception: + 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) -> None: + with self._lock: + q = self._frame_queue + active = cam_id in self._recorders + + if not active or q is None: + return + + try: + q.put_nowait((cam_id, frame, timestamp if timestamp is not None else time.time(), timestamp_metadata)) + except queue.Full: + log.warning( + "Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s", + cam_id, + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) def get_stats_summary(self) -> str: totals = { @@ -231,7 +343,11 @@ def get_stats_summary(self) -> str: "max_latency": 0.0, "avg_latencies": [], } - for rec in self._recorders.values(): + + with self._lock: + recorders = list(self._recorders.values()) + + for rec in recorders: stats: RecorderStats | None = rec.get_stats() if not stats: continue @@ -245,8 +361,8 @@ def get_stats_summary(self) -> str: totals["max_latency"] = max(totals["max_latency"], stats.last_latency) totals["avg_latencies"].append(stats.average_latency) - if len(self._recorders) == 1: - rec = next(iter(self._recorders.values())) + if len(recorders) == 1: + rec = recorders[0] stats = rec.get_stats() if stats: from dlclivegui.utils.stats import format_recorder_stats @@ -261,7 +377,7 @@ def get_stats_summary(self) -> str: fill_pct = (100.0 * totals["queue"] / buffer) if buffer > 0 else 0.0 return ( - f"{len(self._recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " + f"{len(recorders)} cams | {totals['written']}/{totals['enqueued']} frames | " f"writer {totals['write_fps']:.1f} fps | " f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " f"queue {queue_text} ({fill_pct:.0f}%) | " From 39964d1e4376e14ece55815bd1e08c1d048ac0e8 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:54:38 +0200 Subject: [PATCH 108/194] Update recording_manager.py --- dlclivegui/gui/recording_manager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 6f5f4f43a..dd1deadc5 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -281,7 +281,9 @@ def stop_all(self) -> None: self._session_dir = None self._run_dir = None - def _write_frame_now(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None) -> None: + def _write_frame_now( + self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None + ) -> None: with self._lock: rec = self._recorders.get(cam_id) @@ -313,7 +315,9 @@ def _write_frame_now(self, cam_id: str, frame: np.ndarray, timestamp: float | No except Exception: 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) -> None: + def write_frame( + 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 From 1885cfe598096f5754f31a9722773b2a2ef06953 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 13 Aug 2026 14:05:25 +0200 Subject: [PATCH 109/194] Use runner pose directly in DLC processor Switch pose handling to validate `self._dlc.pose` from `runner.get_pose` instead of calling the private `_post_process_pose` path. The emitted `PosePacket.raw` now carries the validated pose array, keeping packet contents aligned with the processed keypoints. --- dlclivegui/services/dlc_processor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 4d4df7584..ce45dd6a4 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -590,19 +590,19 @@ def _process_frame( with self._timing.measure("DLC.runner.get_pose"): self._dlc.pose = self._dlc.runner.get_pose(processed_frame) - with self._timing.measure("DLC.post_process_pose"): - raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) + # with self._timing.measure("DLC.post_process_pose"): + # raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start with self._timing.measure("DLC.validate_pose"): - pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) + pose_arr: np.ndarray = validate_pose_array(self._dlc.pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, keypoint_names=None, individual_ids=None, source=PoseSource(backend=PoseBackends.DLC_LIVE, model_type=self._settings.model_type), - raw=raw_pose, + raw=pose_arr, ) processor_overhead = 0.0 From e0ebe7bf4a407f8b68d612f39b2c5717114e1ee9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 13 Aug 2026 17:07:59 +0200 Subject: [PATCH 110/194] Split timing loggers across worker thread --- dlclivegui/services/dlc_processor.py | 76 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index ce45dd6a4..ec92461e7 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -183,8 +183,14 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) - self._timing = WorkerTimingStats( - "DLCLiveProcessor", + self._enqueue_timing = WorkerTimingStats( + "DLCLiveEnqueue", + logger=logger, + log_interval=1.0, + enabled=bool(DLC_DO_LOG_TIMING or ENABLE_PROFILING), + ) + self._worker_timing = WorkerTimingStats( + "DLCLiveWorker", logger=logger, log_interval=1.0, enabled=bool(DLC_DO_LOG_TIMING or ENABLE_PROFILING), @@ -250,7 +256,7 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: q = self._queue should_start = t is None or not t.is_alive() - with self._timing.measure("DLC.enqueue.copy_frame"): + with self._enqueue_timing.measure("DLC.enqueue.copy_frame"): frame_c = frame.copy() enq_time = time.perf_counter() @@ -276,6 +282,9 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: timestamp, ) self._start_worker_locked(frame_c, timestamp) + + self._enqueue_timing.note_frame() + self._enqueue_timing.maybe_log() return # Worker is now running; refresh queue snapshot. q = self._queue @@ -287,15 +296,18 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: while True: try: - with self._timing.measure("DLC.enqueue.put"): + with self._enqueue_timing.measure("DLC.enqueue.put"): q.put_nowait(item) with self._stats_lock: self._frames_enqueued += 1 + + self._enqueue_timing.note_frame() + self._enqueue_timing.maybe_log() return except queue.Full: try: - with self._timing.measure("DLC.enqueue.drop_stale"): + with self._enqueue_timing.measure("DLC.enqueue.drop_stale"): q.get_nowait() try: q.task_done() @@ -534,7 +546,7 @@ def _timed_processor(self): def timed_process(pose, _op=original, _holder=holder, **kwargs): start = time.perf_counter() try: - with self._timing.measure("DLC.processor.process"): + with self._worker_timing.measure("DLC.processor.process"): return _op(pose, **kwargs) finally: _holder[0] = time.perf_counter() - start @@ -579,22 +591,22 @@ def _process_frame( if self._dlc is None: raise RuntimeError("DLCLive instance is not initialized.") # Time GPU inference (and processor overhead when present) - with self._timing.measure("DLC.prepare_frame"): + with self._worker_timing.measure("DLC.prepare_frame"): frame = self._prepare_input_frame(frame) with self._timed_processor() as proc_holder: inference_start = time.perf_counter() - with self._timing.measure("DLC.process_frame"): + with self._worker_timing.measure("DLC.process_frame"): processed_frame = self._dlc.process_frame(frame) - with self._timing.measure("DLC.runner.get_pose"): + with self._worker_timing.measure("DLC.runner.get_pose"): self._dlc.pose = self._dlc.runner.get_pose(processed_frame) - # with self._timing.measure("DLC.post_process_pose"): + # with self._worker_timing.measure("DLC.post_process_pose"): # raw_pose: Any = self._dlc._post_process_pose(processed_frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start - with self._timing.measure("DLC.validate_pose"): + with self._worker_timing.measure("DLC.validate_pose"): pose_arr: np.ndarray = validate_pose_array(self._dlc.pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, @@ -613,7 +625,7 @@ def _process_frame( # Emit pose (measure signal overhead) signal_start = time.perf_counter() - with self._timing.measure("DLC.emit.pose_ready"): + with self._worker_timing.measure("DLC.emit.pose_ready"): self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) signal_time = time.perf_counter() - signal_start @@ -635,8 +647,8 @@ def _process_frame( self._gpu_inference_times.append(gpu_inference_time) self._processor_overhead_times.append(processor_overhead) - self._timing.note_frame() - self._timing.maybe_log() + self._worker_timing.note_frame() + self._worker_timing.maybe_log() self.frame_processed.emit() def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: @@ -645,7 +657,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") - with self._timing.measure("DLC.build_options"): + with self._worker_timing.measure("DLC.build_options"): dyn = self._settings.dynamic if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: try: @@ -683,12 +695,12 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: raise RuntimeError( "DLCLive class is not available. Ensure the dlclive package is installed and can be imported." ) - with self._timing.measure("DLC.construct"): + with self._worker_timing.measure("DLC.construct"): self._dlc = DLCLive(**options) - self._timing.maybe_log() + self._worker_timing.maybe_log() except Exception as exc: - self._timing.note_error() - self._timing.maybe_log() + self._worker_timing.note_error() + self._worker_timing.maybe_log() with self._lifecycle_lock: self._state = WorkerState.FAULTED raise RuntimeError( @@ -699,7 +711,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: logger.debug("DLC worker stop requested during construction; exiting before init_inference.") return - with self._timing.measure("DLC.prepare_init_frame"): + with self._worker_timing.measure("DLC.prepare_init_frame"): init_frame = self._prepare_input_frame(init_frame) if logger.isEnabledFor(logging.DEBUG): @@ -710,12 +722,12 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: init_frame.flags["C_CONTIGUOUS"], ) # First inference to initialize - with self._timing.measure("DLC.init_inference"): + with self._worker_timing.measure("DLC.init_inference"): self._dlc.init_inference(init_frame) self._debug_log_dlc_runner_device() - self._timing.note_frame() - self._timing.maybe_log() + self._worker_timing.note_frame() + self._worker_timing.maybe_log() if self._stop_event.is_set(): logger.debug("DLC worker stop requested after init_inference; exiting before RUNNING state.") @@ -723,7 +735,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # Pass DLCLive cfg to processor if available if hasattr(self._dlc, "processor") and hasattr(self._dlc.processor, "set_dlc_cfg"): - with self._timing.measure("DLC.processor.set_dlc_cfg"): + with self._worker_timing.measure("DLC.processor.set_dlc_cfg"): self._dlc.processor.set_dlc_cfg(getattr(self._dlc, "cfg", None)) self._initialized = True @@ -777,8 +789,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: self._process_frame(frame, ts, enq, queue_wait_time=0.0) except Exception as exc: - self._timing.note_error() - self._timing.maybe_log() + self._worker_timing.note_error() + self._worker_timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -791,14 +803,14 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # Normal operation: timed get try: wait_start = time.perf_counter() - with self._timing.measure("DLC.queue_get"): + with self._worker_timing.measure("DLC.queue_wait"): item = q.get(timeout=0.05) queue_wait_time = time.perf_counter() - wait_start except queue.Empty: continue except Exception as exc: - self._timing.note_error() - self._timing.maybe_log() + self._worker_timing.note_error() + self._worker_timing.maybe_log() logger.exception("Error getting item from queue", exc_info=exc) with self._lifecycle_lock: self._state = WorkerState.FAULTED @@ -809,8 +821,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: frame, ts, enq = item self._process_frame(frame, ts, enq, queue_wait_time=queue_wait_time) except Exception as exc: - self._timing.note_error() - self._timing.maybe_log() + self._worker_timing.note_error() + self._worker_timing.maybe_log() logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: @@ -821,7 +833,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: logger.info("DLC worker thread exiting") - self._timing.maybe_log() + self._worker_timing.maybe_log() class DLCService: From c2a7289f916f0bd4ae573f702f45bada2c1eae68 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 13 Aug 2026 17:10:07 +0200 Subject: [PATCH 111/194] pre-commit --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 16c3449ec..0526c70c0 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -51,9 +51,9 @@ from dlclivegui.config import ( ALLOWED_VIDEO_CONTAINERS, DEFAULT_CONFIG, - GUI_MAX_DISPLAY_FPS, DEFAULT_RECORDING_CONTAINER, DLC_DO_LOG_TIMING, + GUI_MAX_DISPLAY_FPS, ApplicationSettings, BoundingBoxSettings, CameraSettings, From 95938914145b8032343477d46a97a4e61f34a98d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:09:52 +0200 Subject: [PATCH 112/194] Remove duplicate node value reader --- dlclivegui/cameras/backends/gentl_backend.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 067a70a13..1e5be592e 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1217,19 +1217,6 @@ def _node_symbolics(node) -> list[str]: except Exception: return [] - @staticmethod - def _node_value(node_map, name: str, default=None): - """Best-effort read of a GenICam node value.""" - try: - node = getattr(node_map, name) - except Exception: - return default - - try: - return node.value - except Exception: - return default - @classmethod def _node_float(cls, node_map, *names: str, allow_zero: bool = False) -> float | None: """Return the first positive float value from a list of GenICam node names.""" From a41cd7f6a41ca1e0ace3aa6a23acf0264fe57e3f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 15:52:37 +0200 Subject: [PATCH 113/194] Pass timestamp metadata to frame writer Update the recording queue consumer to unpack `timestamp_metadata` from each frame item and forward it to `_write_frame_now`. This keeps queued frame payload handling aligned with the expanded tuple format and preserves metadata during recording. --- dlclivegui/gui/recording_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index dd1deadc5..cf9be9eb5 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -149,8 +149,8 @@ def _dispatch_loop(self) -> None: 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: From db6a057e3506b8c65cff9e7fc790975448d4edfa Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:01:40 +0200 Subject: [PATCH 114/194] Stabilize rec manager async write tests Add a small `_wait_until` polling helper and use it in recorder-manager tests that assert write calls or recorder removal after `write_frame`. This removes race-prone immediate assertions against asynchronous work, making timestamp and stable-camera-ID behavior tests deterministic. --- tests/gui/test_rec_manager.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 5ba5e2dca..d11969983 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import time + import numpy as np import pytest @@ -36,6 +38,16 @@ def current_frames(_active_cams_two): return frames +def _wait_until(predicate, timeout: float = 1.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.01) + + raise AssertionError("Condition was not reached before timeout") + + @pytest.mark.unit def test_start_all_creates_recorders_and_returns_run_dir( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir @@ -204,6 +216,7 @@ def test_write_frame_uses_given_timestamp( mgr.write_frame(cam0_id, frame, timestamp=123.0) rec = mgr.recorders[cam0_id] + _wait_until(lambda: len(rec.write_calls) > 0) assert rec.write_calls[-1][1] == 123.0 @@ -223,6 +236,7 @@ def test_write_frame_uses_time_when_timestamp_missing( mgr.write_frame(cam0_id, frame, timestamp=None) rec = mgr.recorders[cam0_id] + _wait_until(lambda: len(rec.write_calls) > 0) assert rec.write_calls[-1][1] == 999.0 @@ -238,6 +252,7 @@ def test_write_frame_removes_recorder_on_exception( rec.raise_on_write = True mgr.write_frame(cam0_id, current_frames[cam0_id], timestamp=1.0) + _wait_until(lambda: cam0_id not in mgr.recorders) assert cam0_id not in mgr.recorders @@ -346,11 +361,14 @@ def test_recording_manager_uses_stable_camera_id_not_display_id( assert rec.frame_size == (480, 640) mgr.write_frame(stable_id, frame, timestamp=123.0) + _wait_until(lambda: len(rec.write_calls) > 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) + # No async work for unknown ID + time.sleep(0.05) assert len(rec.write_calls) == 1 @@ -461,7 +479,7 @@ def test_write_frame_passes_timestamp_metadata( mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta) rec = mgr.recorders[cam0_id] - assert len(rec.write_calls) == 1 + _wait_until(lambda: len(rec.write_calls) > 0) written_frame, written_timestamp, written_metadata = rec.write_calls[0] assert written_frame is frame From cc7494c64e1bcd03216ac7c7aa6b02e2c19e60b1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 30 Jun 2026 16:40:43 +0200 Subject: [PATCH 115/194] Lock processor settings during DLC inference Disable all DLC and processor configuration widgets consistently while inference is active, including the processor-control checkbox. Refactor processor discovery into shared helpers that detect direct and indirect `dlclive.Processor` subclasses, standardize metadata extraction, and reuse the same fallback logic for package scans and file-based loading. --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e47dbe2f8..e5b62ed97 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -18,7 +18,7 @@ def default_processors_dir() -> str: def _processor_base_class(): - from dlclive.processor import Processor + from dlclive import Processor return Processor From ab8d82dbbc4f690a9e386de5c07bc022ff5103b7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 09:50:45 +0200 Subject: [PATCH 116/194] Improve processor discovery and logging Expand processor class discovery to include re-exported classes by disabling module-only filtering in package/file scans. Also broaden subclass-check error handling to catch unexpected exceptions and log full context when discovery encounters problematic objects. --- dlclivegui/processors/processor_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e5b62ed97..d93093b20 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,8 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module) + # here module only is disabled to allow classes re-exported in other modules to be discovered + return discover_processor_classes(module, only_defined_in_module=False) except Exception: # Full traceback helps a ton when a plugin fails to import From 09a0dc9d302bc6431ce1e018a83fc3f08d1ab36f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:06:38 +0200 Subject: [PATCH 117/194] Add processors package exports Create `dlclivegui/processors/__init__.py` to re-export `register_processor`, `BaseProcessorSocket`, and `PROCESSOR_REGISTRY` from `dlc_processor_socket`, making these APIs available via package-level imports. --- dlclivegui/processors/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index 8e7717155..ee94194dd 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .registry import PROCESSOR_REGISTRY, register_processor +from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor -__all__ = ["register_processor", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] From 976a44b1ed55d75e5d7a323f6b8b7e5e9673a4bf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:08:11 +0200 Subject: [PATCH 118/194] Move example socket processors to examples module Refactors `dlc_processor_socket.py` by removing the in-file example processors and `OneEuroFilter`, and adds them to a new `dlclivegui/processors/examples.py` module. This separates demonstration/experiment-specific logic from the core socket processor implementation, improving maintainability while preserving existing example processor behavior. --- dlclivegui/processors/dlc_processor_socket.py | 49 +++++++++++++++++++ dlclivegui/processors/examples.py | 3 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 6a91ef1a3..2c120c35c 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -23,6 +23,20 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +def register_processor(cls): + registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) + if registry_key in PROCESSOR_REGISTRY: + raise ValueError( + f"Duplicate processor registration key '{registry_key}': " + f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" + ) + PROCESSOR_REGISTRY[registry_key] = cls + return cls + # pragma: cover class BaseProcessorSocket(Processor): @@ -421,3 +435,38 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping registry keys to processor info. + """ + return { + name: { + "class": cls, + "name": getattr(cls, "PROCESSOR_NAME", name), + "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(cls, "PROCESSOR_PARAMS", {}), + } + for name, cls in PROCESSOR_REGISTRY.items() + } + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Registry key (e.g., "MyProcessorSocket") + **kwargs: Constructor kwargs + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 7e96fc068..60c5f8421 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,8 +6,7 @@ import numpy as np -from dlclivegui.processors import register_processor -from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket +from dlclivegui.processors import BaseProcessorSocket, register_processor logger = logging.getLogger(__name__) From a0038e5520a2a788dc344baa23a9172ac67232ee Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:12:22 +0200 Subject: [PATCH 119/194] Skip socket base module in processor scan Update processor package discovery to ignore `dlc_processor_socket` during namespace scanning, since it only provides the base class/registry and should not be listed as an available processor source. The package fallback scan now uses default class discovery behavior, and related outdated comments/docstring lines were cleaned up. --- dlclivegui/processors/processor_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index d93093b20..e631a44a0 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,6 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - # here module only is disabled to allow classes re-exported in other modules to be discovered return discover_processor_classes(module, only_defined_in_module=False) except Exception: From 6311a728670741a83c9e72ee9db5112637158a54 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:13:13 +0200 Subject: [PATCH 120/194] Update processor_utils.py --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e631a44a0..e5b62ed97 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -194,7 +194,7 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: spec.loader.exec_module(module) # Fallback path: discover subclasses of dlclive.Processor - return discover_processor_classes(module, only_defined_in_module=False) + return discover_processor_classes(module) except Exception: # Full traceback helps a ton when a plugin fails to import From bdaa56fd22072eb085c5dd620b06a173df268365 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:21:40 +0200 Subject: [PATCH 121/194] Warn on duplicate processor registration Change `register_processor` to log a warning instead of raising on duplicate `PROCESSOR_ID` keys, allowing later registrations to override earlier ones without import-time failures. Update subclass save tests to load processor classes from `dlclivegui.processors.examples` via a dedicated fixture, so the parametrized tests validate the concrete example processors against the correct module data path. --- dlclivegui/processors/dlc_processor_socket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 2c120c35c..80fa72291 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -30,10 +30,11 @@ def register_processor(cls): registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) if registry_key in PROCESSOR_REGISTRY: - raise ValueError( + msg = ( f"Duplicate processor registration key '{registry_key}': " f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" ) + logger.warning(msg) PROCESSOR_REGISTRY[registry_key] = cls return cls From d66803976aa62b2a22dbd56e258308cb51a39a5b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:40:46 +0200 Subject: [PATCH 122/194] Fix dlclive Processor import paths Update processor imports to use `from dlclive.processor import Processor` in runtime code to avoid torch import side effects --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e5b62ed97..e47dbe2f8 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -18,7 +18,7 @@ def default_processors_dir() -> str: def _processor_base_class(): - from dlclive import Processor + from dlclive.processor import Processor return Processor From b18935cbed203d552f0ef7520ff4f5f5cf80a9c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 1 Jul 2026 10:49:10 +0200 Subject: [PATCH 123/194] Extract processor registry into new module Moves processor registration and discovery helpers out of `dlc_processor_socket.py` into a new `registry.py` module so registry access no longer depends on importing socket logic. `dlc_processor_socket.py` now imports the shared registry helpers and adds a safe fallback when `dlclive` is unavailable, reducing import-time failures in environments without that dependency. Package exports were updated to expose registry APIs from the new module. --- dlclivegui/processors/__init__.py | 4 +- dlclivegui/processors/dlc_processor_socket.py | 50 ------------------- dlclivegui/processors/examples.py | 3 +- 3 files changed, 4 insertions(+), 53 deletions(-) diff --git a/dlclivegui/processors/__init__.py b/dlclivegui/processors/__init__.py index ee94194dd..8e7717155 100644 --- a/dlclivegui/processors/__init__.py +++ b/dlclivegui/processors/__init__.py @@ -1,3 +1,3 @@ -from .dlc_processor_socket import PROCESSOR_REGISTRY, BaseProcessorSocket, register_processor +from .registry import PROCESSOR_REGISTRY, register_processor -__all__ = ["register_processor", "BaseProcessorSocket", "PROCESSOR_REGISTRY"] +__all__ = ["register_processor", "PROCESSOR_REGISTRY"] diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 80fa72291..6a91ef1a3 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -23,21 +23,6 @@ _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) -# Registry for GUI discovery -PROCESSOR_REGISTRY = {} - - -def register_processor(cls): - registry_key = getattr(cls, "PROCESSOR_ID", cls.__name__) - if registry_key in PROCESSOR_REGISTRY: - msg = ( - f"Duplicate processor registration key '{registry_key}': " - f"{PROCESSOR_REGISTRY[registry_key].__name__} vs {cls.__name__}" - ) - logger.warning(msg) - PROCESSOR_REGISTRY[registry_key] = cls - return cls - # pragma: cover class BaseProcessorSocket(Processor): @@ -436,38 +421,3 @@ def get_data(self): if self.dlc_cfg is not None: save_dict["dlc_cfg"] = self.dlc_cfg return save_dict - - -def get_available_processors(): - """ - Get list of available processor classes. - - Returns: - dict: Dictionary mapping registry keys to processor info. - """ - return { - name: { - "class": cls, - "name": getattr(cls, "PROCESSOR_NAME", name), - "description": getattr(cls, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(cls, "PROCESSOR_PARAMS", {}), - } - for name, cls in PROCESSOR_REGISTRY.items() - } - - -def instantiate_processor(class_name, **kwargs): - """ - Instantiate a processor by class name with given parameters. - - Args: - class_name: Registry key (e.g., "MyProcessorSocket") - **kwargs: Constructor kwargs - - Raises: - ValueError: If class_name is not in registry - """ - if class_name not in PROCESSOR_REGISTRY: - available = ", ".join(PROCESSOR_REGISTRY.keys()) - raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - return PROCESSOR_REGISTRY[class_name](**kwargs) diff --git a/dlclivegui/processors/examples.py b/dlclivegui/processors/examples.py index 60c5f8421..7e96fc068 100644 --- a/dlclivegui/processors/examples.py +++ b/dlclivegui/processors/examples.py @@ -6,7 +6,8 @@ import numpy as np -from dlclivegui.processors import BaseProcessorSocket, register_processor +from dlclivegui.processors import register_processor +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket logger = logging.getLogger(__name__) From 3b3a41e41bea92c76f638fef875fccc4f3f62ccd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 16:28:04 +0200 Subject: [PATCH 124/194] 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. --- dlclivegui/services/camera_controller.py | 267 ++++++++++++++++++ .../services/multi_camera_controller.py | 262 ++--------------- .../{gui => services}/recording_manager.py | 0 3 files changed, 293 insertions(+), 236 deletions(-) create mode 100644 dlclivegui/services/camera_controller.py rename dlclivegui/{gui => services}/recording_manager.py (100%) diff --git a/dlclivegui/services/camera_controller.py b/dlclivegui/services/camera_controller.py new file mode 100644 index 000000000..7c64b498e --- /dev/null +++ b/dlclivegui/services/camera_controller.py @@ -0,0 +1,267 @@ +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) # camera_id, frame, timestamp + 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() + + if self._stop_event.is_set(): + try: + self._backend.close() + except Exception: + logger.exception(f"[Worker %s] failed to close backend during early stop", self._camera_id) + finally: + self._backend = None + + self.stopped.emit(self._camera_id) + return + + 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"): + frame, timestamp = self._backend.read() + 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) + 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) + + 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 f5b011150..5e54b3a02 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,195 +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() - - if self._stop_event.is_set(): - try: - self._backend.close() - except Exception: - LOGGER.debug( - "[Worker %s] failed to close backend during early stop", self._camera_id, exc_info=True - ) - finally: - self._backend = None - - self.stopped.emit(self._camera_id) - return - - 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 @@ -326,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] = [] @@ -367,12 +178,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. @@ -478,6 +286,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) @@ -495,7 +305,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) @@ -630,20 +446,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"): @@ -719,32 +535,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 100% rename from dlclivegui/gui/recording_manager.py rename to dlclivegui/services/recording_manager.py From 15c5959a6fb436b4dc0b47841a97f70d5ae689bb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 16:28:23 +0200 Subject: [PATCH 125/194] Route recording frames through recording sink Switch `RecordingManager` import to the new `services` package path and update recording flow to use an explicit recording sink callback. Recording now sets `multi_camera_controller.set_recording_sink(self._rec_manager.write_frame)` when starting and clears it on stop, replacing the previous direct `recording_frame_ready` signal hookup. --- dlclivegui/gui/main_window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0526c70c0..a1bb4eaff 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") From 1648b32ce92bad014a1bb8336ce9833c51b04741 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 16:28:39 +0200 Subject: [PATCH 126/194] Update recording manager test imports Adjust test fixtures and GUI recording manager tests to import `RecordingManager` and related module symbols from `dlclivegui.services.recording_manager` instead of the old `dlclivegui.gui.recording_manager` path. --- tests/conftest.py | 6 +++--- tests/gui/test_rec_manager.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 25c5567e2..20752cd8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -325,7 +325,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 +409,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 +428,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/test_rec_manager.py b/tests/gui/test_rec_manager.py index d11969983..c5df932ea 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -6,8 +6,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 @@ -227,7 +227,7 @@ def test_write_frame_uses_time_when_timestamp_missing( 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 + import dlclivegui.services.recording_manager as rm_mod # noqa: E402 monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) From 7bef928ae03e97c591e8589f06093ebd81779ac1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 2 Jul 2026 17:31:45 +0200 Subject: [PATCH 127/194] Propagate capture metadata in single-camera flow Update the single-camera pipeline to use structured capture results from backend reads, extracting `frame`, `software_timestamp`, and `timestamp_metadata` and forwarding metadata through frame emission and recording writes. Recording queue handling now expects the metadata field as well. Preview transform helpers were also switched to reuse `SingleCameraWorker` crop/rotation methods instead of `MultiCameraController`. --- dlclivegui/gui/camera_config/preview.py | 5 +++-- dlclivegui/services/camera_controller.py | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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/services/camera_controller.py b/dlclivegui/services/camera_controller.py index 7c64b498e..687e89134 100644 --- a/dlclivegui/services/camera_controller.py +++ b/dlclivegui/services/camera_controller.py @@ -118,7 +118,10 @@ def run(self) -> None: while not self._stop_event.is_set(): try: with self._timing.measure("Single.read"): - frame, timestamp = self._backend.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: @@ -141,12 +144,12 @@ def run(self) -> None: if recording_enabled and recording_sink is not None: try: with self._timing.measure("Single.recording_sink"): - recording_sink(self._camera_id, frame, timestamp) + 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) + self.frame_captured.emit(self._camera_id, frame, timestamp, timestamp_metadata) self._timing.note_frame() self._timing.maybe_log() From 8c4a84d181990ac85f2f89b1df043fca928b2699 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:12:48 +0200 Subject: [PATCH 128/194] Add timestamp metadata to frame signal Update `SingleCameraWorker.frame_captured` to emit a fourth argument for timestamp metadata alongside camera ID, frame, and timestamp. This extends the signal contract so downstream multi-camera consumers can receive richer timing context per frame. --- dlclivegui/services/camera_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/camera_controller.py b/dlclivegui/services/camera_controller.py index 687e89134..9c3a76c8d 100644 --- a/dlclivegui/services/camera_controller.py +++ b/dlclivegui/services/camera_controller.py @@ -25,7 +25,7 @@ class SingleCameraWorker(QObject): """Worker for a single camera in multi-camera mode.""" - frame_captured = Signal(str, object, float) # camera_id, frame, timestamp + 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 From 39f3916460987e997ec92618f744300c4e70e50a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:13:11 +0200 Subject: [PATCH 129/194] Comment previous signals --- dlclivegui/services/multi_camera_controller.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 5e54b3a02..94dc8654d 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -110,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 @@ -446,7 +446,7 @@ def _on_frame_captured( frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): - self._settings.get(camera_id) + # self._settings.get(camera_id) # with timing.measure("Multi.apply_transforms"): # if settings and settings.rotation: From f75631cec20584b92509d760ef9a85cce2e3ba7b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:13:43 +0200 Subject: [PATCH 130/194] Fix dispatcher lifecycle and add flush API Refactors recording frame dispatching to start lazily in `write_frame`, simplifies the dispatch loop to block on queue reads, and makes dispatcher shutdown more reliable by waiting to enqueue the sentinel and only clearing thread/queue state when stopping the current thread. Adds `flush(timeout)` to wait for queued frames to be fully dispatched, improving control around recording stop/teardown behavior. --- dlclivegui/services/recording_manager.py | 77 ++++++++++++++++-------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/dlclivegui/services/recording_manager.py b/dlclivegui/services/recording_manager.py index cf9be9eb5..20092827f 100644 --- a/dlclivegui/services/recording_manager.py +++ b/dlclivegui/services/recording_manager.py @@ -96,31 +96,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) @@ -128,8 +125,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: @@ -139,11 +137,8 @@ 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: @@ -260,7 +255,6 @@ def start_all( self._run_dir = None return None - self._start_dispatcher() return run_dir def stop_all(self) -> None: @@ -316,13 +310,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: @@ -335,6 +339,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, From b517df0482b78fc22fa237a02e42a4c3c204d2ab Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:19:52 +0200 Subject: [PATCH 131/194] Update tests for CapturedFrame integration Adjust camera backend and factory tests to return `CapturedFrame` objects instead of `(frame, timestamp)` tuples, matching the updated camera API. Extend `tests/conftest.py` fake DLCLive doubles with runner, processing, and post-processing behavior so `DLCLiveProcessor._process_frame` paths are exercised under the new flow. --- tests/cameras/test_backend_discovery.py | 4 ++-- tests/cameras/test_factory.py | 19 ++++++++-------- tests/cameras/test_fake_backend.py | 2 +- tests/conftest.py | 29 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) 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 20752cd8a..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(): From dc56be102c7db0a190b720cfde4fc5c274e56355 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 3 Jul 2026 16:20:16 +0200 Subject: [PATCH 132/194] Stabilize recording and camera tests Update tests to match recent recording/capture API changes and reduce flakiness. Camera dialog E2E stubs now return `CapturedFrame`, multicam tests validate the new recording sink path (including timestamp metadata forwarding), and recording manager tests consistently clean up with `stop_all()` plus queue flushes before assertions. GUI overlay tests tied to removed behavior are now skipped, and config tests now expect numeric `-input_framerate` values instead of formatted strings. --- .../gui/camera_config/test_cam_dialog_e2e.py | 6 +- tests/gui/test_pose_overlay.py | 2 + tests/gui/test_rec_manager.py | 493 ++++++++++-------- tests/services/test_multicam_controller.py | 113 ++-- tests/test_config.py | 2 + 5 files changed, 341 insertions(+), 275 deletions(-) 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 c5df932ea..0201030bf 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -1,7 +1,5 @@ from __future__ import annotations -import time - import numpy as np import pytest @@ -38,16 +36,6 @@ def current_frames(_active_cams_two): return frames -def _wait_until(predicate, timeout: float = 1.0) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.01) - - raise AssertionError("Condition was not reached before timeout") - - @pytest.mark.unit def test_start_all_creates_recorders_and_returns_run_dir( recording_settings, _active_cams_two, current_frames, patch_video_recorder, patch_build_run_dir @@ -55,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 @@ -95,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 @@ -104,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 @@ -123,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 @@ -187,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 @@ -209,15 +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") + + 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] - _wait_until(lambda: len(rec.write_calls) > 0) - assert rec.write_calls[-1][1] == 123.0 + rec = mgr.recorders[cam0_id] + assert rec.write_calls[-1][1] == 123.0 + finally: + mgr.stop_all() @pytest.mark.unit @@ -225,19 +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.services.recording_manager as rm_mod # noqa: E402 + try: + mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess") + + import dlclivegui.services.recording_manager as rm_mod # noqa: E402 - monkeypatch.setattr(rm_mod.time, "time", lambda: 999.0) + 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) + 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] - _wait_until(lambda: len(rec.write_calls) > 0) - 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 @@ -245,15 +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) - _wait_until(lambda: cam0_id not in mgr.recorders) - 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 @@ -261,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 @@ -279,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 @@ -322,54 +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) + 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 stable_id == "gentl:serial:SER0" - assert display_id == "GenTL cam" - assert stable_id != display_id + run_dir = mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) - 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", - ) + rec = mgr.recorders[stable_id] + assert rec.frame_size == (480, 640) - assert run_dir is not None - assert stable_id in mgr.recorders - assert display_id not in mgr.recorders + mgr.write_frame(stable_id, frame, timestamp=123.0) + assert mgr.flush(timeout=2.0) - rec = mgr.recorders[stable_id] - assert rec.frame_size == (480, 640) + assert len(rec.write_calls) == 1 + assert rec.write_calls[-1][1] == 123.0 - mgr.write_frame(stable_id, frame, timestamp=123.0) - _wait_until(lambda: len(rec.write_calls) > 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) - # No async work for unknown ID - time.sleep(0.05) - assert len(rec.write_calls) == 1 + assert len(rec.write_calls) == 1 + finally: + mgr.stop_all() @pytest.mark.unit @@ -380,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 @@ -430,23 +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") + + for cam in _active_cams_two: + cam_id = get_camera_id(cam) + rec = mgr.recorders[cam_id] + assert rec.codec == "libx264" + assert rec.crf == 23 - assert rec.codec == "libx264" - assert rec.crf == 23 - assert rec.writer_options_overrides == { - "-preset": "ultrafast", - "-tune": "zerolatency", - } + assert rec.writer_options_overrides == { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + finally: + mgr.stop_all() class TestRecordingManagerTimestampMetadata: @@ -460,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] - _wait_until(lambda: len(rec.write_calls) > 0) - 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 1f89fbf5e..add3de9e9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -97,6 +97,8 @@ def test_build_writegear_options_default(): "-vcodec": "libx264", "-crf": 23, } + assert "-preset" not in opts + assert "-tune" not in opts def test_build_writegear_options_fast_encoding_x264(): From e79c78ea8a604fb766d3cc33777b1c09d584415a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:01:26 +0200 Subject: [PATCH 133/194] Harden recording dispatcher shutdown flow Refactors dispatcher lifecycle management to make stop/start behavior safer under load. Introduces explicit dispatcher state flags, bounded constants for queue size/stop timeout, and makes `_stop_dispatcher` return success instead of silently forcing teardown. Shutdown now only enqueues a sentinel once, preserves thread/queue state when sentinel enqueue or join times out, and logs these cases clearly. The dispatch loop now blocks on queue reads, handles full frame payloads consistently, logs unexpected dispatch errors, and warns on invalid `task_done` usage. `stop_all` now returns a boolean to report incomplete shutdown, and `write_frame` only enqueues while the dispatcher is actively accepting frames. --- dlclivegui/services/recording_manager.py | 149 +++++++++++++++++------ 1 file changed, 110 insertions(+), 39 deletions(-) diff --git a/dlclivegui/services/recording_manager.py b/dlclivegui/services/recording_manager.py index 20092827f..512f76ba1 100644 --- a/dlclivegui/services/recording_manager.py +++ b/dlclivegui/services/recording_manager.py @@ -17,6 +17,9 @@ log = logging.getLogger(__name__) +DISPATCH_STOP_TIMEOUT = 2.0 +DISPATCH_QUEUE_MAXSIZE = 4096 + _FRAME_SENTINEL = object() @@ -31,7 +34,8 @@ def __init__(self): self._lock = threading.RLock() self._frame_queue: queue.Queue | None = None self._dispatch_thread: threading.Thread | None = None - self._dispatch_stop = threading.Event() + self._dispatch_accepting: bool = False + self._dispatch_sentinel_enqueued: bool = False @property def is_active(self) -> bool: @@ -96,39 +100,79 @@ def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) def _start_dispatcher(self) -> None: - if self._dispatch_thread is not None and self._dispatch_thread.is_alive(): - return + with self._lock: + 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._frame_queue = queue.Queue(maxsize=DISPATCH_QUEUE_MAXSIZE) + self._dispatch_accepting = True + self._dispatch_sentinel_enqueued = False + 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: + def _stop_dispatcher( + self, + timeout: float = DISPATCH_STOP_TIMEOUT, + ) -> bool: with self._lock: q = self._frame_queue t = self._dispatch_thread - if q is not None: + if t is None: + self._frame_queue = None + self._dispatch_accepting = False + self._dispatch_sentinel_enqueued = False + return True + + # Establish the stop boundary while holding the same lock used + # by write_frame(). No new frame can be accepted after this. + self._dispatch_accepting = False + + should_enqueue_sentinel = not self._dispatch_sentinel_enqueued + self._dispatch_sentinel_enqueued = True + + if should_enqueue_sentinel and q is not None: try: - q.put(_FRAME_SENTINEL, block=True, timeout=timeout) + q.put( + _FRAME_SENTINEL, + block=True, + timeout=timeout, + ) except queue.Full: - log.warning("Recording frame queue full while stopping dispatcher; dispatcher may not stop promptly.") + # No sentinel was inserted, so allow a later stop attempt + # to retry once the dispatcher has freed queue capacity. + with self._lock: + if self._dispatch_thread is t: + self._dispatch_sentinel_enqueued = False - if t is not None: - t.join(timeout=timeout) - if t.is_alive(): - log.warning("Recording frame dispatcher did not stop within %.1fs", timeout) + log.warning( + "Could not enqueue recording dispatcher sentinel within %.1fs; preserving live dispatcher state", + timeout, + ) + return False + + t.join(timeout=timeout) + + if t.is_alive(): + log.warning( + "Recording frame dispatcher did not stop within %.1fs; preserving its queue and thread state", + timeout, + ) + return False with self._lock: + # Do not clear a newer dispatcher if one was somehow started. if self._dispatch_thread is t: self._dispatch_thread = None self._frame_queue = None - self._dispatch_stop.clear() + self._dispatch_accepting = False + self._dispatch_sentinel_enqueued = False + + return True def _dispatch_loop(self) -> None: with self._lock: @@ -142,16 +186,30 @@ def _dispatch_loop(self) -> None: try: if item is _FRAME_SENTINEL: - break + return + + ( + cam_id, + frame, + timestamp, + timestamp_metadata, + ) = item - cam_id, frame, timestamp, timestamp_metadata = item - self._write_frame_now(cam_id, frame, timestamp, timestamp_metadata) + self._write_frame_now( + cam_id, + frame, + timestamp, + timestamp_metadata, + ) + + except Exception: + log.exception("Unhandled error dispatching a recording frame") finally: try: q.task_done() except ValueError: - pass + log.warning("Recording dispatcher called task_done() too many times") def start_all( self, @@ -257,8 +315,10 @@ def start_all( return run_dir - def stop_all(self) -> None: - self._stop_dispatcher() + def stop_all(self) -> bool: + if not self._stop_dispatcher(): + log.warning("Recording stop is incomplete, frame dispatcher is still draining.") + return False with self._lock: recorders = list(self._recorders.items()) @@ -275,6 +335,8 @@ def stop_all(self) -> None: self._session_dir = None self._run_dir = None + return True + def _write_frame_now( self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None ) -> None: @@ -316,9 +378,15 @@ def write_frame( timestamp: float | None = None, timestamp_metadata: object | None = None, ) -> None: + payload = ( + cam_id, + frame, + timestamp if timestamp is not None else time.time(), + timestamp_metadata, + ) + with self._lock: - active = cam_id in self._recorders - if not active: + if cam_id not in self._recorders: return if self._frame_queue is None or self._dispatch_thread is None or not self._dispatch_thread.is_alive(): @@ -326,18 +394,21 @@ def write_frame( q = self._frame_queue - if q is None: - return + if q is None or not self._dispatch_accepting: + return - try: - q.put_nowait((cam_id, frame, timestamp if timestamp is not None else time.time(), timestamp_metadata)) - except queue.Full: - log.warning( - "Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s", - cam_id, - getattr(frame, "shape", None), - getattr(frame, "dtype", None), - ) + try: + q.put_nowait(payload) + return + except queue.Full: + pass + + log.warning( + "Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s", + cam_id, + getattr(frame, "shape", None), + getattr(frame, "dtype", None), + ) def flush(self, timeout: float = 2.0) -> bool: """Wait until all currently queued recording frames have been dispatched. From a4bfabd7dfd82748fdc088f5827d81bc2f99a23a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:03:12 +0200 Subject: [PATCH 134/194] Retry recorder stop until success Make recording shutdown more robust by retrying `stop_all()` in both async stop and app close paths with a configurable interval (`RECORD_STOP_RETRY_INTERVAL`). Also disable recording frame emission before shutdown stop attempts and log stop/shutdown errors for better diagnostics. --- dlclivegui/config.py | 1 + dlclivegui/gui/main_window.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 8f80464e3..b67fe0cb2 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -27,6 +27,7 @@ DEFAULT_RECORDING_FPS: float = 30.0 ALLOWED_VIDEO_CONTAINERS: set[str] = {"mp4", "avi", "mov"} DEFAULT_RECORDING_CONTAINER: str = "mp4" +RECORD_STOP_RETRY_INTERVAL: float = 0.25 ## Debug diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index a1bb4eaff..8dfd98ea7 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -54,6 +54,7 @@ DEFAULT_RECORDING_CONTAINER, DLC_DO_LOG_TIMING, GUI_MAX_DISPLAY_FPS, + RECORD_STOP_RETRY_INTERVAL, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -1652,9 +1653,14 @@ def _stop_multi_camera_recording(self) -> None: def worker(): try: - self._rec_manager.stop_all() - finally: - self._recording_stopped_async.emit() + while not self._rec_manager.stop_all(): + logger.info("Retrying recorder stop...") + time.sleep(RECORD_STOP_RETRY_INTERVAL) + except Exception as e: + logger.exception("Error while stopping recording: %s", e) + return + + self._recording_stopped_async.emit() threading.Thread( target=worker, @@ -2261,7 +2267,13 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if hasattr(self, "_camera_validation_timer") and self._camera_validation_timer.isActive(): self._camera_validation_timer.stop() # Stop all multi-camera recorders - self._rec_manager.stop_all() + try: + self.multi_camera_controller.set_recording_frame_do_emit(False) + except Exception: + logger.exception("Failed to disable recording frame emission during shutdown") + while not self._rec_manager.stop_all(): + logger.info("Retrying recorder stop during shutdown...") + time.sleep(RECORD_STOP_RETRY_INTERVAL) # Close the camera dialog if open (ensures its worker thread is canceled) if getattr(self, "_cam_dialog", None) is not None and self._cam_dialog.isVisible(): From 6901ae3bb9189370ee773ffd1288ec217d939277 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:54:29 +0200 Subject: [PATCH 135/194] Disable debug timing log --- dlclivegui/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index b67fe0cb2..0f35f9ba7 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -35,7 +35,7 @@ SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False REC_DO_LOG_TIMING: bool = False -DLC_DO_LOG_TIMING: bool = True +DLC_DO_LOG_TIMING: bool = False ### Trigger debug logging DEBUG_TRIGGER_LOGS = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False From c187b5f53cf8b3fe3b6d948084ef3931e5fa20f9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:17:19 +0200 Subject: [PATCH 136/194] Add timeout for recorder stop retries Introduce a configurable `RECORD_STOP_RETRY_TIMEOUT` in `config.py` and enforce it in the recording stop worker. The stop loop now tracks cumulative wait time, logs a timeout error, and raises a runtime error if recorders cannot be stopped within the configured limit, preventing indefinite retry loops during shutdown. --- dlclivegui/config.py | 1 + dlclivegui/gui/main_window.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 0f35f9ba7..ff57b1552 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -28,6 +28,7 @@ ALLOWED_VIDEO_CONTAINERS: set[str] = {"mp4", "avi", "mov"} DEFAULT_RECORDING_CONTAINER: str = "mp4" RECORD_STOP_RETRY_INTERVAL: float = 0.25 +RECORD_STOP_RETRY_TIMEOUT: float = 5.0 ## Debug diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 8dfd98ea7..704a288a1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -55,6 +55,7 @@ DLC_DO_LOG_TIMING, GUI_MAX_DISPLAY_FPS, RECORD_STOP_RETRY_INTERVAL, + RECORD_STOP_RETRY_TIMEOUT, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -1652,10 +1653,15 @@ def _stop_multi_camera_recording(self) -> None: logger.exception("Failed to disable recording frame emission") def worker(): + total_wait_time = 0.0 try: while not self._rec_manager.stop_all(): logger.info("Retrying recorder stop...") time.sleep(RECORD_STOP_RETRY_INTERVAL) + total_wait_time += RECORD_STOP_RETRY_INTERVAL + if total_wait_time >= RECORD_STOP_RETRY_TIMEOUT: + logger.error("Timeout while stopping recording after %.1f seconds", total_wait_time) + raise RuntimeError("Could not stop recording within timeout period.") except Exception as e: logger.exception("Error while stopping recording: %s", e) return From cd96790e0abf9a28aeeb82f185c1f265b8559330 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:19:55 +0200 Subject: [PATCH 137/194] Rename recording frame toggle API Renames `set_recording_frame_do_emit` to `set_recording_frame_is_enabled` in `MultiCameraController` and updates all call sites in the main window and multicam tests to match. This makes the recording-frame control API clearer and more explicit. Also refreshes the `_should_emit_display_ready` docstring to clearly describe FPS-based display throttling behavior. --- dlclivegui/gui/main_window.py | 6 +++--- dlclivegui/services/multi_camera_controller.py | 8 +++----- tests/services/test_multicam_controller.py | 6 +++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 704a288a1..0055c8042 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1624,7 +1624,7 @@ def _start_multi_camera_recording(self) -> 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.multi_camera_controller.set_recording_frame_is_enabled(True) self._settings_store.set_session_name(session_name) self.start_record_button.setEnabled(False) @@ -1647,7 +1647,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_frame_is_enabled(False) self.multi_camera_controller.set_recording_sink(None) except Exception: logger.exception("Failed to disable recording frame emission") @@ -2274,7 +2274,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha self._camera_validation_timer.stop() # Stop all multi-camera recorders try: - self.multi_camera_controller.set_recording_frame_do_emit(False) + self.multi_camera_controller.set_recording_frame_is_enabled(False) except Exception: logger.exception("Failed to disable recording frame emission during shutdown") while not self._rec_manager.stop_all(): diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 94dc8654d..396bdd4c2 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -177,16 +177,14 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing - def set_recording_frame_do_emit(self, enabled: bool) -> None: + def set_recording_frame_is_enabled(self, enabled: bool) -> None: 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. - - This only throttles display_ready. It must not throttle frame_ready, - because frame_ready is used for full-rate consumers such as recording. + """ + Return True if enough time has passed since the last display_ready emission, based on GUI_MAX_DISPLAY_FPS. """ if self._gui_display_max_fps <= 0: return True diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 1f8d0f17a..7c93199df 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -530,7 +530,7 @@ def sink(camera_id, frame, timestamp, timestamp_metadata=None): assert seen == [] mc.set_recording_sink(sink) - mc.set_recording_frame_do_emit(True) + mc.set_recording_frame_is_enabled(True) qtbot.waitUntil(lambda: bool(seen), timeout=2000) @@ -539,7 +539,7 @@ def sink(camera_id, frame, timestamp, timestamp_metadata=None): assert isinstance(timestamp, float) assert len(shape) in (2, 3) - mc.set_recording_frame_do_emit(False) + mc.set_recording_frame_is_enabled(False) count_after_disable = len(seen) qtbot.wait(300) @@ -610,7 +610,7 @@ def sink(camera_id, frame, timestamp, timestamp_metadata=None): # 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) + mc.set_recording_frame_is_enabled(True) qtbot.waitUntil(lambda: bool(seen), timeout=2000) From fef76cbd827f45079b082a48d600993e88ee5e99 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:42:24 +0200 Subject: [PATCH 138/194] Respect configured OpenCV device name Update `OpenCVCameraBackend.device_name()` to prefer explicit naming sources before generating a default label. It now returns `device_name` from parsed options first, then `settings.name`, and only falls back to an OpenCV-derived backend label (or generic OpenCV label) when no custom name is provided. --- dlclivegui/cameras/backends/opencv_backend.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 1201749b7..38613fb2c 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -245,15 +245,26 @@ def stop(self) -> None: self._release_capture() def device_name(self) -> str: - base_name = "OpenCV" + ns = self.parse_options(self.settings) + + if ns.device_name: + return ns.device_name + + name = str(getattr(self.settings, "name", "") or "").strip() + if name: + return name + + api_name = "" if self._capture and hasattr(self._capture, "getBackendName"): try: - backend_name = self._capture.getBackendName() + api_name = self._capture.getBackendName() except Exception: - backend_name = "" - if backend_name: - base_name = backend_name - return f"{base_name} camera #{self.settings.index}" + api_name = "" + + if api_name: + return f"OpenCV {api_name} camera #{self.settings.index}" + + return f"OpenCV camera #{self.settings.index}" @property def actual_fps(self) -> float | None: From 8f7a93c10c48813753e3c3707306fcf8f952e2eb Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:15 +0200 Subject: [PATCH 139/194] Use display IDs in camera UI labels Normalize camera labeling in the main window by using `get_display_id()` for active-camera text and DLC camera dropdown entries. This also improves fallback behavior in `_label_for_cam_id` by checking cached display IDs and returning "Unknown camera" instead of raw internal IDs. --- dlclivegui/gui/main_window.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0055c8042..7eb07a9ea 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1320,16 +1320,20 @@ def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> No self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) def _update_active_cameras_label(self) -> None: - """Update the label showing active cameras.""" active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: self.active_cameras_label.setText("No cameras configured") - elif len(active_cams) == 1: + return + + if len(active_cams) == 1: cam = active_cams[0] - self.active_cameras_label.setText(f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps") - else: - cam_names = [f"{c.name}" for c in active_cams] - self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + display_id = get_display_id(cam) + self.active_cameras_label.setText(f"{display_id} [{cam.backend}:{cam.index}]") + return + + cam_names = [get_display_id(c) for c in active_cams] + self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") def _validate_configured_cameras(self) -> None: """Validate that configured cameras are available. @@ -1371,8 +1375,14 @@ def _validate_configured_cameras(self) -> None: def _label_for_cam_id(self, cam_id: str) -> str: for cam in self._config.multi_camera.get_active_cameras(): if get_camera_id(cam) == cam_id: - return f"{cam.name} [{cam.backend}:{cam.index}]" - return cam_id + display_id = get_display_id(cam) + return f"{display_id} [{cam.backend}:{cam.index}]" + + display_id = self._multi_camera_display_ids.get(cam_id) + if display_id: + return display_id + + return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: """Populate the inference camera dropdown from currently running cameras.""" @@ -1408,8 +1418,9 @@ def _refresh_dlc_camera_list(self) -> None: active_cams = self._config.multi_camera.get_active_cameras() for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" - label = f"{cam.name} [{cam.backend}:{cam.index}]" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) + label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) # Keep previous selection if still present, else default to first From f6e2b205938d98c99839436ea71df50096b257df Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:43 +0200 Subject: [PATCH 140/194] Make controls panel scrollable with fixed footer Refactors the main window controls panel to use a new reusable layout helper that keeps the content vertically scrollable while pinning the Preview/Stop buttons in a fixed footer. The new `_VerticalOnlyScrollArea` avoids horizontal clipping/scrollbars by enforcing natural content width and updating constraints on layout/style changes. Also tightens preview shutdown cleanup by resetting running camera/display state and refreshing active camera + DLC camera UI after stopping multi-camera preview. --- dlclivegui/gui/main_window.py | 39 +++++-- dlclivegui/gui/misc/layouts.py | 198 ++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 11 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 7eb07a9ea..4daf78a91 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -273,32 +273,46 @@ def _setup_ui(self) -> None: for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - # Controls panel with fixed width to prevent shifting - controls_widget = QWidget() - # controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - controls_layout = QVBoxLayout(controls_widget) - controls_layout.setContentsMargins(5, 5, 5, 5) + # Controls panel content + controls_content_widget = QWidget() + controls_content_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + controls_layout = QVBoxLayout(controls_content_widget) + controls_layout.setContentsMargins(5, 5, 5, 0) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) controls_layout.addWidget(self._build_viz_group()) - # Preview/Stop buttons at bottom of controls - wrap in widget + # Preview/Stop buttons stay outside the scroll area as a fixed footer button_bar_widget = QWidget() + button_bar_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_bar = QHBoxLayout(button_bar_widget) button_bar.setContentsMargins(0, 5, 0, 5) + self.preview_button = QPushButton("Start Preview") self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) + self.stop_preview_button = QPushButton("Stop Preview") self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) + button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - controls_layout.addWidget(button_bar_widget) - controls_layout.addStretch(1) + + controls_widget = lyts.make_scrollable_with_fixed_footer( + controls_content_widget, + button_bar_widget, + object_name="ControlsPanel", + scroll_object_name="ControlsScrollArea", + footer_object_name="ControlsFooter", + margins=(0, 0, 0, 0), + spacing=0, + footer_margins=(5, 0, 5, 0), + ) # Add controls and video panel to main layout ## Dock widget for controls @@ -1577,12 +1591,19 @@ def _on_multi_camera_stopped(self) -> None: self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._current_frame = None self._multi_camera_frames.clear() self._multi_camera_display_ids.clear() + self._running_cams_ids.clear() + self._display_dirty = False + self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) + + self._update_active_cameras_label() + self._refresh_dlc_camera_list() self._update_inference_buttons() self._update_camera_controls_enabled() self._update_dlc_controls_enabled() diff --git a/dlclivegui/gui/misc/layouts.py b/dlclivegui/gui/misc/layouts.py index 2b09b47fc..d80ba114c 100644 --- a/dlclivegui/gui/misc/layouts.py +++ b/dlclivegui/gui/misc/layouts.py @@ -3,8 +3,19 @@ from collections.abc import Sequence -from PySide6.QtCore import QObject, Qt -from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel, QSizePolicy, QStyle, QStyleOptionComboBox, QWidget +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QGridLayout, + QLabel, + QScrollArea, + QSizePolicy, + QStyle, + QStyleOptionComboBox, + QVBoxLayout, + QWidget, +) def _combo_width_for_current_text(combo: QComboBox, extra_padding: int = 10) -> int: @@ -226,3 +237,186 @@ def _add_pair(label_text: str | None, widget: QWidget | None, stretch: int) -> N grid.setColumnStretch(c, s) return row + + +class _VerticalOnlyScrollArea(QScrollArea): + """ + Vertical-only scroll area. + + It does not introduce horizontal scrolling and does not manually force the + child width on resize. Instead, it makes the child keep at least its natural + layout width, and makes the scroll area advertise that width as its own + minimum width. + + This avoids horizontal clipping without creating a width feedback loop. + """ + + def __init__(self, content_widget: QWidget, parent: QWidget | None = None): + super().__init__(parent) + + self._content_widget = content_widget + + self.setFrameShape(QFrame.Shape.NoFrame) + self.setViewportMargins(0, 0, 0, 0) + self.setContentsMargins(0, 0, 0, 0) + + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # Let the scroll area resize the content to the available viewport size, + # but never below the content's minimum width. + self.setWidgetResizable(True) + self.setWidget(content_widget) + + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + content_widget.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + + self.update_width_constraints() + + def _scrollbar_extent(self) -> int: + return self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent, None, self) + + def _frame_width_total(self) -> int: + frame = self.frameWidth() + return frame * 2 + + def _natural_content_width(self) -> int: + """ + Return the content's natural layout width. + + Important: do not use content_widget.minimumWidth() here, because this + class sets that value. Using it would create a feedback loop where the + natural width grows after resizes. + """ + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().width(), + self._content_widget.minimumSizeHint().width(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().width()) + candidates.append(layout.minimumSize().width()) + + return max(0, *candidates) + + def _natural_content_height(self) -> int: + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().height(), + self._content_widget.minimumSizeHint().height(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().height()) + candidates.append(layout.minimumSize().height()) + + return max(0, *candidates) + + def update_width_constraints(self) -> None: + natural_width = self._natural_content_width() + + # Reserve vertical-scrollbar width so that when the scrollbar appears, + # the content still has enough viewport width and is not horizontally clipped. + min_scroll_width = natural_width + self._scrollbar_extent() + self._frame_width_total() + + if self._content_widget.minimumWidth() != natural_width: + self._content_widget.setMinimumWidth(natural_width) + + if self.minimumWidth() != min_scroll_width: + self.setMinimumWidth(min_scroll_width) + + self._content_widget.updateGeometry() + self.updateGeometry() + + def event(self, event) -> bool: + result = super().event(event) + + if event.type() in { + QEvent.Type.Show, + QEvent.Type.LayoutRequest, + QEvent.Type.FontChange, + QEvent.Type.StyleChange, + QEvent.Type.PaletteChange, + }: + self.update_width_constraints() + + return result + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self.update_width_constraints() + + def sizeHint(self) -> QSize: + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + self._natural_content_height(), + ) + + def minimumSizeHint(self) -> QSize: + # Width is strict, height is deliberately small so vertical scrolling can happen. + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + 120, + ) + + +def make_scrollable_with_fixed_footer( + content_widget: QWidget, + footer_widget: QWidget | None = None, + *, + object_name: str | None = None, + scroll_object_name: str | None = None, + footer_object_name: str | None = None, + margins: tuple[int, int, int, int] = (0, 0, 0, 0), + spacing: int = 0, + footer_margins: tuple[int, int, int, int] = (0, 0, 0, 0), +) -> QWidget: + """ + Wrap `content_widget` in a vertical-only scroll area and keep `footer_widget` + outside the scroll area. + + Guarantees: + - existing content layout is not restructured; + - vertical scrolling appears only when height is insufficient; + - horizontal scrolling is never introduced; + - content is not hidden horizontally: the wrapper advertises the natural + content width as its minimum width; + - footer controls remain visible. + """ + wrapper = QWidget() + if object_name: + wrapper.setObjectName(object_name) + + wrapper_layout = QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(*margins) + wrapper_layout.setSpacing(max(0, int(spacing))) + + scroll = _VerticalOnlyScrollArea(content_widget, wrapper) + if scroll_object_name: + scroll.setObjectName(scroll_object_name) + + wrapper_layout.addWidget(scroll, stretch=1) + + if footer_widget is not None: + footer_wrapper = QWidget(wrapper) + if footer_object_name: + footer_wrapper.setObjectName(footer_object_name) + + footer_layout = QVBoxLayout(footer_wrapper) + footer_layout.setContentsMargins(*footer_margins) + footer_layout.setSpacing(0) + footer_layout.addWidget(footer_widget) + + footer_wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) + wrapper_layout.addWidget(footer_wrapper, stretch=0) + + wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + + # The wrapper must also resist horizontal shrinking, otherwise the dock can + # still become narrower than the scroll area's valid width. + QTimer.singleShot(0, lambda: wrapper.setMinimumWidth(scroll.minimumSizeHint().width())) + + return wrapper From 85d2d969a11a2be0d11935da77e65ba8b5f1a813 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:47:28 +0200 Subject: [PATCH 141/194] Fix test assertion --- tests/cameras/backends/test_opencv_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 5fff09910..e2468297b 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -99,7 +99,7 @@ def fake_videocapture(index, flag): assert any(idx == 0 for idx, _ in calls) assert not any(idx == 1 for idx, _ in calls) # since alt index probe is commented out - assert "camera" in backend.device_name().lower() + assert "test" in backend.device_name().lower() def test_open_raises_when_unable_to_open(monkeypatch, fake_capture_factory): From b477fd36ed6226f56e3faf94cba416b32e028837 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:24 +0200 Subject: [PATCH 142/194] Refactor settings store and add preference APIs Reorganize `DLCLiveGUISettingsStore` with explicit key constants, shared bool/string helpers, and clearer sections/docstrings. Add persisted DLC preference accessors for inference camera ID, processor key, and processor-control state, and tighten processor folder persistence behavior. Also improve `ModelPathStore` readability and diagnostics by clarifying path/model handling logic and adding `exc_info=True` to debug logs for better troubleshooting. --- dlclivegui/utils/settings_store.py | 250 ++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 72 deletions(-) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index 0107afb1c..c6c0171e0 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -13,56 +13,144 @@ class DLCLiveGUISettingsStore: + """Small QSettings-backed store for lightweight GUI preferences. + + Stores UI/session preferences that should survive + application restarts but do not necessarily belong in exported JSON configs. + + Full application configuration snapshots are also stored here separately as + JSON for convenient startup restore. + """ + + # --- app/config keys --- + KEY_LAST_CONFIG_PATH = "app/last_config_path" + KEY_CONFIG_JSON = "app/config_json" + + # --- dlc/model keys --- + KEY_LAST_MODEL_PATH = "dlc/last_model_path" + KEY_PROCESSOR_FOLDER = "dlc/processor_folder" + KEY_INFERENCE_CAMERA_ID = "dlc/inference_camera_id" + KEY_PROCESSOR_KEY = "dlc/processor_key" + KEY_PROCESSOR_CONTROL_ENABLED = "dlc/processor_control_enabled" + + # --- recording keys --- + KEY_SESSION_NAME = "recording/session_name" + KEY_USE_TIMESTAMP = "recording/use_timestamp" + KEY_FAST_ENCODING = "recording/fast_encoding" + def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") - # --- lightweight prefs --- + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _get_bool(self, key: str, default: bool = False) -> bool: + """Read a bool from QSettings, handling Qt/string/int variants.""" + value = self._s.value(key, default) + + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + + return bool(default) + + def _get_optional_str(self, key: str, default: str = "") -> str | None: + """Read an optional string from QSettings.""" + value = self._s.value(key, default) + value = str(value).strip() if value is not None else "" + return value or None + + def _set_optional_str(self, key: str, value: str | None) -> None: + """Persist optional string values as empty strings when unset.""" + self._s.setValue(key, str(value).strip() if value else "") + + # ------------------------------------------------------------------ + # App/model prefs + # ------------------------------------------------------------------ def get_last_model_path(self) -> str | None: - v = self._s.value("dlc/last_model_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_MODEL_PATH) def set_last_model_path(self, path: str) -> None: - self._s.setValue("dlc/last_model_path", path or "") + self._set_optional_str(self.KEY_LAST_MODEL_PATH, path) def get_last_config_path(self) -> str | None: - v = self._s.value("app/last_config_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_CONFIG_PATH) def set_last_config_path(self, path: str) -> None: - self._s.setValue("app/last_config_path", path or "") + self._set_optional_str(self.KEY_LAST_CONFIG_PATH, path) + # ------------------------------------------------------------------ + # Recording prefs + # ------------------------------------------------------------------ def get_session_name(self) -> str: - v = self._s.value("recording/session_name", "") - return str(v) if v else "" + return self._get_optional_str(self.KEY_SESSION_NAME) or "" def set_session_name(self, name: str) -> None: - self._s.setValue("recording/session_name", name or "") + self._set_optional_str(self.KEY_SESSION_NAME, name) def get_use_timestamp(self, default: bool = True) -> bool: - v = self._s.value("recording/use_timestamp", default) - if isinstance(v, bool): - return v - if isinstance(v, (int, float)): - return bool(v) - if isinstance(v, str): - return v.strip().lower() in ("1", "true", "yes", "on") - return bool(default) + return self._get_bool(self.KEY_USE_TIMESTAMP, default=default) def set_use_timestamp(self, value: bool) -> None: - self._s.setValue("recording/use_timestamp", bool(value)) + self._s.setValue(self.KEY_USE_TIMESTAMP, bool(value)) def get_fast_encoding(self, default: bool = False) -> bool: - value = self._s.value("recording/fast_encoding", default) - if isinstance(value, bool): - return value - return str(value).strip().lower() in {"1", "true", "yes", "on"} + return self._get_bool(self.KEY_FAST_ENCODING, default=default) - def get_processor_folder(self, default: str = "") -> str: + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + + # ------------------------------------------------------------------ + # DLC camera / processor prefs + # ------------------------------------------------------------------ + def get_inference_camera_id(self, default: str | None = None) -> str | None: + """Return the last explicitly selected DLC inference camera ID. + + This is a user preference. Runtime fallbacks during preview should not + overwrite this value unless the user explicitly changes the combo. """ - Return the persisted processor folder if it still exists and is a directory. - Otherwise return default. + return self._get_optional_str(self.KEY_INFERENCE_CAMERA_ID, default or "") + + def set_inference_camera_id(self, camera_id: str | None) -> None: + """Persist the explicitly selected DLC inference camera ID.""" + self._set_optional_str(self.KEY_INFERENCE_CAMERA_ID, camera_id) + + def get_processor_key(self, default: str | None = None) -> str | None: + """Return the last selected processor key, if any.""" + return self._get_optional_str(self.KEY_PROCESSOR_KEY, default or "") + + def set_processor_key(self, processor_key: str | None) -> None: + """Persist the selected processor key. + + The key may become unavailable if the processor folder changes. In that + case the GUI should simply fall back to "No Processor" while keeping + refresh behavior graceful. """ - value = self._s.value("dlc/processor_folder", default) + self._set_optional_str(self.KEY_PROCESSOR_KEY, processor_key) + + def get_processor_control_enabled(self, default: bool = False) -> bool: + """Return whether processor-based control was enabled last time.""" + return self._get_bool(self.KEY_PROCESSOR_CONTROL_ENABLED, default=default) + + def set_processor_control_enabled(self, enabled: bool) -> None: + """Persist processor-based control checkbox state.""" + self._s.setValue(self.KEY_PROCESSOR_CONTROL_ENABLED, bool(enabled)) + + def get_processor_folder(self, default: str = "") -> str: + """Return the persisted processor folder if it still exists. + + If the stored folder is missing or invalid, return default. + """ + value = self._s.value(self.KEY_PROCESSOR_FOLDER, default) value = str(value).strip() if value is not None else "" if not value: @@ -78,9 +166,10 @@ def get_processor_folder(self, default: str = "") -> str: return default def set_processor_folder(self, folder: str) -> None: - """ - Persist processor folder only if it exists and is a directory. - Invalid folders are ignored. + """Persist processor folder only if it exists and is a directory. + + Invalid folders are ignored so we do not accidentally replace a valid + stored folder with an unusable value. """ folder = str(folder).strip() if folder is not None else "" if not folder: @@ -89,25 +178,27 @@ def set_processor_folder(self, folder: str) -> None: try: path = Path(folder).expanduser() if path.is_dir(): - self._s.setValue("dlc/processor_folder", str(path.resolve())) + self._s.setValue(self.KEY_PROCESSOR_FOLDER, str(path.resolve())) except Exception: logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) - def set_fast_encoding(self, enabled: bool) -> None: - self._s.setValue("recording/fast_encoding", bool(enabled)) - - # --- optional: snapshot full config as JSON in QSettings --- + # ------------------------------------------------------------------ + # Full config snapshot + # ------------------------------------------------------------------ def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: - self._s.setValue("app/config_json", cfg.model_dump_json()) + """Persist the current full application config as JSON in QSettings.""" + self._s.setValue(self.KEY_CONFIG_JSON, cfg.model_dump_json()) def load_full_config_snapshot(self) -> ApplicationSettings | None: - raw = self._s.value("app/config_json", "") + """Load the previously persisted full application config snapshot.""" + raw = self._s.value(self.KEY_CONFIG_JSON, "") if not raw: return None + try: return ApplicationSettings.model_validate_json(str(raw)) except Exception: - logger.debug("Failed to load full config snapshot from QSettings") + logger.debug("Failed to load full config snapshot from QSettings", exc_info=True) return None @@ -121,19 +212,24 @@ def __init__(self, settings: QSettings | None = None): # Normalization helpers # ------------------------- def _as_path(self, p: str | None) -> Path | None: - """Best-effort conversion to Path (expand ~, interpret '.' as cwd).""" + """Best-effort conversion to Path. + + Expands '~' and interprets '.' as the current working directory. + """ if not p: return None + s = str(p).strip() if not s: return None + try: pp = Path(s).expanduser() if s in (".", "./"): pp = Path.cwd() return pp except Exception: - logger.debug("Failed to parse path: %s", p) + logger.debug("Failed to parse path: %s", p, exc_info=True) return None def _norm_existing_dir(self, p: str | None) -> str | None: @@ -141,27 +237,31 @@ def _norm_existing_dir(self, p: str | None) -> str | None: pp = self._as_path(p) if pp is None: return None + try: - # If a file was given, use its parent directory + # If a file was given, use its parent directory. if pp.exists() and pp.is_file(): pp = pp.parent if pp.exists() and pp.is_dir(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize directory: %s", p) + logger.debug("Failed to normalize directory: %s", p, exc_info=True) + return None def _norm_existing_path(self, p: str | None) -> str | None: - """Return an absolute, resolved existing path (file or dir), else None.""" + """Return an absolute, resolved existing path, file or dir, else None.""" pp = self._as_path(p) if pp is None: return None + try: if pp.exists(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize path: %s", p) + logger.debug("Failed to normalize path: %s", p, exc_info=True) + return None # ------------------------- @@ -176,28 +276,30 @@ def load_last(self) -> str | None: try: pp = Path(path) - # Accept a valid model *file* + + # Accept a valid model file. if pp.is_file() and (Engine.is_pytorch_model_path(pp) or Engine.is_tensorflow_model_dir_path(pp.parent)): return str(pp) except Exception: - logger.debug("Last model path not valid/usable: %s", path) + logger.debug("Last model path not valid/usable: %s", path, exc_info=True) return None def load_last_dir(self) -> str | None: """Return last directory if it still exists and is a directory.""" val = self._settings.value("dlc/last_model_dir") - d = self._norm_existing_dir(str(val)) if val else None - return d + return self._norm_existing_dir(str(val)) if val else None # ------------------------- # Save # ------------------------- def save_if_valid(self, path: str) -> None: - """ - Save last model path if it looks valid/usable, and always save its directory. - - For files: always save parent directory. - - For directories: save directory itself if it looks like a TF model dir. + """Save last model path if it looks valid/usable. + + Also saves a safe directory for QFileDialog.setDirectory(...). + + - For files: saves parent directory. + - For directories: saves the directory itself when appropriate. """ norm = self._norm_existing_path(path) if not norm: @@ -206,7 +308,7 @@ def save_if_valid(self, path: str) -> None: try: p = Path(norm) - # Always persist a *directory* that is safe for QFileDialog.setDirectory(...) + # Always persist a directory that is safe for QFileDialog.setDirectory(...). if p.is_dir(): model_dir = p else: @@ -216,13 +318,12 @@ def save_if_valid(self, path: str) -> None: if model_dir_norm: self._settings.setValue("dlc/last_model_dir", model_dir_norm) - # Persist model path if it is a valid model file, or a TF model directory + # Persist model path if it is a valid model file, or a TF model file + # whose parent is a TensorFlow model directory. if Engine.is_pytorch_model_path(p): self._settings.setValue("dlc/last_model_path", str(p)) elif p.parent.is_dir() and Engine.is_tensorflow_model_dir_path(p.parent): self._settings.setValue("dlc/last_model_path", str(p)) - # elif p.is_dir() and Engine.is_tensorflow_model_dir_path(p): - # self._settings.setValue("dlc/last_model_path", str(p)) except Exception: logger.debug("Failed to save model path: %s", path, exc_info=True) @@ -231,6 +332,7 @@ def save_last_dir(self, directory: str) -> None: d = self._norm_existing_dir(directory) if not d: return + try: self._settings.setValue("dlc/last_model_dir", d) except Exception: @@ -240,12 +342,12 @@ def save_last_dir(self, directory: str) -> None: # Resolve # ------------------------- def resolve(self, config_path: str | None) -> str: - """ - Resolve the best model path to display in the UI. + """Resolve the best model path to display in the UI. + Preference: - 1) config_path if valid/usable - 2) persisted last model path if valid/usable - 3) empty + 1. config_path if valid/usable + 2. persisted last model path if valid/usable + 3. empty string """ cfg = self._norm_existing_path(config_path) if cfg: @@ -256,7 +358,7 @@ def resolve(self, config_path: str | None) -> str: if p.is_dir() and Engine.is_tensorflow_model_dir_path(p): return cfg except Exception: - logger.debug("Config path not usable: %s", cfg) + logger.debug("Config path not usable: %s", cfg, exc_info=True) persisted = self.load_last() if persisted: @@ -265,16 +367,16 @@ def resolve(self, config_path: str | None) -> str: return "" def suggest_start_dir(self, fallback_dir: str | None = None) -> str: + """Pick the best directory to start file dialogs in. + + Guarantees: returns an existing absolute directory, never '.'. """ - Pick the best directory to start file dialogs in. - Guarantees: returns an existing absolute directory (never '.'). - """ - # 1) last dir + # 1. last dir last_dir = self.load_last_dir() if last_dir: return last_dir - # 2) directory of last valid model path + # 2. directory of last valid model path last = self.load_last() if last: try: @@ -288,25 +390,29 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str: if d: return d except Exception: - logger.debug("Failed to derive start dir from last model: %s", last) + logger.debug("Failed to derive start dir from last model: %s", last, exc_info=True) - # 3) fallback dir (e.g. config.dlc.model_directory) + # 3. fallback dir, e.g. config.dlc.model_directory fb = self._norm_existing_dir(fallback_dir) if fb: return fb - # 4) last resort: cwd if exists else home + # 4. last resort: cwd if exists else home cwd = self._norm_existing_dir(str(Path.cwd())) return cwd or str(Path.home()) def suggest_selected_file(self) -> str | None: - """Return a file to preselect if it exists (only files, not directories).""" + """Return a file to preselect if it exists. + + Only files are returned, not directories. + """ last = self.load_last() if not last: return None + try: p = Path(last) return str(p) if p.exists() and p.is_file() else None except Exception: - logger.debug("Failed to check existence of last model: %s", last) + logger.debug("Failed to check existence of last model: %s", last, exc_info=True) return None From 553d3452ce66f6655b0a9a6d06261814c7df5b6b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:48 +0200 Subject: [PATCH 143/194] Persist processor and DLC camera selections Restore and save processor choice, processor-control toggle, and inference camera preference through settings so user selections survive restarts. The camera handling now separates preferred vs active inference camera IDs, using temporary runtime fallback cameras without overwriting the saved preference, and updates overlay/inference routing accordingly. It also improves processor list refresh behavior by restoring the previous selection when available and syncing settings on close. --- dlclivegui/gui/main_window.py | 364 ++++++++++++++++++++++++++++------ 1 file changed, 298 insertions(+), 66 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 4daf78a91..e2884c22a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -144,7 +144,8 @@ def __init__(self, config: ApplicationSettings | None = None): ) self._config = config - self._inference_camera_id: str | None = None # Camera ID used for inference + self._inference_camera_id: str | None = self._settings_store.get_inference_camera_id() + self._active_inference_camera_id: str | None = None self._running_cams_ids: set[str] = set() self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None @@ -840,8 +841,12 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) - self.processor_combo.currentIndexChanged.connect(self._on_processor_selection_changed) - self.use_custom_proc_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) + self.processor_combo.currentIndexChanged.connect( + self._on_processor_selection_changed + ) + self.use_custom_proc_checkbox.stateChanged.connect( + self._on_custom_processor_enabled_changed + ) # Recording settings ## Session name persistence + preview updates @@ -910,6 +915,13 @@ def _apply_config(self, config: ApplicationSettings) -> None: if hasattr(self, "bbox_color_combo"): color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) + # Processor + ## Allow processor control checkbox state + if hasattr(self, "use_custom_proc_checkbox"): + self.use_custom_proc_checkbox.setChecked( + self._settings_store.get_processor_control_enabled(default=False) + ) + # Update DLC camera list self._refresh_dlc_camera_list() @@ -1181,32 +1193,170 @@ def _custom_processor_enabled(self) -> bool: and self.processor_combo.currentData() is not None ) + def _on_processor_selection_changed( + self, + _index: int, + ) -> None: + """Persist selection and enable custom processing for a chosen processor.""" + selected_key = self.processor_combo.currentData() + has_selection = selected_key is not None + + self._settings_store.set_processor_key(selected_key) + self.processor_toggle_row.setVisible(has_selection) + + self.use_custom_proc_checkbox.blockSignals(True) + self.use_custom_proc_checkbox.setChecked(has_selection) + self.use_custom_proc_checkbox.blockSignals(False) + + self._settings_store.set_processor_control_enabled( + has_selection + ) + + if hasattr( + self.processor_combo, + "update_shrink_width", + ): + self.processor_combo.update_shrink_width() + + self._update_processor_status() + + + def _on_custom_processor_enabled_changed( + self, + _state: int, + ) -> None: + """Persist whether the selected custom processor should be used.""" + enabled = self._custom_processor_enabled() + + self._settings_store.set_processor_control_enabled( + enabled + ) + self._update_processor_status() + def _refresh_processors(self) -> None: - self.processor_combo.clear() - self.processor_combo.addItem("No Processor", None) + """Scan processors and restore the previous selection best-effort.""" + previous_key = self.processor_combo.currentData() + preferred_key = ( + previous_key + or self._settings_store.get_processor_key() + ) + preferred_enabled = ( + self.use_custom_proc_checkbox.isChecked() + if previous_key is not None + else self._settings_store.get_processor_control_enabled( + default=False + ) + ) - selected_folder = self.processor_folder_edit.text().strip() - selected_path = Path(selected_folder).expanduser() if selected_folder else None + self.processor_combo.blockSignals(True) + try: + self.processor_combo.clear() + self.processor_combo.addItem( + "No Processor", + None, + ) - if selected_path is not None and selected_path.is_dir(): - resolved_folder = str(selected_path.resolve()) - self._settings_store.set_processor_folder(resolved_folder) - self._scanned_processors = scan_processor_folder(resolved_folder) - source_text = resolved_folder - else: - self._scanned_processors = scan_processor_package("dlclivegui.processors") - source_text = "package dlclivegui.processors" + selected_folder = ( + self.processor_folder_edit.text().strip() + ) + selected_path = ( + Path(selected_folder).expanduser() + if selected_folder + else None + ) + + if ( + selected_path is not None + and selected_path.is_dir() + ): + resolved_folder = str( + selected_path.resolve() + ) + self._settings_store.set_processor_folder( + resolved_folder + ) + self._scanned_processors = ( + scan_processor_folder( + resolved_folder + ) + ) + source_text = resolved_folder + else: + self._scanned_processors = ( + scan_processor_package( + "dlclivegui.processors" + ) + ) + source_text = ( + "package dlclivegui.processors" + ) + + self._processor_keys = list( + self._scanned_processors + ) + + for key in self._processor_keys: + info = self._scanned_processors[key] + display_name = ( + f"{info['name']} ({info['file']})" + ) + self.processor_combo.addItem( + display_name, + key, + ) + + selected_index = 0 + if preferred_key is not None: + found_index = ( + self.processor_combo.findData( + preferred_key + ) + ) + if found_index >= 0: + selected_index = found_index + + self.processor_combo.setCurrentIndex( + selected_index + ) + finally: + self.processor_combo.blockSignals(False) + + has_selection = ( + self.processor_combo.currentData() + is not None + ) + + self.processor_toggle_row.setVisible( + has_selection + ) - self._processor_keys = list(self._scanned_processors.keys()) + self.use_custom_proc_checkbox.blockSignals( + True + ) + self.use_custom_proc_checkbox.setChecked( + has_selection and preferred_enabled + ) + self.use_custom_proc_checkbox.blockSignals( + False + ) - for key in self._processor_keys: - info = self._scanned_processors[key] - display_name = f"{info['name']} ({info['file']})" - self.processor_combo.addItem(display_name, key) + # Clear a saved key that is no longer available. + selected_key = self.processor_combo.currentData() + self._settings_store.set_processor_key( + selected_key + ) + self._settings_store.set_processor_control_enabled( + self._custom_processor_enabled() + ) self.processor_combo.update_shrink_width() - self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) + self._update_processor_status() + self.statusBar().showMessage( + f"Found {len(self._processor_keys)} " + f"processor(s) in {source_text}", + 3000, + ) # ------------------------------------------------------------------ # Recording path preview and session name persistence def _known_recording_extensions(self) -> set[str]: @@ -1399,34 +1549,56 @@ def _label_for_cam_id(self, cam_id: str) -> str: return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: - """Populate the inference camera dropdown from currently running cameras.""" + """Populate inference camera dropdown from currently running cameras. + + - Keep the user's preferred camera if it is running + - Otherwise use a temporary runtime fallback + - Never persist fallback choices caused by preview/update events + """ + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() + for cam in self._config.multi_camera.get_active_cameras(): cam_id = get_camera_id(cam) if cam_id in self._running_cams_ids: self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) - # Keep current selection if still present, else select first running - if self._inference_camera_id in self._running_cams_ids: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id in self._running_cams_ids: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = self.dlc_camera_combo.currentData() + + self._active_inference_camera_id = selected_id + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() - def _set_dlc_combo_to_id(self, cam_id: str) -> None: - """Update combo selection to a given ID without firing signals.""" + def _set_dlc_combo_to_id(self, cam_id: str) -> bool: + """Update combo selection to a given camera ID without firing signals.""" self.dlc_camera_combo.blockSignals(True) - idx = self.dlc_camera_combo.findData(cam_id) - if idx >= 0: - self.dlc_camera_combo.setCurrentIndex(idx) - self.dlc_camera_combo.blockSignals(False) + try: + idx = self.dlc_camera_combo.findData(cam_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + return True + return False + finally: + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() def _refresh_dlc_camera_list(self) -> None: - """Populate the inference camera dropdown from active cameras.""" + """Populate inference camera dropdown from configured active cameras.""" + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() @@ -1437,27 +1609,41 @@ def _refresh_dlc_camera_list(self) -> None: label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) - # Keep previous selection if still present, else default to first - if self._inference_camera_id is not None: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id is not None: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() - else: - if self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + selected_id = self.dlc_camera_combo.currentData() + + # First-run convenience only. + # If there is no previous preference, initialize one. + if self._inference_camera_id is None and preferred_id is None: + self._inference_camera_id = selected_id + self._settings_store.set_inference_camera_id(selected_id) + + self._active_inference_camera_id = selected_id self.dlc_camera_combo.blockSignals(False) self.dlc_camera_combo.update_shrink_width() def _on_dlc_camera_changed(self, _index: int) -> None: - """Track user selection of the inference camera.""" - self._inference_camera_id = self.dlc_camera_combo.currentData() + """Track explicit user selection of the inference camera.""" + cam_id = self.dlc_camera_combo.currentData() + + self._inference_camera_id = cam_id + self._active_inference_camera_id = cam_id + + self._settings_store.set_inference_camera_id(cam_id) + self.dlc_camera_combo.update_shrink_width() - # Force redraw so bbox/pose overlays switch to the new tile immediately + + # Force redraw so bbox/pose overlays switch to the new tile immediately. if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1469,7 +1655,7 @@ def _render_overlays_for_recording(self, cam_id, frame): offset, scale = (0, 0), (1.0, 1.0) # If this is the inference camera, apply pose overlays - if cam_id == self._inference_camera_id and self._last_pose and self._last_pose.pose is not None: + if cam_id == self._active_inference_camera_id and self._last_pose and self._last_pose.pose is not None: output = draw_pose( output, self._last_pose.pose, @@ -1526,25 +1712,30 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._running_cams_ids = new_running self._refresh_dlc_camera_list_running() - # Determine DLC camera (first active camera) - selected_id = self._inference_camera_id + preferred_id = self._inference_camera_id available_ids = list(frame_data.frames.keys()) - if selected_id in frame_data.frames: - dlc_cam_id = selected_id + + if preferred_id in frame_data.frames: + dlc_cam_id = preferred_id else: dlc_cam_id = available_ids[0] if available_ids else "" + if dlc_cam_id: - self._inference_camera_id = dlc_cam_id - self._set_dlc_combo_to_id(dlc_cam_id) - self.statusBar().showMessage( - f"DLC inference camera changed to {self._label_for_cam_id(dlc_cam_id)}", 3000 - ) - else: # No more cameras available + if self._active_inference_camera_id != dlc_cam_id: + self._active_inference_camera_id = dlc_cam_id + self._set_dlc_combo_to_id(dlc_cam_id) + self.statusBar().showMessage( + f"Using temporary DLC inference camera: {self._label_for_cam_id(dlc_cam_id)}", + 3000, + ) + else: if self._dlc_active: self._stop_inference(show_message=True) self._display_dirty = True return + self._active_inference_camera_id = dlc_cam_id + # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -1839,20 +2030,42 @@ def _configure_dlc(self) -> bool: # Instantiate processor if selected processor = None selected_key = self.processor_combo.currentData() + + self._settings_store.set_processor_key( + selected_key + ) + if self._custom_processor_enabled(): try: - # For now, instantiate with no parameters - processor = instantiate_from_scan(self._scanned_processors, selected_key) - processor_name = self._scanned_processors[selected_key]["name"] - self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) - except Exception as e: - error_msg = f"Failed to instantiate processor: {e}" + processor = instantiate_from_scan( + self._scanned_processors, + selected_key, + ) + processor_name = ( + self._scanned_processors[ + selected_key + ]["name"] + ) + self.statusBar().showMessage( + f"Loaded processor: {processor_name}", + 3000, + ) + except Exception as exc: + error_msg = ( + "Failed to instantiate processor: " + f"{exc}" + ) self._show_error(error_msg) logger.error(error_msg) return False - elif selected_key is not None: - self.statusBar().showMessage(f"Custom processor disabled: {selected_key}", 3000) + elif selected_key is not None: + self.statusBar().showMessage( + f"Custom processor disabled: " + f"{selected_key}", + 3000, + ) + self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) return True @@ -2330,9 +2543,28 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit if hasattr(self, "processor_folder_edit"): self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) + # Remember user-preferred inference camera on exit. + if hasattr(self, "_inference_camera_id"): + self._settings_store.set_inference_camera_id(self._inference_camera_id) + + # Remember selected processor on exit + if hasattr(self, "processor_combo"): + self._settings_store.set_processor_key(self.processor_combo.currentData()) + + # Remember processor-control checkbox state on exit + if hasattr(self, "use_custom_proc_checkbox"): + self._settings_store.set_processor_control_enabled(self.use_custom_proc_checkbox.isChecked()) + + # Flush QSettings best-effort + try: + self.settings.sync() + except Exception: + logger.exception("Failed to sync QSettings on close", exc_info=True) + # Close the window super().closeEvent(event) From a6245302668b0740f9fd030846e14741e7ccc0d0 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:23:09 +0200 Subject: [PATCH 144/194] Expand settings store unit test coverage Add broad tests for `DLCLiveGUISettingsStore` behavior, including inference camera ID, processor folder/key persistence, and processor control toggles. The in-memory `QSettings` test double now supports `remove` and `sync`, and new parametrized cases verify robust boolean parsing for processor control plus recording `use_timestamp` and `fast_encoding` settings. --- tests/utils/test_settings_store.py | 166 ++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 318dc49cd..702382429 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -11,10 +11,11 @@ class InMemoryQSettings: - """Stand-in for QSettings""" + """Small stand-in for QSettings.""" def __init__(self): self._d = {} + self.synced = False def value(self, key: str, default=None): return self._d.get(key, default) @@ -22,6 +23,12 @@ def value(self, key: str, default=None): def setValue(self, key: str, value): self._d[key] = value + def remove(self, key: str): + self._d.pop(key, None) + + def sync(self): + self.synced = True + # ----------------------------- # QtSettingsStore @@ -348,3 +355,160 @@ def test_model_path_store_suggest_selected_file_returns_none_when_missing(tmp_pa settings.setValue("dlc/last_model_path", str(missing)) assert mps.suggest_selected_file() is None + + +# ----------------------------- +# Inference camera ID +# ----------------------------- +def test_settings_store_inference_camera_id_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("opencv:0") + assert settstore.get_inference_camera_id() == "opencv:0" + + settstore.set_inference_camera_id(None) + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("") + assert settstore.get_inference_camera_id() is None + + +# ----------------------------- +# Processor settings +# ----------------------------- +def test_settings_store_processor_folder_roundtrip_when_valid(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + folder = tmp_path / "processors" + folder.mkdir() + + settstore.set_processor_folder(str(folder)) + + assert settstore.get_processor_folder(default="fallback") == str(folder.resolve()) + + +def test_settings_store_processor_folder_ignores_invalid_value(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + + settstore.set_processor_folder(str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_get_processor_folder_returns_default_if_stored_missing(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + s.setValue("dlc/processor_folder", str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_processor_key_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_key() is None + + settstore.set_processor_key("my_processor") + assert settstore.get_processor_key() == "my_processor" + + settstore.set_processor_key(None) + assert settstore.get_processor_key() is None + + settstore.set_processor_key("") + assert settstore.get_processor_key() is None + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (True, True), + (False, False), + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + (1, True), + (0, False), + ], +) +def test_settings_store_processor_control_bool_parsing(stored, expected): + s = InMemoryQSettings() + s.setValue("dlc/processor_control_enabled", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=not expected) is expected + + +def test_settings_store_processor_control_enabled_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=False) is False + + settstore.set_processor_control_enabled(True) + assert settstore.get_processor_control_enabled(default=False) is True + + settstore.set_processor_control_enabled(False) + assert settstore.get_processor_control_enabled(default=True) is False + + +# ----------------------------- +# Recording +# ----------------------------- +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_use_timestamp_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/use_timestamp", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_use_timestamp(default=not expected) is expected + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_fast_encoding_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/fast_encoding", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_fast_encoding(default=not expected) is expected From f8dccc315954e32b8f63b455a70c18313fcbb7bf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:39:17 +0200 Subject: [PATCH 145/194] Improve config path handling in main window This updates configuration load/save flow to keep a valid associated config file path when restoring from QSettings snapshots, and to use a smarter default path for file dialogs (current config, last valid config, or derived parent path). It also makes saves return success/failure so `_config_path` is only updated on successful writes, and syncs settings immediately after loading a config to persist metadata reliably. --- dlclivegui/gui/main_window.py | 75 ++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e2884c22a..8bb6fa32c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -101,13 +101,19 @@ def __init__(self, config: ApplicationSettings | None = None): self._model_path_store = ModelPathStore(self.settings) self._settings_store = DLCLiveGUISettingsStore(self.settings) + last_cfg_path = self._settings_store.get_last_config_path() + last_cfg_file = self._valid_config_file_path(last_cfg_path) if config is None: # 1) snapshot cfg = self._settings_store.load_full_config_snapshot() if cfg is not None: config = cfg - self._config_path = None - logger.info("Loaded configuration from QSettings snapshot.") + self._config_path = last_cfg_file + if self._config_path is not None: + logger.info(f"Loaded configuration from QSettings snapshot; associated file: {self._config_path}") + else: + logger.info("Loaded configuration from QSettings snapshot without associated config file.") + else: # 2) last config file path last_cfg_path = self._settings_store.get_last_config_path() @@ -228,6 +234,19 @@ def resizeEvent(self, event): if not self.multi_camera_controller.is_running(): self._show_logo_and_text() + def _valid_config_file_path(self, path: str | None) -> Path | None: + if not path: + return None + + try: + p = Path(path).expanduser() + if p.exists() and p.is_file(): + return p.resolve() + except Exception: + logger.debug("Invalid config file path: %s", path, exc_info=True) + + return None + # ------------------------------------------------------------------ UI def _init_theme_actions(self) -> None: """Set initial checked state for theme actions based on current app stylesheet.""" @@ -1029,10 +1048,36 @@ def _visualization_settings_from_ui(self) -> VisualizationSettings: bbox_color=self._bbox_color, ) + def _suggest_config_dialog_path(self) -> str: + """Return best initial path for load/save config dialogs.""" + if getattr(self, "_config_path", None) is not None: + try: + return str(self._config_path) + except Exception: + pass + + last_cfg = self._settings_store.get_last_config_path() + valid_last = self._valid_config_file_path(last_cfg) + if valid_last is not None: + return str(valid_last) + + if last_cfg: + try: + p = Path(last_cfg).expanduser() + parent = p.parent + if parent.exists() and parent.is_dir(): + return str(parent / (p.name or "config.json")) + except Exception: + logger.debug("Failed to derive config dialog path from %s", last_cfg, exc_info=True) + + return str(Path.home() / "config.json") + # ------------------------------------------------------------------ # Actions def _action_load_config(self) -> None: - file_name, _ = QFileDialog.getOpenFileName(self, "Load configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getOpenFileName( + self, "Load configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return try: @@ -1042,6 +1087,12 @@ def _action_load_config(self) -> None: return self._settings_store.set_last_config_path(file_name) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after loading config", exc_info=True) + self._config = config self._config_path = Path(file_name) self._apply_config(config) @@ -1053,19 +1104,22 @@ def _action_save_config(self) -> None: if self._config_path is None: self._action_save_config_as() return - self._save_config_to_path(self._config_path) + if self._save_config_to_path(self._config_path): + self._config_path = self._config_path.expanduser() def _action_save_config_as(self) -> None: - file_name, _ = QFileDialog.getSaveFileName(self, "Save configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getSaveFileName( + self, "Save configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return - path = Path(file_name) + path = Path(file_name).expanduser() if path.suffix.lower() != ".json": path = path.with_suffix(".json") - self._config_path = path - self._save_config_to_path(path) + if self._save_config_to_path(path): + self._config_path = path - def _save_config_to_path(self, path: Path) -> None: + def _save_config_to_path(self, path: Path) -> bool: try: config = self._current_config(allow_empty_model_path=True) config.save(path) @@ -1073,8 +1127,9 @@ def _save_config_to_path(self, path: Path) -> None: self._settings_store.save_full_config_snapshot(config) except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) - return + return False self.statusBar().showMessage(f"Saved configuration to {path}", 5000) + return True def _action_browse_model(self) -> None: # Prefer persisted last-used directory, then config.dlc.model_directory, then home From 693bd4b0affa55db11476c4e755263579c2593dc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:49:39 +0200 Subject: [PATCH 146/194] pre-commit --- dlclivegui/gui/main_window.py | 140 ++++++++-------------------------- 1 file changed, 33 insertions(+), 107 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 8bb6fa32c..628021e21 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -860,12 +860,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) - self.processor_combo.currentIndexChanged.connect( - self._on_processor_selection_changed - ) - self.use_custom_proc_checkbox.stateChanged.connect( - self._on_custom_processor_enabled_changed - ) + self.processor_combo.currentIndexChanged.connect(self._on_processor_selection_changed) + self.use_custom_proc_checkbox.stateChanged.connect(self._on_custom_processor_enabled_changed) # Recording settings ## Session name persistence + preview updates @@ -937,9 +933,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Processor ## Allow processor control checkbox state if hasattr(self, "use_custom_proc_checkbox"): - self.use_custom_proc_checkbox.setChecked( - self._settings_store.get_processor_control_enabled(default=False) - ) + self.use_custom_proc_checkbox.setChecked(self._settings_store.get_processor_control_enabled(default=False)) # Update DLC camera list self._refresh_dlc_camera_list() @@ -1263,9 +1257,7 @@ def _on_processor_selection_changed( self.use_custom_proc_checkbox.setChecked(has_selection) self.use_custom_proc_checkbox.blockSignals(False) - self._settings_store.set_processor_control_enabled( - has_selection - ) + self._settings_store.set_processor_control_enabled(has_selection) if hasattr( self.processor_combo, @@ -1275,7 +1267,6 @@ def _on_processor_selection_changed( self._update_processor_status() - def _on_custom_processor_enabled_changed( self, _state: int, @@ -1283,24 +1274,17 @@ def _on_custom_processor_enabled_changed( """Persist whether the selected custom processor should be used.""" enabled = self._custom_processor_enabled() - self._settings_store.set_processor_control_enabled( - enabled - ) + self._settings_store.set_processor_control_enabled(enabled) self._update_processor_status() def _refresh_processors(self) -> None: """Scan processors and restore the previous selection best-effort.""" previous_key = self.processor_combo.currentData() - preferred_key = ( - previous_key - or self._settings_store.get_processor_key() - ) + preferred_key = previous_key or self._settings_store.get_processor_key() preferred_enabled = ( self.use_custom_proc_checkbox.isChecked() if previous_key is not None - else self._settings_store.get_processor_control_enabled( - default=False - ) + else self._settings_store.get_processor_control_enabled(default=False) ) self.processor_combo.blockSignals(True) @@ -1311,50 +1295,23 @@ def _refresh_processors(self) -> None: None, ) - selected_folder = ( - self.processor_folder_edit.text().strip() - ) - selected_path = ( - Path(selected_folder).expanduser() - if selected_folder - else None - ) + selected_folder = self.processor_folder_edit.text().strip() + selected_path = Path(selected_folder).expanduser() if selected_folder else None - if ( - selected_path is not None - and selected_path.is_dir() - ): - resolved_folder = str( - selected_path.resolve() - ) - self._settings_store.set_processor_folder( - resolved_folder - ) - self._scanned_processors = ( - scan_processor_folder( - resolved_folder - ) - ) + if selected_path is not None and selected_path.is_dir(): + resolved_folder = str(selected_path.resolve()) + self._settings_store.set_processor_folder(resolved_folder) + self._scanned_processors = scan_processor_folder(resolved_folder) source_text = resolved_folder else: - self._scanned_processors = ( - scan_processor_package( - "dlclivegui.processors" - ) - ) - source_text = ( - "package dlclivegui.processors" - ) + self._scanned_processors = scan_processor_package("dlclivegui.processors") + source_text = "package dlclivegui.processors" - self._processor_keys = list( - self._scanned_processors - ) + self._processor_keys = list(self._scanned_processors) for key in self._processor_keys: info = self._scanned_processors[key] - display_name = ( - f"{info['name']} ({info['file']})" - ) + display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem( display_name, key, @@ -1362,56 +1319,35 @@ def _refresh_processors(self) -> None: selected_index = 0 if preferred_key is not None: - found_index = ( - self.processor_combo.findData( - preferred_key - ) - ) + found_index = self.processor_combo.findData(preferred_key) if found_index >= 0: selected_index = found_index - self.processor_combo.setCurrentIndex( - selected_index - ) + self.processor_combo.setCurrentIndex(selected_index) finally: self.processor_combo.blockSignals(False) - has_selection = ( - self.processor_combo.currentData() - is not None - ) + has_selection = self.processor_combo.currentData() is not None - self.processor_toggle_row.setVisible( - has_selection - ) + self.processor_toggle_row.setVisible(has_selection) - self.use_custom_proc_checkbox.blockSignals( - True - ) - self.use_custom_proc_checkbox.setChecked( - has_selection and preferred_enabled - ) - self.use_custom_proc_checkbox.blockSignals( - False - ) + self.use_custom_proc_checkbox.blockSignals(True) + self.use_custom_proc_checkbox.setChecked(has_selection and preferred_enabled) + self.use_custom_proc_checkbox.blockSignals(False) # Clear a saved key that is no longer available. selected_key = self.processor_combo.currentData() - self._settings_store.set_processor_key( - selected_key - ) - self._settings_store.set_processor_control_enabled( - self._custom_processor_enabled() - ) + self._settings_store.set_processor_key(selected_key) + self._settings_store.set_processor_control_enabled(self._custom_processor_enabled()) self.processor_combo.update_shrink_width() self._update_processor_status() self.statusBar().showMessage( - f"Found {len(self._processor_keys)} " - f"processor(s) in {source_text}", + f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000, ) + # ------------------------------------------------------------------ # Recording path preview and session name persistence def _known_recording_extensions(self) -> set[str]: @@ -2086,9 +2022,7 @@ def _configure_dlc(self) -> bool: processor = None selected_key = self.processor_combo.currentData() - self._settings_store.set_processor_key( - selected_key - ) + self._settings_store.set_processor_key(selected_key) if self._custom_processor_enabled(): try: @@ -2096,31 +2030,23 @@ def _configure_dlc(self) -> bool: self._scanned_processors, selected_key, ) - processor_name = ( - self._scanned_processors[ - selected_key - ]["name"] - ) + processor_name = self._scanned_processors[selected_key]["name"] self.statusBar().showMessage( f"Loaded processor: {processor_name}", 3000, ) except Exception as exc: - error_msg = ( - "Failed to instantiate processor: " - f"{exc}" - ) + error_msg = f"Failed to instantiate processor: {exc}" self._show_error(error_msg) logger.error(error_msg) return False elif selected_key is not None: self.statusBar().showMessage( - f"Custom processor disabled: " - f"{selected_key}", + f"Custom processor disabled: {selected_key}", 3000, ) - + self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) return True From e705b199095857593b04082ebbb4aed797bdd54d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 12:04:26 +0200 Subject: [PATCH 147/194] Persist recording filename in user settings Adds a dedicated QSettings key for the recording filename and wires it through the settings store. The main window now restores a previously saved filename on startup, saves it when filename editing finishes, and persists it again on close to avoid losing the value between sessions. --- dlclivegui/gui/main_window.py | 12 ++++++++++++ dlclivegui/utils/settings_store.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 628021e21..22ad12def 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -866,6 +866,7 @@ def _connect_signals(self) -> None: # Recording settings ## Session name persistence + preview updates self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.filename_edit.editingFinished.connect(self._on_filename_editing_finished) self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) @@ -889,6 +890,9 @@ def _apply_config(self, config: ApplicationSettings) -> None: recording = config.recording self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) + persisted_filename = self._settings_store.get_rec_filename() + if persisted_filename: + self.filename_edit.setText(persisted_filename) self.container_combo.setCurrentText(recording.container) codec_index = self.codec_combo.findText(recording.codec) if codec_index >= 0: @@ -1396,6 +1400,11 @@ def _on_session_name_editing_finished(self) -> None: self._settings_store.set_session_name(name) self._update_recording_path_preview() + def _on_filename_editing_finished(self) -> None: + filename = self.filename_edit.text().strip() + self._settings_store.set_rec_filename(filename) + self._update_recording_path_preview() + def _update_recording_path_preview(self) -> None: """Update the label showing where files will go (best-effort).""" if not hasattr(self, "recording_path_preview"): @@ -2541,6 +2550,9 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if hasattr(self, "use_custom_proc_checkbox"): self._settings_store.set_processor_control_enabled(self.use_custom_proc_checkbox.isChecked()) + if hasattr(self, "filename_edit"): + self._settings_store.set_rec_filename(self.filename_edit.text().strip()) + # Flush QSettings best-effort try: self.settings.sync() diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index c6c0171e0..c54265177 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -37,6 +37,7 @@ class DLCLiveGUISettingsStore: KEY_SESSION_NAME = "recording/session_name" KEY_USE_TIMESTAMP = "recording/use_timestamp" KEY_FAST_ENCODING = "recording/fast_encoding" + KEY_REC_FILENAME = "recording/rec_filename" def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") @@ -109,6 +110,12 @@ def get_fast_encoding(self, default: bool = False) -> bool: def set_fast_encoding(self, enabled: bool) -> None: self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + def get_rec_filename(self) -> str: + return self._get_optional_str(self.KEY_REC_FILENAME) or "" + + def set_rec_filename(self, filename: str) -> None: + self._set_optional_str(self.KEY_REC_FILENAME, filename) + # ------------------------------------------------------------------ # DLC camera / processor prefs # ------------------------------------------------------------------ From 89d1313e5c78b32f758dab58828603d92cd5565f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:10:03 +0200 Subject: [PATCH 148/194] Restore local prefs only on initial config load Add explicit type annotations for the settings stores and update `_apply_config` to accept a `restore_local_prefs` flag. The main window now restores persisted local preferences (like recording filename) only during initial startup, preventing later config applications from unintentionally overriding values loaded from the active config. --- dlclivegui/gui/main_window.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 22ad12def..f072ff097 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -98,8 +98,8 @@ def __init__(self, config: ApplicationSettings | None = None): self.setWindowTitle("DeepLabCut Live GUI") self.settings = QSettings("DeepLabCut", "DLCLiveGUI") - self._model_path_store = ModelPathStore(self.settings) - self._settings_store = DLCLiveGUISettingsStore(self.settings) + self._model_path_store: ModelPathStore = ModelPathStore(self.settings) + self._settings_store: DLCLiveGUISettingsStore = DLCLiveGUISettingsStore(self.settings) last_cfg_path = self._settings_store.get_last_config_path() last_cfg_file = self._valid_config_file_path(last_cfg_path) @@ -199,7 +199,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._preview_pixmap = QPixmap(LOGO_ALPHA) self._setup_ui() self._connect_signals() - self._apply_config(self._config) + self._apply_config(self._config, restore_local_prefs=True) self._refresh_processors() # Scan and populate processor dropdown self._update_inference_buttons() self._update_camera_controls_enabled() @@ -875,7 +875,7 @@ def _connect_signals(self) -> None: # ------------------------------------------------------------------ # Config - def _apply_config(self, config: ApplicationSettings) -> None: + def _apply_config(self, config: ApplicationSettings, *, restore_local_prefs: bool = False) -> None: # Update active cameras label self._update_active_cameras_label() @@ -890,9 +890,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: recording = config.recording self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) - persisted_filename = self._settings_store.get_rec_filename() - if persisted_filename: - self.filename_edit.setText(persisted_filename) + if restore_local_prefs: + persisted_filename = self._settings_store.get_rec_filename() + if persisted_filename: + self.filename_edit.setText(persisted_filename) self.container_combo.setCurrentText(recording.container) codec_index = self.codec_combo.findText(recording.codec) if codec_index >= 0: From 7ba2f212ce3f1e8c5aa82126fce51bf0fa6b2db4 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:11:23 +0200 Subject: [PATCH 149/194] Include camera ID in unknown camera label Update the fallback camera display name to include the camera ID (`Unknown camera []`) instead of a generic `Unknown camera`, making unidentified cameras easier to distinguish in the UI. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index f072ff097..21a0f0098 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1547,7 +1547,7 @@ def _label_for_cam_id(self, cam_id: str) -> str: if display_id: return display_id - return "Unknown camera" + return f"Unknown camera [{cam_id}]" def _refresh_dlc_camera_list_running(self) -> None: """Populate inference camera dropdown from currently running cameras. From 20ca665cea463517a5c8b02d4f506fd4b4cd500c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:23:06 +0200 Subject: [PATCH 150/194] Fix loading of last config file setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup config loading path now uses the already-resolved `last_cfg_file` object instead of re-reading a string path and re-checking it. This aligns the load logic with the earlier file resolution step and updates related log messages from “path” to “file”. --- dlclivegui/gui/main_window.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 21a0f0098..508517b9e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -116,20 +116,14 @@ def __init__(self, config: ApplicationSettings | None = None): else: # 2) last config file path - last_cfg_path = self._settings_store.get_last_config_path() - if last_cfg_path: + if last_cfg_file is not None: try: - p = Path(last_cfg_path) - if p.exists() and p.is_file(): - config = ApplicationSettings.load(str(p)) - self._config_path = p - logger.info(f"Loaded configuration from last config path: {p}") - else: - config = DEFAULT_CONFIG - self._config_path = None + config = ApplicationSettings.load(str(last_cfg_file)) + self._config_path = last_cfg_file + logger.info(f"Loaded configuration from last config file: {last_cfg_file}") except Exception as exc: logger.warning( - f"Failed to load last config path ({last_cfg_path}): {exc}. Using default config." + f"Failed to load last config file ({last_cfg_file}): {exc}. Using default config." ) config = DEFAULT_CONFIG self._config_path = None From ad871f31ef40aa75718db51f2558a1320b22d53a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 19 Aug 2026 11:36:40 +0200 Subject: [PATCH 151/194] Add Help menu link to documentation Adds a new **Help** menu entry in the main window with a "View documentation" action. The action opens the DeepLabCut-live-GUI docs URL in the default browser and shows a warning dialog if opening the link fails. --- dlclivegui/gui/main_window.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 508517b9e..2cd9b0476 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -453,6 +453,13 @@ def _build_menus(self) -> None: self._apply_theme(self._current_style) self._init_theme_actions() + # Help menu + help_menu = self.menuBar().addMenu("&Help") + + view_docs_action = QAction("View documentation", self) + view_docs_action.triggered.connect(self._action_view_documentation) + help_menu.addAction(view_docs_action) + def _build_camera_group(self) -> QGroupBox: group = QGroupBox("Camera") form = QFormLayout(group) @@ -1234,6 +1241,12 @@ def _action_open_recording_folder(self) -> None: logger.error(f"Failed to open folder: {exc}") self.statusBar().showMessage("Could not open recording folder.", 5000) + def _action_view_documentation(self) -> None: + """Open the DeepLabCut-live-GUI documentation.""" + url = QUrl("https://deeplabcut.github.io/DeepLabCut/docs/dlc-live/dlc-live-gui/index.html") + if not QDesktopServices.openUrl(url): + self._show_warning("Could not open the documentation in your web browser.") + def _custom_processor_enabled(self) -> bool: return bool( getattr(self, "use_custom_proc_checkbox", None) From 7430c4cefce4febf38ac6be60b7d7465bad6d88d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:27:00 +0200 Subject: [PATCH 152/194] Add camera settings fixture --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 04992894d..4f5641bb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -269,6 +269,12 @@ def app_config_two_cams(tmp_path) -> ApplicationSettings: return make_app_config(tmp_path=tmp_path, num_cams=2, backend="fake", enabled=True, fps=30.0) +@pytest.fixture +def camera_worker_settings(app_config_two_cams) -> CameraSettings: + """Single enabled fake camera settings for SingleCameraWorker tests.""" + return app_config_two_cams.multi_camera.cameras[0].model_copy(deep=True) + + # --------------------------------------------------------------------- # Main window fixture # --------------------------------------------------------------------- From 6dd9d788072f698a9401407603b90d57615bad9b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:29:35 +0200 Subject: [PATCH 153/194] Refactor full-queue frame drop test Replace the asynchronous `test_queue_full_drops_frames` flow with a deterministic unit test that directly exercises `enqueue_frame` when the queue is full. The new test injects a 1-slot queue, simulates a running worker state, verifies stale-frame dropping via processor stats, and confirms the newest frame/timestamp remains queued. --- tests/services/test_dlc_processor.py | 48 ++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py index 3f5e0cbc6..a58490e3e 100644 --- a/tests/services/test_dlc_processor.py +++ b/tests/services/test_dlc_processor.py @@ -1,13 +1,25 @@ +from __future__ import annotations + +import queue + import numpy as np import pytest -# from dlclivegui.config import DLCProcessorSettings from dlclivegui.config import DLCProcessorSettings + +# from dlclivegui.config import DLCProcessorSettings from dlclivegui.services.dlc_processor import ( DLCLiveProcessor, ProcessorStats, + WorkerState, ) + +class _AliveThread: + def is_alive(self) -> bool: + return True + + # --------------------------------------------------------------------- # Tests # --------------------------------------------------------------------- @@ -73,28 +85,36 @@ def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_model): proc.reset() -@pytest.mark.unit -def test_queue_full_drops_frames(qtbot, monkeypatch_dlclive, settings_model): +def test_enqueue_frame_drops_stale_when_queue_is_full(settings_model): proc = DLCLiveProcessor() proc.configure(settings_model) try: - frame = np.zeros((32, 32, 3), dtype=np.uint8) + proc._queue = queue.Queue(maxsize=1) + proc._worker_thread = _AliveThread() + proc._state = WorkerState.RUNNING + proc._stop_event.clear() - # Start the worker with the first frame - with qtbot.waitSignal(proc.initialized, timeout=1500): - proc.enqueue_frame(frame, 1.0) + frame1 = np.zeros((32, 32, 3), dtype=np.uint8) + frame2 = np.ones((32, 32, 3), dtype=np.uint8) - # Flood the 1-slot queue to force drops - for _ in range(50): - proc.enqueue_frame(frame, 2.0) + proc.enqueue_frame(frame1, 1.0) + proc.enqueue_frame(frame2, 2.0) - # Wait until we observe dropped frames - qtbot.waitUntil(lambda: proc._frames_dropped > 0, timeout=1500) - assert proc._frames_dropped > 0 + stats = proc.get_stats() + assert stats.frames_enqueued == 2 + assert stats.frames_dropped == 1 + assert stats.queue_size == 1 + + queued_frame, queued_timestamp, _queued_at = proc._queue.get_nowait() + assert queued_timestamp == 2.0 + np.testing.assert_array_equal(queued_frame, frame2) finally: - proc.reset() + proc._queue = None + proc._worker_thread = None + proc._state = WorkerState.STOPPED + proc._stop_event.clear() @pytest.mark.unit From efd5904a752f29463bf8fde9857beee1c4a524ef Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:30:04 +0200 Subject: [PATCH 154/194] Add SingleCameraWorker service unit tests Introduce a new test module for `SingleCameraWorker` covering normal fake-backend startup/frame flow, recording sink behavior (enabled/disabled), camera factory initialization failure handling, repeated empty-frame and read-error retry limits, and hardware-trigger timeout behavior that should not surface as errors. --- tests/services/test_camera_controller.py | 307 +++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tests/services/test_camera_controller.py diff --git a/tests/services/test_camera_controller.py b/tests/services/test_camera_controller.py new file mode 100644 index 000000000..cc0194238 --- /dev/null +++ b/tests/services/test_camera_controller.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import numpy as np + +from dlclivegui.cameras.base import CapturedFrame +from dlclivegui.config import CameraSettings +from dlclivegui.services.camera_controller import SingleCameraWorker + + +def _capture_signals(worker: SingleCameraWorker) -> dict[str, list[tuple]]: + """Collect worker Qt signal emissions synchronously.""" + seen: dict[str, list[tuple]] = { + "runtime_info": [], + "started": [], + "frame_captured": [], + "error_occurred": [], + "stopped": [], + } + + worker.runtime_info.connect(lambda *args: seen["runtime_info"].append(args)) + worker.started.connect(lambda *args: seen["started"].append(args)) + worker.frame_captured.connect(lambda *args: seen["frame_captured"].append(args)) + worker.error_occurred.connect(lambda *args: seen["error_occurred"].append(args)) + worker.stopped.connect(lambda *args: seen["stopped"].append(args)) + + return seen + + +def test_worker_fake_backend(qtbot, patch_factory, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + # Stop after first frame so worker.run() returns synchronously. + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert len(seen["error_occurred"]) == 0 + assert len(seen["runtime_info"]) == 1 + assert len(seen["started"]) == 1 + assert len(seen["frame_captured"]) == 1 + assert len(seen["stopped"]) == 1 + + runtime_camera_id, runtime = seen["runtime_info"][0] + assert runtime_camera_id == "fake:index:0" + assert set(runtime) == { + "actual_fps", + "actual_resolution", + "actual_pixel_format", + "actual_output_format", + } + + assert seen["started"][0] == ("fake:index:0",) + + frame_camera_id, frame, timestamp, timestamp_metadata = seen["frame_captured"][0] + assert frame_camera_id == "fake:index:0" + assert isinstance(frame, np.ndarray) + assert frame.shape == (48, 64, 3) + assert frame.dtype == np.uint8 + assert isinstance(timestamp, float) + assert timestamp_metadata is None + + assert seen["stopped"][0] == ("fake:index:0",) + + +def test_worker_recording_sink_receives_frame(qtbot, patch_factory, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + recorded: list[tuple] = [] + + def recording_sink(camera_id, frame, timestamp, timestamp_metadata): + recorded.append((camera_id, frame.copy(), timestamp, timestamp_metadata)) + + worker.set_recording_sink(recording_sink) + worker.set_recording_enabled(True) + + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert len(seen["error_occurred"]) == 0 + assert len(seen["frame_captured"]) == 1 + assert len(recorded) == 1 + + rec_camera_id, rec_frame, rec_timestamp, rec_metadata = recorded[0] + frame_camera_id, emitted_frame, emitted_timestamp, emitted_metadata = seen["frame_captured"][0] + + assert rec_camera_id == "fake:index:0" + assert frame_camera_id == "fake:index:0" + + np.testing.assert_array_equal(rec_frame, emitted_frame) + assert rec_timestamp == emitted_timestamp + assert rec_metadata == emitted_metadata + + +def test_worker_recording_sink_disabled_does_not_receive_frame(qtbot, camera_worker_settings: CameraSettings): + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + + recorded: list[tuple] = [] + + def recording_sink(*args): + recorded.append(args) + + worker.set_recording_sink(recording_sink) + worker.set_recording_enabled(False) + + worker.frame_captured.connect(lambda *_args: worker.stop()) + + worker.run() + + assert recorded == [] + + +def test_worker_backend_creation_failure_emits_error(monkeypatch, qtbot, camera_worker_settings: CameraSettings): + from dlclivegui.services import camera_controller as controller_mod + + def fail_create(_settings): + raise RuntimeError("error") + + monkeypatch.setattr(controller_mod.CameraFactory, "create", staticmethod(fail_create)) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + seen = _capture_signals(worker) + + worker.run() + + assert len(seen["runtime_info"]) == 0 + assert len(seen["started"]) == 0 + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Failed to initialize camera" in message + assert "error" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _EmptyFrameBackend: + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + + def open(self): + self.open_called = True + + def read(self): + return CapturedFrame(frame=None, software_timestamp=123.0, timestamp_metadata=None) + + def close(self): + self.close_called = True + + +def test_worker_too_many_empty_frames_emits_error(monkeypatch, qtbot, camera_worker_settings: CameraSettings): + from dlclivegui.services import camera_controller as controller_mod + + backend = _EmptyFrameBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._max_consecutive_errors = 3 + worker._retry_delay = 0.0 + + seen = _capture_signals(worker) + + worker.run() + + assert backend.open_called + assert backend.close_called + + assert seen["started"] == [("fake:index:0",)] + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Too many empty frames" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _ReadExceptionBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + self.read_count = 0 + + def open(self): + self.open_called = True + + def read(self): + self.read_count += 1 + raise RuntimeError(f"read failed {self.read_count}") + + def close(self): + self.close_called = True + + +def test_worker_read_exception_emits_error_after_retries( + monkeypatch, + qtbot, + camera_worker_settings: CameraSettings, +): + from dlclivegui.services import camera_controller as controller_mod + + backend = _ReadExceptionBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._max_consecutive_errors = 3 + worker._retry_delay = 0.0 + + seen = _capture_signals(worker) + + worker.run() + + assert backend.open_called + assert backend.close_called + assert backend.read_count == 3 + + assert seen["started"] == [("fake:index:0",)] + assert len(seen["frame_captured"]) == 0 + + assert len(seen["error_occurred"]) == 1 + camera_id, message = seen["error_occurred"][0] + assert camera_id == "fake:index:0" + assert "Camera read error" in message + assert "read failed 3" in message + + assert seen["stopped"] == [("fake:index:0",)] + + +class _TimeoutTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings: CameraSettings): + self.settings = settings + self.open_called = False + self.close_called = False + self.read_count = 0 + + def open(self): + self.open_called = True + + def read(self): + self.read_count += 1 + raise TimeoutError("waiting for trigger") + + def close(self): + self.close_called = True + + +def test_worker_hardware_trigger_timeouts_do_not_emit_error( + monkeypatch, + qtbot, + camera_worker_settings: CameraSettings, +): + from dlclivegui.services import camera_controller as controller_mod + + backend = _TimeoutTriggerBackend(camera_worker_settings) + + monkeypatch.setattr( + controller_mod.CameraFactory, + "create", + staticmethod(lambda _settings: backend), + ) + + worker = SingleCameraWorker("fake:index:0", camera_worker_settings) + worker._trigger_timeout_delay = 0.0 + + seen = _capture_signals(worker) + + original_read = backend.read + + def read_then_stop(): + if backend.read_count >= 3: + worker.stop() + return original_read() + + backend.read = read_then_stop + + worker.run() + + assert backend.open_called + assert backend.close_called + assert backend.read_count >= 3 + + assert seen["started"] == [("fake:index:0",)] + assert seen["error_occurred"] == [] + assert seen["frame_captured"] == [] + assert seen["stopped"] == [("fake:index:0",)] From 249932c1fd02e51398a3445f2505e4a025a43dfa Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:30:40 +0200 Subject: [PATCH 155/194] Add GUI tests for preview and recording lifecycle Adds focused GUI test modules for main-window behavior: preview start/stop lifecycle, recording start/stop and pending-start logic, and camera label/display ID handling in UI elements. This improves regression coverage for camera-state transitions and user-facing camera naming, while keeping existing test entry points aligned with the new structure. --- tests/gui/main_window/test_preview.py | 87 +++++++++++++++++++++++ tests/gui/main_window/test_recording.py | 91 +++++++++++++++++++++++++ tests/gui/main_window/test_ui.py | 52 ++++++++++++++ tests/gui/test_main.py | 1 + 4 files changed, 231 insertions(+) create mode 100644 tests/gui/main_window/test_preview.py create mode 100644 tests/gui/main_window/test_recording.py create mode 100644 tests/gui/main_window/test_ui.py diff --git a/tests/gui/main_window/test_preview.py b/tests/gui/main_window/test_preview.py new file mode 100644 index 000000000..4a16b85a8 --- /dev/null +++ b/tests/gui/main_window/test_preview.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import numpy as np +import pytest +from PySide6.QtGui import QPixmap + + +@pytest.mark.gui +class TestPreviewLifecycle: + def test_start_preview_with_no_active_cameras_shows_error(self, monkeypatch, window): + w = window + for cam in w._config.multi_camera.cameras: + cam.enabled = False + + messages: list[str] = [] + monkeypatch.setattr(w, "_show_error", messages.append) + + w._start_preview() + + assert messages == ["No cameras configured. Use 'Configure Cameras...' to add cameras."] + assert not w.multi_camera_controller.is_running() + assert w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + + def test_on_multi_camera_stopped_clears_runtime_state(self, monkeypatch, window): + w = window + monkeypatch.setattr(w, "_stop_multi_camera_recording", lambda: None) + + w.preview_button.setEnabled(False) + w.stop_preview_button.setEnabled(True) + w._current_frame = np.zeros((4, 4, 3), dtype=np.uint8) + w._multi_camera_frames = {"fake:index:0": np.zeros((4, 4, 3), dtype=np.uint8)} + w._multi_camera_display_ids = {"fake:index:0": "Cam0"} + w._running_cams_ids = {"fake:index:0"} + w._display_dirty = True + w.video_label.setPixmap(QPixmap(8, 8)) + + w._on_multi_camera_stopped() + + assert w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + assert w._current_frame is None + assert w._multi_camera_frames == {} + assert w._multi_camera_display_ids == {} + assert w._running_cams_ids == set() + assert w._display_dirty is False + assert w.video_label.text() == "Camera preview not started" + + def test_stop_preview_requests_orderly_shutdown(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_stop_multi_camera_recording", lambda: calls.append("recording")) + monkeypatch.setattr(w, "_stop_inference", lambda show_message=False: calls.append("inference")) + monkeypatch.setattr( + w.multi_camera_controller, + "stop", + lambda *args, **kwargs: calls.append("controller"), + ) + + w.preview_button.setEnabled(True) + w.stop_preview_button.setEnabled(True) + w.start_inference_button.setEnabled(True) + w.stop_inference_button.setEnabled(True) + w._pending_recording_after_preview = True + + w._stop_preview() + + assert calls == ["recording", "inference", "controller"] + assert w._pending_recording_after_preview is False + assert not w.preview_button.isEnabled() + assert not w.stop_preview_button.isEnabled() + assert not w.start_inference_button.isEnabled() + assert not w.stop_inference_button.isEnabled() + assert w.camera_stats_label.text() == "Camera idle" + + def test_on_multi_camera_started_updates_primary_buttons(self, window): + w = window + + w.preview_button.setEnabled(True) + w.stop_preview_button.setEnabled(False) + + w._on_multi_camera_started() + + assert not w.preview_button.isEnabled() + assert w.stop_preview_button.isEnabled() diff --git a/tests/gui/main_window/test_recording.py b/tests/gui/main_window/test_recording.py new file mode 100644 index 000000000..588f8c15b --- /dev/null +++ b/tests/gui/main_window/test_recording.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from dlclivegui.services.multi_camera_controller import get_camera_id + + +@pytest.mark.gui +class TestRecordingLifecycle: + def test_start_recording_auto_starts_preview_when_preview_is_not_running(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: False) + monkeypatch.setattr(w, "_start_preview", lambda: calls.append("preview")) + + w._pending_recording_after_preview = False + w._start_recording() + + assert calls == ["preview"] + assert w._pending_recording_after_preview is True + + def test_start_recording_starts_immediately_when_preview_is_running(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + w._pending_recording_after_preview = False + w._start_recording() + + assert calls == ["recording"] + assert w._pending_recording_after_preview is False + + def test_pending_recording_waits_until_all_expected_frames_are_available(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + active = w._config.multi_camera.get_active_cameras() + assert len(active) >= 2 + first_id = get_camera_id(active[0]) + + w._pending_recording_after_preview = True + w._multi_camera_frames = {first_id: np.zeros((4, 4, 3), dtype=np.uint8)} + + w._try_start_pending_recording() + + assert calls == [] + assert w._pending_recording_after_preview is True + + def test_pending_recording_starts_when_all_expected_frames_are_available(self, monkeypatch, window): + w = window + calls: list[str] = [] + + monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w, "_start_multi_camera_recording", lambda: calls.append("recording")) + + w._pending_recording_after_preview = True + w._multi_camera_frames = { + get_camera_id(cam): np.zeros((4, 4, 3), dtype=np.uint8) + for cam in w._config.multi_camera.get_active_cameras() + } + + w._try_start_pending_recording() + + assert calls == ["recording"] + assert w._pending_recording_after_preview is False + + def test_start_multi_camera_recording_success_sets_buttons_and_sink(self, window, start_all_spy): + w = window + active = w._config.multi_camera.get_active_cameras() + w._multi_camera_frames = {get_camera_id(cam): np.zeros((4, 4, 3), dtype=np.uint8) for cam in active} + + w._start_multi_camera_recording() + + assert start_all_spy["active_cams"] == active + assert not w.start_record_button.isEnabled() + assert w.stop_record_button.isEnabled() + + def test_stop_multi_camera_recording_when_idle_is_noop(self, window): + w = window + w._recording_stopping = False + + w._stop_multi_camera_recording() + + assert w._recording_stopping is False diff --git a/tests/gui/main_window/test_ui.py b/tests/gui/main_window/test_ui.py new file mode 100644 index 000000000..d4ba33622 --- /dev/null +++ b/tests/gui/main_window/test_ui.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest + +from dlclivegui.services.multi_camera_controller import get_camera_id + + +@pytest.mark.gui +class TestCameraLabels: + def test_update_active_cameras_label_uses_backend_device_name(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "" + cam.properties = {"fake": {"device_name": "The Camera"}} + for extra in w._config.multi_camera.cameras[1:]: + extra.enabled = False + + w._update_active_cameras_label() + + assert "The Camera" in w.active_cameras_label.text() + assert "[fake:0]" in w.active_cameras_label.text() + + def test_refresh_dlc_camera_list_displays_friendly_label_but_stores_stable_id(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "" + cam.properties = {"fake": {"device_name": "The Camera", "device_id": "stable-123"}} + for extra in w._config.multi_camera.cameras[1:]: + extra.enabled = False + + w._refresh_dlc_camera_list() + + assert w.dlc_camera_combo.count() == 1 + assert "The Camera" in w.dlc_camera_combo.itemText(0) + assert "stable-123" not in w.dlc_camera_combo.itemText(0) + assert w.dlc_camera_combo.itemData(0) == get_camera_id(cam) + + def test_label_for_cam_id_prefers_configured_friendly_label(self, window): + w = window + cam = w._config.multi_camera.cameras[0] + cam.name = "Top camera" + + assert w._label_for_cam_id(get_camera_id(cam)).startswith("Top camera") + + def test_label_for_cam_id_uses_runtime_display_id_fallback(self, window): + w = window + w._multi_camera_display_ids = {"runtime:id": "Runtime Camera"} + + assert w._label_for_cam_id("runtime:id") == "Runtime Camera" + + def test_label_for_cam_id_unknown_is_neutral(self, window): + assert window._label_for_cam_id("missing:id") == "Unknown camera" diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index ca177149f..9956f0136 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -1,3 +1,4 @@ +# tests/gui/test_main.py import numpy as np import pytest from PySide6.QtCore import Qt, QTimer From b78a7d7ed98fc9e4efb243bb56d70a2d750b1d3d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:31:52 +0200 Subject: [PATCH 156/194] Drop strobe settings when fields are hidden Before saving trigger configuration, this change removes strobe-related properties if the active profile does not expose strobe fields. This prevents hidden/unsupported strobe values from being written to the backend trigger config. --- dlclivegui/gui/camera_config/trigger_config_dialog.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index bda9caebe..d8337e396 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -455,6 +455,14 @@ def _accept(self) -> None: return ns = _backend_namespace(self._cam) - ns["trigger"] = trigger.to_properties() + trigger_props = trigger.to_properties() + + if not self._profile.show_strobe_fields: + trigger_props.pop("strobe_polarity", None) + trigger_props.pop("strobe_operation", None) + trigger_props.pop("strobe_duration", None) + trigger_props.pop("strobe_delay", None) + + ns["trigger"] = trigger_props self.accept() From d7d21f4a15872fcee62c8581247bc8cefd514545 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 15:32:13 +0200 Subject: [PATCH 157/194] Add trigger config dialog GUI test coverage Introduce a new `test_trigger_config.py` suite for camera trigger configuration. The tests cover backend UI profiles, backend namespace normalization, dialog field visibility/enabling by role and backend, loading existing trigger settings, and accept-path payload serialization for off/external/master roles across GenTL and Basler behavior. --- .../gui/camera_config/test_trigger_config.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/gui/camera_config/test_trigger_config.py diff --git a/tests/gui/camera_config/test_trigger_config.py b/tests/gui/camera_config/test_trigger_config.py new file mode 100644 index 000000000..201817773 --- /dev/null +++ b/tests/gui/camera_config/test_trigger_config.py @@ -0,0 +1,287 @@ +# tests/gui/camera_config/test_trigger_config_dialog.py +from __future__ import annotations + +import pytest + +from dlclivegui.config import CameraSettings +from dlclivegui.gui.camera_config.trigger_config_dialog import ( + TriggerConfigDialog, + _backend_namespace, + trigger_ui_profile_for_backend, +) + + +class TestTriggerUiProfiles: + @pytest.mark.parametrize( + ("backend", "supports_input", "supports_master", "show_strobe", "show_line"), + [ + ("gentl", True, True, True, True), + ("basler", True, True, False, True), + ("opencv", False, False, False, False), + ("fake", False, False, False, False), + ], + ) + def test_profile_capabilities_by_backend( + self, + backend: str, + supports_input: bool, + supports_master: bool, + show_strobe: bool, + show_line: bool, + ): + profile = trigger_ui_profile_for_backend(backend) + + assert profile.supports_input is supports_input + assert profile.supports_master is supports_master + assert profile.show_strobe_fields is show_strobe + assert profile.show_line_output_fields is show_line + + def test_profile_backend_is_case_insensitive(self): + upper = trigger_ui_profile_for_backend("GeNtL") + lower = trigger_ui_profile_for_backend("gentl") + + assert upper == lower + + +class TestBackendNamespace: + def test_backend_namespace_creates_backend_dict(self): + cam = CameraSettings(backend="gentl", index=0, properties={}) + + ns = _backend_namespace(cam) + + assert ns == {} + assert cam.properties == {"gentl": {}} + + def test_backend_namespace_replaces_non_dict_properties(self): + cam = CameraSettings(backend="gentl", index=0, properties={}) + cam.properties = None + + ns = _backend_namespace(cam) + ns["trigger"] = {"role": "external"} + + assert cam.properties == {"gentl": {"trigger": {"role": "external"}}} + + def test_backend_namespace_replaces_non_dict_namespace(self): + cam = CameraSettings(backend="gentl", index=0, properties={"gentl": "bad"}) + + ns = _backend_namespace(cam) + + assert ns == {} + assert cam.properties == {"gentl": {}} + + +class TestTriggerConfigDialogPresentation: + @pytest.mark.gui + def test_unknown_backend_exposes_only_off_role_and_disables_trigger_fields(self, qtbot): + cam = CameraSettings(backend="opencv", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off"] + assert not dlg.selector_edit.isVisible() + assert not dlg.source_combo.isVisible() + assert not dlg.activation_combo.isVisible() + assert not dlg.output_line_edit.isVisible() + assert not dlg.strobe_polarity_combo.isVisible() + + @pytest.mark.gui + def test_gentl_profile_shows_input_master_line_and_strobe_fields(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off", "external", "follower", "master"] + assert not dlg.selector_edit.isHidden() + assert not dlg.source_combo.isHidden() + assert not dlg.activation_combo.isHidden() + assert not dlg.output_line_edit.isHidden() + assert not dlg.output_source_edit.isHidden() + assert not dlg.strobe_polarity_combo.isHidden() + assert not dlg.strobe_operation_combo.isHidden() + assert not dlg.strobe_duration_spin.isHidden() + assert not dlg.strobe_delay_spin.isHidden() + + @pytest.mark.gui + def test_basler_profile_shows_line_fields_but_hides_strobe_fields(self, qtbot): + cam = CameraSettings(backend="basler", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] + + assert roles == ["off", "external", "follower", "master"] + assert not dlg.output_line_edit.isHidden() + assert not dlg.output_source_edit.isHidden() + assert dlg.strobe_polarity_combo.isHidden() + assert dlg.strobe_operation_combo.isHidden() + assert dlg.strobe_duration_spin.isHidden() + assert dlg.strobe_delay_spin.isHidden() + + @pytest.mark.gui + def test_role_changes_enable_input_and_output_fields(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("off")) + assert not dlg.selector_edit.isEnabled() + assert not dlg.source_combo.isEnabled() + assert not dlg.output_line_edit.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + assert dlg.selector_edit.isEnabled() + assert dlg.source_combo.isEnabled() + assert dlg.activation_combo.isEnabled() + assert not dlg.output_line_edit.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + assert not dlg.selector_edit.isEnabled() + assert not dlg.source_combo.isEnabled() + assert dlg.output_line_edit.isEnabled() + assert dlg.output_source_edit.isEnabled() + assert dlg.strobe_polarity_combo.isEnabled() + assert dlg.strobe_delay_spin.isEnabled() + + @pytest.mark.gui + def test_fixed_duration_enables_strobe_duration_only_for_master(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.strobe_operation_combo.setCurrentIndex(dlg.strobe_operation_combo.findData("FixedDuration")) + + assert dlg.strobe_duration_spin.isEnabled() + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + + assert not dlg.strobe_duration_spin.isEnabled() + + +class TestTriggerConfigDialogModelRoundtrip: + @pytest.mark.gui + def test_loads_existing_trigger_settings(self, qtbot): + cam = CameraSettings( + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": "external", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 0.25, + "strict": True, + } + } + }, + ) + + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + assert dlg.role_combo.currentData() == "external" + assert dlg.selector_edit.text() == "FrameStart" + assert dlg.source_combo.currentText() == "Line1" + assert dlg.activation_combo.currentData() == "FallingEdge" + assert dlg.timeout_spin.value() == pytest.approx(0.25) + assert dlg.strict_checkbox.isChecked() + + @pytest.mark.gui + def test_accept_external_writes_trigger_payload_to_backend_namespace(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("external")) + dlg.selector_edit.setText("FrameStart") + dlg.source_combo.setCurrentText("Line1") + dlg.activation_combo.setCurrentIndex(dlg.activation_combo.findData("FallingEdge")) + dlg.timeout_spin.setValue(0.5) + dlg.strict_checkbox.setChecked(True) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "external" + assert trigger["selector"] == "FrameStart" + assert trigger["source"] == "Line1" + assert trigger["activation"] == "FallingEdge" + assert trigger["timeout"] == pytest.approx(0.5) + assert trigger["strict"] is True + + @pytest.mark.gui + def test_accept_off_clears_timeout(self, qtbot): + cam = CameraSettings( + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external", "timeout": 2.0}}}, + ) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("off")) + dlg.timeout_spin.setValue(1.25) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "off" + assert trigger.get("timeout") is None + + @pytest.mark.gui + def test_accept_master_gentl_includes_strobe_values(self, qtbot): + cam = CameraSettings(backend="gentl", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.output_line_edit.setText("Line2") + dlg.output_source_edit.setText("ExposureActive") + dlg.strobe_polarity_combo.setCurrentIndex(dlg.strobe_polarity_combo.findData("ActiveLow")) + dlg.strobe_operation_combo.setCurrentIndex(dlg.strobe_operation_combo.findData("FixedDuration")) + dlg.strobe_duration_spin.setValue(1200) + dlg.strobe_delay_spin.setValue(300) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["gentl"]["trigger"] + + assert trigger["role"] == "master" + assert trigger["output_line"] == "Line2" + assert trigger["output_source"] == "ExposureActive" + assert trigger["strobe_polarity"] == "ActiveLow" + assert trigger["strobe_operation"] == "FixedDuration" + assert trigger["strobe_duration"] == 1200 + assert trigger["strobe_delay"] == 300 + + @pytest.mark.gui + def test_accept_master_basler_does_not_add_strobe_values(self, qtbot): + cam = CameraSettings(backend="basler", index=0, properties={}) + dlg = TriggerConfigDialog(cam) + qtbot.addWidget(dlg) + + dlg.role_combo.setCurrentIndex(dlg.role_combo.findData("master")) + dlg.strobe_duration_spin.setValue(1200) + dlg.strobe_delay_spin.setValue(300) + + with qtbot.waitSignal(dlg.accepted, timeout=1000): + dlg._accept() + + trigger = dlg.camera_settings.properties["basler"]["trigger"] + + assert trigger["role"] == "master" + assert "strobe_duration" not in trigger + assert "strobe_delay" not in trigger + assert "strobe_polarity" not in trigger + assert "strobe_operation" not in trigger From dca4fb64f82df6b134c0ccd843a0ad951763f9b1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 15:09:15 +0200 Subject: [PATCH 158/194] Sync settings after saving config Call `self.settings.sync()` after saving the config and updating stored paths/snapshots so settings are flushed promptly. If syncing fails, log a debug message with exception info without interrupting the save flow. --- dlclivegui/gui/main_window.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 2cd9b0476..ab42e5bfc 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1125,6 +1125,12 @@ def _save_config_to_path(self, path: Path) -> bool: config.save(path) self._settings_store.set_last_config_path(str(path)) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after saving config", exc_info=True) + except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) return False From 713668d1b061787b30dd4e388d3a71b5e849a9f8 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 15:10:26 +0200 Subject: [PATCH 159/194] Add unit tests for GenTL discovery utils Introduce a new test module covering `gentl_discovery` behavior end-to-end: CTI input normalization, explicit/env/extra-dir discovery, glob validation and allowed-root checks, candidate deduplication, and selection policies (`FIRST`, `NEWEST`, `RAISE_IF_MULTIPLE`). It also adds lifecycle tests for `SharedHarvesterPool`/`SharedHarvesterEntry`, including reuse/refcount semantics and failure reporting when CTI loading fails. --- .../backends/utils/test_gentl_discovery.py | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/cameras/backends/utils/test_gentl_discovery.py diff --git a/tests/cameras/backends/utils/test_gentl_discovery.py b/tests/cameras/backends/utils/test_gentl_discovery.py new file mode 100644 index 000000000..46ff8ca5c --- /dev/null +++ b/tests/cameras/backends/utils/test_gentl_discovery.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from dlclivegui.cameras.backends.utils import gentl_discovery as gd + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def clear_shared_harvester_pool(): + gd.SharedHarvesterPool._entries.clear() + yield + gd.SharedHarvesterPool._entries.clear() + + +def test_cti_files_as_list_handles_none_strings_and_sequences(): + assert gd.cti_files_as_list(None) == [] + assert gd.cti_files_as_list("") == [] + assert gd.cti_files_as_list(" ") == [] + assert gd.cti_files_as_list("camera.cti") == ["camera.cti"] + assert gd.cti_files_as_list(["a.cti", None, "", " ", Path("b.cti")]) == ["a.cti", "b.cti"] + + +def test_discover_explicit_cti_file_without_harvester(tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(cti), + include_env=False, + ) + + assert candidates == [str(cti.resolve())] + assert diag.explicit_files == [str(cti)] + assert diag.candidates == [str(cti.resolve())] + assert diag.rejected == [] + + +def test_discover_rejects_missing_explicit_file(tmp_path: Path): + missing = tmp_path / "missing.cti" + + candidates, diag = gd.discover_cti_files( + cti_file=str(missing), + include_env=False, + ) + + assert candidates == [] + assert diag.rejected == [(str(missing.resolve()), "not a file (explicit)")] + + +def test_discover_rejects_non_cti_file(tmp_path: Path): + not_cti = tmp_path / "producer.txt" + not_cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(not_cti), + include_env=False, + ) + + assert candidates == [] + assert diag.rejected == [(str(not_cti.resolve()), "not a .cti (explicit)")] + + +def test_discover_accepts_missing_cti_when_must_exist_false(tmp_path: Path): + missing = tmp_path / "missing.cti" + + candidates, diag = gd.discover_cti_files( + cti_file=str(missing), + include_env=False, + must_exist=False, + ) + + assert candidates == [str(missing.resolve())] + assert diag.rejected == [] + + +def test_discover_extra_dir_collects_cti_files_non_recursive(tmp_path: Path): + root = tmp_path / "ctis" + root.mkdir() + + a = root / "a.cti" + b = root / "b.cti" + ignored = root / "ignored.txt" + nested = root / "nested" + nested.mkdir() + nested_cti = nested / "nested.cti" + + a.write_text("", encoding="utf-8") + b.write_text("", encoding="utf-8") + ignored.write_text("", encoding="utf-8") + nested_cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + include_env=False, + extra_dirs=[str(root)], + recursive_extra_search=False, + ) + + assert candidates == [str(a.resolve()), str(b.resolve())] + assert diag.extra_dirs == [str(root)] + + +def test_discover_extra_dir_collects_cti_files_recursive(tmp_path: Path): + root = tmp_path / "ctis" + nested = root / "nested" + nested.mkdir(parents=True) + + top = root / "top.cti" + child = nested / "child.cti" + + top.write_text("", encoding="utf-8") + child.write_text("", encoding="utf-8") + + candidates, _diag = gd.discover_cti_files( + include_env=False, + extra_dirs=[str(root)], + recursive_extra_search=True, + ) + + assert candidates == [str(top.resolve()), str(child.resolve())] + + +def test_discover_deduplicates_candidates_preserving_order(tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_file=str(cti), + cti_files=[str(cti)], + extra_dirs=[str(tmp_path)], + include_env=False, + ) + + assert candidates == [str(cti.resolve())] + assert diag.candidates == [str(cti.resolve())] + + +def test_discover_env_var_direct_file(monkeypatch, tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", str(cti)) + + candidates, diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti.resolve())] + assert diag.env_vars_used == {"MY_GENTL_PATH": str(cti)} + assert diag.env_paths_expanded == [str(cti)] + + +def test_discover_env_var_directory(monkeypatch, tmp_path: Path): + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", str(tmp_path)) + + candidates, diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti.resolve())] + assert diag.env_vars_used == {"MY_GENTL_PATH": str(tmp_path)} + assert diag.env_paths_expanded == [str(tmp_path)] + + +def test_discover_env_var_multiple_entries(monkeypatch, tmp_path: Path): + d1 = tmp_path / "one" + d2 = tmp_path / "two" + d1.mkdir() + d2.mkdir() + + cti1 = d1 / "one.cti" + cti2 = d2 / "two.cti" + cti1.write_text("", encoding="utf-8") + cti2.write_text("", encoding="utf-8") + + monkeypatch.setenv("MY_GENTL_PATH", os.pathsep.join([str(d1), str(d2)])) + + candidates, _diag = gd.discover_cti_files( + include_env=True, + env_vars=("MY_GENTL_PATH",), + ) + + assert candidates == [str(cti1.resolve()), str(cti2.resolve())] + + +def test_validate_glob_pattern_rejects_empty_pattern(): + ok, reason = gd._validate_glob_pattern("") + + assert ok is False + assert reason == "empty glob pattern" + + +def test_validate_glob_pattern_rejects_traversal(tmp_path: Path): + pattern = str(tmp_path / ".." / "*.cti") + + ok, reason = gd._validate_glob_pattern(pattern) + + assert ok is False + assert reason == "glob pattern contains '..' traversal" + + +def test_validate_glob_pattern_rejects_non_cti_pattern(tmp_path: Path): + pattern = str(tmp_path / "*.txt") + + ok, reason = gd._validate_glob_pattern(pattern) + + assert ok is False + assert reason == "glob pattern does not target .cti files" + + +def test_validate_glob_pattern_rejects_outside_allowed_roots(tmp_path: Path): + allowed = tmp_path / "allowed" + outside = tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + + pattern = str(outside / "*.cti") + + ok, reason = gd._validate_glob_pattern( + pattern, + allowed_roots=[str(allowed)], + ) + + assert ok is False + assert reason == "glob pattern base is outside allowed roots" + + +def test_discover_glob_pattern_with_allowed_root(tmp_path: Path): + cti_dir = tmp_path / "ctis" + cti_dir.mkdir() + + cti = cti_dir / "producer.cti" + cti.write_text("", encoding="utf-8") + + candidates, diag = gd.discover_cti_files( + cti_search_paths=[str(cti_dir / "*.cti")], + include_env=False, + root_globs_allowed=[str(tmp_path)], + ) + + assert candidates == [str(cti.resolve())] + assert diag.rejected == [] + + +def test_choose_cti_files_first_policy(): + assert gd.choose_cti_files( + ["a.cti", "b.cti", "c.cti"], + policy=gd.GenTLDiscoveryPolicy.FIRST, + max_files=2, + ) == ["a.cti", "b.cti"] + + +def test_choose_cti_files_raise_if_multiple_policy_raises(): + with pytest.raises(RuntimeError, match="Multiple GenTL producers"): + gd.choose_cti_files( + ["a.cti", "b.cti"], + policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, + ) + + +def test_choose_cti_files_raise_if_multiple_policy_allows_within_limit(): + assert gd.choose_cti_files( + ["a.cti"], + policy=gd.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, + ) == ["a.cti"] + + +def test_choose_cti_files_newest_policy(tmp_path: Path): + old = tmp_path / "old.cti" + new = tmp_path / "new.cti" + + old.write_text("", encoding="utf-8") + new.write_text("", encoding="utf-8") + + os.utime(old, (1000, 1000)) + os.utime(new, (2000, 2000)) + + assert gd.choose_cti_files( + [str(old), str(new)], + policy=gd.GenTLDiscoveryPolicy.NEWEST, + max_files=1, + ) == [str(new)] + + +def test_choose_cti_files_empty_candidates(): + assert gd.choose_cti_files([]) == [] + + +def test_choose_cti_files_unknown_policy_raises(): + with pytest.raises(ValueError, match="Unknown policy"): + gd.choose_cti_files( + ["a.cti"], + policy=object(), # type: ignore[arg-type] + ) + + +def test_shared_harvester_pool_reuses_entry_and_refcounts(monkeypatch, tmp_path: Path): + calls: list[tuple[str, str | None]] = [] + + class FakeHarvester: + def __init__(self): + calls.append(("init", None)) + + def add_file(self, path: str) -> None: + calls.append(("add_file", path)) + + def update(self) -> None: + calls.append(("update", None)) + + def reset(self) -> None: + calls.append(("reset", None)) + + cti = tmp_path / "producer.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setattr(gd, "Harvester", FakeHarvester) + gd.SharedHarvesterPool._entries.clear() + + entry1 = gd.SharedHarvesterPool.acquire([str(cti)]) + entry2 = gd.SharedHarvesterPool.acquire([str(cti)]) + + assert entry1 is entry2 + assert gd.SharedHarvesterPool.get_refcount(entry1) == 2 + + gd.SharedHarvesterPool.release(entry1) + assert gd.SharedHarvesterPool.get_refcount(entry2) == 1 + + gd.SharedHarvesterPool.release(entry2) + assert gd.SharedHarvesterPool.get_refcount(entry2) == 0 + assert ("reset", None) in calls + + +def test_shared_harvester_entry_reports_failed_files(monkeypatch, tmp_path: Path): + class FakeHarvester: + def add_file(self, path: str) -> None: + raise RuntimeError("load failed") + + def update(self) -> None: + raise AssertionError("update should not be called") + + def reset(self) -> None: + pass + + cti = tmp_path / "bad.cti" + cti.write_text("", encoding="utf-8") + + monkeypatch.setattr(gd, "Harvester", FakeHarvester) + + with pytest.raises(RuntimeError, match="No GenTL producer"): + gd.SharedHarvesterEntry([str(cti)]) From d69cbecadeeafbff4f9a6ac8ba8c8a845d348fd8 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 15:17:16 +0200 Subject: [PATCH 160/194] Add GUI tests for config and camera fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand main window GUI coverage with a regression test that ensures runtime camera fallback updates only the active inference camera, not the user’s preferred inference camera. Add a new test module for user-config persistence, covering config path validation, dialog path suggestion logic, successful save side effects (last path, snapshot, sync), and failure behavior that avoids persistence and reports errors. Also add file header comments in related test files. --- tests/gui/main_window/test_preview.py | 37 ++++++ tests/gui/main_window/test_recording.py | 1 + tests/gui/main_window/test_ui.py | 1 + tests/gui/main_window/test_user_config.py | 142 ++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 tests/gui/main_window/test_user_config.py diff --git a/tests/gui/main_window/test_preview.py b/tests/gui/main_window/test_preview.py index 4a16b85a8..571813d79 100644 --- a/tests/gui/main_window/test_preview.py +++ b/tests/gui/main_window/test_preview.py @@ -1,9 +1,14 @@ +# tests/gui/main_window/test_preview.py from __future__ import annotations +from types import SimpleNamespace + import numpy as np import pytest from PySide6.QtGui import QPixmap +from dlclivegui.services.multi_camera_controller import get_camera_id + @pytest.mark.gui class TestPreviewLifecycle: @@ -85,3 +90,35 @@ def test_on_multi_camera_started_updates_primary_buttons(self, window): assert not w.preview_button.isEnabled() assert w.stop_preview_button.isEnabled() + + def test_processing_runtime_fallback_does_not_overwrite_preferred_inference_camera(self, window): + w = window + + active_cams = w._config.multi_camera.get_active_cameras() + if len(active_cams) < 2: + pytest.skip("This regression test requires at least two active cameras.") + + fallback_cam = active_cams[0] + preferred_cam = active_cams[1] + + fallback_id = get_camera_id(fallback_cam) + preferred_id = get_camera_id(preferred_cam) + + w._inference_camera_id = preferred_id + w._active_inference_camera_id = preferred_id + w._running_cams_ids = set() + w._dlc_active = False + + frame = np.zeros((4, 4, 3), dtype=np.uint8) + frame_data = SimpleNamespace( + frames={fallback_id: frame}, + display_ids={fallback_id: "Fallback camera"}, + source_camera_id=fallback_id, + timestamps={fallback_id: 123.0}, + ) + + w._on_multi_frame_processing_ready(frame_data) + + assert w._inference_camera_id == preferred_id + assert w._active_inference_camera_id == fallback_id + assert w.dlc_camera_combo.currentData() == fallback_id diff --git a/tests/gui/main_window/test_recording.py b/tests/gui/main_window/test_recording.py index 588f8c15b..0443adb67 100644 --- a/tests/gui/main_window/test_recording.py +++ b/tests/gui/main_window/test_recording.py @@ -1,3 +1,4 @@ +# tests/gui/main_window/test_recording.py from __future__ import annotations import numpy as np diff --git a/tests/gui/main_window/test_ui.py b/tests/gui/main_window/test_ui.py index d4ba33622..179086448 100644 --- a/tests/gui/main_window/test_ui.py +++ b/tests/gui/main_window/test_ui.py @@ -1,3 +1,4 @@ +# tests/gui/main_window/test_ui.py from __future__ import annotations import pytest diff --git a/tests/gui/main_window/test_user_config.py b/tests/gui/main_window/test_user_config.py new file mode 100644 index 000000000..f42c4b9c9 --- /dev/null +++ b/tests/gui/main_window/test_user_config.py @@ -0,0 +1,142 @@ +# tests/gui/main_window/test_user_config.py +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.mark.gui +class TestUserConfigPersistence: + def test_valid_config_file_path_accepts_existing_file(self, window, tmp_path: Path): + w = window + + config_path = tmp_path / "dlclive_config.json" + config_path.write_text("{}", encoding="utf-8") + + assert w._valid_config_file_path(str(config_path)) == config_path.resolve() + + def test_valid_config_file_path_rejects_missing_file(self, window, tmp_path: Path): + w = window + + missing = tmp_path / "missing_config.json" + + assert w._valid_config_file_path(str(missing)) is None + assert w._valid_config_file_path(None) is None + assert w._valid_config_file_path("") is None + + def test_suggest_config_dialog_path_prefers_current_config_path(self, window, tmp_path: Path): + w = window + + config_path = tmp_path / "current_config.json" + config_path.write_text("{}", encoding="utf-8") + + w._config_path = config_path + + assert w._suggest_config_dialog_path() == str(config_path) + + def test_suggest_config_dialog_path_uses_last_config_path_when_current_path_missing( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + config_path = tmp_path / "last_config.json" + config_path.write_text("{}", encoding="utf-8") + + w._config_path = None + monkeypatch.setattr(w._settings_store, "get_last_config_path", lambda: str(config_path)) + + assert w._suggest_config_dialog_path() == str(config_path.resolve()) + + def test_suggest_config_dialog_path_uses_parent_of_missing_last_config( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + missing_config_path = tmp_path / "missing_config.json" + + w._config_path = None + monkeypatch.setattr(w._settings_store, "get_last_config_path", lambda: str(missing_config_path)) + + assert w._suggest_config_dialog_path() == str(missing_config_path) + + def test_save_config_to_path_persists_last_path_snapshot_and_syncs( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + calls: list[tuple[str, object]] = [] + + class FakeConfig: + def save(self, path: Path | str) -> None: + Path(path).write_text("{}", encoding="utf-8") + + class FakeSettings: + def sync(self) -> None: + calls.append(("sync", True)) + + config_path = tmp_path / "saved_config.json" + fake_config = FakeConfig() + + monkeypatch.setattr(w, "_current_config", lambda allow_empty_model_path=False: fake_config) + monkeypatch.setattr( + w._settings_store, + "set_last_config_path", + lambda path: calls.append(("last_path", path)), + ) + monkeypatch.setattr( + w._settings_store, + "save_full_config_snapshot", + lambda cfg: calls.append(("snapshot", cfg)), + ) + monkeypatch.setattr(w, "settings", FakeSettings()) + + assert w._save_config_to_path(config_path) is True + assert config_path.exists() + assert ("last_path", str(config_path.resolve())) in calls + assert ("snapshot", fake_config) in calls + assert ("sync", True) in calls + + def test_save_config_to_path_returns_false_without_persisting_after_failure( + self, + monkeypatch, + window, + tmp_path: Path, + ): + w = window + + calls: list[tuple[str, object]] = [] + errors: list[str] = [] + + class FakeConfig: + def save(self, path: Path | str) -> None: + raise OSError("cannot save") + + config_path = tmp_path / "failed_config.json" + + monkeypatch.setattr(w, "_current_config", lambda allow_empty_model_path=False: FakeConfig()) + monkeypatch.setattr( + w._settings_store, + "set_last_config_path", + lambda path: calls.append(("last_path", path)), + ) + monkeypatch.setattr( + w._settings_store, + "save_full_config_snapshot", + lambda cfg: calls.append(("snapshot", cfg)), + ) + monkeypatch.setattr(w, "_show_error", errors.append) + + assert w._save_config_to_path(config_path) is False + assert not config_path.exists() + assert calls == [] + assert errors From 4b68f243b53e1b78224d0d69c4de4a73b8d135e7 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 18:20:49 +0200 Subject: [PATCH 161/194] Refactor recording option tests Reworked tests to match the split between base WriteGear option construction and config-level overrides. Added focused unit coverage for `build_writegear_options` (default values, FPS fallback, invalid FPS handling, and override merging), updated `RecordingSettings` tests to validate `writegear_overrides()` behavior by codec/fast-encoding mode, and relaxed GUI manager assertions to only check override-specific flags. --- tests/gui/main_window/test_ui.py | 2 +- tests/services/test_video_recorder.py | 61 +++++++++++++++++++++++++++ tests/test_config.py | 55 ++++++++++++++++++++---- 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/tests/gui/main_window/test_ui.py b/tests/gui/main_window/test_ui.py index 179086448..17ba0513e 100644 --- a/tests/gui/main_window/test_ui.py +++ b/tests/gui/main_window/test_ui.py @@ -50,4 +50,4 @@ def test_label_for_cam_id_uses_runtime_display_id_fallback(self, window): assert w._label_for_cam_id("runtime:id") == "Runtime Camera" def test_label_for_cam_id_unknown_is_neutral(self, window): - assert window._label_for_cam_id("missing:id") == "Unknown camera" + assert "Unknown camera" in window._label_for_cam_id("missing:id") diff --git a/tests/services/test_video_recorder.py b/tests/services/test_video_recorder.py index 8389fbbb4..7a4507216 100644 --- a/tests/services/test_video_recorder.py +++ b/tests/services/test_video_recorder.py @@ -9,6 +9,7 @@ import pytest import dlclivegui.services.video_recorder as vr_mod +from dlclivegui.config import DEFAULT_RECORDING_FPS from dlclivegui.utils.timestamps import FrameTimestampMetadata # ---------------------------- @@ -528,3 +529,63 @@ def test_stop_writes_hardware_timestamp_metadata_sidecar_json( "raw_value": 1_000_000, } assert rec0["hardware_timestamp_default"] == 0.001 + + +def test_build_writegear_options_default(): + opts = vr_mod.build_writegear_options( + frame_rate=100.0, + codec="libx264", + crf=23, + ) + + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + } + + +def test_build_writegear_options_invalid_fps_uses_default(): + opts = vr_mod.build_writegear_options( + frame_rate=0.0, + codec="libx264", + crf=23, + ) + + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS + + +@pytest.mark.parametrize( + "frame_rate", + [None, 0, -1, "invalid"], +) +def test_build_writegear_options_unusable_fps_uses_default( + frame_rate, +): + opts = vr_mod.build_writegear_options( + frame_rate=frame_rate, + codec="libx264", + crf=23, + ) + + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS + + +def test_build_writegear_options_merges_overrides(): + opts = vr_mod.build_writegear_options( + frame_rate=100.0, + codec="libx264", + crf=23, + overrides={ + "-preset": "ultrafast", + "-tune": "zerolatency", + }, + ) + + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "libx264", + "-crf": 23, + "-preset": "ultrafast", + "-tune": "zerolatency", + } diff --git a/tests/test_config.py b/tests/test_config.py index add3de9e9..75dd5b907 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -78,6 +78,44 @@ def test_trigger_source_defaults_to_auto(): assert trigger.source == "auto" +@pytest.mark.unit +def test_recording_settings_writegear_overrides_default(): + settings = RecordingSettings( + codec="libx264", + crf=23, + fast_encoding=False, + ) + + assert settings.writegear_overrides() == {} + + +@pytest.mark.unit +@pytest.mark.parametrize("codec", ["libx264", "libx265"]) +def test_recording_settings_writegear_overrides_fast_encoding(codec): + settings = RecordingSettings( + codec=codec, + crf=23, + fast_encoding=True, + ) + + assert settings.writegear_overrides() == { + "-preset": "ultrafast", + "-tune": "zerolatency", + } + + +@pytest.mark.unit +def test_recording_settings_writegear_overrides_nvenc(): + settings = RecordingSettings( + codec="h264_nvenc", + crf=23, + fast_encoding=True, + ) + + assert settings.writegear_overrides() == {} + + +@pytest.mark.unit def test_build_writegear_options_default(): settings = RecordingSettings( codec="libx264", @@ -97,10 +135,9 @@ def test_build_writegear_options_default(): "-vcodec": "libx264", "-crf": 23, } - assert "-preset" not in opts - assert "-tune" not in opts +@pytest.mark.unit def test_build_writegear_options_fast_encoding_x264(): settings = RecordingSettings( codec="libx264", @@ -124,6 +161,7 @@ def test_build_writegear_options_fast_encoding_x264(): } +@pytest.mark.unit def test_build_writegear_options_fast_encoding_nvenc(): settings = RecordingSettings( codec="h264_nvenc", @@ -138,13 +176,14 @@ def test_build_writegear_options_fast_encoding_nvenc(): overrides=settings.writegear_overrides(), ) - assert opts["-input_framerate"] == 100.0 - assert opts["-vcodec"] == "h264_nvenc" - assert opts["-crf"] == 23 - assert "-preset" not in opts - assert "-tune" not in opts + assert opts == { + "-input_framerate": 100.0, + "-vcodec": "h264_nvenc", + "-crf": 23, + } +@pytest.mark.unit def test_build_writegear_options_invalid_fps_uses_default(): settings = RecordingSettings( codec="libx264", @@ -158,4 +197,4 @@ def test_build_writegear_options_invalid_fps_uses_default(): overrides=settings.writegear_overrides(), ) - assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS \ No newline at end of file From fad1c16bad06058b0c0093bc3448cf46976d0c61 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:09:18 +0200 Subject: [PATCH 162/194] Add mono output format tests for GenTL backend Extend the GenTL test settings factory to accept and propagate `preserve_mono`. Add a parametrized backend test that validates mono frame handling across `Mono8`/`Mono12` inputs, including uint8 normalization, grayscale-to-BGR expansion when mono preservation is disabled, and persistence/reporting of `actual_output_format` and `preserve_mono` in backend settings. --- tests/cameras/backends/conftest.py | 2 + tests/cameras/backends/test_gentl_backend.py | 137 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 946be4598..f4f6163b1 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -1262,6 +1262,7 @@ def _make( fps=0.0, exposure=0, gain=0.0, + preserve_mono=False, enabled=True, properties=None, ): @@ -1293,6 +1294,7 @@ def _make( fps=fps, exposure=exposure, gain=gain, + preserve_mono=preserve_mono, enabled=enabled, properties=props, ) diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index 3cb7d9ea2..d85f978b0 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -67,6 +67,143 @@ def test_open_starts_stream_and_read_returns_frame(patch_gentl_sdk, gentl_settin assert be._device_label is None +@pytest.mark.parametrize( + ( + "pixel_format", + "preserve_mono", + "source_dtype", + "expected_shape", + "expected_output_format", + ), + [ + ( + "Mono8", + True, + np.uint8, + (2, 3), + "Mono8", + ), + ( + "Mono8", + False, + np.uint8, + (2, 3, 3), + "BGR8", + ), + ( + "Mono12", + True, + np.uint16, + (2, 3), + "Mono8", + ), + ], +) +def test_mono_output_format_conversion_and_persistence( + patch_gentl_sdk, + gentl_settings_factory, + pixel_format, + preserve_mono, + source_dtype, + expected_shape, + expected_output_format, +): + gb = patch_gentl_sdk + + settings = gentl_settings_factory( + preserve_mono=preserve_mono, + properties={ + "gentl": { + "pixel_format": pixel_format, + } + }, + ) + backend = gb.GenTLCameraBackend(settings) + + if source_dtype == np.uint8: + source = np.array( + [ + [0, 32, 64], + [128, 192, 255], + ], + dtype=np.uint8, + ) + else: + source = np.array( + [ + [0, 512, 1024], + [2048, 3072, 4095], + ], + dtype=np.uint16, + ) + + component = types.SimpleNamespace( + data=source.ravel(), + height=source.shape[0], + width=source.shape[1], + ) + buffer = types.SimpleNamespace( + payload=types.SimpleNamespace( + components=[component], + ) + ) + + class FetchContext: + def __enter__(self): + return buffer + + def __exit__( + self, + exc_type, + exc_value, + traceback, + ): + return False + + class FakeAcquirer: + remote_device = types.SimpleNamespace( + node_map=types.SimpleNamespace(), + ) + + def fetch(self, timeout): + return FetchContext() + + backend._acquirer = FakeAcquirer() + backend._pixel_format = pixel_format + backend._camera_pixel_format = pixel_format + + captured = backend.read() + frame = captured.frame + + assert frame.dtype == np.uint8 + assert frame.shape == expected_shape + + # Native camera format and emitted application format are distinct. + assert backend.actual_pixel_format == pixel_format + assert backend.actual_output_format == expected_output_format + + namespace = settings.properties["gentl"] + assert namespace["actual_output_format"] == expected_output_format + assert namespace["preserve_mono"] is preserve_mono + + if preserve_mono: + assert frame.ndim == 2 + else: + assert frame.ndim == 3 + assert frame.shape[2] == 3 + + # Grayscale-to-BGR conversion duplicates the mono value + # across all three channels. + np.testing.assert_array_equal( + frame[:, :, 0], + frame[:, :, 1], + ) + np.testing.assert_array_equal( + frame[:, :, 1], + frame[:, :, 2], + ) + + def test_fast_start_does_not_start_stream_and_read_times_out(patch_gentl_sdk, gentl_settings_factory): gb = patch_gentl_sdk From 7ebe491ac8806b2f2658a866afdfaba9d43fd9f8 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:19:50 +0200 Subject: [PATCH 163/194] Use isHidden in trigger config test Update the trigger config presentation test to assert hidden state with `isHidden()` instead of `isVisible()`. This makes the assertions more robust for widgets that may not be shown in the test context but are expected to be explicitly hidden by the dialog logic. --- tests/gui/camera_config/test_trigger_config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/gui/camera_config/test_trigger_config.py b/tests/gui/camera_config/test_trigger_config.py index 201817773..05cc7988c 100644 --- a/tests/gui/camera_config/test_trigger_config.py +++ b/tests/gui/camera_config/test_trigger_config.py @@ -80,11 +80,11 @@ def test_unknown_backend_exposes_only_off_role_and_disables_trigger_fields(self, roles = [dlg.role_combo.itemData(i) for i in range(dlg.role_combo.count())] assert roles == ["off"] - assert not dlg.selector_edit.isVisible() - assert not dlg.source_combo.isVisible() - assert not dlg.activation_combo.isVisible() - assert not dlg.output_line_edit.isVisible() - assert not dlg.strobe_polarity_combo.isVisible() + assert dlg.selector_edit.isHidden() + assert dlg.source_combo.isHidden() + assert dlg.activation_combo.isHidden() + assert dlg.output_line_edit.isHidden() + assert dlg.strobe_polarity_combo.isHidden() @pytest.mark.gui def test_gentl_profile_shows_input_master_line_and_strobe_fields(self, qtbot): From 45211427df130bf36389f6b10484b458364880f0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:26:58 +0200 Subject: [PATCH 164/194] Update test_config.py --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 75dd5b907..6e1cc76cb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -197,4 +197,4 @@ def test_build_writegear_options_invalid_fps_uses_default(): overrides=settings.writegear_overrides(), ) - assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS \ No newline at end of file + assert opts["-input_framerate"] == DEFAULT_RECORDING_FPS From fffdd552c5bbf049af3ca7d4d866833964ca209d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 11:11:09 +0200 Subject: [PATCH 165/194] Build processors inside DLC worker when needed Add deferred processor construction via `ProcessorSpec` so processors that require worker-thread context can be built in `DLCLiveProcessor._worker_loop` instead of the GUI thread. Update both main window and service configuration paths to choose between immediate instantiation and worker-side specs, add lifecycle/context logging helpers, and improve processor shutdown/reset cleanup by calling processor `stop()` when appropriate. Also adjust DLC logging defaults to reduce timing noise while enabling targeted lifecycle diagnostics. --- dlclivegui/config.py | 2 + dlclivegui/gui/main_window.py | 94 +++++++++++----- dlclivegui/processors/dlc_processor_socket.py | 5 + dlclivegui/processors/processor_utils.py | 60 ++++++++++ dlclivegui/services/dlc_processor.py | 106 ++++++++++++++++-- 5 files changed, 231 insertions(+), 36 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index ff57b1552..c9628ca97 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -39,6 +39,8 @@ DLC_DO_LOG_TIMING: bool = False ### Trigger debug logging DEBUG_TRIGGER_LOGS = False +### Extra logs for DLC lifecycle (model loading, etc) +DLC_LIFECYCLE_EXTRA_LOGS: bool = True # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends BASLER_DO_LOG_TIMING: bool = False diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index ab42e5bfc..7a90337da 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -66,8 +66,11 @@ ) from ..processors.processor_utils import ( + create_spec_from_scan, default_processors_dir, instantiate_from_scan, + log_processor_context, + processor_builds_in_worker, scan_processor_folder, scan_processor_package, ) @@ -937,7 +940,7 @@ def _apply_config(self, config: ApplicationSettings, *, restore_local_prefs: boo color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) # Processor - ## Allow processor control checkbox state + ## Use custom processor checkbox state if hasattr(self, "use_custom_proc_checkbox"): self.use_custom_proc_checkbox.setChecked(self._settings_store.get_processor_control_enabled(default=False)) @@ -2034,34 +2037,77 @@ def _stop_preview(self) -> None: def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() - except (ValueError, RuntimeError, json.JSONDecodeError) as exc: - self._show_error(f"Invalid DLCLive settings: {exc}") + except ( + ValueError, + RuntimeError, + json.JSONDecodeError, + ) as exc: + self._show_error( + f"Invalid DLCLive settings: {exc}" + ) return False + if not settings.model_path: - self._show_error("Please select a DLCLive model before starting inference.") + self._show_error( + "Please select a DLCLive model before " + "starting inference." + ) return False - # Instantiate processor if selected processor = None + processor_spec = None selected_key = self.processor_combo.currentData() self._settings_store.set_processor_key(selected_key) if self._custom_processor_enabled(): try: - processor = instantiate_from_scan( - self._scanned_processors, - selected_key, + processor_info = self._scanned_processors[ + selected_key + ] + processor_class = processor_info["class"] + processor_name = processor_info.get( + "name", + processor_class.__name__, ) - processor_name = self._scanned_processors[selected_key]["name"] + + if processor_builds_in_worker( + processor_class + ): + processor_spec = create_spec_from_scan( + self._scanned_processors, + selected_key, + ) + + log_processor_context( + "MainWindow._configure_dlc - " + f"SPEC: {processor_class.__name__}", + logger, + ) + else: + processor = instantiate_from_scan( + self._scanned_processors, + selected_key, + ) + + log_processor_context( + "MainWindow._configure_dlc - " + f"INSTANCE: {type(processor).__name__}", + logger, + ) + self.statusBar().showMessage( f"Loaded processor: {processor_name}", 3000, ) + except Exception as exc: - error_msg = f"Failed to instantiate processor: {exc}" + error_msg = ( + "Failed to configure processor: " + f"{exc}" + ) self._show_error(error_msg) - logger.error(error_msg) + logger.exception(error_msg) return False elif selected_key is not None: @@ -2070,8 +2116,14 @@ def _configure_dlc(self) -> bool: 3000, ) - self._dlc.configure(settings, processor=processor) - self._model_path_store.save_if_valid(settings.model_path) + self._dlc.configure( + settings, + processor=processor, + processor_spec=processor_spec, + ) + self._model_path_store.save_if_valid( + settings.model_path + ) return True def _update_inference_buttons(self) -> None: @@ -2205,20 +2257,6 @@ def _update_metrics(self) -> None: else: self.recording_stats_label.setText(self._last_recorder_summary) - def _on_processor_selection_changed( - self, - _index: int, - ) -> None: - """Enable custom processing when a processor is selected.""" - has_selection = self.processor_combo.currentData() is not None - self.processor_toggle_row.setVisible(has_selection) - - self.use_custom_proc_checkbox.blockSignals(True) - self.use_custom_proc_checkbox.setChecked(has_selection) - self.use_custom_proc_checkbox.blockSignals(False) - - self._update_processor_status() - def _update_processor_status(self) -> None: """Update processor connection and recording status, handle auto-recording.""" if not self._custom_processor_enabled(): @@ -2562,7 +2600,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember processor-control checkbox state on exit if hasattr(self, "use_custom_proc_checkbox"): - self._settings_store.set_processor_control_enabled(self.use_custom_proc_checkbox.isChecked()) + self._settings_store.set_processor_control_enabled(self._custom_processor_enabled()) if hasattr(self, "filename_edit"): self._settings_store.set_rec_filename(self.filename_edit.text().strip()) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 6a91ef1a3..1ab244f5b 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -38,6 +38,11 @@ class BaseProcessorSocket(Processor): PROCESSOR_PARAMS = {} PROCESSOR_DISCOVERABLE = False # base class, not intended to be an example processor + # Experimental: + # Socket/Teensy/Unity processors often start threads, sockets, serial ports, etc. + # Build them inside the DLCLive worker to match legacy Tk GUI behavior. + PROCESSOR_BUILD_IN_WORKER = False + def __init__( self, bind=None, diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index e47dbe2f8..879bab0df 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -5,13 +5,32 @@ import logging import pkgutil import sys +from dataclasses import dataclass, field from importlib import import_module from importlib.resources import as_file, files from pathlib import Path +from typing import Any + +from dlclivegui.config import DLC_LIFECYCLE_EXTRA_LOGS logger = logging.getLogger(__name__) +@dataclass +class ProcessorSpec: + cls: type + kwargs: dict[str, Any] = field(default_factory=dict) + + @property + def name(self) -> str: + return getattr(self.cls, "PROCESSOR_NAME", self.cls.__name__) + + def build(self) -> Any: + """Instantiate the processor class with the provided kwargs.""" + log_processor_context(f"ProcessorSpec.build: {self.name} with kwargs={self.kwargs}", logger) + return self.cls(**self.kwargs) + + def default_processors_dir() -> str: with as_file(files("dlclivegui").joinpath("processors")) as path: return str(path) @@ -202,6 +221,28 @@ def load_processors_from_file(file_path: str | Path) -> dict[str, dict]: return {} +def create_spec_from_scan(processors_dict, processor_key, **kwargs) -> ProcessorSpec: + """Create a ProcessorSpec from scan_processor_folder results, without instantiating the processor yet.""" + if processor_key not in processors_dict: + available = ", ".join(processors_dict.keys()) + raise ValueError(f"Unknown processor '{processor_key}'. Available: {available}") + + processor_info = processors_dict[processor_key] + processor_class = processor_info["class"] + return ProcessorSpec(cls=processor_class, kwargs=kwargs) + + +def processor_builds_in_worker(processor_class: type) -> bool: + """ + Return True if this processor class requests construction inside DLCLiveWorker. + + Processors opt in by defining: + + PROCESSOR_BUILD_IN_WORKER = True + """ + return bool(getattr(processor_class, "PROCESSOR_BUILD_IN_WORKER", False)) + + def instantiate_from_scan(processors_dict: dict[str, dict], processor_key: str, **kwargs): """ Instantiate a processor from scan_processor_folder results. @@ -246,3 +287,22 @@ def display_processor_info(processors): print(f" - {param_name} ({param_info['type']})") print(f" Default: {param_info['default']}") print(f" {param_info['description']}") + + +def log_processor_context(label: str, custom_logger: logging.Logger = logger): + if not DLC_LIFECYCLE_EXTRA_LOGS: + return + + import multiprocessing as mp + import os + import threading + import time + + custom_logger.info( + "[CUSTOM PROCESSOR] %s | pid=%s process=%s thread=%s time=%.6f", + label, + os.getpid(), + mp.current_process().name, + threading.current_thread().name, + time.time(), + ) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index ec92461e7..e51be7500 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -17,7 +17,13 @@ from PySide6.QtCore import QObject, Signal from dlclivegui.config import DLC_DO_LOG_TIMING, DLCProcessorSettings, ModelType -from dlclivegui.processors.processor_utils import instantiate_from_scan +from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket +from dlclivegui.processors.processor_utils import ( + ProcessorSpec, + create_spec_from_scan, + instantiate_from_scan, + log_processor_context, +) from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released from dlclivegui.utils.stats import WorkerTimingStats from dlclivegui.utils.utils import format_thread_stack @@ -156,6 +162,8 @@ def __init__(self) -> None: self._settings = DLCProcessorSettings() self._dlc: Any | None = None self._processor: Any | None = None + self._processor_spec: ProcessorSpec | None = None + self.processor_built_from_spec = False # Worker thread and queue self._queue: queue.Queue[Any] | None = None self._worker_thread: threading.Thread | None = None @@ -200,15 +208,27 @@ def __init__(self) -> None: def get_model_backend(model_path: str) -> Engine: return Engine.from_model_path(model_path) - def configure(self, settings: DLCProcessorSettings, processor: Any | None = None) -> None: + def configure( + self, settings: DLCProcessorSettings, processor: Any | None = None, processor_spec: ProcessorSpec | None = None + ) -> None: with self._lifecycle_lock: if self._state != WorkerState.STOPPED: raise RuntimeError("Cannot configure DLCLiveProcessor while it is running. Please stop it first.") + if processor is not None and processor_spec is not None: + raise ValueError( + "Cannot provide both a processor instance and a processor_spec. Please provide only one." + ) self._settings = settings self._processor = processor + self._processor_spec = processor_spec + self.processor_built_from_spec = False - def reset(self) -> None: + def reset(self, reset_processor_plugin: bool = False) -> None: """Stop the worker thread and drop the current DLCLive instance.""" + had_runtime = ( + self._worker_thread is not None or self._dlc is not None or self._initialized or reset_processor_plugin + ) + stopped = self._stop_worker() if not stopped: with self._lifecycle_lock: @@ -217,6 +237,10 @@ def reset(self) -> None: "Reset requested but worker thread is still alive; skipping DLCLive reset to avoid potential issues." ) return + + if had_runtime and self._processor is not None: + self._cleanup_processor() + self._dlc = None self._initialized = False with self._stats_lock: @@ -232,10 +256,28 @@ def reset(self) -> None: self._gpu_inference_times.clear() self._processor_overhead_times.clear() + def _cleanup_processor(self) -> None: + proc = self._processor + if proc is None: + return + + stop = getattr(proc, "stop", None) + if callable(stop): + try: + log_processor_context(f"Stopping processor: {type(proc).__name__}", logger) + stop() + except Exception: + logger.exception("Failed to stop processor cleanly") + + self._processor = None + self._processor_built_from_spec = False + def shutdown(self) -> None: stopped = self._stop_worker() if not stopped: with self._lifecycle_lock: + if self._processor is not None: + self._cleanup_processor() self._pending_reset = True logger.warning( "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." @@ -458,6 +500,7 @@ def _start_worker_locked(self, init_frame: np.ndarray, init_timestamp: float) -> self._queue = None self._stop_event.clear() self._state = WorkerState.STARTING + log_processor_context("Starting DLCLive worker thread", logger) self._worker_thread = threading.Thread( target=self._worker_loop, args=(init_frame, init_timestamp), @@ -590,6 +633,7 @@ def _process_frame( """ if self._dlc is None: raise RuntimeError("DLCLive instance is not initialized.") + # log_processor_context(f"DLCLiveProcessor._process_frame: timestamp={timestamp:.6f}", logger) # Time GPU inference (and processor overhead when present) with self._worker_timing.measure("DLC.prepare_frame"): frame = self._prepare_input_frame(frame) @@ -653,10 +697,11 @@ def _process_frame( def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: - # -------- Initialization (unchanged) -------- + # -------- Initialization -------- if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") + log_processor_context("DLCLiveProcessor._worker_loop", logger) with self._worker_timing.measure("DLC.build_options"): dyn = self._settings.dynamic if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: @@ -666,10 +711,33 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: raise RuntimeError("Invalid dynamic crop settings format.") from e enabled, margin, max_missing = dyn + custom_proc = None + + if self._processor is not None: + custom_proc = self._processor + log_processor_context( + f"Using existing processor instance: {type(custom_proc).__name__}", + logger, + ) + + elif self._processor_spec is not None: + log_processor_context( + f"Building processor from spec: {self._processor_spec.name}", + logger, + ) + custom_proc = self._processor_spec.build() + + with self._lifecycle_lock: + self._processor = custom_proc + self._processor_built_from_spec = True + + else: + custom_proc = None + options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, - "processor": self._processor, + "processor": custom_proc, "dynamic": [enabled, margin, max_missing], "resize": self._settings.resize, "precision": self._settings.precision, @@ -697,6 +765,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: ) with self._worker_timing.measure("DLC.construct"): self._dlc = DLCLive(**options) + log_processor_context("DLCLive instance constructed", logger) self._worker_timing.maybe_log() except Exception as exc: self._worker_timing.note_error() @@ -724,6 +793,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: # First inference to initialize with self._worker_timing.measure("DLC.init_inference"): self._dlc.init_inference(init_frame) + log_processor_context("DLCLive init_inference completed", logger) self._debug_log_dlc_runner_device() self._worker_timing.note_frame() @@ -871,13 +941,33 @@ def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, se raise RuntimeError("Cannot configure DLCLiveProcessor while it is running. Please stop it first.") processor = None + processor_spec = None + if selected_key is not None and scanned_processors: try: - processor = instantiate_from_scan(scanned_processors, selected_key) + processor_info = scanned_processors[selected_key] + processor_class = processor_info["class"] + + if BaseProcessorSocket.do_build_in_worker(processor_class): + processor_spec = create_spec_from_scan(scanned_processors, selected_key) + + log_processor_context( + f"DLCLiveProcessor.configure - SPEC: {processor_class.__name__}", + logger, + ) + else: + processor = instantiate_from_scan(scanned_processors, selected_key) + + log_processor_context( + f"DLCLiveProcessor.configure - INSTANCE: {type(processor).__name__}", + logger, + ) + except Exception as exc: - logger.error("Failed to instantiate processor: %s", exc) + logger.error("Failed to configure processor: %s", exc, exc_info=True) return False - self._proc.configure(settings, processor=processor) + + self._proc.configure(settings, processor=processor, processor_spec=processor_spec) return True def start(self): From 2309d063118683f2b0fd6726050b53e2f2469630 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 11:11:55 +0200 Subject: [PATCH 166/194] Add mock proc test fiels --- .../custom/mock_socket_processor.py | 532 ++ .../custom/mock_unity_socket_client.ipynb | 4609 +++++++++++++++++ 2 files changed, 5141 insertions(+) create mode 100644 dlclivegui/processors/custom/mock_socket_processor.py create mode 100644 dlclivegui/processors/custom/mock_unity_socket_client.ipynb diff --git a/dlclivegui/processors/custom/mock_socket_processor.py b/dlclivegui/processors/custom/mock_socket_processor.py new file mode 100644 index 000000000..788bde3df --- /dev/null +++ b/dlclivegui/processors/custom/mock_socket_processor.py @@ -0,0 +1,532 @@ +"""Standalone mock DLC processor plugin with socket listener support. + +This fixture intentionally avoids importing dlclive, dlclivegui, Teensy, serial, +NumPy, or project-specific processor base classes. + +It is designed to test two separate concerns without mixing them: + +1. GUI processor plugin discovery/configuration + - Exposes PROCESSOR_* metadata. + - Exposes PROCESSOR_BUILD_IN_WORKER = True. + - Exposes get_available_processors(), which your loader can consume without + requiring this class to inherit from dlclive.processor.Processor. + +2. Runtime socket listener behavior + - Starts a multiprocessing.connection.Listener. + - Accepts one or more clients on a background thread. + - Receives simple command dictionaries from clients. + - Broadcasts mock pose payloads to connected clients from process(). + +It does NOT mock Teensy serial acquisition. For the listener send/receive tests, +Teensy is not required: the Teensy path is a separate serial-reader concern. +""" + +from __future__ import annotations + +import logging +import pickle +import sys +import time +from collections import deque +from multiprocessing.connection import Client, Listener +from pathlib import Path +from threading import Event, Lock, Thread +from typing import Any + +logger = logging.getLogger(__name__) + +IP_ADDRESS = "127.0.0.1" +PORT = 6000 + + +class MockSocketProcessor: + """Standalone socket-based mock processor for tests. + + This intentionally reimplements the core listener/thread/control behavior + instead of inheriting from project classes. It is suitable for fixture usage + where external dependencies such as Teensy, serial, dlclive, or dlclivegui + should not be imported. + + Expected test usage: + + proc = MockSocketProcessor(bind=("127.0.0.1", free_port())) + conn = Client(proc.address, authkey=proc.authkey) + conn.send({"cmd": "ping"}) + assert conn.recv()["type"] == "pong" + proc.process([[1, 2, 0.9]]) + assert conn.recv()["type"] == "pose" + proc.stop() + + Notes: + - `process()` accepts any pose-like Python object. It does not validate + DLC shape because this mock is for socket lifecycle tests, not pose + validation tests. + - Payloads are sent through multiprocessing.connection, matching the + style used by the legacy socket processors. + """ + + PROCESSOR_NAME = "Mock Socket Processor" + PROCESSOR_DESCRIPTION = "Standalone mock socket processor without Teensy or DLCLive imports." + PROCESSOR_BUILD_IN_WORKER = False + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": (IP_ADDRESS, PORT), + "description": "Server bind address. Use port 0 to request an ephemeral port.", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for multiprocessing.connection clients.", + }, + "start_server": { + "type": "bool", + "default": True, + "description": "Whether to start the listener in __init__.", + }, + "socket_timeout": { + "type": "float", + "default": 0.05, + "description": "Accept-loop timeout in seconds.", + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Whether to store raw pose payloads while recording.", + }, + } + + def __init__( + self, + bind: tuple[str, int] = (IP_ADDRESS, PORT), + authkey: bytes = b"secret password", + *, + start_server: bool = True, + socket_timeout: float = 0.05, + save_original: bool = False, + ) -> None: + self.address = bind + self.authkey = authkey + self._socket_timeout = float(socket_timeout) + self.save_original = bool(save_original) + + # Runtime listener/client state. + self.listener: Listener | None = None + self.conns: set[Any] = set() + self._conns_lock = Lock() + self._stop = Event() + self._accept_thread: Thread | None = None + self._rx_threads: set[Thread] = set() + + # Recording/control state compatible with socket-processor expectations. + self._recording = Event() + self._vid_recording = Event() + self._session_name = "test_session" + self.filename: str | None = None + + # Minimal data buffers for save/get_data tests. + self.start_time = time.time() + self.time_stamp = deque() + self.step = deque() + self.frame_time = deque() + self.pose_time = deque() + self.original_pose = deque() if self.save_original else None + self.received_commands = deque() + self.broadcast_count = 0 + self.curr_step = 0 + + if start_server: + self.start_server(bind, authkey=authkey, timeout=self._socket_timeout) + + # ------------------------------------------------------------------ + # Properties matching the real socket processors + # ------------------------------------------------------------------ + @property + def recording(self) -> bool: + return self._recording.is_set() + + @property + def video_recording(self) -> bool: + return self._vid_recording.is_set() + + @property + def session_name(self) -> str: + return self._session_name + + @session_name.setter + def session_name(self, name: str) -> None: + self._session_name = str(name) + self.filename = f"{self._session_name}_mock_processor_data.pkl" + + # ------------------------------------------------------------------ + # Listener lifecycle + # ------------------------------------------------------------------ + def start_server( + self, + bind: tuple[str, int] | None = None, + authkey: bytes | None = None, + *, + timeout: float | None = None, + ) -> None: + """Start the socket listener if it is not already running.""" + if self.listener is not None: + return + + if bind is not None: + self.address = bind + if authkey is not None: + self.authkey = authkey + if timeout is not None: + self._socket_timeout = float(timeout) + + self._stop.clear() + self.listener = Listener(self.address, authkey=self.authkey) + + # If bind used port 0, update address to the actual ephemeral port. + self.address = self._actual_listener_address(self.listener, fallback=self.address) + + self._set_listener_timeout(self.listener, self._socket_timeout) + + self._accept_thread = Thread(target=self._accept_loop, name="MockSocketProcessorAccept", daemon=True) + self._accept_thread.start() + logger.info("MockSocketProcessor listening on %s:%s", self.address[0], self.address[1]) + + @staticmethod + def _actual_listener_address(listener: Listener, fallback: tuple[str, int]) -> tuple[str, int]: + """Best-effort extraction of the actual listener address.""" + try: + raw = getattr(listener, "_listener", None) + sock = getattr(raw, "_socket", None) + if sock is not None: + addr = sock.getsockname() + return (str(addr[0]), int(addr[1])) + except Exception: + pass + try: + addr = listener.address + return (str(addr[0]), int(addr[1])) + except Exception: + return fallback + + @staticmethod + def _set_listener_timeout(listener: Listener, timeout: float) -> None: + """Set accept timeout on CPython listener internals, best effort.""" + raw = getattr(listener, "_listener", None) + for candidate in (raw, getattr(raw, "_socket", None)): + try: + if candidate is not None and hasattr(candidate, "settimeout"): + candidate.settimeout(timeout) + return + except Exception: + pass + + def _accept_loop(self) -> None: + while not self._stop.is_set(): + try: + if self.listener is None: + return + conn = self.listener.accept() + except TimeoutError: + continue + except (OSError, EOFError): + if self._stop.is_set(): + break + continue + except Exception: + if self._stop.is_set(): + break + logger.exception("Unexpected accept-loop error") + continue + + with self._conns_lock: + self.conns.add(conn) + + rx = Thread(target=self._rx_loop, args=(conn,), name="MockSocketProcessorRx", daemon=True) + self._rx_threads.add(rx) + rx.start() + logger.info("MockSocketProcessor client connected") + + def _rx_loop(self, conn: Any) -> None: + while not self._stop.is_set(): + try: + if conn.poll(0.05): + msg = conn.recv() + self._handle_client_message(msg, conn=conn) + continue + + if getattr(conn, "closed", False): + break + + except (EOFError, OSError, ConnectionError, BrokenPipeError): + break + except Exception: + logger.exception("Unexpected receive-loop error") + break + + self._close_conn(conn) + + def _close_conn(self, conn: Any) -> None: + try: + conn.close() + except Exception: + pass + with self._conns_lock: + self.conns.discard(conn) + + def stop(self) -> None: + """Stop listener, close clients, and join background threads best-effort.""" + if self._stop.is_set(): + return + + self._stop.set() + + # Wake accept() if needed. + try: + Client(self.address, authkey=self.authkey).close() + except Exception: + pass + + with self._conns_lock: + conns = list(self.conns) + for conn in conns: + self._close_conn(conn) + + try: + if self.listener is not None: + self.listener.close() + except Exception: + pass + self.listener = None + + if self._accept_thread is not None: + self._accept_thread.join(timeout=1.0) + self._accept_thread = None + + for thread in list(self._rx_threads): + try: + thread.join(timeout=0.5) + except Exception: + pass + self._rx_threads.clear() + + if sys.platform.startswith("win"): + time.sleep(0.05) + + close = stop + + def __del__(self) -> None: + try: + self.stop() + except Exception: + pass + + # ------------------------------------------------------------------ + # Client command handling + # ------------------------------------------------------------------ + def _handle_client_message(self, msg: Any, *, conn: Any | None = None) -> None: + self.received_commands.append(msg) + + if not isinstance(msg, dict): + self._send_to(conn, {"type": "error", "error": "message must be a dict"}) + return + + cmd = msg.get("cmd") + + if cmd == "ping": + self._send_to( + conn, + { + "type": "pong", + "timestamp": time.time(), + "session_name": self.session_name, + "recording": self.recording, + "video_recording": self.video_recording, + "clients": self.client_count(), + }, + ) + + elif cmd == "status": + self._send_to(conn, self.status_payload()) + + elif cmd == "set_session_name": + self.session_name = msg.get("session_name", "default_session") + self._send_to(conn, {"type": "ack", "cmd": cmd, "session_name": self.session_name}) + + elif cmd == "start_recording": + self.start_recording() + self._send_to(conn, {"type": "ack", "cmd": cmd, "recording": True}) + + elif cmd == "stop_recording": + self.stop_recording() + self._send_to(conn, {"type": "ack", "cmd": cmd, "recording": False}) + + elif cmd == "save": + file = msg.get("filename", self.filename) + result = self.save(file) + self._send_to(conn, {"type": "ack", "cmd": cmd, "result": result, "filename": file}) + + elif cmd == "close": + self._send_to(conn, {"type": "ack", "cmd": cmd}) + if conn is not None: + self._close_conn(conn) + + else: + self._send_to(conn, {"type": "error", "error": f"unknown cmd: {cmd!r}"}) + + @staticmethod + def _send_to(conn: Any | None, payload: Any) -> bool: + if conn is None: + return False + try: + conn.send(payload) + return True + except Exception: + return False + + def client_count(self) -> int: + with self._conns_lock: + return len(self.conns) + + def status_payload(self) -> dict[str, Any]: + return { + "type": "status", + "session_name": self.session_name, + "recording": self.recording, + "video_recording": self.video_recording, + "clients": self.client_count(), + "steps": self.curr_step, + "broadcast_count": self.broadcast_count, + "address": self.address, + } + + # ------------------------------------------------------------------ + # Recording helpers + # ------------------------------------------------------------------ + def start_recording(self) -> None: + self._recording.set() + self._vid_recording.set() + self._clear_data_queues() + self.curr_step = 0 + + def stop_recording(self) -> None: + self._recording.clear() + self._vid_recording.clear() + + def _clear_data_queues(self) -> None: + self.time_stamp.clear() + self.step.clear() + self.frame_time.clear() + self.pose_time.clear() + if self.original_pose is not None: + self.original_pose.clear() + + # ------------------------------------------------------------------ + # Process/broadcast path + # ------------------------------------------------------------------ + def process(self, pose: Any, **kwargs: Any) -> Any: + """Mock DLCLive processor callback. + + Records minimal metadata when recording is active and broadcasts a simple + pose payload to all connected clients. + """ + now = time.time() + self.curr_step += 1 + + if self.recording: + self.time_stamp.append(now) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + if self.original_pose is not None: + self.original_pose.append(pose) + + payload = { + "type": "pose", + "timestamp": now, + "step": self.curr_step, + "pose": self._make_pickle_safe_pose(pose), + "frame_time": kwargs.get("frame_time", None), + "pose_time": kwargs.get("pose_time", None), + "recording": self.recording, + } + self.broadcast(payload) + return pose + + @staticmethod + def _make_pickle_safe_pose(pose: Any) -> Any: + """Convert common array-likes to socket-safe Python types.""" + tolist = getattr(pose, "tolist", None) + if callable(tolist): + try: + return tolist() + except Exception: + pass + return pose + + def broadcast(self, payload: Any) -> None: + with self._conns_lock: + conns = list(self.conns) + + dead = [] + for conn in conns: + try: + conn.send(payload) + self.broadcast_count += 1 + except Exception: + dead.append(conn) + + for conn in dead: + self._close_conn(conn) + + # ------------------------------------------------------------------ + # Save/get_data helpers + # ------------------------------------------------------------------ + def get_data(self) -> dict[str, Any]: + return { + "start_time": self.start_time, + "session_name": self.session_name, + "time_stamp": list(self.time_stamp), + "step": list(self.step), + "frame_time": list(self.frame_time), + "pose_time": list(self.pose_time), + "recording": self.recording, + "video_recording": self.video_recording, + "received_commands": list(self.received_commands), + "broadcast_count": self.broadcast_count, + } + + def save(self, file: str | Path | None = None) -> int: + if not file: + return 0 + try: + path = Path(file) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as fh: + pickle.dump(self.get_data(), fh) + return 1 + except Exception: + logger.exception("MockSocketProcessor save failed") + return -1 + + +# Optional aliases useful in different test styles. +MockPDSocketProcessor = MockSocketProcessor +MockUnitySocketProcessor = MockSocketProcessor + + +def get_available_processors() -> dict[str, dict[str, Any]]: + """Plugin-discovery entrypoint used by dlclivegui.processor_utils. + + This avoids requiring the class to inherit from dlclive.processor.Processor + during tests. The loader path that prefers get_available_processors() can + still discover this processor as a GUI plugin fixture. + """ + return { + "MockSocketProcessor": { + "class": MockSocketProcessor, + "name": getattr(MockSocketProcessor, "PROCESSOR_NAME", "MockSocketProcessor"), + "description": getattr(MockSocketProcessor, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(MockSocketProcessor, "PROCESSOR_PARAMS", {}), + } + } diff --git a/dlclivegui/processors/custom/mock_unity_socket_client.ipynb b/dlclivegui/processors/custom/mock_unity_socket_client.ipynb new file mode 100644 index 000000000..63e4569a1 --- /dev/null +++ b/dlclivegui/processors/custom/mock_unity_socket_client.ipynb @@ -0,0 +1,4609 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "source": [ + "# Mock Unity Socket Client for DLCLive Processor\n", + "\n", + "This notebook acts as the **Unity-side socket client** for the legacy DLC socket processor.\n", + "\n", + "## Which processor style this targets\n", + "\n", + "The legacy processor chain you showed is:\n", + "\n", + "```text\n", + "dlc_inference_w_pd_sync\n", + " -> dlc_inference_w_pd\n", + " -> MyProcessor_socket\n", + "```\n", + "\n", + "`MyProcessor_socket` opens a `multiprocessing.connection.Listener`, defaulting to:\n", + "\n", + "```python\n", + "(\"127.0.0.1\", 6000)\n", + "authkey=b\"secret password\"\n", + "```\n", + "\n", + "It sends payloads from `process()` shaped like:\n", + "\n", + "```python\n", + "[time.time(), x, y, heading, head_angle, signal]\n", + "```\n", + "\n", + "This notebook connects as the **client** and tries to catch those pose/kinematics packets.\n", + "\n", + "## Important ordering note\n", + "\n", + "The legacy `MyProcessor_socket` does **not** have a background accept thread. It accepts a client only when `process()` runs. That means if DLC inference is not producing poses yet, `Client(...)` may block or time out. If that happens, start/continue DLC inference and retry." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import json\n", + "import queue\n", + "import threading\n", + "import time\n", + "from dataclasses import asdict, dataclass\n", + "from multiprocessing.connection import Client\n", + "from pathlib import Path\n", + "from typing import Any" + ] + }, + { + "cell_type": "markdown", + "id": "9a63283cbaf04dbcab1f6479b197f3a8", + "metadata": {}, + "source": [ + "## Configuration\n", + "\n", + "Adjust these if the processor uses a different port or auth key.\n", + "\n", + "For the legacy processor, the defaults are usually:\n", + "\n", + "```python\n", + "ADDRESS = (\"127.0.0.1\", 6000)\n", + "AUTHKEY = b\"secret password\"\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8dd0d8092fe74a7c96281538738b07e2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Target processor socket: ('127.0.0.1', 6000)\n" + ] + } + ], + "source": [ + "ADDRESS = (\"127.0.0.1\", 6000)\n", + "AUTHKEY = b\"secret password\"\n", + "\n", + "# How long to wait for a connection attempt before considering it failed.\n", + "CONNECT_TIMEOUT_S = 10.0\n", + "\n", + "# How long the receive loop should poll while waiting for new packets.\n", + "POLL_INTERVAL_S = 0.05\n", + "\n", + "print(\"Target processor socket:\", ADDRESS)" + ] + }, + { + "cell_type": "markdown", + "id": "72eea5119410473aa328ad9291626812", + "metadata": {}, + "source": [ + "## Client helpers\n", + "\n", + "`multiprocessing.connection.Client(...)` can block if the server has not called `accept()` yet. To avoid freezing the notebook, `connect_with_timeout()` performs the connection attempt in a background thread." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "8edb47106e1a46a883d545849b8ab81b", + "metadata": {}, + "outputs": [], + "source": [ + "class ConnectTimeoutError(TimeoutError):\n", + " pass\n", + "\n", + "\n", + "def connect_with_timeout(address, authkey: bytes, timeout_s: float = 10.0):\n", + " \"\"\"Connect to a multiprocessing.connection.Listener without freezing the notebook forever.\"\"\"\n", + " result_q: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=1)\n", + "\n", + " def worker():\n", + " try:\n", + " conn = Client(address, authkey=authkey)\n", + " result_q.put((\"ok\", conn))\n", + " except Exception as exc:\n", + " result_q.put((\"error\", exc))\n", + "\n", + " t = threading.Thread(target=worker, name=\"MockUnityConnect\", daemon=True)\n", + " t.start()\n", + " t.join(timeout_s)\n", + "\n", + " if t.is_alive():\n", + " raise ConnectTimeoutError(\n", + " f\"Timed out after {timeout_s:.1f}s while connecting to {address}. \"\n", + " \"For legacy MyProcessor_socket, this can happen if DLC process() has not called listener.accept() yet.\"\n", + " )\n", + "\n", + " status, payload = result_q.get_nowait()\n", + " if status == \"ok\":\n", + " return payload\n", + " raise payload\n", + "\n", + "\n", + "@dataclass\n", + "class LegacyPosePacket:\n", + " timestamp: float\n", + " x: float\n", + " y: float\n", + " heading: float\n", + " head_angle: float\n", + " signal: float\n", + " raw: Any\n", + "\n", + "\n", + "def decode_payload(payload: Any) -> dict[str, Any]:\n", + " \"\"\"Decode either legacy list payloads or newer dict/list mock payloads.\"\"\"\n", + " # Legacy MyProcessor_socket payload:\n", + " # [time.time(), x, y, heading, head_angle, signal]\n", + " if isinstance(payload, list) and len(payload) == 6:\n", + " pkt = LegacyPosePacket(\n", + " timestamp=float(payload[0]),\n", + " x=float(payload[1]),\n", + " y=float(payload[2]),\n", + " heading=float(payload[3]),\n", + " head_angle=float(payload[4]),\n", + " signal=float(payload[5]),\n", + " raw=payload,\n", + " )\n", + " return {\"kind\": \"legacy_pose\", **asdict(pkt)}\n", + "\n", + " # Newer/base mock payloads may be dictionaries.\n", + " if isinstance(payload, dict):\n", + " kind = payload.get(\"type\", \"dict\")\n", + " return {\"kind\": kind, \"raw\": payload}\n", + "\n", + " # Some processors broadcast [timestamp, pose].\n", + " if isinstance(payload, list) and len(payload) == 2:\n", + " return {\"kind\": \"timestamp_pose\", \"timestamp\": payload[0], \"pose\": payload[1], \"raw\": payload}\n", + "\n", + " return {\"kind\": \"unknown\", \"raw\": payload}" + ] + }, + { + "cell_type": "markdown", + "id": "10185d26023b46108eb7d9f57d49d2b3", + "metadata": {}, + "source": [ + "## Connect to the DLC processor socket\n", + "\n", + "Run this cell once the processor has been created and its listener should be available.\n", + "\n", + "If it times out, it likely means either:\n", + "\n", + "1. the processor has not been instantiated yet,\n", + "2. the address/authkey are wrong,\n", + "3. the legacy processor is waiting until `process()` runs before accepting the connection,\n", + "4. another process is using the port." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8763a12b2bbd4a93a75aff182afb95dc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Connected to processor socket: ('127.0.0.1', 6000)\n" + ] + } + ], + "source": [ + "conn = connect_with_timeout(ADDRESS, AUTHKEY, timeout_s=CONNECT_TIMEOUT_S)\n", + "print(\"Connected to processor socket:\", ADDRESS)" + ] + }, + { + "cell_type": "markdown", + "id": "7623eae2785240b9bd12b16a66d81610", + "metadata": {}, + "source": [ + "## Optional: send a ping/status command\n", + "\n", + "Only use this for processors that implement command handling, such as the newer `BaseProcessorSocket` or the standalone mock processor.\n", + "\n", + "The legacy `MyProcessor_socket` does **not** read commands from the client, so skip this cell for that processor." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7cdc8c89c7104fffa095e18ddfef8986", + "metadata": {}, + "outputs": [], + "source": [ + "# Uncomment only for BaseProcessorSocket-style processors or the standalone mock.\n", + "# conn.send({\"cmd\": \"ping\"})\n", + "# if conn.poll(2.0):\n", + "# print(\"Response:\", conn.recv())\n", + "# else:\n", + "# print(\"No response. This is expected for legacy MyProcessor_socket.\")" + ] + }, + { + "cell_type": "markdown", + "id": "b118ea5561624da68c537baed56e602f", + "metadata": {}, + "source": [ + "## Receive pose packets\n", + "\n", + "This cell listens for up to `duration_s` seconds and prints decoded packets. For legacy `MyProcessor_socket`, you should see `legacy_pose` packets once `process()` is called by DLC inference." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "938c804e27f84196a10c8828c723f798", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.750286,\n", + " \"step\": 12,\n", + " \"pose\": [\n", + " [\n", + " 288.7191162109375,\n", + " 298.63250732421875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.0001220703125,\n", + " 307.95672607421875,\n", + " 0.973543643951416\n", + " ],\n", + " [\n", + " 297.29254150390625,\n", + " 314.52410888671875,\n", + " 0.6293796896934509\n", + " ],\n", + " [\n", + " 290.9452819824219,\n", + " 310.3515319824219,\n", + " 0.640852153301239\n", + " ],\n", + " [\n", + " 304.86236572265625,\n", + " 313.2810974121094,\n", + " 0.6921122670173645\n", + " ],\n", + " [\n", + " 284.668212890625,\n", + " 266.52752685546875,\n", + " 0.9321146607398987\n", + " ],\n", + " [\n", + " 284.6539001464844,\n", + " 237.49722290039062,\n", + " 0.4642881155014038\n", + " ],\n", + " [\n", + " 298.547607421875,\n", + " 218.42587280273438,\n", + " 0.26315996050834656\n", + " ],\n", + " [\n", + " 182.9195098876953,\n", + " 312.5831604003906,\n", + " 0.4563772678375244\n", + " ],\n", + " [\n", + " 181.693359375,\n", + " 306.1708068847656,\n", + " 0.39262306690216064\n", + " ],\n", + " [\n", + " 309.70013427734375,\n", + " 273.22198486328125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.6557922363281,\n", + " 278.49371337890625,\n", + " 0.5969972014427185\n", + " ],\n", + " [\n", + " 362.46343994140625,\n", + " 283.172119140625,\n", + " 0.4280658960342407\n", + " ],\n", + " [\n", + " 185.8195343017578,\n", + " 311.7103271484375,\n", + " 0.27244052290916443\n", + " ],\n", + " [\n", + " 192.61837768554688,\n", + " 305.4747009277344,\n", + " 0.39853179454803467\n", + " ],\n", + " [\n", + " 355.7668151855469,\n", + " 339.8248596191406,\n", + " 0.40097054839134216\n", + " ],\n", + " [\n", + " 364.0209655761719,\n", + " 341.5860595703125,\n", + " 0.5437613129615784\n", + " ],\n", + " [\n", + " 335.2928771972656,\n", + " 344.40032958984375,\n", + " 0.3498704731464386\n", + " ],\n", + " [\n", + " 331.4139404296875,\n", + " 356.1664123535156,\n", + " 0.4611521065235138\n", + " ],\n", + " [\n", + " 366.796142578125,\n", + " 344.254150390625,\n", + " 0.47546425461769104\n", + " ],\n", + " [\n", + " 270.3355712890625,\n", + " 358.1495666503906,\n", + " 0.4529607594013214\n", + " ],\n", + " [\n", + " 379.02679443359375,\n", + " 356.05645751953125,\n", + " 0.4878201186656952\n", + " ],\n", + " [\n", + " 268.0301818847656,\n", + " 359.48583984375,\n", + " 0.366856187582016\n", + " ],\n", + " [\n", + " 599.5546264648438,\n", + " 377.7467956542969,\n", + " 0.564258873462677\n", + " ],\n", + " [\n", + " 185.41236877441406,\n", + " 207.2476043701172,\n", + " 0.3415561616420746\n", + " ],\n", + " [\n", + " 518.7420043945312,\n", + " 391.21240234375,\n", + " 0.3360799551010132\n", + " ],\n", + " [\n", + " 516.0563354492188,\n", + " 429.2253723144531,\n", + " 0.7114025354385376\n", + " ],\n", + " [\n", + " 164.67864990234375,\n", + " 212.08599853515625,\n", + " 0.22173050045967102\n", + " ],\n", + " [\n", + " 508.5932312011719,\n", + " 391.6039123535156,\n", + " 0.30660656094551086\n", + " ],\n", + " [\n", + " 511.993896484375,\n", + " 447.9190368652344,\n", + " 0.5064514875411987\n", + " ],\n", + " [\n", + " 591.1927490234375,\n", + " 409.2403564453125,\n", + " 0.5136002898216248\n", + " ],\n", + " [\n", + " 106.3792495727539,\n", + " 271.0600891113281,\n", + " 0.14335250854492188\n", + " ],\n", + " [\n", + " 112.1520767211914,\n", + " 279.07525634765625,\n", + " 0.2755976915359497\n", + " ],\n", + " [\n", + " 572.9004516601562,\n", + " 393.2549743652344,\n", + " 0.39857158064842224\n", + " ],\n", + " [\n", + " 144.4106903076172,\n", + " 313.3831787109375,\n", + " 0.27947890758514404\n", + " ],\n", + " [\n", + " 550.626953125,\n", + " 466.50970458984375,\n", + " 0.5572487115859985\n", + " ],\n", + " [\n", + " 466.3507385253906,\n", + " 413.0749206542969,\n", + " 0.39403966069221497\n", + " ],\n", + " [\n", + " 461.47198486328125,\n", + " 400.29541015625,\n", + " 0.3778141140937805\n", + " ],\n", + " [\n", + " 460.13824462890625,\n", + " 403.57171630859375,\n", + " 0.5188782811164856\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.7246952,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.750286\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.78031,\n", + " \"step\": 13,\n", + " \"pose\": [\n", + " [\n", + " 287.8656921386719,\n", + " 298.9954833984375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 292.98870849609375,\n", + " 308.14825439453125,\n", + " 0.9376083016395569\n", + " ],\n", + " [\n", + " 297.0699462890625,\n", + " 315.7899169921875,\n", + " 0.605503499507904\n", + " ],\n", + " [\n", + " 290.7883605957031,\n", + " 311.0892333984375,\n", + " 0.653724193572998\n", + " ],\n", + " [\n", + " 304.9418029785156,\n", + " 314.27423095703125,\n", + " 0.6581617593765259\n", + " ],\n", + " [\n", + " 284.5467224121094,\n", + " 266.99755859375,\n", + " 0.9386962056159973\n", + " ],\n", + " [\n", + " 285.4912109375,\n", + " 236.9186248779297,\n", + " 0.48566538095474243\n", + " ],\n", + " [\n", + " 282.66357421875,\n", + " 236.26953125,\n", + " 0.2793366611003876\n", + " ],\n", + " [\n", + " 182.8148193359375,\n", + " 314.7862548828125,\n", + " 0.4555658996105194\n", + " ],\n", + " [\n", + " 183.0238037109375,\n", + " 309.2024230957031,\n", + " 0.3395337760448456\n", + " ],\n", + " [\n", + " 310.2979431152344,\n", + " 272.9541015625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 362.39019775390625,\n", + " 278.04864501953125,\n", + " 0.7049410343170166\n", + " ],\n", + " [\n", + " 362.625,\n", + " 281.328369140625,\n", + " 0.4549161195755005\n", + " ],\n", + " [\n", + " 357.58203125,\n", + " 282.10272216796875,\n", + " 0.26718243956565857\n", + " ],\n", + " [\n", + " 194.67514038085938,\n", + " 306.698974609375,\n", + " 0.45219412446022034\n", + " ],\n", + " [\n", + " 355.5292663574219,\n", + " 339.20318603515625,\n", + " 0.41419631242752075\n", + " ],\n", + " [\n", + " 367.3896179199219,\n", + " 340.3309326171875,\n", + " 0.5223292112350464\n", + " ],\n", + " [\n", + " 335.11529541015625,\n", + " 343.81201171875,\n", + " 0.347744345664978\n", + " ],\n", + " [\n", + " 332.03765869140625,\n", + " 356.7191162109375,\n", + " 0.4733559191226959\n", + " ],\n", + " [\n", + " 367.4532470703125,\n", + " 344.79302978515625,\n", + " 0.4827011823654175\n", + " ],\n", + " [\n", + " 270.3110046386719,\n", + " 357.83343505859375,\n", + " 0.4700448215007782\n", + " ],\n", + " [\n", + " 378.0578308105469,\n", + " 356.4914855957031,\n", + " 0.4333026707172394\n", + " ],\n", + " [\n", + " 267.2149963378906,\n", + " 359.4980773925781,\n", + " 0.39140474796295166\n", + " ],\n", + " [\n", + " 596.2949829101562,\n", + " 378.6457824707031,\n", + " 0.5882202386856079\n", + " ],\n", + " [\n", + " 185.2808837890625,\n", + " 208.80474853515625,\n", + " 0.4029167890548706\n", + " ],\n", + " [\n", + " 563.7208862304688,\n", + " 387.8863220214844,\n", + " 0.390456885099411\n", + " ],\n", + " [\n", + " 519.1982421875,\n", + " 433.89654541015625,\n", + " 0.5289698839187622\n", + " ],\n", + " [\n", + " 442.9023132324219,\n", + " 356.9894714355469,\n", + " 0.2825982868671417\n", + " ],\n", + " [\n", + " 568.7670288085938,\n", + " 195.95079040527344,\n", + " 0.43732380867004395\n", + " ],\n", + " [\n", + " 512.254638671875,\n", + " 449.7223815917969,\n", + " 0.6101775169372559\n", + " ],\n", + " [\n", + " 591.0804443359375,\n", + " 409.246337890625,\n", + " 0.5442432761192322\n", + " ],\n", + " [\n", + " 470.5218200683594,\n", + " 402.7597961425781,\n", + " 0.1880091279745102\n", + " ],\n", + " [\n", + " 107.73304748535156,\n", + " 270.0023193359375,\n", + " 0.14866414666175842\n", + " ],\n", + " [\n", + " 572.8255615234375,\n", + " 393.6127624511719,\n", + " 0.5601691603660583\n", + " ],\n", + " [\n", + " 481.23486328125,\n", + " 431.833984375,\n", + " 0.2556696832180023\n", + " ],\n", + " [\n", + " 550.4242553710938,\n", + " 467.0722961425781,\n", + " 0.4299638867378235\n", + " ],\n", + " [\n", + " 466.45404052734375,\n", + " 412.8644714355469,\n", + " 0.38715386390686035\n", + " ],\n", + " [\n", + " 462.144775390625,\n", + " 401.0147705078125,\n", + " 0.3404390513896942\n", + " ],\n", + " [\n", + " 459.4046325683594,\n", + " 403.1348571777344,\n", + " 0.4926401376724243\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.7554483,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.78031\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.821648,\n", + " \"step\": 14,\n", + " \"pose\": [\n", + " [\n", + " 288.8023681640625,\n", + " 298.86077880859375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 292.8272399902344,\n", + " 308.04949951171875,\n", + " 0.9570764899253845\n", + " ],\n", + " [\n", + " 296.77056884765625,\n", + " 315.70367431640625,\n", + " 0.631300687789917\n", + " ],\n", + " [\n", + " 289.9299621582031,\n", + " 310.9530944824219,\n", + " 0.6520562171936035\n", + " ],\n", + " [\n", + " 305.0509338378906,\n", + " 313.7876892089844,\n", + " 0.7196981310844421\n", + " ],\n", + " [\n", + " 284.6484680175781,\n", + " 266.7134704589844,\n", + " 0.9353039860725403\n", + " ],\n", + " [\n", + " 285.0174560546875,\n", + " 238.79736328125,\n", + " 0.4836384952068329\n", + " ],\n", + " [\n", + " 154.6018829345703,\n", + " 315.4358215332031,\n", + " 0.2590964734554291\n", + " ],\n", + " [\n", + " 336.0355529785156,\n", + " 466.2019348144531,\n", + " 0.37860575318336487\n", + " ],\n", + " [\n", + " 181.9645233154297,\n", + " 308.6495361328125,\n", + " 0.3605335056781769\n", + " ],\n", + " [\n", + " 309.65972900390625,\n", + " 273.17425537109375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 362.54388427734375,\n", + " 277.6341857910156,\n", + " 0.5562496781349182\n", + " ],\n", + " [\n", + " 361.87298583984375,\n", + " 282.2453308105469,\n", + " 0.477169394493103\n", + " ],\n", + " [\n", + " 358.46246337890625,\n", + " 282.86480712890625,\n", + " 0.2796511948108673\n", + " ],\n", + " [\n", + " 189.29440307617188,\n", + " 305.3114929199219,\n", + " 0.41088035702705383\n", + " ],\n", + " [\n", + " 355.5824890136719,\n", + " 341.4902648925781,\n", + " 0.3962235450744629\n", + " ],\n", + " [\n", + " 363.149658203125,\n", + " 340.7292785644531,\n", + " 0.48571205139160156\n", + " ],\n", + " [\n", + " 332.39642333984375,\n", + " 340.5425720214844,\n", + " 0.32239603996276855\n", + " ],\n", + " [\n", + " 331.2975769042969,\n", + " 355.4726257324219,\n", + " 0.4472481310367584\n", + " ],\n", + " [\n", + " 364.4246520996094,\n", + " 346.6827087402344,\n", + " 0.4476216733455658\n", + " ],\n", + " [\n", + " 269.8668212890625,\n", + " 359.0860900878906,\n", + " 0.43882080912590027\n", + " ],\n", + " [\n", + " 379.4068298339844,\n", + " 356.64239501953125,\n", + " 0.4417465329170227\n", + " ],\n", + " [\n", + " 266.974853515625,\n", + " 360.31402587890625,\n", + " 0.33691221475601196\n", + " ],\n", + " [\n", + " 596.21826171875,\n", + " 377.820068359375,\n", + " 0.5398204922676086\n", + " ],\n", + " [\n", + " 183.42034912109375,\n", + " 208.78756713867188,\n", + " 0.34466955065727234\n", + " ],\n", + " [\n", + " 564.060791015625,\n", + " 388.1324157714844,\n", + " 0.4286687970161438\n", + " ],\n", + " [\n", + " 517.4417724609375,\n", + " 430.2832336425781,\n", + " 0.5827439427375793\n", + " ],\n", + " [\n", + " 169.510009765625,\n", + " 212.77224731445312,\n", + " 0.23520678281784058\n", + " ],\n", + " [\n", + " 509.5054016113281,\n", + " 390.251708984375,\n", + " 0.2855446934700012\n", + " ],\n", + " [\n", + " 511.0235290527344,\n", + " 447.9842224121094,\n", + " 0.5595549941062927\n", + " ],\n", + " [\n", + " 591.0059204101562,\n", + " 408.37109375,\n", + " 0.5015893578529358\n", + " ],\n", + " [\n", + " 475.4132385253906,\n", + " 422.1249084472656,\n", + " 0.19282308220863342\n", + " ],\n", + " [\n", + " 110.6484146118164,\n", + " 276.17138671875,\n", + " 0.3112924098968506\n", + " ],\n", + " [\n", + " 572.998046875,\n", + " 393.3849182128906,\n", + " 0.4534406363964081\n", + " ],\n", + " [\n", + " 481.1611022949219,\n", + " 432.43438720703125,\n", + " 0.2341541200876236\n", + " ],\n", + " [\n", + " 551.3787231445312,\n", + " 466.6352844238281,\n", + " 0.5391212105751038\n", + " ],\n", + " [\n", + " 467.7240905761719,\n", + " 412.1317138671875,\n", + " 0.3793080151081085\n", + " ],\n", + " [\n", + " 465.2247619628906,\n", + " 409.2422180175781,\n", + " 0.33666175603866577\n", + " ],\n", + " [\n", + " 460.2418212890625,\n", + " 403.50830078125,\n", + " 0.42937344312667847\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.7889726,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.821648\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.851803,\n", + " \"step\": 15,\n", + " \"pose\": [\n", + " [\n", + " 288.8399963378906,\n", + " 298.389892578125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.063232421875,\n", + " 307.6488952636719,\n", + " 0.9409026503562927\n", + " ],\n", + " [\n", + " 296.73828125,\n", + " 314.7473449707031,\n", + " 0.6393375992774963\n", + " ],\n", + " [\n", + " 290.7121887207031,\n", + " 310.599609375,\n", + " 0.6521811485290527\n", + " ],\n", + " [\n", + " 304.9431457519531,\n", + " 311.9217224121094,\n", + " 0.7004058957099915\n", + " ],\n", + " [\n", + " 284.689697265625,\n", + " 267.53924560546875,\n", + " 0.9271823167800903\n", + " ],\n", + " [\n", + " 182.9373779296875,\n", + " 313.93133544921875,\n", + " 0.5212528109550476\n", + " ],\n", + " [\n", + " 153.7313690185547,\n", + " 313.92779541015625,\n", + " 0.3010638356208801\n", + " ],\n", + " [\n", + " 181.32296752929688,\n", + " 312.8819580078125,\n", + " 0.5419734120368958\n", + " ],\n", + " [\n", + " 183.34437561035156,\n", + " 306.7300720214844,\n", + " 0.37734419107437134\n", + " ],\n", + " [\n", + " 309.9617004394531,\n", + " 273.3157653808594,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.39080810546875,\n", + " 277.68914794921875,\n", + " 0.7047985792160034\n", + " ],\n", + " [\n", + " 362.3508605957031,\n", + " 282.02313232421875,\n", + " 0.4799898564815521\n", + " ],\n", + " [\n", + " 179.46484375,\n", + " 315.53436279296875,\n", + " 0.3219224810600281\n", + " ],\n", + " [\n", + " 193.1734161376953,\n", + " 304.5758056640625,\n", + " 0.4651084244251251\n", + " ],\n", + " [\n", + " 355.8511047363281,\n", + " 340.7768859863281,\n", + " 0.42659905552864075\n", + " ],\n", + " [\n", + " 364.3086242675781,\n", + " 341.3064270019531,\n", + " 0.5809048414230347\n", + " ],\n", + " [\n", + " 310.90179443359375,\n", + " 347.79620361328125,\n", + " 0.37011390924453735\n", + " ],\n", + " [\n", + " 329.7757263183594,\n", + " 356.66290283203125,\n", + " 0.5357598066329956\n", + " ],\n", + " [\n", + " 369.3732604980469,\n", + " 347.80084228515625,\n", + " 0.5102704167366028\n", + " ],\n", + " [\n", + " 269.4798583984375,\n", + " 358.50128173828125,\n", + " 0.36626455187797546\n", + " ],\n", + " [\n", + " 379.74285888671875,\n", + " 356.38800048828125,\n", + " 0.4842686653137207\n", + " ],\n", + " [\n", + " 265.9881286621094,\n", + " 360.00592041015625,\n", + " 0.27803361415863037\n", + " ],\n", + " [\n", + " 596.4786376953125,\n", + " 379.2182922363281,\n", + " 0.5961218476295471\n", + " ],\n", + " [\n", + " 254.6011505126953,\n", + " 382.6790466308594,\n", + " 0.4003625512123108\n", + " ],\n", + " [\n", + " 563.052001953125,\n", + " 386.73590087890625,\n", + " 0.36300426721572876\n", + " ],\n", + " [\n", + " 517.0165405273438,\n", + " 430.59613037109375,\n", + " 0.5643782019615173\n", + " ],\n", + " [\n", + " 164.36508178710938,\n", + " 213.65438842773438,\n", + " 0.2149515450000763\n", + " ],\n", + " [\n", + " 509.01336669921875,\n", + " 392.5158996582031,\n", + " 0.23822596669197083\n", + " ],\n", + " [\n", + " 510.94085693359375,\n", + " 449.33477783203125,\n", + " 0.6286779642105103\n", + " ],\n", + " [\n", + " 590.7813110351562,\n", + " 409.1065368652344,\n", + " 0.4439380168914795\n", + " ],\n", + " [\n", + " 463.3310546875,\n", + " 401.2489318847656,\n", + " 0.1755678504705429\n", + " ],\n", + " [\n", + " 109.76718139648438,\n", + " 271.8696594238281,\n", + " 0.2973553240299225\n", + " ],\n", + " [\n", + " 572.5289306640625,\n", + " 393.1651916503906,\n", + " 0.5296833515167236\n", + " ],\n", + " [\n", + " 480.9592590332031,\n", + " 432.1273193359375,\n", + " 0.21754036843776703\n", + " ],\n", + " [\n", + " 550.6238403320312,\n", + " 466.08721923828125,\n", + " 0.5377715229988098\n", + " ],\n", + " [\n", + " 468.291015625,\n", + " 413.2549133300781,\n", + " 0.3708413243293762\n", + " ],\n", + " [\n", + " 136.44790649414062,\n", + " 171.77438354492188,\n", + " 0.3511451184749603\n", + " ],\n", + " [\n", + " 460.23358154296875,\n", + " 404.2395935058594,\n", + " 0.47236377000808716\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.8206406,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.8528142\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.8770585,\n", + " \"step\": 16,\n", + " \"pose\": [\n", + " [\n", + " 289.1402282714844,\n", + " 299.0330810546875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.5198974609375,\n", + " 308.2564392089844,\n", + " 0.9859002232551575\n", + " ],\n", + " [\n", + " 297.6006164550781,\n", + " 314.6565856933594,\n", + " 0.6514089107513428\n", + " ],\n", + " [\n", + " 290.924072265625,\n", + " 311.3872985839844,\n", + " 0.6389972567558289\n", + " ],\n", + " [\n", + " 304.9306945800781,\n", + " 313.3708801269531,\n", + " 0.7217590808868408\n", + " ],\n", + " [\n", + " 284.5622863769531,\n", + " 267.4127502441406,\n", + " 0.9266435503959656\n", + " ],\n", + " [\n", + " 184.82669067382812,\n", + " 312.5155334472656,\n", + " 0.5616798400878906\n", + " ],\n", + " [\n", + " 154.43678283691406,\n", + " 314.49652099609375,\n", + " 0.24991558492183685\n", + " ],\n", + " [\n", + " 182.59487915039062,\n", + " 312.3272705078125,\n", + " 0.592433512210846\n", + " ],\n", + " [\n", + " 182.8374786376953,\n", + " 306.06494140625,\n", + " 0.419148325920105\n", + " ],\n", + " [\n", + " 310.11920166015625,\n", + " 273.36737060546875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.7231140136719,\n", + " 277.9724426269531,\n", + " 0.5769408345222473\n", + " ],\n", + " [\n", + " 362.3317565917969,\n", + " 282.26922607421875,\n", + " 0.43514740467071533\n", + " ],\n", + " [\n", + " 184.4752655029297,\n", + " 311.1407470703125,\n", + " 0.38407573103904724\n", + " ],\n", + " [\n", + " 187.931640625,\n", + " 302.62933349609375,\n", + " 0.4633309543132782\n", + " ],\n", + " [\n", + " 356.0732421875,\n", + " 340.64599609375,\n", + " 0.4025139808654785\n", + " ],\n", + " [\n", + " 362.9293212890625,\n", + " 341.8232727050781,\n", + " 0.5162671208381653\n", + " ],\n", + " [\n", + " 309.4438781738281,\n", + " 345.9781799316406,\n", + " 0.33583828806877136\n", + " ],\n", + " [\n", + " 331.2337341308594,\n", + " 355.5598449707031,\n", + " 0.4219924509525299\n", + " ],\n", + " [\n", + " 366.71087646484375,\n", + " 345.36962890625,\n", + " 0.4366249144077301\n", + " ],\n", + " [\n", + " 269.210693359375,\n", + " 358.0929260253906,\n", + " 0.3690962493419647\n", + " ],\n", + " [\n", + " 424.5083923339844,\n", + " 376.5726013183594,\n", + " 0.42966294288635254\n", + " ],\n", + " [\n", + " 303.0454406738281,\n", + " 218.3123779296875,\n", + " 0.31208333373069763\n", + " ],\n", + " [\n", + " 598.5891723632812,\n", + " 378.4424133300781,\n", + " 0.5889440178871155\n", + " ],\n", + " [\n", + " 197.69949340820312,\n", + " 217.56845092773438,\n", + " 0.3414533734321594\n", + " ],\n", + " [\n", + " 563.177734375,\n", + " 386.8681945800781,\n", + " 0.39073020219802856\n", + " ],\n", + " [\n", + " 516.6555786132812,\n", + " 428.7958679199219,\n", + " 0.5345339179039001\n", + " ],\n", + " [\n", + " 442.6798095703125,\n", + " 357.461181640625,\n", + " 0.23529931902885437\n", + " ],\n", + " [\n", + " 507.02862548828125,\n", + " 392.94842529296875,\n", + " 0.26026907563209534\n", + " ],\n", + " [\n", + " 511.629150390625,\n", + " 447.88116455078125,\n", + " 0.5966296792030334\n", + " ],\n", + " [\n", + " 592.001708984375,\n", + " 408.8056945800781,\n", + " 0.5580258965492249\n", + " ],\n", + " [\n", + " 463.76220703125,\n", + " 402.1715087890625,\n", + " 0.16743294894695282\n", + " ],\n", + " [\n", + " 111.18097686767578,\n", + " 276.2333679199219,\n", + " 0.2950418293476105\n", + " ],\n", + " [\n", + " 573.3688354492188,\n", + " 393.168701171875,\n", + " 0.4543166756629944\n", + " ],\n", + " [\n", + " 481.0332946777344,\n", + " 433.19580078125,\n", + " 0.2525552809238434\n", + " ],\n", + " [\n", + " 551.67578125,\n", + " 466.51300048828125,\n", + " 0.48711535334587097\n", + " ],\n", + " [\n", + " 468.5693054199219,\n", + " 413.4539489746094,\n", + " 0.38309159874916077\n", + " ],\n", + " [\n", + " 135.75698852539062,\n", + " 170.189697265625,\n", + " 0.30588316917419434\n", + " ],\n", + " [\n", + " 459.2412109375,\n", + " 403.5185852050781,\n", + " 0.3879672586917877\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.8528142,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.8770585\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.9255464,\n", + " \"step\": 17,\n", + " \"pose\": [\n", + " [\n", + " 289.01116943359375,\n", + " 299.0413818359375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.1086730957031,\n", + " 308.51885986328125,\n", + " 0.95180344581604\n", + " ],\n", + " [\n", + " 296.3152770996094,\n", + " 316.4967956542969,\n", + " 0.5928277373313904\n", + " ],\n", + " [\n", + " 290.5437927246094,\n", + " 311.6708679199219,\n", + " 0.6084922552108765\n", + " ],\n", + " [\n", + " 306.17803955078125,\n", + " 313.6896057128906,\n", + " 0.7187818288803101\n", + " ],\n", + " [\n", + " 284.83319091796875,\n", + " 267.0993957519531,\n", + " 0.9082852602005005\n", + " ],\n", + " [\n", + " 285.2369384765625,\n", + " 239.99192810058594,\n", + " 0.4807704985141754\n", + " ],\n", + " [\n", + " 281.5351257324219,\n", + " 244.12281799316406,\n", + " 0.25091421604156494\n", + " ],\n", + " [\n", + " 183.10739135742188,\n", + " 313.957275390625,\n", + " 0.47192785143852234\n", + " ],\n", + " [\n", + " 182.70115661621094,\n", + " 306.3948974609375,\n", + " 0.3859936594963074\n", + " ],\n", + " [\n", + " 310.44354248046875,\n", + " 273.25341796875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 362.1278076171875,\n", + " 276.0966796875,\n", + " 0.66349196434021\n", + " ],\n", + " [\n", + " 363.0843200683594,\n", + " 282.9954528808594,\n", + " 0.44170665740966797\n", + " ],\n", + " [\n", + " 182.47335815429688,\n", + " 311.0877685546875,\n", + " 0.2967713475227356\n", + " ],\n", + " [\n", + " 188.00137329101562,\n", + " 304.48931884765625,\n", + " 0.45905861258506775\n", + " ],\n", + " [\n", + " 354.645263671875,\n", + " 339.31219482421875,\n", + " 0.3976733684539795\n", + " ],\n", + " [\n", + " 363.1065979003906,\n", + " 339.9534606933594,\n", + " 0.5860384702682495\n", + " ],\n", + " [\n", + " 316.1658935546875,\n", + " 342.28369140625,\n", + " 0.3572511076927185\n", + " ],\n", + " [\n", + " 331.590087890625,\n", + " 355.1735534667969,\n", + " 0.4502539336681366\n", + " ],\n", + " [\n", + " 363.5017395019531,\n", + " 344.60235595703125,\n", + " 0.5015437006950378\n", + " ],\n", + " [\n", + " 268.5982666015625,\n", + " 358.1197204589844,\n", + " 0.36430323123931885\n", + " ],\n", + " [\n", + " 378.5502014160156,\n", + " 355.8092041015625,\n", + " 0.42007115483283997\n", + " ],\n", + " [\n", + " 303.5655517578125,\n", + " 217.58612060546875,\n", + " 0.33906295895576477\n", + " ],\n", + " [\n", + " 596.765380859375,\n", + " 379.427001953125,\n", + " 0.5411680340766907\n", + " ],\n", + " [\n", + " 199.35903930664062,\n", + " 222.4739990234375,\n", + " 0.29007023572921753\n", + " ],\n", + " [\n", + " 562.4085083007812,\n", + " 386.65509033203125,\n", + " 0.3170825242996216\n", + " ],\n", + " [\n", + " 517.340087890625,\n", + " 430.5169677734375,\n", + " 0.6213215589523315\n", + " ],\n", + " [\n", + " 441.74444580078125,\n", + " 357.04644775390625,\n", + " 0.2545863389968872\n", + " ],\n", + " [\n", + " 503.5122375488281,\n", + " 393.9283447265625,\n", + " 0.30190107226371765\n", + " ],\n", + " [\n", + " 510.5965576171875,\n", + " 450.106201171875,\n", + " 0.5517356991767883\n", + " ],\n", + " [\n", + " 591.1541748046875,\n", + " 408.65826416015625,\n", + " 0.49750760197639465\n", + " ],\n", + " [\n", + " 475.8544921875,\n", + " 422.05078125,\n", + " 0.1733374446630478\n", + " ],\n", + " [\n", + " 109.9720687866211,\n", + " 271.4387512207031,\n", + " 0.2799350619316101\n", + " ],\n", + " [\n", + " 572.795166015625,\n", + " 393.3072204589844,\n", + " 0.3979363441467285\n", + " ],\n", + " [\n", + " 479.5787353515625,\n", + " 432.65380859375,\n", + " 0.22706195712089539\n", + " ],\n", + " [\n", + " 550.501708984375,\n", + " 467.0165100097656,\n", + " 0.4965691566467285\n", + " ],\n", + " [\n", + " 468.164306640625,\n", + " 412.325927734375,\n", + " 0.3681727349758148\n", + " ],\n", + " [\n", + " 461.05029296875,\n", + " 400.1574401855469,\n", + " 0.36365073919296265\n", + " ],\n", + " [\n", + " 460.8140563964844,\n", + " 402.9066467285156,\n", + " 0.4981914758682251\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.8993094,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.9255464\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.9609218,\n", + " \"step\": 18,\n", + " \"pose\": [\n", + " [\n", + " 288.9327392578125,\n", + " 299.13629150390625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 292.8038330078125,\n", + " 308.6182861328125,\n", + " 0.965558648109436\n", + " ],\n", + " [\n", + " 297.1647644042969,\n", + " 315.5074157714844,\n", + " 0.6364358067512512\n", + " ],\n", + " [\n", + " 290.55609130859375,\n", + " 311.7894287109375,\n", + " 0.6080928444862366\n", + " ],\n", + " [\n", + " 305.4518127441406,\n", + " 313.89239501953125,\n", + " 0.7770905494689941\n", + " ],\n", + " [\n", + " 284.95733642578125,\n", + " 267.11175537109375,\n", + " 0.9243097305297852\n", + " ],\n", + " [\n", + " 285.8481750488281,\n", + " 238.2171630859375,\n", + " 0.49581968784332275\n", + " ],\n", + " [\n", + " 281.67034912109375,\n", + " 238.3629608154297,\n", + " 0.2495463341474533\n", + " ],\n", + " [\n", + " 181.5650177001953,\n", + " 314.2547912597656,\n", + " 0.5168197751045227\n", + " ],\n", + " [\n", + " 182.05751037597656,\n", + " 306.9068908691406,\n", + " 0.36109668016433716\n", + " ],\n", + " [\n", + " 310.2335205078125,\n", + " 273.6082763671875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 362.219482421875,\n", + " 276.6759948730469,\n", + " 0.618270754814148\n", + " ],\n", + " [\n", + " 362.4667663574219,\n", + " 280.8567199707031,\n", + " 0.40474116802215576\n", + " ],\n", + " [\n", + " 184.80101013183594,\n", + " 316.924560546875,\n", + " 0.3415667712688446\n", + " ],\n", + " [\n", + " 189.2113800048828,\n", + " 305.0552062988281,\n", + " 0.44207993149757385\n", + " ],\n", + " [\n", + " 354.8471984863281,\n", + " 341.1282043457031,\n", + " 0.4782800078392029\n", + " ],\n", + " [\n", + " 363.4027404785156,\n", + " 341.2732238769531,\n", + " 0.518268346786499\n", + " ],\n", + " [\n", + " 316.40673828125,\n", + " 342.68695068359375,\n", + " 0.3713846504688263\n", + " ],\n", + " [\n", + " 330.7171630859375,\n", + " 355.67803955078125,\n", + " 0.46434029936790466\n", + " ],\n", + " [\n", + " 367.51568603515625,\n", + " 344.2020568847656,\n", + " 0.4221855401992798\n", + " ],\n", + " [\n", + " 269.15869140625,\n", + " 357.6059265136719,\n", + " 0.43143007159233093\n", + " ],\n", + " [\n", + " 377.92431640625,\n", + " 355.83026123046875,\n", + " 0.42217832803726196\n", + " ],\n", + " [\n", + " 266.35400390625,\n", + " 359.1109313964844,\n", + " 0.36265674233436584\n", + " ],\n", + " [\n", + " 598.98876953125,\n", + " 378.3539123535156,\n", + " 0.5779925584793091\n", + " ],\n", + " [\n", + " 521.8517456054688,\n", + " 317.60223388671875,\n", + " 0.3474227786064148\n", + " ],\n", + " [\n", + " 563.2191162109375,\n", + " 386.7420654296875,\n", + " 0.3701111972332001\n", + " ],\n", + " [\n", + " 516.5422973632812,\n", + " 430.6274108886719,\n", + " 0.5754683613777161\n", + " ],\n", + " [\n", + " 566.955078125,\n", + " 197.94085693359375,\n", + " 0.35363760590553284\n", + " ],\n", + " [\n", + " 573.0870971679688,\n", + " 198.2352294921875,\n", + " 0.5906892418861389\n", + " ],\n", + " [\n", + " 511.6949157714844,\n", + " 449.3252258300781,\n", + " 0.6369988322257996\n", + " ],\n", + " [\n", + " 591.0794067382812,\n", + " 408.18438720703125,\n", + " 0.5102192759513855\n", + " ],\n", + " [\n", + " 508.6004638671875,\n", + " 381.352783203125,\n", + " 0.2138703316450119\n", + " ],\n", + " [\n", + " 109.30960083007812,\n", + " 272.1946716308594,\n", + " 0.336846262216568\n", + " ],\n", + " [\n", + " 572.595703125,\n", + " 393.81610107421875,\n", + " 0.48220178484916687\n", + " ],\n", + " [\n", + " 482.5830383300781,\n", + " 431.64202880859375,\n", + " 0.18888558447360992\n", + " ],\n", + " [\n", + " 549.8436279296875,\n", + " 465.8060302734375,\n", + " 0.6060653924942017\n", + " ],\n", + " [\n", + " 470.1370544433594,\n", + " 413.1282653808594,\n", + " 0.4226745069026947\n", + " ],\n", + " [\n", + " 134.86871337890625,\n", + " 172.37551879882812,\n", + " 0.34861159324645996\n", + " ],\n", + " [\n", + " 460.8798522949219,\n", + " 403.1666564941406,\n", + " 0.4525850713253021\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.933552,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.9609218\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588105.996339,\n", + " \"step\": 19,\n", + " \"pose\": [\n", + " [\n", + " 289.3584289550781,\n", + " 299.6400451660156,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.6114807128906,\n", + " 308.7494201660156,\n", + " 0.9362009763717651\n", + " ],\n", + " [\n", + " 296.5362548828125,\n", + " 315.8563537597656,\n", + " 0.6216031908988953\n", + " ],\n", + " [\n", + " 290.3965759277344,\n", + " 311.8982849121094,\n", + " 0.5908151865005493\n", + " ],\n", + " [\n", + " 305.3473205566406,\n", + " 314.0240783691406,\n", + " 0.6830025911331177\n", + " ],\n", + " [\n", + " 284.38623046875,\n", + " 267.3375244140625,\n", + " 0.938642144203186\n", + " ],\n", + " [\n", + " 183.94895935058594,\n", + " 312.9803771972656,\n", + " 0.5159981846809387\n", + " ],\n", + " [\n", + " 153.8936767578125,\n", + " 314.8314514160156,\n", + " 0.26622244715690613\n", + " ],\n", + " [\n", + " 181.40602111816406,\n", + " 314.3370056152344,\n", + " 0.5327624082565308\n", + " ],\n", + " [\n", + " 182.94410705566406,\n", + " 307.2097473144531,\n", + " 0.4217340648174286\n", + " ],\n", + " [\n", + " 310.5429992675781,\n", + " 273.63134765625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.03759765625,\n", + " 275.5606994628906,\n", + " 0.6987367868423462\n", + " ],\n", + " [\n", + " 363.2841491699219,\n", + " 282.46881103515625,\n", + " 0.45098239183425903\n", + " ],\n", + " [\n", + " 183.90602111816406,\n", + " 316.1698913574219,\n", + " 0.39084556698799133\n", + " ],\n", + " [\n", + " 193.3860321044922,\n", + " 305.0615234375,\n", + " 0.507167398929596\n", + " ],\n", + " [\n", + " 355.57421875,\n", + " 341.30316162109375,\n", + " 0.4452424645423889\n", + " ],\n", + " [\n", + " 363.03021240234375,\n", + " 340.71435546875,\n", + " 0.5201398730278015\n", + " ],\n", + " [\n", + " 317.2238464355469,\n", + " 345.26666259765625,\n", + " 0.34468573331832886\n", + " ],\n", + " [\n", + " 332.4403991699219,\n", + " 356.6281433105469,\n", + " 0.46254584193229675\n", + " ],\n", + " [\n", + " 363.12640380859375,\n", + " 345.547119140625,\n", + " 0.42061465978622437\n", + " ],\n", + " [\n", + " 269.6493225097656,\n", + " 357.64892578125,\n", + " 0.4019133448600769\n", + " ],\n", + " [\n", + " 378.178466796875,\n", + " 356.5284729003906,\n", + " 0.4181462824344635\n", + " ],\n", + " [\n", + " 266.38238525390625,\n", + " 358.6065368652344,\n", + " 0.33007708191871643\n", + " ],\n", + " [\n", + " 599.5531005859375,\n", + " 377.76666259765625,\n", + " 0.629446804523468\n", + " ],\n", + " [\n", + " 184.41952514648438,\n", + " 208.114013671875,\n", + " 0.4009157419204712\n", + " ],\n", + " [\n", + " 519.3410034179688,\n", + " 390.22406005859375,\n", + " 0.3750861883163452\n", + " ],\n", + " [\n", + " 516.8319702148438,\n", + " 428.9804382324219,\n", + " 0.657153844833374\n", + " ],\n", + " [\n", + " 567.7550659179688,\n", + " 193.29896545410156,\n", + " 0.34581509232521057\n", + " ],\n", + " [\n", + " 509.476806640625,\n", + " 386.754150390625,\n", + " 0.3405891954898834\n", + " ],\n", + " [\n", + " 516.4321899414062,\n", + " 443.82684326171875,\n", + " 0.5484656095504761\n", + " ],\n", + " [\n", + " 594.040283203125,\n", + " 409.7172546386719,\n", + " 0.5296698212623596\n", + " ],\n", + " [\n", + " 508.0539855957031,\n", + " 380.4469299316406,\n", + " 0.20867124199867249\n", + " ],\n", + " [\n", + " 115.30363464355469,\n", + " 228.87652587890625,\n", + " 0.15687808394432068\n", + " ],\n", + " [\n", + " 573.0440063476562,\n", + " 393.09100341796875,\n", + " 0.39379948377609253\n", + " ],\n", + " [\n", + " 480.1328125,\n", + " 431.7356262207031,\n", + " 0.2691611051559448\n", + " ],\n", + " [\n", + " 550.718505859375,\n", + " 466.6239318847656,\n", + " 0.5681474804878235\n", + " ],\n", + " [\n", + " 469.20660400390625,\n", + " 413.4725036621094,\n", + " 0.39115187525749207\n", + " ],\n", + " [\n", + " 133.34088134765625,\n", + " 172.20274353027344,\n", + " 0.3368547558784485\n", + " ],\n", + " [\n", + " 459.3551025390625,\n", + " 403.5044250488281,\n", + " 0.45792460441589355\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.9642372,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588105.996339\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.0221846,\n", + " \"step\": 20,\n", + " \"pose\": [\n", + " [\n", + " 288.93731689453125,\n", + " 298.9955749511719,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.2713928222656,\n", + " 308.37408447265625,\n", + " 0.943423330783844\n", + " ],\n", + " [\n", + " 296.5384826660156,\n", + " 315.9530944824219,\n", + " 0.603498101234436\n", + " ],\n", + " [\n", + " 290.7826843261719,\n", + " 311.4992980957031,\n", + " 0.6115655899047852\n", + " ],\n", + " [\n", + " 304.9278259277344,\n", + " 313.83294677734375,\n", + " 0.7098742127418518\n", + " ],\n", + " [\n", + " 284.4320373535156,\n", + " 267.431884765625,\n", + " 0.9166838526725769\n", + " ],\n", + " [\n", + " 287.8583984375,\n", + " 231.46864318847656,\n", + " 0.4841695725917816\n", + " ],\n", + " [\n", + " 294.63043212890625,\n", + " 218.80035400390625,\n", + " 0.29108670353889465\n", + " ],\n", + " [\n", + " 181.39418029785156,\n", + " 314.2521057128906,\n", + " 0.5189660787582397\n", + " ],\n", + " [\n", + " 304.42047119140625,\n", + " 209.36367797851562,\n", + " 0.40350794792175293\n", + " ],\n", + " [\n", + " 310.4044494628906,\n", + " 273.7499694824219,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.916259765625,\n", + " 275.2131042480469,\n", + " 0.6535776853561401\n", + " ],\n", + " [\n", + " 364.2809753417969,\n", + " 282.39776611328125,\n", + " 0.40148383378982544\n", + " ],\n", + " [\n", + " 180.97071838378906,\n", + " 317.89715576171875,\n", + " 0.3581520915031433\n", + " ],\n", + " [\n", + " 187.54930114746094,\n", + " 302.6390686035156,\n", + " 0.4738801419734955\n", + " ],\n", + " [\n", + " 354.7167663574219,\n", + " 341.0120849609375,\n", + " 0.38925886154174805\n", + " ],\n", + " [\n", + " 362.65179443359375,\n", + " 341.0782775878906,\n", + " 0.5246103405952454\n", + " ],\n", + " [\n", + " 333.5454406738281,\n", + " 342.33709716796875,\n", + " 0.3726454973220825\n", + " ],\n", + " [\n", + " 331.2152099609375,\n", + " 355.3799133300781,\n", + " 0.5273229479789734\n", + " ],\n", + " [\n", + " 292.8047790527344,\n", + " 357.6714172363281,\n", + " 0.4529617428779602\n", + " ],\n", + " [\n", + " 269.3791198730469,\n", + " 357.7329406738281,\n", + " 0.33708542585372925\n", + " ],\n", + " [\n", + " 413.47406005859375,\n", + " 371.240478515625,\n", + " 0.38202741742134094\n", + " ],\n", + " [\n", + " 266.7149658203125,\n", + " 358.42596435546875,\n", + " 0.255717396736145\n", + " ],\n", + " [\n", + " 599.5459594726562,\n", + " 377.59088134765625,\n", + " 0.6171793937683105\n", + " ],\n", + " [\n", + " 182.58990478515625,\n", + " 208.6776580810547,\n", + " 0.3807677626609802\n", + " ],\n", + " [\n", + " 563.1406860351562,\n", + " 387.25885009765625,\n", + " 0.4175715744495392\n", + " ],\n", + " [\n", + " 517.3438110351562,\n", + " 431.8337097167969,\n", + " 0.5834364295005798\n", + " ],\n", + " [\n", + " 571.3803100585938,\n", + " 186.68905639648438,\n", + " 0.3315950930118561\n", + " ],\n", + " [\n", + " 572.1192016601562,\n", + " 190.96864318847656,\n", + " 0.44181355834007263\n", + " ],\n", + " [\n", + " 513.9149169921875,\n", + " 444.82080078125,\n", + " 0.5983200073242188\n", + " ],\n", + " [\n", + " 590.0543212890625,\n", + " 409.4712219238281,\n", + " 0.48646411299705505\n", + " ],\n", + " [\n", + " 474.74908447265625,\n", + " 422.23394775390625,\n", + " 0.2016405165195465\n", + " ],\n", + " [\n", + " 108.44601440429688,\n", + " 270.20269775390625,\n", + " 0.18030454218387604\n", + " ],\n", + " [\n", + " 572.8609008789062,\n", + " 393.09124755859375,\n", + " 0.4335196018218994\n", + " ],\n", + " [\n", + " 479.59942626953125,\n", + " 431.8648376464844,\n", + " 0.26634445786476135\n", + " ],\n", + " [\n", + " 551.6661376953125,\n", + " 466.70135498046875,\n", + " 0.5170703530311584\n", + " ],\n", + " [\n", + " 468.4554443359375,\n", + " 413.3636779785156,\n", + " 0.391674280166626\n", + " ],\n", + " [\n", + " 135.02671813964844,\n", + " 171.04122924804688,\n", + " 0.3395681083202362\n", + " ],\n", + " [\n", + " 460.0732727050781,\n", + " 402.4387512207031,\n", + " 0.43441158533096313\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588105.9977791,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.0241945\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.0568671,\n", + " \"step\": 21,\n", + " \"pose\": [\n", + " [\n", + " 289.0395202636719,\n", + " 299.1026611328125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 294.0700378417969,\n", + " 308.541015625,\n", + " 0.9578058123588562\n", + " ],\n", + " [\n", + " 296.70440673828125,\n", + " 315.8837890625,\n", + " 0.6122352480888367\n", + " ],\n", + " [\n", + " 291.8658752441406,\n", + " 311.9002685546875,\n", + " 0.5979498624801636\n", + " ],\n", + " [\n", + " 306.3223876953125,\n", + " 312.81304931640625,\n", + " 0.7840145826339722\n", + " ],\n", + " [\n", + " 284.8127136230469,\n", + " 267.5986328125,\n", + " 0.9234873652458191\n", + " ],\n", + " [\n", + " 295.66680908203125,\n", + " 222.97425842285156,\n", + " 0.5263190865516663\n", + " ],\n", + " [\n", + " 296.760009765625,\n", + " 216.06723022460938,\n", + " 0.33805859088897705\n", + " ],\n", + " [\n", + " 182.27871704101562,\n", + " 313.2104187011719,\n", + " 0.4452279508113861\n", + " ],\n", + " [\n", + " 302.8914794921875,\n", + " 207.4759521484375,\n", + " 0.4147292673587799\n", + " ],\n", + " [\n", + " 310.67901611328125,\n", + " 273.2028503417969,\n", + " 1.0\n", + " ],\n", + " [\n", + " 364.8524169921875,\n", + " 274.2853088378906,\n", + " 0.6022342443466187\n", + " ],\n", + " [\n", + " 363.9833679199219,\n", + " 282.55401611328125,\n", + " 0.4224339723587036\n", + " ],\n", + " [\n", + " 181.81272888183594,\n", + " 307.6803283691406,\n", + " 0.26984354853630066\n", + " ],\n", + " [\n", + " 194.29331970214844,\n", + " 306.2869873046875,\n", + " 0.40715906023979187\n", + " ],\n", + " [\n", + " 351.9360046386719,\n", + " 343.8916015625,\n", + " 0.3862766921520233\n", + " ],\n", + " [\n", + " 363.7510986328125,\n", + " 342.71826171875,\n", + " 0.5607332587242126\n", + " ],\n", + " [\n", + " 333.4976501464844,\n", + " 343.6668701171875,\n", + " 0.35671743750572205\n", + " ],\n", + " [\n", + " 332.6026916503906,\n", + " 355.7337341308594,\n", + " 0.49907931685447693\n", + " ],\n", + " [\n", + " 281.2688903808594,\n", + " 357.4167175292969,\n", + " 0.5524068474769592\n", + " ],\n", + " [\n", + " 270.1759338378906,\n", + " 357.4426574707031,\n", + " 0.47422295808792114\n", + " ],\n", + " [\n", + " 379.6234436035156,\n", + " 356.0726318359375,\n", + " 0.4222455620765686\n", + " ],\n", + " [\n", + " 266.31121826171875,\n", + " 358.93865966796875,\n", + " 0.4061802625656128\n", + " ],\n", + " [\n", + " 598.6159057617188,\n", + " 377.72808837890625,\n", + " 0.5579972863197327\n", + " ],\n", + " [\n", + " 522.2592163085938,\n", + " 318.6231994628906,\n", + " 0.3378920257091522\n", + " ],\n", + " [\n", + " 518.2576293945312,\n", + " 390.44976806640625,\n", + " 0.41228482127189636\n", + " ],\n", + " [\n", + " 515.7354736328125,\n", + " 429.0234069824219,\n", + " 0.5634108185768127\n", + " ],\n", + " [\n", + " 443.417724609375,\n", + " 356.7914733886719,\n", + " 0.26663434505462646\n", + " ],\n", + " [\n", + " 507.91925048828125,\n", + " 388.6474304199219,\n", + " 0.35248884558677673\n", + " ],\n", + " [\n", + " 512.753173828125,\n", + " 449.7189636230469,\n", + " 0.578186571598053\n", + " ],\n", + " [\n", + " 581.30078125,\n", + " 414.9436340332031,\n", + " 0.4276328682899475\n", + " ],\n", + " [\n", + " 463.5183410644531,\n", + " 402.17388916015625,\n", + " 0.13629432022571564\n", + " ],\n", + " [\n", + " 111.8991470336914,\n", + " 278.3125915527344,\n", + " 0.151170015335083\n", + " ],\n", + " [\n", + " 575.8933715820312,\n", + " 398.4580993652344,\n", + " 0.40502646565437317\n", + " ],\n", + " [\n", + " 143.69412231445312,\n", + " 345.78521728515625,\n", + " 0.24476872384548187\n", + " ],\n", + " [\n", + " 143.1280517578125,\n", + " 373.794189453125,\n", + " 0.4513229429721832\n", + " ],\n", + " [\n", + " 468.4044189453125,\n", + " 411.8726806640625,\n", + " 0.37500157952308655\n", + " ],\n", + " [\n", + " 462.43206787109375,\n", + " 399.8568115234375,\n", + " 0.3841189444065094\n", + " ],\n", + " [\n", + " 461.3127136230469,\n", + " 403.23052978515625,\n", + " 0.5030932426452637\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.0300262,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.0578823\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.0853903,\n", + " \"step\": 22,\n", + " \"pose\": [\n", + " [\n", + " 289.0032653808594,\n", + " 299.5898132324219,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.6610412597656,\n", + " 308.364501953125,\n", + " 0.9171743392944336\n", + " ],\n", + " [\n", + " 297.53411865234375,\n", + " 315.0213928222656,\n", + " 0.5967923998832703\n", + " ],\n", + " [\n", + " 291.45599365234375,\n", + " 310.9009094238281,\n", + " 0.6069502234458923\n", + " ],\n", + " [\n", + " 305.15667724609375,\n", + " 312.3479919433594,\n", + " 0.7363993525505066\n", + " ],\n", + " [\n", + " 284.5779724121094,\n", + " 267.80059814453125,\n", + " 0.8958398699760437\n", + " ],\n", + " [\n", + " 287.5237121582031,\n", + " 231.7471923828125,\n", + " 0.5256384611129761\n", + " ],\n", + " [\n", + " 297.3427734375,\n", + " 217.41281127929688,\n", + " 0.32152754068374634\n", + " ],\n", + " [\n", + " 182.88433837890625,\n", + " 314.53692626953125,\n", + " 0.489521861076355\n", + " ],\n", + " [\n", + " 303.3013916015625,\n", + " 209.451171875,\n", + " 0.42159581184387207\n", + " ],\n", + " [\n", + " 310.30352783203125,\n", + " 273.47393798828125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.04669189453125,\n", + " 275.4956970214844,\n", + " 0.6113792061805725\n", + " ],\n", + " [\n", + " 364.1795959472656,\n", + " 282.3038024902344,\n", + " 0.44920432567596436\n", + " ],\n", + " [\n", + " 185.60179138183594,\n", + " 312.93963623046875,\n", + " 0.2924720048904419\n", + " ],\n", + " [\n", + " 188.79063415527344,\n", + " 306.9405822753906,\n", + " 0.4584471881389618\n", + " ],\n", + " [\n", + " 355.7679443359375,\n", + " 340.5166320800781,\n", + " 0.4532307982444763\n", + " ],\n", + " [\n", + " 362.6515197753906,\n", + " 342.4863586425781,\n", + " 0.5490198731422424\n", + " ],\n", + " [\n", + " 334.1615905761719,\n", + " 344.5025939941406,\n", + " 0.33372119069099426\n", + " ],\n", + " [\n", + " 331.80181884765625,\n", + " 355.52691650390625,\n", + " 0.4720294773578644\n", + " ],\n", + " [\n", + " 290.5385437011719,\n", + " 358.4304504394531,\n", + " 0.4409693479537964\n", + " ],\n", + " [\n", + " 270.8581237792969,\n", + " 356.5791931152344,\n", + " 0.4198722839355469\n", + " ],\n", + " [\n", + " 424.2137145996094,\n", + " 375.2749328613281,\n", + " 0.4370681345462799\n", + " ],\n", + " [\n", + " 268.27886962890625,\n", + " 357.68585205078125,\n", + " 0.36227190494537354\n", + " ],\n", + " [\n", + " 599.3430786132812,\n", + " 377.6954650878906,\n", + " 0.5684486031532288\n", + " ],\n", + " [\n", + " 520.1165771484375,\n", + " 318.81927490234375,\n", + " 0.3776821196079254\n", + " ],\n", + " [\n", + " 562.5358276367188,\n", + " 387.04693603515625,\n", + " 0.3308621942996979\n", + " ],\n", + " [\n", + " 516.3358154296875,\n", + " 429.0692443847656,\n", + " 0.5608440637588501\n", + " ],\n", + " [\n", + " 566.9133911132812,\n", + " 193.52452087402344,\n", + " 0.33105629682540894\n", + " ],\n", + " [\n", + " 570.7525634765625,\n", + " 196.58692932128906,\n", + " 0.4755719006061554\n", + " ],\n", + " [\n", + " 512.5366821289062,\n", + " 448.55328369140625,\n", + " 0.5968109369277954\n", + " ],\n", + " [\n", + " 590.8397216796875,\n", + " 408.89471435546875,\n", + " 0.4935222268104553\n", + " ],\n", + " [\n", + " 110.8057632446289,\n", + " 271.4251403808594,\n", + " 0.16251139342784882\n", + " ],\n", + " [\n", + " 113.5406494140625,\n", + " 279.87841796875,\n", + " 0.38004982471466064\n", + " ],\n", + " [\n", + " 576.237548828125,\n", + " 397.9443664550781,\n", + " 0.3451719284057617\n", + " ],\n", + " [\n", + " 144.29734802246094,\n", + " 315.7247009277344,\n", + " 0.25869646668434143\n", + " ],\n", + " [\n", + " 549.8232421875,\n", + " 465.3925476074219,\n", + " 0.483270525932312\n", + " ],\n", + " [\n", + " 141.22862243652344,\n", + " 190.88340759277344,\n", + " 0.3440597653388977\n", + " ],\n", + " [\n", + " 462.27349853515625,\n", + " 407.5166931152344,\n", + " 0.39141225814819336\n", + " ],\n", + " [\n", + " 459.8392333984375,\n", + " 402.88421630859375,\n", + " 0.48188316822052\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.0603576,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.0853903\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.113898,\n", + " \"step\": 23,\n", + " \"pose\": [\n", + " [\n", + " 289.3703918457031,\n", + " 299.01654052734375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.63714599609375,\n", + " 308.040771484375,\n", + " 0.9116361141204834\n", + " ],\n", + " [\n", + " 297.54144287109375,\n", + " 314.6742248535156,\n", + " 0.5906339287757874\n", + " ],\n", + " [\n", + " 292.0235290527344,\n", + " 310.41552734375,\n", + " 0.6293807029724121\n", + " ],\n", + " [\n", + " 305.7689514160156,\n", + " 312.5812072753906,\n", + " 0.6732482314109802\n", + " ],\n", + " [\n", + " 284.58123779296875,\n", + " 267.2427673339844,\n", + " 0.9067515134811401\n", + " ],\n", + " [\n", + " 183.6278076171875,\n", + " 312.8146667480469,\n", + " 0.5189109444618225\n", + " ],\n", + " [\n", + " 373.2416076660156,\n", + " 300.8122863769531,\n", + " 0.3220520615577698\n", + " ],\n", + " [\n", + " 182.9404754638672,\n", + " 312.4514465332031,\n", + " 0.5909151434898376\n", + " ],\n", + " [\n", + " 303.0302429199219,\n", + " 210.0631561279297,\n", + " 0.4749321937561035\n", + " ],\n", + " [\n", + " 310.4212951660156,\n", + " 273.31060791015625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.5233459472656,\n", + " 275.4824523925781,\n", + " 0.5813355445861816\n", + " ],\n", + " [\n", + " 363.8905029296875,\n", + " 281.9700622558594,\n", + " 0.42637211084365845\n", + " ],\n", + " [\n", + " 185.95431518554688,\n", + " 311.6904296875,\n", + " 0.34200912714004517\n", + " ],\n", + " [\n", + " 192.42385864257812,\n", + " 305.3067932128906,\n", + " 0.4337136447429657\n", + " ],\n", + " [\n", + " 355.9394836425781,\n", + " 340.25689697265625,\n", + " 0.43801149725914\n", + " ],\n", + " [\n", + " 363.40325927734375,\n", + " 341.9284362792969,\n", + " 0.5555760264396667\n", + " ],\n", + " [\n", + " 314.46746826171875,\n", + " 342.5721130371094,\n", + " 0.35660916566848755\n", + " ],\n", + " [\n", + " 339.87835693359375,\n", + " 359.97998046875,\n", + " 0.4949004054069519\n", + " ],\n", + " [\n", + " 290.6855773925781,\n", + " 358.2529602050781,\n", + " 0.4353252649307251\n", + " ],\n", + " [\n", + " 270.9769287109375,\n", + " 356.5190124511719,\n", + " 0.40807199478149414\n", + " ],\n", + " [\n", + " 424.1128845214844,\n", + " 374.64202880859375,\n", + " 0.42985209822654724\n", + " ],\n", + " [\n", + " 267.4349670410156,\n", + " 357.9966125488281,\n", + " 0.3516447842121124\n", + " ],\n", + " [\n", + " 597.1614990234375,\n", + " 379.19207763671875,\n", + " 0.5612181425094604\n", + " ],\n", + " [\n", + " 200.91233825683594,\n", + " 229.2719268798828,\n", + " 0.3809609115123749\n", + " ],\n", + " [\n", + " 563.5918579101562,\n", + " 387.2767028808594,\n", + " 0.3711811602115631\n", + " ],\n", + " [\n", + " 516.4181518554688,\n", + " 428.2336120605469,\n", + " 0.6263866424560547\n", + " ],\n", + " [\n", + " 570.1748046875,\n", + " 189.33961486816406,\n", + " 0.3523023724555969\n", + " ],\n", + " [\n", + " 569.3735961914062,\n", + " 194.79763793945312,\n", + " 0.36297234892845154\n", + " ],\n", + " [\n", + " 512.5416259765625,\n", + " 448.44085693359375,\n", + " 0.5241063833236694\n", + " ],\n", + " [\n", + " 155.5992889404297,\n", + " 363.5351867675781,\n", + " 0.46883660554885864\n", + " ],\n", + " [\n", + " 508.84210205078125,\n", + " 377.6853332519531,\n", + " 0.1948014795780182\n", + " ],\n", + " [\n", + " 112.65641021728516,\n", + " 278.4239501953125,\n", + " 0.18293216824531555\n", + " ],\n", + " [\n", + " 572.6771850585938,\n", + " 392.94891357421875,\n", + " 0.39113080501556396\n", + " ],\n", + " [\n", + " 481.0816650390625,\n", + " 432.840087890625,\n", + " 0.22011691331863403\n", + " ],\n", + " [\n", + " 145.10130310058594,\n", + " 373.3096618652344,\n", + " 0.4219147264957428\n", + " ],\n", + " [\n", + " 468.0207824707031,\n", + " 413.1219787597656,\n", + " 0.3619706630706787\n", + " ],\n", + " [\n", + " 136.87318420410156,\n", + " 172.54913330078125,\n", + " 0.3659115135669708\n", + " ],\n", + " [\n", + " 459.65655517578125,\n", + " 403.489013671875,\n", + " 0.46539226174354553\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.0933394,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.113898\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.1507206,\n", + " \"step\": 24,\n", + " \"pose\": [\n", + " [\n", + " 289.14605712890625,\n", + " 298.5685729980469,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.7758483886719,\n", + " 308.03326416015625,\n", + " 0.9337582588195801\n", + " ],\n", + " [\n", + " 297.73248291015625,\n", + " 315.2346496582031,\n", + " 0.6327982544898987\n", + " ],\n", + " [\n", + " 291.4134216308594,\n", + " 311.653564453125,\n", + " 0.609774649143219\n", + " ],\n", + " [\n", + " 305.1743469238281,\n", + " 313.22369384765625,\n", + " 0.7303615808486938\n", + " ],\n", + " [\n", + " 285.11077880859375,\n", + " 267.58795166015625,\n", + " 0.9044018387794495\n", + " ],\n", + " [\n", + " 287.801025390625,\n", + " 230.8278045654297,\n", + " 0.5132544636726379\n", + " ],\n", + " [\n", + " 294.9792785644531,\n", + " 217.2659912109375,\n", + " 0.3110557496547699\n", + " ],\n", + " [\n", + " 182.70785522460938,\n", + " 312.7431640625,\n", + " 0.4303653836250305\n", + " ],\n", + " [\n", + " 302.67926025390625,\n", + " 209.3670196533203,\n", + " 0.3863958716392517\n", + " ],\n", + " [\n", + " 310.23931884765625,\n", + " 273.41265869140625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.62945556640625,\n", + " 276.4300842285156,\n", + " 0.6256653070449829\n", + " ],\n", + " [\n", + " 363.4427185058594,\n", + " 281.51226806640625,\n", + " 0.4842453896999359\n", + " ],\n", + " [\n", + " 357.33184814453125,\n", + " 280.7920227050781,\n", + " 0.27625176310539246\n", + " ],\n", + " [\n", + " 194.59628295898438,\n", + " 306.9119873046875,\n", + " 0.41847968101501465\n", + " ],\n", + " [\n", + " 355.71160888671875,\n", + " 338.88134765625,\n", + " 0.44307053089141846\n", + " ],\n", + " [\n", + " 362.9523010253906,\n", + " 339.22442626953125,\n", + " 0.6132069230079651\n", + " ],\n", + " [\n", + " 317.2615051269531,\n", + " 345.85675048828125,\n", + " 0.35067668557167053\n", + " ],\n", + " [\n", + " 331.7022705078125,\n", + " 355.4823303222656,\n", + " 0.45521220564842224\n", + " ],\n", + " [\n", + " 363.580810546875,\n", + " 345.0674743652344,\n", + " 0.4948749542236328\n", + " ],\n", + " [\n", + " 269.8246765136719,\n", + " 356.2699279785156,\n", + " 0.5052472949028015\n", + " ],\n", + " [\n", + " 423.73480224609375,\n", + " 374.68438720703125,\n", + " 0.4521806836128235\n", + " ],\n", + " [\n", + " 266.7372131347656,\n", + " 357.82562255859375,\n", + " 0.44915807247161865\n", + " ],\n", + " [\n", + " 598.9044189453125,\n", + " 377.9191589355469,\n", + " 0.5668219923973083\n", + " ],\n", + " [\n", + " 185.02847290039062,\n", + " 209.2593536376953,\n", + " 0.29630619287490845\n", + " ],\n", + " [\n", + " 232.27960205078125,\n", + " 424.1368408203125,\n", + " 0.35069289803504944\n", + " ],\n", + " [\n", + " 516.3990478515625,\n", + " 429.19805908203125,\n", + " 0.6708835959434509\n", + " ],\n", + " [\n", + " 443.72869873046875,\n", + " 355.66790771484375,\n", + " 0.18464210629463196\n", + " ],\n", + " [\n", + " 509.17938232421875,\n", + " 388.7170104980469,\n", + " 0.4232410788536072\n", + " ],\n", + " [\n", + " 513.5040893554688,\n", + " 449.72918701171875,\n", + " 0.5325906872749329\n", + " ],\n", + " [\n", + " 583.7595825195312,\n", + " 413.1138916015625,\n", + " 0.4442688822746277\n", + " ],\n", + " [\n", + " 115.84835052490234,\n", + " 221.41104125976562,\n", + " 0.1638604700565338\n", + " ],\n", + " [\n", + " 116.47493743896484,\n", + " 283.7770690917969,\n", + " 0.2362251728773117\n", + " ],\n", + " [\n", + " 576.6185913085938,\n", + " 399.1842041015625,\n", + " 0.4103768765926361\n", + " ],\n", + " [\n", + " 144.26768493652344,\n", + " 343.4598693847656,\n", + " 0.2693617641925812\n", + " ],\n", + " [\n", + " 550.6482543945312,\n", + " 465.9261474609375,\n", + " 0.52559894323349\n", + " ],\n", + " [\n", + " 467.72491455078125,\n", + " 412.5487365722656,\n", + " 0.3213626742362976\n", + " ],\n", + " [\n", + " 466.04193115234375,\n", + " 410.77960205078125,\n", + " 0.32539424300193787\n", + " ],\n", + " [\n", + " 459.31622314453125,\n", + " 402.8010559082031,\n", + " 0.4295216500759125\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.1260552,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.1507206\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.1780674,\n", + " \"step\": 25,\n", + " \"pose\": [\n", + " [\n", + " 289.6149597167969,\n", + " 299.89166259765625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 294.52008056640625,\n", + " 308.8827209472656,\n", + " 0.9231247305870056\n", + " ],\n", + " [\n", + " 298.28369140625,\n", + " 316.6026916503906,\n", + " 0.6108699440956116\n", + " ],\n", + " [\n", + " 292.314697265625,\n", + " 312.0814514160156,\n", + " 0.5867233276367188\n", + " ],\n", + " [\n", + " 306.8653564453125,\n", + " 313.03192138671875,\n", + " 0.7602759599685669\n", + " ],\n", + " [\n", + " 284.97998046875,\n", + " 268.31109619140625,\n", + " 0.9025271534919739\n", + " ],\n", + " [\n", + " 285.36627197265625,\n", + " 239.4760284423828,\n", + " 0.45559558272361755\n", + " ],\n", + " [\n", + " 278.97967529296875,\n", + " 251.67454528808594,\n", + " 0.3040803074836731\n", + " ],\n", + " [\n", + " 182.5161590576172,\n", + " 314.0823974609375,\n", + " 0.487441748380661\n", + " ],\n", + " [\n", + " 303.5965881347656,\n", + " 209.8799591064453,\n", + " 0.39406585693359375\n", + " ],\n", + " [\n", + " 310.803466796875,\n", + " 273.83941650390625,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.4359130859375,\n", + " 276.5120849609375,\n", + " 0.5850675106048584\n", + " ],\n", + " [\n", + " 364.39306640625,\n", + " 281.8566589355469,\n", + " 0.43770086765289307\n", + " ],\n", + " [\n", + " 184.96409606933594,\n", + " 312.2420349121094,\n", + " 0.32801908254623413\n", + " ],\n", + " [\n", + " 188.65463256835938,\n", + " 306.26580810546875,\n", + " 0.44451019167900085\n", + " ],\n", + " [\n", + " 356.0030517578125,\n", + " 340.06390380859375,\n", + " 0.393046498298645\n", + " ],\n", + " [\n", + " 361.8909606933594,\n", + " 340.0927429199219,\n", + " 0.5161962509155273\n", + " ],\n", + " [\n", + " 315.6810607910156,\n", + " 343.1770935058594,\n", + " 0.37498360872268677\n", + " ],\n", + " [\n", + " 330.904052734375,\n", + " 355.0949401855469,\n", + " 0.5339792370796204\n", + " ],\n", + " [\n", + " 367.3807067871094,\n", + " 343.9208679199219,\n", + " 0.41494372487068176\n", + " ],\n", + " [\n", + " 270.63043212890625,\n", + " 356.5071716308594,\n", + " 0.39088016748428345\n", + " ],\n", + " [\n", + " 424.5980529785156,\n", + " 377.47625732421875,\n", + " 0.40465661883354187\n", + " ],\n", + " [\n", + " 267.8541259765625,\n", + " 358.0108642578125,\n", + " 0.33039915561676025\n", + " ],\n", + " [\n", + " 599.1412353515625,\n", + " 378.35345458984375,\n", + " 0.5644627213478088\n", + " ],\n", + " [\n", + " 184.03726196289062,\n", + " 209.42005920410156,\n", + " 0.459592342376709\n", + " ],\n", + " [\n", + " 511.7866516113281,\n", + " 408.0044860839844,\n", + " 0.3771219551563263\n", + " ],\n", + " [\n", + " 516.994873046875,\n", + " 430.9440612792969,\n", + " 0.5924205780029297\n", + " ],\n", + " [\n", + " 443.1100158691406,\n", + " 355.68621826171875,\n", + " 0.2457418441772461\n", + " ],\n", + " [\n", + " 499.76837158203125,\n", + " 422.5597839355469,\n", + " 0.24084235727787018\n", + " ],\n", + " [\n", + " 511.1026611328125,\n", + " 445.35107421875,\n", + " 0.5383387804031372\n", + " ],\n", + " [\n", + " 589.8642578125,\n", + " 408.1378173828125,\n", + " 0.456050306558609\n", + " ],\n", + " [\n", + " 507.7047119140625,\n", + " 379.2082214355469,\n", + " 0.16193071007728577\n", + " ],\n", + " [\n", + " 112.13590240478516,\n", + " 276.6860656738281,\n", + " 0.282148540019989\n", + " ],\n", + " [\n", + " 573.1347045898438,\n", + " 393.6588439941406,\n", + " 0.4297298192977905\n", + " ],\n", + " [\n", + " 482.9963073730469,\n", + " 432.75811767578125,\n", + " 0.2714385986328125\n", + " ],\n", + " [\n", + " 550.9261474609375,\n", + " 467.3409118652344,\n", + " 0.5166769623756409\n", + " ],\n", + " [\n", + " 468.4465026855469,\n", + " 413.2013244628906,\n", + " 0.42883676290512085\n", + " ],\n", + " [\n", + " 133.96446228027344,\n", + " 171.50291442871094,\n", + " 0.35597431659698486\n", + " ],\n", + " [\n", + " 465.5216369628906,\n", + " 407.3671875,\n", + " 0.4744454324245453\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.1553848,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.1780674\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.2132318,\n", + " \"step\": 26,\n", + " \"pose\": [\n", + " [\n", + " 289.6811218261719,\n", + " 299.3060607910156,\n", + " 1.0\n", + " ],\n", + " [\n", + " 294.1546630859375,\n", + " 308.6109924316406,\n", + " 0.9218483567237854\n", + " ],\n", + " [\n", + " 297.23516845703125,\n", + " 316.22552490234375,\n", + " 0.5849137902259827\n", + " ],\n", + " [\n", + " 292.8453369140625,\n", + " 312.3377990722656,\n", + " 0.5977075695991516\n", + " ],\n", + " [\n", + " 306.42193603515625,\n", + " 313.16546630859375,\n", + " 0.73295658826828\n", + " ],\n", + " [\n", + " 285.2742919921875,\n", + " 268.0978698730469,\n", + " 0.8919780254364014\n", + " ],\n", + " [\n", + " 285.22930908203125,\n", + " 240.1131134033203,\n", + " 0.48513516783714294\n", + " ],\n", + " [\n", + " 278.72900390625,\n", + " 252.45925903320312,\n", + " 0.30949145555496216\n", + " ],\n", + " [\n", + " 182.2201690673828,\n", + " 313.8691711425781,\n", + " 0.4198264181613922\n", + " ],\n", + " [\n", + " 302.0194091796875,\n", + " 209.4342803955078,\n", + " 0.3796064555644989\n", + " ],\n", + " [\n", + " 311.20037841796875,\n", + " 273.72052001953125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.5647888183594,\n", + " 275.8450622558594,\n", + " 0.551364004611969\n", + " ],\n", + " [\n", + " 361.8849792480469,\n", + " 280.7410583496094,\n", + " 0.4334920346736908\n", + " ],\n", + " [\n", + " 185.7280731201172,\n", + " 312.75677490234375,\n", + " 0.29518699645996094\n", + " ],\n", + " [\n", + " 188.90249633789062,\n", + " 306.63177490234375,\n", + " 0.43662378191947937\n", + " ],\n", + " [\n", + " 355.60833740234375,\n", + " 340.0720520019531,\n", + " 0.4073914885520935\n", + " ],\n", + " [\n", + " 362.4900817871094,\n", + " 339.8585205078125,\n", + " 0.5256603956222534\n", + " ],\n", + " [\n", + " 310.6485900878906,\n", + " 346.7264404296875,\n", + " 0.3758726119995117\n", + " ],\n", + " [\n", + " 331.7028503417969,\n", + " 357.2618408203125,\n", + " 0.5182921886444092\n", + " ],\n", + " [\n", + " 282.8375244140625,\n", + " 356.63385009765625,\n", + " 0.47025105357170105\n", + " ],\n", + " [\n", + " 271.1107482910156,\n", + " 356.7911376953125,\n", + " 0.49355366826057434\n", + " ],\n", + " [\n", + " 424.5859680175781,\n", + " 375.9132080078125,\n", + " 0.3878205716609955\n", + " ],\n", + " [\n", + " 267.3672180175781,\n", + " 358.4584655761719,\n", + " 0.4485253393650055\n", + " ],\n", + " [\n", + " 598.8714599609375,\n", + " 377.88165283203125,\n", + " 0.5623369216918945\n", + " ],\n", + " [\n", + " 184.80422973632812,\n", + " 207.90528869628906,\n", + " 0.4335136115550995\n", + " ],\n", + " [\n", + " 513.0913696289062,\n", + " 407.55938720703125,\n", + " 0.34691154956817627\n", + " ],\n", + " [\n", + " 517.757080078125,\n", + " 432.6747131347656,\n", + " 0.5728744864463806\n", + " ],\n", + " [\n", + " 163.63232421875,\n", + " 212.3854217529297,\n", + " 0.32033267617225647\n", + " ],\n", + " [\n", + " 570.7784423828125,\n", + " 193.9116973876953,\n", + " 0.40288540720939636\n", + " ],\n", + " [\n", + " 513.4071655273438,\n", + " 445.5836486816406,\n", + " 0.6099948883056641\n", + " ],\n", + " [\n", + " 590.9609985351562,\n", + " 408.857421875,\n", + " 0.4939699172973633\n", + " ],\n", + " [\n", + " 476.909423828125,\n", + " 420.9211120605469,\n", + " 0.17517627775669098\n", + " ],\n", + " [\n", + " 111.49516296386719,\n", + " 278.0699462890625,\n", + " 0.20062971115112305\n", + " ],\n", + " [\n", + " 572.727294921875,\n", + " 393.2834167480469,\n", + " 0.39421916007995605\n", + " ],\n", + " [\n", + " 481.32647705078125,\n", + " 431.9410400390625,\n", + " 0.2601860463619232\n", + " ],\n", + " [\n", + " 511.23797607421875,\n", + " 458.18768310546875,\n", + " 0.5182275772094727\n", + " ],\n", + " [\n", + " 468.54132080078125,\n", + " 413.6605529785156,\n", + " 0.40847253799438477\n", + " ],\n", + " [\n", + " 464.9053649902344,\n", + " 410.059326171875,\n", + " 0.35141322016716003\n", + " ],\n", + " [\n", + " 459.9055480957031,\n", + " 403.2681579589844,\n", + " 0.4966220259666443\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.1875935,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.2132318\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.2483606,\n", + " \"step\": 27,\n", + " \"pose\": [\n", + " [\n", + " 289.65423583984375,\n", + " 298.561767578125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.9566955566406,\n", + " 308.3065490722656,\n", + " 0.9470576047897339\n", + " ],\n", + " [\n", + " 297.505859375,\n", + " 314.407958984375,\n", + " 0.6088659763336182\n", + " ],\n", + " [\n", + " 291.20574951171875,\n", + " 310.8966064453125,\n", + " 0.607652485370636\n", + " ],\n", + " [\n", + " 306.73492431640625,\n", + " 312.7744445800781,\n", + " 0.7566508054733276\n", + " ],\n", + " [\n", + " 285.1037902832031,\n", + " 267.3839416503906,\n", + " 0.906174898147583\n", + " ],\n", + " [\n", + " 185.3070526123047,\n", + " 311.79327392578125,\n", + " 0.5088176727294922\n", + " ],\n", + " [\n", + " 154.76300048828125,\n", + " 314.7828674316406,\n", + " 0.29141828417778015\n", + " ],\n", + " [\n", + " 182.6195068359375,\n", + " 312.42791748046875,\n", + " 0.5560340285301208\n", + " ],\n", + " [\n", + " 304.5721435546875,\n", + " 209.04302978515625,\n", + " 0.4600820243358612\n", + " ],\n", + " [\n", + " 310.9137878417969,\n", + " 273.2908935546875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.1856994628906,\n", + " 276.47381591796875,\n", + " 0.5621199011802673\n", + " ],\n", + " [\n", + " 363.20672607421875,\n", + " 281.5467224121094,\n", + " 0.427944153547287\n", + " ],\n", + " [\n", + " 181.0316619873047,\n", + " 311.6895446777344,\n", + " 0.3624846041202545\n", + " ],\n", + " [\n", + " 188.46315002441406,\n", + " 304.592529296875,\n", + " 0.5054963231086731\n", + " ],\n", + " [\n", + " 353.033447265625,\n", + " 342.5238037109375,\n", + " 0.40231525897979736\n", + " ],\n", + " [\n", + " 362.5194091796875,\n", + " 339.60711669921875,\n", + " 0.6008350849151611\n", + " ],\n", + " [\n", + " 317.26544189453125,\n", + " 342.3359069824219,\n", + " 0.3960641920566559\n", + " ],\n", + " [\n", + " 331.4060974121094,\n", + " 356.75445556640625,\n", + " 0.5937938690185547\n", + " ],\n", + " [\n", + " 293.347412109375,\n", + " 357.84515380859375,\n", + " 0.4450031816959381\n", + " ],\n", + " [\n", + " 269.11407470703125,\n", + " 357.3741455078125,\n", + " 0.4057061970233917\n", + " ],\n", + " [\n", + " 378.8157653808594,\n", + " 355.3031921386719,\n", + " 0.39472222328186035\n", + " ],\n", + " [\n", + " 265.99078369140625,\n", + " 358.6902160644531,\n", + " 0.3461160957813263\n", + " ],\n", + " [\n", + " 598.9271850585938,\n", + " 377.77618408203125,\n", + " 0.5730097889900208\n", + " ],\n", + " [\n", + " 184.35238647460938,\n", + " 209.6236572265625,\n", + " 0.3743739724159241\n", + " ],\n", + " [\n", + " 513.5267333984375,\n", + " 407.8452453613281,\n", + " 0.394991397857666\n", + " ],\n", + " [\n", + " 517.43359375,\n", + " 432.714111328125,\n", + " 0.6186484694480896\n", + " ],\n", + " [\n", + " 566.1676635742188,\n", + " 200.06082153320312,\n", + " 0.36133381724357605\n", + " ],\n", + " [\n", + " 570.642333984375,\n", + " 203.29977416992188,\n", + " 0.2848742604255676\n", + " ],\n", + " [\n", + " 513.6658935546875,\n", + " 447.7688903808594,\n", + " 0.566241979598999\n", + " ],\n", + " [\n", + " 590.916259765625,\n", + " 409.0061950683594,\n", + " 0.5227742791175842\n", + " ],\n", + " [\n", + " 476.4045104980469,\n", + " 421.7107849121094,\n", + " 0.23629191517829895\n", + " ],\n", + " [\n", + " 109.81532287597656,\n", + " 272.0874938964844,\n", + " 0.2973298132419586\n", + " ],\n", + " [\n", + " 572.2440185546875,\n", + " 393.4117431640625,\n", + " 0.4230390191078186\n", + " ],\n", + " [\n", + " 481.6334228515625,\n", + " 434.35992431640625,\n", + " 0.20489251613616943\n", + " ],\n", + " [\n", + " 511.52740478515625,\n", + " 458.0361633300781,\n", + " 0.47707173228263855\n", + " ],\n", + " [\n", + " 468.145751953125,\n", + " 413.9035339355469,\n", + " 0.4124965965747833\n", + " ],\n", + " [\n", + " 465.691650390625,\n", + " 411.6900329589844,\n", + " 0.3242393732070923\n", + " ],\n", + " [\n", + " 459.133544921875,\n", + " 403.29656982421875,\n", + " 0.4457142651081085\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.2229764,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.2503667\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.2815137,\n", + " \"step\": 28,\n", + " \"pose\": [\n", + " [\n", + " 289.7947998046875,\n", + " 299.4747314453125,\n", + " 1.0\n", + " ],\n", + " [\n", + " 294.1377868652344,\n", + " 308.9644470214844,\n", + " 0.9098001718521118\n", + " ],\n", + " [\n", + " 297.0553894042969,\n", + " 316.2694396972656,\n", + " 0.5839970111846924\n", + " ],\n", + " [\n", + " 292.7187194824219,\n", + " 312.2520446777344,\n", + " 0.5812930464744568\n", + " ],\n", + " [\n", + " 306.51007080078125,\n", + " 313.4143981933594,\n", + " 0.7014763355255127\n", + " ],\n", + " [\n", + " 285.4446105957031,\n", + " 267.66400146484375,\n", + " 0.885840654373169\n", + " ],\n", + " [\n", + " 287.2601623535156,\n", + " 232.3909912109375,\n", + " 0.47544583678245544\n", + " ],\n", + " [\n", + " 279.16656494140625,\n", + " 250.76365661621094,\n", + " 0.29200607538223267\n", + " ],\n", + " [\n", + " 182.51055908203125,\n", + " 314.830322265625,\n", + " 0.4986189305782318\n", + " ],\n", + " [\n", + " 304.1475524902344,\n", + " 207.91217041015625,\n", + " 0.5152255892753601\n", + " ],\n", + " [\n", + " 311.350830078125,\n", + " 273.5013122558594,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.06219482421875,\n", + " 276.67120361328125,\n", + " 0.5790920853614807\n", + " ],\n", + " [\n", + " 362.5161437988281,\n", + " 283.22723388671875,\n", + " 0.4104999601840973\n", + " ],\n", + " [\n", + " 181.13710021972656,\n", + " 312.584716796875,\n", + " 0.29986241459846497\n", + " ],\n", + " [\n", + " 188.63394165039062,\n", + " 303.74395751953125,\n", + " 0.45568570494651794\n", + " ],\n", + " [\n", + " 357.4764099121094,\n", + " 339.7001953125,\n", + " 0.4089646339416504\n", + " ],\n", + " [\n", + " 362.9626770019531,\n", + " 339.128662109375,\n", + " 0.4946100115776062\n", + " ],\n", + " [\n", + " 311.04779052734375,\n", + " 346.371826171875,\n", + " 0.39371228218078613\n", + " ],\n", + " [\n", + " 339.0541687011719,\n", + " 359.549560546875,\n", + " 0.5586241483688354\n", + " ],\n", + " [\n", + " 283.3900146484375,\n", + " 356.6086120605469,\n", + " 0.4299786686897278\n", + " ],\n", + " [\n", + " 270.4642333984375,\n", + " 357.4127502441406,\n", + " 0.40563082695007324\n", + " ],\n", + " [\n", + " 423.99005126953125,\n", + " 375.4238586425781,\n", + " 0.43916311860084534\n", + " ],\n", + " [\n", + " 268.08233642578125,\n", + " 358.42919921875,\n", + " 0.3316787779331207\n", + " ],\n", + " [\n", + " 598.7156982421875,\n", + " 377.3929138183594,\n", + " 0.5510212182998657\n", + " ],\n", + " [\n", + " 182.943115234375,\n", + " 208.30332946777344,\n", + " 0.44296297430992126\n", + " ],\n", + " [\n", + " 564.0025024414062,\n", + " 386.8399353027344,\n", + " 0.39091676473617554\n", + " ],\n", + " [\n", + " 517.2132568359375,\n", + " 432.1662292480469,\n", + " 0.6276928186416626\n", + " ],\n", + " [\n", + " 570.1396484375,\n", + " 190.4938507080078,\n", + " 0.2375359982252121\n", + " ],\n", + " [\n", + " 492.12896728515625,\n", + " 413.6061096191406,\n", + " 0.3405477702617645\n", + " ],\n", + " [\n", + " 512.7205200195312,\n", + " 450.4252624511719,\n", + " 0.6235648393630981\n", + " ],\n", + " [\n", + " 590.1025390625,\n", + " 409.0594787597656,\n", + " 0.5204903483390808\n", + " ],\n", + " [\n", + " 476.5130310058594,\n", + " 422.0225524902344,\n", + " 0.14199496805667877\n", + " ],\n", + " [\n", + " 110.05477142333984,\n", + " 271.470703125,\n", + " 0.22186370193958282\n", + " ],\n", + " [\n", + " 571.6903686523438,\n", + " 393.01318359375,\n", + " 0.4487222135066986\n", + " ],\n", + " [\n", + " 142.70831298828125,\n", + " 346.6425476074219,\n", + " 0.2570325434207916\n", + " ],\n", + " [\n", + " 511.7834167480469,\n", + " 456.3761901855469,\n", + " 0.5804262161254883\n", + " ],\n", + " [\n", + " 468.8331298828125,\n", + " 413.937744140625,\n", + " 0.36150258779525757\n", + " ],\n", + " [\n", + " 465.01104736328125,\n", + " 410.2015380859375,\n", + " 0.3218684196472168\n", + " ],\n", + " [\n", + " 460.2878723144531,\n", + " 402.61614990234375,\n", + " 0.44286683201789856\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.2539957,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.2815137\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.313648,\n", + " \"step\": 29,\n", + " \"pose\": [\n", + " [\n", + " 290.2019958496094,\n", + " 299.82733154296875,\n", + " 1.0\n", + " ],\n", + " [\n", + " 294.038818359375,\n", + " 309.61590576171875,\n", + " 0.8994060754776001\n", + " ],\n", + " [\n", + " 297.1882019042969,\n", + " 316.2828369140625,\n", + " 0.580872118473053\n", + " ],\n", + " [\n", + " 292.24969482421875,\n", + " 311.94366455078125,\n", + " 0.553147554397583\n", + " ],\n", + " [\n", + " 305.8408508300781,\n", + " 312.88555908203125,\n", + " 0.7357133626937866\n", + " ],\n", + " [\n", + " 285.2379455566406,\n", + " 267.34576416015625,\n", + " 0.8884855508804321\n", + " ],\n", + " [\n", + " 288.9740905761719,\n", + " 231.48561096191406,\n", + " 0.5197221636772156\n", + " ],\n", + " [\n", + " 299.1871032714844,\n", + " 218.01211547851562,\n", + " 0.3429694175720215\n", + " ],\n", + " [\n", + " 181.70339965820312,\n", + " 317.9532165527344,\n", + " 0.46619749069213867\n", + " ],\n", + " [\n", + " 304.53472900390625,\n", + " 208.62942504882812,\n", + " 0.45269933342933655\n", + " ],\n", + " [\n", + " 311.17626953125,\n", + " 274.0134582519531,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.29833984375,\n", + " 278.41729736328125,\n", + " 0.5592008233070374\n", + " ],\n", + " [\n", + " 363.13763427734375,\n", + " 281.8921813964844,\n", + " 0.45556432008743286\n", + " ],\n", + " [\n", + " 357.6162414550781,\n", + " 281.98394775390625,\n", + " 0.30312928557395935\n", + " ],\n", + " [\n", + " 187.75889587402344,\n", + " 305.17547607421875,\n", + " 0.44207459688186646\n", + " ],\n", + " [\n", + " 355.2580261230469,\n", + " 341.6760559082031,\n", + " 0.38897281885147095\n", + " ],\n", + " [\n", + " 363.04852294921875,\n", + " 339.8902893066406,\n", + " 0.5060890316963196\n", + " ],\n", + " [\n", + " 314.8399963378906,\n", + " 341.91680908203125,\n", + " 0.40578165650367737\n", + " ],\n", + " [\n", + " 338.7541198730469,\n", + " 359.4593200683594,\n", + " 0.548823893070221\n", + " ],\n", + " [\n", + " 366.559814453125,\n", + " 343.24029541015625,\n", + " 0.4393633306026459\n", + " ],\n", + " [\n", + " 270.7618103027344,\n", + " 357.1903991699219,\n", + " 0.4302641451358795\n", + " ],\n", + " [\n", + " 379.385498046875,\n", + " 355.3668212890625,\n", + " 0.45166778564453125\n", + " ],\n", + " [\n", + " 268.61431884765625,\n", + " 357.9519348144531,\n", + " 0.36507532000541687\n", + " ],\n", + " [\n", + " 597.203125,\n", + " 379.3829345703125,\n", + " 0.587417721748352\n", + " ],\n", + " [\n", + " 182.3574676513672,\n", + " 208.2609100341797,\n", + " 0.39093875885009766\n", + " ],\n", + " [\n", + " 512.343994140625,\n", + " 407.104736328125,\n", + " 0.3616585433483124\n", + " ],\n", + " [\n", + " 517.728759765625,\n", + " 431.97174072265625,\n", + " 0.5530462265014648\n", + " ],\n", + " [\n", + " 567.7471313476562,\n", + " 195.76194763183594,\n", + " 0.41424036026000977\n", + " ],\n", + " [\n", + " 568.5385131835938,\n", + " 196.24961853027344,\n", + " 0.3196858763694763\n", + " ],\n", + " [\n", + " 512.130615234375,\n", + " 450.2174072265625,\n", + " 0.6944491863250732\n", + " ],\n", + " [\n", + " 154.75747680664062,\n", + " 365.33026123046875,\n", + " 0.46970120072364807\n", + " ],\n", + " [\n", + " 476.37847900390625,\n", + " 421.13104248046875,\n", + " 0.2024911344051361\n", + " ],\n", + " [\n", + " 109.27305603027344,\n", + " 276.56787109375,\n", + " 0.22318890690803528\n", + " ],\n", + " [\n", + " 573.2276611328125,\n", + " 393.43096923828125,\n", + " 0.45069870352745056\n", + " ],\n", + " [\n", + " 144.93081665039062,\n", + " 343.8260803222656,\n", + " 0.26525822281837463\n", + " ],\n", + " [\n", + " 510.0262145996094,\n", + " 457.258544921875,\n", + " 0.6254387497901917\n", + " ],\n", + " [\n", + " 468.2686767578125,\n", + " 412.5702209472656,\n", + " 0.40957126021385193\n", + " ],\n", + " [\n", + " 464.2906188964844,\n", + " 410.6726989746094,\n", + " 0.32647469639778137\n", + " ],\n", + " [\n", + " 460.0201721191406,\n", + " 403.037841796875,\n", + " 0.41243088245391846\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.285935,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.313648\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.3619356,\n", + " \"step\": 30,\n", + " \"pose\": [\n", + " [\n", + " 290.1323547363281,\n", + " 299.7147521972656,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.99139404296875,\n", + " 309.4753112792969,\n", + " 0.8872848749160767\n", + " ],\n", + " [\n", + " 297.3484802246094,\n", + " 315.24835205078125,\n", + " 0.5460883975028992\n", + " ],\n", + " [\n", + " 291.71209716796875,\n", + " 310.8031311035156,\n", + " 0.5654347538948059\n", + " ],\n", + " [\n", + " 305.3967590332031,\n", + " 312.7275085449219,\n", + " 0.7120907306671143\n", + " ],\n", + " [\n", + " 285.19940185546875,\n", + " 267.3405456542969,\n", + " 0.8942697048187256\n", + " ],\n", + " [\n", + " 289.3546447753906,\n", + " 231.18653869628906,\n", + " 0.483479768037796\n", + " ],\n", + " [\n", + " 299.21112060546875,\n", + " 218.4276580810547,\n", + " 0.4001926779747009\n", + " ],\n", + " [\n", + " 181.76585388183594,\n", + " 313.5556945800781,\n", + " 0.46045035123825073\n", + " ],\n", + " [\n", + " 309.8026428222656,\n", + " 210.481689453125,\n", + " 0.5369957089424133\n", + " ],\n", + " [\n", + " 310.97064208984375,\n", + " 273.8341369628906,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.9340515136719,\n", + " 277.03912353515625,\n", + " 0.6095738410949707\n", + " ],\n", + " [\n", + " 362.3292541503906,\n", + " 282.6873474121094,\n", + " 0.5387850999832153\n", + " ],\n", + " [\n", + " 183.47048950195312,\n", + " 311.634521484375,\n", + " 0.30759158730506897\n", + " ],\n", + " [\n", + " 193.97259521484375,\n", + " 306.5625,\n", + " 0.4332640469074249\n", + " ],\n", + " [\n", + " 355.8443908691406,\n", + " 341.6749267578125,\n", + " 0.3820899724960327\n", + " ],\n", + " [\n", + " 362.45452880859375,\n", + " 340.09088134765625,\n", + " 0.493086040019989\n", + " ],\n", + " [\n", + " 315.4454650878906,\n", + " 343.0971984863281,\n", + " 0.3856670558452606\n", + " ],\n", + " [\n", + " 339.348388671875,\n", + " 359.60870361328125,\n", + " 0.55845707654953\n", + " ],\n", + " [\n", + " 284.3851623535156,\n", + " 356.5133972167969,\n", + " 0.4924909174442291\n", + " ],\n", + " [\n", + " 271.4208679199219,\n", + " 357.4637451171875,\n", + " 0.3702590763568878\n", + " ],\n", + " [\n", + " 423.9642028808594,\n", + " 375.77801513671875,\n", + " 0.42082488536834717\n", + " ],\n", + " [\n", + " 269.3265075683594,\n", + " 358.4918212890625,\n", + " 0.3108918368816376\n", + " ],\n", + " [\n", + " 595.7271728515625,\n", + " 378.74505615234375,\n", + " 0.6383695006370544\n", + " ],\n", + " [\n", + " 182.5079803466797,\n", + " 209.4701690673828,\n", + " 0.3769323527812958\n", + " ],\n", + " [\n", + " 563.5107421875,\n", + " 388.12860107421875,\n", + " 0.34879517555236816\n", + " ],\n", + " [\n", + " 516.7620239257812,\n", + " 429.2193908691406,\n", + " 0.6218969225883484\n", + " ],\n", + " [\n", + " 443.3829650878906,\n", + " 357.2695007324219,\n", + " 0.2294222116470337\n", + " ],\n", + " [\n", + " 508.9411926269531,\n", + " 390.8059387207031,\n", + " 0.27742016315460205\n", + " ],\n", + " [\n", + " 513.4525756835938,\n", + " 451.084228515625,\n", + " 0.588313102722168\n", + " ],\n", + " [\n", + " 590.62939453125,\n", + " 409.5919494628906,\n", + " 0.49015718698501587\n", + " ],\n", + " [\n", + " 505.6385498046875,\n", + " 379.8114929199219,\n", + " 0.16522188484668732\n", + " ],\n", + " [\n", + " 108.85206604003906,\n", + " 271.24273681640625,\n", + " 0.2543381452560425\n", + " ],\n", + " [\n", + " 573.4502563476562,\n", + " 393.5893249511719,\n", + " 0.49820849299430847\n", + " ],\n", + " [\n", + " 142.85484313964844,\n", + " 314.2268981933594,\n", + " 0.19713541865348816\n", + " ],\n", + " [\n", + " 549.9910278320312,\n", + " 465.56768798828125,\n", + " 0.5062677264213562\n", + " ],\n", + " [\n", + " 469.6101379394531,\n", + " 412.96905517578125,\n", + " 0.3788171112537384\n", + " ],\n", + " [\n", + " 465.2295837402344,\n", + " 411.72161865234375,\n", + " 0.343596875667572\n", + " ],\n", + " [\n", + " 460.37811279296875,\n", + " 402.3249206542969,\n", + " 0.46701255440711975\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.3327684,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.3619356\n", + "}\n", + "{\n", + " \"kind\": \"pose\",\n", + " \"raw\": {\n", + " \"type\": \"pose\",\n", + " \"timestamp\": 1783588106.3911848,\n", + " \"step\": 31,\n", + " \"pose\": [\n", + " [\n", + " 289.88616943359375,\n", + " 299.245849609375,\n", + " 1.0\n", + " ],\n", + " [\n", + " 293.7210693359375,\n", + " 308.9605407714844,\n", + " 0.8916722536087036\n", + " ],\n", + " [\n", + " 297.2877502441406,\n", + " 315.5369873046875,\n", + " 0.5746937394142151\n", + " ],\n", + " [\n", + " 292.2024230957031,\n", + " 311.0600891113281,\n", + " 0.5595524907112122\n", + " ],\n", + " [\n", + " 305.3230285644531,\n", + " 313.09588623046875,\n", + " 0.7339632511138916\n", + " ],\n", + " [\n", + " 285.2215576171875,\n", + " 266.96453857421875,\n", + " 0.894293487071991\n", + " ],\n", + " [\n", + " 297.3047180175781,\n", + " 221.42364501953125,\n", + " 0.5297285914421082\n", + " ],\n", + " [\n", + " 295.8182067871094,\n", + " 216.30775451660156,\n", + " 0.38408026099205017\n", + " ],\n", + " [\n", + " 183.77606201171875,\n", + " 316.7649841308594,\n", + " 0.4275979697704315\n", + " ],\n", + " [\n", + " 308.4361572265625,\n", + " 210.48973083496094,\n", + " 0.5560570359230042\n", + " ],\n", + " [\n", + " 311.0546875,\n", + " 273.9168395996094,\n", + " 1.0\n", + " ],\n", + " [\n", + " 363.8443298339844,\n", + " 277.56488037109375,\n", + " 0.632280170917511\n", + " ],\n", + " [\n", + " 363.19598388671875,\n", + " 283.2601013183594,\n", + " 0.5504708290100098\n", + " ],\n", + " [\n", + " 360.5338439941406,\n", + " 282.9017028808594,\n", + " 0.3014270067214966\n", + " ],\n", + " [\n", + " 187.5973663330078,\n", + " 304.839111328125,\n", + " 0.4563285708427429\n", + " ],\n", + " [\n", + " 355.62762451171875,\n", + " 341.2684020996094,\n", + " 0.4316878914833069\n", + " ],\n", + " [\n", + " 362.1797180175781,\n", + " 340.63848876953125,\n", + " 0.523749828338623\n", + " ],\n", + " [\n", + " 310.7569580078125,\n", + " 347.2984313964844,\n", + " 0.3826305568218231\n", + " ],\n", + " [\n", + " 338.3757629394531,\n", + " 360.44317626953125,\n", + " 0.5659022927284241\n", + " ],\n", + " [\n", + " 367.23004150390625,\n", + " 344.0614929199219,\n", + " 0.43424880504608154\n", + " ],\n", + " [\n", + " 272.1240234375,\n", + " 355.71612548828125,\n", + " 0.5028250217437744\n", + " ],\n", + " [\n", + " 379.81280517578125,\n", + " 355.9754943847656,\n", + " 0.38737767934799194\n", + " ],\n", + " [\n", + " 268.998046875,\n", + " 357.2021789550781,\n", + " 0.44784843921661377\n", + " ],\n", + " [\n", + " 596.336181640625,\n", + " 379.1740417480469,\n", + " 0.545729398727417\n", + " ],\n", + " [\n", + " 201.0037841796875,\n", + " 228.0155029296875,\n", + " 0.42291054129600525\n", + " ],\n", + " [\n", + " 562.3812866210938,\n", + " 386.2914123535156,\n", + " 0.36510559916496277\n", + " ],\n", + " [\n", + " 517.466796875,\n", + " 433.1764831542969,\n", + " 0.5716246962547302\n", + " ],\n", + " [\n", + " 570.5004272460938,\n", + " 194.27825927734375,\n", + " 0.31970250606536865\n", + " ],\n", + " [\n", + " 572.74462890625,\n", + " 201.8236541748047,\n", + " 0.39004698395729065\n", + " ],\n", + " [\n", + " 513.2064208984375,\n", + " 451.0941162109375,\n", + " 0.6408159732818604\n", + " ],\n", + " [\n", + " 590.6119995117188,\n", + " 408.5152282714844,\n", + " 0.5167672634124756\n", + " ],\n", + " [\n", + " 114.4150619506836,\n", + " 235.0469207763672,\n", + " 0.13695929944515228\n", + " ],\n", + " [\n", + " 112.1463623046875,\n", + " 279.28265380859375,\n", + " 0.23755685985088348\n", + " ],\n", + " [\n", + " 571.991943359375,\n", + " 393.113525390625,\n", + " 0.4135165214538574\n", + " ],\n", + " [\n", + " 144.2115478515625,\n", + " 313.7943115234375,\n", + " 0.23972396552562714\n", + " ],\n", + " [\n", + " 510.1828918457031,\n", + " 456.5469055175781,\n", + " 0.503180205821991\n", + " ],\n", + " [\n", + " 468.22821044921875,\n", + " 413.3734436035156,\n", + " 0.37297725677490234\n", + " ],\n", + " [\n", + " 465.0234069824219,\n", + " 410.9199523925781,\n", + " 0.36548110842704773\n", + " ],\n", + " [\n", + " 465.41192626953125,\n", + " 406.6351623535156,\n", + " 0.4397272765636444\n", + " ]\n", + " ],\n", + " \"frame_time\": 1783588106.3645778,\n", + " \"pose_time\": null,\n", + " \"recording\": false\n", + " },\n", + " \"received_at\": 1783588106.3911848\n", + "}\n", + "Received 20 packet(s).\n" + ] + } + ], + "source": [ + "def receive_packets(conn, duration_s: float = 30.0, max_packets: int | None = None):\n", + " start = time.time()\n", + " packets: list[dict[str, Any]] = []\n", + "\n", + " while time.time() - start < duration_s:\n", + " if conn.poll(POLL_INTERVAL_S):\n", + " payload = conn.recv()\n", + " decoded = decode_payload(payload)\n", + " decoded[\"received_at\"] = time.time()\n", + " packets.append(decoded)\n", + " print(json.dumps(decoded, default=str, indent=2))\n", + "\n", + " if max_packets is not None and len(packets) >= max_packets:\n", + " break\n", + "\n", + " print(f\"Received {len(packets)} packet(s).\")\n", + " return packets\n", + "\n", + "\n", + "packets = receive_packets(conn, duration_s=30.0, max_packets=20)" + ] + }, + { + "cell_type": "markdown", + "id": "504fb2a444614c0babb325280ed9130a", + "metadata": {}, + "source": [ + "## Save captured packets\n", + "\n", + "This is useful if you want to inspect the stream shape after a test run." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "59bbdb311c014d738909a11f9e486628", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saved 20 packet(s) to C:\\Users\\Cyril A\\Desktop\\Code\\DeepLabCut-live-GUI\\dlclivegui\\processors\\custom\\mock_unity_captured_packets.json\n" + ] + } + ], + "source": [ + "out_path = Path(\"mock_unity_captured_packets.json\")\n", + "out_path.write_text(json.dumps(packets, default=str, indent=2), encoding=\"utf-8\")\n", + "print(\"Saved\", len(packets), \"packet(s) to\", out_path.resolve())" + ] + }, + { + "cell_type": "markdown", + "id": "b43b363d81ae4b689946ece5c682cd59", + "metadata": {}, + "source": [ + "## Close the client connection" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "8a65eabff63a45729fe45fb5ade58bdc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Connection closed\n" + ] + } + ], + "source": [ + "try:\n", + " conn.close()\n", + " print(\"Connection closed\")\n", + "except Exception as exc:\n", + " print(\"Close failed:\", exc)" + ] + }, + { + "cell_type": "markdown", + "id": "c3933fab20d04ec698c2621248eb3be0", + "metadata": {}, + "source": [ + "## Optional local smoke test with a standalone mock server\n", + "\n", + "Use this only if you want to validate the notebook client logic without running DLCLiveGUI.\n", + "\n", + "This requires `mock_socket_processor.py` to be importable, for example by placing it next to this notebook or adding its directory to `sys.path`." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "4dd4641cc4064e0191573fe9c69df29b", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional smoke test. Leave commented unless you have mock_socket_processor.py available.\n", + "#\n", + "# import socket\n", + "# import sys\n", + "# from multiprocessing.connection import Client\n", + "#\n", + "# from mock_socket_processor import MockSocketProcessor\n", + "#\n", + "# def free_port():\n", + "# s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "# s.bind((\"127.0.0.1\", 0))\n", + "# port = s.getsockname()[1]\n", + "# s.close()\n", + "# return port\n", + "#\n", + "# port = free_port()\n", + "# mock = MockSocketProcessor(bind=(\"127.0.0.1\", port), authkey=AUTHKEY)\n", + "# test_conn = Client(mock.address, authkey=AUTHKEY)\n", + "# test_conn.send({\"cmd\": \"ping\"})\n", + "# print(test_conn.recv())\n", + "# mock.process([[1, 2, 0.9]], frame_time=123.456)\n", + "# print(decode_payload(test_conn.recv()))\n", + "# test_conn.close()\n", + "# mock.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "deeplabcut-live-gui (3.12.12)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From d8df2c02c4907c0799e7a54cc2a86be13ab24dd2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 16:35:03 +0200 Subject: [PATCH 167/194] Update main_window.py --- dlclivegui/gui/main_window.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 7a90337da..2bd273973 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1914,7 +1914,42 @@ def worker(): daemon=True, ).start() + def _save_processor_data_if_available(self) -> None: + """Best-effort generic processor save. + + The GUI intentionally does not pass a path here. This lets custom processors + use their own save_path / filename / internal policy. + + Expected processor contract: + processor.save() -> int | bool | None + + Return values are only logged; failure should not crash the GUI. + """ + processor = getattr(self._dlc, "_processor", None) + + # Fallback in case DLCLive owns the processor but _processor was not updated. + if processor is None: + dlc_obj = getattr(self._dlc, "_dlc", None) + processor = getattr(dlc_obj, "processor", None) if dlc_obj is not None else None + + if processor is None: + logger.debug("Processor save skipped: no processor instance available.") + return + + save = getattr(processor, "save", None) + if not callable(save): + logger.debug("Processor save skipped: processor has no callable save().") + return + + try: + result = save() + logger.info("Processor save() completed with result: %r", result) + except Exception: + logger.exception("Processor save() failed.") + def _on_recording_stopped_async(self) -> None: + self._save_processor_data_if_available() + self._recording_stopping = False self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) From f75752818d65462943ff9c11f51acb086f19085b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 11:54:27 +0200 Subject: [PATCH 168/194] Add recording file context and recorder path accessors RecordingManager now captures and exposes a current/last recording file context, including run/session directories plus per-camera video and timestamp sidecar paths, so downstream processor hooks can still resolve finalized files after stop_all(). VideoRecorder adds explicit output_path and timestamp_json_path properties, and timestamp saving now reuses the shared timestamp path accessor. --- dlclivegui/services/recording_manager.py | 72 ++++++++++++++++++++++++ dlclivegui/services/video_recorder.py | 12 +++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/recording_manager.py b/dlclivegui/services/recording_manager.py index 512f76ba1..9dfd68c1f 100644 --- a/dlclivegui/services/recording_manager.py +++ b/dlclivegui/services/recording_manager.py @@ -37,6 +37,9 @@ def __init__(self): self._dispatch_accepting: bool = False self._dispatch_sentinel_enqueued: bool = False + # Utility for operation on latest recording file context (e.g., for processor hooks) + self._last_recording_file_context: dict | None = None + @property def is_active(self) -> bool: with self._lock: @@ -313,6 +316,9 @@ def start_all( self._run_dir = None return None + with self._lock: + self._last_recording_file_context = self._build_recording_file_context_unlocked() + return run_dir def stop_all(self) -> bool: @@ -322,6 +328,11 @@ def stop_all(self) -> bool: with self._lock: recorders = list(self._recorders.items()) + run_dir = self._run_dir + session_dir = self._session_dir + self._last_recording_file_context = self._build_recording_file_context_unlocked( + recorders=recorders, run_dir=run_dir, session_dir=session_dir + ) self._recorders.clear() for cam_id, rec in recorders: @@ -484,3 +495,64 @@ def get_stats_summary(self) -> str: f"backlog {totals['backlog']} | " f"dropped {totals['dropped']}" ) + + def _build_recording_file_context_unlocked( + self, + recorders: list[tuple[str, VideoRecorder]] | None = None, + run_dir: Path | None = None, + session_dir: Path | None = None, + ) -> dict: + """Build a file context for active or recently stopped recorders. + + Must be called with self._lock held if using internal state. + """ + if recorders is None: + recorders = list(self._recorders.items()) + + if run_dir is None: + run_dir = self._run_dir + + if session_dir is None: + session_dir = self._session_dir + + video_files: dict[str, Path] = {} + timestamp_json_files: dict[str, Path] = {} + + for cam_id, recorder in recorders: + video_path = getattr(recorder, "output_path", None) + timestamp_path = getattr(recorder, "timestamp_json_path", None) + + if video_path is not None: + video_files[str(cam_id)] = Path(video_path) + + if timestamp_path is not None: + timestamp_json_files[str(cam_id)] = Path(timestamp_path) + + return { + "run_dir": run_dir, + "session_dir": session_dir, + "video_files": video_files, + "timestamp_json_files": timestamp_json_files, + } + + def get_recording_file_context(self) -> dict: + """Return current or last recording file context. + + This is used by optional custom processors to save compatibility sidecars + next to the videos after RecordingManager.stop_all() has finalized them. + """ + with self._lock: + if self._recorders: + context = self._build_recording_file_context_unlocked() + self._last_recording_file_context = context + return dict(context) + + if self._last_recording_file_context is not None: + return dict(self._last_recording_file_context) + + return { + "run_dir": self._run_dir, + "session_dir": self._session_dir, + "video_files": {}, + "timestamp_json_files": {}, + } diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 3a3c40b2a..8baa98c37 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -160,6 +160,16 @@ def __init__( def is_running(self) -> bool: return self._writer_thread is not None and self._writer_thread.is_alive() + @property + def output_path(self) -> Path: + """Video output path.""" + return self._output + + @property + def timestamp_json_path(self) -> Path: + """Timestamp JSON sidecar path written by _save_timestamps().""" + return self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") + def start(self) -> None: if WriteGear is None: raise RuntimeError("vidgear is required for video recording. Install it with 'pip install vidgear'.") @@ -626,7 +636,7 @@ def _save_timestamps(self) -> None: logger.info("No timestamps to save") return - timestamp_file = self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") + timestamp_file = self.timestamp_json_path try: with self._stats_lock: From 79ac2d3c4ec33572007d32515a3c5ed4f15685eb Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 11:55:06 +0200 Subject: [PATCH 169/194] Add recording lifecycle hooks for processors Wire the main window to notify custom DLC processors when recording starts/stops, with a shared recording context (run dir, filename stem, and file metadata). Refactor processor lookup into a helper and only fall back to generic save() if no stop hook handles persistence. Extend BaseProcessorSocket with recording context/save-path state, start/stop hook methods, and a stop(save=...) option. Update save() to use an explicit path or configured default path, create parent directories, and improve logging for skipped/failed saves. --- dlclivegui/gui/main_window.py | 98 +++++++++++++++++-- dlclivegui/processors/dlc_processor_socket.py | 78 +++++++++++++-- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 2bd273973..cb837bd91 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1863,6 +1863,7 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return + self._notify_processor_recording_started(run_dir) self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame) self.multi_camera_controller.set_recording_frame_is_enabled(True) @@ -1914,6 +1915,20 @@ def worker(): daemon=True, ).start() + def _get_dlc_processor_instance(self): + """Return the active custom DLC processor instance, if available.""" + processor = getattr(self._dlc, "_processor", None) + + if processor is not None: + return processor + + # Fallback: if DLCLive owns it internally. + dlc_obj = getattr(self._dlc, "_dlc", None) + if dlc_obj is not None: + return getattr(dlc_obj, "processor", None) + + return None + def _save_processor_data_if_available(self) -> None: """Best-effort generic processor save. @@ -1925,12 +1940,7 @@ def _save_processor_data_if_available(self) -> None: Return values are only logged; failure should not crash the GUI. """ - processor = getattr(self._dlc, "_processor", None) - - # Fallback in case DLCLive owns the processor but _processor was not updated. - if processor is None: - dlc_obj = getattr(self._dlc, "_dlc", None) - processor = getattr(dlc_obj, "processor", None) if dlc_obj is not None else None + processor = self._get_dlc_processor_instance() if processor is None: logger.debug("Processor save skipped: no processor instance available.") @@ -1947,8 +1957,82 @@ def _save_processor_data_if_available(self) -> None: except Exception: logger.exception("Processor save() failed.") + def _notify_processor_recording_started(self, run_dir) -> None: + processor = self._get_dlc_processor_instance() + if processor is None: + return + + hook = getattr(processor, "on_recording_started", None) + if not callable(hook): + return + + try: + context = self._build_processor_recording_context(run_dir) + hook(context) + logger.info("Notified processor recording started: %s", context) + except Exception: + logger.exception("Processor on_recording_started hook failed") + + from pathlib import Path + + def _build_processor_recording_context(self, run_dir) -> dict: + run_dir = Path(run_dir) if run_dir is not None else None + + file_context = {} + try: + file_context = self._rec_manager.get_recording_file_context() + except Exception: + logger.exception("Failed to get recording file context from RecordingManager") + file_context = {} + + if run_dir is None: + run_dir = file_context.get("run_dir", None) + if run_dir is not None: + run_dir = Path(run_dir) + + session_name = "" + if hasattr(self, "session_name_edit"): + session_name = self.session_name_edit.text().strip() + + filename = "" + if hasattr(self, "filename_edit"): + filename = self.filename_edit.text().strip() + + filename_stem = Path(filename or session_name or "recording").stem + + ctx = { + "run_dir": run_dir, + "session_name": session_name, + "filename": filename, + "filename_stem": filename_stem, + "processor_base_path": run_dir / filename_stem if run_dir is not None else None, + } + ctx.update(file_context) + return ctx + + def _notify_processor_recording_stopped(self) -> None: + processor = self._get_dlc_processor_instance() + if processor is None: + return False + + hook = getattr(processor, "on_recording_stopped", None) + if not callable(hook): + return False + + try: + run_dir = getattr(self._rec_manager, "run_dir", None) + context = self._build_processor_recording_context(run_dir) + hook(context) + logger.info("Notified processor recording stopped") + return True + except Exception: + logger.exception("Processor on_recording_stopped hook failed") + return False + def _on_recording_stopped_async(self) -> None: - self._save_processor_data_if_available() + handled_by_stop_hook = self._notify_processor_recording_stopped() + if not handled_by_stop_hook: + self._save_processor_data_if_available() self._recording_stopping = False self.start_record_button.setEnabled(True) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 1ab244f5b..5c28bc887 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -83,6 +83,8 @@ def __init__( self._vid_recording = Event() self.curr_step = 0 self.save_original = save_original + self.recording_context = {} + self.save_path = None # Networking (optional) self.address = bind @@ -262,8 +264,14 @@ def stop_recording(self): # STOP / SHUTDOWN # -------------------------------------------------------------------------------------- - def stop(self): + def stop(self, save: bool = False, file=None): """Gracefully stop listener and clients.""" + if save: + try: + self.save(file) + except Exception: + logger.exception("Failed to save processor data on stop") + if self._stop.is_set(): return @@ -367,23 +375,79 @@ def _clear_data_queues(self): self.original_pose.clear() def save(self, file=None): - if not file: + target = file + + if target is None: + target = getattr(self, "save_path", None) + + if target is None: + logger.warning("Processor save skipped: no file or save_path provided.") return 0 + try: save_dict = self.get_data() - path2save = Path(__file__).parent.parent.parent / "data" / file - path2save.parent.mkdir(parents=True, exist_ok=True) + save_path = Path(target) + save_path.parent.mkdir(parents=True, exist_ok=True) + if self.save_original: original_pose = save_dict.pop("original_pose") - self.save_original_pose(original_pose, save_dict["frame_time"], save_dict["time_stamp"], path2save) - with open(path2save, "wb") as f: + self.save_original_pose( + original_pose, + save_dict["frame_time"], + save_dict["time_stamp"], + save_path, + ) + + with open(save_path, "wb") as f: pickle.dump(save_dict, f) - logger.info(f"Saved data to {path2save}") + + logger.info(f"Saved processor data to {save_path}") return 1 + except Exception as e: logger.error(f"Save failed: {e}") return -1 + def set_recording_context(self, context: dict | None) -> None: + """Set GUI-provided recording context. + + This is intentionally generic. Custom processors may use it to derive + processor-specific output paths. + + Expected keys may include: + run_dir + session_name + filename + filename_stem + processor_base_path + video_files + timestamp_files + """ + self.recording_context = dict(context or {}) + + base_path = self.recording_context.get("processor_base_path") + if base_path is not None: + self.save_path = Path(base_path) + + def set_save_path(self, path) -> None: + """Set default save path used by save() when no file is provided.""" + self.save_path = Path(path) if path is not None else None + + def get_save_path(self): + """Return default save path, if any.""" + return getattr(self, "save_path", None) + + def on_recording_started(self, context: dict) -> None: + """Optional hook called by GUI when recording starts.""" + self.set_recording_context(context) + + def on_recording_stopped(self, context: dict) -> None: + """Optional hook called by GUI when recording stops. + + Base implementation only updates context. Custom processors can override. + """ + self.set_recording_context(context) + def save_original_pose( self, original_pose: np.ndarray, From eb416adb1382e946badc57e3032b291fef03a409 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 11:56:03 +0200 Subject: [PATCH 170/194] Add processor recording-context tests Expand custom processor test coverage around recording context and save-path behavior. This adds a new test module for BaseProcessorSocket and DLCLiveMainWindow recording hook interactions, including optional hook handling and processor lookup paths. The existing base processor tests were also cleaned up to use pytest `tmp_path` for file outputs instead of writing into module data directories, remove manual cleanup blocks, and tighten assertions/formatting for save and recording flows. --- .../custom_processors/test_base_processor.py | 191 +++++------- .../test_processor_rec_context.py | 292 ++++++++++++++++++ 2 files changed, 363 insertions(+), 120 deletions(-) create mode 100644 tests/custom_processors/test_processor_rec_context.py diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index 8711eec11..cb8d2e7f8 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -1,9 +1,11 @@ -# tests/processors/test_dlc_processor_socket.py +# tests/custom_processors/test_base_processor.py from __future__ import annotations import importlib import pickle from pathlib import Path +import sys +import types import numpy as np import pandas as pd @@ -26,11 +28,6 @@ def example_processor_mod(): return importlib.import_module("dlclivegui.processors.examples") -def _module_data_dir(socket_mod) -> Path: - """Compute the data/ directory where save() writes artifacts.""" - return Path(socket_mod.__file__).parent.parent.parent / "data" - - def _mk_bodyparts(n: int) -> list[str]: return [f"bp{i}" for i in range(n)] @@ -41,7 +38,6 @@ def _mk_pose(n_keypoints: int = 5) -> np.ndarray: Base class does not interpret pose content—only broadcasts/logs it. """ pose = np.zeros((n_keypoints, 3), dtype=float) - # Fill with simple coordinates & confidence for i in range(n_keypoints): pose[i, :] = [10.0 + i, 20.0 + i, 0.9] return pose @@ -53,25 +49,25 @@ def test_base_init_and_stop(socket_mod): and ensure stop() is idempotent. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket - proc = BaseProcessorSocket(bind=("127.0.0.1", 0), use_perf_counter=True, save_original=False) + proc = BaseProcessorSocket( + bind=("127.0.0.1", 0), + use_perf_counter=True, + save_original=False, + ) try: - # Core attributes exist assert hasattr(proc, "listener") assert callable(proc.timing_func) - # perf_counter chosen + import time as _t assert proc.timing_func is _t.perf_counter - # Initial flags & counters assert proc.recording is False assert proc.video_recording is False assert proc.curr_step == 0 assert isinstance(proc.conns, set) finally: - # stop must be safe and idempotent proc.stop() - proc.stop() # second call should be a no-op def test_base_recording_flags_and_session_name(socket_mod): @@ -81,23 +77,19 @@ def test_base_recording_flags_and_session_name(socket_mod): BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) try: - # Start recording proc._handle_client_message({"cmd": "start_recording"}) assert proc.recording is True assert proc.video_recording is True - assert proc.curr_step == 0 # reset + assert proc.curr_step == 0 - # Set a session name proc._handle_client_message({"cmd": "set_session_name", "session_name": "unit_test"}) assert proc.session_name == "unit_test" assert proc.filename == "unit_test_dlc_processor_data.pkl" - # Stop recording proc._handle_client_message({"cmd": "stop_recording"}) assert proc.recording is False assert proc.video_recording is False - # Unknown / invalid messages must not crash proc._handle_client_message(None) proc._handle_client_message({"cmd": "does_not_exist"}) finally: @@ -109,27 +101,27 @@ def test_base_process_without_and_with_recording(socket_mod): BaseProcessorSocket.process() should: - increment curr_step always, - when recording, append time/step/frame_time/pose_time, - - when save_original=True, store copies of pose arrays only while recording. + - when save_original=True, store copies of pose arrays only while recording. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) + try: pose = _mk_pose() - # Not recording yet: curr_step increments, no logs appended before_step = proc.curr_step ret = proc.process(pose, frame_time=0.012, pose_time=0.013) + assert ret is pose assert proc.curr_step == before_step + 1 assert len(proc.time_stamp) == 0 assert len(proc.step) == 0 assert len(proc.frame_time) == 0 assert len(proc.pose_time) == 0 - # Raw poses must stay aligned with recorded metadata. + assert proc.original_pose is not None assert len(proc.original_pose) == 0 - # Start recording and push two frames proc._handle_client_message({"cmd": "start_recording"}) for _ in range(2): proc.process(pose, frame_time=0.01, pose_time=0.011) @@ -139,25 +131,23 @@ def test_base_process_without_and_with_recording(socket_mod): assert len(proc.frame_time) == 2 assert len(proc.pose_time) == 2 assert len(proc.original_pose) == 2 + np.testing.assert_allclose(proc.original_pose[0], pose) np.testing.assert_allclose(proc.original_pose[1], pose) - # Data snapshot integrity data = proc.get_data() assert "start_time" in data assert isinstance(data["time_stamp"], np.ndarray) assert isinstance(data["step"], np.ndarray) assert isinstance(data["frame_time"], np.ndarray) - # pose_time can be None if never provided; here it is provided. assert isinstance(data["pose_time"], np.ndarray) - # original_pose is included when save_original=True assert isinstance(data["original_pose"], np.ndarray) finally: proc.stop() -def test_save_ignores_pre_recording_original_pose_frames(socket_mod): +def test_save_ignores_pre_recording_original_pose_frames(socket_mod, tmp_path): """ save_original data must stay aligned with recorded metadata even if process() is called before recording starts. @@ -183,18 +173,16 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = "unit_test_pre_recording_frames.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_pre_recording_frames.pkl" + h5_path = tmp_path / "unit_test_pre_recording_frames_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) assert len(payload["frame_time"]) == 2 @@ -208,11 +196,6 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): finally: proc.stop() - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass @pytest.mark.parametrize( @@ -223,7 +206,11 @@ def test_save_ignores_pre_recording_original_pose_frames(socket_mod): ], ) def test_subclass_save_ignores_pre_recording_original_pose_frames( - socket_mod, example_processor_mod, class_name, n_keypoints + socket_mod, + example_processor_mod, + class_name, + n_keypoints, + tmp_path, ): """ Concrete processors must keep original_pose aligned with recorded metadata @@ -249,18 +236,16 @@ def test_subclass_save_ignores_pre_recording_original_pose_frames( proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = f"unit_test_{class_name}.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / f"unit_test_{class_name}.pkl" + h5_path = tmp_path / f"unit_test_{class_name}_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) assert len(payload["frame_time"]) == 3 @@ -274,11 +259,6 @@ def test_subclass_save_ignores_pre_recording_original_pose_frames( finally: proc.stop() - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass def test_base_broadcast_handles_bad_connections(socket_mod): @@ -289,7 +269,6 @@ def test_base_broadcast_handles_bad_connections(socket_mod): class BadConn: def __init__(self): - # Minimal attributes to satisfy _close_conn class Sock: def shutdown(self, *_args, **_kwargs): raise RuntimeError("shutdown fail") @@ -303,7 +282,6 @@ def close(self): raise RuntimeError("close fail") def __hash__(self): - # allow put in a set return id(self) def __eq__(self, other): @@ -311,18 +289,20 @@ def __eq__(self, other): BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + try: bad = BadConn() proc.conns.add(bad) - # Should not raise + proc.broadcast(["ts", "payload"]) - # bad conn should be discarded + assert bad not in proc.conns + finally: proc.stop() -def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): +def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, tmp_path, caplog): """ End-to-end save() with save_original=True and a matching dlc_cfg bodypart list. Verifies: @@ -339,56 +319,47 @@ def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): dlc_cfg = {"metadata": {"bodyparts": bodyparts}} proc.set_dlc_cfg(dlc_cfg) - # create 3 frames pose = _mk_pose(n_keypoints=n_keypoints) + proc._handle_client_message({"cmd": "start_recording"}) for _ in range(3): proc.process(pose, frame_time=0.01, pose_time=0.011) proc._handle_client_message({"cmd": "stop_recording"}) - # deterministic relative filename - filename = "unit_test_session.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_session.pkl" + h5_path = tmp_path / "unit_test_session_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists(), f"Missing {pkl_path}" assert h5_path.exists(), f"Missing {h5_path}" - # verify pkl payload - with open(pkl_path, "rb") as f: + with pkl_path.open("rb") as f: payload = pickle.load(f) - assert "original_pose" not in payload # popped out before pickling + assert "original_pose" not in payload assert "dlc_cfg" in payload assert payload["dlc_cfg"] == dlc_cfg - # verify HDF5 contents (skip if tables is not installed) pytest.importorskip("tables") df = pd.read_hdf(h5_path, key="df_with_missing") - # Expect rows == frames + assert df.shape[0] == 3 - # Confirm the labeled columns exist for all bodyparts x (x, y, likelihood) expected_cols = pd.MultiIndex.from_product( [bodyparts, ["x", "y", "likelihood"]], names=["bodyparts", "coords"], ) - # Some pandas versions will allow mixing multiindex + string cols; - # so just check presence of expected label tuples: + for col in expected_cols: assert col in df.columns - # frame_time & pose_time columns are present assert "frame_time" in df.columns assert "pose_time" in df.columns assert list(df["frame_time"]) == [0.01, 0.01, 0.01] assert list(df["pose_time"]) == list(payload["time_stamp"]) - # sanity check values for first row for i, bp in enumerate(bodyparts): assert np.isclose(df[(bp, "x")].iloc[0], 10.0 + i) assert np.isclose(df[(bp, "y")].iloc[0], 20.0 + i) @@ -396,61 +367,45 @@ def test_save_writes_pkl_and_hdf5_with_labels(socket_mod, caplog): finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass -def test_save_without_dlc_cfg_unlabeled_columns(socket_mod, caplog): +def test_save_without_dlc_cfg_unlabeled_columns(socket_mod, tmp_path, caplog): """ Ensure that without dlc_cfg, save() still writes HDF5 with unlabeled columns - and logs a warning (no crash). + and logs a warning or at least succeeds without crashing. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) try: pose = _mk_pose(3) + proc._handle_client_message({"cmd": "start_recording"}) proc.process(pose, frame_time=0.01, pose_time=0.02) proc._handle_client_message({"cmd": "stop_recording"}) - filename = "unit_test_no_dlc_cfg.pkl" - ret = proc.save(filename) - assert ret == 1 + pkl_path = tmp_path / "unit_test_no_dlc_cfg.pkl" + h5_path = tmp_path / "unit_test_no_dlc_cfg_DLC.hdf5" - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") + ret = proc.save(pkl_path) + assert ret == 1 assert pkl_path.exists() assert h5_path.exists() - # Check warning logged - # (Depending on logger config in tests, you may need to set level to capture warnings) + # Depending on logger config in tests, caplog may or may not catch this. [rec for rec in caplog.records if "saving without column labels" in rec.message] - # It's okay if caplog didn't catch it due to logger level; we mainly ensure no crash and files exist. - # Verify HDF5 loads (skip if tables not installed) pytest.importorskip("tables") df = pd.read_hdf(h5_path, key="df_with_missing") - assert df.shape[0] == 1 # 1 frame saved - # Expect unlabeled numeric columns for pose plus "frame_time" and "pose_time" - # We can't rely on a MultiIndex here; just ensure numeric columns exist + + assert df.shape[0] == 1 + numeric_cols = [c for c in df.columns if c not in ("frame_time", "pose_time")] - assert len(numeric_cols) == 3 * 3 # 3 keypoints * 3 coords + assert len(numeric_cols) == 3 * 3 finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass def test_get_data_includes_dlc_cfg(socket_mod): @@ -459,38 +414,34 @@ def test_get_data_includes_dlc_cfg(socket_mod): """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + try: dlc_cfg = {"metadata": {"bodyparts": ["a", "b"]}} proc.set_dlc_cfg(dlc_cfg) + data = proc.get_data() + assert "dlc_cfg" in data assert data["dlc_cfg"] == dlc_cfg + finally: proc.stop() -def test_save_handles_empty_original_pose(socket_mod): +def test_save_handles_empty_original_pose(socket_mod, tmp_path): """ - With save_original=True but no process() calls, save() should not crash. - Depending on pandas behavior, HDF5 should exist with 0 rows or be created successfully. + With save_original=True but no process() calls, save() should not raise. + The exact return value is implementation-dependent for empty data. """ BaseProcessorSocket = socket_mod.BaseProcessorSocket proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=True) + try: - filename = "unit_test_empty_original.pkl" - ret = proc.save(filename) - # If nothing to save, your implementation returns 1 (saved) or could be 0; current code returns 1 - assert ret in (1, 0, -1) # accept current behavior; adjust if you standardize - data_dir = _module_data_dir(socket_mod) - pkl_path = data_dir / filename - h5_path = data_dir / (Path(filename).stem + "_DLC.hdf5") - # pkl exists if ret == 1; hdf5 may or may not depending on your final logic - # Leave assertions lenient; the main check is that no exception bubbles up. + pkl_path = tmp_path / "unit_test_empty_original.pkl" + + ret = proc.save(pkl_path) + + assert ret in (1, 0, -1) + finally: proc.stop() - # cleanup - try: - pkl_path.unlink(missing_ok=True) - h5_path.unlink(missing_ok=True) - except Exception: - pass diff --git a/tests/custom_processors/test_processor_rec_context.py b/tests/custom_processors/test_processor_rec_context.py new file mode 100644 index 000000000..16a1461d9 --- /dev/null +++ b/tests/custom_processors/test_processor_rec_context.py @@ -0,0 +1,292 @@ +# tests/processors/test_processor_recording_context.py +from __future__ import annotations + +import importlib +import pickle +import sys +import types +from types import SimpleNamespace + +import pytest + +# ----------------------------------------------------------------------------- +# Shared fixtures +# ----------------------------------------------------------------------------- + + +def _mock_dlclive(monkeypatch): + """Install a tiny dlclive.processor.Processor mock before importing processors.""" + + class Processor: + def __init__(self, *args, **kwargs): + pass + + def process(self, pose, **kwargs): + return pose + + dlclive_mod = types.ModuleType("dlclive") + processor_mod = types.ModuleType("dlclive.processor") + + dlclive_mod.Processor = Processor + processor_mod.Processor = Processor + + monkeypatch.setitem(sys.modules, "dlclive", dlclive_mod) + monkeypatch.setitem(sys.modules, "dlclive.processor", processor_mod) + + +@pytest.fixture +def socket_mod(monkeypatch): + """Import dlclivegui.processors.dlc_processor_socket with dlclive mocked.""" + _mock_dlclive(monkeypatch) + mod_name = "dlclivegui.processors.dlc_processor_socket" + sys.modules.pop(mod_name, None) + return importlib.import_module(mod_name) + + +class DummyLineEdit: + def __init__(self, value: str): + self._value = value + + def text(self) -> str: + return self._value + + +class HookProcessor: + def __init__(self): + self.started_contexts = [] + self.stopped_contexts = [] + self.save_calls = 0 + + def on_recording_started(self, context): + self.started_contexts.append(context) + + def on_recording_stopped(self, context): + self.stopped_contexts.append(context) + + def save(self): + self.save_calls += 1 + return 1 + + +class SaveOnlyProcessor: + def __init__(self): + self.save_calls = 0 + + def save(self): + self.save_calls += 1 + return 1 + + +# ----------------------------------------------------------------------------- +# BaseProcessorSocket generic recording-context API +# ----------------------------------------------------------------------------- + + +def test_base_processor_recording_context_sets_save_path(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + + try: + base_path = tmp_path / "MouseA_2026-07-10_1" + context = { + "run_dir": tmp_path, + "session_name": "MouseA", + "filename": "MouseA_2026-07-10_1.avi", + "filename_stem": "MouseA_2026-07-10_1", + "processor_base_path": base_path, + } + + proc.set_recording_context(context) + + assert proc.recording_context == context + assert proc.get_save_path() == base_path + assert proc.save_path == base_path + + finally: + proc.stop() + + +def test_base_processor_recording_hooks_update_context(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0)) + + try: + started_context = {"processor_base_path": tmp_path / "started"} + stopped_context = {"processor_base_path": tmp_path / "stopped"} + + proc.on_recording_started(started_context) + assert proc.recording_context == started_context + assert proc.get_save_path() == tmp_path / "started" + + proc.on_recording_stopped(stopped_context) + assert proc.recording_context == stopped_context + assert proc.get_save_path() == tmp_path / "stopped" + + finally: + proc.stop() + + +def test_base_processor_save_uses_save_path_when_file_is_none(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + save_path = tmp_path / "legacy" / "MouseA_2026-07-10_1_PROC" + proc.set_save_path(save_path) + + proc.start_recording() + proc.process([[1.0, 2.0, 0.9]], frame_time=12.34, pose_time=12.35) + proc.stop_recording() + + ret = proc.save() + assert ret == 1 + assert save_path.exists() + + with save_path.open("rb") as f: + payload = pickle.load(f) + + assert "time_stamp" in payload + assert "step" in payload + assert "frame_time" in payload + assert len(payload["frame_time"]) == 1 + + finally: + proc.stop() + + +def test_base_processor_save_writes_to_explicit_absolute_file(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + explicit_path = tmp_path / "explicit" / "processor_data.pkl" + ret = proc.save(explicit_path) + + assert ret == 1 + assert explicit_path.exists() + + with explicit_path.open("rb") as f: + payload = pickle.load(f) + + assert "start_time" in payload + + finally: + proc.stop() + + +def test_base_processor_save_without_file_or_save_path_returns_zero(socket_mod): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + try: + proc.save_path = None + assert proc.save() == 0 + finally: + proc.stop() + + +def test_base_processor_stop_save_true_uses_save_path(socket_mod, tmp_path): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket(bind=("127.0.0.1", 0), save_original=False) + + save_path = tmp_path / "processor_on_stop.pkl" + proc.set_save_path(save_path) + + # stop(save=True) should save, close listener, and be idempotent. + proc.stop(save=True) + assert save_path.exists() + assert proc.listener is None + + proc.stop(save=True) # no crash + + +# ----------------------------------------------------------------------------- +# DLCLiveMainWindow recording-context helpers +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def main_window_cls(): + pytest.importorskip("PySide6") + mod = importlib.import_module("dlclivegui.gui.main_window") + return mod.DLCLiveMainWindow + + +def make_window_shell(main_window_cls, processor=None, run_dir=None): + """Create a DLCLiveMainWindow shell without running QMainWindow.__init__.""" + win = main_window_cls.__new__(main_window_cls) + win._dlc = SimpleNamespace(_processor=processor, _dlc=None) + win._rec_manager = SimpleNamespace(run_dir=run_dir) + win.session_name_edit = DummyLineEdit("MouseA") + win.filename_edit = DummyLineEdit("MouseA_2026-07-10_1.avi") + return win + + +def test_main_window_build_processor_recording_context(main_window_cls, tmp_path): + win = make_window_shell(main_window_cls, run_dir=tmp_path) + + context = win._build_processor_recording_context(tmp_path) + + assert context["run_dir"] == tmp_path + assert context["session_name"] == "MouseA" + assert context["filename"] == "MouseA_2026-07-10_1.avi" + assert context["filename_stem"] == "MouseA_2026-07-10_1" + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_get_processor_instance_prefers_direct_processor(main_window_cls): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor) + + assert win._get_dlc_processor_instance() is processor + + +def test_main_window_get_processor_instance_falls_back_to_dlclive_processor(main_window_cls): + processor = HookProcessor() + win = main_window_cls.__new__(main_window_cls) + win._dlc = SimpleNamespace(_processor=None, _dlc=SimpleNamespace(processor=processor)) + + assert win._get_dlc_processor_instance() is processor + + +def test_main_window_notify_processor_recording_started_calls_hook(main_window_cls, tmp_path): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._notify_processor_recording_started(tmp_path) + + assert len(processor.started_contexts) == 1 + context = processor.started_contexts[0] + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_notify_processor_recording_stopped_calls_hook(main_window_cls, tmp_path): + processor = HookProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._notify_processor_recording_stopped() + + assert len(processor.stopped_contexts) == 1 + context = processor.stopped_contexts[0] + assert context["processor_base_path"] == tmp_path / "MouseA_2026-07-10_1" + + +def test_main_window_save_processor_data_calls_save(main_window_cls, tmp_path): + processor = SaveOnlyProcessor() + win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) + + win._save_processor_data_if_available() + + assert processor.save_calls == 1 + + +def test_main_window_processor_hooks_are_optional(main_window_cls, tmp_path): + class NoHooks: + pass + + win = make_window_shell(main_window_cls, processor=NoHooks(), run_dir=tmp_path) + + # Optional hooks/save absence should not crash. + win._notify_processor_recording_started(tmp_path) + win._notify_processor_recording_stopped() + win._save_processor_data_if_available() From 1fc8210d3be1b656448d1aa0f90cc76104c774c9 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 16:09:27 +0200 Subject: [PATCH 171/194] Remove custom proc testing folder --- .../custom/mock_socket_processor.py | 532 -- .../custom/mock_unity_socket_client.ipynb | 4609 ----------------- 2 files changed, 5141 deletions(-) delete mode 100644 dlclivegui/processors/custom/mock_socket_processor.py delete mode 100644 dlclivegui/processors/custom/mock_unity_socket_client.ipynb diff --git a/dlclivegui/processors/custom/mock_socket_processor.py b/dlclivegui/processors/custom/mock_socket_processor.py deleted file mode 100644 index 788bde3df..000000000 --- a/dlclivegui/processors/custom/mock_socket_processor.py +++ /dev/null @@ -1,532 +0,0 @@ -"""Standalone mock DLC processor plugin with socket listener support. - -This fixture intentionally avoids importing dlclive, dlclivegui, Teensy, serial, -NumPy, or project-specific processor base classes. - -It is designed to test two separate concerns without mixing them: - -1. GUI processor plugin discovery/configuration - - Exposes PROCESSOR_* metadata. - - Exposes PROCESSOR_BUILD_IN_WORKER = True. - - Exposes get_available_processors(), which your loader can consume without - requiring this class to inherit from dlclive.processor.Processor. - -2. Runtime socket listener behavior - - Starts a multiprocessing.connection.Listener. - - Accepts one or more clients on a background thread. - - Receives simple command dictionaries from clients. - - Broadcasts mock pose payloads to connected clients from process(). - -It does NOT mock Teensy serial acquisition. For the listener send/receive tests, -Teensy is not required: the Teensy path is a separate serial-reader concern. -""" - -from __future__ import annotations - -import logging -import pickle -import sys -import time -from collections import deque -from multiprocessing.connection import Client, Listener -from pathlib import Path -from threading import Event, Lock, Thread -from typing import Any - -logger = logging.getLogger(__name__) - -IP_ADDRESS = "127.0.0.1" -PORT = 6000 - - -class MockSocketProcessor: - """Standalone socket-based mock processor for tests. - - This intentionally reimplements the core listener/thread/control behavior - instead of inheriting from project classes. It is suitable for fixture usage - where external dependencies such as Teensy, serial, dlclive, or dlclivegui - should not be imported. - - Expected test usage: - - proc = MockSocketProcessor(bind=("127.0.0.1", free_port())) - conn = Client(proc.address, authkey=proc.authkey) - conn.send({"cmd": "ping"}) - assert conn.recv()["type"] == "pong" - proc.process([[1, 2, 0.9]]) - assert conn.recv()["type"] == "pose" - proc.stop() - - Notes: - - `process()` accepts any pose-like Python object. It does not validate - DLC shape because this mock is for socket lifecycle tests, not pose - validation tests. - - Payloads are sent through multiprocessing.connection, matching the - style used by the legacy socket processors. - """ - - PROCESSOR_NAME = "Mock Socket Processor" - PROCESSOR_DESCRIPTION = "Standalone mock socket processor without Teensy or DLCLive imports." - PROCESSOR_BUILD_IN_WORKER = False - PROCESSOR_PARAMS = { - "bind": { - "type": "tuple", - "default": (IP_ADDRESS, PORT), - "description": "Server bind address. Use port 0 to request an ephemeral port.", - }, - "authkey": { - "type": "bytes", - "default": b"secret password", - "description": "Authentication key for multiprocessing.connection clients.", - }, - "start_server": { - "type": "bool", - "default": True, - "description": "Whether to start the listener in __init__.", - }, - "socket_timeout": { - "type": "float", - "default": 0.05, - "description": "Accept-loop timeout in seconds.", - }, - "save_original": { - "type": "bool", - "default": False, - "description": "Whether to store raw pose payloads while recording.", - }, - } - - def __init__( - self, - bind: tuple[str, int] = (IP_ADDRESS, PORT), - authkey: bytes = b"secret password", - *, - start_server: bool = True, - socket_timeout: float = 0.05, - save_original: bool = False, - ) -> None: - self.address = bind - self.authkey = authkey - self._socket_timeout = float(socket_timeout) - self.save_original = bool(save_original) - - # Runtime listener/client state. - self.listener: Listener | None = None - self.conns: set[Any] = set() - self._conns_lock = Lock() - self._stop = Event() - self._accept_thread: Thread | None = None - self._rx_threads: set[Thread] = set() - - # Recording/control state compatible with socket-processor expectations. - self._recording = Event() - self._vid_recording = Event() - self._session_name = "test_session" - self.filename: str | None = None - - # Minimal data buffers for save/get_data tests. - self.start_time = time.time() - self.time_stamp = deque() - self.step = deque() - self.frame_time = deque() - self.pose_time = deque() - self.original_pose = deque() if self.save_original else None - self.received_commands = deque() - self.broadcast_count = 0 - self.curr_step = 0 - - if start_server: - self.start_server(bind, authkey=authkey, timeout=self._socket_timeout) - - # ------------------------------------------------------------------ - # Properties matching the real socket processors - # ------------------------------------------------------------------ - @property - def recording(self) -> bool: - return self._recording.is_set() - - @property - def video_recording(self) -> bool: - return self._vid_recording.is_set() - - @property - def session_name(self) -> str: - return self._session_name - - @session_name.setter - def session_name(self, name: str) -> None: - self._session_name = str(name) - self.filename = f"{self._session_name}_mock_processor_data.pkl" - - # ------------------------------------------------------------------ - # Listener lifecycle - # ------------------------------------------------------------------ - def start_server( - self, - bind: tuple[str, int] | None = None, - authkey: bytes | None = None, - *, - timeout: float | None = None, - ) -> None: - """Start the socket listener if it is not already running.""" - if self.listener is not None: - return - - if bind is not None: - self.address = bind - if authkey is not None: - self.authkey = authkey - if timeout is not None: - self._socket_timeout = float(timeout) - - self._stop.clear() - self.listener = Listener(self.address, authkey=self.authkey) - - # If bind used port 0, update address to the actual ephemeral port. - self.address = self._actual_listener_address(self.listener, fallback=self.address) - - self._set_listener_timeout(self.listener, self._socket_timeout) - - self._accept_thread = Thread(target=self._accept_loop, name="MockSocketProcessorAccept", daemon=True) - self._accept_thread.start() - logger.info("MockSocketProcessor listening on %s:%s", self.address[0], self.address[1]) - - @staticmethod - def _actual_listener_address(listener: Listener, fallback: tuple[str, int]) -> tuple[str, int]: - """Best-effort extraction of the actual listener address.""" - try: - raw = getattr(listener, "_listener", None) - sock = getattr(raw, "_socket", None) - if sock is not None: - addr = sock.getsockname() - return (str(addr[0]), int(addr[1])) - except Exception: - pass - try: - addr = listener.address - return (str(addr[0]), int(addr[1])) - except Exception: - return fallback - - @staticmethod - def _set_listener_timeout(listener: Listener, timeout: float) -> None: - """Set accept timeout on CPython listener internals, best effort.""" - raw = getattr(listener, "_listener", None) - for candidate in (raw, getattr(raw, "_socket", None)): - try: - if candidate is not None and hasattr(candidate, "settimeout"): - candidate.settimeout(timeout) - return - except Exception: - pass - - def _accept_loop(self) -> None: - while not self._stop.is_set(): - try: - if self.listener is None: - return - conn = self.listener.accept() - except TimeoutError: - continue - except (OSError, EOFError): - if self._stop.is_set(): - break - continue - except Exception: - if self._stop.is_set(): - break - logger.exception("Unexpected accept-loop error") - continue - - with self._conns_lock: - self.conns.add(conn) - - rx = Thread(target=self._rx_loop, args=(conn,), name="MockSocketProcessorRx", daemon=True) - self._rx_threads.add(rx) - rx.start() - logger.info("MockSocketProcessor client connected") - - def _rx_loop(self, conn: Any) -> None: - while not self._stop.is_set(): - try: - if conn.poll(0.05): - msg = conn.recv() - self._handle_client_message(msg, conn=conn) - continue - - if getattr(conn, "closed", False): - break - - except (EOFError, OSError, ConnectionError, BrokenPipeError): - break - except Exception: - logger.exception("Unexpected receive-loop error") - break - - self._close_conn(conn) - - def _close_conn(self, conn: Any) -> None: - try: - conn.close() - except Exception: - pass - with self._conns_lock: - self.conns.discard(conn) - - def stop(self) -> None: - """Stop listener, close clients, and join background threads best-effort.""" - if self._stop.is_set(): - return - - self._stop.set() - - # Wake accept() if needed. - try: - Client(self.address, authkey=self.authkey).close() - except Exception: - pass - - with self._conns_lock: - conns = list(self.conns) - for conn in conns: - self._close_conn(conn) - - try: - if self.listener is not None: - self.listener.close() - except Exception: - pass - self.listener = None - - if self._accept_thread is not None: - self._accept_thread.join(timeout=1.0) - self._accept_thread = None - - for thread in list(self._rx_threads): - try: - thread.join(timeout=0.5) - except Exception: - pass - self._rx_threads.clear() - - if sys.platform.startswith("win"): - time.sleep(0.05) - - close = stop - - def __del__(self) -> None: - try: - self.stop() - except Exception: - pass - - # ------------------------------------------------------------------ - # Client command handling - # ------------------------------------------------------------------ - def _handle_client_message(self, msg: Any, *, conn: Any | None = None) -> None: - self.received_commands.append(msg) - - if not isinstance(msg, dict): - self._send_to(conn, {"type": "error", "error": "message must be a dict"}) - return - - cmd = msg.get("cmd") - - if cmd == "ping": - self._send_to( - conn, - { - "type": "pong", - "timestamp": time.time(), - "session_name": self.session_name, - "recording": self.recording, - "video_recording": self.video_recording, - "clients": self.client_count(), - }, - ) - - elif cmd == "status": - self._send_to(conn, self.status_payload()) - - elif cmd == "set_session_name": - self.session_name = msg.get("session_name", "default_session") - self._send_to(conn, {"type": "ack", "cmd": cmd, "session_name": self.session_name}) - - elif cmd == "start_recording": - self.start_recording() - self._send_to(conn, {"type": "ack", "cmd": cmd, "recording": True}) - - elif cmd == "stop_recording": - self.stop_recording() - self._send_to(conn, {"type": "ack", "cmd": cmd, "recording": False}) - - elif cmd == "save": - file = msg.get("filename", self.filename) - result = self.save(file) - self._send_to(conn, {"type": "ack", "cmd": cmd, "result": result, "filename": file}) - - elif cmd == "close": - self._send_to(conn, {"type": "ack", "cmd": cmd}) - if conn is not None: - self._close_conn(conn) - - else: - self._send_to(conn, {"type": "error", "error": f"unknown cmd: {cmd!r}"}) - - @staticmethod - def _send_to(conn: Any | None, payload: Any) -> bool: - if conn is None: - return False - try: - conn.send(payload) - return True - except Exception: - return False - - def client_count(self) -> int: - with self._conns_lock: - return len(self.conns) - - def status_payload(self) -> dict[str, Any]: - return { - "type": "status", - "session_name": self.session_name, - "recording": self.recording, - "video_recording": self.video_recording, - "clients": self.client_count(), - "steps": self.curr_step, - "broadcast_count": self.broadcast_count, - "address": self.address, - } - - # ------------------------------------------------------------------ - # Recording helpers - # ------------------------------------------------------------------ - def start_recording(self) -> None: - self._recording.set() - self._vid_recording.set() - self._clear_data_queues() - self.curr_step = 0 - - def stop_recording(self) -> None: - self._recording.clear() - self._vid_recording.clear() - - def _clear_data_queues(self) -> None: - self.time_stamp.clear() - self.step.clear() - self.frame_time.clear() - self.pose_time.clear() - if self.original_pose is not None: - self.original_pose.clear() - - # ------------------------------------------------------------------ - # Process/broadcast path - # ------------------------------------------------------------------ - def process(self, pose: Any, **kwargs: Any) -> Any: - """Mock DLCLive processor callback. - - Records minimal metadata when recording is active and broadcasts a simple - pose payload to all connected clients. - """ - now = time.time() - self.curr_step += 1 - - if self.recording: - self.time_stamp.append(now) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) - if self.original_pose is not None: - self.original_pose.append(pose) - - payload = { - "type": "pose", - "timestamp": now, - "step": self.curr_step, - "pose": self._make_pickle_safe_pose(pose), - "frame_time": kwargs.get("frame_time", None), - "pose_time": kwargs.get("pose_time", None), - "recording": self.recording, - } - self.broadcast(payload) - return pose - - @staticmethod - def _make_pickle_safe_pose(pose: Any) -> Any: - """Convert common array-likes to socket-safe Python types.""" - tolist = getattr(pose, "tolist", None) - if callable(tolist): - try: - return tolist() - except Exception: - pass - return pose - - def broadcast(self, payload: Any) -> None: - with self._conns_lock: - conns = list(self.conns) - - dead = [] - for conn in conns: - try: - conn.send(payload) - self.broadcast_count += 1 - except Exception: - dead.append(conn) - - for conn in dead: - self._close_conn(conn) - - # ------------------------------------------------------------------ - # Save/get_data helpers - # ------------------------------------------------------------------ - def get_data(self) -> dict[str, Any]: - return { - "start_time": self.start_time, - "session_name": self.session_name, - "time_stamp": list(self.time_stamp), - "step": list(self.step), - "frame_time": list(self.frame_time), - "pose_time": list(self.pose_time), - "recording": self.recording, - "video_recording": self.video_recording, - "received_commands": list(self.received_commands), - "broadcast_count": self.broadcast_count, - } - - def save(self, file: str | Path | None = None) -> int: - if not file: - return 0 - try: - path = Path(file) - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as fh: - pickle.dump(self.get_data(), fh) - return 1 - except Exception: - logger.exception("MockSocketProcessor save failed") - return -1 - - -# Optional aliases useful in different test styles. -MockPDSocketProcessor = MockSocketProcessor -MockUnitySocketProcessor = MockSocketProcessor - - -def get_available_processors() -> dict[str, dict[str, Any]]: - """Plugin-discovery entrypoint used by dlclivegui.processor_utils. - - This avoids requiring the class to inherit from dlclive.processor.Processor - during tests. The loader path that prefers get_available_processors() can - still discover this processor as a GUI plugin fixture. - """ - return { - "MockSocketProcessor": { - "class": MockSocketProcessor, - "name": getattr(MockSocketProcessor, "PROCESSOR_NAME", "MockSocketProcessor"), - "description": getattr(MockSocketProcessor, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(MockSocketProcessor, "PROCESSOR_PARAMS", {}), - } - } diff --git a/dlclivegui/processors/custom/mock_unity_socket_client.ipynb b/dlclivegui/processors/custom/mock_unity_socket_client.ipynb deleted file mode 100644 index 63e4569a1..000000000 --- a/dlclivegui/processors/custom/mock_unity_socket_client.ipynb +++ /dev/null @@ -1,4609 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", - "metadata": {}, - "source": [ - "# Mock Unity Socket Client for DLCLive Processor\n", - "\n", - "This notebook acts as the **Unity-side socket client** for the legacy DLC socket processor.\n", - "\n", - "## Which processor style this targets\n", - "\n", - "The legacy processor chain you showed is:\n", - "\n", - "```text\n", - "dlc_inference_w_pd_sync\n", - " -> dlc_inference_w_pd\n", - " -> MyProcessor_socket\n", - "```\n", - "\n", - "`MyProcessor_socket` opens a `multiprocessing.connection.Listener`, defaulting to:\n", - "\n", - "```python\n", - "(\"127.0.0.1\", 6000)\n", - "authkey=b\"secret password\"\n", - "```\n", - "\n", - "It sends payloads from `process()` shaped like:\n", - "\n", - "```python\n", - "[time.time(), x, y, heading, head_angle, signal]\n", - "```\n", - "\n", - "This notebook connects as the **client** and tries to catch those pose/kinematics packets.\n", - "\n", - "## Important ordering note\n", - "\n", - "The legacy `MyProcessor_socket` does **not** have a background accept thread. It accepts a client only when `process()` runs. That means if DLC inference is not producing poses yet, `Client(...)` may block or time out. If that happens, start/continue DLC inference and retry." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "acae54e37e7d407bbb7b55eff062a284", - "metadata": {}, - "outputs": [], - "source": [ - "from __future__ import annotations\n", - "\n", - "import json\n", - "import queue\n", - "import threading\n", - "import time\n", - "from dataclasses import asdict, dataclass\n", - "from multiprocessing.connection import Client\n", - "from pathlib import Path\n", - "from typing import Any" - ] - }, - { - "cell_type": "markdown", - "id": "9a63283cbaf04dbcab1f6479b197f3a8", - "metadata": {}, - "source": [ - "## Configuration\n", - "\n", - "Adjust these if the processor uses a different port or auth key.\n", - "\n", - "For the legacy processor, the defaults are usually:\n", - "\n", - "```python\n", - "ADDRESS = (\"127.0.0.1\", 6000)\n", - "AUTHKEY = b\"secret password\"\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "8dd0d8092fe74a7c96281538738b07e2", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Target processor socket: ('127.0.0.1', 6000)\n" - ] - } - ], - "source": [ - "ADDRESS = (\"127.0.0.1\", 6000)\n", - "AUTHKEY = b\"secret password\"\n", - "\n", - "# How long to wait for a connection attempt before considering it failed.\n", - "CONNECT_TIMEOUT_S = 10.0\n", - "\n", - "# How long the receive loop should poll while waiting for new packets.\n", - "POLL_INTERVAL_S = 0.05\n", - "\n", - "print(\"Target processor socket:\", ADDRESS)" - ] - }, - { - "cell_type": "markdown", - "id": "72eea5119410473aa328ad9291626812", - "metadata": {}, - "source": [ - "## Client helpers\n", - "\n", - "`multiprocessing.connection.Client(...)` can block if the server has not called `accept()` yet. To avoid freezing the notebook, `connect_with_timeout()` performs the connection attempt in a background thread." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "8edb47106e1a46a883d545849b8ab81b", - "metadata": {}, - "outputs": [], - "source": [ - "class ConnectTimeoutError(TimeoutError):\n", - " pass\n", - "\n", - "\n", - "def connect_with_timeout(address, authkey: bytes, timeout_s: float = 10.0):\n", - " \"\"\"Connect to a multiprocessing.connection.Listener without freezing the notebook forever.\"\"\"\n", - " result_q: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=1)\n", - "\n", - " def worker():\n", - " try:\n", - " conn = Client(address, authkey=authkey)\n", - " result_q.put((\"ok\", conn))\n", - " except Exception as exc:\n", - " result_q.put((\"error\", exc))\n", - "\n", - " t = threading.Thread(target=worker, name=\"MockUnityConnect\", daemon=True)\n", - " t.start()\n", - " t.join(timeout_s)\n", - "\n", - " if t.is_alive():\n", - " raise ConnectTimeoutError(\n", - " f\"Timed out after {timeout_s:.1f}s while connecting to {address}. \"\n", - " \"For legacy MyProcessor_socket, this can happen if DLC process() has not called listener.accept() yet.\"\n", - " )\n", - "\n", - " status, payload = result_q.get_nowait()\n", - " if status == \"ok\":\n", - " return payload\n", - " raise payload\n", - "\n", - "\n", - "@dataclass\n", - "class LegacyPosePacket:\n", - " timestamp: float\n", - " x: float\n", - " y: float\n", - " heading: float\n", - " head_angle: float\n", - " signal: float\n", - " raw: Any\n", - "\n", - "\n", - "def decode_payload(payload: Any) -> dict[str, Any]:\n", - " \"\"\"Decode either legacy list payloads or newer dict/list mock payloads.\"\"\"\n", - " # Legacy MyProcessor_socket payload:\n", - " # [time.time(), x, y, heading, head_angle, signal]\n", - " if isinstance(payload, list) and len(payload) == 6:\n", - " pkt = LegacyPosePacket(\n", - " timestamp=float(payload[0]),\n", - " x=float(payload[1]),\n", - " y=float(payload[2]),\n", - " heading=float(payload[3]),\n", - " head_angle=float(payload[4]),\n", - " signal=float(payload[5]),\n", - " raw=payload,\n", - " )\n", - " return {\"kind\": \"legacy_pose\", **asdict(pkt)}\n", - "\n", - " # Newer/base mock payloads may be dictionaries.\n", - " if isinstance(payload, dict):\n", - " kind = payload.get(\"type\", \"dict\")\n", - " return {\"kind\": kind, \"raw\": payload}\n", - "\n", - " # Some processors broadcast [timestamp, pose].\n", - " if isinstance(payload, list) and len(payload) == 2:\n", - " return {\"kind\": \"timestamp_pose\", \"timestamp\": payload[0], \"pose\": payload[1], \"raw\": payload}\n", - "\n", - " return {\"kind\": \"unknown\", \"raw\": payload}" - ] - }, - { - "cell_type": "markdown", - "id": "10185d26023b46108eb7d9f57d49d2b3", - "metadata": {}, - "source": [ - "## Connect to the DLC processor socket\n", - "\n", - "Run this cell once the processor has been created and its listener should be available.\n", - "\n", - "If it times out, it likely means either:\n", - "\n", - "1. the processor has not been instantiated yet,\n", - "2. the address/authkey are wrong,\n", - "3. the legacy processor is waiting until `process()` runs before accepting the connection,\n", - "4. another process is using the port." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "8763a12b2bbd4a93a75aff182afb95dc", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Connected to processor socket: ('127.0.0.1', 6000)\n" - ] - } - ], - "source": [ - "conn = connect_with_timeout(ADDRESS, AUTHKEY, timeout_s=CONNECT_TIMEOUT_S)\n", - "print(\"Connected to processor socket:\", ADDRESS)" - ] - }, - { - "cell_type": "markdown", - "id": "7623eae2785240b9bd12b16a66d81610", - "metadata": {}, - "source": [ - "## Optional: send a ping/status command\n", - "\n", - "Only use this for processors that implement command handling, such as the newer `BaseProcessorSocket` or the standalone mock processor.\n", - "\n", - "The legacy `MyProcessor_socket` does **not** read commands from the client, so skip this cell for that processor." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "7cdc8c89c7104fffa095e18ddfef8986", - "metadata": {}, - "outputs": [], - "source": [ - "# Uncomment only for BaseProcessorSocket-style processors or the standalone mock.\n", - "# conn.send({\"cmd\": \"ping\"})\n", - "# if conn.poll(2.0):\n", - "# print(\"Response:\", conn.recv())\n", - "# else:\n", - "# print(\"No response. This is expected for legacy MyProcessor_socket.\")" - ] - }, - { - "cell_type": "markdown", - "id": "b118ea5561624da68c537baed56e602f", - "metadata": {}, - "source": [ - "## Receive pose packets\n", - "\n", - "This cell listens for up to `duration_s` seconds and prints decoded packets. For legacy `MyProcessor_socket`, you should see `legacy_pose` packets once `process()` is called by DLC inference." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "938c804e27f84196a10c8828c723f798", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.750286,\n", - " \"step\": 12,\n", - " \"pose\": [\n", - " [\n", - " 288.7191162109375,\n", - " 298.63250732421875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.0001220703125,\n", - " 307.95672607421875,\n", - " 0.973543643951416\n", - " ],\n", - " [\n", - " 297.29254150390625,\n", - " 314.52410888671875,\n", - " 0.6293796896934509\n", - " ],\n", - " [\n", - " 290.9452819824219,\n", - " 310.3515319824219,\n", - " 0.640852153301239\n", - " ],\n", - " [\n", - " 304.86236572265625,\n", - " 313.2810974121094,\n", - " 0.6921122670173645\n", - " ],\n", - " [\n", - " 284.668212890625,\n", - " 266.52752685546875,\n", - " 0.9321146607398987\n", - " ],\n", - " [\n", - " 284.6539001464844,\n", - " 237.49722290039062,\n", - " 0.4642881155014038\n", - " ],\n", - " [\n", - " 298.547607421875,\n", - " 218.42587280273438,\n", - " 0.26315996050834656\n", - " ],\n", - " [\n", - " 182.9195098876953,\n", - " 312.5831604003906,\n", - " 0.4563772678375244\n", - " ],\n", - " [\n", - " 181.693359375,\n", - " 306.1708068847656,\n", - " 0.39262306690216064\n", - " ],\n", - " [\n", - " 309.70013427734375,\n", - " 273.22198486328125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.6557922363281,\n", - " 278.49371337890625,\n", - " 0.5969972014427185\n", - " ],\n", - " [\n", - " 362.46343994140625,\n", - " 283.172119140625,\n", - " 0.4280658960342407\n", - " ],\n", - " [\n", - " 185.8195343017578,\n", - " 311.7103271484375,\n", - " 0.27244052290916443\n", - " ],\n", - " [\n", - " 192.61837768554688,\n", - " 305.4747009277344,\n", - " 0.39853179454803467\n", - " ],\n", - " [\n", - " 355.7668151855469,\n", - " 339.8248596191406,\n", - " 0.40097054839134216\n", - " ],\n", - " [\n", - " 364.0209655761719,\n", - " 341.5860595703125,\n", - " 0.5437613129615784\n", - " ],\n", - " [\n", - " 335.2928771972656,\n", - " 344.40032958984375,\n", - " 0.3498704731464386\n", - " ],\n", - " [\n", - " 331.4139404296875,\n", - " 356.1664123535156,\n", - " 0.4611521065235138\n", - " ],\n", - " [\n", - " 366.796142578125,\n", - " 344.254150390625,\n", - " 0.47546425461769104\n", - " ],\n", - " [\n", - " 270.3355712890625,\n", - " 358.1495666503906,\n", - " 0.4529607594013214\n", - " ],\n", - " [\n", - " 379.02679443359375,\n", - " 356.05645751953125,\n", - " 0.4878201186656952\n", - " ],\n", - " [\n", - " 268.0301818847656,\n", - " 359.48583984375,\n", - " 0.366856187582016\n", - " ],\n", - " [\n", - " 599.5546264648438,\n", - " 377.7467956542969,\n", - " 0.564258873462677\n", - " ],\n", - " [\n", - " 185.41236877441406,\n", - " 207.2476043701172,\n", - " 0.3415561616420746\n", - " ],\n", - " [\n", - " 518.7420043945312,\n", - " 391.21240234375,\n", - " 0.3360799551010132\n", - " ],\n", - " [\n", - " 516.0563354492188,\n", - " 429.2253723144531,\n", - " 0.7114025354385376\n", - " ],\n", - " [\n", - " 164.67864990234375,\n", - " 212.08599853515625,\n", - " 0.22173050045967102\n", - " ],\n", - " [\n", - " 508.5932312011719,\n", - " 391.6039123535156,\n", - " 0.30660656094551086\n", - " ],\n", - " [\n", - " 511.993896484375,\n", - " 447.9190368652344,\n", - " 0.5064514875411987\n", - " ],\n", - " [\n", - " 591.1927490234375,\n", - " 409.2403564453125,\n", - " 0.5136002898216248\n", - " ],\n", - " [\n", - " 106.3792495727539,\n", - " 271.0600891113281,\n", - " 0.14335250854492188\n", - " ],\n", - " [\n", - " 112.1520767211914,\n", - " 279.07525634765625,\n", - " 0.2755976915359497\n", - " ],\n", - " [\n", - " 572.9004516601562,\n", - " 393.2549743652344,\n", - " 0.39857158064842224\n", - " ],\n", - " [\n", - " 144.4106903076172,\n", - " 313.3831787109375,\n", - " 0.27947890758514404\n", - " ],\n", - " [\n", - " 550.626953125,\n", - " 466.50970458984375,\n", - " 0.5572487115859985\n", - " ],\n", - " [\n", - " 466.3507385253906,\n", - " 413.0749206542969,\n", - " 0.39403966069221497\n", - " ],\n", - " [\n", - " 461.47198486328125,\n", - " 400.29541015625,\n", - " 0.3778141140937805\n", - " ],\n", - " [\n", - " 460.13824462890625,\n", - " 403.57171630859375,\n", - " 0.5188782811164856\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.7246952,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.750286\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.78031,\n", - " \"step\": 13,\n", - " \"pose\": [\n", - " [\n", - " 287.8656921386719,\n", - " 298.9954833984375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 292.98870849609375,\n", - " 308.14825439453125,\n", - " 0.9376083016395569\n", - " ],\n", - " [\n", - " 297.0699462890625,\n", - " 315.7899169921875,\n", - " 0.605503499507904\n", - " ],\n", - " [\n", - " 290.7883605957031,\n", - " 311.0892333984375,\n", - " 0.653724193572998\n", - " ],\n", - " [\n", - " 304.9418029785156,\n", - " 314.27423095703125,\n", - " 0.6581617593765259\n", - " ],\n", - " [\n", - " 284.5467224121094,\n", - " 266.99755859375,\n", - " 0.9386962056159973\n", - " ],\n", - " [\n", - " 285.4912109375,\n", - " 236.9186248779297,\n", - " 0.48566538095474243\n", - " ],\n", - " [\n", - " 282.66357421875,\n", - " 236.26953125,\n", - " 0.2793366611003876\n", - " ],\n", - " [\n", - " 182.8148193359375,\n", - " 314.7862548828125,\n", - " 0.4555658996105194\n", - " ],\n", - " [\n", - " 183.0238037109375,\n", - " 309.2024230957031,\n", - " 0.3395337760448456\n", - " ],\n", - " [\n", - " 310.2979431152344,\n", - " 272.9541015625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 362.39019775390625,\n", - " 278.04864501953125,\n", - " 0.7049410343170166\n", - " ],\n", - " [\n", - " 362.625,\n", - " 281.328369140625,\n", - " 0.4549161195755005\n", - " ],\n", - " [\n", - " 357.58203125,\n", - " 282.10272216796875,\n", - " 0.26718243956565857\n", - " ],\n", - " [\n", - " 194.67514038085938,\n", - " 306.698974609375,\n", - " 0.45219412446022034\n", - " ],\n", - " [\n", - " 355.5292663574219,\n", - " 339.20318603515625,\n", - " 0.41419631242752075\n", - " ],\n", - " [\n", - " 367.3896179199219,\n", - " 340.3309326171875,\n", - " 0.5223292112350464\n", - " ],\n", - " [\n", - " 335.11529541015625,\n", - " 343.81201171875,\n", - " 0.347744345664978\n", - " ],\n", - " [\n", - " 332.03765869140625,\n", - " 356.7191162109375,\n", - " 0.4733559191226959\n", - " ],\n", - " [\n", - " 367.4532470703125,\n", - " 344.79302978515625,\n", - " 0.4827011823654175\n", - " ],\n", - " [\n", - " 270.3110046386719,\n", - " 357.83343505859375,\n", - " 0.4700448215007782\n", - " ],\n", - " [\n", - " 378.0578308105469,\n", - " 356.4914855957031,\n", - " 0.4333026707172394\n", - " ],\n", - " [\n", - " 267.2149963378906,\n", - " 359.4980773925781,\n", - " 0.39140474796295166\n", - " ],\n", - " [\n", - " 596.2949829101562,\n", - " 378.6457824707031,\n", - " 0.5882202386856079\n", - " ],\n", - " [\n", - " 185.2808837890625,\n", - " 208.80474853515625,\n", - " 0.4029167890548706\n", - " ],\n", - " [\n", - " 563.7208862304688,\n", - " 387.8863220214844,\n", - " 0.390456885099411\n", - " ],\n", - " [\n", - " 519.1982421875,\n", - " 433.89654541015625,\n", - " 0.5289698839187622\n", - " ],\n", - " [\n", - " 442.9023132324219,\n", - " 356.9894714355469,\n", - " 0.2825982868671417\n", - " ],\n", - " [\n", - " 568.7670288085938,\n", - " 195.95079040527344,\n", - " 0.43732380867004395\n", - " ],\n", - " [\n", - " 512.254638671875,\n", - " 449.7223815917969,\n", - " 0.6101775169372559\n", - " ],\n", - " [\n", - " 591.0804443359375,\n", - " 409.246337890625,\n", - " 0.5442432761192322\n", - " ],\n", - " [\n", - " 470.5218200683594,\n", - " 402.7597961425781,\n", - " 0.1880091279745102\n", - " ],\n", - " [\n", - " 107.73304748535156,\n", - " 270.0023193359375,\n", - " 0.14866414666175842\n", - " ],\n", - " [\n", - " 572.8255615234375,\n", - " 393.6127624511719,\n", - " 0.5601691603660583\n", - " ],\n", - " [\n", - " 481.23486328125,\n", - " 431.833984375,\n", - " 0.2556696832180023\n", - " ],\n", - " [\n", - " 550.4242553710938,\n", - " 467.0722961425781,\n", - " 0.4299638867378235\n", - " ],\n", - " [\n", - " 466.45404052734375,\n", - " 412.8644714355469,\n", - " 0.38715386390686035\n", - " ],\n", - " [\n", - " 462.144775390625,\n", - " 401.0147705078125,\n", - " 0.3404390513896942\n", - " ],\n", - " [\n", - " 459.4046325683594,\n", - " 403.1348571777344,\n", - " 0.4926401376724243\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.7554483,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.78031\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.821648,\n", - " \"step\": 14,\n", - " \"pose\": [\n", - " [\n", - " 288.8023681640625,\n", - " 298.86077880859375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 292.8272399902344,\n", - " 308.04949951171875,\n", - " 0.9570764899253845\n", - " ],\n", - " [\n", - " 296.77056884765625,\n", - " 315.70367431640625,\n", - " 0.631300687789917\n", - " ],\n", - " [\n", - " 289.9299621582031,\n", - " 310.9530944824219,\n", - " 0.6520562171936035\n", - " ],\n", - " [\n", - " 305.0509338378906,\n", - " 313.7876892089844,\n", - " 0.7196981310844421\n", - " ],\n", - " [\n", - " 284.6484680175781,\n", - " 266.7134704589844,\n", - " 0.9353039860725403\n", - " ],\n", - " [\n", - " 285.0174560546875,\n", - " 238.79736328125,\n", - " 0.4836384952068329\n", - " ],\n", - " [\n", - " 154.6018829345703,\n", - " 315.4358215332031,\n", - " 0.2590964734554291\n", - " ],\n", - " [\n", - " 336.0355529785156,\n", - " 466.2019348144531,\n", - " 0.37860575318336487\n", - " ],\n", - " [\n", - " 181.9645233154297,\n", - " 308.6495361328125,\n", - " 0.3605335056781769\n", - " ],\n", - " [\n", - " 309.65972900390625,\n", - " 273.17425537109375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 362.54388427734375,\n", - " 277.6341857910156,\n", - " 0.5562496781349182\n", - " ],\n", - " [\n", - " 361.87298583984375,\n", - " 282.2453308105469,\n", - " 0.477169394493103\n", - " ],\n", - " [\n", - " 358.46246337890625,\n", - " 282.86480712890625,\n", - " 0.2796511948108673\n", - " ],\n", - " [\n", - " 189.29440307617188,\n", - " 305.3114929199219,\n", - " 0.41088035702705383\n", - " ],\n", - " [\n", - " 355.5824890136719,\n", - " 341.4902648925781,\n", - " 0.3962235450744629\n", - " ],\n", - " [\n", - " 363.149658203125,\n", - " 340.7292785644531,\n", - " 0.48571205139160156\n", - " ],\n", - " [\n", - " 332.39642333984375,\n", - " 340.5425720214844,\n", - " 0.32239603996276855\n", - " ],\n", - " [\n", - " 331.2975769042969,\n", - " 355.4726257324219,\n", - " 0.4472481310367584\n", - " ],\n", - " [\n", - " 364.4246520996094,\n", - " 346.6827087402344,\n", - " 0.4476216733455658\n", - " ],\n", - " [\n", - " 269.8668212890625,\n", - " 359.0860900878906,\n", - " 0.43882080912590027\n", - " ],\n", - " [\n", - " 379.4068298339844,\n", - " 356.64239501953125,\n", - " 0.4417465329170227\n", - " ],\n", - " [\n", - " 266.974853515625,\n", - " 360.31402587890625,\n", - " 0.33691221475601196\n", - " ],\n", - " [\n", - " 596.21826171875,\n", - " 377.820068359375,\n", - " 0.5398204922676086\n", - " ],\n", - " [\n", - " 183.42034912109375,\n", - " 208.78756713867188,\n", - " 0.34466955065727234\n", - " ],\n", - " [\n", - " 564.060791015625,\n", - " 388.1324157714844,\n", - " 0.4286687970161438\n", - " ],\n", - " [\n", - " 517.4417724609375,\n", - " 430.2832336425781,\n", - " 0.5827439427375793\n", - " ],\n", - " [\n", - " 169.510009765625,\n", - " 212.77224731445312,\n", - " 0.23520678281784058\n", - " ],\n", - " [\n", - " 509.5054016113281,\n", - " 390.251708984375,\n", - " 0.2855446934700012\n", - " ],\n", - " [\n", - " 511.0235290527344,\n", - " 447.9842224121094,\n", - " 0.5595549941062927\n", - " ],\n", - " [\n", - " 591.0059204101562,\n", - " 408.37109375,\n", - " 0.5015893578529358\n", - " ],\n", - " [\n", - " 475.4132385253906,\n", - " 422.1249084472656,\n", - " 0.19282308220863342\n", - " ],\n", - " [\n", - " 110.6484146118164,\n", - " 276.17138671875,\n", - " 0.3112924098968506\n", - " ],\n", - " [\n", - " 572.998046875,\n", - " 393.3849182128906,\n", - " 0.4534406363964081\n", - " ],\n", - " [\n", - " 481.1611022949219,\n", - " 432.43438720703125,\n", - " 0.2341541200876236\n", - " ],\n", - " [\n", - " 551.3787231445312,\n", - " 466.6352844238281,\n", - " 0.5391212105751038\n", - " ],\n", - " [\n", - " 467.7240905761719,\n", - " 412.1317138671875,\n", - " 0.3793080151081085\n", - " ],\n", - " [\n", - " 465.2247619628906,\n", - " 409.2422180175781,\n", - " 0.33666175603866577\n", - " ],\n", - " [\n", - " 460.2418212890625,\n", - " 403.50830078125,\n", - " 0.42937344312667847\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.7889726,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.821648\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.851803,\n", - " \"step\": 15,\n", - " \"pose\": [\n", - " [\n", - " 288.8399963378906,\n", - " 298.389892578125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.063232421875,\n", - " 307.6488952636719,\n", - " 0.9409026503562927\n", - " ],\n", - " [\n", - " 296.73828125,\n", - " 314.7473449707031,\n", - " 0.6393375992774963\n", - " ],\n", - " [\n", - " 290.7121887207031,\n", - " 310.599609375,\n", - " 0.6521811485290527\n", - " ],\n", - " [\n", - " 304.9431457519531,\n", - " 311.9217224121094,\n", - " 0.7004058957099915\n", - " ],\n", - " [\n", - " 284.689697265625,\n", - " 267.53924560546875,\n", - " 0.9271823167800903\n", - " ],\n", - " [\n", - " 182.9373779296875,\n", - " 313.93133544921875,\n", - " 0.5212528109550476\n", - " ],\n", - " [\n", - " 153.7313690185547,\n", - " 313.92779541015625,\n", - " 0.3010638356208801\n", - " ],\n", - " [\n", - " 181.32296752929688,\n", - " 312.8819580078125,\n", - " 0.5419734120368958\n", - " ],\n", - " [\n", - " 183.34437561035156,\n", - " 306.7300720214844,\n", - " 0.37734419107437134\n", - " ],\n", - " [\n", - " 309.9617004394531,\n", - " 273.3157653808594,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.39080810546875,\n", - " 277.68914794921875,\n", - " 0.7047985792160034\n", - " ],\n", - " [\n", - " 362.3508605957031,\n", - " 282.02313232421875,\n", - " 0.4799898564815521\n", - " ],\n", - " [\n", - " 179.46484375,\n", - " 315.53436279296875,\n", - " 0.3219224810600281\n", - " ],\n", - " [\n", - " 193.1734161376953,\n", - " 304.5758056640625,\n", - " 0.4651084244251251\n", - " ],\n", - " [\n", - " 355.8511047363281,\n", - " 340.7768859863281,\n", - " 0.42659905552864075\n", - " ],\n", - " [\n", - " 364.3086242675781,\n", - " 341.3064270019531,\n", - " 0.5809048414230347\n", - " ],\n", - " [\n", - " 310.90179443359375,\n", - " 347.79620361328125,\n", - " 0.37011390924453735\n", - " ],\n", - " [\n", - " 329.7757263183594,\n", - " 356.66290283203125,\n", - " 0.5357598066329956\n", - " ],\n", - " [\n", - " 369.3732604980469,\n", - " 347.80084228515625,\n", - " 0.5102704167366028\n", - " ],\n", - " [\n", - " 269.4798583984375,\n", - " 358.50128173828125,\n", - " 0.36626455187797546\n", - " ],\n", - " [\n", - " 379.74285888671875,\n", - " 356.38800048828125,\n", - " 0.4842686653137207\n", - " ],\n", - " [\n", - " 265.9881286621094,\n", - " 360.00592041015625,\n", - " 0.27803361415863037\n", - " ],\n", - " [\n", - " 596.4786376953125,\n", - " 379.2182922363281,\n", - " 0.5961218476295471\n", - " ],\n", - " [\n", - " 254.6011505126953,\n", - " 382.6790466308594,\n", - " 0.4003625512123108\n", - " ],\n", - " [\n", - " 563.052001953125,\n", - " 386.73590087890625,\n", - " 0.36300426721572876\n", - " ],\n", - " [\n", - " 517.0165405273438,\n", - " 430.59613037109375,\n", - " 0.5643782019615173\n", - " ],\n", - " [\n", - " 164.36508178710938,\n", - " 213.65438842773438,\n", - " 0.2149515450000763\n", - " ],\n", - " [\n", - " 509.01336669921875,\n", - " 392.5158996582031,\n", - " 0.23822596669197083\n", - " ],\n", - " [\n", - " 510.94085693359375,\n", - " 449.33477783203125,\n", - " 0.6286779642105103\n", - " ],\n", - " [\n", - " 590.7813110351562,\n", - " 409.1065368652344,\n", - " 0.4439380168914795\n", - " ],\n", - " [\n", - " 463.3310546875,\n", - " 401.2489318847656,\n", - " 0.1755678504705429\n", - " ],\n", - " [\n", - " 109.76718139648438,\n", - " 271.8696594238281,\n", - " 0.2973553240299225\n", - " ],\n", - " [\n", - " 572.5289306640625,\n", - " 393.1651916503906,\n", - " 0.5296833515167236\n", - " ],\n", - " [\n", - " 480.9592590332031,\n", - " 432.1273193359375,\n", - " 0.21754036843776703\n", - " ],\n", - " [\n", - " 550.6238403320312,\n", - " 466.08721923828125,\n", - " 0.5377715229988098\n", - " ],\n", - " [\n", - " 468.291015625,\n", - " 413.2549133300781,\n", - " 0.3708413243293762\n", - " ],\n", - " [\n", - " 136.44790649414062,\n", - " 171.77438354492188,\n", - " 0.3511451184749603\n", - " ],\n", - " [\n", - " 460.23358154296875,\n", - " 404.2395935058594,\n", - " 0.47236377000808716\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.8206406,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.8528142\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.8770585,\n", - " \"step\": 16,\n", - " \"pose\": [\n", - " [\n", - " 289.1402282714844,\n", - " 299.0330810546875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.5198974609375,\n", - " 308.2564392089844,\n", - " 0.9859002232551575\n", - " ],\n", - " [\n", - " 297.6006164550781,\n", - " 314.6565856933594,\n", - " 0.6514089107513428\n", - " ],\n", - " [\n", - " 290.924072265625,\n", - " 311.3872985839844,\n", - " 0.6389972567558289\n", - " ],\n", - " [\n", - " 304.9306945800781,\n", - " 313.3708801269531,\n", - " 0.7217590808868408\n", - " ],\n", - " [\n", - " 284.5622863769531,\n", - " 267.4127502441406,\n", - " 0.9266435503959656\n", - " ],\n", - " [\n", - " 184.82669067382812,\n", - " 312.5155334472656,\n", - " 0.5616798400878906\n", - " ],\n", - " [\n", - " 154.43678283691406,\n", - " 314.49652099609375,\n", - " 0.24991558492183685\n", - " ],\n", - " [\n", - " 182.59487915039062,\n", - " 312.3272705078125,\n", - " 0.592433512210846\n", - " ],\n", - " [\n", - " 182.8374786376953,\n", - " 306.06494140625,\n", - " 0.419148325920105\n", - " ],\n", - " [\n", - " 310.11920166015625,\n", - " 273.36737060546875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.7231140136719,\n", - " 277.9724426269531,\n", - " 0.5769408345222473\n", - " ],\n", - " [\n", - " 362.3317565917969,\n", - " 282.26922607421875,\n", - " 0.43514740467071533\n", - " ],\n", - " [\n", - " 184.4752655029297,\n", - " 311.1407470703125,\n", - " 0.38407573103904724\n", - " ],\n", - " [\n", - " 187.931640625,\n", - " 302.62933349609375,\n", - " 0.4633309543132782\n", - " ],\n", - " [\n", - " 356.0732421875,\n", - " 340.64599609375,\n", - " 0.4025139808654785\n", - " ],\n", - " [\n", - " 362.9293212890625,\n", - " 341.8232727050781,\n", - " 0.5162671208381653\n", - " ],\n", - " [\n", - " 309.4438781738281,\n", - " 345.9781799316406,\n", - " 0.33583828806877136\n", - " ],\n", - " [\n", - " 331.2337341308594,\n", - " 355.5598449707031,\n", - " 0.4219924509525299\n", - " ],\n", - " [\n", - " 366.71087646484375,\n", - " 345.36962890625,\n", - " 0.4366249144077301\n", - " ],\n", - " [\n", - " 269.210693359375,\n", - " 358.0929260253906,\n", - " 0.3690962493419647\n", - " ],\n", - " [\n", - " 424.5083923339844,\n", - " 376.5726013183594,\n", - " 0.42966294288635254\n", - " ],\n", - " [\n", - " 303.0454406738281,\n", - " 218.3123779296875,\n", - " 0.31208333373069763\n", - " ],\n", - " [\n", - " 598.5891723632812,\n", - " 378.4424133300781,\n", - " 0.5889440178871155\n", - " ],\n", - " [\n", - " 197.69949340820312,\n", - " 217.56845092773438,\n", - " 0.3414533734321594\n", - " ],\n", - " [\n", - " 563.177734375,\n", - " 386.8681945800781,\n", - " 0.39073020219802856\n", - " ],\n", - " [\n", - " 516.6555786132812,\n", - " 428.7958679199219,\n", - " 0.5345339179039001\n", - " ],\n", - " [\n", - " 442.6798095703125,\n", - " 357.461181640625,\n", - " 0.23529931902885437\n", - " ],\n", - " [\n", - " 507.02862548828125,\n", - " 392.94842529296875,\n", - " 0.26026907563209534\n", - " ],\n", - " [\n", - " 511.629150390625,\n", - " 447.88116455078125,\n", - " 0.5966296792030334\n", - " ],\n", - " [\n", - " 592.001708984375,\n", - " 408.8056945800781,\n", - " 0.5580258965492249\n", - " ],\n", - " [\n", - " 463.76220703125,\n", - " 402.1715087890625,\n", - " 0.16743294894695282\n", - " ],\n", - " [\n", - " 111.18097686767578,\n", - " 276.2333679199219,\n", - " 0.2950418293476105\n", - " ],\n", - " [\n", - " 573.3688354492188,\n", - " 393.168701171875,\n", - " 0.4543166756629944\n", - " ],\n", - " [\n", - " 481.0332946777344,\n", - " 433.19580078125,\n", - " 0.2525552809238434\n", - " ],\n", - " [\n", - " 551.67578125,\n", - " 466.51300048828125,\n", - " 0.48711535334587097\n", - " ],\n", - " [\n", - " 468.5693054199219,\n", - " 413.4539489746094,\n", - " 0.38309159874916077\n", - " ],\n", - " [\n", - " 135.75698852539062,\n", - " 170.189697265625,\n", - " 0.30588316917419434\n", - " ],\n", - " [\n", - " 459.2412109375,\n", - " 403.5185852050781,\n", - " 0.3879672586917877\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.8528142,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.8770585\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.9255464,\n", - " \"step\": 17,\n", - " \"pose\": [\n", - " [\n", - " 289.01116943359375,\n", - " 299.0413818359375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.1086730957031,\n", - " 308.51885986328125,\n", - " 0.95180344581604\n", - " ],\n", - " [\n", - " 296.3152770996094,\n", - " 316.4967956542969,\n", - " 0.5928277373313904\n", - " ],\n", - " [\n", - " 290.5437927246094,\n", - " 311.6708679199219,\n", - " 0.6084922552108765\n", - " ],\n", - " [\n", - " 306.17803955078125,\n", - " 313.6896057128906,\n", - " 0.7187818288803101\n", - " ],\n", - " [\n", - " 284.83319091796875,\n", - " 267.0993957519531,\n", - " 0.9082852602005005\n", - " ],\n", - " [\n", - " 285.2369384765625,\n", - " 239.99192810058594,\n", - " 0.4807704985141754\n", - " ],\n", - " [\n", - " 281.5351257324219,\n", - " 244.12281799316406,\n", - " 0.25091421604156494\n", - " ],\n", - " [\n", - " 183.10739135742188,\n", - " 313.957275390625,\n", - " 0.47192785143852234\n", - " ],\n", - " [\n", - " 182.70115661621094,\n", - " 306.3948974609375,\n", - " 0.3859936594963074\n", - " ],\n", - " [\n", - " 310.44354248046875,\n", - " 273.25341796875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 362.1278076171875,\n", - " 276.0966796875,\n", - " 0.66349196434021\n", - " ],\n", - " [\n", - " 363.0843200683594,\n", - " 282.9954528808594,\n", - " 0.44170665740966797\n", - " ],\n", - " [\n", - " 182.47335815429688,\n", - " 311.0877685546875,\n", - " 0.2967713475227356\n", - " ],\n", - " [\n", - " 188.00137329101562,\n", - " 304.48931884765625,\n", - " 0.45905861258506775\n", - " ],\n", - " [\n", - " 354.645263671875,\n", - " 339.31219482421875,\n", - " 0.3976733684539795\n", - " ],\n", - " [\n", - " 363.1065979003906,\n", - " 339.9534606933594,\n", - " 0.5860384702682495\n", - " ],\n", - " [\n", - " 316.1658935546875,\n", - " 342.28369140625,\n", - " 0.3572511076927185\n", - " ],\n", - " [\n", - " 331.590087890625,\n", - " 355.1735534667969,\n", - " 0.4502539336681366\n", - " ],\n", - " [\n", - " 363.5017395019531,\n", - " 344.60235595703125,\n", - " 0.5015437006950378\n", - " ],\n", - " [\n", - " 268.5982666015625,\n", - " 358.1197204589844,\n", - " 0.36430323123931885\n", - " ],\n", - " [\n", - " 378.5502014160156,\n", - " 355.8092041015625,\n", - " 0.42007115483283997\n", - " ],\n", - " [\n", - " 303.5655517578125,\n", - " 217.58612060546875,\n", - " 0.33906295895576477\n", - " ],\n", - " [\n", - " 596.765380859375,\n", - " 379.427001953125,\n", - " 0.5411680340766907\n", - " ],\n", - " [\n", - " 199.35903930664062,\n", - " 222.4739990234375,\n", - " 0.29007023572921753\n", - " ],\n", - " [\n", - " 562.4085083007812,\n", - " 386.65509033203125,\n", - " 0.3170825242996216\n", - " ],\n", - " [\n", - " 517.340087890625,\n", - " 430.5169677734375,\n", - " 0.6213215589523315\n", - " ],\n", - " [\n", - " 441.74444580078125,\n", - " 357.04644775390625,\n", - " 0.2545863389968872\n", - " ],\n", - " [\n", - " 503.5122375488281,\n", - " 393.9283447265625,\n", - " 0.30190107226371765\n", - " ],\n", - " [\n", - " 510.5965576171875,\n", - " 450.106201171875,\n", - " 0.5517356991767883\n", - " ],\n", - " [\n", - " 591.1541748046875,\n", - " 408.65826416015625,\n", - " 0.49750760197639465\n", - " ],\n", - " [\n", - " 475.8544921875,\n", - " 422.05078125,\n", - " 0.1733374446630478\n", - " ],\n", - " [\n", - " 109.9720687866211,\n", - " 271.4387512207031,\n", - " 0.2799350619316101\n", - " ],\n", - " [\n", - " 572.795166015625,\n", - " 393.3072204589844,\n", - " 0.3979363441467285\n", - " ],\n", - " [\n", - " 479.5787353515625,\n", - " 432.65380859375,\n", - " 0.22706195712089539\n", - " ],\n", - " [\n", - " 550.501708984375,\n", - " 467.0165100097656,\n", - " 0.4965691566467285\n", - " ],\n", - " [\n", - " 468.164306640625,\n", - " 412.325927734375,\n", - " 0.3681727349758148\n", - " ],\n", - " [\n", - " 461.05029296875,\n", - " 400.1574401855469,\n", - " 0.36365073919296265\n", - " ],\n", - " [\n", - " 460.8140563964844,\n", - " 402.9066467285156,\n", - " 0.4981914758682251\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.8993094,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.9255464\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.9609218,\n", - " \"step\": 18,\n", - " \"pose\": [\n", - " [\n", - " 288.9327392578125,\n", - " 299.13629150390625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 292.8038330078125,\n", - " 308.6182861328125,\n", - " 0.965558648109436\n", - " ],\n", - " [\n", - " 297.1647644042969,\n", - " 315.5074157714844,\n", - " 0.6364358067512512\n", - " ],\n", - " [\n", - " 290.55609130859375,\n", - " 311.7894287109375,\n", - " 0.6080928444862366\n", - " ],\n", - " [\n", - " 305.4518127441406,\n", - " 313.89239501953125,\n", - " 0.7770905494689941\n", - " ],\n", - " [\n", - " 284.95733642578125,\n", - " 267.11175537109375,\n", - " 0.9243097305297852\n", - " ],\n", - " [\n", - " 285.8481750488281,\n", - " 238.2171630859375,\n", - " 0.49581968784332275\n", - " ],\n", - " [\n", - " 281.67034912109375,\n", - " 238.3629608154297,\n", - " 0.2495463341474533\n", - " ],\n", - " [\n", - " 181.5650177001953,\n", - " 314.2547912597656,\n", - " 0.5168197751045227\n", - " ],\n", - " [\n", - " 182.05751037597656,\n", - " 306.9068908691406,\n", - " 0.36109668016433716\n", - " ],\n", - " [\n", - " 310.2335205078125,\n", - " 273.6082763671875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 362.219482421875,\n", - " 276.6759948730469,\n", - " 0.618270754814148\n", - " ],\n", - " [\n", - " 362.4667663574219,\n", - " 280.8567199707031,\n", - " 0.40474116802215576\n", - " ],\n", - " [\n", - " 184.80101013183594,\n", - " 316.924560546875,\n", - " 0.3415667712688446\n", - " ],\n", - " [\n", - " 189.2113800048828,\n", - " 305.0552062988281,\n", - " 0.44207993149757385\n", - " ],\n", - " [\n", - " 354.8471984863281,\n", - " 341.1282043457031,\n", - " 0.4782800078392029\n", - " ],\n", - " [\n", - " 363.4027404785156,\n", - " 341.2732238769531,\n", - " 0.518268346786499\n", - " ],\n", - " [\n", - " 316.40673828125,\n", - " 342.68695068359375,\n", - " 0.3713846504688263\n", - " ],\n", - " [\n", - " 330.7171630859375,\n", - " 355.67803955078125,\n", - " 0.46434029936790466\n", - " ],\n", - " [\n", - " 367.51568603515625,\n", - " 344.2020568847656,\n", - " 0.4221855401992798\n", - " ],\n", - " [\n", - " 269.15869140625,\n", - " 357.6059265136719,\n", - " 0.43143007159233093\n", - " ],\n", - " [\n", - " 377.92431640625,\n", - " 355.83026123046875,\n", - " 0.42217832803726196\n", - " ],\n", - " [\n", - " 266.35400390625,\n", - " 359.1109313964844,\n", - " 0.36265674233436584\n", - " ],\n", - " [\n", - " 598.98876953125,\n", - " 378.3539123535156,\n", - " 0.5779925584793091\n", - " ],\n", - " [\n", - " 521.8517456054688,\n", - " 317.60223388671875,\n", - " 0.3474227786064148\n", - " ],\n", - " [\n", - " 563.2191162109375,\n", - " 386.7420654296875,\n", - " 0.3701111972332001\n", - " ],\n", - " [\n", - " 516.5422973632812,\n", - " 430.6274108886719,\n", - " 0.5754683613777161\n", - " ],\n", - " [\n", - " 566.955078125,\n", - " 197.94085693359375,\n", - " 0.35363760590553284\n", - " ],\n", - " [\n", - " 573.0870971679688,\n", - " 198.2352294921875,\n", - " 0.5906892418861389\n", - " ],\n", - " [\n", - " 511.6949157714844,\n", - " 449.3252258300781,\n", - " 0.6369988322257996\n", - " ],\n", - " [\n", - " 591.0794067382812,\n", - " 408.18438720703125,\n", - " 0.5102192759513855\n", - " ],\n", - " [\n", - " 508.6004638671875,\n", - " 381.352783203125,\n", - " 0.2138703316450119\n", - " ],\n", - " [\n", - " 109.30960083007812,\n", - " 272.1946716308594,\n", - " 0.336846262216568\n", - " ],\n", - " [\n", - " 572.595703125,\n", - " 393.81610107421875,\n", - " 0.48220178484916687\n", - " ],\n", - " [\n", - " 482.5830383300781,\n", - " 431.64202880859375,\n", - " 0.18888558447360992\n", - " ],\n", - " [\n", - " 549.8436279296875,\n", - " 465.8060302734375,\n", - " 0.6060653924942017\n", - " ],\n", - " [\n", - " 470.1370544433594,\n", - " 413.1282653808594,\n", - " 0.4226745069026947\n", - " ],\n", - " [\n", - " 134.86871337890625,\n", - " 172.37551879882812,\n", - " 0.34861159324645996\n", - " ],\n", - " [\n", - " 460.8798522949219,\n", - " 403.1666564941406,\n", - " 0.4525850713253021\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.933552,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.9609218\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588105.996339,\n", - " \"step\": 19,\n", - " \"pose\": [\n", - " [\n", - " 289.3584289550781,\n", - " 299.6400451660156,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.6114807128906,\n", - " 308.7494201660156,\n", - " 0.9362009763717651\n", - " ],\n", - " [\n", - " 296.5362548828125,\n", - " 315.8563537597656,\n", - " 0.6216031908988953\n", - " ],\n", - " [\n", - " 290.3965759277344,\n", - " 311.8982849121094,\n", - " 0.5908151865005493\n", - " ],\n", - " [\n", - " 305.3473205566406,\n", - " 314.0240783691406,\n", - " 0.6830025911331177\n", - " ],\n", - " [\n", - " 284.38623046875,\n", - " 267.3375244140625,\n", - " 0.938642144203186\n", - " ],\n", - " [\n", - " 183.94895935058594,\n", - " 312.9803771972656,\n", - " 0.5159981846809387\n", - " ],\n", - " [\n", - " 153.8936767578125,\n", - " 314.8314514160156,\n", - " 0.26622244715690613\n", - " ],\n", - " [\n", - " 181.40602111816406,\n", - " 314.3370056152344,\n", - " 0.5327624082565308\n", - " ],\n", - " [\n", - " 182.94410705566406,\n", - " 307.2097473144531,\n", - " 0.4217340648174286\n", - " ],\n", - " [\n", - " 310.5429992675781,\n", - " 273.63134765625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.03759765625,\n", - " 275.5606994628906,\n", - " 0.6987367868423462\n", - " ],\n", - " [\n", - " 363.2841491699219,\n", - " 282.46881103515625,\n", - " 0.45098239183425903\n", - " ],\n", - " [\n", - " 183.90602111816406,\n", - " 316.1698913574219,\n", - " 0.39084556698799133\n", - " ],\n", - " [\n", - " 193.3860321044922,\n", - " 305.0615234375,\n", - " 0.507167398929596\n", - " ],\n", - " [\n", - " 355.57421875,\n", - " 341.30316162109375,\n", - " 0.4452424645423889\n", - " ],\n", - " [\n", - " 363.03021240234375,\n", - " 340.71435546875,\n", - " 0.5201398730278015\n", - " ],\n", - " [\n", - " 317.2238464355469,\n", - " 345.26666259765625,\n", - " 0.34468573331832886\n", - " ],\n", - " [\n", - " 332.4403991699219,\n", - " 356.6281433105469,\n", - " 0.46254584193229675\n", - " ],\n", - " [\n", - " 363.12640380859375,\n", - " 345.547119140625,\n", - " 0.42061465978622437\n", - " ],\n", - " [\n", - " 269.6493225097656,\n", - " 357.64892578125,\n", - " 0.4019133448600769\n", - " ],\n", - " [\n", - " 378.178466796875,\n", - " 356.5284729003906,\n", - " 0.4181462824344635\n", - " ],\n", - " [\n", - " 266.38238525390625,\n", - " 358.6065368652344,\n", - " 0.33007708191871643\n", - " ],\n", - " [\n", - " 599.5531005859375,\n", - " 377.76666259765625,\n", - " 0.629446804523468\n", - " ],\n", - " [\n", - " 184.41952514648438,\n", - " 208.114013671875,\n", - " 0.4009157419204712\n", - " ],\n", - " [\n", - " 519.3410034179688,\n", - " 390.22406005859375,\n", - " 0.3750861883163452\n", - " ],\n", - " [\n", - " 516.8319702148438,\n", - " 428.9804382324219,\n", - " 0.657153844833374\n", - " ],\n", - " [\n", - " 567.7550659179688,\n", - " 193.29896545410156,\n", - " 0.34581509232521057\n", - " ],\n", - " [\n", - " 509.476806640625,\n", - " 386.754150390625,\n", - " 0.3405891954898834\n", - " ],\n", - " [\n", - " 516.4321899414062,\n", - " 443.82684326171875,\n", - " 0.5484656095504761\n", - " ],\n", - " [\n", - " 594.040283203125,\n", - " 409.7172546386719,\n", - " 0.5296698212623596\n", - " ],\n", - " [\n", - " 508.0539855957031,\n", - " 380.4469299316406,\n", - " 0.20867124199867249\n", - " ],\n", - " [\n", - " 115.30363464355469,\n", - " 228.87652587890625,\n", - " 0.15687808394432068\n", - " ],\n", - " [\n", - " 573.0440063476562,\n", - " 393.09100341796875,\n", - " 0.39379948377609253\n", - " ],\n", - " [\n", - " 480.1328125,\n", - " 431.7356262207031,\n", - " 0.2691611051559448\n", - " ],\n", - " [\n", - " 550.718505859375,\n", - " 466.6239318847656,\n", - " 0.5681474804878235\n", - " ],\n", - " [\n", - " 469.20660400390625,\n", - " 413.4725036621094,\n", - " 0.39115187525749207\n", - " ],\n", - " [\n", - " 133.34088134765625,\n", - " 172.20274353027344,\n", - " 0.3368547558784485\n", - " ],\n", - " [\n", - " 459.3551025390625,\n", - " 403.5044250488281,\n", - " 0.45792460441589355\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.9642372,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588105.996339\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.0221846,\n", - " \"step\": 20,\n", - " \"pose\": [\n", - " [\n", - " 288.93731689453125,\n", - " 298.9955749511719,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.2713928222656,\n", - " 308.37408447265625,\n", - " 0.943423330783844\n", - " ],\n", - " [\n", - " 296.5384826660156,\n", - " 315.9530944824219,\n", - " 0.603498101234436\n", - " ],\n", - " [\n", - " 290.7826843261719,\n", - " 311.4992980957031,\n", - " 0.6115655899047852\n", - " ],\n", - " [\n", - " 304.9278259277344,\n", - " 313.83294677734375,\n", - " 0.7098742127418518\n", - " ],\n", - " [\n", - " 284.4320373535156,\n", - " 267.431884765625,\n", - " 0.9166838526725769\n", - " ],\n", - " [\n", - " 287.8583984375,\n", - " 231.46864318847656,\n", - " 0.4841695725917816\n", - " ],\n", - " [\n", - " 294.63043212890625,\n", - " 218.80035400390625,\n", - " 0.29108670353889465\n", - " ],\n", - " [\n", - " 181.39418029785156,\n", - " 314.2521057128906,\n", - " 0.5189660787582397\n", - " ],\n", - " [\n", - " 304.42047119140625,\n", - " 209.36367797851562,\n", - " 0.40350794792175293\n", - " ],\n", - " [\n", - " 310.4044494628906,\n", - " 273.7499694824219,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.916259765625,\n", - " 275.2131042480469,\n", - " 0.6535776853561401\n", - " ],\n", - " [\n", - " 364.2809753417969,\n", - " 282.39776611328125,\n", - " 0.40148383378982544\n", - " ],\n", - " [\n", - " 180.97071838378906,\n", - " 317.89715576171875,\n", - " 0.3581520915031433\n", - " ],\n", - " [\n", - " 187.54930114746094,\n", - " 302.6390686035156,\n", - " 0.4738801419734955\n", - " ],\n", - " [\n", - " 354.7167663574219,\n", - " 341.0120849609375,\n", - " 0.38925886154174805\n", - " ],\n", - " [\n", - " 362.65179443359375,\n", - " 341.0782775878906,\n", - " 0.5246103405952454\n", - " ],\n", - " [\n", - " 333.5454406738281,\n", - " 342.33709716796875,\n", - " 0.3726454973220825\n", - " ],\n", - " [\n", - " 331.2152099609375,\n", - " 355.3799133300781,\n", - " 0.5273229479789734\n", - " ],\n", - " [\n", - " 292.8047790527344,\n", - " 357.6714172363281,\n", - " 0.4529617428779602\n", - " ],\n", - " [\n", - " 269.3791198730469,\n", - " 357.7329406738281,\n", - " 0.33708542585372925\n", - " ],\n", - " [\n", - " 413.47406005859375,\n", - " 371.240478515625,\n", - " 0.38202741742134094\n", - " ],\n", - " [\n", - " 266.7149658203125,\n", - " 358.42596435546875,\n", - " 0.255717396736145\n", - " ],\n", - " [\n", - " 599.5459594726562,\n", - " 377.59088134765625,\n", - " 0.6171793937683105\n", - " ],\n", - " [\n", - " 182.58990478515625,\n", - " 208.6776580810547,\n", - " 0.3807677626609802\n", - " ],\n", - " [\n", - " 563.1406860351562,\n", - " 387.25885009765625,\n", - " 0.4175715744495392\n", - " ],\n", - " [\n", - " 517.3438110351562,\n", - " 431.8337097167969,\n", - " 0.5834364295005798\n", - " ],\n", - " [\n", - " 571.3803100585938,\n", - " 186.68905639648438,\n", - " 0.3315950930118561\n", - " ],\n", - " [\n", - " 572.1192016601562,\n", - " 190.96864318847656,\n", - " 0.44181355834007263\n", - " ],\n", - " [\n", - " 513.9149169921875,\n", - " 444.82080078125,\n", - " 0.5983200073242188\n", - " ],\n", - " [\n", - " 590.0543212890625,\n", - " 409.4712219238281,\n", - " 0.48646411299705505\n", - " ],\n", - " [\n", - " 474.74908447265625,\n", - " 422.23394775390625,\n", - " 0.2016405165195465\n", - " ],\n", - " [\n", - " 108.44601440429688,\n", - " 270.20269775390625,\n", - " 0.18030454218387604\n", - " ],\n", - " [\n", - " 572.8609008789062,\n", - " 393.09124755859375,\n", - " 0.4335196018218994\n", - " ],\n", - " [\n", - " 479.59942626953125,\n", - " 431.8648376464844,\n", - " 0.26634445786476135\n", - " ],\n", - " [\n", - " 551.6661376953125,\n", - " 466.70135498046875,\n", - " 0.5170703530311584\n", - " ],\n", - " [\n", - " 468.4554443359375,\n", - " 413.3636779785156,\n", - " 0.391674280166626\n", - " ],\n", - " [\n", - " 135.02671813964844,\n", - " 171.04122924804688,\n", - " 0.3395681083202362\n", - " ],\n", - " [\n", - " 460.0732727050781,\n", - " 402.4387512207031,\n", - " 0.43441158533096313\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588105.9977791,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.0241945\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.0568671,\n", - " \"step\": 21,\n", - " \"pose\": [\n", - " [\n", - " 289.0395202636719,\n", - " 299.1026611328125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 294.0700378417969,\n", - " 308.541015625,\n", - " 0.9578058123588562\n", - " ],\n", - " [\n", - " 296.70440673828125,\n", - " 315.8837890625,\n", - " 0.6122352480888367\n", - " ],\n", - " [\n", - " 291.8658752441406,\n", - " 311.9002685546875,\n", - " 0.5979498624801636\n", - " ],\n", - " [\n", - " 306.3223876953125,\n", - " 312.81304931640625,\n", - " 0.7840145826339722\n", - " ],\n", - " [\n", - " 284.8127136230469,\n", - " 267.5986328125,\n", - " 0.9234873652458191\n", - " ],\n", - " [\n", - " 295.66680908203125,\n", - " 222.97425842285156,\n", - " 0.5263190865516663\n", - " ],\n", - " [\n", - " 296.760009765625,\n", - " 216.06723022460938,\n", - " 0.33805859088897705\n", - " ],\n", - " [\n", - " 182.27871704101562,\n", - " 313.2104187011719,\n", - " 0.4452279508113861\n", - " ],\n", - " [\n", - " 302.8914794921875,\n", - " 207.4759521484375,\n", - " 0.4147292673587799\n", - " ],\n", - " [\n", - " 310.67901611328125,\n", - " 273.2028503417969,\n", - " 1.0\n", - " ],\n", - " [\n", - " 364.8524169921875,\n", - " 274.2853088378906,\n", - " 0.6022342443466187\n", - " ],\n", - " [\n", - " 363.9833679199219,\n", - " 282.55401611328125,\n", - " 0.4224339723587036\n", - " ],\n", - " [\n", - " 181.81272888183594,\n", - " 307.6803283691406,\n", - " 0.26984354853630066\n", - " ],\n", - " [\n", - " 194.29331970214844,\n", - " 306.2869873046875,\n", - " 0.40715906023979187\n", - " ],\n", - " [\n", - " 351.9360046386719,\n", - " 343.8916015625,\n", - " 0.3862766921520233\n", - " ],\n", - " [\n", - " 363.7510986328125,\n", - " 342.71826171875,\n", - " 0.5607332587242126\n", - " ],\n", - " [\n", - " 333.4976501464844,\n", - " 343.6668701171875,\n", - " 0.35671743750572205\n", - " ],\n", - " [\n", - " 332.6026916503906,\n", - " 355.7337341308594,\n", - " 0.49907931685447693\n", - " ],\n", - " [\n", - " 281.2688903808594,\n", - " 357.4167175292969,\n", - " 0.5524068474769592\n", - " ],\n", - " [\n", - " 270.1759338378906,\n", - " 357.4426574707031,\n", - " 0.47422295808792114\n", - " ],\n", - " [\n", - " 379.6234436035156,\n", - " 356.0726318359375,\n", - " 0.4222455620765686\n", - " ],\n", - " [\n", - " 266.31121826171875,\n", - " 358.93865966796875,\n", - " 0.4061802625656128\n", - " ],\n", - " [\n", - " 598.6159057617188,\n", - " 377.72808837890625,\n", - " 0.5579972863197327\n", - " ],\n", - " [\n", - " 522.2592163085938,\n", - " 318.6231994628906,\n", - " 0.3378920257091522\n", - " ],\n", - " [\n", - " 518.2576293945312,\n", - " 390.44976806640625,\n", - " 0.41228482127189636\n", - " ],\n", - " [\n", - " 515.7354736328125,\n", - " 429.0234069824219,\n", - " 0.5634108185768127\n", - " ],\n", - " [\n", - " 443.417724609375,\n", - " 356.7914733886719,\n", - " 0.26663434505462646\n", - " ],\n", - " [\n", - " 507.91925048828125,\n", - " 388.6474304199219,\n", - " 0.35248884558677673\n", - " ],\n", - " [\n", - " 512.753173828125,\n", - " 449.7189636230469,\n", - " 0.578186571598053\n", - " ],\n", - " [\n", - " 581.30078125,\n", - " 414.9436340332031,\n", - " 0.4276328682899475\n", - " ],\n", - " [\n", - " 463.5183410644531,\n", - " 402.17388916015625,\n", - " 0.13629432022571564\n", - " ],\n", - " [\n", - " 111.8991470336914,\n", - " 278.3125915527344,\n", - " 0.151170015335083\n", - " ],\n", - " [\n", - " 575.8933715820312,\n", - " 398.4580993652344,\n", - " 0.40502646565437317\n", - " ],\n", - " [\n", - " 143.69412231445312,\n", - " 345.78521728515625,\n", - " 0.24476872384548187\n", - " ],\n", - " [\n", - " 143.1280517578125,\n", - " 373.794189453125,\n", - " 0.4513229429721832\n", - " ],\n", - " [\n", - " 468.4044189453125,\n", - " 411.8726806640625,\n", - " 0.37500157952308655\n", - " ],\n", - " [\n", - " 462.43206787109375,\n", - " 399.8568115234375,\n", - " 0.3841189444065094\n", - " ],\n", - " [\n", - " 461.3127136230469,\n", - " 403.23052978515625,\n", - " 0.5030932426452637\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.0300262,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.0578823\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.0853903,\n", - " \"step\": 22,\n", - " \"pose\": [\n", - " [\n", - " 289.0032653808594,\n", - " 299.5898132324219,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.6610412597656,\n", - " 308.364501953125,\n", - " 0.9171743392944336\n", - " ],\n", - " [\n", - " 297.53411865234375,\n", - " 315.0213928222656,\n", - " 0.5967923998832703\n", - " ],\n", - " [\n", - " 291.45599365234375,\n", - " 310.9009094238281,\n", - " 0.6069502234458923\n", - " ],\n", - " [\n", - " 305.15667724609375,\n", - " 312.3479919433594,\n", - " 0.7363993525505066\n", - " ],\n", - " [\n", - " 284.5779724121094,\n", - " 267.80059814453125,\n", - " 0.8958398699760437\n", - " ],\n", - " [\n", - " 287.5237121582031,\n", - " 231.7471923828125,\n", - " 0.5256384611129761\n", - " ],\n", - " [\n", - " 297.3427734375,\n", - " 217.41281127929688,\n", - " 0.32152754068374634\n", - " ],\n", - " [\n", - " 182.88433837890625,\n", - " 314.53692626953125,\n", - " 0.489521861076355\n", - " ],\n", - " [\n", - " 303.3013916015625,\n", - " 209.451171875,\n", - " 0.42159581184387207\n", - " ],\n", - " [\n", - " 310.30352783203125,\n", - " 273.47393798828125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.04669189453125,\n", - " 275.4956970214844,\n", - " 0.6113792061805725\n", - " ],\n", - " [\n", - " 364.1795959472656,\n", - " 282.3038024902344,\n", - " 0.44920432567596436\n", - " ],\n", - " [\n", - " 185.60179138183594,\n", - " 312.93963623046875,\n", - " 0.2924720048904419\n", - " ],\n", - " [\n", - " 188.79063415527344,\n", - " 306.9405822753906,\n", - " 0.4584471881389618\n", - " ],\n", - " [\n", - " 355.7679443359375,\n", - " 340.5166320800781,\n", - " 0.4532307982444763\n", - " ],\n", - " [\n", - " 362.6515197753906,\n", - " 342.4863586425781,\n", - " 0.5490198731422424\n", - " ],\n", - " [\n", - " 334.1615905761719,\n", - " 344.5025939941406,\n", - " 0.33372119069099426\n", - " ],\n", - " [\n", - " 331.80181884765625,\n", - " 355.52691650390625,\n", - " 0.4720294773578644\n", - " ],\n", - " [\n", - " 290.5385437011719,\n", - " 358.4304504394531,\n", - " 0.4409693479537964\n", - " ],\n", - " [\n", - " 270.8581237792969,\n", - " 356.5791931152344,\n", - " 0.4198722839355469\n", - " ],\n", - " [\n", - " 424.2137145996094,\n", - " 375.2749328613281,\n", - " 0.4370681345462799\n", - " ],\n", - " [\n", - " 268.27886962890625,\n", - " 357.68585205078125,\n", - " 0.36227190494537354\n", - " ],\n", - " [\n", - " 599.3430786132812,\n", - " 377.6954650878906,\n", - " 0.5684486031532288\n", - " ],\n", - " [\n", - " 520.1165771484375,\n", - " 318.81927490234375,\n", - " 0.3776821196079254\n", - " ],\n", - " [\n", - " 562.5358276367188,\n", - " 387.04693603515625,\n", - " 0.3308621942996979\n", - " ],\n", - " [\n", - " 516.3358154296875,\n", - " 429.0692443847656,\n", - " 0.5608440637588501\n", - " ],\n", - " [\n", - " 566.9133911132812,\n", - " 193.52452087402344,\n", - " 0.33105629682540894\n", - " ],\n", - " [\n", - " 570.7525634765625,\n", - " 196.58692932128906,\n", - " 0.4755719006061554\n", - " ],\n", - " [\n", - " 512.5366821289062,\n", - " 448.55328369140625,\n", - " 0.5968109369277954\n", - " ],\n", - " [\n", - " 590.8397216796875,\n", - " 408.89471435546875,\n", - " 0.4935222268104553\n", - " ],\n", - " [\n", - " 110.8057632446289,\n", - " 271.4251403808594,\n", - " 0.16251139342784882\n", - " ],\n", - " [\n", - " 113.5406494140625,\n", - " 279.87841796875,\n", - " 0.38004982471466064\n", - " ],\n", - " [\n", - " 576.237548828125,\n", - " 397.9443664550781,\n", - " 0.3451719284057617\n", - " ],\n", - " [\n", - " 144.29734802246094,\n", - " 315.7247009277344,\n", - " 0.25869646668434143\n", - " ],\n", - " [\n", - " 549.8232421875,\n", - " 465.3925476074219,\n", - " 0.483270525932312\n", - " ],\n", - " [\n", - " 141.22862243652344,\n", - " 190.88340759277344,\n", - " 0.3440597653388977\n", - " ],\n", - " [\n", - " 462.27349853515625,\n", - " 407.5166931152344,\n", - " 0.39141225814819336\n", - " ],\n", - " [\n", - " 459.8392333984375,\n", - " 402.88421630859375,\n", - " 0.48188316822052\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.0603576,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.0853903\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.113898,\n", - " \"step\": 23,\n", - " \"pose\": [\n", - " [\n", - " 289.3703918457031,\n", - " 299.01654052734375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.63714599609375,\n", - " 308.040771484375,\n", - " 0.9116361141204834\n", - " ],\n", - " [\n", - " 297.54144287109375,\n", - " 314.6742248535156,\n", - " 0.5906339287757874\n", - " ],\n", - " [\n", - " 292.0235290527344,\n", - " 310.41552734375,\n", - " 0.6293807029724121\n", - " ],\n", - " [\n", - " 305.7689514160156,\n", - " 312.5812072753906,\n", - " 0.6732482314109802\n", - " ],\n", - " [\n", - " 284.58123779296875,\n", - " 267.2427673339844,\n", - " 0.9067515134811401\n", - " ],\n", - " [\n", - " 183.6278076171875,\n", - " 312.8146667480469,\n", - " 0.5189109444618225\n", - " ],\n", - " [\n", - " 373.2416076660156,\n", - " 300.8122863769531,\n", - " 0.3220520615577698\n", - " ],\n", - " [\n", - " 182.9404754638672,\n", - " 312.4514465332031,\n", - " 0.5909151434898376\n", - " ],\n", - " [\n", - " 303.0302429199219,\n", - " 210.0631561279297,\n", - " 0.4749321937561035\n", - " ],\n", - " [\n", - " 310.4212951660156,\n", - " 273.31060791015625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.5233459472656,\n", - " 275.4824523925781,\n", - " 0.5813355445861816\n", - " ],\n", - " [\n", - " 363.8905029296875,\n", - " 281.9700622558594,\n", - " 0.42637211084365845\n", - " ],\n", - " [\n", - " 185.95431518554688,\n", - " 311.6904296875,\n", - " 0.34200912714004517\n", - " ],\n", - " [\n", - " 192.42385864257812,\n", - " 305.3067932128906,\n", - " 0.4337136447429657\n", - " ],\n", - " [\n", - " 355.9394836425781,\n", - " 340.25689697265625,\n", - " 0.43801149725914\n", - " ],\n", - " [\n", - " 363.40325927734375,\n", - " 341.9284362792969,\n", - " 0.5555760264396667\n", - " ],\n", - " [\n", - " 314.46746826171875,\n", - " 342.5721130371094,\n", - " 0.35660916566848755\n", - " ],\n", - " [\n", - " 339.87835693359375,\n", - " 359.97998046875,\n", - " 0.4949004054069519\n", - " ],\n", - " [\n", - " 290.6855773925781,\n", - " 358.2529602050781,\n", - " 0.4353252649307251\n", - " ],\n", - " [\n", - " 270.9769287109375,\n", - " 356.5190124511719,\n", - " 0.40807199478149414\n", - " ],\n", - " [\n", - " 424.1128845214844,\n", - " 374.64202880859375,\n", - " 0.42985209822654724\n", - " ],\n", - " [\n", - " 267.4349670410156,\n", - " 357.9966125488281,\n", - " 0.3516447842121124\n", - " ],\n", - " [\n", - " 597.1614990234375,\n", - " 379.19207763671875,\n", - " 0.5612181425094604\n", - " ],\n", - " [\n", - " 200.91233825683594,\n", - " 229.2719268798828,\n", - " 0.3809609115123749\n", - " ],\n", - " [\n", - " 563.5918579101562,\n", - " 387.2767028808594,\n", - " 0.3711811602115631\n", - " ],\n", - " [\n", - " 516.4181518554688,\n", - " 428.2336120605469,\n", - " 0.6263866424560547\n", - " ],\n", - " [\n", - " 570.1748046875,\n", - " 189.33961486816406,\n", - " 0.3523023724555969\n", - " ],\n", - " [\n", - " 569.3735961914062,\n", - " 194.79763793945312,\n", - " 0.36297234892845154\n", - " ],\n", - " [\n", - " 512.5416259765625,\n", - " 448.44085693359375,\n", - " 0.5241063833236694\n", - " ],\n", - " [\n", - " 155.5992889404297,\n", - " 363.5351867675781,\n", - " 0.46883660554885864\n", - " ],\n", - " [\n", - " 508.84210205078125,\n", - " 377.6853332519531,\n", - " 0.1948014795780182\n", - " ],\n", - " [\n", - " 112.65641021728516,\n", - " 278.4239501953125,\n", - " 0.18293216824531555\n", - " ],\n", - " [\n", - " 572.6771850585938,\n", - " 392.94891357421875,\n", - " 0.39113080501556396\n", - " ],\n", - " [\n", - " 481.0816650390625,\n", - " 432.840087890625,\n", - " 0.22011691331863403\n", - " ],\n", - " [\n", - " 145.10130310058594,\n", - " 373.3096618652344,\n", - " 0.4219147264957428\n", - " ],\n", - " [\n", - " 468.0207824707031,\n", - " 413.1219787597656,\n", - " 0.3619706630706787\n", - " ],\n", - " [\n", - " 136.87318420410156,\n", - " 172.54913330078125,\n", - " 0.3659115135669708\n", - " ],\n", - " [\n", - " 459.65655517578125,\n", - " 403.489013671875,\n", - " 0.46539226174354553\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.0933394,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.113898\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.1507206,\n", - " \"step\": 24,\n", - " \"pose\": [\n", - " [\n", - " 289.14605712890625,\n", - " 298.5685729980469,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.7758483886719,\n", - " 308.03326416015625,\n", - " 0.9337582588195801\n", - " ],\n", - " [\n", - " 297.73248291015625,\n", - " 315.2346496582031,\n", - " 0.6327982544898987\n", - " ],\n", - " [\n", - " 291.4134216308594,\n", - " 311.653564453125,\n", - " 0.609774649143219\n", - " ],\n", - " [\n", - " 305.1743469238281,\n", - " 313.22369384765625,\n", - " 0.7303615808486938\n", - " ],\n", - " [\n", - " 285.11077880859375,\n", - " 267.58795166015625,\n", - " 0.9044018387794495\n", - " ],\n", - " [\n", - " 287.801025390625,\n", - " 230.8278045654297,\n", - " 0.5132544636726379\n", - " ],\n", - " [\n", - " 294.9792785644531,\n", - " 217.2659912109375,\n", - " 0.3110557496547699\n", - " ],\n", - " [\n", - " 182.70785522460938,\n", - " 312.7431640625,\n", - " 0.4303653836250305\n", - " ],\n", - " [\n", - " 302.67926025390625,\n", - " 209.3670196533203,\n", - " 0.3863958716392517\n", - " ],\n", - " [\n", - " 310.23931884765625,\n", - " 273.41265869140625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.62945556640625,\n", - " 276.4300842285156,\n", - " 0.6256653070449829\n", - " ],\n", - " [\n", - " 363.4427185058594,\n", - " 281.51226806640625,\n", - " 0.4842453896999359\n", - " ],\n", - " [\n", - " 357.33184814453125,\n", - " 280.7920227050781,\n", - " 0.27625176310539246\n", - " ],\n", - " [\n", - " 194.59628295898438,\n", - " 306.9119873046875,\n", - " 0.41847968101501465\n", - " ],\n", - " [\n", - " 355.71160888671875,\n", - " 338.88134765625,\n", - " 0.44307053089141846\n", - " ],\n", - " [\n", - " 362.9523010253906,\n", - " 339.22442626953125,\n", - " 0.6132069230079651\n", - " ],\n", - " [\n", - " 317.2615051269531,\n", - " 345.85675048828125,\n", - " 0.35067668557167053\n", - " ],\n", - " [\n", - " 331.7022705078125,\n", - " 355.4823303222656,\n", - " 0.45521220564842224\n", - " ],\n", - " [\n", - " 363.580810546875,\n", - " 345.0674743652344,\n", - " 0.4948749542236328\n", - " ],\n", - " [\n", - " 269.8246765136719,\n", - " 356.2699279785156,\n", - " 0.5052472949028015\n", - " ],\n", - " [\n", - " 423.73480224609375,\n", - " 374.68438720703125,\n", - " 0.4521806836128235\n", - " ],\n", - " [\n", - " 266.7372131347656,\n", - " 357.82562255859375,\n", - " 0.44915807247161865\n", - " ],\n", - " [\n", - " 598.9044189453125,\n", - " 377.9191589355469,\n", - " 0.5668219923973083\n", - " ],\n", - " [\n", - " 185.02847290039062,\n", - " 209.2593536376953,\n", - " 0.29630619287490845\n", - " ],\n", - " [\n", - " 232.27960205078125,\n", - " 424.1368408203125,\n", - " 0.35069289803504944\n", - " ],\n", - " [\n", - " 516.3990478515625,\n", - " 429.19805908203125,\n", - " 0.6708835959434509\n", - " ],\n", - " [\n", - " 443.72869873046875,\n", - " 355.66790771484375,\n", - " 0.18464210629463196\n", - " ],\n", - " [\n", - " 509.17938232421875,\n", - " 388.7170104980469,\n", - " 0.4232410788536072\n", - " ],\n", - " [\n", - " 513.5040893554688,\n", - " 449.72918701171875,\n", - " 0.5325906872749329\n", - " ],\n", - " [\n", - " 583.7595825195312,\n", - " 413.1138916015625,\n", - " 0.4442688822746277\n", - " ],\n", - " [\n", - " 115.84835052490234,\n", - " 221.41104125976562,\n", - " 0.1638604700565338\n", - " ],\n", - " [\n", - " 116.47493743896484,\n", - " 283.7770690917969,\n", - " 0.2362251728773117\n", - " ],\n", - " [\n", - " 576.6185913085938,\n", - " 399.1842041015625,\n", - " 0.4103768765926361\n", - " ],\n", - " [\n", - " 144.26768493652344,\n", - " 343.4598693847656,\n", - " 0.2693617641925812\n", - " ],\n", - " [\n", - " 550.6482543945312,\n", - " 465.9261474609375,\n", - " 0.52559894323349\n", - " ],\n", - " [\n", - " 467.72491455078125,\n", - " 412.5487365722656,\n", - " 0.3213626742362976\n", - " ],\n", - " [\n", - " 466.04193115234375,\n", - " 410.77960205078125,\n", - " 0.32539424300193787\n", - " ],\n", - " [\n", - " 459.31622314453125,\n", - " 402.8010559082031,\n", - " 0.4295216500759125\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.1260552,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.1507206\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.1780674,\n", - " \"step\": 25,\n", - " \"pose\": [\n", - " [\n", - " 289.6149597167969,\n", - " 299.89166259765625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 294.52008056640625,\n", - " 308.8827209472656,\n", - " 0.9231247305870056\n", - " ],\n", - " [\n", - " 298.28369140625,\n", - " 316.6026916503906,\n", - " 0.6108699440956116\n", - " ],\n", - " [\n", - " 292.314697265625,\n", - " 312.0814514160156,\n", - " 0.5867233276367188\n", - " ],\n", - " [\n", - " 306.8653564453125,\n", - " 313.03192138671875,\n", - " 0.7602759599685669\n", - " ],\n", - " [\n", - " 284.97998046875,\n", - " 268.31109619140625,\n", - " 0.9025271534919739\n", - " ],\n", - " [\n", - " 285.36627197265625,\n", - " 239.4760284423828,\n", - " 0.45559558272361755\n", - " ],\n", - " [\n", - " 278.97967529296875,\n", - " 251.67454528808594,\n", - " 0.3040803074836731\n", - " ],\n", - " [\n", - " 182.5161590576172,\n", - " 314.0823974609375,\n", - " 0.487441748380661\n", - " ],\n", - " [\n", - " 303.5965881347656,\n", - " 209.8799591064453,\n", - " 0.39406585693359375\n", - " ],\n", - " [\n", - " 310.803466796875,\n", - " 273.83941650390625,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.4359130859375,\n", - " 276.5120849609375,\n", - " 0.5850675106048584\n", - " ],\n", - " [\n", - " 364.39306640625,\n", - " 281.8566589355469,\n", - " 0.43770086765289307\n", - " ],\n", - " [\n", - " 184.96409606933594,\n", - " 312.2420349121094,\n", - " 0.32801908254623413\n", - " ],\n", - " [\n", - " 188.65463256835938,\n", - " 306.26580810546875,\n", - " 0.44451019167900085\n", - " ],\n", - " [\n", - " 356.0030517578125,\n", - " 340.06390380859375,\n", - " 0.393046498298645\n", - " ],\n", - " [\n", - " 361.8909606933594,\n", - " 340.0927429199219,\n", - " 0.5161962509155273\n", - " ],\n", - " [\n", - " 315.6810607910156,\n", - " 343.1770935058594,\n", - " 0.37498360872268677\n", - " ],\n", - " [\n", - " 330.904052734375,\n", - " 355.0949401855469,\n", - " 0.5339792370796204\n", - " ],\n", - " [\n", - " 367.3807067871094,\n", - " 343.9208679199219,\n", - " 0.41494372487068176\n", - " ],\n", - " [\n", - " 270.63043212890625,\n", - " 356.5071716308594,\n", - " 0.39088016748428345\n", - " ],\n", - " [\n", - " 424.5980529785156,\n", - " 377.47625732421875,\n", - " 0.40465661883354187\n", - " ],\n", - " [\n", - " 267.8541259765625,\n", - " 358.0108642578125,\n", - " 0.33039915561676025\n", - " ],\n", - " [\n", - " 599.1412353515625,\n", - " 378.35345458984375,\n", - " 0.5644627213478088\n", - " ],\n", - " [\n", - " 184.03726196289062,\n", - " 209.42005920410156,\n", - " 0.459592342376709\n", - " ],\n", - " [\n", - " 511.7866516113281,\n", - " 408.0044860839844,\n", - " 0.3771219551563263\n", - " ],\n", - " [\n", - " 516.994873046875,\n", - " 430.9440612792969,\n", - " 0.5924205780029297\n", - " ],\n", - " [\n", - " 443.1100158691406,\n", - " 355.68621826171875,\n", - " 0.2457418441772461\n", - " ],\n", - " [\n", - " 499.76837158203125,\n", - " 422.5597839355469,\n", - " 0.24084235727787018\n", - " ],\n", - " [\n", - " 511.1026611328125,\n", - " 445.35107421875,\n", - " 0.5383387804031372\n", - " ],\n", - " [\n", - " 589.8642578125,\n", - " 408.1378173828125,\n", - " 0.456050306558609\n", - " ],\n", - " [\n", - " 507.7047119140625,\n", - " 379.2082214355469,\n", - " 0.16193071007728577\n", - " ],\n", - " [\n", - " 112.13590240478516,\n", - " 276.6860656738281,\n", - " 0.282148540019989\n", - " ],\n", - " [\n", - " 573.1347045898438,\n", - " 393.6588439941406,\n", - " 0.4297298192977905\n", - " ],\n", - " [\n", - " 482.9963073730469,\n", - " 432.75811767578125,\n", - " 0.2714385986328125\n", - " ],\n", - " [\n", - " 550.9261474609375,\n", - " 467.3409118652344,\n", - " 0.5166769623756409\n", - " ],\n", - " [\n", - " 468.4465026855469,\n", - " 413.2013244628906,\n", - " 0.42883676290512085\n", - " ],\n", - " [\n", - " 133.96446228027344,\n", - " 171.50291442871094,\n", - " 0.35597431659698486\n", - " ],\n", - " [\n", - " 465.5216369628906,\n", - " 407.3671875,\n", - " 0.4744454324245453\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.1553848,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.1780674\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.2132318,\n", - " \"step\": 26,\n", - " \"pose\": [\n", - " [\n", - " 289.6811218261719,\n", - " 299.3060607910156,\n", - " 1.0\n", - " ],\n", - " [\n", - " 294.1546630859375,\n", - " 308.6109924316406,\n", - " 0.9218483567237854\n", - " ],\n", - " [\n", - " 297.23516845703125,\n", - " 316.22552490234375,\n", - " 0.5849137902259827\n", - " ],\n", - " [\n", - " 292.8453369140625,\n", - " 312.3377990722656,\n", - " 0.5977075695991516\n", - " ],\n", - " [\n", - " 306.42193603515625,\n", - " 313.16546630859375,\n", - " 0.73295658826828\n", - " ],\n", - " [\n", - " 285.2742919921875,\n", - " 268.0978698730469,\n", - " 0.8919780254364014\n", - " ],\n", - " [\n", - " 285.22930908203125,\n", - " 240.1131134033203,\n", - " 0.48513516783714294\n", - " ],\n", - " [\n", - " 278.72900390625,\n", - " 252.45925903320312,\n", - " 0.30949145555496216\n", - " ],\n", - " [\n", - " 182.2201690673828,\n", - " 313.8691711425781,\n", - " 0.4198264181613922\n", - " ],\n", - " [\n", - " 302.0194091796875,\n", - " 209.4342803955078,\n", - " 0.3796064555644989\n", - " ],\n", - " [\n", - " 311.20037841796875,\n", - " 273.72052001953125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.5647888183594,\n", - " 275.8450622558594,\n", - " 0.551364004611969\n", - " ],\n", - " [\n", - " 361.8849792480469,\n", - " 280.7410583496094,\n", - " 0.4334920346736908\n", - " ],\n", - " [\n", - " 185.7280731201172,\n", - " 312.75677490234375,\n", - " 0.29518699645996094\n", - " ],\n", - " [\n", - " 188.90249633789062,\n", - " 306.63177490234375,\n", - " 0.43662378191947937\n", - " ],\n", - " [\n", - " 355.60833740234375,\n", - " 340.0720520019531,\n", - " 0.4073914885520935\n", - " ],\n", - " [\n", - " 362.4900817871094,\n", - " 339.8585205078125,\n", - " 0.5256603956222534\n", - " ],\n", - " [\n", - " 310.6485900878906,\n", - " 346.7264404296875,\n", - " 0.3758726119995117\n", - " ],\n", - " [\n", - " 331.7028503417969,\n", - " 357.2618408203125,\n", - " 0.5182921886444092\n", - " ],\n", - " [\n", - " 282.8375244140625,\n", - " 356.63385009765625,\n", - " 0.47025105357170105\n", - " ],\n", - " [\n", - " 271.1107482910156,\n", - " 356.7911376953125,\n", - " 0.49355366826057434\n", - " ],\n", - " [\n", - " 424.5859680175781,\n", - " 375.9132080078125,\n", - " 0.3878205716609955\n", - " ],\n", - " [\n", - " 267.3672180175781,\n", - " 358.4584655761719,\n", - " 0.4485253393650055\n", - " ],\n", - " [\n", - " 598.8714599609375,\n", - " 377.88165283203125,\n", - " 0.5623369216918945\n", - " ],\n", - " [\n", - " 184.80422973632812,\n", - " 207.90528869628906,\n", - " 0.4335136115550995\n", - " ],\n", - " [\n", - " 513.0913696289062,\n", - " 407.55938720703125,\n", - " 0.34691154956817627\n", - " ],\n", - " [\n", - " 517.757080078125,\n", - " 432.6747131347656,\n", - " 0.5728744864463806\n", - " ],\n", - " [\n", - " 163.63232421875,\n", - " 212.3854217529297,\n", - " 0.32033267617225647\n", - " ],\n", - " [\n", - " 570.7784423828125,\n", - " 193.9116973876953,\n", - " 0.40288540720939636\n", - " ],\n", - " [\n", - " 513.4071655273438,\n", - " 445.5836486816406,\n", - " 0.6099948883056641\n", - " ],\n", - " [\n", - " 590.9609985351562,\n", - " 408.857421875,\n", - " 0.4939699172973633\n", - " ],\n", - " [\n", - " 476.909423828125,\n", - " 420.9211120605469,\n", - " 0.17517627775669098\n", - " ],\n", - " [\n", - " 111.49516296386719,\n", - " 278.0699462890625,\n", - " 0.20062971115112305\n", - " ],\n", - " [\n", - " 572.727294921875,\n", - " 393.2834167480469,\n", - " 0.39421916007995605\n", - " ],\n", - " [\n", - " 481.32647705078125,\n", - " 431.9410400390625,\n", - " 0.2601860463619232\n", - " ],\n", - " [\n", - " 511.23797607421875,\n", - " 458.18768310546875,\n", - " 0.5182275772094727\n", - " ],\n", - " [\n", - " 468.54132080078125,\n", - " 413.6605529785156,\n", - " 0.40847253799438477\n", - " ],\n", - " [\n", - " 464.9053649902344,\n", - " 410.059326171875,\n", - " 0.35141322016716003\n", - " ],\n", - " [\n", - " 459.9055480957031,\n", - " 403.2681579589844,\n", - " 0.4966220259666443\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.1875935,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.2132318\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.2483606,\n", - " \"step\": 27,\n", - " \"pose\": [\n", - " [\n", - " 289.65423583984375,\n", - " 298.561767578125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.9566955566406,\n", - " 308.3065490722656,\n", - " 0.9470576047897339\n", - " ],\n", - " [\n", - " 297.505859375,\n", - " 314.407958984375,\n", - " 0.6088659763336182\n", - " ],\n", - " [\n", - " 291.20574951171875,\n", - " 310.8966064453125,\n", - " 0.607652485370636\n", - " ],\n", - " [\n", - " 306.73492431640625,\n", - " 312.7744445800781,\n", - " 0.7566508054733276\n", - " ],\n", - " [\n", - " 285.1037902832031,\n", - " 267.3839416503906,\n", - " 0.906174898147583\n", - " ],\n", - " [\n", - " 185.3070526123047,\n", - " 311.79327392578125,\n", - " 0.5088176727294922\n", - " ],\n", - " [\n", - " 154.76300048828125,\n", - " 314.7828674316406,\n", - " 0.29141828417778015\n", - " ],\n", - " [\n", - " 182.6195068359375,\n", - " 312.42791748046875,\n", - " 0.5560340285301208\n", - " ],\n", - " [\n", - " 304.5721435546875,\n", - " 209.04302978515625,\n", - " 0.4600820243358612\n", - " ],\n", - " [\n", - " 310.9137878417969,\n", - " 273.2908935546875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.1856994628906,\n", - " 276.47381591796875,\n", - " 0.5621199011802673\n", - " ],\n", - " [\n", - " 363.20672607421875,\n", - " 281.5467224121094,\n", - " 0.427944153547287\n", - " ],\n", - " [\n", - " 181.0316619873047,\n", - " 311.6895446777344,\n", - " 0.3624846041202545\n", - " ],\n", - " [\n", - " 188.46315002441406,\n", - " 304.592529296875,\n", - " 0.5054963231086731\n", - " ],\n", - " [\n", - " 353.033447265625,\n", - " 342.5238037109375,\n", - " 0.40231525897979736\n", - " ],\n", - " [\n", - " 362.5194091796875,\n", - " 339.60711669921875,\n", - " 0.6008350849151611\n", - " ],\n", - " [\n", - " 317.26544189453125,\n", - " 342.3359069824219,\n", - " 0.3960641920566559\n", - " ],\n", - " [\n", - " 331.4060974121094,\n", - " 356.75445556640625,\n", - " 0.5937938690185547\n", - " ],\n", - " [\n", - " 293.347412109375,\n", - " 357.84515380859375,\n", - " 0.4450031816959381\n", - " ],\n", - " [\n", - " 269.11407470703125,\n", - " 357.3741455078125,\n", - " 0.4057061970233917\n", - " ],\n", - " [\n", - " 378.8157653808594,\n", - " 355.3031921386719,\n", - " 0.39472222328186035\n", - " ],\n", - " [\n", - " 265.99078369140625,\n", - " 358.6902160644531,\n", - " 0.3461160957813263\n", - " ],\n", - " [\n", - " 598.9271850585938,\n", - " 377.77618408203125,\n", - " 0.5730097889900208\n", - " ],\n", - " [\n", - " 184.35238647460938,\n", - " 209.6236572265625,\n", - " 0.3743739724159241\n", - " ],\n", - " [\n", - " 513.5267333984375,\n", - " 407.8452453613281,\n", - " 0.394991397857666\n", - " ],\n", - " [\n", - " 517.43359375,\n", - " 432.714111328125,\n", - " 0.6186484694480896\n", - " ],\n", - " [\n", - " 566.1676635742188,\n", - " 200.06082153320312,\n", - " 0.36133381724357605\n", - " ],\n", - " [\n", - " 570.642333984375,\n", - " 203.29977416992188,\n", - " 0.2848742604255676\n", - " ],\n", - " [\n", - " 513.6658935546875,\n", - " 447.7688903808594,\n", - " 0.566241979598999\n", - " ],\n", - " [\n", - " 590.916259765625,\n", - " 409.0061950683594,\n", - " 0.5227742791175842\n", - " ],\n", - " [\n", - " 476.4045104980469,\n", - " 421.7107849121094,\n", - " 0.23629191517829895\n", - " ],\n", - " [\n", - " 109.81532287597656,\n", - " 272.0874938964844,\n", - " 0.2973298132419586\n", - " ],\n", - " [\n", - " 572.2440185546875,\n", - " 393.4117431640625,\n", - " 0.4230390191078186\n", - " ],\n", - " [\n", - " 481.6334228515625,\n", - " 434.35992431640625,\n", - " 0.20489251613616943\n", - " ],\n", - " [\n", - " 511.52740478515625,\n", - " 458.0361633300781,\n", - " 0.47707173228263855\n", - " ],\n", - " [\n", - " 468.145751953125,\n", - " 413.9035339355469,\n", - " 0.4124965965747833\n", - " ],\n", - " [\n", - " 465.691650390625,\n", - " 411.6900329589844,\n", - " 0.3242393732070923\n", - " ],\n", - " [\n", - " 459.133544921875,\n", - " 403.29656982421875,\n", - " 0.4457142651081085\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.2229764,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.2503667\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.2815137,\n", - " \"step\": 28,\n", - " \"pose\": [\n", - " [\n", - " 289.7947998046875,\n", - " 299.4747314453125,\n", - " 1.0\n", - " ],\n", - " [\n", - " 294.1377868652344,\n", - " 308.9644470214844,\n", - " 0.9098001718521118\n", - " ],\n", - " [\n", - " 297.0553894042969,\n", - " 316.2694396972656,\n", - " 0.5839970111846924\n", - " ],\n", - " [\n", - " 292.7187194824219,\n", - " 312.2520446777344,\n", - " 0.5812930464744568\n", - " ],\n", - " [\n", - " 306.51007080078125,\n", - " 313.4143981933594,\n", - " 0.7014763355255127\n", - " ],\n", - " [\n", - " 285.4446105957031,\n", - " 267.66400146484375,\n", - " 0.885840654373169\n", - " ],\n", - " [\n", - " 287.2601623535156,\n", - " 232.3909912109375,\n", - " 0.47544583678245544\n", - " ],\n", - " [\n", - " 279.16656494140625,\n", - " 250.76365661621094,\n", - " 0.29200607538223267\n", - " ],\n", - " [\n", - " 182.51055908203125,\n", - " 314.830322265625,\n", - " 0.4986189305782318\n", - " ],\n", - " [\n", - " 304.1475524902344,\n", - " 207.91217041015625,\n", - " 0.5152255892753601\n", - " ],\n", - " [\n", - " 311.350830078125,\n", - " 273.5013122558594,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.06219482421875,\n", - " 276.67120361328125,\n", - " 0.5790920853614807\n", - " ],\n", - " [\n", - " 362.5161437988281,\n", - " 283.22723388671875,\n", - " 0.4104999601840973\n", - " ],\n", - " [\n", - " 181.13710021972656,\n", - " 312.584716796875,\n", - " 0.29986241459846497\n", - " ],\n", - " [\n", - " 188.63394165039062,\n", - " 303.74395751953125,\n", - " 0.45568570494651794\n", - " ],\n", - " [\n", - " 357.4764099121094,\n", - " 339.7001953125,\n", - " 0.4089646339416504\n", - " ],\n", - " [\n", - " 362.9626770019531,\n", - " 339.128662109375,\n", - " 0.4946100115776062\n", - " ],\n", - " [\n", - " 311.04779052734375,\n", - " 346.371826171875,\n", - " 0.39371228218078613\n", - " ],\n", - " [\n", - " 339.0541687011719,\n", - " 359.549560546875,\n", - " 0.5586241483688354\n", - " ],\n", - " [\n", - " 283.3900146484375,\n", - " 356.6086120605469,\n", - " 0.4299786686897278\n", - " ],\n", - " [\n", - " 270.4642333984375,\n", - " 357.4127502441406,\n", - " 0.40563082695007324\n", - " ],\n", - " [\n", - " 423.99005126953125,\n", - " 375.4238586425781,\n", - " 0.43916311860084534\n", - " ],\n", - " [\n", - " 268.08233642578125,\n", - " 358.42919921875,\n", - " 0.3316787779331207\n", - " ],\n", - " [\n", - " 598.7156982421875,\n", - " 377.3929138183594,\n", - " 0.5510212182998657\n", - " ],\n", - " [\n", - " 182.943115234375,\n", - " 208.30332946777344,\n", - " 0.44296297430992126\n", - " ],\n", - " [\n", - " 564.0025024414062,\n", - " 386.8399353027344,\n", - " 0.39091676473617554\n", - " ],\n", - " [\n", - " 517.2132568359375,\n", - " 432.1662292480469,\n", - " 0.6276928186416626\n", - " ],\n", - " [\n", - " 570.1396484375,\n", - " 190.4938507080078,\n", - " 0.2375359982252121\n", - " ],\n", - " [\n", - " 492.12896728515625,\n", - " 413.6061096191406,\n", - " 0.3405477702617645\n", - " ],\n", - " [\n", - " 512.7205200195312,\n", - " 450.4252624511719,\n", - " 0.6235648393630981\n", - " ],\n", - " [\n", - " 590.1025390625,\n", - " 409.0594787597656,\n", - " 0.5204903483390808\n", - " ],\n", - " [\n", - " 476.5130310058594,\n", - " 422.0225524902344,\n", - " 0.14199496805667877\n", - " ],\n", - " [\n", - " 110.05477142333984,\n", - " 271.470703125,\n", - " 0.22186370193958282\n", - " ],\n", - " [\n", - " 571.6903686523438,\n", - " 393.01318359375,\n", - " 0.4487222135066986\n", - " ],\n", - " [\n", - " 142.70831298828125,\n", - " 346.6425476074219,\n", - " 0.2570325434207916\n", - " ],\n", - " [\n", - " 511.7834167480469,\n", - " 456.3761901855469,\n", - " 0.5804262161254883\n", - " ],\n", - " [\n", - " 468.8331298828125,\n", - " 413.937744140625,\n", - " 0.36150258779525757\n", - " ],\n", - " [\n", - " 465.01104736328125,\n", - " 410.2015380859375,\n", - " 0.3218684196472168\n", - " ],\n", - " [\n", - " 460.2878723144531,\n", - " 402.61614990234375,\n", - " 0.44286683201789856\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.2539957,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.2815137\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.313648,\n", - " \"step\": 29,\n", - " \"pose\": [\n", - " [\n", - " 290.2019958496094,\n", - " 299.82733154296875,\n", - " 1.0\n", - " ],\n", - " [\n", - " 294.038818359375,\n", - " 309.61590576171875,\n", - " 0.8994060754776001\n", - " ],\n", - " [\n", - " 297.1882019042969,\n", - " 316.2828369140625,\n", - " 0.580872118473053\n", - " ],\n", - " [\n", - " 292.24969482421875,\n", - " 311.94366455078125,\n", - " 0.553147554397583\n", - " ],\n", - " [\n", - " 305.8408508300781,\n", - " 312.88555908203125,\n", - " 0.7357133626937866\n", - " ],\n", - " [\n", - " 285.2379455566406,\n", - " 267.34576416015625,\n", - " 0.8884855508804321\n", - " ],\n", - " [\n", - " 288.9740905761719,\n", - " 231.48561096191406,\n", - " 0.5197221636772156\n", - " ],\n", - " [\n", - " 299.1871032714844,\n", - " 218.01211547851562,\n", - " 0.3429694175720215\n", - " ],\n", - " [\n", - " 181.70339965820312,\n", - " 317.9532165527344,\n", - " 0.46619749069213867\n", - " ],\n", - " [\n", - " 304.53472900390625,\n", - " 208.62942504882812,\n", - " 0.45269933342933655\n", - " ],\n", - " [\n", - " 311.17626953125,\n", - " 274.0134582519531,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.29833984375,\n", - " 278.41729736328125,\n", - " 0.5592008233070374\n", - " ],\n", - " [\n", - " 363.13763427734375,\n", - " 281.8921813964844,\n", - " 0.45556432008743286\n", - " ],\n", - " [\n", - " 357.6162414550781,\n", - " 281.98394775390625,\n", - " 0.30312928557395935\n", - " ],\n", - " [\n", - " 187.75889587402344,\n", - " 305.17547607421875,\n", - " 0.44207459688186646\n", - " ],\n", - " [\n", - " 355.2580261230469,\n", - " 341.6760559082031,\n", - " 0.38897281885147095\n", - " ],\n", - " [\n", - " 363.04852294921875,\n", - " 339.8902893066406,\n", - " 0.5060890316963196\n", - " ],\n", - " [\n", - " 314.8399963378906,\n", - " 341.91680908203125,\n", - " 0.40578165650367737\n", - " ],\n", - " [\n", - " 338.7541198730469,\n", - " 359.4593200683594,\n", - " 0.548823893070221\n", - " ],\n", - " [\n", - " 366.559814453125,\n", - " 343.24029541015625,\n", - " 0.4393633306026459\n", - " ],\n", - " [\n", - " 270.7618103027344,\n", - " 357.1903991699219,\n", - " 0.4302641451358795\n", - " ],\n", - " [\n", - " 379.385498046875,\n", - " 355.3668212890625,\n", - " 0.45166778564453125\n", - " ],\n", - " [\n", - " 268.61431884765625,\n", - " 357.9519348144531,\n", - " 0.36507532000541687\n", - " ],\n", - " [\n", - " 597.203125,\n", - " 379.3829345703125,\n", - " 0.587417721748352\n", - " ],\n", - " [\n", - " 182.3574676513672,\n", - " 208.2609100341797,\n", - " 0.39093875885009766\n", - " ],\n", - " [\n", - " 512.343994140625,\n", - " 407.104736328125,\n", - " 0.3616585433483124\n", - " ],\n", - " [\n", - " 517.728759765625,\n", - " 431.97174072265625,\n", - " 0.5530462265014648\n", - " ],\n", - " [\n", - " 567.7471313476562,\n", - " 195.76194763183594,\n", - " 0.41424036026000977\n", - " ],\n", - " [\n", - " 568.5385131835938,\n", - " 196.24961853027344,\n", - " 0.3196858763694763\n", - " ],\n", - " [\n", - " 512.130615234375,\n", - " 450.2174072265625,\n", - " 0.6944491863250732\n", - " ],\n", - " [\n", - " 154.75747680664062,\n", - " 365.33026123046875,\n", - " 0.46970120072364807\n", - " ],\n", - " [\n", - " 476.37847900390625,\n", - " 421.13104248046875,\n", - " 0.2024911344051361\n", - " ],\n", - " [\n", - " 109.27305603027344,\n", - " 276.56787109375,\n", - " 0.22318890690803528\n", - " ],\n", - " [\n", - " 573.2276611328125,\n", - " 393.43096923828125,\n", - " 0.45069870352745056\n", - " ],\n", - " [\n", - " 144.93081665039062,\n", - " 343.8260803222656,\n", - " 0.26525822281837463\n", - " ],\n", - " [\n", - " 510.0262145996094,\n", - " 457.258544921875,\n", - " 0.6254387497901917\n", - " ],\n", - " [\n", - " 468.2686767578125,\n", - " 412.5702209472656,\n", - " 0.40957126021385193\n", - " ],\n", - " [\n", - " 464.2906188964844,\n", - " 410.6726989746094,\n", - " 0.32647469639778137\n", - " ],\n", - " [\n", - " 460.0201721191406,\n", - " 403.037841796875,\n", - " 0.41243088245391846\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.285935,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.313648\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.3619356,\n", - " \"step\": 30,\n", - " \"pose\": [\n", - " [\n", - " 290.1323547363281,\n", - " 299.7147521972656,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.99139404296875,\n", - " 309.4753112792969,\n", - " 0.8872848749160767\n", - " ],\n", - " [\n", - " 297.3484802246094,\n", - " 315.24835205078125,\n", - " 0.5460883975028992\n", - " ],\n", - " [\n", - " 291.71209716796875,\n", - " 310.8031311035156,\n", - " 0.5654347538948059\n", - " ],\n", - " [\n", - " 305.3967590332031,\n", - " 312.7275085449219,\n", - " 0.7120907306671143\n", - " ],\n", - " [\n", - " 285.19940185546875,\n", - " 267.3405456542969,\n", - " 0.8942697048187256\n", - " ],\n", - " [\n", - " 289.3546447753906,\n", - " 231.18653869628906,\n", - " 0.483479768037796\n", - " ],\n", - " [\n", - " 299.21112060546875,\n", - " 218.4276580810547,\n", - " 0.4001926779747009\n", - " ],\n", - " [\n", - " 181.76585388183594,\n", - " 313.5556945800781,\n", - " 0.46045035123825073\n", - " ],\n", - " [\n", - " 309.8026428222656,\n", - " 210.481689453125,\n", - " 0.5369957089424133\n", - " ],\n", - " [\n", - " 310.97064208984375,\n", - " 273.8341369628906,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.9340515136719,\n", - " 277.03912353515625,\n", - " 0.6095738410949707\n", - " ],\n", - " [\n", - " 362.3292541503906,\n", - " 282.6873474121094,\n", - " 0.5387850999832153\n", - " ],\n", - " [\n", - " 183.47048950195312,\n", - " 311.634521484375,\n", - " 0.30759158730506897\n", - " ],\n", - " [\n", - " 193.97259521484375,\n", - " 306.5625,\n", - " 0.4332640469074249\n", - " ],\n", - " [\n", - " 355.8443908691406,\n", - " 341.6749267578125,\n", - " 0.3820899724960327\n", - " ],\n", - " [\n", - " 362.45452880859375,\n", - " 340.09088134765625,\n", - " 0.493086040019989\n", - " ],\n", - " [\n", - " 315.4454650878906,\n", - " 343.0971984863281,\n", - " 0.3856670558452606\n", - " ],\n", - " [\n", - " 339.348388671875,\n", - " 359.60870361328125,\n", - " 0.55845707654953\n", - " ],\n", - " [\n", - " 284.3851623535156,\n", - " 356.5133972167969,\n", - " 0.4924909174442291\n", - " ],\n", - " [\n", - " 271.4208679199219,\n", - " 357.4637451171875,\n", - " 0.3702590763568878\n", - " ],\n", - " [\n", - " 423.9642028808594,\n", - " 375.77801513671875,\n", - " 0.42082488536834717\n", - " ],\n", - " [\n", - " 269.3265075683594,\n", - " 358.4918212890625,\n", - " 0.3108918368816376\n", - " ],\n", - " [\n", - " 595.7271728515625,\n", - " 378.74505615234375,\n", - " 0.6383695006370544\n", - " ],\n", - " [\n", - " 182.5079803466797,\n", - " 209.4701690673828,\n", - " 0.3769323527812958\n", - " ],\n", - " [\n", - " 563.5107421875,\n", - " 388.12860107421875,\n", - " 0.34879517555236816\n", - " ],\n", - " [\n", - " 516.7620239257812,\n", - " 429.2193908691406,\n", - " 0.6218969225883484\n", - " ],\n", - " [\n", - " 443.3829650878906,\n", - " 357.2695007324219,\n", - " 0.2294222116470337\n", - " ],\n", - " [\n", - " 508.9411926269531,\n", - " 390.8059387207031,\n", - " 0.27742016315460205\n", - " ],\n", - " [\n", - " 513.4525756835938,\n", - " 451.084228515625,\n", - " 0.588313102722168\n", - " ],\n", - " [\n", - " 590.62939453125,\n", - " 409.5919494628906,\n", - " 0.49015718698501587\n", - " ],\n", - " [\n", - " 505.6385498046875,\n", - " 379.8114929199219,\n", - " 0.16522188484668732\n", - " ],\n", - " [\n", - " 108.85206604003906,\n", - " 271.24273681640625,\n", - " 0.2543381452560425\n", - " ],\n", - " [\n", - " 573.4502563476562,\n", - " 393.5893249511719,\n", - " 0.49820849299430847\n", - " ],\n", - " [\n", - " 142.85484313964844,\n", - " 314.2268981933594,\n", - " 0.19713541865348816\n", - " ],\n", - " [\n", - " 549.9910278320312,\n", - " 465.56768798828125,\n", - " 0.5062677264213562\n", - " ],\n", - " [\n", - " 469.6101379394531,\n", - " 412.96905517578125,\n", - " 0.3788171112537384\n", - " ],\n", - " [\n", - " 465.2295837402344,\n", - " 411.72161865234375,\n", - " 0.343596875667572\n", - " ],\n", - " [\n", - " 460.37811279296875,\n", - " 402.3249206542969,\n", - " 0.46701255440711975\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.3327684,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.3619356\n", - "}\n", - "{\n", - " \"kind\": \"pose\",\n", - " \"raw\": {\n", - " \"type\": \"pose\",\n", - " \"timestamp\": 1783588106.3911848,\n", - " \"step\": 31,\n", - " \"pose\": [\n", - " [\n", - " 289.88616943359375,\n", - " 299.245849609375,\n", - " 1.0\n", - " ],\n", - " [\n", - " 293.7210693359375,\n", - " 308.9605407714844,\n", - " 0.8916722536087036\n", - " ],\n", - " [\n", - " 297.2877502441406,\n", - " 315.5369873046875,\n", - " 0.5746937394142151\n", - " ],\n", - " [\n", - " 292.2024230957031,\n", - " 311.0600891113281,\n", - " 0.5595524907112122\n", - " ],\n", - " [\n", - " 305.3230285644531,\n", - " 313.09588623046875,\n", - " 0.7339632511138916\n", - " ],\n", - " [\n", - " 285.2215576171875,\n", - " 266.96453857421875,\n", - " 0.894293487071991\n", - " ],\n", - " [\n", - " 297.3047180175781,\n", - " 221.42364501953125,\n", - " 0.5297285914421082\n", - " ],\n", - " [\n", - " 295.8182067871094,\n", - " 216.30775451660156,\n", - " 0.38408026099205017\n", - " ],\n", - " [\n", - " 183.77606201171875,\n", - " 316.7649841308594,\n", - " 0.4275979697704315\n", - " ],\n", - " [\n", - " 308.4361572265625,\n", - " 210.48973083496094,\n", - " 0.5560570359230042\n", - " ],\n", - " [\n", - " 311.0546875,\n", - " 273.9168395996094,\n", - " 1.0\n", - " ],\n", - " [\n", - " 363.8443298339844,\n", - " 277.56488037109375,\n", - " 0.632280170917511\n", - " ],\n", - " [\n", - " 363.19598388671875,\n", - " 283.2601013183594,\n", - " 0.5504708290100098\n", - " ],\n", - " [\n", - " 360.5338439941406,\n", - " 282.9017028808594,\n", - " 0.3014270067214966\n", - " ],\n", - " [\n", - " 187.5973663330078,\n", - " 304.839111328125,\n", - " 0.4563285708427429\n", - " ],\n", - " [\n", - " 355.62762451171875,\n", - " 341.2684020996094,\n", - " 0.4316878914833069\n", - " ],\n", - " [\n", - " 362.1797180175781,\n", - " 340.63848876953125,\n", - " 0.523749828338623\n", - " ],\n", - " [\n", - " 310.7569580078125,\n", - " 347.2984313964844,\n", - " 0.3826305568218231\n", - " ],\n", - " [\n", - " 338.3757629394531,\n", - " 360.44317626953125,\n", - " 0.5659022927284241\n", - " ],\n", - " [\n", - " 367.23004150390625,\n", - " 344.0614929199219,\n", - " 0.43424880504608154\n", - " ],\n", - " [\n", - " 272.1240234375,\n", - " 355.71612548828125,\n", - " 0.5028250217437744\n", - " ],\n", - " [\n", - " 379.81280517578125,\n", - " 355.9754943847656,\n", - " 0.38737767934799194\n", - " ],\n", - " [\n", - " 268.998046875,\n", - " 357.2021789550781,\n", - " 0.44784843921661377\n", - " ],\n", - " [\n", - " 596.336181640625,\n", - " 379.1740417480469,\n", - " 0.545729398727417\n", - " ],\n", - " [\n", - " 201.0037841796875,\n", - " 228.0155029296875,\n", - " 0.42291054129600525\n", - " ],\n", - " [\n", - " 562.3812866210938,\n", - " 386.2914123535156,\n", - " 0.36510559916496277\n", - " ],\n", - " [\n", - " 517.466796875,\n", - " 433.1764831542969,\n", - " 0.5716246962547302\n", - " ],\n", - " [\n", - " 570.5004272460938,\n", - " 194.27825927734375,\n", - " 0.31970250606536865\n", - " ],\n", - " [\n", - " 572.74462890625,\n", - " 201.8236541748047,\n", - " 0.39004698395729065\n", - " ],\n", - " [\n", - " 513.2064208984375,\n", - " 451.0941162109375,\n", - " 0.6408159732818604\n", - " ],\n", - " [\n", - " 590.6119995117188,\n", - " 408.5152282714844,\n", - " 0.5167672634124756\n", - " ],\n", - " [\n", - " 114.4150619506836,\n", - " 235.0469207763672,\n", - " 0.13695929944515228\n", - " ],\n", - " [\n", - " 112.1463623046875,\n", - " 279.28265380859375,\n", - " 0.23755685985088348\n", - " ],\n", - " [\n", - " 571.991943359375,\n", - " 393.113525390625,\n", - " 0.4135165214538574\n", - " ],\n", - " [\n", - " 144.2115478515625,\n", - " 313.7943115234375,\n", - " 0.23972396552562714\n", - " ],\n", - " [\n", - " 510.1828918457031,\n", - " 456.5469055175781,\n", - " 0.503180205821991\n", - " ],\n", - " [\n", - " 468.22821044921875,\n", - " 413.3734436035156,\n", - " 0.37297725677490234\n", - " ],\n", - " [\n", - " 465.0234069824219,\n", - " 410.9199523925781,\n", - " 0.36548110842704773\n", - " ],\n", - " [\n", - " 465.41192626953125,\n", - " 406.6351623535156,\n", - " 0.4397272765636444\n", - " ]\n", - " ],\n", - " \"frame_time\": 1783588106.3645778,\n", - " \"pose_time\": null,\n", - " \"recording\": false\n", - " },\n", - " \"received_at\": 1783588106.3911848\n", - "}\n", - "Received 20 packet(s).\n" - ] - } - ], - "source": [ - "def receive_packets(conn, duration_s: float = 30.0, max_packets: int | None = None):\n", - " start = time.time()\n", - " packets: list[dict[str, Any]] = []\n", - "\n", - " while time.time() - start < duration_s:\n", - " if conn.poll(POLL_INTERVAL_S):\n", - " payload = conn.recv()\n", - " decoded = decode_payload(payload)\n", - " decoded[\"received_at\"] = time.time()\n", - " packets.append(decoded)\n", - " print(json.dumps(decoded, default=str, indent=2))\n", - "\n", - " if max_packets is not None and len(packets) >= max_packets:\n", - " break\n", - "\n", - " print(f\"Received {len(packets)} packet(s).\")\n", - " return packets\n", - "\n", - "\n", - "packets = receive_packets(conn, duration_s=30.0, max_packets=20)" - ] - }, - { - "cell_type": "markdown", - "id": "504fb2a444614c0babb325280ed9130a", - "metadata": {}, - "source": [ - "## Save captured packets\n", - "\n", - "This is useful if you want to inspect the stream shape after a test run." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "59bbdb311c014d738909a11f9e486628", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saved 20 packet(s) to C:\\Users\\Cyril A\\Desktop\\Code\\DeepLabCut-live-GUI\\dlclivegui\\processors\\custom\\mock_unity_captured_packets.json\n" - ] - } - ], - "source": [ - "out_path = Path(\"mock_unity_captured_packets.json\")\n", - "out_path.write_text(json.dumps(packets, default=str, indent=2), encoding=\"utf-8\")\n", - "print(\"Saved\", len(packets), \"packet(s) to\", out_path.resolve())" - ] - }, - { - "cell_type": "markdown", - "id": "b43b363d81ae4b689946ece5c682cd59", - "metadata": {}, - "source": [ - "## Close the client connection" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "8a65eabff63a45729fe45fb5ade58bdc", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Connection closed\n" - ] - } - ], - "source": [ - "try:\n", - " conn.close()\n", - " print(\"Connection closed\")\n", - "except Exception as exc:\n", - " print(\"Close failed:\", exc)" - ] - }, - { - "cell_type": "markdown", - "id": "c3933fab20d04ec698c2621248eb3be0", - "metadata": {}, - "source": [ - "## Optional local smoke test with a standalone mock server\n", - "\n", - "Use this only if you want to validate the notebook client logic without running DLCLiveGUI.\n", - "\n", - "This requires `mock_socket_processor.py` to be importable, for example by placing it next to this notebook or adding its directory to `sys.path`." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "4dd4641cc4064e0191573fe9c69df29b", - "metadata": {}, - "outputs": [], - "source": [ - "# Optional smoke test. Leave commented unless you have mock_socket_processor.py available.\n", - "#\n", - "# import socket\n", - "# import sys\n", - "# from multiprocessing.connection import Client\n", - "#\n", - "# from mock_socket_processor import MockSocketProcessor\n", - "#\n", - "# def free_port():\n", - "# s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", - "# s.bind((\"127.0.0.1\", 0))\n", - "# port = s.getsockname()[1]\n", - "# s.close()\n", - "# return port\n", - "#\n", - "# port = free_port()\n", - "# mock = MockSocketProcessor(bind=(\"127.0.0.1\", port), authkey=AUTHKEY)\n", - "# test_conn = Client(mock.address, authkey=AUTHKEY)\n", - "# test_conn.send({\"cmd\": \"ping\"})\n", - "# print(test_conn.recv())\n", - "# mock.process([[1, 2, 0.9]], frame_time=123.456)\n", - "# print(decode_payload(test_conn.recv()))\n", - "# test_conn.close()\n", - "# mock.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "deeplabcut-live-gui (3.12.12)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From c9c6191c4988c857a4189709806fa3b63d3e2249 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:21:29 +0200 Subject: [PATCH 172/194] pre-commit --- dlclivegui/gui/main_window.py | 32 +++++-------------- .../custom_processors/test_base_processor.py | 3 -- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index cb837bd91..37b9924fc 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2161,16 +2161,11 @@ def _configure_dlc(self) -> bool: RuntimeError, json.JSONDecodeError, ) as exc: - self._show_error( - f"Invalid DLCLive settings: {exc}" - ) + self._show_error(f"Invalid DLCLive settings: {exc}") return False if not settings.model_path: - self._show_error( - "Please select a DLCLive model before " - "starting inference." - ) + self._show_error("Please select a DLCLive model before starting inference.") return False processor = None @@ -2181,26 +2176,21 @@ def _configure_dlc(self) -> bool: if self._custom_processor_enabled(): try: - processor_info = self._scanned_processors[ - selected_key - ] + processor_info = self._scanned_processors[selected_key] processor_class = processor_info["class"] processor_name = processor_info.get( "name", processor_class.__name__, ) - if processor_builds_in_worker( - processor_class - ): + if processor_builds_in_worker(processor_class): processor_spec = create_spec_from_scan( self._scanned_processors, selected_key, ) log_processor_context( - "MainWindow._configure_dlc - " - f"SPEC: {processor_class.__name__}", + f"MainWindow._configure_dlc - SPEC: {processor_class.__name__}", logger, ) else: @@ -2210,8 +2200,7 @@ def _configure_dlc(self) -> bool: ) log_processor_context( - "MainWindow._configure_dlc - " - f"INSTANCE: {type(processor).__name__}", + f"MainWindow._configure_dlc - INSTANCE: {type(processor).__name__}", logger, ) @@ -2221,10 +2210,7 @@ def _configure_dlc(self) -> bool: ) except Exception as exc: - error_msg = ( - "Failed to configure processor: " - f"{exc}" - ) + error_msg = f"Failed to configure processor: {exc}" self._show_error(error_msg) logger.exception(error_msg) return False @@ -2240,9 +2226,7 @@ def _configure_dlc(self) -> bool: processor=processor, processor_spec=processor_spec, ) - self._model_path_store.save_if_valid( - settings.model_path - ) + self._model_path_store.save_if_valid(settings.model_path) return True def _update_inference_buttons(self) -> None: diff --git a/tests/custom_processors/test_base_processor.py b/tests/custom_processors/test_base_processor.py index cb8d2e7f8..db42d4952 100644 --- a/tests/custom_processors/test_base_processor.py +++ b/tests/custom_processors/test_base_processor.py @@ -3,9 +3,6 @@ import importlib import pickle -from pathlib import Path -import sys -import types import numpy as np import pandas as pd From b85127c4651d871a4995822d9dfe581cffbd30af Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:47:41 +0200 Subject: [PATCH 173/194] Use shared worker-build processor helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the service’s direct dependency on `BaseProcessorSocket.do_build_in_worker` with `processor_builds_in_worker` from `processor_utils`, improving reuse and reducing coupling to socket internals. Also updated the recording context docstring to use `timestamp_json_files` for consistent key naming. --- dlclivegui/processors/dlc_processor_socket.py | 2 +- dlclivegui/services/dlc_processor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 5c28bc887..c1f7aa5e8 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -421,7 +421,7 @@ def set_recording_context(self, context: dict | None) -> None: filename_stem processor_base_path video_files - timestamp_files + timestamp_json_files """ self.recording_context = dict(context or {}) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index e51be7500..d141a8562 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -17,12 +17,12 @@ from PySide6.QtCore import QObject, Signal from dlclivegui.config import DLC_DO_LOG_TIMING, DLCProcessorSettings, ModelType -from dlclivegui.processors.dlc_processor_socket import BaseProcessorSocket from dlclivegui.processors.processor_utils import ( ProcessorSpec, create_spec_from_scan, instantiate_from_scan, log_processor_context, + processor_builds_in_worker, ) from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released from dlclivegui.utils.stats import WorkerTimingStats @@ -948,7 +948,7 @@ def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, se processor_info = scanned_processors[selected_key] processor_class = processor_info["class"] - if BaseProcessorSocket.do_build_in_worker(processor_class): + if processor_builds_in_worker(processor_class): processor_spec = create_spec_from_scan(scanned_processors, selected_key) log_processor_context( From 083108ed203617912fe56593280e99c66db42d97 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:52:25 +0200 Subject: [PATCH 174/194] Preserve processor recording context lifecycle Store the processor recording context when recording starts and reuse that same context when notifying `on_recording_stopped`, instead of rebuilding it from recorder state. This also centralizes stop-time processor finalization (stop hook + save) and invokes it both on normal async stop and during shutdown when a recording context exists. --- dlclivegui/gui/main_window.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 37b9924fc..21f526681 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -154,6 +154,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False + self._processor_recording_context: dict | None = None self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 @@ -1863,7 +1864,9 @@ def _start_multi_camera_recording(self) -> None: if run_dir is None: self._show_error("Failed to start recording.") return - self._notify_processor_recording_started(run_dir) + + self._processor_recording_context = self._build_processor_recording_context(run_dir) + self._notify_processor_recording_started(self._processor_recording_context) self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame) self.multi_camera_controller.set_recording_frame_is_enabled(True) @@ -1957,7 +1960,7 @@ def _save_processor_data_if_available(self) -> None: except Exception: logger.exception("Processor save() failed.") - def _notify_processor_recording_started(self, run_dir) -> None: + def _notify_processor_recording_started(self, context: dict) -> None: processor = self._get_dlc_processor_instance() if processor is None: return @@ -1967,14 +1970,11 @@ def _notify_processor_recording_started(self, run_dir) -> None: return try: - context = self._build_processor_recording_context(run_dir) hook(context) logger.info("Notified processor recording started: %s", context) except Exception: logger.exception("Processor on_recording_started hook failed") - from pathlib import Path - def _build_processor_recording_context(self, run_dir) -> dict: run_dir = Path(run_dir) if run_dir is not None else None @@ -2013,26 +2013,30 @@ def _build_processor_recording_context(self, run_dir) -> dict: def _notify_processor_recording_stopped(self) -> None: processor = self._get_dlc_processor_instance() if processor is None: - return False + return hook = getattr(processor, "on_recording_stopped", None) if not callable(hook): - return False + return try: - run_dir = getattr(self._rec_manager, "run_dir", None) - context = self._build_processor_recording_context(run_dir) + context = self._processor_recording_context or self._build_processor_recording_context(None) hook(context) logger.info("Notified processor recording stopped") - return True except Exception: logger.exception("Processor on_recording_stopped hook failed") - return False - def _on_recording_stopped_async(self) -> None: - handled_by_stop_hook = self._notify_processor_recording_stopped() - if not handled_by_stop_hook: + def _finalize_processor_recording(self) -> None: + """Notify the processor and save its recording data.""" + try: + self._notify_processor_recording_stopped() self._save_processor_data_if_available() + finally: + self._processor_recording_finalized = None + + def _on_recording_stopped_async(self) -> None: + if self._processor_recording_context is not None: + self._finalize_processor_recording() self._recording_stopping = False self.start_record_button.setEnabled(True) @@ -2667,9 +2671,12 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha self.multi_camera_controller.set_recording_frame_is_enabled(False) except Exception: logger.exception("Failed to disable recording frame emission during shutdown") + while not self._rec_manager.stop_all(): logger.info("Retrying recorder stop during shutdown...") time.sleep(RECORD_STOP_RETRY_INTERVAL) + if self._processor_recording_context is not None: + self._finalize_processor_recording() # Close the camera dialog if open (ensures its worker thread is canceled) if getattr(self, "_cam_dialog", None) is not None and self._cam_dialog.isVisible(): From a326bc33c2850aa34a6b4b853b6ae4b43da4a397 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:53:22 +0200 Subject: [PATCH 175/194] Defer DLC processor cleanup until worker exits Adjust shutdown/reset flow to avoid cleaning up the processor while the worker thread is still alive. Cleanup now runs immediately only after a successful stop, and is deferred to the reaper path when a pending reset completes after the worker eventually joins. --- dlclivegui/services/dlc_processor.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index d141a8562..4a51bbc55 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -276,13 +276,13 @@ def shutdown(self) -> None: stopped = self._stop_worker() if not stopped: with self._lifecycle_lock: - if self._processor is not None: - self._cleanup_processor() self._pending_reset = True - logger.warning( - "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." - ) - return + logger.warning( + "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." + ) + return + + self._cleanup_processor() self._dlc = None self._initialized = False @@ -546,6 +546,7 @@ def _schedule_reap(self, t: threading.Thread) -> None: # ensure only one reaper def reap(): + should_cleanup = False try: t.join() # wait without timeout in background with self._lifecycle_lock: @@ -557,12 +558,16 @@ def reap(): self._stop_event.clear() if self._pending_reset: + should_cleanup = True self._dlc = None self._initialized = False self._pending_reset = False logger.warning("DLC worker thread stopped after the timeout and was cleaned up late.") + if should_cleanup: # worker joined, cleanup is safe + self._cleanup_processor() + finally: with self._lifecycle_lock: self._reaping = False From c27c8e541a899cf58db653051d6702372173cf70 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:55:08 +0200 Subject: [PATCH 176/194] Fix processor recording context cleanup Corrects the recording-stop cleanup path to clear `_processor_recording_context` instead of an unrelated attribute. This ensures the async stop handler sees the finalized state consistently and avoids stale recording context after shutdown. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 21f526681..525599c9b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2032,7 +2032,7 @@ def _finalize_processor_recording(self) -> None: self._notify_processor_recording_stopped() self._save_processor_data_if_available() finally: - self._processor_recording_finalized = None + self._processor_recording_context = None def _on_recording_stopped_async(self) -> None: if self._processor_recording_context is not None: From d31d38945eb7bade74a69f027af782a540875c10 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 14:28:01 +0200 Subject: [PATCH 177/194] Improve processor reset and recording hooks Refines recording lifecycle integration by capturing finalized recording file context when notifying `on_recording_stopped`, and introducing tracking for recording-start notifications during DLC initialization. It also tightens processor teardown behavior by distinguishing pending reset vs pending plugin cleanup, and ensures full processor plugin cleanup when stopping DLC from the UI/service. --- dlclivegui/gui/main_window.py | 30 +++++++++++++++++++++++----- dlclivegui/services/dlc_processor.py | 14 +++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 525599c9b..caa9f9e33 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -155,6 +155,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._last_pose: PoseResult | None = None self._dlc_active: bool = False self._processor_recording_context: dict | None = None + self._processor_recording_started_notified = False self._pending_recording_after_preview = False self._active_camera_settings: CameraSettings | None = None self._last_drop_warning = 0.0 @@ -1866,6 +1867,7 @@ def _start_multi_camera_recording(self) -> None: return self._processor_recording_context = self._build_processor_recording_context(run_dir) + self._processor_recording_started_notified = False self._notify_processor_recording_started(self._processor_recording_context) self.multi_camera_controller.set_recording_sink(self._rec_manager.write_frame) self.multi_camera_controller.set_recording_frame_is_enabled(True) @@ -1960,20 +1962,22 @@ def _save_processor_data_if_available(self) -> None: except Exception: logger.exception("Processor save() failed.") - def _notify_processor_recording_started(self, context: dict) -> None: + def _notify_processor_recording_started(self, context: dict) -> bool: processor = self._get_dlc_processor_instance() if processor is None: - return + return False hook = getattr(processor, "on_recording_started", None) if not callable(hook): - return + return False try: hook(context) logger.info("Notified processor recording started: %s", context) + return True except Exception: logger.exception("Processor on_recording_started hook failed") + return False def _build_processor_recording_context(self, run_dir) -> dict: run_dir = Path(run_dir) if run_dir is not None else None @@ -2010,6 +2014,18 @@ def _build_processor_recording_context(self, run_dir) -> dict: ctx.update(file_context) return ctx + def _final_processor_recording_context(self) -> dict: + context = dict(self._processor_recording_context or {}) + + try: + final_file_context = self._rec_manager.get_recording_file_context() + except Exception: + logger.exception("Failed to get finalized recording file context") + final_file_context = {} + + context.update(final_file_context) + return context + def _notify_processor_recording_stopped(self) -> None: processor = self._get_dlc_processor_instance() if processor is None: @@ -2020,7 +2036,7 @@ def _notify_processor_recording_stopped(self) -> None: return try: - context = self._processor_recording_context or self._build_processor_recording_context(None) + context = self._final_processor_recording_context() hook(context) logger.info("Notified processor recording stopped") except Exception: @@ -2033,6 +2049,7 @@ def _finalize_processor_recording(self) -> None: self._save_processor_data_if_available() finally: self._processor_recording_context = None + self._processor_recording_started_notified = False def _on_recording_stopped_async(self) -> None: if self._processor_recording_context is not None: @@ -2457,7 +2474,7 @@ def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active self._dlc_active = False self._dlc_initialized = False - self._dlc.reset() + self._dlc.reset(reset_processor_plugin=True) self._last_pose = None self._last_processor_vid_recording = False self._auto_record_session_name = None @@ -2605,6 +2622,9 @@ def _on_bbox_changed(self, _value: int = 0) -> None: def _on_dlc_initialised(self, success: bool) -> None: if success: self._dlc_initialized = True + if self._processor_recording_context is not None and not self._processor_recording_started_notified: + self._notify_processor_recording_started(self._processor_recording_context) + # Update button to show running state self.start_inference_button.setText("DLCLive running!") self.start_inference_button.setStyleSheet("background-color: #4CAF50; color: white;") diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 4a51bbc55..8bd6cc755 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -163,7 +163,7 @@ def __init__(self) -> None: self._dlc: Any | None = None self._processor: Any | None = None self._processor_spec: ProcessorSpec | None = None - self.processor_built_from_spec = False + self._processor_built_from_spec = False # Worker thread and queue self._queue: queue.Queue[Any] | None = None self._worker_thread: threading.Thread | None = None @@ -174,6 +174,7 @@ def __init__(self) -> None: ## Worker cleanup self._reaping = False self._pending_reset = False + self._pending_processor_cleanup = False # Statistics tracking self._frames_enqueued = 0 @@ -221,7 +222,7 @@ def configure( self._settings = settings self._processor = processor self._processor_spec = processor_spec - self.processor_built_from_spec = False + self._processor_built_from_spec = False def reset(self, reset_processor_plugin: bool = False) -> None: """Stop the worker thread and drop the current DLCLive instance.""" @@ -233,6 +234,9 @@ def reset(self, reset_processor_plugin: bool = False) -> None: if not stopped: with self._lifecycle_lock: self._pending_reset = True + self._pending_processor_cleanup = ( + self._pending_processor_cleanup or reset_processor_plugin or had_runtime + ) logger.warning( "Reset requested but worker thread is still alive; skipping DLCLive reset to avoid potential issues." ) @@ -277,6 +281,7 @@ def shutdown(self) -> None: if not stopped: with self._lifecycle_lock: self._pending_reset = True + self._pending_processor_cleanup = True logger.warning( "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." ) @@ -558,10 +563,11 @@ def reap(): self._stop_event.clear() if self._pending_reset: - should_cleanup = True + should_cleanup = self._pending_processor_cleanup self._dlc = None self._initialized = False self._pending_reset = False + self._pending_processor_cleanup = False logger.warning("DLC worker thread stopped after the timeout and was cleaned up late.") @@ -981,7 +987,7 @@ def start(self): def stop(self): self.active = False - self._proc.reset() + self._proc.reset(reset_processor_plugin=True) self._last_pose = None def stats(self) -> ProcessorStats: From f0f8b770360192a0df5595faa2ec9f264979e8e6 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 14:43:42 +0200 Subject: [PATCH 178/194] Disable debug log --- dlclivegui/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c9628ca97..11bcf8049 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -40,7 +40,7 @@ ### Trigger debug logging DEBUG_TRIGGER_LOGS = False ### Extra logs for DLC lifecycle (model loading, etc) -DLC_LIFECYCLE_EXTRA_LOGS: bool = True +DLC_LIFECYCLE_EXTRA_LOGS: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False #### Backends BASLER_DO_LOG_TIMING: bool = False From 48967c09b52748ef0279ecc76b8d94e4be43ad62 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 14:44:12 +0200 Subject: [PATCH 179/194] Fix save path and recording context resolution Improve processor metadata finalization by rebuilding recording context from the current run directory and then overlaying cached values, so missing fields are backfilled before merging file context. Also update socket processor saving so explicitly provided relative filenames are saved under the `data/` directory, matching expected output behavior. --- dlclivegui/gui/main_window.py | 12 +++++++++++- dlclivegui/processors/dlc_processor_socket.py | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index caa9f9e33..bb84dc70f 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2015,7 +2015,17 @@ def _build_processor_recording_context(self, run_dir) -> dict: return ctx def _final_processor_recording_context(self) -> dict: - context = dict(self._processor_recording_context or {}) + cached = self._processor_recording_context + + run_dir = None + if cached is not None: + run_dir = cached.get("run_dir") + if run_dir is None: + run_dir = getattr(self._rec_manager, "run_dir", None) + + context = self._build_processor_recording_context(run_dir) + if cached is not None: + context.update(cached) try: final_file_context = self._rec_manager.get_recording_file_context() diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index c1f7aa5e8..58513f80c 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -376,6 +376,7 @@ def _clear_data_queues(self): def save(self, file=None): target = file + explicit_file = file is not None if target is None: target = getattr(self, "save_path", None) @@ -387,6 +388,9 @@ def save(self, file=None): try: save_dict = self.get_data() save_path = Path(target) + if explicit_file and not save_path.is_absolute(): + save_path = Path("data") / save_path + save_path.parent.mkdir(parents=True, exist_ok=True) if self.save_original: From d799ec45495170cf718cc7f537c5501bccc4b8d1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 14:44:36 +0200 Subject: [PATCH 180/194] Expand recording context test coverage Adds focused tests around processor recording context handling in `DLCLiveMainWindow`, including file metadata propagation, stop-time context refresh, and optional processor hooks. It also introduces a regression test for `BaseProcessorSocket.save()` to ensure explicit relative filenames still write under the legacy `data/` directory. UI label testing for unknown camera IDs was relaxed to assert the neutral text is present rather than requiring an exact string. --- .../test_processor_rec_context.py | 118 +++++++++++++++++- tests/gui/main_window/test_ui.py | 2 +- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/tests/custom_processors/test_processor_rec_context.py b/tests/custom_processors/test_processor_rec_context.py index 16a1461d9..85d840f39 100644 --- a/tests/custom_processors/test_processor_rec_context.py +++ b/tests/custom_processors/test_processor_rec_context.py @@ -212,13 +212,27 @@ def main_window_cls(): return mod.DLCLiveMainWindow -def make_window_shell(main_window_cls, processor=None, run_dir=None): +class DummyRecordingManager: + def __init__(self, run_dir=None, file_context=None): + self.run_dir = run_dir + self.file_context = dict(file_context or {}) + + def get_recording_file_context(self): + context = dict(self.file_context) + if self.run_dir is not None: + context.setdefault("run_dir", self.run_dir) + return context + + +def make_window_shell(main_window_cls, processor=None, run_dir=None, file_context=None): """Create a DLCLiveMainWindow shell without running QMainWindow.__init__.""" win = main_window_cls.__new__(main_window_cls) win._dlc = SimpleNamespace(_processor=processor, _dlc=None) - win._rec_manager = SimpleNamespace(run_dir=run_dir) + win._rec_manager = DummyRecordingManager(run_dir=run_dir, file_context=file_context) win.session_name_edit = DummyLineEdit("MouseA") win.filename_edit = DummyLineEdit("MouseA_2026-07-10_1.avi") + win._processor_recording_context = None + win._processor_recording_started_notified = False return win @@ -253,7 +267,8 @@ def test_main_window_notify_processor_recording_started_calls_hook(main_window_c processor = HookProcessor() win = make_window_shell(main_window_cls, processor=processor, run_dir=tmp_path) - win._notify_processor_recording_started(tmp_path) + context = win._build_processor_recording_context(tmp_path) + win._notify_processor_recording_started(context) assert len(processor.started_contexts) == 1 context = processor.started_contexts[0] @@ -287,6 +302,101 @@ class NoHooks: win = make_window_shell(main_window_cls, processor=NoHooks(), run_dir=tmp_path) # Optional hooks/save absence should not crash. - win._notify_processor_recording_started(tmp_path) + ctx = win._build_processor_recording_context(tmp_path) + win._notify_processor_recording_started(ctx) win._notify_processor_recording_stopped() win._save_processor_data_if_available() + + +def test_main_window_build_context_includes_recording_files( + main_window_cls, + tmp_path, +): + video_file = tmp_path / "camera_0.mp4" + timestamp_file = tmp_path / "camera_0.json" + + win = make_window_shell( + main_window_cls, + run_dir=tmp_path, + file_context={ + "video_files": { + "camera-0": video_file, + }, + "timestamp_json_files": { + "camera-0": timestamp_file, + }, + }, + ) + + context = win._build_processor_recording_context(tmp_path) + + assert context["video_files"] == { + "camera-0": video_file, + } + assert context["timestamp_json_files"] == { + "camera-0": timestamp_file, + } + + +def test_processor_stop_context_uses_finalized_file_context( + main_window_cls, + tmp_path, +): + processor = HookProcessor() + initial_video = tmp_path / "initial.mp4" + final_video = tmp_path / "final.mp4" + + win = make_window_shell( + main_window_cls, + processor=processor, + run_dir=tmp_path, + file_context={ + "video_files": { + "camera-0": initial_video, + }, + }, + ) + win._processor_recording_context = win._build_processor_recording_context(tmp_path) + + # Simulate RecordingManager updating its context during stop_all(). + win._rec_manager.file_context = { + "video_files": { + "camera-0": final_video, + }, + } + + win._notify_processor_recording_stopped() + + assert len(processor.stopped_contexts) == 1 + assert processor.stopped_contexts[0]["video_files"] == { + "camera-0": final_video, + } + + +def test_base_processor_explicit_relative_file_uses_legacy_data_dir( + socket_mod, + tmp_path, + monkeypatch, +): + BaseProcessorSocket = socket_mod.BaseProcessorSocket + proc = BaseProcessorSocket( + bind=("127.0.0.1", 0), + save_original=False, + ) + + monkeypatch.chdir(tmp_path) + + try: + ret = proc.save("session_dlc_processor_data.pkl") + + expected = tmp_path / "data" / "session_dlc_processor_data.pkl" + + assert ret == 1 + assert expected.exists() + + with expected.open("rb") as handle: + payload = pickle.load(handle) + + assert "start_time" in payload + finally: + proc.stop() diff --git a/tests/gui/main_window/test_ui.py b/tests/gui/main_window/test_ui.py index 17ba0513e..351539883 100644 --- a/tests/gui/main_window/test_ui.py +++ b/tests/gui/main_window/test_ui.py @@ -49,5 +49,5 @@ def test_label_for_cam_id_uses_runtime_display_id_fallback(self, window): assert w._label_for_cam_id("runtime:id") == "Runtime Camera" - def test_label_for_cam_id_unknown_is_neutral(self, window): + def test_label_for_cam_id_unknown(self, window): assert "Unknown camera" in window._label_for_cam_id("missing:id") From fd2e7164ce007fb790984bab987cc74038fe4e2e Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 15:25:40 +0200 Subject: [PATCH 181/194] Fix recording start flag and add worker test Set `_processor_recording_started_notified` immediately after a successful recording-start hook callback so the UI tracks notification state correctly and avoids repeated notifications. Also add a unit test that verifies `ProcessorSpec` processors are instantiated on the `DLCLiveWorker` thread, wired into `DLCLive`, and properly stopped/cleared when resetting the processor. --- dlclivegui/gui/main_window.py | 1 + tests/services/test_dlc_processor.py | 100 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index bb84dc70f..c4480a521 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1973,6 +1973,7 @@ def _notify_processor_recording_started(self, context: dict) -> bool: try: hook(context) + self._processor_recording_started_notified = True logger.info("Notified processor recording started: %s", context) return True except Exception: diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py index a58490e3e..42b72d718 100644 --- a/tests/services/test_dlc_processor.py +++ b/tests/services/test_dlc_processor.py @@ -1,6 +1,7 @@ from __future__ import annotations import queue +import threading import numpy as np import pytest @@ -8,6 +9,7 @@ from dlclivegui.config import DLCProcessorSettings # from dlclivegui.config import DLCProcessorSettings +from dlclivegui.processors.processor_utils import ProcessorSpec from dlclivegui.services.dlc_processor import ( DLCLiveProcessor, ProcessorStats, @@ -55,6 +57,104 @@ def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_ proc.reset() # Ensure thread cleanup +@pytest.mark.unit +def test_processor_spec_builds_in_worker_and_is_cleaned( + qtbot, + monkeypatch, + settings_model, +): + """Build a ProcessorSpec in DLCLiveWorker and clean it on reset.""" + from dlclivegui.services import dlc_processor as dlc_processor_module + + built_processors = [] + dlclive_instances = [] + + class WorkerBuiltProcessor: + PROCESSOR_NAME = "Worker-built test processor" + + def __init__(self): + self.build_thread_name = threading.current_thread().name + self.stop_calls = 0 + built_processors.append(self) + + def process(self, pose, **kwargs): + return pose + + def stop(self): + self.stop_calls += 1 + + class FakeRunner: + def get_pose(self, _processed_frame): + return np.array( + [[1.0, 2.0, 0.9]], + dtype=np.float32, + ) + + class FakeDLCLive: + def __init__(self, **options): + self.processor = options["processor"] + self.runner = FakeRunner() + self.pose = None + self.cfg = {} + self.init_called = False + dlclive_instances.append(self) + + def init_inference(self, _frame): + self.init_called = True + + def process_frame(self, frame): + return frame + + monkeypatch.setattr( + dlc_processor_module, + "DLCLive", + FakeDLCLive, + ) + + proc = DLCLiveProcessor() + proc.configure( + settings_model, + processor_spec=ProcessorSpec( + cls=WorkerBuiltProcessor, + ), + ) + + frame = np.zeros( + (32, 32, 3), + dtype=np.uint8, + ) + + try: + with qtbot.waitSignal( + proc.initialized, + timeout=1500, + ) as blocker: + proc.enqueue_frame(frame, timestamp=1.0) + + assert blocker.args == [True] + assert len(built_processors) == 1 + assert len(dlclive_instances) == 1 + + built_processor = built_processors[0] + dlclive_instance = dlclive_instances[0] + + assert built_processor.build_thread_name == "DLCLiveWorker" + assert proc._processor is built_processor + assert proc._processor_built_from_spec is True + assert dlclive_instance.processor is built_processor + assert dlclive_instance.init_called is True + + proc.reset(reset_processor_plugin=True) + + assert built_processor.stop_calls == 1 + assert proc._processor is None + assert proc._processor_built_from_spec is False + assert proc._state == WorkerState.STOPPED + + finally: + proc.shutdown() + + @pytest.mark.unit def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_model): proc = DLCLiveProcessor() From 4968b2464757511d6c1d46920bba34a1fcb5e96b Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:17:41 +0200 Subject: [PATCH 182/194] fix: save processor data before destroying it when stopping inference during recording When the user clicks "Stop pose inference" before "Stop recording", the processor instance was destroyed by reset() without saving its accumulated data. Later the recording stop flow would find no processor instance and silently skip the save. Now _stop_inference() saves processor data first if recording is still active, so data is preserved regardless of stop-button order. --- dlclivegui/gui/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c4480a521..16365a7ac 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2483,6 +2483,10 @@ def _start_inference(self) -> None: def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active + + if self._rec_manager.is_active: + self._save_processor_data_if_available() + self._dlc_active = False self._dlc_initialized = False self._dlc.reset(reset_processor_plugin=True) From 2fcba1e177877d54f55a91918c88d7c25e8c630b Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:39:16 +0200 Subject: [PATCH 183/194] revert 3eba29f85b7e187c37bb791307c4a1b0e94caa27 partial save for crash path should not be called when stopping inference. --- dlclivegui/gui/main_window.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 16365a7ac..c4480a521 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2483,10 +2483,6 @@ def _start_inference(self) -> None: def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active - - if self._rec_manager.is_active: - self._save_processor_data_if_available() - self._dlc_active = False self._dlc_initialized = False self._dlc.reset(reset_processor_plugin=True) From 75e4921a8225c455b69963ff9bebc1e81c3a34ec Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:41:10 +0200 Subject: [PATCH 184/194] fix dlc_processor: clean up custom processor during DLC shutdown `shutdown()` was skipping `_cleanup_processor()` when the worker thread stopped cleanly, leaving the custom processor's resources unreleased and its buffered data unsaved. This commit adds the missing `_cleanup_processor()` call before tearing down the DLCLive instance. --- dlclivegui/services/dlc_processor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 8bd6cc755..c2993d791 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -282,10 +282,10 @@ def shutdown(self) -> None: with self._lifecycle_lock: self._pending_reset = True self._pending_processor_cleanup = True - logger.warning( - "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." - ) - return + logger.warning( + "Shutdown requested but worker thread is still alive; DLCLive instance may not be fully released." + ) + return self._cleanup_processor() self._dlc = None From d9f6bf257aa36aeb7903f467bf8a3eb781bc48c9 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:55:07 +0200 Subject: [PATCH 185/194] warn: confirm before stopping inference while recording Stopping the DLC processor during a recording skips the processor's `on_recording_stopped` hook, which would normally handle legacy output copies and DB-compatible file alignment. Show a confirmation dialog when the user attempts to stop inference while recording is still active, recommending they stop recording first. --- dlclivegui/gui/main_window.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c4480a521..e26d61cf3 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2482,6 +2482,18 @@ def _start_inference(self) -> None: self._update_dlc_controls_enabled() def _stop_inference(self, show_message: bool = True) -> None: + if self._rec_manager.is_active: + answer = QMessageBox.question( + self, + "Stop inference while recording?", + "This will stop any currently running DLC-processor. \n" + "File saving will not be handled via standard stop-recording hook." + "The processor might still save it's own data now, but this will not be paired with the recording.\n\n" + "Stop inference anyway?", + ) + if answer != QMessageBox.Yes: + return + was_active = self._dlc_active self._dlc_active = False self._dlc_initialized = False From 4e1683d0a02183b9e617056bcca9c2f1acf126cd Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:34:31 +0200 Subject: [PATCH 186/194] Update dlclivegui/gui/main_window.py Co-authored-by: Cyril Achard --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e26d61cf3..fa2db20c1 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2486,7 +2486,7 @@ def _stop_inference(self, show_message: bool = True) -> None: answer = QMessageBox.question( self, "Stop inference while recording?", - "This will stop any currently running DLC-processor. \n" + "This will stop currently running DLC-live custom processor. \n" "File saving will not be handled via standard stop-recording hook." "The processor might still save it's own data now, but this will not be paired with the recording.\n\n" "Stop inference anyway?", From 9c2bbd631e69ee693fa30350ecebd01a361af630 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:34:41 +0200 Subject: [PATCH 187/194] Update dlclivegui/gui/main_window.py Co-authored-by: Cyril Achard --- dlclivegui/gui/main_window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index fa2db20c1..108b2da3e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2487,8 +2487,8 @@ def _stop_inference(self, show_message: bool = True) -> None: self, "Stop inference while recording?", "This will stop currently running DLC-live custom processor. \n" - "File saving will not be handled via standard stop-recording hook." - "The processor might still save it's own data now, but this will not be paired with the recording.\n\n" + "File saving will not be handled via the standard 'recording stopped' event hooks." + "The processor might still save data now, but this will not be paired with the recording.\n\n" "Stop inference anyway?", ) if answer != QMessageBox.Yes: From fdc929c5ec057b7f061df366b2e0ccc8317d9cb8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 10 Aug 2026 16:09:47 +0200 Subject: [PATCH 188/194] Fix message when stopping inference while recording --- dlclivegui/gui/main_window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 108b2da3e..abd3d5c17 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2486,9 +2486,9 @@ def _stop_inference(self, show_message: bool = True) -> None: answer = QMessageBox.question( self, "Stop inference while recording?", - "This will stop currently running DLC-live custom processor. \n" + "This will stop the currently running DLC-live custom processor, if any.\n" "File saving will not be handled via the standard 'recording stopped' event hooks." - "The processor might still save data now, but this will not be paired with the recording.\n\n" + "The processor might still save data now, but it will not be paired with the recording.\n\n" "Stop inference anyway?", ) if answer != QMessageBox.Yes: From cd463f8d233ab7bd630fb429fd1bf7e419827043 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:07:52 +0200 Subject: [PATCH 189/194] Fix camera startup state and sink arg handling Updates camera worker and multi-camera startup logic for more reliable runtime behavior. `recording_sink` is now called with `timestamp_metadata` as a keyword argument to match expected callable signatures, and `is_starting()` now reports startup-in-progress until all expected cameras have either started or failed (instead of only checking for zero started cameras). The worker also no longer clears `_stop_event` at run start, preserving pending stop state. --- dlclivegui/services/camera_controller.py | 25 +++++++++++++------ .../services/multi_camera_controller.py | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/dlclivegui/services/camera_controller.py b/dlclivegui/services/camera_controller.py index 9c3a76c8d..9f29af95d 100644 --- a/dlclivegui/services/camera_controller.py +++ b/dlclivegui/services/camera_controller.py @@ -63,7 +63,9 @@ def set_recording_enabled(self, enabled: bool) -> None: @Slot() def run(self) -> None: - self._stop_event.clear() + if self._stop_event.is_set(): + self.stopped.emit(self._camera_id) + return try: logger.debug( @@ -85,18 +87,18 @@ def run(self) -> None: ) self._backend.open() - + if self._stop_event.is_set(): try: self._backend.close() except Exception: - logger.exception(f"[Worker %s] failed to close backend during early stop", self._camera_id) + logger.exception("[Worker %s] failed to close backend during early stop", self._camera_id) finally: self._backend = None - + self.stopped.emit(self._camera_id) return - + self.runtime_info.emit( self._camera_id, { @@ -107,7 +109,16 @@ def run(self) -> None: }, ) except Exception as exc: - logger.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) + logger.exception("[Worker %s] Failed to initialize camera: %s", self._camera_id, exc) + + if self._backend is not None: + try: + self._backend.close() + except Exception: + logger.exception("[Worker %s] failed to close backend after initialization error", self._camera_id) + finally: + self._backend = None + self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") self.stopped.emit(self._camera_id) return @@ -144,7 +155,7 @@ def run(self) -> None: 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) + recording_sink(self._camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) except Exception as exc: logger.exception(f"Failed to write frame for camera {self._camera_id}: {exc}") diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 396bdd4c2..134efe355 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -159,7 +159,8 @@ def is_active(self) -> bool: def is_starting(self) -> bool: """Check whether cam initialization is still in progress""" - return bool(self._running and not self._stopping and not self._started_cameras and self._workers) + total_reported = len(self._started_cameras) + len(self._failed_cameras) + return bool(self._running and not self._stopping and total_reported < self._expected_cameras) def get_active_count(self) -> int: """Get the number of active cameras.""" From f2b034ce298ef5cbaa348f617ae0a20e83fa675f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:17:30 +0200 Subject: [PATCH 190/194] Copy frames before queueing for recorder Ensure each frame is copied before enqueueing in `VideoRecorder` so the async writer works with an immutable snapshot. This prevents downstream frame mutation/race issues when control returns immediately to the capture pipeline. --- dlclivegui/services/video_recorder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/video_recorder.py b/dlclivegui/services/video_recorder.py index 8baa98c37..532a46e34 100644 --- a/dlclivegui/services/video_recorder.py +++ b/dlclivegui/services/video_recorder.py @@ -326,7 +326,9 @@ def write( try: with self._process_timing.measure("Recorder.queue_put"): - q.put((frame, timestamp, timestamp_metadata), block=False) + # writer consumes frames async, so we copy before returning control to the capture pipeline + queued_frame = frame.copy() + q.put((queued_frame, timestamp, timestamp_metadata), block=False) except queue.Full: with self._stats_lock: self._dropped_frames += 1 From fbbefe7669e0699d647ba2cb505bd2eba55539c2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:21:15 +0200 Subject: [PATCH 191/194] Handle recording stop failures in UI Emit a dedicated async signal when stopping a recording raises an exception, and handle it on the GUI thread. The new failure handler clears the stopping state, restores start/stop button availability based on recorder activity, updates camera controls, and shows a longer status-bar error message so users get clear feedback instead of a silent stuck state. --- dlclivegui/gui/main_window.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index abd3d5c17..1f77c4847 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -95,6 +95,7 @@ class DLCLiveMainWindow(QMainWindow): """Main application window.""" _recording_stopped_async = Signal() + _rec_stop_failed_async = Signal(str) def __init__(self, config: ApplicationSettings | None = None): super().__init__() @@ -193,6 +194,7 @@ def __init__(self, config: ApplicationSettings | None = None): # Recording state self._recording_stopping = False self._recording_stopped_async.connect(self._on_recording_stopped_async) + self._rec_stop_failed_async.connect(self._on_recording_stop_failed_async) self._load_icons() self._preview_pixmap = QPixmap(LOGO_ALPHA) @@ -1910,6 +1912,7 @@ def worker(): raise RuntimeError("Could not stop recording within timeout period.") except Exception as e: logger.exception("Error while stopping recording: %s", e) + self._rec_stop_failed_async.emit(str(e)) return self._recording_stopped_async.emit() @@ -2072,6 +2075,19 @@ def _on_recording_stopped_async(self) -> None: self.statusBar().showMessage("Multi-camera recording stopped", 3000) self._update_camera_controls_enabled() + def _on_recording_stop_failed_async(self, message: str) -> None: + self._recording_stopping = False + + still_active = self._rec_manager.is_active + self.start_record_button.setEnabled(not still_active) + self.stop_record_button.setEnabled(still_active) + + self.statusBar().showMessage( + f"Failed to stop recording: {message}", + 10000, + ) + self._update_camera_controls_enabled() + # ------------------------------------------------------------------ # Camera control def _show_logo_and_text(self): From fecfb0c2d427533a6519bee9a22902d33892b958 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:25:17 +0200 Subject: [PATCH 192/194] Clarify stop-inference data loss warning Improves the recording-stop confirmation text to clearly state that standard recording-stopped save hooks are bypassed, saving depends on processor stop handling, and unsaved processor data may be lost or unpaired. Also adds an inline code comment documenting that reset does not trigger normal recording stop/save hooks. --- dlclivegui/gui/main_window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 1f77c4847..3efc7334a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -2503,8 +2503,9 @@ def _stop_inference(self, show_message: bool = True) -> None: self, "Stop inference while recording?", "This will stop the currently running DLC-live custom processor, if any.\n" - "File saving will not be handled via the standard 'recording stopped' event hooks." - "The processor might still save data now, but it will not be paired with the recording.\n\n" + "File saving will not be handled via the standard 'recording stopped' event hooks" + ", so data will only be saved if the processor handles saving on stop.\n\n" + "Unsaved processor data may be lost or not be paired with the recording.\n\n" "Stop inference anyway?", ) if answer != QMessageBox.Yes: @@ -2513,6 +2514,7 @@ def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active self._dlc_active = False self._dlc_initialized = False + # Does NOT invoke the normal rec-stop/save hooks. Persistence is processor-dependent. self._dlc.reset(reset_processor_plugin=True) self._last_pose = None self._last_processor_vid_recording = False From e1e8f935c19893c6d098da4feff11ea50dc6896c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:29:26 +0200 Subject: [PATCH 193/194] Remove unused recording frame callback Deletes `_on_recording_frame_ready` from `main_window.py`, removing a lean per-camera recording path that bypassed processing, DLC routing, display updates, and FPS tracking. This cleanup reduces dead code and keeps frame handling centered on the multi-frame processing flow. --- dlclivegui/gui/main_window.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 3efc7334a..d049863e7 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -856,7 +856,6 @@ 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.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) @@ -1695,26 +1694,6 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_recording_frame_ready( - self, camera_id: str, frame: np.ndarray, timestamp: float, timestamp_metadata: object | None = None - ) -> None: - """Handle full-rate per-camera frames for recording only. - - Intentionally lean: - - no MultiFrameData processing - - no DLC routing - - no display state updates - - no FPS tracker - - optional overlays only if user requested recording overlays - """ - if not self._rec_manager.is_active: - return - - # if self.record_with_overlays_checkbox.isChecked(): - # frame = self._render_overlays_for_recording(camera_id, frame) - - self._rec_manager.write_frame(camera_id, frame, timestamp, timestamp_metadata=timestamp_metadata) - def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. From 7d1dee215b3583029076435f2dce017bb6419d50 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 21 Aug 2026 10:35:31 +0200 Subject: [PATCH 194/194] Cover preview stop during camera startup Update GUI preview tests to mock `is_active` alongside `is_running`, aligning with current controller-state checks. Adds a regression test for stopping preview while a camera is still starting (`is_active=True`, `is_running=False`) and verifies the expected shutdown order: recording, inference, then controller stop. --- tests/gui/main_window/test_preview.py | 1 + tests/gui/test_main.py | 45 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/gui/main_window/test_preview.py b/tests/gui/main_window/test_preview.py index 571813d79..f14f15e14 100644 --- a/tests/gui/main_window/test_preview.py +++ b/tests/gui/main_window/test_preview.py @@ -56,6 +56,7 @@ def test_stop_preview_requests_orderly_shutdown(self, monkeypatch, window): calls: list[str] = [] monkeypatch.setattr(w.multi_camera_controller, "is_running", lambda: True) + monkeypatch.setattr(w.multi_camera_controller, "is_active", lambda: True) monkeypatch.setattr(w, "_stop_multi_camera_recording", lambda: calls.append("recording")) monkeypatch.setattr(w, "_stop_inference", lambda show_message=False: calls.append("inference")) monkeypatch.setattr( diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 9956f0136..db3738225 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -45,6 +45,9 @@ def test_preview_renders_frames( def fake_is_running(): return running + def fake_is_active(): + return running + def fake_get_active_count(): return 1 if running else 0 @@ -65,6 +68,7 @@ def fake_stop(*, wait=True): QTimer.singleShot(0, ctrl.all_stopped.emit) monkeypatch.setattr(ctrl, "is_running", fake_is_running) + monkeypatch.setattr(ctrl, "is_active", fake_is_active) monkeypatch.setattr( ctrl, "get_active_count", @@ -96,6 +100,47 @@ def fake_stop(*, wait=True): assert w._current_frame is None +def test_stop_preview_while_camera_is_starting( + monkeypatch, + window, +): + calls = [] + + monkeypatch.setattr( + window.multi_camera_controller, + "is_running", + lambda: False, + ) + monkeypatch.setattr( + window.multi_camera_controller, + "is_active", + lambda: True, + ) + monkeypatch.setattr( + window.multi_camera_controller, + "stop", + lambda *args, **kwargs: calls.append("controller"), + ) + monkeypatch.setattr( + window, + "_stop_multi_camera_recording", + lambda: calls.append("recording"), + ) + monkeypatch.setattr( + window, + "_stop_inference", + lambda show_message=False: calls.append("inference"), + ) + + window._stop_preview() + + assert calls == [ + "recording", + "inference", + "controller", + ] + + @pytest.mark.gui @pytest.mark.functional def test_start_inference_emits_pose(qtbot, window, multi_camera_controller, dlc_processor, tmp_path):