Ultralytics YOLO27: Join the waitlist.
Get Started
Ultralytics YOLO in Production·Track and Build Pipelines·Lesson 6/9
Lessonintermediate

Counting, Heatmaps, and Speed Estimation

Build line counters, zone occupancy, heatmaps, and speed estimates on top of tracking, and calibrate them honestly.

Most production vision systems are not model.predict(). They are a tracker plus a little geometry: a line that objects cross, a zone they occupy, a scale that turns pixels into meters. The geometry is where results go wrong: a speed estimator on the demo clip in this lesson reports walking pedestrians at 1 km/h, a quarter of their real pace, because of one guessed number.

Outcome

Build a line counter, a zone count, a heatmap, and a speed estimate on real video, and calibrate the speed against a known reference.

Fast Track
If you already know your way around, here's the short version.
  1. Line crossing: solutions.ObjectCounter(region=[p1, p2]); a track's center crossing the line adds to IN or OUT.

  2. Zone occupancy: one point-in-polygon test per tracked box gives who is inside right now.

  3. Heatmap: solutions.Heatmap accumulates where tracked objects spend time.

  4. Speed: pixel displacement × meters per pixel ÷ time. Measure the scale in the scene and confirm the real FPS.

Hands-on

Tracking Plus Geometry#

Every solution class in ultralytics.solutions runs Track mode internally, then applies its own geometry to the track history. Install the extras once with pip install "ultralytics[solutions]".

ClassGeometryKey results
ObjectCounter2 points = line, 3+ points = polygonin_count, out_count, classwise_count
RegionCounterOne or more named polygonsregion_counts
HeatmapAccumulates box areas over timeplot_im (plus counts if given a region)
SpeedEstimatorTrack displacement × meter_per_pixelspeed_dict (km/h per track ID)

Solutions use tracker="tracktrack.yaml" by default; pass the tracker you validated in the previous lesson.

Count Line Crossings#

Ultralytics YOLO counting parcels on a warehouse conveyor: each box has a track ID (#013 to #018) and the IN counter reads 12 where boxes cross the cyan counting line

A counting line is a segment. An object is counted once, when its box center crosses the line between two frames, and the side it came from decides IN or OUT. That is why the conveyor above needs tracking: without the ID, box #015 would be counted again on every frame it touches the line.

import cv2

from ultralytics import solutions
from ultralytics.utils.downloads import safe_download

video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video))  # 640x360, 30 FPS, 62 frames
w, h, fps = (int(cap.get(p)) for p in (cv2.CAP_PROP_FRAME_WIDTH, cv2.CAP_PROP_FRAME_HEIGHT, cv2.CAP_PROP_FPS))
writer = cv2.VideoWriter("counted.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))

counter = solutions.ObjectCounter(
    model="yolo26n.pt",
    region=[(0, 180), (640, 180)],  # two points: a horizontal line across the middle of the frame
    classes=[0],  # person
    tracker="bytetrack.yaml",
    verbose=False,
)

while True:
    ok, frame = cap.read()
    if not ok:
        break
    results = counter(frame)  # detect + track + count in one call
    writer.write(results.plot_im)

cap.release()
writer.release()
print(f"in={results.in_count} out={results.out_count} classwise={results.classwise_count}")
in=2 out=2 classwise={'person': {'IN': 2, 'OUT': 2}}

Four people crossed the middle of the plaza in the clip's two seconds: two in each direction. With the default TrackTrack config the same script reports in=0 out=1, because fewer tracks means fewer crossings. Open counted.mp4 and check each crossing by eye before you trust a number.

Ultralytics YOLO line-crossing counter on a divided road: tracked cars and a truck cross a cyan line, with arrows marking direction and a vehicle count of 24 in and 18 out

On a road, the same line splits traffic by direction: vehicles crossing upward count as IN, downward as OUT. Place the line where objects cross it cleanly and at a right angle, away from frame edges, occluders, and the far end of the road where boxes are tiny.

Count Occupancy in a Zone#

A polygon zone answers "how many objects are inside right now?" The region counting guide ships a ready-made RegionCounter; underneath, it is one point-in-polygon test per tracked box:

from shapely.geometry import Point, Polygon

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
video = "https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4"
zone = Polygon([(100, 100), (500, 100), (500, 300), (100, 300)])  # pixel coordinates

peak = 0
for r in model.track(video, stream=True, tracker="bytetrack.yaml", classes=[0], verbose=False):
    if not r.boxes.is_track:
        continue
    inside = sum(zone.contains(Point(cx, cy)) for cx, cy in r.boxes.xywh[:, :2].tolist())
    peak = max(peak, inside)
print(f"peak occupancy: {peak} people inside the zone")
peak occupancy: 10 people inside the zone

xywh[:, :2] is each box's center. For queues and dwell time, keep a per-ID timestamp of when it entered the zone.

Estimate Speed#

Speed estimation on a highway: each tracked car and truck is labeled with its estimated speed in km/h (48 to 65) and trails a short motion line

Each label above comes from the trail behind the vehicle: the distance its track moved in pixels over a known number of frames. Speed = pixel distance × meters per pixel ÷ elapsed time. Two inputs decide whether the answer is right:

  1. Meters per pixel. Measure a known length in the scene (a floor tile, a lane marking, a parking space) at the depth where objects move. For angled cameras, map the ground plane with a four-point homography instead.
  2. The real capture rate. Speed scales with FPS; footage re-encoded or slowed down scales every speed by the same factor.

This loop measures from the bottom-center of each box (where the object meets the ground) and averages each track over 10 frames:

from collections import defaultdict, deque

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
video = "https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4"
fps = 30  # the real capture rate, not the playback rate
pixels_per_meter = 33  # example: a 1.2 m floor feature spans 40 px; measure your own scene

prev = {}  # track ID -> last bottom-center point
window = defaultdict(lambda: deque(maxlen=10))  # about 0.3 s of speeds per track at 30 FPS
for r in model.track(video, stream=True, tracker="bytetrack.yaml", classes=[0], verbose=False):
    if not r.boxes.is_track:
        continue
    for (x1, y1, x2, y2), tid in zip(r.boxes.xyxy.tolist(), r.boxes.id.int().tolist()):
        cx, cy = (x1 + x2) / 2, y2  # bottom-center sits on the ground plane
        if tid in prev:
            px, py = prev[tid]
            meters = ((cx - px) ** 2 + (cy - py) ** 2) ** 0.5 / pixels_per_meter
            window[tid].append(meters * fps * 3.6)  # km/h
        prev[tid] = (cx, cy)

smoothed = sorted(sum(s) / len(s) for s in window.values() if len(s) == s.maxlen)
print(f"{len(smoothed)} tracks; median {smoothed[len(smoothed) // 2]:.1f} km/h")
29 tracks; median 2.1 km/h

People walk at 4–5 km/h, so 2.1 km/h says the example scale is wrong for this scene: the pixels_per_meter value was not measured here. That sanity check, comparing the median against a speed you know, is the habit that matters.

solutions.SpeedEstimator packages the same idea for production: it measures each track's box center over max_hist frames, converts with meter_per_pixel, and locks one speed per ID (capped at max_speed):

import cv2

from ultralytics import solutions
from ultralytics.utils.downloads import safe_download

video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video))
speed = solutions.SpeedEstimator(
    model="yolo26n.pt",
    classes=[0],
    tracker="bytetrack.yaml",
    fps=30,  # the real capture rate, not the playback rate
    meter_per_pixel=0.03,  # a guess: a ~0.45 m shoulder width spans ~15 px here
    max_hist=15,  # 0.5 s of history before a speed is locked
    verbose=False,
)

speeds = {}
while True:
    ok, frame = cap.read()
    if not ok:
        break
    speeds.update(speed(frame).speed_dict)  # {track_id: km/h} once locked
cap.release()

kmh = sorted(speeds.values())
print(f"{len(kmh)} tracks; median {kmh[len(kmh) // 2]} km/h, range {kmh[0]}-{kmh[-1]} km/h")
25 tracks; median 1 km/h, range 0-4 km/h

Off by about 4× again, for the same reason: a scale guessed from body size. The box center is fine for top-down cameras like this one; on angled cameras prefer the bottom-center loop or a homography.

Accumulate a Heatmap#

Ultralytics YOLO heatmap on a city highway: a blue-to-red overlay is brightest along the busiest lanes, with car and truck boxes and a running count of 12 cars and 2 trucks

A heatmap adds heat under every tracked box on every frame, so lanes or aisles that carry the most traffic glow brightest. Give it a region and it counts objects too, as in the highway above:

import cv2

from ultralytics import solutions
from ultralytics.utils.downloads import safe_download

video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video))
heatmap = solutions.Heatmap(
    model="yolo26n.pt",
    classes=[0],
    tracker="bytetrack.yaml",
    colormap=cv2.COLORMAP_PARULA,
    verbose=False,
)

while True:
    ok, frame = cap.read()
    if not ok:
        break
    results = heatmap(frame)
cap.release()
cv2.imwrite("heatmap.jpg", results.plot_im)  # last frame with the accumulated heat overlay

Over hours of footage, the same overlay shows congested aisles, unused zones, and the paths people actually take. Workouts monitoring and a Streamlit live inference UI are built from the same blocks.

Pitfalls#

Running the model twice per frame

Symptom: a counter pipeline runs at half the expected FPS. Fix: ObjectCounter, Heatmap, and SpeedEstimator detect and track internally. Don't wrap them in your own model.track() loop and pass r.orig_img; feed them raw frames.

Trusting an uncalibrated speed

Symptom: pedestrians at 1 km/h or cars at 300 km/h. Fix: measure meter_per_pixel from a known length where objects move, confirm the camera's real FPS, and check the median against a known speed (walking pace or the posted limit).

Use the bottom-center of the box on angled cameras

The geometric center rises as an object approaches the camera and its box grows. The bottom-center approximates where the object touches the ground, so distances measured from it track real movement far more closely.

Try It

On a video from your own domain (or the demo clip), build the line counter and check every crossing in counted.mp4 by eye. Then measure one real-world length in the scene, compute the scale with the solution script, rerun speed_loop.py, and compare the median speed with a known reference. Deliver the counts, the scale, and the before-and-after median speeds.

Done When
You've finished the lesson when all of these are true.
  • Your line counter's IN and OUT match a manual count of the same clip.

  • Your speed estimate uses a scale measured in the scene, and its median is within 25% of a known reference speed.

  • You can explain why the bottom-center of the box is the right point for speed on an angled camera.

Show solution

Calibrate from something you can measure in the frame, then sanity-check:

# Two points along a floor feature whose real length you know (read them off a saved frame)
(x1, y1), (x2, y2) = (100, 300), (140, 300)
real_length_m = 1.2  # e.g. measured tile width or lane marking

pixels = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
print(f"pixels_per_meter = {pixels / real_length_m:.1f}, meter_per_pixel = {real_length_m / pixels:.4f}")
pixels_per_meter = 33.3, meter_per_pixel = 0.0300

Put pixels_per_meter in speed_loop.py (or meter_per_pixel in SpeedEstimator) and rerun. If walkers still read far from 4–5 km/h, the scale was measured at the wrong depth or the FPS is wrong: a clip slowed to half speed halves every reading. On the demo clip, both scripts used the same guessed scale (0.03 m per pixel is 33 pixels per meter) and read 2–4× low, so either a pixel here covers more ground than the guess or the clip's real capture rate is not 30 FPS. Only a measurement in the scene settles which.

What's next

One model, one stream is tractable. Next: many streams in parallel, and the thread-safe inference discipline that comes with them.