AsianPlayer/TriDrive / code /models /kp_forecaster.py
AsianPlayer's picture
download
raw
13.6 kB
"""cwm3/models/kp_forecaster.py — LANE F1 keypoint RESIDUAL forecaster on BATON (R2 WP4 batch 2; pre-registration docs/decisions.md 2026-09-17T19:33:12Z).
Observation (all frames <= t, 10 Hz ticks relative to the anchor): `full` = dense last 2 s (ticks -19..0, 20 frames) + sparse 1 Hz history (ticks -30, -40, …,
-100, 8 frames); `dense` = the 20 dense frames only; `1hz` = ticks 0, -10, …, -100 (11 frames = the observation DOWNSAMPLED to 1 Hz). Every frame carries the
11 upper-body joints (x/W, y/H, conf, valid) and its time gap Δt = t_frame − t (<= 0); the H = 4 horizon QUERY frames (+0.5 / +1 / +2 / +3 s) carry zeros and
Δt = +h. CAUSAL NORMALISATION (`causal_normalise`): per joint subtract the LAST VALID position <= t (`ref`), divide by the median shoulder width over the observed
frames (`scale`, fallback constant when no frame has both shoulders; clamped) — no future frame enters (tests/test_kp_forecaster.py poisons the future).
Output: residual of the future joints relative to `ref` in normalised units, M = 3 trajectory MODES [B,H,M,J,2] + mode logits [B,M] + state logits [B,3,2]
(P(hands_on), P(head_turned) at +1 / +2 / +3 s) — the distillation channel (state probabilities / modes, never the mean coordinates).
Backbones (parameter-matched ±10 %, both random init):
`mb` = the MotionBERT ARCHITECTURE: the official DSTformer modules (aide/models/motionbert_official/DSTformer.py — dual-stream spatial / temporal blocks with
the att_fuse mixing, joints_embed + pos_embed + temp_embed) in a small config, plus a time-gap Linear on Δt; NOT the released weights.
`cwm` = the CockpitWM part-tokenised causal temporal-part Transformer form: 4 part tokens per frame (head, torso, left arm, right arm), causal mask over frames,
horizon read-out from the query frames' part tokens.
Loss (`kp_loss`): winner-takes-all masked L1 over the modes + CE on the arg-min mode + masked BCE on the future states. No CAN column, label or DMS field is read here.
"""
from __future__ import annotations
import sys
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
CWM3 = Path(__file__).resolve().parents[1]
if str(CWM3) not in sys.path:
sys.path.insert(0, str(CWM3))
from aide.models.motionbert_official.DSTformer import DSTformer # noqa: E402 (architecture only; no weights)
NJ = 11
N_IN = 5 # xn_x, xn_y, conf, valid, dt/10
HORIZON_TICKS = (5, 10, 20, 30) # +0.5 / +1 / +2 / +3 s at 10 Hz
HORIZONS_S = tuple(k / 10.0 for k in HORIZON_TICKS)
H = len(HORIZON_TICKS)
N_MODES = 3
N_STATES, N_STATE_H = 2, 3 # hands_on / head_turned at +1 / +2 / +3 s
L_SHO, R_SHO = 5, 6
SCALE_FALLBACK, SCALE_MIN, SCALE_MAX = 0.12, 0.03, 0.50
PARTS = {"head": [0, 1, 2, 3, 4], "torso": [5, 6], "larm": [5, 7, 9], "rarm": [6, 8, 10]}
PART_OF_JOINT = [0, 0, 0, 0, 0, 1, 1, 2, 3, 2, 3] # read-out part per joint (shoulders from torso)
KPF_DIM = N_STATES * N_STATE_H + N_MODES + 1 # distillation channel: 6 state probs + 3 mode probs + valid bit = 10
def obs_offsets(kind):
"""relative 10 Hz ticks of the observed frames (ascending, all <= 0)."""
if kind == "full":
off = list(range(-100, -20, 10)) + list(range(-19, 1))
elif kind == "dense":
off = list(range(-19, 1))
elif kind == "1hz":
off = list(range(-100, 1, 10))
else:
raise ValueError(kind)
assert max(off) == 0 and all(o <= 0 for o in off)
return torch.tensor(off, dtype=torch.long)
def causal_normalise(xy, valid, scale_fallback=SCALE_FALLBACK):
"""xy [B,T,J,2] (frames ascending in time, the last one = t), valid [B,T,J] bool -> xn [B,T,J,2], ref [B,J,2], ref_ok [B,J], scale [B].
ref = last valid position <= t per joint (0 when never valid); scale = median shoulder width over frames with both shoulders valid (fallback
constant), clamped to [SCALE_MIN, SCALE_MAX]. Only the given (observed) frames are read."""
B, T, J, _ = xy.shape
xy = torch.nan_to_num(xy.float())
ref = torch.zeros(B, J, 2, device=xy.device)
ref_ok = torch.zeros(B, J, dtype=torch.bool, device=xy.device)
for k in range(T): # oldest -> newest: the newest valid frame wins
v = valid[:, k]
ref = torch.where(v[..., None], xy[:, k], ref)
ref_ok = ref_ok | v
sw = (xy[:, :, L_SHO] - xy[:, :, R_SHO]).norm(dim=-1) # [B,T]
both = valid[:, :, L_SHO] & valid[:, :, R_SHO]
sw = torch.where(both, sw, torch.full_like(sw, float("nan")))
med = torch.nanmedian(sw, dim=1).values
scale = torch.where(torch.isfinite(med), med, torch.full_like(med, float(scale_fallback))).clamp(SCALE_MIN, SCALE_MAX)
xn = (xy - ref[:, None]) / scale[:, None, None, None] * valid[..., None].float()
return xn, ref, ref_ok, scale
def make_tokens(xn, conf, valid, dt_s):
"""-> [B,T,J,N_IN] input channels for the observed frames. dt_s [T] (<= 0)."""
B, T, J, _ = xn.shape
dt = dt_s.to(xn.device).float()[None, :, None, None].expand(B, T, J, 1) / 10.0
return torch.cat([xn, torch.nan_to_num(conf.float())[..., None] * valid[..., None].float(), valid[..., None].float(), dt], -1)
def query_tokens(B, J, device):
dt = torch.tensor(HORIZONS_S, device=device).float()[None, :, None, None].expand(B, H, J, 1) / 10.0
return torch.cat([torch.zeros(B, H, J, N_IN - 1, device=device), dt], -1)
class MBBackbone(nn.Module):
"""MotionBERT-architecture (official DSTformer modules) with a time-gap embedding; returns per-token features [B,F,J,d]."""
def __init__(self, d=128, depth=3, heads=4, mlp_ratio=2, maxlen=64, dropout=0.0):
super().__init__()
self.net = DSTformer(dim_in=N_IN, dim_out=0, dim_feat=d, dim_rep=0, depth=depth, num_heads=heads, mlp_ratio=mlp_ratio, num_joints=NJ, maxlen=maxlen,
drop_rate=dropout, attn_drop_rate=dropout, drop_path_rate=0.0, att_fuse=True)
self.gap = nn.Linear(1, d) # time-gap embedding on Δt (seconds / 10), shared by observed and query frames
self.d = d
def forward(self, x, dt): # x [B,F,J,N_IN], dt [B,F] (seconds)
n = self.net
B, Fr, J, C = x.shape
h = n.joints_embed(x.reshape(-1, J, C)) + n.pos_embed
h = h.reshape(B, Fr, J, -1) + n.temp_embed[:, :Fr] + self.gap((dt / 10.0)[..., None])[:, :, None, :]
h = n.pos_drop(h.reshape(B * Fr, J, -1))
for idx, (blk_st, blk_ts) in enumerate(zip(n.blocks_st, n.blocks_ts)):
x_st, x_ts = blk_st(h, Fr), blk_ts(h, Fr)
alpha = n.ts_attn[idx](torch.cat([x_st, x_ts], -1)).softmax(-1)
h = x_st * alpha[:, :, 0:1] + x_ts * alpha[:, :, 1:2]
return n.norm(h).reshape(B, Fr, J, -1)
class TBlock(nn.Module):
def __init__(self, d, heads, ffn, dropout=0.0):
super().__init__()
self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
self.attn = nn.MultiheadAttention(d, heads, dropout=dropout, batch_first=True)
self.ff = nn.Sequential(nn.Linear(d, ffn), nn.GELU(), nn.Dropout(dropout), nn.Linear(ffn, d))
self.drop = nn.Dropout(dropout)
def forward(self, x, mask):
h = self.ln1(x)
x = x + self.drop(self.attn(h, h, h, attn_mask=mask, need_weights=False)[0])
return x + self.drop(self.ff(self.ln2(x)))
def causal_part_mask(T, n_part):
frame = torch.arange(T * n_part) // n_part
return frame[None, :] > frame[:, None] # True = blocked (later frames)
class CWMBackbone(nn.Module):
"""CockpitWM form: part tokens per frame -> causal temporal-part Transformer -> per-joint features read from the joint's part token [B,F,J,d]."""
def __init__(self, d=128, layers=3, heads=4, ffn=256, maxlen=64, dropout=0.0):
super().__init__()
self.parts = list(PARTS.values())
self.tok = nn.ModuleList([nn.Linear(len(p) * N_IN, d) for p in self.parts])
self.part_emb = nn.Embedding(len(self.parts), d)
self.slot_emb = nn.Embedding(maxlen, d)
self.gap = nn.Linear(1, d)
self.blocks = nn.ModuleList([TBlock(d, heads, ffn, dropout) for _ in range(layers)])
self.norm = nn.LayerNorm(d)
self.joint_emb = nn.Embedding(NJ, d) # distinguishes the joints read from one part token
self.register_buffer("part_of_joint", torch.tensor(PART_OF_JOINT), persistent=False)
self.d = d
def forward(self, x, dt): # x [B,F,J,N_IN], dt [B,F]
B, Fr, J, C = x.shape
toks = torch.stack([lin(x[:, :, p].reshape(B, Fr, -1)) for lin, p in zip(self.tok, self.parts)], 2) # [B,F,P,d]
P = toks.shape[2]
toks = toks + self.part_emb.weight[None, None] + self.slot_emb.weight[None, :Fr, None] + self.gap((dt / 10.0)[..., None])[:, :, None, :]
h = toks.reshape(B, Fr * P, -1)
mask = causal_part_mask(Fr, P).to(h.device)
for blk in self.blocks:
h = blk(h, mask)
h = self.norm(h).reshape(B, Fr, P, -1)
return h[:, :, self.part_of_joint] + self.joint_emb.weight[None, None]
class KpForecaster(nn.Module):
"""observation tokens + query tokens -> {res [B,H,M,J,2], mode_logits [B,M], state_logits [B,3,2]}."""
def __init__(self, backbone="mb", obs="full", d=128, depth=3, heads=4, mlp_ratio=2, ffn=256, dropout=0.0, scale_fallback=SCALE_FALLBACK):
super().__init__()
self.obs_kind, self.scale_fallback = obs, float(scale_fallback)
self.register_buffer("offsets", obs_offsets(obs), persistent=False)
self.n_obs = int(self.offsets.numel())
if backbone == "mb":
self.backbone = MBBackbone(d, depth, heads, mlp_ratio, maxlen=self.n_obs + H, dropout=dropout)
elif backbone == "cwm":
self.backbone = CWMBackbone(d, depth, heads, ffn, maxlen=self.n_obs + H, dropout=dropout)
else:
raise ValueError(backbone)
self.backbone_kind = backbone
self.res_head = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, N_MODES * 2))
nn.init.zeros_(self.res_head[1].weight) # zero-velocity at init (every mode = the current pose)
nn.init.zeros_(self.res_head[1].bias)
self.pool_head = nn.Sequential(nn.LayerNorm(d * H), nn.Linear(d * H, 64), nn.GELU(), nn.Linear(64, N_MODES + N_STATES * N_STATE_H))
def forward(self, xy, conf, valid):
"""xy [B,T,J,2] (T = n_obs observed frames, ascending), conf [B,T,J], valid [B,T,J] -> dict (+ ref / ref_ok / scale of the normalisation)."""
B, T, J, _ = xy.shape
assert T == self.n_obs and J == NJ, (xy.shape, self.n_obs)
xn, ref, ref_ok, scale = causal_normalise(xy, valid, self.scale_fallback)
dt_obs = self.offsets.float() / 10.0 # seconds, <= 0
x = torch.cat([make_tokens(xn, conf, valid, dt_obs), query_tokens(B, J, xy.device)], 1) # [B,T+H,J,N_IN]
dt = torch.cat([dt_obs.to(xy.device)[None].expand(B, -1), torch.tensor(HORIZONS_S, device=xy.device)[None].expand(B, -1)], 1)
feat = self.backbone(x, dt) # [B,T+H,J,d]
q = feat[:, T:] # query frames [B,H,J,d]
res = self.res_head(q).view(B, H, J, N_MODES, 2).permute(0, 1, 3, 2, 4).contiguous() # [B,H,M,J,2]
pooled = self.pool_head(q.mean(2).reshape(B, -1))
return {"res": res, "mode_logits": pooled[:, :N_MODES], "state_logits": pooled[:, N_MODES:].view(B, N_STATE_H, N_STATES),
"ref": ref, "ref_ok": ref_ok, "scale": scale}
def kp_loss(out, fut_xy, fut_valid, S, lam_ce=0.1, lam_state=0.5):
"""fut_xy [B,H,J,2] frame units, fut_valid [B,H,J], S [B,3,2] future states (NaN censored) -> total, parts, per-mode L1 [B,M]."""
ref, ref_ok, scale = out["ref"], out["ref_ok"], out["scale"]
tgt = (torch.nan_to_num(fut_xy.float()) - ref[:, None]) / scale[:, None, None, None] # normalised residual target
m = (fut_valid & ref_ok[:, None]).float()[:, :, None, :, None] # [B,H,1,J,1]
err = ((out["res"] - tgt[:, :, None]).abs() * m).sum((1, 3, 4)) / m.sum((1, 3, 4)).clamp_min(1.0) # [B,M]
has = (m.sum((1, 2, 3, 4)) > 0)
best = err.detach().argmin(1)
wta = (err.gather(1, best[:, None]).squeeze(1) * has.float()).sum() / has.float().sum().clamp_min(1.0)
ce = F.cross_entropy(out["mode_logits"][has], best[has]) if has.any() else out["mode_logits"].sum() * 0
sm = torch.isfinite(S)
sbce = (F.binary_cross_entropy_with_logits(out["state_logits"], torch.nan_to_num(S), reduction="none") * sm.float()).sum() / sm.float().sum().clamp_min(1.0)
total = wta + lam_ce * ce + lam_state * sbce
return total, {"wta_l1": float(wta.detach()), "mode_ce": float(ce.detach()), "state_bce": float(sbce.detach())}, err
def kpf_channel(out, anchor_ok):
"""the distillation channel [B,KPF_DIM]: 6 state probabilities, 3 mode probabilities, valid bit (0 -> every other entry 0)."""
p_state = torch.sigmoid(out["state_logits"]).reshape(-1, N_STATES * N_STATE_H)
p_mode = torch.softmax(out["mode_logits"], -1)
ok = anchor_ok.float()[:, None]
return torch.cat([p_state * ok, p_mode * ok, ok], 1)
def n_params(m):
return sum(p.numel() for p in m.parameters() if p.requires_grad)

Xet Storage Details

Size:
13.6 kB
·
Xet hash:
da43b733d37a14d91aeb1e38f6bfa1567d517a6facdfcfe60a073e54299f34ad

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.