Skip to content

Commit 5b5d04c

Browse files
deruyter92C-Achard
andauthored
Add validation + compatibility tests for DeepLabCut-live (#56)
* dlc_processor: add lightweight validation of shape and dtype (+tests) * Add basic compatibility tests for deeplabcut-live API * Add optional smoke test for exported dlclive model via env vars * Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard <cyril.achard@epfl.ch> * Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard <cyril.achard@epfl.ch> * Update dlclivegui/services/dlc_processor.py Co-authored-by: Cyril Achard <cyril.achard@epfl.ch> * 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. * 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. * Run pre-commit * 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. * 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. * Fix hard-coded str to use enum * 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. * 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. * 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. * 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. * Add finite check and improve compatibility tests dlc_processor: broaden validate_pose_array signature to accept source_backend as PoseBackends|str and add a check_finite option (default True) that raises on non-finite values to catch invalid pose outputs early. tests: make torch stubbing more robust by using importlib.util.find_spec in tests/compat/conftest.py; update tests/compat/test_dlclive_package_compat.py to refine _get_signature_params typing, import Parameter, and handle constructors that accept **kwargs more gracefully (provide clearer diagnostic when expected GUI kwargs may be passed through). tox.ini: add a [testenv:dlclive] passenv block for DLCLIVE test env vars and propagate that passenv to dlclive-pypi and dlclive-github envs so DLCLive compatibility tests pick up required environment variables. * Refactor DLCLive signature checks in tests Add helper functions to inspect method signatures (_signature_accepts_positional_after_self and _signature_accepts_keyword) and replace previous name-based parameter checks for DLCLive.init_inference and DLCLive.get_pose. Tests now verify that these methods accept a frame-like positional argument after self and that get_pose accepts frame_time as a keyword (or **kwargs). Also update an example in the docstring to match the new signature helper usage and improve assertion messages. * tox.ini: skip install for dlclive-github env Update dlclive-github testenv to run compatibility tests against GitHub main without installing the local package. Adds a comment explaining the rationale, sets package = skip, adds pytest to deps, and inserts a small diagnostic python command to print the imported dlclive file and deeplabcut-live version before running the compatibility test. * Set pytest --confcutdir for compat tests Add --confcutdir=tests/compat to the pytest command in tox.ini for the dlclive compatibility test. This forces pytest to stop config discovery at the tests/compat directory so the compat tests run with their intended configuration and aren't affected by higher-level pytest configs. --------- Co-authored-by: Cyril Achard <cyril.achard@epfl.ch>
1 parent c8db693 commit 5b5d04c

9 files changed

Lines changed: 389 additions & 13 deletions

File tree

.github/workflows/testing-ci.yml

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ jobs:
6161
libxcb-cursor0
6262
6363
- name: Run tests (exclude hardware) with coverage via tox
64+
shell: bash -eo pipefail {0}
6465
run: |
65-
tox -q | tee tox-output.log
66+
tox -q 2>&1 | tee tox-output.log
6667
6768
6869
- name: Append Coverage Summary to Job
@@ -85,3 +86,43 @@ jobs:
8586
token: ${{ secrets.CODECOV_TOKEN }}
8687
files: ./.coverage.py312.xml
8788
fail_ci_if_error: false
89+
90+
dlclive-compat:
91+
name: DLCLive Compatibility • ${{ matrix.label }}
92+
runs-on: ubuntu-latest
93+
strategy:
94+
fail-fast: false
95+
matrix:
96+
include:
97+
- label: pypi-1.1
98+
tox_env: dlclive-pypi
99+
- label: github-main
100+
tox_env: dlclive-github
101+
102+
steps:
103+
- uses: actions/checkout@v6
104+
105+
- uses: actions/setup-python@v6
106+
with:
107+
python-version: '3.12'
108+
cache: 'pip'
109+
110+
- name: Install Qt/OpenGL runtime deps (Ubuntu)
111+
run: |
112+
sudo apt-get update
113+
sudo apt-get install -y \
114+
libegl1 \
115+
libgl1 \
116+
libopengl0 \
117+
libxkbcommon-x11-0 \
118+
libxcb-cursor0
119+
120+
- name: Install tox
121+
run: |
122+
python -m pip install -U pip wheel
123+
python -m pip install -U tox tox-gh-actions
124+
125+
- name: Run DLCLive compatibility tests via tox
126+
shell: bash -eo pipefail {0}
127+
run: |
128+
tox -e ${{ matrix.tox_env }} -q

dlclivegui/gui/main_window.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,17 @@ def __init__(self, config: ApplicationSettings | None = None):
196196

197197
# Validate cameras from loaded config (deferred to allow window to show first)
198198
# NOTE IMPORTANT (tests/CI): This is scheduled via a QTimer and may fire during pytest-qt teardown.
199-
QTimer.singleShot(100, self._validate_configured_cameras)
199+
# NOTE @C-Achard 2026-03-02: Handling this in closeEvent should help
200+
self._camera_validation_timer = QTimer(self)
201+
self._camera_validation_timer.setSingleShot(True)
202+
self._camera_validation_timer.timeout.connect(self._validate_configured_cameras)
203+
self._camera_validation_timer.start(100)
200204
# If validation triggers a modal QMessageBox (warning/error) while the parent window is closing,
201205
# it can cause errors with unpredictable timing (heap corruption / access violations).
202206
#
203207
# Mitigations for tests/CI:
204208
# - Disable this timer by monkeypatching _validate_configured_cameras in GUI tests
205209
# - OR monkeypatch/override _show_warning/_show_error to no-op in GUI tests (easiest)
206-
# - OR use a cancellable QTimer attribute and stop() it in closeEvent
207210

208211
def resizeEvent(self, event):
209212
super().resizeEvent(event)
@@ -2021,6 +2024,8 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha
20212024
if self.multi_camera_controller.is_running():
20222025
self.multi_camera_controller.stop(wait=True)
20232026

2027+
if hasattr(self, "_camera_validation_timer") and self._camera_validation_timer.isActive():
2028+
self._camera_validation_timer.stop()
20242029
# Stop all multi-camera recorders
20252030
self._rec_manager.stop_all()
20262031

dlclivegui/services/dlc_processor.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
from collections import deque
1111
from contextlib import contextmanager
1212
from dataclasses import dataclass
13+
from enum import Enum, auto
1314
from typing import Any
1415

1516
import numpy as np
1617
from PySide6.QtCore import QObject, Signal
1718

18-
from dlclivegui.config import DLCProcessorSettings
19+
from dlclivegui.config import DLCProcessorSettings, ModelType
1920
from dlclivegui.processors.processor_utils import instantiate_from_scan
2021
from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released
2122

@@ -33,10 +34,79 @@
3334
DLCLive = None # type: ignore[assignment]
3435

3536

37+
class PoseBackends(Enum):
38+
DLC_LIVE = auto()
39+
40+
3641
@dataclass
3742
class PoseResult:
3843
pose: np.ndarray | None
3944
timestamp: float
45+
packet: PosePacket | None = None
46+
47+
48+
@dataclass(slots=True, frozen=True)
49+
class PoseSource:
50+
backend: PoseBackends # e.g. "DLCLive"
51+
model_type: ModelType | None = None
52+
53+
54+
@dataclass(slots=True, frozen=True)
55+
class PosePacket:
56+
schema_version: int = 0
57+
keypoints: np.ndarray | None = None
58+
keypoint_names: list[str] | None = None
59+
individual_ids: list[str] | None = None
60+
source: PoseSource = PoseSource(backend=PoseBackends.DLC_LIVE)
61+
raw: Any | None = None
62+
63+
64+
def validate_pose_array(
65+
pose: Any, *, source_backend: PoseBackends | str = PoseBackends.DLC_LIVE, check_finite: bool = True
66+
) -> np.ndarray:
67+
"""
68+
Validate pose output shape and dtype.
69+
70+
Accepted runner output shapes:
71+
- (K, 3): single-animal
72+
- (N, K, 3): multi-animal
73+
"""
74+
try:
75+
arr = np.asarray(pose)
76+
except Exception as exc:
77+
raise ValueError(
78+
f"{source_backend} returned an invalid pose output format: could not convert to array ({exc})"
79+
) from exc
80+
81+
if arr.ndim not in (2, 3):
82+
raise ValueError(
83+
f"{source_backend} returned an invalid pose output format:"
84+
f" expected a 2D or 3D array, got ndim={arr.ndim}, shape={arr.shape!r}"
85+
)
86+
87+
if arr.shape[-1] != 3:
88+
raise ValueError(
89+
f"{source_backend} returned an invalid pose output format:"
90+
f" expected last dimension size 3 (x, y, likelihood), got shape={arr.shape!r}"
91+
)
92+
93+
if arr.ndim == 2 and arr.shape[0] <= 0:
94+
raise ValueError(f"{source_backend} returned an invalid pose output format: expected at least one keypoint")
95+
if arr.ndim == 3 and (arr.shape[0] <= 0 or arr.shape[1] <= 0):
96+
raise ValueError(
97+
f"{source_backend} returned an invalid pose output format:"
98+
f" expected at least one individual and one keypoint, got shape={arr.shape!r}"
99+
)
100+
101+
if not np.issubdtype(arr.dtype, np.number):
102+
raise ValueError(
103+
f"{source_backend} returned an invalid pose output format: expected numeric values, got dtype={arr.dtype}"
104+
)
105+
106+
if check_finite and not np.isfinite(arr).all():
107+
raise ValueError(f"{source_backend} returned an invalid pose output format: contains non-finite values")
108+
109+
return arr
40110

41111

42112
@dataclass
@@ -60,9 +130,6 @@ class ProcessorStats:
60130
avg_processor_overhead: float = 0.0 # Socket processor overhead
61131

62132

63-
# _SENTINEL = object()
64-
65-
66133
class DLCLiveProcessor(QObject):
67134
"""Background pose estimation using DLCLive with queue-based threading."""
68135

@@ -269,8 +336,17 @@ def _process_frame(
269336
# Time GPU inference (and processor overhead when present)
270337
with self._timed_processor() as proc_holder:
271338
inference_start = time.perf_counter()
272-
pose = self._dlc.get_pose(frame, frame_time=timestamp)
339+
raw_pose: Any = self._dlc.get_pose(frame, frame_time=timestamp)
273340
inference_time = time.perf_counter() - inference_start
341+
pose_arr: np.ndarray = validate_pose_array(raw_pose, source_backend=PoseBackends.DLC_LIVE)
342+
pose_packet = PosePacket(
343+
schema_version=0,
344+
keypoints=pose_arr,
345+
keypoint_names=None,
346+
individual_ids=None,
347+
source=PoseSource(backend=PoseBackends.DLC_LIVE, model_type=self._settings.model_type),
348+
raw=raw_pose,
349+
)
274350

275351
processor_overhead = 0.0
276352
gpu_inference_time = inference_time
@@ -280,7 +356,7 @@ def _process_frame(
280356

281357
# Emit pose (measure signal overhead)
282358
signal_start = time.perf_counter()
283-
self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp))
359+
self.pose_ready.emit(PoseResult(pose=pose_packet.keypoints, timestamp=timestamp, packet=pose_packet))
284360
signal_time = time.perf_counter() - signal_start
285361

286362
end_ts = time.perf_counter()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ markers = [
115115
"unit: Unit tests for individual components",
116116
"integration: Integration tests for component interaction",
117117
"functional: Functional tests for end-to-end workflows",
118+
"dlclive_compat: Package/API compatibility tests against supported dlclive versions",
118119
"hardware: Tests that require specific hardware, notable camera backends",
119120
# "slow: Tests that take a long time to run",
120121
"gui: Tests that require GUI interaction",

tests/compat/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# tests/compat/conftest.py
2+
import importlib.util
3+
import sys
4+
import types
5+
6+
# Stub out torch imports to avoid ImportError when torch is not installed in DLCLive package.
7+
# This allows testing of DLCLive API compatibility without requiring torch.
8+
# Ideally imports should be guarded in the package itself, but this is a pragmatic solution for now.
9+
# IMPORTANT NOTE: This should ideally be removed and replaced whenever possible.
10+
if importlib.util.find_spec("torch") is None:
11+
sys.modules["torch"] = types.ModuleType("torch")

0 commit comments

Comments
 (0)