AI & Computer Vision, lesson 32 of 52
Zone Detection & Spatial Logic
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
| Use | Example |
|---|---|
| Trigger | Ball in the goal zone (lesson 31) |
| Statistic | Seconds each player spends in each zone |
| Rule | A 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_courseenvironment 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_xCell 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
| Error | Cause | Fix |
|---|---|---|
| Zone looks like a bow-tie | Corners clicked in a crossing order | Click around the edge in order. Use u to undo. |
| Everything is "outside" | Zone saved for a different resolution | Draw and test on the same frame size |
| Player in the wrong zone | Box centre used instead of feet | Use the bottom middle of the box |
| Click does nothing | Window doesn't have focus, or remote desktop | Click the window first. Try a local session. |
FileNotFoundError: zones.json | Not saved yet, or wrong folder | Press s, check the folder |
ZeroDivisionError in the test | Edge exactly horizontal | The (y1 > y) != (y2 > y) check skips those edges. Keep it. |
| Time in zones looks too big | Overlapping zones both count | Intended. Sum only one zone, or change the design. |
| Offside calls look wrong | Perspective: the pitch is not flat in the picture | Wait for lesson 33 |
Try it
- Add a
distance_to_zonefunction - Draw the penalty box and compute possession by zone
- Write the test that shows what happens on the edge
Check what you learnt
How does the computer know a point is inside a polygon?
Why test the player's feet, not the box centre?
Why store zones in a file?
Why is our offside check only approximate?
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.

