Skip to content

Commit 489af3a

Browse files
committed
feat: read signs from the rear camera too
1 parent 5f017d1 commit 489af3a

7 files changed

Lines changed: 577 additions & 6 deletions

File tree

src/infrastructure/vision/MediaPipeLandmarkSource.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from '@domain/landmarks/value-objects/Landmark';
1111
import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision';
1212

13+
export type CameraFacing = 'user' | 'environment';
14+
1315
export interface MediaPipeOptions {
1416
/** Where the vendored WASM lives, relative to the deployed base. */
1517
readonly wasmPath: string;
@@ -31,6 +33,8 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
3133
private frameHandle: number | null = null;
3234
private lastVideoTime = -1;
3335
private running = false;
36+
private facing: CameraFacing = 'user';
37+
private listener: LandmarkListener | null = null;
3438

3539
constructor(
3640
private readonly video: HTMLVideoElement,
@@ -41,6 +45,26 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
4145
return this.running;
4246
}
4347

48+
get camera(): CameraFacing {
49+
return this.facing;
50+
}
51+
52+
/**
53+
* Front camera for signing to yourself, rear for reading someone else.
54+
*
55+
* Restarts the stream rather than reconfiguring it: `getUserMedia` cannot change
56+
* `facingMode` on a live track, and phones expose the two cameras as separate devices.
57+
*/
58+
async useCamera(facing: CameraFacing): Promise<void> {
59+
if (facing === this.facing) return;
60+
this.facing = facing;
61+
62+
if (!this.running) return;
63+
const listener = this.listener;
64+
this.stop();
65+
if (listener) await this.start(listener);
66+
}
67+
4468
/**
4569
* Downloads and initialises the engine. Separate from `start` so the UI can show download
4670
* progress for the ~29 MB before asking for the camera — asking for permission and then
@@ -58,10 +82,11 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
5882

5983
async start(listener: LandmarkListener): Promise<void> {
6084
await this.load();
85+
this.listener = listener;
6186

6287
try {
6388
this.stream = await navigator.mediaDevices.getUserMedia({
64-
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
89+
video: { facingMode: this.facing, width: { ideal: 1280 }, height: { ideal: 720 } },
6590
audio: false,
6691
});
6792
} catch (cause) {
@@ -87,7 +112,7 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
87112
this.lastVideoTime = this.video.currentTime;
88113
const timestampMs = performance.now();
89114
const result = this.landmarker.detectForVideo(this.video, timestampMs);
90-
listener({ timestampMs, hands: toHands(result) });
115+
listener({ timestampMs, hands: toHands(result, this.facing) });
91116
}
92117

93118
this.frameHandle = requestAnimationFrame(tick);
@@ -116,13 +141,28 @@ interface MediaPipeResult {
116141
readonly handedness?: { categoryName?: string }[][];
117142
}
118143

119-
function toHands(result: MediaPipeResult): HandLandmarks[] {
144+
/**
145+
* Which of the signer's hands MediaPipe just labelled.
146+
*
147+
* MediaPipe reports handedness for a *mirrored* view, which is what a selfie camera gives:
148+
* its "Left" is the user's right hand. The rear camera is not mirrored, so the mapping
149+
* inverts. Getting this wrong swaps every hand silently — and because `normalizeHand`
150+
* mirrors left hands into the right hand's space, two-handed signs come out reflected and
151+
* the model quietly sees the wrong thing.
152+
*
153+
* Exported so that reasoning can be tested without a camera.
154+
*/
155+
export function handednessFor(label: string | undefined, facing: CameraFacing): Handedness {
156+
const mirrored = facing === 'user';
157+
const isRight = mirrored ? label === 'Left' : label === 'Right';
158+
return isRight ? 'right' : 'left';
159+
}
160+
161+
function toHands(result: MediaPipeResult, facing: CameraFacing): HandLandmarks[] {
120162
const hands: HandLandmarks[] = [];
121163
result.landmarks?.forEach((points, i) => {
122164
const label = result.handedness?.[i]?.[0]?.categoryName;
123-
// MediaPipe labels the *mirrored* selfie view, so its "Left" is the user's right hand.
124-
const handedness: Handedness = label === 'Left' ? 'right' : 'left';
125-
hands.push(createHandLandmarks(handedness, points));
165+
hands.push(createHandLandmarks(handednessFor(label, facing), points));
126166
});
127167
return hands;
128168
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { handednessFor } from '../MediaPipeLandmarkSource';
3+
4+
describe('handednessFor', () => {
5+
it('reads the selfie view as mirrored', () => {
6+
// What MediaPipe calls "Left" in a mirrored frame is the signer's right hand.
7+
expect(handednessFor('Left', 'user')).toBe('right');
8+
expect(handednessFor('Right', 'user')).toBe('left');
9+
});
10+
11+
it('reads the rear camera as-is', () => {
12+
expect(handednessFor('Right', 'environment')).toBe('right');
13+
expect(handednessFor('Left', 'environment')).toBe('left');
14+
});
15+
16+
it('inverts between the two cameras for the same label', () => {
17+
// The whole point: one label, two answers. If these ever agree, the fix has been undone.
18+
expect(handednessFor('Left', 'user')).not.toBe(handednessFor('Left', 'environment'));
19+
});
20+
21+
it('falls back to left when the label is missing rather than throwing', () => {
22+
expect(handednessFor(undefined, 'user')).toBe('left');
23+
expect(handednessFor(undefined, 'environment')).toBe('left');
24+
});
25+
});

src/presentation/App.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function renderApp(root: HTMLElement): void {
3232
<button class="button" id="toggle" type="button">Empezar a leer</button>
3333
<button class="button button--quiet" id="undo" type="button">Borrar último</button>
3434
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
35+
<button class="button button--quiet" id="flip" type="button">Cámara trasera</button>
3536
</div>
3637
3738
<p class="status" id="status" role="status"></p>
@@ -144,6 +145,29 @@ export function renderApp(root: HTMLElement): void {
144145
render(recognize.current.toText(), []);
145146
});
146147

148+
const flip = must<HTMLButtonElement>(root, '#flip');
149+
flip.addEventListener('click', async () => {
150+
const next = container.source.camera === 'user' ? 'environment' : 'user';
151+
flip.disabled = true;
152+
try {
153+
await container.source.useCamera(next);
154+
// Only the selfie view is mirrored. Un-mirroring the rear camera matters beyond looks:
155+
// the overlay is mirrored to match the video, so the two must agree or the skeleton
156+
// lands on the wrong side of the screen.
157+
const mirrored = next === 'user';
158+
video.classList.toggle('is-flipped', !mirrored);
159+
overlayCanvas.classList.toggle('is-flipped', !mirrored);
160+
flip.textContent = mirrored ? 'Cámara trasera' : 'Cámara frontal';
161+
status.textContent = mirrored
162+
? 'Cámara frontal: para signar tú.'
163+
: 'Cámara trasera: para leer a quien tienes delante.';
164+
} catch {
165+
status.textContent = 'No se pudo cambiar de cámara.';
166+
} finally {
167+
flip.disabled = false;
168+
}
169+
});
170+
147171
new StoragePanel(must<HTMLElement>(root, '#storage'), {
148172
isSupported: () => container.engineStorage.isSupported(),
149173
report: () => container.engineStorage.report(),

src/presentation/styles/global.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,9 @@ body {
367367
font-size: 0.78rem;
368368
margin-top: 10px;
369369
}
370+
371+
/* The rear camera shows the world as it is; only the selfie view gets mirrored. */
372+
.stage__video.is-flipped,
373+
.stage__overlay.is-flipped {
374+
transform: none;
375+
}

tools/train/experiment.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)