Buckets:

Rishik001's picture
download
raw
14.9 kB
"""CTC training on FS-Spot: learn to timestamp skating elements from transcripts.
The point of CTC here: it needs only the ORDERED TRANSCRIPT per clip
(["Axel", "Flip", "Toeloop", ...]) -- no frame numbers -- and recovers the
alignment itself by marginalizing over every way the transcript could map onto
the frames. Timestamps fall out of the emissions at inference.
Background is NOT a class. CTC's blank token is what covers the ~98% of frames
between elements; adding an explicit NONE class alongside blank would be
double-counting. Classes are just the 7 element types.
We DO have ground-truth boundaries here (element_takeoff/element_landing), which
is what makes this worth running: CTC never sees them, so they are a clean test
of whether the recovered timestamps are real. That is the experiment.
features (T, F) per-frame, scale/translation-normalized skeleton + motion
target (L,) ordered element ids, 1..7 (0 is reserved for CTC blank)
emissions (T/4, C+1) encoder stride-4 downsamples; every input frame is
still consumed, the emission grid is just coarser
(160ms), which is fine against ~700ms jumps.
Run: python3 -m temporal_scripts.fs_spot_ctc [--epochs N] [--limit N]
"""
from __future__ import annotations
import argparse
import glob
import sys
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from temporal_scripts.fs_spot_data import OUT_DIR
DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu"
# CTC blank is 0, so element ids start at 1.
CTC_CLASSES = ["Axel", "Flip", "Loop", "Lutz", "Salchow", "Toeloop", "Spin"]
CTC_LABEL_TO_ID = {name: i + 1 for i, name in enumerate(CTC_CLASSES)}
CTC_ID_TO_LABEL = {i + 1: name for i, name in enumerate(CTC_CLASSES)}
NUM_CTC_CLASSES = len(CTC_CLASSES) + 1 # + blank
STRIDE = 4 # encoder temporal downsample
# COCO-17 indices
L_SHO, R_SHO, L_HIP, R_HIP = 5, 6, 11, 12
def skeleton_features(skel: np.ndarray) -> np.ndarray:
"""(T,17,3) normalized-pixel keypoints -> (T, F) scale/translation invariant features.
Broadcast cameras pan and zoom constantly, so raw normalized pixel coords
encode camera work as much as skating. Center each frame on the hip midpoint
and divide by torso length, which cancels both. Motion is then added back as
explicit velocity channels -- of the normalized coords (body-relative motion,
e.g. limbs) and of the hip centre itself (whole-body translation, which is
what actually spikes on a jump).
"""
xy = skel[:, :, :2].astype(np.float32)
conf = skel[:, :, 2].astype(np.float32)
hip = (xy[:, L_HIP] + xy[:, R_HIP]) / 2.0 # (T,2)
sho = (xy[:, L_SHO] + xy[:, R_SHO]) / 2.0
torso = np.linalg.norm(sho - hip, axis=1, keepdims=True) # (T,1)
# Frames with no detection are all-zero -> torso 0. Guard, then let the
# invalid mask tell the model which frames are junk rather than hiding it.
valid = (conf > 0).any(axis=1) & (torso[:, 0] > 1e-5)
torso = np.maximum(torso, 1e-3)
centered = (xy - hip[:, None, :]) / torso[:, None, :] # (T,17,2)
centered[~valid] = 0.0
def d(a: np.ndarray) -> np.ndarray:
return np.concatenate([np.zeros((1,) + a.shape[1:], a.dtype), np.diff(a, axis=0)], axis=0)
vel = d(centered) # body-relative joint motion
hip_v = d(hip) # whole-body translation (the jump signal)
hip_a = d(hip_v)
feats = np.concatenate(
[
centered.reshape(len(xy), -1), # 34
vel.reshape(len(xy), -1), # 34
conf, # 17
hip, # 2 (where in frame -- weak, but free)
hip_v, # 2
hip_a, # 2
torso, # 1 (scale = camera zoom proxy)
valid[:, None].astype(np.float32), # 1 (explicit "no detection here")
],
axis=1,
)
return np.nan_to_num(feats, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
class FSSpotCTC(Dataset):
def __init__(self, files: list[Path]):
self.items = []
for f in files:
d = np.load(f, allow_pickle=True)
labels = [str(x) for x in d["element_label"]]
target = [CTC_LABEL_TO_ID[x] for x in labels if x in CTC_LABEL_TO_ID]
if not target:
continue
self.items.append(
{
"file": f,
"feats": skeleton_features(d["skeleton"]),
"target": np.array(target, dtype=np.int64),
"takeoff": d["element_takeoff"],
"landing": d["element_landing"],
"labels": labels,
"name": f.stem,
}
)
def __len__(self) -> int:
return len(self.items)
def __getitem__(self, i: int) -> dict:
return self.items[i]
def collate(batch: list[dict]) -> dict:
T = max(len(b["feats"]) for b in batch)
F_ = batch[0]["feats"].shape[1]
x = torch.zeros(len(batch), T, F_)
in_len, tg_len, targets = [], [], []
for i, b in enumerate(batch):
t = len(b["feats"])
x[i, :t] = torch.from_numpy(b["feats"])
in_len.append(t // STRIDE) # emission length after the strided stem
tg_len.append(len(b["target"]))
targets.append(torch.from_numpy(b["target"]))
return {
"x": x,
"targets": torch.cat(targets),
"input_lengths": torch.tensor(in_len, dtype=torch.long),
"target_lengths": torch.tensor(tg_len, dtype=torch.long),
"meta": batch,
}
class CTCTagger(nn.Module):
"""Encoder (conv stem + LSTM) -> per-frame class logits.
The encoder is BOTH parts: the conv stem does local feature extraction and the
stride-4 downsample, the LSTM adds temporal context. Only the LSTM's direction
is configurable, because only the LSTM has unbounded context.
Stem receptive field: conv(k5,s2) -> 5, conv(k5,s2) -> 13, conv(k3,s1) -> 21
input frames, centered, i.e. +/-10 frames (~0.4s) of lookahead. That window is
~one jump wide (a jump is ~18 frames) and is far too small to let the model
know at frame 0 what happens at frame 970.
bidirectional=False is the CTC alignment experiment. CTC's loss marginalizes
over alignments, so it cannot distinguish emitting the transcript at frame 0
from emitting it at the truth -- both collapse to the same label sequence and
score the same. A BiLSTM's backward pass has read all 4,275 frames by step 0,
which makes the frame-0 dump representable AND cheaper to learn than genuine
onset detection. Going unidirectional removes the global lookahead, so firing
early would require predicting the future. If the alignment-invariance
diagnosis is right, this should localize; if it stays ~1%, the diagnosis is
wrong. Expect emissions biased LATE regardless -- streaming CTC must see
enough of an event to commit to a class.
"""
def __init__(self, in_features: int, num_classes: int = NUM_CTC_CLASSES, hidden: int = 256,
bidirectional: bool = True):
super().__init__()
self.stem = nn.Sequential(
nn.Conv1d(in_features, 128, 5, stride=2, padding=2), nn.BatchNorm1d(128), nn.GELU(),
nn.Conv1d(128, 256, 5, stride=2, padding=2), nn.BatchNorm1d(256), nn.GELU(),
nn.Conv1d(256, 256, 3, padding=1), nn.BatchNorm1d(256), nn.GELU(),
)
self.bidirectional = bidirectional
self.rnn = nn.LSTM(256, hidden, num_layers=2, batch_first=True,
bidirectional=bidirectional, dropout=0.2)
self.out = nn.Linear(hidden * (2 if bidirectional else 1), num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.stem(x.transpose(1, 2)).transpose(1, 2)
x, _ = self.rnn(x)
return self.out(x) # (B, T/4, C+1)
def greedy_decode(logp: torch.Tensor, in_len: int) -> list[tuple[int, int]]:
"""Collapse CTC emissions to (class_id, emission_frame) pairs."""
ids = logp[:in_len].argmax(dim=-1).cpu().numpy()
out, prev = [], 0
for t, k in enumerate(ids):
if k != prev and k != 0:
out.append((int(k), t))
prev = k
return out
def edit_distance(a: list, b: list) -> int:
prev = list(range(len(b) + 1))
for i, x in enumerate(a, 1):
cur = [i]
for j, y in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (x != y)))
prev = cur
return prev[-1]
def evaluate(model: nn.Module, loader: DataLoader, tol_s: float = 1.0) -> dict:
"""Transcript accuracy + whether the emissions land on the true elements.
CTC never sees a frame number in training, so localization here is emergent.
A hit = decoded spike within `tol_s` of the true [takeoff, landing] window
AND the right class.
"""
model.eval()
tot_ed = tot_len = exact = n = 0
hits = gt_total = pred_total = 0
offsets = []
with torch.no_grad():
for b in loader:
logp = F.log_softmax(model(b["x"].to(DEVICE)), dim=-1)
for i, m in enumerate(b["meta"]):
dec = greedy_decode(logp[i], int(b["input_lengths"][i]))
pred = [CTC_ID_TO_LABEL[k] for k, _ in dec]
gt = [x for x in m["labels"] if x in CTC_LABEL_TO_ID]
tot_ed += edit_distance(pred, gt)
tot_len += len(gt)
exact += int(pred == gt)
n += 1
gt_total += len(gt)
pred_total += len(pred)
used = set()
for k, t_em in dec:
t_frame = t_em * STRIDE # emission grid -> input frames
for j, (lab, t0, t1) in enumerate(zip(m["labels"], m["takeoff"], m["landing"])):
if j in used or lab != CTC_ID_TO_LABEL[k]:
continue
# distance to the true window (0 if inside it)
dist = max(t0 - t_frame, 0, t_frame - t1) / 25.0
if dist <= tol_s:
hits += 1
used.add(j)
offsets.append(t_frame - (t0 + t1) / 2)
break
ser = tot_ed / max(tot_len, 1)
return {
"transcript_err": ser, # like WER: edits / true elements
"exact_clip": exact / max(n, 1),
"loc_recall": hits / max(gt_total, 1),
"loc_prec": hits / max(pred_total, 1),
"median_offset_s": float(np.median(np.abs(offsets)) / 25.0) if offsets else float("nan"),
"n_clips": n,
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--epochs", type=int, default=60)
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--batch", type=int, default=4)
ap.add_argument("--lr", type=float, default=3e-4)
ap.add_argument("--unidirectional", action="store_true",
help="causal-ish LSTM: removes the global lookahead that lets CTC dump at frame 0")
ap.add_argument("--tag", type=str, default="ctc", help="checkpoint name")
args = ap.parse_args()
files = sorted(Path(p) for p in glob.glob(str(OUT_DIR / "*.npz")))
if args.limit:
files = files[: args.limit]
if len(files) < 10:
print(f"only {len(files)} clips extracted -- wait for extraction")
return 1
# Split BY BROADCAST: clips from one broadcast share camera work, lighting and
# commentary bumpers, so a random split would leak that and flatter the score.
by_bc: dict[str, list[Path]] = {}
for f in files:
by_bc.setdefault("_".join(f.stem.split("_")[:-3]), []).append(f)
bcs = sorted(by_bc)
n_val = max(1, len(bcs) // 4)
val_bcs, train_bcs = bcs[:n_val], bcs[n_val:]
train_f = [f for b in train_bcs for f in by_bc[b]]
val_f = [f for b in val_bcs for f in by_bc[b]]
print(f"clips: {len(files)} | broadcasts: {len(bcs)}")
print(f" train {len(train_f)} clips from {train_bcs}")
print(f" val {len(val_f)} clips from {val_bcs}")
tr, va = FSSpotCTC(train_f), FSSpotCTC(val_f)
if not len(tr) or not len(va):
print("empty split")
return 1
in_features = tr[0]["feats"].shape[1]
print(f" in_features={in_features} | mean T={np.mean([len(i['feats']) for i in tr.items]):.0f}")
tl = DataLoader(tr, batch_size=args.batch, shuffle=True, collate_fn=collate)
vl = DataLoader(va, batch_size=args.batch, shuffle=False, collate_fn=collate)
model = CTCTagger(in_features, bidirectional=not args.unidirectional).to(DEVICE)
print(f" LSTM: {'UNIdirectional (no global lookahead)' if args.unidirectional else 'BIdirectional'}"
f" | stem lookahead +/-10 frames (0.4s)")
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs)
# zero_infinity: a clip whose emission length falls under its target length
# yields inf loss; drop those grads instead of poisoning the whole batch.
ctc = nn.CTCLoss(blank=0, zero_infinity=True)
best = float("inf")
for ep in range(1, args.epochs + 1):
model.train()
tot = nb = 0
for b in tl:
opt.zero_grad()
logp = F.log_softmax(model(b["x"].to(DEVICE)), dim=-1).transpose(0, 1) # (T,B,C)
loss = ctc(logp, b["targets"].to(DEVICE), b["input_lengths"], b["target_lengths"])
if not torch.isfinite(loss):
continue
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5.0)
opt.step()
tot += float(loss); nb += 1
sched.step()
if ep % 5 == 0 or ep == args.epochs:
m = evaluate(model, vl)
print(
f"ep {ep:3d} | loss {tot/max(nb,1):6.3f} | "
f"transcript_err {m['transcript_err']:.3f} | exact {m['exact_clip']*100:5.1f}% | "
f"loc R {m['loc_recall']*100:5.1f}% P {m['loc_prec']*100:5.1f}% | "
f"med_off {m['median_offset_s']:.2f}s",
flush=True,
)
if tot / max(nb, 1) < best:
best = tot / max(nb, 1)
torch.save({"state_dict": model.state_dict(), "in_features": in_features,
"bidirectional": not args.unidirectional},
OUT_DIR.parent / f"{args.tag}_model.pt")
print("\nsaved ->", OUT_DIR.parent / f"{args.tag}_model.pt")
return 0
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
14.9 kB
·
Xet hash:
2b8216f01a04a050262f923f862d8cbec865cd2bb5ac58f88d2de5db0ec8d425

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