Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2784bd5
Remove module-level debug logging overrides
C-Achard Feb 20, 2026
9fd192e
Reduce log verbosity and remove auto-load message
C-Achard Feb 20, 2026
0daebba
Infer model backend from selected model path
C-Achard Feb 20, 2026
cfd67c7
Add temporary Engine enum and use in dlc_processor
C-Achard Feb 20, 2026
b6fd336
Use Engine enum and improve model detection
C-Achard Feb 20, 2026
58f6110
Use Engine enum and validate model path
C-Achard Feb 23, 2026
f495649
Update GUI tests and adjust tox config
C-Achard Feb 23, 2026
f2c5fb6
Remove GUI and multi-camera exports
C-Achard Feb 23, 2026
d8da888
Update tox.ini
C-Achard Feb 23, 2026
94a4d04
Add Qt/OpenGL deps to CI and restrict Codecov
C-Achard Feb 23, 2026
eb255db
Refactor camera scan state and loaders
C-Achard Feb 23, 2026
48b274c
CI, packaging: coverage PRs, export main, tox deps
C-Achard Feb 23, 2026
31e09d8
Normalize model_type and fix model paths
C-Achard Feb 23, 2026
e517aed
Enhance ModelPathStore path normalization tests
C-Achard Feb 23, 2026
c7b11c9
Add concurrency to testing CI workflow
C-Achard Feb 23, 2026
4865edb
Add Engine helpers for model path detection
C-Achard Feb 23, 2026
1f73c89
Ignore stale scan worker signals
C-Achard Feb 23, 2026
0b47bc4
Update testing-ci.yml
C-Achard Feb 23, 2026
1cf1945
Update engine.py
C-Achard Feb 23, 2026
6952b68
Update __init__.py
C-Achard Feb 23, 2026
559c775
Import Engine from dlclivegui.temp in tests
C-Achard Feb 23, 2026
025a18e
Pass file path for non-.pb model checks
C-Achard Feb 24, 2026
735b9a0
Add ModelType Literal and apply to model_type
C-Achard Feb 24, 2026
af06d09
Move package version to pyproject.toml
C-Achard Feb 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/testing-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ jobs:
python -m pip install -U pip wheel
python -m pip install -U tox tox-gh-actions

- name: Install Qt/OpenGL runtime deps (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
libegl1 \
libgl1 \
libopengl0 \
libxkbcommon-x11-0 \
libxcb-cursor0

- name: Run tests (exclude hardware) with coverage via tox
run: |
tox -q
Expand All @@ -54,6 +65,7 @@ jobs:
echo '```' >> "$GITHUB_STEP_SUMMARY"

- name: Upload coverage to Codecov
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
Expand Down
8 changes: 0 additions & 8 deletions dlclivegui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,13 @@
MultiCameraSettings,
RecordingSettings,
)
from .gui.camera_config.camera_config_dialog import CameraConfigDialog
from .gui.main_window import DLCLiveMainWindow
from .main import main
from .services.multi_camera_controller import MultiCameraController, MultiFrameData

__all__ = [
"ApplicationSettings",
"CameraSettings",
"DLCProcessorSettings",
"MultiCameraSettings",
"RecordingSettings",
"DLCLiveMainWindow",
"MultiCameraController",
"MultiFrameData",
"CameraConfigDialog",
"main",
]
__version__ = "2.0.0rc0" # PLACEHOLDER
7 changes: 3 additions & 4 deletions dlclivegui/cameras/backends/opencv_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
)

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) # FIXME @C-Achard remove before release

if TYPE_CHECKING:
from dlclivegui.config import CameraSettings
Expand Down Expand Up @@ -169,7 +168,7 @@ def open(self) -> None:
ns["device_pid"] = int(chosen.pid)
if chosen.name:
ns["device_name"] = chosen.name
logger.info("Persisted OpenCV device_id=%s", chosen.stable_id)
logger.debug("Persisted OpenCV device_id=%s", chosen.stable_id)

self._capture, spec = open_with_fallbacks(index, backend_flag)

Expand Down Expand Up @@ -399,7 +398,7 @@ def _configure_capture(self) -> None:
self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)

# For clarity in logs
logger.info("Resolution requested=Auto, actual=%sx%s", self._actual_width, self._actual_height)
logger.debug("Resolution requested=Auto, actual=%sx%s", self._actual_width, self._actual_height)

elif not self._fast_start:
# Verified, robust path (tries candidates + verifies)
Expand Down Expand Up @@ -432,7 +431,7 @@ def _configure_capture(self) -> None:
if (self._actual_width or 0) > 0 and (self._actual_height or 0) > 0:
actual_res = (int(self._actual_width), int(self._actual_height))

logger.info(
logger.debug(
"Resolution requested=%s, actual=%s",
f"{req_w}x{req_h}" if (req_w > 0 and req_h > 0) else "Auto",
f"{actual_res[0]}x{actual_res[1]}" if actual_res else "unknown",
Expand Down
4 changes: 3 additions & 1 deletion dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from pydantic import BaseModel, Field, field_validator, model_validator

from dlclivegui.temp import Engine

Rotation = Literal[0, 90, 180, 270]
TileLayout = Literal["auto", "2x2", "1x4", "4x1"]
Precision = Literal["FP32", "FP16"]
Expand Down Expand Up @@ -239,7 +241,7 @@ class DLCProcessorSettings(BaseModel):
resize: float = Field(default=1.0, gt=0)
precision: Precision = "FP32"
additional_options: dict[str, Any] = Field(default_factory=dict)
model_type: Literal["pytorch"] = "pytorch"
model_type: Engine = Engine.PYTORCH
single_animal: bool = True

@field_validator("dynamic", mode="before")
Expand Down
205 changes: 124 additions & 81 deletions dlclivegui/gui/camera_config/camera_config_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@

from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key
from ...config import CameraSettings, MultiCameraSettings
from .loaders import CameraLoadWorker, CameraProbeWorker, DetectCamerasWorker
from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker
from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap
from .ui_blocks import setup_camera_config_dialog_ui

LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG) # TODO @C-Achard remove for release


class CameraConfigDialog(QDialog):
Expand Down Expand Up @@ -65,6 +64,7 @@ def __init__(

# Camera detection worker
self._scan_worker: DetectCamerasWorker | None = None
self._scan_state: CameraScanState = CameraScanState.IDLE

# UI elements for eventFilter (assigned in _setup_ui)
self._settings_scroll: QScrollArea | None = None
Expand Down Expand Up @@ -172,7 +172,8 @@ def _on_close_cleanup(self) -> None:
pass
# Keep this short to reduce UI freeze
sw.wait(300)
self._scan_worker = None
self._set_scan_state(CameraScanState.IDLE)
self._cleanup_scan_worker()

# Cancel probe worker
pw = getattr(self, "_probe_worker", None)
Expand Down Expand Up @@ -261,7 +262,7 @@ def _connect_signals(self) -> None:
self.cancel_btn.clicked.connect(self.reject)
self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True))
self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False))
self.scan_cancel_btn.clicked.connect(self._on_scan_cancel)
self.scan_cancel_btn.clicked.connect(self.request_scan_cancel)

def _mark_dirty(*_args):
self.apply_settings_btn.setEnabled(True)
Expand Down Expand Up @@ -313,29 +314,6 @@ def _update_button_states(self) -> None:
available_row = self.available_cameras_list.currentRow()
self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running)

def _sync_scan_ui(self) -> None:
"""
Sync *scan-related* UI controls based on scan state.

Conservative policy during scan:
- Allow editing/previewing already configured cameras (Active list)
- Disallow structural changes (add/remove/reorder) and available-list actions
"""
scanning = self._is_scan_running()

# Discovery controls
self.backend_combo.setEnabled(not scanning)
self.refresh_btn.setEnabled(not scanning)

# Available camera list + add flow is blocked during scan
self.available_cameras_list.setEnabled(not scanning)
self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0))

# Scan cancel button visibility is already managed in your scan start/finish,
# but keeping enabled state here makes it robust.
if hasattr(self, "scan_cancel_btn"):
self.scan_cancel_btn.setEnabled(scanning)

def _sync_preview_ui(self) -> None:
"""Update buttons/overlays based on preview state only."""
st = self._preview.state
Expand Down Expand Up @@ -479,93 +457,158 @@ def _on_backend_changed(self, _index: int) -> None:
self._refresh_available_cameras()

def _is_scan_running(self) -> bool:
return bool(self._scan_worker and self._scan_worker.isRunning())
return self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING)

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
if scanning:
self._show_scan_overlay(
message or ("Canceling discovery…" if state == CameraScanState.CANCELING else "Discovering cameras…")
)
else:
self._hide_scan_overlay()

# Progress + cancel controls
self.scan_progress.setVisible(scanning)
if scanning:
self.scan_progress.setRange(0, 0) # indeterminate
self.scan_cancel_btn.setVisible(scanning)
self.scan_cancel_btn.setEnabled(state == CameraScanState.RUNNING) # disabled while canceling

# 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()

def _cleanup_scan_worker(self) -> None:
# worker is truly finished now
w = self._scan_worker
self._scan_worker = None
if w is not None:
w.deleteLater()

def _finish_scan(self, reason: str) -> None:
"""Mark scan UX complete (idempotent) and emit scan_finished queued."""
if self._scan_state in (CameraScanState.DONE, CameraScanState.IDLE):
return

# Transition scan UX to DONE (UI controls restored)
self._set_scan_state(CameraScanState.DONE)

QTimer.singleShot(0, self.scan_finished.emit)

LOGGER.debug("[Scan] finished reason=%s", reason)

def _refresh_available_cameras(self) -> None:
"""Refresh the list of available cameras asynchronously."""
backend = self.backend_combo.currentData()
if not backend:
backend = self.backend_combo.currentText().split()[0]
backend = self.backend_combo.currentData() or self.backend_combo.currentText().split()[0]

# If already scanning, ignore new requests to avoid races
if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning():
if self._is_scan_running():
self._show_scan_overlay("Already discovering cameras…")
return

# Reset list UI and show progress
# Reset UI/list
self.available_cameras_list.clear()
self._detected_cameras = []
msg = f"Discovering {backend} cameras…"
self._show_scan_overlay(msg)
self.scan_progress.setRange(0, 0)
self.scan_progress.setVisible(True)
self.scan_cancel_btn.setVisible(True)
self.available_cameras_list.setEnabled(False)
self.add_camera_btn.setEnabled(False)
self.refresh_btn.setEnabled(False)
self.backend_combo.setEnabled(False)

self._sync_scan_ui()
self._update_button_states()

self._set_scan_state(CameraScanState.RUNNING, message=f"Discovering {backend} cameras…")

# Start worker
self._scan_worker = DetectCamerasWorker(backend, max_devices=10, parent=self)
self._scan_worker.progress.connect(self._on_scan_progress)
self._scan_worker.result.connect(self._on_scan_result)
self._scan_worker.error.connect(self._on_scan_error)
self._scan_worker.finished.connect(self._on_scan_finished)
w = DetectCamerasWorker(backend, max_devices=10, parent=self)
self._scan_worker = w

w.progress.connect(self._on_scan_progress)
w.result.connect(self._on_scan_result)
w.error.connect(self._on_scan_error)
w.canceled.connect(self._on_scan_canceled)

# Cleanup only
w.finished.connect(self._cleanup_scan_worker)

self.scan_started.emit(f"Scanning {backend} cameras…")
self._scan_worker.start()
w.start()

def _on_scan_progress(self, msg: str) -> None:
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
return
self._show_scan_overlay(msg or "Discovering cameras…")

def _on_scan_result(self, cams: list) -> None:
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
return

# Apply results to UI first (stability guarantee)
self._detected_cameras = cams or []
self.available_cameras_list.clear() # replace list contents
self.available_cameras_list.clear()

if not self._detected_cameras:
placeholder = QListWidgetItem("No cameras detected.")
placeholder.setFlags(Qt.ItemIsEnabled)
self.available_cameras_list.addItem(placeholder)
return

for cam in self._detected_cameras:
item = QListWidgetItem(f"{cam.label} (index {cam.index})")
item.setData(Qt.ItemDataRole.UserRole, cam)
self.available_cameras_list.addItem(item)
else:
for cam in self._detected_cameras:
item = QListWidgetItem(f"{cam.label} (index {cam.index})")
item.setData(Qt.ItemDataRole.UserRole, cam)
self.available_cameras_list.addItem(item)
self.available_cameras_list.setCurrentRow(0)

self.available_cameras_list.setCurrentRow(0)
# Now UI is stable: finish scan UX and emit scan_finished queued
self._finish_scan("result")

def _on_scan_error(self, msg: str) -> None:
if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING):
return

QMessageBox.warning(self, "Camera Scan", f"Failed to detect cameras:\n{msg}")

def _on_scan_finished(self) -> None:
self._hide_scan_overlay()
self.scan_progress.setVisible(False)
self._scan_worker = None
# Ensure UI is stable (list is stable even if empty) before finishing
if self.available_cameras_list.count() == 0:
placeholder = QListWidgetItem("Scan failed.")
placeholder.setFlags(Qt.ItemIsEnabled)
self.available_cameras_list.addItem(placeholder)

self.scan_cancel_btn.setVisible(False)
self.scan_cancel_btn.setEnabled(True)
self.available_cameras_list.setEnabled(True)
self.refresh_btn.setEnabled(True)
self.backend_combo.setEnabled(True)
self._finish_scan("error")

self._sync_scan_ui()
self._update_button_states()
self.scan_finished.emit()
def request_scan_cancel(self) -> None:
if not self._is_scan_running():
return

def _on_scan_cancel(self) -> None:
"""User requested to cancel discovery."""
if self._scan_worker and self._scan_worker.isRunning():
self._set_scan_state(CameraScanState.CANCELING, message="Canceling discovery…")

w = self._scan_worker
if w is not None:
try:
self._scan_worker.requestInterruption()
w.requestInterruption()
except Exception:
pass
# Keep the busy bar, update texts
self._show_scan_overlay("Canceling discovery…")
self.scan_progress.setVisible(True) # stay visible as indeterminate
self.scan_cancel_btn.setEnabled(False)

# Guarantee UI stability before scan_finished:
if self.available_cameras_list.count() == 0:
placeholder = QListWidgetItem("Scan canceled.")
placeholder.setFlags(Qt.ItemIsEnabled)
self.available_cameras_list.addItem(placeholder)

self._finish_scan("cancel")

def _on_scan_canceled(self) -> None:
self._set_scan_state(CameraScanState.CANCELING, message="Finalizing cancellation…")
# If cancel is requested without clicking cancel (e.g., dialog closing), ensure UI finishes
if self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING):
if self.available_cameras_list.count() == 0:
placeholder = QListWidgetItem("Scan canceled.")
placeholder.setFlags(Qt.ItemIsEnabled)
self.available_cameras_list.addItem(placeholder)
self._finish_scan("canceled")

def _on_available_camera_selected(self, row: int) -> None:
if self._scan_worker and self._scan_worker.isRunning():
Expand Down Expand Up @@ -1394,7 +1437,7 @@ def _execute_pending_restart(self, *, reason: str) -> None:
if not cam:
return

LOGGER.info("[Preview] executing restart reason=%s", reason)
LOGGER.debug("[Preview] executing restart reason=%s", reason)
self._begin_preview_load(cam, reason="restart")

def _cancel_loading(self) -> None:
Expand Down
Loading