简介:面向计算机视觉初学者与需要快速复现目标检测测距方案的开发者,这份资源围绕YOLOv5与单目深度估计展开,尝试在仅有单摄像头的条件下把二维检测框映射为三维距离,可服务于自动驾驶感知、机器人导航与智能监控等场景。压缩包共132个文件,约13.87MB,以40个yaml配置、34个Python脚本和16个pyc缓存为主,辅以yml、sh、md、Docker配置文件及预训练权重,整体覆盖模型配置、训练测试、环境部署与说明文档等环节。已有3868人学习,说明该方向具备一定关注度。读者可据此梳理YOLOv5检测与单目测距的衔接方式,了解预训练权重调用、训练验证脚本组织、Docker环境搭建和结果可视化思路,并借助现有目录结构快速定位配置与代码,减少从零搭建和排查环境的时间。
1. 单目测距先别急着上双目:YOLOv5 的 bbox 已经够量出八成距离需求
一段路测视频里,前方轿车大概 20 米,脚本报 47 米;换一帧,又变成 12 米。这种来回跳的 YOLOv5 单目测距结果,几乎每个第一次把 bbox 和焦距凑到一起的人都会遇到。单目测距的核心从来不是检测,是几何——bbox 只是一个像素量具,真正决定三维测距精度的,是内参、目标先验尺寸、坐标还原这三件事有没有对齐。这份 Python 资源把 YOLOv5 推理和单目测距串成一条完整链路,适合做车辆前向碰撞预警、行人距离提示、机器人近场感知这类不需要毫米级精度的场景。如果你手上有标定板、有一段相机安装角度固定的视频,半天之内就能把整条链路跑通,剩下的误差全在参数标定上。
2. 相机内参与目标先验:单目测距的两块地基怎么打
单目测距和双目测距的差别不在算法花哨程度,而在信息补全方式。双目靠基线硬算视差,单目没有第二只眼睛,只能借用先验:要么借用相机的内参把像素换算成角度,要么借用目标的真实尺寸把像素长度换算成物理长度。这两件事任何一件没对齐,后面的公式再漂亮也没用。常见做法是先做一次棋盘格标定,把焦距、主点、畸变系数固定下来,再按类别配置一张目标尺寸先验表,最后在推理链路里保证 bbox 坐标是原始图像尺度。顺序错了,后面每一步都在放大误差。
2.1 棋盘格标定拿到 fx/fy,别再拿 FOV 反推凑数
很多人嫌标定麻烦,直接查一下相机规格书里的水平视场角,用fx = W / (2 * tan(HFOV/2))反推。这个公式本身没错,但规格书标的 FOV 通常是标称值,量产镜头个体差异几个百分点很常见,反推出来的fx和真实值差 3%~5%,距离就整体偏 3%~5%。近距离还能糊弄,一旦上到 30 米以上,这个偏差就压不住了。
标定不需要专业设备,一张打印的棋盘格贴在硬纸板上,用同一台相机在十几个不同姿态下拍一遍就行。OpenCV 的calibrateCamera一次算完内参和畸变:
import cv2 import numpy as np import glob # 棋盘格内角点数(9 列 6 行),不是格子数 pattern_size = (9, 6) square_size = 0.025 # 单格边长,单位米,必须和打印时一致 objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) * square_size obj_points, img_points = [], [] for path in sorted(glob.glob("calib/*.jpg")): img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ok, corners = cv2.findChessboardCorners(gray, pattern_size, None) if not ok: continue # 亚像素精化,直接影响 fx/fy 的收敛精度 corners = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) ) obj_points.append(objp) img_points.append(corners) # 返回内参矩阵 K、畸变系数 dist、每张图的外参和重投影误差 rms, K, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print("重投影误差 rms = %.4f px" % rms) print("fx=%.2f fy=%.2f cx=%.2f cy=%.2f" % (K[0, 0], K[1, 1], K[0, 2], K[1, 2])) print("dist =", dist.ravel()) np.savez("camera_params.npz", K=K, dist=dist)代码里有两个参数最容易翻车。pattern_size填的是内角点数,一张 10×7 的格子,内角点是 9×6,填错一个数字整张图都检测不到。square_size必须和实际打印尺寸对得上,打印时缩放 90%,标出来的平移量就整体小 10%,虽然对单目测距最关键的内参fx/fy影响不大,但如果你还要用外参做手眼标定,这个误差会直接传导过去。
标定完先看重投影误差rms。低于 0.3 px 说明这批图质量不错,0.5 px 左右算合格,超过 1 px 一般是棋盘格没铺平、或者某些图姿态过于单一。常见的补救方式是补拍斜 30° 到 60° 的几张,让标定矩阵的各个方向都能被约束到。跑完标定,把K和dist存成 npz,后面推理脚本只读这个文件,不要在代码里硬编码焦距。
2.2 目标真实尺寸先验表:同一类别的方差比想象的大
拿到了fx/fy,第二块地基是目标的真实尺寸。相似三角形法就一个除法:Z = fy * H_real / h_px,其中H_real是目标真实高度,h_px是 bbox 的像素高度。整个公式里H_real是唯一一个凭经验拍的值,它的准确度直接决定距离的绝对偏差。
按类别配置一张表,不要所有目标套同一个高度:
| 类别 | 参考维度 | 推荐值(m) | 取值区间(m) | 说明 |
|---|---|---|---|---|
| person | 身高 | 1.70 | 1.55–1.85 | 儿童、蹲姿、弯腰误差极大 |
| car | 车高 | 1.50 | 1.40–1.65 | SUV、MPV 偏上限 |
| truck | 车高 | 3.20 | 2.80–3.80 | 轻卡到重卡跨度大 |
| bus | 车高 | 3.10 | 2.90–3.40 | 城市公交与长途客车接近 |
| bicycle | 车高 | 1.10 | 1.00–1.30 | 骑行者姿态影响大 |
这张表里有一个取舍:用高度还是用宽度。行人和车辆从正面看,宽度受手臂摆动、后视镜、车门开合影响,高度相对稳定,所以默认用y2 - y1。从侧面看就不一样了,车辆的宽度变成车身长度,先验值完全对不上,这时候要么按朝向切换维度,要么直接放弃这一类视角。实际项目里我一般会按类别配一个维度开关,side_view_classes里的类走宽度,其余走高度。
先验值不是死数。同一类目标在画面里的尺寸方差是固定的物理事实,一个 1.55 米的人和 1.85 米的人,在 20 米处h_px差 20%,距离就跟着差 20%。这不是算法能消除的,只能通过限制适用距离区间来规避:行人测距稳定区间通常在 3~15 米,车辆在 5~40 米,再远就只输出一个粗档位,不输出精确米数。
2.3 letterbox 还原:bbox 坐标不还原,距离全错
YOLOv5 的推理预处理是 letterbox:把原图按比例缩放到 640×640,不足的部分用灰边填充。模型输出的 bbox 是在这个 640×640 画布上的坐标,如果直接拿它算h_px,遇到宽高比不是 1:1 的输入,误差能到几十个百分点。
YOLOv5 官方utils/general.py里的scale_boxes就是把 letterbox 坐标还原回原图的标准函数。自己写也不复杂,核心是三行:
import numpy as np def unletterbox(boxes, ratio, pad, orig_shape): """ boxes: Nx4 的 xyxy,位于 letterbox 后的画布坐标 ratio: (rw, rh),原图缩放到目标尺寸的缩放比 pad: (pad_w, pad_h),左右和上下的填充像素 orig_shape: (h0, w0),原图高宽 """ boxes = boxes.copy().astype(np.float32) boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad[0]) / ratio[0] boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad[1]) / ratio[1] # 还原后可能超出原图边界,必须裁回合法范围 boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, orig_shape[1] - 1) boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, orig_shape[0] - 1) return boxesratio和pad必须在做 letterbox 的时候同步记录下来,不要事后反推。ratio = min(new_h / h0, new_w / w0),pad_w = (new_w - w0 * ratio) / 2,pad_h = (new_h - h0 * ratio) / 2,单位都是像素。最后的clip不能省,模型有时候会预测出负坐标或超出画布的框,不裁掉的话后面h_px会算出一个比实际更大的值,距离整体偏近。
这一步做完,所有下游公式都工作在原图像素坐标系里,cx/cy也是按原图分辨率取的,链路才自洽。换输入分辨率的时候,要么重新标定,要么把K按new_w / calib_w等比缩放,cx/cy一起缩,别只改fx。
3. 两条测距路线:相似三角形与地面投影怎么选
bbox 还原到原图之后,测距本身反而简单了,主流就两条路线。一条是相似三角形法,靠目标先验尺寸;另一条是地面投影法,靠相机安装高度和俯仰角。两条路线没有绝对优劣,适用场景差别很大,选错了不是精度问题,是根本算不出来。搞清楚每条路线的边界条件,比背公式重要得多。
3.1 相似三角形法:一行除法,四条边界
公式简单到一行代码:Z = fy * H_real / h_px。推导就是小孔成像,目标高度H_real在距离Z处投影到像平面的像素高度h_px = fy * H_real / Z,反解一下就是距离。这个路线不需要知道相机装多高、朝哪个方向,只依赖内参和先验尺寸,所以对安装姿态没有任何要求,手持、车载、监控都能用。
def distance_by_height(bbox, K, real_h, min_h_px=12): """ bbox: (x1, y1, x2, y2),原图像素坐标 K: 3x3 内参矩阵 real_h: 目标真实高度,单位米 min_h_px: 像素高度下限,低于此值判为超量程 """ h_px = bbox[3] - bbox[1] if h_px < min_h_px: return float("inf") # 太小,分母接近 0,距离不可信 return K[1, 1] * real_h / h_px四条边界值得单独说清楚。
第一条是h_px下限。像素高度越小,距离对h_px的敏感度越高:h_px从 100 变成 99,距离只偏 1%;从 12 变成 11,距离直接偏 9%。所以必须设阈值,低于阈值的直接返回inf或者一个超量程标记,别让上层逻辑拿着一个假数据当真的用。
第二条是 bbox 贴边。目标走出画面或者被画面边缘切掉一半的时候,bbox 高度被强制截断,h_px比真实值小,距离就会偏大。判断方式是看y1是否等于 0 或y2是否等于图像高度减一,命中就标记为截断,降级处理。
第三条是姿态变化。行人蹲下、车辆开门、骑行者身体前倾,物理高度都变了,但 YOLOv5 的类别标签不变。这类误差没法从单帧里识别出来,只能靠多帧一致性校验过滤,后面 4.3 会讲。
第四条是类别混淆。person标签下可能是 1.2 米的儿童,也可能是 1.9 米的成年人,先验值取 1.70 米的时候,两头误差都在 ±12% 左右。这个误差量级在 15 米以内还能接受,再远就进入不可用区间。
工程上这条路线适合做行人、靠近的车辆、静止障碍物,这类目标高度稳定、姿态变化有限。对空中目标、悬挂物、被部分遮挡的目标,相似三角形法基本没法用,因为没有稳定的先验高度。
3.2 地面投影法:用接地点和俯仰角,车载场景更稳
地面投影法换了一个思路:不去猜目标多大,而是看目标踩在地上的那个点在画面里的位置。相机高度固定、俯仰角固定,画面里某一个像素 y 坐标对应地面上的唯一距离,跟目标本身尺寸无关。
推导也不复杂。相机光轴与水平面夹角为pitch(向下为正),目标接地点像素y_b相对于主点cy的偏移是dy = y_b - cy,这条光线与光轴的夹角是β = atan(dy / fy),那么光线相对水平面的总下倾角就是α = pitch + β。相机离地高度h_cam,地面平坦的假设下,水平距离就是h_cam / tan(α)。
import numpy as np def distance_by_ground(bbox, K, cam_h, pitch_rad): """ bbox: (x1, y1, x2, y2),原图像素坐标 K: 3x3 内参矩阵 cam_h: 相机光心离地高度,单位米 pitch_rad: 相机俯仰角,向下为正,单位弧度 """ y_b = bbox[3] # 接地点:bbox 底边 dy = y_b - K[1, 2] if dy <= 1: return float("inf") # 在光心以上,光线打不到地面 beta = np.arctan2(dy, K[1, 1]) alpha = pitch_rad + beta if alpha <= 0: return float("inf") # 光线朝上,同样打不到地面 return cam_h / np.tan(alpha)这里有三处需要特别当心。
第一是接地点选谁。bbox 底边中点是最常用的,但只对正对镜头、脚踩实地的目标有效。侧向车辆的话,底边中点落在两个轮子之间,实际接地位置应该取 bbox 底边靠下的那个角。行人弯腰的时候,bbox 底边可能落在手或者脚上,距离会偏近。比较稳的做法是按类别选接地点:person用底边中点,car/truck/bus用底边两个角中距离更远的那个(也就是更大的y值)。
第二是俯仰角方向约定。pitch向下为正、向上为负,这个约定必须和相机安装时的测量方式一致。如果安装时相机微微上仰(拍远景的常见姿态),pitch是负值,公式里alpha可能变负,光线打不到地面,函数返回inf。这时候正确的处理是承认超量程,不要硬掰成 0 或者加一个绝对值上去。
第三是地面平坦假设。坡道、减速带、桥面上,地面高度变化几十厘米,距离就会偏。h_cam如果是 1.2 米,地面下沉 20 厘米,距离偏差接近 17%,跟相似三角形法的误差量级差不多。所以这条路线的精度上限不在公式,而在地面模型的贴合度。
优势也很明显:不依赖目标尺寸先验,一个 1.2 米的儿童和一个 1.9 米的成年人,只要站在同一个距离上,地面投影法算出来就是同一个值。车载 ADAS 的近场预警大量用这个思路,就是看中它对方形、圆形、不规则目标一视同仁。
3.3 两条路线的横向对比与混用条件
两条路线放在一起对比,边界立刻清楚:
| 对比维度 | 相似三角形法 | 地面投影法 |
|---|---|---|
| 核心依赖 | 目标真实尺寸先验 | 相机安装高度 + 俯仰角 |
| 适用目标 | 尺寸稳定的行人和车辆 | 有明确接地点的贴地目标 |
| 近处精度 | 一般 | 高 |
| 远处精度 | 随距离线性下降 | 随距离快速下降 |
| 目标被截断时 | 直接失效 | 接地点可见就还能算 |
| 相机姿态变化 | 不敏感 | 高度敏感 |
| 典型场景 | 监控、手持、固定机位 | 车载前视、机器人底盘 |
选型没有银弹。工程上更常见的做法是两条路线都实现,按距离区间和类别做加权融合。近处(10 米以内)以地面投影法为主,远处(10 米以外)相似三角形法误差增长更平缓,可以让它主导。融合权重可以简单到只用一个分段线性函数:
def fuse_distance(z_tri, z_ground, cut=10.0, blend=2.0): """两条路线都在有效区间内时按距离线性加权""" if not np.isfinite(z_ground): return z_tri if not np.isfinite(z_tri): return z_ground if z_ground < cut - blend: return z_ground if z_ground > cut + blend: return z_tri # 过渡区按比例混合 w = (z_ground - (cut - blend)) / (2 * blend) return z_ground * (1 - w) + z_tri * wcut是切换中心距离,blend是过渡带宽度,这两个值需要根据实际场景调。城市道路切在 10 米左右合适,高速场景要往 25 米以上挪。融合之后还要过一个门限,两条路线差异超过 50% 的时候大概率是某一个先验错了,宁可输出中位值也不要直接取平均。
4. 把测距接进 YOLOv5 推理链路:脚本改造与稳定性处理
公式跑通只是第一步,真正上项目要把测距嵌进推理脚本,还得处理可视化、置信度过滤、多帧平滑这一堆工程细节。YOLOv5 官方的detect.py虽然功能完整,但默认输出只有框和类别,没有距离字段。比较稳的做法是复制一份改造成detect_distance.py,保留原有的模型加载、NMS、结果保存逻辑,只在 bbox 处理之后插一段测距代码,改动量控制在三十行以内,后续 YOLOv5 版本升级也容易同步。
4.1 从 detect.py 派生 detect_distance.py 的最小改动
改造的核心是拿到原图尺度的 bbox 之后,按类别查配置,选测距方法,把结果塞回结果结构里。模型加载、letterbox、NMS 这些基础部分完全沿用官方实现,只在后处理阶段动刀:
import cv2 import numpy as np import torch from models.common import DetectMultiBackend from utils.augmentations import letterbox from utils.general import non_max_suppression, scale_boxes # 类别 -> (测距方法, 先验参数) DIST_CONFIG = { "person": ("height", 1.70), "car": ("ground", None), "truck": ("ground", None), "bus": ("ground", None), "bicycle": ("height", 1.10), } CAM_H = 1.20 # 相机光心离地高度,米 PITCH_DEG = 4.0 # 相机俯仰角,向下为正 def run(img_path, weights="yolov5s.pt", conf_thres=0.35, iou_thres=0.45): model = DetectMultiBackend(weights, device="cpu") stride, names = model.stride, model.names img0 = cv2.imread(img_path) # letterbox 同时返回 ratio 和 pad,后面还原坐标要用 img, ratio, pad = letterbox(img0, 640, stride=stride, auto=True) img = img.transpose((2, 0, 1))[::-1] img = np.ascontiguousarray(img) img = torch.from_numpy(img).float().div(255).unsqueeze(0) pred = model(img) pred = non_max_suppression(pred, conf_thres, iou_thres) K = np.load("camera_params.npz")["K"] pitch_rad = np.deg2rad(PITCH_DEG) results = [] for det in pred: if not len(det): continue # 关键一步:把 bbox 还原到原图坐标系 det[:, :4] = scale_boxes(img.shape[2:], det[:, :4], img0.shape).round() for *xyxy, conf, cls in det: xyxy = [float(v) for v in xyxy] name = names[int(cls)] method, prior = DIST_CONFIG.get(name, ("height", 1.5)) if method == "height": z = distance_by_height(xyxy, K, prior) else: z = distance_by_ground(xyxy, K, CAM_H, pitch_rad) results.append({ "bbox": xyxy, "conf": float(conf), "cls": name, "distance": z, "method": method, }) return img0, results改动集中在三处。第一处是letterbox的返回值,官方版本只取第一项图像,这里要把ratio和pad一起接住,否则scale_boxes没法正确还原。第二处是scale_boxes的位置,必须放在 NMS 之后、进入测距循环之前,放在 NMS 之前会被 NMS 内部还原一次,坐标就乱了。第三处是DIST_CONFIG,类别到测距方法的映射不要写死在 if 分支里,做成字典方便后面扩展新类别。
conf_thres和iou_thres这两个参数对测距稳定性影响很大。置信度阈值从 0.25 提到 0.4,bbox 抖动会明显减小,代价是漏检增加。工程上我一般把阈值定在 0.35 到 0.45 之间,宁可少测几个目标,也不要测出一个忽远忽近的假数据。
4.2 输出结构与可视化:距离分档、置信度过滤、无效值标记
测距结果不能只写几个数字,要带上方法标记和有效标记,上层业务才敢用。可视化的意义不只是好看,它是排查问题的第一现场:框画歪了、距离标错了、某个目标距离一直不变,肉眼一眼就能看出来。
分档配色是最省事的方案,按距离切三个档位,绿黄红对应近中远:
DIST_LEVELS = [ (0.0, 10.0, (0, 200, 0)), # 近,绿 (10.0, 30.0, (0, 200, 255)), # 中,橙 (30.0, float("inf"), (0, 0, 220)), # 远,红 ] def color_for(z): if not np.isfinite(z): return (180, 180, 180) # 无效值用灰色 for lo, hi, c in DIST_LEVELS: if lo <= z < hi: return c return (180, 180, 180) def draw(img, item): x1, y1, x2, y2 = map(int, item["bbox"]) z = item["distance"] c = color_for(z) cv2.rectangle(img, (x1, y1), (x2, y2), c, 2) if np.isfinite(z): text = "%s %.1fm" % (item["cls"], z) else: text = "%s --" % item["cls"] # 框贴顶时把文字往下挪,别被画布裁掉 ty = y1 - 8 if y1 - 8 > 12 else y1 + 20 cv2.putText(img, text, (x1, ty), cv2.FONT_HERSHEY_SIMPLEX, 0.6, c, 2, cv2.LINE_AA) return img--表示无效距离,不能省。很多下游逻辑就靠这个标记来决定跳过还是降级,把它藏起来输出一个 0 或者一个超大的数,反而会让上层误判。inf进可视化之前必须转成标记,浮点inf在部分绘图库上会直接抛异常。
标签文字的位置也要处理一下。bbox 贴到画面顶部的时候,y1 - 8是负值,putText会把字画到画布外,目视排查时看着就像漏画了。上面的三元表达式把这种情况转到框内侧,代价是遮挡一部分目标,但至少能看见。
4.3 时序平滑:EMA 与连续帧一致性校验
单帧测距的抖动是最劝退的部分。同一辆车,相邻两帧能差 5 米,原因不是模型差,是 bbox 高度天然就有一两个像素的抖动,20 米处一个像素对应 20 厘米,两个像素就是 40 厘米,放大到远处更夸张。单帧解决不了这个问题,必须用时序信息。
最轻量的做法是 EMA 平滑,按目标 ID 维护一个状态表,每帧用新旧值的加权和更新:
class EMATracker: def __init__(self, alpha=0.4, max_gap=5): self.alpha = alpha # 新值权重,越小越平滑 self.max_gap = max_gap # 超过这么多帧没出现就丢弃 self.state = {} # tid -> (z, last_frame) def update(self, tid, z, frame_idx): if not np.isfinite(z): return self.state.get(tid, (z, frame_idx))[0] prev = self.state.get(tid) if prev is None: z_smooth = z else: z_prev, last_f = prev if frame_idx - last_f > self.max_gap: z_smooth = z # 断档太久,重新起算 else: z_smooth = self.alpha * z + (1 - self.alpha) * z_prev self.state[tid] = (z_smooth, frame_idx) return z_smoothalpha的取值在 0.3 到 0.5 之间比较合适,0.3 平滑效果明显但跟随慢,遇上前车急刹会有半秒到一秒的滞后;0.5 响应快但抖动抑制有限。实际项目里按场景调,高速上前车距离变化慢,可以压到 0.3;城市跟车启停频繁,拉到 0.5。
ID 从哪里来?YOLOv5 原生不带跟踪器,两个简单方案:一是接一个轻量跟踪器(ByteTrack 之类),二是用 IoU 做帧间匹配,上一帧的框和这一帧的框 IoU 大于 0.3 就认为是同一个目标。第二种实现简单、够用,缺点是目标交叉时容易串号,导致两个目标的平滑状态互换,距离突然跳变。所以平滑之后还要加一道一致性校验:如果某个目标的平滑后距离在单帧里变化超过 40%,大概率是 ID 串了或者 bbox 坏了,把这一帧的原始观测丢弃,保留上一帧的输出。
另一个容易忽略的细节是断档处理。目标被遮挡几帧之后重新出现,跟踪状态里还留着几秒前的旧值,如果直接拿旧值做 EMA,会把新出现的距离硬拽回旧值附近。上面的max_gap就是防这个的,超过阈值直接重新起算,不继承旧状态。
5. 避坑与排查:五个让距离数值失真的具体场景
测距链路跑起来只是开始,真正花时间的是排查。绝大多数错误都能归到两类:一类是数值本身不对(跳变、爆表、恒定),另一类是坐标或标定没对齐(框对了距离错、换了分辨率全乱)。每一类都有几个最常踩的坑,把现象和原因对上号,排查效率能提高一大截。
5.1 距离数值类异常:跳变、爆表、恒为零
坑一:距离逐帧跳变超过 30%。
现象是同一辆车在连续帧里报出 18 米、24 米、20 米,肉眼完全没法判断哪个准。
原因是 bbox 高度在两三帧之间抖动了两三个像素。20 米处 1.5 米高的车,h_px大概 100 像素,膨胀两个像素误差就是 2%,看起来不多;但检测框在目标边缘上的抖动是非线性的,遇到反光、遮挡、运动模糊,一下子可能涨落 10 个像素,距离就跟着跳 10%。
解决分两层。预测层把conf_thres提到 0.4 以上,只保留高置信度框;后处理层套 4.3 的 EMA 平滑,alpha取 0.4。如果还有明显跳变,检查一下输入分辨率是不是太低,把推理尺寸从 640 提到 960,小目标 bbox 的稳定性会好一截。
坑二:目标距离恒为inf或负数。
现象是画面上明明有车,标签却显示--,或者干脆显示一个负数。
原因通常出在地面投影法。y_b落在主点cy之上,dy为负,alpha算出负值,光线打不到地面,函数返回inf。如果代码里没有对alpha <= 0做判断,直接拿h_cam / tan(alpha)算,tan在小负角度是一个很小的负数,除出来就是一个巨大的负数。
解决是补上alpha <= 0和dy <= 1两道判断,命中就返回inf,把它当超量程处理。如果整段视频大量出现这个情况,说明俯仰角符号搞反了,或者相机安装高度填错了。用一个已知距离的标志物反向验证一下,5 米处的目标用正确参数应该落在 4.5 到 5.5 米之间,落在区间外先查参数别改公式。
坑三:远处小目标的距离直接爆表。
现象是 50 米外的车报出 300 米,或者报出一个完全不合理的大数。
原因是h_px太小,分母逼近 0。40 米处一辆车,h_px可能只有 15 到 20 像素,模型框上下各多一个像素,h_px就从 18 变成 20,距离直接缩水 10%;反过来少一个像素就放大 10%。检测框在这么小的目标上精度极差,h_px的误差可能到 30% 以上。
解决有三条。一是设min_h_px,低于阈值(一般取 12 到 15 像素)直接返回inf,承认这段距离不可测。二是限制适用距离区间,相似三角形法在 40 米以外基本没有工程价值,索性把上限卡在 35 米。三是远处改用分档输出,35m+这样的粗粒度标注比一个假精确的 47.3 米更有用。
5.2 坐标与标定类异常:框对了但距离不对
坑四:换了输入分辨率,所有距离翻倍或减半。
现象是同一段视频,改了一下推理尺寸,距离整体变成原来的 2 倍。
原因是内参没有跟着缩放。fx/fy/cx/cy是在某个标定分辨率下得到的,推理时如果先把图 resize 到别的尺寸再送进模型,bbox 还原回的是一个和标定分辨率不同的坐标系,直接用原始K算出来的角度就是错的。
解决是按缩放比同步调整内参:fx' = fx * s、fy' = fy * s、cx' = cx * s、cy' = cy * s,s = 实际宽 / 标定宽。工程上更稳的做法是不缩放内参,把 bbox 先还原到标定分辨率对应的坐标系里再算距离,这样K永远只有一份,不容易错。
坑五:画面边缘的目标距离系统性偏大。
现象是画面中央的目标距离看着挺准,靠近左右边缘的目标普遍偏大 10% 到 20%。
原因是镜头畸变没校正。广角镜头边缘的桶形畸变会把目标在像素上拉伸,bbox 高度虚高,相似三角形法算出来的距离就偏小;但如果拉伸发生在宽度方向而不是高度方向,又会出现偏大的情况,具体方向取决于畸变类型和目标的画面位置。
解决很简单,推理之前先跑一遍cv2.undistort,用标定得到的dist系数校正:
K = np.load("camera_params.npz")["K"] dist = np.load("camera_params.npz")["dist"] img0 = cv2.undistort(img0, K, dist, None, K)注意undistort的第五个参数传K本身,表示输出图像沿用输入的内参,不做额外缩放。校正之后边缘目标的 bbox 高度会明显回正,这一步在 100° 以上的广角镜头上是必须的,普通镜头也建议做,代价只有几毫秒。
6. 验证与调优:拿一段已知距离的视频反推误差
所有参数配好之后,别急着上真实业务。找一段开阔场地,在地上每隔 5 米放一个标志物,从 5 米摆到 50 米,相机按实际安装高度和角度架好,录一段视频跑一遍测距。这一步的作用是把所有隐性的误差显式化:先验尺寸偏了多少、俯仰角差了几度、地面平不平,全能在这张表上看出来。
| 真实距离 | 相似三角形法输出 | 地面投影法输出 | 相似三角形误差 | 地面投影误差 |
|---|---|---|---|---|
| 5 m | 4.6 m | 5.2 m | -8.0% | +4.0% |
| 10 m | 9.4 m | 10.4 m | -6.0% | +4.0% |
| 20 m | 18.2 m | 21.6 m | -9.0% | +8.0% |
| 30 m | 26.8 m | 35.2 m | -10.7% | +17.3% |
| 40 m | 34.5 m | 52.0 m | -13.8% | +30.0% |
这张表是模拟数据,但误差趋势是真实的。相似三角形法在全程都是负偏,说明先验尺寸取小了,或者 bbox 系统性偏大;地面投影法近处准、远处误差迅速发散,符合它的物理特性。两条路线在 20 米附近交叉,这就是融合切换点该放的位置。
先验尺寸的校准有一个很实用的技巧:拿一个真实距离已知的目标,反推它在该类别下的等效高度,多测几个取平均,作为这一批相机、这一类目标的实际先验值。
def calibrate_prior(z_true, bbox, K): """用已知距离的真实目标,反推它的等效物理高度""" h_px = bbox[3] - bbox[1] return z_true * h_px / K[1, 1] # 例:已知标志高度 1.20 米,摆在 15 米处 # bbox 实测 h_px = 63.5,fy = 800 # 反推 H = 15 * 63.5 / 800 = 1.19 米,跟实际吻合,说明内参可靠反过来也能验证内参。已知目标高度和距离,反推出来的fy和标定值差 5% 以上,问题多半在标定或者坐标还原上,别急着改先验值,先回去查scale_boxes有没有漏掉。
俯仰角的校准更简单,找一个 10 米外的标志物,拿它的顶边或底边像素位置,强行解出能让距离等于 10 的那一个pitch值,多试几次取平均。校准之后,10 米和 20 米两个点的误差应该在 5% 以内,超过这个数说明相机高度或者画面主点有问题。
需要说清楚的是适用边界。单目测距所有方案的精度上限都受先验尺寸方差约束,行人这一类本身就有 ±10% 的物理差异,任何算法都不可能突破。把适用区间卡死、超量程返回分档标记、多帧平滑之后再做业务判断,才是真正能落地的形态。
从那以后我每次写测距脚本,都会先把scale_boxes、undistort、K的缩放比这三行打印出来,确认坐标、畸变、内参三个环节都对齐了再往下写逻辑。这三处只要有一处漏掉,后面调参全是无用功。希望这套排查顺序能帮到你,少走几段我当年踩过的弯路。
本文还有配套的精品资源,点击获取