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_landmarksdo 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.