tartanimu-e003 / src /features.py
duj626's picture
Publish frozen E003 release: src
5bb6aee verified
Raw History Blame Contribute Delete
8.5 kB
#!/usr/bin/env python3
"""Deterministic, leak-free IMU feature extraction for the TartanIMU challenge."""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
WINDOW = 200
def _add(parts: list[np.ndarray], names: list[str], prefix: str, values: np.ndarray) -> None:
values = np.asarray(values, dtype=np.float32)
if values.ndim == 1:
values = values[:, None]
parts.append(values)
names.extend(f"{prefix}_{i}" for i in range(values.shape[1]))
def window_features(windows: np.ndarray) -> tuple[np.ndarray, list[str]]:
"""Extract per-window statistics from (windows, 200, 6) raw IMU."""
w = np.asarray(windows, dtype=np.float32)
parts: list[np.ndarray] = []
names: list[str] = []
mean = w.mean(axis=1)
std = w.std(axis=1)
centered = w - mean[:, None, :]
rms = np.sqrt(np.mean(w * w, axis=1))
quant = np.quantile(w, [0.10, 0.25, 0.50, 0.75, 0.90], axis=1)
slope_t = np.arange(WINDOW, dtype=np.float32) - (WINDOW - 1) / 2
slope = np.einsum("t,ntc->nc", slope_t, centered) / np.sum(slope_t * slope_t)
diff = np.diff(w, axis=1)
for prefix, values in (
("mean", mean),
("std", std),
("q10", quant[0]),
("q25", quant[1]),
("median", quant[2]),
("q75", quant[3]),
("q90", quant[4]),
("min", w.min(axis=1)),
("max", w.max(axis=1)),
("rms", rms),
("absmean", np.abs(w).mean(axis=1)),
("first", w[:, 0]),
("last", w[:, -1]),
("delta", w[:, -1] - w[:, 0]),
("slope", slope),
("diff_absmean", np.abs(diff).mean(axis=1)),
("diff_std", diff.std(axis=1)),
("diff_rms", np.sqrt(np.mean(diff * diff, axis=1))),
):
_add(parts, names, prefix, values)
norms = np.stack(
[np.linalg.norm(w[:, :, :3], axis=2), np.linalg.norm(w[:, :, 3:], axis=2)],
axis=2,
)
nq = np.quantile(norms, [0.10, 0.50, 0.90], axis=1)
for prefix, values in (
("norm_mean", norms.mean(axis=1)),
("norm_std", norms.std(axis=1)),
("norm_q10", nq[0]),
("norm_median", nq[1]),
("norm_q90", nq[2]),
("norm_min", norms.min(axis=1)),
("norm_max", norms.max(axis=1)),
("norm_rms", np.sqrt(np.mean(norms * norms, axis=1))),
):
_add(parts, names, prefix, values)
chunk_mean = w.reshape(len(w), 10, 20, 6).mean(axis=2).reshape(len(w), -1)
chunk_std = w.reshape(len(w), 4, 50, 6).std(axis=2).reshape(len(w), -1)
_add(parts, names, "chunk20_mean", chunk_mean)
_add(parts, names, "chunk50_std", chunk_std)
spectrum = np.abs(np.fft.rfft(centered, axis=1)).astype(np.float32) / WINDOW
bands = ((1, 3), (3, 6), (6, 11), (11, 21), (21, 41), (41, 61), (61, 81), (81, 101))
band_values = []
for lo, hi in bands:
band_values.append(np.sqrt(np.mean(spectrum[:, lo:hi] ** 2, axis=1)))
_add(parts, names, "fft_band_rms", np.concatenate(band_values, axis=1))
denom = np.maximum(std[:, :, None] * std[:, None, :], 1e-8)
corr = np.einsum("ntc,ntd->ncd", centered, centered) / WINDOW / denom
pairs = [(i, j) for i in range(6) for j in range(i + 1, 6)]
corr_values = np.stack([corr[:, i, j] for i, j in pairs], axis=1)
_add(parts, names, "corr", corr_values)
x = np.concatenate(parts, axis=1).astype(np.float32)
if not np.isfinite(x).all():
raise ValueError("non-finite feature generated")
return x, names
def add_trajectory_context(x: np.ndarray, names: list[str]) -> tuple[np.ndarray, list[str]]:
"""Add same-trajectory context without platform labels or hidden information."""
core_idx = [i for i, n in enumerate(names) if n.startswith(("mean_", "std_", "rms_", "norm_mean_", "norm_std_"))]
core = x[:, core_idx]
context = [x]
context_names = list(names)
for shift in (-5, -2, -1, 1, 2, 5):
idx = np.clip(np.arange(len(x)) + shift, 0, len(x) - 1)
context.append(core[idx])
context_names.extend(f"ctx_s{shift:+d}_{names[i]}" for i in core_idx)
for radius in (2, 5):
padded = np.pad(core, ((radius, radius), (0, 0)), mode="edge")
csum = np.vstack([np.zeros((1, core.shape[1]), dtype=np.float64), np.cumsum(padded, axis=0)])
rolled = (csum[2 * radius + 1:] - csum[: -(2 * radius + 1)]) / (2 * radius + 1)
context.append(rolled.astype(np.float32))
context_names.extend(f"ctx_r{radius}_{names[i]}" for i in core_idx)
global_stats = np.concatenate(
[np.median(core, axis=0), np.std(core, axis=0)], axis=0
).astype(np.float32)
context.append(np.repeat(global_stats[None, :], len(x), axis=0))
context_names.extend([f"traj_median_{names[i]}" for i in core_idx])
context_names.extend([f"traj_std_{names[i]}" for i in core_idx])
pos = np.linspace(0.0, 1.0, len(x), dtype=np.float32)
context.append(np.stack([pos, 1.0 - pos], axis=1))
context_names.extend(["trajectory_fraction", "trajectory_fraction_reverse"])
out = np.concatenate(context, axis=1).astype(np.float32)
return out, context_names
def _trajectory_path(root: Path, split: str, platform: str | None, traj_id: str) -> Path:
if split == "test":
return root / split / f"{traj_id}.npz"
assert platform is not None
return root / split / platform / f"{traj_id}.npz"
def extract_split(root: Path, split: str, out: Path) -> None:
index = pd.read_csv(root / "index" / f"{split}_windows.csv")
target = None
if split != "test":
target = pd.read_csv(root / "index" / f"{split}_targets.csv").set_index("window_id")
x_parts: list[np.ndarray] = []
ids: list[np.ndarray] = []
trajs: list[np.ndarray] = []
win_idxs: list[np.ndarray] = []
platforms: list[np.ndarray] = []
y_parts: list[np.ndarray] = []
feature_names: list[str] | None = None
grouped = list(index.groupby("traj_id", sort=False))
for number, (traj_id, rows) in enumerate(grouped, start=1):
rows = rows.sort_values("win_idx")
platform = None if split == "test" else str(rows["platform"].iloc[0])
path = _trajectory_path(root, split, platform, str(traj_id))
if not path.exists():
raise FileNotFoundError(path)
with np.load(path) as loaded:
imu = np.asarray(loaded["imu"], dtype=np.float32)
wi = rows["win_idx"].to_numpy(np.int64)
if wi.min() < 0 or (wi.max() + 1) * WINDOW > len(imu):
raise ValueError(f"window index out of bounds for {path}")
windows = np.stack([imu[k * WINDOW : (k + 1) * WINDOW] for k in wi])
raw_x, raw_names = window_features(windows)
traj_x, traj_names = add_trajectory_context(raw_x, raw_names)
if feature_names is None:
feature_names = traj_names
elif feature_names != traj_names:
raise ValueError("feature name/order mismatch")
x_parts.append(traj_x)
ids.append(rows["window_id"].to_numpy(np.int64))
trajs.append(np.repeat(str(traj_id), len(rows)))
win_idxs.append(wi)
platforms.append(np.repeat(platform or "unknown", len(rows)))
if target is not None:
y_parts.append(target.loc[rows["window_id"], ["vx", "vy", "vz"]].to_numpy(np.float32))
if number % 25 == 0 or number == len(grouped):
print(f"{split}: {number}/{len(grouped)} trajectories")
payload: dict[str, np.ndarray] = {
"X": np.concatenate(x_parts),
"window_id": np.concatenate(ids),
"traj_id": np.concatenate(trajs),
"win_idx": np.concatenate(win_idxs),
"platform": np.concatenate(platforms),
"feature_names": np.asarray(feature_names),
}
if y_parts:
payload["y"] = np.concatenate(y_parts)
out.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(out, **payload)
print(f"wrote {out}: X={payload['X'].shape}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path("tartan_imu/data/extracted"))
parser.add_argument("--out-dir", type=Path, default=Path("tartan_imu/artifacts/features_v1"))
parser.add_argument("--splits", nargs="+", choices=("train", "val", "test"), default=("train", "val", "test"))
args = parser.parse_args()
for split in args.splits:
extract_split(args.root, split, args.out_dir / f"{split}.npz")
if __name__ == "__main__":
main()