LSF_transcription / lsf /features.py
SamDNX's picture
Initial commit: LSF (French Sign Language) interpreter
5029d29
Raw
History Blame Contribute Delete
4.72 kB
"""Turn a `LandmarkFrame` into a fixed-length, normalised feature vector.
Design goals
------------
* **Fixed length** regardless of which body parts are visible (missing groups
are zero-filled and flagged), so downstream buffers/matchers stay simple.
* **Body-relative** coordinates: we re-express every landmark in a frame whose
origin is the shoulder centre and whose unit is the shoulder width. This makes
the features invariant to where the signer stands and how far they are from
the camera, while preserving the three things LSF phonology cares about:
hand *location* relative to the body, hand *shape* (all 21 finger points),
and *movement* (because successive frames share the same reference).
* Only the face points that carry linguistic meaning in LSF are kept: eyes /
gaze, eyebrows, and mouth (mouthing + non-manual markers).
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from .landmarks import LandmarkFrame
# Pose points relevant to arms + chest (MediaPipe Pose indices).
POSE_IDX = [
0, # nose
11, 12, # shoulders (chest line)
13, 14, # elbows
15, 16, # wrists
23, 24, # hips (torso anchor)
]
# Curated face mesh points: brows, eyes (+ iris when refined), nose, lips.
FACE_IDX = [
# eyebrows
70, 105, 107, 336, 334, 300,
# left eye ring
33, 159, 145, 133,
# right eye ring
362, 386, 374, 263,
# irises (present only with refine_face_landmarks=True)
468, 473,
# nose tip + bridge
1, 4,
# outer lips
61, 291, 0, 17,
# inner lips (mouth opening)
13, 14,
]
N_POSE_SEL = len(POSE_IDX)
N_FACE_SEL = len(FACE_IDX)
N_HAND = 21
# 3 coords (x, y, z) per point, for pose-subset + face-subset + two hands,
# plus 4 presence flags (pose, face, left hand, right hand).
FEATURE_DIM = (N_POSE_SEL + N_FACE_SEL + N_HAND + N_HAND) * 3 + 4
def _reference(frame: LandmarkFrame) -> tuple[np.ndarray, float]:
"""Pick an (origin_xy z), scale to normalise a frame into a body frame.
Prefers shoulders; falls back to face span, then to hand wrists, so the
pipeline still produces stable features when only part of the body shows.
"""
if frame.pose is not None:
l_sh, r_sh = frame.pose[11, :3], frame.pose[12, :3]
origin = (l_sh + r_sh) / 2.0
scale = float(np.linalg.norm(l_sh[:2] - r_sh[:2]))
elif frame.face is not None:
# Use inter-ocular-ish span (eye corners) as scale.
origin = frame.face[1, :3] # nose tip
scale = float(np.linalg.norm(frame.face[33, :2] - frame.face[263, :2]))
else:
wrists = [h[0, :3] for h in (frame.left_hand, frame.right_hand) if h is not None]
origin = np.mean(wrists, axis=0) if wrists else np.zeros(3, np.float32)
scale = 0.2 # arbitrary but non-zero; single-hand close-up
return origin.astype(np.float32), (scale or 1e-3)
def _normalise(points: Optional[np.ndarray], idx, origin, scale) -> np.ndarray:
"""Return (len(idx), 3) body-relative coords, or zeros when absent."""
n = len(idx)
if points is None:
return np.zeros((n, 3), dtype=np.float32)
# Guard against meshes without iris points (refine disabled).
safe_idx = [i if i < points.shape[0] else 0 for i in idx]
sel = points[safe_idx, :3]
return (sel - origin) / scale
def _normalise_all(points: Optional[np.ndarray], origin, scale) -> np.ndarray:
n = N_HAND
if points is None:
return np.zeros((n, 3), dtype=np.float32)
return (points[:, :3] - origin) / scale
def extract_features(frame: LandmarkFrame) -> np.ndarray:
"""Flatten a LandmarkFrame into a (FEATURE_DIM,) float32 vector."""
origin, scale = _reference(frame)
pose = _normalise(frame.pose, POSE_IDX, origin, scale)
face = _normalise(frame.face, FACE_IDX, origin, scale)
lh = _normalise_all(frame.left_hand, origin, scale)
rh = _normalise_all(frame.right_hand, origin, scale)
flags = np.array(
[
frame.pose is not None,
frame.face is not None,
frame.left_hand is not None,
frame.right_hand is not None,
],
dtype=np.float32,
)
return np.concatenate(
[pose.ravel(), face.ravel(), lh.ravel(), rh.ravel(), flags]
).astype(np.float32)
def mouth_openness(frame: LandmarkFrame) -> float:
"""Vertical lip gap / mouth width, a cheap non-manual marker. 0 when no face."""
if frame.face is None:
return 0.0
top, bottom = frame.face[13, :2], frame.face[14, :2]
left, right = frame.face[61, :2], frame.face[291, :2]
width = float(np.linalg.norm(left - right)) or 1e-3
return float(np.linalg.norm(top - bottom)) / width