From 1ab9d70e3225b46015552898170178384fbcfb55 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:42:24 +0200 Subject: [PATCH 1/8] Respect configured OpenCV device name Update `OpenCVCameraBackend.device_name()` to prefer explicit naming sources before generating a default label. It now returns `device_name` from parsed options first, then `settings.name`, and only falls back to an OpenCV-derived backend label (or generic OpenCV label) when no custom name is provided. --- dlclivegui/cameras/backends/opencv_backend.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/dlclivegui/cameras/backends/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py index 1201749..38613fb 100644 --- a/dlclivegui/cameras/backends/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -245,15 +245,26 @@ def stop(self) -> None: self._release_capture() def device_name(self) -> str: - base_name = "OpenCV" + ns = self.parse_options(self.settings) + + if ns.device_name: + return ns.device_name + + name = str(getattr(self.settings, "name", "") or "").strip() + if name: + return name + + api_name = "" if self._capture and hasattr(self._capture, "getBackendName"): try: - backend_name = self._capture.getBackendName() + api_name = self._capture.getBackendName() except Exception: - backend_name = "" - if backend_name: - base_name = backend_name - return f"{base_name} camera #{self.settings.index}" + api_name = "" + + if api_name: + return f"OpenCV {api_name} camera #{self.settings.index}" + + return f"OpenCV camera #{self.settings.index}" @property def actual_fps(self) -> float | None: From 08ac8eb02e47dc6473d2e6488ceedf7361904091 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:15 +0200 Subject: [PATCH 2/8] Use display IDs in camera UI labels Normalize camera labeling in the main window by using `get_display_id()` for active-camera text and DLC camera dropdown entries. This also improves fallback behavior in `_label_for_cam_id` by checking cached display IDs and returning "Unknown camera" instead of raw internal IDs. --- dlclivegui/gui/main_window.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 1770240..ff7e74c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1302,16 +1302,20 @@ def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> No self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) def _update_active_cameras_label(self) -> None: - """Update the label showing active cameras.""" active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: self.active_cameras_label.setText("No cameras configured") - elif len(active_cams) == 1: + return + + if len(active_cams) == 1: cam = active_cams[0] - self.active_cameras_label.setText(f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps") - else: - cam_names = [f"{c.name}" for c in active_cams] - self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + display_id = get_display_id(cam) + self.active_cameras_label.setText(f"{display_id} [{cam.backend}:{cam.index}]") + return + + cam_names = [get_display_id(c) for c in active_cams] + self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") def _validate_configured_cameras(self) -> None: """Validate that configured cameras are available. @@ -1353,8 +1357,14 @@ def _validate_configured_cameras(self) -> None: def _label_for_cam_id(self, cam_id: str) -> str: for cam in self._config.multi_camera.get_active_cameras(): if get_camera_id(cam) == cam_id: - return f"{cam.name} [{cam.backend}:{cam.index}]" - return cam_id + display_id = get_display_id(cam) + return f"{display_id} [{cam.backend}:{cam.index}]" + + display_id = self._multi_camera_display_ids.get(cam_id) + if display_id: + return display_id + + return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: """Populate the inference camera dropdown from currently running cameras.""" @@ -1390,8 +1400,9 @@ def _refresh_dlc_camera_list(self) -> None: active_cams = self._config.multi_camera.get_active_cameras() for cam in active_cams: - cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" - label = f"{cam.name} [{cam.backend}:{cam.index}]" + cam_id = get_camera_id(cam) + display_id = get_display_id(cam) + label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) # Keep previous selection if still present, else default to first From e8c4831d6d56e20872e847bda752f52e4234768b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:43 +0200 Subject: [PATCH 3/8] Make controls panel scrollable with fixed footer Refactors the main window controls panel to use a new reusable layout helper that keeps the content vertically scrollable while pinning the Preview/Stop buttons in a fixed footer. The new `_VerticalOnlyScrollArea` avoids horizontal clipping/scrollbars by enforcing natural content width and updating constraints on layout/style changes. Also tightens preview shutdown cleanup by resetting running camera/display state and refreshing active camera + DLC camera UI after stopping multi-camera preview. --- dlclivegui/gui/main_window.py | 39 +++++-- dlclivegui/gui/misc/layouts.py | 198 ++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 11 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index ff7e74c..250b8ff 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -270,32 +270,46 @@ def _setup_ui(self) -> None: for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - # Controls panel with fixed width to prevent shifting - controls_widget = QWidget() - # controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - controls_layout = QVBoxLayout(controls_widget) - controls_layout.setContentsMargins(5, 5, 5, 5) + # Controls panel content + controls_content_widget = QWidget() + controls_content_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + controls_layout = QVBoxLayout(controls_content_widget) + controls_layout.setContentsMargins(5, 5, 5, 0) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) controls_layout.addWidget(self._build_viz_group()) - # Preview/Stop buttons at bottom of controls - wrap in widget + # Preview/Stop buttons stay outside the scroll area as a fixed footer button_bar_widget = QWidget() + button_bar_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_bar = QHBoxLayout(button_bar_widget) button_bar.setContentsMargins(0, 5, 0, 5) + self.preview_button = QPushButton("Start Preview") self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) + self.stop_preview_button = QPushButton("Stop Preview") self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) + button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - controls_layout.addWidget(button_bar_widget) - controls_layout.addStretch(1) + + controls_widget = lyts.make_scrollable_with_fixed_footer( + controls_content_widget, + button_bar_widget, + object_name="ControlsPanel", + scroll_object_name="ControlsScrollArea", + footer_object_name="ControlsFooter", + margins=(0, 0, 0, 0), + spacing=0, + footer_margins=(5, 0, 5, 0), + ) # Add controls and video panel to main layout ## Dock widget for controls @@ -1559,12 +1573,19 @@ def _on_multi_camera_stopped(self) -> None: self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._current_frame = None self._multi_camera_frames.clear() self._multi_camera_display_ids.clear() + self._running_cams_ids.clear() + self._display_dirty = False + self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Multi-camera preview stopped", 3000) + + self._update_active_cameras_label() + self._refresh_dlc_camera_list() self._update_inference_buttons() self._update_camera_controls_enabled() self._update_dlc_controls_enabled() diff --git a/dlclivegui/gui/misc/layouts.py b/dlclivegui/gui/misc/layouts.py index 2b09b47..d80ba11 100644 --- a/dlclivegui/gui/misc/layouts.py +++ b/dlclivegui/gui/misc/layouts.py @@ -3,8 +3,19 @@ from collections.abc import Sequence -from PySide6.QtCore import QObject, Qt -from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel, QSizePolicy, QStyle, QStyleOptionComboBox, QWidget +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QGridLayout, + QLabel, + QScrollArea, + QSizePolicy, + QStyle, + QStyleOptionComboBox, + QVBoxLayout, + QWidget, +) def _combo_width_for_current_text(combo: QComboBox, extra_padding: int = 10) -> int: @@ -226,3 +237,186 @@ def _add_pair(label_text: str | None, widget: QWidget | None, stretch: int) -> N grid.setColumnStretch(c, s) return row + + +class _VerticalOnlyScrollArea(QScrollArea): + """ + Vertical-only scroll area. + + It does not introduce horizontal scrolling and does not manually force the + child width on resize. Instead, it makes the child keep at least its natural + layout width, and makes the scroll area advertise that width as its own + minimum width. + + This avoids horizontal clipping without creating a width feedback loop. + """ + + def __init__(self, content_widget: QWidget, parent: QWidget | None = None): + super().__init__(parent) + + self._content_widget = content_widget + + self.setFrameShape(QFrame.Shape.NoFrame) + self.setViewportMargins(0, 0, 0, 0) + self.setContentsMargins(0, 0, 0, 0) + + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # Let the scroll area resize the content to the available viewport size, + # but never below the content's minimum width. + self.setWidgetResizable(True) + self.setWidget(content_widget) + + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + content_widget.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + + self.update_width_constraints() + + def _scrollbar_extent(self) -> int: + return self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent, None, self) + + def _frame_width_total(self) -> int: + frame = self.frameWidth() + return frame * 2 + + def _natural_content_width(self) -> int: + """ + Return the content's natural layout width. + + Important: do not use content_widget.minimumWidth() here, because this + class sets that value. Using it would create a feedback loop where the + natural width grows after resizes. + """ + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().width(), + self._content_widget.minimumSizeHint().width(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().width()) + candidates.append(layout.minimumSize().width()) + + return max(0, *candidates) + + def _natural_content_height(self) -> int: + layout = self._content_widget.layout() + + candidates: list[int] = [ + self._content_widget.sizeHint().height(), + self._content_widget.minimumSizeHint().height(), + ] + + if layout is not None: + candidates.append(layout.sizeHint().height()) + candidates.append(layout.minimumSize().height()) + + return max(0, *candidates) + + def update_width_constraints(self) -> None: + natural_width = self._natural_content_width() + + # Reserve vertical-scrollbar width so that when the scrollbar appears, + # the content still has enough viewport width and is not horizontally clipped. + min_scroll_width = natural_width + self._scrollbar_extent() + self._frame_width_total() + + if self._content_widget.minimumWidth() != natural_width: + self._content_widget.setMinimumWidth(natural_width) + + if self.minimumWidth() != min_scroll_width: + self.setMinimumWidth(min_scroll_width) + + self._content_widget.updateGeometry() + self.updateGeometry() + + def event(self, event) -> bool: + result = super().event(event) + + if event.type() in { + QEvent.Type.Show, + QEvent.Type.LayoutRequest, + QEvent.Type.FontChange, + QEvent.Type.StyleChange, + QEvent.Type.PaletteChange, + }: + self.update_width_constraints() + + return result + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self.update_width_constraints() + + def sizeHint(self) -> QSize: + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + self._natural_content_height(), + ) + + def minimumSizeHint(self) -> QSize: + # Width is strict, height is deliberately small so vertical scrolling can happen. + return QSize( + self._natural_content_width() + self._scrollbar_extent() + self._frame_width_total(), + 120, + ) + + +def make_scrollable_with_fixed_footer( + content_widget: QWidget, + footer_widget: QWidget | None = None, + *, + object_name: str | None = None, + scroll_object_name: str | None = None, + footer_object_name: str | None = None, + margins: tuple[int, int, int, int] = (0, 0, 0, 0), + spacing: int = 0, + footer_margins: tuple[int, int, int, int] = (0, 0, 0, 0), +) -> QWidget: + """ + Wrap `content_widget` in a vertical-only scroll area and keep `footer_widget` + outside the scroll area. + + Guarantees: + - existing content layout is not restructured; + - vertical scrolling appears only when height is insufficient; + - horizontal scrolling is never introduced; + - content is not hidden horizontally: the wrapper advertises the natural + content width as its minimum width; + - footer controls remain visible. + """ + wrapper = QWidget() + if object_name: + wrapper.setObjectName(object_name) + + wrapper_layout = QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(*margins) + wrapper_layout.setSpacing(max(0, int(spacing))) + + scroll = _VerticalOnlyScrollArea(content_widget, wrapper) + if scroll_object_name: + scroll.setObjectName(scroll_object_name) + + wrapper_layout.addWidget(scroll, stretch=1) + + if footer_widget is not None: + footer_wrapper = QWidget(wrapper) + if footer_object_name: + footer_wrapper.setObjectName(footer_object_name) + + footer_layout = QVBoxLayout(footer_wrapper) + footer_layout.setContentsMargins(*footer_margins) + footer_layout.setSpacing(0) + footer_layout.addWidget(footer_widget) + + footer_wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) + wrapper_layout.addWidget(footer_wrapper, stretch=0) + + wrapper.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding) + + # The wrapper must also resist horizontal shrinking, otherwise the dock can + # still become narrower than the scroll area's valid width. + QTimer.singleShot(0, lambda: wrapper.setMinimumWidth(scroll.minimumSizeHint().width())) + + return wrapper From 7879fc6975c08c9d29762673099aca7ac1aedb15 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:47:28 +0200 Subject: [PATCH 4/8] Fix test assertion --- tests/cameras/backends/test_opencv_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cameras/backends/test_opencv_backend.py b/tests/cameras/backends/test_opencv_backend.py index 5fff099..e246829 100644 --- a/tests/cameras/backends/test_opencv_backend.py +++ b/tests/cameras/backends/test_opencv_backend.py @@ -99,7 +99,7 @@ def fake_videocapture(index, flag): assert any(idx == 0 for idx, _ in calls) assert not any(idx == 1 for idx, _ in calls) # since alt index probe is commented out - assert "camera" in backend.device_name().lower() + assert "test" in backend.device_name().lower() def test_open_raises_when_unable_to_open(monkeypatch, fake_capture_factory): From 4071bc9f62d1a1b62cd6d90741d5ac7d4bda319c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:24 +0200 Subject: [PATCH 5/8] Refactor settings store and add preference APIs Reorganize `DLCLiveGUISettingsStore` with explicit key constants, shared bool/string helpers, and clearer sections/docstrings. Add persisted DLC preference accessors for inference camera ID, processor key, and processor-control state, and tighten processor folder persistence behavior. Also improve `ModelPathStore` readability and diagnostics by clarifying path/model handling logic and adding `exc_info=True` to debug logs for better troubleshooting. --- dlclivegui/utils/settings_store.py | 250 ++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 72 deletions(-) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index 0107afb..c6c0171 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -13,56 +13,144 @@ class DLCLiveGUISettingsStore: + """Small QSettings-backed store for lightweight GUI preferences. + + Stores UI/session preferences that should survive + application restarts but do not necessarily belong in exported JSON configs. + + Full application configuration snapshots are also stored here separately as + JSON for convenient startup restore. + """ + + # --- app/config keys --- + KEY_LAST_CONFIG_PATH = "app/last_config_path" + KEY_CONFIG_JSON = "app/config_json" + + # --- dlc/model keys --- + KEY_LAST_MODEL_PATH = "dlc/last_model_path" + KEY_PROCESSOR_FOLDER = "dlc/processor_folder" + KEY_INFERENCE_CAMERA_ID = "dlc/inference_camera_id" + KEY_PROCESSOR_KEY = "dlc/processor_key" + KEY_PROCESSOR_CONTROL_ENABLED = "dlc/processor_control_enabled" + + # --- recording keys --- + KEY_SESSION_NAME = "recording/session_name" + KEY_USE_TIMESTAMP = "recording/use_timestamp" + KEY_FAST_ENCODING = "recording/fast_encoding" + def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") - # --- lightweight prefs --- + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _get_bool(self, key: str, default: bool = False) -> bool: + """Read a bool from QSettings, handling Qt/string/int variants.""" + value = self._s.value(key, default) + + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + + return bool(default) + + def _get_optional_str(self, key: str, default: str = "") -> str | None: + """Read an optional string from QSettings.""" + value = self._s.value(key, default) + value = str(value).strip() if value is not None else "" + return value or None + + def _set_optional_str(self, key: str, value: str | None) -> None: + """Persist optional string values as empty strings when unset.""" + self._s.setValue(key, str(value).strip() if value else "") + + # ------------------------------------------------------------------ + # App/model prefs + # ------------------------------------------------------------------ def get_last_model_path(self) -> str | None: - v = self._s.value("dlc/last_model_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_MODEL_PATH) def set_last_model_path(self, path: str) -> None: - self._s.setValue("dlc/last_model_path", path or "") + self._set_optional_str(self.KEY_LAST_MODEL_PATH, path) def get_last_config_path(self) -> str | None: - v = self._s.value("app/last_config_path", "") - return str(v) if v else None + return self._get_optional_str(self.KEY_LAST_CONFIG_PATH) def set_last_config_path(self, path: str) -> None: - self._s.setValue("app/last_config_path", path or "") + self._set_optional_str(self.KEY_LAST_CONFIG_PATH, path) + # ------------------------------------------------------------------ + # Recording prefs + # ------------------------------------------------------------------ def get_session_name(self) -> str: - v = self._s.value("recording/session_name", "") - return str(v) if v else "" + return self._get_optional_str(self.KEY_SESSION_NAME) or "" def set_session_name(self, name: str) -> None: - self._s.setValue("recording/session_name", name or "") + self._set_optional_str(self.KEY_SESSION_NAME, name) def get_use_timestamp(self, default: bool = True) -> bool: - v = self._s.value("recording/use_timestamp", default) - if isinstance(v, bool): - return v - if isinstance(v, (int, float)): - return bool(v) - if isinstance(v, str): - return v.strip().lower() in ("1", "true", "yes", "on") - return bool(default) + return self._get_bool(self.KEY_USE_TIMESTAMP, default=default) def set_use_timestamp(self, value: bool) -> None: - self._s.setValue("recording/use_timestamp", bool(value)) + self._s.setValue(self.KEY_USE_TIMESTAMP, bool(value)) def get_fast_encoding(self, default: bool = False) -> bool: - value = self._s.value("recording/fast_encoding", default) - if isinstance(value, bool): - return value - return str(value).strip().lower() in {"1", "true", "yes", "on"} + return self._get_bool(self.KEY_FAST_ENCODING, default=default) - def get_processor_folder(self, default: str = "") -> str: + def set_fast_encoding(self, enabled: bool) -> None: + self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + + # ------------------------------------------------------------------ + # DLC camera / processor prefs + # ------------------------------------------------------------------ + def get_inference_camera_id(self, default: str | None = None) -> str | None: + """Return the last explicitly selected DLC inference camera ID. + + This is a user preference. Runtime fallbacks during preview should not + overwrite this value unless the user explicitly changes the combo. """ - Return the persisted processor folder if it still exists and is a directory. - Otherwise return default. + return self._get_optional_str(self.KEY_INFERENCE_CAMERA_ID, default or "") + + def set_inference_camera_id(self, camera_id: str | None) -> None: + """Persist the explicitly selected DLC inference camera ID.""" + self._set_optional_str(self.KEY_INFERENCE_CAMERA_ID, camera_id) + + def get_processor_key(self, default: str | None = None) -> str | None: + """Return the last selected processor key, if any.""" + return self._get_optional_str(self.KEY_PROCESSOR_KEY, default or "") + + def set_processor_key(self, processor_key: str | None) -> None: + """Persist the selected processor key. + + The key may become unavailable if the processor folder changes. In that + case the GUI should simply fall back to "No Processor" while keeping + refresh behavior graceful. """ - value = self._s.value("dlc/processor_folder", default) + self._set_optional_str(self.KEY_PROCESSOR_KEY, processor_key) + + def get_processor_control_enabled(self, default: bool = False) -> bool: + """Return whether processor-based control was enabled last time.""" + return self._get_bool(self.KEY_PROCESSOR_CONTROL_ENABLED, default=default) + + def set_processor_control_enabled(self, enabled: bool) -> None: + """Persist processor-based control checkbox state.""" + self._s.setValue(self.KEY_PROCESSOR_CONTROL_ENABLED, bool(enabled)) + + def get_processor_folder(self, default: str = "") -> str: + """Return the persisted processor folder if it still exists. + + If the stored folder is missing or invalid, return default. + """ + value = self._s.value(self.KEY_PROCESSOR_FOLDER, default) value = str(value).strip() if value is not None else "" if not value: @@ -78,9 +166,10 @@ def get_processor_folder(self, default: str = "") -> str: return default def set_processor_folder(self, folder: str) -> None: - """ - Persist processor folder only if it exists and is a directory. - Invalid folders are ignored. + """Persist processor folder only if it exists and is a directory. + + Invalid folders are ignored so we do not accidentally replace a valid + stored folder with an unusable value. """ folder = str(folder).strip() if folder is not None else "" if not folder: @@ -89,25 +178,27 @@ def set_processor_folder(self, folder: str) -> None: try: path = Path(folder).expanduser() if path.is_dir(): - self._s.setValue("dlc/processor_folder", str(path.resolve())) + self._s.setValue(self.KEY_PROCESSOR_FOLDER, str(path.resolve())) except Exception: logger.debug("Failed to persist processor folder: %s", folder, exc_info=True) - def set_fast_encoding(self, enabled: bool) -> None: - self._s.setValue("recording/fast_encoding", bool(enabled)) - - # --- optional: snapshot full config as JSON in QSettings --- + # ------------------------------------------------------------------ + # Full config snapshot + # ------------------------------------------------------------------ def save_full_config_snapshot(self, cfg: ApplicationSettings) -> None: - self._s.setValue("app/config_json", cfg.model_dump_json()) + """Persist the current full application config as JSON in QSettings.""" + self._s.setValue(self.KEY_CONFIG_JSON, cfg.model_dump_json()) def load_full_config_snapshot(self) -> ApplicationSettings | None: - raw = self._s.value("app/config_json", "") + """Load the previously persisted full application config snapshot.""" + raw = self._s.value(self.KEY_CONFIG_JSON, "") if not raw: return None + try: return ApplicationSettings.model_validate_json(str(raw)) except Exception: - logger.debug("Failed to load full config snapshot from QSettings") + logger.debug("Failed to load full config snapshot from QSettings", exc_info=True) return None @@ -121,19 +212,24 @@ def __init__(self, settings: QSettings | None = None): # Normalization helpers # ------------------------- def _as_path(self, p: str | None) -> Path | None: - """Best-effort conversion to Path (expand ~, interpret '.' as cwd).""" + """Best-effort conversion to Path. + + Expands '~' and interprets '.' as the current working directory. + """ if not p: return None + s = str(p).strip() if not s: return None + try: pp = Path(s).expanduser() if s in (".", "./"): pp = Path.cwd() return pp except Exception: - logger.debug("Failed to parse path: %s", p) + logger.debug("Failed to parse path: %s", p, exc_info=True) return None def _norm_existing_dir(self, p: str | None) -> str | None: @@ -141,27 +237,31 @@ def _norm_existing_dir(self, p: str | None) -> str | None: pp = self._as_path(p) if pp is None: return None + try: - # If a file was given, use its parent directory + # If a file was given, use its parent directory. if pp.exists() and pp.is_file(): pp = pp.parent if pp.exists() and pp.is_dir(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize directory: %s", p) + logger.debug("Failed to normalize directory: %s", p, exc_info=True) + return None def _norm_existing_path(self, p: str | None) -> str | None: - """Return an absolute, resolved existing path (file or dir), else None.""" + """Return an absolute, resolved existing path, file or dir, else None.""" pp = self._as_path(p) if pp is None: return None + try: if pp.exists(): return str(pp.resolve()) except Exception: - logger.debug("Failed to normalize path: %s", p) + logger.debug("Failed to normalize path: %s", p, exc_info=True) + return None # ------------------------- @@ -176,28 +276,30 @@ def load_last(self) -> str | None: try: pp = Path(path) - # Accept a valid model *file* + + # Accept a valid model file. if pp.is_file() and (Engine.is_pytorch_model_path(pp) or Engine.is_tensorflow_model_dir_path(pp.parent)): return str(pp) except Exception: - logger.debug("Last model path not valid/usable: %s", path) + logger.debug("Last model path not valid/usable: %s", path, exc_info=True) return None def load_last_dir(self) -> str | None: """Return last directory if it still exists and is a directory.""" val = self._settings.value("dlc/last_model_dir") - d = self._norm_existing_dir(str(val)) if val else None - return d + return self._norm_existing_dir(str(val)) if val else None # ------------------------- # Save # ------------------------- def save_if_valid(self, path: str) -> None: - """ - Save last model path if it looks valid/usable, and always save its directory. - - For files: always save parent directory. - - For directories: save directory itself if it looks like a TF model dir. + """Save last model path if it looks valid/usable. + + Also saves a safe directory for QFileDialog.setDirectory(...). + + - For files: saves parent directory. + - For directories: saves the directory itself when appropriate. """ norm = self._norm_existing_path(path) if not norm: @@ -206,7 +308,7 @@ def save_if_valid(self, path: str) -> None: try: p = Path(norm) - # Always persist a *directory* that is safe for QFileDialog.setDirectory(...) + # Always persist a directory that is safe for QFileDialog.setDirectory(...). if p.is_dir(): model_dir = p else: @@ -216,13 +318,12 @@ def save_if_valid(self, path: str) -> None: if model_dir_norm: self._settings.setValue("dlc/last_model_dir", model_dir_norm) - # Persist model path if it is a valid model file, or a TF model directory + # Persist model path if it is a valid model file, or a TF model file + # whose parent is a TensorFlow model directory. if Engine.is_pytorch_model_path(p): self._settings.setValue("dlc/last_model_path", str(p)) elif p.parent.is_dir() and Engine.is_tensorflow_model_dir_path(p.parent): self._settings.setValue("dlc/last_model_path", str(p)) - # elif p.is_dir() and Engine.is_tensorflow_model_dir_path(p): - # self._settings.setValue("dlc/last_model_path", str(p)) except Exception: logger.debug("Failed to save model path: %s", path, exc_info=True) @@ -231,6 +332,7 @@ def save_last_dir(self, directory: str) -> None: d = self._norm_existing_dir(directory) if not d: return + try: self._settings.setValue("dlc/last_model_dir", d) except Exception: @@ -240,12 +342,12 @@ def save_last_dir(self, directory: str) -> None: # Resolve # ------------------------- def resolve(self, config_path: str | None) -> str: - """ - Resolve the best model path to display in the UI. + """Resolve the best model path to display in the UI. + Preference: - 1) config_path if valid/usable - 2) persisted last model path if valid/usable - 3) empty + 1. config_path if valid/usable + 2. persisted last model path if valid/usable + 3. empty string """ cfg = self._norm_existing_path(config_path) if cfg: @@ -256,7 +358,7 @@ def resolve(self, config_path: str | None) -> str: if p.is_dir() and Engine.is_tensorflow_model_dir_path(p): return cfg except Exception: - logger.debug("Config path not usable: %s", cfg) + logger.debug("Config path not usable: %s", cfg, exc_info=True) persisted = self.load_last() if persisted: @@ -265,16 +367,16 @@ def resolve(self, config_path: str | None) -> str: return "" def suggest_start_dir(self, fallback_dir: str | None = None) -> str: + """Pick the best directory to start file dialogs in. + + Guarantees: returns an existing absolute directory, never '.'. """ - Pick the best directory to start file dialogs in. - Guarantees: returns an existing absolute directory (never '.'). - """ - # 1) last dir + # 1. last dir last_dir = self.load_last_dir() if last_dir: return last_dir - # 2) directory of last valid model path + # 2. directory of last valid model path last = self.load_last() if last: try: @@ -288,25 +390,29 @@ def suggest_start_dir(self, fallback_dir: str | None = None) -> str: if d: return d except Exception: - logger.debug("Failed to derive start dir from last model: %s", last) + logger.debug("Failed to derive start dir from last model: %s", last, exc_info=True) - # 3) fallback dir (e.g. config.dlc.model_directory) + # 3. fallback dir, e.g. config.dlc.model_directory fb = self._norm_existing_dir(fallback_dir) if fb: return fb - # 4) last resort: cwd if exists else home + # 4. last resort: cwd if exists else home cwd = self._norm_existing_dir(str(Path.cwd())) return cwd or str(Path.home()) def suggest_selected_file(self) -> str | None: - """Return a file to preselect if it exists (only files, not directories).""" + """Return a file to preselect if it exists. + + Only files are returned, not directories. + """ last = self.load_last() if not last: return None + try: p = Path(last) return str(p) if p.exists() and p.is_file() else None except Exception: - logger.debug("Failed to check existence of last model: %s", last) + logger.debug("Failed to check existence of last model: %s", last, exc_info=True) return None From aa8bcb89e4bb508735ef82a2606108d8c44f83bb Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:48 +0200 Subject: [PATCH 6/8] Persist processor and DLC camera selections Restore and save processor choice, processor-control toggle, and inference camera preference through settings so user selections survive restarts. The camera handling now separates preferred vs active inference camera IDs, using temporary runtime fallback cameras without overwriting the saved preference, and updates overlay/inference routing accordingly. It also improves processor list refresh behavior by restoring the previous selection when available and syncing settings on close. --- dlclivegui/gui/main_window.py | 191 ++++++++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 41 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 250b8ff..6813d8f 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -141,7 +141,8 @@ def __init__(self, config: ApplicationSettings | None = None): ) self._config = config - self._inference_camera_id: str | None = None # Camera ID used for inference + self._inference_camera_id: str | None = self._settings_store.get_inference_camera_id() + self._active_inference_camera_id: str | None = None self._running_cams_ids: set[str] = set() self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None @@ -827,8 +828,8 @@ def _connect_signals(self) -> None: self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) self.dlc_camera_combo.currentTextChanged.connect(self.dlc_camera_combo.update_shrink_width) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_dlc_controls_enabled()) - self.allow_processor_ctrl_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) + self.processor_combo.currentIndexChanged.connect(self._on_processor_changed) + self.allow_processor_ctrl_checkbox.stateChanged.connect(self._on_processor_control_changed) # Recording settings ## Session name persistence + preview updates @@ -897,6 +898,13 @@ def _apply_config(self, config: ApplicationSettings) -> None: if hasattr(self, "bbox_color_combo"): color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) + # Processor + ## Allow processor control checkbox state + if hasattr(self, "allow_processor_ctrl_checkbox"): + self.allow_processor_ctrl_checkbox.setChecked( + self._settings_store.get_processor_control_enabled(default=False) + ) + # Update DLC camera list self._refresh_dlc_camera_list() @@ -1163,7 +1171,31 @@ def _processor_control_enabled(self) -> bool: getattr(self, "allow_processor_ctrl_checkbox", None) and self.allow_processor_ctrl_checkbox.isChecked() ) + def _on_processor_changed(self, _index: int) -> None: + """Persist selected processor key when the user changes the processor combo.""" + processor_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(processor_key) + + if hasattr(self.processor_combo, "update_shrink_width"): + self.processor_combo.update_shrink_width() + + def _on_processor_control_changed(self, _state: int) -> None: + """Persist processor-control checkbox state and refresh dependent UI.""" + enabled = self.allow_processor_ctrl_checkbox.isChecked() + self._settings_store.set_processor_control_enabled(enabled) + + self._update_dlc_controls_enabled() + self._update_processor_status() + def _refresh_processors(self) -> None: + """Scan processors and restore the last selected processor best-effort.""" + previous_key = None + if hasattr(self, "processor_combo"): + previous_key = self.processor_combo.currentData() + + preferred_key = previous_key or self._settings_store.get_processor_key() + + self.processor_combo.blockSignals(True) self.processor_combo.clear() self.processor_combo.addItem("No Processor", None) @@ -1186,8 +1218,23 @@ def _refresh_processors(self) -> None: display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem(display_name, key) + # Restore selected processor best-effort. + if preferred_key is not None: + idx = self.processor_combo.findData(preferred_key) + if idx >= 0: + self.processor_combo.setCurrentIndex(idx) + else: + self.processor_combo.setCurrentIndex(0) + else: + self.processor_combo.setCurrentIndex(0) + + self.processor_combo.blockSignals(False) self.processor_combo.update_shrink_width() - self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) + + self.statusBar().showMessage( + f"Found {len(self._processor_keys)} processor(s) in {source_text}", + 3000, + ) # ------------------------------------------------------------------ # Recording path preview and session name persistence @@ -1381,34 +1428,56 @@ def _label_for_cam_id(self, cam_id: str) -> str: return "Unknown camera" def _refresh_dlc_camera_list_running(self) -> None: - """Populate the inference camera dropdown from currently running cameras.""" + """Populate inference camera dropdown from currently running cameras. + + - Keep the user's preferred camera if it is running + - Otherwise use a temporary runtime fallback + - Never persist fallback choices caused by preview/update events + """ + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() + 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: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id in self._running_cams_ids: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = self.dlc_camera_combo.currentData() + + self._active_inference_camera_id = selected_id + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() - def _set_dlc_combo_to_id(self, cam_id: str) -> None: - """Update combo selection to a given ID without firing signals.""" + def _set_dlc_combo_to_id(self, cam_id: str) -> bool: + """Update combo selection to a given camera ID without firing signals.""" self.dlc_camera_combo.blockSignals(True) - idx = self.dlc_camera_combo.findData(cam_id) - if idx >= 0: - self.dlc_camera_combo.setCurrentIndex(idx) - self.dlc_camera_combo.blockSignals(False) + try: + idx = self.dlc_camera_combo.findData(cam_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + return True + return False + finally: + self.dlc_camera_combo.blockSignals(False) + self.dlc_camera_combo.update_shrink_width() def _refresh_dlc_camera_list(self) -> None: - """Populate the inference camera dropdown from active cameras.""" + """Populate inference camera dropdown from configured active cameras.""" + preferred_id = self._inference_camera_id or self._settings_store.get_inference_camera_id() + self.dlc_camera_combo.blockSignals(True) self.dlc_camera_combo.clear() @@ -1419,27 +1488,41 @@ def _refresh_dlc_camera_list(self) -> None: label = f"{display_id} [{cam.backend}:{cam.index}]" self.dlc_camera_combo.addItem(label, cam_id) - # Keep previous selection if still present, else default to first - if self._inference_camera_id is not None: - idx = self.dlc_camera_combo.findData(self._inference_camera_id) + selected_id = None + + if preferred_id is not None: + idx = self.dlc_camera_combo.findData(preferred_id) if idx >= 0: self.dlc_camera_combo.setCurrentIndex(idx) - elif self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() - else: - if self.dlc_camera_combo.count() > 0: - self.dlc_camera_combo.setCurrentIndex(0) - self._inference_camera_id = self.dlc_camera_combo.currentData() + selected_id = preferred_id + + if selected_id is None and self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + selected_id = self.dlc_camera_combo.currentData() + + # First-run convenience only. + # If there is no previous preference, initialize one. + if self._inference_camera_id is None and preferred_id is None: + self._inference_camera_id = selected_id + self._settings_store.set_inference_camera_id(selected_id) + + self._active_inference_camera_id = selected_id self.dlc_camera_combo.blockSignals(False) self.dlc_camera_combo.update_shrink_width() def _on_dlc_camera_changed(self, _index: int) -> None: - """Track user selection of the inference camera.""" - self._inference_camera_id = self.dlc_camera_combo.currentData() + """Track explicit user selection of the inference camera.""" + cam_id = self.dlc_camera_combo.currentData() + + self._inference_camera_id = cam_id + self._active_inference_camera_id = cam_id + + self._settings_store.set_inference_camera_id(cam_id) + self.dlc_camera_combo.update_shrink_width() - # Force redraw so bbox/pose overlays switch to the new tile immediately + + # Force redraw so bbox/pose overlays switch to the new tile immediately. if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1451,7 +1534,7 @@ def _render_overlays_for_recording(self, cam_id, frame): offset, scale = (0, 0), (1.0, 1.0) # If this is the inference camera, apply pose overlays - if cam_id == self._inference_camera_id and self._last_pose and self._last_pose.pose is not None: + if cam_id == self._active_inference_camera_id and self._last_pose and self._last_pose.pose is not None: output = draw_pose( output, self._last_pose.pose, @@ -1508,25 +1591,30 @@ def _on_multi_frame_processing_ready(self, frame_data: MultiFrameData) -> None: self._running_cams_ids = new_running self._refresh_dlc_camera_list_running() - # Determine DLC camera (first active camera) - selected_id = self._inference_camera_id + preferred_id = self._inference_camera_id available_ids = list(frame_data.frames.keys()) - if selected_id in frame_data.frames: - dlc_cam_id = selected_id + + if preferred_id in frame_data.frames: + dlc_cam_id = preferred_id else: dlc_cam_id = available_ids[0] if available_ids else "" + if dlc_cam_id: - self._inference_camera_id = dlc_cam_id - self._set_dlc_combo_to_id(dlc_cam_id) - self.statusBar().showMessage( - f"DLC inference camera changed to {self._label_for_cam_id(dlc_cam_id)}", 3000 - ) - else: # No more cameras available + if self._active_inference_camera_id != dlc_cam_id: + self._active_inference_camera_id = dlc_cam_id + self._set_dlc_combo_to_id(dlc_cam_id) + self.statusBar().showMessage( + f"Using temporary DLC inference camera: {self._label_for_cam_id(dlc_cam_id)}", + 3000, + ) + else: if self._dlc_active: self._stop_inference(show_message=True) self._display_dirty = True return + self._active_inference_camera_id = dlc_cam_id + # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -1812,6 +1900,8 @@ def _configure_dlc(self) -> bool: processor = None if self._processor_control_enabled(): selected_key = self.processor_combo.currentData() + self._settings_store.set_processor_key(selected_key) + if selected_key is not None and self._scanned_processors: try: # For now, instantiate with no parameters @@ -2280,9 +2370,28 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha # Remember model path on exit self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) + # Remember processor folder on exit if hasattr(self, "processor_folder_edit"): self._settings_store.set_processor_folder(self.processor_folder_edit.text().strip()) + # Remember user-preferred inference camera on exit. + if hasattr(self, "_inference_camera_id"): + self._settings_store.set_inference_camera_id(self._inference_camera_id) + + # Remember selected processor on exit + if hasattr(self, "processor_combo"): + self._settings_store.set_processor_key(self.processor_combo.currentData()) + + # Remember processor-control checkbox state on exit + if hasattr(self, "allow_processor_ctrl_checkbox"): + self._settings_store.set_processor_control_enabled(self.allow_processor_ctrl_checkbox.isChecked()) + + # Flush QSettings best-effort + try: + self.settings.sync() + except Exception: + logger.exception("Failed to sync QSettings on close", exc_info=True) + # Close the window super().closeEvent(event) From c3ad024e0c663d2c3751608565599ff06bbf02a5 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:23:09 +0200 Subject: [PATCH 7/8] Expand settings store unit test coverage Add broad tests for `DLCLiveGUISettingsStore` behavior, including inference camera ID, processor folder/key persistence, and processor control toggles. The in-memory `QSettings` test double now supports `remove` and `sync`, and new parametrized cases verify robust boolean parsing for processor control plus recording `use_timestamp` and `fast_encoding` settings. --- tests/utils/test_settings_store.py | 166 ++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/tests/utils/test_settings_store.py b/tests/utils/test_settings_store.py index 318dc49..7023824 100644 --- a/tests/utils/test_settings_store.py +++ b/tests/utils/test_settings_store.py @@ -11,10 +11,11 @@ class InMemoryQSettings: - """Stand-in for QSettings""" + """Small stand-in for QSettings.""" def __init__(self): self._d = {} + self.synced = False def value(self, key: str, default=None): return self._d.get(key, default) @@ -22,6 +23,12 @@ def value(self, key: str, default=None): def setValue(self, key: str, value): self._d[key] = value + def remove(self, key: str): + self._d.pop(key, None) + + def sync(self): + self.synced = True + # ----------------------------- # QtSettingsStore @@ -348,3 +355,160 @@ def test_model_path_store_suggest_selected_file_returns_none_when_missing(tmp_pa settings.setValue("dlc/last_model_path", str(missing)) assert mps.suggest_selected_file() is None + + +# ----------------------------- +# Inference camera ID +# ----------------------------- +def test_settings_store_inference_camera_id_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("opencv:0") + assert settstore.get_inference_camera_id() == "opencv:0" + + settstore.set_inference_camera_id(None) + assert settstore.get_inference_camera_id() is None + + settstore.set_inference_camera_id("") + assert settstore.get_inference_camera_id() is None + + +# ----------------------------- +# Processor settings +# ----------------------------- +def test_settings_store_processor_folder_roundtrip_when_valid(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + folder = tmp_path / "processors" + folder.mkdir() + + settstore.set_processor_folder(str(folder)) + + assert settstore.get_processor_folder(default="fallback") == str(folder.resolve()) + + +def test_settings_store_processor_folder_ignores_invalid_value(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + + settstore.set_processor_folder(str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_get_processor_folder_returns_default_if_stored_missing(tmp_path: Path): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + missing = tmp_path / "missing" + s.setValue("dlc/processor_folder", str(missing)) + + assert settstore.get_processor_folder(default="fallback") == "fallback" + + +def test_settings_store_processor_key_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_key() is None + + settstore.set_processor_key("my_processor") + assert settstore.get_processor_key() == "my_processor" + + settstore.set_processor_key(None) + assert settstore.get_processor_key() is None + + settstore.set_processor_key("") + assert settstore.get_processor_key() is None + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (True, True), + (False, False), + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + (1, True), + (0, False), + ], +) +def test_settings_store_processor_control_bool_parsing(stored, expected): + s = InMemoryQSettings() + s.setValue("dlc/processor_control_enabled", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=not expected) is expected + + +def test_settings_store_processor_control_enabled_roundtrip(): + s = InMemoryQSettings() + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_processor_control_enabled(default=False) is False + + settstore.set_processor_control_enabled(True) + assert settstore.get_processor_control_enabled(default=False) is True + + settstore.set_processor_control_enabled(False) + assert settstore.get_processor_control_enabled(default=True) is False + + +# ----------------------------- +# Recording +# ----------------------------- +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_use_timestamp_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/use_timestamp", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_use_timestamp(default=not expected) is expected + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("true", True), + ("false", False), + ("1", True), + ("0", False), + ("yes", True), + ("no", False), + ("on", True), + ("off", False), + ], +) +def test_settings_store_fast_encoding_bool_variants(stored, expected): + s = InMemoryQSettings() + s.setValue("recording/fast_encoding", stored) + + settstore = store.DLCLiveGUISettingsStore(qsettings=s) + + assert settstore.get_fast_encoding(default=not expected) is expected From 3c563ca82b67c0ef1126fa147ac0c6d56e4c3b33 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:39:17 +0200 Subject: [PATCH 8/8] Improve config path handling in main window This updates configuration load/save flow to keep a valid associated config file path when restoring from QSettings snapshots, and to use a smarter default path for file dialogs (current config, last valid config, or derived parent path). It also makes saves return success/failure so `_config_path` is only updated on successful writes, and syncs settings immediately after loading a config to persist metadata reliably. --- dlclivegui/gui/main_window.py | 75 ++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 6813d8f..adf825c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -98,13 +98,19 @@ def __init__(self, config: ApplicationSettings | None = None): self._model_path_store = ModelPathStore(self.settings) self._settings_store = DLCLiveGUISettingsStore(self.settings) + last_cfg_path = self._settings_store.get_last_config_path() + last_cfg_file = self._valid_config_file_path(last_cfg_path) if config is None: # 1) snapshot cfg = self._settings_store.load_full_config_snapshot() if cfg is not None: config = cfg - self._config_path = None - logger.info("Loaded configuration from QSettings snapshot.") + self._config_path = last_cfg_file + if self._config_path is not None: + logger.info(f"Loaded configuration from QSettings snapshot; associated file: {self._config_path}") + else: + logger.info("Loaded configuration from QSettings snapshot without associated config file.") + else: # 2) last config file path last_cfg_path = self._settings_store.get_last_config_path() @@ -225,6 +231,19 @@ def resizeEvent(self, event): if not self.multi_camera_controller.is_running(): self._show_logo_and_text() + def _valid_config_file_path(self, path: str | None) -> Path | None: + if not path: + return None + + try: + p = Path(path).expanduser() + if p.exists() and p.is_file(): + return p.resolve() + except Exception: + logger.debug("Invalid config file path: %s", path, exc_info=True) + + return None + # ------------------------------------------------------------------ UI def _init_theme_actions(self) -> None: """Set initial checked state for theme actions based on current app stylesheet.""" @@ -1010,10 +1029,36 @@ def _visualization_settings_from_ui(self) -> VisualizationSettings: bbox_color=self._bbox_color, ) + def _suggest_config_dialog_path(self) -> str: + """Return best initial path for load/save config dialogs.""" + if getattr(self, "_config_path", None) is not None: + try: + return str(self._config_path) + except Exception: + pass + + last_cfg = self._settings_store.get_last_config_path() + valid_last = self._valid_config_file_path(last_cfg) + if valid_last is not None: + return str(valid_last) + + if last_cfg: + try: + p = Path(last_cfg).expanduser() + parent = p.parent + if parent.exists() and parent.is_dir(): + return str(parent / (p.name or "config.json")) + except Exception: + logger.debug("Failed to derive config dialog path from %s", last_cfg, exc_info=True) + + return str(Path.home() / "config.json") + # ------------------------------------------------------------------ # Actions def _action_load_config(self) -> None: - file_name, _ = QFileDialog.getOpenFileName(self, "Load configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getOpenFileName( + self, "Load configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return try: @@ -1023,6 +1068,12 @@ def _action_load_config(self) -> None: return self._settings_store.set_last_config_path(file_name) self._settings_store.save_full_config_snapshot(config) + + try: + self.settings.sync() + except Exception: + logger.debug("Failed to sync settings after loading config", exc_info=True) + self._config = config self._config_path = Path(file_name) self._apply_config(config) @@ -1034,19 +1085,22 @@ def _action_save_config(self) -> None: if self._config_path is None: self._action_save_config_as() return - self._save_config_to_path(self._config_path) + if self._save_config_to_path(self._config_path): + self._config_path = self._config_path.expanduser() def _action_save_config_as(self) -> None: - file_name, _ = QFileDialog.getSaveFileName(self, "Save configuration", str(Path.home()), "JSON files (*.json)") + file_name, _ = QFileDialog.getSaveFileName( + self, "Save configuration", self._suggest_config_dialog_path(), "JSON files (*.json)" + ) if not file_name: return - path = Path(file_name) + path = Path(file_name).expanduser() if path.suffix.lower() != ".json": path = path.with_suffix(".json") - self._config_path = path - self._save_config_to_path(path) + if self._save_config_to_path(path): + self._config_path = path - def _save_config_to_path(self, path: Path) -> None: + def _save_config_to_path(self, path: Path) -> bool: try: config = self._current_config(allow_empty_model_path=True) config.save(path) @@ -1054,8 +1108,9 @@ def _save_config_to_path(self, path: Path) -> None: self._settings_store.save_full_config_snapshot(config) except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) - return + return False self.statusBar().showMessage(f"Saved configuration to {path}", 5000) + return True def _action_browse_model(self) -> None: # Prefer persisted last-used directory, then config.dlc.model_directory, then home