Skip to content

Commit 2fb4fb7

Browse files
author
Endika Iglesias
committed
feat: teach the app your own signs and recognise them offline
1 parent dc90399 commit 2fb4fb7

16 files changed

Lines changed: 1079 additions & 5 deletions

File tree

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Three engines answer through one port, so the app does not care which one produc
1515
| Engine | What it reads | Where it comes from |
1616
| --- | --- | --- |
1717
| **Alphabet** | Fingerspelled letters (dactilológico). Spell anything, letter by letter. | Geometric handshape rules — no training data needed. |
18-
| **Vocabulary** | Whole LSE signs, one word each. | ONNX model trained on [SWL-LSE](https://zenodo.org/records/13691887): 300 classes, ~180 concepts after merging variants. Health domain. |
19-
| **Taught** | Any sign you record yourself, in any sign language. | Nearest-prototype match over 3+ examples you give it, stored on-device. |
18+
| **Vocabulary** | Whole LSE signs, one word each. | ONNX model trained on [SWL-LSE](https://zenodo.org/records/13691887): 300 classes, ~180 concepts after merging variants. Health domain. **Not built yet.** |
19+
| **Taught** | Any sign you record yourself, in any sign language. | Nearest-prototype match over 3+ recordings, stored in IndexedDB on your device. Working now. |
2020

2121
### What it does not do
2222

@@ -46,8 +46,15 @@ src/
4646

4747
The domain is where the interesting logic lives and it is fully testable without a camera:
4848
`SignSegmenter` decides where one sign ends and the next begins, `CandidateStabilizer` stops
49-
a jittering classifier from spelling `AAAABAAAA`, and `handShape` reduces 21 landmarks to
50-
scale- and handedness-invariant ratios.
49+
a jittering classifier from spelling `AAAABAAAA`, `handShape` reduces 21 landmarks to scale-
50+
and handedness-invariant ratios, and `windowSignature` collapses a whole sign into one
51+
fixed-length vector so two performances of it can be compared.
52+
53+
**Signature similarity is Euclidean, not cosine, and that is load-bearing.** Every hand
54+
shares the same gross structure, so cosine scored a fist against an open hand at 0.965 and an
55+
index point against a Y at 0.962 — no threshold separates those. Distance over already
56+
normalised coordinates gives 0.10 and 0.25 for the same pairs. If taught-sign recognition
57+
ever starts matching everything, check that this has not been "simplified" back to cosine.
5158

5259
**Normalisation must match training.** `normalizeHand` mirrors what `tools/train` applies to
5360
the dataset. A model trained on normalised coordinates and fed raw ones predicts noise
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { CustomSign } from '@domain/recognition/entities/CustomSign';
2+
import type { ICustomSignRepository } from '@domain/recognition/repositories/ICustomSignRepository';
3+
4+
/**
5+
* Listing and deleting taught signs.
6+
*
7+
* Deletion is not a nicety: these recordings are derived from the user's body and may encode
8+
* health vocabulary, so being able to remove one is part of the app's privacy posture, not a
9+
* convenience feature.
10+
*/
11+
export class ManageCustomSignsUseCase {
12+
constructor(private readonly repository: ICustomSignRepository) {}
13+
14+
async list(): Promise<CustomSign[]> {
15+
return this.repository.findAll();
16+
}
17+
18+
async delete(id: string): Promise<void> {
19+
await this.repository.delete(id);
20+
}
21+
}

src/application/use-cases/RecognizeSignsUseCase.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export class RecognizeSignsUseCase {
3737
private listener: RecognitionListener | null = null;
3838
/** Guards against overlapping async classify calls piling up behind a slow frame. */
3939
private busy = false;
40+
/** Set while teaching: the next completed sign is handed over instead of transcribed. */
41+
private capture: ((window: readonly LandmarkFrame[]) => void) | null = null;
4042

4143
constructor(
4244
private readonly source: ILandmarkSource,
@@ -75,6 +77,27 @@ export class RecognizeSignsUseCase {
7577
return this.transcript;
7678
}
7779

80+
/**
81+
* Resolves with the next completed sign instead of transcribing it.
82+
*
83+
* Recording reuses the live segmenter rather than a separate timed capture, so a taught
84+
* example is delimited exactly the way a recognised sign will be. Capturing on a stopwatch
85+
* would train the app on windows it never sees at recognition time.
86+
*/
87+
captureWindow(): Promise<readonly LandmarkFrame[]> {
88+
return new Promise((resolve) => {
89+
this.capture = resolve;
90+
});
91+
}
92+
93+
cancelCapture(): void {
94+
this.capture = null;
95+
}
96+
97+
get isCapturing(): boolean {
98+
return this.capture !== null;
99+
}
100+
78101
private async onFrame(frame: LandmarkFrame): Promise<void> {
79102
if (frame.hands.length === 0) {
80103
// A hand leaving frame is a deliberate boundary: it lets the same letter repeat.
@@ -83,6 +106,18 @@ export class RecognizeSignsUseCase {
83106

84107
const closedWindow = this.segmenter.push(frame);
85108

109+
if (this.capture) {
110+
// Teaching: hand the finished sign over, and transcribe nothing meanwhile — the user
111+
// is demonstrating a sign, not dictating.
112+
if (closedWindow) {
113+
const deliver = this.capture;
114+
this.capture = null;
115+
deliver(closedWindow);
116+
}
117+
this.emit([], frame);
118+
return;
119+
}
120+
86121
if (this.busy) return;
87122
this.busy = true;
88123
try {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
2+
import {
3+
type CustomSign,
4+
DuplicateSignTextError,
5+
MIN_PROTOTYPES_PER_SIGN,
6+
NotEnoughExamplesError,
7+
} from '@domain/recognition/entities/CustomSign';
8+
import type { ICustomSignRepository } from '@domain/recognition/repositories/ICustomSignRepository';
9+
import { windowSignature } from '@domain/recognition/services/windowSignature';
10+
11+
export class EmptySignTextError extends Error {
12+
constructor() {
13+
super('A taught sign needs a word to write when it is recognised');
14+
this.name = 'EmptySignTextError';
15+
}
16+
}
17+
18+
/**
19+
* Turns a handful of recordings of one sign into something the app can recognise.
20+
*
21+
* Clock and id generator are injected so a test can assert on exact stored values instead of
22+
* whatever the wall clock happened to say.
23+
*/
24+
export class TeachCustomSignUseCase {
25+
constructor(
26+
private readonly repository: ICustomSignRepository,
27+
private readonly now: () => number = () => Date.now(),
28+
private readonly newId: () => string = () => crypto.randomUUID(),
29+
) {}
30+
31+
async execute(
32+
text: string,
33+
examples: readonly (readonly LandmarkFrame[])[],
34+
): Promise<CustomSign> {
35+
const trimmed = text.trim();
36+
if (trimmed.length === 0) throw new EmptySignTextError();
37+
38+
// Empty recordings are dropped first: three attempts where two caught no hand is not
39+
// three examples, and accepting them would produce a sign that matches almost anything.
40+
const usable = examples.filter((example) => example.some((frame) => frame.hands.length > 0));
41+
if (usable.length < MIN_PROTOTYPES_PER_SIGN) {
42+
throw new NotEnoughExamplesError(usable.length);
43+
}
44+
45+
const existing = await this.repository.findAll();
46+
if (existing.some((sign) => sign.text.toLowerCase() === trimmed.toLowerCase())) {
47+
throw new DuplicateSignTextError(trimmed);
48+
}
49+
50+
const sign: CustomSign = {
51+
id: this.newId(),
52+
text: trimmed,
53+
prototypes: usable.map(windowSignature),
54+
createdAtMs: this.now(),
55+
};
56+
57+
await this.repository.save(sign);
58+
return sign;
59+
}
60+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
DuplicateSignTextError,
3+
NotEnoughExamplesError,
4+
} from '@domain/recognition/entities/CustomSign';
5+
import { SIGNATURE_LENGTH } from '@domain/recognition/services/windowSignature';
6+
import { InMemoryCustomSignRepository } from '@infrastructure/persistence/InMemoryCustomSignRepository';
7+
import { beforeEach, describe, expect, it } from 'vitest';
8+
import { buildFrame, buildHand } from '@/test/handFixtures';
9+
import { EmptySignTextError, TeachCustomSignUseCase } from '../TeachCustomSignUseCase';
10+
11+
function record(nudge = 0) {
12+
return Array.from({ length: 10 }, (_, i) =>
13+
buildFrame(i * 33, buildHand({ curls: [0.9, 0, 0.9, 0.9, 0.9], offset: { x: nudge, y: 0 } })),
14+
);
15+
}
16+
17+
const EMPTY_RECORDING = [buildFrame(0, null), buildFrame(33, null)];
18+
19+
describe('TeachCustomSignUseCase', () => {
20+
let repository: InMemoryCustomSignRepository;
21+
let teach: TeachCustomSignUseCase;
22+
23+
beforeEach(() => {
24+
repository = new InMemoryCustomSignRepository();
25+
teach = new TeachCustomSignUseCase(
26+
repository,
27+
() => 1_700_000_000_000,
28+
() => 'fixed-id',
29+
);
30+
});
31+
32+
it('stores one prototype per usable recording', async () => {
33+
const sign = await teach.execute('ibuprofeno', [record(0), record(0.02), record(0.04)]);
34+
35+
expect(sign.prototypes).toHaveLength(3);
36+
expect(sign.prototypes[0]).toHaveLength(SIGNATURE_LENGTH);
37+
expect(await repository.findAll()).toHaveLength(1);
38+
});
39+
40+
it('records the injected id and timestamp', async () => {
41+
const sign = await teach.execute('ibuprofeno', [record(0), record(0.02), record(0.04)]);
42+
43+
expect(sign.id).toBe('fixed-id');
44+
expect(sign.createdAtMs).toBe(1_700_000_000_000);
45+
});
46+
47+
it('trims the text it will write', async () => {
48+
const sign = await teach.execute(' ibuprofeno ', [record(0), record(0.02), record(0.04)]);
49+
expect(sign.text).toBe('ibuprofeno');
50+
});
51+
52+
it('rejects a sign with no word to write', async () => {
53+
await expect(teach.execute(' ', [record(0), record(0.02), record(0.04)])).rejects.toThrow(
54+
EmptySignTextError,
55+
);
56+
});
57+
58+
it('rejects fewer than three examples', async () => {
59+
await expect(teach.execute('ibuprofeno', [record(0), record(0.02)])).rejects.toThrow(
60+
NotEnoughExamplesError,
61+
);
62+
});
63+
64+
it('does not count recordings that caught no hand', async () => {
65+
// Three attempts where two saw nothing is one example, not three. Accepting them would
66+
// store zero-filled prototypes that match almost anything.
67+
await expect(
68+
teach.execute('ibuprofeno', [record(0), EMPTY_RECORDING, EMPTY_RECORDING]),
69+
).rejects.toThrow(NotEnoughExamplesError);
70+
});
71+
72+
it('rejects a word that is already taught, whatever the casing', async () => {
73+
await teach.execute('ibuprofeno', [record(0), record(0.02), record(0.04)]);
74+
75+
await expect(
76+
teach.execute('Ibuprofeno', [record(0), record(0.02), record(0.04)]),
77+
).rejects.toThrow(DuplicateSignTextError);
78+
});
79+
80+
it('leaves the repository untouched when it rejects', async () => {
81+
await expect(teach.execute('', [record(0), record(0.02), record(0.04)])).rejects.toThrow();
82+
expect(await repository.findAll()).toEqual([]);
83+
});
84+
});

src/bootstrap/Container.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { ManageCustomSignsUseCase } from '@application/use-cases/ManageCustomSignsUseCase';
12
import { RecognizeSignsUseCase } from '@application/use-cases/RecognizeSignsUseCase';
3+
import { TeachCustomSignUseCase } from '@application/use-cases/TeachCustomSignUseCase';
24
import type { ISignClassifier } from '@domain/recognition/services/ISignClassifier';
5+
import { IndexedDBCustomSignRepository } from '@infrastructure/persistence/indexeddb/IndexedDBCustomSignRepository';
36
import { HandshapeAlphabetClassifier } from '@infrastructure/recognition/HandshapeAlphabetClassifier';
7+
import { PrototypeSignClassifier } from '@infrastructure/recognition/PrototypeSignClassifier';
48
import { MediaPipeLandmarkSource } from '@infrastructure/vision/MediaPipeLandmarkSource';
59

610
/**
@@ -9,6 +13,10 @@ import { MediaPipeLandmarkSource } from '@infrastructure/vision/MediaPipeLandmar
913
*/
1014
export class Container {
1115
readonly source: MediaPipeLandmarkSource;
16+
readonly customSigns = new IndexedDBCustomSignRepository();
17+
readonly taught = new PrototypeSignClassifier(this.customSigns);
18+
readonly teach = new TeachCustomSignUseCase(this.customSigns);
19+
readonly manageCustomSigns = new ManageCustomSignsUseCase(this.customSigns);
1220
readonly classifiers: readonly ISignClassifier[];
1321
readonly recognize: RecognizeSignsUseCase;
1422

@@ -22,7 +30,7 @@ export class Container {
2230
maxHands: 2,
2331
});
2432

25-
this.classifiers = [new HandshapeAlphabetClassifier()];
33+
this.classifiers = [new HandshapeAlphabetClassifier(), this.taught];
2634
this.recognize = new RecognizeSignsUseCase(this.source, this.classifiers);
2735
}
2836
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { buildFrame, buildHand } from '@/test/handFixtures';
3+
import { SIGNATURE_LENGTH, similarity, windowSignature } from '../windowSignature';
4+
5+
type Curls = [number, number, number, number, number];
6+
7+
/** A held handshape, repeated for `frames` — a static sign. */
8+
function hold(curls: Curls, frames: number, handedness: 'left' | 'right' = 'right') {
9+
return Array.from({ length: frames }, (_, i) =>
10+
buildFrame(i * 33, buildHand({ curls, handedness })),
11+
);
12+
}
13+
14+
/** A handshape travelling across the frame — a dynamic sign. */
15+
function travel(curls: Curls, frames: number, step = 0.04) {
16+
return Array.from({ length: frames }, (_, i) =>
17+
buildFrame(i * 33, buildHand({ curls, offset: { x: i * step, y: 0 } })),
18+
);
19+
}
20+
21+
const FIST: Curls = [0.9, 0.9, 0.9, 0.9, 0.9];
22+
const OPEN: Curls = [0, 0, 0, 0, 0];
23+
const POINT: Curls = [0.9, 0, 0.9, 0.9, 0.9];
24+
25+
describe('windowSignature', () => {
26+
it('always produces the same length, whatever the sign lasted', () => {
27+
expect(windowSignature(hold(OPEN, 3))).toHaveLength(SIGNATURE_LENGTH);
28+
expect(windowSignature(hold(OPEN, 40))).toHaveLength(SIGNATURE_LENGTH);
29+
});
30+
31+
it('returns a zero signature for an empty window rather than throwing', () => {
32+
const signature = windowSignature([]);
33+
expect(signature).toHaveLength(SIGNATURE_LENGTH);
34+
expect(signature.every((value) => value === 0)).toBe(true);
35+
});
36+
37+
it('matches the same sign performed at different speeds', () => {
38+
// The whole point of resampling: duration must not be what distinguishes two signs.
39+
const fast = windowSignature(travel(POINT, 6));
40+
const slow = windowSignature(travel(POINT, 30));
41+
expect(similarity(fast, slow)).toBeGreaterThan(0.95);
42+
});
43+
44+
it('separates two genuinely different handshapes by a wide margin', () => {
45+
// Asserting a real number, not just an ordering: an earlier cosine metric ranked these
46+
// "correctly" while scoring them 0.965, which no threshold could have used.
47+
const fist = windowSignature(hold(FIST, 12));
48+
const open = windowSignature(hold(OPEN, 12));
49+
expect(similarity(fist, open)).toBeLessThan(0.3);
50+
});
51+
52+
it('keeps the closest distinct pair below the recognition threshold', () => {
53+
// A fist and an index point share four curled fingers, so they are the hardest pair on
54+
// this fixture set. If even they scored above 0.86 the classifier would be guessing.
55+
const fist = windowSignature(hold(FIST, 12));
56+
const point = windowSignature(hold(POINT, 12));
57+
expect(similarity(fist, point)).toBeLessThan(0.86);
58+
});
59+
60+
it('still matches a re-recording of the same sign with natural variation', () => {
61+
const first = windowSignature(hold(POINT, 12));
62+
const wobbly = Array.from({ length: 14 }, (_, i) =>
63+
buildFrame(
64+
i * 33,
65+
buildHand({
66+
curls: [0.85, 0.06, 0.96, 0.84, 0.95],
67+
offset: { x: 0.03, y: 0.02 },
68+
}),
69+
),
70+
);
71+
expect(similarity(first, windowSignature(wobbly))).toBeGreaterThan(0.86);
72+
});
73+
74+
it('is unaffected by where in frame the sign happens', () => {
75+
const here = windowSignature(hold(POINT, 12));
76+
const shifted = hold(POINT, 12).map((frame) =>
77+
buildFrame(frame.timestampMs, buildHand({ curls: POINT, offset: { x: 0.25, y: -0.2 } })),
78+
);
79+
expect(similarity(here, windowSignature(shifted))).toBeGreaterThan(0.99);
80+
});
81+
82+
it('keeps the two hands in separate slots', () => {
83+
// A right-handed sign and its left-handed twin must not collide, or a two-handed sign
84+
// could never be told apart from a one-handed one.
85+
const right = windowSignature(hold(POINT, 10, 'right'));
86+
const left = windowSignature(hold(POINT, 10, 'left'));
87+
expect(similarity(right, left)).toBeLessThan(0.99);
88+
});
89+
});
90+
91+
describe('similarity', () => {
92+
it('scores a vector against itself at the top of the range', () => {
93+
const signature = windowSignature(hold(POINT, 10));
94+
expect(similarity(signature, signature)).toBeCloseTo(1, 5);
95+
});
96+
97+
it('scores zero against a signature where nothing was tracked', () => {
98+
expect(similarity(new Float32Array(SIGNATURE_LENGTH), windowSignature(hold(OPEN, 5)))).toBe(0);
99+
});
100+
101+
it('does not call two empty signatures a perfect match', () => {
102+
// They sit at distance zero from each other, so the raw metric would say 1.0 — "I saw
103+
// no hand" agreeing with "I saw no hand" is not a recognised sign.
104+
const empty = new Float32Array(SIGNATURE_LENGTH);
105+
expect(similarity(empty, new Float32Array(SIGNATURE_LENGTH))).toBe(0);
106+
});
107+
108+
it('refuses to compare signatures of different lengths', () => {
109+
expect(similarity(new Float32Array(4), new Float32Array(8))).toBe(0);
110+
});
111+
});

0 commit comments

Comments
 (0)