From d0601ee276a3dbe234827659a3582383265775a5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:20:24 +0100 Subject: [PATCH 01/16] Reset working settings on show; allow camera edits Add showEvent to reset the cleanup guard and rebuild the working settings from the latest accepted settings, then repopulate the dialog. Allow removing/moving active cameras even while a scan is running and adjust preview button enablement logic. Auto-select the first active camera when populating the list and ensure multi_camera_settings is updated from the working copy before emitting settings_changed on apply. --- .../gui/camera_config/camera_config_dialog.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 5f2caff..587cc14 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -85,6 +85,19 @@ def dlc_camera_id(self, value: str | None) -> None: self._dlc_camera_id = value self._refresh_camera_labels() + def showEvent(self, event): + super().showEvent(event) + try: + # Reset cleanup guard so close cleanup runs for each session + self._cleanup_done = False + except Exception: + pass + + # Rebuild the working copy from the latest “accepted” settings + self._working_settings = self._multi_camera_settings.model_copy(deep=True) + self._current_edit_index = None + self._populate_from_settings() + # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) @@ -304,13 +317,14 @@ def _update_button_states(self) -> None: active_row = self.active_cameras_list.currentRow() has_active_selection = active_row >= 0 - allow_structure_edits = has_active_selection and not scan_running - self.remove_camera_btn.setEnabled(allow_structure_edits) - self.move_up_btn.setEnabled(allow_structure_edits and active_row > 0) - self.move_down_btn.setEnabled(allow_structure_edits and active_row < self.active_cameras_list.count() - 1) - # During loading, preview button becomes "Cancel Loading" + # Allow removing/moving active cameras even during scanning + self.remove_camera_btn.setEnabled(has_active_selection) + self.move_up_btn.setEnabled(has_active_selection and active_row > 0) + self.move_down_btn.setEnabled(has_active_selection and active_row < self.active_cameras_list.count() - 1) + self.preview_btn.setEnabled(has_active_selection or self._preview.state == PreviewState.LOADING) + available_row = self.available_cameras_list.currentRow() self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running) @@ -1014,6 +1028,9 @@ def _populate_from_settings(self) -> None: item.setForeground(Qt.GlobalColor.gray) self.active_cameras_list.addItem(item) + if self.active_cameras_list.count() > 0: + self.active_cameras_list.setCurrentRow(0) + self._refresh_available_cameras() self._update_button_states() @@ -1076,6 +1093,7 @@ def _on_ok_clicked(self) -> None: if self._working_settings.cameras and not active: QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") return + self._multi_camera_settings = self._working_settings.model_copy(deep=True) self.settings_changed.emit(copy.deepcopy(self._working_settings)) self._on_close_cleanup() From 6da0e498fbdf9af9ac1eb2aa7410c0722e525764 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:20:42 +0100 Subject: [PATCH 02/16] Add GUI e2e tests for camera dialog Add two end-to-end GUI tests for camera configuration dialog to prevent regressions: - test_remove_active_camera_works_while_scan_running: Verifies that removing the active camera still works while a discovery scan is running. The test slows CameraFactory.detect_cameras via monkeypatch to keep the scan running, ensures the remove button is enabled during scan, removes the selected camera, and cleans up by cancelling the scan. - test_ok_updates_internal_multicamera_settings: Ensures that after adding a second camera and accepting the dialog (OK), the settings_changed signal emits the updated MultiCameraSettings and the dialog's internal _multi_camera_settings is updated to match the accepted settings. These tests guard against regressions where scan-running state blocked structure edits (remove/move) and where dialog acceptance did not update the dialog's internal settings. --- .../gui/camera_config/test_cam_dialog_e2e.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 49867b8..31ea759 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -424,3 +424,99 @@ def slow_run(self): qtbot.waitUntil(lambda: dialog._preview.loader is None and dialog._preview.state == PreviewState.IDLE, timeout=2000) assert dialog._preview.backend is None + + +@pytest.mark.gui +def test_remove_active_camera_works_while_scan_running(dialog, qtbot, monkeypatch): + """ + Regression test for: + - 'When coming back to camera config after choosing a camera, it cannot be removed' + Root cause: scan_running disabled structure edits (Remove/Move). + Expected: Remove works even while discovery scan is running. + """ + + # Slow down camera detection so scan stays RUNNING long enough for interaction + def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, **kwargs): + for i in range(50): + if should_cancel and should_cancel(): + break + if progress_cb: + progress_cb(f"Scanning… {i}") + time.sleep(0.02) + return [ + DetectedCamera(index=0, label=f"{backend}-X"), + DetectedCamera(index=1, label=f"{backend}-Y"), + ] + + monkeypatch.setattr(CameraFactory, "detect_cameras", staticmethod(slow_detect)) + + # Ensure an active row is selected + dialog.active_cameras_list.setCurrentRow(0) + qtbot.waitUntil(lambda: dialog.active_cameras_list.currentRow() == 0, timeout=1000) + + initial_active = dialog.active_cameras_list.count() + initial_model = len(dialog._working_settings.cameras) + assert initial_active == initial_model == 1 + + # Trigger scan; wait until scan controls indicate it's running + qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) + qtbot.waitUntil(lambda: dialog._is_scan_running(), timeout=1000) + qtbot.waitUntil(lambda: dialog.scan_cancel_btn.isVisible(), timeout=1000) + + # EXPECTATION: remove button should be enabled even during scan + # (This will fail until _update_button_states is changed to not block remove/move during scan) + qtbot.waitUntil(lambda: dialog.remove_camera_btn.isEnabled(), timeout=1000) + + # Remove the selected active camera during scan + qtbot.mouseClick(dialog.remove_camera_btn, Qt.LeftButton) + + assert dialog.active_cameras_list.count() == initial_active - 1 + assert len(dialog._working_settings.cameras) == initial_model - 1 + + # Clean up: cancel scan so teardown doesn't hang waiting for scan completion + if dialog.scan_cancel_btn.isVisible() and dialog.scan_cancel_btn.isEnabled(): + qtbot.mouseClick(dialog.scan_cancel_btn, Qt.LeftButton) + + qtbot.waitUntil(lambda: not dialog._is_scan_running(), timeout=3000) + + +@pytest.mark.gui +def test_ok_updates_internal_multicamera_settings(dialog, qtbot): + """ + Regression test for: + - 'adding another camera and hitting OK does not add the new extra camera' + when caller reads dialog._multi_camera_settings after closing. + + Expected: + - OK emits updated settings + - dialog._multi_camera_settings is updated to match accepted settings + """ + + # Ensure backend combo matches the active camera backend, so duplicate logic behaves consistently + _select_backend_for_active_cam(dialog, cam_row=0) + + # Scan and add a non-duplicate camera (index 1) + _run_scan_and_wait(dialog, qtbot, timeout=2000) + dialog.available_cameras_list.setCurrentRow(1) + qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) + + qtbot.waitUntil(lambda: dialog.active_cameras_list.count() == 2, timeout=1000) + assert len(dialog._working_settings.cameras) == 2 + + # Click OK and capture emitted settings + with qtbot.waitSignal(dialog.settings_changed, timeout=2000) as sig: + qtbot.mouseClick(dialog.ok_btn, Qt.LeftButton) + + emitted = sig.args[0] + assert isinstance(emitted, MultiCameraSettings) + assert len(emitted.cameras) == 2 + + # Check: internal source-of-truth must match accepted state + # (This will fail until _on_ok_clicked updates self._multi_camera_settings) + assert dialog._multi_camera_settings is not None + assert len(dialog._multi_camera_settings.cameras) == 2 + + # Optional: ensure camera identities match (names/index/backend) + assert [(c.backend, int(c.index)) for c in dialog._multi_camera_settings.cameras] == [ + (c.backend, int(c.index)) for c in emitted.cameras + ] From 83d1edc923e75ed654e7f07a73d9cf875503ea83 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:42:33 +0100 Subject: [PATCH 03/16] Standardize preview checks & validate camera items Introduce a helper to detect live previews and replace scattered direct PreviewState checks with it. Add validation for selected detected-camera items (show a warning if invalid). Normalize camera backend id casing when formatting labels. Remove a redundant try/except around cleanup guard reset and always reset _cleanup_done on show. Drop an extra in-place settings assignment in the apply flow and remove a now-unneeded call to _populate_from_settings on show. These changes tidy preview control, improve robustness for camera selection, and unify camera identity handling. --- .../gui/camera_config/camera_config_dialog.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 587cc14..c4c59a3 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -87,16 +87,12 @@ def dlc_camera_id(self, value: str | None) -> None: def showEvent(self, event): super().showEvent(event) - try: - # Reset cleanup guard so close cleanup runs for each session - self._cleanup_done = False - except Exception: - pass + # Reset cleanup guard so close cleanup runs for each session + self._cleanup_done = False # Rebuild the working copy from the latest “accepted” settings self._working_settings = self._multi_camera_settings.model_copy(deep=True) self._current_edit_index = None - self._populate_from_settings() # Maintain overlay geometry when resizing def resizeEvent(self, event): @@ -301,6 +297,9 @@ def _mark_dirty(*_args): # ------------------------------- # UI state updates # ------------------------------- + def _is_preview_live(self) -> bool: + return self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING) + def _set_apply_dirty(self, dirty: bool) -> None: """Visually mark Apply Settings button as 'dirty' (pending edits).""" if dirty: @@ -391,7 +390,7 @@ def _refresh_camera_labels(self) -> None: def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" - this_id = f"{cam.backend}:{cam.index}" + this_id = f"{(cam.backend or '').lower()}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" @@ -690,7 +689,7 @@ def _on_active_camera_selected(self, row: int) -> None: return # Stop any running preview when selection changes - if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): + if self.is_preview_live(): self._stop_preview() self._current_edit_index = row @@ -726,6 +725,9 @@ def _add_selected_camera(self) -> None: return item = self.available_cameras_list.item(row) detected = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(detected, DetectedCamera): + QMessageBox.warning(self, "Invalid Selection", "Selected item is not a valid camera.") + return # make sure this is to lower for comparison against camera_identity_key backend = (self.backend_combo.currentData() or "opencv").lower() @@ -765,6 +767,8 @@ def _add_selected_camera(self) -> None: self._start_probe_for_camera(new_cam) def _remove_selected_camera(self) -> None: + if self._is_preview_live(): + self._stop_preview() if not self._commit_pending_edits(reason="before removing a camera"): return row = self.active_cameras_list.currentRow() @@ -779,6 +783,8 @@ def _remove_selected_camera(self) -> None: self._update_button_states() def _move_camera_up(self) -> None: + if self._is_preview_live(): + self._stop_preview() if not self._commit_pending_edits(reason="before reordering cameras"): return row = self.active_cameras_list.currentRow() @@ -792,6 +798,8 @@ def _move_camera_up(self) -> None: self._refresh_camera_labels() def _move_camera_down(self) -> None: + if self._is_preview_live(): + self._stop_preview() if not self._commit_pending_edits(reason="before reordering cameras"): return row = self.active_cameras_list.currentRow() @@ -948,9 +956,6 @@ def _apply_camera_settings(self) -> bool: diff = CameraSettings.check_diff(current_model, new_model) - self._working_settings.cameras[row] = new_model - self._update_active_list_item(row, new_model) - LOGGER.debug( "[Apply] backend=%s idx=%s changes=%s", getattr(new_model, "backend", None), @@ -1043,7 +1048,7 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: return # Stop preview to avoid fighting an open capture - if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): + if self.is_preview_live(): self._stop_preview() cam = self._working_settings.cameras[row] @@ -1110,7 +1115,7 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo requested width/height/fps with detected device values. """ # Don’t probe if preview is active/loading - if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): + if self._is_preview_live(): return # Track probe intent @@ -1357,7 +1362,7 @@ def _start_preview(self) -> None: """Start camera preview asynchronously (no UI freeze).""" if not self._commit_pending_edits(reason="before starting preview"): return - if self._preview.state in (PreviewState.ACTIVE, PreviewState.LOADING): + if self._is_preview_live(): return row = self._current_edit_index From f5bfea048ddfd94ba5109864bbfe6b17ef94438f Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:42:44 +0100 Subject: [PATCH 04/16] Simplify comments in camera config tests Clean up and shorten comments in tests/gui/camera_config/test_cam_dialog_e2e.py: remove parenthetical notes about expected failures tied to implementation details and replace them with concise, direct expectations. No functional test logic changed. --- tests/gui/camera_config/test_cam_dialog_e2e.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 31ea759..961536a 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -463,8 +463,7 @@ def slow_detect(backend, max_devices=10, should_cancel=None, progress_cb=None, * qtbot.waitUntil(lambda: dialog._is_scan_running(), timeout=1000) qtbot.waitUntil(lambda: dialog.scan_cancel_btn.isVisible(), timeout=1000) - # EXPECTATION: remove button should be enabled even during scan - # (This will fail until _update_button_states is changed to not block remove/move during scan) + # Remove button should be enabled even during scan qtbot.waitUntil(lambda: dialog.remove_camera_btn.isEnabled(), timeout=1000) # Remove the selected active camera during scan @@ -512,7 +511,6 @@ def test_ok_updates_internal_multicamera_settings(dialog, qtbot): assert len(emitted.cameras) == 2 # Check: internal source-of-truth must match accepted state - # (This will fail until _on_ok_clicked updates self._multi_camera_settings) assert dialog._multi_camera_settings is not None assert len(dialog._multi_camera_settings.cameras) == 2 From 3676ba154972e0928a308b93d7e8250586328f83 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:43:32 +0100 Subject: [PATCH 05/16] Update camera_config_dialog.py --- dlclivegui/gui/camera_config/camera_config_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index c4c59a3..3990551 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -689,7 +689,7 @@ def _on_active_camera_selected(self, row: int) -> None: return # Stop any running preview when selection changes - if self.is_preview_live(): + if self._is_preview_live(): self._stop_preview() self._current_edit_index = row @@ -1048,7 +1048,7 @@ def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: return # Stop preview to avoid fighting an open capture - if self.is_preview_live(): + if self._is_preview_live(): self._stop_preview() cam = self._working_settings.cameras[row] From f66553d121d4a54a294311e1732cd82eadb8e0a6 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:52:21 +0100 Subject: [PATCH 06/16] Improve camera dialog validation & workers Refine CameraConfigDialog behavior to prevent races and invalid states: only clean up the scan worker when it's not running; disable the Add Camera button unless the selected item is a DetectedCamera; improve probe worker cancellation and waiting to avoid concurrent probes; emit the multi-camera model copy instead of a deepcopy when settings change. Add _enabled_count_with and enforce MAX_CAMERAS when enabling a camera and before closing the dialog. Clamp crop coordinates and only apply crop when the rectangle is valid to avoid invalid-frame cropping. Small comment/organization tweaks included. --- .../gui/camera_config/camera_config_dialog.py | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 3990551..cc678b7 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -182,7 +182,8 @@ def _on_close_cleanup(self) -> None: # Keep this short to reduce UI freeze sw.wait(300) self._set_scan_state(CameraScanState.IDLE) - self._cleanup_scan_worker() + if self._scan_worker and not self._scan_worker.isRunning(): + self._cleanup_scan_worker() # Cancel probe worker pw = getattr(self, "_probe_worker", None) @@ -643,7 +644,9 @@ def _on_available_camera_selected(self, row: int) -> None: if self._scan_worker and self._scan_worker.isRunning(): self.add_camera_btn.setEnabled(False) return - self.add_camera_btn.setEnabled(row >= 0 and not self._is_scan_running()) + item = self.available_cameras_list.item(row) if row >= 0 else None + detected = item.data(Qt.ItemDataRole.UserRole) if item else None + self.add_camera_btn.setEnabled(isinstance(detected, DetectedCamera)) def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: if self._is_scan_running(): @@ -927,6 +930,14 @@ def _commit_pending_edits(self, *, reason: str = "") -> bool: ) return False + def _enabled_count_with(self, row: int, new_enabled: bool) -> int: + count = 0 + for i, cam in enumerate(self._working_settings.cameras): + enabled = new_enabled if i == row else bool(cam.enabled) + if enabled: + count += 1 + return count + def _apply_camera_settings(self) -> bool: try: for sb in ( @@ -954,6 +965,14 @@ def _apply_camera_settings(self) -> bool: current_model = self._working_settings.cameras[row] new_model = self._build_model_from_form(current_model) + if bool(new_model.enabled): + if self._enabled_count_with(row, True) > self.MAX_CAMERAS: + QMessageBox.warning( + self, "Maximum Cameras", f"Maximum of {self.MAX_CAMERAS} active cameras allowed." + ) + self.cam_enabled_checkbox.setChecked(bool(current_model.enabled)) + return False + diff = CameraSettings.check_diff(current_model, new_model) LOGGER.debug( @@ -1087,6 +1106,9 @@ def _on_ok_clicked(self) -> None: # Auto-apply pending edits before saving if not self._commit_pending_edits(reason="before going back to the main window"): return + if len(self._working_settings.get_active_cameras()) > self.MAX_CAMERAS: + QMessageBox.warning(self, "Maximum Cameras", f"Maximum of {self.MAX_CAMERAS} active cameras allowed.") + return try: if self.apply_settings_btn.isEnabled(): self._append_status("[OK button] Auto-applying pending settings before closing dialog.") @@ -1099,13 +1121,13 @@ def _on_ok_clicked(self) -> None: QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") return self._multi_camera_settings = self._working_settings.model_copy(deep=True) - self.settings_changed.emit(copy.deepcopy(self._working_settings)) + self.settings_changed.emit(self._multi_camera_settings) self._on_close_cleanup() self.accept() # ------------------------------- - # Probe (device telemetry) management + # Probe management # ------------------------------- def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bool = False) -> None: @@ -1118,6 +1140,15 @@ def _start_probe_for_camera(self, cam: CameraSettings, *, apply_to_requested: bo if self._is_preview_live(): return + pw = getattr(self, "_probe_worker", None) + if pw and pw.isRunning(): + try: + pw.request_cancel() + except Exception: + pass + pw.wait(200) + self._probe_worker = None + # Track probe intent self._probe_apply_to_requested = bool(apply_to_requested) self._probe_target_row = int(self._current_edit_index) if self._current_edit_index is not None else None @@ -1610,13 +1641,21 @@ def _update_preview(self) -> None: rotation = self.cam_rotation.currentData() frame = apply_rotation(frame, rotation) - # Apply crop if set in the form (real-time from UI) + # Compute crop with clamping h, w = frame.shape[:2] - x0 = self.cam_crop_x0.value() - y0 = self.cam_crop_y0.value() - x1 = self.cam_crop_x1.value() or w - y1 = self.cam_crop_y1.value() or h - frame = apply_crop(frame, x0, y0, x1, y1) + x0 = max(0, min(self.cam_crop_x0.value(), w)) + y0 = max(0, min(self.cam_crop_y0.value(), h)) + x1_val = self.cam_crop_x1.value() + y1_val = self.cam_crop_y1.value() + x1 = max(0, min(x1_val if x1_val > 0 else w, w)) + y1 = max(0, min(y1_val if y1_val > 0 else h, h)) + + # Only apply if valid rectangle; otherwise skip crop + if x1 > x0 and y1 > y0: + frame = apply_crop(frame, x0, y0, x1, y1) + else: + # Optional: show a status once, not every frame + pass # Resize to fit preview label frame = resize_to_fit(frame, max_w=400, max_h=300) From 61b6dae7ae6930843295960089aa63be1927ec10 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 15:22:46 +0100 Subject: [PATCH 07/16] Sync selection and improve spinbox Enter handling Ensure selection state is synchronized after initialization by calling _populate_from_settings and a new _post_init_sync_selection to set _current_edit_index. Install event filters on camera numeric spinboxes and their lineEdits, and improve Enter-key handling so interpretText() is invoked on the correct spinbox (whether the event comes from the spinbox or its lineEdit) before applying camera settings. Introduce _selected_detected_camera helper to centralize retrieval of the selected detected camera and use it to control the Add button enablement; simplify scan-related UI enable/disable logic and remove duplicated state handling. Miscellaneous cleanup and minor refactors in camera_config_dialog.py. --- .../gui/camera_config/camera_config_dialog.py | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index cc678b7..f0b77ed 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -71,8 +71,15 @@ def __init__( self._settings_scroll_contents: QWidget | None = None self._setup_ui() - self._populate_from_settings() self._connect_signals() + self._populate_from_settings() + self._post_init_sync_selection() + + def _post_init_sync_selection(self) -> None: + """Ensure current selection state is reflected in _current_edit_index.""" + row = self.active_cameras_list.currentRow() + if row >= 0: + self._on_active_camera_selected(row) @property def dlc_camera_id(self) -> str | None: @@ -94,6 +101,9 @@ def showEvent(self, event): self._working_settings = self._multi_camera_settings.model_copy(deep=True) self._current_edit_index = None + self._populate_from_settings() + self._post_init_sync_selection() + # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) @@ -126,7 +136,7 @@ def eventFilter(self, obj, event): # Intercept Enter in FPS and crop spinboxes if event.type() == QEvent.KeyPress and isinstance(event, QKeyEvent): if event.key() in (Qt.Key_Return, Qt.Key_Enter): - if obj in ( + spinboxes = ( self.cam_fps, self.cam_width, self.cam_height, @@ -136,15 +146,22 @@ def eventFilter(self, obj, event): self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1, - ): - # Commit any pending text → value + ) + + def _matches(sb): + return obj is sb or obj is getattr(sb, "lineEdit", lambda: None)() + + if any(_matches(sb) for sb in spinboxes): try: - obj.interpretText() + # If event came from lineEdit, interpretText still belongs to the spinbox + for sb in spinboxes: + if _matches(sb): + sb.interpretText() + break except Exception: pass - # Apply settings to persist crop/FPS to CameraSettings + self._apply_camera_settings() - # Consume so OK isn't triggered return True return super().eventFilter(obj, event) @@ -226,6 +243,24 @@ def _on_close_cleanup(self) -> None: # ------------------------------- def _setup_ui(self) -> None: setup_camera_config_dialog_ui(self) + for sb in ( + self.cam_fps, + self.cam_width, + self.cam_height, + self.cam_exposure, + self.cam_gain, + self.cam_crop_x0, + self.cam_crop_y0, + self.cam_crop_x1, + self.cam_crop_y1, + ): + try: + sb.installEventFilter(self) + le = sb.lineEdit() + if le is not None: + le.installEventFilter(self) + except Exception: + pass def _position_scan_overlay(self) -> None: """Position scan overlay to cover the available_cameras_list area.""" @@ -325,8 +360,8 @@ def _update_button_states(self) -> None: self.preview_btn.setEnabled(has_active_selection or self._preview.state == PreviewState.LOADING) - available_row = self.available_cameras_list.currentRow() - self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running) + self.available_cameras_list.currentRow() + self.add_camera_btn.setEnabled((self._selected_detected_camera() is not None) and not scan_running) def _sync_preview_ui(self) -> None: """Update buttons/overlays based on preview state only.""" @@ -395,6 +430,16 @@ def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + def _selected_detected_camera(self) -> DetectedCamera | None: + row = self.available_cameras_list.currentRow() + if row < 0: + return None + item = self.available_cameras_list.item(row) + if not item: + return None + detected = item.data(Qt.ItemDataRole.UserRole) + return detected if isinstance(detected, DetectedCamera) else None + def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: """Refresh the active camera list row text and color.""" item = self.active_cameras_list.item(row) @@ -479,7 +524,6 @@ def _is_scan_running(self) -> bool: def _set_scan_state(self, state: CameraScanState, message: str | None = None) -> None: """Single source of truth for scan-related UI controls.""" self._scan_state = state - scanning = state in (CameraScanState.RUNNING, CameraScanState.CANCELING) # Overlay message @@ -500,10 +544,8 @@ def _set_scan_state(self, state: CameraScanState, message: str | None = None) -> # Disable discovery inputs while scanning self.backend_combo.setEnabled(not scanning) self.refresh_btn.setEnabled(not scanning) - # Available list + add flow blocked while scanning (structure edits disallowed) self.available_cameras_list.setEnabled(not scanning) - self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0)) self._update_button_states() @@ -644,8 +686,7 @@ def _on_available_camera_selected(self, row: int) -> None: if self._scan_worker and self._scan_worker.isRunning(): self.add_camera_btn.setEnabled(False) return - item = self.available_cameras_list.item(row) if row >= 0 else None - detected = item.data(Qt.ItemDataRole.UserRole) if item else None + detected = self._selected_detected_camera() self.add_camera_btn.setEnabled(isinstance(detected, DetectedCamera)) def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: From 851efb74db978066092007c289e2f3b917d9b3a7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 15:35:09 +0100 Subject: [PATCH 08/16] Normalize camera ID and improve scan cleanup Normalize dlc_camera_id values (split on ':' and lowercase backend part, fallback to lowercasing whole string) and refresh camera labels accordingly. Improve camera discovery shutdown: set scan state to CANCELING when stopping, avoid immediate cleanup if the scan thread is still running, and route worker finished to a new handler (_on_scan_thread_finished) that performs cleanup and sets state to IDLE. Also stop calling _populate_from_settings() when opening the camera dialog to avoid overwriting unsaved changes; let the dialog refresh itself when shown. --- .../gui/camera_config/camera_config_dialog.py | 21 +++++++++++++++---- dlclivegui/gui/main_window.py | 3 ++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index f0b77ed..9db432e 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -88,8 +88,15 @@ def dlc_camera_id(self) -> str | None: @dlc_camera_id.setter def dlc_camera_id(self, value: str | None) -> None: - """Set the currently selected DLC camera ID.""" - self._dlc_camera_id = value + if not value: + self._dlc_camera_id = None + else: + try: + b, idx = value.split(":", 1) + self._dlc_camera_id = f"{b.lower()}:{idx}" + except ValueError: + # fallback: lowercase entire string + self._dlc_camera_id = value.lower() self._refresh_camera_labels() def showEvent(self, event): @@ -197,8 +204,10 @@ def _on_close_cleanup(self) -> None: except Exception: pass # Keep this short to reduce UI freeze + self._set_scan_state(CameraScanState.CANCELING, message="Canceling discovery…") sw.wait(300) - self._set_scan_state(CameraScanState.IDLE) + if sw.isRunning(): + return # Let finished() handle cleanup if self._scan_worker and not self._scan_worker.isRunning(): self._cleanup_scan_worker() @@ -592,7 +601,7 @@ def _refresh_available_cameras(self) -> None: w.canceled.connect(self._on_scan_canceled) # Cleanup only - w.finished.connect(self._cleanup_scan_worker) + w.finished.connect(self._on_scan_thread_finished) self.scan_started.emit(f"Scanning {backend} cameras…") w.start() @@ -605,6 +614,10 @@ def _on_scan_progress(self, msg: str) -> None: return self._show_scan_overlay(msg or "Discovering cameras…") + def _on_scan_thread_finished(self): + self._cleanup_scan_worker() + self._set_scan_state(CameraScanState.IDLE) + def _on_scan_result(self, cams: list) -> None: if self.sender() is not self._scan_worker: LOGGER.debug("[Scan] Ignoring result from old worker: %d cameras", len(cams) if cams else 0) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 380eda0..82926c4 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1193,7 +1193,8 @@ def _open_camera_config_dialog(self) -> None: self._cam_dialog.settings_changed.connect(self._on_multi_camera_settings_changed) else: # Refresh its UI from current settings when reopened - self._cam_dialog._populate_from_settings() + # self._cam_dialog._populate_from_settings() + # ^ do not call here -let the dialog handle it via showEvent to avoid overwriting unsaved changes self._cam_dialog.dlc_camera_id = self._inference_camera_id self._cam_dialog.show() From 05cff1eba3572e351c840ca1c37813a076192c27 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 15:54:47 +0100 Subject: [PATCH 09/16] Cache camera scans and add settings setter Introduce caching for camera discovery to avoid unnecessary rescans by adding _last_scan_backend and _has_scan_results and a _maybe_refresh_available_cameras(force=False) helper that only calls _refresh_available_cameras when backend or cache state requires it. Refactor population logic: add _populate_active_list_from_working to fill the active cameras list (preserving selection optionally) and simplify _populate_from_settings to use it. Add public set_settings(...) to update the dialog with new MultiCameraSettings and optional dlc_camera_id without destroying unsaved UI state. Improve scan worker cleanup logic and add a debug message to clarify deferred cleanup. Update backend-change handling to invalidate cache, and update scan result handlers to set cache flags appropriately. Minor UI tweaks: remove a stray currentRow() call, ensure button states are updated, and switch main window to call set_settings(...) when reusing the dialog so the dialog manages its own refresh behavior. --- .../gui/camera_config/camera_config_dialog.py | 88 ++++++++++++++++--- dlclivegui/gui/main_window.py | 5 +- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 9db432e..9141d8c 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -70,6 +70,10 @@ def __init__( self._settings_scroll: QScrollArea | None = None self._settings_scroll_contents: QWidget | None = None + # Scan cache + self._last_scan_backend: str | None = None + self._has_scan_results: bool = False + self._setup_ui() self._connect_signals() self._populate_from_settings() @@ -108,9 +112,11 @@ def showEvent(self, event): self._working_settings = self._multi_camera_settings.model_copy(deep=True) self._current_edit_index = None - self._populate_from_settings() + self._populate_active_list_from_working(keep_selection=True) self._post_init_sync_selection() + self._maybe_refresh_available_cameras(force=False) + # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) @@ -207,8 +213,11 @@ def _on_close_cleanup(self) -> None: self._set_scan_state(CameraScanState.CANCELING, message="Canceling discovery…") sw.wait(300) if sw.isRunning(): - return # Let finished() handle cleanup - if self._scan_worker and not self._scan_worker.isRunning(): + LOGGER.debug("Cleanup: scan worker still running; deferring worker cleanup to finished()") + elif self._scan_worker is sw: + # Worker has stopped; safe to perform scan-worker-specific cleanup now + self._cleanup_scan_worker() + elif self._scan_worker and not self._scan_worker.isRunning(): self._cleanup_scan_worker() # Cancel probe worker @@ -369,7 +378,6 @@ def _update_button_states(self) -> None: self.preview_btn.setEnabled(has_active_selection or self._preview.state == PreviewState.LOADING) - self.available_cameras_list.currentRow() self.add_camera_btn.setEnabled((self._selected_detected_camera() is not None) and not scan_running) def _sync_preview_ui(self) -> None: @@ -522,6 +530,8 @@ def _append_status(self, text: str) -> None: # Camera discovery and probing # ------------------------------- def _on_backend_changed(self, _index: int) -> None: + self._has_scan_results = False + self._last_scan_backend = None self._refresh_available_cameras() def _is_scan_running(self) -> bool: @@ -628,6 +638,8 @@ def _on_scan_result(self, cams: list) -> None: # Apply results to UI first (stability guarantee) self._detected_cameras = cams or [] self.available_cameras_list.clear() + self._has_scan_results = True + self._last_scan_backend = self._current_backend_key() if not self._detected_cameras: placeholder = QListWidgetItem("No cameras detected.") @@ -657,6 +669,8 @@ def _on_scan_error(self, msg: str) -> None: placeholder = QListWidgetItem("Scan failed.") placeholder.setFlags(Qt.ItemIsEnabled) self.available_cameras_list.addItem(placeholder) + self._has_scan_results = False + self._last_scan_backend = self._current_backend_key() self._finish_scan("error") @@ -678,6 +692,8 @@ def request_scan_cancel(self) -> None: placeholder = QListWidgetItem("Scan canceled.") placeholder.setFlags(Qt.ItemIsEnabled) self.available_cameras_list.addItem(placeholder) + self._has_scan_results = True # keep any results that did arrive, even if cancel requested + self._last_scan_backend = self._current_backend_key() if w is None or not w.isRunning(): self._finish_scan("cancel") @@ -1096,21 +1112,65 @@ def _clear_settings_form(self) -> None: self.apply_settings_btn.setEnabled(False) self.reset_settings_btn.setEnabled(False) + def set_settings(self, settings: MultiCameraSettings, *, dlc_camera_id: str | None = None) -> None: + self._multi_camera_settings = settings or MultiCameraSettings(cameras=[]) + self._working_settings = self._multi_camera_settings.model_copy(deep=True) + self._current_edit_index = None + + if dlc_camera_id is not None: + self.dlc_camera_id = dlc_camera_id + + self._populate_active_list_from_working(keep_selection=True) + self._post_init_sync_selection() + self._maybe_refresh_available_cameras() + def _populate_from_settings(self) -> None: """Populate the dialog from existing settings.""" - self.active_cameras_list.clear() - for i, cam in enumerate(self._working_settings.cameras): - item = QListWidgetItem(self._format_camera_label(cam, i)) - item.setData(Qt.ItemDataRole.UserRole, cam) - if not cam.enabled: - item.setForeground(Qt.GlobalColor.gray) - self.active_cameras_list.addItem(item) + self._populate_active_list_from_working(keep_selection=True) + self._update_button_states() + + def _populate_active_list_from_working(self, *, keep_selection: bool = True) -> None: + """Populate only the active cameras list from _working_settings (no scanning).""" + prev_row = self.active_cameras_list.currentRow() if keep_selection else -1 + self.active_cameras_list.blockSignals(True) + try: + self.active_cameras_list.clear() + for i, cam in enumerate(self._working_settings.cameras): + item = QListWidgetItem(self._format_camera_label(cam, i)) + item.setData(Qt.ItemDataRole.UserRole, cam) + if not cam.enabled: + item.setForeground(Qt.GlobalColor.gray) + self.active_cameras_list.addItem(item) + finally: + self.active_cameras_list.blockSignals(False) + + # restore selection if possible if self.active_cameras_list.count() > 0: - self.active_cameras_list.setCurrentRow(0) + if keep_selection and 0 <= prev_row < self.active_cameras_list.count(): + self.active_cameras_list.setCurrentRow(prev_row) + else: + self.active_cameras_list.setCurrentRow(0) - self._refresh_available_cameras() - self._update_button_states() + def _current_backend_key(self) -> str: + return (self.backend_combo.currentData() or self.backend_combo.currentText().split()[0] or "opencv").lower() + + def _maybe_refresh_available_cameras(self, *, force: bool = False) -> None: + """Refresh available list only when needed (backend changed, no cache, or forced).""" + backend = self._current_backend_key() + + needs_scan = ( + force + or not self._has_scan_results + or (self._last_scan_backend is None) + or (backend != self._last_scan_backend) + or (self.available_cameras_list.count() == 0) # defensive: list got cleared + ) + if needs_scan: + self._refresh_available_cameras() + else: + # No scan; just ensure Add button state is consistent + self._update_button_states() def _reset_selected_camera(self, *, clear_backend_cache: bool = False) -> None: """Reset the selected camera by probing device defaults and applying them to requested values.""" diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 82926c4..0e3beb0 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1192,10 +1192,7 @@ def _open_camera_config_dialog(self) -> None: self._cam_dialog = CameraConfigDialog(self, self._config.multi_camera) self._cam_dialog.settings_changed.connect(self._on_multi_camera_settings_changed) else: - # Refresh its UI from current settings when reopened - # self._cam_dialog._populate_from_settings() - # ^ do not call here -let the dialog handle it via showEvent to avoid overwriting unsaved changes - self._cam_dialog.dlc_camera_id = self._inference_camera_id + self._cam_dialog.set_settings(self._config.multi_camera, dlc_camera_id=self._inference_camera_id) self._cam_dialog.show() self._cam_dialog.raise_() From f608361ac8e26a339754e7cbfc0da2749bcce30a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 16:07:04 +0100 Subject: [PATCH 10/16] Improve scan worker cleanup and error logging Replace a bare exception handler when installing event filters with specific (AttributeError, RuntimeError) handling and log a warning so failures are not silently swallowed. Enhance _on_scan_thread_finished to ignore finished signals from stale workers, attempt deleteLater() on old senders, and only transition the active worker to IDLE to avoid race conditions affecting scan state. --- .../gui/camera_config/camera_config_dialog.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 9141d8c..f0b93f4 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -277,8 +277,8 @@ def _setup_ui(self) -> None: le = sb.lineEdit() if le is not None: le.installEventFilter(self) - except Exception: - pass + except (AttributeError, RuntimeError) as exc: + LOGGER.warning("Failed to install event filter on %s: %s", sb.objectName(), exc) def _position_scan_overlay(self) -> None: """Position scan overlay to cover the available_cameras_list area.""" @@ -624,9 +624,22 @@ def _on_scan_progress(self, msg: str) -> None: return self._show_scan_overlay(msg or "Discovering cameras…") - def _on_scan_thread_finished(self): + def _on_scan_thread_finished(self) -> None: + sender = self.sender() + # Ignore finished signals from old workers; only clean up the active one. + if sender is not None and sender is not self._scan_worker: + LOGGER.debug("[Scan] Ignoring finished from old worker") + # Make sure the old worker can be cleaned up by Qt. + try: + sender.deleteLater() + except AttributeError: + pass + return + self._cleanup_scan_worker() - self._set_scan_state(CameraScanState.IDLE) + # Only transition to IDLE for the worker that was actually active. + if self._scan_state not in (CameraScanState.DONE,): + self._set_scan_state(CameraScanState.IDLE) def _on_scan_result(self, cams: list) -> None: if self.sender() is not self._scan_worker: From ef4e8efc7d06985d47a08706301229b3407eb3f0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 16:31:52 +0100 Subject: [PATCH 11/16] Update test_cam_dialog_e2e.py --- tests/gui/camera_config/test_cam_dialog_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index 961536a..c2b387d 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -136,7 +136,7 @@ def dialog(qtbot, patch_detect_cameras): d.close() qtbot.waitUntil(lambda: d._preview.loader is None, timeout=2000) - qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=2000) + qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=5000) qtbot.wait(50) qtbot.waitUntil(lambda: d._preview.state == PreviewState.IDLE, timeout=2000) From 06b1fec14ec8d274fc868aab5a71cf12b03760b0 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 16:48:37 +0100 Subject: [PATCH 12/16] Fix cleanup guards and scan state transitions Introduce explicit cleanup flags and tighten cleanup logic to avoid races and double-run teardown. Add _cleanp_requested and _cleanup_completed, replace uses of the old _cleanup_done flag, and only mark cleanup completed when no scan/preview/probe worker is active. Also always transition the scan state to IDLE when cleaning up. Update the e2e test to wait for scans to finish earlier and reduce related timeouts to reflect the more deterministic teardown behavior. --- .../gui/camera_config/camera_config_dialog.py | 20 +++++++++++++------ .../gui/camera_config/test_cam_dialog_e2e.py | 3 ++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index f0b93f4..3408403 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -48,6 +48,9 @@ def __init__( self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) + self._cleanp_requested = False + self._cleanup_completed = False + self._dlc_camera_id: str | None = None # self.dlc_camera_id: str | None = None # Actual/working camera settings @@ -106,7 +109,7 @@ def dlc_camera_id(self, value: str | None) -> None: def showEvent(self, event): super().showEvent(event) # Reset cleanup guard so close cleanup runs for each session - self._cleanup_done = False + self._cleanup_completed = False # Rebuild the working copy from the latest “accepted” settings self._working_settings = self._multi_camera_settings.model_copy(deep=True) @@ -192,9 +195,9 @@ def reject(self) -> None: def _on_close_cleanup(self) -> None: """Stop preview, cancel workers, and reset scan UI. Safe to call multiple times.""" # Guard to avoid running twice if closeEvent + reject/accept both run - if getattr(self, "_cleanup_done", False): + if getattr(self, "_cleanup_completed", False): return - self._cleanup_done = True + self._cleanp_requested = True # Stop preview (loader + backend + timer) try: @@ -256,6 +259,13 @@ def _on_close_cleanup(self) -> None: except Exception: pass + if ( + not self._is_scan_running() + and not self._is_preview_live() + and not (self._probe_worker and self._probe_worker.isRunning()) + ): + self._cleanup_completed = True + # ------------------------------- # UI setup # ------------------------------- @@ -637,9 +647,7 @@ def _on_scan_thread_finished(self) -> None: return self._cleanup_scan_worker() - # Only transition to IDLE for the worker that was actually active. - if self._scan_state not in (CameraScanState.DONE,): - self._set_scan_state(CameraScanState.IDLE) + self._set_scan_state(CameraScanState.IDLE) def _on_scan_result(self, cams: list) -> None: if self.sender() is not self._scan_worker: diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py index c2b387d..df1c357 100644 --- a/tests/gui/camera_config/test_cam_dialog_e2e.py +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -120,6 +120,7 @@ def dialog(qtbot, patch_detect_cameras): d = CameraConfigDialog(None, s) qtbot.addWidget(d) d.show() + qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=2000) qtbot.waitExposed(d) yield d @@ -136,7 +137,7 @@ def dialog(qtbot, patch_detect_cameras): d.close() qtbot.waitUntil(lambda: d._preview.loader is None, timeout=2000) - qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=5000) + qtbot.waitUntil(lambda: not d._is_scan_running(), timeout=2000) qtbot.wait(50) qtbot.waitUntil(lambda: d._preview.state == PreviewState.IDLE, timeout=2000) From df7a79424740f3cf15fc7db6bc3c745e1f8f7cee Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 16:49:20 +0100 Subject: [PATCH 13/16] Update camera_config_dialog.py --- dlclivegui/gui/camera_config/camera_config_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config/camera_config_dialog.py b/dlclivegui/gui/camera_config/camera_config_dialog.py index 3408403..4c94fb7 100644 --- a/dlclivegui/gui/camera_config/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config/camera_config_dialog.py @@ -48,7 +48,7 @@ def __init__( self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) - self._cleanp_requested = False + self._cleanup_requested = False self._cleanup_completed = False self._dlc_camera_id: str | None = None @@ -197,7 +197,7 @@ def _on_close_cleanup(self) -> None: # Guard to avoid running twice if closeEvent + reject/accept both run if getattr(self, "_cleanup_completed", False): return - self._cleanp_requested = True + self._cleanup_requested = True # Stop preview (loader + backend + timer) try: From 2f3ebb1371fc289df357dd6b4643484cfb676faf Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 25 Feb 2026 10:40:33 +0100 Subject: [PATCH 14/16] Add skeleton rendering and GUI integration Introduce a new skeleton utility and integrate skeleton overlays into the GUI. Adds dlclivegui/utils/skeleton.py (SkeletonModel, Skeleton, loaders, render status/codes) and a helper to load DLC config skeleton. Wire skeleton drawing into display.draw_pose and the main window: add UI checkbox, auto-enable/disable logic, model-based skeleton configuration, and safe handling when keypoint counts or shapes mismatch. Also add a QScrollArea for the controls dock to allow scrolling. Remove unused demo code Add skeleton UI controls and gradient coloring Add UI and rendering support for configurable skeleton appearance: color mode (solid or keypoint-gradient) and line thickness. main_window.py: introduce skeleton color combo, thickness spinbox, handlers (_on_skeleton_style_changed, _sync_skeleton_controls_from_model), and wire these into model loading and drawing flow; enable/disable controls appropriately and preserve auto-disable behavior. gui/misc/color_dropdowns.py: add gradient swatch icon and helpers to create/populate/get/set a skeleton color combo (supports Gradient and solid BGR swatches). utils/display.py: add keypoint_colors_bgr(colormap, num_keypoints) to produce exact BGR colors from a Matplotlib colormap and remove direct skeleton coupling from draw_pose. utils/skeleton.py: ensure draw_many forwards style, color_override and keypoint_colors to per-pose draw calls and validates pose shapes/keypoint counts. Overall this enables gradient coloring of skeleton lines based on keypoint colormap and exposes user controls to tweak skeleton rendering. --- dlclivegui/assets/skeletons/__init__.py | 0 dlclivegui/gui/main_window.py | 279 ++++++++++++++++- dlclivegui/gui/misc/color_dropdowns.py | 133 +++++++- dlclivegui/temp/yolo/__init__.py | 0 dlclivegui/utils/display.py | 38 ++- dlclivegui/utils/skeleton.py | 384 ++++++++++++++++++++++++ 6 files changed, 828 insertions(+), 6 deletions(-) create mode 100644 dlclivegui/assets/skeletons/__init__.py create mode 100644 dlclivegui/temp/yolo/__init__.py create mode 100644 dlclivegui/utils/skeleton.py diff --git a/dlclivegui/assets/skeletons/__init__.py b/dlclivegui/assets/skeletons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 0e3beb0..798870d 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -42,6 +42,7 @@ QMainWindow, QMessageBox, QPushButton, + QScrollArea, QSizePolicy, QSpinBox, QStatusBar, @@ -70,7 +71,8 @@ ) from ..services.dlc_processor import DLCLiveProcessor, PoseResult from ..services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id -from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose +from ..utils import skeleton as skel +from ..utils.display import BBoxColors, compute_tile_info, create_tiled_frame, draw_bbox, draw_pose, keypoint_colors_bgr from ..utils.settings_store import DLCLiveGUISettingsStore, ModelPathStore from ..utils.stats import format_dlc_stats from ..utils.utils import FPSTracker @@ -164,6 +166,10 @@ def __init__(self, config: ApplicationSettings | None = None): self._p_cutoff = 0.6 self._colormap = "hot" self._bbox_color = (0, 0, 255) # BGR: red + ## Skeleton settings + self._skeleton: skel.Skeleton | None = None + self._skeleton_auto_disabled: bool = False + self._last_skeleton_disable_msg: str | None = None # Multi-camera state self._multi_camera_mode = False @@ -207,6 +213,8 @@ def __init__(self, config: ApplicationSettings | None = None): def resizeEvent(self, event): super().resizeEvent(event) + if hasattr(self, "controls_scroll"): + self._sync_controls_dock_min_width() if not self.multi_camera_controller.is_running(): self._show_logo_and_text() @@ -224,6 +232,34 @@ def _apply_theme(self, mode: AppStyle) -> None: def _load_icons(self): self.setWindowIcon(QIcon(LOGO)) + def _sync_controls_dock_min_width(self) -> None: + """Ensure the dock/scroll area is at least as wide as the controls content.""" + if not hasattr(self, "controls_scroll") or self.controls_scroll is None: + return + w = self.controls_scroll.widget() + if w is None: + return + + # Ensure layout has calculated its hints + w.adjustSize() + + # Minimum width needed by the controls content + content_w = w.minimumSizeHint().width() + if content_w <= 0: + content_w = w.sizeHint().width() + + # Reserve space for the vertical scrollbar (even if not currently visible) + vbar_w = self.controls_scroll.verticalScrollBar().sizeHint().width() + + # Account for scrollarea frame/margins + frame = self.controls_scroll.frameWidth() * 2 + + target = content_w + vbar_w + frame + + # Apply to both scroll area and dock (dock is what user resizes) + self.controls_scroll.setMinimumWidth(target) + self.controls_dock.setMinimumWidth(target) + def _setup_ui(self) -> None: # central = QWidget() # layout = QHBoxLayout(central) @@ -262,6 +298,7 @@ def _setup_ui(self) -> None: controls_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) controls_layout = QVBoxLayout(controls_widget) controls_layout.setContentsMargins(5, 5, 5, 5) + controls_layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetMinimumSize) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) @@ -287,7 +324,16 @@ def _setup_ui(self) -> None: ## Dock widget for controls self.controls_dock = QDockWidget("Controls", self) self.controls_dock.setObjectName("ControlsDock") # important for state saving - self.controls_dock.setWidget(controls_widget) + self.controls_scroll = QScrollArea() + controls_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + self.controls_scroll.setWidget(controls_widget) + self.controls_scroll.setWidgetResizable(True) + self.controls_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.controls_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.controls_scroll.setFrameShape(QScrollArea.Shape.NoFrame) + self.controls_scroll.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents) + # self.controls_dock.setWidget(controls_widget) + self.controls_dock.setWidget(self.controls_scroll) ### Dock features self.controls_dock.setFeatures( # must not be closable by user but visibility can be toggled from View -> Show controls @@ -310,6 +356,7 @@ def _setup_ui(self) -> None: self.setStatusBar(QStatusBar()) self._build_menus() + QTimer.singleShot(0, self._sync_controls_dock_min_width) # ensure dock is wide enough for controls after layout QTimer.singleShot(0, self._show_logo_and_text) def _build_stats_layout(self, stats_widget: QWidget) -> QGridLayout: @@ -664,8 +711,10 @@ def _build_recording_group(self) -> QGroupBox: return group def _build_viz_group(self) -> QGroupBox: + # Visualization settings group group = QGroupBox("Visualization") form = QFormLayout(group) + ## Pose overlay self.show_predictions_checkbox = QCheckBox("Display pose predictions") self.show_predictions_checkbox.setChecked(True) @@ -690,6 +739,47 @@ def _build_viz_group(self) -> QGroupBox: ) form.addRow(keypoints_settings) + ## Skeleton overlay + self.show_skeleton_checkbox = QCheckBox("Display skeleton") + self.show_skeleton_checkbox.setChecked(False) + self.show_skeleton_checkbox.setEnabled(False) + self.show_skeleton_checkbox.setToolTip( + "If enabled, draws connections between keypoints based on the model's skeleton definition.\n" + "Auto-disables if the model keypoints do not match the skeleton definition." + ) + + # Skeleton color mode / color + self.skeleton_color_combo = color_ui.make_skeleton_color_combo( + BBoxColors, # re-use PrimaryColors palette; you aliased it already + current_mode="solid", + current_color=(0, 255, 255), + include_icons=True, + tooltip="Select skeleton color, or Gradient to blend endpoint keypoint colors", + sizing=color_ui.ComboSizing(min_width=80, max_width=200), + ) + self.skeleton_color_combo.setEnabled(False) + + # Skeleton thickness + self.skeleton_thickness_spin = QSpinBox() + self.skeleton_thickness_spin.setRange(1, 20) + self.skeleton_thickness_spin.setValue(2) + self.skeleton_thickness_spin.setToolTip("Skeleton line thickness (scaled with zoom if enabled in style)") + self.skeleton_thickness_spin.setEnabled(False) + + # Layout like keypoints/bbox + skeleton_row = lyts.make_two_field_row( + "Skeleton:", + self.skeleton_color_combo, + None, + self.show_skeleton_checkbox, + key_width=120, + left_stretch=0, + right_stretch=0, + ) + form.addRow(skeleton_row) + form.addRow("Skeleton thickness:", self.skeleton_thickness_spin) + + ## Bounding box overlay & controls self.bbox_enabled_checkbox = QCheckBox("Show bounding box") self.bbox_enabled_checkbox.setChecked(False) @@ -740,7 +830,7 @@ def _build_viz_group(self) -> QGroupBox: self.bbox_y1_spin.setValue(100) bbox_layout.addWidget(self.bbox_y1_spin) - form.addRow("Coordinates", bbox_layout) + form.addRow("Box coordinates", bbox_layout) return group @@ -760,6 +850,10 @@ def _connect_signals(self) -> None: # Visualization settings ## Colormap change self.cmap_combo.currentIndexChanged.connect(self._on_colormap_changed) + ## Skeleton change + self.show_skeleton_checkbox.stateChanged.connect(self._on_show_skeleton_changed) + self.skeleton_color_combo.currentIndexChanged.connect(self._on_skeleton_style_changed) + self.skeleton_thickness_spin.valueChanged.connect(self._on_skeleton_style_changed) ## Connect bounding box controls self.bbox_enabled_checkbox.stateChanged.connect(self._on_bbox_changed) self.bbox_x0_spin.valueChanged.connect(self._on_bbox_changed) @@ -839,6 +933,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.bbox_y1_spin.setValue(bbox.y1) # Set visualization settings from config + ## Keypoints viz = config.visualization self._p_cutoff = viz.p_cutoff self._colormap = viz.colormap @@ -847,6 +942,9 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._bbox_color = viz.get_bbox_color_bgr() if hasattr(self, "bbox_color_combo"): color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color) + ## Skeleton + if resolved_model_path.strip(): + self._configure_skeleton_for_model(resolved_model_path) # Update DLC camera list self._refresh_dlc_camera_list() @@ -1018,6 +1116,7 @@ def _action_browse_model(self) -> None: return file_path = str(file_path) self.model_path_edit.setText(file_path) + self._configure_skeleton_for_model(file_path) # Persist model path + directory self._model_path_store.save_if_valid(file_path) @@ -1180,6 +1279,38 @@ def _on_bbox_color_changed(self, _index: int) -> None: if self._current_frame is not None: self._display_frame(self._current_frame, force=True) + def _on_show_skeleton_changed(self, _state: int) -> None: + self._skeleton_auto_disabled = False + self._last_skeleton_disable_msg = None + if self._current_frame is not None: + self._display_frame(self._current_frame, force=True) + + def _on_skeleton_style_changed(self, _value: int = 0) -> None: + """Apply UI skeleton styling to the current Skeleton instance.""" + if self._skeleton is None: + return + + mode, color = color_ui.get_skeleton_style_from_combo( + self.skeleton_color_combo, + fallback_mode="solid", + fallback_color=self._skeleton.style.color, + ) + + # Update style mode + if mode == "gradient_keypoints": + self._skeleton.style.mode = skel.SkeletonColorMode.GRADIENT_KEYPOINTS + else: + self._skeleton.style.mode = skel.SkeletonColorMode.SOLID + if color is not None: + self._skeleton.style.color = tuple(color) + + # Thickness + self._skeleton.style.thickness = int(self.skeleton_thickness_spin.value()) + + # Redraw + if self._current_frame is not None: + self._display_frame(self._current_frame, force=True) + # ------------------------------------------------------------------ # Multi-camera def _open_camera_config_dialog(self) -> None: @@ -1338,6 +1469,18 @@ def _render_overlays_for_recording(self, cam_id, frame): offset=offset, scale=scale, ) + + if self._skeleton and hasattr(self, "show_skeleton_checkbox") and self.show_skeleton_checkbox.isChecked(): + pose_arr = np.asarray(self._last_pose.pose) + if pose_arr.ndim == 3: + st = self._skeleton.draw_many(output, pose_arr, self._p_cutoff, offset, scale) + else: + self._skeleton.draw(output, self._last_pose.pose, self._p_cutoff, offset, scale) + if st.should_disable: + self.show_skeleton_checkbox.blockSignals(True) + self.show_skeleton_checkbox.setChecked(False) + self.show_skeleton_checkbox.blockSignals(False) + if self._bbox_enabled: output = draw_bbox( frame=output, @@ -1608,6 +1751,68 @@ def _stop_preview(self) -> None: self.camera_stats_label.setText("Camera idle") # self._show_logo_and_text() + def _sync_skeleton_controls_from_model(self) -> None: + """Enable and initialize skeleton UI controls from the current Skeleton.style.""" + enabled = self._skeleton is not None + + self.show_skeleton_checkbox.setEnabled(enabled) + self.skeleton_color_combo.setEnabled(enabled) + self.skeleton_thickness_spin.setEnabled(enabled) + + if not enabled: + return + + # Set thickness + self.skeleton_thickness_spin.blockSignals(True) + self.skeleton_thickness_spin.setValue(int(self._skeleton.style.thickness)) + self.skeleton_thickness_spin.blockSignals(False) + + # Set color/mode + mode = ( + "gradient_keypoints" if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS else "solid" + ) + color_ui.set_skeleton_combo_from_style( + self.skeleton_color_combo, + mode=mode, + color=self._skeleton.style.color, + ) + + if hasattr(self.skeleton_color_combo, "update_shrink_width"): + self.skeleton_color_combo.update_shrink_width() + + def _configure_skeleton_for_model(self, model_path: str) -> None: + """Select an appropriate skeleton definition for the currently configured model.""" + self._skeleton = None + self._skeleton_auto_disabled = False + self._last_skeleton_disable_msg = None + + # Default: disable until we find a compatible skeleton + if hasattr(self, "show_skeleton_checkbox"): + self.show_skeleton_checkbox.setEnabled(False) + # keep checked state but it won't be used unless enabled + + p = Path(model_path).expanduser() + + root = p if p.is_dir() else p.parent + cfg = root / "config.yaml" + if cfg.exists(): + try: + sk = skel.load_dlc_skeleton(cfg) + except Exception as e: + logger.warning(f"Failed to load DLC skeleton from {cfg}: {e}") + sk = None + + if sk is not None: + self._skeleton = sk + self._sync_skeleton_controls_from_model() + if hasattr(self, "show_skeleton_checkbox"): + self.show_skeleton_checkbox.setEnabled(True) + self.statusBar().showMessage("Skeleton available: DLC config.yaml", 3000) + return + + # None found + self.statusBar().showMessage("No skeleton definition available for this model.", 3000) + def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() @@ -1639,6 +1844,7 @@ def _configure_dlc(self) -> bool: self.statusBar().showMessage(f"Processor selection ignored (control disabled): {selected_key}", 3000) self._dlc.configure(settings, processor=processor) + self._configure_skeleton_for_model(settings.model_path) self._model_path_store.save_if_valid(settings.model_path) return True @@ -1864,6 +2070,18 @@ def _stop_inference(self, show_message: bool = True) -> None: self._last_processor_vid_recording = False self._auto_record_session_name = None + # Reset skeleton + self._skeleton = None + self._skeleton_auto_disabled = False + self._last_skeleton_disable_msg = None + self.skeleton_color_combo.setEnabled(False) + self.skeleton_thickness_spin.setEnabled(False) + if hasattr(self, "show_skeleton_checkbox"): + self.show_skeleton_checkbox.blockSignals(True) + self.show_skeleton_checkbox.setChecked(False) + self.show_skeleton_checkbox.setEnabled(False) + self.show_skeleton_checkbox.blockSignals(False) + # Reset button appearance self.start_inference_button.setText("Start pose inference") self.start_inference_button.setStyleSheet("") @@ -1908,6 +2126,59 @@ def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) self._show_error(message) + def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None: + if self._skeleton is None: + return + if not self.show_skeleton_checkbox.isChecked(): + return + if self._skeleton_auto_disabled: + return + + pose_arr = np.asarray(pose) + + # Compute keypoint colors only if gradient mode is active + kp_colors = None + try: + if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS: + n_kpts = pose_arr.shape[1] if pose_arr.ndim == 3 else pose_arr.shape[0] + kp_colors = keypoint_colors_bgr(self._colormap, int(n_kpts)) + + if pose_arr.ndim == 3: + status = self._skeleton.draw_many( + overlay, + pose_arr, + p_cutoff=self._p_cutoff, + offset=self._dlc_tile_offset, + scale=self._dlc_tile_scale, + keypoint_colors=kp_colors, + ) + else: + status = self._skeleton.draw( + overlay, + pose_arr, + p_cutoff=self._p_cutoff, + offset=self._dlc_tile_offset, + scale=self._dlc_tile_scale, + keypoint_colors=kp_colors, + ) + + except Exception as e: + status = skel.SkeletonRenderStatus( + code=skel.SkeletonRenderCode.POSE_SHAPE_INVALID, + message=f"Skeleton rendering error: {e}", + ) + + if status.should_disable: + self._skeleton_auto_disabled = True + msg = status.message or "Skeleton disabled due to keypoint mismatch." + if msg != self._last_skeleton_disable_msg: + self._last_skeleton_disable_msg = msg + self.statusBar().showMessage(f"Skeleton disabled: {msg}", 6000) + + self.show_skeleton_checkbox.blockSignals(True) + self.show_skeleton_checkbox.setChecked(False) + self.show_skeleton_checkbox.blockSignals(False) + def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame @@ -1921,6 +2192,8 @@ def _update_video_display(self, frame: np.ndarray) -> None: scale=self._dlc_tile_scale, ) + self._try_draw_skeleton(display_frame, self._last_pose.pose) + if self._bbox_enabled: display_frame = draw_bbox( display_frame, diff --git a/dlclivegui/gui/misc/color_dropdowns.py b/dlclivegui/gui/misc/color_dropdowns.py index bb0f0ac..c434ae2 100644 --- a/dlclivegui/gui/misc/color_dropdowns.py +++ b/dlclivegui/gui/misc/color_dropdowns.py @@ -16,7 +16,7 @@ import numpy as np from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap +from PySide6.QtGui import QBrush, QColor, QIcon, QImage, QLinearGradient, QPainter, QPixmap from PySide6.QtWidgets import ( QComboBox, QSizePolicy, @@ -28,6 +28,26 @@ TEnum = TypeVar("TEnum") +def make_gradient_swatch_icon(*, width: int = 40, height: int = 16, border: int = 1) -> QIcon: + """Small gradient swatch icon for 'Gradient' mode.""" + pix = QPixmap(width, height) + pix.fill(Qt.transparent) + p = QPainter(pix) + + # border/background + p.fillRect(0, 0, width, height, Qt.black) + p.fillRect(border, border, width - 2 * border, height - 2 * border, Qt.white) + + # inner gradient (blue -> red, arbitrary but clearly "gradient") + grad = QLinearGradient(border + 1, 0, width - (border + 1), 0) + grad.setColorAt(0.0, QColor(0, 140, 255)) + grad.setColorAt(1.0, QColor(255, 80, 0)) + p.fillRect(border + 1, border + 1, width - 2 * (border + 1), height - 2 * (border + 1), QBrush(grad)) + + p.end() + return QIcon(pix) + + # ----------------------------------------------------------------------------- # Combo sizing: shrink to current selection + wide popup # ----------------------------------------------------------------------------- @@ -413,3 +433,114 @@ def get_cmap_name_from_combo(combo: QComboBox, *, fallback: str = "viridis") -> return data text = combo.currentText().strip() return text or fallback + + +# ----------------------------------------------------------------------------- +# Skeleton color combo helpers (enum-based + gradient) +# ----------------------------------------------------------------------------- +def get_skeleton_style_from_combo( + combo: QComboBox, + *, + fallback_mode: str = "solid", + fallback_color: BGR | None = None, +) -> tuple[str, BGR | None]: + data = combo.currentData() + if isinstance(data, dict): + mode = data.get("mode", fallback_mode) + color = data.get("color", fallback_color) + return mode, color + return fallback_mode, fallback_color + + +def make_skeleton_color_combo( + colors_enum: Iterable[TEnum], + *, + current_mode: str = "solid", + current_color: BGR | None = (0, 255, 255), + include_icons: bool = True, + tooltip: str = "Select skeleton line color or Gradient (from keypoints)", + sizing: ComboSizing | None = None, +) -> QComboBox: + combo = ShrinkCurrentWidePopupComboBox(sizing=sizing) if sizing is not None else QComboBox() + combo.setToolTip(tooltip) + populate_skeleton_color_combo( + combo, + colors_enum, + current_mode=current_mode, + current_color=current_color, + include_icons=include_icons, + ) + if isinstance(combo, ShrinkCurrentWidePopupComboBox): + combo.update_shrink_width() + return combo + + +def set_skeleton_combo_from_style(combo: QComboBox, *, mode: str, color: BGR | None) -> None: + """Select the best matching item.""" + # Gradient + if mode == "gradient_keypoints": + combo.findData({"mode": "gradient_keypoints"}) # may fail due to dict identity + # robust fallback: scan + for i in range(combo.count()): + d = combo.itemData(i) + if isinstance(d, dict) and d.get("mode") == "gradient_keypoints": + combo.setCurrentIndex(i) + return + return + + # Solid with color + if color is not None: + for i in range(combo.count()): + d = combo.itemData(i) + if isinstance(d, dict) and d.get("mode") == "solid" and tuple(d.get("color")) == tuple(color): + combo.setCurrentIndex(i) + return + + # Default: first solid entry + for i in range(combo.count()): + d = combo.itemData(i) + if isinstance(d, dict) and d.get("mode") == "solid": + combo.setCurrentIndex(i) + return + + +def populate_skeleton_color_combo( + combo: QComboBox, + colors_enum: Iterable[TEnum], + *, + current_mode: str = "solid", + current_color: BGR | None = None, + include_icons: bool = True, + gradient_label: str = "Gradient (from keypoints)", +) -> None: + """ + Populate combo with: + - Gradient mode + - Solid colors (from enum values BGR) + ItemData is a dict with keys: + - mode: 'solid' or 'gradient_keypoints' + - color: optional BGR for solid + """ + combo.blockSignals(True) + combo.clear() + + # 1) Gradient option + if include_icons: + combo.addItem(make_gradient_swatch_icon(), gradient_label, {"mode": "gradient_keypoints"}) + else: + combo.addItem(gradient_label, {"mode": "gradient_keypoints"}) + + # 2) Solid colors + for enum_item in colors_enum: + bgr: BGR = enum_item.value + name = getattr(enum_item, "name", str(enum_item)).title() + data = {"mode": "solid", "color": bgr} + if include_icons: + combo.addItem(make_bgr_swatch_icon(bgr), name, data) + else: + combo.addItem(name, data) + + # Select current + set_skeleton_combo_from_style(combo, mode=current_mode, color=current_color) + + combo.blockSignals(False) diff --git a/dlclivegui/temp/yolo/__init__.py b/dlclivegui/temp/yolo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py index 0eac657..05c3408 100644 --- a/dlclivegui/utils/display.py +++ b/dlclivegui/utils/display.py @@ -8,7 +8,7 @@ import numpy as np -class BBoxColors(enum.Enum): +class PrimaryColors(enum.Enum): RED = (0, 0, 255) GREEN = (0, 255, 0) BLUE = (255, 0, 0) @@ -20,7 +20,26 @@ class BBoxColors(enum.Enum): @staticmethod def get_all_display_names() -> list[str]: - return [color.name.capitalize() for color in BBoxColors] + return [c.name.capitalize() for c in PrimaryColors] + + +BBoxColors = PrimaryColors + + +class SkeletonColors(enum.Enum): + GRADIENT = "gradient" # special mode + RED = PrimaryColors.RED.value + GREEN = PrimaryColors.GREEN.value + BLUE = PrimaryColors.BLUE.value + YELLOW = PrimaryColors.YELLOW.value + CYAN = PrimaryColors.CYAN.value + MAGENTA = PrimaryColors.MAGENTA.value + WHITE = PrimaryColors.WHITE.value + BLACK = PrimaryColors.BLACK.value + + @staticmethod + def get_all_display_names() -> list[str]: + return ["Gradient"] + [c.name.capitalize() for c in SkeletonColors if c != SkeletonColors.GRADIENT] def color_to_rgb(color_name: str) -> tuple[int, int, int]: @@ -31,6 +50,21 @@ def color_to_rgb(color_name: str) -> tuple[int, int, int]: raise ValueError(f"Unknown color name: {color_name}") from None +def keypoint_colors_bgr(colormap: str, num_keypoints: int) -> list[tuple[int, int, int]]: + """ + Return the exact BGR colors used by draw_keypoints() for a given Matplotlib colormap + and number of keypoints. + """ + cmap = plt.get_cmap(colormap) + colors: list[tuple[int, int, int]] = [] + for idx in range(num_keypoints): + t = idx / max(num_keypoints - 1, 1) + rgba = cmap(t) + bgr = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) + colors.append(bgr) + return colors + + def compute_tiling_geometry( frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800), diff --git a/dlclivegui/utils/skeleton.py b/dlclivegui/utils/skeleton.py new file mode 100644 index 0000000..3904572 --- /dev/null +++ b/dlclivegui/utils/skeleton.py @@ -0,0 +1,384 @@ +# dlclivegui/utils/skeleton.py +from __future__ import annotations + +import json +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path + +import cv2 +import numpy as np +import yaml +from pydantic import BaseModel, Field, ValidationError, field_validator + + +# ############### # +# Status & code # +# ############### # +class SkeletonRenderCode(Enum): + OK = auto() + POSE_SHAPE_INVALID = auto() + KEYPOINT_COUNT_MISMATCH = auto() + + +@dataclass(frozen=True) +class SkeletonRenderStatus: + code: SkeletonRenderCode + message: str = "" + + @property + def rendered(self) -> bool: + return self.code == SkeletonRenderCode.OK + + @property + def should_disable(self) -> bool: + # GUI can switch off skeleton drawing if True + return self.code in { + SkeletonRenderCode.POSE_SHAPE_INVALID, + SkeletonRenderCode.KEYPOINT_COUNT_MISMATCH, + } + + +# ############ # +# Exceptions # +# ############ # + + +class SkeletonError(ValueError): + """Raised when a skeleton definition is invalid.""" + + +class SkeletonLoadError(Exception): + """High-level skeleton loading error (safe for GUI display).""" + + +class SkeletonValidationError(SkeletonLoadError): + """Schema or semantic validation error.""" + + +# ################## # +# Skeleton display # +# ################## # + +BGR = tuple[int, int, int] # (B, G, R) color format + + +class SkeletonColorMode(str, Enum): + SOLID = "solid" + GRADIENT_KEYPOINTS = "gradient_keypoints" # use endpoint keypoint colors + + +@dataclass +class SkeletonStyle: + mode: SkeletonColorMode = SkeletonColorMode.SOLID + color: BGR = (0, 255, 255) # default if SOLID + thickness: int = 2 # base thickness in pixels + gradient_steps: int = 16 # segments per edge when gradient + scale_with_zoom: bool = True # scale thickness with (sx, sy) + + def effective_thickness(self, sx: float, sy: float) -> int: + if not self.scale_with_zoom: + return max(1, int(self.thickness)) + return max(1, int(round(self.thickness * min(sx, sy)))) + + +class SkeletonStyleModel(BaseModel): + mode: SkeletonColorMode = SkeletonColorMode.SOLID + color: BGR = (0, 255, 255) # default if SOLID + thickness: int = Field(2, ge=1, description="Base thickness in pixels") + gradient_steps: int = Field(16, ge=2, description="Segments per edge when gradient") + scale_with_zoom: bool = True + + @field_validator("thickness") + @classmethod + def _thickness_positive(cls, v): + if v < 1: + raise ValueError("Thickness must be at least 1 pixel") + return v + + @field_validator("gradient_steps") + @classmethod + def _steps_positive(cls, v): + if v < 2: + raise ValueError("gradient_steps must be >= 2") + return v + + +# ############# # +# Skeleton IO # +# ############# # +class SkeletonModel(BaseModel): + """Validated skeleton definition (IO + schema).""" + + name: str | None = None + + keypoints: list[str] = Field(..., min_length=1, description="Ordered list of keypoint names") + + edges: list[tuple[int, int]] = Field( + default_factory=list, + description="List of (i, j) keypoint index pairs", + ) + + style: SkeletonStyleModel = Field(default_factory=SkeletonStyleModel) + default_color: BGR = (0, 255, 255) # used if style.color is None or in SOLID mode + edge_colors: dict[tuple[int, int], BGR] = Field(default_factory=dict) + + schema_version: int = 1 + + @field_validator("keypoints") + @classmethod + def validate_unique_keypoints(cls, v): + if len(set(v)) != len(v): + raise ValueError("Duplicate keypoint names detected") + return v + + @field_validator("edges") + @classmethod + def validate_edges(cls, edges, info): + keypoints = info.data.get("keypoints", []) + n = len(keypoints) + + for i, j in edges: + if i == j: + raise ValueError(f"Self-loop detected in edge ({i}, {j})") + if not (0 <= i < n and 0 <= j < n): + raise ValueError(f"Edge ({i}, {j}) out of range for {n} keypoints") + return edges + + +def _load_raw_skeleton_data(path: Path) -> dict: + if not path.exists(): + raise SkeletonLoadError(f"Skeleton file not found: {path}") + + if path.suffix in {".yaml", ".yml"}: + return yaml.safe_load(path.read_text()) + + if path.suffix == ".json": + return json.loads(path.read_text()) + + raise SkeletonLoadError(f"Unsupported file type: {path.suffix}") + + +def _format_pydantic_error(err: ValidationError) -> str: + lines = ["Invalid skeleton definition:"] + for e in err.errors(): + loc = " → ".join(map(str, e["loc"])) + msg = e["msg"] + lines.append(f"• {loc}: {msg}") + return "\n".join(lines) + + +def load_skeleton(path: Path) -> Skeleton: + try: + data = _load_raw_skeleton_data(path) + model = SkeletonModel.model_validate(data) + return Skeleton(model) + + except ValidationError as e: + raise SkeletonValidationError(_format_pydantic_error(e)) from None + + except Exception as e: + raise SkeletonLoadError(str(e)) from None + + +def save_skeleton(path: Path, model: SkeletonModel) -> None: + data = model.model_dump() + + if path.suffix in {".yaml", ".yml"}: + path.write_text(yaml.safe_dump(data, sort_keys=False)) + elif path.suffix == ".json": + path.write_text(json.dumps(data, indent=2)) + else: + raise SkeletonLoadError(f"Unsupported skeleton file type: {path.suffix}") + + +def load_dlc_skeleton(config_path: Path) -> Skeleton | None: + if not config_path.exists(): + raise SkeletonLoadError(f"DLC config not found: {config_path}") + + cfg = yaml.safe_load(config_path.read_text()) + + bodyparts = cfg.get("bodyparts") + if not bodyparts: + return None # No pose info + + edges = [] + + # Newer DLC format + if "skeleton" in cfg: + for a, b in cfg["skeleton"]: + edges.append((bodyparts.index(a), bodyparts.index(b))) + + # Older / alternative formats + elif "skeleton_edges" in cfg: + edges = [tuple(e) for e in cfg["skeleton_edges"]] + + if not edges: + return None + + model = SkeletonModel( + name=cfg.get("Task", "DeepLabCut"), + keypoints=bodyparts, + edges=edges, + ) + + return Skeleton(model) + + +class Skeleton: + """Runtime skeleton optimized for drawing.""" + + def __init__(self, model: SkeletonModel): + self.name = model.name + self.keypoints = model.keypoints + self.edges = model.edges + + self.style = SkeletonStyle( + mode=model.style.mode, + color=model.style.color, + thickness=model.style.thickness, + gradient_steps=model.style.gradient_steps, + scale_with_zoom=model.style.scale_with_zoom, + ) + self.default_color = model.default_color + self.edge_colors = model.edge_colors + + def check_pose_compat(self, pose: np.ndarray) -> SkeletonRenderStatus: + pose = np.asarray(pose) + + if pose.ndim != 2 or pose.shape[1] not in (2, 3): + return SkeletonRenderStatus( + SkeletonRenderCode.POSE_SHAPE_INVALID, + f"Pose must be (N,2) or (N,3); got shape={pose.shape}", + ) + + expected = len(self.keypoints) + got = pose.shape[0] + if got != expected: + return SkeletonRenderStatus( + SkeletonRenderCode.KEYPOINT_COUNT_MISMATCH, + f"Skeleton expects {expected} keypoints, but pose has {got}.", + ) + + return SkeletonRenderStatus(SkeletonRenderCode.OK, "") + + def _draw_gradient_edge( + self, + img: np.ndarray, + p1: tuple[int, int], + p2: tuple[int, int], + c1: BGR, + c2: BGR, + thickness: int, + steps: int, + ): + x1, y1 = p1 + x2, y2 = p2 + + for s in range(steps): + a0 = s / steps + a1 = (s + 1) / steps + xs0 = int(x1 + (x2 - x1) * a0) + ys0 = int(y1 + (y2 - y1) * a0) + xs1 = int(x1 + (x2 - x1) * a1) + ys1 = int(y1 + (y2 - y1) * a1) + + t = (s + 0.5) / steps + b = int(c1[0] + (c2[0] - c1[0]) * t) + g = int(c1[1] + (c2[1] - c1[1]) * t) + r = int(c1[2] + (c2[2] - c1[2]) * t) + + cv2.line(img, (xs0, ys0), (xs1, ys1), (b, g, r), thickness, lineType=cv2.LINE_AA) + + def draw( + self, + overlay: np.ndarray, + pose: np.ndarray, + p_cutoff: float, + offset: tuple[int, int], + scale: tuple[float, float], + *, + style: SkeletonStyle | None = None, + color_override: BGR | None = None, + keypoint_colors: list[BGR] | None = None, + ) -> SkeletonRenderStatus: + status = self.check_pose_compat(pose) + if not status.rendered: + return status + + st = style or self.style + ox, oy = offset + sx, sy = scale + th = st.effective_thickness(sx, sy) + + # if gradient mode, require keypoint_colors aligned with keypoint order + if st.mode == SkeletonColorMode.GRADIENT_KEYPOINTS: + if keypoint_colors is None or len(keypoint_colors) != len(self.keypoints): + return SkeletonRenderStatus( + SkeletonRenderCode.KEYPOINT_COUNT_MISMATCH, + f"Gradient mode requires keypoint_colors of length {len(self.keypoints)}.", + ) + + for i, j in self.edges: + xi, yi = pose[i][:2] + xj, yj = pose[j][:2] + ci = pose[i][2] if pose.shape[1] > 2 else 1.0 + cj = pose[j][2] if pose.shape[1] > 2 else 1.0 + if np.isnan(xi) or np.isnan(yi) or ci < p_cutoff or np.isnan(xj) or np.isnan(yj) or cj < p_cutoff: + continue + + p1 = (int(xi * sx + ox), int(yi * sy + oy)) + p2 = (int(xj * sx + ox), int(yj * sy + oy)) + + if st.mode == SkeletonColorMode.GRADIENT_KEYPOINTS: + c1 = keypoint_colors[i] + c2 = keypoint_colors[j] + self._draw_gradient_edge(overlay, p1, p2, c1, c2, th, st.gradient_steps) + else: + # SOLID: priority edge_colors > override > style.color > default_color + color = self.edge_colors.get((i, j), color_override or st.color or self.default_color) + cv2.line(overlay, p1, p2, color, th, lineType=cv2.LINE_AA) + + return SkeletonRenderStatus(SkeletonRenderCode.OK, "") + + def draw_many( + self, + overlay: np.ndarray, + poses: np.ndarray, + p_cutoff: float, + offset: tuple[int, int], + scale: tuple[float, float], + *, + style: SkeletonStyle | None = None, + color_override: BGR | None = None, + keypoint_colors: list[BGR] | None = None, + ) -> SkeletonRenderStatus: + poses = np.asarray(poses) + if poses.ndim != 3: + return SkeletonRenderStatus( + SkeletonRenderCode.POSE_SHAPE_INVALID, + f"Multi-pose must be (A,N,2/3); got shape={poses.shape}", + ) + + expected = len(self.keypoints) + if poses.shape[1] != expected: + return SkeletonRenderStatus( + SkeletonRenderCode.KEYPOINT_COUNT_MISMATCH, + f"Skeleton expects {expected} keypoints, but poses have N={poses.shape[1]}.", + ) + + for pose in poses: + st = self.draw( + overlay, + pose, + p_cutoff, + offset, + scale, + style=style, + color_override=color_override, + keypoint_colors=keypoint_colors, + ) + if not st.rendered: + return st + + return SkeletonRenderStatus(SkeletonRenderCode.OK, "") From 4bcacb7693dc72da6ab747c90286fe17893c2d88 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 25 Feb 2026 11:35:29 +0100 Subject: [PATCH 15/16] Add configurable skeleton styling and UI integration Introduce a first-class SkeletonStyle and SkeletonColorMode (and BGR alias) in config, and wire them through the GUI and skeleton utilities. Updates include: - dlclivegui/config.py: add BGR type, SkeletonColorMode enum, Pydantic SkeletonStyle model, expose skeleton fields on VisualizationSettings and with_overlays on RecordingSettings, and helper color accessors. - dlclivegui/gui/main_window.py: add _apply_viz_settings_to_ui/_apply_viz_settings_to_skeleton, create a unified _draw_skeleton_on_frame renderer, read/write skeleton style from UI, refactor and simplify skeleton enable/disable and model heuristics, and wire recording.with_overlays. - dlclivegui/gui/misc/color_dropdowns.py: reuse BGR from config. - dlclivegui/utils/skeleton.py: remove duplicate style/type definitions, import style and enums from config, and add module docstring. These changes centralize skeleton styling, enable UI control and persistence of style, and clean up duplicate definitions across modules. --- dlclivegui/config.py | 30 +++++ dlclivegui/gui/main_window.py | 161 ++++++++++++++++++------- dlclivegui/gui/misc/color_dropdowns.py | 3 +- dlclivegui/utils/skeleton.py | 25 +--- 4 files changed, 151 insertions(+), 68 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6d9e1de..8d56b12 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -12,6 +12,25 @@ TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] ModelType = Literal["pytorch", "tensorflow"] +BGR = tuple[int, int, int] # (B, G, R) color format + + +class SkeletonColorMode(str, Enum): + SOLID = "solid" + GRADIENT_KEYPOINTS = "gradient_keypoints" # use endpoint keypoint colors + + +class SkeletonStyle(BaseModel): + mode: SkeletonColorMode = SkeletonColorMode.SOLID + color: BGR = (0, 255, 255) # default if SOLID + thickness: int = 2 # base thickness in pixels + gradient_steps: int = 16 # segments per edge when gradient + scale_with_zoom: bool = True # scale thickness with (sx, sy) + + def effective_thickness(self, sx: float, sy: float) -> int: + if not self.scale_with_zoom: + return max(1, int(self.thickness)) + return max(1, int(round(self.thickness * min(sx, sy)))) class CameraSettings(BaseModel): @@ -301,12 +320,22 @@ class VisualizationSettings(BaseModel): colormap: str = "hot" bbox_color: tuple[int, int, int] = (0, 0, 255) + show_pose: bool = True + show_skeleton: bool = False + skeleton_style: SkeletonStyle = Field(default_factory=SkeletonStyle) + def get_bbox_color_bgr(self) -> tuple[int, int, int]: """Get bounding box color in BGR format""" if isinstance(self.bbox_color, (list, tuple)) and len(self.bbox_color) == 3: return tuple(int(c) for c in self.bbox_color) return (0, 0, 255) # default red + def get_skeleton_color_bgr(self) -> tuple[int, int, int]: + c = self.skeleton_style.color + if isinstance(c, (list, tuple)) and len(c) == 3: + return tuple(int(v) for v in c) + return (0, 255, 255) # default yellow + class RecordingSettings(BaseModel): enabled: bool = False @@ -315,6 +344,7 @@ class RecordingSettings(BaseModel): container: Literal["mp4", "avi", "mov"] = "mp4" codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + with_overlays: bool = False def output_path(self) -> Path: """Return the absolute output path for recordings.""" diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 798870d..87b810c 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -63,6 +63,7 @@ VisualizationSettings, ) +from ..config import SkeletonColorMode, SkeletonStyle from ..processors.processor_utils import ( default_processors_dir, instantiate_from_scan, @@ -891,6 +892,45 @@ def _connect_signals(self) -> None: # ------------------------------------------------------------------ # Config + # ------------------------------------------------------------------ + def _apply_viz_settings_to_ui(self, viz: VisualizationSettings) -> None: + """Set UI state from VisualizationSettings (does not require skeleton to exist).""" + # Pose toggle + self.show_predictions_checkbox.blockSignals(True) + self.show_predictions_checkbox.setChecked(bool(viz.show_pose)) + self.show_predictions_checkbox.blockSignals(False) + + # Skeleton toggle (may remain disabled until skeleton exists) + self.show_skeleton_checkbox.blockSignals(True) + self.show_skeleton_checkbox.setChecked(bool(viz.show_skeleton)) + self.show_skeleton_checkbox.blockSignals(False) + + # Skeleton style controls (combo/spin) - set values even if disabled + if hasattr(self, "skeleton_color_combo"): + mode = viz.skeleton_style.mode.value # "solid" or "gradient_keypoints" + color = tuple(viz.skeleton_style.color) + color_ui.set_skeleton_combo_from_style(self.skeleton_color_combo, mode=mode, color=color) + + if hasattr(self, "skeleton_thickness_spin"): + self.skeleton_thickness_spin.blockSignals(True) + self.skeleton_thickness_spin.setValue(int(viz.skeleton_style.thickness)) + self.skeleton_thickness_spin.blockSignals(False) + + def _apply_viz_settings_to_skeleton(self, viz: VisualizationSettings) -> None: + """Apply VisualizationSettings onto the active runtime Skeleton, if present.""" + if self._skeleton is None: + return + + # Copy style fields + self._skeleton.style.mode = skel.SkeletonColorMode(viz.skeleton_style.mode.value) + self._skeleton.style.color = tuple(viz.skeleton_style.color) + self._skeleton.style.thickness = int(viz.skeleton_style.thickness) + self._skeleton.style.gradient_steps = int(viz.skeleton_style.gradient_steps) + self._skeleton.style.scale_with_zoom = bool(viz.skeleton_style.scale_with_zoom) + + # Enable/disable UI controls now that skeleton exists + self._sync_skeleton_controls_from_model() + def _apply_config(self, config: ApplicationSettings) -> None: # Update active cameras label self._update_active_cameras_label() @@ -907,6 +947,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) self.container_combo.setCurrentText(recording.container) + self.record_with_overlays_checkbox.setChecked(recording.with_overlays) codec_index = self.codec_combo.findText(recording.codec) if codec_index >= 0: self.codec_combo.setCurrentIndex(codec_index) @@ -937,6 +978,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: viz = config.visualization self._p_cutoff = viz.p_cutoff self._colormap = viz.colormap + self._apply_viz_settings_to_ui(viz) if hasattr(self, "cmap_combo"): color_ui.set_cmap_combo_from_name(self.cmap_combo, self._colormap, fallback="viridis") self._bbox_color = viz.get_bbox_color_bgr() @@ -945,6 +987,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: ## Skeleton if resolved_model_path.strip(): self._configure_skeleton_for_model(resolved_model_path) + self._apply_viz_settings_to_skeleton(viz) # Update DLC camera list self._refresh_dlc_camera_list() @@ -1007,6 +1050,7 @@ def _recording_settings_from_ui(self) -> RecordingSettings: container=self.container_combo.currentText().strip() or "mp4", codec=self.codec_combo.currentText().strip() or "libx264", crf=int(self.crf_spin.value()), + with_overlays=self.record_with_overlays_checkbox.isChecked(), ) def _bbox_settings_from_ui(self) -> BoundingBoxSettings: @@ -1019,10 +1063,29 @@ def _bbox_settings_from_ui(self) -> BoundingBoxSettings: ) def _visualization_settings_from_ui(self) -> VisualizationSettings: + # Read skeleton mode+color from combo + mode_str, color = color_ui.get_skeleton_style_from_combo( + self.skeleton_color_combo, + fallback_mode="solid", + fallback_color=(0, 255, 255), + ) + + # Build SkeletonStyle (pydantic) + style = SkeletonStyle( + mode=SkeletonColorMode(mode_str), # or SkeletonColorMode.GRADIENT_KEYPOINTS if mode_str matches + color=tuple(color) if color else (0, 255, 255), + thickness=int(self.skeleton_thickness_spin.value()), + gradient_steps=getattr(self._skeleton.style, "gradient_steps", 16) if self._skeleton else 16, + scale_with_zoom=getattr(self._skeleton.style, "scale_with_zoom", True) if self._skeleton else True, + ) + return VisualizationSettings( p_cutoff=self._p_cutoff, colormap=self._colormap, bbox_color=self._bbox_color, + show_pose=self.show_predictions_checkbox.isChecked(), + show_skeleton=self.show_skeleton_checkbox.isChecked(), + skeleton_style=style, ) # ------------------------------------------------------------------ @@ -1286,28 +1349,16 @@ def _on_show_skeleton_changed(self, _state: int) -> None: self._display_frame(self._current_frame, force=True) def _on_skeleton_style_changed(self, _value: int = 0) -> None: - """Apply UI skeleton styling to the current Skeleton instance.""" if self._skeleton is None: return - mode, color = color_ui.get_skeleton_style_from_combo( - self.skeleton_color_combo, - fallback_mode="solid", - fallback_color=self._skeleton.style.color, - ) - - # Update style mode - if mode == "gradient_keypoints": - self._skeleton.style.mode = skel.SkeletonColorMode.GRADIENT_KEYPOINTS - else: - self._skeleton.style.mode = skel.SkeletonColorMode.SOLID - if color is not None: - self._skeleton.style.color = tuple(color) + mode_str, color = color_ui.get_skeleton_style_from_combo(self.skeleton_color_combo) + self._skeleton.style.mode = skel.SkeletonColorMode(mode_str) + if self._skeleton.style.mode == skel.SkeletonColorMode.SOLID and color is not None: + self._skeleton.style.color = tuple(color) - # Thickness self._skeleton.style.thickness = int(self.skeleton_thickness_spin.value()) - # Redraw if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1469,17 +1520,12 @@ def _render_overlays_for_recording(self, cam_id, frame): offset=offset, scale=scale, ) - - if self._skeleton and hasattr(self, "show_skeleton_checkbox") and self.show_skeleton_checkbox.isChecked(): - pose_arr = np.asarray(self._last_pose.pose) - if pose_arr.ndim == 3: - st = self._skeleton.draw_many(output, pose_arr, self._p_cutoff, offset, scale) - else: - self._skeleton.draw(output, self._last_pose.pose, self._p_cutoff, offset, scale) - if st.should_disable: - self.show_skeleton_checkbox.blockSignals(True) - self.show_skeleton_checkbox.setChecked(False) - self.show_skeleton_checkbox.blockSignals(False) + self._draw_skeleton_on_frame( + output, + self._last_pose.pose, + offset=offset, + scale=scale, + ) if self._bbox_enabled: output = draw_bbox( @@ -1795,7 +1841,7 @@ def _configure_skeleton_for_model(self, model_path: str) -> None: root = p if p.is_dir() else p.parent cfg = root / "config.yaml" - if cfg.exists(): + if cfg.exists() and self._skeleton is None: try: sk = skel.load_dlc_skeleton(cfg) except Exception as e: @@ -1808,7 +1854,15 @@ def _configure_skeleton_for_model(self, model_path: str) -> None: if hasattr(self, "show_skeleton_checkbox"): self.show_skeleton_checkbox.setEnabled(True) self.statusBar().showMessage("Skeleton available: DLC config.yaml", 3000) - return + + if self._skeleton is not None: + try: + viz = self._config.visualization + self._apply_viz_settings_to_skeleton(viz) + except Exception as e: + logger.warning(f"Failed to apply visualization settings to skeleton: {e}") + pass + return # None found self.statusBar().showMessage("No skeleton definition available for this model.", 3000) @@ -2126,30 +2180,39 @@ def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) self._show_error(message) - def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None: + def _draw_skeleton_on_frame( + self, + overlay: np.ndarray, + pose: np.ndarray, + *, + offset: tuple[int, int], + scale: tuple[float, float], + allow_auto_disable: bool = True, + ) -> skel.SkeletonRenderStatus | None: + """Draw skeleton on overlay with correct style. Optionally auto-disables UI on mismatch.""" if self._skeleton is None: - return + return None if not self.show_skeleton_checkbox.isChecked(): - return + return None if self._skeleton_auto_disabled: - return + return None pose_arr = np.asarray(pose) - # Compute keypoint colors only if gradient mode is active + # Provide keypoint_colors iff gradient mode is active kp_colors = None - try: - if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS: - n_kpts = pose_arr.shape[1] if pose_arr.ndim == 3 else pose_arr.shape[0] - kp_colors = keypoint_colors_bgr(self._colormap, int(n_kpts)) + if self._skeleton.style.mode == skel.SkeletonColorMode.GRADIENT_KEYPOINTS: + n_kpts = pose_arr.shape[1] if pose_arr.ndim == 3 else pose_arr.shape[0] + kp_colors = keypoint_colors_bgr(self._colormap, int(n_kpts)) + try: if pose_arr.ndim == 3: status = self._skeleton.draw_many( overlay, pose_arr, p_cutoff=self._p_cutoff, - offset=self._dlc_tile_offset, - scale=self._dlc_tile_scale, + offset=offset, + scale=scale, keypoint_colors=kp_colors, ) else: @@ -2157,20 +2220,19 @@ def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None: overlay, pose_arr, p_cutoff=self._p_cutoff, - offset=self._dlc_tile_offset, - scale=self._dlc_tile_scale, + offset=offset, + scale=scale, keypoint_colors=kp_colors, ) - except Exception as e: status = skel.SkeletonRenderStatus( code=skel.SkeletonRenderCode.POSE_SHAPE_INVALID, message=f"Skeleton rendering error: {e}", ) - if status.should_disable: + if allow_auto_disable and status.should_disable: self._skeleton_auto_disabled = True - msg = status.message or "Skeleton disabled due to keypoint mismatch." + msg = status.message or "Skeleton disabled due to mismatch." if msg != self._last_skeleton_disable_msg: self._last_skeleton_disable_msg = msg self.statusBar().showMessage(f"Skeleton disabled: {msg}", 6000) @@ -2179,6 +2241,8 @@ def _try_draw_skeleton(self, overlay: np.ndarray, pose: np.ndarray) -> None: self.show_skeleton_checkbox.setChecked(False) self.show_skeleton_checkbox.blockSignals(False) + return status + def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame @@ -2192,7 +2256,12 @@ def _update_video_display(self, frame: np.ndarray) -> None: scale=self._dlc_tile_scale, ) - self._try_draw_skeleton(display_frame, self._last_pose.pose) + self._draw_skeleton_on_frame( + display_frame, + self._last_pose.pose, + offset=self._dlc_tile_offset, + scale=self._dlc_tile_scale, + ) if self._bbox_enabled: display_frame = draw_bbox( diff --git a/dlclivegui/gui/misc/color_dropdowns.py b/dlclivegui/gui/misc/color_dropdowns.py index c434ae2..6f1351a 100644 --- a/dlclivegui/gui/misc/color_dropdowns.py +++ b/dlclivegui/gui/misc/color_dropdowns.py @@ -24,7 +24,8 @@ QStyleOptionComboBox, ) -BGR = tuple[int, int, int] +from dlclivegui.config import BGR + TEnum = TypeVar("TEnum") diff --git a/dlclivegui/utils/skeleton.py b/dlclivegui/utils/skeleton.py index 3904572..1bacbf2 100644 --- a/dlclivegui/utils/skeleton.py +++ b/dlclivegui/utils/skeleton.py @@ -1,3 +1,5 @@ +"""Skeleton definition, validation, and drawing utilities.""" + # dlclivegui/utils/skeleton.py from __future__ import annotations @@ -11,6 +13,8 @@ import yaml from pydantic import BaseModel, Field, ValidationError, field_validator +from dlclivegui.config import BGR, SkeletonColorMode, SkeletonStyle + # ############### # # Status & code # @@ -60,27 +64,6 @@ class SkeletonValidationError(SkeletonLoadError): # Skeleton display # # ################## # -BGR = tuple[int, int, int] # (B, G, R) color format - - -class SkeletonColorMode(str, Enum): - SOLID = "solid" - GRADIENT_KEYPOINTS = "gradient_keypoints" # use endpoint keypoint colors - - -@dataclass -class SkeletonStyle: - mode: SkeletonColorMode = SkeletonColorMode.SOLID - color: BGR = (0, 255, 255) # default if SOLID - thickness: int = 2 # base thickness in pixels - gradient_steps: int = 16 # segments per edge when gradient - scale_with_zoom: bool = True # scale thickness with (sx, sy) - - def effective_thickness(self, sx: float, sy: float) -> int: - if not self.scale_with_zoom: - return max(1, int(self.thickness)) - return max(1, int(round(self.thickness * min(sx, sy)))) - class SkeletonStyleModel(BaseModel): mode: SkeletonColorMode = SkeletonColorMode.SOLID From 028cff92beb1e6087fcb72a16d5cdb80a8c517b9 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 10 Mar 2026 14:31:47 +0100 Subject: [PATCH 16/16] Sync skeleton controls and improve JSON save Call _sync_skeleton_controls_from_model() to centralize enabling/disabling of skeleton UI controls (ensures controls are disabled when self._skeleton is None) instead of manually toggling the checkbox. Change save_skeleton to use model.model_dump_json() for .json output so Pydantic's JSON serialization is used (ensures Enums and other non-primitive types are serialized correctly); keep YAML branch using model_dump and safe_dump. Raises same error for unsupported suffixes. --- dlclivegui/gui/main_window.py | 4 ++-- dlclivegui/utils/skeleton.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 87b810c..8571dd5 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -1834,8 +1834,8 @@ def _configure_skeleton_for_model(self, model_path: str) -> None: # Default: disable until we find a compatible skeleton if hasattr(self, "show_skeleton_checkbox"): - self.show_skeleton_checkbox.setEnabled(False) - # keep checked state but it won't be used unless enabled + # This will disable all skeleton controls when self._skeleton is None + self._sync_skeleton_controls_from_model() p = Path(model_path).expanduser() diff --git a/dlclivegui/utils/skeleton.py b/dlclivegui/utils/skeleton.py index 1bacbf2..12d2df6 100644 --- a/dlclivegui/utils/skeleton.py +++ b/dlclivegui/utils/skeleton.py @@ -165,12 +165,13 @@ def load_skeleton(path: Path) -> Skeleton: def save_skeleton(path: Path, model: SkeletonModel) -> None: - data = model.model_dump() - if path.suffix in {".yaml", ".yml"}: + data = model.model_dump() path.write_text(yaml.safe_dump(data, sort_keys=False)) elif path.suffix == ".json": - path.write_text(json.dumps(data, indent=2)) + # Use Pydantic's JSON serialization to ensure Enums and other types + # are converted to JSON-friendly values. + path.write_text(model.model_dump_json(indent=2)) else: raise SkeletonLoadError(f"Unsupported skeleton file type: {path.suffix}")