|
| 1 | +"""Compare feature and training variants on the same split, same seed, same budget. |
| 2 | +
|
| 3 | +Every number printed is top-1 / top-3 on SWL-LSE's held-out test set. The point is to decide |
| 4 | +what actually helps before changing the shipped model, rather than assuming. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import sys |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import numpy as np |
| 13 | +import torch |
| 14 | +from torch import nn |
| 15 | + |
| 16 | +DATA = Path(__file__).parent / "data" |
| 17 | + |
| 18 | +# Pose landmarks that frame the signing space. MediaPipe's pose indices. |
| 19 | +NOSE, LEFT_SHOULDER, RIGHT_SHOULDER = 0, 11, 12 |
| 20 | +WRIST, INDEX_MCP, PINKY_MCP = 0, 5, 17 |
| 21 | + |
| 22 | +EPOCHS = 120 |
| 23 | +BATCH = 64 |
| 24 | +PATIENCE = 15 |
| 25 | +SEED = 7 |
| 26 | + |
| 27 | + |
| 28 | +def load_raw(split: str): |
| 29 | + bundle = np.load(DATA / f"{split}_raw.npz", allow_pickle=True) |
| 30 | + count = int(bundle["n"][0]) |
| 31 | + samples = [ |
| 32 | + (bundle[f"r{i}"], bundle[f"l{i}"], bundle[f"p{i}"]) for i in range(count) |
| 33 | + ] |
| 34 | + return samples, bundle["y"] |
| 35 | + |
| 36 | + |
| 37 | +def palm_width(points: np.ndarray) -> float: |
| 38 | + width = float(np.linalg.norm(points[INDEX_MCP] - points[PINKY_MCP])) |
| 39 | + return width if width > 1e-6 else 1e-6 |
| 40 | + |
| 41 | + |
| 42 | +def body_frame(pose: np.ndarray) -> tuple[np.ndarray, float]: |
| 43 | + """Origin and scale taken from the torso, not the image. |
| 44 | +
|
| 45 | + This is the point of using pose at all: a wrist at "chin height" must read the same |
| 46 | + whether the signer is close to the camera or across the room. Shoulder width is the most |
| 47 | + stable body measurement MediaPipe gives, and it does not change when the arms move. |
| 48 | + """ |
| 49 | + left, right = pose[LEFT_SHOULDER], pose[RIGHT_SHOULDER] |
| 50 | + centre = (left + right) / 2 |
| 51 | + width = float(np.linalg.norm(left - right)) |
| 52 | + return centre, (width if width > 1e-6 else 1e-6) |
| 53 | + |
| 54 | + |
| 55 | +def hand_block(points: np.ndarray, side: str, pose: np.ndarray | None) -> np.ndarray: |
| 56 | + if not points.any(): |
| 57 | + return np.zeros(66 if pose is None else 69, dtype=np.float32) |
| 58 | + |
| 59 | + wrist = points[WRIST] |
| 60 | + scale = palm_width(points) |
| 61 | + mirror = -1.0 if side == "left" else 1.0 |
| 62 | + shape = (points - wrist) / scale |
| 63 | + shape[:, 0] *= mirror |
| 64 | + |
| 65 | + if pose is None: |
| 66 | + return np.concatenate([shape.reshape(-1), wrist]).astype(np.float32) |
| 67 | + |
| 68 | + centre, width = body_frame(pose) |
| 69 | + located = (wrist - centre) / width |
| 70 | + # Both: where the hand is relative to the body, and where it is in frame. |
| 71 | + return np.concatenate([shape.reshape(-1), located, wrist]).astype(np.float32) |
| 72 | + |
| 73 | + |
| 74 | +def signature(sample, frames: int, use_pose: bool, deltas: bool) -> np.ndarray: |
| 75 | + right, left, pose = sample |
| 76 | + length = len(right) |
| 77 | + picks = [0] * frames if length == 1 else [ |
| 78 | + int(round(s / (frames - 1) * (length - 1))) for s in range(frames) |
| 79 | + ] |
| 80 | + |
| 81 | + rows = [] |
| 82 | + for index in picks: |
| 83 | + p = pose[index] if use_pose else None |
| 84 | + rows.append(np.concatenate([hand_block(right[index], "right", p), |
| 85 | + hand_block(left[index], "left", p)])) |
| 86 | + stacked = np.stack(rows) |
| 87 | + |
| 88 | + if deltas: |
| 89 | + # Frame-to-frame change, so the model is handed motion instead of inferring it. |
| 90 | + motion = np.diff(stacked, axis=0, prepend=stacked[:1]) |
| 91 | + stacked = np.concatenate([stacked, motion], axis=1) |
| 92 | + |
| 93 | + return stacked.reshape(-1).astype(np.float32) |
| 94 | + |
| 95 | + |
| 96 | +def build(samples, frames: int, use_pose: bool, deltas: bool) -> np.ndarray: |
| 97 | + return np.stack([signature(s, frames, use_pose, deltas) for s in samples]) |
| 98 | + |
| 99 | + |
| 100 | +def augment(x: torch.Tensor, width: int, strength: float) -> torch.Tensor: |
| 101 | + """Jitter and scale each example slightly, differently every epoch. |
| 102 | +
|
| 103 | + With ~27 examples per class, the model memorises signers rather than signs. Noise at this |
| 104 | + level is the cheapest way to tell it which variations do not change the word. |
| 105 | + """ |
| 106 | + if strength <= 0: |
| 107 | + return x |
| 108 | + scale = 1 + (torch.rand(x.shape[0], 1) - 0.5) * strength |
| 109 | + return x * scale + torch.randn_like(x) * (strength * 0.05) |
| 110 | + |
| 111 | + |
| 112 | +class SignHead(nn.Module): |
| 113 | + def __init__(self, width: int, frames: int, classes: int, hidden: int = 128): |
| 114 | + super().__init__() |
| 115 | + self.frames, self.width = frames, width |
| 116 | + self.norm = nn.LayerNorm(width) |
| 117 | + self.gru = nn.GRU(width, hidden, num_layers=2, batch_first=True, |
| 118 | + bidirectional=True, dropout=0.2) |
| 119 | + self.head = nn.Sequential( |
| 120 | + nn.Linear(hidden * 2, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, classes) |
| 121 | + ) |
| 122 | + |
| 123 | + def forward(self, x): |
| 124 | + out, _ = self.gru(self.norm(x.view(-1, self.frames, self.width))) |
| 125 | + return self.head(out.mean(dim=1)) |
| 126 | + |
| 127 | + |
| 128 | +def run(name: str, frames: int, use_pose: bool, deltas: bool, strength: float, cache: dict): |
| 129 | + torch.manual_seed(SEED) |
| 130 | + np.random.seed(SEED) |
| 131 | + |
| 132 | + key = (frames, use_pose, deltas) |
| 133 | + if key not in cache: |
| 134 | + cache[key] = { |
| 135 | + split: build(cache["samples"][split], frames, use_pose, deltas) |
| 136 | + for split in ("train", "val", "test") |
| 137 | + } |
| 138 | + built = cache[key] |
| 139 | + |
| 140 | + concepts = sorted(set(cache["labels"]["train"])) |
| 141 | + index = {c: i for i, c in enumerate(concepts)} |
| 142 | + to_y = lambda raw: torch.tensor([index[c] for c in raw]) |
| 143 | + |
| 144 | + xt, yt = torch.tensor(built["train"]), to_y(cache["labels"]["train"]) |
| 145 | + xv, yv = torch.tensor(built["val"]), to_y(cache["labels"]["val"]) |
| 146 | + xs, ys = torch.tensor(built["test"]), to_y(cache["labels"]["test"]) |
| 147 | + width = xt.shape[1] // frames |
| 148 | + |
| 149 | + model = SignHead(width, frames, len(concepts)) |
| 150 | + opt = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-2) |
| 151 | + sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS) |
| 152 | + crit = nn.CrossEntropyLoss(label_smoothing=0.1) |
| 153 | + |
| 154 | + def score(x, y): |
| 155 | + model.eval() |
| 156 | + with torch.no_grad(): |
| 157 | + top3 = model(x).topk(3, dim=1).indices |
| 158 | + return ((top3[:, 0] == y).float().mean().item(), |
| 159 | + (top3 == y.unsqueeze(1)).any(dim=1).float().mean().item()) |
| 160 | + |
| 161 | + best, best_state, stale = 0.0, None, 0 |
| 162 | + for _ in range(EPOCHS): |
| 163 | + model.train() |
| 164 | + order = torch.randperm(len(xt)) |
| 165 | + for start in range(0, len(order), BATCH): |
| 166 | + batch = order[start:start + BATCH] |
| 167 | + opt.zero_grad() |
| 168 | + crit(model(augment(xt[batch], width, strength)), yt[batch]).backward() |
| 169 | + opt.step() |
| 170 | + sched.step() |
| 171 | + v1, _ = score(xv, yv) |
| 172 | + if v1 > best: |
| 173 | + best, stale = v1, 0 |
| 174 | + best_state = {k: v.clone() for k, v in model.state_dict().items()} |
| 175 | + else: |
| 176 | + stale += 1 |
| 177 | + if stale >= PATIENCE: |
| 178 | + break |
| 179 | + |
| 180 | + model.load_state_dict(best_state) |
| 181 | + t1, t3 = score(xs, ys) |
| 182 | + params = sum(p.numel() for p in model.parameters()) |
| 183 | + print(f"{name:38} top1 {t1:.3f} top3 {t3:.3f} ({params * 4 / 1024 / 1024:.1f} MB)") |
| 184 | + return t1, t3 |
| 185 | + |
| 186 | + |
| 187 | +def main() -> None: |
| 188 | + cache = {"samples": {}, "labels": {}} |
| 189 | + for split in ("train", "val", "test"): |
| 190 | + samples, labels = load_raw(split) |
| 191 | + cache["samples"][split] = samples |
| 192 | + cache["labels"][split] = labels |
| 193 | + |
| 194 | + variants = [ |
| 195 | + ("baseline: 8 frames, hands only", 8, False, False, 0.0), |
| 196 | + ("+ pose-relative location", 8, True, False, 0.0), |
| 197 | + ("+ 16 frames", 16, True, False, 0.0), |
| 198 | + ("+ motion deltas", 16, True, True, 0.0), |
| 199 | + ("+ augmentation", 16, True, True, 0.15), |
| 200 | + ] |
| 201 | + for args in variants: |
| 202 | + run(*args, cache) |
| 203 | + |
| 204 | + |
| 205 | +if __name__ == "__main__": |
| 206 | + main() |
0 commit comments