@sportanalyzer/position-analyzer
v0.1.0
Published
Framework-agnostic pose analyzer for calisthenics. Compares detected body keypoints against a JSON of target joint angles and reports per-joint feedback.
Maintainers
Readme
@sportanalyzer/position-analyzer
A tiny, dependency-free, framework-agnostic library for scoring human poses against a target position. Built for calisthenics, but useful for any application that needs to compare a detected skeleton against an "ideal" pose — yoga, physiotherapy, dance, gesture-based UI, motion capture QA, etc.
You give it:
- A reference pose (a JSON of target joint angles + tolerances).
- A detected pose (an array of 17 MoveNet/COCO keypoints).
It gives you back:
- An overall
scorein[0, 1]. - An
isCorrectboolean. - Per-joint feedback:
ok/off(with aflexorextendhint) /missing.
Everything else (detector, renderer, exercise state-machine, UI) is yours to bring — but the package ships optional adapters for the most common stack (MoveNet via TensorFlow.js + an HTML canvas overlay) so you can be up and running with a webcam in ~30 lines of code.
Table of contents
- Why this library
- Architecture & module map
- Installation
- Quick start (browser + webcam)
- Quick start (any environment)
- Concepts
- API reference
- Plugging in your own detector
- Integrating with frameworks
- Building exercises (state machines)
- Communicating with the library (events, streams, observables)
- TensorFlow.js backends
- Performance tips
- Troubleshooting
- Browser & runtime support
- Local development
- Versioning & changelog
- License
Why this library
Most pose-detection projects stop at "draw a skeleton on the screen". Going from raw keypoints to "is the user holding the right position?" needs three steps that the existing libraries don't give you:
- A stable definition of which joints to evaluate (some libraries name them differently per backend).
- A geometric model that computes joint angles from noisy keypoint coordinates and handles missing/low-confidence keypoints gracefully.
- A scoring function that's robust to small jitter, weighs joints differently when needed, and never crashes when a limb leaves the frame.
This library packages exactly those three pieces — and nothing else.
It has zero runtime dependencies in its core. TF.js and the DOM are only touched in the optional detectors/movenet and renderers/canvas entry points, which are not even loaded unless you import them.
Architecture & module map
The package has three independent entry points. Pick the ones your environment supports — they are tree-shakable.
| Module | Import path | Bundles | Where it runs |
| --- | --- | --- | --- |
| Core analyzer | @sportanalyzer/position-analyzer | Pure TS, no deps | Browser, Node 18+, Bun, Deno, React Native, Web/Service Workers |
| MoveNet detector | @sportanalyzer/position-analyzer/detectors/movenet | TF.js + @tensorflow-models/pose-detection (peer deps, loaded with dynamic import()) | Browser (any), Node with @tensorflow/tfjs-node, RN with @tensorflow/tfjs-react-native |
| Canvas renderer | @sportanalyzer/position-analyzer/renderers/canvas | Uses HTMLCanvasElement / CanvasRenderingContext2D | Browser only |
The core analyzer never imports the detector or renderer files, so SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, Astro) can safely import
@sportanalyzer/position-analyzerfrom server code. The detector and renderer are client-only — gate them behinduseEffect,onMounted, or dynamicimport().
Both ESM and CJS builds are shipped (.js / .cjs), and .d.ts types are emitted for every entry point.
Installation
# Core analyzer (always required)
npm install @sportanalyzer/position-analyzerIf you want to use the bundled MoveNet detector, also install the TF.js peer dependencies:
npm install @tensorflow/tfjs-core @tensorflow/tfjs-backend-webgl @tensorflow-models/pose-detection(WebGPU is also supported — see TensorFlow.js backends.)
The renderer has no extra dependencies.
Quick start (browser + webcam)
<video id="video" autoplay muted playsinline></video>
<canvas id="overlay"></canvas>import { PositionAnalyzer } from "@sportanalyzer/position-analyzer";
import { MoveNetDetector } from "@sportanalyzer/position-analyzer/detectors/movenet";
import { CanvasRenderer } from "@sportanalyzer/position-analyzer/renderers/canvas";
const reference = {
name: "T-Pose",
joints: {
left_elbow: { angle: 180, tolerance: 20 },
right_elbow: { angle: 180, tolerance: 20 },
left_shoulder: { angle: 90, tolerance: 20 },
right_shoulder:{ angle: 90, tolerance: 20 },
},
};
const video = document.getElementById("video") as HTMLVideoElement;
const canvas = document.getElementById("overlay") as HTMLCanvasElement;
const analyzer = new PositionAnalyzer(reference);
const detector = await MoveNetDetector.create({ modelType: "lightning" });
const renderer = new CanvasRenderer(canvas, { mirror: true });
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true });
await video.play();
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
async function frame() {
const pose = await detector.estimate(video);
renderer.clear();
if (pose) {
const analysis = analyzer.analyze(pose);
renderer.draw(pose, analysis);
if (analysis.isCorrect) console.log("Good form!");
}
requestAnimationFrame(frame);
}
frame();Quick start (any environment)
If you already have keypoints from somewhere else (server-side inference, native module, MediaPipe, a custom model), skip the detector entirely:
import { PositionAnalyzer, type Pose } from "@sportanalyzer/position-analyzer";
const analyzer = new PositionAnalyzer({
name: "Push-up bottom",
joints: {
left_elbow: { angle: 90, tolerance: 15 },
right_elbow: { angle: 90, tolerance: 15 },
left_hip: { angle: 180, tolerance: 10, weight: 0.5 },
right_hip: { angle: 180, tolerance: 10, weight: 0.5 },
},
});
const pose: Pose = {
keypoints: [
{ name: "left_shoulder", x: 320, y: 200, score: 0.95 },
{ name: "left_elbow", x: 280, y: 260, score: 0.93 },
{ name: "left_wrist", x: 260, y: 320, score: 0.88 },
// ...
],
};
const result = analyzer.analyze(pose);
console.log(result.score); // 0..1
console.log(result.isCorrect); // true if every joint is within tolerance
console.log(result.joints); // detailed per-joint breakdown
console.log(result.missingJoints);// joints below `minKeypointScore`Concepts
Keypoints
A keypoint is a labelled 2D point with a confidence score:
interface Keypoint {
name: KeypointName; // one of the 17 MoveNet/COCO names below
x: number; // pixels (or any consistent unit)
y: number;
score?: number; // 0..1 — defaults to 1 if not provided
}The 17 names match @tensorflow-models/pose-detection exactly:
nose
left_eye right_eye
left_ear right_ear
left_shoulder right_shoulder
left_elbow right_elbow
left_wrist right_wrist
left_hip right_hip
left_knee right_knee
left_ankle right_ankleA pose is just { keypoints: Keypoint[], score?: number }.
Joints
A joint is the angle at a vertex keypoint, formed by the segments to two adjacent keypoints. The library defines 8 joints:
| Joint | Vertex | Segment A | Segment C |
| --- | --- | --- | --- |
| left_elbow | left_elbow | left_shoulder | left_wrist |
| right_elbow | right_elbow | right_shoulder | right_wrist |
| left_shoulder | left_shoulder | left_elbow | left_hip |
| right_shoulder | right_shoulder | right_elbow | right_hip |
| left_hip | left_hip | left_shoulder | left_knee |
| right_hip | right_hip | right_shoulder | right_knee |
| left_knee | left_knee | left_hip | left_ankle |
| right_knee | right_knee | right_hip | right_ankle |
The full table is exported as JOINT_DEFINITIONS if you want to extend or override it.
Angles are measured in degrees in [0, 180]. 180° = the limb is fully extended; smaller values = more flexed. This is the natural convention: a straight arm is 180°, a 90°-elbow bend is 90°, etc.
Reference poses
A reference pose is the JSON description of "what correct looks like":
interface ReferencePose {
name: string; // human label
description?: string;
joints: Partial<Record<JointName, JointTarget>>; // only the joints you care about
minKeypointScore?: number; // default 0.3
}
interface JointTarget {
angle: number; // target angle in degrees (0..180)
tolerance: number; // ± degrees still considered "ok"
weight?: number; // contribution to the overall score (default 1)
}Only the joints listed in joints are evaluated. You can specify just left_knee to make a knee-only checker, or all 8 for full-body form checks.
minKeypointScore is the floor for the underlying keypoints' confidence. If any of the three keypoints that define a joint scores below it, the joint is reported as missing and does not count against the score.
Scoring model
For each evaluated joint:
delta = measured - target
absDelta = |delta|
withinTol = absDelta <= tolerance
jointScore = max(0, 1 - absDelta / (2 * tolerance))jointScore == 1when the angle is bang-on.jointScore == 0.5at the edge of the tolerance band.jointScore == 0at twice the tolerance away.
The overall score is the weighted average of per-joint scores. isCorrect is true only when every evaluated joint is within its tolerance — and at least one joint was evaluated successfully.
This linear decay is intentionally simple — easy to reason about, no exponents, no calibration, well-defined behavior at the edges. If you need a different shape, run the analyzer to get delta and roll your own.
API reference
PositionAnalyzer
import { PositionAnalyzer } from "@sportanalyzer/position-analyzer";The core. No deps, no DOM, no side effects.
class PositionAnalyzer {
constructor(reference: ReferencePose);
/** Swap the reference (e.g. moving to the next pose in an exercise). */
setReference(reference: ReferencePose): void;
/** Read the current reference back. */
getReference(): ReferencePose;
/** Score a detected pose against the current reference. Pure function. */
analyze(pose: Pose): PositionAnalysis;
}Returns:
interface PositionAnalysis {
score: number; // 0..1
isCorrect: boolean; // all evaluated joints within tolerance
joints: JointEvaluation[]; // one entry per joint in the reference
missingJoints: JointName[]; // joints whose keypoints were below threshold
}
interface JointEvaluation {
joint: JointName;
status: "ok" | "off" | "missing";
measuredAngle: number | null; // null when missing
targetAngle: number;
tolerance: number;
delta: number | null; // signed: measured - target
hint: "extend" | "flex" | null; // direction to correct
}The analyzer is stateless apart from the reference — same pose in = same analysis out. Safe to share between requests, frames, or workers.
MoveNetDetector
import { MoveNetDetector } from "@sportanalyzer/position-analyzer/detectors/movenet";A thin async adapter over @tensorflow-models/pose-detection's MoveNet model. TF.js is loaded with dynamic import() so the file is safe to exist in your bundle even on platforms where TF.js wouldn't run — it just won't be evaluated until you call .create().
class MoveNetDetector implements PoseDetector {
static create(options?: MoveNetOptions): Promise<MoveNetDetector>;
estimate(input: PoseInput): Promise<Pose | null>;
dispose(): Promise<void>;
}
interface MoveNetOptions {
modelType?: "lightning" | "thunder"; // default "lightning"
enableSmoothing?: boolean; // default true
minKeypointScore?: number; // default 0.3 — drops noisy keypoints
backend?: "webgl" | "cpu" | "webgpu" | null; // default "webgl"; null = caller manages
}
type PoseInput =
| HTMLVideoElement
| HTMLImageElement
| HTMLCanvasElement
| OffscreenCanvas
| ImageBitmap
| ImageData;- Lightning ≈ ~50 FPS on a phone, ~120 FPS on a desktop GPU. Use for live form-checking.
- Thunder is more accurate (~2× param count) at ~half the throughput. Use for offline analysis, video review, or low-motion poses.
Always call await detector.dispose() on unmount — it releases the WebGL textures the model is holding.
CanvasRenderer
import { CanvasRenderer } from "@sportanalyzer/position-analyzer/renderers/canvas";Browser-only. Draws the skeleton, color-coding each joint vertex by its analysis status.
class CanvasRenderer {
constructor(canvas: HTMLCanvasElement, options?: CanvasRendererOptions);
setOptions(options: Partial<CanvasRendererOptions>): void;
clear(): void;
draw(pose: Pose, analysis?: PositionAnalysis): void;
}
interface CanvasRendererOptions {
keypointRadius?: number; // default 6 px
edgeWidth?: number; // default 3 px
minKeypointScore?: number; // default 0.3
mirror?: boolean; // default false — set true for selfie cams
colors?: {
ok?: string; // default "#22c55e"
off?: string; // default "#ef4444"
missing?: string; // default "#9ca3af"
neutral?: string; // default "#60a5fa" — non-evaluated keypoints
edge?: string; // default "#e5e7eb"
};
}The renderer expects keypoint coordinates to be in the canvas's pixel space. If you scaled your video element with CSS, set the canvas's internal width/height to match the video's intrinsic resolution (i.e. videoEl.videoWidth/Height) and let CSS handle the on-screen size.
PoseDetector interface
import type { PoseDetector, PoseInput } from "@sportanalyzer/position-analyzer";
interface PoseDetector {
estimate(input: PoseInput): Promise<Pose | null>;
dispose(): Promise<void> | void;
}Implement this and you have a drop-in for MoveNetDetector. See Plugging in your own detector.
Geometry helpers
For when you want to build something custom:
import { angleAtVertex, findKeypoint } from "@sportanalyzer/position-analyzer";
// Angle at vertex B (in degrees, 0..180). Returns NaN on zero-length vectors.
angleAtVertex(a: {x:number,y:number}, b: {x:number,y:number}, c: {x:number,y:number}): number;
// Find a keypoint by name in an array. Returns undefined if missing.
findKeypoint(keypoints: Keypoint[], name: KeypointName): Keypoint | undefined;Constants
import { KEYPOINT_NAMES, SKELETON_EDGES, JOINT_DEFINITIONS } from "@sportanalyzer/position-analyzer";
KEYPOINT_NAMES // readonly array of 17 strings, in MoveNet/COCO order
SKELETON_EDGES // readonly array of [keypointName, keypointName] pairs
JOINT_DEFINITIONS// map<JointName, { vertex, a, c }>Use these to render your own overlays, train models, or generate documentation.
Types
Every type is re-exported from the package root:
import type {
Keypoint,
KeypointName,
Pose,
JointName,
JointDefinition,
JointTarget,
JointStatus,
JointEvaluation,
ReferencePose,
PositionAnalysis,
PoseDetector,
PoseInput,
} from "@sportanalyzer/position-analyzer";Plugging in your own detector
Anything that produces Keypoint[] works. Common sources:
- MediaPipe Pose (web or RN) — map MediaPipe's 33 landmarks down to the 17 COCO names.
- MoveNet via TFLite (mobile native).
- Server-side inference — a Python/Go service returning keypoints over HTTP/WebSocket.
- Wearable IMUs — synthesize 2D projections.
Two patterns work equally well:
1. Just produce Pose and feed analyzer.analyze(pose) directly. Simplest. Use this if your detector has a different shape than PoseDetector.
2. Implement the PoseDetector interface so you can swap detectors at runtime.
import type { Pose, PoseDetector, PoseInput, Keypoint, KeypointName } from "@sportanalyzer/position-analyzer";
export class MediaPipePoseAdapter implements PoseDetector {
constructor(private mp: any) {}
async estimate(input: PoseInput): Promise<Pose | null> {
const result = await this.mp.send({ image: input });
if (!result?.poseLandmarks) return null;
const map: Record<number, KeypointName> = {
0: "nose", 2: "left_eye", 5: "right_eye",
7: "left_ear", 8: "right_ear",
11: "left_shoulder", 12: "right_shoulder",
13: "left_elbow", 14: "right_elbow",
15: "left_wrist", 16: "right_wrist",
23: "left_hip", 24: "right_hip",
25: "left_knee", 26: "right_knee",
27: "left_ankle", 28: "right_ankle",
};
const w = (input as HTMLVideoElement).videoWidth ?? 0;
const h = (input as HTMLVideoElement).videoHeight ?? 0;
const keypoints: Keypoint[] = [];
for (const [idx, name] of Object.entries(map)) {
const lm = result.poseLandmarks[Number(idx)];
if (!lm) continue;
keypoints.push({
name,
x: lm.x * w,
y: lm.y * h,
score: lm.visibility,
});
}
return { keypoints };
}
dispose() { this.mp.close?.(); }
}Integrating with frameworks
React / Next.js
// components/PoseCoach.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { PositionAnalyzer, type PositionAnalysis, type ReferencePose } from "@sportanalyzer/position-analyzer";
export function PoseCoach({ reference }: { reference: ReferencePose }) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [analysis, setAnalysis] = useState<PositionAnalysis | null>(null);
useEffect(() => {
let cancelled = false;
let raf = 0;
let detector: any, renderer: any;
(async () => {
const { MoveNetDetector } = await import("@sportanalyzer/position-analyzer/detectors/movenet");
const { CanvasRenderer } = await import("@sportanalyzer/position-analyzer/renderers/canvas");
const analyzer = new PositionAnalyzer(reference);
detector = await MoveNetDetector.create({ modelType: "lightning" });
renderer = new CanvasRenderer(canvasRef.current!, { mirror: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const video = videoRef.current!;
video.srcObject = stream;
await video.play();
canvasRef.current!.width = video.videoWidth;
canvasRef.current!.height = video.videoHeight;
const tick = async () => {
if (cancelled) return;
const pose = await detector.estimate(video);
renderer.clear();
if (pose) {
const result = analyzer.analyze(pose);
renderer.draw(pose, result);
setAnalysis(result);
}
raf = requestAnimationFrame(tick);
};
tick();
})();
return () => {
cancelled = true;
cancelAnimationFrame(raf);
detector?.dispose?.();
(videoRef.current?.srcObject as MediaStream | null)?.getTracks().forEach(t => t.stop());
};
}, [reference]);
return (
<>
<video ref={videoRef} muted playsInline />
<canvas ref={canvasRef} />
{analysis && <pre>Score: {analysis.score.toFixed(2)}</pre>}
</>
);
}In Next.js App Router, mark the component with "use client" and dynamic-import the detector / renderer modules inside useEffect so they never reach the server bundle. The core PositionAnalyzer is safe to import at module scope.
Vue 3
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from "vue";
import { PositionAnalyzer, type ReferencePose } from "@sportanalyzer/position-analyzer";
const props = defineProps<{ reference: ReferencePose }>();
const videoEl = ref<HTMLVideoElement>();
const canvasEl = ref<HTMLCanvasElement>();
let cleanup = () => {};
onMounted(async () => {
const { MoveNetDetector } = await import("@sportanalyzer/position-analyzer/detectors/movenet");
const { CanvasRenderer } = await import("@sportanalyzer/position-analyzer/renderers/canvas");
const analyzer = new PositionAnalyzer(props.reference);
const detector = await MoveNetDetector.create();
const renderer = new CanvasRenderer(canvasEl.value!, { mirror: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
videoEl.value!.srcObject = stream;
await videoEl.value!.play();
let raf = 0, cancelled = false;
const tick = async () => {
if (cancelled) return;
const pose = await detector.estimate(videoEl.value!);
renderer.clear();
if (pose) renderer.draw(pose, analyzer.analyze(pose));
raf = requestAnimationFrame(tick);
};
tick();
cleanup = () => {
cancelled = true;
cancelAnimationFrame(raf);
detector.dispose();
stream.getTracks().forEach(t => t.stop());
};
});
onBeforeUnmount(() => cleanup());
</script>
<template>
<video ref="videoEl" muted playsinline />
<canvas ref="canvasEl" />
</template>Angular
import { Component, ElementRef, OnDestroy, AfterViewInit, ViewChild, Input } from "@angular/core";
import { PositionAnalyzer, type ReferencePose } from "@sportanalyzer/position-analyzer";
@Component({
selector: "pose-coach",
template: `
<video #video muted playsinline></video>
<canvas #overlay></canvas>
`,
standalone: true,
})
export class PoseCoachComponent implements AfterViewInit, OnDestroy {
@Input({ required: true }) reference!: ReferencePose;
@ViewChild("video") videoRef!: ElementRef<HTMLVideoElement>;
@ViewChild("overlay") canvasRef!: ElementRef<HTMLCanvasElement>;
private cleanup = () => {};
async ngAfterViewInit() {
const { MoveNetDetector } = await import("@sportanalyzer/position-analyzer/detectors/movenet");
const { CanvasRenderer } = await import("@sportanalyzer/position-analyzer/renderers/canvas");
const analyzer = new PositionAnalyzer(this.reference);
const detector = await MoveNetDetector.create();
const renderer = new CanvasRenderer(this.canvasRef.nativeElement, { mirror: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
this.videoRef.nativeElement.srcObject = stream;
await this.videoRef.nativeElement.play();
let raf = 0, cancelled = false;
const tick = async () => {
if (cancelled) return;
const pose = await detector.estimate(this.videoRef.nativeElement);
renderer.clear();
if (pose) renderer.draw(pose, analyzer.analyze(pose));
raf = requestAnimationFrame(tick);
};
tick();
this.cleanup = () => {
cancelled = true;
cancelAnimationFrame(raf);
detector.dispose();
stream.getTracks().forEach(t => t.stop());
};
}
ngOnDestroy() { this.cleanup(); }
}Svelte
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { PositionAnalyzer, type ReferencePose } from "@sportanalyzer/position-analyzer";
export let reference: ReferencePose;
let videoEl: HTMLVideoElement;
let canvasEl: HTMLCanvasElement;
let cleanup = () => {};
onMount(async () => {
const { MoveNetDetector } = await import("@sportanalyzer/position-analyzer/detectors/movenet");
const { CanvasRenderer } = await import("@sportanalyzer/position-analyzer/renderers/canvas");
const analyzer = new PositionAnalyzer(reference);
const detector = await MoveNetDetector.create();
const renderer = new CanvasRenderer(canvasEl, { mirror: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
videoEl.srcObject = stream;
await videoEl.play();
let raf = 0, cancelled = false;
const tick = async () => {
if (cancelled) return;
const pose = await detector.estimate(videoEl);
renderer.clear();
if (pose) renderer.draw(pose, analyzer.analyze(pose));
raf = requestAnimationFrame(tick);
};
tick();
cleanup = () => { cancelled = true; cancelAnimationFrame(raf); detector.dispose(); stream.getTracks().forEach(t => t.stop()); };
});
onDestroy(() => cleanup());
</script>
<video bind:this={videoEl} muted playsinline />
<canvas bind:this={canvasEl} />React Native
The core analyzer is pure JS — it runs in RN without changes. The detectors/movenet adapter loads @tensorflow-models/pose-detection, which on RN needs @tensorflow/tfjs-react-native set up first. Easier path: use a vision-camera frame processor and feed keypoints into the core analyzer.
import { PositionAnalyzer, type Pose } from "@sportanalyzer/position-analyzer";
import { useFrameProcessor } from "react-native-vision-camera";
import { detectPose } from "vision-camera-pose-detection-tflite"; // example
const analyzer = new PositionAnalyzer(reference);
const frameProcessor = useFrameProcessor((frame) => {
"worklet";
const native = detectPose(frame); // returns native keypoints
const pose: Pose = {
keypoints: native.map(k => ({ name: k.name, x: k.x, y: k.y, score: k.confidence })),
};
const result = analyzer.analyze(pose);
// hand `result` back to JS via runOnJS()
}, [analyzer]);Node / server-side
Use the core analyzer to grade poses you've already extracted (e.g. from a Python ML pipeline that POSTs JSON, or from @tensorflow/tfjs-node running on the server).
import { PositionAnalyzer } from "@sportanalyzer/position-analyzer";
const analyzer = new PositionAnalyzer(reference);
app.post("/analyze", (req, res) => {
// req.body: { keypoints: [{ name, x, y, score }, ...] }
const result = analyzer.analyze(req.body);
res.json(result);
});If you do want MoveNet on the server, install @tensorflow/tfjs-node and call MoveNetDetector.create({ backend: null }) — the detector will skip browser backend setup, and TF.js will pick up the Node backend you've registered.
Web Workers
The core analyzer has no DOM dependency and can run inside a worker. A common pattern: stream poses from the main thread to a worker, do scoring + history tracking there, post results back.
// worker.ts
import { PositionAnalyzer, type Pose, type ReferencePose } from "@sportanalyzer/position-analyzer";
let analyzer: PositionAnalyzer | null = null;
self.onmessage = (e: MessageEvent<
| { type: "init"; reference: ReferencePose }
| { type: "pose"; pose: Pose }
>) => {
if (e.data.type === "init") analyzer = new PositionAnalyzer(e.data.reference);
else if (e.data.type === "pose" && analyzer) {
(self as any).postMessage({ type: "analysis", analysis: analyzer.analyze(e.data.pose) });
}
};MoveNet itself can also run in a worker with OffscreenCanvas for the video frames — the detector accepts OffscreenCanvas and ImageBitmap as input.
Building exercises (state machines)
The library deliberately stays at the one-pose-at-a-time layer. Higher-level concepts like "hold for 8 seconds" or "do 10 reps" are easy to build on top by swapping the reference and tracking time. Sketch:
type Step =
| { mode: "hold"; reference: ReferencePose; seconds: number }
| { mode: "until_accomplished"; reference: ReferencePose };
async function runExercise(steps: Step[], analyzer: PositionAnalyzer, getPose: () => Promise<Pose | null>) {
for (const step of steps) {
analyzer.setReference(step.reference);
if (step.mode === "hold") {
let elapsed = 0;
let lastTs = performance.now();
while (elapsed < step.seconds * 1000) {
const now = performance.now();
const dt = now - lastTs;
lastTs = now;
const pose = await getPose();
if (pose && analyzer.analyze(pose).isCorrect) elapsed += dt;
else elapsed = 0; // form break resets the clock
await new Promise(r => requestAnimationFrame(r));
}
} else {
// until_accomplished — wait for a few consecutive correct frames
let streak = 0;
while (streak < 3) {
const pose = await getPose();
if (pose && analyzer.analyze(pose).isCorrect) streak++;
else streak = 0;
await new Promise(r => requestAnimationFrame(r));
}
}
}
}Add rep counting, cues, debouncing, audio prompts as needed. None of it touches the library.
Communicating with the library
The library is a plain object — there are no internal callbacks, events, streams, or observables. You drive it. Here are the common shapes used in real apps:
Callback / function passed in:
function onAnalysis(result: PositionAnalysis) { /* ... */ }
const pose = await detector.estimate(input);
if (pose) onAnalysis(analyzer.analyze(pose));RxJS observable:
import { interval, switchMap, filter, map, share } from "rxjs";
const analysis$ = interval(33).pipe( // ~30 FPS
switchMap(() => detector.estimate(video)),
filter((pose): pose is Pose => pose != null),
map(pose => analyzer.analyze(pose)),
share(),
);
analysis$.subscribe(a => console.log(a.score));EventTarget / DOM events:
const bus = new EventTarget();
async function loop() {
const pose = await detector.estimate(video);
if (pose) {
const analysis = analyzer.analyze(pose);
bus.dispatchEvent(new CustomEvent("analysis", { detail: analysis }));
}
requestAnimationFrame(loop);
}
bus.addEventListener("analysis", (e) => {
const a = (e as CustomEvent<PositionAnalysis>).detail;
// ...
});Async iterator:
async function* analyses(analyzer: PositionAnalyzer, detector: PoseDetector, input: PoseInput) {
while (true) {
const pose = await detector.estimate(input);
if (pose) yield analyzer.analyze(pose);
await new Promise(r => requestAnimationFrame(r));
}
}
for await (const a of analyses(analyzer, detector, video)) {
if (a.isCorrect) break;
}All four are equally valid — pick the one that matches your framework's data-flow style.
TensorFlow.js backends
MoveNetDetector.create() initializes a TF.js backend for you. As of TF.js 4.22, the package lists webgpu as the highest-priority backend; if you skip the explicit backend setup you'll see:
The highest priority backend 'webgpu' has not yet been initialized. Make sure to await
tf.ready()orawait tf.setBackend()before calling other methods.
The detector defaults to "webgl", which works in every modern browser.
// WebGL — default, recommended.
await MoveNetDetector.create({ backend: "webgl" });
// CPU — slow, but works everywhere (good for tests, headless CI, Node).
await MoveNetDetector.create({ backend: "cpu" });
// WebGPU — fastest on supported devices. You must register the backend yourself.
// npm i @tensorflow/tfjs-backend-webgpu
import "@tensorflow/tfjs-backend-webgpu";
await MoveNetDetector.create({ backend: "webgpu" });
// Skip backend setup entirely — your app already calls tf.setBackend().
await MoveNetDetector.create({ backend: null });Performance tips
- Use
lightningunless you need single-frame accuracy. Lightning is 2–3× faster. - Throttle to ~30 FPS with
setIntervalor a frame skipper — most webcams produce 30 FPS anyway, and pose detection at 60 FPS just heats the GPU. - Resize the input before inference if you're feeding HD frames. Render to an offscreen 320×240 canvas and pass that to
estimate()— MoveNet downscales internally, so giving it less to start with is faster. - Re-use one detector across an entire session. Creating a detector loads ~5 MB of weights and warms up WebGL — don't do it per analysis.
- Skip the renderer on headless flows. The analyzer is a few microseconds per call; the renderer's
draw()dwarfs it. - Run heavy logic in a worker if you also have a busy UI. The core analyzer is dependency-free and works inside Web Workers.
Troubleshooting
The highest priority backend 'webgpu' has not yet been initialized. — You're using @tensorflow-models/pose-detection directly without registering a backend. Either use MoveNetDetector.create() (which handles this) or call await tf.setBackend("webgl"); await tf.ready(); yourself.
Every joint is missing. — minKeypointScore is filtering everything out. Lower it (try 0.1) or check that your detector is actually returning scores.
Joints flicker between ok and off. — Either raise tolerance or enable smoothing on the detector (enableSmoothing: true, on by default for MoveNet).
Hint says flex but I'm clearly more extended than the target. — delta = measured - target. Positive delta (measured > target) means the joint is more open than the target, so the fix is to flex (bend it more). Negative delta means it's bent too much, so the fix is to extend.
The CanvasRenderer draws the skeleton in the wrong place. — The renderer assumes pose coordinates are in canvas pixel space. Set canvas.width = video.videoWidth; canvas.height = video.videoHeight; and let CSS handle the display size.
On Next.js, SSR fails with window is not defined. — You imported detectors/movenet or renderers/canvas at module scope on the server. Move the import inside a useEffect (with dynamic import()), or use next/dynamic with { ssr: false }.
The published dist folder is enormous / has nested dist/dist/.... — Make sure your tsconfig.json has rootDir: "src" and exclude: ["dist", ...]. Without rootDir, TypeScript can infer it as the project root once previous build artifacts are present, recreating the path each rebuild.
Browser & runtime support
| Runtime | Core analyzer | MoveNet detector | Canvas renderer |
| --- | --- | --- | --- |
| Chrome / Edge ≥ 90 | ✅ | ✅ (WebGL / WebGPU) | ✅ |
| Firefox ≥ 90 | ✅ | ✅ (WebGL) | ✅ |
| Safari ≥ 14 | ✅ | ✅ (WebGL) | ✅ |
| Node ≥ 18 | ✅ | ✅ with @tensorflow/tfjs-node | ❌ |
| Bun | ✅ | ⚠️ TF.js compatibility varies | ❌ |
| Deno | ✅ | ⚠️ TF.js compatibility varies | ❌ |
| React Native (Hermes) | ✅ | ⚠️ Needs tfjs-react-native or a native detector | ❌ |
| Web Workers / Service Workers | ✅ | ✅ with OffscreenCanvas input | ❌ |
Local development
npm install
npm run build # ESM + CJS + .d.ts → ./dist
npm run dev # tsup --watch
npm run typecheck # tsc --noEmit
npm run clean # rm -rf distThe build output is a flat ./dist:
dist/
├── index.js ESM
├── index.cjs CJS
├── index.d.ts types
├── index.js.map
├── detectors/
│ ├── movenet.js
│ ├── movenet.cjs
│ └── movenet.d.ts
└── renderers/
├── canvas.js
├── canvas.cjs
└── canvas.d.tsTest the package locally before publishing — see Versioning & changelog.
Versioning & changelog
This package follows semver. Until 1.0.0, minor versions may include breaking changes to the API; patch versions are bug-fix only.
To preview a publish without actually pushing to npm:
npm run build
npm pack # creates calisthenics-position-analyzer-X.Y.Z.tgz
tar -tzf calisthenics-position-analyzer-*.tgz | head -50 # inspect contentsInstall the tarball in another project to dry-run consumption:
cd ../some-other-project
npm install ../position-analyzer/calisthenics-position-analyzer-X.Y.Z.tgzTo publish (the package is scoped, so --access public is required for the first publish; publishConfig.access in package.json handles this automatically):
npm login
npm version patch # or minor / major
npm publishLicense
MIT. See LICENSE.
