Showing posts with label Parking. Show all posts
Showing posts with label Parking. Show all posts

Saturday, October 31, 2026

Parking Space Counter with Python and YOLO: From Detections to Occupancy

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.

Actual parking result at application threshold 0.20: six calibrated spaces, four occupied and two free, with accepted detection boxes and centers
Red regions are occupied and green regions free according to the rule. Yellow rectangles and points show accepted detector boxes and their centers. The regions approximate perspective bays rather than tracing their painted outlines.

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

  1. Load one explicit local image, checkpoint and region calibration.
  2. Run YOLOv8n once with a candidate confidence floor of 0.10.
  3. Keep vehicle classes: car, truck and bus.
  4. Filter those fixed candidates using the parking acceptance threshold.
  5. Assign accepted box centers to the calibrated rectangles.
  6. 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]}
  ]
}
Six calibrated non-overlapping rectangular regions labelled A1 A2 A3 and B1 B2 B3 on the unchanged synthetic parking input
These coordinates belong to this image and viewpoint. Equal dimensions alone do not make them valid for another camera.

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.

Crop of B1 at threshold 0.20 showing the real accepted vehicle box, center and score about 0.22
Inspecting one candidate explains the changed decision. The center coordinates refer to the original full image, not this crop's coordinate system.
Actual result at application threshold 0.30 on the identical image: B1 changes to free although the vehicle remains visible
Same pixels, same calibration and same detector candidate set. Only the application acceptance threshold changed.

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.