From d0601ee276a3dbe234827659a3582383265775a5 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 14:20:24 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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: