Finding vehicles is only half of a parking-space counter. The application must also decide which detected vehicle belongs to which monitored space.
In this Python and YOLO example, six calibrated regions produce four occupied and two free statuses. Raising one application threshold changes the result to three occupied and three free, even though the image has not changed. Following that change back to one detection makes the rule much easier to understand.
We use a synthetic 1024 × 1024 course scene and a local Windows/CPU run. This is a static-image demonstration, not a live-camera reliability or accuracy benchmark. Other partial vehicles appear outside the six monitored regions; the reported total is about those six spaces, not every vehicle in the image.
Watch the Parking Lot Monitor walkthrough
Watch the complete Python and YOLO tutorial, or see the quick parking-space demonstration.

The instructional figures include extra yellow detection boxes and centers to make the decisions inspectable. The minimal script below saves the colored region-and-ID overlays and prints the assignment rows; it does not add those extra yellow annotations to its saved images.
The pipeline: image, detections, regions, decisions
- Load one explicit local image, checkpoint and region calibration.
- Run YOLOv8n once with a candidate confidence floor of 0.10.
- Keep vehicle classes: car, truck and bus.
- Filter those fixed candidates using the parking acceptance threshold.
- Assign accepted box centers to the calibrated rectangles.
- Count the resulting occupied and free rows.
YOLO supplies predictions; our Python rule supplies the occupancy decision. A free row means that no accepted candidate center matched that region. It does not independently verify that the physical space is empty.
Prepare the local inputs
Save the complete script below as parking.py. Place yolov8n.pt, parking_lot_sample.png and parking_zones.json beside it. The demonstrated input and its calibration are included in lesson 50 of my course. You can instead use your own permitted image, but you must calibrate regions for that image.
The tested checkpoint matches the official YOLOv8n release asset, SHA-256 f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36. Obtain that file as a separate setup step and review the model license and Ultralytics licensing options. The script checks that the required local files exist before loading the model; it does not download missing inputs.
For Windows with Python 3.13 installed:
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install ultralytics==8.4.24 numpy==2.3.5 opencv-python==4.13.0.92
.venv\Scripts\python.exe parking.py
The measured environment used Python 3.13.9, Ultralytics 8.4.24, PyTorch 2.10.0, NumPy 2.3.5 and OpenCV 4.13.0.92. The installation command pins three direct dependencies, not the complete transitive environment. This is not a fresh-install claim for every operating system. Installation and obtaining the model may need internet access; the demonstrated processing runs offline with local files.
Calibrate regions in the input image's coordinates
Here is the demonstrated calibration. Save it as parking_zones.json. Coordinates are [left, top, right, bottom] in the original 1024 × 1024 image:
{
"image_size": [1024, 1024],
"zones": [
{"spot_id": "A1", "rect": [146, 55, 399, 337]},
{"spot_id": "A2", "rect": [402, 55, 649, 337]},
{"spot_id": "A3", "rect": [651, 55, 856, 337]},
{"spot_id": "B1", "rect": [72, 570, 373, 978]},
{"spot_id": "B2", "rect": [375, 570, 696, 978]},
{"spot_id": "B3", "rect": [698, 570, 959, 978]}
]
}

load_zones rejects a calibration with different image dimensions, an empty region list, duplicate identifiers, invalid rectangles, or overlapping regions. Those checks catch structural mistakes. They do not prove that a rectangle corresponds to the correct physical bay. Inspect the overlay, especially after changing the image or viewpoint.
Complete runnable parking-space counter
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Assign local YOLO candidates to calibrated parking spaces; no asset downloads."""
from pathlib import Path
import json
import sys
import cv2
import numpy as np
from ultralytics import YOLO
def load_zones(path, shape):
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if payload["image_size"] != [shape[1], shape[0]]:
raise ValueError("Zone calibration dimensions differ from the selected image.")
zones = payload["zones"]
if not zones or len({z["spot_id"] for z in zones}) != len(zones):
raise ValueError("Use nonempty zones with unique spot IDs.")
for i, zone in enumerate(zones):
x1, y1, x2, y2 = zone["rect"]
if not 0 <= x1 < x2 <= shape[1] or not 0 <= y1 < y2 <= shape[0]:
raise ValueError("Invalid or out-of-image zone.")
for other in zones[:i]:
a, b, c, d = other["rect"]
if max(x1, a) < min(x2, c) and max(y1, b) < min(y2, d):
raise ValueError("Overlapping zones could assign one center twice.")
return zones
def detect_candidates(model, image):
result = model.predict(
source=image, conf=0.10, imgsz=640, device="cpu", iou=0.70,
max_det=300, rect=True, half=False, classes=None,
augment=False, agnostic_nms=False, verbose=False,
)[0]
detections = []
for box in result.boxes:
label = result.names[int(box.cls.item())]
if label not in {"car", "truck", "bus"}:
continue
x1, y1, x2, y2 = box.xyxy[0].cpu().tolist()
detections.append(dict(
label=label, confidence=float(box.conf.item()), box=[x1, y1, x2, y2],
center=[(x1 + x2) / 2, (y1 + y2) / 2],
))
return detections
def assign_spots(detections, zones, confidence=0.20):
accepted = [d for d in detections if d["confidence"] >= confidence]
rows = []
for zone in zones:
x1, y1, x2, y2 = zone["rect"]
matches = [d for d in accepted
if x1 <= d["center"][0] < x2 and y1 <= d["center"][1] < y2]
best = max(matches, key=lambda d: d["confidence"]) if matches else None
rows.append(dict(spot_id=zone["spot_id"], status="occupied" if best else "free",
confidence=round(best["confidence"], 3) if best else None,
center=best["center"] if best else None))
return rows
def draw_status(image, zones, rows):
canvas = image.copy()
states = {row["spot_id"]: row["status"] for row in rows}
for zone in zones:
x1, y1, x2, y2 = zone["rect"]
color = (70, 170, 70) if states[zone["spot_id"]] == "free" else (50, 70, 225)
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 4)
cv2.putText(canvas, zone["spot_id"], (x1 + 8, y1 + 32),
cv2.FONT_HERSHEY_SIMPLEX, 1, color, 3)
return canvas
def main():
root = Path(__file__).resolve().parent
image_path = Path(sys.argv[1]) if len(sys.argv) > 1 else root / "parking_lot_sample.png"
zones_path = Path(sys.argv[2]) if len(sys.argv) > 2 else root / "parking_zones.json"
weights = root / "yolov8n.pt"
for path in (image_path, zones_path, weights):
if not path.is_file():
raise FileNotFoundError(f"Required local file: {path}")
image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
if image is None:
raise ValueError(f"Cannot decode image: {image_path}")
zones = load_zones(zones_path, image.shape)
detections = detect_candidates(YOLO(str(weights)), image)
output = root / "output"
output.mkdir(exist_ok=True)
for confidence in (0.20, 0.30):
rows = assign_spots(detections, zones, confidence)
occupied = sum(row["status"] == "occupied" for row in rows)
print(f"threshold={confidence:.2f}: {occupied} occupied, {len(rows)-occupied} free")
for row in rows:
print(f" {row['spot_id']}: {row['status']} (score={row['confidence']})")
if not cv2.imwrite(str(output / f"parking_{confidence:.2f}.png"),
draw_status(image, zones, rows)):
raise OSError("Could not save parking overlay.")
if __name__ == "__main__":
main()
Complete sample source above: copyright © 2026 ARTSCI — Author of the AI/ML/CV course materials, licensed under GNU AGPL version 3, without warranty. Keep applicable notices and provide corresponding source when sharing covered modifications. Ultralytics software and weights retain their own terms. Independent course images, narration, slides and videos are not automatically licensed under AGPL.
The script's default paths are relative to the saved Python file. To select your own image and its matching calibration, pass both paths:
.venv\Scripts\python.exe parking.py "C:\images\my_parking.png" "C:\images\my_zones.json"
Keep detector inference separate from the parking threshold
detect_candidates runs prediction once, with conf=0.10, imgsz=640, CPU and the other shown settings fixed. In this execution it returns eight vehicle candidates. Some are outside the monitored spaces, and two predictions can describe the same region. A candidate count is not a parking-space count.
The loop in main compares application thresholds 0.20 and 0.30 by filtering that same post-NMS candidate set. It does not rerun YOLO or non-maximum suppression. This controlled comparison keeps the image, model and underlying predictions unchanged. The official prediction documentation describes confidence and overlap-suppression settings; our additional acceptance threshold belongs to the application rule.
Assign the center, then count the space
For each box, the center is ((x1+x2)/2, (y1+y2)/2). assign_spots tests that center against each region, including left/top boundaries and excluding right/bottom boundaries. With non-overlapping regions, a point cannot belong to two spaces. If several accepted centers match one space, the rule keeps the highest-scoring match and still counts only one occupied row.
This is a center-in-rectangle rule, not an intersection-over-union or segmentation calculation. A long, partly occluded or incorrectly localized vehicle may not behave as expected. Test the geometry and the detector output separately.
Inspect the actual output
threshold=0.20: 4 occupied, 2 free
A1: occupied (score=0.771)
A2: occupied (score=0.428)
A3: free (score=None)
B1: occupied (score=0.222)
B2: occupied (score=0.501)
B3: free (score=None)
threshold=0.30: 3 occupied, 3 free
A1: occupied (score=0.771)
A2: occupied (score=0.428)
A3: free (score=None)
B1: free (score=None)
B2: occupied (score=0.501)
B3: free (score=None)
At 0.20, A1, A2, B1 and B2 are occupied; A3 and B3 are free according to the rule. Seven candidate detections pass that threshold, but only four monitored spaces receive a match. The detector labels the red vehicle in B2 as bus with score about 0.501. The application accepts that class as a vehicle, so a useful occupancy result here does not establish perfect vehicle classification.
Why did B1 become free when the car stayed?
The B1 candidate has score approximately 0.22236 and center (229.74, 740.91). It passes 0.20, but fails 0.30. B1 therefore loses its accepted center and changes to free. Five candidates pass the higher threshold, while only three monitored spaces receive matches.


A stricter threshold can remove mistakes, but it can also miss occupied spaces. A confidence score is not calibrated certainty, and a higher threshold does not automatically make a counter more accurate.
What to verify before adapting the example
Use permitted inputs, recalibrate the regions, and inspect each selected vehicle center alongside the assignment table. Test weak detections, occlusion, shadows and boundaries. Check representative labelled examples before relying on physical occupancy claims. This single synthetic scene does not measure field accuracy or live-camera performance.
For related projects, see how to read YOLO detection outputs and why track IDs are not vehicle counts. My English course, Computer Vision: OpenCV, Machine Learning and YOLO, develops the full Parking Lot Monitor application alongside other practical projects.