Skip to content

Commit f0f86c2

Browse files
committed
feat: measure what each vision model costs per frame
1 parent a62d3cc commit f0f86c2

7 files changed

Lines changed: 141 additions & 0 deletions

File tree

src/application/use-cases/RecognizeSignsUseCase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export class RecognizeSignsUseCase {
291291
wordsEmitted: this.wordsEmitted,
292292
lettersEmitted: this.lettersEmitted,
293293
lastSignature: this.lastSignature,
294+
frameCost: this.source.frameCost?.() ?? null,
294295
};
295296
}
296297

src/domain/landmarks/services/ILandmarkSource.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FrameCost } from '../value-objects/FrameCost';
12
import type { LandmarkFrame } from '../value-objects/LandmarkFrame';
23

34
export type LandmarkListener = (frame: LandmarkFrame) => void;
@@ -11,6 +12,8 @@ export interface ILandmarkSource {
1112
start(listener: LandmarkListener): Promise<void>;
1213
stop(): void;
1314
isRunning(): boolean;
15+
/** Optional: only a source with real models has a per-model cost to report. */
16+
frameCost?(): FrameCost | null;
1417
}
1518

1619
export class CameraUnavailableError extends Error {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* What one frame of the vision pipeline costs, per model.
3+
*
4+
* Frame rate is not a comfort metric in this app: below the segmenter's floor no window can
5+
* satisfy both `minSignMs` and `minFrames`, so the app writes nothing at all. Deciding which
6+
* model to make cheaper needs its share of the budget measured on a real device, not guessed
7+
* from model file sizes — the face model is the smallest of the three on disk.
8+
*/
9+
export interface FrameCost {
10+
/** Median wall-clock milliseconds of one `detectForVideo` pass. */
11+
readonly handsMs: number;
12+
readonly poseMs: number;
13+
readonly faceMs: number;
14+
/** How many frames the medians are taken over. */
15+
readonly samples: number;
16+
}
17+
18+
/**
19+
* Median of the samples taken so far, per model.
20+
*
21+
* Medians, not means: a frame that lands on a garbage collection or a lost animation frame is
22+
* several times the typical cost, and this project has already been misled once by comparing
23+
* against means over data with rare extreme outliers.
24+
*/
25+
export class FrameCostMeter {
26+
private readonly hands: number[] = [];
27+
private readonly pose: number[] = [];
28+
private readonly face: number[] = [];
29+
30+
constructor(private readonly window = 120) {}
31+
32+
record(handsMs: number, poseMs: number, faceMs: number): void {
33+
this.push(this.hands, handsMs);
34+
this.push(this.pose, poseMs);
35+
this.push(this.face, faceMs);
36+
}
37+
38+
reset(): void {
39+
this.hands.length = 0;
40+
this.pose.length = 0;
41+
this.face.length = 0;
42+
}
43+
44+
read(): FrameCost | null {
45+
if (this.hands.length === 0) return null;
46+
return {
47+
handsMs: median(this.hands),
48+
poseMs: median(this.pose),
49+
faceMs: median(this.face),
50+
samples: this.hands.length,
51+
};
52+
}
53+
54+
private push(into: number[], value: number): void {
55+
into.push(value);
56+
if (into.length > this.window) into.shift();
57+
}
58+
}
59+
60+
function median(values: readonly number[]): number {
61+
const sorted = [...values].sort((a, b) => a - b);
62+
const middle = sorted.length >> 1;
63+
return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2;
64+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { FrameCostMeter } from '../FrameCost';
3+
4+
describe('FrameCostMeter', () => {
5+
it('reads nothing before a frame has been measured', () => {
6+
expect(new FrameCostMeter().read()).toBeNull();
7+
});
8+
9+
it('reports the median of each model, not the mean', () => {
10+
const meter = new FrameCostMeter();
11+
meter.record(10, 5, 2);
12+
meter.record(12, 6, 3);
13+
// One frame landing on a garbage collection must not move the reading
14+
meter.record(400, 200, 100);
15+
16+
expect(meter.read()).toEqual({ handsMs: 12, poseMs: 6, faceMs: 3, samples: 3 });
17+
});
18+
19+
it('averages the two middle samples when the count is even', () => {
20+
const meter = new FrameCostMeter();
21+
meter.record(10, 4, 1);
22+
meter.record(20, 6, 3);
23+
24+
expect(meter.read()).toMatchObject({ handsMs: 15, poseMs: 5, faceMs: 2 });
25+
});
26+
27+
it('keeps only the most recent frames, so a slow start stops counting', () => {
28+
const meter = new FrameCostMeter(4);
29+
for (const ms of [100, 100, 100, 100]) meter.record(ms, ms, ms);
30+
for (const ms of [10, 10, 10, 10]) meter.record(ms, ms, ms);
31+
32+
expect(meter.read()).toEqual({ handsMs: 10, poseMs: 10, faceMs: 10, samples: 4 });
33+
});
34+
35+
it('forgets everything on reset, since the cameras do not cost the same', () => {
36+
const meter = new FrameCostMeter();
37+
meter.record(10, 5, 2);
38+
meter.reset();
39+
40+
expect(meter.read()).toBeNull();
41+
});
42+
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { FrameCost } from '@domain/landmarks/value-objects/FrameCost';
2+
13
/**
24
* What the pipeline actually did, so a silent app can be told apart from a wrong one.
35
*
@@ -65,6 +67,8 @@ export interface RecognitionDiagnostics {
6567
readonly lettersEmitted: number;
6668
/** What the model was actually fed for the last window. Null until one is classified. */
6769
readonly lastSignature: SignatureProfile | null;
70+
/** Per-model cost of a frame. Null until the camera has produced one. */
71+
readonly frameCost: FrameCost | null;
6872
}
6973

7074
export const EMPTY_DIAGNOSTICS: RecognitionDiagnostics = {
@@ -82,4 +86,5 @@ export const EMPTY_DIAGNOSTICS: RecognitionDiagnostics = {
8286
wordsEmitted: 0,
8387
lettersEmitted: 0,
8488
lastSignature: null,
89+
frameCost: null,
8590
};

src/infrastructure/vision/MediaPipeLandmarkSource.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type ILandmarkSource,
44
type LandmarkListener,
55
} from '@domain/landmarks/services/ILandmarkSource';
6+
import { type FrameCost, FrameCostMeter } from '@domain/landmarks/value-objects/FrameCost';
67
import {
78
createHandLandmarks,
89
type Handedness,
@@ -44,6 +45,7 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
4445
private running = false;
4546
private facing: CameraFacing = 'user';
4647
private listener: LandmarkListener | null = null;
48+
private readonly cost = new FrameCostMeter();
4749

4850
constructor(
4951
private readonly video: HTMLVideoElement,
@@ -54,6 +56,10 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
5456
return this.running;
5557
}
5658

59+
frameCost(): FrameCost | null {
60+
return this.cost.read();
61+
}
62+
5763
get camera(): CameraFacing {
5864
return this.facing;
5965
}
@@ -139,8 +145,14 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
139145
this.lastVideoTime = this.video.currentTime;
140146
const timestampMs = performance.now();
141147
const result = this.landmarker.detectForVideo(this.video, timestampMs);
148+
const afterHands = performance.now();
142149
const pose = this.pose?.detectForVideo(this.video, timestampMs);
150+
const afterPose = performance.now();
143151
const face = this.face?.detectForVideo(this.video, timestampMs);
152+
const afterFace = performance.now();
153+
154+
// VIDEO mode returns the result, so the call has waited for its own GPU work
155+
this.cost.record(afterHands - timestampMs, afterPose - afterHands, afterFace - afterPose);
144156

145157
listener({
146158
timestampMs,
@@ -160,6 +172,8 @@ export class MediaPipeLandmarkSource implements ILandmarkSource {
160172
if (this.frameHandle !== null) cancelAnimationFrame(this.frameHandle);
161173
this.frameHandle = null;
162174
this.lastVideoTime = -1;
175+
// Front and rear cameras do not cost the same, and useCamera() switches through here
176+
this.cost.reset();
163177

164178
// Releasing the tracks is what turns the camera indicator off. Leaving them live would
165179
// keep recording in the background, which this app must never appear to do.

src/presentation/components/DiagnosticsPanel.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { FrameCost } from '@domain/landmarks/value-objects/FrameCost';
12
import {
23
EMPTY_DIAGNOSTICS,
34
type RecognitionDiagnostics,
@@ -26,6 +27,16 @@ const VETO_LABELS: Record<WindowVeto, string> = {
2627
duplicate: 'repetido: mismo signo que el anterior',
2728
};
2829

30+
function frameCostLabel(cost: FrameCost | null): string {
31+
if (!cost) return '—';
32+
const total = cost.handsMs + cost.poseMs + cost.faceMs;
33+
const share = (ms: number) => `${ms.toFixed(0)} ms`;
34+
return (
35+
`${share(total)} · manos ${share(cost.handsMs)} · pose ${share(cost.poseMs)}` +
36+
` · cara ${share(cost.faceMs)}`
37+
);
38+
}
39+
2940
/**
3041
* Shows what the pipeline did, so "it writes nothing" becomes a specific failure.
3142
*
@@ -78,6 +89,7 @@ export class DiagnosticsPanel {
7889
// closing windows at all, is the engine being asked, and what did it actually score.
7990
const rows: [string, string][] = [
8091
['Fotogramas', `${d.framesSeen} (${d.framesWithHands} con mano)`],
92+
['Coste por fotograma', frameCostLabel(d.frameCost)],
8193
['Segmentador', d.segmenterActive ? `activo, ${d.pendingFrames} fotogramas` : 'en reposo'],
8294
['Ventanas cerradas', `${d.windowsClosed} (${d.windowsTooShort} descartadas por cortas)`],
8395
['Última ventana', d.lastWindowFrames ? `${d.lastWindowFrames} fotogramas` : '—'],

0 commit comments

Comments
 (0)