Skip to content

Commit 4fcf1bd

Browse files
committed
fix: stop drawing pose landmarks the model never saw
1 parent 3f03319 commit 4fcf1bd

4 files changed

Lines changed: 160 additions & 12 deletions

File tree

src/domain/landmarks/value-objects/BodyLandmarks.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ export const PosePoint = {
1818
rightHip: 24,
1919
} as const;
2020

21-
/** Drawn as the torso: the shoulder line and the box down to the hips. */
21+
/**
22+
* Drawn as the torso: the shoulder line, and the box down to the hips when the hips are
23+
* genuinely in shot.
24+
*
25+
* A signer is usually framed from the chest up, so the hip edges are dropped far more often
26+
* than they are drawn. `isVisible` decides, per edge, at render time.
27+
*/
2228
export const TORSO_CONNECTIONS: readonly (readonly [number, number])[] = [
2329
[PosePoint.leftShoulder, PosePoint.rightShoulder],
2430
[PosePoint.leftShoulder, PosePoint.leftHip],
@@ -34,11 +40,49 @@ export const ARM_CONNECTIONS: readonly (readonly [number, number])[] = [
3440
[PosePoint.rightElbow, PosePoint.rightWrist],
3541
];
3642

37-
/** Neck: the head sitting on the shoulder line, drawn as its own segment. */
38-
export const NECK_CONNECTIONS: readonly (readonly [number, number])[] = [
39-
[PosePoint.nose, PosePoint.leftShoulder],
40-
[PosePoint.nose, PosePoint.rightShoulder],
41-
];
43+
/**
44+
* Below this, a pose landmark is the model's guess rather than something it saw.
45+
*
46+
* Filming a signer from the chest up leaves the hips invisible, and MediaPipe answers with
47+
* extrapolated coordinates instead of nothing. Drawing those produces a torso box running
48+
* off the bottom of the picture and arms pointing at the frame edges.
49+
*/
50+
export const MIN_POSE_VISIBILITY = 0.6;
51+
52+
export function isVisible(point: Landmark | undefined): boolean {
53+
// Absent visibility means the estimator does not report it (hands, face) — trust those.
54+
return point !== undefined && (point.visibility ?? 1) >= MIN_POSE_VISIBILITY;
55+
}
56+
57+
/**
58+
* The neck: one segment from the middle of the shoulders up to the head.
59+
*
60+
* MediaPipe has no neck landmark, so it has to be derived. Drawing nose-to-each-shoulder
61+
* instead — the obvious shortcut — paints a wide triangle across the chest that looks
62+
* nothing like a neck and hides the signing space behind it.
63+
*/
64+
export function neckSegment(pose: readonly Landmark[]): readonly [Landmark, Landmark] | null {
65+
const left = pose[PosePoint.leftShoulder];
66+
const right = pose[PosePoint.rightShoulder];
67+
const nose = pose[PosePoint.nose];
68+
if (!isVisible(left) || !isVisible(right) || !isVisible(nose) || !left || !right || !nose) {
69+
return null;
70+
}
71+
72+
const centre: Landmark = {
73+
x: (left.x + right.x) / 2,
74+
y: (left.y + right.y) / 2,
75+
z: (left.z + right.z) / 2,
76+
};
77+
// Stop short of the nose: the neck ends at the chin, and running the line into the face
78+
// mesh just clutters it.
79+
const chin: Landmark = {
80+
x: centre.x + (nose.x - centre.x) * 0.6,
81+
y: centre.y + (nose.y - centre.y) * 0.6,
82+
z: centre.z + (nose.z - centre.z) * 0.6,
83+
};
84+
return [centre, chin];
85+
}
4286

4387
export interface PoseLandmarks {
4488
readonly points: readonly Landmark[];

src/domain/landmarks/value-objects/Landmark.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ export interface Landmark {
33
readonly x: number;
44
readonly y: number;
55
readonly z: number;
6+
/**
7+
* How sure the estimator is that this point is actually in frame, 0..1.
8+
*
9+
* Pose reports it; hands and face do not. It matters because MediaPipe's pose model
10+
* *extrapolates* landmarks it cannot see rather than omitting them — film someone from
11+
* the chest up and it will still hand you hip coordinates, invented, somewhere below the
12+
* bottom of the picture.
13+
*/
14+
readonly visibility?: number | undefined;
615
}
716

817
export type Handedness = 'left' | 'right';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isVisible, neckSegment, PosePoint } from '../BodyLandmarks';
3+
import type { Landmark } from '../Landmark';
4+
5+
/** A pose where everything is confidently seen unless overridden. */
6+
function buildPose(overrides: Record<number, Partial<Landmark>> = {}): Landmark[] {
7+
return Array.from({ length: 33 }, (_, i) => ({
8+
x: 0.5,
9+
y: 0.5,
10+
z: 0,
11+
visibility: 0.95,
12+
...overrides[i],
13+
}));
14+
}
15+
16+
describe('isVisible', () => {
17+
it('accepts a confidently seen landmark', () => {
18+
expect(isVisible({ x: 0, y: 0, z: 0, visibility: 0.9 })).toBe(true);
19+
});
20+
21+
it('rejects a landmark the model only guessed at', () => {
22+
// Framing a signer from the chest up leaves the hips invisible, and MediaPipe answers
23+
// with extrapolated coordinates rather than nothing.
24+
expect(isVisible({ x: 0, y: 0, z: 0, visibility: 0.1 })).toBe(false);
25+
});
26+
27+
it('trusts landmarks from estimators that report no visibility at all', () => {
28+
// Hands and face do not report it; treating absent as invisible would erase them.
29+
expect(isVisible({ x: 0, y: 0, z: 0 })).toBe(true);
30+
});
31+
32+
it('rejects a missing landmark', () => {
33+
expect(isVisible(undefined)).toBe(false);
34+
});
35+
});
36+
37+
describe('neckSegment', () => {
38+
it('runs from the middle of the shoulders toward the head', () => {
39+
const pose = buildPose({
40+
[PosePoint.leftShoulder]: { x: 0.4, y: 0.6 },
41+
[PosePoint.rightShoulder]: { x: 0.6, y: 0.6 },
42+
[PosePoint.nose]: { x: 0.5, y: 0.2 },
43+
});
44+
const segment = neckSegment(pose);
45+
46+
expect(segment?.[0].x).toBeCloseTo(0.5, 5);
47+
expect(segment?.[0].y).toBeCloseTo(0.6, 5);
48+
// Upward, and stopping short of the nose so it does not run into the face mesh.
49+
expect(segment?.[1].y).toBeLessThan(0.6);
50+
expect(segment?.[1].y).toBeGreaterThan(0.2);
51+
});
52+
53+
it('is one segment, not a triangle across the chest', () => {
54+
// Drawing nose-to-each-shoulder was the obvious shortcut and looked nothing like a neck.
55+
const segment = neckSegment(buildPose());
56+
expect(segment).toHaveLength(2);
57+
});
58+
59+
it('declines when a shoulder is not really visible', () => {
60+
const pose = buildPose({ [PosePoint.rightShoulder]: { visibility: 0.1 } });
61+
expect(neckSegment(pose)).toBeNull();
62+
});
63+
64+
it('declines when the head is not really visible', () => {
65+
const pose = buildPose({ [PosePoint.nose]: { visibility: 0.2 } });
66+
expect(neckSegment(pose)).toBeNull();
67+
});
68+
69+
it('declines on an empty pose rather than throwing', () => {
70+
expect(neckSegment([])).toBeNull();
71+
});
72+
});

src/presentation/components/LandmarkOverlay.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
ARM_CONNECTIONS,
3-
NECK_CONNECTIONS,
3+
isVisible,
4+
neckSegment,
5+
PosePoint,
46
TORSO_CONNECTIONS,
57
} from '@domain/landmarks/value-objects/BodyLandmarks';
68
import { HAND_CONNECTIONS, type Landmark } from '@domain/landmarks/value-objects/Landmark';
@@ -82,10 +84,23 @@ export class LandmarkOverlay {
8284
const pose = frame.pose?.points ?? [];
8385
const face = frame.face?.points ?? [];
8486

87+
const neck = pose.length > 0 ? neckSegment(pose) : null;
88+
8589
if (pose.length > 0) {
8690
this.drawEdges(pose, TORSO_CONNECTIONS, PART_COLOURS.torso, 3.5 * unit, project);
8791
this.drawEdges(pose, ARM_CONNECTIONS, PART_COLOURS.arms, 3.5 * unit, project);
88-
this.drawEdges(pose, NECK_CONNECTIONS, PART_COLOURS.neck, 2.5 * unit, project);
92+
}
93+
94+
if (neck) {
95+
this.context.strokeStyle = PART_COLOURS.neck;
96+
this.context.lineWidth = 3 * unit;
97+
const [from, to] = neck;
98+
const start = project(from.x, from.y);
99+
const end = project(to.x, to.y);
100+
this.context.beginPath();
101+
this.context.moveTo(start.x, start.y);
102+
this.context.lineTo(end.x, end.y);
103+
this.context.stroke();
89104
}
90105

91106
if (face.length > 0) {
@@ -101,12 +116,17 @@ export class LandmarkOverlay {
101116

102117
this.context.globalAlpha = 1;
103118

119+
// Reported per part from what was actually drawable, so a chip going red means "not
120+
// seen" rather than "the pose model happened to return an array".
121+
const seen = (index: number) => isVisible(pose[index]);
104122
return {
105123
hands: frame.hands.length > 0,
106124
face: face.length > 0,
107-
neck: pose.length > 0,
108-
torso: pose.length > 0,
109-
arms: pose.length > 0,
125+
neck: neck !== null,
126+
torso: seen(PosePoint.leftShoulder) && seen(PosePoint.rightShoulder),
127+
arms:
128+
(seen(PosePoint.leftElbow) && seen(PosePoint.leftWrist)) ||
129+
(seen(PosePoint.rightElbow) && seen(PosePoint.rightWrist)),
110130
};
111131
}
112132

@@ -122,7 +142,10 @@ export class LandmarkOverlay {
122142
for (const [from, to] of edges) {
123143
const a = points[from];
124144
const b = points[to];
125-
if (!a || !b) continue;
145+
// Both ends must be genuinely seen. MediaPipe extrapolates pose landmarks it cannot
146+
// find rather than omitting them, so drawing unconditionally paints invented hips
147+
// below the picture and arms reaching for the frame edges.
148+
if (!isVisible(a) || !isVisible(b) || !a || !b) continue;
126149
const start = project(a.x, a.y);
127150
const end = project(b.x, b.y);
128151
this.context.beginPath();

0 commit comments

Comments
 (0)