-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexperiment.py
More file actions
349 lines (282 loc) · 12.7 KB
/
Copy pathexperiment.py
File metadata and controls
349 lines (282 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""Compare feature and training variants on the same split, same seed, same budget.
Every number printed is top-1 / top-3 on SWL-LSE's held-out test set. The point is to decide
what actually helps before changing the shipped model, rather than assuming.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import torch
from torch import nn
from face import expression
DATA = Path(__file__).parent / "data"
# Pose landmarks that frame the signing space. MediaPipe's pose indices.
NOSE, LEFT_SHOULDER, RIGHT_SHOULDER = 0, 11, 12
WRIST, INDEX_MCP, PINKY_MCP = 0, 5, 17
EPOCHS = 120
BATCH = 64
PATIENCE = 15
SEED = 7
# SWL-LSE is 20.00 fps in every reference video. Face staleness is asked for in milliseconds,
# not frames, because the app's frame rate is nothing like the corpus's: holding a reading for
# two frames is 100 ms here and ~400 ms on a phone reaching 5 fps.
CORPUS_FPS = 20.0
def stale_face_index(index: int, hold_ms: float) -> int:
"""Which frame's face reading a pipeline running the face model less often would still hold."""
if hold_ms <= 0:
return index
step = round(hold_ms / 1000 * CORPUS_FPS) + 1
return (index // step) * step
def load_raw(split: str):
bundle = np.load(DATA / f"{split}_raw.npz", allow_pickle=True)
count = int(bundle["n"][0])
has_face = "f0" in bundle
samples = [
(
bundle[f"r{i}"],
bundle[f"l{i}"],
bundle[f"p{i}"],
bundle[f"f{i}"] if has_face else None,
)
for i in range(count)
]
return samples, bundle["y"]
def palm_width(points: np.ndarray) -> float:
width = float(np.linalg.norm(points[INDEX_MCP] - points[PINKY_MCP]))
return width if width > 1e-6 else 1e-6
def body_frame(pose: np.ndarray) -> tuple[np.ndarray, float]:
"""Origin and scale taken from the torso, not the image.
This is the point of using pose at all: a wrist at "chin height" must read the same
whether the signer is close to the camera or across the room. Shoulder width is the most
stable body measurement MediaPipe gives, and it does not change when the arms move.
"""
left, right = pose[LEFT_SHOULDER], pose[RIGHT_SHOULDER]
centre = (left + right) / 2
width = float(np.linalg.norm(left - right))
return centre, (width if width > 1e-6 else 1e-6)
def hand_block(points: np.ndarray, side: str, pose: np.ndarray | None) -> np.ndarray:
if not points.any():
return np.zeros(66 if pose is None else 69, dtype=np.float32)
wrist = points[WRIST]
scale = palm_width(points)
mirror = -1.0 if side == "left" else 1.0
shape = (points - wrist) / scale
shape[:, 0] *= mirror
if pose is None:
return np.concatenate([shape.reshape(-1), wrist]).astype(np.float32)
centre, width = body_frame(pose)
located = (wrist - centre) / width
# Both: where the hand is relative to the body, and where it is in frame.
return np.concatenate([shape.reshape(-1), located, wrist]).astype(np.float32)
def torso_block(pose: np.ndarray) -> np.ndarray:
"""Orientation of the torso and of the head on top of it.
The shoulder vector's angle gives the turn, and the depth difference between shoulders
says which way the body is squared — LSE uses that to point at referents in space. The
head offset is what makes a negating head-shake visible: it is a small value, but it
swings across the frames of the signature.
"""
left, right = pose[LEFT_SHOULDER], pose[RIGHT_SHOULDER]
centre, width = body_frame(pose)
across = left - right
head = (pose[NOSE] - centre) / width
return np.array(
[
np.arctan2(across[1], across[0]), # shoulder line tilt
across[2] / width, # one shoulder forward = torso turned
head[0],
head[1],
head[2],
],
dtype=np.float32,
)
def drop_depth(block: np.ndarray) -> np.ndarray:
"""Strip every third value — the z channel — from a flattened point list."""
return np.delete(block.reshape(-1, 3), 2, axis=1).reshape(-1)
def signature(
sample, frames: int, use_pose: bool, deltas: bool, face_mode: str = "none",
use_depth: bool = True, face_hold_ms: float = 0.0, use_torso: bool = True,
) -> np.ndarray:
right, left, pose, face = sample
length = len(right)
picks = [0] * frames if length == 1 else [
int(round(s / (frames - 1) * (length - 1))) for s in range(frames)
]
rows = []
for index in picks:
p = pose[index] if use_pose else None
parts = [hand_block(right[index], "right", p), hand_block(left[index], "left", p)]
# Separate from use_pose on purpose: dropping that would also drop the
# pose-relative hand location, which is the one large measured gain (+5.7)
if use_pose and use_torso:
parts.append(torso_block(pose[index]))
if face_mode != "none" and face is not None:
points = face[stale_face_index(index, face_hold_ms)]
if face_mode == "expression":
parts.append(expression(points))
elif face_mode == "points":
# Located against the torso like the hands, so face position and hand
# position live in the same coordinate frame.
centre, width = body_frame(pose[index])
parts.append(((points - centre) / width).reshape(-1).astype(np.float32))
elif face_mode == "both":
centre, width = body_frame(pose[index])
parts.append(((points - centre) / width).reshape(-1).astype(np.float32))
parts.append(expression(points))
row = np.concatenate(parts)
rows.append(row)
stacked = np.stack(rows)
if not use_depth:
# MediaPipe's z is inferred from one camera rather than measured, so it is the
# noisiest channel by far. Whether it earns its place is a question for the test set,
# not for intuition.
stacked = np.stack(
[drop_depth_row(r, use_pose and use_torso, face_mode) for r in stacked]
)
if deltas:
# Frame-to-frame change, so the model is handed motion instead of inferring it.
motion = np.diff(stacked, axis=0, prepend=stacked[:1])
stacked = np.concatenate([stacked, motion], axis=1)
return stacked.reshape(-1).astype(np.float32)
def drop_depth_row(row: np.ndarray, use_pose: bool, face_mode: str) -> np.ndarray:
"""Remove z from the landmark parts of a frame, leaving derived scalars untouched."""
hand_len = 69 if use_pose else 66
parts: list[np.ndarray] = []
offset = 0
for _ in range(2):
block = row[offset : offset + hand_len]
offset += hand_len
# 21 shape points, then wrist-located (3) and wrist-in-frame (3) when pose is on.
parts.append(drop_depth(block[:63]))
parts.append(drop_depth(block[63:]))
if use_pose:
parts.append(row[offset : offset + 5]) # torso scalars: already derived
offset += 5
if face_mode in ("points", "both"):
parts.append(drop_depth(row[offset : offset + 63]))
offset += 63
if face_mode in ("expression", "both"):
parts.append(row[offset : offset + 6])
offset += 6
return np.concatenate(parts)
def build(
samples, frames: int, use_pose: bool, deltas: bool, face_mode: str, use_depth: bool,
face_hold_ms: float = 0.0, use_torso: bool = True,
) -> np.ndarray:
return np.stack(
[
signature(
s, frames, use_pose, deltas, face_mode, use_depth, face_hold_ms, use_torso
)
for s in samples
]
)
def augment(x: torch.Tensor, width: int, strength: float) -> torch.Tensor:
"""Jitter and scale each example slightly, differently every epoch.
With ~27 examples per class, the model memorises signers rather than signs. Noise at this
level is the cheapest way to tell it which variations do not change the word.
"""
if strength <= 0:
return x
scale = 1 + (torch.rand(x.shape[0], 1) - 0.5) * strength
return x * scale + torch.randn_like(x) * (strength * 0.05)
class SignHead(nn.Module):
def __init__(self, width: int, frames: int, classes: int, hidden: int = 128):
super().__init__()
self.frames, self.width = frames, width
self.norm = nn.LayerNorm(width)
self.gru = nn.GRU(width, hidden, num_layers=2, batch_first=True,
bidirectional=True, dropout=0.2)
self.head = nn.Sequential(
nn.Linear(hidden * 2, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, classes)
)
def forward(self, x):
out, _ = self.gru(self.norm(x.view(-1, self.frames, self.width)))
return self.head(out.mean(dim=1))
def run(name: str, frames: int, use_pose: bool, deltas: bool, strength: float,
face_mode: str, cache: dict, use_depth: bool = True, face_hold_ms: float = 0.0,
seed: int = SEED, use_torso: bool = True):
torch.manual_seed(seed)
np.random.seed(seed)
key = (frames, use_pose, deltas, face_mode, use_depth, face_hold_ms, use_torso)
if key not in cache:
cache[key] = {
split: build(
cache["samples"][split], frames, use_pose, deltas, face_mode, use_depth,
face_hold_ms, use_torso,
)
for split in ("train", "val", "test")
}
built = cache[key]
concepts = sorted(set(cache["labels"]["train"]))
index = {c: i for i, c in enumerate(concepts)}
to_y = lambda raw: torch.tensor([index[c] for c in raw])
xt, yt = torch.tensor(built["train"]), to_y(cache["labels"]["train"])
xv, yv = torch.tensor(built["val"]), to_y(cache["labels"]["val"])
xs, ys = torch.tensor(built["test"]), to_y(cache["labels"]["test"])
width = xt.shape[1] // frames
model = SignHead(width, frames, len(concepts))
opt = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-2)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS)
crit = nn.CrossEntropyLoss(label_smoothing=0.1)
def score(x, y):
model.eval()
with torch.no_grad():
top3 = model(x).topk(3, dim=1).indices
return ((top3[:, 0] == y).float().mean().item(),
(top3 == y.unsqueeze(1)).any(dim=1).float().mean().item())
best, best_state, stale = 0.0, None, 0
for _ in range(EPOCHS):
model.train()
order = torch.randperm(len(xt))
for start in range(0, len(order), BATCH):
batch = order[start:start + BATCH]
opt.zero_grad()
crit(model(augment(xt[batch], width, strength)), yt[batch]).backward()
opt.step()
sched.step()
v1, _ = score(xv, yv)
if v1 > best:
best, stale = v1, 0
best_state = {k: v.clone() for k, v in model.state_dict().items()}
else:
stale += 1
if stale >= PATIENCE:
break
model.load_state_dict(best_state)
t1, t3 = score(xs, ys)
params = sum(p.numel() for p in model.parameters())
print(f"{name:38} top1 {t1:.3f} top3 {t3:.3f} ({params * 4 / 1024 / 1024:.1f} MB)")
return t1, t3
def main() -> None:
cache = {"samples": {}, "labels": {}}
for split in ("train", "val", "test"):
samples, labels = load_raw(split)
cache["samples"][split] = samples
cache["labels"][split] = labels
# Round three. The face expression block costs a whole FaceLandmarker pass per frame for
# 6 of the 149 floats in a frame, and frame rate is what decides whether the app writes
# anything at all. So: how stale may that reading be before its +1.2 top-1 is gone?
#
# Over several seeds, because the gaps being read are ~1 point on 598 test samples, which
# is the same size as the effect one seed change can invent.
# Round four. The torso block's measured +1.0 sits inside the between-seed sd of 0.024
# that round three found, so it gets the same treatment. Both variants keep the
# pose-relative hand location: only the five torso scalars move.
variants = [
("with torso (shipped)", True),
("without torso", False),
]
seeds = [7, 13, 29, 41]
results: dict[str, list[float]] = {name: [] for name, _ in variants}
for seed in seeds:
for name, use_torso in variants:
t1, _ = run(f"{name} [seed {seed}]", 16, True, False, 0.0, "none", cache,
True, 0.0, seed, use_torso)
results[name].append(t1)
print()
for name, _ in variants:
got = np.array(results[name])
print(f"{name:26} mean {got.mean():.3f} sd {got.std(ddof=1):.3f} "
f"min {got.min():.3f} max {got.max():.3f} n={len(got)}")
if __name__ == "__main__":
main()