Files
L'électron rare a199c50297 feat(data-only-viz): action auto-labeler rules
Problem: Action classification (debout/assise/danse) requires rule-based
labeling before neural training. Task 5 of action-head plan.

Approach: Heuristic rules on j3d posture + kinetics (speed/accel):
- Hip height + knee angle → seated vs. standing
- Joint velocity → static vs. dancing
- Confidence scoring for ambiguous windows

Changes:
- action_head.py: scaffold with FeatureExtractor (kinetics, knee angle)
- autolabel.py: AutoLabelConfig, autolabel_window(), autolabel_dataset()
  CLI glue (raw frames jsonl → windowed labeled dataset jsonl)
- test_autolabel.py: 4 TDD tests (debout, assise, danse, ambiguous)

Impact: Enables dataset creation pipeline (extract_j3d → auto-label →
manual review → train ActionHead GRU).
2026-05-13 20:59:47 +02:00

121 lines
3.6 KiB
Python

"""Rule-based labeler for j3d windows.
Outputs one of {"debout", "assise", "danse", None}. None marks
ambiguous windows that should be reviewed manually.
Rules are tuned for SMPL-X joint indexing as used by Multi-HMR.
"""
from __future__ import annotations
import argparse
import logging
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from data_only_viz.action_head import (
FeatureExtractor,
HIP_LEFT,
HIP_RIGHT,
WINDOW_LEN,
)
@dataclass(frozen=True)
class AutoLabelConfig:
hip_y_seated_max: float = 0.55
knee_angle_seated_max: float = 2.0 # rad, ~115°
speed_static_max: float = 0.03 # m/s mean joint speed
speed_dance_min: float = 0.033 # m/s mean joint speed
accel_dance_min: float = 0.001
DEFAULT_CFG = AutoLabelConfig()
def autolabel_window(
frames: list[np.ndarray], cfg: AutoLabelConfig = DEFAULT_CFG
) -> tuple[str | None, float]:
"""Return (label, confidence). label is None when ambiguous."""
if len(frames) < WINDOW_LEN // 2:
return None, 0.0
cur = frames[-1]
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
knee_angle = FeatureExtractor._mean_knee_angle(cur)
kin = FeatureExtractor.kinetics(frames)
speed = float(kin[0])
accel = float(kin[1])
if hip_y < cfg.hip_y_seated_max and knee_angle < cfg.knee_angle_seated_max:
conf = 0.5 + 0.5 * min(1.0, (cfg.hip_y_seated_max - hip_y) / 0.2)
return "assise", conf
if speed >= cfg.speed_dance_min or accel >= cfg.accel_dance_min:
conf = 0.5 + 0.5 * min(1.0, speed / 0.5)
return "danse", conf
if speed <= cfg.speed_static_max:
conf = 0.6
return "debout", conf
return None, 0.0
def autolabel_dataset(
frames_jsonl: Path,
out_jsonl: Path,
window_len: int = WINDOW_LEN,
stride: int = 4,
keep_none: bool = True,
) -> int:
"""Glue: raw frames jsonl → sliding windows → auto-label → DatasetRow jsonl.
Returns the number of windows written.
"""
from data_only_viz.training.dataset import (
DatasetRow,
load_frames_jsonl,
sliding_windows,
write_dataset_jsonl,
)
frames = load_frames_jsonl(frames_jsonl)
rows = []
for win in sliding_windows(frames, window_len=window_len, stride=stride):
frame_list = [win.j3d_stack[t] for t in range(win.j3d_stack.shape[0])]
label, conf = autolabel_window(frame_list)
if label is None and not keep_none:
continue
rows.append(
DatasetRow(
window_id=f"{win.session}_pid{win.pid_local}_t{int(win.first_ts*1000):08d}",
label=label if label is not None else "debout",
j3d_stack=win.j3d_stack,
session=win.session,
pid_local=win.pid_local,
auto_label_confidence=conf,
manually_validated=False,
)
)
out_jsonl.parent.mkdir(parents=True, exist_ok=True)
write_dataset_jsonl(rows, out_jsonl)
return len(rows)
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument(
"--frames",
required=True,
type=Path,
help="Raw frames jsonl from extract_j3d_offline.py",
)
p.add_argument("--out", required=True, type=Path, help="Auto-labeled windowed dataset jsonl")
p.add_argument("--stride", type=int, default=4)
args = p.parse_args()
logging.basicConfig(level=logging.INFO)
n = autolabel_dataset(args.frames, args.out, stride=args.stride)
print(f"wrote {n} windows to {args.out}")
if __name__ == "__main__":
_cli()