Inspiration

Plateau United play in the Nigeria Premier Football League, the country's top flight. Like most clubs here, they have no camera crew, no data provider and no analyst's software. The tools clubs use elsewhere are priced in dollars per pitch per month, and they assume a broadcast feed or a gantry our stadiums don't have. So the post-match conversation runs on memory and a phone clip filmed by a substitute.

We wanted every home game to end with something a coach can act on: a report on how the team played. We also wanted the coach to be able to see the moment behind each finding, because a number nobody can check is a number nobody trusts.

What it does

1. The coach report. A fixed rig of three 4K lenses on the stand records the match, with no operator. From player positions alone, with no player identity, we measure:

  • territory and zone control,
  • team shape,
  • who pushed up in each half,
  • and estimates of possession and pressing.

Our Matchday app adds the official's live event record. Every figure is labelled Recorded by match official or Measured by cameras. Anything a second test can't confirm is printed as "not settled", not given a winner. Reports for Plateau's last three home games are live at kamhq.com/plateau/.

For Plateau United 4-0 Kano Pillars (11 Oct 2026), the report found that Kano pushed Plateau back in the second half: Kano spent 63% of their time in Plateau's half against Plateau's 49%, a 15-point gap (95% interval 4–26). That's a finding a coach needs to watch before acting on it, in a game Plateau won 4-0.

2. The video behind it. This entry's AWS and OpenCV 5 piece. A three-lens panorama can't be watched, so a virtual camera cuts a 1080p broadcast-style follow shot out of the fixed rig. It picks the lens, the aim and the zoom, and nobody steers it. On the live endpoint you can watch 3 minutes around Plateau's second goal, or choose any 10–30 s window and have EC2 render it there and then.

How we built it

Capture. Three USB 4K lenses on a Raspberry Pi 5 record H.264 at a variable 17–18 fps. Each lens has a homography $H$ fitted to the painted lines on a median frame with no players in it.

Tracking. YOLO + ByteTrack on a laptop GPU turn the footage into player positions in pitch metres. That's 103,142 ticks for this match.

The report. Kit colours are split per lens. Zone control compares each side against its own time on the pitch, so the side the cameras happen to see more of gets no head start. With $p_h$ and $p_a$ the shares of each side's own samples that fall in a 4 m cell,

$$z = \frac{p_h - p_a}{p_h + p_a}.$$

Every comparison is gated by a minute-by-minute test with a 95% interval.

The virtual camera (OpenCV 5.0.0 on EC2).

  • Time. The container declares 20 fps, but the true rate varies, so frames are located by time, never by index. Each cv2.VideoCapture is walked with grab(), and CAP_PROP_POS_MSEC is read after each grab. Only the lens on air calls retrieve().
  • Where to aim. Not the players' centroid, which follows whichever lens happened to see more people. We aim at the density peak on a 2 m grid, with $\sigma = 8$ m: $$S(g) = \sum_i \exp!\left(-\frac{\lVert g - p_i\rVert^2}{2\sigma^2}\right).$$ Ticks with fewer than 12 detections, a flat peak, or an implied jump above 22 m/s are rejected. The pan is critically damped, with a dead zone.
  • Which lens, how much zoom, which way up. The aim point goes through each homography. The camera cuts to the lens with the most margin from the frame edge, with hysteresis. The zoom is set from the pixel span of 30 m of pitch at that point. Roll comes from the vertical vanishing point, not the touchline (which slopes from perspective): with $H \propto K[r_1\ r_2\ t]$, it is $v = K(r_1 \times r_2)$.
  • Resampling. An INTER_AREA downscale first, then a 1:1 warpAffine, because warpAffine alone aliases grass into shimmer. The frames are piped to libx264.

AWS. EC2 m7i-flex.large in eu-north-1, Ubuntu 24.04, with nginx in front of a stdlib Python job queue under systemd:

  • the render window and duration are validated, the queue is capped, and one render runs at a time;
  • SSH is open to a single IP only, and IMDSv2 is required;
  • a /api/health check and a per-job log are kept;
  • 3 minutes renders in 5 min 39 s on 2 vCPUs (13.6 fps).

Evaluation

  • Report vs reality: the score and all four scorers agree with the published 4-0.
  • Does the camera point at the ball? Against an independent ball path over the whole match (4,889 sightings), the ball is within the 30 m frame 50.2% of the time, against 39.3% for the centroid and 28.3% for a fixed wide shot. The median distances are 14.9 m, 17.3 m and 22.0 m. The tail is worse than the centroid's: the 90th percentile is 34.2 m against 30.2 m. On a long switch of play the camera holds on the crowd the ball has just left. We report the tail, not just the median.
  • Corners vs the official's record: 5 of Plateau's 6 corners were detected, each within about 11 s. Precision is unmeasured: 13 further detections can't be confirmed, because only Plateau's events were logged.

Challenges we ran into

  • The camera missed the corner and the shot from outside the box. Reviewing our own goal clip, we saw the camera hold on the crowd in the goalmouth. The corner taker stood on the near arc, 33–37 m from the aim point, outside any 30 m frame. "Just zoom out" filled 79% of frames with sky: from a fixed stand, a near corner and a far box don't fit in one watchable shot. What worked was cutting, like a director. We render after the match, so the delivery time is known in advance: it's the moment the player standing still on a corner arc moves off it, with 6+ players in the box. The camera cuts to the taker 4 s before that and back to the box as the ball arrives. A ball boy standing 3.6 m behind the goal line for the whole sequence had to be filtered out. Separately, a capped widening of up to 1.6× brings the edge of the box into shot.
  • Identity doesn't last. In a five-minute test a tracked name lasted about 18 s. So we stopped measuring players and started measuring the team.
  • Getting 4K footage from Jos to the cloud. The link runs at about 1 MB/s, and its public IP rotates mid-upload. A single large scp died; chunked, resumable uploads with automatic SSH-rule updates worked.
  • The free plan. No GPU instances and 2 vCPUs, so detection stays on a laptop and renders are queued.

What we learned

  • Coaches read every gap as a fact. In our review call with Plateau's analyst and head coach, "no tackles" was read as zero tackles. Absences are now labelled "not recorded". They also couldn't tell which end each team attacked, so the report gained a half-by-half section with the attacking direction marked.
  • A coach wants to see the moment before believing the number. That's the whole reason the video exists.
  • The obvious estimator is often measuring the cameras, not the football, whether it's the centroid, the touchline used as "level", or more zoom used as "more context".

What's next

  • Upload whole matches, so every finding in the report links straight to its clip.
  • Move detection and the report onto AWS.
  • Fuse the ball path into the camera's aim.
Share this project:

Updates

Submission history