ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中国机器人大赛先进视觉赛工业测量:标定、亚像素边缘与像素当量全链路实战

中国机器人大赛先进视觉赛工业测量:标定、亚像素边缘与像素当量全链路实战 简介本资源为中国机器人大赛先进视觉赛工业测量方向的完整参赛资料包面向人工智能、自动化、电子信息、通信工程等专业的高校学生、教师及科研从业者可用于毕业设计、课程设计、项目立项演示或竞赛复盘学习。包内共1088个文件约134.53MB以make、cmake构建脚本为主配合py、cpp、h源码及cu、cuh等CUDA文件另有png、jpg图像素材、docx设计文档、md说明与sh运行脚本覆盖ROS节点、深度学习推理与视觉测量模块目录结构完整便于按模块检索。已有95人学习下载。资源包含全部参赛源码与配套资料代码经过测试可正常运行读者可借此理解工业测量赛题的视觉算法实现、ROS通信架构与工程构建流程也可在此基础上修改扩展功能适合进阶学习与二次开发。1. 先进视觉赛工业测量从赛题到源码一套能跑通的落地路径中国机器人大赛里的先进视觉赛项工业测量方向一直是报名人数多、但完赛率不高的赛道。原因不复杂它不像目标检测那样有现成的开源权重可以套工业测量要求的是亚像素级尺寸还原、稳定的标定链路以及一套能在赛场光照抖动下不崩的流水线。很多人第一次拿到赛题第一反应是找个 YOLO 权重跑检测结果发现测出来的尺寸误差在毫米级直接出局。这个方向真正吃的是「标定 边缘定位 像素当量换算」这条链路而不是检测框画得漂不漂亮。这篇笔记围绕「中国机器人大赛-先进视觉赛-工业测量」这个题目把从环境搭建、标定、测量到源码组织的完整路径拆开讲。适合两类人一类是准备参赛、手里只有赛题说明和一份源码压缩包的在校队伍另一类是做工业视觉落地、想拿这套链路当参考模板的工程师。源码包本身是起点不是终点真正决定成绩的是参数怎么调、误差怎么控、翻车点有没有提前堵住。2. 工业测量的技术底座标定、像素当量与边缘定位2.1 为什么先做标定而不是先跑检测工业测量的本质是把图像里的像素距离换算成物理尺寸。这个换算系数叫像素当量单位通常是 mm/pixel。它不是一个固定值取决于相机分辨率、镜头焦距、工作距离和传感器像元尺寸。很多人跳过标定直接拿卷尺量一个已知物体反推系数这在赛场上是玄学——因为镜头畸变会让画面中心和边缘的当量不一致边缘区域误差能到 3% 以上。常见做法是先用棋盘格做相机标定拿到内参矩阵和畸变系数再对图像做去畸变然后才计算像素当量。标定的质量直接决定后面所有测量的上限。我一般会拍 15 到 20 张不同角度的棋盘格图覆盖画面四个角和中心用 OpenCV 的 calibrateCamera 求解。重投影误差控制在 0.3 像素以内才算合格超过 0.5 就要重拍。import cv2 import numpy as np import glob # 棋盘格内角点数量比如 9x6 表示每行9个内角、每列6个内角 pattern_size (9, 6) 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) objpoints, imgpoints [], [] images glob.glob(calib_imgs/*.jpg) for fname in images: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern_size, None) if ret: # 亚像素级角点优化窗口大小 11x11 是经验值 corners2 cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) objpoints.append(objp) imgpoints.append(corners2) ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None) print(重投影误差:, ret) print(内参矩阵:\n, mtx) print(畸变系数:, dist.ravel())这段代码的关键参数有三个。pattern_size 必须和实际棋盘格内角数一致写错了 findChessboardCorners 会一直返回 False。cornerSubPix 的窗口 (11,11) 是亚像素搜索范围太小收敛不稳太大容易把邻近角点算进来。calibrateCamera 返回的 ret 就是重投影误差它是判断标定好坏的唯一硬指标别只看内参矩阵数值合不合理。2.2 像素当量的两种算法与选型拿到标定参数后像素当量有两种算法。第一种是标定板法在测量平面上放一个已知尺寸的标准件去畸变后测它的像素长度用物理长度除以像素长度得到当量。第二种是工作距离法用相似三角形原理物理尺寸 像素尺寸 × 像元尺寸 × 工作距离 / 焦距。第一种更准因为它把镜头实际成像的偏差都吃进去了第二种适合快速估算误差通常在 1% 到 2%。赛场上我推荐标定板法而且要在正式测量前用同一批参数重新算一次。因为赛场的工作距离和你调试时可能不一样哪怕差 5 厘米当量就会变。具体操作是把标准件放在测量平面拍图去畸变用边缘检测提取标准件轮廓算像素宽度然后除。# 去畸变 undistorted cv2.undistort(img, mtx, dist, None, mtx) # 边缘提取Canny 双阈值按实际对比度调 edges cv2.Canny(undistorted, 50, 150) # 找轮廓只保留面积最大的那个标准件 contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnt max(contours, keycv2.contourArea) # 最小外接矩形拿到像素宽高 rect cv2.minAreaRect(cnt) pixel_width max(rect[1]) # 标准件物理宽度已知比如 50.0 mm pixel_scale 50.0 / pixel_width # 单位 mm/pixel print(像素当量:, pixel_scale)Canny 的双阈值是这里最容易翻车的地方。阈值太低边缘断裂轮廓不闭合阈值太高边缘丢失轮廓面积偏小。我一般先用 50/150 跑一遍看轮廓是否贴合标准件边缘不贴合就按 10 的步长调。minAreaRect 返回的角度在 OpenCV 不同版本里范围不一样算宽度时用 max(rect[1]) 取长边避免角度歧义。2.3 亚像素边缘定位把误差从像素级压到亚像素级像素级边缘的定位精度就是 1 个像素换算成物理尺寸可能是 0.05 mm 到 0.1 mm。工业测量赛题通常要求 0.02 mm 级别的重复精度所以必须做亚像素。常见做法是沿边缘法线方向做灰度梯度拟合或者用矩方法。OpenCV 没有直接给亚像素边缘函数但可以用 fitLine 对边缘点做直线拟合或者用 cornerSubPix 的思路自己写一维梯度插值。我一般用梯度重心法在边缘附近取一条垂直于边缘的扫描线算灰度梯度然后对梯度做加权重心重心位置就是亚像素边缘。这个方法实现简单在对比度稳定的场景下精度能到 0.1 像素以内。def subpixel_edge(gray, p1, p2, search_radius5): # p1, p2 是粗略边缘上的两点确定扫描方向 # 沿法线方向采样算梯度重心 import numpy as np direction np.array(p2) - np.array(p1) direction direction / np.linalg.norm(direction) normal np.array([-direction[1], direction[0]]) mid (np.array(p1) np.array(p2)) / 2 samples [] for t in np.linspace(-search_radius, search_radius, 2 * search_radius 1): pt mid t * normal x, y int(round(pt[0])), int(round(pt[1])) if 0 x gray.shape[1] and 0 y gray.shape[0]: samples.append((t, float(gray[y, x]))) ts np.array([s[0] for s in samples]) vals np.array([s[1] for s in samples]) grad np.abs(np.diff(vals)) if grad.sum() 0: return mid weights grad / grad.sum() offset np.sum(weights * (ts[:-1] ts[1:]) / 2) return mid offset * normalsearch_radius 控制扫描范围太大容易跨到别的边缘太小可能覆盖不到真实边缘。一般取 3 到 5 个像素。这个函数返回的是亚像素坐标后续算距离时直接用不要再取整。3. 从源码包到可运行工程环境、依赖与目录组织3.1 源码包到手后的第一件事跑通而不是读懂拿到「含全部参赛源码及资料」的压缩包很多人的习惯是先通读代码。这在赛前时间紧的情况下是低效的。正确顺序是先跑通再定位关键模块。跑通的标准是能加载一张测试图输出一个测量结果哪怕结果不准。跑通之后再去看标定、边缘、换算这三段代码分别在哪。环境上这类赛题源码大概率是 Python OpenCV可能带一点 NumPy 和 SciPy。我一般用 conda 建独立环境避免和系统里的包打架。Python 版本选 3.8 到 3.10 之间太新的版本有些老源码里的 API 会变。conda create -n robot_vision python3.9 -y conda activate robot_vision pip install opencv-python4.8.0.74 numpy scipy matplotlibopencv-python 的版本要留意。4.5 之前和之后有些函数签名变了比如 findContours 的返回值和 approxPolyDP 的行为。如果源码里报参数数量不对先查是不是版本问题别急着改逻辑。3.2 目录结构怎么拆才不乱源码包如果是一坨脚本堆在一起第一件事是拆目录。我一般按功能拆成四个目录calibration 放标定图和标定脚本measure 放测量主流程utils 放亚像素、去畸变、坐标变换这些工具函数data 放测试图和标准件参数。这样做的目的是让「换一批图重新测」这件事变成改一个路径而不是翻遍所有脚本。目录放什么关键文件calibration棋盘格图、标定脚本、标定结果 npzcalibrate.py, calib_result.npzmeasure主测量流程、赛题对应的测量逻辑measure_main.pyutils亚像素边缘、去畸变、当量换算edge.py, undistort.pydata测试图、标准件尺寸配置test_01.jpg, config.yaml标定结果存成 npz 是个好习惯下次跑测量直接加载不用每次重新标定。config.yaml 里放标准件物理尺寸、Canny 阈值、搜索半径这些可调参数调参时只改这一个文件。import numpy as np import yaml # 保存标定结果 np.savez(calibration/calib_result.npz, mtxmtx, distdist) # 加载 calib np.load(calibration/calib_result.npz) mtx, dist calib[mtx], calib[dist] # 加载可调参数 with open(data/config.yaml, r) as f: cfg yaml.safe_load(f) print(cfg[pixel_scale], cfg[canny_low], cfg[canny_high])config.yaml 的内容大概是这样pixel_scale: 0.0523 canny_low: 50 canny_high: 150 subpixel_radius: 5 standard_width_mm: 50.0把 pixel_scale 也放进配置是因为换镜头或换工作距离后它要重算放在代码里容易忘。3.3 主流程的骨架一张图进来一个尺寸出去主流程不需要复杂四步就够读图、去畸变、找边缘、算尺寸。复杂的是每一步的参数和异常处理。我一般把主流程写成函数链每一步的输入输出都打印出来方便定位是哪一步出的问题。def measure_one(img_path, mtx, dist, cfg): img cv2.imread(img_path) if img is None: raise FileNotFoundError(img_path) undistorted cv2.undistort(img, mtx, dist, None, mtx) gray cv2.cvtColor(undistorted, cv2.COLOR_BGR2GRAY) edges cv2.Canny(gray, cfg[canny_low], cfg[canny_high]) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None cnt max(contours, keycv2.contourArea) rect cv2.minAreaRect(cnt) pixel_size max(rect[1]) physical_size pixel_size * cfg[pixel_scale] return physical_size这个骨架里max(contours, keycv2.contourArea) 假设画面里只有一个目标。如果赛场图里有多个物体这里会选错。稳妥做法是按面积范围过滤或者按位置过滤。另外 minAreaRect 对噪声敏感轮廓点如果有毛刺矩形会偏大。可以在 findContours 之前做一次高斯模糊或者对轮廓做 approxPolyDP 简化。4. 避坑与排查工业测量赛场上最容易翻车的五件事4.1 标定重投影误差合格但测量就是偏现象标定脚本输出的重投影误差 0.2 像素看起来很好但测标准件时尺寸偏了 2%。原因通常是标定板和测量平面不在同一个工作距离上。标定时棋盘格可能举得离相机近测量时物体在远处镜头的对焦和畸变表现不一样。解决标定板必须放在和被测物同一平面、同一工作距离上拍。如果做不到至少在测量前用标准件重新算一次像素当量把系统偏差吃掉。4.2 Canny 阈值在调试图上好用换张图就崩现象调试时用 50/150 边缘很干净赛场换了一张光照偏暗的图边缘断成一段一段轮廓面积偏小尺寸偏小。原因Canny 是固定阈值对光照变化没有自适应能力。解决要么改成自适应阈值用图像灰度中位数乘以系数来定双阈值要么在 Canny 之前做一次 CLAHE 直方图均衡把对比度拉回来。我一般两个都上先 CLAHE 再自适应 Canny。clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray_eq clahe.apply(gray) median np.median(gray_eq) low int(max(0, 0.66 * median)) high int(min(255, 1.33 * median)) edges cv2.Canny(gray_eq, low, high)clipLimit 控制对比度增强幅度2.0 是温和值调到 4.0 会放大噪声。tileGridSize 是分块大小8x8 适合大多数分辨率。4.3 亚像素边缘在低对比度区域失效现象亚像素函数返回的坐标和像素级边缘几乎一样精度没提升。原因梯度重心法依赖灰度梯度如果边缘两侧灰度差小于 10梯度信噪比太低重心算出来就是噪声。解决先判断边缘对比度低于阈值就退回像素级结果并把这个位置标记出来。赛场上如果被测物是低对比度材质考虑加背光或换打光角度这比改算法有效。4.4 轮廓选错把背景里的干扰物当成被测物现象测量结果偶尔跳变同一张图跑两次结果不一样。原因findContours 找到多个轮廓max 按面积选如果背景里有个大面积干扰物就选错了。解决不要只按面积选。加两个约束轮廓的宽高比要在合理范围轮廓的填充率面积除以最小外接矩形面积要大于 0.7。这两个条件能把大多数干扰物过滤掉。4.5 源码里的路径写死换机器就跑不起来现象源码在自己电脑上跑得好好的拷到队友机器上报文件找不到。原因源码里用了绝对路径或者 Windows 反斜杠路径。解决全部改成相对路径用 os.path.join 或 pathlib 拼接。配置文件里的路径也改成相对路径并且把 data 目录一起打包。这个坑不涉及算法但赛前换机器调试时最耽误时间。5. 把测量精度再压一档多帧平均与误差验证的实操技巧前面四章把链路跑通了但赛题拿分靠的是重复精度不是单次精度。单次测量受噪声影响可能这次偏 0.01 mm下次偏 0.03 mm。多帧平均是最便宜的提升手段同一个位置拍 5 到 10 张每张独立算尺寸然后取中位数。中位数比均值抗异常值如果某张图边缘提取失败均值会被拉偏中位数不会。def measure_stable(img_paths, mtx, dist, cfg, n7): results [] for p in img_paths[:n]: r measure_one(p, mtx, dist, cfg) if r is not None: results.append(r) if len(results) 3: return None results.sort() return results[len(results) // 2] # 中位数n 取 7 是个经验值再多提升不明显还费时间。如果赛场只允许拍一张那就退而求其次在单张图上对同一条边取多个扫描位置每个位置算一次亚像素边缘然后对多个位置的结果取中位数。这相当于用空间换时间。误差验证不能只看一次测量。我一般会做两组验证第一组是重复性验证同一个标准件测 10 次看极差极差应该小于赛题要求的 1/3第二组是准确性验证用两个不同尺寸的标准件看测量值和真值的偏差是否一致如果偏差随尺寸线性变化说明像素当量有系统误差要重新标定。验证项方法合格标准重复性同件测 10 次取极差极差 要求精度 / 3准确性两个标准件对比真值偏差 要求精度线性偏差随尺寸变化无明显线性趋势最后说一个我自己的习惯每次改完参数不管改多小都重新跑一遍这两组验证把结果记在 config.yaml 旁边的 log 里。赛前调参最怕的就是改了一个参数把之前调好的另一个场景搞崩了有 log 才能回溯。这套链路不复杂难的是每个环节都守住精度底线别让一个偷懒的默认参数把整条链路拖垮。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进