Skip to content

Repository files navigation

Edgeball

Rally structure and ball trajectories from table tennis broadcast footage, where every number the interface shows carries the denominator it was computed from.

The Edgeball homepage: a wireframe table tennis table assembled in 3D under the wordmark, with entry points to matches and evidence

A rally from Fan Zhendong vs Wang Chuqin with the detector's ball marker and both players' pose skeletons drawn on the footage, frame by frame

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: measured metrics with denominators and confidence intervals beside gated metrics showing label counts

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 players roster: a grid of player cards with official portraits, match counts, and the portrait source cited on each card

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.


What it is

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.


Why I built this

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.


What is built

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.

The one thing this does differently

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.


Measured results

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.


Known limitations

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.

Documentation

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

Stack

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.


Acknowledgements

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.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages