Rally structure and ball trajectories from table tennis broadcast footage, where every number the interface shows carries the denominator it was computed from.
Rally #34 of Fan Zhendong vs Wang Chuqin, stepped frame by frame: the teal marker is the detector's own output, the skeletons are the pose model's, and the readouts underneath say exactly what was detected in the window.
The evidence page. Every measured number carries its denominator, interval and method; a metric with no labels behind it renders as gated with the count that would unlock it, not as a hopeful estimate.
The player roster. Names come from the broadcasts' own titles and portraits are hotlinked from official headshots with the source cited on each card. Nothing here is inferred by a model.
No corpus video, frames, model weights or credentials are committed, by rule. The corpus is rebuilt from source refs, timestamps and frame ranges by
fetch.py. The GIF and screenshots above are captures of this project's own UI, which the same rule explicitly permits as authored assets.
Edgeball turns raw table tennis broadcast footage into coach facing analysis: where the rallies are, where the ball went, where it bounced, and how sure any of that is. It runs entirely on one machine with a GPU, a Postgres database, and a browser. The detection and 3D models are borrowed and cited, not trained here (acknowledgements below); the layer on top, the segmentation, the labeling tool, the measurement infrastructure and the analytics surfaces, is what this project is.
I have played table tennis since the first grade of elementary school, and competed in major tournaments around the US, including qualifying for Nationals. That is the reason this project is shaped the way it is.
Watch enough of the sport and you can tell within seconds when an analysis tool is guessing. A "shot quality" score or a "technique efficiency" rating tells a coach nothing, because nothing behind it was ever checked against a rally anyone actually played. Those numbers are model output wearing the costume of a measurement, and they are confident in a way the underlying data never earns.
So Edgeball measures what it can and refuses to display what it cannot. The numbers below are not flattering ones. They are real, and every one of them names the footage it came from.
| Ball detection | Upstream SegFormer++ through torch.hub, reproduced, then measured on holdout footage |
| Table calibration | Homography and camera pose, corrects a 6 to 8 cm bounce placement bias |
| 3D uplift | Upstream model, own windowing |
| Bounce detection | Two branch parabola fit, contact recovered to under 4 ms on synthetic input |
| Rally segmentation | Four vetoes, no learned score |
| Pose extraction | torchvision Keypoint R-CNN over every produced rally span, raw output stored, nothing derived |
| Labeling tool | Frame accurate transport, protocol enforced in the write path, freeze proven immutable |
| Job queue | Postgres FOR UPDATE SKIP LOCKED, no Celery, no Redis |
| Evaluation harness | Wilson intervals, frozen digested frame sets, seeded gates |
| Analytics dashboard | All ten spec surfaces: matches with the detector overlay on footage, evidence, per match analytics, players, compare, jobs, settings, labeling |
What you can do with it:
- Watch a match with the detector's own output drawn on the footage: a ball marker and player skeletons, frame accurate, with an honest readout of what was detected and what was not. The overlay stops drawing between points rather than guessing.
- See a match's structure at a glance: rally count and durations, a bounce map in table coordinates with off table contacts excluded rather than clamped, and 3D trajectories drawn with their errors visible rather than smoothed away.
- Browse players and compare them, with names and portraits derived from the broadcasts' own titles and cited sources, never inferred by a model.
- Read every number's evidence: the value, the denominator, the interval and the method. A metric with no ground truth renders as gated with a label count.
- Label footage in a built in tool with frame accurate transport and provably immutable frozen label sets.
- Run the whole pipeline yourself, and reproduce the published evaluation with one script.
Most analysis demos show a number. This one cannot show a number that has no denominator, because the database refuses to store one.
create table eval_runs (
frame_set_id uuid not null references frame_sets(id), -- which frames, exactly
metric text not null,
numerator int,
denominator int not null, -- the divisor that produced point_est
eligible_n int, -- the set size before the model got a say
point_est numeric,
ci_low numeric,
ci_high numeric,
ci_method text not null default 'wilson',
...
);frame_set_id must point at a frozen, digested set of frames or a trigger
rejects the row. A second trigger rejects any frame set containing footage the
detector was trained on. A rate below its seeded claim_gates threshold is not
rendered as a small number, it is returned as {gated: true, have: n, need: m},
and the frontend renders that as a first class state. There is no third way to
return a rate, and a property test enforces it.
The thresholds were written before any result existed, and the match tolerance
for the rally evaluation was fixed on 2026-08-13 while eval_runs was empty and
the evaluation harness did not exist. That ordering is the point: a threshold
chosen after seeing the result is a dial.
Machine M1: RTX 4060 Laptop GPU, Windows 11. eval_runs holds 66 rows.
| Metric | Value | 95% interval | n |
|---|---|---|---|
| Detector reproduction, PCK at 5 px | 86.81 % | 84.19 to 89.06 | 645 / 743 |
| Ball detection rate, 5 px | 0.879 | 0.861 to 0.894 | 1305 / 1485 |
| Rally boundary recall | 0.465 | 0.397 to 0.534 | 93 / 200 |
| Rally boundary precision | 0.195 | 0.162 to 0.233 | 93 / 476 |
| Median boundary offset | +12 frames | 8 to 15 | 93 |
- All three published PCK values fall inside the measured interval at 2, 5 and 10 px. The test split was reconstructed rather than estimated, and upstream's own PCK function was imported rather than reimplemented.
- The 5 px tolerance was fixed after the ladder of candidates was computed, which is the wrong order. The row records that, and every other tolerance, in its config. 5 px is not the flattering choice.
- Two candidate detectors were measured under the identical protocol over the same 1,485 frames. The intervals overlap, so no swap was earned.
- Rally boundaries are the only ground truth this project labeled itself: 200 boundaries across 5 sources and 4 matches, frozen before the evaluation ran, on a digested set of 78,953 frames a human actually watched.
- Cost at volume: 78,943 detections over 78,948 frames in 5.89 GPU hours, producing 242 rallies and 167 bounces, plus 133,992 pose rows in about 1.15 more. On battery the same work runs 2.8x slower, entirely in the GPU.
- Tests: 998 backend and 174 frontend, 0 failed, 0 skipped, written alongside the code that produces numbers rather than afterward.
The rally numbers are not good ones, and the section below says why. They are
published because a project whose whole claim is that its numbers are trustworthy
does not get to show only the flattering ones. Every figure here is derived in
docs/, with the full workings.
The short list. The measurement behind every one of these, including two readings
that were published and then withdrawn, is in
docs/PHASE4-RESULTS.md and
docs/FAILURE-MODES.md.
- Rally boundary recall is a joint test and cannot separate "did it find the rally" from "does it agree about which instant the rally begins".
- Precision is capped at 0.420 by over production, before placement quality enters at all. There is no shot change signal, so replays and crowd shots cannot be told from live play, and 72 percent of the false positives survive any redefinition of what a boundary is.
- The 3D reconstruction is physically impossible at volume, and the cause is measured. 11.0 percent of consecutive steps exceed the fastest recorded smash, and nine in ten of those sit on a detector jump. The viewer draws the strays rather than hiding them.
- 3D heights are 5 to 72 cm out at annotated contacts on real footage, against 2.7 cm on synthetic input. This is the largest open question in the project, so bounce placement ships with no accuracy claim and its gate reads 0 of 150.
- One phase exit condition was missed and waived, by name: labeling throughput came out at 138.2 boundaries/hr against the 150 asked for, recorded as missed rather than resolved by choosing a more favourable idle cut.
- The dashboard sits behind a sign in. Row level security scopes footage to the account that created it and the evidence tables grant no anonymous read.
docs/SETUP-GUIDE.md |
Start here to run it. Prerequisites (Python 3.12, Node 20, Postgres, an NVIDIA GPU for the model jobs), the migration list applied by name, the CUDA wheel pins, corpus fetch, the worker, and the four optional derivation scripts |
docs/PROJECT-STATE.md |
The single current-state document. What is measured, what is broken, what was decided |
docs/TT-PLATFORM-SPEC.md |
Intent |
docs/PHASE4-RESULTS.md |
The rally boundary measurement in full, including two readings that were published and then withdrawn |
docs/REPRO-RESULTS.md |
The reproduction, and where the wall clock goes |
docs/FAILURE-MODES.md |
Where the detector breaks, characterised rather than described |
CLAUDE.md |
The operating contract, including the rules above stated as rules |
FastAPI, Python 3.12, psycopg. Supabase Postgres with row level security on every
user owned table. A separate worker process claiming jobs with FOR UPDATE SKIP LOCKED. Next.js 16 App Router, TypeScript, Tailwind v4, Radix primitives,
Recharts, framer-motion, anime.js, and three.js via react-three-fiber for the
trajectory viewer and the homepage hero. pytest, and vitest for the frontend.
The ball detector, the 3D uplift model, the annotations and the evaluation
protocol are all from UpliftingTableTennis: Kienzle, Ludwig, Lorenz, Satoh and
Lienhart, Uplifting Table Tennis: A Robust, Real-Time Pipeline for 3D
Reconstruction, WACV 2026 (arXiv:2511.20250).
Their weights are loaded through torch.hub and nothing here was trained.
The TrackNet lineage this builds on is yastrebksv/TrackNet,
and the pose skeletons are torchvision's Keypoint R-CNN, also downloaded, not
trained.



