How to Measure a Fast Bowler From Phone Video Without Lying
By Govind Sharma
The Problem
FastBowlingLab takes one cricket delivery filmed on a phone and returns 19 measurements of a fast bowler's action: knee flexion at front-foot contact, trunk lean, stride length, timing between back-foot contact, front-foot contact and release, and more. Athletes upload one to four clips from different angles. Nothing is calibrated, nothing is synchronized, and there is no reference object in the frame.
Every 2D joint angle is a projection. A knee angle read from a front-on camera is a confident-looking number from the wrong plane. Early on, a side-on tripod clip labelled "front-on" was measured with the frontal formulas at full confidence. That is the failure this post is about: not a crash, but a plausible wrong number shown to an athlete.
The rule I built everything around: a measurement the footage can't support is withheld with a reason, never shown with a lower confidence.
A Measurement Contract That Knows About Cameras
Pose runs on the server: a Python worker running MediaPipe's heavy pose model in video mode over the full edited frame. On top of the 33 landmarks it produces, a policy table declares, for every measurement, which camera views can support it, what kind of evidence it is (a direct plane angle, a timing, a distribution or a video proxy), and its formula version.
- Side-on only: knee angles, forward trunk lean, the elbow proxy, stride length, foot strike.
- Front or back only: lateral trunk lean, crossover step percentage.
- View-independent timings (stride duration, rhythm, airborne time) may run on an unknown view.
Every measurement key gets exactly one typed outcome: measured, or one of invalid clip, unsupported view, insufficient visibility, missing event, insufficient samples, unstable geometry, not implemented or not applicable. A validator enforces that the set is exhaustive and that each status carries its matching reason code. The same table is mirrored in TypeScript for the apps, and a test reads the TypeScript source and fails if the two languages drift.
Pick the best clip per metric, never average
The first design let the best-tracked clip own every metric. Real footage killed it: a crisp front-on track is still the wrong camera for front-knee flexion. Now each metric is sourced from the clip whose view supports it, ranked by preferred view, then confidence, then track quality, and the result records which clip, view and formula version produced it. Clips are never averaged; unsynchronized phone clips are not a calibrated 3D rig, which is also why hip-shoulder separation is a deliberate non-goal.
Checking the Camera Angle the Athlete Declared
Athletes label their camera angle, and they are often wrong. The worker detects the view from torso width ratio, orientation consistency, how much the torso scale changes (approaching the camera means front-on, receding means back-on), sideways pelvis travel and nose-versus-ear depth. Thresholds were calibrated on real clips: side-on clips read 0.35 to 0.40 width ratio and a rear view 0.54, so side-on ends at 0.45 and frontal starts at 0.50. The gap between them is an abstention band, not a decision boundary.
The declared angle wins unless the footage is decisive: labelled frontal but the bowler crossed the frame with an edge-on torso, or labelled side-on but the bowler's size changed by half a torso length or more. When the view is genuinely unresolved, the pipeline parks the analysis and asks the athlete to confirm, then resumes the same durable job. The report can say "you picked front-on; the footage is side-on" instead of quietly measuring the wrong thing.
Finding Release and Foot Contact in Noisy 2D Pose
Every metric is read at an event frame. MediaPipe doesn't track the ball, bowlers fill anywhere from 4% to 54% of the frame, clips run from 30 to 240 fps, the camera often pans, and the run-up plants a foot every stride. Each detector was rewritten after a measured failure on real footage:
- Release used to be the wrist's highest point in the image. The leap's apex beat the real release, and one clip's release came back 24 frames early. Release is now the peak of the wrist above its own shoulder: a landmark difference that cancels body leap and camera pan.
- Foot contact used to be the first frame near the window's maximum, which found the first footfall in view: the run-up. It is now the onset of the last plant before the anchor, in a 0.40 s window, and gives identical results on every real track from 0.25 to 0.80 s windows.
- Thresholds were fractions of the frame, so a landscape clip whose wrist moved 0.02 of frame height was rejected. Every threshold is now in torso lengths, divided by the clip's median torso length. Dividing per frame instead moved release 17 frames, because the torso foreshortens through release.
- Smoothing used to fill pose dropouts, and a 4-frame gap at landing put front-foot contact inside the hole. Missing frames now stay missing, and an event frame must have a pose.
Camera-drift correction is implemented but ships off. On tripod clips the estimator invented 0.07 to 0.52 frame-heights of drift and moved front-foot contact onto the release frame. A wrong correction corrupts clips that already work; a missing one changes nothing.
def test_events_do_not_move_when_the_bowler_shrinks_or_the_camera_pans():
clip = synthetic_delivery()
baseline = detect_events(clip)
for transformed in (scale(clip, 0.1), pan(clip, dx=0.3), scale(pan(clip, dx=0.3), 0.25)):
events = detect_events(transformed)
assert events.release == baseline.release
assert events.front_foot_contact == baseline.front_foot_contact
assert gate(transformed).verdict == gate(clip).verdictTests assert properties rather than thresholds: shrink the delivery to a tenth, pan it, and the events must not move. Any detector change is also A/B-tested against the real clip corpus before it ships, because synthetic tests only reproduce what we already know.
Which Arm Is Bowling?
Get the bowling arm wrong and everything downstream is wrong. Peak wrist height doesn't work, because coached bowlers throw the front arm up hard. The bowling arm is the one sweeping the largest arc around its shoulder; the front arm only rises and falls. Two details make that reliable: frames where the projected arm is shorter than half a torso length are dropped, and the angle is unwrapped per continuous run, never across a visibility gap (stitching once treated frames 2.7 seconds apart as neighbours and invented 118°). Handedness belongs to the job, not the clip, so clips vote, weighted by how decisive they are, and a clip too poor to measure gets no vote.
Framing Is the Athlete's Choice
Detection depends on how much of the frame the bowler fills, not on resolution. On the corpus, a bowler filling 54% of the frame was detected on 99% of frames; at 14% of a 1080p frame, 13%. I tried hidden auto-zoom in the worker and removed it. The athlete now trims and crops explicitly, and the worker writes and verifies a derivative (frame count within one, exact crop size) that pose, overlay and key frames all use. Trims map times to frames using real presentation timestamps, because on variable-frame-rate phone video a 2.0 s trim once landed on a frame shown at 2.3 s. Slow motion keeps its cadence, and the overlay keeps one output frame per source frame so every event index still lines up.
What this bought
The language rules live in a domain glossary: 'video proxy', never 'exact measurement'; 'estimated release', never 'the release frame'; a missing value is never 'low confidence'. The code enforces the same rules, so the coaching model downstream can only talk about numbers the footage actually supports.