mlaf-grammar-engine / training /preprocess.py
Shaankar39's picture
Upload folder using huggingface_hub
4703150 verified
Raw
History Blame Contribute Delete
17.6 kB
"""MLAF Training Pipeline — Preprocessing.
Converts all data sources (Zenodo CSV, HaGRID annotations, webcam captures)
into a unified landmark CSV schema, engineers features, and creates
stratified train/val/test splits.
Unified schema:
gesture_id, lm_0_x, lm_0_y, lm_0_z, ..., lm_20_z, [engineered features]
Usage:
python -m training.preprocess
python training/preprocess.py
"""
from __future__ import annotations
import json
import logging
import math
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from .config import (
CUSTOM_DIR,
FINGER_MCP_INDICES,
FINGER_NAMES,
FINGERTIP_INDICES,
GESTURE_IDS,
HAND_FEATURE_DIM,
ID_TO_IDX,
NUM_HAND_LANDMARKS,
PROCESSED_DIR,
RANDOM_SEED,
RAW_DIR,
SPLIT_RATIOS,
SPLITS_DIR,
)
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# ---------------------------------------------------------------------------
# Landmark column names
# ---------------------------------------------------------------------------
def _landmark_columns() -> list[str]:
"""Return 63 column names: lm_0_x, lm_0_y, lm_0_z, ..., lm_20_z."""
cols = []
for i in range(NUM_HAND_LANDMARKS):
for axis in ("x", "y", "z"):
cols.append(f"lm_{i}_{axis}")
return cols
LANDMARK_COLS = _landmark_columns()
# ---------------------------------------------------------------------------
# Source 1: Zenodo CSV
# ---------------------------------------------------------------------------
def _load_zenodo(path: Path) -> pd.DataFrame | None:
"""Load Zenodo hand-gestures.csv and normalize to unified schema."""
if not path.exists():
logger.warning("Zenodo CSV not found at %s", path)
return None
logger.info("Loading Zenodo CSV: %s", path)
df = pd.read_csv(path)
# Detect landmark columns — expect 63 numeric columns + 1 label
numeric_cols = [c for c in df.columns if df[c].dtype in (np.float64, np.float32, np.int64)]
label_col = [c for c in df.columns if c not in numeric_cols]
if len(numeric_cols) < HAND_FEATURE_DIM:
logger.warning(
"Zenodo CSV has %d numeric cols (expected >= %d), skipping",
len(numeric_cols), HAND_FEATURE_DIM,
)
return None
# Take first 63 numeric columns as landmarks
lm_data = df[numeric_cols[:HAND_FEATURE_DIM]].values
# Normalize landmarks relative to wrist (landmark 0)
lm_data = _normalize_to_wrist(lm_data)
result = pd.DataFrame(lm_data, columns=LANDMARK_COLS)
# Map labels
if label_col:
result["gesture_label_raw"] = df[label_col[0]].values
result["gesture_id"] = result["gesture_label_raw"].apply(_map_external_label)
else:
result["gesture_id"] = "unknown"
result["source"] = "zenodo"
logger.info(" Zenodo: %d samples loaded", len(result))
return result
# ---------------------------------------------------------------------------
# Source 2: HaGRID annotations
# ---------------------------------------------------------------------------
def _load_hagrid(hagrid_dir: Path) -> pd.DataFrame | None:
"""Load HaGRID annotation JSONs containing hand landmark data."""
if not hagrid_dir.exists():
logger.warning("HaGRID directory not found at %s", hagrid_dir)
return None
json_files = sorted(hagrid_dir.glob("*.json"))
if not json_files:
logger.warning("No JSON files in %s", hagrid_dir)
return None
all_rows: list[dict] = []
for jf in json_files:
logger.info(" Loading HaGRID: %s", jf.name)
try:
with open(jf) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning(" Failed to parse %s: %s", jf.name, exc)
continue
# Extract gesture class from filename: train_val_<class>.json
gesture_class = jf.stem.replace("train_val_", "")
if isinstance(data, dict):
for _key, entry in data.items():
landmarks = entry.get("landmarks") or entry.get("hand_landmarks")
if landmarks and isinstance(landmarks, list):
# Flatten list of [x, y, z] triples
flat = _flatten_landmarks(landmarks)
if flat is not None and len(flat) == HAND_FEATURE_DIM:
row = {LANDMARK_COLS[i]: flat[i] for i in range(HAND_FEATURE_DIM)}
row["gesture_label_raw"] = gesture_class
row["gesture_id"] = _map_external_label(gesture_class)
row["source"] = "hagrid"
all_rows.append(row)
elif isinstance(data, list):
for entry in data:
landmarks = entry.get("landmarks") or entry.get("hand_landmarks")
if landmarks and isinstance(landmarks, list):
flat = _flatten_landmarks(landmarks)
if flat is not None and len(flat) == HAND_FEATURE_DIM:
row = {LANDMARK_COLS[i]: flat[i] for i in range(HAND_FEATURE_DIM)}
row["gesture_label_raw"] = gesture_class
row["gesture_id"] = _map_external_label(gesture_class)
row["source"] = "hagrid"
all_rows.append(row)
if not all_rows:
logger.warning("No landmark data extracted from HaGRID")
return None
result = pd.DataFrame(all_rows)
logger.info(" HaGRID: %d samples loaded", len(result))
return result
# ---------------------------------------------------------------------------
# Source 3: Webcam custom data
# ---------------------------------------------------------------------------
def _load_webcam(custom_dir: Path) -> pd.DataFrame | None:
"""Load webcam-captured landmark data.
Supports two layouts:
1. Sharded: data/custom/landmarks/{gesture_id}.csv (25GB+ scalable)
2. Legacy: data/custom/webcam_landmarks.csv (single file)
For sharded layout, reads each CSV in chunks to stay memory-efficient.
"""
frames: list[pd.DataFrame] = []
# --- Layout 1: Sharded per-gesture CSVs ---
landmarks_dir = custom_dir / "landmarks"
if landmarks_dir.is_dir():
shard_files = sorted(landmarks_dir.glob("*.csv"))
if shard_files:
logger.info("Loading sharded webcam data from %s (%d shards)", landmarks_dir, len(shard_files))
for shard in shard_files:
if shard.stat().st_size == 0:
continue
try:
# Chunked reading — keeps peak memory low even for multi-GB shards
chunks = pd.read_csv(shard, chunksize=50_000)
for chunk in chunks:
if "gesture_id" in chunk.columns:
frames.append(chunk)
except Exception as exc:
logger.warning("Failed to read shard %s: %s", shard.name, exc)
# --- Layout 2: Legacy single-file ---
legacy_csv = custom_dir / "webcam_landmarks.csv"
if legacy_csv.exists() and legacy_csv.stat().st_size > 0:
logger.info("Loading legacy webcam data: %s", legacy_csv)
try:
chunks = pd.read_csv(legacy_csv, chunksize=50_000)
for chunk in chunks:
if "gesture_id" in chunk.columns:
frames.append(chunk)
except Exception as exc:
logger.warning("Failed to read legacy CSV: %s", exc)
if not frames:
logger.info("No webcam data found in %s", custom_dir)
return None
df = pd.concat(frames, ignore_index=True)
# Validate landmark columns
for col in LANDMARK_COLS:
if col not in df.columns:
logger.warning("Webcam data missing column: %s", col)
return None
df["source"] = "webcam"
df["gesture_label_raw"] = df["gesture_id"]
logger.info(" Webcam: %d samples loaded", len(df))
return df
# ---------------------------------------------------------------------------
# Label mapping
# ---------------------------------------------------------------------------
# Map external dataset labels → our 18 gesture IDs where possible
_EXTERNAL_LABEL_MAP: dict[str, str] = {
# HaGRID classes → MLAF gestures
"stop": "verb_stop",
"stop_inverted": "verb_stop",
"fist": "verb_grab",
"palm": "verb_stop",
"one": "subject_i",
"like": "subject_you",
"call": "subject_you",
"peace": "object_ball",
"ok": "object_apple",
"mute": "verb_drink",
"rock": "verb_go",
# Direct matches (if Zenodo labels match ours)
"i": "subject_i",
"you": "subject_you",
"he": "subject_he",
"she": "subject_she",
"we": "subject_we",
"they": "subject_they",
"want": "verb_want",
"eat": "verb_eat",
"see": "verb_see",
"grab": "verb_grab",
"drink": "verb_drink",
"go": "verb_go",
"food": "object_food",
"water": "object_water",
"book": "object_book",
"apple": "object_apple",
"ball": "object_ball",
"house": "object_house",
}
def _map_external_label(raw_label: str) -> str:
"""Map an external dataset label to an MLAF gesture ID."""
normalized = str(raw_label).strip().lower().replace(" ", "_").replace("-", "_")
return _EXTERNAL_LABEL_MAP.get(normalized, "unknown")
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
def _normalize_to_wrist(landmarks: np.ndarray) -> np.ndarray:
"""Normalize 63-D landmark vectors relative to wrist (landmark 0).
Translates so wrist = origin, scales so max distance = 1.
"""
n = landmarks.shape[0]
result = landmarks.copy()
for i in range(n):
row = result[i].reshape(NUM_HAND_LANDMARKS, 3)
wrist = row[0].copy()
row -= wrist # translate to wrist origin
max_dist = np.max(np.linalg.norm(row, axis=1))
if max_dist > 1e-8:
row /= max_dist # scale to unit
result[i] = row.flatten()
return result
def _flatten_landmarks(landmarks: list) -> np.ndarray | None:
"""Flatten nested landmark list [[x,y,z], ...] to flat array."""
try:
flat = []
if isinstance(landmarks[0], (list, tuple)):
for pt in landmarks:
flat.extend(pt[:3])
else:
flat = list(landmarks)
return np.array(flat, dtype=np.float32)
except (IndexError, TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Feature engineering
# ---------------------------------------------------------------------------
def engineer_features(df: pd.DataFrame) -> pd.DataFrame:
"""Add engineered features: finger angles, distances, thumb ratios."""
logger.info("Engineering features …")
lm_data = df[LANDMARK_COLS].values.reshape(-1, NUM_HAND_LANDMARKS, 3)
n = lm_data.shape[0]
features: dict[str, np.ndarray] = {}
# 1. Inter-finger distances (all pairs of fingertips) — 10 features
tips = list(FINGERTIP_INDICES.values())
for i_idx in range(len(tips)):
for j_idx in range(i_idx + 1, len(tips)):
name_i = FINGER_NAMES[i_idx]
name_j = FINGER_NAMES[j_idx]
dists = np.linalg.norm(
lm_data[:, tips[i_idx]] - lm_data[:, tips[j_idx]], axis=1
)
features[f"dist_{name_i}_{name_j}"] = dists
# 2. Finger curl angles (tip-MCP-wrist angle) — 5 features
wrist = lm_data[:, 0]
for fname in FINGER_NAMES:
tip = lm_data[:, FINGERTIP_INDICES[fname]]
mcp = lm_data[:, FINGER_MCP_INDICES[fname]]
v1 = tip - mcp
v2 = wrist - mcp
cos_angle = np.sum(v1 * v2, axis=1) / (
np.linalg.norm(v1, axis=1) * np.linalg.norm(v2, axis=1) + 1e-8
)
cos_angle = np.clip(cos_angle, -1, 1)
angles = np.arccos(cos_angle)
features[f"curl_{fname}"] = angles
# 3. Thumb-to-finger distance ratios — 4 features
thumb_tip = lm_data[:, FINGERTIP_INDICES["thumb"]]
for fname in FINGER_NAMES[1:]: # skip thumb
other_tip = lm_data[:, FINGERTIP_INDICES[fname]]
dist = np.linalg.norm(thumb_tip - other_tip, axis=1)
palm_span = np.linalg.norm(
lm_data[:, FINGERTIP_INDICES["index"]] - lm_data[:, FINGERTIP_INDICES["pinky"]],
axis=1,
) + 1e-8
features[f"thumb_ratio_{fname}"] = dist / palm_span
# 4. Hand spread (max distance between any two landmarks) — 1 feature
spreads = np.zeros(n)
for i in range(n):
dists = np.linalg.norm(lm_data[i][:, None] - lm_data[i][None, :], axis=2)
spreads[i] = np.max(dists)
features["hand_spread"] = spreads
# 5. Center of mass offset from wrist — 3 features
com = np.mean(lm_data, axis=1)
features["com_x"] = com[:, 0]
features["com_y"] = com[:, 1]
features["com_z"] = com[:, 2]
feat_df = pd.DataFrame(features, index=df.index)
logger.info(" Added %d engineered features", len(features))
return pd.concat([df, feat_df], axis=1)
# ---------------------------------------------------------------------------
# Splitting
# ---------------------------------------------------------------------------
def create_splits(df: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""Create stratified train/val/test splits."""
# Filter to known gesture IDs
known = df[df["gesture_id"].isin(GESTURE_IDS)].copy()
unknown = df[~df["gesture_id"].isin(GESTURE_IDS)]
if len(unknown) > 0:
logger.info(" Dropping %d samples with unknown gesture IDs", len(unknown))
if len(known) < 10:
logger.warning("Too few known samples (%d) for splitting", len(known))
return {"train": known, "val": known, "test": known}
# Encode labels for stratification
known["class_idx"] = known["gesture_id"].map(ID_TO_IDX)
# First split: train+val vs test
test_ratio = SPLIT_RATIOS["test"]
val_ratio = SPLIT_RATIOS["val"] / (1 - test_ratio)
train_val, test = train_test_split(
known,
test_size=test_ratio,
stratify=known["class_idx"],
random_state=RANDOM_SEED,
)
train, val = train_test_split(
train_val,
test_size=val_ratio,
stratify=train_val["class_idx"],
random_state=RANDOM_SEED,
)
logger.info(" Splits: train=%d, val=%d, test=%d", len(train), len(val), len(test))
return {"train": train, "val": val, "test": test}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> dict:
"""Run full preprocessing pipeline. Returns dataset statistics."""
logger.info("MLAF Training Pipeline — Preprocessing")
# 1. Load all sources
frames: list[pd.DataFrame] = []
zenodo_df = _load_zenodo(RAW_DIR / "zenodo_hand_landmarks.csv")
if zenodo_df is not None:
frames.append(zenodo_df)
hagrid_df = _load_hagrid(RAW_DIR / "hagrid")
if hagrid_df is not None:
frames.append(hagrid_df)
webcam_df = _load_webcam(CUSTOM_DIR)
if webcam_df is not None:
frames.append(webcam_df)
# Load synthetic data (generated by generate_synthetic.py)
synthetic_path = PROCESSED_DIR / "synthetic_landmarks.csv"
if synthetic_path.exists():
logger.info("Loading synthetic data: %s", synthetic_path)
syn_df = pd.read_csv(synthetic_path)
syn_df["gesture_label_raw"] = syn_df["gesture_id"]
frames.append(syn_df)
logger.info(" Synthetic: %d samples loaded", len(syn_df))
if not frames:
logger.error("No data loaded! Run generate_synthetic.py, download_datasets.py, or collect_webcam.py first.")
return {"error": "no data"}
# 2. Concatenate
combined = pd.concat(frames, ignore_index=True)
logger.info("Combined dataset: %d samples", len(combined))
# 3. Engineer features
combined = engineer_features(combined)
# 4. Save processed dataset
processed_path = PROCESSED_DIR / "unified_landmarks.csv"
combined.to_csv(processed_path, index=False)
logger.info("Saved processed dataset: %s", processed_path)
# 5. Create splits
splits = create_splits(combined)
for split_name, split_df in splits.items():
path = SPLITS_DIR / f"{split_name}.csv"
split_df.to_csv(path, index=False)
logger.info("Saved %s split: %s (%d samples)", split_name, path, len(split_df))
# 6. Stats
stats = {
"total_samples": len(combined),
"sources": dict(combined["source"].value_counts()),
"gesture_distribution": dict(combined["gesture_id"].value_counts()),
"num_features": len(combined.columns),
"splits": {k: len(v) for k, v in splits.items()},
"known_gesture_samples": int(combined["gesture_id"].isin(GESTURE_IDS).sum()),
"unknown_samples": int((~combined["gesture_id"].isin(GESTURE_IDS)).sum()),
}
logger.info("Dataset stats: %s", json.dumps(stats, indent=2, default=str))
return stats
if __name__ == "__main__":
result = main()
print(json.dumps(result, indent=2, default=str))
sys.exit(0)