几个月前做园区出入口的视觉方案时,甲方提了一个看着简单、做起来藏坑的需求:画面里的行人和车辆,能不能实时告诉我它们离摄像头大概多远。模型侧我们选了 Ultralytics YOLO11,原因很直接——在检测精度和推理速度的平衡上,YOLO11 正好能覆盖中低端边缘盒子;距离计算这部分没有上激光雷达和深度相机,完全靠单目视频帧做几何估算。这篇文章聊的就是这套“YOLO11 目标检测 + 单目测距”的完整落地过程,包括数学推导、相机标定、代码实现和一批实测踩坑记录。如果你正在做安防监控、智能交通或低成本测距 Demo,看完至少能少走三个弯路。
先把结论放在前面:单目测距不是魔法,它必须知道目标的参考尺寸、摄像头焦距、安装高度和俯仰角这些前提条件。固定相机场景下,这套组合能实现 5% 左右的距离误差;但如果你拿手机边走边测,误差可能让你直接怀疑人生。理解它适用什么、不适用什么,比抄代码更重要。
1. 先想清楚再动手:单目测距到底要解决哪些问题
1.1 YOLO11 负责“看见目标”,距离计算负责“算出来”
很多初学者会把“距离计算”误当成模型的一个内置能力。实际上 YOLO11 解决的是“画面里有什么、目标在哪个位置”这个感知问题,它输出的是一组 bounding box:每个框的左上角坐标、右下角坐标、类别和置信度。距离计算是独立的下游步骤,我拿到这些框之后,还要结合相机参数做几何换算,才能得出“这个人在 8.3 米外”这样的结论。
YOLO11 相对上一代 YOLOv8 的提升,用最直白的话说:在差不多的算力预算下,检测小目标和重叠目标的能力更强了,而且模型文件更小、训练更方便。比如 yolo11n.pt 这个 nano 版本只有 5MB 左右,跑在 CPU 上也能达到接近实时的速度,这对监控场景非常友好。距离计算本身对框的精度很敏感,一个框的宽度误差 5 个像素,在远距离下换算出的距离可能差出 1 米多,所以检测器的底线质量直接决定了测距结果的天花板。
YOLO11 给我们的第二个好处是统一的推理接口。无论你是检测行人、车辆还是特定道具,都走同一套model(frame)调用,返回的 boxes 结构非常规范,和当前主流的视觉框架都能顺畅对接。这意味着我可以把精力集中在距离计算这条链路,而不是花时间在检测器适配和框坐标解析上。
1.2 三种单目测距路线,怎么选
单目相机只有一张二维图像,没有深度信息,所以距离估算本质上是“用先验条件反推三维距离”。实际工程中常见三种路线:
第一种是参考尺寸法,也叫相似三角形法。前提是我知道目标的真实宽度或高度,比如一辆普通轿车约 1.8 米宽、成人肩宽约 0.45 米,然后利用焦距和像素宽度的比例关系算出距离。这个方案最简单、最容易落地,但缺点也很明显:目标真实尺寸必须提前假设,不同车型、不同身材会导致系统偏差。
第二种是面积回归法。我收集一批“已知距离 + 检测框面积”的数据,训练一个小网络或拟合一个曲线,用 bbox 面积直接映射距离。这个方法不需要知道具体物体的真实尺寸,但需要针对每个相机位置、每个目标类别重新采集数据,泛化能力一般。我们测试下来,在固定机位且目标距离范围固定的场景,用多项式拟合效果还不错,一旦换个安装高度误差就开始发散。
第三种是地面平面约束法,也叫单应性测距。它假设目标都在同一地面上,通过标定相机与地面的关系,将图像坐标映射到世界坐标。这个方案的精度最高,尤其适用于固定俯视或平视的监控相机。但它需要精确测量相机离地高度、俯仰角,标定过程也更繁琐,不是每种场景都有条件做好。
我在园区监控这个项目里最终选了“参考尺寸法 + 简易地面约束”的组合:先用 YOLO11 拿到目标框,再根据目标底部中点的位置做俯仰角修正,最后用相似三角形公式算出距离。这个组合兼顾了开发速度和可维护性,是单目方案里性价比最高的起点。
2. 环境搭建与检测链路:让 YOLO11 先把框给你
2.1 5分钟跑通 YOLO11 检测
安装这一步没什么门槛,直接创建虚拟环境然后装包:
pip install ultralytics opencv-python numpyUltralytics 库里已经集成了 YOLO11 的权重,第一次使用时会自动下载yolo11n.pt。如果你不想每次启动都去外网拉模型,可以提前手动下载后放本地:
wget https://github.com/ultralytics/assets/releases/download/v8.3.0/yolo11n.pt跑检测的最小代码只有几行:
from ultralytics import YOLO model = YOLO("yolo11n.pt") results = model("test.jpg", verbose=False) for r in results: boxes = r.boxes if boxes is None: continue for box in boxes: x1, y1, x2, y2 = [int(v) for v in box.xyxy[0]] cls = int(box.cls[0]) conf = float(box.conf[0]) print(model.names[cls], conf, (x1, y1, x2, y2))这里xyxy就是检测框的边界坐标,后续所有距离计算都围绕着它们展开。很多人会忽略verbose=False这个参数,实际跑视频流时,默认的日志输出反而会把控制台刷爆,影响调试体验。
我建议你在自己的图片或者视频上先跑一遍,确认目标的检测框是否稳定。如果发现大量漏检,优先考虑换更大的模型,比如 yolo11s.pt 或 yolo11m.pt。YOLO11n 胜在速度快,但对远距离小目标的召回率确实会弱一些。
2.2 bbox 预处理:低置信度过滤与目标选择
检测器会给出大量低置信度的框,在测距链路里这些噪声框会造成距离值乱跳。我的经验是设一个置信度阈值,行人至少 0.5,车辆至少 0.6;如果场景光照稳定,可以调更高。用参数化写法控制:
CONF_THRESHOLD = 0.5 results = model(frame, verbose=False, conf=CONF_THRESHOLD)除了置信度,还要处理框的尺寸。距离估算对目标像素宽度很敏感,如果一个目标距离特别远导致 bbox 宽度只有几个像素,算出来的距离就完全不可信。我在代码里加一个最小像素宽度限制,例如小于 10 像素的框直接跳过:
pixel_width = x2 - x1 if pixel_width < 10: distance = None另一个关键点是目标底部中点的选择。大部分情况下,目标与地面的接触点更接近其真实位置,而 bbox 的中心则容易受目标姿态、肢体摆动影响。所以我会优先取(x1 + x2) // 2, y2作为有效位置,这个点后续在地面约束计算时会用到。
还要注意 YOLO11 的默认目标列表是 COCO 80 类,里面包含 person, car, truck, bus 等类别。如果你只需要行人和车辆,最好在推理后就过滤类别:
TARGET_CLASSES = {0, 2, 5, 7} # person, car, truck, bus if cls not in TARGET_CLASSES: continue这样做既减少误报,也避免模型把远处的电线杆当成人,产生一个看起来很合理但实际完全离谱的距离。
3. 距离计算的核心数学与相机标定
3.1 相似三角形公式 D = W × f / P 的推导
单目测距的基础是针孔相机模型。把相机想象成一个极小的孔,光线经过这个小孔在感光元件上成像,物体宽度、像宽度、焦距之间存在严格的比例关系。
设物体实际宽度为 W(单位:米),物体到相机光心的距离为 D(单位:米),相机焦距为 f(单位:像素),物体在图像上的像素宽度为 P(单位:像素)。根据相似三角形:
W / D = P / f变形之后得到最核心的测距公式:
D = (W × f) / P这个公式里,P 是 YOLO11 给我们的检测框宽度,W 是我们提前设定的目标真实宽度,剩下需要标定的就是 f。
举一个直观例子。假设焦距 f = 1200 像素,一辆真实宽度 W = 1.8 米的轿车在画面中检测框宽 P = 240 像素,那么距离 D = 1.8 × 1200 / 240 = 9 米。如果同一辆车开到 P = 120 像素,距离就是 18 米。可以看出,像素宽度与距离成反比,目标越远,像素变化越迟钝,这也解释了为什么远距离测距误差总是更大。
由于摄像头存在镜头畸变,直接拿原始图像中的框宽度代入公式会有误差。焦距 f 需要通过标定获得,不能只看厂商标注的参数。下面两种方法都可以得到相对可信的 f。
3.2 焦距 f 的两种获取方式:标定板与土办法
正规的做法是使用棋盘格标定板,用 OpenCV 的cv2.calibrateCamera计算相机内参矩阵。内参矩阵中的fx就是水平方向焦距(像素单位),fy是垂直方向焦距。对于大多数矩形像素传感器,fx 和 fy 非常接近,测距时我直接用 fx。
采集标定图像时至少拍 15 到 20 张不同角度、不同距离的棋盘格照片,让标定板尽可能覆盖整个画面。下面是求解内参的通用代码框架:
import cv2 import glob CHECKERBOARD = (9, 6) subpix_criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objpoints = [] imgpoints = [] for image_path in glob.glob("calib/*.jpg"): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None) if ret: objpoints.append(objp) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), subpix_criteria) imgpoints.append(corners2) cv2.drawChessboardCorners(img, CHECKERBOARD, corners2, ret) ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(objpoints, imgpoints, gray.shape[::-1], None, None) fx = mtx[0, 0] fy = mtx[1, 0]标定完成后,用fx作为公式里的 f。还可以顺便用dist系数做畸变校正:
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, mtx, (w, h), cv2.CV_32FC1) frame = cv2.remap(frame, mapx, mapy, cv2.INTER_LINEAR)畸变校正在广角镜头上是必须的,否则画面边缘的框宽误差会很大。但对 50 度以下视场角的普通镜头,畸变影响较小,可以暂时省略,用土办法估算。
土办法很简单。找一个已知宽度的目标(比如标准车牌 0.44 米),把它放在已知距离 D,比如 5 米处,然后测它在画面中的像素宽度 P。焦距就可以反推:
f = (P × D) / W多测几个距离取平均,就能得到一个可用的 f。这个方法不需要打印棋盘格,适合快速验证,但精度受测量误差影响,只能作为临时方案。
3.3 固定相机场景加一个地面约束,精度还能再涨
直接用相似三角形公式的问题是:当目标在画面中的高度位置变化时,实际距离并不单纯由 bbox 宽度决定。想象一个人站在远处中央,和站到画面左侧靠近相机,即使看起来大小相近,真实距离也不同。这是因为针孔模型的投影没有考虑相机安装高度和俯仰角。
对固定监控相机来说,我可以额外测量两个参数:相机离地高度 H 和俯仰角 θ。当目标底部中点位于图像中的像素纵坐标 y,且图像中心纵坐标为 cy 时,目标到相机的水平距离可以近似表示为:
D_horizontal = (H × f) / |y - cy| × impact_factor这个公式基于地面平面约束,前提是相机光轴与地面有一定的倾斜角,或者目标站在地平面上。实际工程中,如果相机是垂直向下俯视的,直接用 bbox 底部中点配合 H 和 θ 映射到世界坐标是最准的。
完整的实现需要计算单应性矩阵。在固定机位下,我推荐先做一次单应性标定。具体做法:在地面放置 4 个已知坐标的标志点,记录图像坐标,然后计算从图像平面到地面平面的映射矩阵:
src_pts = np.array([[x1, y1], [x2, y2], [x3, y3], [x4, y4]], dtype=np.float32) dst_pts = np.array([[wx1, wy1], [wx2, wy2], [wx3, wy3], [wx4, wy4]], dtype=np.float32) H_mat = cv2.getPerspectiveTransform(src_pts, dst_pts)之后任何 bbox 底部中点经过cv2.perspectiveTransform变换后,得到的 y 坐标就是目标在真实地面坐标系中的纵向距离。这个方案不需要知道焦距,精度比直接相似三角形法高一个量级,固定监控场景强烈建议采用。
我会在代码实现部分同时给出“简易参考尺寸法”和“单应性修正法”,方便你按场景选择。
4. 一次跑通的完整代码:摄像头实时距离估算
4.1 YOLO11 + 测距主流程代码
下面这段代码是我们在园区 Demo 里跑通的完整版本,做了三类目标:行人、小轿车、中型货车。参考宽度分别为 0.45 米、1.8 米和 2.5 米。焦距先通过标定获得,我用一个变量保留,方便替换。
import cv2 import numpy as np from ultralytics import YOLO model = YOLO("yolo11n.pt") # 目标类别映射:COCO 中 person/car/truck KNOWN_WIDTH = { 0: 0.45, # person 2: 1.80, # car 7: 2.50, # truck } FOCAL_LENGTH = 1200.0 # 已标定获得的 fx,单位像素 CONF_THRESHOLD = 0.5 MIN_PIXEL_WIDTH = 10 def compute_distance(cls, pixel_width): width_known = KNOWN_WIDTH.get(cls) if width_known is None: return None if pixel_width < MIN_PIXEL_WIDTH: return None distance = (width_known * FOCAL_LENGTH) / pixel_width return distance cap = cv2.VideoCapture("test_video.mp4") while True: ret, frame = cap.read() if not ret: break results = model(frame, verbose=False, conf=CONF_THRESHOLD) for r in results: boxes = r.boxes if boxes is None: continue for box in boxes: cls = int(box.cls[0]) if cls not in KNOWN_WIDTH: continue x1, y1, x2, y2 = [int(v) for v in box.xyxy[0]] conf = float(box.conf[0]) pixel_width = x2 - x1 distance = compute_distance(cls, pixel_width) if distance is not None: label = f"{model.names[cls]} {distance:.1f}m (conf {conf:.2f})" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, max(y1 - 5, 15)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("YOLO11 Distance", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这段代码的核心逻辑很简单:检测框出来后,先过滤类别,再用框宽代入距离公式。运行后你会发现问题——距离数字跳动得非常厉害,大约每帧都会变,甚至在同一位置连续几帧能差出 1 到 2 米。这不是公式错误,而是 bbox 本身的像素宽度在轻微抖动。下面马上解决这个问题。
如果需要用单应性修正,就把公式替换成 3.3 节提到的方法。先用 4 个地面标志点算出 H_mat,然后把每个目标的底部中点映射到地面坐标:
src_point = np.array([[[(x1 + x2) // 2, y2]]], dtype=np.float32) mapped = cv2.perspectiveTransform(src_point, H_mat) long_distance = mapped[0][0][0]注意单应性矩阵映射出的纵向坐标并不能直接当作水平距离,而是目标在地面坐标系中的纵坐标,如果你的地面坐标系原点设在相机正下方,那这个纵坐标就是水平距离。
4.2 距离结果防抖:滑动窗口与卡尔曼滤波
距离跳动的来源主要是检测框宽度的微小变化。比如真实距离 8 米的物体,一帧检测框宽 210 像素,下一帧变成 215 像素,计算出的距离会从 6.86 米跳到 6.7 米,视觉上非常不自然,业务告警也会被干扰。
最简单的平滑方式是滑动窗口平均。维护一个长度为 N 的队列,只输出窗口内距离的中位数或均值。中位数抗噪能力更强,我实际用下来推荐中位数:
from collections import deque smoother = deque(maxlen=5) def smooth_distance(distance): if distance is None: return None smoother.append(distance) return float(np.median(list(smoother)))如果希望平滑效果更聪明,可以用指数移动平均 EMA:
ema_distance = None alpha = 0.4 def ema_smooth(distance): global ema_distance if distance is None: return None if ema_distance is None: ema_distance = distance else: ema_distance = alpha * distance + (1 - alpha) * ema_distance return ema_distancealpha 控制平滑幅度,alpha 越小越平滑,但响应也越迟钝。我一般调在 0.3 到 0.5 之间。
对精度要求更高的场景,可以上卡尔曼滤波。用filterpy库,维护距离和速度两个状态,对每个目标 ID 单独建一个滤波器。不过多目标场景跟踪对应关系比较复杂,需要配合 ByteTrack 这类追踪器才能保证每个目标的滤波器不串号。如果你的目标只是求快速稳定的距离值,EMA 和中位数已经足够。
4.3 把距离接进业务逻辑:警戒线告警的三种触发方式
测出距离后,最常见的需求是“进入警戒区域触发告警”。这时候不要直接对原始距离做阈值判断,否则大量抖动会造成反复告警。
我建议采用以下三种方式处理:
第一种是滞回判断法。设置两个阈值,比如进入阈值 10 米、退出阈值 12 米。当目标距离小于 10 米才触发告警,大于 12 米才解除告警。这样避免了目标在 10 米附近来回抖动导致的告警闪烁。
第二种是连续触发计数法。连续 3 帧以上距离低于阈值才触发告警,并在告警日志里记录最小距离。这个方案直观且容易实现。
第三种是轨迹速度判断法。结合目标追踪,计算出目标径向接近速度。如果目标只是路过,可能不会触发;如果持续靠近,则提前预警。这个方案需要多目标追踪,但在安防场景中非常有效。
我实际使用的是滞回判断加连续帧确认的组合,误报率可以降得非常低。告警输出到日志后,再接邮件或 Webhook 通知,业务闭环就完成了。
5. 实战中踩过的坑:距离估算常见问题与排查技巧
5.1 距离数值一跳一跳的怎么办
这是单目测距被问得最多的问题。距离不稳定几乎全是检测框宽度不稳定引起的。除了算法平滑外,还要回头检查三点。
第一,检测模型的目标框是否贴合。如果有一帧框住了整个人,下一帧只框住上半身,宽度差异自然巨大。出现这个情况,我一般换更高精度的模型,比如从 yolo11n 换成 yolo11m。
第二,视频是否经过帧率波动或隔行处理。实时视频流偶尔掉帧时,相邻两帧实际跟踪的可能是不同状态下的目标,跳变会加剧。可以在读取视频时做时间戳校验,丢弃异常帧。
第三,是否直接用了原始 bbox 宽度。更稳的做法是使用目标在垂直方向上的投影宽度,或者结合固定宽高比约束。对行人来说,人体高度比肩宽更稳定,如果你能拿到目标的真实高度,可以用 bbox 高度做测距,效果一般比宽度更好。
5.2 参考宽度选错导致系统偏差
参考尺寸法最大的坑在于“你以为你知道目标的真实尺寸”。人肩宽 0.45 米是平均值,但一个 180 斤的人和一个 100 斤的人差距很大。车宽 1.8 米是平均值,但微面和小轿车差出 20 厘米很正常。这个偏差是系统性的,不会因为数据多了就消失。
我的处理方式是对不同子类设定不同宽度。对于行人,可以在数据库里缓存检测到的目标高度,一旦目标连续多帧出现,就动态修正宽度参考值;对于车辆,尽量用车型分类结果,细分轿车、SUV、箱货。如果做不了细分,就在输出距离时附带一个置信区间,而不是给单一数值。
另一个技巧是测量目标的“图像最小外接框”而不是简单检测框。比如一辆侧对相机停放的卡车,检测框宽度可能超过车身实际宽度,导致距离算近。通过 YOLO11 分割模型拿到目标轮廓,再用轮廓求最小外接矩形的宽度,会准确很多。如果预算允许,直接在分割模型上做,效果明显。
5.3 畸变与俯仰角变化对结果的影响
镜头畸变对测距的干扰在画面边缘特别明显。一个目标在画面中心测得 10 米,移到画面边缘后,同样的实际距离可能在公式里变成 12 米。原因就是镜头边缘放大率与中心不一致。遇到这种情况,我强烈建议做畸变校正,尤其是使用广角镜头或监控头的场景。
校正之后还需要关注相机的俯仰角。固定支架如果因为风吹、碰撞后发生微动,哪怕只有 1 度的角度变化,远距离测距结果都会漂移好几米。项目上线后,我会在画面里预留一个参考点,周期性检测背景地面线的位置,一旦偏移超过阈值就提醒重新标定。
说到这,分享一个小技巧:可以在场景中放置一个已知尺寸的静态参考物,比如一个固定尺寸的标志牌。程序每运行 N 帧,用它重新校验和修正 f 值,这样即使镜头或机身发生轻微位移,也能自动纠正测距偏差。
5.4 光线不佳、遮挡与远距离小目标怎么处理
YOLO11 在白天光线充足时表现很好,但监控场景经常遇到逆光、夜间红外、目标被遮挡的情况。检测器一旦漏检或框错,距离计算就无从谈起。
我建议按场景复杂度分梯度处理。低光照条件下,优先保证模型输入帧的质量,做简单的直方图均衡或伽马校正,能显著提升夜间检测率。对远距离小目标,把输入分辨率调高,比如推理时用 1280 而不是默认 640。YOLO11 支持设置推理尺寸:
results = model(frame, imgsz=1280, verbose=False)如果你嫌推理慢,可以试试图像分块推理,把大图切成几个重叠块,每块单独检测再做 NMS 合并,对远处小目标效果提升很大。
遮挡场景下,目标底部经常被栏杆、车辆等物体挡住。此时不能直接用 y2 当作底部中点,否则地面位置估计会偏。我的做法是保留目标的历史底部中点轨迹,用预测位置补充被遮挡的帧;如果目标被遮挡超过一定时长,则暂停测距,等待重新出现后再输出。
6. 写在最后的实际心得
整套方案做完,我最大的感受是:单目测距不是做不到,而是要想清楚误差从哪来。YOLO11 把目标检测的门槛拉得很低,真正耗时间的反而是看似简单的距离计算。参考尺寸的准确性、相机标定质量、平滑算法选择,每一个环节都在影响最终结果。
我给新手的建议非常直接:第一版先用最土的参考尺寸法跑通,用一卷尺量几个真实距离,把误差摸清楚。别一上来就追求单应性、卡尔曼滤波这些高级选项,基础版能稳定工作以后,再逐步加外参修正和跟踪器。技术选型永远是“够用就好,逐步演进”。
这个方案后续还可以扩展很多。接上 ByteTrack 多目标追踪后,不仅可以测距,还能估算目标的运动速度;接上单应性矩阵后,可以画出目标在真实地图中的轨迹。对于很多监控、工业、安放场景,这些低成本视觉方案足够撑起第一版智能告警系统。希望这篇文章能帮你把 YOLO11 的距离计算从“能跑”变成“能干”,少走我踩过的那些弯路。