资讯详情

OpenCV+Python单目测距:标定、滤波与误差控制

📅 2026/10/24 4:17:21 | 华诺云谱 👁 阅读
OpenCV+Python单目测距:标定、滤波与误差控制
简介面向Python与计算机视觉初学者的测距实践项目围绕OpenCV实现基于三角形相似度的单目相机测距先利用已知物体的实际宽度及其在设定距离下的像素宽度求取焦距再在新图像中检测同一标记物根据其像素尺寸反推相机到物体的距离适合课堂实验、视觉入门与小型测量原型验证。压缩包共2个文件含1个Python主脚本与1份Markdown说明整体约2KB主脚本承担图像读取、标记检测、焦距计算和距离输出说明文档交代依赖包、运行方式及图像列表初始化位置便于按代码脉络理解算法实现。已有761人学习可从中梳理焦距标定、标记物像素宽度提取与测距结果换算的关键环节并参考参数设置和检测流程排查常见偏差。代码结构精简算法实现与使用说明分置适合先读说明再逐段调试从而完成从标定到测距的闭环练习。1. 一个摄像头的测距上限先把「能测多准」问清楚装一只普通 USB 摄像头问它前面那个人离镜头几米——答案覆盖几厘米到两米的范围而非简单的「行」或「不行」3 米内误差能压到几厘米8 米内还能守在 ±0.3 米20 米外读数飘 ±2 米都不稀奇。这套基于 OpenCV 和 Python 的单目测距实现做的就是把这条劣化曲线尽量往下压针孔模型负责把像素尺寸翻译成物理距离棋盘格标定把焦距钉死检测框高度代替宽度去抵抗目标姿态变化卡尔曼滤波再把逐帧抖动磨平。它适合机器人避障、货位测距、前向碰撞预警的原型验证也适合当视觉课程的动手案例不适合拿去替代激光雷达做安全冗余。读下去你会知道参数怎么给、误差从哪来、哪一步最容易翻车。2. 针孔模型与内参标定焦距、真实尺寸、像素三者怎么咬合单目测距的整套推算本质上只有一个公式在反复变形。把这一步吃透后面所有代码都只是给它套上工程外壳。2.1 相似三角形的推导与误差传递针孔模型下物体在成像平面上的像素宽度w、物体真实宽度W、镜头焦距f单位是像素不是毫米、物体到光心的距离Z满足w / f W / Z Z f * W / w推导很朴素真实世界里的宽度W投影到焦平面上按f / Z的比例缩小。这里有个初学者最容易栽的点——f必须是像素单位。相机标定给出的内参矩阵里fx、fy就是这个量物理焦距毫米除以像元尺寸毫米/像素才得到它。对公式两边取对数再微分相对误差是可加的dZ / Z dW / W dw / w这条式子决定了方案的精度上限。真实尺寸W只要查表误差能控制在 3% 以内但dw / w会随目标变远急剧恶化。取f 800 px、W 1.8 m普通轿车车宽Z 1440 / w像素宽度 w估算距离 Z每变化 1 px 引起的距离变化180 px8.0 m0.04 m90 px16.0 m0.18 m60 px24.0 m0.39 m36 px40.0 m1.08 m看出来了吗40 米处框宽抖一个像素读数就跳一米。所以这套方案在工程上的合理区间通常是 115 米超过这个范围要么换长焦、要么换双目或深度相机。我会在代码里直接加一条「最小像素阈值」低于阈值不输出距离宁可没有读数也别给错读数。2.2 用棋盘格标定拿内参不标定直接用厂商给的视场角反算焦距是一条血泪经验铺成的歪路。同一型号的摄像头镜头装配公差能让实际焦距差 5%投到 10 米外就是半米误差。老实标一次十分钟的事。准备一张棋盘格内角点数建议 9×6用待标定的相机在 0.43 米范围内拍 2030 张不同角度、不同位置的图覆盖画面四角和中心。import glob import cv2 import numpy as np # 棋盘格内角点数量列, 行数的是黑白格交点的个数不是格子数 PATTERN (9, 6) SQUARE_SIZE 0.025 # 单格实际边长单位米用尺子量 objp np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_SIZE obj_points, img_points, shape [], [], None 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, None) if not ok: print(角点没找到丢弃:, path) continue # 亚像素精修这一步能把角点定位从 ±0.5 px 提到 ±0.1 px 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) shape gray.shape[::-1] rms, K, dist, _, _ cv2.calibrateCamera(obj_points, img_points, shape, 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])) np.save(K.npy, K) np.save(dist.npy, dist)几个参数值得说明。PATTERN数错了findChessboardCorners会一直返回 False这是最高频的翻车点8×6 的格子对应的是 7×5 个内角点。SQUARE_SIZE用米整条链路就统一在米制下别中途换厘米。rms是重投影误差低于 0.5 px 属正常超过 1 px 说明有模糊图或者拍得太斜回去挑图。标定完把K、dist存成文件线上运行直接加载别每帧都算。顺带说一句dist畸变系数在某些场景下必须用广角镜头边缘的画面拉伸会让框宽虚高换算出来的距离系统性偏近。水平视场角超过 90 度的镜头建议先cv2.undistort再进测距流程。2.3 没有标定板时的两点标定法现场没有棋盘格也有退路。把f和W合成一个未知量k f * W于是Z k / w。找一个尺寸已知的物体比如一个身高 1.7 米的人让他在两个确切的距离上各站一次量出像素高度就能反解k。import numpy as np def fit_k(samples): samples: [(卷尺实测距离 m, 该距离下的像素高度 px), ...]至少两组 d np.array([s[0] for s in samples], dtypenp.float64) w np.array([s[1] for s in samples], dtypenp.float64) # Z k / w k Z * w多组取均值即为最小二乘解 k float(np.mean(d * w)) resid d - k / w # 逐点残差正数代表估远了 return k, resid k, resid fit_k([(3.0, 462), (6.0, 228), (9.0, 156)]) print(有效焦距-尺寸积 k %.1f % k) print(逐点残差(米):, np.round(resid, 3))这个做法的好处是把「焦距标定误差」和「目标真实尺寸误差」合并成一个系数反而更贴合实际场景——因为很多时候你要测的不是抽象物体而是固定那一类目标。坏处是k跟分辨率、跟目标类别强绑定换相机、换类别都得重标。残差那行别偷懒不打如果三组点的残差符号一致比如全是正的说明你量的像素高度有系统性偏差多半是框把脚尖或头顶切掉了。3. 从检测框到米OpenCV 测距流水线的落地写法有了内参剩下的活是把「检测框」这个二维矩形变成一维的距离数字。这一步的工程细节比公式本身多得多。3.1 选高度还是选宽度bbox 的四个几何约定同样是框用宽还是用高结果能差一倍。我一般遵循四条约定第一优先用高度。行人侧身走的时候宽度能缩一半高度只受姿态影响站直和走路差别在 5% 以内。车辆也是同理正面对着你时宽度是真的斜着开就虚了。第二框必须贴合。检测器输出的框通常带一点余量尤其是底部——脚部和地面阴影容易被框进去导致高度虚高、距离估近。可以对底部做几像素的收缩补偿或者干脆用「框底中点」作为接地点配合地面假设这一招在第 4 章会详细写。第三被画面边缘截断的框一律丢弃。目标走出画面时框高只剩一半算出来的距离会直接翻倍。判断方式很简单x1 1或y2 H - 1就认为贴边。第四宽高比做一次合法性校验。人对车、车对人宽高比是明显异常的一个h/w 1.2的「行人」多半是两个人黏在一起或者框错了这种数据进滤波器只会污染状态。3.2 完整可运行代码视频流到距离输出下面这段是能直接跑的骨架检测器部分留成接口用现成的单阶段检测模型接进去就行。import cv2 import numpy as np K np.load(K.npy) fx, fy float(K[0, 0]), float(K[1, 1]) cx, cy float(K[0, 2]), float(K[1, 2]) # 类别 - (真实宽度 m, 真实高度 m)拿不准的量三次取中位数 REAL_SIZE { person: (0.50, 1.70), car: (1.80, 1.50), truck: (2.40, 3.00), } MIN_PX 28 # 像素高度低于此值不输出距离 MAX_RATIO_DIFF 0.30 # 高估与宽估偏差超过 30% 视为框不可信 def make_kalman(): kf cv2.KalmanFilter(2, 1) # 状态 [距离, 距离变化率] kf.transitionMatrix np.array([[1, 1], [0, 1]], np.float32) kf.measurementMatrix np.array([[1, 0]], np.float32) kf.processNoiseCov np.eye(2, dtypenp.float32) * 1e-2 kf.measurementNoiseCov np.eye(1, dtypenp.float32) * 2e-1 kf.errorCovPost np.eye(2, dtypenp.float32) return kf def estimate(box, cls, kf, frame_w, frame_h): x1, y1, x2, y2 box if x1 1 or y1 1 or x2 frame_w - 1 or y2 frame_h - 1: return None # 贴边直接放弃 h_px, w_px y2 - y1, x2 - x1 if h_px MIN_PX or w_px 1: return None # 太小误差不可控 real_w, real_h REAL_SIZE[cls] z_h fy * real_h / h_px # 按高度估 z_w fx * real_w / w_px # 按宽度估 if abs(z_h - z_w) / max(z_h, z_w) MAX_RATIO_DIFF: return None # 两个估计打架丢弃 z_meas 0.5 * (z_h z_w) kf.predict() state kf.correct(np.array([[z_meas]], np.float32)) return float(state[0]), z_meas def run(video_path, detector): cap cv2.VideoCapture(video_path) kfs {} while True: ok, frame cap.read() if not ok: break H, W frame.shape[:2] for tid, (box, cls) in enumerate(detector(frame)): kf kfs.setdefault(tid, make_kalman()) out estimate(box, cls, kf, W, H) if out is None: continue z_smooth, z_raw out x1, y1, x2, y2 map(int, box) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, %.2fm % z_smooth, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow(mono-depth, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()逻辑上分三层。第一层是几何有效性筛选贴边、过小、宽高比异常的框在进入数学运算之前就被踢掉这比事后调滤波参数管用得多。第二层是双通道估计z_h和z_w独立算两者一致才采信不一致说明框有问题这一步挡住了绝大多数离谱读数。第三层是卡尔曼平滑状态取[距离, 距离变化率]转移矩阵用匀速模型实测在 2530 fps 下跟踪行人是够用的。参数上processNoiseCov调大意味着更信任观测、响应快但抖调小则更平滑但滞后。行人场景我一般给1e-2车辆因为运动快会给到5e-2。measurementNoiseCov反映的是测量噪声方差按经验距离在 5 米左右时观测噪声标准差约 0.30.5 米方差就是 0.10.25所以给2e-1是个合理起点。3.3 真实尺寸表与误差预算真实尺寸是这个方案里最容易被轻视的输入。同一个类别不同样本差异巨大成年人身高 1.551.90 米直接取 1.70 就意味着 ±9% 的固有误差。所以更靠谱的做法是按场景细分——校园场景取 1.65工地场景取 1.75——而不是一个数字打天下。目标参考尺寸常见波动折算 8 米处误差成年行人高1.70 m±0.10 m±0.47 m轿车宽1.80 m±0.10 m±0.44 m轿车高1.50 m±0.15 m±0.80 m快递纸箱高0.30 m±0.05 m±1.33 m从表里能读出一个反直觉的结论小目标不适合用这套方法。30 厘米的箱子光尺寸波动就能带来 17% 的误差再加上像素量化误差基本没有实用价值。要做小目标测距得换成结构光或者带深度的方案。4. 俯仰角与地面假设把斜视场景的误差压下来绝大多数实际部署里相机不是水平放置的——监控杆上俯拍、车顶略微下倾、机器人头部前倾。这时候纯针孔公式会给出系统性偏小的读数而且偏得很有规律反而是好事能补。4.1 地平线、俯仰角与距离的三角关系相机下俯 θ 角、光心离地高度H、目标接地点像素行v则目标到相机的地面投影距离为α arctan((v - cy) / fy) # 接地点相对光轴的下视角 Z H / tan(θ α)当θ 0相机水平时退化成Z fy * H / (v - cy)这就是经典的「地平线法」。v - cy越大目标越靠画面下方距离越近。import numpy as np def ground_distance(v_bottom, cam_h, fy, cy, pitch_deg): v_bottom 目标接地点像素行cam_h 光心离地高度(m)pitch_deg 下俯角(向下为正) alpha np.arctan2(v_bottom - cy, fy) theta np.deg2rad(pitch_deg) denom np.tan(theta alpha) if denom 1e-3: return float(inf) # 逼近地平线数值发散返回无效值 return cam_h / denom # 同一个接地点不同俯仰角下的读数差异 for p in (0, 3, 5, 10): print(俯仰 %2d 度 - %.2f m % (p, ground_distance(v_bottom520, cam_h1.2, fy800, cy360, pitch_degp)))v_bottom 520、cy 360时水平安装读到 4.50 米下俯 5 度只有 3.15 米差 30%。所以只要相机不是严格水平这条补偿就必须加。「大概水平」这种判断不能代替实测我见过支架标称 0 度、实测偏 4 度的情况。这个方法的另一个价值是不依赖目标真实尺寸——只要目标贴地H和θ标定准一米八的人和一米六的人算出来的距离是一样的。代价是必须有稳定的接地点悬空目标比如举起的手就失效了。4.2 在线自标定用已知地面点反解俯仰角H能用卷尺量θ却很难量准。现在有了地面模型的公式反过来用几个已知距离的地面标记点就能把H和θ一起解出来。import numpy as np from scipy.optimize import least_squares def fit_ground(v_px, z_true, fy, cy, h01.2, pitch00.0): v_px/z_true: 地面上若干标记点的像素行与卷尺实测距离 v np.asarray(v_px, dtypenp.float64) z np.asarray(z_true, dtypenp.float64) def resid(p): h, th p alpha np.arctan2(v - cy, fy) pred h / np.tan(np.deg2rad(th) alpha) return (pred - z) / z # 按相对误差加权避免远点主导 r least_squares(resid, x0[h0, pitch0], bounds([0.2, -30], [6.0, 60])) return r.x, float(np.sqrt(np.mean(r.fun ** 2))) (h_hat, pitch_hat), rms fit_ground( v_px[300, 380, 440, 500, 545], z_true[15.0, 8.0, 5.0, 3.0, 2.0], fy800, cy360) print(拟合得到 H%.3f m, pitch%.2f 度, 相对残差%.3f % (h_hat, pitch_hat, rms))残差用(pred - z) / z而不是pred - z是因为远点的绝对误差天然大不归一化的话拟合会被 15 米那个点带跑偏近处反而失准。相对残差低于 0.03 就可以认为模型成立高于 0.08 一般是标记点没取准尤其近处 2 米那个点接地点差几个像素影响很大。约束里把θ的下界放到 -30 度是为了兼容相机略微上仰的安装方式别把范围写死成非负。标定完之后把H、θ写进配置运行时只做一次三角函数运算开销可以忽略。相机被碰过、支架被撞过都得重标——这是这套方法最脆弱的地方。4.3 多帧一致性中位数加四分位剔除卡尔曼滤波能压噪声但压不住离群点。一个框突然被检测成两个人观测值翻倍滤波器要好几帧才能拉回来屏幕上就是一个明显的跳变。工程上更省事的做法是先做一次鲁棒统计再喂给滤波器。from collections import deque import numpy as np class StableDepth: def __init__(self, n9, k1.5): self.buf deque(maxlenn) self.k k def push(self, z): self.buf.append(float(z)) if len(self.buf) 3: return float(z) arr np.asarray(self.buf) q1, q3 np.percentile(arr, [25, 75]) iqr q3 - q1 lo, hi q1 - self.k * iqr, q3 self.k * iqr keep arr[(arr lo) (arr hi)] return float(np.median(keep)) if keep.size else float(np.median(arr))窗口n 9大约对应 0.3 秒的历史够滤掉单帧跳变又不至于让跟车距离滞后太多。k 1.5是标准的 Tukey 围栏系数想更激进就调到 1.0想保守就 2.0。注意 IQR 在小样本下不稳所以len 3时直接返回原始值别硬套。这个类按目标 ID 各持一个实例别全局共用一个否则两个目标的距离会互相污染。5. 排查清单五个让读数飘出 20% 的现场问题下面这些不是理论上的可能性是我在实际调试中反复撞见的。5.1 近处很稳十米外上下跳 ±1.5 米现象5 米内读数稳定在 ±0.1 米走到 10 米数字开始来回跳幅度超过一米。原因框高掉到 40 px 以下检测器在远距离的框回归本身就不稳±2 px 抖动折算成距离就是 5% 以上再加上前面算过的dZ/Z与dw/w成正比分母小了误差自然放大。解决设最小像素阈值h_px 28直接不显示数字只画框同时按距离分段调平滑窗口近处用短窗保持响应远处用长窗n 15压抖动。如果业务上必须测 20 米换更长焦距的镜头比调代码有用得多。5.2 行人一转身距离突然变大一半现象同一个人正面走过来读数 6 米侧身走过变成 9 米。原因用了框宽做估计。人的肩宽 0.5 米、侧身厚度 0.25 米差一倍w_px减半Z就翻倍。解决改成以高度为主宽度只做一致性校验对车辆同样适用因为车宽受朝向影响大车高相对稳定。如果业务必须用宽度比如从正上方俯拍那就得先做朝向估计按朝向查不同的尺寸表。5.3 画面边缘的目标总是偏近现象目标在画面中央时读数正常移到左右边缘距离系统性偏小 10% 左右。原因广角镜头的桶形畸变让边缘的物体被「推远」看起来比实际小根据Z fW/ww小了距离就大了——但如果只做了部分畸变校正、或者校正后仍用原始cx, cy结果会反过来偏近。两种错误都很常见。解决测距前统一走一遍cv2.undistort(frame, K, dist)并且内参必须用同一套标定结果别一半用标定值一半用默认值。校正后图像的有效区域会缩小cx, cy仍然是校正后的主点不要手动改。5.4 换了分辨率所有距离全错现象调试用 1280×720 一切正常切到 640×480 后距离全部偏大或偏小比例大概是 2 倍。原因内参矩阵和分辨率强绑定。fx、fy、cx、cy的单位都是像素图像缩放一半它们全都要减半。解决按比例缩放而不是重标。比例系数s W_new / W_oldimport numpy as np def rescale_K(K, sx, sy): K2 K.copy().astype(np.float64) K2[0, 0] * sx # fx K2[1, 1] * sy # fy K2[0, 2] * sx # cx K2[1, 2] * sy # cy return K2 K np.load(K.npy) K_half rescale_K(K, 0.5, 0.5) # 1280x720 - 640x360注意sx和sy要分别算非等比缩放比如 1280×720 裁成 640×480 再缩放会让fx、fy不再相等用同一个系数就错了。裁剪的情况更麻烦cx、cy还得减去裁剪偏移这种场景我一般直接重新标定省事也不容易错。5.5 相机被碰了一下远处全偏现象设备运行几天都正常某天开始 10 米外的读数普遍偏小 15%近处几乎看不出来。原因支架松动相机下俯角变了 23 度。俯仰角误差对远处的影响远大于近处这正是第 4 章那个三角公式的直接后果。解决加一个自检机制。在场景里找一个已知距离的固定参照物地面标线、墙角每帧算一次距离与实际值偏差超过 8% 就报警并要求重标。别指望人工巡检能发现这种缓慢漂移我在一个长期运行的场景里就是靠这个自检抓到支架螺丝松动的。6. 精度验收用一套可复用的测试流程给结果签字代码跑通不等于能用得拿数据说话。我现在的习惯是任何测距方案上线前先做一轮固定流程的验收。流程很土但有效。在平整地面上用卷尺量出 1、2、3、5、8、10、15、20 米每个位置放一个目标静态采集 50 帧动态再让目标匀速走过一遍。把结果按真值分组算均值、标准差、平均绝对误差和相对误差。import csv import numpy as np def load(path): CSV 两列gt卷尺真值, measured算法输出一行一次测量 buckets {} with open(path, newline) as f: for row in csv.DictReader(f): buckets.setdefault(float(row[gt]), []).append(float(row[measured])) return buckets def report(buckets): rows [] for gt in sorted(buckets): arr np.asarray(buckets[gt], dtypenp.float64) mae float(np.mean(np.abs(arr - gt))) rows.append({ gt: gt, n: arr.size, mean: round(float(arr.mean()), 2), std: round(float(arr.std()), 2), mae: round(mae, 2), rel_%: round(mae / gt * 100, 1), }) return rows for r in report(load(depth_log.csv)): print(r)得到的表长这样某次车载前向场景的实测真值均值标准差MAE相对误差2 m2.060.050.073.5%5 m4.880.140.163.2%10 m10.420.380.454.5%15 m15.911.021.057.0%20 m21.832.102.0510.3%读这张表有三个要点。第一看标准差它反映抖动比均值更重要——均值偏了还能加偏置补偿抖动没法补。第二看相对误差随距离的增长斜率如果 10 米以后陡增说明像素分辨率到极限了该换镜头。第三看mean - gt的符号是否一致全是正数说明有系统性偏置多半是真实尺寸表填大了一点把这个偏差整体减掉就能改善一截。验收标准我一般定成可用区间内相对误差 5%、标准差 0.3 米超出这个区间就在界面上做视觉降级比如不再显示精确数值只显示「远/中/近」三档。这不是偷懒是承认方法的边界。最后提一个容易忽略的点验收要在真实光照下做。夜间、逆光、雨天的检测框质量会明显下降白天调好的参数到晚上可能差一倍。我的做法是至少采集白天、黄昏、夜间三组数据分别给出可用区间。从那以后我每次接单目测距的活都会先花半天把标定和验收跑完哪怕业务方催着要 Demo——因为跳过这一步省下的时间后面会以三倍的调试成本还回来。这套东西的门槛不在代码而在你愿不愿意老老实实量那几组数据。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑