AI & Computer Vision, lesson 33 of 52

Homography: From Camera View to Pitch Map

advanced24 min read

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.findHomography accepts 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_course environment 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 img

Cell 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

ErrorCauseFix
Pitch looks like a bow-tiePoints clicked in a different order from the real positionsMatch the order in LANDMARKS. Use u to undo.
Most players are off the pitchBad clicks, wrong measurements, or a camera that movedRecalibrate. Check with the sanity test.
Near and far disagreeLandmarks too close togetherPick corners far apart
Ball in the air lands far awayThe homography is for the ground planeUse ground positions only, or accept the error
findHomography returns NoneFewer than four points or three on a lineAdd points; spread them out
Numbers differ between runsSlightly different clicksSave calibration.json, reuse it
Units confusionMixing pixels and metres in one fileName the unit in the file (units: metres)
Picture is wide-angle and curvedLens distortion; a homography assumes a straight pictureUse 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

  1. Why can't you measure speed in pixels?

  2. How many points does a homography need?

  3. What kind of surface does it work on?

  4. Why map the feet, not the head?

  5. 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.