From f1c169b5bed9dc107c121ffb6e2923d9d9136c47 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:42:24 +0200 Subject: [PATCH 01/13] 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 1201749b7..38613fb2c 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 af2403cf2ddb70d2eaf7f78dcade7d70d091250f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:15 +0200 Subject: [PATCH 02/13] 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 76de1e009..e8c263dc5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1320,16 +1320,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. @@ -1371,8 +1375,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.""" @@ -1408,8 +1418,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 789345835b592a871c8c80875fc9a3b9fb7ec08c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:43:43 +0200 Subject: [PATCH 03/13] 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 e8c263dc5..d5ad59fdb 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -273,32 +273,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 @@ -1577,12 +1591,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 2b09b47fc..d80ba114c 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 c25dd8335f217f6dfc9a10f9973dba6ad85a2019 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 8 Jul 2026 10:47:28 +0200 Subject: [PATCH 04/13] 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 5fff09910..e2468297b 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 bd3d38478634e303b0d9302ef5a9a6c8acad693c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:24 +0200 Subject: [PATCH 05/13] 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 0107afb1c..c6c0171e0 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 10bb72fbe959a5d5d84a5343ebe5ac8e4e2b4485 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:22:48 +0200 Subject: [PATCH 06/13] 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 | 364 ++++++++++++++++++++++++++++------ 1 file changed, 298 insertions(+), 66 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index d5ad59fdb..e79396942 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -144,7 +144,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 @@ -840,8 +841,12 @@ 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.processor_combo.currentIndexChanged.connect(self._on_processor_selection_changed) - self.use_custom_proc_checkbox.stateChanged.connect(lambda _s: self._update_processor_status()) + self.processor_combo.currentIndexChanged.connect( + self._on_processor_selection_changed + ) + self.use_custom_proc_checkbox.stateChanged.connect( + self._on_custom_processor_enabled_changed + ) # Recording settings ## Session name persistence + preview updates @@ -910,6 +915,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, "use_custom_proc_checkbox"): + self.use_custom_proc_checkbox.setChecked( + self._settings_store.get_processor_control_enabled(default=False) + ) + # Update DLC camera list self._refresh_dlc_camera_list() @@ -1181,32 +1193,170 @@ def _custom_processor_enabled(self) -> bool: and self.processor_combo.currentData() is not None ) + def _on_processor_selection_changed( + self, + _index: int, + ) -> None: + """Persist selection and enable custom processing for a chosen processor.""" + selected_key = self.processor_combo.currentData() + has_selection = selected_key is not None + + self._settings_store.set_processor_key(selected_key) + self.processor_toggle_row.setVisible(has_selection) + + self.use_custom_proc_checkbox.blockSignals(True) + self.use_custom_proc_checkbox.setChecked(has_selection) + self.use_custom_proc_checkbox.blockSignals(False) + + self._settings_store.set_processor_control_enabled( + has_selection + ) + + if hasattr( + self.processor_combo, + "update_shrink_width", + ): + self.processor_combo.update_shrink_width() + + self._update_processor_status() + + + def _on_custom_processor_enabled_changed( + self, + _state: int, + ) -> None: + """Persist whether the selected custom processor should be used.""" + enabled = self._custom_processor_enabled() + + self._settings_store.set_processor_control_enabled( + enabled + ) + self._update_processor_status() + def _refresh_processors(self) -> None: - self.processor_combo.clear() - self.processor_combo.addItem("No Processor", None) + """Scan processors and restore the previous selection best-effort.""" + previous_key = self.processor_combo.currentData() + preferred_key = ( + previous_key + or self._settings_store.get_processor_key() + ) + preferred_enabled = ( + self.use_custom_proc_checkbox.isChecked() + if previous_key is not None + else self._settings_store.get_processor_control_enabled( + default=False + ) + ) - selected_folder = self.processor_folder_edit.text().strip() - selected_path = Path(selected_folder).expanduser() if selected_folder else None + self.processor_combo.blockSignals(True) + try: + self.processor_combo.clear() + self.processor_combo.addItem( + "No Processor", + None, + ) - if selected_path is not None and selected_path.is_dir(): - resolved_folder = str(selected_path.resolve()) - self._settings_store.set_processor_folder(resolved_folder) - self._scanned_processors = scan_processor_folder(resolved_folder) - source_text = resolved_folder - else: - self._scanned_processors = scan_processor_package("dlclivegui.processors") - source_text = "package dlclivegui.processors" + selected_folder = ( + self.processor_folder_edit.text().strip() + ) + selected_path = ( + Path(selected_folder).expanduser() + if selected_folder + else None + ) + + if ( + selected_path is not None + and selected_path.is_dir() + ): + resolved_folder = str( + selected_path.resolve() + ) + self._settings_store.set_processor_folder( + resolved_folder + ) + self._scanned_processors = ( + scan_processor_folder( + resolved_folder + ) + ) + source_text = resolved_folder + else: + self._scanned_processors = ( + scan_processor_package( + "dlclivegui.processors" + ) + ) + source_text = ( + "package dlclivegui.processors" + ) + + self._processor_keys = list( + self._scanned_processors + ) + + for key in self._processor_keys: + info = self._scanned_processors[key] + display_name = ( + f"{info['name']} ({info['file']})" + ) + self.processor_combo.addItem( + display_name, + key, + ) + + selected_index = 0 + if preferred_key is not None: + found_index = ( + self.processor_combo.findData( + preferred_key + ) + ) + if found_index >= 0: + selected_index = found_index + + self.processor_combo.setCurrentIndex( + selected_index + ) + finally: + self.processor_combo.blockSignals(False) + + has_selection = ( + self.processor_combo.currentData() + is not None + ) + + self.processor_toggle_row.setVisible( + has_selection + ) - self._processor_keys = list(self._scanned_processors.keys()) + self.use_custom_proc_checkbox.blockSignals( + True + ) + self.use_custom_proc_checkbox.setChecked( + has_selection and preferred_enabled + ) + self.use_custom_proc_checkbox.blockSignals( + False + ) - for key in self._processor_keys: - info = self._scanned_processors[key] - display_name = f"{info['name']} ({info['file']})" - self.processor_combo.addItem(display_name, key) + # Clear a saved key that is no longer available. + selected_key = self.processor_combo.currentData() + self._settings_store.set_processor_key( + selected_key + ) + self._settings_store.set_processor_control_enabled( + self._custom_processor_enabled() + ) self.processor_combo.update_shrink_width() - self.statusBar().showMessage(f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000) + self._update_processor_status() + self.statusBar().showMessage( + f"Found {len(self._processor_keys)} " + f"processor(s) in {source_text}", + 3000, + ) # ------------------------------------------------------------------ # Recording path preview and session name persistence def _known_recording_extensions(self) -> set[str]: @@ -1399,34 +1549,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() @@ -1437,27 +1609,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) @@ -1469,7 +1655,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, @@ -1526,25 +1712,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 @@ -1839,20 +2030,42 @@ def _configure_dlc(self) -> bool: # Instantiate processor if selected processor = None selected_key = self.processor_combo.currentData() + + self._settings_store.set_processor_key( + selected_key + ) + if self._custom_processor_enabled(): try: - # For now, instantiate with no parameters - processor = instantiate_from_scan(self._scanned_processors, selected_key) - processor_name = self._scanned_processors[selected_key]["name"] - self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) - except Exception as e: - error_msg = f"Failed to instantiate processor: {e}" + processor = instantiate_from_scan( + self._scanned_processors, + selected_key, + ) + processor_name = ( + self._scanned_processors[ + selected_key + ]["name"] + ) + self.statusBar().showMessage( + f"Loaded processor: {processor_name}", + 3000, + ) + except Exception as exc: + error_msg = ( + "Failed to instantiate processor: " + f"{exc}" + ) self._show_error(error_msg) logger.error(error_msg) return False - elif selected_key is not None: - self.statusBar().showMessage(f"Custom processor disabled: {selected_key}", 3000) + elif selected_key is not None: + self.statusBar().showMessage( + f"Custom processor disabled: " + f"{selected_key}", + 3000, + ) + self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) return True @@ -2330,9 +2543,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, "use_custom_proc_checkbox"): + self._settings_store.set_processor_control_enabled(self.use_custom_proc_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 3fef8751a721ac4b128d2e1bb84dde3a21ee636f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:23:09 +0200 Subject: [PATCH 07/13] 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 318dc49cd..702382429 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 c5196e09ae6a1a64bfe8bef7a032d90299d3b9e5 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 10 Jul 2026 14:39:17 +0200 Subject: [PATCH 08/13] 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 e79396942..55e1b8c9e 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -101,13 +101,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() @@ -228,6 +234,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.""" @@ -1029,10 +1048,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: @@ -1042,6 +1087,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) @@ -1053,19 +1104,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) @@ -1073,8 +1127,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 From 9ff5e9b4300297305cd53f5a42440aae24bf2d95 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 10:49:39 +0200 Subject: [PATCH 09/13] pre-commit --- dlclivegui/gui/main_window.py | 140 ++++++++-------------------------- 1 file changed, 33 insertions(+), 107 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 55e1b8c9e..e99d6dedb 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -860,12 +860,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.processor_combo.currentIndexChanged.connect( - self._on_processor_selection_changed - ) - self.use_custom_proc_checkbox.stateChanged.connect( - self._on_custom_processor_enabled_changed - ) + self.processor_combo.currentIndexChanged.connect(self._on_processor_selection_changed) + self.use_custom_proc_checkbox.stateChanged.connect(self._on_custom_processor_enabled_changed) # Recording settings ## Session name persistence + preview updates @@ -937,9 +933,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Processor ## Allow processor control checkbox state if hasattr(self, "use_custom_proc_checkbox"): - self.use_custom_proc_checkbox.setChecked( - self._settings_store.get_processor_control_enabled(default=False) - ) + self.use_custom_proc_checkbox.setChecked(self._settings_store.get_processor_control_enabled(default=False)) # Update DLC camera list self._refresh_dlc_camera_list() @@ -1263,9 +1257,7 @@ def _on_processor_selection_changed( self.use_custom_proc_checkbox.setChecked(has_selection) self.use_custom_proc_checkbox.blockSignals(False) - self._settings_store.set_processor_control_enabled( - has_selection - ) + self._settings_store.set_processor_control_enabled(has_selection) if hasattr( self.processor_combo, @@ -1275,7 +1267,6 @@ def _on_processor_selection_changed( self._update_processor_status() - def _on_custom_processor_enabled_changed( self, _state: int, @@ -1283,24 +1274,17 @@ def _on_custom_processor_enabled_changed( """Persist whether the selected custom processor should be used.""" enabled = self._custom_processor_enabled() - self._settings_store.set_processor_control_enabled( - enabled - ) + self._settings_store.set_processor_control_enabled(enabled) self._update_processor_status() def _refresh_processors(self) -> None: """Scan processors and restore the previous selection best-effort.""" previous_key = self.processor_combo.currentData() - preferred_key = ( - previous_key - or self._settings_store.get_processor_key() - ) + preferred_key = previous_key or self._settings_store.get_processor_key() preferred_enabled = ( self.use_custom_proc_checkbox.isChecked() if previous_key is not None - else self._settings_store.get_processor_control_enabled( - default=False - ) + else self._settings_store.get_processor_control_enabled(default=False) ) self.processor_combo.blockSignals(True) @@ -1311,50 +1295,23 @@ def _refresh_processors(self) -> None: None, ) - selected_folder = ( - self.processor_folder_edit.text().strip() - ) - selected_path = ( - Path(selected_folder).expanduser() - if selected_folder - else None - ) + selected_folder = self.processor_folder_edit.text().strip() + selected_path = Path(selected_folder).expanduser() if selected_folder else None - if ( - selected_path is not None - and selected_path.is_dir() - ): - resolved_folder = str( - selected_path.resolve() - ) - self._settings_store.set_processor_folder( - resolved_folder - ) - self._scanned_processors = ( - scan_processor_folder( - resolved_folder - ) - ) + if selected_path is not None and selected_path.is_dir(): + resolved_folder = str(selected_path.resolve()) + self._settings_store.set_processor_folder(resolved_folder) + self._scanned_processors = scan_processor_folder(resolved_folder) source_text = resolved_folder else: - self._scanned_processors = ( - scan_processor_package( - "dlclivegui.processors" - ) - ) - source_text = ( - "package dlclivegui.processors" - ) + self._scanned_processors = scan_processor_package("dlclivegui.processors") + source_text = "package dlclivegui.processors" - self._processor_keys = list( - self._scanned_processors - ) + self._processor_keys = list(self._scanned_processors) for key in self._processor_keys: info = self._scanned_processors[key] - display_name = ( - f"{info['name']} ({info['file']})" - ) + display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem( display_name, key, @@ -1362,56 +1319,35 @@ def _refresh_processors(self) -> None: selected_index = 0 if preferred_key is not None: - found_index = ( - self.processor_combo.findData( - preferred_key - ) - ) + found_index = self.processor_combo.findData(preferred_key) if found_index >= 0: selected_index = found_index - self.processor_combo.setCurrentIndex( - selected_index - ) + self.processor_combo.setCurrentIndex(selected_index) finally: self.processor_combo.blockSignals(False) - has_selection = ( - self.processor_combo.currentData() - is not None - ) + has_selection = self.processor_combo.currentData() is not None - self.processor_toggle_row.setVisible( - has_selection - ) + self.processor_toggle_row.setVisible(has_selection) - self.use_custom_proc_checkbox.blockSignals( - True - ) - self.use_custom_proc_checkbox.setChecked( - has_selection and preferred_enabled - ) - self.use_custom_proc_checkbox.blockSignals( - False - ) + self.use_custom_proc_checkbox.blockSignals(True) + self.use_custom_proc_checkbox.setChecked(has_selection and preferred_enabled) + self.use_custom_proc_checkbox.blockSignals(False) # Clear a saved key that is no longer available. selected_key = self.processor_combo.currentData() - self._settings_store.set_processor_key( - selected_key - ) - self._settings_store.set_processor_control_enabled( - self._custom_processor_enabled() - ) + self._settings_store.set_processor_key(selected_key) + self._settings_store.set_processor_control_enabled(self._custom_processor_enabled()) self.processor_combo.update_shrink_width() self._update_processor_status() self.statusBar().showMessage( - f"Found {len(self._processor_keys)} " - f"processor(s) in {source_text}", + f"Found {len(self._processor_keys)} processor(s) in {source_text}", 3000, ) + # ------------------------------------------------------------------ # Recording path preview and session name persistence def _known_recording_extensions(self) -> set[str]: @@ -2086,9 +2022,7 @@ def _configure_dlc(self) -> bool: processor = None selected_key = self.processor_combo.currentData() - self._settings_store.set_processor_key( - selected_key - ) + self._settings_store.set_processor_key(selected_key) if self._custom_processor_enabled(): try: @@ -2096,31 +2030,23 @@ def _configure_dlc(self) -> bool: self._scanned_processors, selected_key, ) - processor_name = ( - self._scanned_processors[ - selected_key - ]["name"] - ) + processor_name = self._scanned_processors[selected_key]["name"] self.statusBar().showMessage( f"Loaded processor: {processor_name}", 3000, ) except Exception as exc: - error_msg = ( - "Failed to instantiate processor: " - f"{exc}" - ) + error_msg = f"Failed to instantiate processor: {exc}" self._show_error(error_msg) logger.error(error_msg) return False elif selected_key is not None: self.statusBar().showMessage( - f"Custom processor disabled: " - f"{selected_key}", + f"Custom processor disabled: {selected_key}", 3000, ) - + self._dlc.configure(settings, processor=processor) self._model_path_store.save_if_valid(settings.model_path) return True From 3c97a680db8d75179a6bb05a8a649aecf903ffb5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 14 Aug 2026 12:04:26 +0200 Subject: [PATCH 10/13] Persist recording filename in user settings Adds a dedicated QSettings key for the recording filename and wires it through the settings store. The main window now restores a previously saved filename on startup, saves it when filename editing finishes, and persists it again on close to avoid losing the value between sessions. --- dlclivegui/gui/main_window.py | 12 ++++++++++++ dlclivegui/utils/settings_store.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e99d6dedb..8b11d23c5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -866,6 +866,7 @@ def _connect_signals(self) -> None: # Recording settings ## Session name persistence + preview updates self.session_name_edit.editingFinished.connect(self._on_session_name_editing_finished) + self.filename_edit.editingFinished.connect(self._on_filename_editing_finished) self.use_timestamp_checkbox.stateChanged.connect(self._on_use_timestamp_changed) self.output_directory_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) self.filename_edit.textChanged.connect(lambda _t: self._update_recording_path_preview()) @@ -889,6 +890,9 @@ def _apply_config(self, config: ApplicationSettings) -> None: recording = config.recording self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) + persisted_filename = self._settings_store.get_rec_filename() + if persisted_filename: + self.filename_edit.setText(persisted_filename) self.container_combo.setCurrentText(recording.container) codec_index = self.codec_combo.findText(recording.codec) if codec_index >= 0: @@ -1396,6 +1400,11 @@ def _on_session_name_editing_finished(self) -> None: self._settings_store.set_session_name(name) self._update_recording_path_preview() + def _on_filename_editing_finished(self) -> None: + filename = self.filename_edit.text().strip() + self._settings_store.set_rec_filename(filename) + self._update_recording_path_preview() + def _update_recording_path_preview(self) -> None: """Update the label showing where files will go (best-effort).""" if not hasattr(self, "recording_path_preview"): @@ -2541,6 +2550,9 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if hasattr(self, "use_custom_proc_checkbox"): self._settings_store.set_processor_control_enabled(self.use_custom_proc_checkbox.isChecked()) + if hasattr(self, "filename_edit"): + self._settings_store.set_rec_filename(self.filename_edit.text().strip()) + # Flush QSettings best-effort try: self.settings.sync() diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py index c6c0171e0..c54265177 100644 --- a/dlclivegui/utils/settings_store.py +++ b/dlclivegui/utils/settings_store.py @@ -37,6 +37,7 @@ class DLCLiveGUISettingsStore: KEY_SESSION_NAME = "recording/session_name" KEY_USE_TIMESTAMP = "recording/use_timestamp" KEY_FAST_ENCODING = "recording/fast_encoding" + KEY_REC_FILENAME = "recording/rec_filename" def __init__(self, qsettings: QSettings | None = None): self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") @@ -109,6 +110,12 @@ def get_fast_encoding(self, default: bool = False) -> bool: def set_fast_encoding(self, enabled: bool) -> None: self._s.setValue(self.KEY_FAST_ENCODING, bool(enabled)) + def get_rec_filename(self) -> str: + return self._get_optional_str(self.KEY_REC_FILENAME) or "" + + def set_rec_filename(self, filename: str) -> None: + self._set_optional_str(self.KEY_REC_FILENAME, filename) + # ------------------------------------------------------------------ # DLC camera / processor prefs # ------------------------------------------------------------------ From 5572d51e3261f165ffeb3e9d9092da94763d7b61 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:10:03 +0200 Subject: [PATCH 11/13] Restore local prefs only on initial config load Add explicit type annotations for the settings stores and update `_apply_config` to accept a `restore_local_prefs` flag. The main window now restores persisted local preferences (like recording filename) only during initial startup, preventing later config applications from unintentionally overriding values loaded from the active config. --- dlclivegui/gui/main_window.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 8b11d23c5..a67e18295 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -98,8 +98,8 @@ def __init__(self, config: ApplicationSettings | None = None): self.setWindowTitle("DeepLabCut Live GUI") self.settings = QSettings("DeepLabCut", "DLCLiveGUI") - self._model_path_store = ModelPathStore(self.settings) - self._settings_store = DLCLiveGUISettingsStore(self.settings) + self._model_path_store: ModelPathStore = ModelPathStore(self.settings) + self._settings_store: DLCLiveGUISettingsStore = DLCLiveGUISettingsStore(self.settings) last_cfg_path = self._settings_store.get_last_config_path() last_cfg_file = self._valid_config_file_path(last_cfg_path) @@ -199,7 +199,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._preview_pixmap = QPixmap(LOGO_ALPHA) self._setup_ui() self._connect_signals() - self._apply_config(self._config) + self._apply_config(self._config, restore_local_prefs=True) self._refresh_processors() # Scan and populate processor dropdown self._update_inference_buttons() self._update_camera_controls_enabled() @@ -875,7 +875,7 @@ def _connect_signals(self) -> None: # ------------------------------------------------------------------ # Config - def _apply_config(self, config: ApplicationSettings) -> None: + def _apply_config(self, config: ApplicationSettings, *, restore_local_prefs: bool = False) -> None: # Update active cameras label self._update_active_cameras_label() @@ -890,9 +890,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: recording = config.recording self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) - persisted_filename = self._settings_store.get_rec_filename() - if persisted_filename: - self.filename_edit.setText(persisted_filename) + if restore_local_prefs: + persisted_filename = self._settings_store.get_rec_filename() + if persisted_filename: + self.filename_edit.setText(persisted_filename) self.container_combo.setCurrentText(recording.container) codec_index = self.codec_combo.findText(recording.codec) if codec_index >= 0: From 0776ef19312fd2f808ee3d18d35bae7bd924e05a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 17 Aug 2026 11:11:23 +0200 Subject: [PATCH 12/13] Include camera ID in unknown camera label Update the fallback camera display name to include the camera ID (`Unknown camera []`) instead of a generic `Unknown camera`, making unidentified cameras easier to distinguish in the UI. --- dlclivegui/gui/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index a67e18295..c351da97b 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1547,7 +1547,7 @@ def _label_for_cam_id(self, cam_id: str) -> str: if display_id: return display_id - return "Unknown camera" + return f"Unknown camera [{cam_id}]" def _refresh_dlc_camera_list_running(self) -> None: """Populate inference camera dropdown from currently running cameras. From 08bc8e608421862b820bfc4807cbd6862311efe7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 18 Aug 2026 16:23:06 +0200 Subject: [PATCH 13/13] Fix loading of last config file setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup config loading path now uses the already-resolved `last_cfg_file` object instead of re-reading a string path and re-checking it. This aligns the load logic with the earlier file resolution step and updates related log messages from “path” to “file”. --- dlclivegui/gui/main_window.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c351da97b..a499a9b3f 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -116,20 +116,14 @@ def __init__(self, config: ApplicationSettings | None = None): else: # 2) last config file path - last_cfg_path = self._settings_store.get_last_config_path() - if last_cfg_path: + if last_cfg_file is not None: try: - p = Path(last_cfg_path) - if p.exists() and p.is_file(): - config = ApplicationSettings.load(str(p)) - self._config_path = p - logger.info(f"Loaded configuration from last config path: {p}") - else: - config = DEFAULT_CONFIG - self._config_path = None + config = ApplicationSettings.load(str(last_cfg_file)) + self._config_path = last_cfg_file + logger.info(f"Loaded configuration from last config file: {last_cfg_file}") except Exception as exc: logger.warning( - f"Failed to load last config path ({last_cfg_path}): {exc}. Using default config." + f"Failed to load last config file ({last_cfg_file}): {exc}. Using default config." ) config = DEFAULT_CONFIG self._config_path = None