PitchTrack: the coach report, and the video behind it

Kam Labs, Jos, Nigeria · OpenCV AI Competition 2026, powered by AWS · Overall Awards track · 26 October 2026

1. Problem

Below the very top of African football, clubs play without match analysis. Plateau United are in the Nigeria Premier Football League (NPFL), the country's top flight, and like most clubs here they have no camera crew and no data provider. The tools that exist elsewhere are priced in dollars per pitch per month, and they assume a broadcast feed or a gantry the stadium doesn't have. So the post-match conversation runs on memory and a phone clip.

PitchTrack gives a club a coach report after each home game it films, from a fixed rig of three 4K lenses on the stand with no operator. It shows territory, team shape, who pushed up in each half, possession and pressing estimates, and the official's event record. A report only changes what a coach does if they can go and see what it describes, though. A three-lens panorama isn't something a coach can scrub through to find "the minute Kano pushed us back". This entry closes that gap with a virtual camera: OpenCV 5 on AWS turns any moment the report points at into a broadcast-style follow shot cut from the same fixed rig.

2. Users, and what they told us

3. The coach report (what the club receives)

From the 11 October match, Plateau United 4-0 Kano Pillars, which is also the source of this entry's footage:

The report is built from player positions with no player identity. It measures the team, so the tracker's identity breaks don't touch it. In a five-minute test, a name lasted about 18 s. Names on the page come only from the official's event record.

4. Architecture

Four stages. The first three existed before this competition. The virtual camera's AWS service, and its link to the report, were built for this entry.

  1. Capture (rig). Three lenses at 3840×2160 capture at a variable 17–18 fps, recorded as H.264 .mkv on a Pi 5. A JSON manifest stores each lens's start offset (offset_ms) and its pitch homography. The homography is fitted to the painted lines on a median frame with no players in it.
  2. Tracking (laptop GPU). YOLO detection plus ByteTrack per lens produces tracking_log.csv: one row per player per tick, in pitch metres, on a shared session clock. For this match it holds 103,142 ticks over 6.9 h of compute.
  3. Coach report (laptop). Kit split per lens, then territory, shape, half-by-half, possession and pressing estimates, each gated by a minute-by-minute test. The result is a static HTML report published to the club.
  4. Virtual camera (AWS, OpenCV 5). ptz.py reads the three videos and the tracking log and renders a 1920×1080 follow shot at 25 fps of any window of the match. server.py exposes it as a job queue behind nginx on EC2. The endpoint shows a 3-minute render around Plateau's second goal, and a form lets anyone render a 10–30 s window live.

The architecture diagram is on the endpoint's home page and in the submission images.

5. OpenCV 5 implementation

Everything that touches pixels in stage 3 is OpenCV 5.0.0 (opencv-python-headless==5.0.0.93), running unchanged on Windows and on the Ubuntu EC2 instance.

5.1 Time: three variable-rate decoders on one clock

The rig's container declares 20 fps, but the true rate varies with USB load. Frames are therefore located by time, never by index. Each lens has a cv2.VideoCapture (FFmpeg backend) that we walk with grab(), and CAP_PROP_POS_MSEC is read after each grab. Session time maps to local time through offset_ms. The output timeline is resampled onto a fixed 25 fps by advancing each decoder to the first frame at or after the wanted instant. Only the lens on air calls retrieve(); the others only grab(), which keeps decode cost near one stream. For the cloud clip, each lens was stream-copied from a keyframe, and its offset_ms was shifted by that keyframe's exact presentation time, so no re-encode was needed and no drift was introduced.

5.2 Where to aim: a density peak, not a centroid

The obvious aim point, the mean position of every tracked player, follows which lens happened to see how many people, because per-lens detection counts swing from 0 to 19 between ticks. We aim instead at the point with the most players within 8 m. That is a Gaussian-weighted density on a 2 m grid, followed by a weighted-mean refinement off the argmax. Three gates keep a bad tick from moving the camera: at least 12 detections, a peak score of at least 2.5, and no implied movement above 22 m/s. Over the full match those gates kept 61,528 of 102,290 ticks. Movement is critically damped, with a 3 m dead zone and a speed cap.

5.3 Which lens, how much zoom, which way up

5.4 Resampling

The crop's axis-aligned footprint is downscaled first with cv2.resize(INTER_AREA), because warpAffine has no area filter and aliases grass texture into shimmer. A 1:1 warpAffine then cuts the final rotated window. Output frames are piped to ffmpeg/libx264.

5.5 Set pieces: a director, not a wider lens

Reviewing the goal, our own team spotted that the first version never showed the corner being taken, or the shot from outside the box. The camera held on the crowd in the goalmouth. The data showed why. The taker stood on the near corner arc at (100.0, −1.2), 33–37 m from the aim point, which the 30 m frame can never contain. The shooter also stood apart from the crowd.

6. AWS deployment

ComponentChoiceWhy
ComputeEC2 m7i-flex.large (2 vCPU, 8 GB), eu-north-1Three 4K decoders need memory headroom. It is the largest free-plan instance type, and our project's Region is fixed to eu-north-1.
OS / imageUbuntu 24.04, Canonical AMI via SSM parameter, 30 GB gp3Reproducible AMI lookup.
ServicePython stdlib HTTP server under systemd, behind nginxNo framework dependency. nginx serves the MP4s directly.
SecurityPort 80 open. SSH only from one admin IP, with an ED25519 key. IMDSv2 required.Minimum exposure for a public demo.
Abuse limitsWindow bounded to the clip, duration 10–30 s, at most 5 queued jobs, one render at a time, 1 KB request bodiesA public render button must not be a free CPU farm.
Observability/api/health (OpenCV version, queue length). Each job's timing and log are appended to jobs.jsonl. systemd journal.

The detector does not run on AWS in this entry. It needs a GPU, and the free plan does not offer GPU instances. The boundary is deliberate: the tracker's output is a small CSV in metres, and everything downstream of it is CPU-only OpenCV work, well suited to cheap cloud instances.

7. Evaluation

7.1 Does the camera point at the ball?

The virtual camera never sees the ball; it aims at players. To test whether that is good enough, we used an independent ball path. Our rig ball extractor, built for possession estimates, found the ball on 4,899 ticks of this match, mostly on the centre lens. For each of the 4,889 ticks where every rule had an aim point, we measured the distance from aim point to ball (eval/aim_vs_ball.py):

Aim ruleMedian distance90th percentileBall inside the 30 m frame (≤15 m)
Density peak (this entry)14.9 m34.2 m50.2%
Centroid of all players17.3 m30.2 m39.3%
Fixed shot at the centre spot22.0 m37.9 m28.3%

These figures measure the base aim rule; the corner director (5.5) overrides it for about 5 s per corner and is evaluated separately in 7.3. The density peak puts the ball inside the frame 28% more often than the centroid, and it beats the fixed shot by 1.8×. Its tail is worse than the centroid's (90th percentile 34.2 m against 30.2 m). When it commits to the wrong cluster, such as a crowded box while the ball is switched to the far wing, it is further wrong than an average would be. That is the main failure mode, discussed below.

This is aim-point distance, not what the viewer finally sees. The rendered camera is damped and its crop is wider than 30 m on near-side play, so the share of frames with the ball visible is higher than these figures. The ball path is itself a model output: it was spot-checked by eye (about 88 of 96 audit crops real) and is biased towards the centre lens.

7.2 Ground truth for the clip

The official match record (the Matchday app, entered live by a match official) logs Plateau's second goal, Ekundayo Ojo (#10) from a corner in first-half stoppage time (45+3'). On the session clock that is about 2,793 s. The render covering 2,740–2,820 s shows the attack in Kano's penalty area and then the celebration at the bench in that window. The camera cut to the right-hand lens for the goalmouth on its own, cut to the corner taker at the near flag about 4 s before the delivery, and cut back to the box for the finish.

7.3 Corner detection against the official record

We ran the corner director's detector over the whole match and compared it with the official's record, which logs Plateau's corners only:

7.4 Speed

WhereRenderThroughput
Laptop (Windows, OpenCV 5)20 s and 80 s windows45–53 fps (2–2.1× real time)
EC2 m7i-flex.large (2 vCPU)3 min (2640–2820 s), 4,500 output frames13.6 fps (0.54× real time): 5 min 39 s wall clock, 196% CPU, 478 MB peak memory, with the corner director and final-third widening on

The render is CPU-bound on decoding the three 4K streams. Only the lens on air is fully decoded, but every lens must be grab()bed to stay in sync. A full 90-minute match on this instance would take about 2 h 40 min; on a 16-vCPU compute instance, or rendering halves in parallel, it would take well under real time. The free plan limits us to 2 vCPUs.

7.5 Failure cases we observed

8. Limitations

9. Responsible use

10. Reproducing

The repository's README.md has pinned dependencies, the local run, the EC2 deployment steps (with the exact systemd unit and nginx site in deploy/), and curl tests for each endpoint. The evaluation is python eval/aim_vs_ball.py --log … --ball … --out eval/aim_vs_ball.json, and its output is committed.