From 4832525096ebe29fb6e6adbbe36517da82e571e3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 12:39:50 -0500 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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 04/23] 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 05/23] 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 06/23] 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 07/23] 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 08/23] 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 09/23] 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 10/23] 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 11/23] 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 12/23] 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 13/23] 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 14/23] 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 15/23] 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 16/23] 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 17/23] 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 18/23] 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 19/23] 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 20/23] 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 21/23] 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 22/23] 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 23/23] 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"):