Can you estimate how fast a vehicle moves using a video? In this Python example, YOLO detects vehicles, a tracker connects detections between frames, and we divide a tracked point’s image displacement by the elapsed video time.
The output in this demonstration is the magnitude of average image-plane velocity in pixels per second (px/s), not road speed in km/h. It is computed from net image displacement, not the distance travelled along the full path. The source is a synthetic traffic video. It has no established real-world scale, so a physical speed cannot be recovered from it just by naming a conversion factor.
Watch the tutorial
Watch the complete Python and YOLO tutorial or see the Short demonstration.

From video to a measurable result
- Open the video and read its frame rate. A frame is one image in a sequence, and FPS is the number of frames per second.
- Detect vehicles and associate detections across frames. The tracker assigns an ID to an association; it is not a permanent identity or a guarantee of correct tracking.
- For one continuous track, find the chosen point at the start and end of the measurement interval. In this example the point is the center of its bounding box.
- Divide the distance in image pixels by the elapsed video time. Label the answer in px/s.
The sample is 1280 × 720 pixels at 24 frames per second. We use the vehicle associated with track ID 2 in frames 0–96. The straight-line screen displacement of its box center is about 302.85 pixels in 4 seconds, or 75.7 px/s. The calculation and output concern this track and interval, not every vehicle in the scene.
How is speed calculated?
For two observations from the same tracked object at frame indices f0 and f1, a constant-frame-rate video gives elapsed_seconds = (f1 - f0) / fps. The two image positions are (x0, y0) and (x1, y1). This example uses the straight-line displacement sqrt((x1 - x0)² + (y1 - y0)²). Dividing it by elapsed seconds gives the magnitude of average image-plane velocity in pixels per second over that interval.
This is net displacement, not total path length, and it is not an instantaneous speed. A curved or back-and-forth path can yield a smaller result than summing every successive movement. FPS must describe the source video correctly; variable-frame-rate footage needs appropriate presentation timestamps rather than assuming constant spacing.
What you need
Use Python and a permitted traffic video. The demonstration ran on Windows, Python 3.13.9, Ultralytics 8.4.24, OpenCV 4.13.0, NumPy 2.3.5, PyTorch 2.10.0+cpu and lap 0.5.13. Other versions, input videos and hardware may change detections and IDs. Install the required packages in a virtual environment; downloading packages and model weights needs an internet connection unless they are already available locally.
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install ultralytics==8.4.24 lap==0.5.13
This command pins two direct packages, not the complete dependency tree. A clean online install of this exact command has not been independently tested; platform, version and hardware differences can affect compatibility and results.
Place an input video and the official yolov8n.pt checkpoint where the script expects them. Course students can find the sample traffic_counter_highway_clip.mp4 in 04_zasoby and yolov8n.pt in 02_praktyka of the lesson 51 practice ZIP. Copy them into the project folder as input.mp4 and yolov8n.pt, respectively; do not assume the video is otherwise licensed for public redistribution. Use your own permitted video if the sample is unavailable: exact tracks and speeds will differ.
Complete Python example
Save the following as measure_screen_speed.py. The source is the same file used for the demonstration. Check the paths and parameters before running.
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Measure image-plane displacement of one tracked car; no real-world calibration."""
import argparse
import json
import math
import os
from pathlib import Path
import cv2
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--video', type=Path, required=True)
parser.add_argument('--weights', type=Path, required=True)
parser.add_argument('--output', type=Path, default=Path('speed_output'))
parser.add_argument('--track-id', type=int, default=2)
parser.add_argument('--end-frame', type=int, default=96)
args = parser.parse_args()
for name, path in [('Video', args.video), ('Weights', args.weights)]:
if not path.is_file():
raise FileNotFoundError(f'{name} not found: {path}')
args.output.mkdir(parents=True, exist_ok=True)
(args.output / 'config').mkdir(exist_ok=True)
os.environ.setdefault('YOLO_CONFIG_DIR', str((args.output / 'config').resolve()))
os.environ.setdefault('YOLO_OFFLINE', 'true')
os.environ.setdefault('YOLO_AUTOINSTALL', 'false')
from ultralytics import YOLO
import torch
torch.set_num_threads(4)
model = YOLO(str(args.weights))
cap = cv2.VideoCapture(str(args.video))
if not cap.isOpened():
raise RuntimeError('Could not open video')
fps = cap.get(cv2.CAP_PROP_FPS)
if not math.isfinite(fps) or fps <= 0:
raise ValueError('Invalid video frame rate')
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
writer = cv2.VideoWriter(str(args.output / 'annotated.mp4'),
cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
if not writer.isOpened():
raise RuntimeError('Could not create output video')
start = None
path = []
result = None
try:
for frame_number in range(args.end_frame + 1):
ok, frame = cap.read()
if not ok:
raise RuntimeError(f'Video ended at frame {frame_number}')
prediction = model.track(frame, persist=True, device='cpu', imgsz=640,
conf=0.20, classes=[2, 3, 5, 7], tracker='bytetrack.yaml',
verbose=False)[0]
boxes = prediction.boxes
matches = [] if boxes.id is None else [box for box in boxes
if box.id is not None and int(box.id.item()) == args.track_id]
if len(matches) != 1:
raise ValueError(f'Expected one track ID {args.track_id} in frame {frame_number}; got {len(matches)}')
xyxy = [float(v) for v in matches[0].xyxy[0].tolist()]
center = ((xyxy[0]+xyxy[2])/2, (xyxy[1]+xyxy[3])/2)
if start is None:
start = center
path.append(tuple(round(x) for x in center))
elapsed = frame_number / fps
displacement = math.dist(start, center)
mean_px_s = displacement / elapsed if elapsed else None
cv2.rectangle(frame, (round(xyxy[0]),round(xyxy[1])),
(round(xyxy[2]),round(xyxy[3])), (0,220,255), 2)
for pt in path:
cv2.circle(frame, pt, 2, (0,0,255), -1)
cv2.circle(frame, path[-1], 6, (0,0,255), -1)
cv2.rectangle(frame, (10,18), (710,102), (30,30,30), -1)
cv2.putText(frame, f'Track ID {args.track_id} | frame {frame_number} | {elapsed:.2f} s',
(25,55), cv2.FONT_HERSHEY_SIMPLEX, .8, (255,255,255), 2)
label = 'Screen displacement / elapsed time: ' + (f'{mean_px_s:.1f} px/s' if mean_px_s is not None else 'N/A')
cv2.putText(frame, label, (25,88), cv2.FONT_HERSHEY_SIMPLEX, .72, (255,255,255), 2)
writer.write(frame)
if frame_number in (0, 48, args.end_frame):
if not cv2.imwrite(str(args.output / f'frame_{frame_number:03d}.png'), frame):
raise RuntimeError('Could not write frame')
if frame_number == args.end_frame:
result = {'track_id': args.track_id, 'first_frame': 0, 'last_frame': frame_number,
'fps': fps, 'width': width, 'height': height, 'elapsed_s': elapsed,
'start_xy': start, 'end_xy': center, 'displacement_px': displacement,
'mean_screen_displacement_px_per_s': mean_px_s,
'definition': 'straight-line image-plane displacement divided by elapsed time',
'not_kmh': True}
finally:
writer.release()
cap.release()
(args.output/'result.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()
From the project directory run:
.venv\Scripts\python.exe measure_screen_speed.py --video input.mp4 --weights yolov8n.pt --output speed_output
The defaults select track ID 2 from frames 0 through 96 for this specific example. The script produces speed_output/annotated.mp4, sample annotated PNG frames and speed_output/result.json. It raises an error if the chosen ID is missing in any frame rather than silently guessing an association.
Follow the code
- Read the source.
cv2.VideoCaptureopens the input and reads the recorded FPS, width and height. The loop uses zero-based frame numbers and stops at the requested end frame. - Detect and associate.
model.track(..., persist=True, tracker='bytetrack.yaml')detects the selected vehicle classes and retains tracker state across frames. The code requires exactly one match for the selected ID in every frame; if an association is absent, it stops instead of filling gaps. - Pick a point. For each matching box, the midpoint between its left and right coordinates and between its top and bottom coordinates is the box center. The first center becomes the starting position; subsequent centers add visible points to the red trail.
- Calculate and save. For each later frame,
math.dist(start, center)returns the straight-line pixel distance from the starting center. Dividing by elapsed source-video time gives the running net displacement per second; the script saves an annotated MP4 and the final coordinates and numbers in JSON.
The defaults are chosen for this particular clip: ID 2 must be present already in frame 0 and remain present through frame 96. For another video, inspect its tracks and set --track-id and --end-frame to an association that satisfies that condition. The same numeric ID in another run is not necessarily the same physical object. This small script is deliberately strict; it does not automatically find an arbitrary new car later in a clip.
How to check the number
Compare the same vehicle at both endpoints. Check that the displayed ID remains associated with that vehicle throughout the measured interval. Read the endpoint coordinates, frame indices and source FPS; calculate the displacement and elapsed time independently. A different track, box jitter, occlusion or camera movement can change the answer. An ID switch can make an apparently precise calculation describe the wrong association.
For this run, frame 0 gives center approximately (692.45, 306.86), and frame 96 gives (966.27, 436.23). The elapsed time is (96 − 0) / 24 = 4 seconds; sqrt((966.27 − 692.45)² + (436.23 − 306.86)²) is approximately 302.85 pixels. Thus net displacement per second is about 302.85 / 4 = 75.7 px/s. These rounded coordinates are for illustration; the program computes with full precision. We visually inspected sampled frames of the association; this is not a ground-truth accuracy measurement.
Why this is not a speedometer
A pixel is a location in the image, not a fixed distance on the road. Perspective means the same road distance can occupy a different number of pixels in different parts of the image. A moving camera, distortion, an inaccurate frame rate and a box center that shifts as the view changes introduce further errors.
A physical speed estimate requires a calibrated mapping between image and ground-plane positions, a valid time base and a suitable known reference. A single global meters-per-pixel factor is valid only under suitable geometric conditions and assumptions. Even after calibration, compare estimates with independently measured ground truth across representative cases. The Ultralytics speed-estimation guide also describes FPS and scale as inputs and calls the output an estimate.
This demonstration is educational; it is not a validated instrument for enforcement or safety-critical decisions.
Explore further
For a related application, read Count Cars with YOLO: Why Track IDs Are Not Vehicle Counts. For more practical computer vision projects, see my Computer Vision: OpenCV, Machine Learning and YOLO course on Udemy.
Code and component licenses
The example script is © 2026 ARTSCI - Author of the AI/ML/CV course materials, licensed under GNU AGPL-3.0 only, without warranty. Keep applicable notices and provide corresponding source when sharing covered modifications. This notice does not automatically license the source video or other media.
Ultralytics software and model weights have their own terms; review the Ultralytics assets license and Ultralytics licensing information for your intended use. Other dependencies and media may have separate terms.













