AI & Computer Vision, lesson 32 of 52

Zone Detection & Spatial Logic

advanced24 min read

Introduction

Lots of football questions are really questions about places: is anyone in the penalty box? How long did the defence spend in its own half? Is that attacker past the last defender? Today you'll draw zones on the pitch, teach the computer to tell whether a point is inside a shape, and use that to count, alert and even make a rough offside call.

By the end of this lesson you'll be able to:

  • Represent a zone as a polygon (a list of corner points)
  • Explain the ray casting idea behind the point-in-polygon test
  • Draw zones on a frame, save them to a file and load them back
  • Use zones in a trigger and in a statistic (time in zone)
  • Explain why a simple offside rule is only an approximation

Working principle

Why a rectangle isn't enough

In lesson 31 the goal was a rectangle x, y, w, h. Real zones are not rectangles in the picture: the camera looks at an angle, so a rectangular goal becomes a trapezoid. A polygon (a list of corners) can describe any shape, including the L-shaped ones that appear when two areas overlap.

The point-in-polygon test (ray casting)

For one point, look at every edge of the polygon. Ask: does the edge cross the horizontal line through my point, and is the crossing to my right? Count the crossings. Odd means inside.

The ray crosses 1 edge: odd, so the point is inside the zone.

Two details make it work: the test (y1 > y) != (y2 > y) handles the corners without double counting, and the same code works for concave shapes (the notch of an L).

Which point of the player?

A bounding box is a rectangle around a player, but a position is a point. Use the feet (bottom middle of the box). The feet touch the pitch; the box centre floats in the air. This was fixed in the lesson 30 data contract: x, y is the feet.

Zones can overlap

The penalty box is also part of the left half. Return every zone that contains the point, not just the first.

Using zones

UseExample
TriggerBall in the goal zone (lesson 31)
StatisticSeconds each player spends in each zone
RuleA player in the penalty box when the ball arrives
Heatmap (lesson 41)Which cells of a grid are busiest

The offside line (simplified)

The line sits at the second-last defender (the keeper counts as one). An attacker is offside if they are ahead of the line and ahead of the ball. Our version works along the x-axis only. It does not handle the arms rule, whether the player is involved in play, or camera perspective. The lesson uses it as a thinking exercise, not a referee. lesson 33 fixes the perspective problem.

Zones live in pixels, for now

Everything today uses picture coordinates. If the camera moves, the zones are wrong. A tripod, and a note of where it stood (lesson 28), makes them stay right. lesson 33 moves everything onto a flat top-down pitch.

What you'll need

  • Your cv_course environment with Ultralytics
  • Your detector and a match clip

Build it

Cell 1: zones.py

"""Zones as polygons, and the question 'is this point inside?'"""
 
 
def point_in_polygon(x, y, poly):
    """Ray casting: shoot a ray to the right and count edge crossings.
    Odd number of crossings = inside."""
    inside = False
    n = len(poly)
    for i in range(n):
        x1, y1 = poly[i]
        x2, y2 = poly[(i + 1) % n]
        if (y1 > y) != (y2 > y):                       # the edge straddles our horizontal line
            x_cross = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
            if x < x_cross:
                inside = not inside
    return inside
 
 
class Zone:
    def __init__(self, name, points):
        self.name = name
        self.points = [tuple(p) for p in points]
 
    def contains(self, x, y):
        return point_in_polygon(x, y, self.points)
 
    def __repr__(self):
        return f"Zone({self.name!r}, {len(self.points)} points)"
 
 
def zones_containing(x, y, zones):
    """Every zone that holds the point. Zones may overlap."""
    return [z.name for z in zones if z.contains(x, y)]
 
 
def offside_line_x(defenders_x, attacking_right=True):
    """The line is set by the second-last defender (the keeper counts as one).
    Pass the x of every defending outfield player AND the keeper."""
    xs = sorted(defenders_x, reverse=attacking_right)
    return xs[1] if len(xs) > 1 else None
 
 
def is_offside(attacker_x, ball_x, line_x, attacking_right=True):
    """Simplified: ahead of both the ball and the line, in the attacking direction."""
    if line_x is None:
        return False
    if attacking_right:
        return attacker_x > line_x and attacker_x > ball_x
    return attacker_x < line_x and attacker_x < ball_x

Cell 2: test it

from zones import Zone, point_in_polygon, zones_containing, offside_line_x, is_offside
 
square = [(0, 0), (10, 0), (10, 10), (0, 10)]
L = [(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)]          # an L shape (concave)
tri = [(0, 0), (10, 0), (5, 8)]
tests = [
    ("square centre", point_in_polygon(5, 5, square), True),
    ("square outside", point_in_polygon(15, 5, square), False),
    ("L inside the long arm", point_in_polygon(2, 8, L), True),
    ("L in the notch (outside)", point_in_polygon(8, 8, L), False),
    ("triangle inside", point_in_polygon(5, 3, tri), True),
    ("triangle outside corner", point_in_polygon(1, 6, tri), False),
]
z = [Zone("box", square), Zone("small", [(4, 4), (6, 4), (6, 6), (4, 6)])]
tests += [
    ("overlap reports both", zones_containing(5, 5, z), ["box", "small"]),
    ("only big one", zones_containing(1, 1, z), ["box"]),
    ("none", zones_containing(50, 50, z), []),
]
line = offside_line_x([30, 55, 70, 90, 100], attacking_right=True)   # keeper at 100
tests += [
    ("offside line is second-last", line, 90),
    ("attacker 95, ball 60 -> offside", is_offside(95, 60, line), True),
    ("attacker 95, ball 98 -> onside", is_offside(95, 98, line), False),
    ("attacker 85 -> onside", is_offside(85, 60, line), False),
]
bad = 0
for name, got, want in tests:
    ok = got == want
    bad += not ok
    print("PASS" if ok else "FAIL", name, got)
print("failures:", bad)

All thirteen should print PASS. Draw a shape of your own on paper, pick a point, predict the answer, then add a test.

Cell 3: draw zones on a frame

Run python zone_tool.py frame.jpg (grab one frame from your own video, lesson 11). Click the corners, press n to finish a zone and name it, s to save.

"""Click to draw zones on a frame, save them to zones.json, and draw them back.
Keys: click = add a corner, n = finish this zone and name it, u = undo corner, s = save, q = quit."""
import json
import sys
 
import cv2
import numpy as np
 
 
def save_zones(zones, path="zones.json"):
    with open(path, "w") as f:
        json.dump({z["name"]: z["points"] for z in zones}, f, indent=2)
 
 
def load_zones(path="zones.json"):
    with open(path) as f:
        return [{"name": k, "points": [list(map(float, p)) for p in v]} for k, v in json.load(f).items()]
 
 
def draw_zones(frame, zones, colour=(0, 255, 255)):
    out = frame.copy()
    overlay = out.copy()
    for z in zones:
        pts = np.array(z["points"], np.int32)
        cv2.fillPoly(overlay, [pts], colour)
        cv2.polylines(out, [pts], True, colour, 2)
        cv2.putText(out, z["name"], tuple(pts[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.6, colour, 2)
    return cv2.addWeighted(overlay, 0.25, out, 0.75, 0)
 
 
def main(image_path):
    frame = cv2.imread(image_path)
    zones, current = [], []
 
    def on_mouse(event, x, y, flags, param):
        if event == cv2.EVENT_LBUTTONDOWN:
            current.append([x, y])
 
    cv2.namedWindow("zones")
    cv2.setMouseCallback("zones", on_mouse)
    while True:
        view = draw_zones(frame, zones)
        for p in current:
            cv2.circle(view, tuple(p), 4, (0, 0, 255), -1)
        if len(current) > 1:
            cv2.polylines(view, [np.array(current, np.int32)], False, (0, 0, 255), 1)
        cv2.imshow("zones", view)
        k = cv2.waitKey(30) & 0xFF
        if k == ord("u") and current:
            current.pop()
        elif k == ord("n") and len(current) >= 3:
            name = input("name for this zone: ").strip() or f"zone{len(zones) + 1}"
            zones.append({"name": name, "points": current.copy()})
            current.clear()
        elif k == ord("s"):
            save_zones(zones)
            print("saved zones.json")
        elif k == ord("q"):
            break
    cv2.destroyAllWindows()
 
 
if __name__ == "__main__":
    main(sys.argv[1])

Cell 4: who spends time where?

Uses detections.json from lesson 30 and the zones.json you saved.

"""Time spent in each zone, from detections.json (lesson 30). Player x,y is the feet."""
import json
from collections import defaultdict
 
from zone_tool import load_zones
from zones import Zone, zones_containing
 
 
def zone_times(detections_path="detections.json", zones_path="zones.json"):
    data = json.load(open(detections_path))
    zones = [Zone(z["name"], z["points"]) for z in load_zones(zones_path)]
    dt = 1.0 / data["fps"]
    seconds = defaultdict(float)                        # (player id, zone name) -> seconds
    for fr in data["frames"]:
        for p in fr["players"]:
            for name in zones_containing(p["x"], p["y"], zones):
                seconds[(p["id"], name)] += dt
    return seconds
 
 
if __name__ == "__main__":
    for (pid, zone), s in sorted(zone_times().items()):
        print(f"player {pid:>2} in {zone:<12} {s:5.1f} s")

Output looks like player 1 in left half 18.0 s. Which player is the most defensive? What would you need to know to be sure?

Cell 5: the slanted goal (lesson 31 with a polygon)

Only the inside method changes, which is what the event bus design made easy.

from goal_detector import GoalDetector
from zones import Zone
 
 
class PolygonGoalDetector(GoalDetector):
    """lesson 31's detector, with a polygon goal instead of a rectangle. Only 'inside' changes."""
 
    def __init__(self, polygon_zone, bus, **kw):
        super().__init__({"x": 0, "y": 0, "w": 0, "h": 0}, bus, **kw)
        self.polygon = polygon_zone
 
    def inside(self, ball):
        return ball is not None and self.polygon.contains(ball["x"], ball["y"])

Questions: where exactly does a point on the edge count? (It rarely matters; why?) What if the zone has a hole? What if two corners are clicked in the wrong order?

If it goes wrong

ErrorCauseFix
Zone looks like a bow-tieCorners clicked in a crossing orderClick around the edge in order. Use u to undo.
Everything is "outside"Zone saved for a different resolutionDraw and test on the same frame size
Player in the wrong zoneBox centre used instead of feetUse the bottom middle of the box
Click does nothingWindow doesn't have focus, or remote desktopClick the window first. Try a local session.
FileNotFoundError: zones.jsonNot saved yet, or wrong folderPress s, check the folder
ZeroDivisionError in the testEdge exactly horizontalThe (y1 > y) != (y2 > y) check skips those edges. Keep it.
Time in zones looks too bigOverlapping zones both countIntended. Sum only one zone, or change the design.
Offside calls look wrongPerspective: the pitch is not flat in the pictureWait for lesson 33

Try it

  • Add a distance_to_zone function
  • Draw the penalty box and compute possession by zone
  • Write the test that shows what happens on the edge

Check what you learnt

  1. How does the computer know a point is inside a polygon?

  2. Why test the player's feet, not the box centre?

  3. Why store zones in a file?

  4. Why is our offside check only approximate?

  5. What happens if the camera moves?

Conclusion

Zones turn positions into answers. Test the feet with point-in-polygon (count the crossings), keep zones in a JSON file, and count who's in each zone or who just entered. Spatial rules like offside are useful but approximate, and they break if the camera moves.

What next?

Next: From camera to pitch: perspective & homography. You'll map the camera picture onto a flat pitch with a homography.