Tuesday, October 27, 2026

Count Cars with YOLO: Why Track IDs Are Not Vehicle Counts

A box follows a car, a number appears above it, and a counter increases. It looks simple. But what exactly did the application count: detections, tracking identities, or vehicles passing a particular point? Those are different measurements.

In this Python example, YOLOv8n detects vehicles, ByteTrack associates observations across frames, and a separate rule accepts downward crossings of a horizontal line. Keeping those responsibilities separate makes the result easier to explain and test.

Traffic counter showing tracked vehicle boxes, temporary IDs and a horizontal counting segment
The demonstration uses a synthetic traffic scene. Boxes, IDs and crossing events come from the same recorded tracking run.

Three measurements that should stay separate

A detection describes an object in one frame. A track ID associates observations over time. A crossing event records an observation sequence that satisfies our counting rule.

One car can appear in many frames. A tracker may also lose a vehicle and assign a new ID when it is detected again. Counting every detection would repeatedly count visible objects; counting distinct IDs would measure the tracker's associations. Neither automatically tells us how many physical vehicles passed our chosen location.

In this particular 241-frame, 1280 × 720 clip, the recorded run contains 23 temporary IDs. Our selected rule accepts 7 crossing events. This is a result for one clip and configuration, not an accuracy claim for general traffic footage.

Define a crossing before calculating a total

Image coordinates start at the top left: y increases downward. We place the line at line_y = 413 and restrict its horizontal span to x_range = (80, 1000). A qualifying center must move from above the line to the line or below it. Its current x must fall within that span.

The complete rule below remembers the previous y for each ID. It also remembers IDs already counted during this function call.

# Turn tracked positions into one downward pass per ID.
def count_passes(frame_tracks, line_y, x_range):
    previous_y, counted_ids, events = {}, set(), []
    for frame in frame_tracks:
        for obj in frame["objects"]:
            track_id = obj["track_id"]
            x, y = obj["center"]
            crossed = previous_y.get(track_id, y) < line_y <= y
            if crossed and x_range[0] <= x <= x_range[1] and track_id not in counted_ids:
                counted_ids.add(track_id)
                events.append({"frame": frame["frame_idx"], "id": track_id, "class": obj["class_name"]})
            previous_y[track_id] = y
    return events

Code: Copyright © 2026 ARTSCI — Author of the AI/ML/CV course materials. Licensed under the GNU AGPL version 3, without warranty. This code notice does not automatically apply to the independent images, video or course presentation.

The decisive comparison is previous_y.get(track_id, y) < line_y <= y. For a first observation, the default previous value is the current y, so the comparison is false. An object first detected below the line therefore does not produce an invented crossing.

counted_ids prevents a second accepted event for the same ID in one call. The function updates the previous y after examining every observation, even when the object is outside the horizontal span. It does not interpolate where the center crossed the line: the span check uses the current x.

Explain one event with pictures and numbers

For ID 3, the recorded center moves from y = 411.236 in frame 36 to y = 414.239 in frame 37. The rule accepts frame 37 because 411.236 < 413 <= 414.239. Its current x is approximately 474.9, inside the permitted span. ID 3 has not yet been counted.

Before, crossing and after observations for track ID 3 in frames 36, 37 and 38
The same ID before, at and after its accepted event. Coordinates are rounded for display; the rule uses the original values.

This evidence is more informative than a final total alone. You can inspect the visible vehicle, the relevant center positions and the exact decision together.

Equal totals can hide different events

Move only the line upward by 30 pixels, from y = 413 to y = 383. Reuse the same recorded tracks and horizontal span. The result is again 7 events, but the accepted IDs differ:

LineEventsAccepted IDs
y = 41374, 3, 2, 5, 9, 7, 55
y = 38373, 2, 5, 9, 7, 55, 48

ID 4 is first observed at y ≈ 398.5, already below the shifted line. Its observations support a crossing of y = 413, but not y = 383. ID 48 supplies an event only at the shifted line. Comparing event membership exposes what the matching totals hide.

Check logic separately from tracking quality

Run these checks after defining count_passes above. Their deliberately chosen center positions isolate Python logic from model quality. The three assertions test repeated downward crossings by one ID, an ID first seen below the line, and upward movement. All x positions are inside the counting segment.

def sample(y_values):
    return [{"frame_idx": i, "objects": [{"track_id": 1,
             "class_name": "car", "center": (200, y)}]}
            for i, y in enumerate(y_values)]

assert len(count_passes(sample([411, 413, 410, 416]), 413, (80, 1000))) == 1
assert len(count_passes(sample([414, 420]), 413, (80, 1000))) == 0
assert len(count_passes(sample([420, 410]), 413, (80, 1000))) == 0
print("All three counting-rule checks passed.")

The expected counts are 1, 0 and 0. After these checks pass, inspect a short video interval manually and match individual passages to event rows.

This compact rule has limits. A changed ID can allow the same vehicle to count again. Missing observations may leave a long gap between the two positions used by the comparison. There is no gap limit or hysteresis band here, and motion upward is intentionally excluded. Real deployment needs representative footage and separate checks for missed passages and false counts.

For the tracking API and tracker configuration, see the official Ultralytics tracking documentation. This example explicitly selects bytetrack.yaml; it does not rely on whichever tracker a library version uses by default.

If you want to build the full project, including the notebook, local application and practical exercises, see my English course: Computer Vision: OpenCV, Machine Learning and YOLO.

No comments:

Post a Comment