Skip to content

Commit eb24e11

Browse files
committed
fix: measure sign windows in time, so recognition survives any frame rate
1 parent 214b1c5 commit eb24e11

6 files changed

Lines changed: 277 additions & 95 deletions

File tree

src/application/use-cases/__tests__/RecognizeSignsUseCase.test.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,30 @@ class CountingWindowClassifier implements ISignClassifier {
8484
}
8585
}
8686

87+
const FRAME_MS = 33;
88+
8789
function movingFrames(count: number, from = 0) {
8890
return Array.from({ length: count }, (_, i) =>
89-
buildFrame((from + i) * 33, buildHand({ offset: { x: (from + i) * 0.06, y: 0 } })),
91+
buildFrame((from + i) * FRAME_MS, buildHand({ offset: { x: (from + i) * 0.06, y: 0 } })),
9092
);
9193
}
9294

93-
function stillFrames(count: number, at: number) {
94-
return Array.from({ length: count }, () => buildFrame(0, buildHand({ offset: { x: at, y: 0 } })));
95+
/**
96+
* One complete sign: moving, then held still long enough to read as a boundary.
97+
*
98+
* The frame count is chosen for *duration* — 40 frames at 33 ms is 1.3 s, past the
99+
* segmenter's `minSignMs`. Timestamps have to be real and continuous: these frames used to
100+
* be stamped 0 and the segmenter counted frames, so the lie was invisible. It is not any
101+
* more, and a collapsed timestamp span now silently means "no sign happened".
102+
*/
103+
function scriptedSign(moving = 40, still = 6) {
104+
const heldAt = (moving - 1) * 0.06;
105+
return [
106+
...movingFrames(moving),
107+
...Array.from({ length: still }, (_, i) =>
108+
buildFrame((moving + i) * FRAME_MS, buildHand({ offset: { x: heldAt, y: 0 } })),
109+
),
110+
];
95111
}
96112

97113
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -121,7 +137,7 @@ describe('RecognizeSignsUseCase', () => {
121137
await tick();
122138

123139
// The sign now completes entirely while that first call is still in flight.
124-
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
140+
for (const frame of scriptedSign()) source.push(frame);
125141
expect(vocabulary.calls).toBe(0);
126142

127143
slow.blocking = false;
@@ -133,7 +149,7 @@ describe('RecognizeSignsUseCase', () => {
133149

134150
it('transcribes a recognised sign as a word', async () => {
135151
await recognize.start(() => {});
136-
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
152+
for (const frame of scriptedSign()) source.push(frame);
137153
await tick();
138154

139155
expect(recognize.current.toText().toLowerCase()).toContain('dolor');
@@ -146,7 +162,7 @@ describe('RecognizeSignsUseCase', () => {
146162
await recognize.start((update) => {
147163
last = update;
148164
});
149-
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
165+
for (const frame of scriptedSign()) source.push(frame);
150166
await tick();
151167
return last!.diagnostics;
152168
}
@@ -213,7 +229,7 @@ describe('RecognizeSignsUseCase', () => {
213229
slow.blocking = true;
214230
source.push(buildFrame(0, buildHand()));
215231
await tick();
216-
for (const frame of [...movingFrames(20), ...stillFrames(14, 1.2)]) source.push(frame);
232+
for (const frame of scriptedSign()) source.push(frame);
217233

218234
recognize.stop();
219235
slow.blocking = false;

src/domain/recognition/services/SignSegmenter.ts

Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import { HandPoint, pointAt } from '@domain/landmarks/value-objects/Landmark';
33
import { dominantHand, type LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
44

55
export interface SegmenterOptions {
6-
/** Movement above this (palm widths per frame) counts as "signing". */
7-
readonly motionThreshold: number;
6+
/**
7+
* Movement above this (palm widths per **second**) counts as "signing".
8+
*
9+
* Per second, not per frame: a frame-relative floor is a speed floor divided by the frame
10+
* rate, so the same unhurried sign counts as motion on a slow phone and as stillness on a
11+
* fast one.
12+
*/
13+
readonly motionRate: number;
814
/**
915
* Fraction of the window's peak speed below which the signer is decelerating.
1016
*
@@ -14,17 +20,27 @@ export interface SegmenterOptions {
1420
* subsumes the old one rather than sitting beside it.
1521
*/
1622
readonly decelerationDrop: number;
17-
/** Frames the deceleration must persist before it counts as a boundary, not a wobble. */
18-
readonly decelerationHold: number;
23+
/** How long the deceleration must persist before it counts as a boundary, not a wobble. */
24+
readonly decelerationHoldMs: number;
1925
/**
20-
* Frames a window must reach before a deceleration may close it.
26+
* How long a window must run before a deceleration may close it.
2127
*
2228
* Signs decelerate internally — a two-part sign slows at its hinge — so without a floor
2329
* near the typical sign length the rule chops signs in half. Measured: dropping this from
24-
* 24 to 18 costs 8 points of isolated top-1 to buy 3 points of continuous recovery.
30+
* 1150 ms to 850 ms costs 8 points of isolated top-1 to buy 3 points of continuous recovery.
2531
*/
26-
readonly minSignFrames: number;
32+
readonly minSignMs: number;
2733
/** Shortest accepted sign; below this it is camera noise, not a sign. */
34+
readonly minMs: number;
35+
/**
36+
* Fewest frames a window may contain, whatever its duration.
37+
*
38+
* A separate concern from `minMs`, and both are needed. `minMs` rejects what is too brief
39+
* to be a sign; this rejects what has too few samples to *describe* one — sixteen feature
40+
* slots resampled from two frames is a vector the model has never seen. On a device slow
41+
* enough to trip this, recognition is genuinely impossible, and the discarded-window
42+
* counter is what makes that visible instead of silent.
43+
*/
2844
readonly minFrames: number;
2945
/**
3046
* Longest a window may run before it is emitted anyway.
@@ -34,7 +50,7 @@ export interface SegmenterOptions {
3450
* doing all the work while stillness was the only other rule, which is precisely why
3551
* windows were cut at a fixed length instead of at sign boundaries.
3652
*/
37-
readonly maxFrames: number;
53+
readonly maxMs: number;
3854
}
3955

4056
/**
@@ -43,43 +59,59 @@ export interface SegmenterOptions {
4359
* `tools/train/sweep.py` replays SWL-LSE's isolated recordings; `tools/train/continuous.py`
4460
* splices them into unbroken streams and asks how many signs survive. The shipped
4561
* stillness rule scored 0.739 on the first and **0.146 on the second** — it never closes
46-
* without a pause, so it only closed at `maxFrames`, and with a median sign of 30 frames
62+
* without a pause, so it only closed at the cap, and with a median sign of 30 frames
4763
* nearly every 48-frame window straddled a boundary. That is why the app read a signing
4864
* video and wrote nothing.
4965
*
5066
* These settings give 0.696 isolated and 0.384 continuous at higher precision (0.755 against
5167
* 0.710). Four points of the validated path bought twenty-four of the one people actually
5268
* use. Note the continuous benchmark splices isolated recordings and so cannot reproduce
5369
* real co-articulation: read it as an upper bound.
70+
*
71+
* **Expressed in time, not frames, and that was a bug for a while.** Every threshold here
72+
* used to be a frame count, swept against SWL-LSE — which is 20.00 fps in all 300 of its
73+
* recordings. The live pipeline runs three MediaPipe models per frame and reaches whatever
74+
* the device allows, so those counts silently meant a different duration on every phone.
75+
* Below the dataset's rate a sign never reached that 24-frame floor and every window was
76+
* discarded as too short: measured in a real browser, 23 frames in 16 s, six windows
77+
* discarded, the vocabulary engine asked **zero** times, nothing written. The same build
78+
* recognised "Dolor" at 68% as soon as the input was slowed to restore the sampling density.
79+
* `LandmarkFrame` has always carried `timestampMs`; it just was not read.
80+
*
81+
* The values below are the swept frame counts converted at 20.00 fps — and N frames span
82+
* N-1 intervals, so the 24-frame floor is 1150 ms, not 1200. Converting exactly is what lets
83+
* `simulate_app.py` reproduce the pre-conversion scores and prove the port faithful.
5484
*/
5585
export const DEFAULT_SEGMENTER_OPTIONS: SegmenterOptions = {
56-
motionThreshold: 0.03,
86+
motionRate: 0.6,
5787
decelerationDrop: 0.45,
58-
decelerationHold: 1,
59-
minSignFrames: 24,
88+
decelerationHoldMs: 50,
89+
minSignMs: 1150,
90+
minMs: 150,
6091
minFrames: 4,
61-
maxFrames: 48,
92+
maxMs: 2350,
6293
};
6394

6495
/**
6596
* Turns a continuous landmark stream into discrete sign windows.
6697
*
67-
* A window closes where the signer decelerates off their own peak speed, with `maxFrames`
98+
* A window closes where the signer decelerates off their own peak speed, with `maxMs`
6899
* as a backstop. Deceleration rather than stillness because stillness is a property of
69100
* dictionary recordings, not of signing: measured on spliced continuous streams, waiting
70101
* for a pause recovered 14.6% of signs against 38.4% for this rule.
71102
*
72-
* `minSignFrames` is what keeps it honest — signs decelerate internally too, so a boundary
103+
* `minSignMs` is what keeps it honest — signs decelerate internally too, so a boundary
73104
* is only believed once the window is already about as long as a sign.
74105
*
75-
* Deliberately a pure state machine over frames — no timers, no clock, no I/O — so its
76-
* behaviour is reproducible in tests by pushing a scripted frame sequence.
106+
* Deliberately a pure state machine over the frames' own `timestampMs` — no timers, no
107+
* clock, no I/O — so its behaviour is reproducible in tests by pushing a scripted sequence,
108+
* and identical at any frame rate.
77109
*/
78110
export class SignSegmenter {
79111
private readonly options: SegmenterOptions;
80112
private window: LandmarkFrame[] = [];
81-
private slowFrames = 0;
82-
private peakMotion = 0;
113+
private slowMs = 0;
114+
private peakRate = 0;
83115
private active = false;
84116
private shortWindows = 0;
85117

@@ -98,7 +130,7 @@ export class SignSegmenter {
98130
}
99131

100132
/**
101-
* Windows completed and then discarded for being shorter than `minFrames`.
133+
* Windows completed and then discarded for being too brief or too sparsely sampled.
102134
*
103135
* Invisible from outside otherwise: `push` returns null both when nothing ended and when
104136
* something ended and was judged too short to be a sign. Those are opposite diagnoses.
@@ -112,34 +144,38 @@ export class SignSegmenter {
112144
const hand = dominantHand(frame);
113145
if (!hand) return this.handleHandLost();
114146

115-
const motion = this.motionSince(frame);
147+
const sinceLastMs = this.gapBefore(frame);
148+
const rate = this.motionRateSince(frame, sinceLastMs);
116149
this.window.push(frame);
117150

118-
if (motion > this.options.motionThreshold) {
151+
if (rate > this.options.motionRate) {
119152
this.active = true;
120-
this.peakMotion = Math.max(this.peakMotion, motion);
153+
this.peakRate = Math.max(this.peakRate, rate);
121154
}
122155

123156
if (!this.active) {
124157
// Still idle: keep only a short tail so the next sign's start is not truncated.
125-
if (this.window.length > this.options.minFrames) this.window.shift();
158+
while (this.window.length > 1 && this.span() > this.options.minMs) this.window.shift();
126159
return null;
127160
}
128161

129-
if (this.window.length >= this.options.maxFrames) return this.close();
162+
if (this.span() >= this.options.maxMs) return this.close();
130163

131164
const decelerating =
132-
this.window.length >= this.options.minSignFrames &&
133-
this.peakMotion > 0 &&
134-
motion < this.peakMotion * this.options.decelerationDrop;
165+
this.span() >= this.options.minSignMs &&
166+
this.peakRate > 0 &&
167+
rate < this.peakRate * this.options.decelerationDrop;
135168

136169
if (!decelerating) {
137-
this.slowFrames = 0;
170+
this.slowMs = 0;
138171
return null;
139172
}
140173

141-
this.slowFrames += 1;
142-
return this.slowFrames >= this.options.decelerationHold ? this.close() : null;
174+
// Accumulated rather than counted: one frame is 50 ms at the frame rate this was tuned
175+
// at and 17 ms at 60 fps, so counting frames would make the hold three times stricter on
176+
// a fast device — the same units mistake one level down.
177+
this.slowMs += sinceLastMs;
178+
return this.slowMs >= this.options.decelerationHoldMs ? this.close() : null;
143179
}
144180

145181
/** A hand leaving frame ends the sign as surely as stillness does. */
@@ -153,26 +189,44 @@ export class SignSegmenter {
153189

154190
private close(): readonly LandmarkFrame[] | null {
155191
const completed = this.window;
192+
const spanMs = spanOf(completed);
156193
this.reset();
157-
if (completed.length >= this.options.minFrames) return completed;
194+
195+
const longEnough = spanMs >= this.options.minMs;
196+
const sampledEnough = completed.length >= this.options.minFrames;
197+
if (longEnough && sampledEnough) return completed;
198+
158199
this.shortWindows += 1;
159200
return null;
160201
}
161202

162203
reset(): void {
163204
this.window = [];
164-
this.slowFrames = 0;
165-
this.peakMotion = 0;
205+
this.slowMs = 0;
206+
this.peakRate = 0;
166207
this.active = false;
167208
}
168209

210+
/** Wall-clock span of the frames buffered so far. */
211+
private span(): number {
212+
return spanOf(this.window);
213+
}
214+
215+
/** Time since the previous buffered frame, from the frames themselves. */
216+
private gapBefore(frame: LandmarkFrame): number {
217+
const previous = this.window.at(-1);
218+
return previous ? frame.timestampMs - previous.timestampMs : 0;
219+
}
220+
169221
/**
170-
* Mean fingertip displacement from the previous frame, in palm widths so that moving the
171-
* phone closer does not read as faster signing.
222+
* Mean fingertip speed since the previous frame, in palm widths per second.
223+
*
224+
* Palm widths so that moving the phone closer does not read as faster signing; per second
225+
* so that a slow phone does not read as faster signing either.
172226
*/
173-
private motionSince(frame: LandmarkFrame): number {
227+
private motionRateSince(frame: LandmarkFrame, sinceLastMs: number): number {
174228
const previous = this.window.at(-1);
175-
if (!previous) return 0;
229+
if (!previous || sinceLastMs <= 0) return 0;
176230

177231
const current = dominantHand(frame);
178232
const before = dominantHand(previous);
@@ -190,6 +244,11 @@ export class SignSegmenter {
190244
(sum, tip) => sum + distance(pointAt(current, tip), pointAt(before, tip)),
191245
0,
192246
);
193-
return total / tips.length / scale;
247+
return total / tips.length / scale / (sinceLastMs / 1000);
194248
}
195249
}
250+
251+
function spanOf(frames: readonly LandmarkFrame[]): number {
252+
if (frames.length < 2) return 0;
253+
return frames.at(-1)!.timestampMs - frames[0]!.timestampMs;
254+
}

0 commit comments

Comments
 (0)