diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..dc20f0c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements-dev.txt + + # The tested modules only need NumPy and OpenCV, so PyTorch and + # Ultralytics are not installed to keep this job fast. + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install numpy opencv-python-headless pillow -r requirements-dev.txt + + - name: Lint (syntax errors and undefined names only) + run: | + pip install ruff + ruff check --select E9,F63,F7,F82 . + + - name: Run tests + run: pytest tests -q diff --git a/README.md b/README.md index f127304..4c41e2b 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,13 @@ python src/app.py --model "Rigged Figure" python src/app.py --camera 1 --pose-size 448 --no-mirror ``` +## Tests + +Unit tests cover the smoothing filters, rotation/skinning math, glTF node matrices and mesh decimation. They need only NumPy and OpenCV (no PyTorch, weights or camera). + +```bash +pip install pytest +pytest tests +``` + by Salimli Ayzek (Салимли Айзек): https://mathematiclove.github.io diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4af617c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + +# The modules in src/ import each other by bare name (`import gltf`). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) diff --git a/tests/test_decimate.py b/tests/test_decimate.py new file mode 100644 index 0000000..d95c294 --- /dev/null +++ b/tests/test_decimate.py @@ -0,0 +1,76 @@ +import numpy as np + +import decimate +import gltf + + +def grid_primitive(n=30, with_uv=False): + """A flat n x n grid of quads (2 triangles each) bound to a single bone.""" + xs, ys = np.meshgrid(np.linspace(0, 1, n), np.linspace(0, 1, n)) + pos = np.column_stack([xs.ravel(), ys.ravel(), np.zeros(n * n)]) + idx = np.arange(n * n).reshape(n, n) + tris = [] + for r in range(n - 1): + for c in range(n - 1): + a, b, d, e = idx[r, c], idx[r, c + 1], idx[r + 1, c], idx[r + 1, c + 1] + tris += [[a, b, d], [b, e, d]] + count = n * n + return gltf.Primitive( + positions=pos, + normals=np.tile([0.0, 0.0, 1.0], (count, 1)), + joints=np.zeros((count, 4), dtype=np.int64), + weights=np.tile([1.0, 0.0, 0.0, 0.0], (count, 1)), + triangles=np.array(tris, dtype=np.int64), + uv=pos[:, :2].copy() if with_uv else None, + ) + + +def total_triangles(prims): + return sum(len(p.triangles) for p in prims) + + +def test_under_budget_is_returned_untouched(): + prims = [grid_primitive(10)] + assert decimate.to_budget(prims, budget=10_000) is prims + + +def test_reduces_triangles_toward_budget(): + prim = grid_primitive(40) + before = len(prim.triangles) + out = decimate.to_budget([prim], budget=before // 4) + assert total_triangles(out) < before + + +def test_attributes_stay_consistent(): + out = decimate.to_budget([grid_primitive(40)], budget=300)[0] + n = len(out.positions) + assert len(out.normals) == len(out.joints) == len(out.weights) == n + assert out.triangles.max() < n + assert out.triangles.min() >= 0 + + +def test_no_degenerate_triangles_remain(): + out = decimate.to_budget([grid_primitive(40)], budget=300)[0] + t = out.triangles + assert np.all(t[:, 0] != t[:, 1]) + assert np.all(t[:, 1] != t[:, 2]) + assert np.all(t[:, 0] != t[:, 2]) + + +def test_uv_is_carried_along(): + out = decimate.to_budget([grid_primitive(40, with_uv=True)], budget=300)[0] + assert out.uv is not None and len(out.uv) == len(out.positions) + + +def test_different_bones_are_not_welded_together(): + prim = grid_primitive(20) + # Alternate the dominant bone so neighbouring vertices belong to different bones. + prim.joints[:, 0] = np.arange(len(prim.joints)) % 2 + welded = decimate._weld(prim, cells=4) + plain = decimate._weld(grid_primitive(20), cells=4) + assert len(welded.positions) > len(plain.positions) + + +def test_weld_returns_input_if_everything_collapses(): + prim = grid_primitive(5) + assert decimate._weld(prim, cells=1) is prim diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..67f71ba --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,126 @@ +import numpy as np +import pytest + +from character import ( + _axis_rotation, + _orthonormal_inverse, + _rotation_between, + _scale_rotation, +) +from gltf import _node_matrix +from mesh3d import _rotation + + +def is_rotation(m): + return np.allclose(m @ m.T, np.eye(3), atol=1e-9) and np.isclose(np.linalg.det(m), 1.0) + + +class TestRotationBetween: + def test_maps_a_onto_b(self): + a = np.array([1.0, 0.0, 0.0]) + b = np.array([0.0, 1.0, 1.0]) + r = _rotation_between(a, b) + assert is_rotation(r) + assert np.allclose(r @ a, b / np.linalg.norm(b)) + + def test_same_vector_is_identity(self): + v = np.array([0.0, 2.0, 0.0]) + assert np.allclose(_rotation_between(v, v), np.eye(3)) + + def test_opposite_vectors_flip(self): + a = np.array([0.0, 0.0, 1.0]) + r = _rotation_between(a, -a) + assert np.allclose(r @ a, -a) + assert np.allclose(r @ r.T, np.eye(3)) + + def test_opposite_along_x_uses_other_axis(self): + a = np.array([1.0, 0.0, 0.0]) + assert np.allclose(_rotation_between(a, -a) @ a, -a) + + +class TestOrthonormalInverse: + def test_rotation_inverse_is_transpose(self): + r = _rotation(0.4, -0.7, 0.2) + assert np.allclose(_orthonormal_inverse(r) @ r, np.eye(3)) + + def test_uniform_scale_is_removed(self): + r = 2.5 * _rotation(0.3, 0.2) + assert np.allclose(_orthonormal_inverse(r) @ r, np.eye(3)) + + def test_degenerate_matrix_returns_identity(self): + assert np.allclose(_orthonormal_inverse(np.zeros((3, 3))), np.eye(3)) + + +class TestAxisRotation: + def test_quarter_turn_about_z(self): + r = _axis_rotation(np.array([0.0, 0.0, 1.0]), np.pi / 2) + assert np.allclose(r @ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + + def test_axis_is_normalized(self): + a = _axis_rotation(np.array([0.0, 0.0, 5.0]), 0.8) + b = _axis_rotation(np.array([0.0, 0.0, 1.0]), 0.8) + assert np.allclose(a, b) + + def test_zero_axis_is_identity(self): + assert np.allclose(_axis_rotation(np.zeros(3), 1.0), np.eye(3)) + + def test_result_is_a_rotation(self): + assert is_rotation(_axis_rotation(np.array([1.0, 2.0, 3.0]), 1.1)) + + +class TestScaleRotation: + def test_gain_two_doubles_angle(self): + axis = np.array([0.0, 1.0, 0.0]) + r = _axis_rotation(axis, 0.4) + assert np.allclose(_scale_rotation(r, 2.0), _axis_rotation(axis, 0.8)) + + def test_gain_zero_gives_identity(self): + r = _axis_rotation(np.array([1.0, 0.0, 0.0]), 0.5) + assert np.allclose(_scale_rotation(r, 0.0), np.eye(3)) + + def test_identity_is_returned_unchanged(self): + assert np.allclose(_scale_rotation(np.eye(3), 3.0), np.eye(3)) + + def test_angle_is_clamped(self): + r = _axis_rotation(np.array([0.0, 0.0, 1.0]), 2.0) + assert is_rotation(_scale_rotation(r, 10.0)) + + +class TestMeshRotation: + def test_zero_angles_is_identity(self): + assert np.allclose(_rotation(0.0, 0.0, 0.0), np.eye(3)) + + @pytest.mark.parametrize("angles", [(0.3, 0.1, 0.0), (1.2, -0.8, 0.5), (3.0, 2.0, -1.0)]) + def test_is_a_rotation(self, angles): + assert is_rotation(_rotation(*angles)) + + def test_yaw_turns_around_y(self): + r = _rotation(np.pi / 2, 0.0) + assert np.allclose(r @ [0.0, 1.0, 0.0], [0.0, 1.0, 0.0]) + assert np.allclose(r @ [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]) + + +class TestNodeMatrix: + def test_empty_node_is_identity(self): + assert np.allclose(_node_matrix({}), np.eye(4)) + + def test_translation(self): + m = _node_matrix({"translation": [1, 2, 3]}) + assert np.allclose(m[:3, 3], [1, 2, 3]) + + def test_scale(self): + m = _node_matrix({"scale": [2, 3, 4]}) + assert np.allclose(np.diag(m)[:3], [2, 3, 4]) + + def test_identity_quaternion(self): + assert np.allclose(_node_matrix({"rotation": [0, 0, 0, 1]}), np.eye(4)) + + def test_quaternion_quarter_turn_about_z(self): + s = np.sqrt(0.5) + m = _node_matrix({"rotation": [0, 0, s, s]}) + assert np.allclose(m[:3, :3] @ [1, 0, 0], [0, 1, 0]) + + def test_explicit_matrix_is_column_major(self): + col_major = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1] + m = _node_matrix({"matrix": col_major}) + assert np.allclose(m[:3, 3], [5, 6, 7]) diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py new file mode 100644 index 0000000..546048d --- /dev/null +++ b/tests/test_smoothing.py @@ -0,0 +1,101 @@ +import numpy as np +import pytest + +from smoothing import LandmarkFilter, OneEuroFilter, ScalarEMA, _LowPass + + +class TestLowPass: + def test_first_sample_passes_through(self): + f = _LowPass() + assert np.allclose(f(np.array([3.0, 4.0]), 0.5), [3.0, 4.0]) + + def test_alpha_one_follows_input(self): + f = _LowPass() + f(np.array([0.0]), 1.0) + assert f(np.array([10.0]), 1.0)[0] == 10.0 + + def test_blends_with_previous(self): + f = _LowPass() + f(np.array([0.0]), 0.5) + assert f(np.array([10.0]), 0.5)[0] == pytest.approx(5.0) + + def test_reset_forgets_state(self): + f = _LowPass() + f(np.array([1.0]), 0.5) + f.reset() + assert f(np.array([9.0]), 0.1)[0] == 9.0 + + +class TestOneEuroFilter: + def test_alpha_is_between_zero_and_one_and_grows_with_cutoff(self): + low = OneEuroFilter._alpha(0.5, 30.0) + high = OneEuroFilter._alpha(5.0, 30.0) + assert 0.0 < low < high < 1.0 + + def test_constant_signal_stays_constant(self): + f = OneEuroFilter() + for _ in range(20): + out = f([100.0, 200.0]) + assert np.allclose(out, [100.0, 200.0]) + + def test_first_point_is_unchanged(self): + assert np.allclose(OneEuroFilter()([5.0, 6.0]), [5.0, 6.0]) + + def test_jitter_is_reduced(self): + rng = np.random.default_rng(1) + f = OneEuroFilter() + noisy = 50 + rng.normal(0, 3, size=(200, 2)) + out = np.array([f(p, dt=1 / 30) for p in noisy]) + assert out[50:].std() < noisy[50:].std() + + def test_dt_updates_frequency(self): + f = OneEuroFilter() + f([0.0, 0.0], dt=0.5) + assert f.freq == pytest.approx(2.0) + + def test_reset(self): + f = OneEuroFilter() + f([1.0, 1.0]) + f.reset() + assert np.allclose(f([8.0, 8.0]), [8.0, 8.0]) + + +class TestLandmarkFilter: + def test_first_frame_unchanged(self): + pts = np.arange(34, dtype=float).reshape(17, 2) + assert np.allclose(LandmarkFilter()(pts), pts) + + def test_constant_pose_stays_constant(self): + pts = np.random.default_rng(2).random((17, 2)) * 100 + f = LandmarkFilter() + for _ in range(15): + out = f(pts) + assert np.allclose(out, pts) + + def test_shape_change_resets_state(self): + f = LandmarkFilter() + f(np.zeros((17, 2))) + new = np.ones((17, 3)) * 4 + assert np.allclose(f(new), new) + + def test_output_keeps_shape(self): + out = LandmarkFilter()(np.zeros((17, 3))) + assert out.shape == (17, 3) + + +class TestScalarEMA: + def test_moves_toward_target(self): + ema = ScalarEMA(alpha=0.5, value=0.0) + assert ema(10.0) == pytest.approx(5.0) + assert ema(10.0) == pytest.approx(7.5) + + def test_converges(self): + ema = ScalarEMA(alpha=0.35) + for _ in range(100): + ema(3.0) + assert ema.value == pytest.approx(3.0, abs=1e-6) + + def test_set(self): + ema = ScalarEMA() + ema.set(4.0) + assert ema.value == 4.0