feat(data-only-viz): action-head v2 fingers+face
Extend action-head to 32 joints (body22 + 10 fingertips), 10 SMPL-X expression PCA scalars, and mouth_open distance. FEATURE_DIM 201→302. MIRROR_MAP extended to 32. Dataset, augment, training, publisher, offline extractor all updated.
This commit is contained in:
@@ -21,12 +21,26 @@ WARMUP_FRAMES: int = 3
|
||||
NAN_SKIP_BUDGET: int = 5
|
||||
|
||||
WINDOW_LEN: int = 16
|
||||
J3D_JOINTS: int = 22
|
||||
J3D_BODY: int = 22
|
||||
J3D_FINGERS_PER_HAND: int = 5
|
||||
J3D_FINGERS: int = 2 * J3D_FINGERS_PER_HAND # 10
|
||||
J3D_JOINTS: int = J3D_BODY + J3D_FINGERS # 32
|
||||
J3D_DIMS: int = 3
|
||||
NUM_CLASSES: int = 3
|
||||
LABELS: tuple[str, str, str] = ("debout", "assise", "danse")
|
||||
FEATURE_DIM: int = J3D_JOINTS * J3D_DIMS * 3 + 3 # j3d + vel + accel + 3 scalars
|
||||
|
||||
EXPR_DIM: int = 10
|
||||
EXTRA_SCALARS: int = 4 # hip_y, knee_angle, sym_score, mouth_open
|
||||
|
||||
# Layout per step:
|
||||
# [0 : 96 ] j3d (32, 3)
|
||||
# [96 : 192] vel (32, 3)
|
||||
# [192 : 288] accel (32, 3)
|
||||
# [288 : 298] expression (10,)
|
||||
# [298 : 302] scalars (hip_y, knee_angle, sym, mouth_open)
|
||||
FEATURE_DIM: int = J3D_JOINTS * J3D_DIMS * 3 + EXPR_DIM + EXTRA_SCALARS # 302
|
||||
|
||||
# Body joint indices (unchanged from v1, indices 0..21).
|
||||
HIP_LEFT: int = 1
|
||||
HIP_RIGHT: int = 2
|
||||
KNEE_LEFT: int = 4
|
||||
@@ -38,19 +52,26 @@ SHOULDER_RIGHT: int = 17
|
||||
WRIST_LEFT: int = 20
|
||||
WRIST_RIGHT: int = 21
|
||||
|
||||
# Fingertip indices (new, 22..31), order: L thumb..pinky, R thumb..pinky.
|
||||
FINGERTIP_LEFT_BASE: int = 22
|
||||
FINGERTIP_RIGHT_BASE: int = 27
|
||||
|
||||
|
||||
class FeatureExtractor:
|
||||
"""Stateless feature builder over a list of recent j3d frames.
|
||||
|
||||
Vector layout (FEATURE_DIM = 201):
|
||||
[0 : 66] j3d current frame, flattened (22 joints × 3 dims)
|
||||
[66 : 132] velocity j3d[t] - j3d[t-1] (22 × 3)
|
||||
[132 : 198] acceleration vel[t] - vel[t-1] (22 × 3)
|
||||
[198 : 201] kinetics scalars (hip_y, knee_angle, symmetry_score)
|
||||
Vector layout (FEATURE_DIM = 302):
|
||||
[0 : 96 ] j3d current frame, flattened (32 joints x 3 dims)
|
||||
[96 : 192] velocity j3d[t] - j3d[t-1] (32 x 3)
|
||||
[192 : 288] acceleration vel[t] - vel[t-1] (32 x 3)
|
||||
[288 : 298] expression PCA coefficients (10,)
|
||||
[298 : 302] kinetics scalars (hip_y, knee_angle, symmetry_score, mouth_open)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_buffer(frames: list[np.ndarray]) -> np.ndarray:
|
||||
def from_buffer(frames: list[np.ndarray],
|
||||
expr: np.ndarray | None = None,
|
||||
mouth_open: float = 0.0) -> np.ndarray:
|
||||
if not frames:
|
||||
return np.zeros(FEATURE_DIM, dtype=np.float32)
|
||||
cur = frames[-1]
|
||||
@@ -62,13 +83,19 @@ class FeatureExtractor:
|
||||
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
|
||||
knee_angle = FeatureExtractor._mean_knee_angle(cur)
|
||||
sym = FeatureExtractor._symmetry_score(vel)
|
||||
feat = np.concatenate([
|
||||
if expr is None:
|
||||
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
else:
|
||||
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
n = min(EXPR_DIM, len(expr))
|
||||
expr_vec[:n] = expr[:n]
|
||||
return np.concatenate([
|
||||
cur.reshape(-1),
|
||||
vel.reshape(-1),
|
||||
accel.reshape(-1),
|
||||
np.array([hip_y, knee_angle, sym], dtype=np.float32),
|
||||
expr_vec,
|
||||
np.array([hip_y, knee_angle, sym, float(mouth_open)], dtype=np.float32),
|
||||
]).astype(np.float32, copy=False)
|
||||
return feat
|
||||
|
||||
@staticmethod
|
||||
def kinetics(frames: list[np.ndarray]) -> np.ndarray:
|
||||
@@ -148,7 +175,7 @@ class PerPersonBuffer:
|
||||
class ActionHeadModel(nn.Module):
|
||||
"""1-layer GRU + small MLP head.
|
||||
|
||||
Input : (B, FEATURE_DIM) — single step
|
||||
Input : (B, FEATURE_DIM) -- single step
|
||||
Hidden : (1, B, HIDDEN_DIM)
|
||||
Output : (B, NUM_CLASSES) logits, new hidden
|
||||
"""
|
||||
@@ -198,7 +225,9 @@ class ActionHead:
|
||||
self._hidden: dict[int, torch.Tensor] = {}
|
||||
self._nan_streak: dict[int, int] = {}
|
||||
|
||||
def step(self, pid: int, j3d: np.ndarray) -> tuple[str, np.ndarray, np.ndarray]:
|
||||
def step(self, pid: int, j3d: np.ndarray,
|
||||
expr: np.ndarray | None = None,
|
||||
mouth_open: float = 0.0) -> tuple[str, np.ndarray, np.ndarray]:
|
||||
if np.isnan(j3d).any():
|
||||
streak = self._nan_streak.get(pid, 0) + 1
|
||||
self._nan_streak[pid] = streak
|
||||
@@ -212,7 +241,7 @@ class ActionHead:
|
||||
if len(frames) < WARMUP_FRAMES:
|
||||
probs = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
||||
return LABELS[0], probs, np.zeros(3, dtype=np.float32)
|
||||
feat = FeatureExtractor.from_buffer(frames)
|
||||
feat = FeatureExtractor.from_buffer(frames, expr=expr, mouth_open=mouth_open)
|
||||
kin = FeatureExtractor.kinetics(frames)
|
||||
h = self._hidden.get(pid)
|
||||
if h is None:
|
||||
|
||||
@@ -14,7 +14,13 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.action_head import ActionHead, LABELS
|
||||
from data_only_viz.action_head import (
|
||||
ActionHead,
|
||||
EXPR_DIM,
|
||||
J3D_FINGERS,
|
||||
J3D_FINGERS_PER_HAND,
|
||||
LABELS,
|
||||
)
|
||||
|
||||
LOG = logging.getLogger("action_head_pub")
|
||||
|
||||
@@ -22,19 +28,39 @@ DEFAULT_CKPT = (
|
||||
Path.home() / ".cache" / "av-live-action" / "checkpoints" / "action_head.pt"
|
||||
)
|
||||
|
||||
# 22 vertex indices on the 10475-vertex SMPL-X mesh, approximating
|
||||
# the 22-joint kinematic chain used by ActionHead.
|
||||
# NOTE: approximate vertex anchors — real SMPL-X joints come from
|
||||
# Approximate fingertip vertex indices on SMPL-X 10475-vert mesh.
|
||||
# Order: L thumb, L index, L middle, L ring, L pinky,
|
||||
# R thumb, R index, R middle, R ring, R pinky.
|
||||
SMPLX_FINGERTIP_VERTS: tuple[int, ...] = (
|
||||
7174, 7397, 7670, 7942, 8214, # L
|
||||
4631, 4854, 5127, 5399, 5671, # R
|
||||
)
|
||||
|
||||
# 32 vertex indices on the 10475-vertex SMPL-X mesh:
|
||||
# 22 body (UNCHANGED from v1) + 10 fingertips.
|
||||
# NOTE: approximate vertex anchors -- real SMPL-X joints come from
|
||||
# J_regressor @ v3d, but loading the regressor here is avoided for
|
||||
# live OSC performance. Action-head training must use the same anchors.
|
||||
SMPLX_JOINT_ANCHOR_VERTS: tuple[int, ...] = (
|
||||
# 22 body (UNCHANGED indices, same vertex IDs as before)
|
||||
8204, 3992, 6677, 3500, 3469, 6394, 3279, 3327, 6736, 3074,
|
||||
8846, 8889, 8848, 1300, 4660, 8964, 3013, 6470, 1602, 5083,
|
||||
2114, 5559,
|
||||
# 10 fingertips
|
||||
*SMPLX_FINGERTIP_VERTS,
|
||||
)
|
||||
assert len(SMPLX_JOINT_ANCHOR_VERTS) == 32
|
||||
|
||||
# Mouth-open: distance between two lip vertices on SMPL-X mesh.
|
||||
# vert 8970 (upper outer lip), 8855 (lower outer lip) -- approximate.
|
||||
SMPLX_UPPER_LIP_VERT: int = 8970
|
||||
SMPLX_LOWER_LIP_VERT: int = 8855
|
||||
|
||||
# MediaPipe HAND fingertip indices (21-kp hand model).
|
||||
MEDIAPIPE_HAND_FINGERTIPS: tuple[int, ...] = (4, 8, 12, 16, 20)
|
||||
|
||||
# MediaPipe 33-landmark indices mapped into the 22-joint slot order.
|
||||
# NOTE: approximate mapping — spine joints reuse hip/shoulder anchors.
|
||||
# NOTE: approximate mapping -- spine joints reuse hip/shoulder anchors.
|
||||
# https://developers.google.com/mediapipe/solutions/vision/pose_landmarker
|
||||
MEDIAPIPE_TO_22: tuple[int, ...] = (
|
||||
24, 23, 24, 23, 25, 26, 11, 27, 28, 11,
|
||||
@@ -85,7 +111,7 @@ class ActionHeadPublisher(threading.Thread):
|
||||
LOG.info("publisher stopped")
|
||||
|
||||
def _tick(self, t_now: float) -> None:
|
||||
persons22, source_t, source_tag, is_new = self._read_sources()
|
||||
persons32, source_t, source_tag, is_new = self._read_sources()
|
||||
if not is_new:
|
||||
return
|
||||
if "smplx" in source_tag:
|
||||
@@ -93,10 +119,12 @@ class ActionHeadPublisher(threading.Thread):
|
||||
else:
|
||||
self._last_body_t = source_t
|
||||
current_pids: set[int] = set()
|
||||
if persons22:
|
||||
for pid, j3d in persons22:
|
||||
if persons32:
|
||||
for pid, j3d, expr, mouth in persons32:
|
||||
current_pids.add(pid)
|
||||
label, probs, kin = self.head.step(pid, j3d)
|
||||
label, probs, kin = self.head.step(pid, j3d,
|
||||
expr=expr,
|
||||
mouth_open=mouth)
|
||||
idx = LABELS.index(label)
|
||||
self.bridge.send_action(pid, idx, probs, t_now, force=True)
|
||||
self.bridge.send_kin(pid, kin, t_now, force=True)
|
||||
@@ -109,9 +137,11 @@ class ActionHeadPublisher(threading.Thread):
|
||||
|
||||
def _read_sources(
|
||||
self,
|
||||
) -> tuple[list[tuple[int, np.ndarray]] | None, float, str, bool]:
|
||||
"""Return (persons22, source_t, source_tag, is_new).
|
||||
) -> tuple[list[tuple[int, np.ndarray, np.ndarray, float]] | None,
|
||||
float, str, bool]:
|
||||
"""Return (persons32, source_t, source_tag, is_new).
|
||||
|
||||
Each person entry is (pid, j3d32, expr10, mouth_open).
|
||||
is_new is True when the timestamp advanced (even if person list
|
||||
is empty), so _tick can still run the purge loop.
|
||||
"""
|
||||
@@ -121,9 +151,11 @@ class ActionHeadPublisher(threading.Thread):
|
||||
persons_b3d = getattr(self.state, "persons_body3d", None)
|
||||
ids_b3d = getattr(self.state, "persons_body_ids", None)
|
||||
t_body = getattr(self.state, "pose_last_t", 0.0)
|
||||
hands_ids = list(getattr(self.state, "persons_hands_ids", None) or [])
|
||||
hands_lists = list(getattr(self.state, "persons_hands", None) or [])
|
||||
# Prefer smplx when its timestamp advanced.
|
||||
if t_smplx > self._last_smplx_t:
|
||||
out: list[tuple[int, np.ndarray]] = []
|
||||
out: list[tuple[int, np.ndarray, np.ndarray, float]] = []
|
||||
for i, p in enumerate(persons_smplx or []):
|
||||
pid = int(p.get("pid", i))
|
||||
v3d = p.get("v3d")
|
||||
@@ -136,19 +168,55 @@ class ActionHeadPublisher(threading.Thread):
|
||||
v3d_np = np.asarray(v3d, dtype=np.float32)
|
||||
if v3d_np.shape[0] < max(SMPLX_JOINT_ANCHOR_VERTS) + 1:
|
||||
continue
|
||||
j3d22 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
|
||||
out.append((pid, j3d22))
|
||||
j3d32 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
|
||||
# expression
|
||||
expr = p.get("expression")
|
||||
if expr is not None:
|
||||
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
|
||||
expr = expr.numpy()
|
||||
expr_np = np.asarray(expr, dtype=np.float32).flatten()
|
||||
else:
|
||||
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
# mouth_open
|
||||
if v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
|
||||
mouth = float(np.linalg.norm(
|
||||
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
|
||||
))
|
||||
else:
|
||||
mouth = 0.0
|
||||
out.append((pid, j3d32, expr_np, mouth))
|
||||
return out or None, t_smplx, "smplx", True
|
||||
if t_body > self._last_body_t:
|
||||
ids = ids_b3d or list(range(len(persons_b3d or [])))
|
||||
# Build hands lookup by pid
|
||||
hands_by_pid: dict[int, dict[str, Any]] = {}
|
||||
for hi, hkp in enumerate(hands_lists):
|
||||
hpid = int(hands_ids[hi]) if hi < len(hands_ids) else hi
|
||||
side = "L" if hi % 2 == 0 else "R"
|
||||
hands_by_pid.setdefault(hpid, {})[side] = hkp
|
||||
out = []
|
||||
for i, body in enumerate(persons_b3d or []):
|
||||
pid = int(ids[i]) if i < len(ids) else i
|
||||
arr = self._kp_list_to_array(body)
|
||||
if arr is None or arr.shape[0] < 33:
|
||||
continue
|
||||
j3d22 = arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
|
||||
out.append((pid, j3d22))
|
||||
body22 = arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
|
||||
# fingertips from hands if available
|
||||
tips = np.zeros((J3D_FINGERS, 3), dtype=np.float32)
|
||||
hpair = hands_by_pid.get(pid, {})
|
||||
for side_idx, side in enumerate(("L", "R")):
|
||||
hkp = hpair.get(side)
|
||||
if hkp is None:
|
||||
continue
|
||||
hkp_arr = self._kp_list_to_array(hkp)
|
||||
if hkp_arr is None or hkp_arr.shape[0] < 21:
|
||||
continue
|
||||
for k, mp_idx in enumerate(MEDIAPIPE_HAND_FINGERTIPS):
|
||||
tips[side_idx * J3D_FINGERS_PER_HAND + k] = hkp_arr[mp_idx]
|
||||
j3d32 = np.concatenate([body22, tips], axis=0)
|
||||
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
mouth = 0.0
|
||||
out.append((pid, j3d32, expr_np, mouth))
|
||||
return out or None, t_body, "body3d", True
|
||||
return None, 0.0, "", False
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Extract j3d (22 SMPL-X joint anchors) from a recorded MP4 using the
|
||||
"""Extract j3d (32 SMPL-X joint anchors) from a recorded MP4 using the
|
||||
Multi-HMR CoreML backend, write per-frame per-person jsonl rows.
|
||||
|
||||
Usage:
|
||||
@@ -17,7 +17,12 @@ from pathlib import Path
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.action_head_pub import SMPLX_JOINT_ANCHOR_VERTS
|
||||
from data_only_viz.action_head import EXPR_DIM
|
||||
from data_only_viz.action_head_pub import (
|
||||
SMPLX_JOINT_ANCHOR_VERTS,
|
||||
SMPLX_UPPER_LIP_VERT,
|
||||
SMPLX_LOWER_LIP_VERT,
|
||||
)
|
||||
from data_only_viz.multihmr_coreml import MultiHMRCoreMLBackend
|
||||
|
||||
LOG = logging.getLogger("extract_j3d_offline")
|
||||
@@ -47,7 +52,11 @@ def _frame_to_chw(frame_bgr: np.ndarray, size: int = IMG_SIZE) -> np.ndarray:
|
||||
return rgb.transpose(2, 0, 1) # CHW
|
||||
|
||||
|
||||
def _person_to_j3d22(person: dict, anchors: tuple[int, ...]) -> np.ndarray | None:
|
||||
def _person_to_j3d32(
|
||||
person: dict,
|
||||
anchors: tuple[int, ...],
|
||||
) -> tuple[np.ndarray, np.ndarray, float] | None:
|
||||
"""Return (j3d32, expression, mouth_open) or None if v3d absent/too small."""
|
||||
v3d = person.get("v3d")
|
||||
if v3d is None:
|
||||
return None
|
||||
@@ -57,7 +66,23 @@ def _person_to_j3d22(person: dict, anchors: tuple[int, ...]) -> np.ndarray | Non
|
||||
v3d_np = np.asarray(v3d, dtype=np.float32)
|
||||
if v3d_np.shape[0] < max(anchors) + 1:
|
||||
return None
|
||||
return v3d_np[list(anchors)].astype(np.float32)
|
||||
j3d32 = v3d_np[list(anchors)].astype(np.float32)
|
||||
# expression
|
||||
expr = person.get("expression")
|
||||
if expr is not None:
|
||||
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
|
||||
expr = expr.numpy()
|
||||
expr_np = np.asarray(expr, dtype=np.float32).flatten()
|
||||
else:
|
||||
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
# mouth_open
|
||||
if v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
|
||||
mouth = float(np.linalg.norm(
|
||||
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
|
||||
))
|
||||
else:
|
||||
mouth = 0.0
|
||||
return j3d32, expr_np, mouth
|
||||
|
||||
|
||||
def extract(session: str, video: Path, out: Path,
|
||||
@@ -89,14 +114,17 @@ def extract(session: str, video: Path, out: Path,
|
||||
continue
|
||||
ts = n_frames / fps
|
||||
for i, person in enumerate(persons):
|
||||
j3d = _person_to_j3d22(person, anchors)
|
||||
if j3d is None:
|
||||
result = _person_to_j3d32(person, anchors)
|
||||
if result is None:
|
||||
continue
|
||||
j3d32, expr_np, mouth = result
|
||||
f.write(json.dumps({
|
||||
"ts": ts,
|
||||
"session": session,
|
||||
"pid": int(person.get("pid", i)),
|
||||
"j3d": j3d.tolist(),
|
||||
"j3d": j3d32.tolist(),
|
||||
"expression": expr_np.tolist(),
|
||||
"mouth_open": mouth,
|
||||
}) + "\n")
|
||||
n_rows += 1
|
||||
n_frames += 1
|
||||
|
||||
@@ -11,14 +11,15 @@ def test_module_imports() -> None:
|
||||
assert hasattr(action_head, "PerPersonBuffer")
|
||||
assert hasattr(action_head, "ActionHead")
|
||||
assert action_head.WINDOW_LEN == 16
|
||||
assert action_head.J3D_JOINTS == 22
|
||||
assert action_head.J3D_JOINTS == 32
|
||||
assert action_head.FEATURE_DIM == 302
|
||||
assert action_head.NUM_CLASSES == 3
|
||||
assert action_head.LABELS == ("debout", "assise", "danse")
|
||||
|
||||
|
||||
def _rand_j3d(seed: int = 0) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.normal(size=(22, 3)).astype(np.float32)
|
||||
return rng.normal(size=(32, 3)).astype(np.float32)
|
||||
|
||||
|
||||
def test_buffer_starts_empty() -> None:
|
||||
@@ -58,15 +59,15 @@ def test_buffer_forget_releases_pid() -> None:
|
||||
def test_buffer_rejects_bad_shape() -> None:
|
||||
from data_only_viz.action_head import PerPersonBuffer
|
||||
buf = PerPersonBuffer()
|
||||
with pytest.raises(ValueError, match="22"):
|
||||
buf.append(pid=1, j3d=np.zeros((17, 3), dtype=np.float32))
|
||||
with pytest.raises(ValueError, match="32"):
|
||||
buf.append(pid=1, j3d=np.zeros((22, 3), dtype=np.float32))
|
||||
|
||||
|
||||
def test_feature_extractor_shape_full_buffer() -> None:
|
||||
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN, FEATURE_DIM
|
||||
frames = [_rand_j3d(i) for i in range(WINDOW_LEN)]
|
||||
feat = FeatureExtractor.from_buffer(frames)
|
||||
assert feat.shape == (FEATURE_DIM,)
|
||||
assert feat.shape == (302,)
|
||||
assert feat.dtype == np.float32
|
||||
assert not np.isnan(feat).any()
|
||||
|
||||
@@ -91,13 +92,13 @@ def test_feature_extractor_kinetics_speed_and_accel() -> None:
|
||||
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN
|
||||
frames = []
|
||||
for t in range(WINDOW_LEN):
|
||||
f = np.zeros((22, 3), dtype=np.float32)
|
||||
f = np.zeros((32, 3), dtype=np.float32)
|
||||
f[0, 0] = 0.1 * t
|
||||
frames.append(f)
|
||||
kin = FeatureExtractor.kinetics(frames)
|
||||
assert kin.shape == (3,)
|
||||
assert kin[0] > 0
|
||||
assert abs(kin[0] - 0.1 / 22) < 1e-4
|
||||
assert abs(kin[0] - 0.1 / 32) < 1e-4
|
||||
assert abs(kin[1]) < 1e-4
|
||||
|
||||
|
||||
@@ -105,7 +106,7 @@ def test_feature_extractor_symmetry_sign() -> None:
|
||||
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN, WRIST_LEFT, WRIST_RIGHT
|
||||
frames = []
|
||||
for t in range(WINDOW_LEN):
|
||||
f = np.zeros((22, 3), dtype=np.float32)
|
||||
f = np.zeros((32, 3), dtype=np.float32)
|
||||
f[WRIST_LEFT, 0] = 0.05 * t
|
||||
f[WRIST_RIGHT, 0] = -0.05 * t
|
||||
frames.append(f)
|
||||
|
||||
@@ -11,7 +11,7 @@ torch = pytest.importorskip("torch")
|
||||
|
||||
def _rand_j3d(seed: int = 0) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.normal(size=(22, 3)).astype(np.float32)
|
||||
return rng.normal(size=(32, 3)).astype(np.float32)
|
||||
|
||||
|
||||
def test_model_forward_shape() -> None:
|
||||
@@ -24,11 +24,11 @@ def test_model_forward_shape() -> None:
|
||||
assert h_new.shape == h.shape
|
||||
|
||||
|
||||
def test_model_param_count_under_50k() -> None:
|
||||
def test_model_param_count_under_80k() -> None:
|
||||
from data_only_viz.action_head import ActionHeadModel
|
||||
model = ActionHeadModel()
|
||||
n = sum(p.numel() for p in model.parameters())
|
||||
assert n < 50_000, f"too many params: {n}"
|
||||
assert n < 80_000, f"too many params: {n}"
|
||||
|
||||
|
||||
def test_action_head_step_warmup_returns_debout() -> None:
|
||||
|
||||
@@ -17,6 +17,8 @@ class _FakeState:
|
||||
self.persons_body3d = []
|
||||
self.persons_body_ids = []
|
||||
self.pose_last_t = 0.0
|
||||
self.persons_hands = []
|
||||
self.persons_hands_ids = []
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def lock(self):
|
||||
|
||||
@@ -8,16 +8,17 @@ WINDOW_LEN = 16
|
||||
|
||||
def _sample_stack(seed: int = 0) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.normal(size=(WINDOW_LEN, 22, 3)).astype(np.float32)
|
||||
return rng.normal(size=(WINDOW_LEN, 32, 3)).astype(np.float32)
|
||||
|
||||
|
||||
def test_mirror_swap_left_right_joints() -> None:
|
||||
from data_only_viz.training.augment import mirror_x
|
||||
from data_only_viz.training.augment import mirror_x, MIRROR_MAP
|
||||
x = _sample_stack(0)
|
||||
y = mirror_x(x)
|
||||
assert np.allclose(y[..., 0], -x[..., 0][:, [
|
||||
0,2,1,3,5,4,6,8,7,9,11,10,12,14,13,15,17,16,19,18,21,20
|
||||
]], atol=1e-6)
|
||||
# Check output shape
|
||||
assert y.shape == (WINDOW_LEN, 32, 3)
|
||||
# x-coords are negated after reindexing
|
||||
assert np.allclose(y[..., 0], -x[:, list(MIRROR_MAP), :][:, :, 0], atol=1e-6)
|
||||
|
||||
|
||||
def test_noise_within_sigma() -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ def _static_seated(frame_count: int = WINDOW_LEN) -> list[np.ndarray]:
|
||||
"""Hip low (y small), knee bent ~80°."""
|
||||
frames = []
|
||||
for _ in range(frame_count):
|
||||
f = np.zeros((22, 3), dtype=np.float32)
|
||||
f = np.zeros((32, 3), dtype=np.float32)
|
||||
f[1] = [-0.1, 0.4, 0.0]
|
||||
f[2] = [0.1, 0.4, 0.0]
|
||||
f[4] = [-0.1, 0.4, 0.3]
|
||||
@@ -25,7 +25,7 @@ def _static_standing(frame_count: int = WINDOW_LEN) -> list[np.ndarray]:
|
||||
"""Hip high, knees ~180°."""
|
||||
frames = []
|
||||
for _ in range(frame_count):
|
||||
f = np.zeros((22, 3), dtype=np.float32)
|
||||
f = np.zeros((32, 3), dtype=np.float32)
|
||||
f[1] = [-0.1, 0.9, 0.0]
|
||||
f[2] = [0.1, 0.9, 0.0]
|
||||
f[4] = [-0.1, 0.5, 0.0]
|
||||
|
||||
@@ -15,7 +15,7 @@ def _make_session_jsonl(path: Path, n_frames: int = 64) -> None:
|
||||
row = {"ts": t / 30.0,
|
||||
"session": "sess01",
|
||||
"pid": 1,
|
||||
"j3d": rng.normal(size=(22, 3)).tolist()}
|
||||
"j3d": rng.normal(size=(32, 3)).tolist()}
|
||||
f.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_load_frames_jsonl(tmp_path: Path) -> None:
|
||||
_make_session_jsonl(p)
|
||||
frames = load_frames_jsonl(p)
|
||||
assert len(frames) == 64
|
||||
assert frames[0].j3d.shape == (22, 3)
|
||||
assert frames[0].j3d.shape == (32, 3)
|
||||
assert frames[0].pid == 1
|
||||
assert frames[0].session == "sess01"
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_sliding_windows(tmp_path: Path) -> None:
|
||||
frames = load_frames_jsonl(p)
|
||||
windows = list(sliding_windows(frames, window_len=16, stride=4))
|
||||
assert len(windows) == 13
|
||||
assert windows[0].j3d_stack.shape == (16, 22, 3)
|
||||
assert windows[0].j3d_stack.shape == (16, 32, 3)
|
||||
assert windows[0].session == "sess01"
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_write_and_load_dataset_jsonl(tmp_path: Path) -> None:
|
||||
DatasetRow(
|
||||
window_id=f"sess01_pid1_w{i:04d}",
|
||||
label="debout" if i % 2 == 0 else "danse",
|
||||
j3d_stack=rng.normal(size=(16, 22, 3)).astype(np.float32),
|
||||
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
|
||||
session="sess01",
|
||||
pid_local=1,
|
||||
auto_label_confidence=0.8,
|
||||
@@ -68,7 +68,7 @@ def test_write_and_load_dataset_jsonl(tmp_path: Path) -> None:
|
||||
loaded = load_dataset_jsonl(out)
|
||||
assert len(loaded) == 5
|
||||
assert loaded[0].label == "debout"
|
||||
assert loaded[0].j3d_stack.shape == (16, 22, 3)
|
||||
assert loaded[0].j3d_stack.shape == (16, 32, 3)
|
||||
assert np.allclose(loaded[0].j3d_stack, rows[0].j3d_stack, atol=1e-6)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_split_by_session(tmp_path: Path) -> None:
|
||||
for sess in ("s01", "s02", "s03", "s04", "s05", "s06", "s07"):
|
||||
rows.append(DatasetRow(
|
||||
window_id=f"{sess}_w0", label="debout",
|
||||
j3d_stack=rng.normal(size=(16, 22, 3)).astype(np.float32),
|
||||
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
|
||||
session=sess, pid_local=1, auto_label_confidence=0.7,
|
||||
manually_validated=False,
|
||||
))
|
||||
|
||||
@@ -19,10 +19,12 @@ def _make_tiny_dataset(tmp_path: Path) -> Path:
|
||||
rows.append(DatasetRow(
|
||||
window_id=f"{sess}_w{w:03d}",
|
||||
label=label,
|
||||
j3d_stack=rng.normal(size=(16, 22, 3)).astype(np.float32),
|
||||
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
|
||||
session=sess, pid_local=1,
|
||||
auto_label_confidence=0.8,
|
||||
manually_validated=True,
|
||||
expr_stack=np.zeros((16, 10), dtype=np.float32),
|
||||
mouth_open_stack=np.zeros(16, dtype=np.float32),
|
||||
))
|
||||
out = tmp_path / "tiny.jsonl"
|
||||
write_dataset_jsonl(rows, out)
|
||||
@@ -57,5 +59,5 @@ def test_trained_checkpoint_loadable(tmp_path: Path) -> None:
|
||||
lr=1e-3, device="cpu", seed=0, log_every=10_000)
|
||||
head = ActionHead(ckpt_path=ckpt)
|
||||
for i in range(5):
|
||||
label, probs, _ = head.step(pid=1, j3d=np.zeros((22, 3), dtype=np.float32))
|
||||
label, probs, _ = head.step(pid=1, j3d=np.zeros((32, 3), dtype=np.float32))
|
||||
assert abs(float(probs.sum()) - 1.0) < 1e-5
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
# SMPL-X left/right joint mirror map (subset 22 joints used by Multi-HMR).
|
||||
# SMPL-X left/right joint mirror map for 32-joint layout.
|
||||
# Body joints 0..21 (unchanged), fingertips 22..31 (L 22..26 <-> R 27..31).
|
||||
MIRROR_MAP: tuple[int, ...] = (
|
||||
# 22 body (unchanged)
|
||||
0,
|
||||
2, 1,
|
||||
3,
|
||||
@@ -19,7 +21,10 @@ MIRROR_MAP: tuple[int, ...] = (
|
||||
17, 16,
|
||||
19, 18,
|
||||
21, 20,
|
||||
# 10 fingertips: L (22..26) <-> R (27..31)
|
||||
27, 28, 29, 30, 31, 22, 23, 24, 25, 26,
|
||||
)
|
||||
assert len(MIRROR_MAP) == 32
|
||||
|
||||
|
||||
def mirror_x(stack: np.ndarray) -> np.ndarray:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
@@ -15,26 +15,32 @@ class RawFrame:
|
||||
ts: float
|
||||
session: str
|
||||
pid: int
|
||||
j3d: np.ndarray # (22, 3) float32
|
||||
j3d: np.ndarray # (32, 3) float32 (v2: body22 + 10 fingertips)
|
||||
expression: np.ndarray | None = None # (EXPR_DIM,) or None
|
||||
mouth_open: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WindowRow:
|
||||
j3d_stack: np.ndarray # (window_len, 22, 3) float32
|
||||
j3d_stack: np.ndarray # (window_len, 32, 3) float32
|
||||
session: str
|
||||
pid_local: int
|
||||
first_ts: float
|
||||
expr_stack: np.ndarray | None = None # (window_len, 10) or None
|
||||
mouth_open_stack: np.ndarray | None = None # (window_len,) or None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasetRow:
|
||||
window_id: str
|
||||
label: str
|
||||
j3d_stack: np.ndarray # (window_len, 22, 3) float32
|
||||
j3d_stack: np.ndarray # (window_len, 32, 3) float32
|
||||
session: str
|
||||
pid_local: int
|
||||
auto_label_confidence: float
|
||||
manually_validated: bool
|
||||
expr_stack: np.ndarray | None = None # (window_len, 10) or None
|
||||
mouth_open_stack: np.ndarray | None = None # (window_len,) or None
|
||||
|
||||
|
||||
def load_frames_jsonl(path: Path) -> list[RawFrame]:
|
||||
@@ -45,11 +51,15 @@ def load_frames_jsonl(path: Path) -> list[RawFrame]:
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
expr_raw = d.get("expression")
|
||||
expr = np.asarray(expr_raw, dtype=np.float32) if expr_raw is not None else None
|
||||
rows.append(RawFrame(
|
||||
ts=float(d["ts"]),
|
||||
session=str(d["session"]),
|
||||
pid=int(d["pid"]),
|
||||
j3d=np.asarray(d["j3d"], dtype=np.float32),
|
||||
expression=expr,
|
||||
mouth_open=float(d.get("mouth_open", 0.0)),
|
||||
))
|
||||
return rows
|
||||
|
||||
@@ -68,14 +78,32 @@ def sliding_windows(frames: list[RawFrame],
|
||||
for start in range(0, len(grp) - window_len + 1, stride):
|
||||
chunk = grp[start:start + window_len]
|
||||
stack = np.stack([c.j3d for c in chunk]).astype(np.float32)
|
||||
# Expression stack: zeros if not present
|
||||
if any(c.expression is not None for c in chunk):
|
||||
expr_dim = max(
|
||||
(len(c.expression) for c in chunk if c.expression is not None),
|
||||
default=10,
|
||||
)
|
||||
expr_stack = np.zeros((window_len, expr_dim), dtype=np.float32)
|
||||
for t, c in enumerate(chunk):
|
||||
if c.expression is not None:
|
||||
n = min(expr_dim, len(c.expression))
|
||||
expr_stack[t, :n] = c.expression[:n]
|
||||
else:
|
||||
expr_stack = None
|
||||
mouth_stack = np.array(
|
||||
[c.mouth_open for c in chunk], dtype=np.float32
|
||||
)
|
||||
yield WindowRow(j3d_stack=stack, session=sess,
|
||||
pid_local=pid, first_ts=chunk[0].ts)
|
||||
pid_local=pid, first_ts=chunk[0].ts,
|
||||
expr_stack=expr_stack,
|
||||
mouth_open_stack=mouth_stack)
|
||||
|
||||
|
||||
def write_dataset_jsonl(rows: Iterable[DatasetRow], path: Path) -> None:
|
||||
with path.open("w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps({
|
||||
d: dict = {
|
||||
"window_id": r.window_id,
|
||||
"label": r.label,
|
||||
"j3d": r.j3d_stack.astype(np.float32).tolist(),
|
||||
@@ -83,7 +111,12 @@ def write_dataset_jsonl(rows: Iterable[DatasetRow], path: Path) -> None:
|
||||
"pid_local": r.pid_local,
|
||||
"auto_label_confidence": float(r.auto_label_confidence),
|
||||
"manually_validated": bool(r.manually_validated),
|
||||
}) + "\n")
|
||||
}
|
||||
if r.expr_stack is not None:
|
||||
d["expr_stack"] = r.expr_stack.astype(np.float32).tolist()
|
||||
if r.mouth_open_stack is not None:
|
||||
d["mouth_open_stack"] = r.mouth_open_stack.astype(np.float32).tolist()
|
||||
f.write(json.dumps(d) + "\n")
|
||||
|
||||
|
||||
def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
|
||||
@@ -94,6 +127,10 @@ def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
expr_raw = d.get("expr_stack")
|
||||
expr = np.asarray(expr_raw, dtype=np.float32) if expr_raw is not None else None
|
||||
mouth_raw = d.get("mouth_open_stack")
|
||||
mouth = np.asarray(mouth_raw, dtype=np.float32) if mouth_raw is not None else None
|
||||
out.append(DatasetRow(
|
||||
window_id=d["window_id"],
|
||||
label=d["label"],
|
||||
@@ -102,6 +139,8 @@ def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
|
||||
pid_local=int(d["pid_local"]),
|
||||
auto_label_confidence=float(d["auto_label_confidence"]),
|
||||
manually_validated=bool(d["manually_validated"]),
|
||||
expr_stack=expr,
|
||||
mouth_open_stack=mouth,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from torch.utils.data import DataLoader, Dataset
|
||||
|
||||
from data_only_viz.action_head import (
|
||||
ActionHeadModel,
|
||||
EXPR_DIM,
|
||||
FeatureExtractor,
|
||||
HIP_LEFT,
|
||||
HIP_RIGHT,
|
||||
@@ -52,19 +53,35 @@ class WindowDataset(Dataset[tuple[torch.Tensor, int]]):
|
||||
stack = row.j3d_stack
|
||||
if self._augment:
|
||||
stack = random_augment(stack, self._rng)
|
||||
T = stack.shape[0]
|
||||
# expression and mouth_open stacks (zeros if absent / legacy)
|
||||
if row.expr_stack is not None:
|
||||
expr_s = row.expr_stack.astype(np.float32)
|
||||
else:
|
||||
expr_s = np.zeros((T, EXPR_DIM), dtype=np.float32)
|
||||
if row.mouth_open_stack is not None:
|
||||
mouth_s = row.mouth_open_stack.astype(np.float32)
|
||||
else:
|
||||
mouth_s = np.zeros(T, dtype=np.float32)
|
||||
feats = []
|
||||
prev = stack[0]
|
||||
prev_vel = np.zeros_like(prev)
|
||||
for t in range(stack.shape[0]):
|
||||
for t in range(T):
|
||||
cur = stack[t]
|
||||
vel = cur - prev
|
||||
accel = vel - prev_vel
|
||||
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
|
||||
knee_angle = FeatureExtractor._mean_knee_angle(cur)
|
||||
sym = FeatureExtractor._symmetry_score(vel)
|
||||
expr_t = expr_s[t] if t < len(expr_s) else np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
|
||||
n = min(EXPR_DIM, len(expr_t))
|
||||
expr_vec[:n] = expr_t[:n]
|
||||
mouth_t = float(mouth_s[t]) if t < len(mouth_s) else 0.0
|
||||
feat = np.concatenate([
|
||||
cur.reshape(-1), vel.reshape(-1), accel.reshape(-1),
|
||||
np.array([hip_y, knee_angle, sym], dtype=np.float32),
|
||||
expr_vec,
|
||||
np.array([hip_y, knee_angle, sym, mouth_t], dtype=np.float32),
|
||||
]).astype(np.float32, copy=False)
|
||||
feats.append(feat)
|
||||
prev_vel = vel
|
||||
|
||||
Reference in New Issue
Block a user