Case study / SwimVolt
SwimVolt
Computer Vision · Sports · 2026

SwimVolt

Problem

You can't see your own start. It's the one part of the race that happens behind you, it's over in about a second, and a coach on deck is watching an entire lane at once. So the feedback you get is “that felt slow,” never “your back knee was at 91° coming off the block.”

What I built

SwimVolt turns a ten-second phone clip into 45 biomechanical measurements across the four phases of a racing start: setup, launch, flight, and entry. Each one gets compared to a technique target, and the three that matter most for that dive become the corrections you actually see. I built the whole thing myself, from talking to coaches through the CV pipeline, the product, billing, and deploy. It's live at swimvolt.com with 100+ users.

Demo
SwimVolt results screen — video paused on the measured frame with the skeleton overlay on and a 122° angle drawn on the front knee, beside the correction it produced
The measurement and the instruction are the same thing.
26 keypoints a frame, tracked through splash and rotation.
Architecture
  • Next.js 15 / React 19 on Vercel → HMAC ticket against a per-account quota → FastAPI + RTMPose on a Modal T4
  • Model loaded once per container at startup, not once per request
  • Phase detection, all 45 measurements and every coaching rule run client-side, deterministic TypeScript
  • Neon Postgres and Cloudflare R2 store the result; the server never scores the dive

I split it that way on purpose. Pose inference needs a T4 and a warm 200MB model, so it gets its own service, with an HMAC ticket minted against a per-account quota in front of it. A public URL that spends GPU seconds is a bill anyone can run up.

Everything after pose inference runs in milliseconds and is deterministic TypeScript. The server only stores the result. That way the expensive half scales on its own, and the half that decides what a swimmer gets told is testable in-process — about 2,100 TypeScript tests and 166 Python tests.

Engineering
  • Frame timing is the contract everything else depends on

    Container metadata lies. One real clip claims 82 frames at 27fps and decodes 73 at about 30fps, so seeking per sample and labelling each frame with the time I asked for gave me a timing error that grew the further into the clip you got. Now I decode sequentially and stamp every frame with its own presentation timestamp — the one clock the pose overlay, the phase boundaries, and every duration metric all share.

  • Getting keypoints to survive real pool footage

    One mistracked frame published a bogus coaching cue, and neither of the obvious guards caught it. The One Euro filter raises its cutoff with speed by design, so a keypoint that teleports gets the least smoothing in the whole clip, and the bad joint scored higher confidence than the correct ones next to it. I fixed it with a temporal-continuity rejector, plus a left/right leg-swap corrector that decides the whole limb chain in one vote per frame.

  • Camera motion, measured rather than assumed

    Optical flow on background features with the swimmer masked out. This has to be server-side, because a swimmer moving left and a camera panning right look identical in a keypoint stream. Four of the metrics are fitted across frames in pixel space, so those get nulled when the background drifts. The within-frame joint angles are fine either way, which means the check can name the four measurements it invalidated instead of throwing out the dive.

  • Keeping the coaching engine deterministic, and cutting the one LLM call

    It praised “good arm drive” on the same dive whose correction card read arm pull 0.33 → ≥0.40. Now every instruction comes out of a rule table reading measured values, so the praise and the corrections can't contradict each other.

  • The production surface

    Stripe subscriptions over a free tier, Firebase auth, presigned R2 uploads that run in parallel with the 30 to 85 second GPU pass, an H.264 transcode path because iPhone HEVC decodes fine on the GPU but renders black in Chrome, an age gate, and GPS stripping at upload time. A raw phone capture pins the pool a frequently-teenage swimmer trains at to within about nine metres.

Research & validation
  • How reliable is a measurement?

    I filmed dives with two phones at once, scored both takes, and measured how often each metric's verdict flips between them. A metric only gets to count as a repeated fault if it flips 10% of the time or less, which lets in 19 of the 45. The rest still get shown, just without a verdict attached.

  • Are the phase boundaries in the right place?

    I hand-labelled 14 clips frame by frame and checked the detector against them. Mean error is 1.5 frames or better on launch, foot-off, and entry.

  • Where do the targets come from?

    Two come from published biomechanics literature, nineteen from a rule table I built with a USA Swimming high-performance consultant, and nine are still my own estimate. There's a public /research page that prints that breakdown per metric. If a number the app is coaching on isn't backed by anything yet, it says so.

Stack
Next.js 15React 19TypeScriptPythonFastAPIRTMPoseModalNeon PostgresCloudflare R2Firebase AuthStripeVercel