How well is an NBA offense spaced, frame by frame, from nothing but the TV broadcast?
floorspace tracks all ten players in a broadcast clip, maps them onto a top-down court in feet, measures how spread out the five offensive players are, and renders a broadcast-style overlay: rings on the floor, a live spacing polygon that runs from red (cramped) to green (spread), a minimap and a stat panel. It runs on a laptop CPU. It doesn't use tracking data, manual labels or cloud APIs.
Ten clips from Heat vs Lakers, Nov 2 2026 are processed end to end. All numbers below come from those clips.
flowchart LR
A[raw .mov<br/>HEVC, VFR] --> B[prepare<br/>30 fps H.264,<br/>crop, scorebug mask]
B --> C[detect<br/>YOLO11x persons]
B --> D[camera<br/>per-frame PTZ fit<br/>to court lines]
C --> E[track<br/>ByteTrack +<br/>camera-motion comp.]
D --> E
E --> F[teams<br/>jersey colour clusters<br/>MIA / LAL / refs]
F --> G[identities<br/>split + relink tracks,<br/>max 5 per team]
G --> H[smooth<br/>Kalman + RTS<br/>in court feet]
H --> I[metrics<br/>hull area, spacing,<br/>outside the 3]
I --> J[render<br/>overlay MP4 + GIF]
I --> K[data/*.csv<br/>+ charts]
| Stage | What it does |
|---|---|
| prepare | ffprobe the phone recordings, convert to 30 fps constant-frame-rate H.264, crop to the broadcast picture, record the scorebug box so it is ignored downstream. |
| camera | The broadcast camera sits in a fixed spot and only pans, tilts and zooms. Its position is estimated once (data/camera.json), then every frame is fitted by matching the projected court model to the white lines and the purple 3-point line. Frame-to-frame optical flow predicts the next frame, and a Savitzky–Golay filter removes jitter. |
| detect + track | YOLO11x finds people, ByteTrack links them, with the Kalman state warped by the camera motion so pans don't break tracks. Boxes whose feet aren't on the court, or whose size doesn't fit a person at that spot, are dropped (bench, crowd). |
| teams | Torso / shorts / leg colours (floor pixels removed using a clean plate of the court) are clustered into Heat, Lakers and referees with one model fitted on all clips. Tracks are split where the label flips and relinked per team, so ID switches don't mix teams. |
| smooth | Feet positions in court feet go through a constant-velocity Kalman filter + RTS smoother. Gaps up to 0.8 s are bridged; lost players are held briefly, then faded. Players hidden behind the scorebug get their feet estimated from their head height. |
| metrics | Computed only when all five offensive players are tracked; otherwise the frame is flagged and left blank. |
| render | Floor graphics are drawn in court coordinates and projected into each frame, then cut out with player segmentation masks so they sit under the players. Rendered at 2× supersampling in Inter. |
All distances are on the court floor, in feet (court 94 × 50 ft).
| Metric | Definition |
|---|---|
| Spacing (sq ft) | Area of the convex hull around the five offensive players' feet. A half court is ~2,350 sq ft. |
| Mean teammate distance (ft) | Average of the 10 pairwise distances between the five offensive players. |
| # outside the 3 | Offensive players behind the 3-point line (23.75 ft arc, 22 ft in the corners; on the line counts as inside). |
| Per clip | Average and minimum inside the analysis window (from the start of the half-court set to the shot release, clips/clips.csv), plus the average over the last 1 s before the shot. |
| Colour scale | The polygon is red at 426 sq ft and green at 1,111 sq ft: the 10th and 90th percentile of every tracked frame across all ten clips (data/spacing_thresholds.json). |
| Heat offense (3 clips) | Lakers offense (7 clips) | |
|---|---|---|
| Avg spacing | 598 sq ft | 810 sq ft |
| Avg teammate distance | 26.8 ft | 30.0 ft |
| Avg players outside the 3 | 3.0 | 3.3 |
- The Lakers spaced the floor wider in this sample: about 35 % more hull area and 3 ft more between teammates on average. Their widest possession (p07, 1,129 sq ft) ended in a make, with the spacing still growing into the shot (1,228 sq ft in the last second).
- The Heat scored from tight shapes. p01 and p08 averaged about 500 sq ft; p08 shrank below 200 sq ft a second before the make.
- The turnover (p06) has the deepest dip: spacing fell from ~930 to ~215 sq ft mid-possession before the Lakers spread out again.
- With 10 clips (3 Heat) none of this is statistically meaningful. It shows what the pipeline measures, not a scouting conclusion.
| p07 · Lakers · widest spacing, make | p08 · Heat · spacing collapses, make |
|---|---|
![]() |
![]() |
Click a preview for the full video. All ten are in outputs/.
Per-clip numbers are in data/clips_summary.csv.
Share of analysis-window frames with all five offensive players tracked (observed or bridged across a short gap). Frames below five aren't scored. Counts never exceed five per team, and the hull never exceeds a half court.
| clip | p01 | p02 | p03 | p04 | p05 | p06 | p07 | p08 | p09 | p10 |
|---|---|---|---|---|---|---|---|---|---|---|
| offense | MIA | LAL | MIA | LAL | LAL | LAL | LAL | MIA | LAL | LAL |
| all 5 tracked | 92 % | 94 % | 78 % | 100 % | 100 % | 72 % | 95 % | 69 % | 78 % | 93 % |
The main reasons for misses are players off-screen when the camera is zoomed in (p02, p06), a player standing
behind the scorebug (p03), and deep pile-ups at the rim (p08, p09). Full counts are in data/qa_report.csv;
court-line check videos are in outputs/debug/.
Plain CSVs, ready for SQL or Power BI.
data/tracking.csv: one row per player per frame
| column | meaning |
|---|---|
| clip, frame, t_sec | clip id, frame at 30 fps, seconds from clip start |
| track_id | player id within the clip (stable after relinking) |
| team, side | MIA / LAL, offense / defense |
| x_ft, y_ft | court position in feet: x 0–94 along the court, y 0–50 across |
| visible | 1 = seen by the detector this frame, 0 = bridged by the smoother |
data/spacing.csv: one row per frame: clip, frame, t_sec, offense, attack_side, in_window, n_offense_tracked, all5_tracked, hull_area_sqft, mean_dist_ft, n_outside_3
data/clips_summary.csv: one row per clip: avg / min / last-1 s of each metric, share of frames with all five tracked.
clips/clips.csv: hand-entered clip notes: offense, defense, result, game clock, analysis window and shot time.
The CSVs load straight into any database (or Power BI / Excel). A few questions answered from the data, written for SQLite / Postgres:
Spacing by team (frame-weighted, so slightly different from the per-clip averages above)
SELECT offense,
COUNT(DISTINCT clip) AS possessions,
ROUND(AVG(hull_area_sqft), 0) AS avg_spacing_sqft,
ROUND(AVG(mean_dist_ft), 1) AS avg_teammate_dist_ft,
ROUND(AVG(n_outside_3), 1) AS avg_outside_3
FROM spacing
WHERE in_window = 1 AND all5_tracked = 1
GROUP BY offense;| offense | possessions | avg_spacing_sqft | avg_teammate_dist_ft | avg_outside_3 |
|---|---|---|---|---|
| LAL | 7 | 823 | 30.3 | 3.4 |
| MIA | 3 | 576 | 26.3 | 2.7 |
Spacing in the last second before makes vs misses
SELECT c.result,
COUNT(DISTINCT s.clip) AS shots,
ROUND(AVG(s.hull_area_sqft), 0) AS spacing_last_1s_sqft
FROM spacing s
JOIN clips c ON c.clip = s.clip -- clips/clips.csv
WHERE c.ends_in_shot = 1
AND s.all5_tracked = 1
AND s.t_sec BETWEEN c.shot_t_s - 1.0 AND c.shot_t_s
GROUP BY c.result;| result | shots | spacing_last_1s_sqft |
|---|---|---|
| make | 7 | 698 |
| miss | 2 | 763 |
The most cramped moment of each possession (window function)
SELECT clip, offense, t_sec, hull_area_sqft
FROM (
SELECT clip, offense, t_sec, hull_area_sqft,
ROW_NUMBER() OVER (PARTITION BY clip ORDER BY hull_area_sqft) AS rn
FROM spacing
WHERE in_window = 1 AND all5_tracked = 1
) t
WHERE rn = 1
ORDER BY hull_area_sqft
LIMIT 3;| clip | offense | t_sec | hull_area_sqft |
|---|---|---|---|
| p08 | MIA | 3.90 | 198 |
| p06 | LAL | 4.17 | 215 |
| p09 | LAL | 0.10 | 311 |
With two misses in the sample, the make / miss split says nothing yet; it shows the kind of question the data answers.
outputs/pNN_floorspace.mp4: overlay video for every clip (H.264, broadcast resolution)outputs/debug/pNN_court_check.mp4: projected court lines over the raw video, to check the camera fitdocs/hero.gif: the loop at the top of this page
Python 3.12+ and ffmpeg. CPU only. The full run of ten clips takes a few hours, mostly detection and
segmentation; every stage is cached under work/.
python -m venv .venv && . .venv/bin/activatepip install -r requirements.txt && pip install -e .Model weights (yolo11x.pt, yolo11l-seg.pt) are downloaded into weights/ from the
Ultralytics GitHub releases.
The original broadcast recordings aren't published. Put your own clips in clips/raw/ (e.g. p01.mov)
and describe each one in clips/clips.csv.
python -m floorspace prepare # clips/raw/*.mov -> clips/mp4/*.mp4python -m floorspace run clips/raw/p01.mov # one clip, end to endpython -m floorspace run-all # every clip in clips/clips.csv + data, charts, videos, GIFpython -m floorspace qa p01 -n 12 # court-check video + sampled QA frames with minimappip install pytest && PYTHONPATH=src pytest -q # metric unit testsRerun from a stage with --force-from camera|track|features|identities|smooth.
- Small sample: 10 possessions from one game, only 3 with Miami on offense.
- Single camera angle: the camera model assumes the main broadcast camera. Replays, close-ups and baseline angles aren't handled, so clips have to stay on the wide shot.
- Off-screen players: when the camera zooms in, weak-side players leave the picture and the frame isn't scored rather than guessed.
- Feet, not bodies: positions are the bottom-centre of each detection box, so a jumping or leaning player is a foot or two off.
- Team colours are fitted per game: a new game with different uniforms needs the team model refitted
(
run-alldoes this automatically). - No ball tracking: the ball was detected in only ~28 % of frames on p01, so the ball trail is left out.
- Input is a phone recording of a TV: moiré and variable frame rate cost some accuracy; a direct broadcast feed would be cleaner.
Built by Viktor Berg. Questions or ideas? Reach out on LinkedIn.
Code, data and charts are MIT licensed (see LICENSE). The NBA broadcast footage in the videos belongs to its rights holders and is shown only as a short demonstration.




