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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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(