Depth Maps: See and Measure Distance¶
A depth map holds a distance for every pixel of an image. Starting with 0.31.0, supervision has a small family of tools for them:
sv.DepthMapholds the map together with what it measures: stereo disparity in pixels (disparity_px), metric depth in metres (depth_m), or a monocular model's relative depth (relative_inverse, larger is nearer).- Loaders read maps from Hugging Face Transformers, Ultralytics YOLO26 depth, Roboflow Inference, and 16-bit PNG and PFM dataset files.
sv.DepthAnnotatorcolours a map over an image, near objects warm, and leaves pixels without depth unpainted.sv.DepthCameraturns stereo disparity into metres, andDepthMap.measure_detectionsstores the depth under each object indetections.data.sv.DepthClipRangekeeps one colour scale across a whole video.
This cookbook runs two depth models on the same street scene, colours their maps, labels every person with their distance, and colours a video with a fixed range.
Install required packages¶
supervision: depth tools ship in0.31.0. Until it is released, install from thedevelopbranch.transformers: runs Depth Anything V2, a monocular model that predicts relative depth.ultralytics: runs YOLO26 depth, which predicts metric depth in metres, and YOLO11 for detections.
%pip install -q "supervision @ git+https://github.com/roboflow/supervision.git@develop" transformers ultralytics
%matplotlib inline
import numpy as np
import supervision as sv
from supervision.assets import ImageAssets, VideoAssets, download_assets
print(sv.__version__)
0.31.0.dev0
Download the sample image¶
A plaza seen from above, with people walking at many distances from the camera.
from PIL import Image
image_path = download_assets(ImageAssets.PEOPLE_WALKING)
image = sv.pillow_to_cv2(Image.open(image_path))
height, width = image.shape[:2]
print(f"Image size: {width}x{height}")
[2026-10-15 18:34:20] [INFO] supervision.assets.downloader - people-walking.jpg asset download complete.
Image size: 1920x1080
Relative depth with Depth Anything V2¶
Monocular models such as Depth Anything V2 predict relative inverse depth: larger values are nearer, but the values have no unit, so they order pixels from near to far without saying how far anything is. The Transformers pipeline does not say what its model predicts, so name the kind when loading.
from transformers import pipeline
estimator = pipeline(
task="depth-estimation", model="depth-anything/Depth-Anything-V2-Small-hf"
)
result = estimator(sv.cv2_to_pillow(image))
relative_map = sv.DepthMap.from_transformers(result, kind="relative_inverse")
relative_map
DepthMap(kind='relative_inverse', resolution_wh=(1920, 1080), camera=None)
sv.DepthAnnotator colours the map over the image. By default the colour range spans the map's 2nd to 98th percentile, so a few extreme pixels do not wash out the rest.
depth_annotator = sv.DepthAnnotator(opacity=0.65)
annotated_image = depth_annotator.annotate(image.copy(), relative_map)
sv.plot_image(annotated_image, size=(10, 6))
Choose a colour map¶
"turbo" (default) separates the most depth steps. "viridis" and "cividis" keep their order in grayscale and for colour-blind readers. To show the depth alone, annotate a blank canvas instead of the image.
canvas = np.zeros_like(image)
colormaps = ["turbo", "viridis", "cividis", "inferno", "magma", "grayscale"]
sv.plot_images_grid(
images=[
sv.DepthAnnotator(colormap=colormap).annotate(canvas.copy(), relative_map)
for colormap in colormaps
],
grid_size=(2, 3),
titles=colormaps,
size=(15, 7),
)
Metric depth with YOLO26¶
YOLO26 depth models predict metric depth in metres, so their maps can measure distances. from_ultralytics loads the result as a depth_m map.
from ultralytics import YOLO
depth_model = YOLO("yolo26n-depth.pt")
metric_map = sv.DepthMap.from_ultralytics(depth_model(image, verbose=False)[0])
metric_map
DepthMap(kind='depth_m', resolution_wh=(1920, 1080), camera=None)
The default scale="inverse" colours a metric map as inverse depth, which spends most of the colours on near detail, the way the eye and a stereo camera perceive it. scale="metric" colours metres evenly instead, and a fixed display_range pins the colours to distances: here everything nearer than 2 m is red and everything beyond 10 m is blue.
inverse_annotator = sv.DepthAnnotator(opacity=0.65)
metric_annotator = sv.DepthAnnotator(
scale="metric", display_range=(2.0, 10.0), opacity=0.65
)
sv.plot_images_grid(
images=[
inverse_annotator.annotate(image.copy(), metric_map),
metric_annotator.annotate(image.copy(), metric_map),
],
grid_size=(1, 2),
titles=['scale="inverse"', 'scale="metric", 2 to 10 m'],
size=(16, 5),
)
A metric map answers point queries in metres. value_at takes a point in image pixels when given the image size, and returns None where the map has no depth.
for x, y in [(width // 2, height - 50), (width // 2, height // 2)]:
distance = metric_map.value_at(x, y, resolution_wh=(width, height))
print(f"({x}, {y}): {distance:.1f} m" if distance is not None else "no depth")
(960, 1030): 3.0 m (960, 540): 5.0 m
Label people with their distance¶
measure_detections stores the median depth inside each mask, or each box without masks, in detections.data["depth_m"]. A box median is an estimate and may be affected by valid background pixels. Use a segmentation mask when the measurement should be restricted to the person. Pass the image size as resolution_wh whenever the map may have another size than the image: the map is stretched over the image the way sv.DepthAnnotator draws it.
detection_model = YOLO("yolo11n.pt")
detections = sv.Detections.from_ultralytics(detection_model(image, verbose=False)[0])
detections = detections[detections.class_id == 0] # people only
detections = metric_map.measure_detections(detections, resolution_wh=(width, height))
detections.data["depth_m"]
array([ 2.6949, 3.4327, 3.896, 4.7342, 3.2344, 4.8108, 5.3514, 4.4864, 4.5342, 6.0895, 7.3511, 7.2175, 5.9982, 4.6431, 6.9138, 7.1384, 7.6068, 8.8437], dtype=float32)
An object whose region holds no depth gets NaN, so check np.isfinite before printing a distance.
labels = [
f"{depth:.1f} m" if np.isfinite(depth) else "no depth"
for depth in detections.data["depth_m"]
]
annotated_image = sv.DepthAnnotator(opacity=0.5).annotate(image.copy(), metric_map)
annotated_image = sv.BoxAnnotator().annotate(annotated_image, detections)
annotated_image = sv.LabelAnnotator(smart_position=True).annotate(
annotated_image, detections, labels
)
sv.plot_image(annotated_image, size=(12, 7))
Highlight the nearest people¶
The depth column is an ordinary detections.data field, so it filters and sorts like any other. Keep the people within 5 m, nearest first.
nearby = detections[detections.data["depth_m"] < 5.0]
nearby = nearby[np.argsort(nearby.data["depth_m"])]
print(f"{len(nearby)} of {len(detections)} people are within 5 m")
print(np.round(nearby.data["depth_m"], 1))
9 of 18 people are within 5 m [ 2.7 3.2 3.4 3.9 4.5 4.5 4.6 4.7 4.8]
Relative maps order, metric maps measure¶
A relative map has no metres, so measure_detections fills detections.data["relative_inverse"] instead. It still sorts people from near to far within one image, but its values cannot be compared across images or turned into distances.
relative_detections = relative_map.measure_detections(
detections, resolution_wh=(width, height)
)
near_to_far_relative = np.argsort(-relative_detections.data["relative_inverse"])
near_to_far_metric = np.argsort(detections.data["depth_m"])
print("near to far, relative:", near_to_far_relative[:5])
print("near to far, metric: ", near_to_far_metric[:5])
near to far, relative: [ 0 4 1 2 13] near to far, metric: [0 4 1 2 7]
Stereo disparity to metres¶
A stereo camera measures disparity: how many pixels a point shifts between the left and right views. With the rig's focal length and baseline, sv.DepthCamera converts it to metric depth, depth = fx_px * baseline_m / disparity. Below, a synthetic disparity map of a KITTI-like rig (focal length 721.5 px, baseline 0.54 m) stands in for a stereo matcher's output: a ground plane that recedes towards the horizon, with a box standing on it.
rows = np.arange(200, dtype=np.float32)[:, None]
disparity = np.repeat(np.clip(rows - 60.0, 0.0, None), 400, axis=1) # 0 above horizon
disparity[90:150, 160:240] = 60.0 # an object standing on the ground
stereo_map = sv.DepthMap(
disparity,
kind="disparity_px",
camera=sv.DepthCamera(fx_px=721.5, baseline_m=0.54),
)
print(f"pixels with depth: {stereo_map.valid_mask.mean():.0%}")
print(f"object at {stereo_map.to_depth().value_at(200, 120):.2f} m")
pixels with depth: 70% object at 6.49 m
Disparity 0 above the horizon means no depth, and those pixels stay unpainted. With a camera, scale="metric" works on a disparity map too.
stereo_canvas = np.full((200, 400, 3), 40, dtype=np.uint8)
sv.plot_images_grid(
images=[
sv.DepthAnnotator().annotate(stereo_canvas.copy(), stereo_map),
sv.DepthAnnotator(scale="metric", display_range=(5.0, 30.0)).annotate(
stereo_canvas.copy(), stereo_map
),
],
grid_size=(1, 2),
titles=["disparity", "metres, 5 to 30 m"],
size=(14, 4),
)
Dataset files load directly: sv.DepthMap.from_png16(path, scale=256, kind="disparity_px") for KITTI and sv.DepthMap.from_pfm(path) for Middlebury and SceneFlow.
Colour a video with one range¶
Colouring each frame with its own "auto" range makes a still wall change colour whenever something enters the frame. sv.DepthClipRange.from_depth_maps computes one percentile range over the whole clip, so every frame shares the same colour scale. For a short clip, keep the maps in a list to run the model once per frame.
video_path = download_assets(VideoAssets.PEOPLE_WALKING)
stride = 5
video_info = sv.VideoInfo.from_video_path(video_path)
video_info.fps = max(1, round(video_info.fps / stride)) # keep playback speed
frames = list(sv.get_video_frames_generator(video_path, stride=stride, end=150))
depth_maps = [
sv.DepthMap.from_ultralytics(depth_model(frame, verbose=False)[0])
for frame in frames
]
clip_range = sv.DepthClipRange.from_depth_maps(depth_maps)
clip_range
[2026-10-15 18:34:26] [INFO] supervision.assets.downloader - people-walking.mp4 asset download complete.
DepthClipRange(display_range=(2.872657299041748, 12.180829048156738))
clip_annotator = sv.DepthAnnotator(display_range=clip_range, opacity=0.65)
with sv.VideoSink("depth-people-walking.mp4", video_info) as sink:
for frame, depth_map in zip(frames, depth_maps):
sink.write_frame(clip_annotator.annotate(frame.copy(), depth_map))
sv.plot_images_grid(
images=[
clip_annotator.annotate(frames[i].copy(), depth_maps[i])
for i in (0, len(frames) // 2, -1)
],
grid_size=(1, 3),
titles=["first", "middle", "last"],
size=(18, 4),
)
For a long clip, pass a generator to from_depth_maps instead of a list: it reads one map at a time with bounded memory, at the cost of estimating depth twice per frame. See the Visualize Depth Maps guide for that pattern.
Next steps¶
- Use
depth_map.crop(xyxy)to cut out the depth of one object, anddepth_map.resize((width, height), method="foreground")to shrink a map while keeping thin near objects. - Swap in any Roboflow Inference depth model with
sv.DepthMap.from_inference. - Read the
sv.DepthMapandsv.DepthAnnotatorAPI reference.