feat(input/gestures): implement normalize_hand() with scale/translation invariance

This commit is contained in:
pyr0ball 2026-04-26 19:58:00 -07:00
parent b2b58913c7
commit 460530bb03
2 changed files with 81 additions and 0 deletions

View file

@ -0,0 +1,32 @@
"""
Landmark normalization for MediaPipe hand landmarks.
Converts raw (21, 3) landmark array into a 63-element translation- and
scale-invariant feature vector suitable for gesture classifiers.
"""
import numpy as np
def normalize_hand(points: np.ndarray) -> np.ndarray:
"""
Normalize 21 MediaPipe hand landmarks into a scale/translation-invariant
63-element feature vector.
Steps:
1. Translate so wrist (landmark 0) is at origin.
2. Scale so distance from wrist to middle-finger MCP (landmark 9) = 1.0.
If that distance is near-zero (degenerate hand), return zeros.
3. Flatten to shape (63,).
Args:
points: (21, 3) float32 array raw MediaPipe landmark coords.
Returns:
(63,) float32 feature vector.
"""
pts = points.astype(np.float32).copy()
pts -= pts[0] # translate: wrist → origin
scale = float(np.linalg.norm(pts[9])) # wrist-to-middle-MCP distance
if scale > 1e-6:
pts /= scale
return pts.flatten()

View file

@ -0,0 +1,49 @@
import numpy as np
import pytest
from circuitforge_core.input.gestures.normalizer import normalize_hand
def _synthetic_hand(scale: float = 1.0, offset: float = 0.0) -> np.ndarray:
"""21 landmarks, wrist at offset, middle MCP at offset + (scale, 0, 0)."""
pts = np.zeros((21, 3), dtype=np.float32)
# All landmarks start at the offset (roughly at the wrist)
for i in range(21):
pts[i] = [offset, 0.0, 0.0]
# Then define a few key landmarks relative to wrist
pts[0] = [offset, 0.0, 0.0] # wrist
pts[9] = [offset + scale, 0.0, 0.0] # middle MCP at distance scale from wrist
pts[1] = [offset + 0.1 * scale, 0.05 * scale, 0.0] # thumb
pts[5] = [offset + 0.4 * scale, 0.2 * scale, 0.0] # index
return pts
def test_output_shape():
pts = _synthetic_hand()
result = normalize_hand(pts)
assert result.shape == (63,)
def test_translation_invariance():
pts_a = _synthetic_hand(offset=0.0)
pts_b = _synthetic_hand(offset=5.0)
np.testing.assert_allclose(normalize_hand(pts_a), normalize_hand(pts_b), atol=1e-5)
def test_scale_invariance():
pts_small = _synthetic_hand(scale=0.5)
pts_large = _synthetic_hand(scale=2.0)
np.testing.assert_allclose(normalize_hand(pts_small), normalize_hand(pts_large), atol=1e-5)
def test_zero_scale_does_not_crash():
"""All landmarks at same point — degenerate hand. Should return zeros, not raise."""
pts = np.zeros((21, 3), dtype=np.float32)
result = normalize_hand(pts)
assert result.shape == (63,)
assert not np.any(np.isnan(result))
def test_dtype_is_float32():
pts = _synthetic_hand()
result = normalize_hand(pts)
assert result.dtype == np.float32