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
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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"))
76 changes: 76 additions & 0 deletions tests/test_decimate.py
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions tests/test_geometry.py
Original file line number Diff line number Diff line change
@@ -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])
101 changes: 101 additions & 0 deletions tests/test_smoothing.py
Original file line number Diff line number Diff line change
@@ -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
Loading