Skip to content

Commit a20e9d4

Browse files
committed
update pytests: add huggingface and end-to-end functional tests
1 parent 7bd29c1 commit a20e9d4

7 files changed

Lines changed: 566 additions & 221 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ line_length = 100
7070
[tool.pytest.ini_options]
7171
markers = [
7272
"functional: marks tests that require pretrained weights (deselect with '-m \"not functional\"')",
73+
"network: marks tests that may need internet access on first run (deselect with '-m \"not network\"')",
7374
]
7475

7576
[tool.codespell]

tests/__init__.py

Whitespace-only changes.

tests/fmpose3d_api/__init__.py

Whitespace-only changes.

tests/fmpose3d_api/conftest.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Shared fixtures, markers, and skip-helpers for the ``fmpose3d_api`` test suite.
3+
4+
Skip logic
5+
----------
6+
* **weights_ready(filename)** – ``True`` when the HuggingFace-cached file
7+
already exists on disk *or* we can reach ``huggingface.co`` so that
8+
``hf_hub_download`` will succeed.
9+
* **has_internet** – evaluated once at collection time via a quick TCP probe.
10+
* **HF_HUB_OFFLINE** – if set to ``"1"`` in the environment the network
11+
check is skipped entirely (consistent with how ``huggingface_hub``
12+
itself behaves).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import os
18+
import socket
19+
20+
import pytest
21+
22+
# ---------------------------------------------------------------------------
23+
# HuggingFace repo & filenames (must match fmpose3d.fmpose3d._HF_REPO_ID)
24+
# ---------------------------------------------------------------------------
25+
26+
HF_REPO_ID: str = "deruyter92/fmpose_temp"
27+
28+
HUMAN_WEIGHTS_FILENAME: str = "fmpose3d_humans.pth"
29+
ANIMAL_WEIGHTS_FILENAME: str = "fmpose3d_animals.pth"
30+
31+
# ---------------------------------------------------------------------------
32+
# Connectivity helpers
33+
# ---------------------------------------------------------------------------
34+
35+
36+
def _has_internet(host: str = "huggingface.co", port: int = 443, timeout: float = 3) -> bool:
37+
"""Return ``True`` if *host* is reachable via TCP."""
38+
if os.environ.get("HF_HUB_OFFLINE", "0") == "1":
39+
return False
40+
try:
41+
socket.create_connection((host, port), timeout=timeout)
42+
return True
43+
except OSError:
44+
return False
45+
46+
47+
def _weights_cached(filename: str) -> bool:
48+
"""Return ``True`` if *filename* already lives in the local HF cache."""
49+
try:
50+
from huggingface_hub import try_to_load_from_cache
51+
52+
result = try_to_load_from_cache(HF_REPO_ID, filename)
53+
return isinstance(result, str)
54+
except Exception:
55+
return False
56+
57+
58+
def weights_ready(filename: str) -> bool:
59+
"""``True`` when we can obtain *filename* — either from cache or network."""
60+
return _weights_cached(filename) or _has_internet()
61+
62+
63+
# Evaluate once at collection time.
64+
HAS_INTERNET: bool = _has_internet()
65+
HUMAN_WEIGHTS_READY: bool = weights_ready(HUMAN_WEIGHTS_FILENAME)
66+
ANIMAL_WEIGHTS_READY: bool = weights_ready(ANIMAL_WEIGHTS_FILENAME)
67+
68+
try:
69+
import deeplabcut # noqa: F401
70+
71+
DLC_AVAILABLE: bool = True
72+
except ImportError:
73+
DLC_AVAILABLE = False
74+
75+
# ---------------------------------------------------------------------------
76+
# Reusable skip markers
77+
# ---------------------------------------------------------------------------
78+
79+
requires_network = pytest.mark.skipif(
80+
not HAS_INTERNET,
81+
reason="No internet connection (cannot reach huggingface.co)",
82+
)
83+
84+
requires_human_weights = pytest.mark.skipif(
85+
not HUMAN_WEIGHTS_READY,
86+
reason="Human weights not cached and no internet connection",
87+
)
88+
89+
requires_animal_weights = pytest.mark.skipif(
90+
not ANIMAL_WEIGHTS_READY,
91+
reason="Animal weights not cached and no internet connection",
92+
)
93+
94+
requires_dlc = pytest.mark.skipif(
95+
not DLC_AVAILABLE,
96+
reason="DeepLabCut is not installed",
97+
)
Lines changed: 1 addition & 221 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@
66
by Ti Wang, Xiaohang Yu, and Mackenzie Weygandt Mathis
77
Licensed under Apache 2.0
88
9-
Tests for fmpose3d/fmpose3d.py — the high-level inference API.
10-
11-
Organised into:
12-
* Unit tests for individual components (no pretrained weights needed).
13-
* Functional integration tests for the full human and animal pipelines
14-
(require pretrained weights on disk; marked ``@pytest.mark.functional``).
9+
Unit tests for fmpose3d/fmpose3d.py — the high-level inference API.
1510
"""
1611

1712
from __future__ import annotations
@@ -42,50 +37,6 @@
4237
)
4338
from fmpose3d.common.config import FMPose3DConfig, InferenceConfig
4439

45-
# ---------------------------------------------------------------------------
46-
# Paths
47-
# ---------------------------------------------------------------------------
48-
49-
PROJECT_ROOT = Path(__file__).resolve().parent.parent
50-
51-
def _find_first(candidates: list[Path]) -> Path | None:
52-
"""Return the first path that exists, or ``None``."""
53-
for p in candidates:
54-
if p.exists():
55-
return p
56-
return None
57-
58-
59-
HUMAN_WEIGHTS = _find_first([
60-
PROJECT_ROOT / "pre_trained_models" / "fmpose3d_h36m" / "FMpose3D_pretrained_weights.pth",
61-
])
62-
ANIMAL_WEIGHTS = _find_first([
63-
PROJECT_ROOT / "animals" / "pre_trained_models" / "fmpose3d_animals" / "fmpose3d_animals_pretrained_weights.pth",
64-
PROJECT_ROOT / "pre_trained_models" / "fmpose3d_animals" / "fmpose3d_animals_pretrained_weights.pth",
65-
])
66-
HUMAN_TEST_IMAGE = _find_first([
67-
PROJECT_ROOT / "demo" / "images" / "running.png",
68-
])
69-
ANIMAL_TEST_IMAGE = _find_first([
70-
PROJECT_ROOT / "animals" / "demo" / "images" / "dog.JPEG",
71-
PROJECT_ROOT / "animals" / "demo" / "images" / "dog.jpeg",
72-
PROJECT_ROOT / "animals" / "demo" / "images" / "dog.jpg",
73-
])
74-
75-
# Evaluated at collection time — files either exist or they don't.
76-
_human_pipeline_available = HUMAN_WEIGHTS is not None and HUMAN_TEST_IMAGE is not None
77-
_animal_pipeline_available = ANIMAL_WEIGHTS is not None and ANIMAL_TEST_IMAGE is not None
78-
79-
try:
80-
import deeplabcut # noqa: F401
81-
82-
_dlc_available = True
83-
except ImportError:
84-
_dlc_available = False
85-
86-
_animal_pipeline_available = _animal_pipeline_available and _dlc_available
87-
88-
8940
# ---------------------------------------------------------------------------
9041
# Helpers
9142
# ---------------------------------------------------------------------------
@@ -747,174 +698,3 @@ def test_predict_maps_valid_bodyparts(self):
747698
assert scores.shape == (1, 1, 26)
748699
# target[24] ← source[0] → (0*3, 0*3+1) = (0.0, 1.0)
749700
np.testing.assert_allclose(kpts[0, 0, 24], fake_bp[0, 0, :2])
750-
751-
752-
# =========================================================================
753-
# Functional tests — human pipeline
754-
# =========================================================================
755-
756-
757-
@pytest.mark.functional
758-
@pytest.mark.skipif(
759-
not _human_pipeline_available,
760-
reason="Human pretrained weights or test image not found",
761-
)
762-
class TestHumanPipeline:
763-
"""End-to-end integration test for the human (17-joint H36M) pipeline."""
764-
765-
@pytest.fixture(scope="class")
766-
def pipeline(self):
767-
"""Run the full pipeline once and cache all results."""
768-
from PIL import Image
769-
770-
api = FMPose3DInference(
771-
model_weights_path=str(HUMAN_WEIGHTS),
772-
device="cpu",
773-
)
774-
775-
# 2D estimation
776-
result_2d = api.prepare_2d(source=str(HUMAN_TEST_IMAGE))
777-
778-
# Image size
779-
img = Image.open(str(HUMAN_TEST_IMAGE))
780-
w, h = img.size
781-
image_size = (h, w)
782-
783-
# 3D lifting — two runs with the same seed for reproducibility check
784-
result_3d_a = api.pose_3d(
785-
result_2d.keypoints, image_size=image_size, seed=42,
786-
)
787-
result_3d_b = api.pose_3d(
788-
result_2d.keypoints, image_size=image_size, seed=42,
789-
)
790-
791-
return {
792-
"result_2d": result_2d,
793-
"image_size": image_size,
794-
"result_3d_a": result_3d_a,
795-
"result_3d_b": result_3d_b,
796-
}
797-
798-
def test_2d_keypoints_shape(self, pipeline):
799-
r2d = pipeline["result_2d"]
800-
P, F, J, C = r2d.keypoints.shape
801-
assert J == 17
802-
assert C == 2
803-
assert F >= 1
804-
805-
def test_2d_scores_shape(self, pipeline):
806-
r2d = pipeline["result_2d"]
807-
assert r2d.scores.ndim == 3
808-
assert r2d.scores.shape[2] == 17
809-
810-
def test_2d_image_size(self, pipeline):
811-
r2d = pipeline["result_2d"]
812-
h, w = pipeline["image_size"]
813-
assert r2d.image_size == (h, w)
814-
815-
def test_3d_poses_shape(self, pipeline):
816-
r3d = pipeline["result_3d_a"]
817-
F = pipeline["result_2d"].keypoints.shape[1]
818-
assert r3d.poses_3d.shape == (F, 17, 3)
819-
assert r3d.poses_3d_world.shape == (F, 17, 3)
820-
821-
def test_root_joint_zeroed(self, pipeline):
822-
r3d = pipeline["result_3d_a"]
823-
np.testing.assert_allclose(r3d.poses_3d[:, 0, :], 0.0, atol=1e-6)
824-
825-
def test_world_z_floor(self, pipeline):
826-
r3d = pipeline["result_3d_a"]
827-
assert np.min(r3d.poses_3d_world[:, :, 2]) >= -1e-6
828-
829-
def test_poses_finite(self, pipeline):
830-
r3d = pipeline["result_3d_a"]
831-
assert np.all(np.isfinite(r3d.poses_3d))
832-
assert np.all(np.isfinite(r3d.poses_3d_world))
833-
834-
def test_reproducibility(self, pipeline):
835-
r3d_a = pipeline["result_3d_a"]
836-
r3d_b = pipeline["result_3d_b"]
837-
np.testing.assert_allclose(r3d_a.poses_3d, r3d_b.poses_3d, atol=1e-6)
838-
np.testing.assert_allclose(
839-
r3d_a.poses_3d_world, r3d_b.poses_3d_world, atol=1e-6,
840-
)
841-
842-
843-
# =========================================================================
844-
# Functional tests — animal pipeline
845-
# =========================================================================
846-
847-
848-
@pytest.mark.functional
849-
@pytest.mark.skipif(
850-
not _animal_pipeline_available,
851-
reason="Animal pretrained weights, test image, or DeepLabCut not available",
852-
)
853-
class TestAnimalPipeline:
854-
"""End-to-end integration test for the animal (26-joint Animal3D) pipeline."""
855-
856-
@pytest.fixture(scope="class")
857-
def pipeline(self):
858-
"""Run the full pipeline once and cache all results."""
859-
from PIL import Image
860-
861-
api = FMPose3DInference.for_animals(
862-
model_weights_path=str(ANIMAL_WEIGHTS),
863-
device="cpu",
864-
)
865-
866-
result_2d = api.prepare_2d(source=str(ANIMAL_TEST_IMAGE))
867-
868-
img = Image.open(str(ANIMAL_TEST_IMAGE))
869-
w, h = img.size
870-
image_size = (h, w)
871-
872-
result_3d_a = api.pose_3d(
873-
result_2d.keypoints, image_size=image_size, seed=42,
874-
)
875-
result_3d_b = api.pose_3d(
876-
result_2d.keypoints, image_size=image_size, seed=42,
877-
)
878-
879-
return {
880-
"result_2d": result_2d,
881-
"image_size": image_size,
882-
"result_3d_a": result_3d_a,
883-
"result_3d_b": result_3d_b,
884-
}
885-
886-
def test_2d_keypoints_shape(self, pipeline):
887-
r2d = pipeline["result_2d"]
888-
_, F, J, C = r2d.keypoints.shape
889-
assert J == 26
890-
assert C == 2
891-
892-
def test_2d_scores_shape(self, pipeline):
893-
r2d = pipeline["result_2d"]
894-
assert r2d.scores.ndim == 3
895-
assert r2d.scores.shape[2] == 26
896-
897-
def test_3d_poses_shape(self, pipeline):
898-
r3d = pipeline["result_3d_a"]
899-
F = pipeline["result_2d"].keypoints.shape[1]
900-
assert r3d.poses_3d.shape == (F, 26, 3)
901-
assert r3d.poses_3d_world.shape == (F, 26, 3)
902-
903-
def test_poses_finite(self, pipeline):
904-
r3d = pipeline["result_3d_a"]
905-
assert np.all(np.isfinite(r3d.poses_3d))
906-
assert np.all(np.isfinite(r3d.poses_3d_world))
907-
908-
def test_poses_reasonable_magnitude(self, pipeline):
909-
"""Poses should not be excessively large (basic sanity)."""
910-
r3d = pipeline["result_3d_a"]
911-
assert np.max(np.abs(r3d.poses_3d)) < 1e4
912-
assert np.max(np.abs(r3d.poses_3d_world)) < 1e4
913-
914-
def test_reproducibility(self, pipeline):
915-
r3d_a = pipeline["result_3d_a"]
916-
r3d_b = pipeline["result_3d_b"]
917-
np.testing.assert_allclose(r3d_a.poses_3d, r3d_b.poses_3d, atol=1e-6)
918-
np.testing.assert_allclose(
919-
r3d_a.poses_3d_world, r3d_b.poses_3d_world, atol=1e-6,
920-
)

0 commit comments

Comments
 (0)