Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,31 @@ 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)
```

---

## 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 |
Expand Down
1 change: 0 additions & 1 deletion src/biomechanics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import math
from collections import deque

import cv2
import numpy as np

# MediaPipe landmark indices used in 3D analysis
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
numpy==1.26.4
122 changes: 122 additions & 0 deletions tests/test_biomechanics.py
Original file line number Diff line number Diff line change
@@ -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()