From 65712caf8f56ee74864917c3242de520cb7ca9b2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:26:15 +0200 Subject: [PATCH 01/14] 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 d51c8f7e725549eaecfb00f99686e993777854b0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:29:36 +0200 Subject: [PATCH 02/14] 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 97398246e4f8cb9410e7f9945f4110b1367bdb89 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 14:36:08 +0200 Subject: [PATCH 03/14] 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 358ebcf5b17d40408131be9575764a468dde8d52 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:25:21 +0200 Subject: [PATCH 04/14] 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 a1a139b1deb68298881e3fb19384f71e4514c3a9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 10:43:11 +0200 Subject: [PATCH 05/14] 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 2043e574eb5f22b9b8f24e4f0c05279fa8e7462a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:10:49 +0200 Subject: [PATCH 06/14] 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 18446c28107b75de1adb056bf395ea0f0e0e6c55 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 11:50:10 +0200 Subject: [PATCH 07/14] 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 3ff4472e..d546e8ab 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -18,7 +18,8 @@ from dlclivegui.cameras.factory import camera_identity_key # from dlclivegui.config import CameraSettings -from dlclivegui.config import CameraSettings, CameraTriggerSettings +from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings, CameraSettings +from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -55,6 +56,11 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._retry_delay = 0.1 self._trigger_timeout_delay = 0.05 + # Performance logs + self._timing = WorkerTimingStats( + camera_id, logger=LOGGER, log_interval=1.0, enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING + ) + @Slot() def run(self) -> None: self._stop_event.clear() @@ -90,7 +96,8 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - frame, timestamp = self._backend.read() + with self._timing.measure("GenTL.read"): + frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: @@ -103,9 +110,15 @@ def run(self) -> None: continue consecutive_errors = 0 - self.frame_captured.emit(self._camera_id, frame, timestamp) + with self._timing.measure("GenTL.emit.frame_captured"): + self.frame_captured.emit(self._camera_id, frame, timestamp) + + self._timing.note_frame() + self._timing.maybe_log() except TimeoutError as exc: + self._timing.note_timeout() + self._timing.maybe_log() if self._stop_event.is_set(): break @@ -133,6 +146,8 @@ def run(self) -> None: continue except Exception as exc: + self._timing.note_error() + self._timing.maybe_log() consecutive_errors += 1 if self._stop_event.is_set(): break diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py index 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 b5c586f73110c1e7b96e5e4f7d345e4dd2d7f92a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:01:43 +0200 Subject: [PATCH 08/14] Add per-camera timing to MultiCameraController Introduce MULTI_CAMERA_WORKER_DO_LOG_TIMING and disable single-worker timing by default (SINGLE_CAMERA_WORKER_DO_LOG_TIMING=false). Add per-camera WorkerTimingStats storage and a _timing_for_camera factory that respects the new config. Wrap _on_frame_captured processing in timed sections (total, apply_transforms, update_latest), call note_frame/maybe_log per camera, and emit frames as before. Also adjust SingleCameraWorker timing labels from GenTL.* to Single.* for clearer logs. --- dlclivegui/config.py | 3 +- .../services/multi_camera_controller.py | 93 ++++++++++++------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 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 d546e8ab..77bb6bbe 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -19,6 +19,7 @@ # from dlclivegui.config import CameraSettings from dlclivegui.config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings, CameraSettings +from dlclivegui.config import MULTI_CAMERA_WORKER_DO_LOG_TIMING, SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraSettings from dlclivegui.utils.stats import WorkerTimingStats LOGGER = logging.getLogger(__name__) @@ -96,7 +97,7 @@ def run(self) -> None: while not self._stop_event.is_set(): try: - with self._timing.measure("GenTL.read"): + with self._timing.measure("Single.read"): frame, timestamp = self._backend.read() if frame is None or frame.size == 0: consecutive_errors += 1 @@ -110,7 +111,7 @@ def run(self) -> None: continue consecutive_errors = 0 - with self._timing.measure("GenTL.emit.frame_captured"): + with self._timing.measure("Single.emit.frame_captured"): self.frame_captured.emit(self._camera_id, frame, timestamp) self._timing.note_frame() @@ -244,6 +245,9 @@ def __init__(self): self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start + # Performance logs + self._timing_per_cam: dict[str, WorkerTimingStats] = {} + def is_running(self) -> bool: """Check if any camera is currently running.""" return self._running and len(self._started_cameras) > 0 @@ -252,6 +256,20 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) + def _timing_for_camera(self, camera_id: str) -> WorkerTimingStats: + if not MULTI_CAMERA_WORKER_DO_LOG_TIMING: + return WorkerTimingStats(camera_id, enabled=False) + timing = self._timing_per_cam.get(camera_id) + if timing is None: + timing = WorkerTimingStats( + f"Controller {camera_id}", + logger=LOGGER, + log_interval=1.0, + enabled=MULTI_CAMERA_WORKER_DO_LOG_TIMING, + ) + self._timing_per_cam[camera_id] = timing + return timing + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras.""" if self._running: @@ -445,38 +463,42 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" # Apply rotation if configured - settings = self._settings.get(camera_id) - if settings and settings.rotation: - frame = MultiCameraController.apply_rotation(frame, settings.rotation) - - # Apply cropping if configured - if settings: - crop_region = settings.get_crop_region() - if crop_region: - frame = MultiCameraController.apply_crop(frame, crop_region) - - with self._frame_lock: - self._frames[camera_id] = frame - self._timestamps[camera_id] = timestamp - - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] + timing = self._timing_for_camera(camera_id) + + with timing.measure("Multi.slot.total"): + settings = self._settings.get(camera_id) + with timing.measure("Multi.slot.apply_transforms"): + if settings and settings.rotation: + frame = MultiCameraController.apply_rotation(frame, settings.rotation) + + # Apply cropping if configured + if settings: + crop_region = settings.get_crop_region() + if crop_region: + frame = MultiCameraController.apply_crop(frame, crop_region) + with timing.measure("Multi.update_latest"): + with self._frame_lock: + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp + + # Emit frame data without tiling (tiling done in GUI for performance) + if self._frames: + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] frame_data = MultiFrameData( frames=ordered_frames, @@ -487,6 +509,9 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float ) self.frame_ready.emit(frame_data) + timing.note_frame() + timing.maybe_log() + @staticmethod def apply_rotation(frame: np.ndarray, degrees: int) -> np.ndarray: """Apply rotation to frame.""" From 60077f235b5c045ee2b788dfe054363a779e101c Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 12:02:38 +0200 Subject: [PATCH 09/14] 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 e605c8088e550ef5ac88a144edcac1dc468091fa Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 29 May 2026 13:45:09 +0200 Subject: [PATCH 10/14] 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 77bb6bbe..f82ae367 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -462,52 +462,57 @@ def stop(self, wait: bool = True) -> None: def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" - # Apply rotation if configured timing = self._timing_for_camera(camera_id) + frame_data: MultiFrameData | None = None with timing.measure("Multi.slot.total"): settings = self._settings.get(camera_id) - with timing.measure("Multi.slot.apply_transforms"): + + with timing.measure("Multi.apply_transforms"): if settings and settings.rotation: frame = MultiCameraController.apply_rotation(frame, settings.rotation) - # Apply cropping if configured if settings: crop_region = settings.get_crop_region() if crop_region: frame = MultiCameraController.apply_crop(frame, crop_region) - with timing.measure("Multi.update_latest"): - with self._frame_lock: + + with self._frame_lock: + with timing.measure("Multi.store_latest"): self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Emit frame data without tiling (tiling done in GUI for performance) - if self._frames: - ordered_frames: dict[str, np.ndarray] = {} - ordered_timestamps: dict[str, float] = {} - - for cam_id in self._camera_display_order: - if cam_id in self._frames: - ordered_frames[cam_id] = self._frames[cam_id] - if cam_id in self._timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - # Any unexpected/legacy IDs, appended deterministically. - for cam_id in self._frames: - if cam_id not in ordered_frames: - ordered_frames[cam_id] = self._frames[cam_id] - for cam_id in self._timestamps: - if cam_id not in ordered_timestamps: - ordered_timestamps[cam_id] = self._timestamps[cam_id] - - frame_data = MultiFrameData( - frames=ordered_frames, - timestamps=ordered_timestamps, - source_camera_id=camera_id, - tiled_frame=None, - display_ids=dict(self._display_ids), - ) - self.frame_ready.emit(frame_data) + with timing.measure("Multi.build_ordered"): + ordered_frames: dict[str, np.ndarray] = {} + ordered_timestamps: dict[str, float] = {} + + for cam_id in self._camera_display_order: + if cam_id in self._frames: + ordered_frames[cam_id] = self._frames[cam_id] + if cam_id in self._timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + # Any unexpected/legacy IDs, appended deterministically. + for cam_id in self._frames: + if cam_id not in ordered_frames: + ordered_frames[cam_id] = self._frames[cam_id] + for cam_id in self._timestamps: + if cam_id not in ordered_timestamps: + ordered_timestamps[cam_id] = self._timestamps[cam_id] + + with timing.measure("Multi.construct_frame_data"): + frame_data = MultiFrameData( + frames=ordered_frames, + timestamps=ordered_timestamps, + source_camera_id=camera_id, + tiled_frame=None, + display_ids=dict(self._display_ids), + ) + + + if frame_data is not None: + with timing.measure("Multi.emit.frame_ready"): + self.frame_ready.emit(frame_data) timing.note_frame() timing.maybe_log() From fa311215cff7856e5f6725ce383dcbf3c73668e6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 2 Jun 2026 15:48:40 +0200 Subject: [PATCH 11/14] 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 2a08f9dd..7866438a 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -185,6 +185,89 @@ def static_capabilities(cls) -> dict[str, SupportLevel]: "hardware_trigger": SupportLevel.BEST_EFFORT, } + def _debug_trigger_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "TriggerMode", + "TriggerSelector", + "TriggerSource", + "TriggerActivation", + "AcquisitionMode", + # Generic line nodes, if available. + "LineSelector", + "LineMode", + "LineSource", + # TIS 37U / DMK 37BUX287 strobe/output nodes. + "GPIn", + "GPOut", + "StrobeEnable", + "StrobePolarity", + "StrobeOperation", + "StrobeDuration", + "StrobeDelay", + ) + + label = f"GenTL trigger debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + + symbolics = self._node_symbolics(node) + if symbolics: + extras.append(f"symbolics={symbolics}") + + for attr in ("access_mode", "is_writable", "is_readable"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + + def _debug_frame_rate_nodes(self, node_map, *, context: str = "") -> None: + names = ( + "AcquisitionFrameRateEnable", + "AcquisitionFrameRateControlEnable", + "AcquisitionFrameRate", + "AcquisitionFrameRateAbs", + "AcquisitionResultingFrameRate", + "ResultingFrameRate", + "AcquisitionFrameRateResulting", + "DeviceFrameRate", + "ExposureAuto", + "ExposureTime", + "ExposureTimeAbs", + "DeviceLinkThroughputLimit", + "DeviceLinkThroughputLimitMode", + "PayloadSize", + "Width", + "Height", + "PixelFormat", + ) + + label = f"GenTL FPS debug {context}".strip() + + for name in names: + node = self._node(node_map, name) + if node is None: + continue + + value = self._node_value(node_map, name, None) + + extras = [] + for attr in ("min", "max", "inc"): + try: + extras.append(f"{attr}={getattr(node, attr)}") + except Exception: + pass + + LOG.debug("%s: %s=%r %s", label, name, value, " ".join(extras)) + # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ @@ -462,6 +545,7 @@ def open(self) -> None: self._configure_trigger(node_map) self._apply_effective_fetch_timeout() + self._debug_trigger_nodes(node_map, context="after configuration before acquisition") actual_trigger = self._trigger_to_dict(self._trigger) actual_trigger["fetch_timeout"] = self._timeout @@ -1214,31 +1298,42 @@ def _configure_trigger_off(self, node_map, *, strict: bool = False) -> None: def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> None: role = str(self._trigger_attr(cfg, "role", "external") or "external").strip().lower() selector = str(self._trigger_attr(cfg, "selector", "FrameStart") or "FrameStart") - source = str(self._trigger_attr(cfg, "source", "Line0") or "Line0") activation = str(self._trigger_attr(cfg, "activation", "RisingEdge") or "RisingEdge") + source = str(self._trigger_attr(cfg, "source", "auto") or "auto").strip() # Disable trigger while changing trigger-related nodes. self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - source, source_resolved = self._resolve_trigger_source(node_map, source, strict=strict) - source_ok = source_resolved and self._set_enum_node(node_map, "TriggerSource", source, strict=strict) activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) - # TriggerSelector and TriggerSource are required routing nodes. - # If either failed in non-strict mode, do not arm TriggerMode=On. - # Otherwise the camera may wait on a previous/default input line. - if not (selector_ok and source_ok): + source_ok = False + if source and source.lower() not in {"", "auto", "none"}: + source_node = self._node(node_map, "TriggerSource") + source_symbolics = self._node_symbolics(source_node) + + if source_node is not None: + if source in source_symbolics: + source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) + if not source_ok: + LOG.warning( + "GenTL TriggerSource=%s is supported but not writable; " + "continuing without changing TriggerSource. Available: %s", + source, + source_symbolics, + ) + else: + LOG.warning( + "Requested GenTL TriggerSource=%s not in available sources %s; " + "continuing without changing TriggerSource.", + source, + source_symbolics, + ) + + if not selector_ok: LOG.warning( - "Could not apply GenTL trigger input routing " - "(selector_ok=%s, source_ok=%s); disabling trigger. " - "requested role=%s selector=%s source=%s activation=%s", - selector_ok, - source_ok, - role, + "Could not apply GenTL TriggerSelector=%s; disabling trigger.", selector, - source, - activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1259,55 +1354,153 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s source=%s activation=%s activation_ok=%s", + "GenTL trigger input configured: role=%s selector=%s activation=%s " + "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", role, selector, - source, activation, + selector_ok, activation_ok, + source, + source_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: + """Configure this camera as a free-running master that emits STROBE_OUT pulses. + + For DMK 37BUX287 / TIS 37U series, the physical output is controlled by + StrobeEnable/StrobePolarity/StrobeOperation rather than SFNC LineSelector/ + LineMode/LineSource nodes. + """ output_line = str(self._trigger_attr(cfg, "output_line", "Line2") or "Line2") output_source = str(self._trigger_attr(cfg, "output_source", "ExposureActive") or "ExposureActive") - # Master camera runs freerun and exposes an output signal. + # Optional extra fields if present in trigger dict/model. + strobe_polarity = str(self._trigger_attr(cfg, "strobe_polarity", "ActiveHigh") or "ActiveHigh") + strobe_operation = str(self._trigger_attr(cfg, "strobe_operation", "Exposure") or "Exposure") + strobe_duration = self._trigger_attr(cfg, "strobe_duration", None) + strobe_delay = self._trigger_attr(cfg, "strobe_delay", None) + + # Master camera should be free-running. self._configure_trigger_off(node_map, strict=False) - line_selected = self._set_enum_node( - node_map, - "LineSelector", - output_line, - strict=strict, - ) + # ------------------------------------------------------------------ + # Preferred path for The Imaging Source 37U / DMK 37BUX287: + # StrobeEnable, StrobePolarity, StrobeOperation, StrobeDuration, StrobeDelay + # ------------------------------------------------------------------ + strobe_enable_node = self._node(node_map, "StrobeEnable") + + if strobe_enable_node is not None: + # Disable first while changing parameters. + self._set_enum_node(node_map, "StrobeEnable", "Off", strict=False) + + polarity_ok = self._set_enum_node( + node_map, + "StrobePolarity", + strobe_polarity, + strict=False, + ) - # In non-strict mode, do not continue configuring output behavior if the - # requested line could not be selected. Otherwise we may accidentally drive - # whichever GPIO line the camera had selected previously/defaulted to. - if not line_selected: - LOG.warning( - "Could not select GenTL output line '%s'; skipping trigger output configuration.", - output_line, + operation_ok = self._set_enum_node( + node_map, + "StrobeOperation", + strobe_operation, + strict=False, ) - return - mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) - source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + if strobe_duration is not None: + try: + node = self._node(node_map, "StrobeDuration") + if node is not None: + node.value = int(strobe_duration) + LOG.info("Configured GenTL StrobeDuration=%s", int(strobe_duration)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDuration={strobe_duration}: {exc}") from exc + LOG.warning("Failed to set StrobeDuration=%s: %s", strobe_duration, exc) + + if strobe_delay is not None: + try: + node = self._node(node_map, "StrobeDelay") + if node is not None: + node.value = int(strobe_delay) + LOG.info("Configured GenTL StrobeDelay=%s", int(strobe_delay)) + except Exception as exc: + if strict: + raise RuntimeError(f"Failed to set StrobeDelay={strobe_delay}: {exc}") from exc + LOG.warning("Failed to set StrobeDelay=%s: %s", strobe_delay, exc) + + enable_ok = self._set_enum_node( + node_map, + "StrobeEnable", + "On", + strict=strict, + ) + + if enable_ok: + LOG.info( + "GenTL trigger master configured via Strobe*: " + "StrobeEnable=On StrobePolarity=%s polarity_ok=%s " + "StrobeOperation=%s operation_ok=%s", + strobe_polarity, + polarity_ok, + strobe_operation, + operation_ok, + ) + return + + if strict: + raise RuntimeError("Could not enable GenTL StrobeEnable=On") - if not (mode_ok and source_ok): LOG.warning( - "GenTL trigger master output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", - mode_ok, - source_ok, + "StrobeEnable node exists but could not be enabled; falling back to generic Line* output configuration." ) - return - LOG.info( - "GenTL trigger master configured: output_line=%s output_source=%s", - output_line, - output_source, + # ------------------------------------------------------------------ + # Generic SFNC fallback for cameras that expose LineSelector/LineMode/LineSource. + # ------------------------------------------------------------------ + line_selector = self._node(node_map, "LineSelector") + if line_selector is not None: + line_selected = self._set_enum_node( + node_map, + "LineSelector", + output_line, + strict=strict, + ) + + if not line_selected: + LOG.warning( + "Could not select GenTL output line '%s'; skipping Line* output configuration.", + output_line, + ) + else: + mode_ok = self._set_enum_node(node_map, "LineMode", "Output", strict=strict) + source_ok = self._set_enum_node(node_map, "LineSource", output_source, strict=strict) + + if mode_ok and source_ok: + LOG.info( + "GenTL trigger master configured via Line*: output_line=%s output_source=%s", + output_line, + output_source, + ) + return + + LOG.warning( + "GenTL Line* trigger output configuration incomplete (LineMode ok=%s, LineSource ok=%s).", + mode_ok, + source_ok, + ) + + msg = ( + "Could not configure GenTL trigger master output. " + "No supported Strobe* or Line* output path was successfully configured." ) + if strict: + raise RuntimeError(msg) + + LOG.warning(msg) + def _restore_trigger_idle(self, node_map) -> None: """Best-effort restore to a safe non-triggering state after acquisition stops. diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 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 c0b6e58d66ce0a96ef291f24fc682f130ca4fafc Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:46:42 -0500 Subject: [PATCH 12/14] 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 7866438a..4f1b1456 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1099,6 +1099,32 @@ def _node(node_map, name: str): except Exception: return None + @staticmethod + def _node_value(node_map, name: str, default=None): + """Best-effort read of a GenICam node value. + + Debug helpers must not make open() fail just because a value cannot be read. + Harvesters-style fake/test nodes usually expose `.value`; some SDK-style + nodes may expose `GetValue()`. + """ + node = GenTLCameraBackend._node(node_map, name) + if node is None: + return default + + try: + return node.value + except Exception: + pass + + try: + getter = getattr(node, "GetValue", None) + if getter is not None: + return getter() + except Exception: + pass + + return default + @staticmethod def _node_symbolics(node) -> list[str]: try: From 715f270166f44a1f448fa49c2c6a6a77cf078493 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 19 Jun 2026 15:48:33 -0500 Subject: [PATCH 13/14] 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 4f1b1456..9d417d70 100644 --- a/dlclivegui/cameras/backends/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -1331,35 +1331,44 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No self._set_enum_node(node_map, "TriggerMode", "Off", strict=False) selector_ok = self._set_enum_node(node_map, "TriggerSelector", selector, strict=strict) - activation_ok = self._set_enum_node(node_map, "TriggerActivation", activation, strict=strict) + + resolved_source, source_supported = self._resolve_trigger_source( + node_map, + source, + strict=strict, + ) source_ok = False - if source and source.lower() not in {"", "auto", "none"}: - source_node = self._node(node_map, "TriggerSource") - source_symbolics = self._node_symbolics(source_node) - - if source_node is not None: - if source in source_symbolics: - source_ok = self._set_enum_node(node_map, "TriggerSource", source, strict=False) - if not source_ok: - LOG.warning( - "GenTL TriggerSource=%s is supported but not writable; " - "continuing without changing TriggerSource. Available: %s", - source, - source_symbolics, - ) - else: - LOG.warning( - "Requested GenTL TriggerSource=%s not in available sources %s; " - "continuing without changing TriggerSource.", - source, - source_symbolics, - ) + if source_supported: + source_ok = self._set_enum_node( + node_map, + "TriggerSource", + resolved_source, + strict=strict, + ) - if not selector_ok: + activation_ok = self._set_enum_node( + node_map, + "TriggerActivation", + activation, + strict=False, + ) + + # TriggerSelector and TriggerSource are required routing nodes. + # If either failed in non-strict mode, do not arm TriggerMode=On. + # Otherwise the camera may wait on a previous/default input line. + if not (selector_ok and source_ok): LOG.warning( - "Could not apply GenTL TriggerSelector=%s; disabling trigger.", + "Could not apply GenTL trigger input routing " + "(selector_ok=%s, source_ok=%s); disabling trigger. " + "requested role=%s selector=%s source=%s resolved_source=%s activation=%s", + selector_ok, + source_ok, + role, selector, + source, + resolved_source, + activation, ) self._configure_trigger_off(node_map, strict=False) self._trigger = CameraTriggerSettings() @@ -1380,15 +1389,16 @@ def _configure_trigger_input(self, node_map, cfg, *, strict: bool = False) -> No return LOG.info( - "GenTL trigger input configured: role=%s selector=%s activation=%s " - "selector_ok=%s activation_ok=%s source_requested=%s source_ok=%s", + "GenTL trigger input configured: role=%s selector=%s source_requested=%s " + "source=%s activation=%s selector_ok=%s source_ok=%s activation_ok=%s", role, selector, + source, + resolved_source, activation, selector_ok, - activation_ok, - source, source_ok, + activation_ok, ) def _configure_trigger_master(self, node_map, cfg, *, strict: bool = False) -> None: From 2925176810b027fb45aba7ce907b62e11c8860f5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 29 Jun 2026 09:56:28 +0200 Subject: [PATCH 14/14] Update multi_camera_controller.py --- dlclivegui/services/multi_camera_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index f82ae367..d5315ea3 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -509,7 +509,6 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float display_ids=dict(self._display_ids), ) - if frame_data is not None: with timing.measure("Multi.emit.frame_ready"): self.frame_ready.emit(frame_data)