Hockey-Vision turns broadcast NHL video into player and puck tracking data and play-by-play
events. It finds every player, referee and the puck in each frame, reads the players' jersey
numbers, works out where each frame's camera is looking on the rink, and maps everyone onto a
top-down rink in real-world feet. From those rink positions it finds possessions, shots, passes,
turnovers and defensive plays. The output is machine-readable data: per-frame and per-player
tables, an event list with times, and a game_report.json written for a language model to read
and turn into feedback.
It runs on a Mac or a CUDA GPU, and on an NVIDIA Jetson Orin Nano with TensorRT engines (see jetson/README.md).
broadcast video
│
▼
run_models.py ── five YOLO models per frame ──────────────► outputs/<clip>/detections.json
│ player players, goalies (team a / b), referees; tracked across frames (ByteTrack)
│ puck the puck
│ number jersey digits on each player's box, voted per track → jersey number
│ rink rink keypoints (lines, circles, goals)
│ dots the faceoff dots on the ice
│ (+ optional --teams roster: limits numbers to each team's and names the players)
▼
homography.py ── rink + dots keypoints → per-frame homography → rink feet ─► positions.json/.csv
│ matched to named landmarks in rink.py, outliers dropped, smoothed over time
▼
export_data.py ── tables + play events (events.py) ──────► outputs/<clip>/export/
possessions, shots, passes, turnovers, *.csv, game_report.json
defensive plays, per-player stats
- Detection and tracking (
run_models.py): the player model's boxes are tracked across frames, so each player keeps an id. The number model reads digits on each player's torso, and every frame's reading votes for that track's number, so the number holds through frames where it can't be read. With--teams, a roster fromfetch_roster.pyrestricts the numbers to those each team wears and names the players (roster.py). - Rink mapping (
homography.py,rink.py): the rink and dots models' keypoints are matched to known rink landmarks, and a homography from image pixels to rink feet is fitted for each frame. Players are placed where their skates meet the ice. Rink coordinates are feet from the centre dot: x along the rink (-100 to 100), y across it (-42.5 to 42.5). - Export and play events (
export_data.py,events.py): one or more clips' outputs become tables, play events and per-player stats (see Output). Events are found from the rink positions alone, in the 2D rink plane.
The five YOLO models aren't in git; they're on Hugging Face at
AlexRHaigh/Hockey-Vision and go in
CV_Models/Models/. CV_Models/README.md lists each model, what it
detects and how to download them.
pip install -r requirements.txt
pip install -U huggingface_hub
hf download AlexRHaigh/Hockey-Vision --include "*.pt" --local-dir CV_Models/Modelsrun_models.py <video>runs the detection models and writesoutputs/<clip>/detections.json.homography.py outputs/<clip>/detections.json <video>maps players and the puck onto the rink (positions.json,positions.csv,homographies.csv).export_data.py outputs/<clip>writes the tables, events andgame_report.jsontooutputs/<clip>/export/(several clips:export_data.py outputs, written tooutputs/export/).
run_models.py --width <px> (e.g. 640, 960, 1280) resizes the frames before the models see them,
to test how they do on lower-resolution video; the default is the video's own resolution.
Detections are still written in the source video's pixels, so the later steps run unchanged.
run_models.py --rink-stride 2 runs the rink and dots models only on every other frame (and on
every camera cut), and homography.py holds the rink fit in between. This makes run_models.py
about 12% faster on a Mac. Players, the puck and jersey numbers stay the same; player rink
positions move by about 0.3-0.5 ft (median). run_models.py records camera cuts in
detections.json, so homography.py only decodes the video when it writes videos.
jetson/run_clip.sh <video> [--teams SJS MTL] runs all three. Only data is written by default.
Annotated videos are optional outputs, for checking the models by eye:
run_models.py --save-video writes combined.mp4 (--per-model adds one video per model), and
homography.py --save-video writes side_by_side.mp4 with the top-down rink beside the video
(--debug adds radar.mp4 and overlay.mp4).
outputs/<clip>/export/:
| File | Contents |
|---|---|
game_report.json |
Everything a language model needs for feedback, in one file: definitions, team totals, each player's stats, and every event with its time and a plain-English description |
events.csv |
One row per event: type (shot, pass, turnover, defensive_play), subtype, time, who did it, who to/from, rink position, zone; type-specific fields in details (JSON) |
player_stats.csv |
One row per player: time detected, distance skated, possessions, shots, passes (good ones too), takeaways, interceptions, turnovers, defensive plays |
possessions.csv |
One row per spell of a player carrying the puck, and how it ended |
players.csv, puck.csv, frames.csv, tracks.csv |
Per-frame positions and per-track summaries (jetson/README.md) |
metadata.json |
Source files, frame rate, coordinate system and a description of every column |
The events (events.py has the exact rules and thresholds):
- Shots: the puck leaves a player's stick at shot speed on a line at the other team's net, or
the other team's goalie gets it straight after a release near the net. Outcomes:
saved,blocked,possible_goal,rebound_recovered,recovered_by_opponent,unknown. - Passes: the puck goes from one player to a teammate.
goodpasses say why: advanced the puck, beat defenders, relieved pressure, found an open teammate, or led to a shot. - Turnovers: the other team gets the puck: a
takeaway(stolen off the carrier), aninterception, or agoalie_recovery. - Defensive plays: an opponent close to the carrier forces them to backtrack, slows them down, or makes them pass the puck away. Credited to that defender.
- Time detected: per player, every frame of every track carrying their jersey number.
Which way each team attacks comes from where the goalies stand. Times are video time, not the game clock. The puck is only seen in part of the frames and players are only named once their number is read, so treat the counts as estimates.
webapp/ is a web app on top of the exported data: every NHL team's players, a 0-100 score for
each player in each analysed game, favourites, playlists, and hockey pools with trade calls and an
assessment of whether to trade at all. It uses only the Python standard library (SQLite and
http.server), so it runs wherever the pipeline does.
python -m webapp.server # http://127.0.0.1:8000 (--host 0.0.0.0 to share it)
python -m webapp.ingest outputs/<clip>/export --roster rosters/2026-03-03_MTL_at_SJS.csv
python -m webapp.demo # or: 300 made-up demo games to try it (--clear removes them)-
Overview: your own setup rather than league totals. For your pool (or any playlist you pick) it shows strength, trade urgency, the average of the last game, trend and keep / watch / trade counts. It also charts your players' average score per week, lists your top performers and the players needing attention (with the best available replacement), compares your form with the league median by position, and lists your players' recent games and your favourites.
-
NHL data: official, up-to-date information from the NHL's public API (documented at gitlab.com/dword4/nhlapi,
new-api.md), refreshed withpython -m webapp.nhl syncor the Refresh NHL data button on the overview: standings, current rosters (trades and call-ups move players; players on no roster, e.g. sent down or waived, are marked Off roster, left out of team lists and upgrade suggestions, and flagged in pool assessments; headshots, handedness, height, weight, birth date and place), every player's season totals (GP, G, A, P, +/-, PIM, PPP, SOG, TOI/GP; goalies W, L, OTL, GAA, SV%, SO) and this week's schedule. Player pages also fetch the player's last 5 games, draft and career totals when opened, cached for 6 hours. Imported games are matched to their NHL game by date and teams (orwebapp.ingest --game-id) and get the official box score, which goes into the game scores (below). The overview adds your pool's NHL points, goalie wins and games this week. -
Players: all 32 teams from
rosters/nhl_active_players.csv(refresh withfetch_roster.py; the server loads it into an empty database,python -c "from webapp import db; db.load_roster(db.connect())"reloads it). Each player's page has their form, trend, a chart of their game scores, their NHL season, and their game log. -
Imports:
webapp.ingestreads an export'splayer_stats.csvand matches players to NHL ids by team and jersey number, so the clip must have been run with--teams. With--roster(thefetch_roster.py --gamefile) the game's date and teams come from the roster; otherwise pass--date,--awayand--home. Importing the same folder again replaces the game. -
Game score: the video and the NHL's box score together, in three parts:
- Play, from the video: movement, possession, pass quality and pressure (good pass +1, takeaway +2, turnover -2, ...), as points per minute the player was detected, blended with 3 minutes of typical play so brief appearances can't run away.
- Results, from the box score: goals +6, assists +4, shots on goal, +/-, hits, blocks, penalty minutes; goalies saves, goals against, the win and shutouts.
- Backed by video: official results the video saw the player create in the same game count extra. That covers an assist when the video saw good passes, a goal when it saw the shots, points after it saw the player win the puck, and a plus backed by defensive plays. Turnovers on video in a minus game count against. Each result needs its own play on video: 4 good passes back at most 4 assists.
Play is compared with every other player-game in the database, and results by where the game ranks among them. Until the database has enough games, results are ranked against 100 of this season's real box scores. The two are blended, and play counts less when the player was barely on screen: 50 is a typical game, 73 one spread better, 88 two. With no box score yet, the score is the video alone (its own shots and saves standing in); players the video never identified are scored on their box score alone. The matching is per game, not per play: the video's times aren't lined up with the game clock yet. The weights are in
webapp/scoring.py. -
Form and trade calls: form is the last 5 scores, recent and longer-seen games counting more. A player is Trade below 40 form, Watch below 46 or after a 10-point slide, Keep otherwise, and Hold until 3 games are in.
-
Pools: a playlist marked as a pool, with its league's forward / defense / goalie slots, gets a strength grade, depth against the slots, the best available upgrade at each weak spot, and a 0-100 trade urgency: hold, make a targeted trade, or be active.
The weights and thresholds are all in webapp/scoring.py. Scores come from what the video picks
up, so treat them as estimates, like the counts they are built from. The database is
webapp/hockey_vision.db (--db to use another); there are no user accounts, so everyone who can
reach the server shares the favourites and playlists.
| Path | Contents |
|---|---|
run_models.py |
Runs the detection models, tracks players and reads jersey numbers |
homography.py, rink.py |
Maps detections onto the rink; rink geometry and the template drawing |
export_data.py, events.py |
Turns a clip's outputs into tables, play events and game_report.json |
fetch_roster.py, roster.py, rosters/ |
NHL rosters for --teams / --roster |
webapp/ |
The web app: server, scores and trade calls, importing exports, NHL API data (nhl.py), the page in static/ |
export_engines.py, jetson/ |
TensorRT engines and the Docker setup for the Jetson |
video_io.py |
Background-thread video decoding (hardware decoder when available), camera-cut detection, background output writing |
CV_Models/ |
The models (see CV_Models/README.md) |
assets/ |
The rink template image (for the optional videos) |
tests/ |
Tests |
run_models.py reads jersey numbers with the YOLO number model, CV_Models/Models/new_nums.pt (YOLO26n,
classes 0-9). It detects single digits on each player's box and joins them into the number;
digits outside the torso band (stripes on socks, sticks, the boards) are ignored. Each player
track's readings are combined in a vote, so a number stays with the player through frames where
it can't be read and one-off misreads are outvoted. Referees aren't read.
Give the two teams' abbreviations, team_a's first (the player model's team_a_player / goalie_a
classes), and jersey numbers are limited to what each team wears, players are named, and referees
get no number:
python fetch_roster.py # all 32 teams' active rosters -> rosters/nhl_active_players.csv
python run_models.py videos/<clip>.mp4 --teams SJS MTL # team_a = SJS, team_b = MTL
python fetch_roster.py --game 2025020969 # a past game: exactly who dressed
python run_models.py videos/<clip>.mp4 --teams SJS MTL --roster rosters/2026-03-03_MTL_at_SJS.csvA reading that isn't a number that team wears is then dropped. detections.json, positions.csv
and the exported players.csv / tracks.csv gain team_abbrev / player_name. The roster CSV has one row per player:
team, team_name, number, player, position, player_id, as_of; refetch it as rosters change.
detections.json lists the digit boxes in number.