From 41db059486ef2aaa202aa2f29765845d610a9ad3 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:08:21 +0100 Subject: [PATCH 01/16] dlc_processor: add lightweight validation of shape and dtype (+tests) --- dlclivegui/services/dlc_processor.py | 72 ++++++++++++++++++++++++++-- tests/services/test_pose_contract.py | 40 ++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 tests/services/test_pose_contract.py diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 42b5868..8d73ed5 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -15,7 +15,7 @@ import numpy as np from PySide6.QtCore import QObject, Signal -from dlclivegui.config import DLCProcessorSettings +from dlclivegui.config import DLCProcessorSettings, ModelType from dlclivegui.processors.processor_utils import instantiate_from_scan from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released @@ -37,6 +37,63 @@ class PoseResult: pose: np.ndarray | None timestamp: float + packet: "PosePacketV0 | None" = None + + +@dataclass(slots=True, frozen=True) +class PoseSource: + backend: str # e.g. "DLCLive" + model_type: ModelType | None = None + + +@dataclass(slots=True, frozen=True) +class PosePacketV0: + schema_version: int = 0 + keypoints: np.ndarray | None = None + keypoint_names: list[str] | None = None + individual_ids: list[str] | None = None + source: PoseSource = PoseSource(backend="DLCLive") + raw: Any | None = None + + +def validate_pose_array(pose: Any, *, source_backend: str = "DLCLive") -> np.ndarray: + """ + Validate pose output shape and dtype. + + Accepted runner output shapes: + - (K, 3): single-animal + - (N, K, 3): multi-animal + """ + try: + arr = np.asarray(pose) + except Exception as exc: + raise ValueError( + f"{source_backend} returned an invalid pose output format: could not convert to array ({exc})" + ) from exc + + if arr.ndim not in (2, 3): + raise ValueError( + f"{source_backend} returned an invalid pose output format: expected a 2D or 3D array, got ndim={arr.ndim}, shape={arr.shape!r}" + ) + + if arr.shape[-1] != 3: + raise ValueError( + f"{source_backend} returned an invalid pose output format: expected last dimension size 3 (x, y, likelihood), got shape={arr.shape!r}" + ) + + if arr.ndim == 2 and arr.shape[0] <= 0: + raise ValueError(f"{source_backend} returned an invalid pose output format: expected at least one keypoint") + if arr.ndim == 3 and (arr.shape[0] <= 0 or arr.shape[1] <= 0): + raise ValueError( + f"{source_backend} returned an invalid pose output format: expected at least one individual and one keypoint, got shape={arr.shape!r}" + ) + + if not np.issubdtype(arr.dtype, np.number): + raise ValueError( + f"{source_backend} returned an invalid pose output format: expected numeric values, got dtype={arr.dtype}" + ) + + return arr @dataclass @@ -269,8 +326,17 @@ def _process_frame( # Time GPU inference (and processor overhead when present) with self._timed_processor() as proc_holder: inference_start = time.perf_counter() - pose = self._dlc.get_pose(frame, frame_time=timestamp) + raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start + pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend="DLCLive") + pose_packet = PosePacketV0( + schema_version=0, + keypoints=pose_arr, + keypoint_names=None, + individual_ids=None, + source=PoseSource(backend="DLCLive", model_type=self._settings.model_type), + raw=raw_pose, + ) processor_overhead = 0.0 gpu_inference_time = inference_time @@ -280,7 +346,7 @@ def _process_frame( # Emit pose (measure signal overhead) signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) + self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet)) signal_time = time.perf_counter() - signal_start end_ts = time.perf_counter() diff --git a/tests/services/test_pose_contract.py b/tests/services/test_pose_contract.py new file mode 100644 index 0000000..2c909f7 --- /dev/null +++ b/tests/services/test_pose_contract.py @@ -0,0 +1,40 @@ +import numpy as np +import pytest + +from dlclivegui.services.dlc_processor import validate_pose_array + + +@pytest.mark.unit +def test_validate_pose_array_keeps_single_animal_shape(): + pose = np.ones((5, 3), dtype=np.float64) + out = validate_pose_array(pose) + assert out.shape == (5, 3) + assert out.dtype == np.float64 + + +@pytest.mark.unit +def test_validate_pose_array_accepts_multi_animal(): + pose = np.ones((2, 5, 3), dtype=np.float32) + out = validate_pose_array(pose) + assert out.shape == (2, 5, 3) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "bad_pose,expected", + [ + (np.ones((5, 2), dtype=np.float32), "last dimension size 3"), + (np.ones((2, 5, 4), dtype=np.float32), "last dimension size 3"), + (np.ones((3,), dtype=np.float32), "expected a 2D or 3D array"), + ], +) +def test_validate_pose_array_rejects_invalid_shapes(bad_pose, expected): + with pytest.raises(ValueError, match=expected): + validate_pose_array(bad_pose) + + +@pytest.mark.unit +def test_validate_pose_array_rejects_non_numeric(): + pose = np.array([[["x", "y", "p"]]], dtype=object) + with pytest.raises(ValueError, match="expected numeric values"): + validate_pose_array(pose) From 4c97d58f0ee8e8da70d93e3bb23b5b170059cd46 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:39:39 +0100 Subject: [PATCH 02/16] Add basic compatibility tests for deeplabcut-live API --- .github/workflows/testing-ci.yml | 37 ++++++++++ pyproject.toml | 1 + tests/compat/test_dlclive_package_compat.py | 77 +++++++++++++++++++++ tox.ini | 3 + 4 files changed, 118 insertions(+) create mode 100644 tests/compat/test_dlclive_package_compat.py diff --git a/.github/workflows/testing-ci.yml b/.github/workflows/testing-ci.yml index a915d32..eb052c6 100644 --- a/.github/workflows/testing-ci.yml +++ b/.github/workflows/testing-ci.yml @@ -78,3 +78,40 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./.coverage.py312.xml fail_ci_if_error: false + + dlclive-compat: + name: DLCLive Compatibility • ${{ matrix.label }} • py${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ['3.11'] + include: + - label: pypi-1.1 + dlclive_spec: deeplabcut-live==1.1 + - label: github-main + dlclive_spec: git+https://github.com/DeepLabCut/DeepLabCut-live.git@main + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: 'pip' + + - name: Install package + test dependencies + run: | + python -m pip install -U pip wheel + python -m pip install -e .[test] + + - name: Install matrix DLCLive build + run: | + python -m pip install --upgrade --force-reinstall "${{ matrix.dlclive_spec }}" + python -m pip show deeplabcut-live + + - name: Run DLCLive compatibility tests + run: | + python -m pytest -m dlclive_compat tests/compat/test_dlclive_package_compat.py -q diff --git a/pyproject.toml b/pyproject.toml index 02e8a96..f1e81ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ markers = [ "unit: Unit tests for individual components", "integration: Integration tests for component interaction", "functional: Functional tests for end-to-end workflows", + "dlclive_compat: Package/API compatibility tests against supported dlclive versions", "hardware: Tests that require specific hardware, notable camera backends", # "slow: Tests that take a long time to run", "gui: Tests that require GUI interaction", diff --git a/tests/compat/test_dlclive_package_compat.py b/tests/compat/test_dlclive_package_compat.py new file mode 100644 index 0000000..f4ab101 --- /dev/null +++ b/tests/compat/test_dlclive_package_compat.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import importlib.metadata +import inspect + +import pytest + + +def _get_signature_params(callable_obj) -> tuple[set[str], bool]: + """ + Return allowed keyword names for callable, allowing for **kwargs. + + Example: + >>> params, accepts_var_kw = _get_signature_params(lambda x, y, **kwargs: None, {"x", "y"}) + >>> params == {"x", "y"} + True + >>> accepts_var_kw + True + """ + sig = inspect.signature(callable_obj) + params = sig.parameters + accepts_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) + return params, accepts_var_kw + + +@pytest.mark.dlclive_compat +def test_dlclive_package_is_importable(): + from dlclive import DLCLive # noqa: PLC0415 + + assert DLCLive is not None + # Helpful for CI logs to confirm matrix install result. + _ = importlib.metadata.version("deeplabcut-live") + + +@pytest.mark.dlclive_compat +def test_dlclive_constructor_accepts_gui_expected_kwargs(): + """ + GUI passes these kwargs when constructing DLCLive. + This test catches upstream API changes that would break initialization. + """ + from dlclive import DLCLive # noqa: PLC0415 + + expected = { + "model_path", + "model_type", + "processor", + "dynamic", + "resize", + "precision", + "single_animal", + "device", + } + params, accepts_var_kw = _get_signature_params(DLCLive.__init__) + missing = {name for name in expected if name not in params} + assert not missing, f"DLCLive.__init__ is missing expected kwargs called by GUI: {sorted(missing)}" + assert accepts_var_kw, "DLCLive.__init__ should accept **kwargs" # captures current behavior + + +@pytest.mark.dlclive_compat +def test_dlclive_methods_match_gui_usage(): + """ + GUI expects: + - init_inference(frame) + - get_pose(frame, frame_time=) + """ + from dlclive import DLCLive # noqa: PLC0415 + + assert hasattr(DLCLive, "init_inference"), "DLCLive must provide init_inference(frame)" + assert hasattr(DLCLive, "get_pose"), "DLCLive must provide get_pose(frame, frame_time=...)" + + init_params, _ = _get_signature_params(DLCLive.init_inference) + init_missing = {name for name in {"frame"} if name not in init_params} + assert not init_missing, f"DLCLive.init_inference signature mismatch, missing: {sorted(init_missing)}" + + get_pose_params, _ = _get_signature_params(DLCLive.get_pose) + get_pose_missing = {name for name in {"frame", "frame_time"} if name not in get_pose_params} + assert not get_pose_missing, f"DLCLive.get_pose signature mismatch, missing: {sorted(get_pose_missing)}" diff --git a/tox.ini b/tox.ini index d6f8d86..26422d2 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,9 @@ description = Unit + smoke tests (exclude hardware) with coverage package = wheel extras = test +commands = + pytest -m "not hardware and not dlclive_compat" --maxfail=1 --disable-warnings \ + --cov=dlclivegui --cov-report=xml --cov-report=term-missing {posargs} # Helpful defaults for headless CI runs (Qt/OpenCV): setenv = From 1ad9a61f90a0900a5814ab859dba5ce459c6ea22 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:51:00 +0100 Subject: [PATCH 03/16] Add optional smoke test for exported dlclive model via env vars --- tests/compat/test_dlclive_package_compat.py | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/compat/test_dlclive_package_compat.py b/tests/compat/test_dlclive_package_compat.py index f4ab101..8d49cfc 100644 --- a/tests/compat/test_dlclive_package_compat.py +++ b/tests/compat/test_dlclive_package_compat.py @@ -2,7 +2,10 @@ import importlib.metadata import inspect +import os +from pathlib import Path +import numpy as np import pytest @@ -75,3 +78,44 @@ def test_dlclive_methods_match_gui_usage(): get_pose_params, _ = _get_signature_params(DLCLive.get_pose) get_pose_missing = {name for name in {"frame", "frame_time"} if name not in get_pose_params} assert not get_pose_missing, f"DLCLive.get_pose signature mismatch, missing: {sorted(get_pose_missing)}" + + +@pytest.mark.dlclive_compat +def test_dlclive_minimal_inference_smoke(): + """ + Real runtime smoke test (init + pose call) using a tiny exported model. + + Opt-in via env vars: + - DLCLIVE_TEST_MODEL_PATH: absolute/relative path to exported model folder/file + - DLCLIVE_TEST_MODEL_TYPE: optional model type (default: pytorch) + """ + model_path_env = os.getenv("DLCLIVE_TEST_MODEL_PATH", "").strip() + if not model_path_env: + pytest.skip("Set DLCLIVE_TEST_MODEL_PATH to run real DLCLive inference smoke test.") + + model_path = Path(model_path_env).expanduser() + if not model_path.exists(): + pytest.skip(f"DLCLIVE_TEST_MODEL_PATH does not exist: {model_path}") + + model_type = os.getenv("DLCLIVE_TEST_MODEL_TYPE", "pytorch").strip() or "pytorch" + + from dlclive import DLCLive # noqa: PLC0415 + from dlclivegui.services.dlc_processor import validate_pose_array # noqa: PLC0415 + + dlc = DLCLive( + model_path=str(model_path), + model_type=model_type, + dynamic=[False, 0.5, 10], + resize=1.0, + precision="FP32", + single_animal=True, + ) + + frame = np.zeros((64, 64, 3), dtype=np.uint8) + dlc.init_inference(frame) + pose = dlc.get_pose(frame, frame_time=0.0) + pose_arr = validate_pose_array(pose, source_backend="DLCLive.get_pose") + + assert pose_arr.ndim in (2, 3) + assert pose_arr.shape[-1] == 3 + assert np.isfinite(pose_arr).all() From 7e4b753d9abf685ee7031cc11321b4f025a9ea14 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:01 +0100 Subject: [PATCH 04/16] Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard --- dlclivegui/services/dlc_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 8d73ed5..76f95f8 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -47,7 +47,7 @@ class PoseSource: @dataclass(slots=True, frozen=True) -class PosePacketV0: +class PosePacket: schema_version: int = 0 keypoints: np.ndarray | None = None keypoint_names: list[str] | None = None From d9c0fdfeab02cec140083f0271b482f169a8b43d Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:10 +0100 Subject: [PATCH 05/16] Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard --- dlclivegui/services/dlc_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 76f95f8..fe5d17b 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -329,7 +329,7 @@ def _process_frame( raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend="DLCLive") - pose_packet = PosePacketV0( + pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, keypoint_names=None, From 693c931eb7f50c86ddba8e6cfcbae5799e644520 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:35 +0100 Subject: [PATCH 06/16] Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard --- dlclivegui/services/dlc_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index fe5d17b..eb00d1f 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -37,7 +37,7 @@ class PoseResult: pose: np.ndarray | None timestamp: float - packet: "PosePacketV0 | None" = None + packet: "PosePacket | None" = None @dataclass(slots=True, frozen=True) From 622722b5842b5c9f4347fbef37c06a68b858c8c7 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 27 Feb 2026 16:12:34 +0100 Subject: [PATCH 07/16] Remove duplicate commands block from rebase Remove the earlier pytest commands block and update the tox 'commands' to run pytest with the marker excluding both 'hardware' and 'dlclive_compat'. Adjust coverage invocation to use the installed package path (--cov={envsitepackagesdir}/dlclivegui) and emit per-env XML coverage files (.coverage.{envname}.xml). This aligns tox behavior with the GitHub Actions job and removes the prior posargs-based command duplication. --- tox.ini | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index 26422d2..93a081d 100644 --- a/tox.ini +++ b/tox.ini @@ -11,10 +11,6 @@ description = Unit + smoke tests (exclude hardware) with coverage package = wheel extras = test -commands = - pytest -m "not hardware and not dlclive_compat" --maxfail=1 --disable-warnings \ - --cov=dlclivegui --cov-report=xml --cov-report=term-missing {posargs} - # Helpful defaults for headless CI runs (Qt/OpenCV): setenv = PYTHONWARNINGS = default @@ -24,9 +20,8 @@ setenv = OPENCV_VIDEOIO_PRIORITY_MSMF = 0 COVERAGE_FILE = {toxinidir}/.coverage.{envname} -# Keep behavior aligned with your GitHub Actions job: commands = - pytest -m "not hardware" --maxfail=1 --disable-warnings \ + pytest -m "not hardware and not dlclive_compat" --maxfail=1 --disable-warnings \ --cov={envsitepackagesdir}/dlclivegui \ --cov-report=xml:{toxinidir}/.coverage.{envname}.xml \ --cov-report=term-missing \ From 60a0e8ab170af6b604be6a9874ad05db5c663f91 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 27 Feb 2026 16:14:14 +0100 Subject: [PATCH 08/16] Use Python 3.12 in CI testing matrix Update the GitHub Actions testing matrix to run on Python 3.12 instead of 3.11. This moves CI to test against the newer Python runtime while keeping existing matrix include entries unchanged. --- .github/workflows/testing-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing-ci.yml b/.github/workflows/testing-ci.yml index eb052c6..f006f5e 100644 --- a/.github/workflows/testing-ci.yml +++ b/.github/workflows/testing-ci.yml @@ -85,7 +85,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.11'] + python: ['3.12'] include: - label: pypi-1.1 dlclive_spec: deeplabcut-live==1.1 From 990654baec4324da2de08c5535ddd4ced56c9c81 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Fri, 27 Feb 2026 16:15:54 +0100 Subject: [PATCH 09/16] Run pre-commit --- dlclivegui/services/dlc_processor.py | 13 ++++++++----- tests/compat/test_dlclive_package_compat.py | 5 +++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index eb00d1f..17dfae5 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -37,12 +37,12 @@ class PoseResult: pose: np.ndarray | None timestamp: float - packet: "PosePacket | None" = None + packet: PosePacket | None = None @dataclass(slots=True, frozen=True) class PoseSource: - backend: str # e.g. "DLCLive" + backend: str # e.g. "DLCLive" model_type: ModelType | None = None @@ -73,19 +73,22 @@ def validate_pose_array(pose: Any, *, source_backend: str = "DLCLive") -> np.nda if arr.ndim not in (2, 3): raise ValueError( - f"{source_backend} returned an invalid pose output format: expected a 2D or 3D array, got ndim={arr.ndim}, shape={arr.shape!r}" + f"{source_backend} returned an invalid pose output format:" + f" expected a 2D or 3D array, got ndim={arr.ndim}, shape={arr.shape!r}" ) if arr.shape[-1] != 3: raise ValueError( - f"{source_backend} returned an invalid pose output format: expected last dimension size 3 (x, y, likelihood), got shape={arr.shape!r}" + f"{source_backend} returned an invalid pose output format:" + f" expected last dimension size 3 (x, y, likelihood), got shape={arr.shape!r}" ) if arr.ndim == 2 and arr.shape[0] <= 0: raise ValueError(f"{source_backend} returned an invalid pose output format: expected at least one keypoint") if arr.ndim == 3 and (arr.shape[0] <= 0 or arr.shape[1] <= 0): raise ValueError( - f"{source_backend} returned an invalid pose output format: expected at least one individual and one keypoint, got shape={arr.shape!r}" + f"{source_backend} returned an invalid pose output format:" + f" expected at least one individual and one keypoint, got shape={arr.shape!r}" ) if not np.issubdtype(arr.dtype, np.number): diff --git a/tests/compat/test_dlclive_package_compat.py b/tests/compat/test_dlclive_package_compat.py index 8d49cfc..522cb8e 100644 --- a/tests/compat/test_dlclive_package_compat.py +++ b/tests/compat/test_dlclive_package_compat.py @@ -12,7 +12,7 @@ def _get_signature_params(callable_obj) -> tuple[set[str], bool]: """ Return allowed keyword names for callable, allowing for **kwargs. - + Example: >>> params, accepts_var_kw = _get_signature_params(lambda x, y, **kwargs: None, {"x", "y"}) >>> params == {"x", "y"} @@ -56,7 +56,7 @@ def test_dlclive_constructor_accepts_gui_expected_kwargs(): params, accepts_var_kw = _get_signature_params(DLCLive.__init__) missing = {name for name in expected if name not in params} assert not missing, f"DLCLive.__init__ is missing expected kwargs called by GUI: {sorted(missing)}" - assert accepts_var_kw, "DLCLive.__init__ should accept **kwargs" # captures current behavior + assert accepts_var_kw, "DLCLive.__init__ should accept **kwargs" # captures current behavior @pytest.mark.dlclive_compat @@ -100,6 +100,7 @@ def test_dlclive_minimal_inference_smoke(): model_type = os.getenv("DLCLIVE_TEST_MODEL_TYPE", "pytorch").strip() or "pytorch" from dlclive import DLCLive # noqa: PLC0415 + from dlclivegui.services.dlc_processor import validate_pose_array # noqa: PLC0415 dlc = DLCLive( From 882717b8062a52af47e91d2d8ceea04ff5414eb8 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 09:25:04 +0100 Subject: [PATCH 10/16] Use PoseBackends enum for backend field Add a PoseBackends Enum and switch PoseSource.backend from a string to that enum for stronger typing and clarity. Update default PosePacket/PoseSource instances and the validate_pose_array signature to use PoseBackends.DLC_LIVE. Import Enum/auto and remove an unused sentinel variable. These are small refactors to improve type-safety and consistency around backend identification. --- dlclivegui/services/dlc_processor.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 17dfae5..601d525 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -10,6 +10,7 @@ from collections import deque from contextlib import contextmanager from dataclasses import dataclass +from enum import Enum, auto from typing import Any import numpy as np @@ -33,6 +34,10 @@ DLCLive = None # type: ignore[assignment] +class PoseBackends(Enum): + DLC_LIVE = auto() + + @dataclass class PoseResult: pose: np.ndarray | None @@ -42,7 +47,7 @@ class PoseResult: @dataclass(slots=True, frozen=True) class PoseSource: - backend: str # e.g. "DLCLive" + backend: PoseBackends # e.g. "DLCLive" model_type: ModelType | None = None @@ -52,11 +57,11 @@ class PosePacket: keypoints: np.ndarray | None = None keypoint_names: list[str] | None = None individual_ids: list[str] | None = None - source: PoseSource = PoseSource(backend="DLCLive") + source: PoseSource = PoseSource(backend=PoseBackends.DLC_LIVE) raw: Any | None = None -def validate_pose_array(pose: Any, *, source_backend: str = "DLCLive") -> np.ndarray: +def validate_pose_array(pose: Any, *, source_backend: PoseBackends = PoseBackends.DLC_LIVE) -> np.ndarray: """ Validate pose output shape and dtype. @@ -120,9 +125,6 @@ class ProcessorStats: avg_processor_overhead: float = 0.0 # Socket processor overhead -# _SENTINEL = object() - - class DLCLiveProcessor(QObject): """Background pose estimation using DLCLive with queue-based threading.""" @@ -337,7 +339,7 @@ def _process_frame( keypoints=pose_arr, keypoint_names=None, individual_ids=None, - source=PoseSource(backend="DLCLive", model_type=self._settings.model_type), + source=PoseSource(backend=PoseBackends.DLC_LIVE, model_type=self._settings.model_type), raw=raw_pose, ) From e52477f0a3bf5418e36a96bb516557221bb3e07a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 09:43:33 +0100 Subject: [PATCH 11/16] Update & fix DLCLive compatibility tests Relax and correct DLCLive compatibility checks: only consider keyword/positional params when inspecting __init__, drop the assertion that __init__ must accept **kwargs, and only require 'frame' for init_inference/get_pose (frame_time is passed as a kwarg to processors). Also update FakeDLCLive.get_pose to return an array of shape (2, 3) to match the expected pose output shape. --- tests/compat/test_dlclive_package_compat.py | 12 +++++++++--- tests/conftest.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/compat/test_dlclive_package_compat.py b/tests/compat/test_dlclive_package_compat.py index 522cb8e..c57f7dc 100644 --- a/tests/compat/test_dlclive_package_compat.py +++ b/tests/compat/test_dlclive_package_compat.py @@ -53,10 +53,14 @@ def test_dlclive_constructor_accepts_gui_expected_kwargs(): "single_animal", "device", } - params, accepts_var_kw = _get_signature_params(DLCLive.__init__) + params, _ = _get_signature_params(DLCLive.__init__) + params = { + name + for name, p in params.items() + if p.kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + } missing = {name for name in expected if name not in params} assert not missing, f"DLCLive.__init__ is missing expected kwargs called by GUI: {sorted(missing)}" - assert accepts_var_kw, "DLCLive.__init__ should accept **kwargs" # captures current behavior @pytest.mark.dlclive_compat @@ -70,13 +74,15 @@ def test_dlclive_methods_match_gui_usage(): assert hasattr(DLCLive, "init_inference"), "DLCLive must provide init_inference(frame)" assert hasattr(DLCLive, "get_pose"), "DLCLive must provide get_pose(frame, frame_time=...)" + # NOTE: frame_time is passed as a kwarg, so we only check for "frame" as a required param. + # This is used by DLCLive Processor classes, rather than the DLCLive class itself. init_params, _ = _get_signature_params(DLCLive.init_inference) init_missing = {name for name in {"frame"} if name not in init_params} assert not init_missing, f"DLCLive.init_inference signature mismatch, missing: {sorted(init_missing)}" get_pose_params, _ = _get_signature_params(DLCLive.get_pose) - get_pose_missing = {name for name in {"frame", "frame_time"} if name not in get_pose_params} + get_pose_missing = {name for name in {"frame"} if name not in get_pose_params} assert not get_pose_missing, f"DLCLive.get_pose signature mismatch, missing: {sorted(get_pose_missing)}" diff --git a/tests/conftest.py b/tests/conftest.py index 6f3ee40..7d12a70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -168,7 +168,7 @@ def init_inference(self, frame): def get_pose(self, frame, frame_time=None): self.pose_calls += 1 - return np.ones((2, 2), dtype=float) + return np.ones((2, 3), dtype=float) @pytest.fixture From 34b8727c3e435049a5105193f7314f2c24cb57eb Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 09:43:47 +0100 Subject: [PATCH 12/16] Fix hard-coded str to use enum --- dlclivegui/services/dlc_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 601d525..fecad15 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -333,7 +333,7 @@ def _process_frame( inference_start = time.perf_counter() raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start - pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend="DLCLive") + pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE) pose_packet = PosePacket( schema_version=0, keypoints=pose_arr, From edec653a28144e543608d120165ccd79898ea382 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 09:52:51 +0100 Subject: [PATCH 13/16] Ensure tox failure fails CI job rather than silently passing Set the test step shell to `bash -eo pipefail` and redirect `tox` stderr into `tee` (changed `tox -q` to `tox -q 2>&1 | tee tox-output.log`). This ensures pipeline failures are detected (pipefail) and that tox's stderr is recorded in `tox-output.log` for improved debugging in the CI workflow. --- .github/workflows/testing-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing-ci.yml b/.github/workflows/testing-ci.yml index f006f5e..d6ef96d 100644 --- a/.github/workflows/testing-ci.yml +++ b/.github/workflows/testing-ci.yml @@ -54,8 +54,9 @@ jobs: libxcb-cursor0 - name: Run tests (exclude hardware) with coverage via tox + shell: bash -eo pipefail {0} run: | - tox -q | tee tox-output.log + tox -q 2>&1 | tee tox-output.log - name: Append Coverage Summary to Job From fd17969146e46ffd3570f3e5e316700b4454d5dd Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 10:04:15 +0100 Subject: [PATCH 14/16] Use tox for DLCLive compatibility in CI Replace ad-hoc DLCLive installs in the GitHub Actions job with tox-based testenvs. The workflow fixes Python to 3.12, installs required Qt/OpenGL runtime libs on Ubuntu, installs tox and tox-gh-actions, and runs tox -e matrix.tox_env. tox.ini was extended with dlclive-pypi and dlclive-github testenvs (pypi pinned and GitHub main respectively) to run the compatibility pytest, and the new envs were added to env_list to allow local and CI execution. --- .github/workflows/testing-ci.yml | 37 +++++++++++++++++--------------- tox.ini | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/.github/workflows/testing-ci.yml b/.github/workflows/testing-ci.yml index d6ef96d..dddced3 100644 --- a/.github/workflows/testing-ci.yml +++ b/.github/workflows/testing-ci.yml @@ -81,38 +81,41 @@ jobs: fail_ci_if_error: false dlclive-compat: - name: DLCLive Compatibility • ${{ matrix.label }} • py${{ matrix.python }} + name: DLCLive Compatibility • ${{ matrix.label }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python: ['3.12'] include: - label: pypi-1.1 - dlclive_spec: deeplabcut-live==1.1 + tox_env: dlclive-pypi - label: github-main - dlclive_spec: git+https://github.com/DeepLabCut/DeepLabCut-live.git@main + tox_env: dlclive-github steps: - - name: Checkout - uses: actions/checkout@v6 + - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python }} + python-version: '3.12' cache: 'pip' - - name: Install package + test dependencies + - name: Install Qt/OpenGL runtime deps (Ubuntu) run: | - python -m pip install -U pip wheel - python -m pip install -e .[test] + sudo apt-get update + sudo apt-get install -y \ + libegl1 \ + libgl1 \ + libopengl0 \ + libxkbcommon-x11-0 \ + libxcb-cursor0 - - name: Install matrix DLCLive build + - name: Install tox run: | - python -m pip install --upgrade --force-reinstall "${{ matrix.dlclive_spec }}" - python -m pip show deeplabcut-live + python -m pip install -U pip wheel + python -m pip install -U tox tox-gh-actions - - name: Run DLCLive compatibility tests + - name: Run DLCLive compatibility tests via tox + shell: bash -eo pipefail {0} run: | - python -m pytest -m dlclive_compat tests/compat/test_dlclive_package_compat.py -q + tox -e ${{ matrix.tox_env }} -q diff --git a/tox.ini b/tox.ini index 93a081d..bed8511 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,8 @@ min_version = 4.0 env_list = py{310,311,312} + dlclive-pypi + dlclive-github lint isolated_build = true skip_missing_interpreters = true @@ -45,6 +47,22 @@ passenv = ; ruff check . ; ruff format --check . +# Run locally : tox -e dlclive-pypi +[testenv:dlclive-pypi] +description = DLCLive compatibility tests against specific PyPi release +deps = + deeplabcut-live==1.1 +commands = + pytest -m dlclive_compat tests/compat/test_dlclive_package_compat.py -q + +# Run locally : tox -e dlclive-github +[testenv:dlclive-github] +description = DLCLive compatibility tests against GitHub main +deps = + git+https://github.com/DeepLabCut/DeepLabCut-live.git@main +commands = + pytest -m dlclive_compat tests/compat/test_dlclive_package_compat.py -q + [gh-actions] python = 3.10: py310 From ebfad63705431bac19d84895842febb70ed41717 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 10:20:07 +0100 Subject: [PATCH 15/16] tests(compat): stub torch module in conftest Add tests/compat/conftest.py to inject a stub torch module into sys.modules when torch is not installed. This prevents ImportError during DLCLive compatibility tests so the API can be validated without requiring torch to be installed. This is a pragmatic workaround and includes a note to remove or replace it once imports are properly guarded in the package. --- tests/compat/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/compat/conftest.py diff --git a/tests/compat/conftest.py b/tests/compat/conftest.py new file mode 100644 index 0000000..65c9478 --- /dev/null +++ b/tests/compat/conftest.py @@ -0,0 +1,10 @@ +# tests/compat/conftest.py +import sys +import types + +# Stub out torch imports to avoid ImportError when torch is not installed in DLCLive package. +# This allows testing of DLCLive API compatibility without requiring torch. +# Ideally imports should be guarded in the package itself, but this is a pragmatic solution for now. +# IMPORTANT NOTE: This should ideally be removed and replaced whenever possible. +if "torch" not in sys.modules: + sys.modules["torch"] = types.ModuleType("torch") From 5c03b4e36e77aad64386bf66a9ab2ddc0a59739d Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 2 Mar 2026 10:28:48 +0100 Subject: [PATCH 16/16] Make camera validation timer cancellable Replace QTimer.singleShot with a cancellable QTimer instance (self._camera_validation_timer) that is single-shot, connected to _validate_configured_cameras, and started with a 100ms delay. The closeEvent now stops the timer if it's still active to prevent validation from firing while the window is closing, avoiding modal QMessageBox races/crashes during tests/CI teardown. --- dlclivegui/gui/main_window.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index 380eda0..df99e83 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -196,14 +196,17 @@ def __init__(self, config: ApplicationSettings | None = None): # Validate cameras from loaded config (deferred to allow window to show first) # NOTE IMPORTANT (tests/CI): This is scheduled via a QTimer and may fire during pytest-qt teardown. - QTimer.singleShot(100, self._validate_configured_cameras) + # NOTE @C-Achard 2026-03-02: Handling this in closeEvent should help + self._camera_validation_timer = QTimer(self) + self._camera_validation_timer.setSingleShot(True) + self._camera_validation_timer.timeout.connect(self._validate_configured_cameras) + self._camera_validation_timer.start(100) # If validation triggers a modal QMessageBox (warning/error) while the parent window is closing, # it can cause errors with unpredictable timing (heap corruption / access violations). # # Mitigations for tests/CI: # - Disable this timer by monkeypatching _validate_configured_cameras in GUI tests # - OR monkeypatch/override _show_warning/_show_error to no-op in GUI tests (easiest) - # - OR use a cancellable QTimer attribute and stop() it in closeEvent def resizeEvent(self, event): super().resizeEvent(event) @@ -2023,6 +2026,8 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if self.multi_camera_controller.is_running(): self.multi_camera_controller.stop(wait=True) + if hasattr(self, "_camera_validation_timer") and self._camera_validation_timer.isActive(): + self._camera_validation_timer.stop() # Stop all multi-camera recorders self._rec_manager.stop_all()