AI & Computer Vision, lesson 33 of 52
Homography: From Camera View to Pitch Map
Introduction
A player near the camera covers 100 pixels in a step; one far away covers 10. So you can't measure distance or speed in pixels. Today you'll fix that with a homography: a 3 by 3 grid of numbers, worked out from four known points, that stretches the camera's slanted view of the pitch into a flat, top-down map in metres.
By the end of this lesson you'll be able to:
- Explain why pixel distances are wrong when the camera looks at an angle
- Define a homography as a way to flatten a plane using four known points
- Calibrate: click four landmarks and give their real positions in metres
- Convert detections to pitch coordinates and draw a top-down view
- Measure how clicking errors become metre errors, and sanity-check a calibration
Working principle
The problem
A far player looks small and a near player looks big. Ten metres is 44 pixels at the far end of the pitch, but 88 pixels at the near end (see Cell 2). Speed, distance and zones measured in pixels are therefore wrong. We need a map where one unit means the same thing everywhere.
What a homography is
A homography is a 3 by 3 matrix H that maps points on one flat plane (the picture) to points on another (the pitch). OpenCV works it out from four or more matching points: where they are in the picture, and where they really are in metres.
From the camera the far side looks shorter. The far player moves fewer pixels than the near one, even at the same speed.
pixel (x, y) -> H -> metre (X, Y)You don't need to do the matrix maths by hand. You need to understand the inputs and what can go wrong.
It only works for a flat plane
The pitch is flat; a player who jumps is not. A homography maps things on the ground. This is why we use the feet (lesson 30, lesson 32). The ball in the air will be mapped to the wrong place, a point worth saying aloud.
Choosing landmarks
- Use points whose real positions you know: pitch corners, the penalty box corners, the centre spot, or four cones you measured.
- Spread them out. Four points bunched in a corner give a bad map elsewhere.
- Click carefully. A few pixels of error grow into around a metre (Cell 2).
- More than four points, then averaged, is more accurate.
cv2.findHomographyaccepts as many as you give it.
Click order
The picture points and the real points must be in the same order. Mixing them gives a mirrored or twisted map. If your pitch looks like a bow-tie, check the order first.
Check your work
Sanity checks beat trust:
- The reprojection error: do the four corners land on where they should?
- What fraction of players land on the pitch? Over 90% for a good calibration.
- Walk a known 10 m in the video. Does the map say 10 m?
Camera and calibration move together
If the camera moves a little, the map is wrong. Calibrate every time the camera is set up (lesson 28 field notes), keep the frame you calibrated on, and save calibration.json with each recording.
What you'll need
- Your
cv_courseenvironment with Ultralytics - A still frame and a clip from a fixed camera that shows four known pitch points (corners, box corners or line crossings)
- The real sizes of your pitch
Build it
Cell 1: pitch_map.py (the maths in four functions)
"""Homography: map picture pixels onto a flat, top-down pitch measured in metres."""
import cv2
import numpy as np
PITCH_W, PITCH_H = 105.0, 68.0 # metres. Change these for a school pitch.
def make_homography(image_pts, pitch_pts):
"""image_pts: 4+ pixel points. pitch_pts: the SAME corners in metres, same order."""
src = np.array(image_pts, np.float32)
dst = np.array(pitch_pts, np.float32)
H, _ = cv2.findHomography(src, dst)
return H
def to_pitch(H, x, y):
"""One pixel point -> metres."""
v = H @ np.array([x, y, 1.0])
return float(v[0] / v[2]), float(v[1] / v[2])
def to_pitch_many(H, points):
pts = np.array(points, np.float32).reshape(-1, 1, 2)
return cv2.perspectiveTransform(pts, H).reshape(-1, 2)
def reprojection_error(H, image_pts, pitch_pts):
"""Average distance (metres) between where the corners land and where they should be."""
got = to_pitch_many(H, image_pts)
return float(np.linalg.norm(got - np.array(pitch_pts), axis=1).mean())
def draw_topdown(points_m, scale=8, margin=20):
"""A small top-down picture: a green pitch with a dot per player."""
w, h = int(PITCH_W * scale) + 2 * margin, int(PITCH_H * scale) + 2 * margin
img = np.full((h, w, 3), (50, 120, 50), np.uint8)
cv2.rectangle(img, (margin, margin), (w - margin, h - margin), (255, 255, 255), 2)
cv2.line(img, (w // 2, margin), (w // 2, h - margin), (255, 255, 255), 2)
cv2.circle(img, (w // 2, h // 2), int(9.15 * scale), (255, 255, 255), 2)
for (mx, my) in points_m:
cv2.circle(img, (int(margin + mx * scale), int(margin + my * scale)), 5, (0, 0, 255), -1)
return imgCell 2: test it
import numpy as np
import cv2
from pitch_map import make_homography, to_pitch, to_pitch_many, reprojection_error, draw_topdown, PITCH_W, PITCH_H
pitch = [(0, 0), (PITCH_W, 0), (PITCH_W, PITCH_H), (0, PITCH_H)]
# Pretend camera: the far touchline looks short, the near one long (a trapezoid).
image = [(420, 150), (860, 150), (1180, 620), (100, 620)]
H = make_homography(image, pitch)
for name, img_pt, want in [
("far-left corner", (420, 150), (0, 0)),
("near-right corner", (1180, 620), (PITCH_W, PITCH_H)),
]:
got = to_pitch(H, *img_pt)
ok = np.allclose(got, want, atol=1e-3)
print("PASS" if ok else "FAIL", name, np.round(got, 2))
# Pixel distance lies: the same 10 m is fewer pixels far away than near.
far_px = to_pitch_many(np.linalg.inv(H), [(50, 5), (60, 5)])
near_px = to_pitch_many(np.linalg.inv(H), [(50, 60), (60, 60)])
far_len = np.linalg.norm(far_px[0] - far_px[1]); near_len = np.linalg.norm(near_px[0] - near_px[1])
print("PASS" if far_len < near_len else "FAIL", f"10 m is {far_len:.0f} px far away, {near_len:.0f} px near")
# Pitch centre round trip
centre_px = to_pitch_many(np.linalg.inv(H), [(PITCH_W / 2, PITCH_H / 2)])[0]
back = to_pitch(H, *centre_px)
print("PASS" if np.allclose(back, (PITCH_W / 2, PITCH_H / 2), atol=1e-3) else "FAIL", "round trip", np.round(centre_px, 1))
print("reprojection error (m):", round(reprojection_error(H, image, pitch), 6))
# Noise: click each corner 5 px wrong and see the error in metres at the pitch centre
rng = np.random.default_rng(1)
errs = []
for _ in range(200):
noisy = np.array(image, float) + rng.normal(0, 5, (4, 2))
Hn = make_homography(noisy, pitch)
errs.append(np.linalg.norm(np.array(to_pitch(Hn, *centre_px)) - (PITCH_W / 2, PITCH_H / 2)))
print(f"5 px clicking noise -> typical error {np.mean(errs):.2f} m at the centre")
img = draw_topdown([(10, 10), (52, 34)])
print("topdown image", img.shape)Look at three lines of the output: the corners land exactly where they should, 10 m is about half as many pixels far away as near, and 5 pixels of clicking error becomes nearly a metre at the centre. Corner clicking is the weak point.
Cell 3: calibrate on your own frame
Run python calibrate.py frame.jpg. Click the four corners in the order shown on screen, press s. Edit LANDMARKS for your pitch: use a measured rectangle (a penalty box, or four cones you placed) if the full pitch corners are not in view.
"""Click the 4 pitch corners (or any 4+ known points) on a frame; save them with their real positions."""
import json
import sys
import cv2
from pitch_map import PITCH_H, PITCH_W
# The order you will click, and where each point really is, in metres. Edit for your pitch.
LANDMARKS = [("far-left corner", (0, 0)), ("far-right corner", (PITCH_W, 0)),
("near-right corner", (PITCH_W, PITCH_H)), ("near-left corner", (0, PITCH_H))]
def main(image_path):
frame = cv2.imread(image_path)
clicks = []
def on_mouse(event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN and len(clicks) < len(LANDMARKS):
clicks.append([x, y])
cv2.namedWindow("calibrate")
cv2.setMouseCallback("calibrate", on_mouse)
while True:
view = frame.copy()
for i, p in enumerate(clicks):
cv2.circle(view, tuple(p), 6, (0, 0, 255), -1)
cv2.putText(view, LANDMARKS[i][0], (p[0] + 8, p[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
nxt = LANDMARKS[len(clicks)][0] if len(clicks) < len(LANDMARKS) else "all done: press s to save"
cv2.putText(view, "click: " + nxt, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
cv2.imshow("calibrate", view)
k = cv2.waitKey(30) & 0xFF
if k == ord("u") and clicks:
clicks.pop()
if k == ord("s") and len(clicks) == len(LANDMARKS):
json.dump({"image": clicks, "pitch": [list(p) for _, p in LANDMARKS]}, open("calibration.json", "w"), indent=2)
print("saved calibration.json")
if k == ord("q"):
break
cv2.destroyAllWindows()
if __name__ == "__main__":
main(sys.argv[1])Cell 4: convert lesson 30's pixels to metres
"""Convert detections.json (pixels, lesson 30) into detections_pitch.json (metres)."""
import json
from pitch_map import make_homography, to_pitch_many
def convert(det_path="detections.json", cal_path="calibration.json", out_path="detections_pitch.json"):
det = json.load(open(det_path))
cal = json.load(open(cal_path))
H = make_homography(cal["image"], cal["pitch"])
out = {"units": "metres", "fps": det["fps"], "frames": []}
for fr in det["frames"]:
players = []
if fr["players"]:
pts = to_pitch_many(H, [(p["x"], p["y"]) for p in fr["players"]])
players = [{"id": p["id"], "x": round(float(x), 2), "y": round(float(y), 2)}
for p, (x, y) in zip(fr["players"], pts)]
ball = None
if fr["ball"]:
bx, by = to_pitch_many(H, [(fr["ball"]["x"], fr["ball"]["y"])])[0]
ball = {"x": round(float(bx), 2), "y": round(float(by), 2)}
out["frames"].append({"frame": fr["frame"], "t": fr["t"], "players": players, "ball": ball})
json.dump(out, open(out_path, "w"))
return out
def fraction_on_pitch(out, w=105.0, h=68.0, slack=2.0):
"""Sanity check: most players should land on the pitch (a little slack for the touchline)."""
pts = [p for f in out["frames"] for p in f["players"]]
on = [p for p in pts if -slack <= p["x"] <= w + slack and -slack <= p["y"] <= h + slack]
return len(on) / len(pts) if pts else 0.0
if __name__ == "__main__":
out = convert()
print("frames:", len(out["frames"]))
print("first player, frame 0:", out["frames"][0]["players"][:1])
frac = fraction_on_pitch(out)
print(f"{frac:.0%} of player positions are on the pitch")
if frac < 0.9:
print("WARNING: many points fall outside the pitch. Check the calibration clicks and their order.")With the demo data and the example corners from Cell 2, you should see about 71% on the pitch and a warning. That is expected: the demo players are scattered over the whole picture, and the example corners are made up. A real calibration on a real video should give over 90%. The warning is the point: always check your calibration with a sanity test.
Cell 5: look at it from above
import json, cv2
from pitch_map import draw_topdown
d = json.load(open("detections_pitch.json"))
frame = d["frames"][100]
img = draw_topdown([(p["x"], p["y"]) for p in frame["players"]])
cv2.imwrite("topdown.png", img)Open topdown.png. This is the picture a coach wants, and lesson 41, lesson 45 and lesson 46 are built on it.
Questions: which points should you click for the best accuracy, near together or far apart? What happens to a player who jumps? (Hint: the feet leave the ground plane.) Can you use a goalpost top as a landmark?
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| Pitch looks like a bow-tie | Points clicked in a different order from the real positions | Match the order in LANDMARKS. Use u to undo. |
| Most players are off the pitch | Bad clicks, wrong measurements, or a camera that moved | Recalibrate. Check with the sanity test. |
| Near and far disagree | Landmarks too close together | Pick corners far apart |
| Ball in the air lands far away | The homography is for the ground plane | Use ground positions only, or accept the error |
findHomography returns None | Fewer than four points or three on a line | Add points; spread them out |
| Numbers differ between runs | Slightly different clicks | Save calibration.json, reuse it |
| Units confusion | Mixing pixels and metres in one file | Name the unit in the file (units: metres) |
| Picture is wide-angle and curved | Lens distortion; a homography assumes a straight picture | Use a less wide setting, or undistort first (an extension) |
Try it
- Use eight points and a RANSAC option; compare errors
- Draw a person's path on the top-down view across 100 frames
- Compare clicked and measured distances on the pitch
Check what you learnt
Why can't you measure speed in pixels?
How many points does a homography need?
What kind of surface does it work on?
Why map the feet, not the head?
How do you know the calibration is good?
Conclusion
A homography maps one flat plane to another. Pick at least four matching points between the camera image and a pitch diagram, let OpenCV work out the 3 by 3 matrix, and map each player's feet onto the pitch. Now you can draw a top-down map and measure in metres, as long as the camera stays put and you sanity-check it.
What next?
Next: Python ↔ JS bridge: Flask API. You'll connect Python and JavaScript properly, with an API.

