@@ -3,8 +3,14 @@ import { HandPoint, pointAt } from '@domain/landmarks/value-objects/Landmark';
33import { dominantHand , type LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame' ;
44
55export 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 */
5585export 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 */
78110export 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