news 2026/10/24 4:16:59

基于OpenCV的单目测距:从相机标定到ArUco实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于OpenCV的单目测距:从相机标定到ArUco实战

简介:面向计算机视觉入门者与OpenCV实践者的轻量级Python项目,聚焦在图像中根据已知尺寸标记物估算其到相机的距离。方案以三角形相似关系为核心,需要预先掌握标记物真实宽度或高度及初始标定距离两个参数,先求取焦距,再依据目标在画面中的像素宽度推算实际距离,适合测距实验、视觉算法验证和课程示例等场景。压缩包共2个文件,主要由1个Python脚本和1个Markdown说明组成,包体约2KB,说明文档交代依赖与使用背景,脚本承载测距计算逻辑。已有761人学习或下载,具备一定参考热度。读者可获得一套结构简洁、可阅读复用的测距示例代码,并借说明理解焦距标定、像素尺寸换算与距离估算之间的关系,为后续扩展多目标检测、替换标记物或接入实时视频流提供清晰起点。

1. 从一张普通照片反推距离:这个 OpenCV 单目测距项目能解决什么

之前帮一个做搬运小车的团队看现场问题,车顶只装了一颗两三百块的定焦摄像头,却要判断前方货架还有多远。我第一反应是单目不可能测距,后来发现只要货架上贴一张已知尺寸的标记,用 OpenCV 把图像里的像素宽度和相机焦距做一次除法,近距离误差能压到几厘米。这个项目干的就是这件事:用 Python 加 OpenCV,不依赖双目、不依赖激光雷达,靠单目几何把像素换算成物理距离。它特别适合两类人:手上只有普通 USB 摄像头、想做测距 Demo 或低成本定位的学生和工程师;以及想搞清楚相机内参、畸变系数、像素焦距这些概念到底怎么落到代码里的人。它不是什么重工程,核心公式一行就能写完,但坑全在标定和单位上。

2. 工程骨架与技术选型:为什么一颗普通相机就够用

2.1 目录结构与模块职责

这个项目的代码量不大,但结构划分得比较讲究,因为它把「离线标定」「在线测距」「可视化」三件事彻底拆开了。标定只做一次,结果存成 JSON;测距每次读这个 JSON,从不重复计算内参。这种拆分方式是我比较推荐的,因为标定一旦和主流程耦合,你后面换个分辨率就得从头再跑一遍,血泪经验。

mono-distance/ ├── calibration/ │ ├── calib_images/ # 棋盘格标定图,10~20 张不同角度 │ └── calibrate.py # 标定脚本,输出 config/camera_params.json ├── config/ │ └── camera_params.json # camera_matrix、dist_coeffs、image_size ├── core/ │ ├── distance.py # 测距公式封装:相似三角形 + PnP 两条路径 │ ├── detector.py # 目标宽度提取:轮廓法 / 检测框法 / ArUco │ └── draw.py # 结果绘制,画框、打距离文字、画坐标轴 ├── tools/ │ └── measure_pixel_width.py # 单图小工具,鼠标点两下量像素宽度 ├── main_image.py # 单张图片测距入口 ├── main_video.py # 实时视频流测距入口 └── requirements.txt

calibration/这一层是离线资产,跑一次就够了,但它的产物camera_params.json是整个项目的地基。core/里三个文件各自只干一件事:distance.py不碰图像,只收数字算数字;detector.py只负责从帧里抠出宽度,不关心这个宽度最后怎么用;draw.py只负责把结果糊回画面上。这种「算法 / 检测 / 渲染」三分的好处是,你后面想把轮廓法换成 YOLO,只需要动detector.py,测距公式和绘制逻辑一行都不用改。

tools/measure_pixel_width.py这个文件容易被忽略,但它是我用得最多的。它做的事情很朴素:读一张图,用鼠标点击物体左边界和右边界,打印出像素距离。标定焦距的时候你要知道「物体在画面里占多少像素」,手写代码去量反而慢,有个交互工具效率高很多。

2.2 依赖清单与技术栈取舍

技术栈非常轻,核心只有两个包,其余都是标准库就能搞定的事。真正需要留意的是 OpenCV 装哪个发行版,这个坑我见过太多人踩。

依赖用途安装包注意点
OpenCV读图、灰度、边缘、轮廓、ArUco、solvePnPopencv-contrib-pythonArUco 在 contrib 模块里,装opencv-python会直接报 AttributeError
NumPy矩阵运算、内参矩阵组装numpy装 OpenCV 时会带,但显式写上更保险
json保存和读取标定结果标准库比 pickle 安全,人能直接看
glob批量读标定图标准库只在标定脚本里用
collections / statistics滑动窗口和中位数滤波标准库做距离平滑用,不用引 scipy

为什么不上深度学习做单目深度估计?因为这类项目要的是「可解释的绝对距离」。端到端的单目深度模型输出的是相对深度图,尺度是模糊的,你要么再接一个标定环节把尺度对齐,要么就接受「只能比大小不能报米数」。而传统几何法每一步都能手算验证,出了问题能顺着公式往回查,不是黑匣子。当然如果你的场景是「不贴任何标记、随便什么物体都要测距」,那确实得上模型,这时建议把检测器当宽度提供方,把几何公式当距离解算器,两者结合反而比纯模型稳。

2.3 两条测距路线:相似三角形与平面位姿求解

项目里实现了两条路线,它们的适用面完全不同,我建议两个都留着。

第一条是相似三角形,也就是教科书上那个D = (F × W) / P。其中D是相机到物体的距离(单位随意,但F和W的单位要配套),F是相机的像素焦距,W是物体的真实物理宽度,P是物体在图像里占的像素宽度。这个公式的推导其实就是两个相似三角形:真实物体的宽度与距离之比,等于它在成像平面上的像素宽度与焦距之比。

它的前提非常苛刻:物体成像面必须和相机光轴垂直,且你要能准确拿到物体的像素宽度。物体稍微一斜,投影宽度就变短,算出来的距离就偏大,这是最常见的翻车点。

第二条是平面位姿求解,走cv2.solvePnP。你给四个以上已知物理坐标的共面点(ArUco 码的四个角、棋盘格角点、或者你自己贴的定位标记),它直接解出旋转向量rvec和平移向量tvec,tvec的 Z 分量就是距离。这条路对倾斜不敏感,还能顺带拿到姿态角,缺点是需要标记,且标记必须平整贴牢。

两种路线的对比如下,实际选型时按场景对号入座:

对比项相似三角形(D = F×W/P)PnP 求解(solvePnP)
需要什么已知实物宽度四个以上已知物理坐标的点
对倾斜的鲁棒性差,斜了必偏好,能解出姿态
输出只有距离距离 + 旋转
计算成本一次除法迭代,单帧微秒到毫秒级
典型场景传送带上的规则物体、已知宽度的卡片贴 ArUco 码的货架、标定板定位

我的习惯是:能用 ArUco 就用 ArUco,因为标记法的距离是「解出来的」,不是「比出来的」,稳定性差一个量级;只有在物体不能贴标记、但宽度固定时,才退回相似三角形。

3. 从零跑通:标定、取像素、算距离的三步

3.1 环境和依赖安装

先建虚拟环境再装包,这一步别省。OpenCV 的 contrib 版和普通版同时装在一个环境里会互相覆盖,症状是import cv2成功但一调cv2.aruco就报错,属于典型的玄学问题,其实是包名冲突。

# 建议单独建一个虚拟环境,避免和系统里的 opencv-python 打架 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 注意:必须是 contrib 版,ArUco 和 solvePnP 的完整接口都在里面 pip install opencv-contrib-python numpy # 验证 ArUco 是否可用,没报错说明装对了 python -c "import cv2; print(hasattr(cv2, 'aruco'))"

验证那行输出True才算装好。如果输出False,说明环境里装的是非 contrib 版本,执行pip uninstall opencv-python opencv-contrib-python全部卸掉再重装 contrib 版。

3.2 相机标定:拿到 camera_matrix 和 dist_coeffs

标定这一步是整个项目的地基,也是新手最容易糊弄过去的一步。标定的产出有两个:内参矩阵camera_matrix(里面就包含像素焦距fx、fy和主点cx、cy)和畸变系数dist_coeffs。这两样东西直接决定后面测距准不准。

准备一张棋盘格打印出来贴在硬纸板上,用同一颗摄像头、同一个分辨率,从不同角度拍十几张。角度要覆盖:正对、左斜、右斜、上仰、下俯、近、远。全部正对拍出来的标定结果,重投影误差可能很小,但畸变系数是假的,用在实际场景里就偏。

import glob import json import cv2 import numpy as np PATTERN = (9, 6) # 内角点数量:宽 9 个、高 6 个(不是格子数,是内角点数) SQUARE_SIZE = 25.0 # 单个格子边长,单位 mm,这个值必须和你打印出来的一致 # 生成世界坐标系下的角点:z 固定为 0,xy 按格子间距铺开 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 = [], [] criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) for path in sorted(glob.glob("calib_images/*.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.1 像素级,直接影响标定精度 corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners) rms, mtx, dist, _, _ = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print("重投影误差 RMS =", round(rms, 4), "(小于 0.5 可用,大于 1 建议重拍)") # 分辨率要一起存下来,换分辨率后这份内参就作废了 json.dump( {"camera_matrix": mtx.tolist(), "dist_coeffs": dist.tolist(), "image_size": list(gray.shape[::-1])}, open("config/camera_params.json", "w"), indent=2, )

PATTERN填的是内角点数,这点必须说清楚:一个 10×7 的格子棋盘,内角点是 9×6,填错一位就会一直返回False,而且没有任何报错提示,只能靠肉眼怀疑人生。SQUARE_SIZE要和实际打印尺寸一致,如果你的打印机有缩放,就老老实实用尺子量一遍格子边长再填。

cornerSubPix的(11, 11)是搜索窗口半径,(-1, -1)表示不做额外死区。这个精化步骤能把角点定位精度提升一个档次,别省。

rms是重投影误差,含义是「把三维角点按标定结果投回图像,和实际检测到的角点差多少像素」。低于 0.5 说明标定质量不错,1 以上基本说明有图糊了或者角度太单一,建议挑出误差最大的那张删掉重跑。

3.3 单图测距的最小可运行脚本

标定完先别急着上视频流,用一张静态图把链路走通,出问题好定位。下面这段是最小可运行版本,用的是相似三角形路线。

import json import cv2 # 读取标定结果,只取 fx,因为相似三角形只需要一个方向的像素焦距 params = json.load(open("config/camera_params.json")) FX = params["camera_matrix"][0][0] # 单位:像素 KNOWN_WIDTH_CM = 14.0 # 物体真实宽度,厘米 def distance_cm(pixel_width, known_width, focal_px): """D = F * W / P,单位跟着 known_width 走,这里输出厘米""" if pixel_width <= 0: return None # 除零保护,检测失败时会出现 return (known_width * focal_px) / pixel_width img = cv2.imread("test.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (7, 7), 0) # 先模糊,压掉高频噪点造成的假边缘 edges = cv2.Canny(blur, 50, 120) # 膨胀一次,把断掉的边缘接起来,否则轮廓会被切碎成好几块 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) edges = cv2.dilate(edges, kernel, iterations=1) cnts, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) c = max(cnts, key=cv2.contourArea) # 取最大轮廓,假设画面里最大的就是目标 x, y, w, h = cv2.boundingRect(c) d = distance_cm(w, KNOWN_WIDTH_CM, FX) print(f"像素宽度 {w} px,估算距离 {d:.1f} cm") cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(img, f"{d:.1f} cm", (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imwrite("result.jpg", img)

几个参数值得说清楚。KNOWN_WIDTH_CM是你随手能量出来的物理宽度,用卡尺量一次比用尺子目测准得多;这个值每变一次,测距结果就整体缩放一次,所以它是误差来源之一。FX从camera_matrix[0][0]取,是内参矩阵第一行第一列,代表水平方向的像素焦距。如果你量的是物体的垂直像素高度,就要换成camera_matrix[1][1],也就是fy,两者在方形像素的相机上通常非常接近,但不完全相等。

Canny 的两个阈值50和120是经验值,光照偏暗时把低阈值降到 30 左右,过曝时把高阈值提上去。这两个数没有普适解,只能按现场调。

还有一个细节:max(cnts, key=cv2.contourArea)是「假设最大轮廓就是目标」。这个假设在干净背景里成立,一旦背景里有个更大的物体,量出来的就是那个背景物体的宽度,距离直接算飞。真要上产线,这里必须加面积上限和长宽比约束。

3.4 换成实时视频和 ArUco 码

静态图跑通之后,把读图换成cv2.VideoCapture就能实时测距。如果场景允许贴标记,强烈建议切到 ArUco 路线,因为它对倾斜不敏感,而且不用手工量像素宽度。

import json import cv2 import numpy as np params = json.load(open("config/camera_params.json")) K = np.array(params["camera_matrix"], dtype=np.float64) D = np.array(params["dist_coeffs"], dtype=np.float64) MARKER_SIZE_M = 0.10 # ArUco 码的物理边长,单位:米,必须实测 dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) aruco_params = cv2.aruco.DetectorParameters() # OpenCV 4.7 前后 ArUco 的调用签名改过,这里做兼容,避免换环境就跑不起来 detector = None if hasattr(cv2.aruco, "ArucoDetector"): detector = cv2.aruco.ArucoDetector(dictionary, aruco_params) cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) # 分辨率要和标定时一致,否则内参作废 cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if detector is not None: corners, ids, _ = detector.detectMarkers(gray) else: corners, ids, _ = cv2.aruco.detectMarkers(gray, dictionary, parameters=aruco_params) if ids is not None: cv2.aruco.drawDetectedMarkers(frame, corners, ids) # solvePnP 内部解 R、t,tvec[2] 就是沿光轴方向的距离(米) rvecs, tvecs, _ = cv2.aruco.estimatePoseSingleMarkers( corners, MARKER_SIZE_M, K, D ) for i in range(len(ids)): cv2.drawFrameAxes(frame, K, D, rvecs[i], tvecs[i], 0.05) z = tvecs[i][0][2] cv2.putText(frame, f"{z * 100:.1f} cm", (10, 40 + 30 * i), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imshow("distance", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()

MARKER_SIZE_M是标记的真实边长,单位米,因为你后面输出tvec[2]时也是米。这个值一定要用卡尺量,打印缩放会直接放大误差。drawFrameAxes的最后一个参数是坐标轴长度,0.05 表示画 5 厘米长的轴,纯粹是可视化用,不影响解算。

兼容分支那段看着丑但是必要。老版本detectMarkers是「字典、图、参数」直接传,新版本要先构造ArucoDetector对象再调方法。这两个接口在不同 4.x 小版本之间反复横跳,写死一种写法,换个环境就报TypeError。

4. 核心代码拆解:像素宽度到物理距离的那几行

4.1 焦距基准的反算与单位统一

很多时候你手上拿不到棋盘格,或者摄像头是别人的、内参不给,这时候可以用「单点标定法」反算像素焦距:把物体放在已知距离,量出它的像素宽度,反推F。

def calibrate_focal_by_reference(known_width_cm, known_distance_cm, pixel_width_px): """ 单点反算像素焦距。 known_width_cm : 参照物真实宽度,厘米 known_distance_cm: 相机到参照物的实测距离,厘米 pixel_width_px : 参照物在当前分辨率画面里占的像素宽度 返回:像素焦距 F,单位与像素一致 """ return (pixel_width_px * known_distance_cm) / known_width_cm

这个反算公式就是相似三角形的变形,本质是「先把一个已知点凑准,后面用它去推别的点」。它的精度完全取决于那次测量的准确度:卷尺量歪 2 厘米,后面所有距离都按比例偏。所以我一般会量三到五组不同距离的参照,算出多个F取中位数,而不是随便量一次就完事。

单位方面有个铁律:F的单位是像素,W是物理长度,P是像素,最终D的单位和W完全一致。W用厘米,D就是厘米;W用米,D就是米。代码里最怕的是半路混用,比如宽度填了毫米、显示的时候按厘米写,结果差一个数量级,还以为是算法坏了。

4.2 像素宽度的三种取法

这是整个项目里最需要按场景选的一步,取宽度的方式直接决定测距的上限。

第一种是轮廓法,前面已经贴过。优点是零额外标记、零训练成本,缺点是背景一复杂就抓瞎,边缘有阴影时 Canny 会多出一圈,量出来的宽度偏大,距离偏小。

第二种是检测框法,把任意目标检测器的输出接进来。你不需要关心检测器是什么,只要它返回框的左右边界即可:

def pixel_width_by_bbox(results, min_conf=0.5): """ results: 任意检测器的列表输出,元素形如 (x1, y1, x2, y2, conf, cls) 返回:像素宽度最大的那个框 (x, y, w, h),没有则返回 None """ best = None for x1, y1, x2, y2, conf, _ in results: if conf < min_conf: continue # 低置信度的框宽度不可信,直接丢 w = x2 - x1 if best is None or w > best[2]: best = (int(x1), int(y1), int(w), int(y2 - y1)) return best

这条路的好处是框的左右边界通常是物体真实外接矩形,比轮廓法稳定,因为检测器本身带语义,不会被背景干扰。坏处是框有抖动,同一物体相邻两帧的框宽可能差几个像素,导致距离读数跳。所以后面必须加平滑。

第三种是标记法,用 ArUco 或棋盘格的角点间距当已知宽度。它的宽度不是「量出来的」而是「解出来的」,因为solvePnP综合了四到八个点的位置,相当于做了最小二乘拟合,噪声被平均掉了。这是三种里最稳的,代价是必须贴标记。

4.3 实时链路:绘制、平滑与异常剔除

单帧算得准没用,视频流里读数的抖动才是让人抓狂的地方。目标检测框抖一个像素,距离可能跳几厘米,操作员看久了会怀疑系统坏了。我的做法是中位数滤波加突变抑制,两个一起上。

from collections import deque from statistics import median class DistanceSmoother: def __init__(self, window=7, jump_ratio=0.3, max_valid_m=20.0): self.buf = deque(maxlen=window) # 滑动窗口,保存最近 window 帧的有效距离 self.last = None self.jump_ratio = jump_ratio # 单帧跳变超过这个比例就认为是野值 self.max_valid_m = max_valid_m # 物理上限,超过直接判废 def update(self, d_m): # 空值、负值、超量程统统当成无效帧,返回上一次的稳定值 if d_m is None or d_m <= 0 or d_m > self.max_valid_m: return self.last if self.last is not None and abs(d_m - self.last) / self.last > self.jump_ratio: # 突变只信三成,剩下的往历史值拉,压住单帧抖动 d_m = self.last + (d_m - self.last) * 0.3 self.buf.append(d_m) self.last = median(self.buf) # 中位数比均值抗野值,窗口里有离群点也不怕 return self.last

window=7是经验值,30 帧的视频流下大约 0.2 秒的响应延迟,兼顾平滑和实时性。如果目标移动很快,把窗口缩到 3 到 5;如果是静态测量,窗口开到 15 都行。

jump_ratio=0.3表示单帧变化超过 30% 就认为是异常。这个值不能设太小,否则目标真的快速靠近时会被误判成野值,导致读数「粘」在旧值上不动,看起来像是系统卡死了。

max_valid_m是物理量程上限,非常有必要。检测失败时pixel_width可能变成一个很小的数(比如误检了一个远处的小物体),一除就得到一个巨大的距离,没有上限的话画面上会突然蹦出个「230 米」,一眼假。

5. 避坑与排查:单目测距翻车的五个现场

5.1 标定环节的三个坑

坑一:每次标定出来的焦距都不一样,差个几十像素。

现象:同一个摄像头,上午标一次fx≈920,下午重标变成fx≈880,两次都显示 RMS 误差很小。

原因:标定图角度太单一,几乎全是正对拍。这种情况下棋盘格的角点分布集中在画面中央,畸变系数拟合不出来,calibrateCamera会用内参去「补偿」畸变,导致fx和畸变系数互相拉扯,结果是多解。

解决:标定图必须覆盖画面四个角和边缘,至少三张是明显倾斜的(30 度以上)。拍的时候把棋盘格贴到画面左上、右上、左下、右下各来一张。另外注意全程不要动对焦环,自动对焦的摄像头要么锁死对焦,要么接受它每次焦距都在变这个事实。

坑二:换了分辨率,所有距离全错。

现象:标定时用的 1280×720,代码里cap.set忘了加,摄像头默认出 640×480,测出来的距离直接翻倍。

原因:内参矩阵是针对特定分辨率标定的,像素焦距fx和分辨率成正比。分辨率减半,fx也跟着减半,但代码里还在用旧的fx,公式D = F×W/P里F大了、P也小了,误差叠加。

解决:在camera_params.json里存image_size,程序启动时校验当前帧尺寸,不一致就按比例缩放内参:fx_new = fx_old × (width_new / width_old)。更稳的做法是直接把分辨率写死,别依赖摄像头默认值。

坑三:ArUco 标记贴得不平,距离读数随角度漂。

现象:正对标记时 1.00 米,稍微侧一点变成 1.08 米,再侧一点 1.15 米。

原因:solvePnP假设标记是平面的。如果标记贴在弯曲的纸板或者软包装上,实际四个角点不共面,解出来的位姿就有系统偏差。

解决:标记必须贴在硬质平整基材上,亚克力、硬纸板、打印后过塑都行。贴的时候用手压平,别留气泡。另外标记尺寸要够大,边长至少占画面宽度的 5%,太小了角点定位精度撑不住。

5.2 检测与场景的两个坑

坑四:近距离很准,超过三米就开始乱飞。

现象:1 米误差 2 厘米,2 米误差 5 厘米,3 米误差 20 厘米,5 米没法看。

原因:这是单目测距的物理天花板,不是代码问题。像素宽度P和距离D成反比,距离越远P越小。3 米时物体可能只占 40 个像素,检测框抖动 ±2 像素,相对误差就是 5%,距离误差直接 15 厘米。这是几何决定的,任何滤波都救不回来。

解决:接受它,然后把量程写进业务逻辑。超过设定距离就只报「远于 3 米」这样的定性结果,不要报具体数字。想要远距离精度,只能换长焦镜头(等效于提高fx)或者换测量原理(双目、结构光、飞行时间)。

坑五:距离读数周期性跳动,像是有规律。

现象:静止的物体,读数在 95 厘米和 105 厘米之间来回跳,频率还挺稳定。

原因:多半是摄像头开了自动曝光或自动白平衡。每帧的曝光参数在变,边缘检测的阈值响应跟着变,轮廓宽度就抖。另一种可能是场景里有日光灯,50Hz 工频闪烁导致部分帧过曝。

解决:手动锁死摄像头参数,cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25)关掉自动曝光,再手动设CAP_PROP_EXPOSURE到合适值,自动白平衡同样处理。工频干扰就避开灯光直射,或者给目标打一盏稳定的补光灯。

注意:这几个坑里,只有坑二、坑五属于代码能修的,坑一、坑三属于操作流程问题,坑四属于物理限制。排查时先确认是不是坑四,不然你会花一整天调参数却毫无进展。

6. 二次开发:把它改成能落地的测距模块

6.1 输出结构化结果并加上滤波

原始项目返回的是一个浮点数,这在 Demo 阶段够用,但接进业务系统就不行了。业务侧通常需要知道「这一帧到底测没测到」「置信度多少」「和上一帧比变化多大」。我一般会把返回值改成一个字典或者小的数据类:

from dataclasses import dataclass, field @dataclass class MeasureResult: ok: bool # 本帧是否成功测到 distance_m: float = -1.0 # 平滑后的距离,米 raw_m: float = -1.0 # 未平滑的原始值,调试用 pixel_width: int = 0 # 参与计算的像素宽度 marker_id: int = -1 # 标记法时带上 ID,方便多目标区分 timestamp: float = 0.0

这么改的好处是,上层拿到的不是「一个可能为 None 的数字」,而是一个明确带状态的对象。ok=False时下游知道该走兜底逻辑,而不是对None做除法。raw_m保留原始值非常关键,出问题时对比raw_m和distance_m就能判断是检测抖还是滤波参数设得太激进。

改造位置在core/distance.py的函数返回值上,改动量很小,但会把整个项目的可维护性拉高一个档次。验证方法是录一段视频,打印连续 100 帧的raw_m与distance_m,看两者的标准差比值,正常情况下平滑后应该降到三分之一以下。

6.2 换检测器与自动标定的改造路径

想从「贴标记」升级到「任意已知尺寸物体」,改造点集中在core/detector.py。把pixel_width_by_contour换成一个包装函数,输入是帧,输出是(x, y, w, h),内部随便接什么检测器都行。这样测距主流程一行不用改。

真正值得投入的是自动标定。手工量KNOWN_WIDTH和KNOWN_DISTANCE这两步最容易出错,我的做法是用 ArUco 当「标尺」反过来标定已知宽度:把目标物体和 ArUco 码放在同一个平面,用标记解出精确距离,再反算目标的真实宽度,存进配置。这样重复十次取中位数,KNOWN_WIDTH的精度比卡尺还高。

改造点改动位置验证方法
换检测器core/detector.py的宽度提取函数固定距离下对比新旧检测器的读数标准差
加多目标main_video.py的循环体,按 ID 分发同屏放三个标记,确认每个 ID 的距离独立更新
自动标定tools/下新增脚本,用标记反算真实宽度与卡尺实测值对比,偏差应在 1% 以内
量程告警DistanceSmoother的max_valid_m故意把目标移出量程,确认输出转为定性描述

改造顺序上,我建议先做结构化输出,再做自动标定,最后才换检测器。反过来的话,你会在一个还不稳定的返回格式上反复改检测逻辑,改到最后连哪部分是测距、哪部分是检测都分不清。

单目测距这事儿我踩过最深的一次,是花了两天调滤波参数,最后发现根本原因是标定图全是对着屏幕拍的,屏幕反光导致角点定位偏了一个像素。从那以后我每次标定完都强制走一遍「一米处实测验证」:把棋盘格摆在卷尺量好的一米位置,用标定结果算一遍距离,误差超过 2% 就整批重拍。这个习惯救了我好几次,也省下了那些本来会浪费在参数上的时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:16:16

ObjectARX 2010 屏幕菜单停靠面板开发

简介&#xff1a;这是一份面向AutoCAD二次开发者的ObjectArx 2010屏幕菜单示例工程&#xff0c;聚焦如何基于DockControlBar创建可浮动、可停靠的自定义菜单&#xff0c;并完成命令响应与界面集成。压缩包共16个文件&#xff0c;以6个cpp源文件、5个h头文件为主&#xff0c;辅以…

作者头像 李华
网站建设 2026/10/24 4:15:35

东莞废水治理厂家实力与用户口碑:鼎力环保客户口碑力荐

东莞废水治理厂家实力与用户口碑&#xff1a;鼎力环保客户口碑力荐 废水治理定制厂家哪家靠谱?这是东莞及周边制造企业老板在环保整改时问得最多的一句话。随着环保监管持续加严&#xff0c;污水排放抽检频次逐年提高&#xff0c;排放标准不断收紧&#xff0c;工业企业一旦污水…

作者头像 李华
网站建设 2026/10/24 4:15:17

Avatar 骨骼映射:从任意骨架到标准人形的统一表示

一、问题:同一套动画如何服务高矮胖瘦各种角色 你有一组动画资源:一段走、一段跑、一段待机。它们是为某一个具体角色录的。 现在你要把它们用在另一个角色上——身高差 30 厘米,肩膀更宽,胳膊更长,手臂比例还不一样。 直接把动画播过去会出什么问题? 脚步会打滑:源角…

作者头像 李华
网站建设 2026/10/24 4:15:17

C# 与 C++ 之间传字符串:一次 P/Invoke 调用的逐字节内存追踪

一、先规定一下"看清楚"的标准 1.1 三个必须分清的概念 读内存问题,最容易混淆的是下面这三样东西。它们在语言里长得像,在内存里完全是三码事。 概念 是什么 谁负责它的生死 地址 一个指向某处的数字 谁创建指针,谁决定它还指向谁 内容 地址指向的那些字节 内容…

作者头像 李华
网站建设 2026/10/24 4:15:06

叁方GEO 和传统 SEO 外包的区别:可验证指标、交付物与风险

叁方GEO 和传统 SEO 外包的区别&#xff1a;可验证指标、交付物与风险 叁方GEO 和传统 SEO 外包的区别&#xff1a;可验证指标、交付物与风险> 本文为技术实践笔记&#xff0c;讨论生成式引擎优化&#xff08;GEO&#xff09;的内容结构、收录推送与回测方法。不涉及商业推广…

作者头像 李华