From f360b7377f6cba0cc9fa8a31f3b10199ec8b4a54 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 11 May 2026 13:25:10 +0200 Subject: [PATCH 01/58] Add shared Harvester pool and path normalization Introduce thread-safe SharedHarvesterEntry and SharedHarvesterPool to share Harvester instances keyed by canonical CTI file sets. The pool provides acquire/release/refcount/refresh semantics with RLock protection, reference counting, initial device enumeration, and safe reset on final release. Add optional import handling for harvesters (raising a clear error when missing). Enhance _normalize_path with a casefold_windows flag to produce a canonical path form for pool keys (used when sorting/canonicalizing CTI file lists) and import threading to support locking. --- .../cameras/backends/utils/gentl_discovery.py | 113 ++++++++++++++++-- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index ebce7344..9cc24dcb 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -6,6 +6,7 @@ import glob import os +import threading from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from enum import Enum, auto @@ -18,6 +19,102 @@ class GenTLDiscoveryPolicy(Enum): RAISE_IF_MULTIPLE = auto() # if > N candidates, raise an error to avoid ambiguity (forces explicit config) +try: # pragma: no cover - optional dependency + from harvesters.core import Harvester # type: ignore +except Exception: # pragma: no cover - optional dependency + Harvester = None # type: ignore + + +class SharedHarvesterEntry: + """ + A shared Harvester instance keyed by a canonical tuple of CTI files. + """ + + def __init__(self, cti_files: list[str]): + if Harvester is None: # pragma: no cover + raise RuntimeError( + "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + ) + + self.lock = threading.RLock() + self.key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files)) + self.refcount = 0 + self.harvester = Harvester() + self.loaded_files: list[str] = [] + + for cti in self.key: + self.harvester.add_file(cti) + self.loaded_files.append(cti) + + # Initial device enumeration. + self.harvester.update() + + +class SharedHarvesterPool: + """ + Process-local pool of shared Harvester instances. + + Keyed by the canonicalized CTI file set. + """ + + _lock = threading.RLock() + _entries: dict[tuple[str, ...], SharedHarvesterEntry] = {} + + @classmethod + def acquire(cls, cti_files: list[str]) -> SharedHarvesterEntry: + key = tuple(sorted(_normalize_path(p, casefold_windows=True) for p in cti_files)) + with cls._lock: + entry = cls._entries.get(key) + if entry is None: + entry = SharedHarvesterEntry(list(key)) + cls._entries[key] = entry + entry.refcount += 1 + return entry + + @classmethod + def release(cls, entry: SharedHarvesterEntry | None) -> None: + if entry is None: + return + + with cls._lock: + current = cls._entries.get(entry.key) + if current is None: + # Already released/reset. + return + + current.refcount -= 1 + if current.refcount > 0: + return + + try: + with current.lock: + try: + current.harvester.reset() + except Exception: + pass + finally: + cls._entries.pop(entry.key, None) + + @classmethod + def refresh(cls, entry: SharedHarvesterEntry | None) -> None: + """ + Optional helper when callers want to re-enumerate the device list + on an already-shared Harvester instance. + """ + if entry is None: + return + with entry.lock: + entry.harvester.update() + + @classmethod + def get_refcount(cls, entry: SharedHarvesterEntry | None) -> int: + if entry is None: + return 0 + with cls._lock: + current = cls._entries.get(entry.key) + return int(current.refcount) if current is not None else 0 + + @dataclass class CTIDiscoveryDiagnostics: explicit_files: list[str] = field(default_factory=list) @@ -81,18 +178,18 @@ def _expand_user_and_env(value: str) -> str: return s -def _normalize_path(p: str) -> str: - """ - Normalize a filesystem path in a cross-platform way: - - expands ~ and environment variables - - resolves to absolute where possible (without requiring existence) - """ +def _normalize_path(p: str, *, casefold_windows: bool = False) -> str: expanded = _expand_user_and_env(p) pp = Path(expanded) try: - return str(pp.resolve(strict=False)) + out = str(pp.resolve(strict=False)) except Exception: - return str(pp.absolute()) + out = str(pp.absolute()) + + if casefold_windows: + out = os.path.normcase(out) + + return out def _iter_cti_files_in_dir(directory: str, recursive: bool = False) -> Iterable[str]: From 971f6293e1df8cf367d1c4205d4dcaac75804438 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 11 May 2026 13:26:24 +0200 Subject: [PATCH 02/58] Use shared Harvester pool for GenTL producers Introduce a process-shared Harvester (SharedHarvesterPool) and per-backend _shared_entry to manage GenTL producer state and locking. CTI preflight now records CTIs that passed validation and acquires a shared Harvester instance; device list refresh and acquirer creation/start/stop/destroy are performed under the shared lock to avoid race conditions/hotplug issues. On acquire failure the shared entry is released and a descriptive RuntimeError is raised. _reset_harvester now releases the shared entry when present. Minor diagnostics updates: persist CTI lists and adjust reporting of loaded CTIs. --- dlclivegui/cameras/backends/gentl_backend.py | 97 +++++++++++++------- 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index eb28aea4..474bd53a 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -133,6 +133,7 @@ def __init__(self, settings): # --- Harvesters resources --- self._harvester = None self._acquirer = None + self._shared_entry = None self._device_label: str | None = None self._cti_files_source_used: str | None = None @@ -435,8 +436,6 @@ def open(self) -> None: self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO ) - self._harvester = Harvester() - loaded: list[str] = [] failed: list[tuple[str, str]] = [] @@ -447,17 +446,12 @@ def open(self) -> None: LOG.warning("Skipping CTI '%s': %s", cti, reason) continue - try: - self._harvester.add_file(cti) - loaded.append(cti) - except Exception as exc: - failed.append((str(cti), str(exc))) - LOG.warning("Failed to load CTI '%s': %s", cti, exc) + loaded.append(str(cti)) # Persist diagnostics for UI / debugging ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # successfully added to harvester - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] # load failures + ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] # Keep single-cti convenience key for backward compatibility / display if loaded: @@ -475,10 +469,32 @@ def open(self) -> None: "set properties.gentl.cti_file to a known working producer." ) - # Update device list after loading producers - self._harvester.update() + # Acquire a process-shared Harvester instance for this CTI set + try: + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester + + with self._shared_entry.lock: + # Refresh device list on open; safer for hotplug cases + self._harvester.update() + infos = list(self._harvester.device_info_list or []) + + # Now that shared loading succeeded, persist the actual loaded list + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc - if not self._harvester.device_info_list: + if not infos: self._reset_harvester() raise RuntimeError( "No GenTL cameras detected via Harvesters after loading producers.\n\n" @@ -487,8 +503,6 @@ def open(self) -> None: "Fix: ensure your camera vendor's GenTL producer is installed and working." ) - infos = list(self._harvester.device_info_list) - # Helper: robustly read device_info fields (dict-like or attribute-like) def _info_get(info, key: str, default=None): try: @@ -604,15 +618,17 @@ def _info_get(info, key: str, default=None): # Create ImageAcquirer via Harvester.create(...) try: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(int(selected_index)) + with self._shared_entry.lock: + if selected_serial: + self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) + else: + self._acquirer = self._harvester.create(int(selected_index)) except TypeError: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(index=int(selected_index)) + with self._shared_entry.lock: + if selected_serial: + self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) + else: + self._acquirer = self._harvester.create(index=int(selected_index)) remote = self._acquirer.remote_device node_map = remote.node_map @@ -681,7 +697,8 @@ def _info_get(info, key: str, default=None): LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") return - self._acquirer.start() + with self._shared_entry.lock: + self._acquirer.start() @staticmethod def _device_id_from_info(info) -> str | None: @@ -1042,7 +1059,11 @@ def read(self) -> tuple[np.ndarray, float]: def stop(self) -> None: if self._acquirer is not None: try: - self._acquirer.stop() + if self._shared_entry is not None: + with self._shared_entry.lock: + self._acquirer.stop() + else: + self._acquirer.stop() except Exception: pass @@ -1056,28 +1077,38 @@ def _reset_select_harvester(harvester) -> None: def _reset_harvester(self) -> None: try: - self._reset_select_harvester(self._harvester) + if self._shared_entry is not None: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + self._shared_entry = None + else: + self._reset_select_harvester(self._harvester) finally: self._harvester = None def close(self) -> None: if self._acquirer is not None: try: - self._acquirer.stop() + if self._shared_entry is not None: + with self._shared_entry.lock: + self._acquirer.stop() + else: + self._acquirer.stop() except Exception: pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: - destroy() + if self._shared_entry is not None: + with self._shared_entry.lock: + destroy() + else: + destroy() finally: self._acquirer = None - if self._harvester is not None: - try: - self._harvester.reset() - finally: - self._harvester = None + if self._harvester is not None or self._shared_entry is not None: + self._reset_harvester() self._device_label = None From 60b76073542ecc773382b58b0f2aeac1c7a60eea Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:31:35 +0200 Subject: [PATCH 03/58] WIP Working multi CTI implementation --- dlclivegui/cameras/backends/gentl_backend.py | 673 +++++++++++------- dlclivegui/main.py | 30 +- .../services/multi_camera_controller.py | 53 +- 3 files changed, 496 insertions(+), 260 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 474bd53a..e409c4e7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -3,7 +3,10 @@ # dlclivegui/cameras/backends/gentl_backend.py from __future__ import annotations +import itertools import logging +import os +import threading import time from pathlib import Path from typing import ClassVar @@ -34,17 +37,19 @@ class GenTLCameraBackend(CameraBackend): """Capture frames from GenTL-compatible devices via Harvesters.""" OPTIONS_KEY: ClassVar[str] = "gentl" - _LEGACY_DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms - r"C:\\Program Files\\The Imaging Source Europe GmbH\\IC4 GenTL Driver for USB3Vision Devices *\\bin\\*.cti", - r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", - r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Camera SDK\\bin\\win64_x64\\*.cti", - r"C:\\Program Files (x86)\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", + _OPEN_LOCK: ClassVar[threading.RLock] = threading.RLock() + _DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms + r"C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver for USB3Vision Devices *\bin\*.cti", + r"C:\Program Files\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", + r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", + r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) # Source marker stored in properties["gentl"]["cti_files_source"] # auto : persisted by auto-discovery (env vars, patterns, etc.). Cache, may be stale, re-discover if missing. # user : explicitly set by user via properties.gentl.cti_file(s). Cache, strict raise if missing. _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + _OPEN_SEQ: ClassVar[itertools.count] = itertools.count(1) def __init__(self, settings): super().__init__(settings) @@ -309,15 +314,20 @@ def _resolve_cti_files_for_settings(self) -> list[str]: ) # ------------------------------------------------------------ - # 3) Discovery path: env vars + patterns/dirs (source = "auto") + # 3) Discovery path: env vars + patterns/dirs + built-in defaults # ------------------------------------------------------------ self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) + if search_paths is not None: + search_patterns = cti_finder.cti_files_as_list(search_paths) + else: + search_patterns = list(self._DEFAULT_CTI_PATTERNS) + candidates, diag = cti_finder.discover_cti_files( - cti_search_paths=cti_finder.cti_files_as_list(search_paths) if search_paths is not None else None, + cti_search_paths=search_patterns, include_env=True, extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, recursive_env_search=False, @@ -332,11 +342,28 @@ def _resolve_cti_files_for_settings(self) -> list[str]: " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" - f"Discovery details:\n{diag.summarize()}" + f"Discovery details:\n{diag.summarize(redact_env=False)}" ) return list(candidates) + def _configure_trigger(self, node_map) -> None: + """ + Disable external trigger by default unless user explicitly configured otherwise. + This prevents fetch() timeouts on cameras left in trigger mode. + """ + try: + trigger_mode = getattr(node_map, "TriggerMode", None) + if trigger_mode is None: + return + + symbolics = getattr(trigger_mode, "symbolics", []) + if "Off" in symbolics: + trigger_mode.value = "Off" + LOG.info("TriggerMode set to Off") + except Exception as e: + LOG.warning("Failed to disable trigger mode: %s", e) + @classmethod def _build_harvester_for_discovery( cls, @@ -354,7 +381,7 @@ def _build_harvester_for_discovery( candidates, diag = cti_finder.discover_cti_files( include_env=True, - cti_search_paths=list(cls._LEGACY_DEFAULT_CTI_PATTERNS), + cti_search_paths=list(cls._DEFAULT_CTI_PATTERNS), must_exist=True, ) @@ -416,142 +443,269 @@ def _build_harvester_for_discovery( return harvester, loaded, diag def open(self) -> None: - if Harvester is None: # pragma: no cover - raise RuntimeError( - "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + with type(self)._OPEN_LOCK: + open_id = next(type(self)._OPEN_SEQ) + thread_name = threading.current_thread().name + target_for_log = self._device_id or self._serial_number or getattr(self.settings, "index", None) + + LOG.debug( + "[GenTL:%s] open() ENTER pid=%s thread=%s settings_id=%s target=%s index=%s props=%s", + open_id, + os.getpid(), + thread_name, + id(self.settings), + target_for_log, + getattr(self.settings, "index", None), + self.settings.properties, ) + if Harvester is None: # pragma: no cover + raise RuntimeError( + "The 'harvesters' package is required for the GenTL backend. " + "Install it via 'pip install harvesters'." + ) - # Ensure properties namespace exists for persistence back to UI - if not isinstance(self.settings.properties, dict): - self.settings.properties = {} - props = self.settings.properties - ns = props.get(self.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} - props[self.OPTIONS_KEY] = ns + # Ensure properties namespace exists for persistence back to UI + if not isinstance(self.settings.properties, dict): + self.settings.properties = {} + props = self.settings.properties + ns = props.get(self.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} + props[self.OPTIONS_KEY] = ns + + # Ensure GenTL defalts are present + ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) + ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) + + # Resolve CTIs (may return many). This no longer raises just because there are multiple. + cti_files = self._resolve_cti_files_for_settings() + ns["cti_files_source"] = ( + self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO + ) - # Resolve CTIs (may return many). This no longer raises just because there are multiple. - cti_files = self._resolve_cti_files_for_settings() - ns["cti_files_source"] = ( - self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO - ) + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] - loaded: list[str] = [] - failed: list[tuple[str, str]] = [] + for cti in cti_files: + ok, reason = self._cti_preflight(cti) + if not ok: + failed.append((str(cti), reason or "preflight failed")) + LOG.warning("Skipping CTI '%s': %s", cti, reason) + continue - for cti in cti_files: - ok, reason = self._cti_preflight(cti) - if not ok: - failed.append((str(cti), reason or "preflight failed")) - LOG.warning("Skipping CTI '%s': %s", cti, reason) - continue + loaded.append(str(cti)) - loaded.append(str(cti)) + # Persist diagnostics for UI / debugging + ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates + ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] - # Persist diagnostics for UI / debugging - ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + # Keep single-cti convenience key for backward compatibility / display + if loaded: + ns["cti_file"] = str(loaded[0]) + elif cti_files: + ns["cti_file"] = str(cti_files[0]) # best effort - # Keep single-cti convenience key for backward compatibility / display - if loaded: - ns["cti_file"] = str(loaded[0]) - elif cti_files: - ns["cti_file"] = str(cti_files[0]) # best effort + if not loaded: + self._reset_harvester() + raise RuntimeError( + "No GenTL producer (.cti) could be loaded.\n\n" + f"Resolved CTIs: {cti_files}\n" + f"Failures: {failed}\n" + "Fix: remove/repair incompatible producers or " + "set properties.gentl.cti_file to a known working producer." + ) - if not loaded: - self._reset_harvester() - raise RuntimeError( - "No GenTL producer (.cti) could be loaded.\n\n" - f"Resolved CTIs: {cti_files}\n" - f"Failures: {failed}\n" - "Fix: remove/repair incompatible producers or " - "set properties.gentl.cti_file to a known working producer." - ) + # Use a per-backend Harvester instance. + # + # Important for multi-camera: + # Sharing one Harvester instance across camera workers can cause one open/update + # to disturb another. The Imaging Source U3V GenTL producer also appears sensitive + # to concurrent initialization, so serialize init/open but keep read() concurrent. + try: + LOG.debug("[GenTL:%s] waiting for _OPEN_LOCK", open_id) + t_lock_wait = time.monotonic() - # Acquire a process-shared Harvester instance for this CTI set - try: - self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) - self._harvester = self._shared_entry.harvester + LOG.debug( + "[GenTL:%s] acquired _OPEN_LOCK after %.3fs", + open_id, + time.monotonic() - t_lock_wait, + ) + LOG.debug("[GenTL:%s] creating Harvester()", open_id) + # ------------------------------------------------------------ + # Shared Harvester per CTI set. + # + # Important: + # - SharedHarvesterEntry.__init__() performs the initial update(). + # - Do NOT call update() again here. Calling update() while another + # camera is already open/streaming can make the TIS U3V producer + # report zero devices. + # ------------------------------------------------------------ + try: + LOG.debug("[GenTL:%s] acquiring shared Harvester for CTIs=%s", open_id, loaded) + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester - with self._shared_entry.lock: - # Refresh device list on open; safer for hotplug cases - self._harvester.update() - infos = list(self._harvester.device_info_list or []) + with self._shared_entry.lock: + infos = list(self._harvester.device_info_list or []) + + LOG.debug( + "[GenTL:%s] shared Harvester acquired harvester_id=%s refcount=%s infos=%d", + open_id, + id(self._harvester), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + len(infos), + ) - # Now that shared loading succeeded, persist the actual loaded list - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) - except Exception as exc: - if self._shared_entry is not None: + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc + + def _debug_info(info, i): + def g(key, default=""): + try: + if hasattr(info, "get"): + v = info.get(key) + if v is not None: + return v + except Exception: + pass + try: + return getattr(info, key, default) + except Exception: + return default + + return { + "index": i, + "serial": str(g("serial_number", "")), + "display": str(g("display_name", "")), + "model": str(g("model", "")), + "vendor": str(g("vendor", "")), + "tl_type": str(g("tl_type", "")), + "access_status": str(g("access_status", "")), + "id": str(g("id_", "")), + } + + LOG.debug( + "[GenTL:%s] enumeration target=%s count=%d devices=%s", + open_id, + self._device_id or ns.get("device_id") or props.get("device_id"), + len(infos), + [_debug_info(info, i) for i, info in enumerate(infos)], + ) + + except Exception as exc: + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc + + if not infos: + LOG.exception( + "[GenTL:%s] open() FAILED target=%s harvester_id=%s acquirer_id=%s", + open_id, + target_for_log, + id(self._harvester) if self._harvester is not None else None, + id(self._acquirer) if self._acquirer is not None else None, + ) + self._reset_harvester() + raise RuntimeError( + "No GenTL cameras detected via Harvesters after loading producers.\n\n" + f"Loaded CTIs: {loaded}\n" + f"Failed CTIs: {failed}\n" + "Fix: ensure your camera vendor's GenTL producer is installed and working." + ) + + # Helper: robustly read device_info fields (dict-like or attribute-like) + def _info_get(info, key: str, default=None): try: - cti_finder.SharedHarvesterPool.release(self._shared_entry) + if hasattr(info, "get"): + v = info.get(key) + if v is not None: + return v except Exception: pass - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc - - if not infos: - self._reset_harvester() - raise RuntimeError( - "No GenTL cameras detected via Harvesters after loading producers.\n\n" - f"Loaded CTIs: {loaded}\n" - f"Failed CTIs: {failed}\n" - "Fix: ensure your camera vendor's GenTL producer is installed and working." - ) - - # Helper: robustly read device_info fields (dict-like or attribute-like) - def _info_get(info, key: str, default=None): - try: - if hasattr(info, "get"): - v = info.get(key) + try: + v = getattr(info, key, None) if v is not None: return v - except Exception: - pass - try: - v = getattr(info, key, None) - if v is not None: - return v - except Exception: - pass - return default + except Exception: + pass + return default - # ------------------------------------------------------------------ - # Device selection (stable device_id > serial > index) - # ------------------------------------------------------------------ - requested_index = int(self.settings.index or 0) - selected_index: int | None = None - selected_serial: str | None = None + # ------------------------------------------------------------------ + # Device selection (stable device_id > serial > index) + # ------------------------------------------------------------------ + requested_index = int(self.settings.index or 0) + selected_index: int | None = None + selected_serial: str | None = None - target_device_id = self._device_id or ns.get("device_id") or props.get("device_id") - if target_device_id: - target_device_id = str(target_device_id).strip() + target_device_id = self._device_id or ns.get("device_id") or props.get("device_id") + if target_device_id: + target_device_id = str(target_device_id).strip() - # Exact match against computed device_id - for idx, info in enumerate(infos): - try: - did = self._device_id_from_info(info) - except Exception: - did = None - if did and did == target_device_id: - selected_index = idx - selected_serial = _info_get(info, "serial_number", None) - selected_serial = str(selected_serial).strip() if selected_serial else None - break + # Exact match against computed device_id + for idx, info in enumerate(infos): + try: + did = self._device_id_from_info(info) + except Exception: + did = None + if did and did == target_device_id: + selected_index = idx + selected_serial = _info_get(info, "serial_number", None) + selected_serial = str(selected_serial).strip() if selected_serial else None + break - # If device_id is "serial:XXXX", match serial directly - if selected_index is None and target_device_id.startswith("serial:"): - serial_target = target_device_id.split("serial:", 1)[1].strip() - if serial_target: + # If device_id is "serial:XXXX", match serial directly + if selected_index is None and target_device_id.startswith("serial:"): + serial_target = target_device_id.split("serial:", 1)[1].strip() + if serial_target: + exact = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if sn == serial_target: + exact.append((idx, sn)) + if exact: + selected_index = exact[0][0] + selected_serial = exact[0][1] + else: + sub = [] + for idx, info in enumerate(infos): + sn = _info_get(info, "serial_number", "") + sn = str(sn).strip() if sn is not None else "" + if serial_target and serial_target in sn: + sub.append((idx, sn)) + if len(sub) == 1: + selected_index = sub[0][0] + selected_serial = sub[0][1] or None + elif len(sub) > 1: + candidates = [sn for _, sn in sub] + raise RuntimeError( + f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {candidates}" + ) + + # Legacy serial selection fallback + if selected_index is None: + serial = self._serial_number + if serial: + serial = str(serial).strip() exact = [] for idx, info in enumerate(infos): sn = _info_get(info, "serial_number", "") sn = str(sn).strip() if sn is not None else "" - if sn == serial_target: + if sn == serial: exact.append((idx, sn)) if exact: selected_index = exact[0][0] @@ -561,144 +715,119 @@ def _info_get(info, key: str, default=None): for idx, info in enumerate(infos): sn = _info_get(info, "serial_number", "") sn = str(sn).strip() if sn is not None else "" - if serial_target and serial_target in sn: + if serial and serial in sn: sub.append((idx, sn)) if len(sub) == 1: selected_index = sub[0][0] selected_serial = sub[0][1] or None elif len(sub) > 1: candidates = [sn for _, sn in sub] + raise RuntimeError(f"Ambiguous GenTL serial match for '{serial}'. Candidates: {candidates}") + else: + available = [str(_info_get(i, "serial_number", "")).strip() for i in infos] raise RuntimeError( - f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {candidates}" + f"Camera with serial '{serial}' not found. Available cameras: {available}" ) - # Legacy serial selection fallback - if selected_index is None: - serial = self._serial_number - if serial: - serial = str(serial).strip() - exact = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if sn == serial: - exact.append((idx, sn)) - if exact: - selected_index = exact[0][0] - selected_serial = exact[0][1] - else: - sub = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if serial and serial in sn: - sub.append((idx, sn)) - if len(sub) == 1: - selected_index = sub[0][0] - selected_serial = sub[0][1] or None - elif len(sub) > 1: - candidates = [sn for _, sn in sub] - raise RuntimeError(f"Ambiguous GenTL serial match for '{serial}'. Candidates: {candidates}") - else: - available = [str(_info_get(i, "serial_number", "")).strip() for i in infos] - raise RuntimeError(f"Camera with serial '{serial}' not found. Available cameras: {available}") - - # Index fallback - if selected_index is None: - device_count = len(infos) - if requested_index < 0 or requested_index >= device_count: - raise RuntimeError(f"Camera index {requested_index} out of range for {device_count} GenTL device(s)") - selected_index = requested_index - sn = _info_get(infos[selected_index], "serial_number", "") - selected_serial = str(sn).strip() if sn else None - - # Update settings.index to actual selected index (UI stability) - self.settings.index = int(selected_index) - selected_info = infos[int(selected_index)] - - # Create ImageAcquirer via Harvester.create(...) - try: - with self._shared_entry.lock: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(int(selected_index)) - except TypeError: - with self._shared_entry.lock: - if selected_serial: - self._acquirer = self._harvester.create({"serial_number": str(selected_serial)}) - else: - self._acquirer = self._harvester.create(index=int(selected_index)) - - remote = self._acquirer.remote_device - node_map = remote.node_map + # Index fallback + if selected_index is None: + device_count = len(infos) + if requested_index < 0 or requested_index >= device_count: + raise RuntimeError( + f"Camera index {requested_index} out of range for {device_count} GenTL device(s)" + ) + selected_index = requested_index + sn = _info_get(infos[selected_index], "serial_number", "") + selected_serial = str(sn).strip() if sn else None - self._device_label = self._resolve_device_label(node_map) + # Update settings.index to actual selected index (UI stability) + self.settings.index = int(selected_index) + selected_info = infos[int(selected_index)] - # Apply configuration - self._configure_pixel_format(node_map) - self._configure_resolution(node_map) - self._configure_exposure(node_map) - self._configure_gain(node_map) - self._configure_frame_rate(node_map) + # Create ImageAcquirer via Harvester.create(...) + with self._shared_entry.lock: + self._acquirer = self._create_image_acquirer(selected_serial, selected_index) - # Read back telemetry - try: - self._actual_width = int(node_map.Width.value) - self._actual_height = int(node_map.Height.value) - except Exception: - pass + remote = self._acquirer.remote_device + node_map = remote.node_map - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + self._device_label = self._resolve_device_label(node_map) - try: - self._actual_exposure = float(node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + # Apply configuration + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) - try: - self._actual_gain = float(node_map.Gain.value) - except Exception: - self._actual_gain = None + # Read back telemetry + try: + self._actual_width = int(node_map.Width.value) + self._actual_height = int(node_map.Height.value) + except Exception: + pass - # Persist identity + metadata - computed_id = None - try: - computed_id = self._device_id_from_info(selected_info) - except Exception: - computed_id = None + try: + self._actual_fps = float(node_map.ResultingFrameRate.value) + except Exception: + self._actual_fps = None - if computed_id: - ns["device_id"] = computed_id - elif selected_serial: - ns["device_id"] = f"serial:{selected_serial}" + if self._actual_exposure is None: + try: + self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) + except Exception: + self._actual_exposure = None - if selected_serial: - ns["serial_number"] = str(selected_serial) - ns["device_serial_number"] = str(selected_serial) + if self._actual_gain is None: + try: + self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) + except Exception: + self._actual_gain = None - if self._device_label: - ns["device_name"] = str(self._device_label) - - ns["device_display_name"] = str(_info_get(selected_info, "display_name", "") or "") - ns["device_info_id"] = str(_info_get(selected_info, "id_", "") or "") - ns["device_vendor"] = str(_info_get(selected_info, "vendor", "") or "") - ns["device_model"] = str(_info_get(selected_info, "model", "") or "") - ns["device_tl_type"] = str(_info_get(selected_info, "tl_type", "") or "") - ns["device_user_defined_name"] = str(_info_get(selected_info, "user_defined_name", "") or "") - ns["device_version"] = str(_info_get(selected_info, "version", "") or "") - ns["device_access_status"] = _info_get(selected_info, "access_status", None) - - # Start acquisition unless fast_start - if getattr(self, "_fast_start", False): - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return + # Persist identity + metadata + computed_id = None + try: + computed_id = self._device_id_from_info(selected_info) + except Exception: + computed_id = None + + if computed_id: + ns["device_id"] = computed_id + elif selected_serial: + ns["device_id"] = f"serial:{selected_serial}" + + if selected_serial: + ns["serial_number"] = str(selected_serial) + ns["device_serial_number"] = str(selected_serial) + + if self._device_label: + ns["device_name"] = str(self._device_label) + + ns["device_display_name"] = str(_info_get(selected_info, "display_name", "") or "") + ns["device_info_id"] = str(_info_get(selected_info, "id_", "") or "") + ns["device_vendor"] = str(_info_get(selected_info, "vendor", "") or "") + ns["device_model"] = str(_info_get(selected_info, "model", "") or "") + ns["device_tl_type"] = str(_info_get(selected_info, "tl_type", "") or "") + ns["device_user_defined_name"] = str(_info_get(selected_info, "user_defined_name", "") or "") + ns["device_version"] = str(_info_get(selected_info, "version", "") or "") + ns["device_access_status"] = _info_get(selected_info, "access_status", None) + + # Start acquisition unless fast_start + if getattr(self, "_fast_start", False): + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return - with self._shared_entry.lock: - self._acquirer.start() + with self._shared_entry.lock: + self._acquirer.start() + + LOG.debug( + "[GenTL:%s] open() SUCCESS harvester_id=%s acquirer_id=%s device_label=%s", + open_id, + id(self._harvester), + id(self._acquirer), + self._device_label, + ) @staticmethod def _device_id_from_info(info) -> str | None: @@ -883,6 +1012,22 @@ def rebind_settings(cls, settings): target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") if not target_id: return settings + # For serial-based GenTL devices, open() can select by serial directly. + # Avoid doing Harvester enumeration during CameraFactory.create(), because + # multi-camera startup calls create() concurrently from multiple threads. + target_id_str = str(target_id).strip() + if target_id_str.startswith("serial:"): + serial = target_id_str.split("serial:", 1)[1].strip() + if serial: + if not isinstance(settings.properties, dict): + settings.properties = {} + ns2 = settings.properties.setdefault(cls.OPTIONS_KEY, {}) + if not isinstance(ns2, dict): + ns2 = {} + settings.properties[cls.OPTIONS_KEY] = ns2 + ns2["device_id"] = target_id_str + ns2["serial_number"] = serial + return settings source = ns.get("cti_files_source") source = str(source).strip().lower() if source is not None else None @@ -1012,6 +1157,33 @@ def quick_ping(cls, index: int, _unused=None) -> bool: except Exception: pass + def _call_with_optional_lock(self, func, *args, **kwargs): + """ + Call func under the shared Harvester lock if a shared entry exists. + In per-instance Harvester mode, call directly. + """ + if self._shared_entry is not None: + with self._shared_entry.lock: + return func(*args, **kwargs) + return func(*args, **kwargs) + + def _create_image_acquirer(self, selected_serial: str | None, selected_index: int): + """ + Create a Harvester ImageAcquirer using serial when available. + Supports both create(arg) and create(index=...) API variants. + """ + if self._harvester is None: + raise RuntimeError("Harvester is not initialized") + + try: + if selected_serial: + return self._harvester.create({"serial_number": str(selected_serial)}) + return self._harvester.create(int(selected_index)) + except TypeError: + if selected_serial: + return self._harvester.create({"serial_number": str(selected_serial)}) + return self._harvester.create(index=int(selected_index)) + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -1044,13 +1216,13 @@ def read(self) -> tuple[np.ndarray, float]: if self._actual_exposure is None: try: - self._actual_exposure = float(self._acquirer.node_map.ExposureTime.value) + self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) except Exception: self._actual_exposure = None if self._actual_gain is None: try: - self._actual_gain = float(self._acquirer.node_map.Gain.value) + self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) except Exception: self._actual_gain = None @@ -1059,11 +1231,7 @@ def read(self) -> tuple[np.ndarray, float]: def stop(self) -> None: if self._acquirer is not None: try: - if self._shared_entry is not None: - with self._shared_entry.lock: - self._acquirer.stop() - else: - self._acquirer.stop() + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass @@ -1078,6 +1246,11 @@ def _reset_select_harvester(harvester) -> None: def _reset_harvester(self) -> None: try: if self._shared_entry is not None: + LOG.debug( + "GenTL releasing shared Harvester harvester_id=%s refcount_before=%s", + id(self._shared_entry.harvester), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + ) cti_finder.SharedHarvesterPool.release(self._shared_entry) self._shared_entry = None else: @@ -1088,22 +1261,14 @@ def _reset_harvester(self) -> None: def close(self) -> None: if self._acquirer is not None: try: - if self._shared_entry is not None: - with self._shared_entry.lock: - self._acquirer.stop() - else: - self._acquirer.stop() + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: - if self._shared_entry is not None: - with self._shared_entry.lock: - destroy() - else: - destroy() + self._call_with_optional_lock(destroy) finally: self._acquirer = None diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eb444aa0..eace8bda 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -3,6 +3,7 @@ import argparse import logging +import os import signal import sys @@ -54,6 +55,32 @@ def _sigint_handler(_signum, _frame) -> None: app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit +def configure_logging(debug: bool = False) -> None: + """Configure local application logging.""" + env_debug = os.environ.get("DLCLIVEGUI_DEBUG_LOGGING", "").strip().lower() in ( + "1", + "true", + "yes", + "on", + "debug", + ) + + enabled = bool(debug or env_debug) + level = logging.DEBUG if enabled else logging.INFO + + logging.basicConfig( + level=level, + format="%(asctime)s.%(msecs)03d %(levelname)-8s [%(threadName)s] %(name)s:%(lineno)d - %(message)s", + datefmt="%H:%M:%S", + force=True, + ) + + logging.getLogger("dlclivegui").setLevel(level) + + if enabled: + logging.debug("Debug logging enabled.") + + def parse_args(argv=None): if argv is None: argv = sys.argv[1:] @@ -77,12 +104,13 @@ def parse_args(argv=None): formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.") + parser.add_argument("--debug-log", action="store_true", help="Enable debug logging.") return parser.parse_known_args(argv) def main() -> None: args, _unknown = parse_args() - + configure_logging(debug=args.debug_log) logging.info("Starting DeepLabCut-Live GUI...") # If you want a startup banner, PRINT it (not log), and only in TTY contexts. diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 8db469f0..7d5b5669 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import time from dataclasses import dataclass @@ -14,6 +15,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings @@ -42,7 +44,7 @@ class SingleCameraWorker(QObject): def __init__(self, camera_id: str, settings: CameraSettings): super().__init__() self._camera_id = camera_id - self._settings = settings + self._settings = copy.deepcopy(settings) self._stop_event = Event() self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 @@ -53,7 +55,24 @@ def run(self) -> None: self._stop_event.clear() try: + LOGGER.debug( + "[Worker %s] before create: backend=%s index=%s properties=%s", + self._camera_id, + self._settings.backend, + self._settings.index, + self._settings.properties, + ) + self._backend = CameraFactory.create(self._settings) + + LOGGER.debug( + "[Worker %s] after create: backend=%s index=%s properties=%s", + self._camera_id, + self._backend.settings.backend, + self._backend.settings.index, + self._backend.settings.properties, + ) + self._backend.open() except Exception as exc: LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) @@ -103,8 +122,20 @@ def stop(self) -> None: def get_camera_id(settings: CameraSettings) -> str: - """Generate a unique camera ID from settings.""" - return f"{settings.backend}:{settings.index}" + """Generate a unique camera ID from stable backend identity.""" + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + device_id = ns.get("device_id") + if device_id: + return f"{backend}:{device_id}" + + serial = ns.get("serial_number") or ns.get("device_serial_number") or ns.get("serial") + if serial: + return f"{backend}:serial:{serial}" + + return f"{backend}:index:{int(settings.index)}" class MultiCameraController(QObject): @@ -153,6 +184,15 @@ def start(self, camera_settings: list[CameraSettings]) -> None: LOGGER.warning("No active cameras to start") return + # Check for dupes + seen = set() + for s in active_settings: + key = camera_identity_key(s) + if key in seen: + self.initialization_failed.emit([(key, "Duplicate camera configuration")]) + return + seen.add(key) + self._running = True self._frames.clear() self._timestamps.clear() @@ -165,13 +205,16 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" - cam_id = get_camera_id(settings) + settings_copy = copy.deepcopy(settings) + cam_id = get_camera_id(settings_copy) if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return + LOGGER.info(f"[MultiCameraController] Starting {cam_id} with settings: {settings_copy}") + # Normalize and store the dataclass once - self._settings[cam_id] = settings + self._settings[cam_id] = settings_copy dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) thread = QThread() From 40324021b4adfab3a304b1f8712c064de380caef Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:37:50 +0200 Subject: [PATCH 04/58] Clean up file --- dlclivegui/cameras/backends/gentl_backend.py | 1917 +++++++----------- 1 file changed, 720 insertions(+), 1197 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index e409c4e7..8a06b2d6 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1,11 +1,9 @@ """GenTL backend implemented using the Harvesters library.""" -# dlclivegui/cameras/backends/gentl_backend.py +# dlclivegui/cameras/backends/gentl_backend.py from __future__ import annotations -import itertools import logging -import os import threading import time from pathlib import Path @@ -34,115 +32,82 @@ @register_backend("gentl") class GenTLCameraBackend(CameraBackend): - """Capture frames from GenTL-compatible devices via Harvesters.""" + """Capture frames from GenTL-compatible devices via Harvesters. + + Notes + ----- + Multi-camera operation uses a shared Harvester per CTI set. Some GenTL + producers, including the Imaging Source USB3 Vision producer, can report no + devices if a second independent Harvester enumerates while another camera is + already open/streaming. Therefore open() acquires a shared Harvester and + never calls Harvester.update() during runtime open; initial enumeration is + handled by SharedHarvesterPool when the shared Harvester is created. + """ OPTIONS_KEY: ClassVar[str] = "gentl" _OPEN_LOCK: ClassVar[threading.RLock] = threading.RLock() - _DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( # Windows-only, ignored on other platforms + + _DEFAULT_CTI_PATTERNS: ClassVar[tuple[str, ...]] = ( + # Windows-only defaults; harmless/no-op on other platforms. r"C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver for USB3Vision Devices *\bin\*.cti", r"C:\Program Files\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) - # Source marker stored in properties["gentl"]["cti_files_source"] - # auto : persisted by auto-discovery (env vars, patterns, etc.). Cache, may be stale, re-discover if missing. - # user : explicitly set by user via properties.gentl.cti_file(s). Cache, strict raise if missing. + + # Source marker stored in properties["gentl"]["cti_files_source"]. + # auto: persisted by auto-discovery; may be stale and can fall back. + # user: explicitly set by user; strict if stale/missing. _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" - _OPEN_SEQ: ClassVar[itertools.count] = itertools.count(1) def __init__(self, settings): super().__init__(settings) - # --- Properties namespace handling (new UI stores backend options under properties["gentl"]) --- props = settings.properties if isinstance(settings.properties, dict) else {} ns = props.get(self.OPTIONS_KEY, {}) if not isinstance(ns, dict): ns = {} - # --- Fast probe mode (CameraProbeWorker sets this) --- - # When fast_start=True, open() should avoid starting acquisition if possible. self._fast_start: bool = bool(ns.get("fast_start", False)) - # --- Stable identity / serial selection --- - # New UI stores stable identity as ns["device_id"], with recommended formats: - # - "serial:" for true serials - # - "fp:" when serial is missing/ambiguous - # - # We keep legacy "serial_number"/"serial" behavior as fallback. raw_device_id = ns.get("device_id") or props.get("device_id") legacy_serial = ns.get("serial_number") or ns.get("serial") or props.get("serial_number") or props.get("serial") self._device_id: str | None = str(raw_device_id).strip() if raw_device_id else None + self._serial_number: str | None = self._serial_from_identity(self._device_id, legacy_serial) - # Decide what to use for actual device selection in open(): - # - If device_id is "serial:XXXX" -> use XXXX as serial_number - # - Otherwise, keep legacy serial if present; open() may still use index if serial is None - self._serial_number: str | None = None - if self._device_id: - did = self._device_id - if did.startswith("serial:"): - self._serial_number = did.split("serial:", 1)[1].strip() or None - elif did.startswith("fp:"): - # fingerprint: not directly usable as serial; rebind_settings should map fp -> index - self._serial_number = legacy_serial # keep legacy if any, otherwise None - else: - # If device_id is provided without prefix, treat it as a "serial-like" value for backward compatibility - self._serial_number = did - else: - self._serial_number = str(legacy_serial).strip() if legacy_serial else None - - # --- Pixel format / image transforms (legacy + backend options) --- self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8") self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360 self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop"))) - # --- Exposure / Gain: 0 means Auto (do not set) --- - exp_val = getattr(settings, "exposure", 0) - gain_val = getattr(settings, "gain", 0.0) - - self._exposure: float | None = ( - float(exp_val) if isinstance(exp_val, (int, float)) and float(exp_val) > 0 else None - ) + self._exposure: float | None = self._positive_float(getattr(settings, "exposure", 0)) if self._exposure is None: - v = ns.get("exposure", props.get("exposure")) - try: - self._exposure = float(v) if v is not None and float(v) > 0 else None - except Exception: - self._exposure = None + self._exposure = self._positive_float(ns.get("exposure", props.get("exposure"))) - self._gain: float | None = ( - float(gain_val) if isinstance(gain_val, (int, float)) and float(gain_val) > 0 else None - ) + self._gain: float | None = self._positive_float(getattr(settings, "gain", 0.0)) if self._gain is None: - v = ns.get("gain", props.get("gain")) - try: - self._gain = float(v) if v is not None and float(v) > 0 else None - except Exception: - self._gain = None + self._gain = self._positive_float(ns.get("gain", props.get("gain"))) - # --- Acquisition timeout --- self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) - - # --- Resolution request (None = device default / Auto) --- - # Uses settings.width/settings.height if set; falls back to legacy props["resolution"] if present. self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() - # --- Actuals for GUI --- self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None self._actual_gain: float | None = None self._actual_exposure: float | None = None - # --- Harvesters resources --- self._harvester = None self._acquirer = None self._shared_entry = None self._device_label: str | None = None - self._cti_files_source_used: str | None = None + # ------------------------------------------------------------------ + # Public telemetry / capabilities + # ------------------------------------------------------------------ + @property def actual_resolution(self) -> tuple[int, int] | None: if self._actual_width and self._actual_height: @@ -176,12 +141,13 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "stable_identity": SupportLevel.SUPPORTED, } + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + @classmethod def get_device_count(cls) -> int: - """Get the number of GenTL devices detected by Harvester. - - Returns the number of devices found, or -1 if detection fails. - """ + """Return the number of GenTL devices, or -1 if detection fails.""" if Harvester is None: return -1 @@ -190,192 +156,97 @@ def get_device_count(cls) -> int: harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) if harvester is None: return -1 - infos = harvester.device_info_list or [] - return len(infos) + return len(harvester.device_info_list or []) except Exception: return -1 finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass - - @staticmethod - def _cti_preflight(path: str) -> tuple[bool, str | None]: - """ - Best-effort check right before calling Harvester.add_file(). - Still subject to race conditions (e.g. file deleted after this check), - but helps diagnose common issues like missing files or permission errors more gracefully and early. - Returns (ok, reason_if_not_ok). - """ - p = Path(str(path)) - try: - if not p.exists(): - return False, "missing at load time" - if not p.is_file(): - return False, "not a file at load time" - # Optional: try opening for read to detect permission/locking issues early - with p.open("rb"): - pass - return True, None - except PermissionError: - return False, "permission denied at load time" - except OSError as e: - return False, f"os error at load time: {e}" - - def _resolve_cti_files_for_settings(self) -> list[str]: - """ - Resolve CTI files to load. - - - User override (properties.gentl.cti_file/cti_files OR legacy properties.cti_file/cti_files): - * strict: must exist, otherwise raise - * source = "user" - - Auto-persisted cache (properties.gentl.cti_files_source == "auto"): - * try persisted ctis first - * if stale/missing, fall back to discovery - * source = "auto" - - Default: discovery (env + configured patterns/dirs) => source = "auto" - - NOTE : legacy properties.cti_file(s) always take strict precedence as user override if present, - even if source marker says "auto". - Never raise just because multiple CTIs exist. - Raise only when none are found (after allowed fallback). - """ - props = self.settings.properties if isinstance(self.settings.properties, dict) else {} - ns = props.get(self.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} + cls._safe_reset_harvester(harvester) - # Read source marker - source = ns.get("cti_files_source") - source = str(source).strip().lower() if source is not None else None + @classmethod + def discover_devices( + cls, + *, + max_devices: int = 10, + should_cancel: callable[[], bool] | None = None, + progress_cb: callable[[str], None] | None = None, + ): + """Rich discovery path for CameraFactory.detect_cameras().""" + if Harvester is None: + return [] - # Explicit CTIs (namespace first, then legacy top-level) - ns_cti_files = ns.get("cti_files") - ns_cti_file = ns.get("cti_file") - legacy_cti_files = props.get("cti_files") - legacy_cti_file = props.get("cti_file") + def _canceled() -> bool: + return bool(should_cancel and should_cancel()) - # ------------------------------------------------------------ - # 1) Legacy explicit CTIs: always treat as user override (strict) - # ------------------------------------------------------------ - if legacy_cti_files or legacy_cti_file: - self._cti_files_source_used = self._CTI_FILES_SOURCE_USER + harvester = None + try: + if progress_cb: + progress_cb("Initializing GenTL discovery…") - candidates, diag = cti_finder.discover_cti_files( - cti_file=str(legacy_cti_file) if legacy_cti_file else None, - cti_files=cti_finder.cti_files_as_list(legacy_cti_files) if legacy_cti_files else None, - include_env=False, - must_exist=True, - ) - if not candidates: - raise RuntimeError( - "No valid GenTL producer (.cti) found from properties.cti_file/cti_files.\n\n" - f"Discovery details:\n{diag.summarize()}" - ) - return list(candidates) + harvester, loaded, _ = cls._build_harvester_for_discovery(strict_single=False) + if harvester is None or not loaded: + if progress_cb: + progress_cb("No GenTL producers could be loaded.") + return [] - # ------------------------------------------------------------------------ - # 2) Namespace explicit CTIs: behavior depends on cti_files_source marker - # - source=="auto": treat as cache, stale => fallback to discovery - # - otherwise: strict user override - # ------------------------------------------------------------------------ - if ns_cti_files or ns_cti_file: - is_auto_cache = source == self._CTI_FILES_SOURCE_AUTO + if progress_cb: + progress_cb(f"Loaded {len(loaded)} GenTL producer(s). Scanning devices…") - # Default to "user" if the marker is missing/unknown. - self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO if is_auto_cache else self._CTI_FILES_SOURCE_USER + infos = list(harvester.device_info_list or []) + limit = min(len(infos), max_devices if max_devices > 0 else len(infos)) + out: list[DetectedCamera] = [] - candidates, diag = cti_finder.discover_cti_files( - cti_file=str(ns_cti_file) if ns_cti_file else None, - cti_files=cti_finder.cti_files_as_list(ns_cti_files) if ns_cti_files else None, - include_env=False, - must_exist=True, - ) + for idx in range(limit): + if _canceled(): + break - if candidates: - return list(candidates) + info = infos[idx] + label = cls._label_from_info(info, idx) + device_id = cls._device_id_from_info(info) - # If auto cache is stale, fall back to discovery - if is_auto_cache: - LOG.info( - "Auto-persisted GenTL CTIs appear stale/missing; falling back to discovery. " - "Persisted cti_file=%s cti_files=%s", - ns_cti_file, - ns_cti_files, - ) - # Fall through to discovery (below) - else: - # User override: strict failure - raise RuntimeError( - "No valid GenTL producer (.cti) found from properties.gentl.cti_file/cti_files.\n\n" - f"Discovery details:\n{diag.summarize()}" + out.append( + DetectedCamera( + index=idx, + label=label, + device_id=device_id, + vid=None, + pid=None, + path=None, + backend_hint=None, + ) ) - # ------------------------------------------------------------ - # 3) Discovery path: env vars + patterns/dirs + built-in defaults - # ------------------------------------------------------------ - self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO - - search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) - extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) - - if search_paths is not None: - search_patterns = cti_finder.cti_files_as_list(search_paths) - else: - search_patterns = list(self._DEFAULT_CTI_PATTERNS) - - candidates, diag = cti_finder.discover_cti_files( - cti_search_paths=search_patterns, - include_env=True, - extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, - recursive_env_search=False, - recursive_extra_search=False, - must_exist=True, - ) + if progress_cb: + progress_cb(f"Found: {label}") - if not candidates: - raise RuntimeError( - "Could not locate any GenTL producer (.cti) file.\n\n" - "Fix options:\n" - " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" - " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" - " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" - f"Discovery details:\n{diag.summarize(redact_env=False)}" - ) + out.sort(key=lambda c: c.index) + return out + except Exception: + LOG.debug("GenTL rich discovery failed", exc_info=True) + return [] + finally: + cls._safe_reset_harvester(harvester) - return list(candidates) + @classmethod + def quick_ping(cls, index: int, _unused=None) -> bool: + """Fast presence check by index using a temporary discovery Harvester.""" + if Harvester is None: + return False - def _configure_trigger(self, node_map) -> None: - """ - Disable external trigger by default unless user explicitly configured otherwise. - This prevents fetch() timeouts on cameras left in trigger mode. - """ + harvester = None try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is None: - return - - symbolics = getattr(trigger_mode, "symbolics", []) - if "Off" in symbolics: - trigger_mode.value = "Off" - LOG.info("TriggerMode set to Off") - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + if harvester is None: + return False + infos = harvester.device_info_list or [] + return 0 <= int(index) < len(infos) + except Exception: + return False + finally: + cls._safe_reset_harvester(harvester) @classmethod - def _build_harvester_for_discovery( - cls, - *, - strict_single: bool = False, # retained for optional future use - ): - """ - Build a Harvester instance and load CTI producers for class-level operations - (discover_devices, quick_ping, get_device_count, rebind_settings). - - Default policy: try to load ALL discovered producers. - """ + def _build_harvester_for_discovery(cls, *, strict_single: bool = False): + """Build a temporary Harvester for discovery-only operations.""" if Harvester is None: return None, [], None @@ -384,241 +255,138 @@ def _build_harvester_for_discovery( cti_search_paths=list(cls._DEFAULT_CTI_PATTERNS), must_exist=True, ) - if not candidates: return None, [], diag - # Default: load all candidates cti_files = list(candidates) - - # Optional strict mode (off by default) if strict_single: - # If you ever want strict, use choose_cti_files here; otherwise ignore. cti_files = cti_finder.choose_cti_files( - cti_files, policy=cti_finder.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, max_files=1 + cti_files, + policy=cti_finder.GenTLDiscoveryPolicy.RAISE_IF_MULTIPLE, + max_files=1, ) harvester = Harvester() loaded: list[str] = [] - failures: list[tuple[str, str]] = [] for cti in cti_files: ok, reason = cls._cti_preflight(cti) if not ok: - failures.append((str(cti), reason or "Check failed")) LOG.warning("Skipping CTI '%s' during discovery preflight: %s", cti, reason) continue - try: harvester.add_file(cti) loaded.append(cti) except Exception as exc: - failures.append((str(cti), str(exc))) LOG.warning("Failed to load CTI '%s' during discovery: %s", cti, exc) if not loaded: - try: - harvester.reset() - except Exception: - pass + cls._safe_reset_harvester(harvester) return None, [], diag try: harvester.update() except Exception as exc: - LOG.error( - "Harvester.update() failed during discovery: %s" - " Device list not usable, treating as discovery failure." - " CTIs loaded before failure : %s", - exc, - loaded, - ) - try: - harvester.reset() - except Exception: - pass - # Update failure + LOG.error("Harvester.update() failed during discovery: %s. CTIs loaded: %s", exc, loaded) + cls._safe_reset_harvester(harvester) return None, [], diag return harvester, loaded, diag - def open(self) -> None: - with type(self)._OPEN_LOCK: - open_id = next(type(self)._OPEN_SEQ) - thread_name = threading.current_thread().name - target_for_log = self._device_id or self._serial_number or getattr(self.settings, "index", None) - - LOG.debug( - "[GenTL:%s] open() ENTER pid=%s thread=%s settings_id=%s target=%s index=%s props=%s", - open_id, - os.getpid(), - thread_name, - id(self.settings), - target_for_log, - getattr(self.settings, "index", None), - self.settings.properties, - ) - if Harvester is None: # pragma: no cover - raise RuntimeError( - "The 'harvesters' package is required for the GenTL backend. " - "Install it via 'pip install harvesters'." - ) - - # Ensure properties namespace exists for persistence back to UI - if not isinstance(self.settings.properties, dict): - self.settings.properties = {} - props = self.settings.properties - ns = props.get(self.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} - props[self.OPTIONS_KEY] = ns - - # Ensure GenTL defalts are present - ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) - ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) - - # Resolve CTIs (may return many). This no longer raises just because there are multiple. - cti_files = self._resolve_cti_files_for_settings() - ns["cti_files_source"] = ( - self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO - ) - - loaded: list[str] = [] - failed: list[tuple[str, str]] = [] + # ------------------------------------------------------------------ + # Settings rebinding + # ------------------------------------------------------------------ - for cti in cti_files: - ok, reason = self._cti_preflight(cti) - if not ok: - failed.append((str(cti), reason or "preflight failed")) - LOG.warning("Skipping CTI '%s': %s", cti, reason) - continue + @classmethod + def rebind_settings(cls, settings): + """Map stable identity to current index when necessary. - loaded.append(str(cti)) + Serial identities are stable enough for open() to select directly, so + they intentionally avoid extra Harvester enumeration during multi-camera + startup. + """ + if Harvester is None: + return settings - # Persist diagnostics for UI / debugging - ns["cti_files"] = [str(p) for p in cti_files] # all resolved candidates - ns["cti_files_loaded"] = [str(p) for p in loaded] # CTIs that passed initial checks - ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(cls.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} - # Keep single-cti convenience key for backward compatibility / display - if loaded: - ns["cti_file"] = str(loaded[0]) - elif cti_files: - ns["cti_file"] = str(cti_files[0]) # best effort + target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") + if not target_id: + return settings - if not loaded: - self._reset_harvester() - raise RuntimeError( - "No GenTL producer (.cti) could be loaded.\n\n" - f"Resolved CTIs: {cti_files}\n" - f"Failures: {failed}\n" - "Fix: remove/repair incompatible producers or " - "set properties.gentl.cti_file to a known working producer." - ) + target_id_str = str(target_id).strip() + if target_id_str.startswith("serial:"): + cls._persist_serial_identity(settings, target_id_str) + return settings - # Use a per-backend Harvester instance. - # - # Important for multi-camera: - # Sharing one Harvester instance across camera workers can cause one open/update - # to disturb another. The Imaging Source U3V GenTL producer also appears sensitive - # to concurrent initialization, so serialize init/open but keep read() concurrent. - try: - LOG.debug("[GenTL:%s] waiting for _OPEN_LOCK", open_id) - t_lock_wait = time.monotonic() + # Non-serial fallback retained for older configs / fingerprint IDs. + harvester = None + try: + explicit_files = ns.get("cti_files") or props.get("cti_files") + explicit_file = ns.get("cti_file") or props.get("cti_file") + source = str(ns.get("cti_files_source", "")).strip().lower() + is_auto_cache = source == cls._CTI_FILES_SOURCE_AUTO - LOG.debug( - "[GenTL:%s] acquired _OPEN_LOCK after %.3fs", - open_id, - time.monotonic() - t_lock_wait, + if explicit_files or explicit_file: + candidates, _ = cti_finder.discover_cti_files( + cti_file=explicit_file, + cti_files=cti_finder.cti_files_as_list(explicit_files), + include_env=False, + must_exist=True, ) - LOG.debug("[GenTL:%s] creating Harvester()", open_id) - # ------------------------------------------------------------ - # Shared Harvester per CTI set. - # - # Important: - # - SharedHarvesterEntry.__init__() performs the initial update(). - # - Do NOT call update() again here. Calling update() while another - # camera is already open/streaming can make the TIS U3V producer - # report zero devices. - # ------------------------------------------------------------ - try: - LOG.debug("[GenTL:%s] acquiring shared Harvester for CTIs=%s", open_id, loaded) - self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) - self._harvester = self._shared_entry.harvester - - with self._shared_entry.lock: - infos = list(self._harvester.device_info_list or []) - - LOG.debug( - "[GenTL:%s] shared Harvester acquired harvester_id=%s refcount=%s infos=%d", - open_id, - id(self._harvester), - cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), - len(infos), - ) - - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) - - except Exception as exc: - if self._shared_entry is not None: - try: - cti_finder.SharedHarvesterPool.release(self._shared_entry) - except Exception: - pass - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc - - def _debug_info(info, i): - def g(key, default=""): - try: - if hasattr(info, "get"): - v = info.get(key) - if v is not None: - return v - except Exception: - pass + if not candidates and is_auto_cache: + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + elif candidates: + harvester = Harvester() + loaded = [] + for cti in candidates: try: - return getattr(info, key, default) + harvester.add_file(cti) + loaded.append(cti) except Exception: - return default - - return { - "index": i, - "serial": str(g("serial_number", "")), - "display": str(g("display_name", "")), - "model": str(g("model", "")), - "vendor": str(g("vendor", "")), - "tl_type": str(g("tl_type", "")), - "access_status": str(g("access_status", "")), - "id": str(g("id_", "")), - } - - LOG.debug( - "[GenTL:%s] enumeration target=%s count=%d devices=%s", - open_id, - self._device_id or ns.get("device_id") or props.get("device_id"), - len(infos), - [_debug_info(info, i) for i, info in enumerate(infos)], - ) - - except Exception as exc: - self._shared_entry = None - self._harvester = None - raise RuntimeError( - f"Failed to initialize GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" - ) from exc + continue + if not loaded: + return settings + harvester.update() + else: + harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) + + if harvester is None: + return settings + + infos = list(harvester.device_info_list or []) + match_index, match_serial = cls._match_device(infos, target_id_str) + if match_index is None: + return settings + + settings.index = int(match_index) + ns2 = cls._ensure_ns_for_settings(settings) + ns2["device_id"] = target_id_str + if match_serial: + ns2["serial_number"] = str(match_serial) + return settings + except Exception: + return settings + finally: + cls._safe_reset_harvester(harvester) + + # ------------------------------------------------------------------ + # Open / read / close + # ------------------------------------------------------------------ + def open(self) -> None: + if Harvester is None: # pragma: no cover + raise RuntimeError( + "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." + ) + + with type(self)._OPEN_LOCK: + loaded, failed = self._resolve_and_persist_ctis() + infos = self._acquire_shared_harvester(loaded) if not infos: - LOG.exception( - "[GenTL:%s] open() FAILED target=%s harvester_id=%s acquirer_id=%s", - open_id, - target_for_log, - id(self._harvester) if self._harvester is not None else None, - id(self._acquirer) if self._acquirer is not None else None, - ) self._reset_harvester() raise RuntimeError( "No GenTL cameras detected via Harvesters after loading producers.\n\n" @@ -627,554 +395,477 @@ def g(key, default=""): "Fix: ensure your camera vendor's GenTL producer is installed and working." ) - # Helper: robustly read device_info fields (dict-like or attribute-like) - def _info_get(info, key: str, default=None): - try: - if hasattr(info, "get"): - v = info.get(key) - if v is not None: - return v - except Exception: - pass - try: - v = getattr(info, key, None) - if v is not None: - return v - except Exception: - pass - return default - - # ------------------------------------------------------------------ - # Device selection (stable device_id > serial > index) - # ------------------------------------------------------------------ - requested_index = int(self.settings.index or 0) - selected_index: int | None = None - selected_serial: str | None = None - - target_device_id = self._device_id or ns.get("device_id") or props.get("device_id") - if target_device_id: - target_device_id = str(target_device_id).strip() - - # Exact match against computed device_id - for idx, info in enumerate(infos): - try: - did = self._device_id_from_info(info) - except Exception: - did = None - if did and did == target_device_id: - selected_index = idx - selected_serial = _info_get(info, "serial_number", None) - selected_serial = str(selected_serial).strip() if selected_serial else None - break - - # If device_id is "serial:XXXX", match serial directly - if selected_index is None and target_device_id.startswith("serial:"): - serial_target = target_device_id.split("serial:", 1)[1].strip() - if serial_target: - exact = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if sn == serial_target: - exact.append((idx, sn)) - if exact: - selected_index = exact[0][0] - selected_serial = exact[0][1] - else: - sub = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if serial_target and serial_target in sn: - sub.append((idx, sn)) - if len(sub) == 1: - selected_index = sub[0][0] - selected_serial = sub[0][1] or None - elif len(sub) > 1: - candidates = [sn for _, sn in sub] - raise RuntimeError( - f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {candidates}" - ) - - # Legacy serial selection fallback - if selected_index is None: - serial = self._serial_number - if serial: - serial = str(serial).strip() - exact = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if sn == serial: - exact.append((idx, sn)) - if exact: - selected_index = exact[0][0] - selected_serial = exact[0][1] - else: - sub = [] - for idx, info in enumerate(infos): - sn = _info_get(info, "serial_number", "") - sn = str(sn).strip() if sn is not None else "" - if serial and serial in sn: - sub.append((idx, sn)) - if len(sub) == 1: - selected_index = sub[0][0] - selected_serial = sub[0][1] or None - elif len(sub) > 1: - candidates = [sn for _, sn in sub] - raise RuntimeError(f"Ambiguous GenTL serial match for '{serial}'. Candidates: {candidates}") - else: - available = [str(_info_get(i, "serial_number", "")).strip() for i in infos] - raise RuntimeError( - f"Camera with serial '{serial}' not found. Available cameras: {available}" - ) - - # Index fallback - if selected_index is None: - device_count = len(infos) - if requested_index < 0 or requested_index >= device_count: - raise RuntimeError( - f"Camera index {requested_index} out of range for {device_count} GenTL device(s)" - ) - selected_index = requested_index - sn = _info_get(infos[selected_index], "serial_number", "") - selected_serial = str(sn).strip() if sn else None - - # Update settings.index to actual selected index (UI stability) + selected_index, selected_serial, selected_info = self._select_device(infos) self.settings.index = int(selected_index) - selected_info = infos[int(selected_index)] - # Create ImageAcquirer via Harvester.create(...) with self._shared_entry.lock: - self._acquirer = self._create_image_acquirer(selected_serial, selected_index) + self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) + node_map = self._acquirer.remote_device.node_map + self._device_label = self._resolve_device_label(node_map) + + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) + self._read_telemetry(node_map) + self._persist_device_metadata(selected_info, selected_serial) + + if self._fast_start: + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return - remote = self._acquirer.remote_device - node_map = remote.node_map - - self._device_label = self._resolve_device_label(node_map) + self._acquirer.start() - # Apply configuration - self._configure_pixel_format(node_map) - self._configure_trigger(node_map) - self._configure_resolution(node_map) - self._configure_exposure(node_map) - self._configure_gain(node_map) - self._configure_frame_rate(node_map) + LOG.debug( + "Opened GenTL camera index=%s serial=%s label=%s", selected_index, selected_serial, self._device_label + ) - # Read back telemetry - try: - self._actual_width = int(node_map.Width.value) - self._actual_height = int(node_map.Height.value) - except Exception: - pass + def read(self) -> tuple[np.ndarray, float]: + if self._acquirer is None: + raise RuntimeError("GenTL image acquirer not initialised") - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + try: + with self._acquirer.fetch(timeout=self._timeout) as buffer: + component = buffer.payload.components[0] + channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 + array = np.asarray(component.data) + expected = component.height * component.width * channels + if array.size != expected: + array = np.frombuffer(bytes(component.data), dtype=array.dtype) - if self._actual_exposure is None: try: - self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + if channels > 1: + frame = array.reshape(component.height, component.width, channels).copy() + else: + frame = array.reshape(component.height, component.width).copy() + except ValueError: + frame = array.copy() + except HarvesterTimeoutError as exc: + raise TimeoutError(str(exc) + " (GenTL timeout)") from exc - if self._actual_gain is None: - try: - self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) - except Exception: - self._actual_gain = None + frame = self._convert_frame(frame) + timestamp = time.time() + + 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) - # Persist identity + metadata - computed_id = None + if self._actual_exposure is None or self._actual_gain is None: try: - computed_id = self._device_id_from_info(selected_info) + self._read_telemetry(self._acquirer.remote_device.node_map) except Exception: - computed_id = None - - if computed_id: - ns["device_id"] = computed_id - elif selected_serial: - ns["device_id"] = f"serial:{selected_serial}" - - if selected_serial: - ns["serial_number"] = str(selected_serial) - ns["device_serial_number"] = str(selected_serial) - - if self._device_label: - ns["device_name"] = str(self._device_label) - - ns["device_display_name"] = str(_info_get(selected_info, "display_name", "") or "") - ns["device_info_id"] = str(_info_get(selected_info, "id_", "") or "") - ns["device_vendor"] = str(_info_get(selected_info, "vendor", "") or "") - ns["device_model"] = str(_info_get(selected_info, "model", "") or "") - ns["device_tl_type"] = str(_info_get(selected_info, "tl_type", "") or "") - ns["device_user_defined_name"] = str(_info_get(selected_info, "user_defined_name", "") or "") - ns["device_version"] = str(_info_get(selected_info, "version", "") or "") - ns["device_access_status"] = _info_get(selected_info, "access_status", None) - - # Start acquisition unless fast_start - if getattr(self, "_fast_start", False): - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return - - with self._shared_entry.lock: - self._acquirer.start() - - LOG.debug( - "[GenTL:%s] open() SUCCESS harvester_id=%s acquirer_id=%s device_label=%s", - open_id, - id(self._harvester), - id(self._acquirer), - self._device_label, - ) + pass - @staticmethod - def _device_id_from_info(info) -> str | None: - """ - Build a stable-ish device identifier from Harvester device_info_list entries. - This helper supports both dict-like and attribute-like representations. - """ + return frame, timestamp - def _read(name: str): - # dict-like + def stop(self) -> None: + if self._acquirer is not None: try: - if hasattr(info, "get"): - v = info.get(name) # type: ignore[attr-defined] - if v is not None: - return v + self._call_with_optional_lock(self._acquirer.stop) except Exception: pass - # attribute-like + + def close(self) -> None: + if self._acquirer is not None: try: - return getattr(info, name, None) + self._call_with_optional_lock(self._acquirer.stop) except Exception: - return None - - def _get(*names: str) -> str | None: - for n in names: - v = _read(n) - if v is None: - continue - s = str(v).strip() - if s: - return s - return None + pass - # Prefer serial if present (best stable key when available) - serial = _get("serial_number", "SerialNumber", "device_serial_number", "sn", "serial") - if serial: - return f"serial:{serial}" + try: + destroy = getattr(self._acquirer, "destroy", None) + if destroy is not None: + self._call_with_optional_lock(destroy) + finally: + self._acquirer = None - # Fallback components (best-effort; names may vary per producer) - vendor = _get("vendor", "vendor_name", "manufacturer", "DeviceVendorName") - model = _get("model", "model_name", "DeviceModelName") - user_id = _get("user_defined_name", "user_id", "DeviceUserID", "DeviceUserId", "device_user_id") - tl_type = _get("tl_type", "transport_layer_type", "DeviceTLType") + if self._harvester is not None or self._shared_entry is not None: + self._reset_harvester() - unique = _get("id_", "id", "device_id", "uid", "guid", "mac_address", "interface_id", "display_name") + self._device_label = None - parts = [] - for k, v in (("vendor", vendor), ("model", model), ("user", user_id), ("tl", tl_type), ("uid", unique)): - if v: - parts.append(f"{k}={v}") + # ------------------------------------------------------------------ + # CTI / shared Harvester helpers + # ------------------------------------------------------------------ - if not parts: - return None + def _resolve_and_persist_ctis(self) -> tuple[list[str], list[tuple[str, str]]]: + ns = self._ensure_settings_ns() + ns.setdefault("cti_search_paths", list(self._DEFAULT_CTI_PATTERNS)) + ns.setdefault("cti_files_source", self._CTI_FILES_SOURCE_AUTO) - return "fp:" + "|".join(parts) + cti_files = self._resolve_cti_files_for_settings() + ns["cti_files_source"] = ( + self._cti_files_source_used or ns.get("cti_files_source") or self._CTI_FILES_SOURCE_AUTO + ) - @classmethod - def discover_devices( - cls, - *, - max_devices: int = 10, - should_cancel: callable[[], bool] | None = None, - progress_cb: callable[[str], None] | None = None, - ): - """ - Rich discovery path for CameraFactory.detect_cameras(). - Returns a list of DetectedCamera with device_id filled when possible. + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] + for cti in cti_files: + ok, reason = self._cti_preflight(cti) + if ok: + loaded.append(str(cti)) + else: + failed.append((str(cti), reason or "preflight failed")) + LOG.warning("Skipping CTI '%s': %s", cti, reason) - Cross-platform CTI discovery: - - Uses GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH when available - - Falls back to built-in Windows patterns - - Best-effort loads multiple CTI producers - """ - if Harvester is None: - return [] + ns["cti_files"] = [str(p) for p in cti_files] + ns["cti_files_loaded"] = loaded[:] + ns["cti_files_failed"] = [{"cti": c, "error": e} for c, e in failed] + if loaded: + ns["cti_file"] = loaded[0] + elif cti_files: + ns["cti_file"] = str(cti_files[0]) - def _canceled() -> bool: - return bool(should_cancel and should_cancel()) + if not loaded: + self._reset_harvester() + raise RuntimeError( + "No GenTL producer (.cti) could be loaded.\n\n" + f"Resolved CTIs: {cti_files}\n" + f"Failures: {failed}\n" + "Fix: remove/repair incompatible producers " + "or set properties.gentl.cti_file to a known working producer." + ) - harvester = None + return loaded, failed + + def _acquire_shared_harvester(self, loaded: list[str]) -> list: + ns = self._ensure_settings_ns() try: - if progress_cb: - progress_cb("Initializing GenTL discovery…") + self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) + self._harvester = self._shared_entry.harvester + with self._shared_entry.lock: + infos = list(self._harvester.device_info_list or []) + ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + LOG.debug( + "Using shared GenTL Harvester for %d device(s), refcount=%s", + len(infos), + cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), + ) + return infos + except Exception as exc: + if self._shared_entry is not None: + try: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + except Exception: + pass + self._shared_entry = None + self._harvester = None + raise RuntimeError( + f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" + ) from exc - harvester, loaded, _ = cls._build_harvester_for_discovery(strict_single=False) + def _reset_harvester(self) -> None: + try: + if self._shared_entry is not None: + cti_finder.SharedHarvesterPool.release(self._shared_entry) + self._shared_entry = None + else: + self._reset_select_harvester(self._harvester) + finally: + self._harvester = None - if harvester is None or not loaded: - if progress_cb: - progress_cb("No GenTL producers could be loaded.") - return [] + @staticmethod + def _reset_select_harvester(harvester) -> None: + GenTLCameraBackend._safe_reset_harvester(harvester) - if progress_cb: - progress_cb(f"Loaded {len(loaded)} GenTL producer(s). Scanning devices…") + @staticmethod + def _safe_reset_harvester(harvester) -> None: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass - infos = list(harvester.device_info_list or []) - if not infos: - return [] + @staticmethod + def _cti_preflight(path: str) -> tuple[bool, str | None]: + p = Path(str(path)) + try: + if not p.exists(): + return False, "missing at load time" + if not p.is_file(): + return False, "not a file at load time" + with p.open("rb"): + pass + return True, None + except PermissionError: + return False, "permission denied at load time" + except OSError as e: + return False, f"os error at load time: {e}" - out: list[DetectedCamera] = [] - limit = min(len(infos), max_devices if max_devices > 0 else len(infos)) + def _resolve_cti_files_for_settings(self) -> list[str]: + """Resolve CTI files using explicit user overrides, auto cache, then discovery.""" + props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + ns = props.get(self.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} - for idx in range(limit): - if _canceled(): - break + source = ns.get("cti_files_source") + source = str(source).strip().lower() if source is not None else None - info = infos[idx] + ns_cti_files = ns.get("cti_files") + ns_cti_file = ns.get("cti_file") + legacy_cti_files = props.get("cti_files") + legacy_cti_file = props.get("cti_file") - display_name = None - try: - display_name = ( - info.get("display_name") if hasattr(info, "get") else getattr(info, "display_name", None) - ) - except Exception: - display_name = None + if legacy_cti_files or legacy_cti_file: + self._cti_files_source_used = self._CTI_FILES_SOURCE_USER + candidates, diag = cti_finder.discover_cti_files( + cti_file=str(legacy_cti_file) if legacy_cti_file else None, + cti_files=cti_finder.cti_files_as_list(legacy_cti_files) if legacy_cti_files else None, + include_env=False, + must_exist=True, + ) + if not candidates: + raise RuntimeError( + "No valid GenTL producer (.cti) found from properties.cti_file/cti_files.\n\n" + f"Discovery details:\n{diag.summarize()}" + ) + return list(candidates) - if display_name: - label = str(display_name).strip() - else: - vendor = ( - getattr(info, "vendor", None) or (info.get("vendor") if hasattr(info, "get") else None) or "" - ) - model = getattr(info, "model", None) or (info.get("model") if hasattr(info, "get") else None) or "" - serial = ( - getattr(info, "serial_number", None) - or (info.get("serial_number") if hasattr(info, "get") else None) - or "" - ) - vendor, model, serial = str(vendor).strip(), str(model).strip(), str(serial).strip() - label = f"{vendor} {model}".strip() if (vendor or model) else f"GenTL device {idx}" - if serial: - label = f"{label} ({serial})" + if ns_cti_files or ns_cti_file: + is_auto_cache = source == self._CTI_FILES_SOURCE_AUTO + self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO if is_auto_cache else self._CTI_FILES_SOURCE_USER + candidates, diag = cti_finder.discover_cti_files( + cti_file=str(ns_cti_file) if ns_cti_file else None, + cti_files=cti_finder.cti_files_as_list(ns_cti_files) if ns_cti_files else None, + include_env=False, + must_exist=True, + ) + if candidates: + return list(candidates) + if not is_auto_cache: + raise RuntimeError( + "No valid GenTL producer (.cti) found from properties.gentl.cti_file/cti_files.\n\n" + f"Discovery details:\n{diag.summarize()}" + ) + LOG.info("Auto-persisted GenTL CTIs stale/missing; falling back to discovery.") - device_id = cls._device_id_from_info(info) + self._cti_files_source_used = self._CTI_FILES_SOURCE_AUTO + search_paths = ns.get("cti_search_paths", props.get("cti_search_paths")) + extra_dirs = ns.get("cti_dirs", props.get("cti_dirs")) + search_patterns = ( + cti_finder.cti_files_as_list(search_paths) if search_paths is not None else list(self._DEFAULT_CTI_PATTERNS) + ) - out.append( - DetectedCamera( - index=idx, - label=label, - device_id=device_id, - vid=None, - pid=None, - path=None, - backend_hint=None, - ) - ) + candidates, diag = cti_finder.discover_cti_files( + cti_search_paths=search_patterns, + include_env=True, + extra_dirs=cti_finder.cti_files_as_list(extra_dirs) if extra_dirs is not None else None, + recursive_env_search=False, + recursive_extra_search=False, + must_exist=True, + ) + if not candidates: + raise RuntimeError( + "Could not locate any GenTL producer (.cti) file.\n\n" + "Fix options:\n" + " - Set camera.properties.gentl.cti_file to the full path of a .cti file\n" + " - Or set GENICAM_GENTL64_PATH / GENICAM_GENTL32_PATH to include the producer directory\n" + " - Or provide camera.properties.gentl.cti_search_paths with glob patterns\n\n" + f"Discovery details:\n{diag.summarize(redact_env=False)}" + ) + return list(candidates) - if progress_cb: - progress_cb(f"Found: {label}") + # ------------------------------------------------------------------ + # Device selection / identity helpers + # ------------------------------------------------------------------ - out.sort(key=lambda c: c.index) - return out + def _select_device(self, infos: list) -> tuple[int, str | None, object]: + requested_index = int(self.settings.index or 0) + target_device_id = self._device_id or self._ensure_settings_ns().get("device_id") - except Exception: - return [] - finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass + selected_index: int | None = None + selected_serial: str | None = None - @classmethod - def rebind_settings(cls, settings): - """ - If a stable identity exists in settings.properties['gentl'], map it to the - correct current index (and serial_number if available). - - Strategy: - - If CTIs were persisted: - * if source == "auto" and they are stale -> fall back to discovery - * otherwise use them (best stability) - - Otherwise, fall back to env-var + pattern discovery (best-effort). - """ - if Harvester is None: - return settings + if target_device_id: + selected_index, selected_serial = self._match_device(infos, str(target_device_id).strip()) - props = settings.properties if isinstance(settings.properties, dict) else {} - ns = props.get(cls.OPTIONS_KEY, {}) - if not isinstance(ns, dict): - ns = {} + if selected_index is None and self._serial_number: + selected_index, selected_serial = self._match_device(infos, str(self._serial_number).strip()) - target_id = ns.get("device_id") or ns.get("serial_number") or ns.get("serial") - if not target_id: - return settings - # For serial-based GenTL devices, open() can select by serial directly. - # Avoid doing Harvester enumeration during CameraFactory.create(), because - # multi-camera startup calls create() concurrently from multiple threads. - target_id_str = str(target_id).strip() - if target_id_str.startswith("serial:"): - serial = target_id_str.split("serial:", 1)[1].strip() - if serial: - if not isinstance(settings.properties, dict): - settings.properties = {} - ns2 = settings.properties.setdefault(cls.OPTIONS_KEY, {}) - if not isinstance(ns2, dict): - ns2 = {} - settings.properties[cls.OPTIONS_KEY] = ns2 - ns2["device_id"] = target_id_str - ns2["serial_number"] = serial - return settings + if selected_index is None: + if requested_index < 0 or requested_index >= len(infos): + raise RuntimeError(f"Camera index {requested_index} out of range for {len(infos)} GenTL device(s)") + selected_index = requested_index + serial = self._info_get(infos[selected_index], "serial_number", "") + selected_serial = str(serial).strip() if serial else None - source = ns.get("cti_files_source") - source = str(source).strip().lower() if source is not None else None - is_auto_cache = source == cls._CTI_FILES_SOURCE_AUTO + return int(selected_index), selected_serial, infos[int(selected_index)] - harvester = None - try: - explicit_files = ns.get("cti_files") or props.get("cti_files") - explicit_file = ns.get("cti_file") or props.get("cti_file") + @classmethod + def _match_device(cls, infos: list, target: str) -> tuple[int | None, str | None]: + if not target: + return None, None + + serial_target = target.split("serial:", 1)[1].strip() if target.startswith("serial:") else target + + for idx, info in enumerate(infos): + if cls._device_id_from_info(info) == target: + serial = cls._info_get(info, "serial_number", None) + return idx, str(serial).strip() if serial else None + + exact: list[tuple[int, str]] = [] + for idx, info in enumerate(infos): + sn = str(cls._info_get(info, "serial_number", "") or "").strip() + if sn == serial_target: + exact.append((idx, sn)) + if exact: + return exact[0] + + partial = [] + for idx, info in enumerate(infos): + sn = str(cls._info_get(info, "serial_number", "") or "").strip() + if serial_target and serial_target in sn: + partial.append((idx, sn)) + if len(partial) == 1: + return partial[0] + if len(partial) > 1: + raise RuntimeError( + f"Ambiguous GenTL serial match for '{serial_target}'. Candidates: {[sn for _, sn in partial]}" + ) - if explicit_files or explicit_file: - candidates, _diag = cti_finder.discover_cti_files( - cti_file=explicit_file, - cti_files=cti_finder.cti_files_as_list(explicit_files), - include_env=False, - must_exist=True, - ) + return None, None - if not candidates and is_auto_cache: - # Auto cache stale -> fallback to discovery - harvester, _loaded, _diag2 = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings - elif not candidates: - # User override stale or unknown -> no rebind - return settings - else: - harvester = Harvester() - loaded: list[str] = [] - for cti in candidates: - try: - harvester.add_file(cti) - loaded.append(cti) - except Exception: - continue - if not loaded: - cls._reset_select_harvester(harvester) - if is_auto_cache: - harvester, _loaded, _diag2 = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings - else: - return settings - else: - harvester.update() - else: - harvester, _loaded, _diag = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return settings + @staticmethod + def _device_id_from_info(info) -> str | None: + serial = GenTLCameraBackend._first_info_value( + info, + "serial_number", + "SerialNumber", + "device_serial_number", + "sn", + "serial", + ) + if serial: + return f"serial:{serial}" - infos = list(harvester.device_info_list or []) - if not infos: - return settings + parts = [] + for key, names in ( + ("vendor", ("vendor", "vendor_name", "manufacturer", "DeviceVendorName")), + ("model", ("model", "model_name", "DeviceModelName")), + ("user", ("user_defined_name", "user_id", "DeviceUserID", "DeviceUserId", "device_user_id")), + ("tl", ("tl_type", "transport_layer_type", "DeviceTLType")), + ("uid", ("id_", "id", "device_id", "uid", "guid", "mac_address", "interface_id", "display_name")), + ): + value = GenTLCameraBackend._first_info_value(info, *names) + if value: + parts.append(f"{key}={value}") + return "fp:" + "|".join(parts) if parts else None - target_id_str = str(target_id).strip() - match_index = None - match_serial = None + @staticmethod + def _first_info_value(info, *names: str) -> str | None: + for name in names: + value = GenTLCameraBackend._info_get(info, name, None) + if value is not None and str(value).strip(): + return str(value).strip() + return None - # 1) Exact match by computed device_id - for idx, info in enumerate(infos): - dev_id = cls._device_id_from_info(info) - if dev_id and dev_id == target_id_str: - match_index = idx - match_serial = getattr(info, "serial_number", None) - break + @staticmethod + def _info_get(info, key: str, default=None): + try: + if hasattr(info, "get"): + value = info.get(key) + if value is not None: + return value + except Exception: + pass + try: + value = getattr(info, key, None) + if value is not None: + return value + except Exception: + pass + return default - # 2) Fallback: treat target as serial-ish substring - if match_index is None: - for idx, info in enumerate(infos): - serial = getattr(info, "serial_number", None) - if serial and target_id_str in str(serial): - match_index = idx - match_serial = serial - break + @staticmethod + def _label_from_info(info, index: int) -> str: + display = GenTLCameraBackend._info_get(info, "display_name", None) + if display: + return str(display).strip() - if match_index is None: - return settings + vendor = str(GenTLCameraBackend._info_get(info, "vendor", "") or "").strip() + model = str(GenTLCameraBackend._info_get(info, "model", "") or "").strip() + serial = str(GenTLCameraBackend._info_get(info, "serial_number", "") or "").strip() + label = f"{vendor} {model}".strip() if (vendor or model) else f"GenTL device {index}" + return f"{label} ({serial})" if serial else label - # Apply rebinding - settings.index = int(match_index) + @staticmethod + def _serial_from_identity(device_id: str | None, legacy_serial) -> str | None: + if device_id: + did = str(device_id).strip() + if did.startswith("serial:"): + return did.split("serial:", 1)[1].strip() or None + if not did.startswith("fp:"): + return did + return str(legacy_serial).strip() if legacy_serial else None - # Ensure namespace exists - if not isinstance(settings.properties, dict): - settings.properties = {} - ns2 = settings.properties.setdefault(cls.OPTIONS_KEY, {}) - if not isinstance(ns2, dict): - ns2 = {} - settings.properties[cls.OPTIONS_KEY] = ns2 + @classmethod + def _persist_serial_identity(cls, settings, device_id: str) -> None: + serial = device_id.split("serial:", 1)[1].strip() + if not serial: + return + ns = cls._ensure_ns_for_settings(settings) + ns["device_id"] = device_id + ns["serial_number"] = serial - if match_serial: - ns2["serial_number"] = str(match_serial) - ns2["device_id"] = target_id_str + def _persist_device_metadata(self, selected_info, selected_serial: str | None) -> None: + ns = self._ensure_settings_ns() + computed_id = self._device_id_from_info(selected_info) - return settings + if computed_id: + ns["device_id"] = computed_id + elif selected_serial: + ns["device_id"] = f"serial:{selected_serial}" - except Exception: - return settings - finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass + if selected_serial: + ns["serial_number"] = str(selected_serial) + ns["device_serial_number"] = str(selected_serial) - @classmethod - def quick_ping(cls, index: int, _unused=None) -> bool: - """ - Fast check: is there a device at this index according to Harvester? - Does not open/start acquisition. - """ - if Harvester is None: - return False + if self._device_label: + ns["device_name"] = str(self._device_label) + + for out_key, info_key in ( + ("device_display_name", "display_name"), + ("device_info_id", "id_"), + ("device_vendor", "vendor"), + ("device_model", "model"), + ("device_tl_type", "tl_type"), + ("device_user_defined_name", "user_defined_name"), + ("device_version", "version"), + ("device_access_status", "access_status"), + ): + value = self._info_get(selected_info, info_key, "") + ns[out_key] = value if out_key == "device_access_status" else str(value or "") - harvester = None - try: - harvester, _, _ = cls._build_harvester_for_discovery(strict_single=False) - if harvester is None: - return False - infos = harvester.device_info_list or [] - return 0 <= int(index) < len(infos) - except Exception: - return False - finally: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass + @classmethod + def _ensure_ns_for_settings(cls, settings) -> dict: + if not isinstance(settings.properties, dict): + settings.properties = {} + ns = settings.properties.get(cls.OPTIONS_KEY, {}) + if not isinstance(ns, dict): + ns = {} + settings.properties[cls.OPTIONS_KEY] = ns + return ns + + def _ensure_settings_ns(self) -> dict: + return self._ensure_ns_for_settings(self.settings) + + # ------------------------------------------------------------------ + # Existing compatibility helpers + # ------------------------------------------------------------------ def _call_with_optional_lock(self, func, *args, **kwargs): - """ - Call func under the shared Harvester lock if a shared entry exists. - In per-instance Harvester mode, call directly. - """ if self._shared_entry is not None: with self._shared_entry.lock: return func(*args, **kwargs) return func(*args, **kwargs) def _create_image_acquirer(self, selected_serial: str | None, selected_index: int): - """ - Create a Harvester ImageAcquirer using serial when available. - Supports both create(arg) and create(index=...) API variants. - """ if self._harvester is None: raise RuntimeError("Harvester is not initialized") - try: if selected_serial: return self._harvester.create({"serial_number": str(selected_serial)}) @@ -1184,355 +875,160 @@ def _create_image_acquirer(self, selected_serial: str | None, selected_index: in return self._harvester.create({"serial_number": str(selected_serial)}) return self._harvester.create(index=int(selected_index)) - def read(self) -> tuple[np.ndarray, float]: - if self._acquirer is None: - raise RuntimeError("GenTL image acquirer not initialised") - - try: - with self._acquirer.fetch(timeout=self._timeout) as buffer: - component = buffer.payload.components[0] - channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 - array = np.asarray(component.data) - expected = component.height * component.width * channels - if array.size != expected: - array = np.frombuffer(bytes(component.data), dtype=array.dtype) - try: - if channels > 1: - frame = array.reshape(component.height, component.width, channels).copy() - else: - frame = array.reshape(component.height, component.width).copy() - except ValueError: - frame = array.copy() - except HarvesterTimeoutError as exc: - raise TimeoutError(str(exc) + " (GenTL timeout)") from exc - - frame = self._convert_frame(frame) - timestamp = time.time() - - 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) - - if self._actual_exposure is None: - try: - self._actual_exposure = float(self._acquirer.remote_device.node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None - - if self._actual_gain is None: - try: - self._actual_gain = float(self._acquirer.remote_device.node_map.Gain.value) - except Exception: - self._actual_gain = None - - return frame, timestamp - - def stop(self) -> None: - if self._acquirer is not None: - try: - self._call_with_optional_lock(self._acquirer.stop) - except Exception: - pass - - @staticmethod - def _reset_select_harvester(harvester) -> None: - if harvester is not None: - try: - harvester.reset() - except Exception: - pass - - def _reset_harvester(self) -> None: - try: - if self._shared_entry is not None: - LOG.debug( - "GenTL releasing shared Harvester harvester_id=%s refcount_before=%s", - id(self._shared_entry.harvester), - cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), - ) - cti_finder.SharedHarvesterPool.release(self._shared_entry) - self._shared_entry = None - else: - self._reset_select_harvester(self._harvester) - finally: - self._harvester = None - - def close(self) -> None: - if self._acquirer is not None: - try: - self._call_with_optional_lock(self._acquirer.stop) - except Exception: - pass - - try: - destroy = getattr(self._acquirer, "destroy", None) - if destroy is not None: - self._call_with_optional_lock(destroy) - finally: - self._acquirer = None - - if self._harvester is not None or self._shared_entry is not None: - self._reset_harvester() + def _available_serials(self) -> list[str]: + assert self._harvester is not None + return [ + str(s).strip() + for s in (self._info_get(i, "serial_number", "") for i in self._harvester.device_info_list) + if s + ] - self._device_label = None + def _create_acquirer(self, serial: str | None, index: int): + """Compatibility wrapper for older code/tests.""" + return self._create_image_acquirer(serial, index) # ------------------------------------------------------------------ - # Helpers + # Camera configuration helpers # ------------------------------------------------------------------ - def _parse_crop(self, crop) -> tuple[int, int, int, int] | None: - if isinstance(crop, (list, tuple)) and len(crop) == 4: - return tuple(int(v) for v in crop) - return None - - def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: - """ - Return (w, h) if user explicitly requested a resolution. - Return None to keep device defaults. - """ - props = self.settings.properties if isinstance(self.settings.properties, dict) else {} - - legacy = props.get("resolution") - if isinstance(legacy, (list, tuple)) and len(legacy) == 2: - try: - w, h = int(legacy[0]), int(legacy[1]) - if w > 0 and h > 0: - return (w, h) - except Exception: - pass - + def _configure_pixel_format(self, node_map) -> None: try: - w = int(getattr(self.settings, "width", 0) or 0) - h = int(getattr(self.settings, "height", 0) or 0) - if w > 0 and h > 0: - return (w, h) - except Exception: - pass + if self._pixel_format in node_map.PixelFormat.symbolics: + node_map.PixelFormat.value = self._pixel_format + actual = node_map.PixelFormat.value + if actual != self._pixel_format: + LOG.warning("Pixel format mismatch: requested '%s', got '%s'", self._pixel_format, actual) + else: + LOG.warning( + "Pixel format '%s' not in available formats: %s", self._pixel_format, node_map.PixelFormat.symbolics + ) + except Exception as e: + LOG.warning("Failed to set pixel format '%s': %s", self._pixel_format, e) - return None + def _configure_trigger(self, node_map) -> None: + try: + trigger_mode = getattr(node_map, "TriggerMode", None) + if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): + trigger_mode.value = "Off" + except Exception as e: + LOG.warning("Failed to disable trigger mode: %s", e) def _configure_resolution(self, node_map) -> None: - """ - Configure camera resolution only if explicitly requested. - If None, keep device defaults. - """ - req = self._requested_resolution - if req is None: - LOG.info("Resolution: using device default.") + if self._requested_resolution is None: return - requested_width, requested_height = req + requested_width, requested_height = self._requested_resolution actual_width, actual_height = None, None - # Width try: node = node_map.Width - min_w, max_w = node.min, node.max - inc_w = getattr(node, "inc", 1) - width = self._adjust_to_increment(requested_width, min_w, max_w, inc_w) + width = self._adjust_to_increment(requested_width, node.min, node.max, getattr(node, "inc", 1)) node.value = int(width) - actual_width = node.value + actual_width = int(node.value) except Exception as e: - LOG.warning(f"Failed to set width: {e}") + LOG.warning("Failed to set width: %s", e) - # Height try: node = node_map.Height - min_h, max_h = node.min, node.max - inc_h = getattr(node, "inc", 1) - height = self._adjust_to_increment(requested_height, min_h, max_h, inc_h) + height = self._adjust_to_increment(requested_height, node.min, node.max, getattr(node, "inc", 1)) node.value = int(height) - actual_height = node.value + actual_height = int(node.value) except Exception as e: - LOG.warning(f"Failed to set height: {e}") + LOG.warning("Failed to set height: %s", e) if actual_width is not None and actual_height is not None: - self._actual_width = int(actual_width) - self._actual_height = int(actual_height) + self._actual_width = actual_width + self._actual_height = actual_height if (actual_width, actual_height) != (requested_width, requested_height): LOG.warning( - f"Resolution mismatch: requested {requested_width}x{requested_height}, " - f"got {actual_width}x{actual_height}" - ) - else: - LOG.info(f"Resolution set to {actual_width}x{actual_height}") - - def _available_serials(self) -> list[str]: - assert self._harvester is not None - serials: list[str] = [] - for info in self._harvester.device_info_list: - serial = getattr(info, "serial_number", "") - if serial: - serials.append(serial) - return serials - - def _create_acquirer(self, serial: str | None, index: int): - assert self._harvester is not None - methods = [ - getattr(self._harvester, "create", None), - getattr(self._harvester, "create_image_acquirer", None), - ] - methods = [m for m in methods if m is not None] - errors: list[str] = [] - device_info = None - if not serial: - device_list = self._harvester.device_info_list - if 0 <= index < len(device_list): - device_info = device_list[index] - for create in methods: - try: - if serial: - return create({"serial_number": serial}) - except Exception as exc: - errors.append(f"{create.__name__} serial: {exc}") - for create in methods: - try: - return create(index=index) - except TypeError: - try: - return create(index) - except Exception as exc: - errors.append(f"{create.__name__} index positional: {exc}") - except Exception as exc: - errors.append(f"{create.__name__} index: {exc}") - if device_info is not None: - for create in methods: - try: - return create(device_info) - except Exception as exc: - errors.append(f"{create.__name__} device_info: {exc}") - if not serial and index == 0: - for create in methods: - try: - return create() - except Exception as exc: - errors.append(f"{create.__name__} default: {exc}") - joined = "; ".join(errors) or "no creation methods available" - raise RuntimeError(f"Failed to initialise GenTL image acquirer ({joined})") - - def _configure_pixel_format(self, node_map) -> None: - try: - if self._pixel_format in node_map.PixelFormat.symbolics: - node_map.PixelFormat.value = self._pixel_format - actual = node_map.PixelFormat.value - if actual != self._pixel_format: - LOG.warning(f"Pixel format mismatch: requested '{self._pixel_format}', got '{actual}'") - else: - LOG.info(f"Pixel format set to '{actual}'") - else: - LOG.warning( - f"Pixel format '{self._pixel_format}' not in available formats: {node_map.PixelFormat.symbolics}" + "Resolution mismatch: requested %sx%s, got %sx%s", + requested_width, + requested_height, + actual_width, + actual_height, ) - except Exception as e: - LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") def _configure_exposure(self, node_map) -> None: if self._exposure is None: return - # Try to disable auto exposure first - for attr in ("ExposureAuto",): - try: - node = getattr(node_map, attr) - node.value = "Off" - LOG.info("Auto exposure disabled") - break - except AttributeError: - continue - except Exception as e: - LOG.warning(f"Failed to disable auto exposure: {e}") + try: + node_map.ExposureAuto.value = "Off" + except Exception: + pass - # Set exposure value for attr in ("ExposureTime", "Exposure"): try: node = getattr(node_map, attr) - except AttributeError: - continue - try: node.value = float(self._exposure) - actual = node.value - if abs(actual - self._exposure) > 1.0: # Allow 1μs tolerance - LOG.warning(f"Exposure mismatch: requested {self._exposure}μs, got {actual}μs") - else: - LOG.info(f"Exposure set to {actual}μs") return - except Exception as e: - LOG.warning(f"Failed to set exposure via {attr}: {e}") + except AttributeError: continue - - LOG.warning(f"Could not set exposure to {self._exposure}μs (no compatible attribute found)") + except Exception as e: + LOG.warning("Failed to set exposure via %s: %s", attr, e) + LOG.warning("Could not set exposure to %s µs", self._exposure) def _configure_gain(self, node_map) -> None: if self._gain is None: return - # Try to disable auto gain first - for attr in ("GainAuto",): - try: - node = getattr(node_map, attr) - node.value = "Off" - LOG.info("Auto gain disabled") - break - except AttributeError: - continue - except Exception as e: - LOG.warning(f"Failed to disable auto gain: {e}") - - # Set gain value - for attr in ("Gain",): - try: - node = getattr(node_map, attr) - except AttributeError: - continue - try: - node.value = float(self._gain) - actual = node.value - if abs(actual - self._gain) > 0.1: # Allow 0.1 tolerance - LOG.warning(f"Gain mismatch: requested {self._gain}, got {actual}") - else: - LOG.info(f"Gain set to {actual}") - return - except Exception as e: - LOG.warning(f"Failed to set gain via {attr}: {e}") - continue + try: + node_map.GainAuto.value = "Off" + except Exception: + pass - LOG.warning(f"Could not set gain to {self._gain} (no compatible attribute found)") + try: + node_map.Gain.value = float(self._gain) + except Exception as e: + LOG.warning("Could not set gain to %s: %s", self._gain, e) def _configure_frame_rate(self, node_map) -> None: if not self.settings.fps: return target = float(self.settings.fps) - - # Try to enable frame rate control for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: getattr(node_map, attr).value = True - LOG.info(f"Frame rate control enabled via {attr}") break except Exception: - continue + pass - # Set frame rate value for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): try: - node = getattr(node_map, attr) + getattr(node_map, attr).value = target + return except AttributeError: continue - try: - node.value = target - actual = node.value - if abs(actual - target) > 0.1: - LOG.warning(f"FPS mismatch: requested {target:.2f}, got {actual:.2f}") - else: - LOG.info(f"Frame rate set to {actual:.2f} FPS") - return except Exception as e: - LOG.warning(f"Failed to set frame rate via {attr}: {e}") - continue + LOG.warning("Failed to set frame rate via %s: %s", attr, e) + LOG.warning("Could not set frame rate to %s FPS", target) + + def _read_telemetry(self, node_map) -> None: + try: + self._actual_width = int(node_map.Width.value) + self._actual_height = int(node_map.Height.value) + except Exception: + pass + + try: + self._actual_fps = float(node_map.ResultingFrameRate.value) + except Exception: + self._actual_fps = None - LOG.warning(f"Could not set frame rate to {target} FPS (no compatible attribute found)") + try: + self._actual_exposure = float(node_map.ExposureTime.value) + except Exception: + self._actual_exposure = None + + try: + self._actual_gain = float(node_map.Gain.value) + except Exception: + self._actual_gain = None + + # ------------------------------------------------------------------ + # Frame conversion / local helpers + # ------------------------------------------------------------------ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: if frame.dtype != np.uint8: @@ -1551,9 +1047,7 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: left = max(0, left) bottom = bottom if bottom > 0 else frame.shape[0] right = right if right > 0 else frame.shape[1] - bottom = min(frame.shape[0], bottom) - right = min(frame.shape[1], right) - frame = frame[top:bottom, left:right] + frame = frame[top : min(frame.shape[0], bottom), left : min(frame.shape[1], right)] if self._rotate in (90, 180, 270): rotations = { @@ -1566,11 +1060,10 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: return frame.copy() def _resolve_device_label(self, node_map) -> str | None: - candidates = [ + for name_attr, serial_attr in ( ("DeviceModelName", "DeviceSerialNumber"), ("DeviceDisplayName", "DeviceSerialNumber"), - ] - for name_attr, serial_attr in candidates: + ): try: model = getattr(node_map, name_attr).value except AttributeError: @@ -1581,18 +1074,48 @@ def _resolve_device_label(self, node_map) -> str | None: except AttributeError: pass if model: - model_str = str(model) - serial_str = str(serial) if serial else None - return f"{model_str} ({serial_str})" if serial_str else model_str + return f"{model} ({serial})" if serial else str(model) + return None + + def _parse_crop(self, crop) -> tuple[int, int, int, int] | None: + if isinstance(crop, (list, tuple)) and len(crop) == 4: + return tuple(int(v) for v in crop) + return None + + def _get_requested_resolution_or_none(self) -> tuple[int, int] | None: + props = self.settings.properties if isinstance(self.settings.properties, dict) else {} + legacy = props.get("resolution") + if isinstance(legacy, (list, tuple)) and len(legacy) == 2: + try: + w, h = int(legacy[0]), int(legacy[1]) + if w > 0 and h > 0: + return (w, h) + except Exception: + pass + + try: + w = int(getattr(self.settings, "width", 0) or 0) + h = int(getattr(self.settings, "height", 0) or 0) + if w > 0 and h > 0: + return (w, h) + except Exception: + pass return None - def _adjust_to_increment(self, value: int, minimum: int, maximum: int, increment: int) -> int: + @staticmethod + def _adjust_to_increment(value: int, minimum: int, maximum: int, increment: int) -> int: value = max(minimum, min(maximum, int(value))) if increment <= 0: return value - offset = value - minimum - steps = offset // increment - return minimum + steps * increment + return minimum + ((value - minimum) // increment) * increment + + @staticmethod + def _positive_float(value) -> float | None: + try: + number = float(value) + return number if number > 0 else None + except Exception: + return None def device_name(self) -> str: if self._device_label: From b2e53dc030fa97e79ac26879ec89d6e49dcfb16d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:40:40 +0200 Subject: [PATCH 05/58] Handle GenTL open errors & improve device matching Wrap harvester acquisition and camera initialization in a try/except to ensure resources are cleaned up on failure and to raise a clearer RuntimeError that includes loaded/failed CTIs and the original exception. Improve device selection logic: when a target_device_id or explicit serial is provided but not found, raise a descriptive error listing available serials; otherwise keep index-based selection and validate index range. --- dlclivegui/cameras/backends/gentl_backend.py | 102 +++++++++++-------- 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 8a06b2d6..728577a3 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -385,42 +385,54 @@ def open(self) -> None: with type(self)._OPEN_LOCK: loaded, failed = self._resolve_and_persist_ctis() - infos = self._acquire_shared_harvester(loaded) - if not infos: - self._reset_harvester() - raise RuntimeError( - "No GenTL cameras detected via Harvesters after loading producers.\n\n" - f"Loaded CTIs: {loaded}\n" - f"Failed CTIs: {failed}\n" - "Fix: ensure your camera vendor's GenTL producer is installed and working." - ) - - selected_index, selected_serial, selected_info = self._select_device(infos) - self.settings.index = int(selected_index) - - with self._shared_entry.lock: - self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) - node_map = self._acquirer.remote_device.node_map - self._device_label = self._resolve_device_label(node_map) - - self._configure_pixel_format(node_map) - self._configure_trigger(node_map) - self._configure_resolution(node_map) - self._configure_exposure(node_map) - self._configure_gain(node_map) - self._configure_frame_rate(node_map) - self._read_telemetry(node_map) - self._persist_device_metadata(selected_info, selected_serial) - - if self._fast_start: - LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") - return - - self._acquirer.start() + try: + infos = self._acquire_shared_harvester(loaded) + if not infos: + self._reset_harvester() + raise RuntimeError( + "No GenTL cameras detected via Harvesters after loading producers.\n\n" + f"Loaded CTIs: {loaded}\n" + f"Failed CTIs: {failed}\n" + "Fix: ensure your camera vendor's GenTL producer is installed and working." + ) - LOG.debug( - "Opened GenTL camera index=%s serial=%s label=%s", selected_index, selected_serial, self._device_label - ) + selected_index, selected_serial, selected_info = self._select_device(infos) + self.settings.index = int(selected_index) + + with self._shared_entry.lock: + self._acquirer = self._create_image_acquirer(selected_serial, int(selected_index)) + node_map = self._acquirer.remote_device.node_map + self._device_label = self._resolve_device_label(node_map) + + self._configure_pixel_format(node_map) + self._configure_trigger(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) + self._read_telemetry(node_map) + self._persist_device_metadata(selected_info, selected_serial) + + if self._fast_start: + LOG.info("GenTL open() in fast_start probe mode: acquisition not started.") + return + + self._acquirer.start() + + LOG.debug( + "Opened GenTL camera index=%s serial=%s label=%s", + selected_index, + selected_serial, + self._device_label, + ) + except Exception as exc: + try: + self.close() + except Exception: + pass + raise RuntimeError( + f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" + ) from exc def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: @@ -681,12 +693,20 @@ def _select_device(self, infos: list) -> tuple[int, str | None, object]: selected_serial: str | None = None if target_device_id: - selected_index, selected_serial = self._match_device(infos, str(target_device_id).strip()) - - if selected_index is None and self._serial_number: - selected_index, selected_serial = self._match_device(infos, str(self._serial_number).strip()) - - if selected_index is None: + target = str(target_device_id).strip() + selected_index, selected_serial = self._match_device(infos, target) + if selected_index is None: + available = [str(self._info_get(i, "serial_number", "") or "").strip() for i in infos] + raise RuntimeError(f"GenTL device '{target}' not found. Available serials: {available}") + + elif self._serial_number: + serial = str(self._serial_number).strip() + selected_index, selected_serial = self._match_device(infos, serial) + if selected_index is None: + available = [str(self._info_get(i, "serial_number", "") or "").strip() for i in infos] + raise RuntimeError(f"GenTL camera with serial '{serial}' not found. Available serials: {available}") + + else: if requested_index < 0 or requested_index >= len(infos): raise RuntimeError(f"Camera index {requested_index} out of range for {len(infos)} GenTL device(s)") selected_index = requested_index From ab2598e77944ea3d4cbba5a67ab22a4ec473ec1d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 11:48:37 +0200 Subject: [PATCH 06/58] Improve GenTL pixel format selection and conversion Add explicit lists of color and mono GenTL pixel formats and default pixel_format to "auto" with normalization. Rewrite _configure_pixel_format to handle missing PixelFormat node, choose a suitable format when "auto" is requested (prefer color formats, then mono, then first available), warn and fallback when a requested format is unavailable, and persist the selected format. Update frame postprocessing to use the normalized pixel format for proper Bayer demosaicing (BayerRG/GB/GR/BG) and correct RGB->BGR conversion while leaving BGR8 native. Minor logging message tweaks and added defensive checks to improve robustness. --- dlclivegui/cameras/backends/gentl_backend.py | 94 +++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 728577a3..8b52e49e 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -54,6 +54,20 @@ class GenTLCameraBackend(CameraBackend): r"C:\Program Files\The Imaging Source Europe GmbH\TIS Camera SDK\bin\win64_x64\*.cti", r"C:\Program Files (x86)\The Imaging Source Europe GmbH\TIS Grabber\bin\win64_x64\*.cti", ) + _COLOR_PIXEL_FORMATS: ClassVar[tuple[str, ...]] = ( + "BGR8", + "RGB8", + "BayerRG8", + "BayerGB8", + "BayerGR8", + "BayerBG8", + ) + _MONO_PIXEL_FORMATS: ClassVar[tuple[str, ...]] = ( + "Mono8", + "Mono10", + "Mono12", + "Mono16", + ) # Source marker stored in properties["gentl"]["cti_files_source"]. # auto: persisted by auto-discovery; may be stale and can fall back. @@ -77,7 +91,8 @@ def __init__(self, settings): self._device_id: str | None = str(raw_device_id).strip() if raw_device_id else None self._serial_number: str | None = self._serial_from_identity(self._device_id, legacy_serial) - self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8") + self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto") + self._pixel_format = str(self._pixel_format).strip() self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360 self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop"))) @@ -913,17 +928,56 @@ def _create_acquirer(self, serial: str | None, index: int): def _configure_pixel_format(self, node_map) -> None: try: - if self._pixel_format in node_map.PixelFormat.symbolics: - node_map.PixelFormat.value = self._pixel_format - actual = node_map.PixelFormat.value - if actual != self._pixel_format: - LOG.warning("Pixel format mismatch: requested '%s', got '%s'", self._pixel_format, actual) + pixel_format_node = getattr(node_map, "PixelFormat", None) + if pixel_format_node is None: + return + + available = list(getattr(pixel_format_node, "symbolics", []) or []) + if not available: + return + + requested = str(self._pixel_format or "auto").strip() + + if requested.lower() == "auto": + selected = None + + for fmt in self._COLOR_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + + if selected is None: + for fmt in self._MONO_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + + if selected is None: + selected = available[0] + else: - LOG.warning( - "Pixel format '%s' not in available formats: %s", self._pixel_format, node_map.PixelFormat.symbolics - ) + selected = requested + if selected not in available: + LOG.warning( + "Pixel format '%s' not available. Available formats: %s. Falling back to auto.", + selected, + available, + ) + selected = None + for fmt in self._COLOR_PIXEL_FORMATS + self._MONO_PIXEL_FORMATS: + if fmt in available: + selected = fmt + break + if selected is None: + selected = available[0] + + pixel_format_node.value = selected + self._pixel_format = str(pixel_format_node.value) + + LOG.debug("GenTL pixel format selected: %s", self._pixel_format) + except Exception as e: - LOG.warning("Failed to set pixel format '%s': %s", self._pixel_format, e) + LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) def _configure_trigger(self, node_map) -> None: try: @@ -1056,10 +1110,24 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: scale = 255.0 / max_val if max_val > 0.0 else 1.0 frame = np.clip(frame * scale, 0, 255).astype(np.uint8) + fmt = str(self._pixel_format or "").strip() + if frame.ndim == 2: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) - elif frame.ndim == 3 and frame.shape[2] == 3 and self._pixel_format == "RGB8": - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + if fmt == "BayerRG8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerRG2BGR) + elif fmt == "BayerGB8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerGB2BGR) + elif fmt == "BayerGR8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerGR2BGR) + elif fmt == "BayerBG8": + frame = cv2.cvtColor(frame, cv2.COLOR_BayerBG2BGR) + else: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + + elif frame.ndim == 3 and frame.shape[2] == 3: + if fmt == "RGB8": + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + # BGR8 is already OpenCV-native. if self._crop is not None: top, bottom, left, right = (int(v) for v in self._crop) From 95c864716690171e5e108752eedd2a4145fcb90a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 12:00:52 +0200 Subject: [PATCH 07/58] Expose shared GenTL harvester diagnostics Capture and surface CTI load diagnostics when acquiring the shared GenTL Harvester: record loaded and failed CTI files from the shared entry and from acquire-time exceptions, and slightly reformat the open() error message. Add a FakeSharedHarvesterPool test double (with FakeSharedEntry and custom acquire/release/refcount behavior) and integrate it into the test fixture patch_gentl_sdk so tests can exercise shared-harvester reuse, update counting and failure release semantics. Also adjust FakeImageAcquirer to clear its queue on start and to synthesize payloads when the queue is empty, wrap FakeHarvester.update to track update calls, and update tests to reflect new rebind/open behavior and to add coverage for shared harvester reuse and error propagation. --- dlclivegui/cameras/backends/gentl_backend.py | 20 +- tests/cameras/backends/conftest.py | 180 ++++++++++++++- tests/cameras/backends/test_gentl_backend.py | 231 +++++++++++-------- 3 files changed, 319 insertions(+), 112 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 8b52e49e..0952dac3 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -563,23 +563,41 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: try: self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded) self._harvester = self._shared_entry.harvester + + actual_loaded = list(getattr(self._shared_entry, "loaded_files", loaded)) + actual_failed = list(getattr(self._shared_entry, "failed_files", [])) + + ns["cti_files_loaded"] = actual_loaded + if actual_failed: + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in actual_failed] + with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) - ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded)) + LOG.debug( "Using shared GenTL Harvester for %d device(s), refcount=%s", len(infos), cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry), ) return infos + except Exception as exc: + exc_loaded = list(getattr(exc, "loaded_files", [])) + exc_failed = list(getattr(exc, "failed_files", [])) + + if exc_loaded or exc_failed: + ns["cti_files_loaded"] = [str(p) for p in exc_loaded] + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed] + if self._shared_entry is not None: try: cti_finder.SharedHarvesterPool.release(self._shared_entry) except Exception: pass + self._shared_entry = None self._harvester = None + raise RuntimeError( f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}" ) from exc diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 2d965a2e..8b6d08fa 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -4,6 +4,7 @@ import importlib import logging import os +import threading from dataclasses import dataclass from typing import Any @@ -641,6 +642,122 @@ def __exit__(self, exc_type, exc, tb): return False +class FakeSharedHarvesterPoolAcquireError(RuntimeError): + """Raised by the fake shared pool when no CTI can be loaded.""" + + def __init__(self, message: str, *, loaded_files=None, failed_files=None): + super().__init__(message) + self.loaded_files = list(loaded_files or []) + self.failed_files = list(failed_files or []) + + +class FakeSharedEntry: + def __init__(self, harvester, loaded_files, failed_files=None): + self.harvester = harvester + self.loaded_files = list(loaded_files or []) + self.failed_files = list(failed_files or []) + self.lock = threading.RLock() + + +class FakeSharedHarvesterPool: + """ + Test double for cti_finder.SharedHarvesterPool. + + Important behavior: + - Reuses one Harvester per normalized CTI set. + - Calls update() only when creating the shared Harvester. + - Does not call update() when reusing an existing shared Harvester. + - Tracks loaded_files/failed_files so backend diagnostics can be tested. + """ + + _entries: dict[tuple[str, ...], FakeSharedEntry] = {} + _refcounts: dict[tuple[str, ...], int] = {} + _harvester_factory = None + + @classmethod + def configure(cls, harvester_factory): + cls.reset() + cls._harvester_factory = harvester_factory + + @staticmethod + def _key(cti_files) -> tuple[str, ...]: + # Stable across case/path spelling on Windows while preserving loaded_files separately. + return tuple(os.path.normcase(os.path.abspath(str(p))) for p in cti_files) + + @classmethod + def acquire(cls, cti_files): + key = cls._key(cti_files) + + if key in cls._entries: + cls._refcounts[key] += 1 + return cls._entries[key] + + if cls._harvester_factory is None: + raise RuntimeError("FakeSharedHarvesterPool is not configured") + + h = cls._harvester_factory() + + loaded: list[str] = [] + failed: list[tuple[str, str]] = [] + + for cti in cti_files: + cti_str = str(cti) + try: + h.add_file(cti_str) + loaded.append(cti_str) + except Exception as exc: + failed.append((cti_str, str(exc))) + + if not loaded: + try: + h.reset() + except Exception: + pass + raise FakeSharedHarvesterPoolAcquireError( + "No fake CTIs could be loaded", + loaded_files=[], + failed_files=failed, + ) + + h.update() + + entry = FakeSharedEntry(h, loaded_files=loaded, failed_files=failed) + cls._entries[key] = entry + cls._refcounts[key] = 1 + return entry + + @classmethod + def release(cls, entry): + for key, value in list(cls._entries.items()): + if value is entry: + cls._refcounts[key] -= 1 + if cls._refcounts[key] <= 0: + try: + entry.harvester.reset() + except Exception: + pass + del cls._entries[key] + del cls._refcounts[key] + return + + @classmethod + def get_refcount(cls, entry): + for key, value in cls._entries.items(): + if value is entry: + return cls._refcounts[key] + return 0 + + @classmethod + def reset(cls): + for entry in list(cls._entries.values()): + try: + entry.harvester.reset() + except Exception: + pass + cls._entries.clear() + cls._refcounts.clear() + + @dataclass class FakeImageAcquirer: """ @@ -691,6 +808,7 @@ def _enqueue_default_frame(self): def start(self): self.start_calls += 1 self._started = True + self._queue.clear() def stop(self): self.stop_calls += 1 @@ -707,10 +825,28 @@ def fetch(self, timeout: float = 2.0): if not self._started: raise FakeGenTLTimeoutException("fetch called while not started") - if not self._queue: - raise FakeGenTLTimeoutException(f"timeout after {timeout}s") + if self._queue: + payload = self._queue.pop(0) + else: + # Generate from the current node map, because backend may have changed + # PixelFormat/Width/Height during open(). + pf = str(self.node_map.PixelFormat.value or "Mono8") + if pf in ("RGB8", "BGR8"): + channels, dtype = 3, np.uint8 + elif pf in ("Mono16", "Mono12", "Mono10"): + channels, dtype = 1, np.uint16 + else: + # Mono8 and Bayer*8 are single-channel uint8 + channels, dtype = 1, np.uint8 + + comp = _FakeComponent( + int(self.node_map.Width.value), + int(self.node_map.Height.value), + channels, + dtype=dtype, + ) + payload = _FakePayload(comp) - payload = self._queue.pop(0) return _FakeFetchedBufferCtx(payload) @@ -854,18 +990,37 @@ def gentl_fail_add_file_for(): def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for, tmp_path): """ Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout. - Ensure CTI discovery succeeds for classmethods by creating a real dummy .cti and - exposing it via GENICAM_GENTL64_PATH. + + Important: + The production backend now uses cti_finder.SharedHarvesterPool.acquire() + during open(), so tests must patch that pool too. """ import dlclivegui.cameras.backends.gentl_backend as gb - # Patch Harvester symbol (the backend calls Harvester() directly) + # Reset and expose test counters/state. + gb.update_count = 0 + gb.fail_add_file_for = gentl_fail_add_file_for + + # Patch Harvester symbol for discovery/rebind paths. monkeypatch.setattr(gb, "Harvester", lambda: fake_harvester_factory(), raising=False) - # Keep timeout contract + # Count all fake update() calls. + original_update = FakeHarvester.update + + def update_with_count(self): + gb.update_count += 1 + return original_update(self) + + monkeypatch.setattr(FakeHarvester, "update", update_with_count, raising=True) + + # Keep timeout contract. monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False) - # Create a real CTI file and advertise it via env var + # Patch the shared pool used by open(). + FakeSharedHarvesterPool.configure(fake_harvester_factory) + monkeypatch.setattr(gb.cti_finder, "SharedHarvesterPool", FakeSharedHarvesterPool, raising=False) + + # Create a real CTI file and advertise it via env var. cti_file = tmp_path / "dummy.cti" if not cti_file.exists(): cti_file.write_text("fake", encoding="utf-8") @@ -873,10 +1028,11 @@ def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for monkeypatch.setenv("GENICAM_GENTL64_PATH", str(tmp_path)) monkeypatch.delenv("GENICAM_GENTL32_PATH", raising=False) - # OPTIONAL: expose failure control so tests can do gb.fail_add_file_for.add(...) - gb.fail_add_file_for = gentl_fail_add_file_for - - return gb + try: + yield gb + finally: + FakeSharedHarvesterPool.reset() + gb.fail_add_file_for = set() @pytest.fixture() diff --git a/tests/cameras/backends/test_gentl_backend.py b/tests/cameras/backends/test_gentl_backend.py index ecef1b7a..3ffdab20 100644 --- a/tests/cameras/backends/test_gentl_backend.py +++ b/tests/cameras/backends/test_gentl_backend.py @@ -318,11 +318,12 @@ def test_quick_ping_true_for_existing_false_for_missing(patch_gentl_sdk, gentl_i assert gb.GenTLCameraBackend.quick_ping(1) is False -def test_rebind_settings_updates_index_using_device_id_with_attribute_entries( +def test_rebind_settings_serial_device_id_persists_serial_without_enumeration( patch_gentl_sdk, gentl_settings_factory, gentl_inventory ): """ - rebind_settings has some getattr(...) usage; feed attribute-like entries to match that path. + Serial GenTL IDs are handled directly by open(), so rebind_settings() + intentionally avoids Harvester enumeration and leaves index unchanged. """ gb = patch_gentl_sdk @@ -338,9 +339,12 @@ def test_rebind_settings_updates_index_using_device_id_with_attribute_entries( settings = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER1"}}) out = gb.GenTLCameraBackend.rebind_settings(settings) - assert int(out.index) == 1 + # New behavior: no enumeration/rebind for serial IDs. + assert int(out.index) == 0 + ns = out.properties.get("gentl", {}) assert ns.get("device_id") == "serial:SER1" + assert ns.get("serial_number") == "SER1" def test_rebind_settings_no_device_id_no_change(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): @@ -470,102 +474,6 @@ def create(self, *args, **kwargs): assert acq == "ACQ_POS_INDEX" -def test__create_acquirer_falls_back_to_create_image_acquirer_when_create_fails( - patch_gentl_sdk, gentl_settings_factory -): - gb = patch_gentl_sdk - - settings = gentl_settings_factory() - be = gb.GenTLCameraBackend(settings) - - class H: - device_info_list = [{"serial_number": "SER0"}] - - def create(self, *args, **kwargs): - raise RuntimeError("create fails") - - def create_image_acquirer(self, selector=None, index=None): - # Succeeds here - if isinstance(selector, dict) and selector.get("serial_number") == "SERX": - return "ACQ_CIA_SERIAL" - if index == 1: - return "ACQ_CIA_INDEX" - return "ACQ_CIA_OTHER" - - be._harvester = H() - acq = be._create_acquirer("SERX", 1) - assert acq == "ACQ_CIA_SERIAL" - - -def test__create_acquirer_uses_device_info_fallback_when_available(patch_gentl_sdk, gentl_settings_factory): - gb = patch_gentl_sdk - - settings = gentl_settings_factory() - be = gb.GenTLCameraBackend(settings) - - device_info_obj = {"serial_number": "SER0", "id_": "ID0"} - - class H: - device_info_list = [device_info_obj] - - def create(self, *args, **kwargs): - # Fail index, succeed if given device_info object - if "index" in kwargs or (len(args) == 1 and isinstance(args[0], int)): - raise RuntimeError("index path fails") - if len(args) == 1 and args[0] is device_info_obj: - return "ACQ_DEVICE_INFO" - raise RuntimeError("unexpected call") - - be._harvester = H() - acq = be._create_acquirer(None, 0) - assert acq == "ACQ_DEVICE_INFO" - - -def test__create_acquirer_tries_default_create_when_index0_and_no_serial(patch_gentl_sdk, gentl_settings_factory): - gb = patch_gentl_sdk - - settings = gentl_settings_factory() - be = gb.GenTLCameraBackend(settings) - - class H: - device_info_list = [{"serial_number": "SER0"}] - - def create(self, *args, **kwargs): - # Fail index attempts; succeed only on no-arg create() - if args or kwargs: - raise RuntimeError("only no-arg create works") - return "ACQ_DEFAULT" - - be._harvester = H() - acq = be._create_acquirer(None, 0) - assert acq == "ACQ_DEFAULT" - - -def test__create_acquirer_raises_runtimeerror_with_joined_errors(patch_gentl_sdk, gentl_settings_factory): - gb = patch_gentl_sdk - - settings = gentl_settings_factory() - be = gb.GenTLCameraBackend(settings) - - class H: - device_info_list = [{"serial_number": "SER0"}] - - def create(self, *args, **kwargs): - raise RuntimeError("create boom") - - def create_image_acquirer(self, *args, **kwargs): - raise RuntimeError("cia boom") - - be._harvester = H() - - with pytest.raises(RuntimeError) as ei: - be._create_acquirer("SERX", 0) - - # Error message should include some context about attempted creation methods - msg = str(ei.value).lower() - assert "failed to initialise gentl image acquirer" in msg - - # ---------------------------------- # CTI discovery and selection logic # ---------------------------------- @@ -767,3 +675,128 @@ def test_open_persists_cti_load_diagnostics_complete_failure(patch_gentl_sdk, ge assert sorted(d["cti"] for d in failed) == sorted([str(b1), str(b2)]) for d in failed: assert isinstance(d.get("error"), str) and d["error"] + + +def test_two_gentl_backends_share_same_harvester(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + {"display_name": "Dev0", "serial_number": "SER0"}, + {"display_name": "Dev1", "serial_number": "SER1"}, + ] + + s0 = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER0"}}) + s1 = gentl_settings_factory(index=1, properties={"gentl": {"device_id": "serial:SER1"}}) + + b0 = gb.GenTLCameraBackend(s0) + b1 = gb.GenTLCameraBackend(s1) + + b0.open() + b1.open() + + assert b0._harvester is b1._harvester + assert b0._shared_entry is b1._shared_entry + + b1.close() + b0.close() + + +def test_second_open_reuses_shared_harvester_without_update(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [ + {"display_name": "Dev0", "serial_number": "SER0"}, + {"display_name": "Dev1", "serial_number": "SER1"}, + ] + + s0 = gentl_settings_factory(index=0, properties={"gentl": {"device_id": "serial:SER0"}}) + s1 = gentl_settings_factory(index=1, properties={"gentl": {"device_id": "serial:SER1"}}) + + b0 = gb.GenTLCameraBackend(s0) + b1 = gb.GenTLCameraBackend(s1) + + b0.open() + update_count_after_first = gb.update_count + + b1.open() + + assert gb.update_count == update_count_after_first + + b1.close() + b0.close() + + +def test_open_failure_releases_shared_harvester(patch_gentl_sdk, gentl_settings_factory, gentl_inventory): + gb = patch_gentl_sdk + + gentl_inventory[:] = [{"display_name": "Dev0", "serial_number": "SER0"}] + + settings = gentl_settings_factory(properties={"gentl": {"device_id": "serial:DOES_NOT_EXIST"}}) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test__create_acquirer_serial_create_runtimeerror_propagates(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + raise RuntimeError("create fails") + + def create_image_acquirer(self, *args, **kwargs): + return "SHOULD_NOT_BE_USED" + + be._harvester = H() + + with pytest.raises(RuntimeError, match="create fails"): + be._create_acquirer("SERX", 1) + + +def test__create_acquirer_index_runtimeerror_propagates(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + if len(args) == 1 and args[0] == 0: + raise RuntimeError("index path fails") + return "UNEXPECTED" + + be._harvester = H() + + with pytest.raises(RuntimeError, match="index path fails"): + be._create_acquirer(None, 0) + + +def test__create_acquirer_positional_typeerror_falls_back_to_index_keyword(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class H: + device_info_list = [{"serial_number": "SER0"}] + + def create(self, *args, **kwargs): + if args: + raise TypeError("positional index not supported") + if kwargs.get("index") == 2: + return "ACQ_KW_INDEX" + raise RuntimeError("unexpected call") + + be._harvester = H() + + acq = be._create_acquirer(None, 2) + assert acq == "ACQ_KW_INDEX" From f2d0cc765c0ad0e1a2fc4803a08fd73229aca833 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:14:11 +0200 Subject: [PATCH 08/58] Handle CTI load failures and track failed files Treat failed CTI loads as a mapping of CTI->error and record failures when initializing the shared Harvester. gentl_discovery.py now logs exceptions, captures failed_files as a dict, and raises a runtime error if no CTI producers were successfully loaded; it also attaches loaded_files and failed_files to raised exceptions and attempts to reset the harvester. gentl_backend.py updated to consume failed_files as a dict (using .items()) when building reporting data. Added a helper to reset the harvester and propagate context for callers. --- dlclivegui/cameras/backends/gentl_backend.py | 10 ++++--- .../cameras/backends/utils/gentl_discovery.py | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 0952dac3..1bcce321 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -565,11 +565,13 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: self._harvester = self._shared_entry.harvester actual_loaded = list(getattr(self._shared_entry, "loaded_files", loaded)) - actual_failed = list(getattr(self._shared_entry, "failed_files", [])) + actual_failed = dict(getattr(self._shared_entry, "failed_files", {})) ns["cti_files_loaded"] = actual_loaded if actual_failed: - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in actual_failed] + ns["cti_files_failed"] = [ + {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() + ] with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) @@ -583,11 +585,11 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: except Exception as exc: exc_loaded = list(getattr(exc, "loaded_files", [])) - exc_failed = list(getattr(exc, "failed_files", [])) + exc_failed = dict(getattr(exc, "failed_files", {})) if exc_loaded or exc_failed: ns["cti_files_loaded"] = [str(p) for p in exc_loaded] - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed] + ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()] if self._shared_entry is not None: try: diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index 9cc24dcb..9d15a829 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -5,6 +5,7 @@ from __future__ import annotations import glob +import logging import os import threading from collections.abc import Iterable, Sequence @@ -24,6 +25,8 @@ class GenTLDiscoveryPolicy(Enum): except Exception: # pragma: no cover - optional dependency Harvester = None # type: ignore +logger = logging.getLogger(__name__) + class SharedHarvesterEntry: """ @@ -41,13 +44,34 @@ def __init__(self, cti_files: list[str]): self.refcount = 0 self.harvester = Harvester() self.loaded_files: list[str] = [] + self.failed_files: dict[str, str] = {} for cti in self.key: - self.harvester.add_file(cti) - self.loaded_files.append(cti) + try: + self.harvester.add_file(cti) + self.loaded_files.append(cti) + except Exception as e: + logger.exception(f"Failed to load CTI file: {cti}. Skipping.") + self.failed_files[cti] = str(e) + + if not self.loaded_files: + e = RuntimeError("No GenTL producer (.cti) could be loaded by shared Harvester.") + self._raise_and_reset_harvester(e) # Initial device enumeration. - self.harvester.update() + try: + self.harvester.update() + except Exception as e: + self._raise_and_reset_harvester(e) + + def _raise_and_reset_harvester(self, exc: Exception) -> None: + exc.loaded_files = self.loaded_files[:] + exc.failed_files = dict(self.failed_files) + try: + self.harvester.reset() + except Exception: + pass + raise exc class SharedHarvesterPool: From 02bcbb9ebe2fe95625f6f9f98740151b28b82b81 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:16:16 +0200 Subject: [PATCH 09/58] Improve duplicate camera detection and reporting Change seen from a set to a mapping of identity key -> camera_id to record which camera produced each key. Add get_camera_id and fallback to camera_id if camera_identity_key raises, logging the exception. Emit a more informative initialization_failed message that includes the camera_id and the conflicting camera, improving diagnostics and robustness when computing identity keys. --- .../services/multi_camera_controller.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 7d5b5669..7058d59a 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -185,13 +185,30 @@ def start(self, camera_settings: list[CameraSettings]) -> None: return # Check for dupes - seen = set() + seen = {} for s in active_settings: - key = camera_identity_key(s) + camera_id = get_camera_id(s) + try: + key = camera_identity_key(s) + except Exception: + LOGGER.exception( + "Failed to compute camera identity key for %s; falling back to camera_id", + camera_id, + ) + key = camera_id + if key in seen: - self.initialization_failed.emit([(key, "Duplicate camera configuration")]) + self.initialization_failed.emit( + [ + ( + camera_id, + f"Duplicate camera configuration. Conflicts with {seen[key]}", + ) + ] + ) return - seen.add(key) + + seen[key] = camera_id self._running = True self._frames.clear() From 92dd6240c582c088b545bb230046922522bac1f4 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:17:47 +0200 Subject: [PATCH 10/58] Sort file paths in FakeSharedHarvesterPool._key Make the pool key order-independent by sorting normalized absolute file paths before forming the tuple. This prevents different keys for the same set of CTI files when they are provided in a different order (while preserving normcase/abspath normalization). --- tests/cameras/backends/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 8b6d08fa..9459c35e 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -682,7 +682,7 @@ def configure(cls, harvester_factory): @staticmethod def _key(cti_files) -> tuple[str, ...]: # Stable across case/path spelling on Windows while preserving loaded_files separately. - return tuple(os.path.normcase(os.path.abspath(str(p))) for p in cti_files) + return tuple(sorted(os.path.normcase(os.path.abspath(str(p))) for p in cti_files)) @classmethod def acquire(cls, cti_files): From 5f56a5f43506a1ca38be1c7f3dd97995fbf07ecf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:35:24 +0200 Subject: [PATCH 11/58] Merge failed CTI files instead of overwrite Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/cameras/backends/gentl_backend.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 1bcce321..ad86f8c7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -569,9 +569,12 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: ns["cti_files_loaded"] = actual_loaded if actual_failed: - ns["cti_files_failed"] = [ + existing_failed = ns.get("cti_files_failed") + merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] + merged_failed.extend( {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() - ] + ) + ns["cti_files_failed"] = merged_failed with self._shared_entry.lock: infos = list(self._harvester.device_info_list or []) From 9b77c42e05d4ea4f74d8fb3cd9c0e8d1555a8117 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 14:35:51 +0200 Subject: [PATCH 12/58] Avoid overwriting failed CTIs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/cameras/backends/gentl_backend.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index ad86f8c7..3ba2985f 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -592,7 +592,10 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: if exc_loaded or exc_failed: ns["cti_files_loaded"] = [str(p) for p in exc_loaded] - ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()] + existing_failed = ns.get("cti_files_failed") + merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] + merged_failed.extend({"cti": str(cti), "error": str(error)} for cti, error in exc_failed.items()) + ns["cti_files_failed"] = merged_failed if self._shared_entry is not None: try: From 90241f67d94517ce93d9dfb984be8556fd0fca7d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 16:12:54 +0200 Subject: [PATCH 13/58] pre-commit --- dlclivegui/cameras/backends/gentl_backend.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 3ba2985f..b55abdf7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -571,9 +571,7 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list: if actual_failed: existing_failed = ns.get("cti_files_failed") merged_failed = list(existing_failed) if isinstance(existing_failed, list) else [] - merged_failed.extend( - {"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items() - ) + merged_failed.extend({"cti": str(cti), "error": str(error)} for cti, error in actual_failed.items()) ns["cti_files_failed"] = merged_failed with self._shared_entry.lock: From 760825f35dd0774a42d9af049baa4f6be77dcf55 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:11:16 +0200 Subject: [PATCH 14/58] Revert display name change --- dlclivegui/services/multi_camera_controller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 7058d59a..ad1a8636 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -121,6 +121,10 @@ def stop(self) -> None: self._stop_event.set() +def get_display_id(settings: CameraSettings) -> str: + return f"{settings.backend}:{settings.index}" + + def get_camera_id(settings: CameraSettings) -> str: """Generate a unique camera ID from stable backend identity.""" backend = (settings.backend or "").lower() @@ -223,7 +227,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" settings_copy = copy.deepcopy(settings) - cam_id = get_camera_id(settings_copy) + cam_id = get_display_id(settings_copy) if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return From 2e54469c3e4f923a9be5a44a1cdc054a32b155b6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:41:35 +0200 Subject: [PATCH 15/58] Use get_display_id in multicam test Update tests/services/test_multicam_controller.py to import and use get_display_id instead of get_camera_id when accessing mfd.frames. This aligns the test with the frames dict keys (display IDs) and the updated multi_camera_controller API. --- tests/services/test_multicam_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index eca50218..0893bd9f 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,7 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id @pytest.mark.unit @@ -60,7 +60,7 @@ def test_rotation_and_crop(qtbot, patch_factory): last_shape = {"shape": None} def on_ready(mfd): - f = mfd.frames.get(get_camera_id(cam)) + f = mfd.frames.get(get_display_id(cam)) if f is not None: last_shape["shape"] = f.shape From ece0005d734e98e29cca4ff1feb41b62fcdee313 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:16 +0200 Subject: [PATCH 16/58] Add GenTL hardware trigger support Add generic hardware-trigger support for GenTL cameras and expose trigger settings in CameraSettings. - Introduce CameraTriggerSettings model (config.py) with role/input/output/timeout/strict and helpers to coerce and serialize. - Allow CameraSettings to read/write backend-specific trigger options. - GenTLCameraBackend: import and use trigger settings, apply trigger timeout override, persist actual trigger config, and mark hardware_trigger capability as BEST_EFFORT. - Implement trigger configuration paths: off, external/follower (input), and master (output). Add helper methods (_set_enum_node, _node, _node_symbolics, _trigger_attr, _trigger_to_dict) to safely interact with GenICam nodes. - Improve lifecycle handling: configure trigger after other settings, restore a safe non-triggering state on stop, and provide waits_for_hardware_trigger property and clearer timeout messages when waiting for hardware triggers. - Add DEFAULT_CAPABILITIES entry for hardware_trigger in base defaults. These changes enable safer and configurable hardware-trigger operation for GenTL backends and improve error reporting and shutdown behavior. --- dlclivegui/cameras/backends/gentl_backend.py | 180 ++++++++++++++++++- dlclivegui/cameras/base.py | 1 + dlclivegui/config.py | 96 ++++++++++ 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index b55abdf7..18c17d23 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -7,11 +7,12 @@ import threading import time from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar import cv2 import numpy as np +from ...config import CameraTriggerSettings from ..base import CameraBackend, SupportLevel, register_backend from ..factory import DetectedCamera from .utils import gentl_discovery as cti_finder @@ -105,6 +106,16 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + except Exception as exc: + LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + self._trigger = CameraTriggerSettings() + + trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) + if trigger_timeout is not None: + self._timeout = float(trigger_timeout) + self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() self._actual_width: int | None = None @@ -154,6 +165,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "set_gain": SupportLevel.SUPPORTED, "device_discovery": SupportLevel.SUPPORTED, "stable_identity": SupportLevel.SUPPORTED, + "hardware_trigger": SupportLevel.BEST_EFFORT, } # ------------------------------------------------------------------ @@ -420,11 +432,12 @@ def open(self) -> None: self._device_label = self._resolve_device_label(node_map) self._configure_pixel_format(node_map) - self._configure_trigger(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) self._configure_gain(node_map) self._configure_frame_rate(node_map) + self._configure_trigger(node_map) # keep low in the list + self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -449,6 +462,11 @@ def open(self) -> None: f"Failed to open GenTL camera.\n\nLoaded CTIs: {loaded}\nFailed CTIs: {failed}\nReason: {exc}" ) from exc + @property + def waits_for_hardware_trigger(self) -> bool: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + return role in {"external", "follower"} + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -470,6 +488,9 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + if role in {"external", "follower"}: + raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc frame = self._convert_frame(frame) @@ -502,6 +523,12 @@ def close(self) -> None: except Exception: pass + try: + node_map = self._acquirer.remote_device.node_map + self._call_with_optional_lock(self._restore_trigger_idle, node_map) + except Exception: + pass + try: destroy = getattr(self._acquirer, "destroy", None) if destroy is not None: @@ -949,6 +976,62 @@ def _create_acquirer(self, serial: str | None, index: int): # ------------------------------------------------------------------ # Camera configuration helpers # ------------------------------------------------------------------ + @staticmethod + def _node(node_map, name: str): + try: + return getattr(node_map, name) + except Exception: + return None + + @staticmethod + def _node_symbolics(node) -> list[str]: + try: + return list(getattr(node, "symbolics", []) or []) + except Exception: + return [] + + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: + node = self._node(node_map, name) + if node is None: + if strict: + raise RuntimeError(f"GenICam node '{name}' is not available") + LOG.debug("GenICam node '%s' is not available; skipping", name) + return False + + symbolics = self._node_symbolics(node) + if symbolics and value not in symbolics: + if strict: + raise RuntimeError(f"GenICam node '{name}' does not support '{value}'. Available: {symbolics}") + LOG.warning("GenICam node '%s' does not support '%s'. Available: %s", name, value, symbolics) + return False + + try: + node.value = value + return True + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set GenICam node '{name}' to '{value}': {exc}") from exc + LOG.warning("Failed to set GenICam node '%s' to '%s': %s", name, value, exc) + return False + + @staticmethod + def _trigger_attr(trigger, name: str, default=None): + if isinstance(trigger, dict): + return trigger.get(name, default) + return getattr(trigger, name, default) + + @staticmethod + def _trigger_to_dict(trigger) -> dict[str, Any]: + if trigger is None: + return {} + if isinstance(trigger, dict): + return dict(trigger) + if hasattr(trigger, "model_dump"): + try: + return trigger.model_dump(exclude_none=True) + except Exception: + pass + return {} def _configure_pixel_format(self, node_map) -> None: try: @@ -1004,12 +1087,95 @@ def _configure_pixel_format(self, node_map) -> None: LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e) def _configure_trigger(self, node_map) -> None: + cfg = self._trigger + role = str(self._trigger_attr(cfg, "role", "off") or "off").strip().lower() + strict = bool(self._trigger_attr(cfg, "strict", False)) + + if role in {"off", "disabled"}: + self._configure_trigger_off(node_map, strict=strict) + return + + if role in {"external", "follower"}: + self._configure_trigger_input(node_map, cfg, strict=strict) + return + + if role == "master": + self._configure_trigger_master(node_map, cfg, strict=strict) + return + + if strict: + raise RuntimeError(f"Unsupported GenTL trigger role: {role!r}") + + LOG.warning("Unsupported GenTL trigger role '%s'; disabling trigger.", role) + self._configure_trigger_off(node_map, strict=False) + + def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) + + def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") + source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") + activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + + # Disable trigger while changing trigger-related nodes. + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) + + if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): + if strict: + raise RuntimeError("Could not enable GenTL TriggerMode=On") + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", + self._trigger_attr(cfg, "role", "external"), + selector, + source, + activation, + ) + + def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") + output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") + + # Master camera runs freerun and exposes an output signal. + self._configure_trigger_off(node_map, strict=False) + + self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + LOG.info( + "GenTL trigger master configured: output_line=%s output_source=%s", + output_line, + output_source, + ) + + def _restore_trigger_idle(self, node_map) -> None: + """Best-effort restore to a safe non-triggering state after acquisition stops. + + Important: + - This should be called after acquirer.stop(), not while acquisition is active. + - It is intentionally non-strict because shutdown should not fail if a node + is missing or read-only. + """ + role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() + try: - trigger_mode = getattr(node_map, "TriggerMode", None) - if trigger_mode is not None and "Off" in getattr(trigger_mode, "symbolics", []): - trigger_mode.value = "Off" - except Exception as e: - LOG.warning("Failed to disable trigger mode: %s", e) + if role in {"external", "follower"}: + self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) + + elif role == "master": + # Stop driving output if the camera exposes these nodes. + self._set_enum_node(node_map, "LineSource", "Off", strict=False) + self._set_enum_node(node_map, "LineMode", "Input", strict=False) + + except Exception: + LOG.debug("Best-effort GenTL trigger restore failed", exc_info=True) def _configure_resolution(self, node_map) -> None: if self._requested_resolution is None: diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index bc91ce9b..fefedd1d 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -70,6 +70,7 @@ class SupportLevel(str, Enum): "set_gain": SupportLevel.UNSUPPORTED, "device_discovery": SupportLevel.UNSUPPORTED, "stable_identity": SupportLevel.UNSUPPORTED, + "hardware_trigger": SupportLevel.UNSUPPORTED, } diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6d9e1de7..154eabf4 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -12,6 +12,8 @@ TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] ModelType = Literal["pytorch", "tensorflow"] +TriggerRole = Literal["off", "external", "master", "follower"] +TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] class CameraSettings(BaseModel): @@ -168,6 +170,100 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict: pass return out + def backend_options(self, backend: str | None = None) -> dict[str, Any]: + key = backend or self.backend + props = self.properties if isinstance(self.properties, dict) else {} + ns = props.get(str(key).lower(), {}) + return ns if isinstance(ns, dict) else {} + + def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings: + ns = self.backend_options(backend) + return CameraTriggerSettings.from_any(ns.get("trigger")) + + def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None: + key = backend or self.backend + if not isinstance(self.properties, dict): + self.properties = {} + ns = self.properties.setdefault(str(key).lower(), {}) + if not isinstance(ns, dict): + ns = {} + self.properties[str(key).lower()] = ns + ns["trigger"] = trigger.to_properties() + + +class CameraTriggerSettings(BaseModel): + """ + Generic hardware-trigger settings. + + Backend-specific code may ignore fields that are unsupported by a given + camera/SDK. For GenTL, these map to common GenICam nodes such as: + TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, + LineSelector, LineMode, and LineSource. + """ + + role: TriggerRole = "off" + + # Input trigger config: external/follower + selector: str = "FrameStart" + source: str = "Line0" + activation: TriggerActivation | str = "RisingEdge" + + # Output config: master + output_line: str = "Line2" + output_source: str = "ExposureActive" + + # Runtime behavior + timeout: float | None = None + strict: bool = False + + @field_validator("role", mode="before") + @classmethod + def _coerce_role(cls, v): + if v is None: + return "off" + + s = str(v).strip().lower() + aliases = { + "": "off", + "none": "off", + "false": "off", + "disabled": "off", + "disable": "off", + "off": "off", + "true": "external", + "on": "external", + "trigger": "external", + "triggered": "external", + "external": "external", + "follower": "follower", + "slave": "follower", + "master": "master", + "main": "master", + } + return aliases.get(s, s) + + @field_validator("timeout", mode="before") + @classmethod + def _coerce_timeout(cls, v): + if v in (None, ""): + return None + try: + fv = float(v) + except Exception: + return None + return fv if fv > 0 else None + + @classmethod + def from_any(cls, value) -> CameraTriggerSettings: + if isinstance(value, cls): + return value + if isinstance(value, dict): + return cls(**value) + return cls() + + def to_properties(self) -> dict[str, Any]: + return self.model_dump(exclude_none=True) + class MultiCameraSettings(BaseModel): cameras: list[CameraSettings] = Field(default_factory=list) From de99825ab04fbf96dbf7376c4920afae65c75918 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:44 +0200 Subject: [PATCH 17/58] Handle camera trigger defaults & trigger-aware startup Populate gentl trigger defaults on save and allow saving configs with empty DLCLive model path. Add _with_camera_defaults_for_save to ensure CameraTriggerSettings are present for gentl cameras and thread through allow_empty_model_path in _dlc_settings_from_ui/_current_config so configs can be saved without a model while preserving existing DLC fields. Improve multi-camera runtime robustness: treat TimeoutError from hardware-trigger backends as an expected 'no trigger' event (don't count as a camera failure) and add _trigger_role_from_settings/_camera_start_priority helpers. Start active cameras sorted by trigger role so trigger-waiting (external/follower) devices are armed before masters. Also extend DLCLive configuration error handling to include RuntimeError. --- dlclivegui/gui/main_window.py | 47 ++++++++++++-- .../services/multi_camera_controller.py | 64 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0e3beb0e..8a66e6ab 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,6 +56,7 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, + CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -854,15 +855,36 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _current_config(self) -> ApplicationSettings: + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + for cam in out.cameras: + backend = (cam.backend or "").lower() + if backend != "gentl": + continue + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility + multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) active_cameras = self._config.multi_camera.get_active_cameras() camera = active_cameras[0] if active_cameras else CameraSettings() return ApplicationSettings( camera=camera, - multi_camera=self._config.multi_camera, - dlc=self._dlc_settings_from_ui(), + multi_camera=multi_camera, + dlc=self._dlc_settings_from_ui(allow_empty_model_path=allow_empty_model_path), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), visualization=self._visualization_settings_from_ui(), @@ -874,13 +896,24 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessorSettings: model_path = self.model_path_edit.text().strip() if Path(model_path).exists() and Path(model_path).suffix == ".pb": # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) - if model_path == "": + if not model_path: + if allow_empty_model_path: + return DLCProcessorSettings( + model_path="", + model_directory=self._config.dlc.model_directory, # Preserve from config + device=self._config.dlc.device, # Preserve from config + dynamic=self._config.dlc.dynamic, # Preserve from config + resize=self._config.dlc.resize, # Preserve from config + precision=self._config.dlc.precision, # Preserve from config + model_type=None, + # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + ) raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) @@ -965,7 +998,7 @@ def _action_save_config_as(self) -> None: def _save_config_to_path(self, path: Path) -> None: try: - config = self._current_config() + config = self._current_config(allow_empty_model_path=True) config.save(path) self._settings_store.set_last_config_path(str(path)) self._settings_store.save_full_config_snapshot(config) @@ -1611,7 +1644,7 @@ def _stop_preview(self) -> None: def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() - except (ValueError, json.JSONDecodeError) as exc: + except (ValueError, RuntimeError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") return False if not settings.model_path: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index ad1a8636..778a86e9 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -99,6 +99,29 @@ def run(self) -> None: consecutive_errors = 0 self.frame_captured.emit(self._camera_id, frame, timestamp) + except TimeoutError as exc: + if self._stop_event.is_set(): + break + + # In hardware-trigger mode, a timeout usually means: + # "no trigger pulse arrived during this poll interval". + # This is expected and should not count as a camera failure. + if bool(getattr(self._backend, "waits_for_hardware_trigger", False)): + LOGGER.debug( + "[Worker %s] waiting for hardware trigger: %s", + self._camera_id, + exc, + ) + consecutive_errors = 0 + continue + + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") + break + time.sleep(self._retry_delay) + continue + except Exception as exc: consecutive_errors += 1 if self._stop_event.is_set(): @@ -142,6 +165,46 @@ def get_camera_id(settings: CameraSettings) -> str: return f"{backend}:index:{int(settings.index)}" +def _trigger_role_from_settings(settings: CameraSettings) -> str: + backend = (settings.backend or "").lower() + props = settings.properties if isinstance(settings.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + + role = str(trigger.get("role", "off") or "off").strip().lower() + + # Match CameraTriggerSettings aliases enough for controller ordering. + if role in {"true", "on", "trigger", "triggered"}: + return "external" + if role in {"slave"}: + return "follower" + if role in {"main"}: + return "master" + if role in {"false", "none", "disabled", "disable", ""}: + return "off" + + return role + + +def _camera_start_priority(settings: CameraSettings) -> int: + """Start trigger-waiting cameras before trigger-generating cameras. + + Priority: + 0: external/follower cameras, which should be armed first + 1: normal/free-run cameras + 2: master cameras, which may generate trigger pulses + """ + role = _trigger_role_from_settings(settings) + if role in {"external", "follower"}: + return 0 + if role == "master": + return 2 + return 1 + + class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" @@ -184,6 +247,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: return active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + active_settings = sorted(active_settings, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return From a27a1f26e40ee7f6ca0e267ddc2164031ef78fe6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:13:55 +0200 Subject: [PATCH 18/58] Improve signal handling and graceful shutdown Enhance Qt app signal handling to support SIGTERM and SIGBREAK, and make Ctrl+C shutdown more robust. Adds a quitting flag and a two-stage interrupt: first interrupt triggers window close and schedules app.quit, second interrupt forces immediate exit (os._exit(130)). Parents the keepalive QTimer to the QApplication, sets a 100ms interval, and safely stops any previous timer to avoid duplicates. Adds logging and exception handling around window close and timer cleanup, and uses QTimer.singleShot to ensure Qt leaves its event loop even if closeEvent cleanup is asynchronous. --- dlclivegui/main.py | 60 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index eace8bda..9e38a08c 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -27,32 +27,70 @@ def _maybe_allow_keyboard_interrupt(app: QApplication) -> None: """ - Gracefully handle Ctrl+C (SIGINT) by closing the main window and quitting Qt. + Gracefully handle Ctrl+C/SIGTERM by closing the main window and quitting Qt. + + Notes: + - The small timer keeps Python signal handling responsive while Qt owns the event loop. + - First Ctrl+C tries graceful cleanup via closeEvent(). + - Second Ctrl+C exits immediately with code 130. """ + quitting = {"requested": False} def _request_quit() -> None: + if quitting["requested"]: + return + + quitting["requested"] = True logging.info("Keyboard interrupt received, closing application...") + win = getattr(app, "_main_window", None) + if win is not None: - # Trigger your existing closeEvent cleanup (camera stop, threads, timers, etc.) - win.close() - else: - app.quit() + try: + # Trigger existing closeEvent cleanup: + # camera stop, controller shutdown, timers, DLC shutdown, etc. + win.close() + except Exception: + logging.exception("Error while closing main window after Ctrl+C") + + # Explicitly ask Qt to leave app.exec(). + # Do this even after win.close(), because closeEvent cleanup can be async + # and relying only on quitOnLastWindowClosed can be fragile. + QTimer.singleShot(0, app.quit) + + def _force_exit() -> None: + logging.warning("Second interrupt received, forcing process exit.") + os._exit(130) def _sigint_handler(_signum, _frame) -> None: + if quitting["requested"]: + _force_exit() QTimer.singleShot(0, _request_quit) signal.signal(signal.SIGINT, _sigint_handler) - # Keepalive timer to allow Python to handle signals while Qt is running. - sig_timer = QTimer() - sig_timer.setInterval(100) # 50–200ms typical; keep low overhead + # Ctrl+Break on Windows. + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _sigint_handler) + + # Useful when process is terminated from shells/process managers. + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _sigint_handler) + + # Parent the timer to app so Qt owns its lifetime. + sig_timer = QTimer(app) + sig_timer.setInterval(100) sig_timer.timeout.connect(lambda: None) sig_timer.start() - if hasattr(app, "_sig_timer"): - app._sig_timer.stop() # Stop any existing timer to avoid duplicates - app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + old_timer = getattr(app, "_sig_timer", None) + if old_timer is not None: + try: + old_timer.stop() + except Exception: + pass + + app._sig_timer = sig_timer def configure_logging(debug: bool = False) -> None: From 4e6dac0ff7801de836690ad7ab0f236d4f6c9a67 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:14:12 +0200 Subject: [PATCH 19/58] Add GenTL trigger tests and fake node map Add a comprehensive test suite for GenTL hardware trigger handling (tests/cameras/backends/test_gentl_trigger.py). Tests cover trigger roles (off/external/follower/master), selector/source/activation settings, strict vs non-strict behavior for invalid sources, master output configuration, alias mapping, timeout handling and error messaging, and persistence of trigger_actual for debugging. Update the test conftest fake node map (tests/cameras/backends/conftest.py) to include Trigger* and Line* nodes (AcquisitionMode, TriggerSelector, TriggerMode, TriggerSource, TriggerActivation, LineSelector, LineMode, LineSource) so the tests can exercise trigger and GPIO-related configuration. --- tests/cameras/backends/conftest.py | 12 + tests/cameras/backends/test_gentl_trigger.py | 333 +++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 tests/cameras/backends/test_gentl_trigger.py diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index 9459c35e..f21bf481 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -599,6 +599,18 @@ def __init__( self.GainAuto = _FakeNode("Off") self.Gain = _FakeNode(float(gain)) + # Trigger input nodes + self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) + self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) + self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) + self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) + + # GPIO output nodes for master/follower setups + self.LineSelector = _FakeNode("Line0", symbolics=["Line0", "Line1", "Line2"]) + self.LineMode = _FakeNode("Input", symbolics=["Input", "Output"]) + self.LineSource = _FakeNode("Off", symbolics=["Off", "ExposureActive", "AcquisitionActive"]) + class _FakeRemoteDevice: def __init__(self, node_map: _FakeNodeMap): diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py new file mode 100644 index 00000000..f0d5f452 --- /dev/null +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -0,0 +1,333 @@ +# tests/cameras/backends/test_gentl_trigger.py +from __future__ import annotations + +import pytest + +# --------------------------------------------------------------------- +# GenTL hardware trigger configuration +# --------------------------------------------------------------------- + + +def _gentl_trigger_settings(gentl_settings_factory, trigger: dict, **kwargs): + """Build CameraSettings with a GenTL trigger block.""" + return gentl_settings_factory(properties={"gentl": {"trigger": trigger}}, **kwargs) + + +def test_gentl_capabilities_advertise_hardware_trigger_best_effort(patch_gentl_sdk): + gb = patch_gentl_sdk + + caps = gb.GenTLCameraBackend.static_capabilities() + + assert caps.get("hardware_trigger") == gb.SupportLevel.BEST_EFFORT + + +def test_trigger_default_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = gentl_settings_factory() + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + + ns = settings.properties.get("gentl", {}) + assert ns.get("trigger_actual", {}).get("role") == "off" + + be.close() + + +def test_trigger_explicit_off_configures_trigger_mode_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings(gentl_settings_factory, {"role": "off"}) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "off" + + be.close() + + +def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "FrameStart", + "source": "Line0", + "activation": "RisingEdge", + "timeout": 10.0, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line0" + assert nm.TriggerActivation.value == "RisingEdge" + assert nm.TriggerMode.value == "On" + assert be._timeout == pytest.approx(10.0) + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "external" + assert ns["trigger_actual"]["source"] == "Line0" + + be.close() + + +def test_trigger_follower_configures_input_line(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "selector": "FrameStart", + "source": "Line1", + "activation": "FallingEdge", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerSelector.value == "FrameStart" + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerActivation.value == "FallingEdge" + assert nm.TriggerMode.value == "On" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "follower" + + be.close() + + +def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "ExposureActive", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + assert nm.LineSource.value == "ExposureActive" + + ns = settings.properties["gentl"] + assert ns["trigger_actual"]["role"] == "master" + assert ns["trigger_actual"]["output_line"] == "Line2" + + be.close() + + +def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Source was unsupported, so the fake node should retain its default. + assert nm.TriggerSource.value == "Line0" + # Non-strict mode should still allow opening; TriggerMode may be enabled + # because TriggerSource failure is best-effort in this mode. + assert be._acquirer is not None + + be.close() + + +def test_trigger_invalid_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "source": "LineDoesNotExist", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_invalid_master_output_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "Off" + assert nm.LineSelector.value == "Line2" + assert nm.LineMode.value == "Output" + # Unsupported source should not be applied in non-strict mode. + assert nm.LineSource.value == "Off" + + be.close() + + +def test_trigger_invalid_master_output_source_strict_raises_and_cleans_up(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "output_line": "Line2", + "output_source": "NotARealLineSource", + "strict": True, + }, + ) + be = gb.GenTLCameraBackend(settings) + + with pytest.raises(RuntimeError): + be.open() + + assert be._harvester is None + assert be._shared_entry is None + assert be._acquirer is None + + +def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "on", + "source": "Line1", + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + assert nm.TriggerMode.value == "On" + assert nm.TriggerSource.value == "Line1" + assert settings.properties["gentl"]["trigger_actual"]["role"] == "external" + + be.close() + + +def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + assert be._timeout == pytest.approx(7.5) + + # Fake acquisition is started, so read should pass and record the timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + + be.close() + + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "timeout": 3.0, + }, + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. + ) + settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) + + be.open() + + with pytest.raises(TimeoutError) as ei: + be.read() + + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + be.close() + + +def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "follower", + "source": "Line1", + "activation": "FallingEdge", + "timeout": 9.0, + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) + + be.close() From 3e40d948349b1c1550ac8fc1cfce69ce534e05a5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:03 +0200 Subject: [PATCH 20/58] Respect user camera order for display/tiling Use the user-configured camera order for display/tiling and ensure tiling geometry matches displayed frames. Changes: - GUI: populate the inference camera dropdown from active cameras in the configured order and only add entries for cameras that are actually running. - MultiCameraController: store the user display order, derive startup order by sorting for trigger safety (followers/external first, master last), emit frames/timestamps in display order (with any unexpected IDs appended deterministically), clear display order on stop, and handle no-active-cameras early. - Utils: compute_tiling_geometry now uses the frames' display order (insertion order) rather than sorted keys so tile dimensions and overlays align with the tiled frame; updated docstrings to reflect this. These changes ensure consistent tiling, overlay transforms, and UI behavior that follow the user's configured ordering. --- dlclivegui/gui/main_window.py | 6 ++- .../services/multi_camera_controller.py | 38 ++++++++++++++++--- dlclivegui/utils/display.py | 10 ++--- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 8a66e6ab..def60941 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1298,8 +1298,10 @@ def _refresh_dlc_camera_list_running(self) -> None: """Populate the inference camera dropdown from currently running cameras.""" self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() - for cam_id in sorted(self._running_cams_ids): - self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) + for cam in self._config.multi_camera.get_active_cameras(): + cam_id = get_camera_id(cam) + if cam_id in self._running_cams_ids: + self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) # Keep current selection if still present, else select first running if self._inference_camera_id in self._running_cams_ids: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 778a86e9..17af14e6 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -229,6 +229,7 @@ def __init__(self): self._frame_lock = Lock() self._running = False self._started_cameras: set = set() + self._camera_display_order: list[str] = [] self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start @@ -246,8 +247,17 @@ def start(self, camera_settings: list[CameraSettings]) -> None: LOGGER.warning("Multi-camera controller already running") return - active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] - active_settings = sorted(active_settings, key=_camera_start_priority) + active_settings_user_order = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + if not active_settings_user_order: + LOGGER.warning("No active cameras to start") + return + + # Display/tile order follows the user-configured camera order. + self._camera_display_order = [get_camera_id(s) for s in active_settings_user_order] + + # Startup order may differ for trigger safety: + # followers/external first, master last. + active_settings = sorted(active_settings_user_order, key=_camera_start_priority) if not active_settings: LOGGER.warning("No active cameras to start") return @@ -339,6 +349,7 @@ def stop(self, wait: bool = True) -> None: self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._camera_display_order.clear() self._expected_cameras = 0 self.all_stopped.emit() @@ -361,10 +372,27 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float # Emit frame data without tiling (tiling done in GUI for performance) if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + frame_data = MultiFrameData( - frames=dict(self._frames), - timestamps=dict(self._timestamps), - source_camera_id=camera_id, # Track which camera triggered this + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, tiled_frame=None, ) self.frame_ready.emit(frame_data) diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 0eac657c..01b0be0b 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -38,10 +38,10 @@ def compute_tiling_geometry( """Compute consistent tiling geometry for both tiling and overlay transforms. Returns: - (sorted_cam_ids, rows, cols, tile_w, tile_h) + (cam_ids, rows, cols, tile_w, tile_h) Notes: - - We intentionally base tile aspect on the first frame in sorted_cam_ids, + - We intentionally base tile aspect on the first frame in cam_ids, because create_tiled_frame uses the same ordering. This guarantees that compute_tile_info() and create_tiled_frame() agree on tile_w/tile_h. - If frames have different aspect ratios, they will be resized (possibly distorted) @@ -50,7 +50,7 @@ def compute_tiling_geometry( if not frames: return ([], 1, 1, 640, 480) - cam_ids = sorted(frames.keys()) + cam_ids = list(frames.keys()) frames_list = [frames[cid] for cid in cam_ids] num_frames = len(frames_list) @@ -63,7 +63,7 @@ def compute_tiling_geometry( max_w, max_h = max_canvas - # Reference aspect is based on the first frame in sorted order (matches tiler). + # Reference aspect is based on the first frame in display order (matches tiler). h0, w0 = frames_list[0].shape[:2] frame_aspect = (w0 / h0) if h0 > 0 else 1.0 @@ -135,7 +135,7 @@ def compute_tile_info( Critical robustness fix: - Tile dimensions are computed from the same reference used by create_tiled_frame() - (first frame in sorted order), so offsets/scales match the actual tiling. + (first frame in display order), so offsets/scales match the actual tiling. """ if not frames: return (0, 0), (1.0, 1.0) From d602c1ecb6970a8041c9925f590f672701952b7a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:30 +0200 Subject: [PATCH 21/58] tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. --- tests/services/test_multicam_controller.py | 300 +++++++++++++++++++++ tests/utils/test_display.py | 111 +++++++- 2 files changed, 398 insertions(+), 13 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0893bd9f..e8f94f6b 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -6,6 +6,12 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id +from dlclivegui.services.multi_camera_controller import ( + MultiCameraController, + _camera_start_priority, + _trigger_role_from_settings, + get_camera_id, +) @pytest.mark.unit @@ -95,3 +101,297 @@ def _create(_settings): # Expect initialization_failed with the camera id with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: mc.start([cam]) + + +@pytest.mark.unit +def test_get_camera_id_prefers_stable_device_id(): + cam = CameraSettings( + name="GenTL Cam", + backend="gentl", + index=0, + properties={ + "gentl": { + "device_id": "serial:30220469", + "serial_number": "30220469", + } + }, + ).apply_defaults() + + assert get_camera_id(cam) == "gentl:serial:30220469" + + +@pytest.mark.unit +def test_get_camera_id_falls_back_to_index_without_stable_identity(): + cam = CameraSettings( + name="Cam", + backend="opencv", + index=2, + ).apply_defaults() + + assert get_camera_id(cam) == "opencv:index:2" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("role", "expected"), + [ + ("off", "off"), + ("disabled", "off"), + ("on", "external"), + ("triggered", "external"), + ("external", "external"), + ("follower", "follower"), + ("slave", "follower"), + ("master", "master"), + ("main", "master"), + ], +) +def test_trigger_role_from_settings_aliases(role, expected): + cam = CameraSettings( + name="C", + backend="gentl", + index=0, + properties={ + "gentl": { + "trigger": { + "role": role, + } + } + }, + ).apply_defaults() + + assert _trigger_role_from_settings(cam) == expected + + +@pytest.mark.unit +def test_camera_start_priority_orders_trigger_roles(): + external = CameraSettings( + name="External", + backend="gentl", + index=0, + properties={"gentl": {"trigger": {"role": "external"}}}, + ).apply_defaults() + + normal = CameraSettings( + name="Normal", + backend="gentl", + index=1, + properties={"gentl": {"trigger": {"role": "off"}}}, + ).apply_defaults() + + master = CameraSettings( + name="Master", + backend="gentl", + index=2, + properties={"gentl": {"trigger": {"role": "master"}}}, + ).apply_defaults() + + assert _camera_start_priority(external) == 0 + assert _camera_start_priority(normal) == 1 + assert _camera_start_priority(master) == 2 + + +@pytest.mark.unit +def test_start_preserves_user_display_order_even_when_trigger_start_order_differs(qtbot, patch_factory): + mc = MultiCameraController() + + # User wants master first in tiled view, follower second. + # Startup order should still be follower first internally. + master = CameraSettings( + name="Master", + backend="opencv", + index=0, + enabled=True, + properties={ + "opencv": { + "device_id": "master-cam", + "trigger": {"role": "master"}, + } + }, + ).apply_defaults() + + follower = CameraSettings( + name="Follower", + backend="opencv", + index=1, + enabled=True, + properties={ + "opencv": { + "device_id": "follower-cam", + "trigger": {"role": "follower"}, + } + }, + ).apply_defaults() + + expected_display_order = [get_camera_id(master), get_camera_id(follower)] + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([master, follower]) + + assert mc._camera_display_order == expected_display_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory): + mc = MultiCameraController() + + cam_a = CameraSettings( + name="A", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-a"}}, + ).apply_defaults() + + cam_b = CameraSettings( + name="B", + backend="opencv", + index=1, + enabled=True, + properties={"opencv": {"device_id": "cam-b"}}, + ).apply_defaults() + + expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + seen_orders: list[list[str]] = [] + + def on_ready(mfd): + if len(mfd.frames) >= 2: + seen_orders.append(list(mfd.frames.keys())) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam_a, cam_b]) + + qtbot.waitUntil(lambda: bool(seen_orders), timeout=2500) + + assert seen_orders[-1] == expected_order + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_display_order_is_cleared_on_stop(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + properties={"opencv": {"device_id": "cam-0"}}, + ).apply_defaults() + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + assert mc._camera_display_order == [get_camera_id(cam)] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + assert mc._camera_display_order == [] + + +@pytest.mark.unit +def test_hardware_trigger_timeouts_are_not_fatal(qtbot, monkeypatch): + class WaitingTriggerBackend: + waits_for_hardware_trigger = True + + def __init__(self, settings): + self.settings = settings + self.opened = False + self.closed = False + + def open(self): + self.opened = True + + def read(self): + raise TimeoutError("waiting for hardware trigger") + + def close(self): + self.closed = True + + def _create(settings): + return WaitingTriggerBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings( + name="Triggered", + backend="gentl", + index=0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:30220469", + "trigger": {"role": "external", "timeout": 0.1}, + } + }, + ).apply_defaults() + + errors: list[tuple[str, str]] = [] + mc.camera_error.connect(lambda cam_id, msg: errors.append((cam_id, msg))) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Let several timeout cycles happen. + qtbot.wait(500) + + assert mc.is_running() + assert errors == [] + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_non_trigger_timeouts_are_fatal_after_retries(qtbot, monkeypatch): + class TimeoutBackend: + waits_for_hardware_trigger = False + + def __init__(self, settings): + self.settings = settings + + def open(self): + pass + + def read(self): + raise TimeoutError("camera timeout") + + def close(self): + pass + + def _create(settings): + return TimeoutBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings(name="TimeoutCam", backend="opencv", index=0, enabled=True).apply_defaults() + + with qtbot.waitSignal(mc.camera_error, timeout=3000) as blocker: + mc.start([cam]) + + cam_id, msg = blocker.args + assert cam_id == get_camera_id(cam) + assert "Camera read timeout" in msg + + # Cleanup if still running. + if mc.is_running(): + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) diff --git a/tests/utils/test_display.py b/tests/utils/test_display.py index 9ce8d49e..559aa152 100644 --- a/tests/utils/test_display.py +++ b/tests/utils/test_display.py @@ -41,7 +41,9 @@ def test_compute_tiling_geometry_single_frame_respects_max_canvas_and_min_tile() def test_compute_tiling_geometry_two_frames_is_1x2(): frames = {"camB": _frame(480, 640, 3), "camA": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] # sorted + + # Preserve insertion/display order, do not sort by camera ID. + assert cam_ids == ["camB", "camA"] assert (rows, cols) == (1, 2) assert tile_w >= 160 and tile_h >= 120 @@ -49,25 +51,106 @@ def test_compute_tiling_geometry_two_frames_is_1x2(): def test_compute_tiling_geometry_three_frames_is_2x2(): frames = {"c3": _frame(480, 640, 3), "c1": _frame(480, 640, 3), "c2": _frame(480, 640, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["c1", "c2", "c3"] + + # Preserve insertion/display order. + assert cam_ids == ["c3", "c1", "c2"] assert (rows, cols) == (2, 2) assert tile_w >= 160 and tile_h >= 120 -def test_compute_tiling_geometry_reference_aspect_is_first_sorted_cam(): - # camA has aspect 2.0 (w/h), camB has aspect 0.5 +def test_compute_tiling_geometry_reference_aspect_is_first_display_order_cam(): + # camB is first in insertion/display order and has aspect 0.5. + # camA has aspect 2.0. frames = { - "camB": _frame(400, 200, 3), - "camA": _frame(200, 400, 3), + "camB": _frame(400, 200, 3), # aspect = 200 / 400 = 0.5 + "camA": _frame(200, 400, 3), # aspect = 400 / 200 = 2.0 } + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) - assert cam_ids == ["camA", "camB"] + + assert cam_ids == ["camB", "camA"] # For 2 cams, rows=1 cols=2 => initial tile_w=600 tile_h=800 => tile_aspect=0.75 - # frame_aspect for camA = 400/200 = 2.0 > 0.75 => tile_h adjusted to tile_w/frame_aspect = 600/2 = 300 + # frame_aspect for camB = 0.5 <= 0.75 => tile_w adjusted to tile_h * frame_aspect = 800 * 0.5 = 400 + assert (rows, cols) == (1, 2) + assert tile_w == 400 + assert tile_h == 800 + + +def test_compute_tiling_geometry_preserves_frame_insertion_order(): + frames = { + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] + assert rows == 1 + assert cols == 2 + assert tile_w > 0 + assert tile_h > 0 + + +def test_compute_tiling_geometry_preserves_reversed_insertion_order(): + frames = { + "gentl:serial:10620051": np.zeros((10, 20, 3), dtype=np.uint8), + "gentl:serial:30220469": np.zeros((10, 20, 3), dtype=np.uint8), + } + + cam_ids, *_ = compute_tiling_geometry(frames) + + assert cam_ids == ["gentl:serial:10620051", "gentl:serial:30220469"] + + +def test_compute_tile_info_uses_display_order_for_offsets(): + cam0 = "gentl:serial:30220469" + cam1 = "gentl:serial:10620051" + + frames = { + cam0: np.zeros((100, 200, 3), dtype=np.uint8), + cam1: np.zeros((100, 200, 3), dtype=np.uint8), + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames) + + offset0, scale0 = compute_tile_info(cam0, frames[cam0], frames) + offset1, scale1 = compute_tile_info(cam1, frames[cam1], frames) + + assert cam_ids == [cam0, cam1] + assert offset0 == (0, 0) + assert offset1 == (tile_w, 0) + assert scale0[0] > 0 + assert scale0[1] > 0 + assert scale1[0] > 0 + assert scale1[1] > 0 + + +def test_create_tiled_frame_preserves_display_order_by_tile_content(): + # First frame is blue-ish, second is red-ish. + first = np.zeros((100, 100, 3), dtype=np.uint8) + first[:, :] = (255, 0, 0) # BGR blue + + second = np.zeros((100, 100, 3), dtype=np.uint8) + second[:, :] = (0, 0, 255) # BGR red + + frames = { + "gentl:serial:30220469": first, + "gentl:serial:10620051": second, + } + + cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(400, 200)) + out = create_tiled_frame(frames, max_canvas=(400, 200)) + + assert cam_ids == ["gentl:serial:30220469", "gentl:serial:10620051"] assert (rows, cols) == (1, 2) - assert tile_w == 600 - assert tile_h == 300 + + # Sample away from text label area. + left_sample = out[tile_h // 2, tile_w // 2] + right_sample = out[tile_h // 2, tile_w + tile_w // 2] + + assert left_sample[0] > left_sample[2] # blue tile first + assert right_sample[2] > right_sample[0] # red tile second def test_create_tiled_frame_empty_returns_default_canvas(): @@ -110,16 +193,18 @@ def test_create_tiled_frame_canvas_shape_matches_geometry(): def test_compute_tile_info_offset_and_scale_matches_tiling(): - # 2 frames => 1x2 tiling, cam ids sorted: ["cam1", "cam2"] + # 2 frames => 1x2 tiling, preserving insertion/display order: ["cam2", "cam1"] frames = {"cam2": _frame(200, 400, 3), "cam1": _frame(200, 400, 3)} cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800)) original = _frame(200, 400, 3) (ox, oy), (sx, sy) = compute_tile_info("cam2", original, frames, max_canvas=(1200, 800)) - # cam2 is index 1 -> row 0 col 1 + assert cam_ids == ["cam2", "cam1"] assert (rows, cols) == (1, 2) - assert ox == tile_w + + # cam2 is first in display order => row 0 col 0 + assert ox == 0 assert oy == 0 assert sx == pytest.approx(tile_w / 400) assert sy == pytest.approx(tile_h / 200) From cf7a23795b7f2106b95fcade860b945bdd13ed27 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:08:00 +0200 Subject: [PATCH 22/58] Mock _maybe_allow_keyboard_interrupt in GUI tests Patch tests/gui/test_app_entrypoint.py to monkeypatch appmod._maybe_allow_keyboard_interrupt with a MagicMock in both test_main_with_splash and test_main_without_splash. This prevents the real interrupt-handling helper from running during GUI tests and avoids side effects on global keyboard/signal handling. --- tests/gui/test_app_entrypoint.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 0a68bb2e..b6b1c605 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -31,6 +31,7 @@ def set_use_splash_false(monkeypatch): @pytest.mark.gui def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # --- Patch Qt app & icon in the entry module's namespace --- QApplication_cls = MagicMock(name="QApplication") @@ -101,6 +102,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() + monkeypatch.setattr(appmod, "_maybe_allow_keyboard_interrupt", MagicMock(name="_maybe_allow_keyboard_interrupt")) # Patch Qt app creation & window icon QApplication_cls = MagicMock(name="QApplication") From f56710362203ed66d3effc6096387785a2051528 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:21:09 +0200 Subject: [PATCH 23/58] Support strict GenTL trigger and defaults GenTLCameraBackend: add handling for a 'strict' flag when parsing GenTL trigger config so invalid configs raise in strict mode but fall back with a warning otherwise. Preserve the original exception when raising, and improve the warning text to mention strict mode. Return whether LineSelector was actually set and skip configuring trigger output if selection failed to avoid driving an unintended GPIO line. DLCLiveMainWindow: introduce _with_camera_trigger_defaults_for_save to ensure gentl trigger defaults are stored per camera, refactor _with_camera_defaults_for_save to apply that per-camera, and adjust _current_config to use the first camera with defaults applied. This ensures trigger settings are persisted and validated consistently. --- dlclivegui/cameras/backends/gentl_backend.py | 32 ++++++++++++++-- dlclivegui/gui/main_window.py | 39 ++++++++++++-------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 18c17d23..d97da492 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -106,10 +106,20 @@ def __init__(self, settings): self._gain = self._positive_float(ns.get("gain", props.get("gain"))) self._timeout: float = float(ns.get("timeout", props.get("timeout", 2.0))) + raw_trigger = ns.get("trigger", props.get("trigger")) + raw_trigger_strict = isinstance(raw_trigger, dict) and bool(raw_trigger.get("strict", False)) + try: - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger", props.get("trigger"))) + self._trigger = CameraTriggerSettings.from_any(raw_trigger) except Exception as exc: - LOG.warning("Invalid GenTL trigger config; falling back to trigger role=off: %s", exc) + if raw_trigger_strict: + raise ValueError(f"Strict mode failure - Invalid GenTL trigger configuration: {exc}") from exc + + LOG.warning( + "Invalid GenTL trigger config; falling back to trigger role=off: %s. " + "Enable strict mode to force this to raise.", + exc, + ) self._trigger = CameraTriggerSettings() trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) @@ -1145,7 +1155,23 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N # Master camera runs freerun and exposes an output signal. self._configure_trigger_off(node_map, strict=False) - self._set_enum_node(node_map, "LineSelector", output_line, strict=strict) + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + # In non-strict mode, do not continue configuring output behavior if the + # requested line could not be selected. Otherwise we may accidentally drive + # whichever GPIO line the camera had selected previously/defaulted to. + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping trigger output configuration.", + output_line, + ) + return + self._set_enum_node(node_map, "LineMode", "Output", strict=strict) self._set_enum_node(node_map, "LineSource", output_source, strict=strict) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index def60941..29516814 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -855,31 +855,40 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) + def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: + out = cam.model_copy(deep=True) - for cam in out.cameras: - backend = (cam.backend or "").lower() - if backend != "gentl": - continue + backend = (out.backend or "").lower() + if backend != "gentl": + return out - if not isinstance(cam.properties, dict): - cam.properties = {} + if not isinstance(out.properties, dict): + out.properties = {} - ns = cam.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - cam.properties["gentl"] = ns + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + + return out + + def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: + out = settings.model_copy(deep=True) + + out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] return out def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) - active_cameras = self._config.multi_camera.get_active_cameras() - camera = active_cameras[0] if active_cameras else CameraSettings() + camera = ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._with_camera_trigger_defaults_for_save(self._config.camera) + ) return ApplicationSettings( camera=camera, From ea49e5abaa2f04852ec3d4de0fd64fd0ecc2831c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:30:07 +0200 Subject: [PATCH 24/58] Apply gentl trigger defaults when saving Add with_save_defaults helpers to CameraSettings and MultiCameraSettings and use them when serializing ApplicationSettings so gentl "trigger" defaults are applied to saved configs. Remove duplicated camera-default helper logic from DLCLiveMainWindow and simplify _current_config to rely on model methods. Add unit tests to verify gentl trigger defaults are included for top-level and multi-camera cases. --- dlclivegui/config.py | 44 +++++++++++++++++++++++++++++++---- dlclivegui/gui/main_window.py | 32 ++----------------------- tests/test_config.py | 36 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 tests/test_config.py diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 154eabf4..c7a474b8 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -190,6 +190,25 @@ def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | No self.properties[str(key).lower()] = ns ns["trigger"] = trigger.to_properties() + def with_save_defaults(self) -> CameraSettings: + out = self.model_copy(deep=True) + + backend = (out.backend or "").lower() + if backend != "gentl": + return out + + if not isinstance(out.properties, dict): + out.properties = {} + + ns = out.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + out.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().to_properties()) + + return out + class CameraTriggerSettings(BaseModel): """ @@ -302,12 +321,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) def to_dict(self) -> dict[str, Any]: + out = self.with_save_defaults() return { - "cameras": [cam.model_dump() for cam in self.cameras], - "max_cameras": self.max_cameras, - "tile_layout": self.tile_layout, + "cameras": [cam.model_dump() for cam in out.cameras], + "max_cameras": out.max_cameras, + "tile_layout": out.tile_layout, } + def with_save_defaults(self) -> MultiCameraSettings: + """Return a copy with save defaults applied to all cameras.""" + out = self.model_copy(deep=True) + out.cameras = [cam.with_save_defaults() for cam in out.cameras] + return out + class DynamicCropModel(BaseModel): enabled: bool = False @@ -473,10 +499,18 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + multi_camera = self.multi_camera.with_save_defaults() + active_cameras = multi_camera.get_active_cameras() + + if active_cameras: + camera = active_cameras[0].model_copy(deep=True) + else: + camera = self.camera.with_save_defaults() + return { "version": self.version, - "camera": self.camera.model_dump(), - "multi_camera": self.multi_camera.to_dict(), + "camera": camera.model_dump(), + "multi_camera": multi_camera.to_dict(), "dlc": self.dlc.model_dump(), "recording": self.recording.model_dump(), "bbox": self.bbox.model_dump(), diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 29516814..4dbf8305 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -56,7 +56,6 @@ ApplicationSettings, BoundingBoxSettings, CameraSettings, - CameraTriggerSettings, DLCProcessorSettings, MultiCameraSettings, RecordingSettings, @@ -855,39 +854,12 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update recording path preview self._update_recording_path_preview() - def _with_camera_trigger_defaults_for_save(self, cam: CameraSettings) -> CameraSettings: - out = cam.model_copy(deep=True) - - backend = (out.backend or "").lower() - if backend != "gentl": - return out - - if not isinstance(out.properties, dict): - out.properties = {} - - ns = out.properties.setdefault("gentl", {}) - if not isinstance(ns, dict): - ns = {} - out.properties["gentl"] = ns - - ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) - - return out - - def _with_camera_defaults_for_save(self, settings: MultiCameraSettings) -> MultiCameraSettings: - out = settings.model_copy(deep=True) - - out.cameras = [self._with_camera_trigger_defaults_for_save(cam) for cam in out.cameras] - - return out - def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: - # Get the first camera from multi-camera config for backward compatibility - multi_camera = self._with_camera_defaults_for_save(self._config.multi_camera) + multi_camera = self._config.multi_camera camera = ( multi_camera.cameras[0].model_copy(deep=True) if multi_camera.cameras - else self._with_camera_trigger_defaults_for_save(self._config.camera) + else self._config.camera.model_copy(deep=True) ) return ApplicationSettings( diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..f0165f45 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,36 @@ +import pytest + +from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_top_level_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + camera=cam, + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["camera"]["properties"]["gentl"] + + +@pytest.mark.unit +def test_save_applies_gentl_trigger_defaults_to_multi_camera(): + cam = CameraSettings( + backend="gentl", + properties={"gentl": {}}, + ) + + settings = ApplicationSettings( + multi_camera=MultiCameraSettings(cameras=[cam]), + ) + + data = settings.to_dict() + + assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] From b073ce3a27b39f905adbd8c5fa68d08826696d68 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:36:54 +0200 Subject: [PATCH 25/58] Don't sort available camera IDs Remove sorting when building available_ids so the original frame key order is preserved. The DLC camera selection relies on the first active camera in frame_data.frames; using list(...) keeps the insertion/order semantics (dict order) instead of reordering keys with sorted(). This avoids unintended changes to camera priority caused by alphabetical/numeric sorting. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 4dbf8305..0b8a215e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1384,7 +1384,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # Determine DLC camera (first active camera) selected_id = self._inference_camera_id - available_ids = sorted(frame_data.frames.keys()) + available_ids = list(frame_data.frames.keys()) if selected_id in frame_data.frames: dlc_cam_id = selected_id else: From cd562d54b78e9755e1e2d225fc26b6887f5f0dea Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:02:13 +0200 Subject: [PATCH 26/58] Preserve DLC config using model_copy Initialize an existing DLC config (falling back to DEFAULT_CONFIG if unset) and use its model_copy(update=...) to return settings. This replaces explicit field-by-field construction so only model_path (and model_type when set) are changed, preserving other DLC options and avoiding duplication. --- dlclivegui/gui/main_window.py | 38 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0b8a215e..4f07bd7b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -883,19 +883,23 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor # IMPORTANT NOTE: DLClive expects a directory for TensorFlow models, # so if user selects a .pb file, we should pass the parent directory to DLCLive model_path = str(Path(model_path).parent) + + existing_dlc = ( # explicitly init from default if unset + self._config.dlc.model_copy(deep=True) + if getattr(self._config, "dlc", None) is not None + else DEFAULT_CONFIG.dlc.model_copy(deep=True) + ) if not model_path: if allow_empty_model_path: - return DLCProcessorSettings( - model_path="", - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=None, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + # Preserve all existing DLC settings and only clear the model path. + return existing_dlc.model_copy( + update={ + "model_path": "", + } ) + raise ValueError("Model path cannot be empty. Please enter a valid path to a DLCLive model file.") + try: model_bknd = DLCLiveProcessor.get_model_backend(model_path) except Exception as e: @@ -904,15 +908,13 @@ def _dlc_settings_from_ui(self, *, allow_empty_model_path=False) -> DLCProcessor "Please ensure the model file is valid and has an appropriate extension " "(.pt, .pth for PyTorch or model directory for TensorFlow)." ) from e - return DLCProcessorSettings( - model_path=model_path, - model_directory=self._config.dlc.model_directory, # Preserve from config - device=self._config.dlc.device, # Preserve from config - dynamic=self._config.dlc.dynamic, # Preserve from config - resize=self._config.dlc.resize, # Preserve from config - precision=self._config.dlc.precision, # Preserve from config - model_type=model_bknd, - # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + + # Preserve all unchanged DLC settings and only update values derived from the UI. + return existing_dlc.model_copy( + update={ + "model_path": model_path, + "model_type": model_bknd, + } ) def _recording_settings_from_ui(self) -> RecordingSettings: From 0cc318ba2a5d6839754fc80b97d815f7a1989d7b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 16:56:14 +0200 Subject: [PATCH 27/58] Warn when GenTL TriggerMode fails to enable Add a warning log when setting GenTL TriggerMode to 'On' fails in non-strict mode. Previously the code only raised an exception in strict mode; now it emits a warning to inform users that the trigger mode may not be correctly configured when continuing without strict enforcement. --- dlclivegui/cameras/backends/gentl_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index d97da492..d7811fc7 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1139,6 +1139,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): if strict: raise RuntimeError("Could not enable GenTL TriggerMode=On") + else: + LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", From 973d69fe67e709bdcbc91723d6632e8d67803879 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:47:00 +0200 Subject: [PATCH 28/58] Improve GenTL trigger routing safety and tests Make GenTLCameraBackend trigger configuration more robust and adjust tests. - Use the waits_for_hardware_trigger property when converting GenTL timeouts to user-facing errors instead of inferring from the role string. - Read and record the configured trigger role early in _configure_trigger_input. - Check results when setting TriggerSelector, TriggerSource and TriggerActivation (selector_ok, source_ok, activation_ok). If selector/source routing fails in non-strict mode, disable the trigger, reset internal trigger state and log a warning to avoid arming the camera on a previous/default input line. If activation fails, warn and continue using the camera default. - If enabling TriggerMode=On fails, disable the trigger and reset internal state instead of only warning. - Validate LineMode and LineSource when configuring master output and log if configuration is incomplete. Tests updated to reflect safety changes: - Expect waits_for_hardware_trigger to be set for external input configuration. - Rename and change a non-strict invalid-source test to assert the trigger is disabled, waits_for_hardware_trigger is False, and trigger_actual is persisted as off. - Add a new test ensuring an invalid selector in non-strict mode disables the trigger and persists the off state. These changes prevent the camera from being left armed on an unintended/default input if routing nodes could not be applied, improving safety and predictability. --- dlclivegui/cameras/backends/gentl_backend.py | 64 ++++++++++++++++---- tests/cameras/backends/test_gentl_trigger.py | 49 +++++++++++++-- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index d7811fc7..a09a2737 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -498,8 +498,7 @@ def read(self) -> tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: - role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower() - if role in {"external", "follower"}: + if self.waits_for_hardware_trigger: raise TimeoutError(str(exc) + " (GenTL timeout; waiting for hardware trigger?)") from exc raise TimeoutError(str(exc) + " (GenTL timeout)") from exc @@ -1123,6 +1122,7 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: self._set_enum_node(node_map, "TriggerMode", "Off", strict=strict) def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: + role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") @@ -1130,17 +1130,51 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) - self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - self._set_enum_node(node_map, "TriggerSource", source, strict=strict) - self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): + LOG.warning( + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s activation=%s", + selector_ok, + source_ok, + role, + selector, + source, + activation, + ) + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + if not activation_ok: + LOG.warning( + "Could not apply GenTL TriggerActivation=%s; using camera default/current activation.", + activation, + ) self._set_enum_node(node_map, "AcquisitionMode", "Continuous", strict=False) if not self._set_enum_node(node_map, "TriggerMode", "On", strict=strict): - if strict: - raise RuntimeError("Could not enable GenTL TriggerMode=On") - else: - LOG.warning("Could not enable GenTL TriggerMode=On; trigger mode may not be correctly configured.") + LOG.warning("Could not enable GenTL TriggerMode=On; disabling trigger.") + self._configure_trigger_off(node_map, strict=False) + self._trigger = CameraTriggerSettings() + return + + LOG.info( + "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + role, + selector, + source, + activation, + activation_ok, + ) LOG.info( "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", @@ -1174,8 +1208,16 @@ def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> N ) return - self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if not (mode_ok and source_ok): + LOG.warning( + "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + return LOG.info( "GenTL trigger master configured: output_line=%s output_source=%s", diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index f0d5f452..5d774af5 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -75,6 +75,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerSource.value == "Line0" assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" + assert be.waits_for_hardware_trigger is True assert be._timeout == pytest.approx(10.0) ns = settings.properties["gentl"] @@ -140,7 +141,7 @@ def test_trigger_master_configures_output_line_and_keeps_trigger_off(patch_gentl be.close() -def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -158,9 +159,17 @@ def test_trigger_invalid_source_non_strict_does_not_crash(patch_gentl_sdk, gentl # Source was unsupported, so the fake node should retain its default. assert nm.TriggerSource.value == "Line0" - # Non-strict mode should still allow opening; TriggerMode may be enabled - # because TriggerSource failure is best-effort in this mode. - assert be._acquirer is not None + + # Safety behavior: do not arm TriggerMode on the previous/default source. + assert nm.TriggerMode.value == "Off" + + # Controller should not treat timeouts as expected trigger waits. + assert be.waits_for_hardware_trigger is False + + # trigger_actual is persisted after _configure_trigger(); since we reset + # self._trigger to off, the effective trigger state is off. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" be.close() @@ -331,3 +340,35 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin assert actual["timeout"] == pytest.approx(9.0) be.close() + + +def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "external", + "selector": "NotARealSelector", + "source": "Line1", + "strict": False, + }, + ) + be = gb.GenTLCameraBackend(settings) + + be.open() + nm = be._acquirer.remote_device.node_map + + # Selector was unsupported, so the fake node should retain its default. + assert nm.TriggerSelector.value == "FrameStart" + + # Source may have been applied, but trigger must not be armed because + # the required selector routing failed. + assert nm.TriggerSource.value == "Line1" + assert nm.TriggerMode.value == "Off" + assert be.waits_for_hardware_trigger is False + + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["role"] == "off" + + be.close() From 7942066c9067f0773f39775e93c1e547272b7c4f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 28 May 2026 17:54:22 +0200 Subject: [PATCH 29/58] Cap hardware-trigger fetch timeout and update tests Introduce a MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT and cap Harvester.fetch() timeouts when the trigger role waits for external hardware (roles: external, follower) to keep individual fetch calls short and allow prompt shutdown. Preserve legacy behavior for non-waiting roles (e.g. master). Remove a noisy Trigger input LOG.info call. Update tests to expect the capped fetch timeout, verify the original requested timeout is still persisted in trigger_actual, add a test that master mode is not capped, and make test resource cleanup more robust (use try/finally around open/close). --- dlclivegui/cameras/backends/gentl_backend.py | 24 +++-- tests/cameras/backends/test_gentl_trigger.py | 106 ++++++++++++++----- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index a09a2737..1c63c609 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -76,6 +76,11 @@ class GenTLCameraBackend(CameraBackend): _CTI_FILES_SOURCE_AUTO: ClassVar[str] = "auto" _CTI_FILES_SOURCE_USER: ClassVar[str] = "user" + # Keep individual Harvester.fetch() calls short enough that controller + # shutdown can stop worker threads promptly. Hardware-trigger waits are + # handled by repeated polling in SingleCameraWorker. + _MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT: ClassVar[float] = 1.0 + def __init__(self, settings): super().__init__(settings) @@ -124,7 +129,16 @@ def __init__(self, settings): trigger_timeout = self._positive_float(self._trigger_attr(self._trigger, "timeout", None)) if trigger_timeout is not None: - self._timeout = float(trigger_timeout) + role = str(self._trigger_attr(self._trigger, "role", "off") or "off").strip().lower() + + if role in {"external", "follower"}: + # Do not let a long hardware-trigger wait block shutdown. + # SingleCameraWorker treats these fetch timeouts as expected + # polling misses while waits_for_hardware_trigger is true. + self._timeout = min(float(trigger_timeout), self._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + else: + # For non-trigger-waiting modes, preserve legacy behavior. + self._timeout = float(trigger_timeout) self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none() @@ -1176,14 +1190,6 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No activation_ok, ) - LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s", - self._trigger_attr(cfg, "role", "external"), - selector, - source, - activation, - ) - def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index 5d774af5..e060035a 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -63,7 +63,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen "selector": "FrameStart", "source": "Line0", "activation": "RisingEdge", - "timeout": 10.0, + "timeout": gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT, }, ) be = gb.GenTLCameraBackend(settings) @@ -76,7 +76,7 @@ def test_trigger_external_configures_input_line_and_timeout(patch_gentl_sdk, gen assert nm.TriggerActivation.value == "RisingEdge" assert nm.TriggerMode.value == "On" assert be.waits_for_hardware_trigger is True - assert be._timeout == pytest.approx(10.0) + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) ns = settings.properties["gentl"] assert ns["trigger_actual"]["role"] == "external" @@ -265,8 +265,12 @@ def test_trigger_alias_on_maps_to_external(patch_gentl_sdk, gentl_settings_facto be.close() -def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_settings_factory): +def test_trigger_timeout_is_capped_for_hardware_trigger_fetch_polling( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk + expected_fetch_timeout = gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT settings = _gentl_trigger_settings( gentl_settings_factory, @@ -277,18 +281,30 @@ def test_trigger_timeout_overrides_default_fetch_timeout(patch_gentl_sdk, gentl_ ) be = gb.GenTLCameraBackend(settings) - be.open() - assert be._timeout == pytest.approx(7.5) + try: + be.open() - # Fake acquisition is started, so read should pass and record the timeout. - frame, _ = be.read() - assert frame is not None - assert be._acquirer.fetch_calls[-1] == pytest.approx(7.5) + # Hardware-trigger fetch calls are intentionally capped so stop(wait=True) + # is not blocked by a long user trigger timeout. + assert be._timeout == pytest.approx(expected_fetch_timeout) - be.close() + # Fake acquisition is started, so read should pass and record the capped timeout. + frame, _ = be.read() + assert frame is not None + assert be._acquirer.fetch_calls[-1] == pytest.approx(expected_fetch_timeout) + + # The requested trigger timeout is still preserved in persisted trigger_actual. + actual = settings.properties["gentl"]["trigger_actual"] + assert actual["timeout"] == pytest.approx(7.5) + finally: + be.close() -def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gentl_sdk, gentl_settings_factory): + +def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting( + patch_gentl_sdk, + gentl_settings_factory, +): gb = patch_gentl_sdk settings = _gentl_trigger_settings( @@ -297,22 +313,27 @@ def test_trigger_timeout_error_mentions_hardware_trigger_when_waiting(patch_gent "role": "external", "timeout": 3.0, }, - # fast_start keeps acquisition stopped; fake fetch then raises timeout. - # This lets us assert the backend timeout message without hardware. ) + # fast_start keeps acquisition stopped; fake fetch then raises timeout. + # This lets us assert the backend timeout message without hardware. settings.properties["gentl"]["fast_start"] = True + be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - with pytest.raises(TimeoutError) as ei: - be.read() + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) - msg = str(ei.value).lower() - assert "gentl timeout" in msg - assert "hardware trigger" in msg or "trigger" in msg + with pytest.raises(TimeoutError) as ei: + be.read() - be.close() + msg = str(ei.value).lower() + assert "gentl timeout" in msg + assert "hardware trigger" in msg or "trigger" in msg + + finally: + be.close() def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settings_factory): @@ -330,16 +351,22 @@ def test_trigger_actual_is_persisted_for_debugging(patch_gentl_sdk, gentl_settin ) be = gb.GenTLCameraBackend(settings) - be.open() + try: + be.open() - actual = settings.properties["gentl"].get("trigger_actual") - assert isinstance(actual, dict) - assert actual["role"] == "follower" - assert actual["source"] == "Line1" - assert actual["activation"] == "FallingEdge" - assert actual["timeout"] == pytest.approx(9.0) + # Requested timeout remains in trigger_actual for debugging/config visibility. + actual = settings.properties["gentl"].get("trigger_actual") + assert isinstance(actual, dict) + assert actual["role"] == "follower" + assert actual["source"] == "Line1" + assert actual["activation"] == "FallingEdge" + assert actual["timeout"] == pytest.approx(9.0) - be.close() + # But each blocking Harvester.fetch() call is capped for responsive shutdown. + assert be._timeout == pytest.approx(gb.GenTLCameraBackend._MAX_HARDWARE_TRIGGER_FETCH_TIMEOUT) + + finally: + be.close() def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, gentl_settings_factory): @@ -372,3 +399,26 @@ def test_trigger_invalid_selector_non_strict_disables_trigger(patch_gentl_sdk, g assert actual["role"] == "off" be.close() + + +def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_settings_factory): + gb = patch_gentl_sdk + + settings = _gentl_trigger_settings( + gentl_settings_factory, + { + "role": "master", + "timeout": 7.5, + }, + ) + be = gb.GenTLCameraBackend(settings) + + try: + be.open() + + # Master is free-running / trigger-generating, not waiting for hardware input. + assert be.waits_for_hardware_trigger is False + assert be._timeout == pytest.approx(7.5) + + finally: + be.close() From 93c2a730d794c834fbbbf7b19595a729390102e7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:19:58 +0200 Subject: [PATCH 30/58] Interruptible camera waits; simplify config save Make SingleCameraWorker sleep calls interruptible by using self._stop_event.wait() instead of time.sleep(), and add a small _trigger_timeout_delay to allow early exit during trigger/wait cycles. This prevents the worker from being unresponsive to stop requests during retry and trigger waits. Also simplify ApplicationSettings.to_dict() to always start from self.camera.with_save_defaults() and stop overriding it with active multi-camera selection, ensuring consistent camera settings are saved. --- dlclivegui/config.py | 7 +------ dlclivegui/services/multi_camera_controller.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a474b8..6f204acc 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -499,13 +499,8 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: ) def to_dict(self) -> dict[str, Any]: + camera = self.camera.with_save_defaults() multi_camera = self.multi_camera.with_save_defaults() - active_cameras = multi_camera.get_active_cameras() - - if active_cameras: - camera = active_cameras[0].model_copy(deep=True) - else: - camera = self.camera.with_save_defaults() return { "version": self.version, diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 17af14e6..cd13ae03 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,7 +4,6 @@ import copy import logging -import time from dataclasses import dataclass from threading import Event, Lock @@ -49,6 +48,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 self._retry_delay = 0.1 + self._trigger_timeout_delay = 0.05 @Slot() def run(self) -> None: @@ -93,7 +93,8 @@ def run(self) -> None: self._camera_id, "Too many empty frames.\nWas the device disconnected ?" ) break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue consecutive_errors = 0 @@ -113,13 +114,17 @@ def run(self) -> None: exc, ) consecutive_errors = 0 + + if self._stop_event.wait(self._trigger_timeout_delay): + break # Stop event set during wait continue consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit(self._camera_id, f"Camera read timeout: {exc}") break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue except Exception as exc: @@ -129,7 +134,8 @@ def run(self) -> None: if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") break - time.sleep(self._retry_delay) + if self._stop_event.wait(self._retry_delay): + break continue # Cleanup From 96354ec363efcea931762fe45a7bcd14540eac3d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:32:12 +0200 Subject: [PATCH 31/58] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dlclivegui/gui/main_window.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 4f07bd7b..673e801d 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -856,11 +856,15 @@ def _apply_config(self, config: ApplicationSettings) -> None: def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSettings: multi_camera = self._config.multi_camera + active_cameras = multi_camera.get_active_cameras() camera = ( - multi_camera.cameras[0].model_copy(deep=True) - if multi_camera.cameras - else self._config.camera.model_copy(deep=True) - ) + active_cameras[0].model_copy(deep=True) + if active_cameras + else ( + multi_camera.cameras[0].model_copy(deep=True) + if multi_camera.cameras + else self._config.camera.model_copy(deep=True) + ) return ApplicationSettings( camera=camera, From fe826a1872a2774c8e79ef3341aa8a95e90b9c8d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:34:52 +0200 Subject: [PATCH 32/58] Fix broken suggestion --- dlclivegui/gui/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 673e801d..c16964bf 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -865,6 +865,7 @@ def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSetting if multi_camera.cameras else self._config.camera.model_copy(deep=True) ) + ) return ApplicationSettings( camera=camera, From d44bf18814d6c6786221df224680af2290e5f13b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:55:11 +0200 Subject: [PATCH 33/58] Resolve 'auto' trigger source in GenTL backend Add resolution logic for trigger source: implement _resolve_trigger_source to map the model-level default 'auto' to the first supported camera line (Line0/Line1/Line2/Any), return whether the resolved value is supported, and log/warn or raise depending on strict mode. Integrate this into _configure_pixel_format/_configure trigger flow so TriggerSource is only set if resolution succeeded. Change CameraTriggerSettings default source to 'auto' and add a field validator to coerce common aliases (e.g. 'default', 'automatic', 'device', 'camera') to 'auto'. Update tests and fixtures: adjust fake node default TriggerSource to Line1, update expectations, and add unit tests for auto-selection behavior and strict-mode error. This makes trigger configuration more robust across cameras with differing GenICam enum symbolics. --- dlclivegui/cameras/backends/gentl_backend.py | 48 +++++++++++++++++++- dlclivegui/config.py | 20 +++++++- tests/cameras/backends/conftest.py | 2 +- tests/cameras/backends/test_gentl_trigger.py | 38 +++++++++++++++- tests/test_config.py | 9 +++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 1c63c609..0992db01 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1056,6 +1056,51 @@ def _trigger_to_dict(trigger) -> dict[str, Any]: pass return {} + def _resolve_trigger_source(self, node_map, requested: str, *, strict: bool) -> tuple[str, bool]: + """Resolve TriggerSource against the camera-supported GenICam enum values. + + Model-level default is "auto"; this backend maps it to the first preferred + source supported by the actual camera. + """ + requested = str(requested or "auto").strip() + node = self._node(node_map, "TriggerSource") + available = self._node_symbolics(node) + + if not available: + if strict: + raise RuntimeError("GenICam node 'TriggerSource' is not available or has no symbolics") + LOG.warning("GenICam node 'TriggerSource' is not available; disabling trigger input.") + return requested, False + + if requested in available: + return requested, True + + if requested.lower() == "auto": + for candidate in ("Line0", "Line1", "Line2", "Any"): + if candidate in available: + LOG.info( + "GenTL TriggerSource auto-selected '%s'. Available: %s", + candidate, + available, + ) + return candidate, True + + LOG.warning( + "Could not auto-select a GenTL TriggerSource. Available: %s", + available, + ) + return requested, False + + if strict: + raise RuntimeError(f"GenICam node 'TriggerSource' does not support '{requested}'. Available: {available}") + + LOG.warning( + "GenTL TriggerSource '%s' is not available. Available: %s", + requested, + available, + ) + return requested, False + def _configure_pixel_format(self, node_map) -> None: try: pixel_format_node = getattr(node_map, "PixelFormat", None) @@ -1145,7 +1190,8 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=strict) + source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) + source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) # TriggerSelector and TriggerSource are required routing nodes. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6f204acc..11530162 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -224,7 +224,7 @@ class CameraTriggerSettings(BaseModel): # Input trigger config: external/follower selector: str = "FrameStart" - source: str = "Line0" + source: str = "auto" activation: TriggerActivation | str = "RisingEdge" # Output config: master @@ -272,6 +272,24 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("source", mode="before") + @classmethod + def _coerce_source(cls, v): + if v is None: + return "auto" + + s = str(v).strip() + if not s: + return "auto" + + aliases = { + "default": "auto", + "automatic": "auto", + "device": "auto", + "camera": "auto", + } + return aliases.get(s.lower(), s) + @classmethod def from_any(cls, value) -> CameraTriggerSettings: if isinstance(value, cls): diff --git a/tests/cameras/backends/conftest.py b/tests/cameras/backends/conftest.py index f21bf481..f0a0b6b2 100644 --- a/tests/cameras/backends/conftest.py +++ b/tests/cameras/backends/conftest.py @@ -603,7 +603,7 @@ def __init__( self.AcquisitionMode = _FakeNode("Continuous", symbolics=["Continuous", "SingleFrame"]) self.TriggerSelector = _FakeNode("FrameStart", symbolics=["FrameStart"]) self.TriggerMode = _FakeNode("Off", symbolics=["Off", "On"]) - self.TriggerSource = _FakeNode("Line0", symbolics=["Line0", "Line1", "Software"]) + self.TriggerSource = _FakeNode("Line1", symbolics=["Line0", "Line1", "Software"]) self.TriggerActivation = _FakeNode("RisingEdge", symbolics=["RisingEdge", "FallingEdge"]) # GPIO output nodes for master/follower setups diff --git a/tests/cameras/backends/test_gentl_trigger.py b/tests/cameras/backends/test_gentl_trigger.py index e060035a..57339a10 100644 --- a/tests/cameras/backends/test_gentl_trigger.py +++ b/tests/cameras/backends/test_gentl_trigger.py @@ -158,7 +158,7 @@ def test_trigger_invalid_source_non_strict_disables_trigger(patch_gentl_sdk, gen nm = be._acquirer.remote_device.node_map # Source was unsupported, so the fake node should retain its default. - assert nm.TriggerSource.value == "Line0" + assert nm.TriggerSource.value == "Line1" # Safety behavior: do not arm TriggerMode on the previous/default source. assert nm.TriggerMode.value == "Off" @@ -422,3 +422,39 @@ def test_trigger_timeout_not_capped_for_master_mode(patch_gentl_sdk, gentl_setti finally: be.close() + + +def test_resolve_trigger_source_auto_selects_supported_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + source, ok = be._resolve_trigger_source(NodeMap(), "auto", strict=False) + + assert ok is True + assert source == "Line1" + + +def test_resolve_trigger_source_strict_raises_for_unsupported_explicit_line( + patch_gentl_sdk, + gentl_settings_factory, +): + gb = patch_gentl_sdk + be = gb.GenTLCameraBackend(gentl_settings_factory()) + + class Node: + symbolics = ["Line1", "Software", "Any"] + + class NodeMap: + TriggerSource = Node() + + with pytest.raises(RuntimeError, match="TriggerSource.*Line0"): + be._resolve_trigger_source(NodeMap(), "Line0", strict=True) diff --git a/tests/test_config.py b/tests/test_config.py index f0165f45..9f82017e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ import pytest -from dlclivegui.config import ApplicationSettings, CameraSettings, MultiCameraSettings +from dlclivegui.config import ApplicationSettings, CameraSettings, CameraTriggerSettings, MultiCameraSettings @pytest.mark.unit @@ -34,3 +34,10 @@ def test_save_applies_gentl_trigger_defaults_to_multi_camera(): data = settings.to_dict() assert "trigger" in data["multi_camera"]["cameras"][0]["properties"]["gentl"] + + +@pytest.mark.unit +def test_trigger_source_defaults_to_auto(): + trigger = CameraTriggerSettings() + + assert trigger.source == "auto" From 662313a3efa2c013b128c36625c1b5d7c9bc87eb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:03:57 +0200 Subject: [PATCH 34/58] Fix display tests --- tests/services/test_multicam_controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index e8f94f6b..99047be8 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -11,6 +11,7 @@ _camera_start_priority, _trigger_role_from_settings, get_camera_id, + get_display_id, ) @@ -256,7 +257,7 @@ def test_frame_ready_emits_frames_in_user_configured_order(qtbot, patch_factory) properties={"opencv": {"device_id": "cam-b"}}, ).apply_defaults() - expected_order = [get_camera_id(cam_a), get_camera_id(cam_b)] + expected_order = [get_display_id(cam_a), get_display_id(cam_b)] seen_orders: list[list[str]] = [] def on_ready(mfd): @@ -388,7 +389,7 @@ def _create(settings): mc.start([cam]) cam_id, msg = blocker.args - assert cam_id == get_camera_id(cam) + assert cam_id == get_display_id(cam) assert "Camera read timeout" in msg # Cleanup if still running. From 3edb48ce1f2f3f7ce16f5f34a6d71a03f5413a6d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 18:05:01 +0200 Subject: [PATCH 35/58] Update test_multicam_controller.py --- tests/services/test_multicam_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 99047be8..d41befeb 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,7 +5,6 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id from dlclivegui.services.multi_camera_controller import ( MultiCameraController, _camera_start_priority, From 0af561594be7d3d846366543353b35ef127bc29f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:58:57 +0200 Subject: [PATCH 36/58] Propagate display IDs for camera labeling Add support for display_id labels throughout the multi-camera flow. MultiFrameData now includes display_ids; MultiCameraController stores per-camera display_ids (set on start, cleared on stop/reset) and emits them with frame_ready. Also switch to getting camera_id and display_id separately when starting workers. The tiled-frame helper create_tiled_frame gained an optional labels mapping and will draw the display label (falling back to camera_id) on each tile so the GUI can show friendly names. --- dlclivegui/services/multi_camera_controller.py | 11 ++++++++++- dlclivegui/utils/display.py | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index ad1a8636..99f92ddc 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -31,6 +31,7 @@ class MultiFrameData: timestamps: dict[str, float] # camera_id -> timestamp source_camera_id: str = "" # ID of camera that triggered this emission tiled_frame: np.ndarray | None = None # Combined tiled frame (deprecated, done in GUI) + display_ids: dict[str, str] = None # camera_id -> display_id (for labeling) class SingleCameraWorker(QObject): @@ -166,6 +167,7 @@ def __init__(self): self._frame_lock = Lock() self._running = False self._started_cameras: set = set() + self._display_ids: dict[str, str] = {} # camera_id -> display_id (for labeling) self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start @@ -219,6 +221,7 @@ def start(self, camera_settings: list[CameraSettings]) -> None: self._timestamps.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._display_ids.clear() self._expected_cameras = len(active_settings) for settings in active_settings: @@ -227,7 +230,9 @@ def start(self, camera_settings: list[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" settings_copy = copy.deepcopy(settings) - cam_id = get_display_id(settings_copy) + cam_id = get_camera_id(settings_copy) + display_id = get_display_id(settings_copy) + if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return @@ -236,6 +241,7 @@ def _start_camera(self, settings: CameraSettings) -> None: # Normalize and store the dataclass once self._settings[cam_id] = settings_copy + self._display_ids[cam_id] = display_id dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) thread = QThread() @@ -275,6 +281,7 @@ def stop(self, wait: bool = True) -> None: self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() + self._display_ids.clear() self._expected_cameras = 0 self.all_stopped.emit() @@ -302,6 +309,7 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float timestamps=dict(self._timestamps), source_camera_id=camera_id, # Track which camera triggered this tiled_frame=None, + display_ids=dict(self._display_ids), ) self.frame_ready.emit(frame_data) @@ -489,6 +497,7 @@ def _on_camera_stopped(self, camera_id: str) -> None: # Check if this camera never started (initialization failure) 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})") diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 0eac657c..00eae0d6 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -83,7 +83,9 @@ def compute_tiling_geometry( return cam_ids, rows, cols, tile_w, tile_h -def create_tiled_frame(frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800)) -> np.ndarray: +def create_tiled_frame( + frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800), labels: dict[str, str] = None +) -> np.ndarray: """Create a tiled canvas (1x1, 1x2, or 2x2) with camera-id labels. Uses compute_tiling_geometry() so tile_w/tile_h are consistent with compute_tile_info(). @@ -105,10 +107,11 @@ def create_tiled_frame(frames: dict[str, np.ndarray], max_canvas: tuple[int, int frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) resized = cv2.resize(frame, (tile_w, tile_h), interpolation=cv2.INTER_AREA) + label = labels.get(cam_id, cam_id) if labels else cam_id cv2.putText( resized, - cam_id, + label, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, From ed1a9f59420cd5cf2cc29d686232d6f994d868f9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:59:13 +0200 Subject: [PATCH 37/58] Support fp: targets and downgrade CTI error log Treat target IDs starting with "fp:" as valid and return settings early so open() can match devices by fingerprint via existing _select_device/_match_device logic. Also change CTI file load failures from logger.exception to logger.warning to reduce noisy stack traces while still recording failed files for diagnostics. --- dlclivegui/cameras/backends/gentl_backend.py | 3 +++ dlclivegui/cameras/backends/utils/gentl_discovery.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index b55abdf7..5c71cd28 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -337,6 +337,9 @@ def rebind_settings(cls, settings): cls._persist_serial_identity(settings, target_id_str) return settings + if target_id_str.startswith("fp:"): + return settings # open() will match by fingerprint via _select_device → _match_device + # Non-serial fallback retained for older configs / fingerprint IDs. harvester = None try: diff --git a/dlclivegui/cameras/backends/utils/gentl_discovery.py b/dlclivegui/cameras/backends/utils/gentl_discovery.py index 9d15a829..3eed1997 100644 --- a/dlclivegui/cameras/backends/utils/gentl_discovery.py +++ b/dlclivegui/cameras/backends/utils/gentl_discovery.py @@ -51,7 +51,7 @@ def __init__(self, cti_files: list[str]): self.harvester.add_file(cti) self.loaded_files.append(cti) except Exception as e: - logger.exception(f"Failed to load CTI file: {cti}. Skipping.") + logger.warning(f"Failed to load CTI file: {cti}. Skipping.") self.failed_files[cti] = str(e) if not self.loaded_files: From 0d5e1dcdbdf17e749e555cb1f7a9ec439a78fcd6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:59:28 +0200 Subject: [PATCH 38/58] Add tests for stable camera ID usage Add unit tests to ensure services use stable camera IDs (get_camera_id) rather than GUI display IDs (get_display_id). Tests added to tests/gui/test_rec_manager.py verify RecordingManager routes frames by stable ID, doesn't accept frames keyed by display ID, and doesn't infer frame size from display-only keys. Tests added/updated in tests/services/test_multicam_controller.py assert MultiCameraController emits frames and timestamps keyed by stable IDs, exposes a display_id mapping, and no internal use of display IDs; also update an existing rotation test to reference the stable camera ID. Imported get_camera_id/get_display_id and CameraSettings where needed. --- tests/gui/test_rec_manager.py | 103 ++++++++++++++++++++- tests/services/test_multicam_controller.py | 83 ++++++++++++++++- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/tests/gui/test_rec_manager.py b/tests/gui/test_rec_manager.py index 6a75d456..c789078b 100644 --- a/tests/gui/test_rec_manager.py +++ b/tests/gui/test_rec_manager.py @@ -3,8 +3,9 @@ import numpy as np import pytest +from dlclivegui.config import CameraSettings from dlclivegui.gui.recording_manager import RecordingManager -from dlclivegui.services.multi_camera_controller import get_camera_id +from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id from dlclivegui.services.video_recorder import RecorderStats @@ -277,3 +278,103 @@ def test_get_stats_summary_multi_aggregates( assert "30 frames" in summary # 10 + 20 assert "dropped 4" in summary # 1 + 3 assert "queue 6" in summary # 2 + 4 + + +@pytest.mark.unit +def test_recording_manager_uses_stable_camera_id_not_display_id( + recording_settings, + patch_video_recorder, + patch_build_run_dir, +): + mgr = RecordingManager() + + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "gentl:0" + assert stable_id != display_id + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + current_frames = {stable_id: frame} + + run_dir = mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert run_dir is not None + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders + + rec = mgr.recorders[stable_id] + assert rec.frame_size == (480, 640) + + mgr.write_frame(stable_id, frame, timestamp=123.0) + assert len(rec.write_calls) == 1 + assert rec.write_calls[-1][1] == 123.0 + + # Display ID is GUI-only and must not route frames internally. + mgr.write_frame(display_id, frame, timestamp=456.0) + assert len(rec.write_calls) == 1 + + +@pytest.mark.unit +def test_start_all_does_not_infer_frame_size_from_display_id( + recording_settings, + patch_video_recorder, + patch_build_run_dir, +): + mgr = RecordingManager() + + cam = CameraSettings( + name="GenTL cam", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + frame = np.zeros((480, 640, 3), dtype=np.uint8) + + # Simulate the buggy situation: frames are keyed by display ID. + current_frames = {display_id: frame} + + mgr.start_all( + recording_settings, + [cam], + current_frames, + session_name="Sess", + ) + + assert stable_id in mgr.recorders + assert display_id not in mgr.recorders + + # Since RecordingManager uses stable IDs internally, it should not find this frame. + rec = mgr.recorders[stable_id] + assert rec.frame_size is None diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0893bd9f..7c9e1f05 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -5,18 +5,23 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController, get_display_id +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id, get_display_id @pytest.mark.unit def test_start_and_frames(qtbot, patch_factory): mc = MultiCameraController() - # One dataclass + one dict (simulate mixed inputs) cam1 = CameraSettings(name="C1", backend="opencv", index=0, fps=25.0).apply_defaults() cam2 = {"name": "C2", "backend": "opencv", "index": 1, "fps": 30.0, "enabled": True} cam2 = CameraSettings.from_dict(cam2).apply_defaults() + cam1_id = get_camera_id(cam1) + cam2_id = get_camera_id(cam2) + + cam1_display = get_display_id(cam1) + cam2_display = get_display_id(cam2) + frames_seen = [] def on_ready(mfd): @@ -28,11 +33,24 @@ def on_ready(mfd): with qtbot.waitSignal(mc.all_started, timeout=1500): mc.start([cam1, cam2]) - # Wait for at least one composite emission qtbot.waitUntil(lambda: len(frames_seen) >= 1, timeout=2000) assert mc.is_running() - # We should have at least one entry with 1 or 2 frames (depending on timing) + + # Internal IDs should be used as frame keys. + seen_keys = set() + seen_sources = set() + for source_id, shape_map in frames_seen: + seen_sources.add(source_id) + seen_keys.update(shape_map.keys()) + + assert seen_keys <= {cam1_id, cam2_id} + assert seen_sources <= {cam1_id, cam2_id} + + # Display IDs should not be used as internal frame keys. + assert cam1_display not in seen_keys + assert cam2_display not in seen_keys + assert any(len(shape_map) >= 1 for _, shape_map in frames_seen) finally: @@ -60,7 +78,7 @@ def test_rotation_and_crop(qtbot, patch_factory): last_shape = {"shape": None} def on_ready(mfd): - f = mfd.frames.get(get_display_id(cam)) + f = mfd.frames.get(get_camera_id(cam)) if f is not None: last_shape["shape"] = f.shape @@ -95,3 +113,58 @@ def _create(_settings): # Expect initialization_failed with the camera id with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: mc.start([cam]) + + +@pytest.mark.unit +def test_controller_uses_stable_camera_id_not_display_id(qtbot, patch_factory): + mc = MultiCameraController() + + cam = CameraSettings( + name="C1", + backend="gentl", + index=0, + fps=30.0, + enabled=True, + properties={ + "gentl": { + "device_id": "serial:SER0", + "serial_number": "SER0", + } + }, + ).apply_defaults() + + stable_id = get_camera_id(cam) + display_id = get_display_id(cam) + + assert stable_id == "gentl:serial:SER0" + assert display_id == "gentl:0" + assert stable_id != display_id + + seen = [] + + def on_ready(mfd): + seen.append(mfd) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + qtbot.waitUntil(lambda: bool(seen), timeout=2000) + + mfd = seen[-1] + + assert mfd.source_camera_id == stable_id + assert stable_id in mfd.frames + assert stable_id in mfd.timestamps + + assert display_id not in mfd.frames + assert display_id not in mfd.timestamps + + assert mfd.display_ids is not None + assert mfd.display_ids[stable_id] == display_id + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) From f6894a027c30884a8dcd825907d529347e43cef3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:13:34 -0500 Subject: [PATCH 39/58] Update test_multicam_controller.py --- tests/services/test_multicam_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index b1fd7f1d..0e0d464b 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -482,4 +482,4 @@ def _create(settings): # Cleanup if still running. if mc.is_running(): with qtbot.waitSignal(mc.all_stopped, timeout=2000): - mc.stop(wait=True) \ No newline at end of file + mc.stop(wait=True) From 3675751ee721b6b71a92dc8d57717d3c3c6d2f6c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:01:30 +0200 Subject: [PATCH 40/58] tests: preserve display order and add MultiCamera tests Update display tests to assert that tiling and tile computations preserve frame insertion/display order (no longer sorting by camera ID) and add coverage for tile offsets, scaling, and tiled frame content. Add a suite of unit tests for MultiCameraController utilities and behavior: get_camera_id, trigger role aliasing, camera start priority, preserving user display order on start, frame_ready emission order, clearing display order on stop, hardware trigger timeouts (non-fatal), and non-trigger timeouts (fatal). Also import newly-tested helper functions from multi_camera_controller. --- tests/services/test_multicam_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 0e0d464b..e5bf6093 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -259,7 +259,6 @@ def test_start_preserves_user_display_order_even_when_trigger_start_order_differ with qtbot.waitSignal(mc.all_started, timeout=1500): mc.start([master, follower]) - # Display order follows user order, but stores stable IDs. assert mc._camera_display_order == expected_display_order finally: From ded0b554344ba271d3648f089fcc4c8aa665d675 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:26:15 +0200 Subject: [PATCH 41/58] Add trigger settings dialog and UI button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TriggerConfigDialog to edit per-camera hardware trigger settings (CameraTriggerSettings). The dialog provides fields for role, selector, source, activation, output line/source, timeout and strict mode, and persists settings into camera.properties[]['trigger']. Also add a "Trigger Settings…" button to the camera settings UI (disabled by default) with tooltip and icon; wiring to open the dialog can be connected elsewhere. --- .../camera_config/trigger_config_dialog.py | 175 ++++++++++++++++++ dlclivegui/gui/camera_config/ui_blocks.py | 7 + 2 files changed, 182 insertions(+) create mode 100644 dlclivegui/gui/camera_config/trigger_config_dialog.py diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py new file mode 100644 index 00000000..d7546e95 --- /dev/null +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -0,0 +1,175 @@ +# dlclivegui/gui/camera_config/trigger_config_dialog.py +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, +) + +from ...config import CameraSettings, CameraTriggerSettings + + +def _backend_namespace(cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + if not isinstance(cam.properties, dict): + cam.properties = {} + ns = cam.properties.setdefault(backend, {}) + if not isinstance(ns, dict): + ns = {} + cam.properties[backend] = ns + return ns + + +class TriggerConfigDialog(QDialog): + """Small dialog for editing per-camera hardware trigger settings.""" + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Trigger Settings") + self.setMinimumWidth(420) + + self._cam = cam.model_copy(deep=True) + + ns = _backend_namespace(self._cam) + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + + self._setup_ui() + self._load_from_trigger(self._trigger) + self._sync_role_ui() + + @property + def camera_settings(self) -> CameraSettings: + return self._cam + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + + info = QLabel( + "Configure hardware trigger settings for this camera.\n" + "Unsupported fields are ignored by the backend unless strict mode is enabled." + ) + info.setWordWrap(True) + root.addWidget(info) + + group = QGroupBox("Hardware Trigger") + form = QFormLayout(group) + + self.role_combo = QComboBox() + self.role_combo.addItem("Off / Free-run", "off") + self.role_combo.addItem("External trigger", "external") + self.role_combo.addItem("Follower", "follower") + self.role_combo.addItem("Master", "master") + form.addRow("Role:", self.role_combo) + + self.selector_edit = QLineEdit() + self.selector_edit.setPlaceholderText("FrameStart") + form.addRow("Trigger selector:", self.selector_edit) + + self.source_edit = QLineEdit() + self.source_edit.setPlaceholderText("Line0") + form.addRow("Trigger source:", self.source_edit) + + self.activation_combo = QComboBox() + for value in ("RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"): + self.activation_combo.addItem(value, value) + form.addRow("Activation:", self.activation_combo) + + self.output_line_edit = QLineEdit() + self.output_line_edit.setPlaceholderText("Line2") + form.addRow("Output line:", self.output_line_edit) + + self.output_source_edit = QLineEdit() + self.output_source_edit.setPlaceholderText("ExposureActive") + form.addRow("Output source:", self.output_source_edit) + + self.timeout_spin = QDoubleSpinBox() + self.timeout_spin.setRange(0.0, 3600.0) + self.timeout_spin.setDecimals(3) + self.timeout_spin.setSingleStep(0.1) + self.timeout_spin.setSpecialValueText("Default") + self.timeout_spin.setToolTip( + "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + ) + form.addRow("Read timeout:", self.timeout_spin) + + self.strict_checkbox = QCheckBox("Strict mode") + self.strict_checkbox.setToolTip("If enabled, missing/unsupported GenICam trigger nodes fail camera open.") + form.addRow(self.strict_checkbox) + + root.addWidget(group) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + + def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: + role = str(getattr(trigger, "role", "off") or "off").lower() + idx = self.role_combo.findData(role) + self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) + self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + + activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") + idx = self.activation_combo.findData(activation) + self.activation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) + self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + + timeout = getattr(trigger, "timeout", None) + self.timeout_spin.setValue(float(timeout) if timeout else 0.0) + + self.strict_checkbox.setChecked(bool(getattr(trigger, "strict", False))) + + def _sync_role_ui(self) -> None: + role = str(self.role_combo.currentData() or "off") + + input_enabled = role in {"external", "follower"} + output_enabled = role == "master" + + self.selector_edit.setEnabled(input_enabled) + self.source_edit.setEnabled(input_enabled) + self.activation_combo.setEnabled(input_enabled) + + self.output_line_edit.setEnabled(output_enabled) + self.output_source_edit.setEnabled(output_enabled) + + # Timeout is mostly useful for external/follower, but harmless for any role. + self.timeout_spin.setEnabled(role in {"external", "follower"}) + + def _accept(self) -> None: + role = str(self.role_combo.currentData() or "off") + + payload = { + "role": role, + "selector": self.selector_edit.text().strip() or "FrameStart", + "source": self.source_edit.text().strip() or "Line0", + "activation": str(self.activation_combo.currentData() or "RisingEdge"), + "output_line": self.output_line_edit.text().strip() or "Line2", + "output_source": self.output_source_edit.text().strip() or "ExposureActive", + "strict": bool(self.strict_checkbox.isChecked()), + } + + timeout = float(self.timeout_spin.value()) + if timeout > 0: + payload["timeout"] = timeout + + trigger = CameraTriggerSettings.from_any(payload) + + ns = _backend_namespace(self._cam) + ns["trigger"] = trigger.model_dump(exclude_none=True) + + self.accept() diff --git a/dlclivegui/gui/camera_config/ui_blocks.py b/dlclivegui/gui/camera_config/ui_blocks.py index 86e4f19d..07a8025e 100644 --- a/dlclivegui/gui/camera_config/ui_blocks.py +++ b/dlclivegui/gui/camera_config/ui_blocks.py @@ -354,6 +354,13 @@ def build_settings_group(dlg: CameraConfigDialog) -> QGroupBox: dlg.settings_form.addRow("Crop:", crop_widget) + # --- Trigger settings button --- + dlg.trigger_settings_btn = QPushButton("Trigger Settings…") + dlg.trigger_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView)) + dlg.trigger_settings_btn.setEnabled(False) + dlg.trigger_settings_btn.setToolTip("Configure hardware trigger / GPIO sync settings for this camera.") + dlg.settings_form.addRow("Sync:", dlg.trigger_settings_btn) + # Apply/Reset buttons row dlg.apply_settings_btn = QPushButton("Apply Settings") dlg.apply_settings_btn.setIcon(dlg.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) From bec049d1070605bd3417872d94b4d51833617357 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:29:36 +0200 Subject: [PATCH 42/58] Add per-camera hardware trigger settings Introduce support for per-camera hardware trigger configuration in the camera config dialog. Imports TriggerConfigDialog and CameraTriggerSettings, wires the trigger settings button to open a modal, and adds _open_trigger_settings_dialog to commit edits, show the dialog, apply updates, and restart the preview if needed. Adds helpers: _ensure_default_trigger_config to initialize gentl.trigger defaults, _trigger_role_for_label to show trigger role in camera list labels, and _trigger_dict_for_cam to compare trigger settings when deciding to restart previews. Also integrates the hardware trigger field into the settings summary and ensures new/loaded cameras get a default trigger config. --- .../gui/camera_config/camera_config_dialog.py | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 4c94fb70..af22842f 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -18,9 +18,10 @@ ) from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key -from ...config import CameraSettings, MultiCameraSettings +from ...config import CameraSettings, CameraTriggerSettings, MultiCameraSettings from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap +from .trigger_config_dialog import TriggerConfigDialog from .ui_blocks import setup_camera_config_dialog_ui LOGGER = logging.getLogger(__name__) @@ -328,6 +329,7 @@ def _connect_signals(self) -> None: self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked) + self.trigger_settings_btn.clicked.connect(self._open_trigger_settings_dialog) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.reset_settings_btn.clicked.connect(self._reset_selected_camera) self.preview_btn.clicked.connect(self._toggle_preview) @@ -451,11 +453,24 @@ def _refresh_camera_labels(self) -> None: finally: cam_list.blockSignals(False) + def _trigger_role_for_label(self, cam: CameraSettings) -> str: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + if not isinstance(trigger, dict): + return "off" + return str(trigger.get("role", "off") or "off").lower() + def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{(cam.backend or '').lower()}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + + trigger_role = self._trigger_role_for_label(cam) + trigger_indicator = "" if trigger_role in {"off", "disabled"} else f" [{trigger_role}]" + + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{trigger_indicator}{dlc_indicator}" def _selected_detected_camera(self) -> DetectedCamera | None: row = self.available_cameras_list.currentRow() @@ -514,6 +529,9 @@ def apply(widget, feature: str, label: str, *, allow_best_effort: bool = True): apply(self.cam_exposure, "set_exposure", "Exposure") apply(self.cam_gain, "set_gain", "Gain") + # Hardware trigger / sync + apply(self.trigger_settings_btn, "hardware_trigger", "Hardware trigger") + def _set_preview_button_loading(self, loading: bool) -> None: if loading: self.preview_btn.setText("Cancel Loading") @@ -800,6 +818,21 @@ def _on_active_camera_selected(self, row: int) -> None: self._load_camera_to_form(cam) self._start_probe_for_camera(cam, apply_to_requested=False) + def _ensure_default_trigger_config(self, cam: CameraSettings) -> None: + backend = (cam.backend or "").lower() + if backend != "gentl": + return + + if not isinstance(cam.properties, dict): + cam.properties = {} + + ns = cam.properties.setdefault("gentl", {}) + if not isinstance(ns, dict): + ns = {} + cam.properties["gentl"] = ns + + ns.setdefault("trigger", CameraTriggerSettings().model_dump(exclude_none=True)) + def _add_selected_camera(self) -> None: if not self._commit_pending_edits(reason="before adding a new camera"): return @@ -850,6 +883,7 @@ def _add_selected_camera(self) -> None: properties={}, ) apply_detected_identity(new_cam, detected, backend) + self._ensure_default_trigger_config(new_cam) self._working_settings.cameras.append(new_cam) new_index = len(self._working_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) @@ -969,6 +1003,7 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_crop_y0.setValue(cam.crop_y0) self.cam_crop_x1.setValue(cam.crop_x1) self.cam_crop_y1.setValue(cam.crop_y1) + self._ensure_default_trigger_config(cam) self.apply_settings_btn.setEnabled(True) self._set_detected_labels(cam) finally: @@ -1029,6 +1064,39 @@ def _enabled_count_with(self, row: int, new_enabled: bool) -> int: count += 1 return count + def _open_trigger_settings_dialog(self) -> None: + """Open per-camera hardware trigger settings dialog.""" + if self._current_edit_index is None: + return + + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return + + # Commit normal camera edits first so we do not lose pending UI changes. + if not self._commit_pending_edits(reason="before opening trigger settings"): + return + + cam = self._working_settings.cameras[row] + + dlg = TriggerConfigDialog(cam, self) + if dlg.exec() != QDialog.Accepted: + return + + updated = dlg.camera_settings + + self._working_settings.cameras[row] = updated + self._update_active_list_item(row, updated) + self._load_camera_to_form(updated) + + # Trigger changes require reopening the camera preview/backend. + if self._preview.state == PreviewState.ACTIVE: + self._append_status("[Trigger] Restarting preview to apply trigger settings.") + self._request_preview_restart(updated, reason="trigger-settings") + + self.apply_settings_btn.setEnabled(False) + self._set_apply_dirty(False) + def _apply_camera_settings(self) -> bool: try: for sb in ( @@ -1597,6 +1665,13 @@ def _bump_epoch(self) -> int: self._preview.epoch += 1 return self._preview.epoch + def _trigger_dict_for_cam(self, cam: CameraSettings) -> dict: + backend = (cam.backend or "").lower() + props = cam.properties if isinstance(cam.properties, dict) else {} + ns = props.get(backend, {}) if isinstance(props.get(backend), dict) else {} + trigger = ns.get("trigger", {}) + return trigger if isinstance(trigger, dict) else {} + def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> bool: """ Fast UX policy: @@ -1612,6 +1687,9 @@ def _should_restart_preview(self, old: CameraSettings, new: CameraSettings) -> b except Exception: return True # safest: restart + if self._trigger_dict_for_cam(old) != self._trigger_dict_for_cam(new): + return True + # No restart needed if only rotation/crop/enabled changed return False From 89817887adb6f5d07779017a7538a92ec2e1f260 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:36:08 +0200 Subject: [PATCH 43/58] Update trigger_config_dialog.py --- dlclivegui/gui/camera_config/trigger_config_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d7546e95..d4c4b19e 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -34,7 +34,7 @@ class TriggerConfigDialog(QDialog): def __init__(self, cam: CameraSettings, parent: QWidget | None = None): super().__init__(parent) - self.setWindowTitle("Trigger Settings") + self.setWindowTitle("Configure trigger mode") self.setMinimumWidth(420) self._cam = cam.model_copy(deep=True) From 3cea2a350c7bde9525c3102859a390bb25a0f464 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:25:21 +0200 Subject: [PATCH 44/58] Use _is_preview_live and clear trigger timeout Replace direct checks of self._preview.state == PreviewState.ACTIVE with self._is_preview_live() in camera_config_dialog to centralize preview-active logic and ensure consistent behavior when restarting the preview after trigger or camera setting changes. In trigger_config_dialog, only set payload["timeout"] for external/follower roles when timeout > 0, and explicitly set payload["timeout"] = None when role == "off" to clear any stale timeout when disabling the trigger. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 8 +++----- dlclivegui/gui/camera_config/trigger_config_dialog.py | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index af22842f..e005617b 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1090,7 +1090,7 @@ def _open_trigger_settings_dialog(self) -> None: self._load_camera_to_form(updated) # Trigger changes require reopening the camera preview/backend. - if self._preview.state == PreviewState.ACTIVE: + if self._is_preview_live(): self._append_status("[Trigger] Restarting preview to apply trigger settings.") self._request_preview_restart(updated, reason="trigger-settings") @@ -1153,9 +1153,7 @@ def _apply_camera_settings(self) -> bool: old_settings = current_model restart = False - should_consider_restart = self._preview.state == PreviewState.ACTIVE and isinstance( - old_settings, CameraSettings - ) + should_consider_restart = self._is_preview_live() and isinstance(old_settings, CameraSettings) if should_consider_restart: restart = self._should_restart_preview(old_settings, new_model) @@ -1167,7 +1165,7 @@ def _apply_camera_settings(self) -> bool: new_model.index, ) - if self._preview.state == PreviewState.ACTIVE and restart: + if self._is_preview_live() and restart: self._append_status("[Apply] Restarting preview to apply camera settings changes.") self._request_preview_restart(new_model, reason="apply-settings") diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d4c4b19e..07e9570a 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -164,8 +164,10 @@ def _accept(self) -> None: } timeout = float(self.timeout_spin.value()) - if timeout > 0: + if role in {"external", "follower"} and timeout > 0: payload["timeout"] = timeout + elif role == "off": + payload["timeout"] = None # ensure timeout is cleared when disabling trigger trigger = CameraTriggerSettings.from_any(payload) From d12c0982ea0eb9303acb0a230259d30a02f26d67 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:43:11 +0200 Subject: [PATCH 45/58] Restart active previews; ignore bad trigger Allow pending preview restarts to proceed when the preview is ACTIVE (previously only IDLE) and return early to avoid double UI sync in camera_config_dialog.py. Also wrap CameraTriggerSettings loading in a try/except and fall back to a default instance when parsing fails, making trigger_config_dialog.py tolerant of malformed or missing trigger data. --- dlclivegui/gui/camera_config/camera_config_dialog.py | 3 ++- dlclivegui/gui/camera_config/trigger_config_dialog.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index e005617b..444f2738 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -1832,9 +1832,10 @@ def _on_loader_finished(self, e: int) -> None: self._preview.restart_scheduled = False self._preview.loader = None - if pending and self._preview.state == PreviewState.IDLE: + if pending and self._preview.state in (PreviewState.IDLE, PreviewState.ACTIVE): LOGGER.debug("[Loader] finished with pending restart for backend=%s idx=%s", pending.backend, pending.index) self._begin_preview_load(pending, reason="pending-restart-after-finish") + return # UI sync is already handled in _begin_preview_load self._sync_preview_ui() diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index 07e9570a..cd72701b 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -40,7 +40,10 @@ def __init__(self, cam: CameraSettings, parent: QWidget | None = None): self._cam = cam.model_copy(deep=True) ns = _backend_namespace(self._cam) - self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + try: + self._trigger = CameraTriggerSettings.from_any(ns.get("trigger")) + except Exception: + self._trigger = CameraTriggerSettings() self._setup_ui() self._load_from_trigger(self._trigger) From 2c387b5a509421df95fe6e8c8338ce56bcdd71c1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:10:49 +0200 Subject: [PATCH 46/58] Improve trigger dialog defaults and error handling Make trigger dialog friendlier and more robust: recommend using 'auto' for trigger source and switch default/fallback source from "Line0" to "auto", update source placeholder and info/timeout tooltips, and import QMessageBox. Wrap CameraTriggerSettings.from_any in a try/except to show a critical error dialog on failure and abort apply. Store trigger settings via trigger.to_properties() instead of model_dump(exclude_none=True). These changes provide clearer defaults and better error feedback when applying trigger settings. --- .../camera_config/trigger_config_dialog.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index cd72701b..d1efa036 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -11,6 +11,7 @@ QGroupBox, QLabel, QLineEdit, + QMessageBox, QVBoxLayout, QWidget, ) @@ -58,7 +59,8 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Unsupported fields are ignored by the backend unless strict mode is enabled." + "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) root.addWidget(info) @@ -78,7 +80,7 @@ def _setup_ui(self) -> None: form.addRow("Trigger selector:", self.selector_edit) self.source_edit = QLineEdit() - self.source_edit.setPlaceholderText("Line0") + self.source_edit.setPlaceholderText("auto, Line0, Software, ...") form.addRow("Trigger source:", self.source_edit) self.activation_combo = QComboBox() @@ -100,7 +102,7 @@ def _setup_ui(self) -> None: self.timeout_spin.setSingleStep(0.1) self.timeout_spin.setSpecialValueText("Default") self.timeout_spin.setToolTip( - "Fetch poll timeout in seconds. For triggered cameras, 0.2–0.5s is usually responsive." + "Fetch poll timeout in seconds. The backend may cap individual fetches to keep preview shutdown responsive." ) form.addRow("Read timeout:", self.timeout_spin) @@ -123,7 +125,7 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.role_combo.setCurrentIndex(idx if idx >= 0 else 0) self.selector_edit.setText(str(getattr(trigger, "selector", "FrameStart") or "FrameStart")) - self.source_edit.setText(str(getattr(trigger, "source", "Line0") or "Line0")) + self.source_edit.setText(str(getattr(trigger, "source", "auto") or "auto")) activation = str(getattr(trigger, "activation", "RisingEdge") or "RisingEdge") idx = self.activation_combo.findData(activation) @@ -159,7 +161,7 @@ def _accept(self) -> None: payload = { "role": role, "selector": self.selector_edit.text().strip() or "FrameStart", - "source": self.source_edit.text().strip() or "Line0", + "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", @@ -172,9 +174,13 @@ def _accept(self) -> None: elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger - trigger = CameraTriggerSettings.from_any(payload) + try: + trigger = CameraTriggerSettings.from_any(payload) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to apply trigger settings: {e}") + return ns = _backend_namespace(self._cam) - ns["trigger"] = trigger.model_dump(exclude_none=True) + ns["trigger"] = trigger.to_properties() self.accept() From d377d19fa40e28c4b24471899d4d4251327585f3 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:50:10 +0200 Subject: [PATCH 47/58] Add worker timing logs to camera worker Introduce WorkerTimingStats (utils/stats.py) to collect simple timing counters (per-named section totals, frame/timeouts/errors) and periodically emit debug logs. Integrate it into SingleCameraWorker: create a timing instance (configurable via new SINGLE_CAMERA_WORKER_DO_LOG_TIMING in config.py), wrap backend.read and frame emit calls with timed sections, and call note_frame/note_timeout/note_error + maybe_log to accumulate and flush stats. Also update imports accordingly and use a 1s default log interval. --- dlclivegui/config.py | 2 + .../services/multi_camera_controller.py | 21 +++- dlclivegui/utils/stats.py | 103 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 11530162..827fdfc6 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,6 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True + class CameraSettings(BaseModel): name: str = "Camera 0" diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 4ec9a822..b6260175 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,8 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings +from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -55,6 +56,11 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + # Performance logs + self._timing = WorkerTimingStats( + camera_id, logger=LOGGER, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING + ) + @Slot() def run(self) -> None: self._stop_event.clear() @@ -90,7 +96,8 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - frame, timestamp = self._backend.read() + with self._timing.measure("GenTL.read"): + frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: @@ -103,9 +110,15 @@ def run(self) -> None: continue consecutive_errors = 0 - self.frame_captured.emit(self._camera_id, frame, timestamp) + with self._timing.measure("GenTL.emit.frame_captured"): + self.frame_captured.emit(self._camera_id, frame, timestamp) + + self._timing.note_frame() + self._timing.maybe_log() except TimeoutError as exc: + self._timing.note_timeout() + self._timing.maybe_log() if self._stop_event.is_set(): break @@ -133,6 +146,8 @@ def run(self) -> None: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() consecutive_errors += 1 if self._stop_event.is_set(): break diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 23e9d57f..38e3798b 100644 --- a/dlclivegui/utils/stats.py +++ b/dlclivegui/utils/stats.py @@ -1,10 +1,113 @@ # dlclivegui/utils/stats.py from __future__ import annotations +import logging +import time + from dlclivegui.services.dlc_processor import ProcessorStats from dlclivegui.services.video_recorder import RecorderStats +class WorkerTimingStats: + """Tiny timing accumulator for camera worker performance diagnostics. + + Usage: + with stats.measure("read"): + frame, ts = backend.read() + + Logs aggregate timings once per log_interval seconds. + """ + + def __init__( + self, camera_id: str, *, logger: logging.Logger | None = None, log_interval: float = 1.0, enabled: bool = True + ): + self.camera_id = camera_id + self.log_interval = float(log_interval) + self.enabled = bool(enabled) + self.logger = logger or logging.getLogger(__name__) + if self.enabled: # force logger to proper level + if not self.logger.isEnabledFor(logging.DEBUG): + self.logger.setLevel(logging.DEBUG) + + self._last_log = time.perf_counter() + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals: dict[str, float] = {} + self._counts: dict[str, int] = {} + + class _Measure: + def __init__(self, parent: WorkerTimingStats, name: str): + self.parent = parent + self.name = name + self.t0 = 0.0 + + def __enter__(self): + if self.parent.enabled: + self.t0 = time.perf_counter() + return self + + def __exit__(self, exc_type, exc, tb): + if not self.parent.enabled: + return False + + dt = time.perf_counter() - self.t0 + self.parent._totals[self.name] = self.parent._totals.get(self.name, 0.0) + dt + self.parent._counts[self.name] = self.parent._counts.get(self.name, 0) + 1 + return False + + def measure(self, name: str): + return self._Measure(self, name) + + def note_frame(self) -> None: + if self.enabled: + self._frames += 1 + + def note_timeout(self) -> None: + if self.enabled: + self._timeouts += 1 + + def note_error(self) -> None: + if self.enabled: + self._errors += 1 + + def maybe_log(self) -> None: + if not self.enabled: + return + + now = time.perf_counter() + elapsed = now - self._last_log + if elapsed < self.log_interval: + return + + fps = self._frames / max(elapsed, 1e-9) + + parts = [ + f"[Worker {self.camera_id}]", + f"fps={fps:.1f}", + f"frames={self._frames}", + ] + + if self._timeouts: + parts.append(f"timeouts={self._timeouts}") + if self._errors: + parts.append(f"errors={self._errors}") + + for name in sorted(self._totals): + count = max(self._counts.get(name, 0), 1) + avg_ms = 1000.0 * self._totals[name] / count + parts.append(f"avg_{name}_ms={avg_ms:.3f}") + + self.logger.debug(" ".join(parts)) + + self._last_log = now + self._frames = 0 + self._timeouts = 0 + self._errors = 0 + self._totals.clear() + self._counts.clear() + + def format_recorder_stats(stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 From 4003e91d2f7076c170d957d1f616c8d4412144cc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:01:43 +0200 Subject: [PATCH 48/58] Add per-camera timing to MultiCameraController Introduce MULTI_CAMERA_WORKER_DO_LOG_TIMING and disable single-worker timing by default (SINGLE_CAMERA_WORKER_DO_LOG_TIMING=false). Add per-camera WorkerTimingStats storage and a _timing_for_camera factory that respects the new config. Wrap _on_frame_captured processing in timed sections (total, apply_transforms, update_latest), call note_frame/maybe_log per camera, and emit frames as before. Also adjust SingleCameraWorker timing labels from GenTL.* to Single.* for clearer logs. --- dlclivegui/config.py | 3 +- .../services/multi_camera_controller.py | 94 ++++++++++++------- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 827fdfc6..5793f590 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,7 +15,8 @@ TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = True +SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False +MULTI_CAMERA_WORKER_DO_LOG_TIMING = True class CameraSettings(BaseModel): diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index b6260175..adca38aa 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,7 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - with self._timing.measure("GenTL.read"): + with self._timing.measure("Single.read"): frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 @@ -110,7 +110,7 @@ def run(self) -> None: continue consecutive_errors = 0 - with self._timing.measure("GenTL.emit.frame_captured"): + with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) self._timing.note_frame() @@ -260,6 +260,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # Performance logs + self._timing_per_cam: dict[str, WorkerTimingStats] = {} + def is_running(self) -> bool: """Check if any camera is currently running.""" return self._running and len(self._started_cameras) > 0 @@ -268,6 +271,20 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) + def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: + if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: + return WorkerTimingStats(camera_id, enabled=False) + timing = self._timing_per_cam.get(camera_id) + if timing is None: + timing = WorkerTimingStats( + f"Controller {camera_id}", + logger=LOGGER, + log_interval=1.0, + enabled=MULTI_CAMERA_WORKER_DO_LOG_TIMING, + ) + self._timing_per_cam[camera_id] = timing + return timing + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -453,38 +470,42 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" # Apply rotation if configured - settings = self._settings.get(camera_id) - if settings and settings.rotation: - frame = MultiCameraController.apply_rotation(frame, settings.rotation) - - # Apply cropping if configured - if settings: - crop_region = settings.get_crop_region() - if crop_region: - frame = MultiCameraController.apply_crop(frame, crop_region) - - with self._frame_lock: - self._frames[camera_id] = frame - self._timestamps[camera_id] = timestamp - - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] + timing = self._timing_for_camera(camera_id) + + with timing.measure("Multi.slot.total"): + settings = self._settings.get(camera_id) + with timing.measure("Multi.slot.apply_transforms"): + if settings and settings.rotation: + frame = MultiCameraController.apply_rotation(frame, settings.rotation) + + # Apply cropping if configured + if settings: + crop_region = settings.get_crop_region() + if crop_region: + frame = MultiCameraController.apply_crop(frame, crop_region) + with timing.measure("Multi.update_latest"): + with self._frame_lock: + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp + + # Emit frame data without tiling (tiling done in GUI for performance) + if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] frame_data = MultiFrameData( frames=ordered_frames, @@ -495,6 +516,9 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float ) self.frame_ready.emit(frame_data) + timing.note_frame() + timing.maybe_log() + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" From 5cac636f9d6f0e5f969ad588c5d407db3e523faf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:02:38 +0200 Subject: [PATCH 49/58] Add pretty/str/repr to CameraSettings Introduce a human-readable representation for CameraSettings by adding a pretty() method and overriding __str__ and __repr__. The pretty output formats key fields (name, index, backend, enabled, fps, size, exposure, gain, rotation) and displays a readable crop region (showing 'none' or coordinate range with 'edge' fallbacks). This aids debugging and logging without changing existing validation or behavior. --- dlclivegui/config.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 5793f590..c7a9f431 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -43,6 +43,27 @@ class CameraSettings(BaseModel): enabled: bool = True properties: dict[str, Any] = Field(default_factory=dict) + def pretty(self) -> str: + crop = ( + "none" + if self.get_crop_region() is None + else f"({self.crop_x0}, {self.crop_y0}) -> ({self.crop_x1 or 'edge'}, {self.crop_y1 or 'edge'})" + ) + return ( + f"CameraSettings[\n" + f" name={self.name!r}, index={self.index}, backend={self.backend!r}, enabled={self.enabled}\n" + f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, " + f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n" + f" rotation={self.rotation}, crop={crop}\n" + f"]" + ) + + def __str__(self) -> str: + return self.pretty() + + def __repr__(self) -> str: + return self.pretty() + @field_validator("fps", mode="before") @classmethod def _coerce_fps(cls, v): From ff81a9e56c28320dfc15cce1d21a406b42d663ac Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:45:09 +0200 Subject: [PATCH 50/58] Reduce lock scope and refactor frame handling Refactor _on_frame_captured to minimize time spent under _frame_lock and improve timing granularity. Introduces a frame_data local, renames timing labels (e.g. Multi.apply_transforms, Multi.store_latest, Multi.build_ordered, Multi.construct_frame_data), and moves frame_data construction inside measured blocks. The frame_ready emit is now performed outside the lock and guarded by a None check to avoid holding the lock during signal emission. Also small whitespace and cleanup changes. --- .../services/multi_camera_controller.py | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index adca38aa..c88fbda5 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -469,52 +469,57 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): settings = self._settings.get(camera_id) - with timing.measure("Multi.slot.apply_transforms"): + + with timing.measure("Multi.apply_transforms"): if settings and settings.rotation: frame = MultiCameraController.apply_rotation(frame, settings.rotation) - # Apply cropping if configured if settings: crop_region = settings.get_crop_region() if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) - with timing.measure("Multi.update_latest"): - with self._frame_lock: + + with self._frame_lock: + with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - frame_data = MultiFrameData( - frames=ordered_frames, - timestamps=ordered_timestamps, - source_camera_id=camera_id, - tiled_frame=None, - display_ids=dict(self._display_ids), - ) - self.frame_ready.emit(frame_data) + with timing.measure("Multi.build_ordered"): + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + with timing.measure("Multi.construct_frame_data"): + frame_data = MultiFrameData( + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, + tiled_frame=None, + display_ids=dict(self._display_ids), + ) + + + if frame_data is not None: + with timing.measure("Multi.emit.frame_ready"): + self.frame_ready.emit(frame_data) timing.note_frame() timing.maybe_log() From 52feea97956a2a76247136df7c13c2dc428338d9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 2 Jun 2026 15:48:40 +0200 Subject: [PATCH 51/58] Add GenTL strobe support and trigger debugging Add debugging helpers for GenTL trigger and frame-rate nodes and log their values during camera configuration. Improve GenTL trigger input handling by treating TriggerSource as best-effort (supporting read-only/auto cases) and emitting clearer warnings. Implement a dedicated master/output path for TIS/DMK 37U cameras using Strobe* nodes (StrobeEnable/Polarity/Operation/Duration/Delay) with a fallback to generic Line* configuration; respect strict mode and surface informative errors. Extend CameraTriggerSettings with strobe fields (polarity, operation, duration, delay) and validation/coercion. Update the trigger configuration dialog to expose strobe controls, tooltips, UI sync logic, and include strobe values in the saved payload. --- dlclivegui/cameras/backends/gentl_backend.py | 279 +++++++++++++++--- dlclivegui/config.py | 31 +- .../camera_config/trigger_config_dialog.py | 89 +++++- 3 files changed, 350 insertions(+), 49 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 44d12708..29d60a46 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -192,6 +192,89 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "hardware_trigger": SupportLevel.BEST_EFFORT, } + def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "TriggerMode", + "TriggerSelector", + "TriggerSource", + "TriggerActivation", + "AcquisitionMode", + # Generic line nodes, if available. + "LineSelector", + "LineMode", + "LineSource", + # TIS 37U / DMK 37BUX287 strobe/output nodes. + "GPIn", + "GPOut", + "StrobeEnable", + "StrobePolarity", + "StrobeOperation", + "StrobeDuration", + "StrobeDelay", + ) + + label = f"GenTL trigger debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + + symbolics = self._node_symbolics(node) + if symbolics: + extras.append(f"symbolics={symbolics}") + + for attr in ("access_mode", "is_writable", "is_readable"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _debug_frame_rate_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRateControlEnable", + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "DeviceLinkThroughputLimit", + "DeviceLinkThroughputLimitMode", + "PayloadSize", + "Width", + "Height", + "PixelFormat", + ) + + label = f"GenTL FPS debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + for attr in ("min", "max", "inc"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -464,6 +547,7 @@ def open(self) -> None: self._configure_gain(node_map) self._configure_frame_rate(node_map) self._configure_trigger(node_map) # keep low in the list + self._debug_trigger_nodes(node_map, context="after configuration before acquisition") self._ensure_settings_ns()["trigger_actual"] = self._trigger_to_dict(self._trigger) self._read_telemetry(node_map) self._persist_device_metadata(selected_info, selected_serial) @@ -1186,31 +1270,42 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") - source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) - source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) - # TriggerSelector and TriggerSource are required routing nodes. - # If either failed in non-strict mode, do not arm TriggerMode=On. - # Otherwise the camera may wait on a previous/default input line. - if not (selector_ok and source_ok): + source_ok = False + if source and source.lower() not in {"", "auto", "none"}: + source_node = self._node(node_map, "TriggerSource") + source_symbolics = self._node_symbolics(source_node) + + if source_node is not None: + if source in source_symbolics: + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) + if not source_ok: + LOG.warning( + "GenTL TriggerSource=%s is supported but not writable; " + "continuing without changing TriggerSource. Available: %s", + source, + source_symbolics, + ) + else: + LOG.warning( + "Requested GenTL TriggerSource=%s not in available sources %s; " + "continuing without changing TriggerSource.", + source, + source_symbolics, + ) + + if not selector_ok: LOG.warning( - "Could not apply GenTL trigger input routing " - "(selector_ok=%s, source_ok=%s); disabling trigger. " - "requested role=%s selector=%s source=%s activation=%s", - selector_ok, - source_ok, - role, + "Could not apply GenTL TriggerSelector=%s; disabling trigger.", selector, - source, - activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1231,55 +1326,153 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + "GenTL trigger input configured: role=%s selector=%s activation=%s " + "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", role, selector, - source, activation, + selector_ok, activation_ok, + source, + source_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + """Configure this camera as a free-running master that emits STROBE_OUT pulses. + + For DMK 37BUX287 / TIS 37U series, the physical output is controlled by + StrobeEnable/StrobePolarity/StrobeOperation rather than SFNC LineSelector/ + LineMode/LineSource nodes. + """ output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") - # Master camera runs freerun and exposes an output signal. + # Optional extra fields if present in trigger dict/model. + strobe_polarity = str(self._trigger_attr(cfg, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + strobe_operation = str(self._trigger_attr(cfg, "strobe_operation", "Exposure") or "Exposure") + strobe_duration = self._trigger_attr(cfg, "strobe_duration", None) + strobe_delay = self._trigger_attr(cfg, "strobe_delay", None) + + # Master camera should be free-running. self._configure_trigger_off(node_map, strict=False) - line_selected = self._set_enum_node( - node_map, - "LineSelector", - output_line, - strict=strict, - ) + # ------------------------------------------------------------------ + # Preferred path for The Imaging Source 37U / DMK 37BUX287: + # StrobeEnable, StrobePolarity, StrobeOperation, StrobeDuration, StrobeDelay + # ------------------------------------------------------------------ + strobe_enable_node = self._node(node_map, "StrobeEnable") + + if strobe_enable_node is not None: + # Disable first while changing parameters. + self._set_enum_node(node_map, "StrobeEnable", "Off", strict=False) + + polarity_ok = self._set_enum_node( + node_map, + "StrobePolarity", + strobe_polarity, + strict=False, + ) - # In non-strict mode, do not continue configuring output behavior if the - # requested line could not be selected. Otherwise we may accidentally drive - # whichever GPIO line the camera had selected previously/defaulted to. - if not line_selected: - LOG.warning( - "Could not select GenTL output line '%s'; skipping trigger output configuration.", - output_line, + operation_ok = self._set_enum_node( + node_map, + "StrobeOperation", + strobe_operation, + strict=False, ) - return - mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + if strobe_duration is not None: + try: + node = self._node(node_map, "StrobeDuration") + if node is not None: + node.value = int(strobe_duration) + LOG.info("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc + LOG.warning("Failed to set StrobeDuration=%s: %s", strobe_duration, exc) + + if strobe_delay is not None: + try: + node = self._node(node_map, "StrobeDelay") + if node is not None: + node.value = int(strobe_delay) + LOG.info("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc + LOG.warning("Failed to set StrobeDelay=%s: %s", strobe_delay, exc) + + enable_ok = self._set_enum_node( + node_map, + "StrobeEnable", + "On", + strict=strict, + ) + + if enable_ok: + LOG.info( + "GenTL trigger master configured via Strobe*: " + "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " + "StrobeOperation=%s operation_ok=%s", + strobe_polarity, + polarity_ok, + strobe_operation, + operation_ok, + ) + return + + if strict: + raise RuntimeError("Could not enable GenTL StrobeEnable=On") - if not (mode_ok and source_ok): LOG.warning( - "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", - mode_ok, - source_ok, + "StrobeEnable node exists but could not be enabled; falling back to generic Line* output configuration." ) - return - LOG.info( - "GenTL trigger master configured: output_line=%s output_source=%s", - output_line, - output_source, + # ------------------------------------------------------------------ + # Generic SFNC fallback for cameras that expose LineSelector/LineMode/LineSource. + # ------------------------------------------------------------------ + line_selector = self._node(node_map, "LineSelector") + if line_selector is not None: + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping Line* output configuration.", + output_line, + ) + else: + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "GenTL trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + LOG.warning( + "GenTL Line* trigger output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + + msg = ( + "Could not configure GenTL trigger master output. " + "No supported Strobe* or Line* output path was successfully configured." ) + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + def _restore_trigger_idle(self, node_map) -> None: """Best-effort restore to a safe non-triggering state after acquisition stops. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c7a9f431..2d176224 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -14,6 +14,8 @@ ModelType = Literal["pytorch", "tensorflow"] TriggerRole = Literal["off", "external", "master", "follower"] TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] +TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] +TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False MULTI_CAMERA_WORKER_DO_LOG_TIMING = True @@ -239,9 +241,13 @@ class CameraTriggerSettings(BaseModel): Generic hardware-trigger settings. Backend-specific code may ignore fields that are unsupported by a given - camera/SDK. For GenTL, these map to common GenICam nodes such as: - TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, - LineSelector, LineMode, and LineSource. + camera/SDK. + + For GenTL/TIS DMK 37BUX287: + - follower/external maps mainly to TriggerMode, TriggerSelector, + TriggerActivation. TriggerSource may be read-only and is best-effort. + - master output maps primarily to StrobeEnable, StrobePolarity, + StrobeOperation, StrobeDuration, and StrobeDelay. """ role: TriggerRole = "off" @@ -251,10 +257,16 @@ class CameraTriggerSettings(BaseModel): source: str = "auto" activation: TriggerActivation | str = "RisingEdge" - # Output config: master + # Generic/SFNC output config: master fallback for cameras exposing Line* nodes. output_line: str = "Line2" output_source: str = "ExposureActive" + # Strobe output config: master path for TIS/DMK 37U cameras. + strobe_polarity: TriggerStrobePolarity | str = "ActiveHigh" + strobe_operation: TriggerStrobeOperation | str = "Exposure" + strobe_duration: int | None = None # µs, used when strobe_operation=FixedDuration + strobe_delay: int | None = None # µs + # Runtime behavior timeout: float | None = None strict: bool = False @@ -296,6 +308,17 @@ def _coerce_timeout(cls, v): return None return fv if fv > 0 else None + @field_validator("strobe_duration", "strobe_delay", mode="before") + @classmethod + def _coerce_optional_nonnegative_int(cls, v): + if v in (None, ""): + return None + try: + iv = int(float(v)) + except Exception: + return None + return iv if iv >= 0 else None + @field_validator("source", mode="before") @classmethod def _coerce_source(cls, v): diff --git a/dlclivegui/gui/camera_config/trigger_config_dialog.py b/dlclivegui/gui/camera_config/trigger_config_dialog.py index d1efa036..c6f78140 100644 --- a/dlclivegui/gui/camera_config/trigger_config_dialog.py +++ b/dlclivegui/gui/camera_config/trigger_config_dialog.py @@ -12,6 +12,7 @@ QLabel, QLineEdit, QMessageBox, + QSpinBox, QVBoxLayout, QWidget, ) @@ -59,7 +60,9 @@ def _setup_ui(self) -> None: info = QLabel( "Configure hardware trigger settings for this camera.\n" - "Use 'auto' for trigger source unless you know the exact GenICam line name. " + "Follower/external mode arms the camera and waits for electrical pulses on TRIGGER_IN.\n" + "Master mode enables STROBE_OUT pulses. For TIS/DMK 37U cameras this uses Strobe settings; " + "Line output settings are kept as a generic fallback.\n" "In strict mode, unsupported trigger nodes fail camera open." ) info.setWordWrap(True) @@ -90,12 +93,57 @@ def _setup_ui(self) -> None: self.output_line_edit = QLineEdit() self.output_line_edit.setPlaceholderText("Line2") + self.output_line_edit.setToolTip( + "Generic Line* output selector for cameras exposing LineSelector/LineSource. " + "Ignored by TIS/DMK 37U strobe-based output." + ) form.addRow("Output line:", self.output_line_edit) self.output_source_edit = QLineEdit() self.output_source_edit.setPlaceholderText("ExposureActive") + self.output_source_edit.setToolTip( + "Generic LineSource value for cameras exposing LineSource. " + "For TIS/DMK 37U cameras, use Strobe operation instead." + ) form.addRow("Output source:", self.output_source_edit) + self.strobe_polarity_combo = QComboBox() + self.strobe_polarity_combo.addItem("Active high", "ActiveHigh") + self.strobe_polarity_combo.addItem("Active low", "ActiveLow") + self.strobe_polarity_combo.setToolTip( + "Polarity of STROBE_OUT. If the follower does not trigger, also try changing the follower activation edge." + ) + form.addRow("Strobe polarity:", self.strobe_polarity_combo) + + self.strobe_operation_combo = QComboBox() + self.strobe_operation_combo.addItem("Exposure duration", "Exposure") + self.strobe_operation_combo.addItem("Fixed duration", "FixedDuration") + self.strobe_operation_combo.setToolTip( + "Exposure: strobe pulse length follows exposure time. " + "FixedDuration: strobe pulse length is set by Strobe duration." + ) + form.addRow("Strobe operation:", self.strobe_operation_combo) + + self.strobe_duration_spin = QSpinBox() + self.strobe_duration_spin.setRange(0, 32767) + self.strobe_duration_spin.setSingleStep(100) + self.strobe_duration_spin.setSuffix(" µs") + self.strobe_duration_spin.setSpecialValueText("Default") + self.strobe_duration_spin.setToolTip( + "Used only when Strobe operation is FixedDuration. 0 means backend/device default." + ) + form.addRow("Strobe duration:", self.strobe_duration_spin) + + self.strobe_delay_spin = QSpinBox() + self.strobe_delay_spin.setRange(0, 32767) + self.strobe_delay_spin.setSingleStep(100) + self.strobe_delay_spin.setSuffix(" µs") + self.strobe_delay_spin.setSpecialValueText("Default") + self.strobe_delay_spin.setToolTip( + "Delay between start of exposure and STROBE_OUT pulse. 0 means no delay/device default." + ) + form.addRow("Strobe delay:", self.strobe_delay_spin) + self.timeout_spin = QDoubleSpinBox() self.timeout_spin.setRange(0.0, 3600.0) self.timeout_spin.setDecimals(3) @@ -118,6 +166,7 @@ def _setup_ui(self) -> None: root.addWidget(buttons) self.role_combo.currentIndexChanged.connect(self._sync_role_ui) + self.strobe_operation_combo.currentIndexChanged.connect(self._sync_role_ui) def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: role = str(getattr(trigger, "role", "off") or "off").lower() @@ -134,6 +183,20 @@ def _load_from_trigger(self, trigger: CameraTriggerSettings) -> None: self.output_line_edit.setText(str(getattr(trigger, "output_line", "Line2") or "Line2")) self.output_source_edit.setText(str(getattr(trigger, "output_source", "ExposureActive") or "ExposureActive")) + strobe_polarity = str(getattr(trigger, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + idx = self.strobe_polarity_combo.findData(strobe_polarity) + self.strobe_polarity_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_operation = str(getattr(trigger, "strobe_operation", "Exposure") or "Exposure") + idx = self.strobe_operation_combo.findData(strobe_operation) + self.strobe_operation_combo.setCurrentIndex(idx if idx >= 0 else 0) + + strobe_duration = getattr(trigger, "strobe_duration", None) + self.strobe_duration_spin.setValue(int(strobe_duration) if strobe_duration is not None else 0) + + strobe_delay = getattr(trigger, "strobe_delay", None) + self.strobe_delay_spin.setValue(int(strobe_delay) if strobe_delay is not None else 0) + timeout = getattr(trigger, "timeout", None) self.timeout_spin.setValue(float(timeout) if timeout else 0.0) @@ -143,15 +206,26 @@ def _sync_role_ui(self) -> None: role = str(self.role_combo.currentData() or "off") input_enabled = role in {"external", "follower"} - output_enabled = role == "master" self.selector_edit.setEnabled(input_enabled) self.source_edit.setEnabled(input_enabled) self.activation_combo.setEnabled(input_enabled) + output_enabled = role == "master" + # Generic Line* fallback fields. self.output_line_edit.setEnabled(output_enabled) self.output_source_edit.setEnabled(output_enabled) + # TIS/DMK 37U Strobe* fields. + self.strobe_polarity_combo.setEnabled(output_enabled) + self.strobe_operation_combo.setEnabled(output_enabled) + + fixed_duration = ( + output_enabled and str(self.strobe_operation_combo.currentData() or "Exposure") == "FixedDuration" + ) + self.strobe_duration_spin.setEnabled(fixed_duration) + self.strobe_delay_spin.setEnabled(output_enabled) + # Timeout is mostly useful for external/follower, but harmless for any role. self.timeout_spin.setEnabled(role in {"external", "follower"}) @@ -163,8 +237,12 @@ def _accept(self) -> None: "selector": self.selector_edit.text().strip() or "FrameStart", "source": self.source_edit.text().strip() or "auto", "activation": str(self.activation_combo.currentData() or "RisingEdge"), + # Generic/SFNC Line* fallback output settings. "output_line": self.output_line_edit.text().strip() or "Line2", "output_source": self.output_source_edit.text().strip() or "ExposureActive", + # Strobe output settings used by TIS/DMK 37U cameras. + "strobe_polarity": str(self.strobe_polarity_combo.currentData() or "ActiveHigh"), + "strobe_operation": str(self.strobe_operation_combo.currentData() or "Exposure"), "strict": bool(self.strict_checkbox.isChecked()), } @@ -173,6 +251,13 @@ def _accept(self) -> None: payload["timeout"] = timeout elif role == "off": payload["timeout"] = None # ensure timeout is cleared when disabling trigger + strobe_duration = int(self.strobe_duration_spin.value()) + if role == "master" and strobe_duration > 0: + payload["strobe_duration"] = strobe_duration + + strobe_delay = int(self.strobe_delay_spin.value()) + if role == "master" and strobe_delay > 0: + payload["strobe_delay"] = strobe_delay try: trigger = CameraTriggerSettings.from_any(payload) From 658cea61c72f234fed329b6235042a400bfe10c9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:46:42 -0500 Subject: [PATCH 52/58] Add _node_value helper for safe node reads Introduce a static _node_value(node_map, name, default) helper that performs a best-effort read of GenICam node values. It looks up the node, returns default if missing, then tries node.value and falls back to node.GetValue() while swallowing exceptions. This prevents debug helpers and open() from failing when test/SDK nodes expose different accessors or raise errors. --- dlclivegui/cameras/backends/gentl_backend.py | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 29d60a46..cbd1632d 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1093,6 +1093,32 @@ def _node(node_map, name: str): except Exception: return None + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value. + + Debug helpers must not make open() fail just because a value cannot be read. + Harvesters-style fake/test nodes usually expose `.value`; some SDK-style + nodes may expose `GetValue()`. + """ + node = GenTLCameraBackend._node(node_map, name) + if node is None: + return default + + try: + return node.value + except Exception: + pass + + try: + getter = getattr(node, "GetValue", None) + if getter is not None: + return getter() + except Exception: + pass + + return default + @staticmethod def _node_symbolics(node) -> list[str]: try: From 732f5fd51b68e66b057ee17c9bd1b09009e3677d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:48:33 -0500 Subject: [PATCH 53/58] Refactor GenTL trigger source handling and logging Replace manual TriggerSource symbol checks with _resolve_trigger_source and only set TriggerSource when supported. Delay setting TriggerActivation until after source resolution and use non-strict writes for activation. Add safety check: do not arm TriggerMode=On unless both TriggerSelector and TriggerSource succeeded (avoids waiting on a previous/default input). Improve log messages to include selector_ok, source_ok, requested/ resolved source and activation for clearer diagnostics. --- dlclivegui/cameras/backends/gentl_backend.py | 66 +++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index cbd1632d..260c55a8 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1303,35 +1303,44 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source( + node_map, + source, + strict=strict, + ) source_ok = False - if source and source.lower() not in {"", "auto", "none"}: - source_node = self._node(node_map, "TriggerSource") - source_symbolics = self._node_symbolics(source_node) - - if source_node is not None: - if source in source_symbolics: - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) - if not source_ok: - LOG.warning( - "GenTL TriggerSource=%s is supported but not writable; " - "continuing without changing TriggerSource. Available: %s", - source, - source_symbolics, - ) - else: - LOG.warning( - "Requested GenTL TriggerSource=%s not in available sources %s; " - "continuing without changing TriggerSource.", - source, - source_symbolics, - ) + if source_supported: + source_ok = self._set_enum_node( + node_map, + "TriggerSource", + resolved_source, + strict=strict, + ) - if not selector_ok: + activation_ok = self._set_enum_node( + node_map, + "TriggerActivation", + activation, + strict=False, + ) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): LOG.warning( - "Could not apply GenTL TriggerSelector=%s; disabling trigger.", + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s resolved_source=%s activation=%s", + selector_ok, + source_ok, + role, selector, + source, + resolved_source, + activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1352,15 +1361,16 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s activation=%s " - "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", + "GenTL trigger input configured: role=%s selector=%s source_requested=%s " + "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", role, selector, + source, + resolved_source, activation, selector_ok, - activation_ok, - source, source_ok, + activation_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: From 0d13a4c1f60b118316f84996990cc690e2a19cf9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:08:22 +0200 Subject: [PATCH 54/58] Split multi-camera frame signals; throttle display Introduce a separate, throttled UI/display path for multi-camera frames. Add GUI_MAX_DISPLAY_FPS config and a new display_ready signal that is emitted at most at that rate, while frame_ready remains full-rate for recording/inference. Implement _should_emit_display_ready, wire the MultiCameraController to emit both signals, and reset throttling state when starting. Update the main window to handle processing and display paths via _on_multi_frame_processing_ready and _on_multi_frame_display_ready, and adjust tests accordingly. Also add GUI/debug timing config keys and a temporary timing-only early path in SingleCameraWorker (marked as FIXME). --- dlclivegui/config.py | 12 ++++- dlclivegui/gui/main_window.py | 15 ++++-- .../services/multi_camera_controller.py | 48 +++++++++++++++++-- tests/gui/test_pose_overlay.py | 6 +-- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 2d176224..936a7422 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -17,8 +17,16 @@ TriggerStrobePolarity = Literal["ActiveHigh", "ActiveLow"] TriggerStrobeOperation = Literal["Exposure", "FixedDuration"] -SINGLE_CAMERA_WORKER_DO_LOG_TIMING = False -MULTI_CAMERA_WORKER_DO_LOG_TIMING = True +# Global settings +## GUI +GUI_MAX_DISPLAY_FPS: float = 30.0 + + +## Debug +### Timing logs +SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True +MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 6fddba6b..69061e98 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -772,7 +772,8 @@ def _connect_signals(self) -> None: self.bbox_color_combo.currentIndexChanged.connect(self._on_bbox_color_changed) # Multi-camera controller signals (used for both single and multi-camera modes) - self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_display_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) @@ -1374,13 +1375,12 @@ def _render_overlays_for_recording(self, cam_id, frame): ) return output - def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: + def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. - Priority order for performance: + Priority: 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) 2. Recording (queued writes, non-blocking) - 3. Display (lowest priority - tiled and updated on separate timer) """ self._multi_camera_frames = frame_data.frames src_id = frame_data.source_camera_id @@ -1437,7 +1437,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: ts = frame_data.timestamps.get(src_id, time.time()) self._rec_manager.write_frame(src_id, frame, ts) - # PRIORITY 3: Mark display dirty (tiling done in display timer) + def _on_multi_frame_display_ready(self, frame_data: MultiFrameData) -> None: + """Throttled UI/display path. + + Called at GUI_MAX_DISPLAY_FPS, not at camera capture FPS for performance reasons. + """ + self._multi_camera_frames = frame_data.frames self._display_dirty = True def _on_multi_camera_started(self) -> None: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index c88fbda5..dc4024bd 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import copy import logging +import time from dataclasses import dataclass from functools import partial from threading import Event, Lock @@ -18,7 +19,12 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings +from dlclivegui.config import ( + GUI_MAX_DISPLAY_FPS, + MULTI_CAMERA_WORKER_DO_LOG_TIMING, + SINGLE_CAMERA_WORKER_DO_LOG_TIMING, + CameraSettings, +) from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -110,6 +116,10 @@ def run(self) -> None: continue consecutive_errors = 0 + # if True: # TEMP FIXME REMOVE + # self._timing.note_frame() + # self._timing.maybe_log() + # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) @@ -235,7 +245,8 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = Signal(object) # MultiFrameData + frame_ready = Signal(object) # MultiFrameData (full cam FPS; recording and inference only) + display_ready = Signal(object) # MultiFrameData for GUI display (throttled to GUI_MAX_DISPLAY_FPS) camera_started = Signal(str, object) # camera_id, settings camera_stopped = Signal(str) # camera_id camera_error = Signal(str, str) # camera_id, error_message @@ -260,6 +271,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # GUI display max FPS (for throttling display updates when many cameras are active) + self._gui_display_max_fps: float = GUI_MAX_DISPLAY_FPS + self._gui_display_last_emit: float = 0.0 # Performance logs self._timing_per_cam: dict[str, WorkerTimingStats] = {} @@ -272,8 +286,6 @@ def get_active_count(self) -> int: return len(self._started_cameras) def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: - if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: - return WorkerTimingStats(camera_id, enabled=False) timing = self._timing_per_cam.get(camera_id) if timing is None: timing = WorkerTimingStats( @@ -285,6 +297,24 @@ def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: self._timing_per_cam[camera_id] = timing return timing + def _should_emit_display_ready(self) -> bool: + """Return True when the UI/display path should be updated. + + This only throttles display_ready. It must not throttle frame_ready, + because frame_ready is used for full-rate consumers such as recording. + """ + if self._gui_display_max_fps <= 0: + return True + + now = time.perf_counter() + min_interval = 1.0 / max(self._gui_display_max_fps, 1e-9) + + if now - self._gui_display_last_emit < min_interval: + return False + + self._gui_display_last_emit = now + return True + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -456,6 +486,11 @@ def stop(self, wait: bool = True) -> None: self.all_stopped.emit() return + self._timing_per_cam.clear() + self._gui_display_last_emit = 0.0 + self._workers.clear() + self._threads.clear() + self._settings.clear() self._started_cameras.clear() self._failed_cameras.clear() self._camera_display_order.clear() @@ -521,6 +556,11 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data) + # GUI-only path: throttled display updates + if self._should_emit_display_ready(): + with timing.measure("Multi.emit.display_ready"): + self.display_ready.emit(frame_data) + timing.note_frame() timing.maybe_log() diff --git a/tests/gui/test_pose_overlay.py b/tests/gui/test_pose_overlay.py index 369baf84..3af35308 100644 --- a/tests/gui/test_pose_overlay.py +++ b/tests/gui/test_pose_overlay.py @@ -65,7 +65,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # Provide a frame raw = np.zeros((100, 100, 3), dtype=np.uint8) - # Build minimal frame_data to call _on_multi_frame_ready + # Build minimal frame_data to call _on_multi_frame_processing_ready from dlclivegui.services.multi_camera_controller import MultiFrameData frame_data = MultiFrameData( @@ -76,7 +76,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 1) toggle OFF: should record raw window.record_with_overlays_checkbox.setChecked(False) - window._on_multi_frame_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) assert cam_id in recording_frame_spy recorded_off = recording_frame_spy[cam_id] @@ -84,7 +84,7 @@ def test_record_overlay_toggle_affects_frames_sent_to_recorder(window, recording # 2) toggle ON: should record overlay frame (different) window.record_with_overlays_checkbox.setChecked(True) - window._on_multi_frame_ready(frame_data) + window._on_multi_frame_processing_ready(frame_data) recorded_on = recording_frame_spy[cam_id] assert not np.array_equal(recorded_on, raw) From 126c1c3de10508492ec352b410c949cadda88824 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 15:25:39 +0200 Subject: [PATCH 55/58] Add GenTL telemetry and frame-rate debugging Add helpers and telemetry to better read and debug GenTL/GenICam cameras. - New debug helper _debug_frame_rate_nodes to log many common frame-rate/exposure/throughput nodes. - Added robust node accessors: _node_value, _node_float, _node_str to safely read various GenICam node types and try multiple node names. - Enhance frame-rate configuration to log before/after values when enabling rate control and when setting AcquisitionFrameRate/AcquisitionFrameRateAbs; record any accepted frame-rate as _actual_fps. - After starting acquisition, attempt to read telemetry and run FPS debug logging; warn (but continue) if telemetry read fails. - Improve _read_telemetry to prefer resulting frame-rate nodes over requested ones, and to populate actual_fps, actual_exposure, actual_gain and other useful properties (pixel format, throughput, resolution, etc.) into the settings namespace for GUI/debugging. These changes make frame-rate/exposure behavior more observable and more tolerant across different camera implementations. --- dlclivegui/cameras/backends/gentl_backend.py | 168 +++++++++++++++++-- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 260c55a8..01c495f2 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -558,6 +558,15 @@ def open(self) -> None: self._acquirer.start() + try: + self._read_telemetry(node_map) + self._debug_frame_rate_nodes(node_map, context="after starting acquisition") + except Exception: + LOG.warning( + "Failed to read telemetry after starting acquisition; some 'actual' values may be missing.", + exc_info=True, + ) + LOG.debug( "Opened GenTL camera index=%s serial=%s label=%s", selected_index, @@ -1126,6 +1135,48 @@ def _node_symbolics(node) -> list[str]: except Exception: return [] + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value.""" + try: + node = getattr(node_map, name) + except Exception: + return default + + try: + return node.value + except Exception: + return default + + @classmethod + def _node_float(cls, node_map, *names: str) -> float | None: + """Return the first positive float value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + try: + fvalue = float(value) + except Exception: + continue + + if fvalue > 0: + return fvalue + + return None + + @classmethod + def _node_str(cls, node_map, *names: str) -> str | None: + """Return the first non-empty string value from a list of GenICam node names.""" + for name in names: + value = cls._node_value(node_map, name, None) + if value is None: + continue + + text = str(value).strip() + if text: + return text + + return None + def _set_enum_node(self, node_map, name: str, value: str, *, strict: bool = False) -> bool: node = self._node(node_map, name) if node is None: @@ -1605,21 +1656,48 @@ def _configure_frame_rate(self, node_map) -> None: return target = float(self.settings.fps) + LOG.info("Configuring GenTL frame rate: requested %.3f FPS", target) + for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: - getattr(node_map, attr).value = True + node = getattr(node_map, attr) + before = getattr(node, "value", None) + node.value = True + after = getattr(node, "value", None) + LOG.info("Enabled GenTL %s: before=%r after=%r", attr, before, after) break except Exception: pass - for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): + for attr in ("AcquisitionFrameRate", "AcquisitionFrameRateAbs"): try: - getattr(node_map, attr).value = target + node = getattr(node_map, attr) + before = getattr(node, "value", None) + node.value = target + after = getattr(node, "value", None) + + LOG.info( + "Set GenTL %s: before=%r requested=%.3f after=%r", + attr, + before, + target, + after, + ) + + try: + accepted = float(after) + if accepted > 0: + self._actual_fps = accepted + except Exception: + pass + return + except AttributeError: continue except Exception as e: LOG.warning("Failed to set frame rate via %s: %s", attr, e) + LOG.warning("Could not set frame rate to %s FPS", target) def _read_telemetry(self, node_map) -> None: @@ -1629,20 +1707,84 @@ def _read_telemetry(self, node_map) -> None: except Exception: pass - try: - self._actual_fps = float(node_map.ResultingFrameRate.value) - except Exception: - self._actual_fps = None + # Prefer true/resulting frame-rate readback nodes. + resulting_fps = self._node_float( + node_map, + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + ) - try: - self._actual_exposure = float(node_map.ExposureTime.value) - except Exception: - self._actual_exposure = None + # Fallback to requested/accepted frame-rate nodes only if no resulting node exists. + requested_fps = self._node_float( + node_map, + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + ) + + if resulting_fps is not None: + self._actual_fps = resulting_fps + elif requested_fps is not None: + self._actual_fps = requested_fps + exposure = self._node_float( + node_map, + "ExposureTime", + "ExposureTimeAbs", + "Exposure", + ) + if exposure is not None: + self._actual_exposure = exposure + + gain = self._node_float( + node_map, + "Gain", + "GainRaw", + ) + if gain is not None: + self._actual_gain = gain + + # Persist useful telemetry into properties["gentl"] for GUI/debugging. try: - self._actual_gain = float(node_map.Gain.value) + ns = self._ensure_settings_ns() + + if self._actual_width and self._actual_height: + ns["actual_resolution"] = [int(self._actual_width), int(self._actual_height)] + + if self._actual_fps is not None: + ns["actual_fps"] = float(self._actual_fps) + + if resulting_fps is not None: + ns["actual_resulting_frame_rate"] = float(resulting_fps) + + if requested_fps is not None: + ns["actual_acquisition_frame_rate"] = float(requested_fps) + + if self._actual_exposure is not None: + ns["actual_exposure"] = float(self._actual_exposure) + + if self._actual_gain is not None: + ns["actual_gain"] = float(self._actual_gain) + + exposure_auto = self._node_str(node_map, "ExposureAuto") + if exposure_auto is not None: + ns["actual_exposure_auto"] = exposure_auto + + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + if throughput is not None: + ns["actual_device_link_throughput_limit"] = float(throughput) + + throughput_mode = self._node_str(node_map, "DeviceLinkThroughputLimitMode") + if throughput_mode is not None: + ns["actual_device_link_throughput_limit_mode"] = throughput_mode + + pixel_format = self._node_str(node_map, "PixelFormat") + if pixel_format is not None: + ns["actual_pixel_format"] = pixel_format + except Exception: - self._actual_gain = None + pass # ------------------------------------------------------------------ # Frame conversion / local helpers From 0e7710fab5b7312bf8f6573e34b2b47b0aa8e4d5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:13:56 +0200 Subject: [PATCH 56/58] Remove temp debug block --- dlclivegui/services/multi_camera_controller.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index dc4024bd..ee3061f9 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -116,10 +116,6 @@ def run(self) -> None: continue consecutive_errors = 0 - # if True: # TEMP FIXME REMOVE - # self._timing.note_frame() - # self._timing.maybe_log() - # continue with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) From 4f09b21770eb7a48d3c4ba73597333aa348529be Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 17:26:52 +0200 Subject: [PATCH 57/58] Fix missing signal wiring Fix signal hookup and disable main-window timing flag. Replace the second connection of multi_camera_controller.frame_ready to _on_multi_frame_display_ready with multi_camera_controller.display_ready to separate processing-ready and display-ready events. Also comment out MAIN_WINDOW_DO_LOG_TIMING in config.py to disable main-window timing logging. --- dlclivegui/config.py | 2 +- dlclivegui/gui/main_window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 936a7422..dac523da 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -26,7 +26,7 @@ ### Timing logs SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True -MAIN_WINDOW_DO_LOG_TIMING: bool = False +# MAIN_WINDOW_DO_LOG_TIMING: bool = False class CameraSettings(BaseModel): diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 69061e98..ef06a5d9 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -773,7 +773,7 @@ def _connect_signals(self) -> None: # Multi-camera controller signals (used for both single and multi-camera modes) self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_processing_ready) - self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_display_ready) + self.multi_camera_controller.display_ready.connect(self._on_multi_frame_display_ready) self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) From fb1b2ed2fe8d56ee3a3d8c8d954cd5e45119a1c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 16:21:36 +0200 Subject: [PATCH 58/58] Allow zero node values and reduce log level Change LOG.info to LOG.debug to reduce noise when printing node information. Add an allow_zero parameter to _node_float so callers can accept zero as a valid value (previously only positive values were returned). Update callers for exposure, gain, and DeviceLinkThroughputLimit to pass allow_zero=True so reported zeros are treated as real readings while preserving the original behavior by default. --- dlclivegui/cameras/backends/gentl_backend.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dlclivegui/cameras/backends/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py index 01c495f2..26d5c2f0 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1149,7 +1149,7 @@ def _node_value(node_map, name: str, default=None): return default @classmethod - def _node_float(cls, node_map, *names: str) -> float | None: + def _node_float(cls, node_map, *names: str, allow_zero: bool = False) -> float | None: """Return the first positive float value from a list of GenICam node names.""" for name in names: value = cls._node_value(node_map, name, None) @@ -1158,7 +1158,7 @@ def _node_float(cls, node_map, *names: str) -> float | None: except Exception: continue - if fvalue > 0: + if fvalue > 0 or (allow_zero and fvalue == 0): return fvalue return None @@ -1733,6 +1733,7 @@ def _read_telemetry(self, node_map) -> None: "ExposureTime", "ExposureTimeAbs", "Exposure", + allow_zero=True, ) if exposure is not None: self._actual_exposure = exposure @@ -1741,6 +1742,7 @@ def _read_telemetry(self, node_map) -> None: node_map, "Gain", "GainRaw", + allow_zero=True, ) if gain is not None: self._actual_gain = gain @@ -1771,7 +1773,7 @@ def _read_telemetry(self, node_map) -> None: if exposure_auto is not None: ns["actual_exposure_auto"] = exposure_auto - throughput = self._node_float(node_map, "DeviceLinkThroughputLimit") + throughput = self._node_float(node_map, "DeviceLinkThroughputLimit", allow_zero=True) if throughput is not None: ns["actual_device_link_throughput_limit"] = float(throughput)