Skip to content

Commit 024723d

Browse files
authored
feat: report the feature vector the model was actually fed (#39)
1 parent f3c16ba commit 024723d

8 files changed

Lines changed: 182 additions & 4 deletions

File tree

src/application/use-cases/RecognizeSignsUseCase.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import type {
1111
RawScore,
1212
RecognitionDiagnostics,
13+
SignatureProfile,
1314
WindowVeto,
1415
} from '@domain/recognition/value-objects/RecognitionDiagnostics';
1516
import { Transcript } from '@domain/transcript/entities/Transcript';
@@ -64,6 +65,7 @@ export class RecognizeSignsUseCase {
6465
private lastRawTop: readonly RawScore[] = [];
6566
private lastVeto: WindowVeto | null = null;
6667
private wordsEmitted = 0;
68+
private lastSignature: SignatureProfile | null = null;
6769
/** The segmenter outlives a session, so short-window counts are read as a delta. */
6870
private shortWindowsAtStart = 0;
6971

@@ -169,6 +171,7 @@ export class RecognizeSignsUseCase {
169171
this.vocabularyInvocations += 1;
170172
const words = await this.classifyWindow(pending);
171173
this.lastRawTop = this.collectRawScores();
174+
this.lastSignature = this.collectSignatureProfile();
172175
const top = words[0] ?? null;
173176
const word = this.windowStabilizer.accept(top);
174177
if (word) this.append(word, frame.timestampMs);
@@ -234,6 +237,15 @@ export class RecognizeSignsUseCase {
234237
return [];
235238
}
236239

240+
private collectSignatureProfile(): SignatureProfile | null {
241+
for (const engine of this.classifiers) {
242+
if (engine.granularity === 'window' && engine.lastSignatureProfile) {
243+
return engine.lastSignatureProfile;
244+
}
245+
}
246+
return null;
247+
}
248+
237249
private resetDiagnostics(): void {
238250
this.framesSeen = 0;
239251
this.framesWithHands = 0;
@@ -243,6 +255,7 @@ export class RecognizeSignsUseCase {
243255
this.lastRawTop = [];
244256
this.lastVeto = null;
245257
this.wordsEmitted = 0;
258+
this.lastSignature = null;
246259
this.shortWindowsAtStart = this.segmenter.discardedShortWindows;
247260
}
248261

@@ -262,6 +275,7 @@ export class RecognizeSignsUseCase {
262275
lastRawTop: this.lastRawTop,
263276
lastVeto: this.lastVeto,
264277
wordsEmitted: this.wordsEmitted,
278+
lastSignature: this.lastSignature,
265279
};
266280
}
267281

src/domain/recognition/services/ISignClassifier.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
22
import type { SignCandidate } from '../value-objects/Gloss';
3-
import type { RawScore } from '../value-objects/RecognitionDiagnostics';
3+
import type { RawScore, SignatureProfile } from '../value-objects/RecognitionDiagnostics';
44

55
/**
66
* The single port every recognition engine implements, so the application layer never
@@ -34,4 +34,6 @@ export interface ISignClassifier {
3434
* returns an empty array in both cases.
3535
*/
3636
readonly lastScores?: readonly RawScore[];
37+
/** The feature vector the last `classify` was fed, summarised per body part. */
38+
readonly lastSignatureProfile?: SignatureProfile | null;
3739
}

src/domain/recognition/services/__tests__/vocabularySignature.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { join } from 'node:path';
33
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
44
import { describe, expect, it } from 'vitest';
55
import { buildFrame, buildHand } from '@/test/handFixtures';
6-
import { VOCABULARY_SIGNATURE_LENGTH, vocabularySignature } from '../vocabularySignature';
6+
import {
7+
profileSignature,
8+
VOCABULARY_SIGNATURE_LENGTH,
9+
vocabularySignature,
10+
} from '../vocabularySignature';
711

812
/** Written by `tools/train/make_parity.py` from the same synthetic input. */
913
const parity = JSON.parse(
@@ -79,4 +83,24 @@ describe('vocabularySignature', () => {
7983
// certainly not a different sign.
8084
expect(short).toHaveLength(stretched.length);
8185
});
86+
87+
describe('profileSignature', () => {
88+
it('summarises each body part of a well-formed window', () => {
89+
const profile = profileSignature(vocabularySignature(parityFrames()));
90+
91+
expect(profile.torso.emptyFrames).toBe(0);
92+
expect(profile.rightHand.meanMagnitude).toBeGreaterThan(0);
93+
expect(profile.face.meanMagnitude).toBeGreaterThan(0);
94+
});
95+
96+
it('reports the hands as empty when pose is missing, which is what breaks the model', () => {
97+
// Hand coordinates are expressed relative to the torso, so losing pose does not degrade
98+
// the hand block — it zeroes it. Invisible on screen, fatal to the prediction.
99+
const noPose = parityFrames().map(({ pose: _pose, ...frame }) => frame);
100+
const profile = profileSignature(vocabularySignature(noPose));
101+
102+
expect(profile.torso.emptyFrames).toBe(1);
103+
expect(profile.rightHand.emptyFrames).toBe(1);
104+
});
105+
});
82106
});

src/domain/recognition/services/vocabularySignature.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { PosePoint } from '@domain/landmarks/value-objects/BodyLandmarks';
22
import { HandPoint, type Landmark } from '@domain/landmarks/value-objects/Landmark';
33
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
4+
import type {
5+
SignatureBlock,
6+
SignatureProfile,
7+
} from '@domain/recognition/value-objects/RecognitionDiagnostics';
48

59
/**
610
* What the trained LSE vocabulary model reads.
@@ -25,6 +29,14 @@ const FACE_FLOATS = 6;
2529
const FRAME_FLOATS = HAND_FLOATS * 2 + TORSO_FLOATS + FACE_FLOATS;
2630
export const VOCABULARY_SIGNATURE_LENGTH = VOCABULARY_FRAMES * FRAME_FLOATS;
2731

32+
/** Where each part lives inside one frame's slice, for reading a built signature back. */
33+
const BLOCKS = {
34+
rightHand: [0, HAND_FLOATS],
35+
leftHand: [HAND_FLOATS, HAND_FLOATS * 2],
36+
torso: [HAND_FLOATS * 2, HAND_FLOATS * 2 + TORSO_FLOATS],
37+
face: [HAND_FLOATS * 2 + TORSO_FLOATS, FRAME_FLOATS],
38+
} as const;
39+
2840
/**
2941
* Face Mesh indices for the landmarks that carry grammar while signing. MediaPipe's
3042
* FaceLandmarker returns 478 points; 0–467 are the same mesh these indices refer to.
@@ -70,6 +82,44 @@ export function vocabularySignature(window: readonly LandmarkFrame[]): Float32Ar
7082
return signature;
7183
}
7284

85+
/**
86+
* Reads a built signature back as four per-part summaries.
87+
*
88+
* The model scores near-noise in the browser while measuring 0.741 offline, and the feature
89+
* code has verified parity with the trainer — so what differs is the input, not the maths.
90+
* Comparing these four numbers against the same statistics over SWL-LSE's test split says
91+
* which part is wrong without guessing: a part that is empty here and never empty in
92+
* training, or an order-of-magnitude gap, is the answer.
93+
*/
94+
export function profileSignature(signature: Float32Array): SignatureProfile {
95+
const read = (from: number, to: number): SignatureBlock => {
96+
let empty = 0;
97+
let total = 0;
98+
let count = 0;
99+
for (let slot = 0; slot < VOCABULARY_FRAMES; slot += 1) {
100+
const base = slot * FRAME_FLOATS;
101+
let magnitude = 0;
102+
for (let i = from; i < to; i += 1) magnitude += Math.abs(signature[base + i] ?? 0);
103+
if (magnitude === 0) empty += 1;
104+
else {
105+
total += magnitude / (to - from);
106+
count += 1;
107+
}
108+
}
109+
return {
110+
emptyFrames: empty / VOCABULARY_FRAMES,
111+
meanMagnitude: count ? total / count : 0,
112+
};
113+
};
114+
115+
return {
116+
rightHand: read(...BLOCKS.rightHand),
117+
leftHand: read(...BLOCKS.leftHand),
118+
torso: read(...BLOCKS.torso),
119+
face: read(...BLOCKS.face),
120+
};
121+
}
122+
73123
function sampleIndex(slot: number, length: number): number {
74124
if (length === 1) return 0;
75125
return Math.round((slot / (VOCABULARY_FRAMES - 1)) * (length - 1));

src/domain/recognition/value-objects/RecognitionDiagnostics.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ export interface RawScore {
2222
*/
2323
export type WindowVeto = 'classifier' | 'stabilizer' | 'duplicate';
2424

25+
export interface SignatureBlock {
26+
/** 0..1 of the 16 sampled frames where this part contributed nothing at all. */
27+
readonly emptyFrames: number;
28+
/** Mean absolute value across the part's floats, over the frames that had it. */
29+
readonly meanMagnitude: number;
30+
}
31+
32+
/**
33+
* The feature vector the browser actually built, summarised per body part.
34+
*
35+
* Measured over SWL-LSE's test split for comparison — right hand 3.37, left 3.61, torso
36+
* 0.515, face 0.157, and torso empty on 0.0% of frames. A part that reads far from its
37+
* number here is receiving something training never saw.
38+
*/
39+
export interface SignatureProfile {
40+
readonly rightHand: SignatureBlock;
41+
readonly leftHand: SignatureBlock;
42+
readonly torso: SignatureBlock;
43+
readonly face: SignatureBlock;
44+
}
45+
2546
export interface RecognitionDiagnostics {
2647
readonly framesSeen: number;
2748
readonly framesWithHands: number;
@@ -39,6 +60,8 @@ export interface RecognitionDiagnostics {
3960
readonly lastRawTop: readonly RawScore[];
4061
readonly lastVeto: WindowVeto | null;
4162
readonly wordsEmitted: number;
63+
/** What the model was actually fed for the last window. Null until one is classified. */
64+
readonly lastSignature: SignatureProfile | null;
4265
}
4366

4467
export const EMPTY_DIAGNOSTICS: RecognitionDiagnostics = {
@@ -54,4 +77,5 @@ export const EMPTY_DIAGNOSTICS: RecognitionDiagnostics = {
5477
lastRawTop: [],
5578
lastVeto: null,
5679
wordsEmitted: 0,
80+
lastSignature: null,
5781
};

src/infrastructure/recognition/VocabularySignClassifier.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { LandmarkFrame } from '@domain/landmarks/value-objects/LandmarkFrame';
22
import type { ISignClassifier } from '@domain/recognition/services/ISignClassifier';
33
import {
4+
profileSignature,
45
VOCABULARY_SIGNATURE_LENGTH,
56
vocabularySignature,
67
} from '@domain/recognition/services/vocabularySignature';
@@ -9,7 +10,10 @@ import {
910
createGloss,
1011
type SignCandidate,
1112
} from '@domain/recognition/value-objects/Gloss';
12-
import type { RawScore } from '@domain/recognition/value-objects/RecognitionDiagnostics';
13+
import type {
14+
RawScore,
15+
SignatureProfile,
16+
} from '@domain/recognition/value-objects/RecognitionDiagnostics';
1317
import {
1418
affine,
1519
type GruDirection,
@@ -68,6 +72,7 @@ export class VocabularySignClassifier implements ISignClassifier {
6872
private manifest: VocabularyManifest | null = null;
6973
private tensors: Map<string, Float32Array> | null = null;
7074
private rawTop: readonly RawScore[] = [];
75+
private profile: SignatureProfile | null = null;
7176

7277
constructor(
7378
private readonly manifestUrl: string,
@@ -79,6 +84,11 @@ export class VocabularySignClassifier implements ISignClassifier {
7984
return this.rawTop;
8085
}
8186

87+
/** What the last window looked like as features, per body part. */
88+
get lastSignatureProfile(): SignatureProfile | null {
89+
return this.profile;
90+
}
91+
8292
isReady(): boolean {
8393
return this.tensors !== null;
8494
}
@@ -119,7 +129,9 @@ export class VocabularySignClassifier implements ISignClassifier {
119129
const tensors = this.tensors;
120130
if (!manifest || !tensors || window.length === 0) return [];
121131

122-
const probabilities = softmax(this.forward(vocabularySignature(window), manifest, tensors));
132+
const signature = vocabularySignature(window);
133+
this.profile = profileSignature(signature);
134+
const probabilities = softmax(this.forward(signature, manifest, tensors));
123135

124136
const ranked = manifest.concepts
125137
.map((concept, i) => ({

src/presentation/components/DiagnosticsPanel.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import {
22
EMPTY_DIAGNOSTICS,
33
type RecognitionDiagnostics,
4+
type SignatureBlock,
45
type WindowVeto,
56
} from '@domain/recognition/value-objects/RecognitionDiagnostics';
67

8+
/**
9+
* The same statistics measured over SWL-LSE's test split — `tools/train`, 598 recordings.
10+
*
11+
* Shown beside the live numbers because the model scores near-noise in the browser while
12+
* measuring 0.741 offline, and feature parity with the trainer is already verified. So the
13+
* input differs, and the part whose numbers do not match is where.
14+
*/
15+
const EXPECTED: Record<string, { empty: number; magnitude: number }> = {
16+
'Mano derecha': { empty: 0.238, magnitude: 3.37 },
17+
'Mano izquierda': { empty: 0.377, magnitude: 3.61 },
18+
Torso: { empty: 0.0, magnitude: 0.515 },
19+
Cara: { empty: 0.002, magnitude: 0.157 },
20+
};
21+
722
/**
823
* Shows what the pipeline did, so "it writes nothing" becomes a specific failure.
924
*
@@ -90,6 +105,37 @@ export class DiagnosticsPanel {
90105
<dt>Mejores opciones, sin filtrar</dt>
91106
<dd>${scores}</dd>
92107
</div>
108+
<div class="diagnostics__row diagnostics__row--wide">
109+
<dt>Lo que recibió el modelo (esperado entre paréntesis)</dt>
110+
<dd>${this.describeSignature()}</dd>
111+
</div>
93112
`;
94113
}
114+
115+
/** Live feature magnitudes next to the training reference, one line per body part. */
116+
private describeSignature(): string {
117+
const profile = this.latest.lastSignature;
118+
if (!profile) return 'todavía nada';
119+
120+
const parts: [string, SignatureBlock][] = [
121+
['Mano derecha', profile.rightHand],
122+
['Mano izquierda', profile.leftHand],
123+
['Torso', profile.torso],
124+
['Cara', profile.face],
125+
];
126+
127+
return parts
128+
.map(([label, block]) => {
129+
const reference = EXPECTED[label]!;
130+
const empty = `${(block.emptyFrames * 100).toFixed(0)}% vacío (${(reference.empty * 100).toFixed(0)}%)`;
131+
const size = `${block.meanMagnitude.toFixed(2)} (${reference.magnitude.toFixed(2)})`;
132+
// Flagged rather than left to be eyeballed: an order of magnitude is the signal.
133+
const off =
134+
block.emptyFrames - reference.empty > 0.3 ||
135+
block.meanMagnitude > reference.magnitude * 3 ||
136+
block.meanMagnitude < reference.magnitude / 3;
137+
return `<div${off ? ' class="diagnostics__off"' : ''}>${label}: ${empty} · ${size}</div>`;
138+
})
139+
.join('');
140+
}
95141
}

src/presentation/styles/global.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,9 @@ body {
470470
.diagnostics__row--wide dd {
471471
text-align: left;
472472
}
473+
474+
/* A block whose numbers are nowhere near training: the one line worth finding fast. */
475+
.diagnostics__off {
476+
color: var(--danger);
477+
font-weight: 600;
478+
}

0 commit comments

Comments
 (0)