diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 4c94fb7..af22842 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 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 0000000..d4c4b19 --- /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("Configure trigger mode") + 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 86e4f19..07a8025 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))