From c4acde5672efcfa4d2a24d9d84e73ee7e8240048 Mon Sep 17 00:00:00 2001 From: Jacob Goldstein Date: Sat, 22 Aug 2026 11:00:33 -0400 Subject: [PATCH] Add CI for biomechanics core --- .github/workflows/ci.yml | 43 +++++++++++++ README.md | 18 ++++++ src/biomechanics.py | 1 - tests/requirements.txt | 1 + tests/test_biomechanics.py | 122 +++++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/requirements.txt create mode 100644 tests/test_biomechanics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e64b3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + core: + name: core (${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: tests/requirements.txt + + - name: Install core test dependencies + run: python -m pip install -r tests/requirements.txt + + - name: Compile the Python sources + run: python -m compileall -q run.py src scripts + + - name: Test the model-free biomechanics core + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 70f99af..f8fdbcf 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,9 @@ OpenFall/ ├── scripts/ │ ├── download_samples.py # download UR Fall Dataset videos │ └── generate_test_video.py # generate synthetic stick-figure test video +├── tests/ +│ ├── requirements.txt # lightweight core-test dependency +│ └── test_biomechanics.py # model-free 3D geometry and feature tests └── data/ ├── pose_landmarker_lite.task # MediaPipe model (downloaded on first run) └── videos/ # place test videos here (gitignored) @@ -443,6 +446,21 @@ OpenFall/ --- +## Testing + +The core biomechanics checks do not download model weights or require a camera: + +```bash +python -m pip install -r tests/requirements.txt +python -m unittest discover -s tests -v +``` + +CI runs these tests and compiles the Python sources on Python 3.11 and 3.12. +Full detector runs still require the dependencies and model setup from the +quickstart above. + +--- + ## Dependencies | Package | Purpose | diff --git a/src/biomechanics.py b/src/biomechanics.py index ba1287f..db0d27a 100644 --- a/src/biomechanics.py +++ b/src/biomechanics.py @@ -13,7 +13,6 @@ import math from collections import deque -import cv2 import numpy as np # MediaPipe landmark indices used in 3D analysis diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..2c04913 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +numpy==1.26.4 diff --git a/tests/test_biomechanics.py b/tests/test_biomechanics.py new file mode 100644 index 0000000..2aa9826 --- /dev/null +++ b/tests/test_biomechanics.py @@ -0,0 +1,122 @@ +import math +import unittest +from collections import deque +from types import SimpleNamespace + +import numpy as np + +from src.biomechanics import ( + IDX_LEFT_HIP, + IDX_LEFT_SHOULDER, + IDX_RIGHT_HIP, + IDX_RIGHT_SHOULDER, + build_camera_matrix, + compute_3d_features, + estimate_ground_plane, + lift_landmarks_3d, + point_plane_distance, +) + + +class CameraGeometryTests(unittest.TestCase): + def test_camera_matrix_uses_the_horizontal_field_of_view(self): + fx, fy, cx, cy = build_camera_matrix(640, 480, fov_h_deg=90.0) + + self.assertAlmostEqual(fx, 320.0) + self.assertEqual(fy, fx) + self.assertEqual(cx, 320.0) + self.assertEqual(cy, 240.0) + + def test_lift_landmarks_unprojects_pixels_at_the_sampled_depth(self): + landmarks = [ + SimpleNamespace(x=0.5, y=0.5), + SimpleNamespace(x=0.0, y=0.0), + ] + depth_map = np.full((2, 2), 0.5, dtype=np.float32) + + points = lift_landmarks_3d( + landmarks, + depth_map, + width=2, + height=2, + fx=1.0, + fy=1.0, + cx=1.0, + cy=1.0, + ) + + np.testing.assert_allclose(points[0], [0.0, 0.0, 0.5]) + np.testing.assert_allclose(points[1], [-0.5, -0.5, 0.5]) + + def test_ground_plane_estimation_returns_a_unit_plane_for_flat_depth(self): + depth_map = np.full((24, 32), 0.5, dtype=np.float32) + fx, fy, cx, cy = build_camera_matrix(32, 24) + + result = estimate_ground_plane( + depth_map, + fx, + fy, + cx, + cy, + bottom_fraction=0.5, + min_inliers=20, + ) + + self.assertIsNotNone(result) + normal, plane_d = result + self.assertAlmostEqual(float(np.linalg.norm(normal)), 1.0, places=5) + sample = np.array([0.0, 0.0, 0.5], dtype=np.float32) + self.assertLess(abs(point_plane_distance(sample, normal, plane_d)), 1e-4) + + +class FeatureTests(unittest.TestCase): + @staticmethod + def pose(shoulder_mid, hip_mid): + points = np.zeros((33, 3), dtype=np.float32) + shoulder_mid = np.asarray(shoulder_mid, dtype=np.float32) + hip_mid = np.asarray(hip_mid, dtype=np.float32) + points[IDX_LEFT_SHOULDER] = shoulder_mid + points[IDX_RIGHT_SHOULDER] = shoulder_mid + points[IDX_LEFT_HIP] = hip_mid + points[IDX_RIGHT_HIP] = hip_mid + return points + + def test_spine_angle_distinguishes_upright_and_horizontal_poses(self): + normal = np.array([0.0, 1.0, 0.0], dtype=np.float32) + + upright = compute_3d_features( + self.pose([0.0, 0.0, 0.2], [0.0, 1.0, 0.2]), + normal, + 0.0, + deque(maxlen=10), + ) + horizontal = compute_3d_features( + self.pose([0.0, 0.0, 0.2], [1.0, 0.0, 0.2]), + normal, + 0.0, + deque(maxlen=10), + ) + + self.assertTrue(math.isclose(upright["spine_angle_3d"], 0.0, abs_tol=1e-5)) + self.assertTrue(math.isclose(upright["spine_horiz_3d"], 90.0, abs_tol=1e-5)) + self.assertTrue(math.isclose(horizontal["spine_angle_3d"], 90.0, abs_tol=1e-5)) + self.assertTrue(math.isclose(horizontal["spine_horiz_3d"], 0.0, abs_tol=1e-5)) + + def test_com_drop_rate_turns_positive_for_downward_motion(self): + normal = np.array([0.0, 1.0, 0.0], dtype=np.float32) + history = deque(maxlen=10) + + for offset in (0.0, 0.1, 0.2): + metrics = compute_3d_features( + self.pose([0.0, offset, 0.2], [0.0, 1.0 + offset, 0.2]), + normal, + 0.0, + history, + ) + + self.assertGreater(metrics["com_drop_rate"], 0.0) + self.assertGreater(metrics["com_velocity_3d"], 0.0) + + +if __name__ == "__main__": + unittest.main()