From 395352d74e7ae6ea34331811d2d1434a85b45d94 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:16 +0200 Subject: [PATCH 01/71] Add GenTL hardware trigger support Add generic hardware-trigger support for GenTL cameras and expose trigger settings in CameraSettings. - Introduce CameraTriggerSettings model (config.py) with role/input/output/timeout/strict and helpers to coerce and serialize. - Allow CameraSettings to read/write backend-specific trigger options. - GenTLCameraBackend: import and use trigger settings, apply trigger timeout override, persist actual trigger config, and mark hardware_trigger capability as BEST_EFFORT. - Implement trigger configuration paths: off, external/follower (input), and master (output). Add helper methods (_set_enum_node, _node, _node_symbolics, _trigger_attr, _trigger_to_dict) to safely interact with GenICam nodes. - Improve lifecycle handling: configure trigger after other settings, restore a safe non-triggering state on stop, and provide waits_for_hardware_trigger property and clearer timeout messages when waiting for hardware triggers. - Add DEFAULT_CAPABILITIES entry for hardware_trigger in base defaults. These changes enable safer and configurable hardware-trigger operation for GenTL backends and improve error reporting and shutdown behavior. --- dlclivegui/cameras/backends/gentl_backend.py | 180 ++++++++++++++++++- dlclivegui/cameras/base.py | 1 + dlclivegui/config.py | 96 ++++++++++ 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 5c71cd287..ae5175b16 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -7,11 +7,12 @@ import threading import time from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar import cv2 import numpy as np +from ...config import CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -105,6 +106,16 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + except Exception as exc: + LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + self._timeout = float(trigger_timeout) + self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() self._actual_width: int | None = None @@ -154,6 +165,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.SUPPORTED, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, } # ------------------------------------------------------------------ @@ -423,11 +435,12 @@ def open(self) -> None: self._device_label = self._resolve_device_label(node_map) self._configure_pixel_format(node_map) - self._configure_trigger(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) + self._configure_trigger(node_map) # keep low in the list + self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -452,6 +465,11 @@ def open(self) -> None: f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" ) from exc + @property + 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 read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -473,6 +491,9 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + if role in {"external", "follower"}: + raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc frame = self._convert_frame(frame) @@ -505,6 +526,12 @@ def close(self) -> None: except Exception: pass + try: + node_map = self._acquirer.remote_device.node_map + self._call_with_optional_lock(self._restore_trigger_idle, node_map) + except Exception: + pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: @@ -952,6 +979,62 @@ def _create_acquirer(self, serial: str | None, index: int): # ------------------------------------------------------------------ # Camera configuration helpers # ------------------------------------------------------------------ + @staticmethod + def _node(node_map, name: str): + try: + return getattr(node_map, name) + except Exception: + return None + + @staticmethod + def _node_symbolics(node) -> list[str]: + try: + return list(getattr(node, "symbolics", []) or []) + except Exception: + return [] + + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: + node = self._node(node_map, name) + if node is None: + if strict: + raise RuntimeError(f"GenICam node '{name}' is not available") + LOG.debug("GenICam node '%s' is not available; skipping", name) + return False + + symbolics = self._node_symbolics(node) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"GenICam node '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("GenICam node '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + node.value = value + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set GenICam node '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set GenICam node '%s' to '%s': %s", name, value, exc) + return False + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict[str, Any]: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} def _configure_pixel_format(self, node_map) -> None: try: @@ -1007,12 +1090,95 @@ def _configure_pixel_format(self, node_map) -> None: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) def _configure_trigger(self, node_map) -> None: + cfg = self._trigger + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(node_map, strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(node_map, cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(node_map, cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported GenTL trigger role: {role!r}") + + LOG.warning("Unsupported GenTL trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(node_map, strict=False) + + def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + + # Disable trigger while changing trigger-related nodes. + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) + + if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): + if strict: + raise RuntimeError("Could not enable GenTL TriggerMode=On") + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", + self._trigger_attr(cfg, "role", "external"), + selector, + source, + activation, + ) + + def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera runs freerun and exposes an output signal. + self._configure_trigger_off(node_map, strict=False) + + self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + LOG.info( + "GenTL trigger master configured: output_line=%s output_source=%s", + output_line, + output_source, + ) + + def _restore_trigger_idle(self, node_map) -> None: + """Best-effort restore to a safe non-triggering state after acquisition stops. + + Important: + - This should be called after acquirer.stop(), not while acquisition is active. + - It is intentionally non-strict because shutdown should not fail if a node + is missing or read-only. + """ + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): - trigger_mode.value = "Off" - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + if role in {"external", "follower"}: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + elif role == "master": + # Stop driving output if the camera exposes these nodes. + self._set_enum_node(node_map, "LineSource", "Off", strict=False) + self._set_enum_node(node_map, "LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort GenTL trigger restore failed", exc_info=True) def _configure_resolution(self, node_map) -> None: if self._requested_resolution is None: diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index bc91ce9bf..fefedd1d5 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -70,6 +70,7 @@ class SupportLevel(str, Enum): "set_gain": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, + "hardware_trigger": SupportLevel.UNSUPPORTED, } diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6d9e1de76..154eabf40 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -12,6 +12,8 @@ TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] ModelType = Literal["pytorch", "tensorflow"] +TriggerRole = Literal["off", "external", "master", "follower"] +TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] class CameraSettings(BaseModel): @@ -168,6 +170,100 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict: pass return out + def backend_options(self, backend: str | None = None) -> dict[str, Any]: + key = backend or self.backend + props = self.properties if isinstance(self.properties, dict) else {} + ns = props.get(str(key).lower(), {}) + return ns if isinstance(ns, dict) else {} + + def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings: + ns = self.backend_options(backend) + return CameraTriggerSettings.from_any(ns.get("trigger")) + + def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None: + key = backend or self.backend + if not isinstance(self.properties, dict): + self.properties = {} + ns = self.properties.setdefault(str(key).lower(), {}) + if not isinstance(ns, dict): + ns = {} + self.properties[str(key).lower()] = ns + ns["trigger"] = trigger.to_properties() + + +class CameraTriggerSettings(BaseModel): + """ + Generic hardware-trigger settings. + + Backend-specific code may ignore fields that are unsupported by a given + camera/SDK. For GenTL, these map to common GenICam nodes such as: + TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, + LineSelector, LineMode, and LineSource. + """ + + role: TriggerRole = "off" + + # Input trigger config: external/follower + selector: str = "FrameStart" + source: str = "Line0" + activation: TriggerActivation | str = "RisingEdge" + + # Output config: master + output_line: str = "Line2" + output_source: str = "ExposureActive" + + # Runtime behavior + timeout: float | None = None + strict: bool = False + + @field_validator("role", mode="before") + @classmethod + def _coerce_role(cls, v): + if v is None: + return "off" + + s = str(v).strip().lower() + aliases = { + "": "off", + "none": "off", + "false": "off", + "disabled": "off", + "disable": "off", + "off": "off", + "true": "external", + "on": "external", + "trigger": "external", + "triggered": "external", + "external": "external", + "follower": "follower", + "slave": "follower", + "master": "master", + "main": "master", + } + return aliases.get(s, s) + + @field_validator("timeout", mode="before") + @classmethod + def _coerce_timeout(cls, v): + if v in (None, ""): + return None + try: + fv = float(v) + except Exception: + return None + return fv if fv > 0 else None + + @classmethod + def from_any(cls, value) -> CameraTriggerSettings: + if isinstance(value, cls): + return value + if isinstance(value, dict): + return cls(**value) + return cls() + + def to_properties(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + class MultiCameraSettings(BaseModel): cameras: list[CameraSettings] = Field(default_factory=list) From 5ea687bfd1f8a41134de3559f456c592c8b1376c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:44 +0200 Subject: [PATCH 02/71] Handle camera trigger defaults & trigger-aware startup Populate gentl trigger defaults on save and allow saving configs with empty DLCLive model path. Add _with_camera_defaults_for_save to ensure CameraTriggerSettings are present for gentl cameras and thread through allow_empty_model_path in _dlc_settings_from_ui/_current_config so configs can be saved without a model while preserving existing DLC fields. Improve multi-camera runtime robustness: treat TimeoutError from hardware-trigger backends as an expected 'no trigger' event (don't count as a camera failure) and add _trigger_role_from_settings/_camera_start_priority helpers. Start active cameras sorted by trigger role so trigger-waiting (external/follower) devices are armed before masters. Also extend DLCLive configuration error handling to include RuntimeError. --- dlclivegui/gui/main_window.py | 47 ++++++++++++-- .../services/multi_camera_controller.py | 64 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index caf0937e2..267fe8f8a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,6 +56,7 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, + CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -857,15 +858,36 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _current_config(self) -> ApplicationSettings: + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + for cam in out.cameras: + backend = (cam.backend or "").lower() + if backend != "gentl": + continue + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility + multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) active_cameras = self._config.multi_camera.get_active_cameras() camera = active_cameras[0] if active_cameras else CameraSettings() return ApplicationSettings( camera=camera, - multi_camera=self._config.multi_camera, - dlc=self._dlc_settings_from_ui(), + multi_camera=multi_camera, + dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), visualization=self._visualization_settings_from_ui(), @@ -877,13 +899,24 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings: model_path = self.model_path_edit.text().strip() if Path(model_path).exists() and Path(model_path).suffix == ".pb": # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) - if model_path == "": + if not model_path: + if allow_empty_model_path: + return DLCProcessorSettings( + model_path="", + model_directory=self._config.dlc.model_directory, # Preserve from config + device=self._config.dlc.device, # Preserve from config + dynamic=self._config.dlc.dynamic, # Preserve from config + resize=self._config.dlc.resize, # Preserve from config + precision=self._config.dlc.precision, # Preserve from config + model_type=None, + # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + ) raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) @@ -968,7 +1001,7 @@ def _action_save_config_as(self) -> None: def _save_config_to_path(self, path: Path) -> None: try: - config = self._current_config() + config = self._current_config(allow_empty_model_path=True) config.save(path) self._settings_store.set_last_config_path(str(path)) self._settings_store.save_full_config_snapshot(config) @@ -1614,7 +1647,7 @@ def _stop_preview(self) -> None: def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() - except (ValueError, json.JSONDecodeError) as exc: + except (ValueError, RuntimeError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") return False if not settings.model_path: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c9b65dd4a..6d2bcb71f 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -104,6 +104,29 @@ def run(self) -> None: consecutive_errors = 0 self.frame_captured.emit(self._camera_id, frame, timestamp) + except TimeoutError as exc: + 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)): + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) + consecutive_errors = 0 + continue + + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") + break + time.sleep(self._retry_delay) + continue + except Exception as exc: consecutive_errors += 1 if self._stop_event.is_set(): @@ -147,6 +170,46 @@ def get_camera_id(settings: CameraSettings) -> str: return f"{backend}:index:{int(settings.index)}" +def _trigger_role_from_settings(settings: CameraSettings) -> str: + 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 {} + + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + + role = str(trigger.get("role", "off") or "off").strip().lower() + + # Match CameraTriggerSettings aliases enough for controller ordering. + if role in {"true", "on", "trigger", "triggered"}: + return "external" + if role in {"slave"}: + return "follower" + if role in {"main"}: + return "master" + if role in {"false", "none", "disabled", "disable", ""}: + return "off" + + return role + + +def _camera_start_priority(settings: CameraSettings) -> int: + """Start trigger-waiting cameras before trigger-generating cameras. + + Priority: + 0: external/follower cameras, which should be armed first + 1: normal/free-run cameras + 2: master cameras, which may generate trigger pulses + """ + role = _trigger_role_from_settings(settings) + if role in {"external", "follower"}: + return 0 + if role == "master": + return 2 + return 1 + + class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" @@ -190,6 +253,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: return active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + active_settings = sorted(active_settings, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return From 9132ddf43ed0bddd9bdfe3c3222745615cd4aaa4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:55 +0200 Subject: [PATCH 03/71] Improve signal handling and graceful shutdown Enhance Qt app signal handling to support SIGTERM and SIGBREAK, and make Ctrl+C shutdown more robust. Adds a quitting flag and a two-stage interrupt: first interrupt triggers window close and schedules app.quit, second interrupt forces immediate exit (os._exit(130)). Parents the keepalive QTimer to the QApplication, sets a 100ms interval, and safely stops any previous timer to avoid duplicates. Adds logging and exception handling around window close and timer cleanup, and uses QTimer.singleShot to ensure Qt leaves its event loop even if closeEvent cleanup is asynchronous. --- dlclivegui/main.py | 60 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eace8bda6..9e38a08cf 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -27,32 +27,70 @@ def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: """ - Gracefully handle Ctrl+C (SIGINT) by closing the main window and quitting Qt. + Gracefully handle Ctrl+C/SIGTERM by closing the main window and quitting Qt. + + Notes: + - The small timer keeps Python signal handling responsive while Qt owns the event loop. + - First Ctrl+C tries graceful cleanup via closeEvent(). + - Second Ctrl+C exits immediately with code 130. """ + quitting = {"requested": False} def _request_quit() -> None: + if quitting["requested"]: + return + + quitting["requested"] = True logging.info("Keyboard interrupt received, closing application...") + win = getattr(app, "_main_window", None) + if win is not None: - # Trigger your existing closeEvent cleanup (camera stop, threads, timers, etc.) - win.close() - else: - app.quit() + try: + # Trigger existing closeEvent cleanup: + # camera stop, controller shutdown, timers, DLC shutdown, etc. + win.close() + except Exception: + logging.exception("Error while closing main window after Ctrl+C") + + # Explicitly ask Qt to leave app.exec(). + # Do this even after win.close(), because closeEvent cleanup can be async + # and relying only on quitOnLastWindowClosed can be fragile. + QTimer.singleShot(0, app.quit) + + def _force_exit() -> None: + logging.warning("Second interrupt received, forcing process exit.") + os._exit(130) def _sigint_handler(_signum, _frame) -> None: + if quitting["requested"]: + _force_exit() QTimer.singleShot(0, _request_quit) signal.signal(signal.SIGINT, _sigint_handler) - # Keepalive timer to allow Python to handle signals while Qt is running. - sig_timer = QTimer() - sig_timer.setInterval(100) # 50–200ms typical; keep low overhead + # Ctrl+Break on Windows. + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _sigint_handler) + + # Useful when process is terminated from shells/process managers. + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _sigint_handler) + + # Parent the timer to app so Qt owns its lifetime. + sig_timer = QTimer(app) + sig_timer.setInterval(100) sig_timer.timeout.connect(lambda: None) sig_timer.start() - if hasattr(app, "_sig_timer"): - app._sig_timer.stop() # Stop any existing timer to avoid duplicates - app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + old_timer = getattr(app, "_sig_timer", None) + if old_timer is not None: + try: + old_timer.stop() + except Exception: + pass + + app._sig_timer = sig_timer def configure_logging(debug: bool = False) -> None: From c9d2811dd1e64aea904be342f746b441d5ce6bbd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:14:12 +0200 Subject: [PATCH 04/71] Add GenTL trigger tests and fake node map Add a comprehensive test suite for GenTL hardware trigger handling (tests/cameras/backends/test_gentl_trigger.py). Tests cover trigger roles (off/external/follower/master), selector/source/activation settings, strict vs non-strict behavior for invalid sources, master output configuration, alias mapping, timeout handling and error messaging, and persistence of trigger_actual for debugging. Update the test conftest fake node map (tests/cameras/backends/conftest.py) to include Trigger* and Line* nodes (AcquisitionMode, TriggerSelector, TriggerMode, TriggerSource, TriggerActivation, LineSelector, LineMode, LineSource) so the tests can exercise trigger and GPIO-related configuration. --- tests/cameras/backends/conftest.py | 12 + tests/cameras/backends/test_gentl_trigger.py | 333 +++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 tests/cameras/backends/test_gentl_trigger.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 9459c35ef..f21bf4819 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -599,6 +599,18 @@ def __init__( self.GainAuto = _FakeNode("Off") self.Gain = _FakeNode(float(gain)) + # Trigger input nodes + self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) + self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) + + # GPIO output nodes for master/follower setups + self.LineSelector = _FakeNode("Line0", symbolics=["Line0", "Line1", "Line2"]) + self.LineMode = _FakeNode("Input", symbolics=["Input", "Output"]) + self.LineSource = _FakeNode("Off", symbolics=["Off", "ExposureActive", "AcquisitionActive"]) + class _FakeRemoteDevice: def __init__(self, node_map: _FakeNodeMap): diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py new file mode 100644 index 000000000..f0d5f4528 --- /dev/null +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -0,0 +1,333 @@ +# tests/cameras/backends/test_gentl_trigger.py +from __future__ import annotations + +import pytest + +# --------------------------------------------------------------------- +# GenTL hardware trigger configuration +# --------------------------------------------------------------------- + + +def _gentl_trigger_settings(gentl_settings_factory, trigger: dict, **kwargs): + """Build CameraSettings with a GenTL trigger block.""" + return gentl_settings_factory(properties={"gentl": {"trigger": trigger}}, **kwargs) + + +def test_gentl_capabilities_advertise_hardware_trigger_best_effort(patch_gentl_sdk): + gb = patch_gentl_sdk + + caps = gb.GenTLCameraBackend.static_capabilities() + + assert caps.get("hardware_trigger") == gb.SupportLevel.BEST_EFFORT + + +def test_trigger_default_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + + ns = settings.properties.get("gentl", {}) + assert ns.get("trigger_actual", {}).get("role") == "off" + + be.close() + + +def test_trigger_explicit_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings(gentl_settings_factory, {"role": "off"}) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "off" + + be.close() + + +def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "FrameStart", + "source": "Line0", + "activation": "RisingEdge", + "timeout": 10.0, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line0" + assert nm.TriggerActivation.value == "RisingEdge" + assert nm.TriggerMode.value == "On" + assert be._timeout == pytest.approx(10.0) + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "external" + assert ns["trigger_actual"]["source"] == "Line0" + + be.close() + + +def test_trigger_follower_configures_input_line(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerActivation.value == "FallingEdge" + assert nm.TriggerMode.value == "On" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "follower" + + be.close() + + +def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + assert nm.LineSource.value == "ExposureActive" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "master" + assert ns["trigger_actual"]["output_line"] == "Line2" + + be.close() + + +def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Source was unsupported, so the fake node should retain its default. + assert nm.TriggerSource.value == "Line0" + # Non-strict mode should still allow opening; TriggerMode may be enabled + # because TriggerSource failure is best-effort in this mode. + assert be._acquirer is not None + + be.close() + + +def test_trigger_invalid_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_invalid_master_output_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + # Unsupported source should not be applied in non-strict mode. + assert nm.LineSource.value == "Off" + + be.close() + + +def test_trigger_invalid_master_output_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "on", + "source": "Line1", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "On" + assert nm.TriggerSource.value == "Line1" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "external" + + be.close() + + +def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + assert be._timeout == pytest.approx(7.5) + + # Fake acquisition is started, so read should pass and record the timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + + be.close() + + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 3.0, + }, + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. + ) + settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) + + be.open() + + with pytest.raises(TimeoutError) as ei: + be.read() + + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + be.close() + + +def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 9.0, + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) + + be.close() From 6d8824e3c3852440c9a92839f8e8f7ffc2613263 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:03 +0200 Subject: [PATCH 05/71] Respect user camera order for display/tiling Use the user-configured camera order for display/tiling and ensure tiling geometry matches displayed frames. Changes: - GUI: populate the inference camera dropdown from active cameras in the configured order and only add entries for cameras that are actually running. - MultiCameraController: store the user display order, derive startup order by sorting for trigger safety (followers/external first, master last), emit frames/timestamps in display order (with any unexpected IDs appended deterministically), clear display order on stop, and handle no-active-cameras early. - Utils: compute_tiling_geometry now uses the frames' display order (insertion order) rather than sorted keys so tile dimensions and overlays align with the tiled frame; updated docstrings to reflect this. These changes ensure consistent tiling, overlay transforms, and UI behavior that follow the user's configured ordering. --- dlclivegui/gui/main_window.py | 6 ++- .../services/multi_camera_controller.py | 39 ++++++++++++++++--- dlclivegui/utils/display.py | 10 ++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 267fe8f8a..881d4ce21 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1301,8 +1301,10 @@ def _refresh_dlc_camera_list_running(self) -> None: """Populate the inference camera dropdown from currently running cameras.""" self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() - for cam_id in sorted(self._running_cams_ids): - self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) + 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: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 6d2bcb71f..fe867604e 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -235,6 +235,7 @@ def __init__(self): self._running = False self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) + self._camera_display_order: list[str] = [] self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start @@ -252,8 +253,17 @@ def start(self, camera_settings: list[CameraSettings]) -> None: LOGGER.warning("Multi-camera controller already running") return - active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] - active_settings = sorted(active_settings, key=_camera_start_priority) + active_settings_user_order = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + if not active_settings_user_order: + LOGGER.warning("No active cameras to start") + return + + # Display/tile order follows the user-configured camera order. + self._camera_display_order = [get_camera_id(s) for s in active_settings_user_order] + + # Startup order may differ for trigger safety: + # followers/external first, master last. + active_settings = sorted(active_settings_user_order, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return @@ -416,6 +426,8 @@ def stop(self, wait: bool = True) -> None: self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() + self._camera_display_order.clear() + with self._frame_lock: self._frames.clear() self._timestamps.clear() @@ -442,10 +454,27 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float # Emit frame data without tiling (tiling done in GUI for performance) if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + frame_data = MultiFrameData( - frames=dict(self._frames), - timestamps=dict(self._timestamps), - source_camera_id=camera_id, # Track which camera triggered this + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, tiled_frame=None, display_ids=dict(self._display_ids), ) diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 00eae0d64..5cd297449 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -38,10 +38,10 @@ def compute_tiling_geometry( """Compute consistent tiling geometry for both tiling and overlay transforms. Returns: - (sorted_cam_ids, rows, cols, tile_w, tile_h) + (cam_ids, rows, cols, tile_w, tile_h) Notes: - - We intentionally base tile aspect on the first frame in sorted_cam_ids, + - We intentionally base tile aspect on the first frame in cam_ids, because create_tiled_frame uses the same ordering. This guarantees that compute_tile_info() and create_tiled_frame() agree on tile_w/tile_h. - If frames have different aspect ratios, they will be resized (possibly distorted) @@ -50,7 +50,7 @@ def compute_tiling_geometry( if not frames: return ([], 1, 1, 640, 480) - cam_ids = sorted(frames.keys()) + cam_ids = list(frames.keys()) frames_list = [frames[cid] for cid in cam_ids] num_frames = len(frames_list) @@ -63,7 +63,7 @@ def compute_tiling_geometry( max_w, max_h = max_canvas - # Reference aspect is based on the first frame in sorted order (matches tiler). + # Reference aspect is based on the first frame in display order (matches tiler). h0, w0 = frames_list[0].shape[:2] frame_aspect = (w0 / h0) if h0 > 0 else 1.0 @@ -138,7 +138,7 @@ def compute_tile_info( Critical robustness fix: - Tile dimensions are computed from the same reference used by create_tiled_frame() - (first frame in sorted order), so offsets/scales match the actual tiling. + (first frame in display order), so offsets/scales match the actual tiling. """ if not frames: return (0, 0), (1.0, 1.0) From df1a201ef5729e74afc64e3d8e28415935dca6ca Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:30 +0200 Subject: [PATCH 06/71] tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. --- tests/services/test_multicam_controller.py | 218 ++++++++++++++++++++- tests/utils/test_display.py | 111 +++++++++-- 2 files changed, 315 insertions(+), 14 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 7c9e1f051..43beef14d 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,13 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id, get_display_id +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id +from dlclivegui.services.multi_camera_controller import ( + MultiCameraController, + _camera_start_priority, + _trigger_role_from_settings, + get_camera_id, +) @pytest.mark.unit @@ -168,3 +174,213 @@ def on_ready(mfd): 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( + name="GenTL Cam", + backend="gentl", + index=0, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + + +@pytest.mark.unit +def test_get_camera_id_falls_back_to_index_without_stable_identity(): + cam = CameraSettings( + name="Cam", + backend="opencv", + index=2, + ).apply_defaults() + + assert get_camera_id(cam) == "opencv:index:2" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("role", "expected"), + [ + ("off", "off"), + ("disabled", "off"), + ("on", "external"), + ("triggered", "external"), + ("external", "external"), + ("follower", "follower"), + ("slave", "follower"), + ("master", "master"), + ("main", "master"), + ], +) +def test_trigger_role_from_settings_aliases(role, expected): + cam = CameraSettings( + name="C", + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": role, + } + } + }, + ).apply_defaults() + + assert _trigger_role_from_settings(cam) == expected + + +@pytest.mark.unit +def test_camera_start_priority_orders_trigger_roles(): + external = CameraSettings( + name="External", + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external"}}}, + ).apply_defaults() + + normal = CameraSettings( + name="Normal", + backend="gentl", + index=1, + properties={"gentl": {"trigger": {"role": "off"}}}, + ).apply_defaults() + + master = CameraSettings( + name="Master", + backend="gentl", + index=2, + properties={"gentl": {"trigger": {"role": "master"}}}, + ).apply_defaults() + + assert _camera_start_priority(external) == 0 + assert _camera_start_priority(normal) == 1 + assert _camera_start_priority(master) == 2 + + +@pytest.mark.unit +def test_start_preserves_user_display_order_even_when_trigger_start_order_differs(qtbot, patch_factory): + mc = MultiCameraController() + + # User wants master first in tiled view, follower second. + # Startup order should still be follower first internally. + master = CameraSettings( + name="Master", + backend="opencv", + index=0, + enabled=True, + properties={ + "opencv": { + "device_id": "master-cam", + "trigger": {"role": "master"}, + } + }, + ).apply_defaults() + + follower = CameraSettings( + name="Follower", + backend="opencv", + index=1, + enabled=True, + properties={ + "opencv": { + "device_id": "follower-cam", + "trigger": {"role": "follower"}, + } + }, + ).apply_defaults() + + expected_display_order = [get_camera_id(master), get_camera_id(follower)] + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([master, follower]) + + assert mc._camera_display_order == expected_display_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory): + mc = MultiCameraController() + + cam_a = CameraSettings( + name="A", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-a"}}, + ).apply_defaults() + + cam_b = CameraSettings( + name="B", + backend="opencv", + index=1, + enabled=True, + properties={"opencv": {"device_id": "cam-b"}}, + ).apply_defaults() + + expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + seen_orders: list[list[str]] = [] + + def on_ready(mfd): + if len(mfd.frames) >= 2: + seen_orders.append(list(mfd.frames.keys())) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam_a, cam_b]) + + qtbot.waitUntil(lambda: bool(seen_orders), timeout=2500) + + assert seen_orders[-1] == expected_order + + 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() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + assert mc._camera_display_order == [get_camera_id(cam)] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + assert mc._camera_display_order == [] + + +@pytest.mark.unit +def test_hardware_trigger_timeouts_are_not_fatal(qtbot, monkeypatch): + class WaitingTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings): + self.settings = settings + self.opened = False diff --git a/tests/utils/test_display.py b/tests/utils/test_display.py index 9ce8d49e7..559aa1522 100644 --- a/tests/utils/test_display.py +++ b/tests/utils/test_display.py @@ -41,7 +41,9 @@ def test_compute_tiling_geometry_single_frame_respects_max_canvas_and_min_tile() def test_compute_tiling_geometry_two_frames_is_1x2(): frames = {"camB": _frame(480, 640, 3), "camA": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] # sorted + + # Preserve insertion/display order, do not sort by camera ID. + assert cam_ids == ["camB", "camA"] assert (rows, cols) == (1, 2) assert tile_w >= 160 and tile_h >= 120 @@ -49,25 +51,106 @@ def test_compute_tiling_geometry_two_frames_is_1x2(): def test_compute_tiling_geometry_three_frames_is_2x2(): frames = {"c3": _frame(480, 640, 3), "c1": _frame(480, 640, 3), "c2": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["c1", "c2", "c3"] + + # Preserve insertion/display order. + assert cam_ids == ["c3", "c1", "c2"] assert (rows, cols) == (2, 2) assert tile_w >= 160 and tile_h >= 120 -def test_compute_tiling_geometry_reference_aspect_is_first_sorted_cam(): - # camA has aspect 2.0 (w/h), camB has aspect 0.5 +def test_compute_tiling_geometry_reference_aspect_is_first_display_order_cam(): + # camB is first in insertion/display order and has aspect 0.5. + # camA has aspect 2.0. frames = { - "camB": _frame(400, 200, 3), - "camA": _frame(200, 400, 3), + "camB": _frame(400, 200, 3), # aspect = 200 / 400 = 0.5 + "camA": _frame(200, 400, 3), # aspect = 400 / 200 = 2.0 } + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] + + assert cam_ids == ["camB", "camA"] # For 2 cams, rows=1 cols=2 => initial tile_w=600 tile_h=800 => tile_aspect=0.75 - # frame_aspect for camA = 400/200 = 2.0 > 0.75 => tile_h adjusted to tile_w/frame_aspect = 600/2 = 300 + # frame_aspect for camB = 0.5 <= 0.75 => tile_w adjusted to tile_h * frame_aspect = 800 * 0.5 = 400 + assert (rows, cols) == (1, 2) + assert tile_w == 400 + assert tile_h == 800 + + +def test_compute_tiling_geometry_preserves_frame_insertion_order(): + frames = { + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] + assert rows == 1 + assert cols == 2 + assert tile_w > 0 + assert tile_h > 0 + + +def test_compute_tiling_geometry_preserves_reversed_insertion_order(): + frames = { + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, *_ = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:10620051", "gentl:serial:30220469"] + + +def test_compute_tile_info_uses_display_order_for_offsets(): + cam0 = "gentl:serial:30220469" + cam1 = "gentl:serial:10620051" + + frames = { + cam0: np.zeros((100, 200, 3), dtype=np.uint8), + cam1: np.zeros((100, 200, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + offset0, scale0 = compute_tile_info(cam0, frames[cam0], frames) + offset1, scale1 = compute_tile_info(cam1, frames[cam1], frames) + + assert cam_ids == [cam0, cam1] + assert offset0 == (0, 0) + assert offset1 == (tile_w, 0) + assert scale0[0] > 0 + assert scale0[1] > 0 + assert scale1[0] > 0 + assert scale1[1] > 0 + + +def test_create_tiled_frame_preserves_display_order_by_tile_content(): + # First frame is blue-ish, second is red-ish. + first = np.zeros((100, 100, 3), dtype=np.uint8) + first[:, :] = (255, 0, 0) # BGR blue + + second = np.zeros((100, 100, 3), dtype=np.uint8) + second[:, :] = (0, 0, 255) # BGR red + + frames = { + "gentl:serial:30220469": first, + "gentl:serial:10620051": second, + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(400, 200)) + out = create_tiled_frame(frames, max_canvas=(400, 200)) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] assert (rows, cols) == (1, 2) - assert tile_w == 600 - assert tile_h == 300 + + # Sample away from text label area. + left_sample = out[tile_h // 2, tile_w // 2] + right_sample = out[tile_h // 2, tile_w + tile_w // 2] + + assert left_sample[0] > left_sample[2] # blue tile first + assert right_sample[2] > right_sample[0] # red tile second def test_create_tiled_frame_empty_returns_default_canvas(): @@ -110,16 +193,18 @@ def test_create_tiled_frame_canvas_shape_matches_geometry(): def test_compute_tile_info_offset_and_scale_matches_tiling(): - # 2 frames => 1x2 tiling, cam ids sorted: ["cam1", "cam2"] + # 2 frames => 1x2 tiling, preserving insertion/display order: ["cam2", "cam1"] frames = {"cam2": _frame(200, 400, 3), "cam1": _frame(200, 400, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) original = _frame(200, 400, 3) (ox, oy), (sx, sy) = compute_tile_info("cam2", original, frames, max_canvas=(1200, 800)) - # cam2 is index 1 -> row 0 col 1 + assert cam_ids == ["cam2", "cam1"] assert (rows, cols) == (1, 2) - assert ox == tile_w + + # cam2 is first in display order => row 0 col 0 + assert ox == 0 assert oy == 0 assert sx == pytest.approx(tile_w / 400) assert sy == pytest.approx(tile_h / 200) From 424fe1658ce3cd7372d0580095e0a03320ea0c98 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:08:00 +0200 Subject: [PATCH 07/71] Mock _maybe_allow_keyboard_interrupt in GUI tests Patch tests/gui/test_app_entrypoint.py to monkeypatch appmod._maybe_allow_keyboard_interrupt with a MagicMock in both test_main_with_splash and test_main_without_splash. This prevents the real interrupt-handling helper from running during GUI tests and avoids side effects on global keyboard/signal handling. --- tests/gui/test_app_entrypoint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 0a68bb2ef..b6b1c6051 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -31,6 +31,7 @@ def set_use_splash_false(monkeypatch): @pytest.mark.gui def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # --- Patch Qt app & icon in the entry module's namespace --- QApplication_cls = MagicMock(name="QApplication") @@ -101,6 +102,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # Patch Qt app creation & window icon QApplication_cls = MagicMock(name="QApplication") From c6ea7ad0d150a058a46c277a7baad4f22e33924b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:21:09 +0200 Subject: [PATCH 08/71] Support strict GenTL trigger and defaults GenTLCameraBackend: add handling for a 'strict' flag when parsing GenTL trigger config so invalid configs raise in strict mode but fall back with a warning otherwise. Preserve the original exception when raising, and improve the warning text to mention strict mode. Return whether LineSelector was actually set and skip configuring trigger output if selection failed to avoid driving an unintended GPIO line. DLCLiveMainWindow: introduce _with_camera_trigger_defaults_for_save to ensure gentl trigger defaults are stored per camera, refactor _with_camera_defaults_for_save to apply that per-camera, and adjust _current_config to use the first camera with defaults applied. This ensures trigger settings are persisted and validated consistently. --- dlclivegui/cameras/backends/gentl_backend.py | 32 ++++++++++++++-- dlclivegui/gui/main_window.py | 39 ++++++++++++-------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ae5175b16..bb93cc8c0 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -106,10 +106,20 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + raw_trigger = ns.get("trigger", props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + try: - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + self._trigger = CameraTriggerSettings.from_any(raw_trigger) except Exception as exc: - LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid GenTL trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid GenTL trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) self._trigger = CameraTriggerSettings() trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) @@ -1148,7 +1158,23 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N # Master camera runs freerun and exposes an output signal. self._configure_trigger_off(node_map, strict=False) - self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + # In non-strict mode, do not continue configuring output behavior if the + # requested line could not be selected. Otherwise we may accidentally drive + # whichever GPIO line the camera had selected previously/defaulted to. + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping trigger output configuration.", + output_line, + ) + return + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) self._set_enum_node(node_map, "LineSource", output_source, strict=strict) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 881d4ce21..b68ab876f 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -858,31 +858,40 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) + def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: + out = cam.model_copy(deep=True) - for cam in out.cameras: - backend = (cam.backend or "").lower() - if backend != "gentl": - continue + backend = (out.backend or "").lower() + if backend != "gentl": + return out - if not isinstance(cam.properties, dict): - cam.properties = {} + if not isinstance(out.properties, dict): + out.properties = {} - ns = cam.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - cam.properties["gentl"] = ns + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] return out def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) - active_cameras = self._config.multi_camera.get_active_cameras() - camera = active_cameras[0] if active_cameras else CameraSettings() + camera = ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._with_camera_trigger_defaults_for_save(self._config.camera) + ) return ApplicationSettings( camera=camera, From 38a41667ad9045b9218b6483d1ffebc66f2a28dc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:30:07 +0200 Subject: [PATCH 09/71] Apply gentl trigger defaults when saving Add with_save_defaults helpers to CameraSettings and MultiCameraSettings and use them when serializing ApplicationSettings so gentl "trigger" defaults are applied to saved configs. Remove duplicated camera-default helper logic from DLCLiveMainWindow and simplify _current_config to rely on model methods. Add unit tests to verify gentl trigger defaults are included for top-level and multi-camera cases. --- dlclivegui/config.py | 44 +++++++++++++++++++++++++++++++---- dlclivegui/gui/main_window.py | 32 ++----------------------- tests/test_config.py | 36 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 tests/test_config.py diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 154eabf40..c7a474b8f 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -190,6 +190,25 @@ def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | No self.properties[str(key).lower()] = ns ns["trigger"] = trigger.to_properties() + def with_save_defaults(self) -> CameraSettings: + out = self.model_copy(deep=True) + + backend = (out.backend or "").lower() + if backend != "gentl": + return out + + if not isinstance(out.properties, dict): + out.properties = {} + + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().to_properties()) + + return out + class CameraTriggerSettings(BaseModel): """ @@ -302,12 +321,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) def to_dict(self) -> dict[str, Any]: + out = self.with_save_defaults() return { - "cameras": [cam.model_dump() for cam in self.cameras], - "max_cameras": self.max_cameras, - "tile_layout": self.tile_layout, + "cameras": [cam.model_dump() for cam in out.cameras], + "max_cameras": out.max_cameras, + "tile_layout": out.tile_layout, } + def with_save_defaults(self) -> MultiCameraSettings: + """Return a copy with save defaults applied to all cameras.""" + out = self.model_copy(deep=True) + out.cameras = [cam.with_save_defaults() for cam in out.cameras] + return out + class DynamicCropModel(BaseModel): enabled: bool = False @@ -473,10 +499,18 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + multi_camera = self.multi_camera.with_save_defaults() + active_cameras = multi_camera.get_active_cameras() + + if active_cameras: + camera = active_cameras[0].model_copy(deep=True) + else: + camera = self.camera.with_save_defaults() + return { "version": self.version, - "camera": self.camera.model_dump(), - "multi_camera": self.multi_camera.to_dict(), + "camera": camera.model_dump(), + "multi_camera": multi_camera.to_dict(), "dlc": self.dlc.model_dump(), "recording": self.recording.model_dump(), "bbox": self.bbox.model_dump(), diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index b68ab876f..bb8b8045a 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,7 +56,6 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, - CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -858,39 +857,12 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: - out = cam.model_copy(deep=True) - - backend = (out.backend or "").lower() - if backend != "gentl": - return out - - if not isinstance(out.properties, dict): - out.properties = {} - - ns = out.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - out.properties["gentl"] = ns - - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) - - return out - - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) - - out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] - - return out - def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: - # Get the first camera from multi-camera config for backward compatibility - multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) + multi_camera = self._config.multi_camera camera = ( multi_camera.cameras[0].model_copy(deep=True) if multi_camera.cameras - else self._with_camera_trigger_defaults_for_save(self._config.camera) + else self._config.camera.model_copy(deep=True) ) return ApplicationSettings( diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..f0165f45e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,36 @@ +import pytest + +from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_top_level_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + camera=cam, + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["camera"]["properties"]["gentl"] + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_multi_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] From 9fd218ccca97afde7198765d90c1f91371a7777d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:36:54 +0200 Subject: [PATCH 10/71] Don't sort available camera IDs Remove sorting when building available_ids so the original frame key order is preserved. The DLC camera selection relies on the first active camera in frame_data.frames; using list(...) keeps the insertion/order semantics (dict order) instead of reordering keys with sorted(). This avoids unintended changes to camera priority caused by alphabetical/numeric sorting. --- 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 bb8b8045a..5f4839057 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1387,7 +1387,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # Determine DLC camera (first active camera) selected_id = self._inference_camera_id - available_ids = sorted(frame_data.frames.keys()) + available_ids = list(frame_data.frames.keys()) if selected_id in frame_data.frames: dlc_cam_id = selected_id else: From e8ce43f374a2f812cc8b47ae3eb5863879a4316c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:02:13 +0200 Subject: [PATCH 11/71] Preserve DLC config using model_copy Initialize an existing DLC config (falling back to DEFAULT_CONFIG if unset) and use its model_copy(update=...) to return settings. This replaces explicit field-by-field construction so only model_path (and model_type when set) are changed, preserving other DLC options and avoiding duplication. --- dlclivegui/gui/main_window.py | 38 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 5f4839057..df6c03a5b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -886,19 +886,23 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) + + existing_dlc = ( # explicitly init from default if unset + self._config.dlc.model_copy(deep=True) + if getattr(self._config, "dlc", None) is not None + else DEFAULT_CONFIG.dlc.model_copy(deep=True) + ) if not model_path: if allow_empty_model_path: - return DLCProcessorSettings( - model_path="", - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=None, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + # Preserve all existing DLC settings and only clear the model path. + return existing_dlc.model_copy( + update={ + "model_path": "", + } ) + raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") + try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) except Exception as e: @@ -907,15 +911,13 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor "Please ensure the model file is valid and has an appropriate extension " "(.pt, .pth for PyTorch or model directory for TensorFlow)." ) from e - return DLCProcessorSettings( - model_path=model_path, - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=model_bknd, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + + # Preserve all unchanged DLC settings and only update values derived from the UI. + return existing_dlc.model_copy( + update={ + "model_path": model_path, + "model_type": model_bknd, + } ) def _recording_settings_from_ui(self) -> RecordingSettings: From d57f9c3686b4d9961d4e11d50298ac350867262a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:56:14 +0200 Subject: [PATCH 12/71] Warn when GenTL TriggerMode fails to enable Add a warning log when setting GenTL TriggerMode to 'On' fails in non-strict mode. Previously the code only raised an exception in strict mode; now it emits a warning to inform users that the trigger mode may not be correctly configured when continuing without strict enforcement. --- dlclivegui/cameras/backends/gentl_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index bb93cc8c0..55bf387bf 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1142,6 +1142,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): if strict: raise RuntimeError("Could not enable GenTL TriggerMode=On") + else: + LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", From 5b02e2aa247050b92aa03a765ef001eb26e38731 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:47:00 +0200 Subject: [PATCH 13/71] Improve GenTL trigger routing safety and tests Make GenTLCameraBackend trigger configuration more robust and adjust tests. - Use the waits_for_hardware_trigger property when converting GenTL timeouts to user-facing errors instead of inferring from the role string. - Read and record the configured trigger role early in _configure_trigger_input. - Check results when setting TriggerSelector, TriggerSource and TriggerActivation (selector_ok, source_ok, activation_ok). If selector/source routing fails in non-strict mode, disable the trigger, reset internal trigger state and log a warning to avoid arming the camera on a previous/default input line. If activation fails, warn and continue using the camera default. - If enabling TriggerMode=On fails, disable the trigger and reset internal state instead of only warning. - Validate LineMode and LineSource when configuring master output and log if configuration is incomplete. Tests updated to reflect safety changes: - Expect waits_for_hardware_trigger to be set for external input configuration. - Rename and change a non-strict invalid-source test to assert the trigger is disabled, waits_for_hardware_trigger is False, and trigger_actual is persisted as off. - Add a new test ensuring an invalid selector in non-strict mode disables the trigger and persists the off state. These changes prevent the camera from being left armed on an unintended/default input if routing nodes could not be applied, improving safety and predictability. --- dlclivegui/cameras/backends/gentl_backend.py | 64 ++++++++++++++++---- tests/cameras/backends/test_gentl_trigger.py | 49 +++++++++++++-- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 55bf387bf..7290e0ed5 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -501,8 +501,7 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: - role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() - if role in {"external", "follower"}: + if self.waits_for_hardware_trigger: raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc @@ -1126,6 +1125,7 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") @@ -1133,17 +1133,51 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) - self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - self._set_enum_node(node_map, "TriggerSource", source, strict=strict) - self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): + LOG.warning( + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s activation=%s", + selector_ok, + source_ok, + role, + selector, + source, + activation, + ) + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + if not activation_ok: + LOG.warning( + "Could not apply GenTL TriggerActivation=%s; using camera default/current activation.", + activation, + ) self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): - if strict: - raise RuntimeError("Could not enable GenTL TriggerMode=On") - else: - LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") + LOG.warning("Could not enable GenTL TriggerMode=On; disabling trigger.") + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + role, + selector, + source, + activation, + activation_ok, + ) LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", @@ -1177,8 +1211,16 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N ) return - self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if not (mode_ok and source_ok): + LOG.warning( + "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + return LOG.info( "GenTL trigger master configured: output_line=%s output_source=%s", diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index f0d5f4528..5d774af5e 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -75,6 +75,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerSource.value == "Line0" assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" + assert be.waits_for_hardware_trigger is True assert be._timeout == pytest.approx(10.0) ns = settings.properties["gentl"] @@ -140,7 +141,7 @@ def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl be.close() -def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -158,9 +159,17 @@ def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl # Source was unsupported, so the fake node should retain its default. assert nm.TriggerSource.value == "Line0" - # Non-strict mode should still allow opening; TriggerMode may be enabled - # because TriggerSource failure is best-effort in this mode. - assert be._acquirer is not None + + # Safety behavior: do not arm TriggerMode on the previous/default source. + assert nm.TriggerMode.value == "Off" + + # Controller should not treat timeouts as expected trigger waits. + assert be.waits_for_hardware_trigger is False + + # trigger_actual is persisted after _configure_trigger(); since we reset + # self._trigger to off, the effective trigger state is off. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" be.close() @@ -331,3 +340,35 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin assert actual["timeout"] == pytest.approx(9.0) be.close() + + +def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "NotARealSelector", + "source": "Line1", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Selector was unsupported, so the fake node should retain its default. + assert nm.TriggerSelector.value == "FrameStart" + + # Source may have been applied, but trigger must not be armed because + # the required selector routing failed. + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerMode.value == "Off" + assert be.waits_for_hardware_trigger is False + + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" + + be.close() From a1e9da13c542bbf5be0c2582f7f3c04f067329f6 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:54:22 +0200 Subject: [PATCH 14/71] Cap hardware-trigger fetch timeout and update tests Introduce a MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT and cap Harvester.fetch() timeouts when the trigger role waits for external hardware (roles: external, follower) to keep individual fetch calls short and allow prompt shutdown. Preserve legacy behavior for non-waiting roles (e.g. master). Remove a noisy Trigger input LOG.info call. Update tests to expect the capped fetch timeout, verify the original requested timeout is still persisted in trigger_actual, add a test that master mode is not capped, and make test resource cleanup more robust (use try/finally around open/close). --- dlclivegui/cameras/backends/gentl_backend.py | 24 +++-- tests/cameras/backends/test_gentl_trigger.py | 106 ++++++++++++++----- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 7290e0ed5..13b4c7486 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -76,6 +76,11 @@ class GenTLCameraBackend(CameraBackend): _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + # Keep individual Harvester.fetch() calls short enough that controller + # shutdown can stop worker threads promptly. Hardware-trigger waits are + # handled by repeated polling in SingleCameraWorker. + _MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT: ClassVar[float] = 1.0 + def __init__(self, settings): super().__init__(settings) @@ -124,7 +129,16 @@ def __init__(self, settings): trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) if trigger_timeout is not None: - self._timeout = float(trigger_timeout) + role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() + + if role in {"external", "follower"}: + # Do not let a long hardware-trigger wait block shutdown. + # SingleCameraWorker treats these fetch timeouts as expected + # polling misses while waits_for_hardware_trigger is true. + self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + else: + # For non-trigger-waiting modes, preserve legacy behavior. + self._timeout = float(trigger_timeout) self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() @@ -1179,14 +1193,6 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No activation_ok, ) - LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", - self._trigger_attr(cfg, "role", "external"), - selector, - source, - activation, - ) - def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index 5d774af5e..e060035a4 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -63,7 +63,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen "selector": "FrameStart", "source": "Line0", "activation": "RisingEdge", - "timeout": 10.0, + "timeout": gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT, }, ) be = gb.GenTLCameraBackend(settings) @@ -76,7 +76,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" assert be.waits_for_hardware_trigger is True - assert be._timeout == pytest.approx(10.0) + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) ns = settings.properties["gentl"] assert ns["trigger_actual"]["role"] == "external" @@ -265,8 +265,12 @@ def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_facto be.close() -def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk + expected_fetch_timeout = gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT settings = _gentl_trigger_settings( gentl_settings_factory, @@ -277,18 +281,30 @@ def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_ ) be = gb.GenTLCameraBackend(settings) - be.open() - assert be._timeout == pytest.approx(7.5) + try: + be.open() - # Fake acquisition is started, so read should pass and record the timeout. - frame, _ = be.read() - assert frame is not None - assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + # Hardware-trigger fetch calls are intentionally capped so stop(wait=True) + # is not blocked by a long user trigger timeout. + assert be._timeout == pytest.approx(expected_fetch_timeout) - be.close() + # Fake acquisition is started, so read should pass and record the capped timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) + + # The requested trigger timeout is still preserved in persisted trigger_actual. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["timeout"] == pytest.approx(7.5) + finally: + be.close() -def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -297,22 +313,27 @@ def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gent "role": "external", "timeout": 3.0, }, - # fast_start keeps acquisition stopped; fake fetch then raises timeout. - # This lets us assert the backend timeout message without hardware. ) + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - with pytest.raises(TimeoutError) as ei: - be.read() + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) - msg = str(ei.value).lower() - assert "gentl timeout" in msg - assert "hardware trigger" in msg or "trigger" in msg + with pytest.raises(TimeoutError) as ei: + be.read() - be.close() + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + finally: + be.close() def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): @@ -330,16 +351,22 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin ) be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - actual = settings.properties["gentl"].get("trigger_actual") - assert isinstance(actual, dict) - assert actual["role"] == "follower" - assert actual["source"] == "Line1" - assert actual["activation"] == "FallingEdge" - assert actual["timeout"] == pytest.approx(9.0) + # Requested timeout remains in trigger_actual for debugging/config visibility. + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) - be.close() + # But each blocking Harvester.fetch() call is capped for responsive shutdown. + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + finally: + be.close() def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): @@ -372,3 +399,26 @@ def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, g assert actual["role"] == "off" be.close() + + +def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Master is free-running / trigger-generating, not waiting for hardware input. + assert be.waits_for_hardware_trigger is False + assert be._timeout == pytest.approx(7.5) + + finally: + be.close() From 36ae6802628f5353a24327912b1f644af0ae05c0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:19:58 +0200 Subject: [PATCH 15/71] Interruptible camera waits; simplify config save Make SingleCameraWorker sleep calls interruptible by using self._stop_event.wait() instead of time.sleep(), and add a small _trigger_timeout_delay to allow early exit during trigger/wait cycles. This prevents the worker from being unresponsive to stop requests during retry and trigger waits. Also simplify ApplicationSettings.to_dict() to always start from self.camera.with_save_defaults() and stop overriding it with active multi-camera selection, ensuring consistent camera settings are saved. --- dlclivegui/config.py | 7 +------ dlclivegui/services/multi_camera_controller.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a474b8f..6f204accc 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -499,13 +499,8 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + camera = self.camera.with_save_defaults() multi_camera = self.multi_camera.with_save_defaults() - active_cameras = multi_camera.get_active_cameras() - - if active_cameras: - camera = active_cameras[0].model_copy(deep=True) - else: - camera = self.camera.with_save_defaults() return { "version": self.version, diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index fe867604e..4161aef13 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,7 +4,6 @@ import copy import logging -import time from dataclasses import dataclass from functools import partial from threading import Event, Lock @@ -54,6 +53,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 self._retry_delay = 0.1 + self._trigger_timeout_delay = 0.05 @Slot() def run(self) -> None: @@ -98,7 +98,8 @@ def run(self) -> None: self._camera_id, "Too many empty frames.\nWas the device disconnected ?" ) break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue consecutive_errors = 0 @@ -118,13 +119,17 @@ def run(self) -> None: 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 - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue except Exception as exc: @@ -134,7 +139,8 @@ def run(self) -> None: if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue # Cleanup From e5f77a1bf922eac3aa5681a579f77fdf5eab9902 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:32:12 +0200 Subject: [PATCH 16/71] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/gui/main_window.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index df6c03a5b..c401037b3 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -859,11 +859,15 @@ def _apply_config(self, config: ApplicationSettings) -> None: def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: multi_camera = self._config.multi_camera + active_cameras = multi_camera.get_active_cameras() camera = ( - multi_camera.cameras[0].model_copy(deep=True) - if multi_camera.cameras - else self._config.camera.model_copy(deep=True) - ) + active_cameras[0].model_copy(deep=True) + if active_cameras + else ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._config.camera.model_copy(deep=True) + ) return ApplicationSettings( camera=camera, From f4726b8aa8b5a48a0d84831bcb1ac878934f4601 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:34:52 +0200 Subject: [PATCH 17/71] Fix broken suggestion --- dlclivegui/gui/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c401037b3..6fddba6b2 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -868,6 +868,7 @@ def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSetting if multi_camera.cameras else self._config.camera.model_copy(deep=True) ) + ) return ApplicationSettings( camera=camera, From b067c0eac6730ee6e15ac268ce5d771132d6deaf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:55:11 +0200 Subject: [PATCH 18/71] Resolve 'auto' trigger source in GenTL backend Add resolution logic for trigger source: implement _resolve_trigger_source to map the model-level default 'auto' to the first supported camera line (Line0/Line1/Line2/Any), return whether the resolved value is supported, and log/warn or raise depending on strict mode. Integrate this into _configure_pixel_format/_configure trigger flow so TriggerSource is only set if resolution succeeded. Change CameraTriggerSettings default source to 'auto' and add a field validator to coerce common aliases (e.g. 'default', 'automatic', 'device', 'camera') to 'auto'. Update tests and fixtures: adjust fake node default TriggerSource to Line1, update expectations, and add unit tests for auto-selection behavior and strict-mode error. This makes trigger configuration more robust across cameras with differing GenICam enum symbolics. --- dlclivegui/cameras/backends/gentl_backend.py | 48 +++++++++++++++++++- dlclivegui/config.py | 20 +++++++- tests/cameras/backends/conftest.py | 2 +- tests/cameras/backends/test_gentl_trigger.py | 38 +++++++++++++++- tests/test_config.py | 9 +++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 13b4c7486..44d12708f 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1059,6 +1059,51 @@ def _trigger_to_dict(trigger) -> dict[str, Any]: pass return {} + def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> tuple[str, bool]: + """Resolve TriggerSource against the camera-supported GenICam enum values. + + Model-level default is "auto"; this backend maps it to the first preferred + source supported by the actual camera. + """ + requested = str(requested or "auto").strip() + node = self._node(node_map, "TriggerSource") + available = self._node_symbolics(node) + + if not available: + if strict: + raise RuntimeError("GenICam node 'TriggerSource' is not available or has no symbolics") + LOG.warning("GenICam node 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line0", "Line1", "Line2", "Any"): + if candidate in available: + LOG.info( + "GenTL TriggerSource auto-selected '%s'. Available: %s", + candidate, + available, + ) + return candidate, True + + LOG.warning( + "Could not auto-select a GenTL TriggerSource. Available: %s", + available, + ) + return requested, False + + if strict: + raise RuntimeError(f"GenICam node 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning( + "GenTL TriggerSource '%s' is not available. Available: %s", + requested, + available, + ) + return requested, False + def _configure_pixel_format(self, node_map) -> None: try: pixel_format_node = getattr(node_map, "PixelFormat", None) @@ -1148,7 +1193,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) + source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) # TriggerSelector and TriggerSource are required routing nodes. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6f204accc..115301626 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -224,7 +224,7 @@ class CameraTriggerSettings(BaseModel): # Input trigger config: external/follower selector: str = "FrameStart" - source: str = "Line0" + source: str = "auto" activation: TriggerActivation | str = "RisingEdge" # Output config: master @@ -272,6 +272,24 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("source", mode="before") + @classmethod + def _coerce_source(cls, v): + if v is None: + return "auto" + + s = str(v).strip() + if not s: + return "auto" + + aliases = { + "default": "auto", + "automatic": "auto", + "device": "auto", + "camera": "auto", + } + return aliases.get(s.lower(), s) + @classmethod def from_any(cls, value) -> CameraTriggerSettings: if isinstance(value, cls): diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index f21bf4819..f0a0b6b23 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -603,7 +603,7 @@ def __init__( self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) - self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerSource = _FakeNode("Line1", symbolics=["Line0", "Line1", "Software"]) self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) # GPIO output nodes for master/follower setups diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index e060035a4..57339a103 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -158,7 +158,7 @@ def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gen nm = be._acquirer.remote_device.node_map # Source was unsupported, so the fake node should retain its default. - assert nm.TriggerSource.value == "Line0" + assert nm.TriggerSource.value == "Line1" # Safety behavior: do not arm TriggerMode on the previous/default source. assert nm.TriggerMode.value == "Off" @@ -422,3 +422,39 @@ def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_setti finally: be.close() + + +def test_resolve_trigger_source_auto_selects_supported_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + source, ok = be._resolve_trigger_source(NodeMap(), "auto", strict=False) + + assert ok is True + assert source == "Line1" + + +def test_resolve_trigger_source_strict_raises_for_unsupported_explicit_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + with pytest.raises(RuntimeError, match="TriggerSource.*Line0"): + be._resolve_trigger_source(NodeMap(), "Line0", strict=True) diff --git a/tests/test_config.py b/tests/test_config.py index f0165f45e..9f82017ed 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings +from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings @pytest.mark.unit @@ -34,3 +34,10 @@ def test_save_applies_gentl_trigger_defaults_to_multi_camera(): data = settings.to_dict() assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] + + +@pytest.mark.unit +def test_trigger_source_defaults_to_auto(): + trigger = CameraTriggerSettings() + + assert trigger.source == "auto" From ac865f4ad40fbbeb195bc5ecb91604cee269b7cf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:03:57 +0200 Subject: [PATCH 19/71] Fix display tests --- tests/services/test_multicam_controller.py | 87 +++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 43beef14d..7b46458c4 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -11,6 +11,7 @@ _camera_start_priority, _trigger_role_from_settings, get_camera_id, + get_display_id, ) @@ -329,7 +330,7 @@ def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory) properties={"opencv": {"device_id": "cam-b"}}, ).apply_defaults() - expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + expected_order = [get_display_id(cam_a), get_display_id(cam_b)] seen_orders: list[list[str]] = [] def on_ready(mfd): @@ -384,3 +385,87 @@ class WaitingTriggerBackend: def __init__(self, settings): self.settings = settings self.opened = False + self.closed = False + + def open(self): + self.opened = True + + def read(self): + raise TimeoutError("waiting for hardware trigger") + + def close(self): + self.closed = True + + def _create(settings): + return WaitingTriggerBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings( + name="Triggered", + backend="gentl", + index=0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:30220469", + "trigger": {"role": "external", "timeout": 0.1}, + } + }, + ).apply_defaults() + + errors: list[tuple[str, str]] = [] + mc.camera_error.connect(lambda cam_id, msg: errors.append((cam_id, msg))) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Let several timeout cycles happen. + qtbot.wait(500) + + assert mc.is_running() + assert errors == [] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_non_trigger_timeouts_are_fatal_after_retries(qtbot, monkeypatch): + class TimeoutBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + + def open(self): + pass + + def read(self): + raise TimeoutError("camera timeout") + + def close(self): + pass + + def _create(settings): + return TimeoutBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings(name="TimeoutCam", backend="opencv", index=0, enabled=True).apply_defaults() + + with qtbot.waitSignal(mc.camera_error, timeout=3000) as blocker: + mc.start([cam]) + + cam_id, msg = blocker.args + assert cam_id == get_display_id(cam) + assert "Camera read timeout" in msg + + # Cleanup if still running. + if mc.is_running(): + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) From 88696c13dd3ed008fa1f9958c45135159160e80b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:05:01 +0200 Subject: [PATCH 20/71] Update test_multicam_controller.py --- tests/services/test_multicam_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 7b46458c4..a0950b063 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,6 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id from dlclivegui.services.multi_camera_controller import ( MultiCameraController, _camera_start_priority, From e597464a6f5cf955cd863d3fb50c35aae2788433 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 11:48:07 +0200 Subject: [PATCH 21/71] Fix duplication --- dlclivegui/services/multi_camera_controller.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 4161aef13..21623aa58 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -177,14 +177,9 @@ def get_camera_id(settings: CameraSettings) -> str: def _trigger_role_from_settings(settings: CameraSettings) -> str: - 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 {} - - trigger = ns.get("trigger", {}) + trigger = settings.get_trigger_settings() if not isinstance(trigger, dict): return "off" - role = str(trigger.get("role", "off") or "off").strip().lower() # Match CameraTriggerSettings aliases enough for controller ordering. From bc1906630b52214cbd9d632db3751bfaa9fa98d6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 13:02:27 +0200 Subject: [PATCH 22/71] Refine GenTL trigger timeout handling Refactors GenTL timeout logic to keep a base fetch timeout separate from trigger-requested timeout, then computes the effective timeout based on trigger role. External/follower roles remain capped to avoid long hardware-trigger waits, master uses the trigger timeout directly, and non-trigger modes fall back to the base timeout. Also updates trigger persistence to store both requested and actual trigger settings, including `fetch_timeout` and `requested_timeout`, and improves trigger serialization by supporting objects that expose `to_properties()`. --- dlclivegui/cameras/backends/gentl_backend.py | 54 +++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 44d12708f..2a08f9dd3 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -127,18 +127,11 @@ def __init__(self, settings): ) self._trigger = CameraTriggerSettings() + self._base_timeout = float(ns.get("timeout", props.get("timeout", 2.0))) + self._timeout = self._base_timeout trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) - if trigger_timeout is not None: - role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() - - if role in {"external", "follower"}: - # Do not let a long hardware-trigger wait block shutdown. - # SingleCameraWorker treats these fetch timeouts as expected - # polling misses while waits_for_hardware_trigger is true. - self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) - else: - # For non-trigger-waiting modes, preserve legacy behavior. - self._timeout = float(trigger_timeout) + self._trigger_requested_timeout: float | None = trigger_timeout + self._apply_effective_fetch_timeout() self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() @@ -463,8 +456,21 @@ def open(self) -> None: self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) - self._configure_trigger(node_map) # keep low in the list - self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) + + ns = self._ensure_settings_ns() + requested_trigger = self._trigger_to_dict(self._trigger) + + self._configure_trigger(node_map) + self._apply_effective_fetch_timeout() + + actual_trigger = self._trigger_to_dict(self._trigger) + actual_trigger["fetch_timeout"] = self._timeout + if self._trigger_requested_timeout is not None: + actual_trigger["requested_timeout"] = float(self._trigger_requested_timeout) + + ns["trigger"] = requested_trigger + ns["trigger_actual"] = actual_trigger + self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -1052,6 +1058,11 @@ def _trigger_to_dict(trigger) -> dict[str, Any]: return {} if isinstance(trigger, dict): return dict(trigger) + if hasattr(trigger, "to_properties"): + try: + return trigger.to_properties() + except Exception: + pass if hasattr(trigger, "model_dump"): try: return trigger.model_dump(exclude_none=True) @@ -1104,6 +1115,23 @@ def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> ) return requested, False + def _apply_effective_fetch_timeout(self) -> None: + trigger_timeout = self._trigger_requested_timeout + if trigger_timeout is None: + self._timeout = self._base_timeout + return + + role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() + + if role in {"external", "follower"}: + self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + elif role == "master": + self._timeout = float(trigger_timeout) + else: + # Only cap trigger-bound modes + # master/off should not block waiting for a hardware trigger + self._timeout = self._base_timeout + def _configure_pixel_format(self, node_map) -> None: try: pixel_format_node = getattr(node_map, "PixelFormat", None) From 85b6de6df7140022f08a65be9ff0af03ffe48afd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 13:05:52 +0200 Subject: [PATCH 23/71] Use CameraTriggerSettings for trigger role Replace manual trigger-role normalization in `multi_camera_controller` with `CameraTriggerSettings.from_any(...)` and normalize via its parsed role. This centralizes alias handling in config parsing and keeps a safe fallback to `"off"` when trigger settings are invalid. --- .../services/multi_camera_controller.py | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 21623aa58..1a53509c5 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,7 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings +from dlclivegui.config import CameraSettings, CameraTriggerSettings LOGGER = logging.getLogger(__name__) @@ -177,22 +177,11 @@ def get_camera_id(settings: CameraSettings) -> str: def _trigger_role_from_settings(settings: CameraSettings) -> str: - trigger = settings.get_trigger_settings() - if not isinstance(trigger, dict): + try: + trigger = settings.get_trigger_settings() + return str(CameraTriggerSettings.from_any(trigger).role).strip().lower() + except Exception: return "off" - role = str(trigger.get("role", "off") or "off").strip().lower() - - # Match CameraTriggerSettings aliases enough for controller ordering. - if role in {"true", "on", "trigger", "triggered"}: - return "external" - if role in {"slave"}: - return "follower" - if role in {"main"}: - return "master" - if role in {"false", "none", "disabled", "disable", ""}: - return "off" - - return role def _camera_start_priority(settings: CameraSettings) -> int: From f58279fab2a932c4b160d528eb314a201763686f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 13:33:44 +0200 Subject: [PATCH 24/71] Fix incorrect test expectations --- tests/services/test_multicam_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index a0950b063..7dcde8908 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -329,7 +329,7 @@ def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory) properties={"opencv": {"device_id": "cam-b"}}, ).apply_defaults() - expected_order = [get_display_id(cam_a), get_display_id(cam_b)] + expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] seen_orders: list[list[str]] = [] def on_ready(mfd): @@ -461,7 +461,7 @@ def _create(settings): mc.start([cam]) cam_id, msg = blocker.args - assert cam_id == get_display_id(cam) + assert cam_id == get_camera_id(cam) assert "Camera read timeout" in msg # Cleanup if still running. From ed74bd2960ad745220705c12274be5a467d1b49a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 9 Jul 2026 13:44:14 +0200 Subject: [PATCH 25/71] Update multi_camera_controller.py --- dlclivegui/services/multi_camera_controller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 1a53509c5..3ff4472e4 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -253,6 +253,8 @@ def start(self, camera_settings: list[CameraSettings]) -> None: # Startup order may differ for trigger safety: # followers/external first, master last. + # Note that this is not a hard sync guarantee, it just calls start() on the workers + # in the order of priority. active_settings = sorted(active_settings_user_order, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") @@ -417,7 +419,7 @@ def stop(self, wait: bool = True) -> None: self._failed_cameras.clear() self._display_ids.clear() self._camera_display_order.clear() - + with self._frame_lock: self._frames.clear() self._timestamps.clear() From 65712caf8f56ee74864917c3242de520cb7ca9b2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:26:15 +0200 Subject: [PATCH 26/71] Add trigger settings dialog and UI button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TriggerConfigDialog to edit per-camera hardware trigger settings (CameraTriggerSettings). The dialog provides fields for role, selector, source, activation, output line/source, timeout and strict mode, and persists settings into camera.properties[]['trigger']. Also add a "Trigger Settings…" button to the camera settings UI (disabled by default) with tooltip and icon; wiring to open the dialog can be connected elsewhere. --- .../camera_config/trigger_config_dialog.py | 175 ++++++++++++++++++ dlclivegui/gui/camera_config/ui_blocks.py | 7 + 2 files changed, 182 insertions(+) create mode 100644 dlclivegui/gui/camera_config/trigger_config_dialog.py diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py new file mode 100644 index 000000000..d7546e95b --- /dev/null +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -0,0 +1,175 @@ +# dlclivegui/gui/camera_config/trigger_config_dialog.py +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) + +from ...config import CameraSettings, CameraTriggerSettings + + +def _backend_namespace(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + if not isinstance(cam.properties, dict): + cam.properties = {} + ns = cam.properties.setdefault(backend, {}) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend] = ns + return ns + + +class TriggerConfigDialog(QDialog): + """Small dialog for editing per-camera hardware trigger settings.""" + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Trigger Settings") + self.setMinimumWidth(420) + + self._cam = cam.model_copy(deep=True) + + ns = _backend_namespace(self._cam) + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + + self._setup_ui() + self._load_from_trigger(self._trigger) + self._sync_role_ui() + + @property + def camera_settings(self) -> CameraSettings: + return self._cam + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + + info = QLabel( + "Configure hardware trigger settings for this camera.\n" + "Unsupported fields are ignored by the backend unless strict mode is enabled." + ) + info.setWordWrap(True) + root.addWidget(info) + + group = QGroupBox("Hardware Trigger") + form = QFormLayout(group) + + self.role_combo = QComboBox() + self.role_combo.addItem("Off / Free-run", "off") + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + self.role_combo.addItem("Master", "master") + form.addRow("Role:", self.role_combo) + + self.selector_edit = QLineEdit() + self.selector_edit.setPlaceholderText("FrameStart") + form.addRow("Trigger selector:", self.selector_edit) + + self.source_edit = QLineEdit() + self.source_edit.setPlaceholderText("Line0") + form.addRow("Trigger source:", self.source_edit) + + self.activation_combo = QComboBox() + for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): + self.activation_combo.addItem(value, value) + form.addRow("Activation:", self.activation_combo) + + self.output_line_edit = QLineEdit() + self.output_line_edit.setPlaceholderText("Line2") + form.addRow("Output line:", self.output_line_edit) + + self.output_source_edit = QLineEdit() + self.output_source_edit.setPlaceholderText("ExposureActive") + form.addRow("Output source:", self.output_source_edit) + + self.timeout_spin = QDoubleSpinBox() + self.timeout_spin.setRange(0.0, 3600.0) + self.timeout_spin.setDecimals(3) + self.timeout_spin.setSingleStep(0.1) + self.timeout_spin.setSpecialValueText("Default") + self.timeout_spin.setToolTip( + "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + ) + form.addRow("Read timeout:", self.timeout_spin) + + self.strict_checkbox = QCheckBox("Strict mode") + self.strict_checkbox.setToolTip("If enabled, missing/unsupported GenICam trigger nodes fail camera open.") + form.addRow(self.strict_checkbox) + + root.addWidget(group) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: + role = str(getattr(trigger, "role", "off") or "off").lower() + idx = self.role_combo.findData(role) + self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) + self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + + activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") + idx = self.activation_combo.findData(activation) + self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) + self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + + timeout = getattr(trigger, "timeout", None) + self.timeout_spin.setValue(float(timeout) if timeout else 0.0) + + self.strict_checkbox.setChecked(bool(getattr(trigger, "strict", False))) + + def _sync_role_ui(self) -> None: + role = str(self.role_combo.currentData() or "off") + + input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + + self.selector_edit.setEnabled(input_enabled) + self.source_edit.setEnabled(input_enabled) + self.activation_combo.setEnabled(input_enabled) + + self.output_line_edit.setEnabled(output_enabled) + self.output_source_edit.setEnabled(output_enabled) + + # Timeout is mostly useful for external/follower, but harmless for any role. + self.timeout_spin.setEnabled(role in {"external", "follower"}) + + def _accept(self) -> None: + role = str(self.role_combo.currentData() or "off") + + payload = { + "role": role, + "selector": self.selector_edit.text().strip() or "FrameStart", + "source": self.source_edit.text().strip() or "Line0", + "activation": str(self.activation_combo.currentData() or "RisingEdge"), + "output_line": self.output_line_edit.text().strip() or "Line2", + "output_source": self.output_source_edit.text().strip() or "ExposureActive", + "strict": bool(self.strict_checkbox.isChecked()), + } + + timeout = float(self.timeout_spin.value()) + if timeout > 0: + payload["timeout"] = timeout + + trigger = CameraTriggerSettings.from_any(payload) + + ns = _backend_namespace(self._cam) + ns["trigger"] = trigger.model_dump(exclude_none=True) + + self.accept() diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 86e4f19d6..07a8025e3 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -354,6 +354,13 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow("Crop:", crop_widget) + # --- Trigger settings button --- + dlg.trigger_settings_btn = QPushButton("Trigger Settings…") + dlg.trigger_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView)) + dlg.trigger_settings_btn.setEnabled(False) + dlg.trigger_settings_btn.setToolTip("Configure hardware trigger / GPIO sync settings for this camera.") + dlg.settings_form.addRow("Sync:", dlg.trigger_settings_btn) + # Apply/Reset buttons row dlg.apply_settings_btn = QPushButton("Apply Settings") dlg.apply_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) From d51c8f7e725549eaecfb00f99686e993777854b0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:29:36 +0200 Subject: [PATCH 27/71] Add per-camera hardware trigger settings Introduce support for per-camera hardware trigger configuration in the camera config dialog. Imports TriggerConfigDialog and CameraTriggerSettings, wires the trigger settings button to open a modal, and adds _open_trigger_settings_dialog to commit edits, show the dialog, apply updates, and restart the preview if needed. Adds helpers: _ensure_default_trigger_config to initialize gentl.trigger defaults, _trigger_role_for_label to show trigger role in camera list labels, and _trigger_dict_for_cam to compare trigger settings when deciding to restart previews. Also integrates the hardware trigger field into the settings summary and ensures new/loaded cameras get a default trigger config. --- .../gui/camera_config/camera_config_dialog.py | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 4c94fb701..af22842f2 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -18,9 +18,10 @@ ) from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key -from ...config import CameraSettings, MultiCameraSettings +from ...config import CameraSettings, CameraTriggerSettings, MultiCameraSettings from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap +from .trigger_config_dialog import TriggerConfigDialog from .ui_blocks import setup_camera_config_dialog_ui LOGGER = logging.getLogger(__name__) @@ -328,6 +329,7 @@ def _connect_signals(self) -> None: self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked) + self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.reset_settings_btn.clicked.connect(self._reset_selected_camera) self.preview_btn.clicked.connect(self._toggle_preview) @@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None: finally: cam_list.blockSignals(False) + def _trigger_role_for_label(self, cam: CameraSettings) -> str: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + return str(trigger.get("role", "off") or "off").lower() + 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 "" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + + 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}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -514,6 +529,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") + # Hardware trigger / sync + apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") + def _set_preview_button_loading(self, loading: bool) -> None: if loading: self.preview_btn.setText("Cancel Loading") @@ -800,6 +818,21 @@ def _on_active_camera_selected(self, row: int) -> None: self._load_camera_to_form(cam) self._start_probe_for_camera(cam, apply_to_requested=False) + def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: + backend = (cam.backend or "").lower() + if backend != "gentl": + return + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + def _add_selected_camera(self) -> None: if not self._commit_pending_edits(reason="before adding a new camera"): return @@ -850,6 +883,7 @@ def _add_selected_camera(self) -> None: properties={}, ) apply_detected_identity(new_cam, detected, backend) + self._ensure_default_trigger_config(new_cam) self._working_settings.cameras.append(new_cam) new_index = len(self._working_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) @@ -969,6 +1003,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0.setValue(cam.crop_y0) self.cam_crop_x1.setValue(cam.crop_x1) self.cam_crop_y1.setValue(cam.crop_y1) + self._ensure_default_trigger_config(cam) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) finally: @@ -1029,6 +1064,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int: count += 1 return count + def _open_trigger_settings_dialog(self) -> None: + """Open per-camera hardware trigger settings dialog.""" + if self._current_edit_index is None: + return + + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return + + # Commit normal camera edits first so we do not lose pending UI changes. + if not self._commit_pending_edits(reason="before opening trigger settings"): + return + + cam = self._working_settings.cameras[row] + + dlg = TriggerConfigDialog(cam, self) + if dlg.exec() != QDialog.Accepted: + return + + updated = dlg.camera_settings + + self._working_settings.cameras[row] = updated + self._update_active_list_item(row, updated) + self._load_camera_to_form(updated) + + # Trigger changes require reopening the camera preview/backend. + if self._preview.state == PreviewState.ACTIVE: + self._append_status("[Trigger] Restarting preview to apply trigger settings.") + self._request_preview_restart(updated, reason="trigger-settings") + + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) + def _apply_camera_settings(self) -> bool: try: for sb in ( @@ -1597,6 +1665,13 @@ def _bump_epoch(self) -> int: self._preview.epoch += 1 return self._preview.epoch + def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + return trigger if isinstance(trigger, dict) else {} + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: """ Fast UX policy: @@ -1612,6 +1687,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b except Exception: return True # safest: restart + if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new): + return True + # No restart needed if only rotation/crop/enabled changed return False From 97398246e4f8cb9410e7f9945f4110b1367bdb89 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:36:08 +0200 Subject: [PATCH 28/71] Update trigger_config_dialog.py --- dlclivegui/gui/camera_config/trigger_config_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d7546e95b..d4c4b19eb 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -34,7 +34,7 @@ class TriggerConfigDialog(QDialog): def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) - self.setWindowTitle("Trigger Settings") + self.setWindowTitle("Configure trigger mode") self.setMinimumWidth(420) self._cam = cam.model_copy(deep=True) From 358ebcf5b17d40408131be9575764a468dde8d52 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:25:21 +0200 Subject: [PATCH 29/71] Use _is_preview_live and clear trigger timeout Replace direct checks of self._preview.state == PreviewState.ACTIVE with self._is_preview_live() in camera_config_dialog to centralize preview-active logic and ensure consistent behavior when restarting the preview after trigger or camera setting changes. In trigger_config_dialog, only set payload["timeout"] for external/follower roles when timeout > 0, and explicitly set payload["timeout"] = None when role == "off" to clear any stale timeout when disabling the trigger. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 8 +++----- dlclivegui/gui/camera_config/trigger_config_dialog.py | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index af22842f2..e005617ba 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1090,7 +1090,7 @@ def _open_trigger_settings_dialog(self) -> None: self._load_camera_to_form(updated) # Trigger changes require reopening the camera preview/backend. - if self._preview.state == PreviewState.ACTIVE: + if self._is_preview_live(): self._append_status("[Trigger] Restarting preview to apply trigger settings.") self._request_preview_restart(updated, reason="trigger-settings") @@ -1153,9 +1153,7 @@ def _apply_camera_settings(self) -> bool: old_settings = current_model restart = False - should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance( - old_settings, CameraSettings - ) + should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings) if should_consider_restart: restart = self._should_restart_preview(old_settings, new_model) @@ -1167,7 +1165,7 @@ def _apply_camera_settings(self) -> bool: new_model.index, ) - if self._preview.state == PreviewState.ACTIVE and restart: + if self._is_preview_live() and restart: self._append_status("[Apply] Restarting preview to apply camera settings changes.") self._request_preview_restart(new_model, reason="apply-settings") diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d4c4b19eb..07e9570aa 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -164,8 +164,10 @@ def _accept(self) -> None: } timeout = float(self.timeout_spin.value()) - if timeout > 0: + if role in {"external", "follower"} and timeout > 0: payload["timeout"] = timeout + elif role == "off": + payload["timeout"] = None # ensure timeout is cleared when disabling trigger trigger = CameraTriggerSettings.from_any(payload) From a1a139b1deb68298881e3fb19384f71e4514c3a9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:43:11 +0200 Subject: [PATCH 30/71] Restart active previews; ignore bad trigger Allow pending preview restarts to proceed when the preview is ACTIVE (previously only IDLE) and return early to avoid double UI sync in camera_config_dialog.py. Also wrap CameraTriggerSettings loading in a try/except and fall back to a default instance when parsing fails, making trigger_config_dialog.py tolerant of malformed or missing trigger data. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 3 ++- dlclivegui/gui/camera_config/trigger_config_dialog.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index e005617ba..444f27387 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1832,9 +1832,10 @@ def _on_loader_finished(self, e: int) -> None: self._preview.restart_scheduled = False self._preview.loader = None - if pending and self._preview.state == PreviewState.IDLE: + if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE): LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index) self._begin_preview_load(pending, reason="pending-restart-after-finish") + return # UI sync is already handled in _begin_preview_load self._sync_preview_ui() diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index 07e9570aa..cd72701b1 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -40,7 +40,10 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): self._cam = cam.model_copy(deep=True) ns = _backend_namespace(self._cam) - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + except Exception: + self._trigger = CameraTriggerSettings() self._setup_ui() self._load_from_trigger(self._trigger) From 2043e574eb5f22b9b8f24e4f0c05279fa8e7462a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:10:49 +0200 Subject: [PATCH 31/71] Improve trigger dialog defaults and error handling Make trigger dialog friendlier and more robust: recommend using 'auto' for trigger source and switch default/fallback source from "Line0" to "auto", update source placeholder and info/timeout tooltips, and import QMessageBox. Wrap CameraTriggerSettings.from_any in a try/except to show a critical error dialog on failure and abort apply. Store trigger settings via trigger.to_properties() instead of model_dump(exclude_none=True). These changes provide clearer defaults and better error feedback when applying trigger settings. --- .../camera_config/trigger_config_dialog.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index cd72701b1..d1efa0362 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -11,6 +11,7 @@ QGroupBox, QLabel, QLineEdit, + QMessageBox, QVBoxLayout, QWidget, ) @@ -58,7 +59,8 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Unsupported fields are ignored by the backend unless strict mode is enabled." + "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) root.addWidget(info) @@ -78,7 +80,7 @@ def _setup_ui(self) -> None: form.addRow("Trigger selector:", self.selector_edit) self.source_edit = QLineEdit() - self.source_edit.setPlaceholderText("Line0") + self.source_edit.setPlaceholderText("auto, Line0, Software, ...") form.addRow("Trigger source:", self.source_edit) self.activation_combo = QComboBox() @@ -100,7 +102,7 @@ def _setup_ui(self) -> None: self.timeout_spin.setSingleStep(0.1) self.timeout_spin.setSpecialValueText("Default") self.timeout_spin.setToolTip( - "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + "Fetch poll timeout in seconds. The backend may cap individual fetches to keep preview shutdown responsive." ) form.addRow("Read timeout:", self.timeout_spin) @@ -123,7 +125,7 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) - self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + self.source_edit.setText(str(getattr(trigger, "source", "auto") or "auto")) activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") idx = self.activation_combo.findData(activation) @@ -159,7 +161,7 @@ def _accept(self) -> None: payload = { "role": role, "selector": self.selector_edit.text().strip() or "FrameStart", - "source": self.source_edit.text().strip() or "Line0", + "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", @@ -172,9 +174,13 @@ def _accept(self) -> None: elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger - trigger = CameraTriggerSettings.from_any(payload) + try: + trigger = CameraTriggerSettings.from_any(payload) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {e}") + return ns = _backend_namespace(self._cam) - ns["trigger"] = trigger.model_dump(exclude_none=True) + ns["trigger"] = trigger.to_properties() self.accept() From 18446c28107b75de1adb056bf395ea0f0e0e6c55 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:50:10 +0200 Subject: [PATCH 32/71] Add worker timing logs to camera worker Introduce WorkerTimingStats (utils/stats.py) to collect simple timing counters (per-named section totals, frame/timeouts/errors) and periodically emit debug logs. Integrate it into SingleCameraWorker: create a timing instance (configurable via new SINGLE_CAMERA_WORKER_DO_LOG_TIMING in config.py), wrap backend.read and frame emit calls with timed sections, and call note_frame/note_timeout/note_error + maybe_log to accumulate and flush stats. Also update imports accordingly and use a 1s default log interval. --- dlclivegui/config.py | 2 + .../services/multi_camera_controller.py | 21 +++- dlclivegui/utils/stats.py | 103 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 115301626..827fdfc6d 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,6 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True + class CameraSettings(BaseModel): name: str = "Camera 0" diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 3ff4472e4..d546e8ab9 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,8 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings, CameraTriggerSettings +from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings, CameraSettings +from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -55,6 +56,11 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + # 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() @@ -90,7 +96,8 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - frame, timestamp = self._backend.read() + with self._timing.measure("GenTL.read"): + frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: @@ -103,9 +110,15 @@ def run(self) -> None: continue consecutive_errors = 0 - self.frame_captured.emit(self._camera_id, frame, timestamp) + with self._timing.measure("GenTL.emit.frame_captured"): + 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 @@ -133,6 +146,8 @@ def run(self) -> None: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() consecutive_errors += 1 if self._stop_event.is_set(): break diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 23e9d57f0..38e3798b7 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -1,10 +1,113 @@ # dlclivegui/utils/stats.py from __future__ import annotations +import logging +import time + from dlclivegui.services.dlc_processor import ProcessorStats from dlclivegui.services.video_recorder import RecorderStats +class WorkerTimingStats: + """Tiny timing accumulator for camera worker performance diagnostics. + + Usage: + with stats.measure("read"): + frame, ts = backend.read() + + Logs aggregate timings once per log_interval seconds. + """ + + def __init__( + self, camera_id: str, *, logger: logging.Logger | None = None, log_interval: float = 1.0, enabled: bool = True + ): + self.camera_id = camera_id + self.log_interval = float(log_interval) + self.enabled = bool(enabled) + self.logger = logger or logging.getLogger(__name__) + if self.enabled: # force logger to proper level + if not self.logger.isEnabledFor(logging.DEBUG): + self.logger.setLevel(logging.DEBUG) + + self._last_log = time.perf_counter() + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals: dict[str, float] = {} + self._counts: dict[str, int] = {} + + class _Measure: + def __init__(self, parent: WorkerTimingStats, name: str): + self.parent = parent + self.name = name + self.t0 = 0.0 + + def __enter__(self): + if self.parent.enabled: + self.t0 = time.perf_counter() + return self + + 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.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 + return False + + def measure(self, name: str): + return self._Measure(self, name) + + def note_frame(self) -> None: + if self.enabled: + self._frames += 1 + + def note_timeout(self) -> None: + if self.enabled: + self._timeouts += 1 + + def note_error(self) -> None: + if self.enabled: + self._errors += 1 + + def maybe_log(self) -> None: + if not self.enabled: + return + + now = time.perf_counter() + elapsed = now - self._last_log + if elapsed < self.log_interval: + return + + fps = self._frames / max(elapsed, 1e-9) + + parts = [ + f"[Worker {self.camera_id}]", + f"fps={fps:.1f}", + f"frames={self._frames}", + ] + + if self._timeouts: + parts.append(f"timeouts={self._timeouts}") + if self._errors: + parts.append(f"errors={self._errors}") + + for name in sorted(self._totals): + count = max(self._counts.get(name, 0), 1) + avg_ms = 1000.0 * self._totals[name] / count + parts.append(f"avg_{name}_ms={avg_ms:.3f}") + + self.logger.debug(" ".join(parts)) + + self._last_log = now + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals.clear() + self._counts.clear() + + def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 From b5c586f73110c1e7b96e5e4f7d345e4dd2d7f92a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:01:43 +0200 Subject: [PATCH 33/71] Add per-camera timing to MultiCameraController Introduce MULTI_CAMERA_WORKER_DO_LOG_TIMING and disable single-worker timing by default (SINGLE_CAMERA_WORKER_DO_LOG_TIMING=false). Add per-camera WorkerTimingStats storage and a _timing_for_camera factory that respects the new config. Wrap _on_frame_captured processing in timed sections (total, apply_transforms, update_latest), call note_frame/maybe_log per camera, and emit frames as before. Also adjust SingleCameraWorker timing labels from GenTL.* to Single.* for clearer logs. --- dlclivegui/config.py | 3 +- .../services/multi_camera_controller.py | 93 ++++++++++++------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 827fdfc6d..5793f5904 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,7 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING = True class CameraSettings(BaseModel): diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index d546e8ab9..77bb6bbee 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -19,6 +19,7 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings, CameraSettings +from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -96,7 +97,7 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - with self._timing.measure("GenTL.read"): + with self._timing.measure("Single.read"): frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 @@ -110,7 +111,7 @@ def run(self) -> None: continue consecutive_errors = 0 - with self._timing.measure("GenTL.emit.frame_captured"): + with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) self._timing.note_frame() @@ -244,6 +245,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # Performance logs + self._timing_per_cam: dict[str, WorkerTimingStats] = {} + def is_running(self) -> bool: """Check if any camera is currently running.""" return self._running and len(self._started_cameras) > 0 @@ -252,6 +256,20 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) + def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: + if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: + return WorkerTimingStats(camera_id, enabled=False) + timing = self._timing_per_cam.get(camera_id) + if timing is None: + timing = WorkerTimingStats( + f"Controller {camera_id}", + logger=LOGGER, + log_interval=1.0, + enabled=MULTI_CAMERA_WORKER_DO_LOG_TIMING, + ) + self._timing_per_cam[camera_id] = timing + return timing + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -445,38 +463,42 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" # Apply rotation if configured - settings = self._settings.get(camera_id) - if settings and settings.rotation: - frame = MultiCameraController.apply_rotation(frame, settings.rotation) - - # Apply cropping if configured - if settings: - crop_region = settings.get_crop_region() - if crop_region: - frame = MultiCameraController.apply_crop(frame, crop_region) - - with self._frame_lock: - self._frames[camera_id] = frame - self._timestamps[camera_id] = timestamp - - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] + timing = self._timing_for_camera(camera_id) + + with timing.measure("Multi.slot.total"): + settings = self._settings.get(camera_id) + with timing.measure("Multi.slot.apply_transforms"): + if settings and settings.rotation: + frame = MultiCameraController.apply_rotation(frame, settings.rotation) + + # Apply cropping if configured + if settings: + crop_region = settings.get_crop_region() + if crop_region: + frame = MultiCameraController.apply_crop(frame, crop_region) + with timing.measure("Multi.update_latest"): + with self._frame_lock: + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp + + # Emit frame data without tiling (tiling done in GUI for performance) + if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] frame_data = MultiFrameData( frames=ordered_frames, @@ -487,6 +509,9 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float ) self.frame_ready.emit(frame_data) + timing.note_frame() + timing.maybe_log() + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" From 60077f235b5c045ee2b788dfe054363a779e101c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:02:38 +0200 Subject: [PATCH 34/71] Add pretty/str/repr to CameraSettings Introduce a human-readable representation for CameraSettings by adding a pretty() method and overriding __str__ and __repr__. The pretty output formats key fields (name, index, backend, enabled, fps, size, exposure, gain, rotation) and displays a readable crop region (showing 'none' or coordinate range with 'edge' fallbacks). This aids debugging and logging without changing existing validation or behavior. --- dlclivegui/config.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 5793f5904..c7a9f431e 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -43,6 +43,27 @@ class CameraSettings(BaseModel): enabled: bool = True properties: dict[str, Any] = Field(default_factory=dict) + def pretty(self) -> str: + crop = ( + "none" + if self.get_crop_region() is None + else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})" + ) + return ( + f"CameraSettings[\n" + f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n" + 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"]" + ) + + def __str__(self) -> str: + return self.pretty() + + def __repr__(self) -> str: + return self.pretty() + @field_validator("fps", mode="before") @classmethod def _coerce_fps(cls, v): From e605c8088e550ef5ac88a144edcac1dc468091fa Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:45:09 +0200 Subject: [PATCH 35/71] Reduce lock scope and refactor frame handling Refactor _on_frame_captured to minimize time spent under _frame_lock and improve timing granularity. Introduces a frame_data local, renames timing labels (e.g. Multi.apply_transforms, Multi.store_latest, Multi.build_ordered, Multi.construct_frame_data), and moves frame_data construction inside measured blocks. The frame_ready emit is now performed outside the lock and guarded by a None check to avoid holding the lock during signal emission. Also small whitespace and cleanup changes. --- .../services/multi_camera_controller.py | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 77bb6bbee..f82ae3674 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -462,52 +462,57 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): settings = self._settings.get(camera_id) - with timing.measure("Multi.slot.apply_transforms"): + + with timing.measure("Multi.apply_transforms"): if settings and settings.rotation: frame = MultiCameraController.apply_rotation(frame, settings.rotation) - # Apply cropping if configured if settings: crop_region = settings.get_crop_region() if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) - with timing.measure("Multi.update_latest"): - with self._frame_lock: + + with self._frame_lock: + with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - frame_data = MultiFrameData( - frames=ordered_frames, - timestamps=ordered_timestamps, - source_camera_id=camera_id, - tiled_frame=None, - display_ids=dict(self._display_ids), - ) - self.frame_ready.emit(frame_data) + with timing.measure("Multi.build_ordered"): + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + with timing.measure("Multi.construct_frame_data"): + frame_data = MultiFrameData( + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, + tiled_frame=None, + display_ids=dict(self._display_ids), + ) + + + if frame_data is not None: + with timing.measure("Multi.emit.frame_ready"): + self.frame_ready.emit(frame_data) timing.note_frame() timing.maybe_log() From fa311215cff7856e5f6725ce383dcbf3c73668e6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 2 Jun 2026 15:48:40 +0200 Subject: [PATCH 36/71] Add GenTL strobe support and trigger debugging Add debugging helpers for GenTL trigger and frame-rate nodes and log their values during camera configuration. Improve GenTL trigger input handling by treating TriggerSource as best-effort (supporting read-only/auto cases) and emitting clearer warnings. Implement a dedicated master/output path for TIS/DMK 37U cameras using Strobe* nodes (StrobeEnable/Polarity/Operation/Duration/Delay) with a fallback to generic Line* configuration; respect strict mode and surface informative errors. Extend CameraTriggerSettings with strobe fields (polarity, operation, duration, delay) and validation/coercion. Update the trigger configuration dialog to expose strobe controls, tooltips, UI sync logic, and include strobe values in the saved payload. --- dlclivegui/cameras/backends/gentl_backend.py | 279 +++++++++++++++--- dlclivegui/config.py | 31 +- .../camera_config/trigger_config_dialog.py | 89 +++++- 3 files changed, 350 insertions(+), 49 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 2a08f9dd3..7866438a4 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -185,6 +185,89 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "hardware_trigger": SupportLevel.BEST_EFFORT, } + def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "TriggerMode", + "TriggerSelector", + "TriggerSource", + "TriggerActivation", + "AcquisitionMode", + # Generic line nodes, if available. + "LineSelector", + "LineMode", + "LineSource", + # TIS 37U / DMK 37BUX287 strobe/output nodes. + "GPIn", + "GPOut", + "StrobeEnable", + "StrobePolarity", + "StrobeOperation", + "StrobeDuration", + "StrobeDelay", + ) + + label = f"GenTL trigger debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + + symbolics = self._node_symbolics(node) + if symbolics: + extras.append(f"symbolics={symbolics}") + + for attr in ("access_mode", "is_writable", "is_readable"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _debug_frame_rate_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRateControlEnable", + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "DeviceLinkThroughputLimit", + "DeviceLinkThroughputLimitMode", + "PayloadSize", + "Width", + "Height", + "PixelFormat", + ) + + label = f"GenTL FPS debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + for attr in ("min", "max", "inc"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -462,6 +545,7 @@ def open(self) -> None: self._configure_trigger(node_map) self._apply_effective_fetch_timeout() + self._debug_trigger_nodes(node_map, context="after configuration before acquisition") actual_trigger = self._trigger_to_dict(self._trigger) actual_trigger["fetch_timeout"] = self._timeout @@ -1214,31 +1298,42 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") - source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) - source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) - # TriggerSelector and TriggerSource are required routing nodes. - # If either failed in non-strict mode, do not arm TriggerMode=On. - # Otherwise the camera may wait on a previous/default input line. - if not (selector_ok and source_ok): + source_ok = False + if source and source.lower() not in {"", "auto", "none"}: + source_node = self._node(node_map, "TriggerSource") + source_symbolics = self._node_symbolics(source_node) + + if source_node is not None: + if source in source_symbolics: + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) + if not source_ok: + LOG.warning( + "GenTL TriggerSource=%s is supported but not writable; " + "continuing without changing TriggerSource. Available: %s", + source, + source_symbolics, + ) + else: + LOG.warning( + "Requested GenTL TriggerSource=%s not in available sources %s; " + "continuing without changing TriggerSource.", + source, + source_symbolics, + ) + + if not selector_ok: LOG.warning( - "Could not apply GenTL trigger input routing " - "(selector_ok=%s, source_ok=%s); disabling trigger. " - "requested role=%s selector=%s source=%s activation=%s", - selector_ok, - source_ok, - role, + "Could not apply GenTL TriggerSelector=%s; disabling trigger.", selector, - source, - activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1259,55 +1354,153 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + "GenTL trigger input configured: role=%s selector=%s activation=%s " + "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", role, selector, - source, activation, + selector_ok, activation_ok, + source, + source_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + """Configure this camera as a free-running master that emits STROBE_OUT pulses. + + For DMK 37BUX287 / TIS 37U series, the physical output is controlled by + StrobeEnable/StrobePolarity/StrobeOperation rather than SFNC LineSelector/ + LineMode/LineSource nodes. + """ output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") - # Master camera runs freerun and exposes an output signal. + # Optional extra fields if present in trigger dict/model. + strobe_polarity = str(self._trigger_attr(cfg, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + strobe_operation = str(self._trigger_attr(cfg, "strobe_operation", "Exposure") or "Exposure") + strobe_duration = self._trigger_attr(cfg, "strobe_duration", None) + strobe_delay = self._trigger_attr(cfg, "strobe_delay", None) + + # Master camera should be free-running. self._configure_trigger_off(node_map, strict=False) - line_selected = self._set_enum_node( - node_map, - "LineSelector", - output_line, - strict=strict, - ) + # ------------------------------------------------------------------ + # Preferred path for The Imaging Source 37U / DMK 37BUX287: + # StrobeEnable, StrobePolarity, StrobeOperation, StrobeDuration, StrobeDelay + # ------------------------------------------------------------------ + strobe_enable_node = self._node(node_map, "StrobeEnable") + + if strobe_enable_node is not None: + # Disable first while changing parameters. + self._set_enum_node(node_map, "StrobeEnable", "Off", strict=False) + + polarity_ok = self._set_enum_node( + node_map, + "StrobePolarity", + strobe_polarity, + strict=False, + ) - # In non-strict mode, do not continue configuring output behavior if the - # requested line could not be selected. Otherwise we may accidentally drive - # whichever GPIO line the camera had selected previously/defaulted to. - if not line_selected: - LOG.warning( - "Could not select GenTL output line '%s'; skipping trigger output configuration.", - output_line, + operation_ok = self._set_enum_node( + node_map, + "StrobeOperation", + strobe_operation, + strict=False, ) - return - mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + if strobe_duration is not None: + try: + 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)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc + LOG.warning("Failed to set StrobeDuration=%s: %s", strobe_duration, exc) + + if strobe_delay is not None: + try: + 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)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc + LOG.warning("Failed to set StrobeDelay=%s: %s", strobe_delay, exc) + + enable_ok = self._set_enum_node( + node_map, + "StrobeEnable", + "On", + strict=strict, + ) + + if enable_ok: + LOG.info( + "GenTL trigger master configured via Strobe*: " + "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " + "StrobeOperation=%s operation_ok=%s", + strobe_polarity, + polarity_ok, + strobe_operation, + operation_ok, + ) + return + + if strict: + raise RuntimeError("Could not enable GenTL StrobeEnable=On") - if not (mode_ok and source_ok): LOG.warning( - "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", - mode_ok, - source_ok, + "StrobeEnable node exists but could not be enabled; falling back to generic Line* output configuration." ) - return - LOG.info( - "GenTL trigger master configured: output_line=%s output_source=%s", - output_line, - output_source, + # ------------------------------------------------------------------ + # Generic SFNC fallback for cameras that expose LineSelector/LineMode/LineSource. + # ------------------------------------------------------------------ + line_selector = self._node(node_map, "LineSelector") + if line_selector is not None: + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping Line* output configuration.", + output_line, + ) + else: + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "GenTL trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + LOG.warning( + "GenTL Line* trigger output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + + msg = ( + "Could not configure GenTL trigger master output. " + "No supported Strobe* or Line* output path was successfully configured." ) + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + def _restore_trigger_idle(self, node_map) -> None: """Best-effort restore to a safe non-triggering state after acquisition stops. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a9f431e..2d176224f 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -14,6 +14,8 @@ ModelType = Literal["pytorch", "tensorflow"] TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] +TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False MULTI_CAMERA_WORKER_DO_LOG_TIMING = True @@ -239,9 +241,13 @@ class CameraTriggerSettings(BaseModel): Generic hardware-trigger settings. Backend-specific code may ignore fields that are unsupported by a given - camera/SDK. For GenTL, these map to common GenICam nodes such as: - TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, - LineSelector, LineMode, and LineSource. + camera/SDK. + + For GenTL/TIS DMK 37BUX287: + - follower/external maps mainly to TriggerMode, TriggerSelector, + TriggerActivation. TriggerSource may be read-only and is best-effort. + - master output maps primarily to StrobeEnable, StrobePolarity, + StrobeOperation, StrobeDuration, and StrobeDelay. """ role: TriggerRole = "off" @@ -251,10 +257,16 @@ class CameraTriggerSettings(BaseModel): source: str = "auto" activation: TriggerActivation | str = "RisingEdge" - # Output config: master + # Generic/SFNC output config: master fallback for cameras exposing Line* nodes. output_line: str = "Line2" output_source: str = "ExposureActive" + # Strobe output config: master path for TIS/DMK 37U cameras. + strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh" + strobe_operation: TriggerStrobeOperation | str = "Exposure" + strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration + strobe_delay: int | None = None # µs + # Runtime behavior timeout: float | None = None strict: bool = False @@ -296,6 +308,17 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("strobe_duration", "strobe_delay", mode="before") + @classmethod + def _coerce_optional_nonnegative_int(cls, v): + if v in (None, ""): + return None + try: + iv = int(float(v)) + except Exception: + return None + return iv if iv >= 0 else None + @field_validator("source", mode="before") @classmethod def _coerce_source(cls, v): diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d1efa0362..c6f78140b 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -12,6 +12,7 @@ QLabel, QLineEdit, QMessageBox, + QSpinBox, QVBoxLayout, QWidget, ) @@ -59,7 +60,9 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "Follower/external mode arms the camera and waits for electrical pulses on TRIGGER_IN.\n" + "Master mode enables STROBE_OUT pulses. For TIS/DMK 37U cameras this uses Strobe settings; " + "Line output settings are kept as a generic fallback.\n" "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) @@ -90,12 +93,57 @@ def _setup_ui(self) -> None: self.output_line_edit = QLineEdit() self.output_line_edit.setPlaceholderText("Line2") + self.output_line_edit.setToolTip( + "Generic Line* output selector for cameras exposing LineSelector/LineSource. " + "Ignored by TIS/DMK 37U strobe-based output." + ) form.addRow("Output line:", self.output_line_edit) self.output_source_edit = QLineEdit() self.output_source_edit.setPlaceholderText("ExposureActive") + self.output_source_edit.setToolTip( + "Generic LineSource value for cameras exposing LineSource. " + "For TIS/DMK 37U cameras, use Strobe operation instead." + ) form.addRow("Output source:", self.output_source_edit) + self.strobe_polarity_combo = QComboBox() + self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") + self.strobe_polarity_combo.addItem("Active low", "ActiveLow") + self.strobe_polarity_combo.setToolTip( + "Polarity of STROBE_OUT. If the follower does not trigger, also try changing the follower activation edge." + ) + form.addRow("Strobe polarity:", self.strobe_polarity_combo) + + self.strobe_operation_combo = QComboBox() + self.strobe_operation_combo.addItem("Exposure duration", "Exposure") + self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") + self.strobe_operation_combo.setToolTip( + "Exposure: strobe pulse length follows exposure time. " + "FixedDuration: strobe pulse length is set by Strobe duration." + ) + form.addRow("Strobe operation:", self.strobe_operation_combo) + + self.strobe_duration_spin = QSpinBox() + self.strobe_duration_spin.setRange(0, 32767) + self.strobe_duration_spin.setSingleStep(100) + self.strobe_duration_spin.setSuffix(" µs") + self.strobe_duration_spin.setSpecialValueText("Default") + self.strobe_duration_spin.setToolTip( + "Used only when Strobe operation is FixedDuration. 0 means backend/device default." + ) + form.addRow("Strobe duration:", self.strobe_duration_spin) + + self.strobe_delay_spin = QSpinBox() + self.strobe_delay_spin.setRange(0, 32767) + self.strobe_delay_spin.setSingleStep(100) + self.strobe_delay_spin.setSuffix(" µs") + self.strobe_delay_spin.setSpecialValueText("Default") + self.strobe_delay_spin.setToolTip( + "Delay between start of exposure and STROBE_OUT pulse. 0 means no delay/device default." + ) + form.addRow("Strobe delay:", self.strobe_delay_spin) + self.timeout_spin = QDoubleSpinBox() self.timeout_spin.setRange(0.0, 3600.0) self.timeout_spin.setDecimals(3) @@ -118,6 +166,7 @@ def _setup_ui(self) -> None: root.addWidget(buttons) self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: role = str(getattr(trigger, "role", "off") or "off").lower() @@ -134,6 +183,20 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + idx = self.strobe_polarity_combo.findData(strobe_polarity) + self.strobe_polarity_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_operation = str(getattr(trigger, "strobe_operation", "Exposure") or "Exposure") + idx = self.strobe_operation_combo.findData(strobe_operation) + self.strobe_operation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_duration = getattr(trigger, "strobe_duration", None) + self.strobe_duration_spin.setValue(int(strobe_duration) if strobe_duration is not None else 0) + + strobe_delay = getattr(trigger, "strobe_delay", None) + self.strobe_delay_spin.setValue(int(strobe_delay) if strobe_delay is not None else 0) + timeout = getattr(trigger, "timeout", None) self.timeout_spin.setValue(float(timeout) if timeout else 0.0) @@ -143,15 +206,26 @@ def _sync_role_ui(self) -> None: role = str(self.role_combo.currentData() or "off") input_enabled = role in {"external", "follower"} - output_enabled = role == "master" self.selector_edit.setEnabled(input_enabled) self.source_edit.setEnabled(input_enabled) self.activation_combo.setEnabled(input_enabled) + output_enabled = role == "master" + # Generic Line* fallback fields. self.output_line_edit.setEnabled(output_enabled) self.output_source_edit.setEnabled(output_enabled) + # TIS/DMK 37U Strobe* fields. + self.strobe_polarity_combo.setEnabled(output_enabled) + self.strobe_operation_combo.setEnabled(output_enabled) + + fixed_duration = ( + output_enabled and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + ) + self.strobe_duration_spin.setEnabled(fixed_duration) + self.strobe_delay_spin.setEnabled(output_enabled) + # Timeout is mostly useful for external/follower, but harmless for any role. self.timeout_spin.setEnabled(role in {"external", "follower"}) @@ -163,8 +237,12 @@ def _accept(self) -> None: "selector": self.selector_edit.text().strip() or "FrameStart", "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), + # Generic/SFNC Line* fallback output settings. "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", + # Strobe output settings used by TIS/DMK 37U cameras. + "strobe_polarity": str(self.strobe_polarity_combo.currentData() or "ActiveHigh"), + "strobe_operation": str(self.strobe_operation_combo.currentData() or "Exposure"), "strict": bool(self.strict_checkbox.isChecked()), } @@ -173,6 +251,13 @@ def _accept(self) -> None: payload["timeout"] = timeout elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration + + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay try: trigger = CameraTriggerSettings.from_any(payload) From c0b6e58d66ce0a96ef291f24fc682f130ca4fafc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:46:42 -0500 Subject: [PATCH 37/71] Add _node_value helper for safe node reads Introduce a static _node_value(node_map, name, default) helper that performs a best-effort read of GenICam node values. It looks up the node, returns default if missing, then tries node.value and falls back to node.GetValue() while swallowing exceptions. This prevents debug helpers and open() from failing when test/SDK nodes expose different accessors or raise errors. --- dlclivegui/cameras/backends/gentl_backend.py | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 7866438a4..4f1b14560 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1099,6 +1099,32 @@ def _node(node_map, name: str): except Exception: return None + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value. + + Debug helpers must not make open() fail just because a value cannot be read. + Harvesters-style fake/test nodes usually expose `.value`; some SDK-style + nodes may expose `GetValue()`. + """ + node = GenTLCameraBackend._node(node_map, name) + if node is None: + return default + + try: + return node.value + except Exception: + pass + + try: + getter = getattr(node, "GetValue", None) + if getter is not None: + return getter() + except Exception: + pass + + return default + @staticmethod def _node_symbolics(node) -> list[str]: try: From 715f270166f44a1f448fa49c2c6a6a77cf078493 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:48:33 -0500 Subject: [PATCH 38/71] Refactor GenTL trigger source handling and logging Replace manual TriggerSource symbol checks with _resolve_trigger_source and only set TriggerSource when supported. Delay setting TriggerActivation until after source resolution and use non-strict writes for activation. Add safety check: do not arm TriggerMode=On unless both TriggerSelector and TriggerSource succeeded (avoids waiting on a previous/default input). Improve log messages to include selector_ok, source_ok, requested/ resolved source and activation for clearer diagnostics. --- dlclivegui/cameras/backends/gentl_backend.py | 66 +++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 4f1b14560..9d417d70f 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1331,35 +1331,44 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source( + node_map, + source, + strict=strict, + ) source_ok = False - if source and source.lower() not in {"", "auto", "none"}: - source_node = self._node(node_map, "TriggerSource") - source_symbolics = self._node_symbolics(source_node) - - if source_node is not None: - if source in source_symbolics: - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) - if not source_ok: - LOG.warning( - "GenTL TriggerSource=%s is supported but not writable; " - "continuing without changing TriggerSource. Available: %s", - source, - source_symbolics, - ) - else: - LOG.warning( - "Requested GenTL TriggerSource=%s not in available sources %s; " - "continuing without changing TriggerSource.", - source, - source_symbolics, - ) + if source_supported: + source_ok = self._set_enum_node( + node_map, + "TriggerSource", + resolved_source, + strict=strict, + ) - if not selector_ok: + activation_ok = self._set_enum_node( + node_map, + "TriggerActivation", + activation, + strict=False, + ) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): LOG.warning( - "Could not apply GenTL TriggerSelector=%s; disabling trigger.", + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s resolved_source=%s activation=%s", + selector_ok, + source_ok, + role, selector, + source, + resolved_source, + activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1380,15 +1389,16 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s activation=%s " - "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", + "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, selector, + source, + resolved_source, activation, selector_ok, - activation_ok, - source, source_ok, + activation_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: From 2925176810b027fb45aba7ce907b62e11c8860f5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 09:56:28 +0200 Subject: [PATCH 39/71] Update multi_camera_controller.py --- dlclivegui/services/multi_camera_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index f82ae3674..d5315ea39 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -509,7 +509,6 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float display_ids=dict(self._display_ids), ) - if frame_data is not None: with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data) From 10957b045cd6e3870965029550438e37a7b2a997 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 13 Jul 2026 16:08:53 +0200 Subject: [PATCH 40/71] pre-commit --- dlclivegui/services/multi_camera_controller.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index d5315ea39..629a94140 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,8 +18,12 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings, CameraSettings -from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.config import ( + MULTI_CAMERA_WORKER_DO_LOG_TIMING, + SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, + CameraTriggerSettings, +) from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) From c5e044c066c74685e5457d99908d02048dabad31 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:25:39 +0200 Subject: [PATCH 41/71] Add GenTL telemetry and frame-rate debugging Add helpers and telemetry to better read and debug GenTL/GenICam cameras. - New debug helper _debug_frame_rate_nodes to log many common frame-rate/exposure/throughput nodes. - Added robust node accessors: _node_value, _node_float, _node_str to safely read various GenICam node types and try multiple node names. - Enhance frame-rate configuration to log before/after values when enabling rate control and when setting AcquisitionFrameRate/AcquisitionFrameRateAbs; record any accepted frame-rate as _actual_fps. - After starting acquisition, attempt to read telemetry and run FPS debug logging; warn (but continue) if telemetry read fails. - Improve _read_telemetry to prefer resulting frame-rate nodes over requested ones, and to populate actual_fps, actual_exposure, actual_gain and other useful properties (pixel format, throughput, resolution, etc.) into the settings namespace for GUI/debugging. These changes make frame-rate/exposure behavior more observable and more tolerant across different camera implementations. --- dlclivegui/cameras/backends/gentl_backend.py | 168 +++++++++++++++++-- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 9d417d70f..1f13d4dea 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -564,6 +564,15 @@ def open(self) -> None: self._acquirer.start() + try: + self._read_telemetry(node_map) + self._debug_frame_rate_nodes(node_map, context="after starting acquisition") + except Exception: + LOG.warning( + "Failed to read telemetry after starting acquisition; some 'actual' values may be missing.", + exc_info=True, + ) + LOG.debug( "Opened GenTL camera index=%s serial=%s label=%s", selected_index, @@ -1132,6 +1141,48 @@ 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) -> float | None: + """Return the first positive float value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + try: + fvalue = float(value) + except Exception: + continue + + if fvalue > 0: + return fvalue + + return None + + @classmethod + def _node_str(cls, node_map, *names: str) -> str | None: + """Return the first non-empty string value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + if value is None: + continue + + text = str(value).strip() + if text: + return text + + return None + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: node = self._node(node_map, name) if node is None: @@ -1633,21 +1684,48 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) + LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target) + for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: - getattr(node_map, attr).value = True + node = getattr(node_map, attr) + 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) break except Exception: pass - for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): + for attr in ("AcquisitionFrameRate", "AcquisitionFrameRateAbs"): try: - getattr(node_map, attr).value = target + node = getattr(node_map, attr) + before = getattr(node, "value", None) + node.value = target + after = getattr(node, "value", None) + + LOG.info( + "Set GenTL %s: before=%r requested=%.3f after=%r", + attr, + before, + target, + after, + ) + + try: + accepted = float(after) + if accepted > 0: + self._actual_fps = accepted + except Exception: + pass + return + except AttributeError: continue except Exception as e: LOG.warning("Failed to set frame rate via %s: %s", attr, e) + LOG.warning("Could not set frame rate to %s FPS", target) def _read_telemetry(self, node_map) -> None: @@ -1657,20 +1735,84 @@ def _read_telemetry(self, node_map) -> None: except Exception: pass - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + # Prefer true/resulting frame-rate readback nodes. + resulting_fps = self._node_float( + node_map, + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + ) - try: - self._actual_exposure = float(node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + # Fallback to requested/accepted frame-rate nodes only if no resulting node exists. + requested_fps = self._node_float( + node_map, + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + ) + + if resulting_fps is not None: + self._actual_fps = resulting_fps + elif requested_fps is not None: + self._actual_fps = requested_fps + exposure = self._node_float( + node_map, + "ExposureTime", + "ExposureTimeAbs", + "Exposure", + ) + if exposure is not None: + self._actual_exposure = exposure + + gain = self._node_float( + node_map, + "Gain", + "GainRaw", + ) + if gain is not None: + self._actual_gain = gain + + # Persist useful telemetry into properties["gentl"] for GUI/debugging. try: - self._actual_gain = float(node_map.Gain.value) + ns = self._ensure_settings_ns() + + if self._actual_width and self._actual_height: + ns["actual_resolution"] = [int(self._actual_width), int(self._actual_height)] + + if self._actual_fps is not None: + ns["actual_fps"] = float(self._actual_fps) + + if resulting_fps is not None: + ns["actual_resulting_frame_rate"] = float(resulting_fps) + + if requested_fps is not None: + ns["actual_acquisition_frame_rate"] = float(requested_fps) + + if self._actual_exposure is not None: + ns["actual_exposure"] = float(self._actual_exposure) + + if self._actual_gain is not None: + ns["actual_gain"] = float(self._actual_gain) + + exposure_auto = self._node_str(node_map, "ExposureAuto") + if exposure_auto is not None: + ns["actual_exposure_auto"] = exposure_auto + + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + if throughput is not None: + ns["actual_device_link_throughput_limit"] = float(throughput) + + throughput_mode = self._node_str(node_map, "DeviceLinkThroughputLimitMode") + if throughput_mode is not None: + ns["actual_device_link_throughput_limit_mode"] = throughput_mode + + pixel_format = self._node_str(node_map, "PixelFormat") + if pixel_format is not None: + ns["actual_pixel_format"] = pixel_format + except Exception: - self._actual_gain = None + pass # ------------------------------------------------------------------ # Frame conversion / local helpers From a773c9ae734d6216a11c7692fb06dbdfeeb25769 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:21:36 +0200 Subject: [PATCH 42/71] Allow zero node values and reduce log level Change LOG.info to LOG.debug to reduce noise when printing node information. Add an allow_zero parameter to _node_float so callers can accept zero as a valid value (previously only positive values were returned). Update callers for exposure, gain, and DeviceLinkThroughputLimit to pass allow_zero=True so reported zeros are treated as real readings while preserving the original behavior by default. --- dlclivegui/cameras/backends/gentl_backend.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 1f13d4dea..7dc7d4bad 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1155,7 +1155,7 @@ def _node_value(node_map, name: str, default=None): return default @classmethod - def _node_float(cls, node_map, *names: str) -> float | None: + 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.""" for name in names: value = cls._node_value(node_map, name, None) @@ -1164,7 +1164,7 @@ def _node_float(cls, node_map, *names: str) -> float | None: except Exception: continue - if fvalue > 0: + if fvalue > 0 or (allow_zero and fvalue == 0): return fvalue return None @@ -1761,6 +1761,7 @@ def _read_telemetry(self, node_map) -> None: "ExposureTime", "ExposureTimeAbs", "Exposure", + allow_zero=True, ) if exposure is not None: self._actual_exposure = exposure @@ -1769,6 +1770,7 @@ def _read_telemetry(self, node_map) -> None: node_map, "Gain", "GainRaw", + allow_zero=True, ) if gain is not None: self._actual_gain = gain @@ -1799,7 +1801,7 @@ def _read_telemetry(self, node_map) -> None: if exposure_auto is not None: ns["actual_exposure_auto"] = exposure_auto - throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit", allow_zero=True) if throughput is not None: ns["actual_device_link_throughput_limit"] = float(throughput) From a33f84746a13e69f5ad4b8b5524cf4a1da7323a9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:08:22 +0200 Subject: [PATCH 43/71] Split multi-camera frame signals; throttle display Introduce a separate, throttled UI/display path for multi-camera frames. Add GUI_MAX_DISPLAY_FPS config and a new display_ready signal that is emitted at most at that rate, while frame_ready remains full-rate for recording/inference. Implement _should_emit_display_ready, wire the MultiCameraController to emit both signals, and reset throttling state when starting. Update the main window to handle processing and display paths via _on_multi_frame_processing_ready and _on_multi_frame_display_ready, and adjust tests accordingly. Also add GUI/debug timing config keys and a temporary timing-only early path in SingleCameraWorker (marked as FIXME). --- dlclivegui/config.py | 12 +++++- dlclivegui/gui/main_window.py | 15 ++++--- .../services/multi_camera_controller.py | 42 +++++++++++++++++-- tests/gui/test_pose_overlay.py | 6 +-- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 2d176224f..936a74226 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -17,8 +17,16 @@ TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING = True +# Global settings +## GUI +GUI_MAX_DISPLAY_FPS: float = 30.0 + + +## Debug +### Timing logs +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 6fddba6b2..69061e989 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -772,7 +772,8 @@ def _connect_signals(self) -> None: self.bbox_color_combo.currentIndexChanged.connect(self._on_bbox_color_changed) # Multi-camera controller signals (used for both single and multi-camera modes) - self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_display_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) @@ -1374,13 +1375,12 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. - Priority order for performance: + Priority: 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) 2. Recording (queued writes, non-blocking) - 3. Display (lowest priority - tiled and updated on separate timer) """ self._multi_camera_frames = frame_data.frames src_id = frame_data.source_camera_id @@ -1437,7 +1437,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: ts = frame_data.timestamps.get(src_id, time.time()) self._rec_manager.write_frame(src_id, frame, ts) - # PRIORITY 3: Mark display dirty (tiling done in display timer) + def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: + """Throttled UI/display path. + + Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. + """ + self._multi_camera_frames = frame_data.frames self._display_dirty = True def _on_multi_camera_started(self) -> None: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 629a94140..35b99d401 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import copy import logging +import time from dataclasses import dataclass from functools import partial from threading import Event, Lock @@ -19,6 +20,7 @@ # 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, @@ -115,6 +117,10 @@ def run(self) -> None: continue consecutive_errors = 0 + # if True: # TEMP FIXME REMOVE + # self._timing.note_frame() + # self._timing.maybe_log() + # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) @@ -224,7 +230,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData + frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + 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 camera_error = Signal(str, str) # camera_id, error_message @@ -249,6 +256,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # GUI display max FPS (for throttling display updates when many cameras are active) + self._gui_display_max_fps: float = GUI_MAX_DISPLAY_FPS + self._gui_display_last_emit: float = 0.0 # Performance logs self._timing_per_cam: dict[str, WorkerTimingStats] = {} @@ -261,8 +271,6 @@ def get_active_count(self) -> int: return len(self._started_cameras) def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: - if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: - return WorkerTimingStats(camera_id, enabled=False) timing = self._timing_per_cam.get(camera_id) if timing is None: timing = WorkerTimingStats( @@ -274,6 +282,24 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + 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. + """ + if self._gui_display_max_fps <= 0: + return True + + now = time.perf_counter() + min_interval = 1.0 / max(self._gui_display_max_fps, 1e-9) + + if now - self._gui_display_last_emit < min_interval: + return False + + self._gui_display_last_emit = now + return True + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -452,6 +478,11 @@ def stop(self, wait: bool = True) -> None: self.all_stopped.emit() return + self._timing_per_cam.clear() + self._gui_display_last_emit = 0.0 + self._workers.clear() + self._threads.clear() + self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() @@ -517,6 +548,11 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data) + # GUI-only path: throttled display updates + if self._should_emit_display_ready(): + with timing.measure("Multi.emit.display_ready"): + self.display_ready.emit(frame_data) + timing.note_frame() timing.maybe_log() diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 369baf846..3af35308f 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,7 +65,7 @@ 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_ready + # Build minimal frame_data to call _on_multi_frame_processing_ready from dlclivegui.services.multi_camera_controller import MultiFrameData frame_data = MultiFrameData( @@ -76,7 +76,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +84,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_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) From e625cfdfeeb3f20319a450bcfd8264a0c0cdd165 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:13:56 +0200 Subject: [PATCH 44/71] Remove temp debug block --- dlclivegui/services/multi_camera_controller.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 35b99d401..99cb8dcfa 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -117,10 +117,6 @@ def run(self) -> None: continue consecutive_errors = 0 - # if True: # TEMP FIXME REMOVE - # self._timing.note_frame() - # self._timing.maybe_log() - # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) From 2a4b4d561d709bb6d32e7ca03fdfa66a943aa02e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:26:52 +0200 Subject: [PATCH 45/71] Fix missing signal wiring Fix signal hookup and disable main-window timing flag. Replace the second connection of multi_camera_controller.frame_ready to _on_multi_frame_display_ready with multi_camera_controller.display_ready to separate processing-ready and display-ready events. Also comment out MAIN_WINDOW_DO_LOG_TIMING in config.py to disable main-window timing logging. --- dlclivegui/config.py | 2 +- dlclivegui/gui/main_window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 936a74226..dac523da8 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,7 +26,7 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -MAIN_WINDOW_DO_LOG_TIMING: bool = False +# MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 69061e989..ef06a5d92 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -773,7 +773,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.frame_ready.connect(self._on_multi_frame_display_ready) + self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_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) From c58586d82355aaa6f0a48f46f0555331c7e7022b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 10 Aug 2026 13:16:02 +0200 Subject: [PATCH 46/71] Revert debug logging to disabled --- dlclivegui/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index dac523da8..1a90cc095 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -24,8 +24,8 @@ ## Debug ### Timing logs -SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True -MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False # MAIN_WINDOW_DO_LOG_TIMING: bool = False From a460f919777f39027a56f5cf71ca31e8f337057a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 10 Aug 2026 13:18:25 +0200 Subject: [PATCH 47/71] Update main_window.py --- dlclivegui/gui/main_window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index ef06a5d92..b84149680 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -53,6 +53,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( DEFAULT_CONFIG, + GUI_MAX_DISPLAY_FPS, ApplicationSettings, BoundingBoxSettings, CameraSettings, @@ -190,7 +191,7 @@ def __init__(self, config: ApplicationSettings | None = None): # Display timer - decoupled from frame capture for performance self._display_timer = QTimer(self) - self._display_timer.setInterval(33) # ~30 fps display rate + self._display_timer.setInterval(1000 / GUI_MAX_DISPLAY_FPS) # in ms, ~30 fps display rate self._display_timer.timeout.connect(self._update_display_from_pending) self._display_timer.start() From cbcb38aaae0f4ea3bd801a18e08953d46ca17f88 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 13:54:45 +0200 Subject: [PATCH 48/71] Fix DLC model_type regression from detected backend Update `_dlc_settings_from_ui` to rebuild settings via `model_dump` + `model_validate` instead of `model_copy(update=...)`, ensuring Pydantic validation/coercion runs on UI-derived updates. This fixes cases where detected backends (e.g., `Engine.PYTORCH`) were not stored as validated string values. Added a GUI test that mocks backend detection and asserts `model_type` is normalized to the string `"pytorch"`. --- dlclivegui/gui/main_window.py | 9 ++++++--- tests/gui/test_main.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index b84149680..d73252559 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -919,13 +919,15 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor "(.pt, .pth for PyTorch or model directory for TensorFlow)." ) from e - # Preserve all unchanged DLC settings and only update values derived from the UI. - return existing_dlc.model_copy( - update={ + # Preserve all unchanged DLC settings and only update values derived from the UI + updated_dlc = existing_dlc.model_dump() + updated_dlc.update( + { "model_path": model_path, "model_type": model_bknd, } ) + return DLCProcessorSettings.model_validate(updated_dlc) def _recording_settings_from_ui(self) -> RecordingSettings: return RecordingSettings( @@ -1035,6 +1037,7 @@ def _action_browse_model(self) -> None: model_check_path = file_path.parent else: model_check_path = file_path + # Raise if the model backend cannot be determined (invalid file or unsupported extension) DLCLiveProcessor.get_model_backend(str(model_check_path)) except FileNotFoundError as e: QMessageBox.warning(self, "Model selection error", str(e)) diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index b9ed7daf0..1d08dbe06 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -2,6 +2,8 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QImage +from dlclivegui.services.dlc_processor import DLCLiveProcessor, Engine + def pixmap_bytes(label) -> bytes: pm = label.pixmap() @@ -99,3 +101,24 @@ def _on_pose(result): qtbot.mouseClick(w.stop_preview_button, Qt.LeftButton) assert not ctrl.is_running() + + +def test_dlc_settings_from_ui_validates_detected_model_type( + monkeypatch, + window, + tmp_path, +): + model_path = tmp_path / "model.pt" + model_path.touch() + window.model_path_edit.setText(str(model_path)) + + monkeypatch.setattr( + DLCLiveProcessor, + "get_model_backend", + lambda _path: Engine.PYTORCH, + ) + + settings = window._dlc_settings_from_ui() + + assert settings.model_type == "pytorch" + assert isinstance(settings.model_type, str) From 4832525096ebe29fb6e6adbbe36517da82e571e3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:39:50 -0500 Subject: [PATCH 49/71] Add Basler trigger support and timeout handling Implement comprehensive trigger support for the Basler backend: import CameraTriggerSettings, parse trigger config (roles: off, external/follower, software, master), and persist trigger_actual into the namespace. Add trigger configuration helpers (_configure_trigger*, _resolve_trigger_source, _restore_trigger_idle), software trigger execution (trigger_once), and many feature/enum/numeric helper methods with debug logging. Make RetrieveResult use a configurable _retrieve_timeout_ms (derived from trigger.timeout) and limit it for hardware-triggered cameras to allow prompt shutdown; raise a TimeoutError when waiting for hardware triggers. Expose hardware_trigger capability as BEST_EFFORT and add an env var-based pylon emulation toggle for testing. Misc: add debug dumps of trigger-related nodes and best-effort restore of trigger state on close. --- dlclivegui/cameras/backends/basler_backend.py | 453 +++++++++++++++++- 1 file changed, 452 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 8e7b0e19b..30a5ea0de 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,10 +9,21 @@ import numpy as np +from ...config import CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend LOG = logging.getLogger(__name__) + +# NOTE @C-Achard: This could be added in settings eventually +# Forces pypylon to create N emulation virtual cameras, +# mostly for testing. This should not be enabled for release. +ENABLE_PYLON_EMU = True +if ENABLE_PYLON_EMU: + import os + + os.environ["PYLON_CAMEMU"] = "4" + try: # pragma: no cover - optional dependency from pypylon import pylon except Exception: # pragma: no cover - optional dependency @@ -25,6 +36,10 @@ class BaslerCameraBackend(CameraBackend): OPTIONS_KEY: ClassVar[str] = "basler" + # Keep RetrieveResult calls short enough that controller shutdown can stop + # worker threads promptly while waiting for external hardware triggers. + _MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS: ClassVar[int] = 1000 + def __init__(self, settings): super().__init__(settings) @@ -33,6 +48,37 @@ def __init__(self, settings): # Optional fast-start hint for probe workers # (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 + + # ---- Trigger settings ---- + raw_trigger = self.ns.get("trigger", self._props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + + try: + self._trigger = CameraTriggerSettings.from_any(raw_trigger) + except Exception as exc: + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid Basler trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid Basler trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + # pypylon RetrieveResult timeout is milliseconds. + self._retrieve_timeout_ms = max(1, int(float(trigger_timeout) * 1000.0)) + else: + self._retrieve_timeout_ms = 100 + + if self.waits_for_hardware_trigger: + self._retrieve_timeout_ms = min( + self._retrieve_timeout_ms, + self._MAX_HARDWARE_TRIGGER_RETRIEVE_TIMEOUT_MS, + ) # Stable identity (serial-based). Prefer new namespace; fall back to legacy keys read-only. self._device_id: str | None = None @@ -95,6 +141,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.BEST_EFFORT, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, } ) return caps @@ -314,6 +361,26 @@ def _positive_float(value) -> float | None: except Exception: return None + def trigger_once(self) -> None: + if self._camera is None: + raise RuntimeError("Basler camera not opened") + + # pypylon commonly exposes ExecuteSoftwareTrigger on InstantCamera. + method = getattr(self._camera, "ExecuteSoftwareTrigger", None) + if method is not None: + method() + return + + command = self._feature("TriggerSoftware") + if command is not None: + try: + command.Execute() + return + except Exception as exc: + raise RuntimeError(f"Failed to execute Basler software trigger: {exc}") from exc + + raise RuntimeError("Basler software trigger command is not available") + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -374,6 +441,19 @@ def open(self) -> None: except Exception: LOG.debug("Frame rate not writable or not supported", exc_info=True) + # ---------------------------- + # Trigger configuration + # ---------------------------- + self._debug_trigger_nodes(context="before configuration") + self._configure_trigger() + self._debug_trigger_nodes(context="after configuration") + + try: + ns = self._ensure_mutable_ns() + ns["trigger_actual"] = self._trigger_to_dict(self._trigger) + except Exception: + pass + # ---------------------------- # Read back actual values (telemetry for GUI / probe) # ---------------------------- @@ -443,6 +523,7 @@ def open(self) -> None: getattr(self.settings, "exposure", None), getattr(self.settings, "gain", None), ) + # ---------------------------- # Persist stable identity into namespace (migration-safe) # ---------------------------- @@ -464,8 +545,13 @@ def read(self) -> tuple[np.ndarray, float]: if self._converter is None: raise RuntimeError("Basler camera opened in fast-start probe mode; cannot read frames") try: - grab_result = self._camera.RetrieveResult(100, pylon.TimeoutHandling_ThrowException) + grab_result = self._camera.RetrieveResult( + int(getattr(self, "_retrieve_timeout_ms", 100)), + pylon.TimeoutHandling_ThrowException, + ) except Exception as exc: + if self.waits_for_hardware_trigger: + raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc raise RuntimeError("Failed to retrieve image from Basler camera.") from exc if not grab_result.GrabSucceeded(): grab_result.Release() @@ -494,7 +580,13 @@ def close(self) -> None: self._camera.StopGrabbing() except Exception: pass + if self._camera.IsOpen(): + try: + self._restore_trigger_idle() + except Exception: + pass + self._camera.Close() self._camera = None self._converter = None @@ -571,6 +663,365 @@ def _snap_to_node(value: int, node) -> int: return int(v) + @property + 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 _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} + + def _feature(self, name: str): + if self._camera is None: + return None + try: + return getattr(self._camera, name) + except Exception: + return None + + @staticmethod + def _feature_value(feature, default=None): + if feature is None: + return default + try: + return feature.GetValue() + except Exception: + return default + + @staticmethod + def _feature_symbolics(feature) -> list[str]: + if feature is None: + return [] + + for method_name in ("GetSymbolics", "GetEntries"): + try: + method = getattr(feature, method_name, None) + if method is None: + continue + + values = method() + out = [] + + for value in values: + try: + if hasattr(value, "GetSymbolic"): + out.append(str(value.GetSymbolic())) + else: + out.append(str(value)) + except Exception: + continue + + return [v for v in out if v] + except Exception: + continue + + return [] + + def _set_enum_feature(self, name: str, value: str, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + symbolics = self._feature_symbolics(feature) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"Basler feature '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("Basler feature '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _set_numeric_feature(self, name: str, value, *, strict: bool = False) -> bool: + feature = self._feature(name) + + if feature is None: + if strict: + raise RuntimeError(f"Basler feature '{name}' is not available") + LOG.debug("Basler feature '%s' is not available; skipping", name) + return False + + try: + feature.SetValue(value) + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set Basler feature '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set Basler feature '%s' to '%s': %s", name, value, exc) + return False + + def _debug_trigger_nodes(self, *, context: str = "") -> None: + names = ( + "TriggerSelector", + "TriggerMode", + "TriggerSource", + "TriggerActivation", + "TriggerDelay", + "TriggerDelayAbs", + "AcquisitionMode", + "LineSelector", + "LineMode", + "LineSource", + "LineInverter", + ) + + label = f"Basler trigger debug {context}".strip() + + for name in names: + feature = self._feature(name) + if feature is None: + continue + + value = self._feature_value(feature, None) + symbolics = self._feature_symbolics(feature) + + extras = [] + if symbolics: + extras.append(f"symbolics={symbolics}") + + for method_name in ("IsReadable", "IsWritable"): + try: + method = getattr(feature, method_name, None) + if method is not None: + extras.append(f"{method_name}={method()}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _resolve_trigger_source(self, requested: str, *, strict: bool) -> tuple[str, bool]: + requested = str(requested or "auto").strip() + feature = self._feature("TriggerSource") + available = self._feature_symbolics(feature) + + if not available: + if strict: + raise RuntimeError("Basler feature 'TriggerSource' is not available or has no symbolics") + LOG.warning("Basler feature 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line1", "Line2", "Line3", "Line4", "Line0", "Software", "Action1"): + if candidate in available: + LOG.info("Basler TriggerSource auto-selected '%s'. Available: %s", candidate, available) + return candidate, True + + LOG.warning("Could not auto-select a Basler TriggerSource. Available: %s", available) + return requested, False + + if strict: + raise RuntimeError(f"Basler feature 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning("Basler TriggerSource '%s' is not available. Available: %s", requested, available) + return requested, False + + def _configure_trigger(self) -> None: + cfg = getattr(self, "_trigger", CameraTriggerSettings()) + self._trigger = cfg + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(cfg, strict=strict) + return + + if role == "software": + self._configure_trigger_software(cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported Basler trigger role: {role!r}") + + LOG.warning("Unsupported Basler trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(strict=False) + + def _configure_trigger_off(self, *, strict: bool = False) -> None: + # Select FrameStart first when possible so TriggerMode=Off applies to + # the frame-start trigger path. + self._set_enum_feature("TriggerSelector", "FrameStart", strict=False) + self._set_enum_feature("TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() + delay = self._trigger_attr(cfg, "delay", None) + + # Disable trigger while changing trigger-related parameters. + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source(source, strict=strict) + source_ok = False + if source_supported: + source_ok = self._set_enum_feature("TriggerSource", resolved_source, strict=strict) + + activation_ok = self._set_enum_feature("TriggerActivation", activation, strict=False) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok: + LOG.warning("Could not apply Basler TriggerSelector=%s; disabling trigger.", selector) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not source_ok: + LOG.warning( + "Could not apply Basler TriggerSource=%s resolved=%s; disabling trigger.", + source, + resolved_source, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info( + "Basler trigger input configured: role=%s selector=%s source=%s activation=%s " + "selector_ok=%s source_ok=%s activation_ok=%s", + role, + selector, + resolved_source, + activation, + selector_ok, + source_ok, + activation_ok, + ) + + def _configure_trigger_software(self, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + delay = self._trigger_attr(cfg, "delay", None) + + self._set_enum_feature("TriggerMode", "Off", strict=False) + + selector_ok = self._set_enum_feature("TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_feature("TriggerSource", "Software", strict=strict) + + if delay is not None: + delay_value = float(delay) + if not self._set_numeric_feature("TriggerDelay", delay_value, strict=False): + self._set_numeric_feature("TriggerDelayAbs", delay_value, strict=False) + + self._set_enum_feature("AcquisitionMode", "Continuous", strict=False) + + if not selector_ok or not source_ok: + LOG.warning( + "Could not configure Basler software trigger selector_ok=%s source_ok=%s; disabling trigger.", + selector_ok, + source_ok, + ) + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + if not self._set_enum_feature("TriggerMode", "On", strict=strict): + LOG.warning("Could not enable Basler software TriggerMode=On; disabling trigger.") + self._configure_trigger_off(strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info("Basler software trigger configured: selector=%s source=Software", selector) + + def _configure_trigger_master(self, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera should acquire freely. + self._configure_trigger_off(strict=False) + + selected = self._set_enum_feature("LineSelector", output_line, strict=strict) + if not selected: + msg = f"Could not select Basler output line '{output_line}'" + if strict: + raise RuntimeError(msg) + LOG.warning("%s; skipping master output configuration.", msg) + return + + mode_ok = self._set_enum_feature("LineMode", "Output", strict=strict) + source_ok = self._set_enum_feature("LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "Basler trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + msg = ( + "Could not configure Basler trigger master output completely " + f"(LineMode ok={mode_ok}, LineSource ok={source_ok})." + ) + + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + + def _restore_trigger_idle(self) -> None: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + + try: + if role in {"external", "follower", "software"}: + self._set_enum_feature("TriggerMode", "Off", strict=False) + + elif role == "master": + self._set_enum_feature("LineSource", "Off", strict=False) + self._set_enum_feature("LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort Basler trigger restore failed", exc_info=True) + def _configure_resolution(self) -> None: """ Apply width/height only if explicitly requested (GUI or override). From d9d3d018137b0855f484eb06e5c443ed4a54c396 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:05 -0500 Subject: [PATCH 50/71] Remove PYLON_CAMEMU test comment Delete commented-out code that forced pypylon to create emulation virtual cameras (PYLON_CAMEMU), which was only intended for testing and should not be enabled for release. Also remove an extraneous blank line to tidy up the file. --- 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 d73252559..c011adb53 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -9,10 +9,6 @@ import time from pathlib import Path -# NOTE @C-Achard: his could be added in settings eventually -# Forces pypylon to create 2 emulation virtual cameras, -# mostly for testing. This shold not be enabled for release. -# os.environ["PYLON_CAMEMU"] = "2" import cv2 import numpy as np from PySide6.QtCore import QRect, QSettings, Qt, QTimer, QUrl From e1559691844a14216a71cd885cabfb49703d4e00 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:26 -0500 Subject: [PATCH 51/71] Make trigger config dialog backend-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TriggerUiProfile dataclass and trigger_ui_profile_for_backend() to drive dialog presentation per backend. Replace free-text source field with an editable QComboBox providing backend suggestions and defaults. Add profile-driven visibility/enabling for input, master, software and strobe/line output fields, plus helper methods to manage form rows and combo text. Only include strobe-related payload fields when the backend profile exposes them. Misc: expand info/help text, show backend in group title, increase dialog min-width, refine tooltips, and improve model↔UI mapping and payload construction. --- .../camera_config/trigger_config_dialog.py | 345 ++++++++++++++---- 1 file changed, 267 insertions(+), 78 deletions(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index c6f78140b..bda9caebe 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -1,6 +1,8 @@ # dlclivegui/gui/camera_config/trigger_config_dialog.py from __future__ import annotations +from dataclasses import dataclass + from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -31,15 +33,92 @@ def _backend_namespace(cam: CameraSettings) -> dict: return ns +@dataclass(frozen=True) +class TriggerUiProfile: + supports_input: bool = True + supports_master: bool = False + supports_software: bool = False + + show_strobe_fields: bool = False + show_line_output_fields: bool = False + + source_suggestions: tuple[str, ...] = ("auto",) + default_source: str = "auto" + + default_output_line: str = "Line2" + default_output_source: str = "ExposureActive" + + help_text: str = "" + + +def trigger_ui_profile_for_backend(backend: str) -> TriggerUiProfile: + """Return GUI-only trigger presentation profile for a backend. + + This intentionally does not perform backend/runtime validation. + Backends still own actual GenICam/pypylon/Harvesters configuration. + """ + backend = (backend or "").lower() + + if backend == "gentl": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, + show_strobe_fields=True, + show_line_output_fields=True, + source_suggestions=("auto", "Line0", "Line1", "Line2", "Any", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "GenTL trigger support is best-effort and depends on the camera's GenICam nodes. " + "Some cameras expose generic Line* output nodes; TIS/DMK 37U cameras may expose Strobe* nodes." + ), + ) + + if backend == "basler": + return TriggerUiProfile( + supports_input=True, + supports_master=True, + supports_software=False, # enable later when controller supports trigger_once() + show_strobe_fields=False, + show_line_output_fields=True, + source_suggestions=("auto", "Line1", "Line2", "Line3", "Line4", "Software"), + default_source="auto", + default_output_line="Line2", + default_output_source="ExposureActive", + help_text=( + "Basler trigger support uses pylon camera features when available. " + "The available trigger sources and output lines depend on the camera model." + ), + ) + + return TriggerUiProfile( + supports_input=False, + supports_master=False, + supports_software=False, + show_strobe_fields=False, + show_line_output_fields=False, + source_suggestions=("auto",), + help_text="This backend does not expose trigger configuration.", + ) + + class TriggerConfigDialog(QDialog): - """Small dialog for editing per-camera hardware trigger settings.""" + """Dialog for editing per-camera trigger settings. + + The dialog is backend-aware only for presentation. + Actual trigger configuration remains backend-owned. + """ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) self.setWindowTitle("Configure trigger mode") - self.setMinimumWidth(420) + self.setMinimumWidth(460) self._cam = cam.model_copy(deep=True) + self._backend = (self._cam.backend or "").lower() + self._profile = trigger_ui_profile_for_backend(self._backend) ns = _backend_namespace(self._cam) try: @@ -58,71 +137,106 @@ def camera_settings(self) -> CameraSettings: def _setup_ui(self) -> None: root = QVBoxLayout(self) - info = QLabel( - "Configure hardware trigger settings for this camera.\n" - "Follower/external mode arms the camera and waits for electrical pulses on TRIGGER_IN.\n" - "Master mode enables STROBE_OUT pulses. For TIS/DMK 37U cameras this uses Strobe settings; " - "Line output settings are kept as a generic fallback.\n" + info_text = ( + "Configure per-camera trigger settings.\n" + "External/follower mode arms the camera and waits for trigger pulses on a selected input source.\n" + "Master mode configures an output signal if the backend/camera exposes compatible output-line features.\n" + "Some fields are backend- or camera-model-specific and may be ignored unless strict mode is enabled.\n" "In strict mode, unsupported trigger nodes fail camera open." ) - info.setWordWrap(True) - root.addWidget(info) + if self._profile.help_text: + info_text += f"\n\n{self._profile.help_text}" - group = QGroupBox("Hardware Trigger") - form = QFormLayout(group) + self.info_label = QLabel(info_text) + self.info_label.setWordWrap(True) + root.addWidget(self.info_label) + group_title = f"Trigger Settings ({self._backend or 'unknown'})" + group = QGroupBox(group_title) + self.form = QFormLayout(group) + + # ---------------------------- + # Role + # ---------------------------- self.role_combo = QComboBox() self.role_combo.addItem("Off / Free-run", "off") - self.role_combo.addItem("External trigger", "external") - self.role_combo.addItem("Follower", "follower") - self.role_combo.addItem("Master", "master") - form.addRow("Role:", self.role_combo) + if self._profile.supports_input: + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + + if self._profile.supports_master: + self.role_combo.addItem("Master output", "master") + + if self._profile.supports_software: + self.role_combo.addItem("Software trigger", "software") + + self.form.addRow("Role:", self.role_combo) + + # ---------------------------- + # Input trigger fields + # ---------------------------- self.selector_edit = QLineEdit() self.selector_edit.setPlaceholderText("FrameStart") - form.addRow("Trigger selector:", self.selector_edit) - - self.source_edit = QLineEdit() - self.source_edit.setPlaceholderText("auto, Line0, Software, ...") - form.addRow("Trigger source:", self.source_edit) + self.selector_edit.setToolTip("TriggerSelector value. Most area-scan cameras use FrameStart.") + self.form.addRow("Trigger selector:", self.selector_edit) + + self.source_combo = QComboBox() + self.source_combo.setEditable(True) + for value in self._profile.source_suggestions: + self.source_combo.addItem(value, value) + self.source_combo.setToolTip( + "TriggerSource value. Suggestions are backend defaults only; " + "the backend validates the actual camera-supported values when opening." + ) + if self.source_combo.lineEdit() is not None: + self.source_combo.lineEdit().setPlaceholderText("auto, Line1, Software, ...") + self.form.addRow("Trigger source:", self.source_combo) self.activation_combo = QComboBox() for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): self.activation_combo.addItem(value, value) - form.addRow("Activation:", self.activation_combo) + self.activation_combo.setToolTip( + "TriggerActivation value. Some software/internal trigger sources may ignore this." + ) + self.form.addRow("Activation:", self.activation_combo) + # ---------------------------- + # Generic output line fields + # ---------------------------- self.output_line_edit = QLineEdit() - self.output_line_edit.setPlaceholderText("Line2") + self.output_line_edit.setPlaceholderText(self._profile.default_output_line) self.output_line_edit.setToolTip( - "Generic Line* output selector for cameras exposing LineSelector/LineSource. " - "Ignored by TIS/DMK 37U strobe-based output." + "Generic LineSelector value for cameras exposing LineSelector/LineSource. " + "Ignored if the backend/camera does not support generic line output." ) - form.addRow("Output line:", self.output_line_edit) + self.form.addRow("Output line:", self.output_line_edit) self.output_source_edit = QLineEdit() - self.output_source_edit.setPlaceholderText("ExposureActive") + self.output_source_edit.setPlaceholderText(self._profile.default_output_source) self.output_source_edit.setToolTip( - "Generic LineSource value for cameras exposing LineSource. " - "For TIS/DMK 37U cameras, use Strobe operation instead." + "Generic LineSource value for cameras exposing LineSource, e.g. ExposureActive." ) - form.addRow("Output source:", self.output_source_edit) + self.form.addRow("Output source:", self.output_source_edit) + # ---------------------------- + # Strobe fields, mainly useful for specific GenTL/TIS devices + # ---------------------------- self.strobe_polarity_combo = QComboBox() self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") self.strobe_polarity_combo.addItem("Active low", "ActiveLow") self.strobe_polarity_combo.setToolTip( - "Polarity of STROBE_OUT. If the follower does not trigger, also try changing the follower activation edge." + "Strobe output polarity. Only used by backends/cameras exposing compatible Strobe* nodes." ) - form.addRow("Strobe polarity:", self.strobe_polarity_combo) + self.form.addRow("Strobe polarity:", self.strobe_polarity_combo) self.strobe_operation_combo = QComboBox() self.strobe_operation_combo.addItem("Exposure duration", "Exposure") self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") self.strobe_operation_combo.setToolTip( - "Exposure: strobe pulse length follows exposure time. " - "FixedDuration: strobe pulse length is set by Strobe duration." + "Strobe operation. Only used by backends/cameras exposing compatible Strobe* nodes." ) - form.addRow("Strobe operation:", self.strobe_operation_combo) + self.form.addRow("Strobe operation:", self.strobe_operation_combo) self.strobe_duration_spin = QSpinBox() self.strobe_duration_spin.setRange(0, 32767) @@ -130,33 +244,34 @@ def _setup_ui(self) -> None: self.strobe_duration_spin.setSuffix(" µs") self.strobe_duration_spin.setSpecialValueText("Default") self.strobe_duration_spin.setToolTip( - "Used only when Strobe operation is FixedDuration. 0 means backend/device default." + "Used only when strobe operation is FixedDuration. 0 means backend/device default." ) - form.addRow("Strobe duration:", self.strobe_duration_spin) + self.form.addRow("Strobe duration:", self.strobe_duration_spin) self.strobe_delay_spin = QSpinBox() self.strobe_delay_spin.setRange(0, 32767) self.strobe_delay_spin.setSingleStep(100) self.strobe_delay_spin.setSuffix(" µs") self.strobe_delay_spin.setSpecialValueText("Default") - self.strobe_delay_spin.setToolTip( - "Delay between start of exposure and STROBE_OUT pulse. 0 means no delay/device default." - ) - form.addRow("Strobe delay:", self.strobe_delay_spin) + self.strobe_delay_spin.setToolTip("Delay before strobe output. 0 means no explicit delay/device default.") + self.form.addRow("Strobe delay:", self.strobe_delay_spin) + # ---------------------------- + # Common options + # ---------------------------- self.timeout_spin = QDoubleSpinBox() self.timeout_spin.setRange(0.0, 3600.0) self.timeout_spin.setDecimals(3) self.timeout_spin.setSingleStep(0.1) self.timeout_spin.setSpecialValueText("Default") self.timeout_spin.setToolTip( - "Fetch poll timeout in seconds. The backend may cap individual fetches to keep preview shutdown responsive." + "Read/fetch timeout in seconds. The backend may cap individual waits to keep preview shutdown responsive." ) - form.addRow("Read timeout:", self.timeout_spin) + self.form.addRow("Read timeout:", self.timeout_spin) self.strict_checkbox = QCheckBox("Strict mode") - self.strict_checkbox.setToolTip("If enabled, missing/unsupported GenICam trigger nodes fail camera open.") - form.addRow(self.strict_checkbox) + self.strict_checkbox.setToolTip("If enabled, missing/unsupported trigger features fail camera open.") + self.form.addRow(self.strict_checkbox) root.addWidget(group) @@ -168,20 +283,86 @@ def _setup_ui(self) -> None: self.role_combo.currentIndexChanged.connect(self._sync_role_ui) self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) + # Hide backend-irrelevant rows immediately. + self._apply_profile_visibility() + + # ------------------------------------------------------------------ + # UI helpers + # ------------------------------------------------------------------ + + def _set_form_row_visible(self, widget: QWidget, visible: bool) -> None: + """Hide/show a QFormLayout field and its label.""" + widget.setVisible(visible) + try: + label = self.form.labelForField(widget) + if label is not None: + label.setVisible(visible) + except Exception: + pass + + def _set_combo_text(self, combo: QComboBox, text: str) -> None: + text = str(text or "") + idx = combo.findText(text) + if idx >= 0: + combo.setCurrentIndex(idx) + else: + combo.setCurrentText(text) + + def _combo_text(self, combo: QComboBox, fallback: str) -> str: + text = str(combo.currentText() or "").strip() + return text or fallback + + def _apply_profile_visibility(self) -> None: + """Apply static backend-profile visibility. + + Role-specific enablement is handled separately by _sync_role_ui(). + """ + # Input trigger fields are only meaningful for input/software roles. + self._set_form_row_visible(self.selector_edit, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible(self.source_combo, self._profile.supports_input or self._profile.supports_software) + self._set_form_row_visible( + self.activation_combo, + self._profile.supports_input, + ) + + # Output fields depend on backend presentation profile. + self._set_form_row_visible(self.output_line_edit, self._profile.show_line_output_fields) + self._set_form_row_visible(self.output_source_edit, self._profile.show_line_output_fields) + + # Strobe fields should not appear for Basler. + self._set_form_row_visible(self.strobe_polarity_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_operation_combo, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_duration_spin, self._profile.show_strobe_fields) + self._set_form_row_visible(self.strobe_delay_spin, self._profile.show_strobe_fields) + + # ------------------------------------------------------------------ + # Model <-> UI + # ------------------------------------------------------------------ + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: role = str(getattr(trigger, "role", "off") or "off").lower() idx = self.role_combo.findData(role) self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) - self.source_edit.setText(str(getattr(trigger, "source", "auto") or "auto")) + + source = str(getattr(trigger, "source", self._profile.default_source) or self._profile.default_source) + self._set_combo_text(self.source_combo, source) activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") idx = self.activation_combo.findData(activation) self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) - self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) - self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + output_line = str( + getattr(trigger, "output_line", self._profile.default_output_line) or self._profile.default_output_line + ) + self.output_line_edit.setText(output_line) + + output_source = str( + getattr(trigger, "output_source", self._profile.default_output_source) + or self._profile.default_output_source + ) + self.output_source_edit.setText(output_source) strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") idx = self.strobe_polarity_combo.findData(strobe_polarity) @@ -205,29 +386,34 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: def _sync_role_ui(self) -> None: role = str(self.role_combo.currentData() or "off") - input_enabled = role in {"external", "follower"} + input_enabled = role in {"external", "follower", "software"} + hw_input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + # Input fields. self.selector_edit.setEnabled(input_enabled) - self.source_edit.setEnabled(input_enabled) - self.activation_combo.setEnabled(input_enabled) + self.source_combo.setEnabled(input_enabled) + self.activation_combo.setEnabled(hw_input_enabled) - output_enabled = role == "master" - # Generic Line* fallback fields. - self.output_line_edit.setEnabled(output_enabled) - self.output_source_edit.setEnabled(output_enabled) + # Generic Line* output fields. + line_output_active = output_enabled and self._profile.show_line_output_fields + self.output_line_edit.setEnabled(line_output_active) + self.output_source_edit.setEnabled(line_output_active) - # TIS/DMK 37U Strobe* fields. - self.strobe_polarity_combo.setEnabled(output_enabled) - self.strobe_operation_combo.setEnabled(output_enabled) + # Strobe fields. + strobe_active = output_enabled and self._profile.show_strobe_fields + self.strobe_polarity_combo.setEnabled(strobe_active) + self.strobe_operation_combo.setEnabled(strobe_active) fixed_duration = ( - output_enabled and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + strobe_active and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" ) self.strobe_duration_spin.setEnabled(fixed_duration) - self.strobe_delay_spin.setEnabled(output_enabled) + self.strobe_delay_spin.setEnabled(strobe_active) - # Timeout is mostly useful for external/follower, but harmless for any role. - self.timeout_spin.setEnabled(role in {"external", "follower"}) + # Timeout is useful for trigger-waiting modes. Keep it available for + # software too if software support is later enabled. + self.timeout_spin.setEnabled(role in {"external", "follower", "software"}) def _accept(self) -> None: role = str(self.role_combo.currentData() or "off") @@ -235,34 +421,37 @@ def _accept(self) -> None: payload = { "role": role, "selector": self.selector_edit.text().strip() or "FrameStart", - "source": self.source_edit.text().strip() or "auto", + "source": self._combo_text(self.source_combo, self._profile.default_source), "activation": str(self.activation_combo.currentData() or "RisingEdge"), - # Generic/SFNC Line* fallback output settings. - "output_line": self.output_line_edit.text().strip() or "Line2", - "output_source": self.output_source_edit.text().strip() or "ExposureActive", - # Strobe output settings used by TIS/DMK 37U cameras. - "strobe_polarity": str(self.strobe_polarity_combo.currentData() or "ActiveHigh"), - "strobe_operation": str(self.strobe_operation_combo.currentData() or "Exposure"), + "output_line": self.output_line_edit.text().strip() or self._profile.default_output_line, + "output_source": self.output_source_edit.text().strip() or self._profile.default_output_source, "strict": bool(self.strict_checkbox.isChecked()), } timeout = float(self.timeout_spin.value()) - if role in {"external", "follower"} and timeout > 0: + if role in {"external", "follower", "software"} and timeout > 0: payload["timeout"] = timeout elif role == "off": - payload["timeout"] = None # ensure timeout is cleared when disabling trigger - strobe_duration = int(self.strobe_duration_spin.value()) - if role == "master" and strobe_duration > 0: - payload["strobe_duration"] = strobe_duration + payload["timeout"] = None + + # Only include strobe-specific settings for profiles that expose them. + # This avoids cluttering Basler trigger configs with TIS-specific fields. + if self._profile.show_strobe_fields: + payload["strobe_polarity"] = str(self.strobe_polarity_combo.currentData() or "ActiveHigh") + payload["strobe_operation"] = str(self.strobe_operation_combo.currentData() or "Exposure") + + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration - strobe_delay = int(self.strobe_delay_spin.value()) - if role == "master" and strobe_delay > 0: - payload["strobe_delay"] = strobe_delay + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay try: trigger = CameraTriggerSettings.from_any(payload) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {e}") + except Exception as exc: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {exc}") return ns = _backend_namespace(self._cam) From ef06c35d225519927d58fa307ff90c0d9add8634 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:39 -0500 Subject: [PATCH 52/71] Support basler backend in trigger defaults Previously the default trigger configuration was only applied for backend 'gentl' and always stored under the 'gentl' properties key. This change treats the backend name dynamically (accepting both 'gentl' and 'basler'), and stores the default trigger settings under the actual backend key in cam.properties. Also preserves behavior when cam.properties or the backend namespace is not a dict. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 444f27387..d8defb45e 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -820,16 +820,16 @@ def _on_active_camera_selected(self, row: int) -> None: def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: backend = (cam.backend or "").lower() - if backend != "gentl": + if backend not in {"gentl", "basler"}: return if not isinstance(cam.properties, dict): cam.properties = {} - ns = cam.properties.setdefault("gentl", {}) + ns = cam.properties.setdefault(backend, {}) if not isinstance(ns, dict): ns = {} - cam.properties["gentl"] = ns + cam.properties[backend] = ns ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) From 9ccb8d8ecae2222925fc9f86bf154f72978f5426 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:40:53 -0500 Subject: [PATCH 53/71] Throttle hardware-trigger wait logs Reduce log flooding when waiting for hardware triggers by adding throttled logging. Changes in dlclivegui/services/multi_camera_controller.py: - Import time. - Add a new _log_interval_while_waiting_for_trigger_s attribute to SingleCameraWorker. - Replace the direct LOGGER.debug call for expected trigger wait timeouts with a call to _log_trigger_wait_throttled. - Implement _log_trigger_wait_throttled to suppress repeated timeout messages, emit a consolidated debug message, and report how many repeated logs were suppressed. This prevents high-frequency expected poll-timeout logs (common in trigger-waiting modes) from overwhelming the logs. --- .../services/multi_camera_controller.py | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 99cb8dcfa..567c095d1 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -62,6 +62,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._max_consecutive_errors = 5 self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + self._log_interval_while_waiting_for_trigger_s = 2.0 # Performance logs self._timing = WorkerTimingStats( @@ -133,11 +134,7 @@ def run(self) -> None: # "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)): - LOGGER.debug( - "[Worker %s] waiting for hardware trigger: %s", - self._camera_id, - exc, - ) + self._log_trigger_wait_throttled(exc) consecutive_errors = 0 if self._stop_event.wait(self._trigger_timeout_delay): @@ -295,6 +292,35 @@ def _should_emit_display_ready(self) -> bool: self._gui_display_last_emit = now return True + 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 start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" From 2813cc5a535d6853af46f0fc9bfaf830406b1762 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 14:52:00 -0500 Subject: [PATCH 54/71] Enhance FakePylon and add Basler backend tests Extend the test conftest FakePylon to better emulate pypylon: add FakePylonTimeoutException, richer _Feature (symbolics, min/max/inc, read/write checks, call tracking), _EnumEntry, expanded _DeviceInfo, GrabResult.release tracking, and a more complete InstantCamera (timeouts, software trigger, trigger/line features, buffer and grab controls, test knobs). Reset the fake factory and provide default fake devices and a basler_settings_factory fixture. Patch the basler SDK fixture to use FakePylon. Add new test suite tests/cameras/backends/test_basler_backend.py covering lifecycle (open/read/close, fast-start, idempotent close), discovery/rebind, resolution/exposure/gain/fps handling, and comprehensive trigger behavior (follower/master/software/external) to validate backend logic. --- tests/cameras/backends/conftest.py | 228 ++++++++- tests/cameras/backends/test_basler_backend.py | 463 ++++++++++++++++++ 2 files changed, 672 insertions(+), 19 deletions(-) create mode 100644 tests/cameras/backends/test_basler_backend.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index f0a0b6b23..dfec64fe2 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -381,32 +381,115 @@ def _make(buffers): # ----------------------------------------------------------------------------- +class FakePylonTimeoutException(RuntimeError): + pass + + class FakePylon: - """Minimal fake for 'from pypylon import pylon' usage in basler_backend.""" + """Fake for 'from pypylon import pylon' used by BaslerCameraBackend.""" - # Constants used by Basler backend GrabStrategy_LatestImageOnly = 1 TimeoutHandling_ThrowException = 1 - PixelType_BGR8packed = 0x02180014 # arbitrary token + PixelType_BGR8packed = 0x02180014 OutputBitAlignment_MsbAligned = 1 + class _EnumEntry: + def __init__(self, symbolic: str): + self._symbolic = symbolic + + def GetSymbolic(self): + return self._symbolic + class _Feature: - def __init__(self, value=0): + def __init__( + self, + value=0, + *, + symbolics: list[str] | None = None, + minimum=None, + maximum=None, + increment=1, + writable=True, + readable=True, + ): self._value = value + self._symbolics = list(symbolics or []) + self._min = minimum + self._max = maximum + self._inc = increment + self._writable = writable + self._readable = readable + self.set_calls: list[object] = [] def SetValue(self, v): + if not self._writable: + raise RuntimeError("feature is not writable") + if self._symbolics and v not in self._symbolics: + raise RuntimeError(f"unsupported symbolic {v!r}; available={self._symbolics}") self._value = v + self.set_calls.append(v) def GetValue(self): + if not self._readable: + raise RuntimeError("feature is not readable") return self._value + def GetSymbolics(self): + return list(self._symbolics) + + def GetEntries(self): + return [FakePylon._EnumEntry(s) for s in self._symbolics] + + def IsWritable(self): + return bool(self._writable) + + def IsReadable(self): + return bool(self._readable) + + def GetMin(self): + if self._min is None: + raise RuntimeError("no min") + return self._min + + def GetMax(self): + if self._max is None: + raise RuntimeError("no max") + return self._max + + def GetInc(self): + return self._inc + class _DeviceInfo: - def __init__(self, serial: str): + def __init__( + self, + serial: str, + *, + vendor: str = "Basler", + model: str = "FakeBasler", + friendly: str | None = None, + full_name: str | None = None, + ): self._serial = serial + self._vendor = vendor + self._model = model + self._friendly = friendly or f"{vendor} {model} ({serial})" + self._full_name = full_name or f"FakeFullName-{serial}" def GetSerialNumber(self): return self._serial + def GetVendorName(self): + return self._vendor + + def GetModelName(self): + return self._model + + def GetFriendlyName(self): + return self._friendly + + def GetFullName(self): + return self._full_name + class _Device: def __init__(self, info): self.info = info @@ -433,12 +516,13 @@ class _GrabResult: def __init__(self, ok=True, array=None): self._ok = ok self._array = array + self.released = False def GrabSucceeded(self): return bool(self._ok) def Release(self): - return None + self.released = True class InstantCamera: def __init__(self, device): @@ -446,36 +530,106 @@ def __init__(self, device): self._open = False self._grabbing = False - # Feature nodes the backend uses + self.retrieve_calls: list[int] = [] + self.start_calls = 0 + self.stop_calls = 0 + self.close_calls = 0 + self.software_trigger_calls = 0 + self._software_trigger_pending = 0 + + # General camera controls. + self.ExposureAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.ExposureTime = FakePylon._Feature(1000.0) + self.GainAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"]) self.Gain = FakePylon._Feature(0.0) - self.Width = FakePylon._Feature(1920) - self.Height = FakePylon._Feature(1080) + + self.Width = FakePylon._Feature(1920, minimum=64, maximum=4096, increment=2) + self.Height = FakePylon._Feature(1080, minimum=64, maximum=4096, increment=2) self.AcquisitionFrameRateEnable = FakePylon._Feature(False) self.AcquisitionFrameRate = FakePylon._Feature(30.0) + self.MaxNumBuffer = FakePylon._Feature(10) + + # Basler/pypylon trigger features. + self.AcquisitionMode = FakePylon._Feature("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = FakePylon._Feature("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = FakePylon._Feature("Off", symbolics=["Off", "On"]) + self.TriggerSource = FakePylon._Feature( + "Software", + symbolics=[ + "Software", + "Line1", + "Line2", + "Line3", + "PeriodicSignal1", + "Action1", + ], + ) + self.TriggerActivation = FakePylon._Feature( + "RisingEdge", + symbolics=["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"], + ) + self.TriggerDelay = FakePylon._Feature(0.0) + + # Generic output line features. + self.LineSelector = FakePylon._Feature("Line1", symbolics=["Line1", "Line2", "Line3"]) + self.LineMode = FakePylon._Feature("Input", symbolics=["Input", "Output"]) + self.LineSource = FakePylon._Feature( + "Off", + symbolics=["Off", "ExposureActive", "AcquisitionActive"], + ) + self.LineInverter = FakePylon._Feature(False) + + # Test knobs. + self.allow_hardware_trigger_frame = False + self.force_failed_grab = False + def Open(self): self._open = True def Close(self): + self.close_calls += 1 self._open = False def IsOpen(self): return bool(self._open) def StartGrabbing(self, *_args, **_kwargs): + self.start_calls += 1 self._grabbing = True def StopGrabbing(self): + self.stop_calls += 1 self._grabbing = False def IsGrabbing(self): return bool(self._grabbing) - def RetrieveResult(self, *_args, **_kwargs): - # Always succeed with a small dummy image (BGR) - import numpy as np + def ExecuteSoftwareTrigger(self): + self.software_trigger_calls += 1 + self._software_trigger_pending += 1 + + def RetrieveResult(self, timeout_ms, *_args, **_kwargs): + self.retrieve_calls.append(int(timeout_ms)) + + if not self._grabbing: + raise FakePylonTimeoutException("Grab timed out: acquisition not started") + + if self.force_failed_grab: + return FakePylon._GrabResult(ok=False, array=None) + + trigger_on = self.TriggerMode.GetValue() == "On" + source = self.TriggerSource.GetValue() + + if trigger_on: + if source == "Software": + if self._software_trigger_pending <= 0: + raise FakePylonTimeoutException("Grab timed out: waiting for software trigger") + self._software_trigger_pending -= 1 + else: + if not self.allow_hardware_trigger_frame: + raise FakePylonTimeoutException("Grab timed out: waiting for hardware trigger") frame = np.zeros((10, 10, 3), dtype=np.uint8) return FakePylon._GrabResult(ok=True, array=frame) @@ -498,25 +652,61 @@ def Convert(self, grab_result): @pytest.fixture() def fake_pylon_module(): - """ - Returns the FakePylon 'module' and resets singleton devices for isolation. - """ - # reset singleton factory so devices list resets per test + """Returns fake pylon module and resets fake device inventory.""" FakePylon.TlFactory._instance = None + factory = FakePylon.TlFactory.GetInstance() + factory._devices = [ + FakePylon._DeviceInfo("FAKE-BASLER-0"), + FakePylon._DeviceInfo("FAKE-BASLER-1"), + ] return FakePylon @pytest.fixture() def patch_basler_sdk(monkeypatch, fake_pylon_module): - """ - Patch Basler backend to behave as if pypylon is installed, using FakePylon. - """ + """Patch Basler backend to use FakePylon.""" import dlclivegui.cameras.backends.basler_backend as bb monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False) return fake_pylon_module +@pytest.fixture() +def basler_settings_factory(): + from dlclivegui.config import CameraSettings + + def _make( + *, + index=0, + name="BaslerTestCam", + width=0, + height=0, + fps=0.0, + exposure=0, + gain=0.0, + enabled=True, + properties=None, + ): + props = properties if isinstance(properties, dict) else {} + props.setdefault("basler", {}) + props["basler"] = dict(props["basler"]) + + return CameraSettings( + name=name, + index=index, + backend="basler", + width=width, + height=height, + fps=fps, + exposure=exposure, + gain=gain, + enabled=enabled, + properties=props, + ) + + return _make + + # ----------------------------------------------------------------------------- # Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests # ----------------------------------------------------------------------------- diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py new file mode 100644 index 000000000..2edff9092 --- /dev/null +++ b/tests/cameras/backends/test_basler_backend.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------- +# Core lifecycle +# --------------------------------------------------------------------- + + +def test_basler_open_starts_grabbing_and_read_returns_frame(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert be._camera.IsGrabbing() + assert be._converter is not None + + frame, ts = be.read() + assert isinstance(ts, float) + assert isinstance(frame, np.ndarray) + assert frame.shape == (10, 10, 3) + + be.close() + assert be._camera is None + assert be._converter is None + + +def test_basler_fast_start_does_not_start_grabbing_and_read_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(properties={"basler": {"fast_start": True}}) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera is not None + assert be._camera.IsOpen() + assert not be._camera.IsGrabbing() + assert be._converter is None + + with pytest.raises(RuntimeError, match="fast-start"): + be.read() + + be.close() + + +def test_basler_close_is_idempotent(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + be.open() + be.close() + 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 + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + be.stop() + + be.open() + be.stop() + + assert be._camera is not None + assert not be._camera.IsGrabbing() + + be.close() + be.stop() + + +def test_basler_read_before_open_raises_runtimeerror(patch_basler_sdk, basler_settings_factory): + import dlclivegui.cameras.backends.basler_backend as bb + + be = bb.BaslerCameraBackend(basler_settings_factory()) + + with pytest.raises(RuntimeError, match="not opened"): + be.read() + + +# --------------------------------------------------------------------- +# Discovery / identity / rebind +# --------------------------------------------------------------------- + + +def test_basler_discover_devices_returns_serial_identity_and_label( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + + cams = bb.BaslerCameraBackend.discover_devices(max_devices=10) + + assert len(cams) == 2 + assert cams[0].device_id == "FAKE-BASLER-0" + assert "Basler" in cams[0].label + assert "FAKE-BASLER-0" in cams[0].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 + + assert bb.BaslerCameraBackend.quick_ping(0) is True + assert bb.BaslerCameraBackend.quick_ping(1) is True + assert bb.BaslerCameraBackend.quick_ping(2) is False + + +def test_basler_rebind_settings_uses_serial_device_id( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + out = bb.BaslerCameraBackend.rebind_settings(settings) + + assert int(out.index) == 1 + ns = out.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + +def test_basler_open_selects_device_id_and_persists_identity( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + index=0, + properties={"basler": {"device_id": "FAKE-BASLER-1"}}, + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + ns = settings.properties["basler"] + assert ns["device_id"] == "FAKE-BASLER-1" + assert ns["device_name"] + + 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 + + settings = basler_settings_factory(index=99) + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="out of range"): + be.open() + + +# --------------------------------------------------------------------- +# Camera controls +# --------------------------------------------------------------------- + + +def test_basler_resolution_auto_does_not_modify_dimensions( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=0, height=0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 1920 + assert be._camera.Height.GetValue() == 1080 + assert be.actual_resolution == (1920, 1080) + + be.close() + + +def test_basler_resolution_request_snaps_to_increment( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(width=641, height=481) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.Width.GetValue() == 640 + assert be._camera.Height.GetValue() == 480 + assert be.actual_resolution == (640, 480) + + be.close() + + +def test_basler_exposure_gain_fps_are_applied_when_nonzero( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory(exposure=20000, gain=2.5, fps=50.0) + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.ExposureAuto.GetValue() == "Off" + assert be._camera.ExposureTime.GetValue() == pytest.approx(20000.0) + assert be._camera.GainAuto.GetValue() == "Off" + assert be._camera.Gain.GetValue() == pytest.approx(2.5) + assert be._camera.AcquisitionFrameRateEnable.GetValue() is True + assert be._camera.AcquisitionFrameRate.GetValue() == pytest.approx(50.0) + + be.close() + + +# --------------------------------------------------------------------- +# Basler trigger behavior +# --------------------------------------------------------------------- + + +def test_basler_static_capabilities_advertises_hardware_trigger_best_effort( + patch_basler_sdk, +): + import dlclivegui.cameras.backends.basler_backend as bb + from dlclivegui.cameras.base import SupportLevel + + caps = bb.BaslerCameraBackend.static_capabilities() + assert caps["hardware_trigger"] == SupportLevel.BEST_EFFORT + + +def test_basler_default_trigger_is_off_and_free_runs( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory() + be = bb.BaslerCameraBackend(settings) + + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_follower_auto_selects_line1_and_times_out_waiting_for_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "auto", + "activation": "RisingEdge", + "timeout": 5.0, + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + # Timeout is configured in seconds but pypylon RetrieveResult uses ms; + # hardware-trigger waits should be capped for responsive shutdown. + assert be._retrieve_timeout_ms == 1000 + + be.open() + + assert be.waits_for_hardware_trigger is True + assert be._camera.TriggerSelector.GetValue() == "FrameStart" + assert be._camera.TriggerSource.GetValue() == "Line1" + assert be._camera.TriggerActivation.GetValue() == "RisingEdge" + assert be._camera.TriggerMode.GetValue() == "On" + + with pytest.raises(TimeoutError, match="waiting for hardware trigger"): + be.read() + + assert be._camera.retrieve_calls[-1] == 1000 + + be.close() + + +def test_basler_follower_strict_invalid_source_raises( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "selector": "FrameStart", + "source": "NotARealSource", + "activation": "RisingEdge", + "strict": True, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + + with pytest.raises(RuntimeError, match="TriggerSource"): + be.open() + + +def test_basler_follower_non_strict_invalid_source_disables_trigger( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "source": "NotARealSource", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "Off" + assert be.waits_for_hardware_trigger is False + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_master_configures_generic_line_output_and_restores_on_close( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.LineSelector.GetValue() == "Line2" + assert cam.LineMode.GetValue() == "Output" + assert cam.LineSource.GetValue() == "ExposureActive" + assert be.waits_for_hardware_trigger is False + + be.close() + + # Local reference remains valid after backend clears self._camera. + assert cam.LineSource.GetValue() == "Off" + assert cam.LineMode.GetValue() == "Input" + + +def test_basler_software_trigger_requires_trigger_once_before_read( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "software", + "selector": "FrameStart", + "strict": False, + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + assert be._camera.TriggerMode.GetValue() == "On" + assert be._camera.TriggerSource.GetValue() == "Software" + assert be.waits_for_hardware_trigger is False + + # No software trigger has been fired yet. + with pytest.raises(RuntimeError, match="Failed to retrieve image"): + be.read() + + be.trigger_once() + assert be._camera.software_trigger_calls == 1 + + frame, _ = be.read() + assert frame.shape == (10, 10, 3) + + be.close() + + +def test_basler_close_turns_input_trigger_off( + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "external", + "source": "Line1", + "activation": "RisingEdge", + } + } + } + ) + + be = bb.BaslerCameraBackend(settings) + be.open() + + cam = be._camera + assert cam.TriggerMode.GetValue() == "On" + + be.close() + + assert cam.TriggerMode.GetValue() == "Off" From acbf23f6effdd9b5ee59a46d8e2701b1bc4ffa6f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 16:03:16 -0500 Subject: [PATCH 55/71] Throttle trigger-wait logs, mark Basler test xfail Add throttled logging for hardware-trigger wait timeouts in SingleCameraWorker to avoid noisy repeated timeout messages. Introduce _trigger_wait_log_interval, _last_trigger_wait_log and _trigger_wait_suppressed_count and move _log_trigger_wait_throttled into the worker; remove the duplicate implementation from MultiCameraController. Also mark the Basler software-trigger test as xfail because software trigger support is not implemented yet. --- .../services/multi_camera_controller.py | 37 ++++++++++++++++++- tests/cameras/backends/test_basler_backend.py | 1 + 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 567c095d1..e5c44b526 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -62,7 +62,12 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._max_consecutive_errors = 5 self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 - self._log_interval_while_waiting_for_trigger_s = 2.0 + + # Hardware-trigger wait logging can be noisy because timeouts are expected + # while no trigger pulse is arriving. Log only occasionally. + 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( @@ -173,6 +178,36 @@ def run(self) -> None: 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 f"{settings.backend}:{settings.index}" diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 2edff9092..64496b948 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -396,6 +396,7 @@ def test_basler_master_configures_generic_line_output_and_restores_on_close( assert cam.LineMode.GetValue() == "Input" +@pytest.mark.xfail(reason="Software trigger support is not implemented yet.") def test_basler_software_trigger_requires_trigger_once_before_read( patch_basler_sdk, basler_settings_factory, From 11e6b0f70d19986d13c094e53a60729f88273ce9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 16:06:19 -0500 Subject: [PATCH 56/71] Update multi_camera_controller.py --- dlclivegui/services/multi_camera_controller.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index e5c44b526..5cd95e29d 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -63,8 +63,6 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 - # Hardware-trigger wait logging can be noisy because timeouts are expected - # while no trigger pulse is arriving. Log only occasionally. self._trigger_wait_log_interval = 2.0 self._last_trigger_wait_log = 0.0 self._trigger_wait_suppressed_count = 0 From fdf35847f328b652a41e7275f66c02e2e3d95ab2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 23 Jun 2026 13:06:28 -0500 Subject: [PATCH 57/71] Basler backend: add timing and FPS config Integrates optional WorkerTimingStats into the Basler camera backend and refactors FPS handling and frame retrieval. Adds a _configure_frame_rate() helper to centralize AcquisitionFrameRate enabling, setting and readbacks (logs many related nodes and records actual_fps). Initializes a WorkerTimingStats instance (controlled by SINGLE_CAMERA_WORKER_DO_LOG_TIMING) and wraps RetrieveResult/convert/get array/release steps with timing measurements, improved error handling, proper grab_result release on exceptions, and frame counting/logging. Overall improves observability and robustness when setting frame rates and reading frames from Basler cameras. --- dlclivegui/cameras/backends/basler_backend.py | 152 ++++++++++++++---- 1 file changed, 121 insertions(+), 31 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 30a5ea0de..4ac36745d 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -9,7 +9,8 @@ import numpy as np -from ...config import CameraTriggerSettings +from ...config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings +from ...utils.stats import WorkerTimingStats from ..base import CameraBackend, SupportLevel, register_backend LOG = logging.getLogger(__name__) @@ -108,6 +109,16 @@ def __init__(self, settings): self._actual_exposure: float | None = None self._actual_gain: float | None = None + # ---- Timing stats for logging (optional) ---- + msg = self._device_id or f"index:{getattr(settings, 'index', '?')}" + timing_id = f"Basler {msg}" + self._timing = WorkerTimingStats( + timing_id, + logger=LOG, + log_interval=1.0, + enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + ) + @property def actual_resolution(self) -> tuple[int, int] | None: if self._actual_width and self._actual_height: @@ -381,6 +392,66 @@ def trigger_once(self) -> None: raise RuntimeError("Basler software trigger command is not available") + def _configure_frame_rate(self) -> None: + if self._camera is None: + return + + fps = self._positive_float(getattr(self.settings, "fps", 0.0)) + if fps is None: + LOG.info("[Basler] FPS: auto/free-run, not forcing AcquisitionFrameRate") + return + + enable = self._feature("AcquisitionFrameRateEnable") + rate = self._feature("AcquisitionFrameRate") + + try: + if enable is not None: + enable.SetValue(True) + + if rate is None: + LOG.warning("[Basler] AcquisitionFrameRate node not available; cannot set FPS=%s", fps) + return + + 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) + except Exception: + pass + + rate.SetValue(float(fps)) + + except Exception as exc: + LOG.warning("[Basler] Failed to set AcquisitionFrameRate=%s: %s", fps, exc, exc_info=True) + + # Readbacks + readbacks = {} + for name in ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRate", + "ResultingFrameRate", + "ResultingAcquisitionFrameRate", + "AcquisitionResultingFrameRate", + "BslResultingAcquisitionFrameRate", + "ExposureAuto", + "ExposureTime", + "Width", + "Height", + "PixelFormat", + "TestImageSelector", + "ImageFileMode", + ): + feature = self._feature(name) + if feature is not None: + readbacks[name] = self._feature_value(feature, None) + + LOG.info("[Basler] FPS readback requested=%s values=%s", fps, readbacks) + + try: + self._actual_fps = float(readbacks.get("AcquisitionFrameRate")) + except Exception: + self._actual_fps = None + def open(self) -> None: if pylon is None: raise RuntimeError("pypylon is required for the Basler backend but is not installed") @@ -427,19 +498,7 @@ def open(self) -> None: # ---------------------------- # Frame rate (0.0 = Auto → do not set) # ---------------------------- - fps = self._positive_float(getattr(self.settings, "fps", 0.0)) - - if fps is not None: - try: - # Some models require enable flag to be writable - if hasattr(self._camera, "AcquisitionFrameRateEnable"): - try: - self._camera.AcquisitionFrameRateEnable.SetValue(True) - except Exception: - pass - self._camera.AcquisitionFrameRate.SetValue(fps) - except Exception: - LOG.debug("Frame rate not writable or not supported", exc_info=True) + self._configure_frame_rate() # ---------------------------- # Trigger configuration @@ -544,28 +603,59 @@ def read(self) -> tuple[np.ndarray, float]: raise RuntimeError("Basler camera not opened") if self._converter is None: raise RuntimeError("Basler camera opened in fast-start probe mode; cannot read frames") + + grab_result = None + try: - grab_result = self._camera.RetrieveResult( - int(getattr(self, "_retrieve_timeout_ms", 100)), - pylon.TimeoutHandling_ThrowException, - ) + with self._timing.measure("Basler.retrieve"): + grab_result = self._camera.RetrieveResult( + int(getattr(self, "_retrieve_timeout_ms", 100)), + pylon.TimeoutHandling_ThrowException, + ) + + with self._timing.measure("Basler.check_result"): + if not grab_result.GrabSucceeded(): + grab_result.Release() + grab_result = None + self._timing.note_error() + self._timing.maybe_log() + raise RuntimeError("Basler camera did not return an image") + + with self._timing.measure("Basler.convert"): + image = self._converter.Convert(grab_result) + + with self._timing.measure("Basler.get_array"): + frame = image.GetArray() + + with self._timing.measure("Basler.release"): + grab_result.Release() + grab_result = None + + if self._actual_width is None or self._actual_height is None: + h, w = frame.shape[:2] + self._actual_width = int(w) + self._actual_height = int(h) + + self._timing.note_frame() + self._timing.maybe_log() + + return frame, time.time() + except Exception as exc: + if grab_result is not None: + try: + grab_result.Release() + except Exception: + pass + if self.waits_for_hardware_trigger: + self._timing.note_timeout() + self._timing.maybe_log() raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc + + self._timing.note_error() + self._timing.maybe_log() raise RuntimeError("Failed to retrieve image from Basler camera.") from exc - if not grab_result.GrabSucceeded(): - grab_result.Release() - raise RuntimeError("Basler camera did not return an image") - image = self._converter.Convert(grab_result) - frame = image.GetArray() - grab_result.Release() - - if self._actual_width is None or self._actual_height is None: - h, w = frame.shape[:2] - self._actual_width = int(w) - self._actual_height = int(h) - - return frame, time.time() def close(self) -> None: LOG.info( From 0b83a64437e6a6c4d2e446617ef4e86bb671bd77 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 14 Jul 2026 11:26:57 +0200 Subject: [PATCH 58/71] style: apply pre-commit --- dlclivegui/services/multi_camera_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 5cd95e29d..d9a446614 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -325,6 +325,7 @@ def _should_emit_display_ready(self) -> bool: self._gui_display_last_emit = now return True + def _log_trigger_wait_throttled(self, exc: BaseException) -> None: """Log hardware-trigger wait timeouts at a controlled rate. From ea39ecd374b342306224ff8148b1394d0cfcb18d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 10:59:29 +0200 Subject: [PATCH 59/71] Update basler_backend.py --- dlclivegui/cameras/backends/basler_backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 4ac36745d..2611b2fab 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -949,6 +949,7 @@ def _configure_trigger(self) -> None: return if role == "software": + # TODO: @C-Achard 2026-07-16 - software mode not implemented yet self._configure_trigger_software(cfg, strict=strict) return From 48df4ef0cf3eada15ba0715d44db76156fb1fce0 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 11:00:19 +0200 Subject: [PATCH 60/71] Fix multi-camera shutdown synchronization Refactors `MultiCameraController.stop()` to track a dedicated stopping state and finalize shutdown only after all camera threads are actually stopped. It now delays `all_stopped` emission until cleanup is truly complete, keeps references to threads that refuse to terminate, and avoids the previous premature "stopped" state. The main window now explicitly calls `stop(wait=True)` during camera/project shutdown so UI transitions block until camera teardown is done. --- dlclivegui/gui/main_window.py | 2 +- .../services/multi_camera_controller.py | 161 ++++++++++-------- 2 files changed, 89 insertions(+), 74 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c011adb53..915f6c5c5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1631,7 +1631,7 @@ def _stop_preview(self) -> None: # Stop any active recording first self._stop_multi_camera_recording() - self.multi_camera_controller.stop() + self.multi_camera_controller.stop(wait=True) self._stop_inference(show_message=False) self._fps_tracker.clear() self._last_display_time = 0.0 diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index d9a446614..60a025eed 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -276,6 +276,8 @@ def __init__(self): self._timestamps: dict[str, float] = {} self._frame_lock = Lock() self._running = False + self._stopping = False + self._all_stopped_emitted = False self._started_cameras: set = set() self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._camera_display_order: list[str] = [] @@ -454,8 +456,7 @@ def _start_camera(self, settings: CameraSettings) -> None: worker.stopped.connect(thread.quit) thread.start() - def _cleanup_camera(self, camera_id: str) -> None: - # remove stored frame data + def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: with self._frame_lock: self._frames.pop(camera_id, None) self._timestamps.pop(camera_id, None) @@ -463,81 +464,35 @@ def _cleanup_camera(self, camera_id: str) -> None: worker = self._workers.pop(camera_id, None) thread = self._threads.pop(camera_id, None) self._settings.pop(camera_id, None) + self._display_ids.pop(camera_id, None) + self._started_cameras.discard(camera_id) if worker is not None: worker.deleteLater() if thread is not None: thread.deleteLater() - def stop(self, wait: bool = True) -> None: - """Stop all cameras.""" - if not self._running: - return + if finalize: + self._maybe_finalize_stop() - self._running = False - - # Signal all workers to stop - for worker in self._workers.values(): - worker.stop() + def _maybe_finalize_stop(self) -> None: + if not self._stopping: + return - # Wait for threads to finish - if wait: - still_running: list[str] = [] - for cam_id, thread in list(self._threads.items()): - if thread is None: - self._cleanup_camera(cam_id) - continue - if not thread.isRunning(): - self._cleanup_camera(cam_id) - continue + live_threads = [thread for thread in self._threads.values() if thread is not None and thread.isRunning()] + if live_threads: + return - thread.quit() - if thread.wait(QUIT_WAIT_MS): - self._cleanup_camera(cam_id) - continue # Clean exit - - LOGGER.error( - "Camera thread %s did not quit within %dms; forcing terminate()", - cam_id, - QUIT_WAIT_MS, - ) + for camera_id, thread in list(self._threads.items()): + if thread is None or not thread.isRunning(): + self._cleanup_camera(camera_id, finalize=False) - thread.terminate() - if thread.wait(TERMINATE_WAIT_MS): - self._cleanup_camera(cam_id) - continue # Terminated successfully - - LOGGER.critical( - "Camera thread %s refused to terminate after terminate()+wait(%dms). " - "Keeping references to avoid use-after-free/segfaults. " - "Application restart may be required.", - cam_id, - TERMINATE_WAIT_MS, - ) - still_running.append(cam_id) - - if still_running: - self._started_cameras.clear() - self._failed_cameras.clear() - with self._frame_lock: - self._frames.clear() - self._timestamps.clear() - self._expected_cameras = 0 - - LOGGER.critical( - "Force stop failed for camera threads: %s. " - "Sending all_stopped to leave GUI in running state, " - "but background camera threads may still be active and interfere with future camera starts. " - "Application restart may be required.", - still_running, - ) - self.all_stopped.emit() - return + if self._threads: + return self._timing_per_cam.clear() self._gui_display_last_emit = 0.0 self._workers.clear() - self._threads.clear() self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() @@ -547,9 +502,78 @@ def stop(self, wait: bool = True) -> None: with self._frame_lock: self._frames.clear() self._timestamps.clear() + self._expected_cameras = 0 + self._stopping = False + + if not self._all_stopped_emitted: + self._all_stopped_emitted = True + self.all_stopped.emit() + + def stop(self, wait: bool = True) -> None: + """Request shutdown of all cameras. + + If wait is True, block while attempting cooperative and forced shutdown. + If wait is False, retain all worker/thread references and emit all_stopped + later, after every QThread has actually finished. + """ + if not self._running and not self._stopping: + return + + self._running = False + self._stopping = True + self._all_stopped_emitted = False + + for worker in list(self._workers.values()): + worker.stop() + + if not wait: + self._maybe_finalize_stop() + return - self.all_stopped.emit() + still_running: list[str] = [] + + for camera_id, thread in list(self._threads.items()): + if thread is None or not thread.isRunning(): + self._cleanup_camera(camera_id, finalize=False) + continue + + thread.quit() + + if thread.wait(QUIT_WAIT_MS): + self._cleanup_camera(camera_id, finalize=False) + continue + + LOGGER.error( + "Camera thread %s did not quit within %dms; forcing terminate()", + camera_id, + QUIT_WAIT_MS, + ) + + thread.terminate() + + if thread.wait(TERMINATE_WAIT_MS): + self._cleanup_camera(camera_id, finalize=False) + continue + + LOGGER.critical( + "Camera thread %s refused to terminate after terminate()+wait(%dms). " + "Keeping references to avoid destroying a running QThread. " + "Application restart may be required.", + camera_id, + TERMINATE_WAIT_MS, + ) + still_running.append(camera_id) + + if still_running: + LOGGER.critical( + "Camera shutdown incomplete; threads remain active: %s. " + "all_stopped will not be emitted because restarting cameras is unsafe.", + still_running, + ) + return + + self._maybe_finalize_stop() def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" @@ -822,15 +846,6 @@ def _on_camera_stopped(self, camera_id: str) -> None: self.all_stopped.emit() return - # Check if all running cameras have stopped (normal shutdown) - if ( - not self._started_cameras - and self._running - and all(not t.isRunning() for t in self._threads.values() if t is not None) - ): - self._running = False - self.all_stopped.emit() - def _on_camera_error(self, camera_id: str, message: str) -> None: """Handle camera error event.""" LOGGER.error(f"Camera {camera_id} error: {message}") From 0d58b7bf6b525b048d31b515eb924095464ad5bf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 11:15:00 +0200 Subject: [PATCH 61/71] Handle only PyPylon timeout errors Import `genicam` alongside `pylon` and add a helper to detect `genicam.TimeoutException` safely. The hardware-trigger timeout path now converts exceptions to `TimeoutError` only for true SDK timeout exceptions, avoiding incorrect timeout handling for unrelated grab failures. --- dlclivegui/cameras/backends/basler_backend.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index 2611b2fab..e82c35ba6 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -26,9 +26,10 @@ os.environ["PYLON_CAMEMU"] = "4" try: # pragma: no cover - optional dependency - from pypylon import pylon + from pypylon import genicam, pylon except Exception: # pragma: no cover - optional dependency - pylon = None # type: ignore + genicam = None # type: ignore[assignment] + pylon = None # type: ignore[assignment] @register_backend("basler") @@ -363,6 +364,16 @@ def sanitize_for_probe(cls, settings): return dc + @staticmethod + def _is_pylon_timeout_exception(exc: BaseException) -> bool: + """Return whether an exception is PyPylon's SDK timeout exception.""" + timeout_type = getattr(genicam, "TimeoutException", None) + + if not isinstance(timeout_type, type): + return False + + return isinstance(exc, timeout_type) + @staticmethod def _positive_float(value) -> float | None: """Return float(value) if > 0 else None.""" @@ -648,7 +659,7 @@ def read(self) -> tuple[np.ndarray, float]: except Exception: pass - if self.waits_for_hardware_trigger: + if self.waits_for_hardware_trigger and self._is_pylon_timeout_exception(exc): self._timing.note_timeout() self._timing.maybe_log() raise TimeoutError(f"Basler timeout while waiting for hardware trigger: {exc}") from exc From 73e4bdd7e34b286532deed4440f75ccdb9343b6d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 11:15:27 +0200 Subject: [PATCH 62/71] Test Basler timeout mapping for trigger reads Add coverage for hardware-triggered Basler reads to ensure SDK timeout errors are re-raised as `TimeoutError` with the expected message and exception cause. Update the Basler SDK test patching to also inject a fake `genicam.TimeoutException`, so timeout handling paths can be exercised reliably in tests. --- tests/cameras/backends/conftest.py | 4 ++ tests/cameras/backends/test_basler_backend.py | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index dfec64fe2..798b2b80b 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -6,6 +6,7 @@ import os import threading from dataclasses import dataclass +from types import SimpleNamespace from typing import Any import numpy as np @@ -667,7 +668,10 @@ def patch_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 diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 64496b948..196327c2c 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -462,3 +462,44 @@ def test_basler_close_turns_input_trigger_off( be.close() assert cam.TriggerMode.GetValue() == "Off" + + +def test_basler_hardware_trigger_maps_pylon_timeout_to_timeout_error( + monkeypatch, + patch_basler_sdk, + basler_settings_factory, +): + import dlclivegui.cameras.backends.basler_backend as bb + + class FakePylonTimeout(Exception): + pass + + settings = basler_settings_factory( + properties={ + "basler": { + "trigger": { + "role": "follower", + "source": "Line1", + } + } + } + ) + backend = bb.BaslerCameraBackend(settings) + backend.open() + + def raise_timeout(*_args, **_kwargs): + raise FakePylonTimeout("Grab timed out") + + monkeypatch.setattr(backend._camera, "RetrieveResult", raise_timeout) + + try: + with pytest.raises( + TimeoutError, + match="waiting for hardware trigger", + ) as exc_info: + backend.read() + + assert isinstance(exc_info.value.__cause__, FakePylonTimeout) + + finally: + backend.close() From 4d99c74439d9cb881dac00f6be6f405a5c320bee Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 11:34:19 +0200 Subject: [PATCH 63/71] Harden multi-camera shutdown lifecycle Tightens controller start/stop state handling to prevent unsafe restarts while shutdown is in progress, and finalizes shutdown only after all camera threads actually exit. Cleanup now resets runtime state consistently, `all_stopped` emission is guarded against duplicates, and stop requests only fan out to workers when actively running. Camera stop handling was also refined so full initialization failure and last-camera runtime shutdown both transition into the same safe finalization path, with clearer logging around incomplete shutdowns. --- .../services/multi_camera_controller.py | 100 ++++++++---------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 60a025eed..08a087531 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -328,39 +328,9 @@ def _should_emit_display_ready(self) -> bool: self._gui_display_last_emit = now return True - 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 start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" - if self._running: + if self._running or self._stopping: LOGGER.warning("Multi-camera controller already running") return @@ -408,6 +378,8 @@ def start(self, camera_settings: list[CameraSettings]) -> None: seen[key] = camera_id self._running = True + self._stopping = False + self._all_stopped_emitted = False self._frames.clear() self._timestamps.clear() self._started_cameras.clear() @@ -476,11 +448,12 @@ def _cleanup_camera(self, camera_id: str, *, finalize: bool = True) -> None: self._maybe_finalize_stop() 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 - live_threads = [thread for thread in self._threads.values() if thread is not None and thread.isRunning()] - if live_threads: + if any(thread is not None and thread.isRunning() for thread in self._threads.values()): return for camera_id, thread in list(self._threads.items()): @@ -490,10 +463,14 @@ def _maybe_finalize_stop(self) -> None: if self._threads: return + self._running = False + self._recording_frame_emission_enabled = False self._timing_per_cam.clear() self._gui_display_last_emit = 0.0 + self._workers.clear() self._settings.clear() + self._runtime_info.clear() self._started_cameras.clear() self._failed_cameras.clear() self._display_ids.clear() @@ -506,9 +483,11 @@ def _maybe_finalize_stop(self) -> None: self._expected_cameras = 0 self._stopping = False - if not self._all_stopped_emitted: - self._all_stopped_emitted = True - self.all_stopped.emit() + if self._all_stopped_emitted: + return + + self._all_stopped_emitted = True + self.all_stopped.emit() def stop(self, wait: bool = True) -> None: """Request shutdown of all cameras. @@ -520,12 +499,13 @@ def stop(self, wait: bool = True) -> None: if not self._running and not self._stopping: return - self._running = False - self._stopping = True - self._all_stopped_emitted = False + if self._running: + self._running = False + self._stopping = True + self._all_stopped_emitted = False - for worker in list(self._workers.values()): - worker.stop() + for worker in list(self._workers.values()): + worker.stop() if not wait: self._maybe_finalize_stop() @@ -568,7 +548,8 @@ def stop(self, wait: bool = True) -> None: if still_running: LOGGER.critical( "Camera shutdown incomplete; threads remain active: %s. " - "all_stopped will not be emitted because restarting cameras is unsafe.", + "all_stopped will not be emitted until every remaining thread actually finishes. " + "Restarting cameras is unsafe in the meantime.", still_running, ) return @@ -821,30 +802,33 @@ def _on_camera_stopped(self, camera_id: str) -> None: was_started = camera_id in self._started_cameras self._started_cameras.discard(camera_id) self._display_ids.pop(camera_id, None) + self.camera_stopped.emit(camera_id) - LOGGER.info(f"Camera {camera_id} stopped (was_started={was_started})") + LOGGER.info("Camera %s stopped (was_started=%s)", camera_id, was_started) - # Cleanup thread - if camera_id in self._threads: - thread = self._threads.get(camera_id) - if thread is not None and thread.isRunning(): - thread.quit() + thread = self._threads.get(camera_id) + if thread is not None and thread.isRunning(): + thread.quit() - # Remove frame data with self._frame_lock: self._frames.pop(camera_id, None) self._timestamps.pop(camera_id, None) - # Check if all cameras have reported and none started total_reported = len(self._started_cameras) + len(self._failed_cameras) - if total_reported == self._expected_cameras and not self._started_cameras: - # All cameras failed to start - if self._running and self._failed_cameras: - self._running = False - failure_list = list(self._failed_cameras.items()) - self.initialization_failed.emit(failure_list) - self.all_stopped.emit() - return + all_initialization_failed = ( + total_reported == self._expected_cameras and not self._started_cameras and bool(self._failed_cameras) + ) + + if all_initialization_failed and self._running: + self._running = False + self._stopping = True + self.initialization_failed.emit(list(self._failed_cameras.items())) + return + + # If no camera remains after a runtime stop, enter shutdown finalization. + if was_started and not self._started_cameras and self._running: + self._running = False + self._stopping = True def _on_camera_error(self, camera_id: str, message: str) -> None: """Handle camera error event.""" From 72d12adf50079f99e9bc6548f3a83a0b75dfcfe8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 16 Jul 2026 13:09:27 +0200 Subject: [PATCH 64/71] Do not use software as auto mode for source Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/cameras/backends/basler_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index e82c35ba6..f74febf8c 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -931,7 +931,7 @@ def _resolve_trigger_source(self, requested: str, *, strict: bool) -> tuple[str, return requested, True if requested.lower() == "auto": - for candidate in ("Line1", "Line2", "Line3", "Line4", "Line0", "Software", "Action1"): + for candidate in ("Line1", "Line2", "Line3", "Line4", "Line0", "Action1"): if candidate in available: LOG.info("Basler TriggerSource auto-selected '%s'. Available: %s", candidate, available) return candidate, True From 9cf6ef1f14ece7f34f9d0d642991cc136e7fa42f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 13:13:46 +0200 Subject: [PATCH 65/71] Preserve runtime info when clearing workers Stop clearing `_runtime_info` as it is not currently defined --- dlclivegui/services/multi_camera_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 08a087531..85a0053cd 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -470,7 +470,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 490d9b5df967966f446fd88b93ff6e03d5203a0a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 16 Jul 2026 13:20:25 +0200 Subject: [PATCH 66/71] Stabilize Basler timeout mapping test Update the hardware-trigger timeout test to raise `bb.genicam.TimeoutException` directly instead of a fake timeout class, and assert that this real pylon exception is preserved as the `__cause__` of the mapped `TimeoutError`. This keeps the test aligned with actual backend behavior. --- tests/cameras/backends/test_basler_backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/cameras/backends/test_basler_backend.py b/tests/cameras/backends/test_basler_backend.py index 196327c2c..a88db91d6 100644 --- a/tests/cameras/backends/test_basler_backend.py +++ b/tests/cameras/backends/test_basler_backend.py @@ -487,8 +487,10 @@ class FakePylonTimeout(Exception): backend = bb.BaslerCameraBackend(settings) backend.open() + pylon_to = bb.genicam.TimeoutException + def raise_timeout(*_args, **_kwargs): - raise FakePylonTimeout("Grab timed out") + raise pylon_to("Simulated timeout") monkeypatch.setattr(backend._camera, "RetrieveResult", raise_timeout) @@ -499,7 +501,7 @@ def raise_timeout(*_args, **_kwargs): ) as exc_info: backend.read() - assert isinstance(exc_info.value.__cause__, FakePylonTimeout) + assert isinstance(exc_info.value.__cause__, bb.genicam.TimeoutException) finally: backend.close() From acf9a4e46f1b66ec1bb46426090a6451a32e7d43 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:13:32 +0200 Subject: [PATCH 67/71] Stop injecting GenTL trigger save defaults Simplify `CameraSettings.with_save_defaults()` to return only a deep copy, removing the special-case logic that auto-populated `properties.gentl.trigger`. This avoids silently adding GenTL trigger settings during save. --- dlclivegui/config.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 1a90cc095..dedb1d44c 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -225,23 +225,13 @@ def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | No ns["trigger"] = trigger.to_properties() def with_save_defaults(self) -> CameraSettings: - out = self.model_copy(deep=True) - - backend = (out.backend or "").lower() - if backend != "gentl": - return out - - if not isinstance(out.properties, dict): - out.properties = {} - - ns = out.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - out.properties["gentl"] = ns - - ns.setdefault("trigger", CameraTriggerSettings().to_properties()) + """ + Return a serializable deep copy. - return out + Runtime defaults (incl. TriggerSettings) are applied + when settings are read and not inserted into the saved config. + """ + return self.model_copy(deep=True) class CameraTriggerSettings(BaseModel): From d04bca91fa87220e1f68556026195e7d8e2e6ec5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:14:08 +0200 Subject: [PATCH 68/71] Stop auto-initializing camera trigger config Removes the helper that injected default trigger settings for GenTL/Basler cameras and drops its call sites when adding or loading cameras. This avoids silently mutating camera properties with trigger defaults unless they are explicitly configured. --- .../gui/camera_config/camera_config_dialog.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index d8defb45e..f259d3ef0 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -18,7 +18,7 @@ ) from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key -from ...config import CameraSettings, CameraTriggerSettings, MultiCameraSettings +from ...config import CameraSettings, MultiCameraSettings from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap from .trigger_config_dialog import TriggerConfigDialog @@ -818,21 +818,6 @@ def _on_active_camera_selected(self, row: int) -> None: self._load_camera_to_form(cam) self._start_probe_for_camera(cam, apply_to_requested=False) - def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: - backend = (cam.backend or "").lower() - if backend not in {"gentl", "basler"}: - return - - if not isinstance(cam.properties, dict): - cam.properties = {} - - ns = cam.properties.setdefault(backend, {}) - if not isinstance(ns, dict): - ns = {} - cam.properties[backend] = ns - - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) - def _add_selected_camera(self) -> None: if not self._commit_pending_edits(reason="before adding a new camera"): return @@ -883,7 +868,6 @@ def _add_selected_camera(self) -> None: properties={}, ) apply_detected_identity(new_cam, detected, backend) - self._ensure_default_trigger_config(new_cam) self._working_settings.cameras.append(new_cam) new_index = len(self._working_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) @@ -1003,7 +987,6 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0.setValue(cam.crop_y0) self.cam_crop_x1.setValue(cam.crop_x1) self.cam_crop_y1.setValue(cam.crop_y1) - self._ensure_default_trigger_config(cam) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) finally: From 2e79d7b51e314c79db65e23eee1d6714449e259e Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:14:26 +0200 Subject: [PATCH 69/71] Revise trigger config behavior tests Updates `tests/test_config.py` to reflect the new trigger configuration semantics. The tests now verify that missing trigger config defaults to `role="off"` and `source="auto"`, explicit trigger settings round-trip through `ApplicationSettings` for both `gentl` and `basler`, and serialization does not inject implicit trigger blocks into camera properties. --- tests/test_config.py | 46 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 9f82017ed..b7b4ceb15 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,39 +1,71 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings +from dlclivegui.config import ( + ApplicationSettings, + CameraSettings, + CameraTriggerSettings, + MultiCameraSettings, +) @pytest.mark.unit -def test_save_applies_gentl_trigger_defaults_to_top_level_camera(): +def test_missing_trigger_config_defaults_to_off(): cam = CameraSettings( backend="gentl", properties={"gentl": {}}, ) + trigger = cam.get_trigger_settings() + + assert trigger.role == "off" + assert trigger.source == "auto" + + +@pytest.mark.unit +@pytest.mark.parametrize("backend", ["gentl", "basler"]) +def test_explicit_trigger_config_roundtrips_through_application_settings(backend): + cam = CameraSettings( + backend=backend, + properties={}, + ) + cam.set_trigger_settings( + CameraTriggerSettings( + role="follower", + source="Line1", + strict=True, + ) + ) + settings = ApplicationSettings( camera=cam, multi_camera=MultiCameraSettings(cameras=[cam]), ) + restored = ApplicationSettings.from_dict(settings.to_dict()) - data = settings.to_dict() + top_level_trigger = restored.camera.get_trigger_settings() + multi_camera_trigger = restored.multi_camera.cameras[0].get_trigger_settings() - assert "trigger" in data["camera"]["properties"]["gentl"] + for trigger in (top_level_trigger, multi_camera_trigger): + assert trigger.role == "follower" + assert trigger.source == "Line1" + assert trigger.strict is True @pytest.mark.unit -def test_save_applies_gentl_trigger_defaults_to_multi_camera(): +def test_save_does_not_insert_implicit_trigger_config(): cam = CameraSettings( backend="gentl", properties={"gentl": {}}, ) - settings = ApplicationSettings( + camera=cam, multi_camera=MultiCameraSettings(cameras=[cam]), ) data = settings.to_dict() - assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] + assert data["camera"]["properties"]["gentl"] == {} + assert data["multi_camera"]["cameras"][0]["properties"]["gentl"] == {} @pytest.mark.unit From 42bbb1f0c96fad1753dd265773ab20a984b7675f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 11 Aug 2026 10:29:02 +0200 Subject: [PATCH 70/71] Disable CAMEMU debug setting --- dlclivegui/cameras/backends/basler_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py index f74febf8c..41ab4bb8a 100644 --- a/dlclivegui/cameras/backends/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -19,7 +19,7 @@ # NOTE @C-Achard: This could be added in settings eventually # Forces pypylon to create N emulation virtual cameras, # mostly for testing. This should not be enabled for release. -ENABLE_PYLON_EMU = True +ENABLE_PYLON_EMU = False if ENABLE_PYLON_EMU: import os From f909592932cbb38fd5a91e4809cafd4fc2bd90de Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 12 Aug 2026 16:29:07 +0200 Subject: [PATCH 71/71] Fix model_type coercion in Engine parser Adjust `Engine.from_model_type` to normalize non-string `model_type` values by converting either `.value` or the original object with `str(...)` in a single step. This avoids eager fallback conversion inside `getattr` and makes enum-like inputs more reliably coercible. --- dlclivegui/temp/engine.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dlclivegui/temp/engine.py b/dlclivegui/temp/engine.py index a6bb225eb..22138ede9 100644 --- a/dlclivegui/temp/engine.py +++ b/dlclivegui/temp/engine.py @@ -26,6 +26,12 @@ def is_tensorflow_model_dir_path(model_path: str | Path) -> bool: @classmethod def from_model_type(cls, model_type: str) -> "Engine": + if not isinstance(model_type, str): + try: + model_type = str(getattr(model_type, "value", model_type)) + except Exception as e: + raise ValueError(f"Could not convert model_type to string: {model_type}") from e + if model_type.lower() == "pytorch": return cls.PYTORCH elif model_type.lower() in ("tensorflow", "base", "tensorrt", "lite"):