Skip to content
CO
← Back to projects
HealthAIIn production

Gonio Shard

Measures joint range of motion from a browser camera — no goniometer, no sensors. It began as my 2024 thesis, and revalidating it showed the result I had reported was wrong.

The problem

Measuring how many degrees a joint moves is routine in physiotherapy and orthopaedics, and it is done with a manual goniometer: two plastic arms, a protractor, and the clinician’s eye. It is cheap and slow, it depends on who is measuring, and it leaves no record beyond a handwritten number.

My thesis asked whether an ordinary camera could replace it. The short answer, two years later with the validation redone, is not yet — and that answer turned out to be far more interesting than a yes.

What I found when I revalidated my own thesis

The original validation reported an angular error of 5.32°. That number was miscalculated. In validation/metrics.py, arctan2 was applied element-wise over the x and y columns separately, producing an array of shape (90,2) instead of (90,). It was not an angle: it was two columns of numbers I had called an angle.

The real per-frame error is 14.92°.

I redid the measurement over the same 90 hand-annotated frames with the current pipeline:

Metric Value
Correlation with manual annotation 0.991
Mean per-frame error (MAE) 13.59°
— signed mean (bias) +13.59°
— standard deviation (noise) 5.34°
95% limits of agreement (Bland-Altman) [+3.12°, +24.06°]
ROM error, raw min/max 8.92°
ROM error, 2nd/98th percentiles 6.47°

The error is systematic bias, not noise. The signed mean equals the MAE: the model is wrong in the same direction every time. That is the best possible news, because a constant offset can be calibrated away and random noise cannot. The 0.991 correlation says the shape of the movement is already tracked almost perfectly.

I also corrected an intermediate claim of my own. A preliminary measurement reported 3.28° of error using “smoothing + percentiles”. It was an artifact: the smoothing used np.convolve(..., 'valid'), which trims frames from both ends, and in that clip the peaks of the movement fall right near the edges. I was not smoothing, I was discarding the hardest data. With a real Savitzky-Golay filter, smoothing does not improve ROM — it preserves peaks by design — so compute_rom no longer smooths by default. The percentiles do all the work.

Why 14.92° is not a failure

The most thorough published evaluation to date (Rode et al., Scientific Reports 15:38767, 2025) compared 11 pose estimators against a 27-camera Vicon system over 2.2 million frames. It reports 16.3°–28.9° of elbow-angle error for monocular estimation and concludes that none reach the <5° that clinical use would demand.

The 14.92° is not a defect of this implementation. It is the state of the art — and knowing that changes what is worth building on top.

Decisions that follow from the evidence

  • 2D measurement, deliberately. The same study measures 72–122 mm of error in the image plane versus 146–249 mm once depth is added. MediaPipe’s 3D pose_world_landmarks do not improve the angle in a case like this, and often make it worse. Keeping the movement in the camera plane and calibrating the offset beats chasing unreliable 3D.
  • Calibration from a reference pose. An extended arm is a known 180°. That is enough to measure this camera’s offset in this position and subtract it from the rest of the session.
  • Two different computations for two purposes. The on-screen value uses a low-latency causal filter (One Euro), which can only look backwards. The final ROM is computed once capture ends, over the whole window, using percentiles instead of min and max.
  • Everything client-side. MediaPipe Tasks runs in the browser via WebAssembly. There is no video upload and no backend. This is health data, and the safest thing you can do with it is never receive it.

Two bugs in the original script

video.py:109 fed a BGR frame to a model that expects RGB. The conversion sat commented out on lines 107-108. The striking part is that validation/mediapipe_estimation.py:38 did convert: I validated one pipeline and shipped a different one.

video.py:172-181 wrote the annotated video inside the if results.pose_landmarks block. Frames with no detection never reached the file, so the output was shorter than the input and out of sync. In gonio/cli.py the write() sits outside the conditional, and a regression test asserts the annotated video has the same 90 frames as the original.

Separately: the API the thesis used no longer exists. mediapipe 1.0.0 removed the entire solutions module, so video.py will not run on a current install. Migrating to Tasks stopped being an optional improvement.

How it is built

The Python package separates angle computation (angles.py) from the detection backend (pose.py), which is the only piece tied to MediaPipe. rom.py handles gating, filtering and range extraction; report.py persists the full time series rather than three summary numbers — without the series you cannot audit afterwards what actually happened.

The site reimplements angles.py and rom.py in JavaScript to run in the browser. That opens the door to the two implementations silently drifting apart, so parity tests run the same input through Python and JS and compare the outputs. They are part of the repository’s 60 tests.

What is missing

Expanding the ground truth: 90 hand-annotated frames from one subject performing one movement are not enough to claim anything general. The annotation tool (validation/medir_datos_a_mano.py) works and is where I would continue.

And validating whether reference-pose calibration actually removes the +13.59° bias. The hypothesis is sound — the error is systematic — but a sound hypothesis is not a measured result, and that distinction is exactly the one this project cost me to learn.