Skip to content

Commit e3b4ead

Browse files
“mohansree14”claude
andcommitted
feat: live pose overlay for form checking (issue #17 MVP)
Adds a "Form check" button to each exercise during a workout that opens the front camera and overlays a live MoveNet skeleton on top of it, using on-device tfjs pose detection (no frames leave the browser). Scoped as an MVP per the issue discussion: proves the pose-detection pipeline works end-to-end in the app before any rep-counting or form-correction logic is built on top of it. - FormTracker.jsx: camera + canvas overlay component - @tensorflow/tfjs + @tensorflow-models/pose-detection (MoveNet Lightning) - code-split via React.lazy so the ~1MB+ model/runtime only loads when the sheet is opened, not on every app boot - @mediapipe/pose aliased to a stub in vite.config.js — pose-detection statically imports it for the BlazePose runtime we don't use, and the real package isn't ESM-bundler-friendly Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c42ba6b commit e3b4ead

8 files changed

Lines changed: 784 additions & 40 deletions

File tree

frontend/package-lock.json

Lines changed: 670 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"@capacitor/filesystem": "^7.1.8",
1818
"@capacitor/local-notifications": "^7.0.7",
1919
"@capacitor/share": "^7.0.4",
20+
"@tensorflow-models/pose-detection": "^2.1.3",
21+
"@tensorflow/tfjs": "^4.22.0",
2022
"react": "^19.2.7",
2123
"react-dom": "^19.2.7",
2224
"react-router-dom": "^7.18.2",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import * as tf from '@tensorflow/tfjs'
3+
import * as poseDetection from '@tensorflow-models/pose-detection'
4+
import { t } from '../lib/i18n.js'
5+
6+
// Bone pairs for MoveNet's 17-keypoint layout — only the ones useful for checking
7+
// lifting form (arms, torso, legs); face points are detected but not drawn.
8+
const EDGES = [
9+
['left_shoulder', 'right_shoulder'], ['left_shoulder', 'left_elbow'], ['left_elbow', 'left_wrist'],
10+
['right_shoulder', 'right_elbow'], ['right_elbow', 'right_wrist'],
11+
['left_shoulder', 'left_hip'], ['right_shoulder', 'right_hip'], ['left_hip', 'right_hip'],
12+
['left_hip', 'left_knee'], ['left_knee', 'left_ankle'],
13+
['right_hip', 'right_knee'], ['right_knee', 'right_ankle'],
14+
]
15+
const MIN_SCORE = 0.3
16+
17+
// Live skeleton overlay on the front camera — issue #17's MVP: prove the pose-detection
18+
// pipeline works end to end before adding any rep-counting or form-correction logic.
19+
// Everything runs on-device (tfjs + MoveNet); the video frame never leaves the browser.
20+
export default function FormTracker() {
21+
const videoRef = useRef(null)
22+
const canvasRef = useRef(null)
23+
const [status, setStatus] = useState('loading') // loading | ready | denied | error
24+
25+
useEffect(() => {
26+
let stream, detector, raf, stopped = false
27+
;(async () => {
28+
try {
29+
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } })
30+
if (stopped) return
31+
const v = videoRef.current
32+
v.srcObject = stream
33+
await v.play()
34+
await tf.ready()
35+
detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, {
36+
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING,
37+
})
38+
if (stopped) return
39+
setStatus('ready')
40+
41+
const draw = async () => {
42+
if (stopped) return
43+
const c = canvasRef.current
44+
if (v.readyState >= 2) {
45+
if (c.width !== v.videoWidth) { c.width = v.videoWidth; c.height = v.videoHeight }
46+
const poses = await detector.estimatePoses(v)
47+
const kp = poses[0]?.keypoints || []
48+
const byName = Object.fromEntries(kp.map(p => [p.name, p]))
49+
const ctx = c.getContext('2d')
50+
ctx.clearRect(0, 0, c.width, c.height)
51+
ctx.strokeStyle = '#5eead4'
52+
ctx.lineWidth = Math.max(2, c.width / 160)
53+
for (const [a, b] of EDGES) {
54+
const pa = byName[a], pb = byName[b]
55+
if (pa && pb && pa.score > MIN_SCORE && pb.score > MIN_SCORE) {
56+
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke()
57+
}
58+
}
59+
ctx.fillStyle = '#facc15'
60+
const r = Math.max(3, c.width / 120)
61+
for (const p of kp) if (p.score > MIN_SCORE) { ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill() }
62+
}
63+
raf = requestAnimationFrame(draw)
64+
}
65+
draw()
66+
} catch (e) {
67+
setStatus(e && e.name === 'NotAllowedError' ? 'denied' : 'error')
68+
}
69+
})()
70+
return () => {
71+
stopped = true
72+
if (raf) cancelAnimationFrame(raf)
73+
stream?.getTracks().forEach(tr => tr.stop())
74+
detector?.dispose()
75+
}
76+
}, [])
77+
78+
return <>
79+
<h3>{t('Form check')}</h3>
80+
{status === 'denied' && <div className="muted">{t('Camera access was denied — allow it in your browser settings to see your form.')}</div>}
81+
{status === 'error' && <div className="muted">{t('Could not start the camera on this device.')}</div>}
82+
{status !== 'denied' && status !== 'error' && <div style={{ position: 'relative', marginTop: 8 }}>
83+
<video ref={videoRef} playsInline muted style={{ width: '100%', borderRadius: 12, display: 'block', transform: 'scaleX(-1)' }} />
84+
<canvas ref={canvasRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', transform: 'scaleX(-1)' }} />
85+
{status === 'loading' && <div className="muted" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{t('Starting camera…')}</div>}
86+
</div>}
87+
<div className="muted small" style={{ marginTop: 10 }}>{t('Skeleton overlay only, for now — nothing is recorded or uploaded.')}</div>
88+
</>
89+
}

frontend/src/components/Icon.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const P = {
112112
signOut: <><path d="M14.2 4.6H7a1.9 1.9 0 0 0-1.9 1.9v11a1.9 1.9 0 0 0 1.9 1.9h7.2" /><path d="m16.8 8.4 3.6 3.6-3.6 3.6M20.4 12H10.2" /></>,
113113
shuffle: <><path d="M3.6 7.2h2.9c1.6 0 2.8.9 3.8 2.4l3 4.8c1 1.5 2.2 2.4 3.8 2.4h2.9M3.6 16.8h2.9c1.6 0 2.8-.9 3.8-2.4l.7-1.1M15.6 9.9l.7-1.1c1-1.5 2.2-2.4 3.8-2.4h1.9" /><path d="m17.9 4.3 2.8 2.1-2.8 2.1M17.9 14.7l2.8 2.1-2.8 2.1" /></>,
114114
info: <><circle cx="12" cy="12" r="8.2" /><path d="M12 11v5.4" /><circle cx="12" cy="7.9" r=".9" fill="currentColor" stroke="none" /></>,
115+
camera: <><path d="M8.4 6.6 9.6 4.6h4.8l1.2 2h2.4A1.6 1.6 0 0 1 19.6 8.2v9.2a1.6 1.6 0 0 1-1.6 1.6H6A1.6 1.6 0 0 1 4.4 17.4V8.2A1.6 1.6 0 0 1 6 6.6Z" /><circle cx="12" cy="12.4" r="3.4" /></>,
115116
}
116117

117118
// A few keys are aliases so call sites can say what they mean.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// pose-detection statically imports @mediapipe/pose for its BlazePose "mediapipe" runtime,
2+
// which we don't use (FormTracker only loads the MoveNet runtime) and whose package doesn't
3+
// expose an ESM-compatible named export for bundlers. Aliased in vite.config.js so the build
4+
// doesn't need the real (much larger) package.
5+
export class Pose {}

frontend/src/sheets.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { beep, vibrate } from './lib/sound.js'
88
import { t, instrFor, getLang, INSTR_LANGS } from './lib/i18n.js'
99
import { nav } from './lib/nav.js'
1010
import { starterRoutines } from './lib/starter.js'
11+
import { lazy, Suspense } from 'react'
1112
import Media, { Thumb } from './components/Media.jsx'
13+
// Pulls in tfjs + the pose model (~1MB+), so it's code-split and only fetched
14+
// when someone actually opens the form-check sheet.
15+
const FormTracker = lazy(() => import('./components/FormTracker.jsx'))
1216
import Stepper from './components/Stepper.jsx'
1317
import Icon from './components/Icon.jsx'
1418
import { Button, Slider, Switch, Segmented, SelectRow, Row } from './components/ui.jsx'
@@ -306,6 +310,9 @@ function ExerciseDetail({ ex, close }) {
306310
}
307311
export const exerciseDetailSheet = ex => ui().openSheet(close => <ExerciseDetail ex={ex} close={close} />)
308312

313+
/* ============================ form check (live pose overlay, issue #17) ============================ */
314+
export const formCheckSheet = () => ui().openSheet(() => <Suspense fallback={<div className="muted">{t('Loading…')}</div>}><FormTracker /></Suspense>)
315+
309316
/* ============================ add to routine ============================ */
310317
function AddToRoutine({ ex, close }) {
311318
const st = useStore(s => s.S)

frontend/src/views/Workout.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { beep, vibrate } from '../lib/sound.js'
99
import { t } from '../lib/i18n.js'
1010
import { api } from '../lib/api.js'
1111
import Media from '../components/Media.jsx'
12-
import { startFlow, exercisePicker, exConfigSheet, exerciseDetailSheet, topWeightSheet, finishWorkout, workoutCompleteSheet, confirmSheet } from '../sheets.jsx'
12+
import { startFlow, exercisePicker, exConfigSheet, exerciseDetailSheet, formCheckSheet, topWeightSheet, finishWorkout, workoutCompleteSheet, confirmSheet } from '../sheets.jsx'
1313
import Icon from '../components/Icon.jsx'
1414
import { Button, Check, NumberField } from '../components/ui.jsx'
1515
import { nextPrescription, applyPrescription } from '../lib/progression.js'
@@ -112,7 +112,10 @@ function ExerciseBlock({ entryIdx, compact, onToggle, onField, onAddSet, onRemov
112112
<Media ex={ex} key={entry.id} compact={compact} minimizable />
113113
<div className="row between" style={{ marginBottom: 6 }}>
114114
<div style={{ fontSize: compact ? 17 : 20, fontWeight: 600, letterSpacing: '-.02em', textTransform: 'capitalize', lineHeight: 1.2 }}>{ex.n}</div>
115-
<button className="iconbtn" aria-label={t('Details')} onClick={() => exerciseDetailSheet(ex)}><Icon name="info" /></button>
115+
<div className="row" style={{ gap: 4 }}>
116+
<button className="iconbtn" aria-label={t('Form check')} onClick={() => formCheckSheet()}><Icon name="camera" /></button>
117+
<button className="iconbtn" aria-label={t('Details')} onClick={() => exerciseDetailSheet(ex)}><Icon name="info" /></button>
118+
</div>
116119
</div>
117120
<div className="row" style={{ gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
118121
{cardio && <span className="tag acc"><Icon name="figureRun" />{t('Cardio')}</span>}

frontend/vite.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { defineConfig } from 'vite'
22
import react from '@vitejs/plugin-react'
3+
import { fileURLToPath } from 'url'
34

45
const backend = process.env.API_TARGET || 'http://127.0.0.1:3000'
56
const media = process.env.MEDIA_TARGET || 'http://127.0.0.1:8888'
67

78
export default defineConfig({
89
plugins: [react()],
910
base: './',
11+
resolve: {
12+
// FormTracker only uses the MoveNet runtime; see src/lib/mediapipe-pose-stub.js
13+
alias: { '@mediapipe/pose': fileURLToPath(new URL('./src/lib/mediapipe-pose-stub.js', import.meta.url)) }
14+
},
1015
server: {
1116
proxy: {
1217
'/api': { target: backend, changeOrigin: true },

0 commit comments

Comments
 (0)