ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Realsense D435i机械臂手眼标定:从1cm到1mm的精度优化实战

Realsense D435i机械臂手眼标定:从1cm到1mm的精度优化实战 1. 先搞清楚误差从哪来一整套标定链路的瓶颈在哪机械臂装上Realsense D435i之后我第一版手眼标定是在网上找了个现成脚本跑的。流程看起来没毛病拍标定板、读机械臂位姿、solvePnP解标定板位姿、calibrateHandEye出手眼矩阵最后把相机坐标转到机械臂基座坐标。结果一实测视觉引导机械臂去点一个目标点偏差直接1cm往上走。这个精度在大多数抓取、装配场景里都不可用我当时第一反应是算法选型有问题后来把整条链路从头捋了一遍才发现手眼标定这个活儿算法只占三成剩下七成都藏在数据采集和预处理里。先说清楚手眼标定到底在解什么题。相机能看到目标点得到的是“目标在相机坐标系下的坐标”机械臂只能执行“目标在基座坐标系下的坐标”。这两个坐标系之间差了一个固定的刚体变换就是手眼矩阵。标定的时候我们借助一块已知几何尺寸的棋盘格标定板把相机和机械臂两套系统关联起来。Eye-in-Hand相机装在机械臂末端和Eye-to-Hand相机固定在外侧两种模式本质上都是解AXXB这个问题OpenCV里calibrateHandEye这个函数就是干这个的。但问题在于这个函数只负责从输入的两组位姿序列里求解你喂给它的数据如果是歪的解出来的手眼矩阵必然歪。当时我做了个误差链路分析把从相机成像到最终坐标转换的每一个环节都列出来发现有五个地方都可能贡献误差内参不准导致solvePnP求出的标定板位姿带偏差角点提取停在整像素精度机械臂末端位姿读取的坐标系约定搞错姿态数据集合太单一导致旋转分量约束不足标定板不平整或者方格尺寸量错。这五个问题每一个单独拎出来都足以让最终误差到厘米级。我后来把每一环都处理了一遍最后实际对点测试稳定在0.8到1.2mm。这篇文章就是从这五条线展开把我实际用的方法、代码、筛选逻辑全部写出来。优化前后的量化结果先放出来后面我会逐个环节解释怎么做到的。指标优化前优化后棋盘格角点重投影误差0.3-0.5 px0.05-0.1 px手眼标定残差基座下固定点波动5-8 mm0.5-1.2 mm视觉引导实际对点测试10-15 mm0.8-1.2 mm2. 内参是地基把D435i的相机参数重新标一遍2.1 为什么不能直接信任出厂内参很多人做手眼标定有个习惯直接从Realsense SDK里读内参或者干脆用厂商给的默认参数然后就直接去跑solvePnP。我第一版也是这么干的后来发现这就是误差链路上的第一块多米诺骨牌。Realsense D435i出厂时确实有内参标定数据烧录在设备里SDK的get_intrinsics()读出来的就是出厂值。但这个出厂值有两个问题一是镜头模组在运输、温度变化、长期使用后参数会有漂移二是出厂标定用的标定环境和你的实际使用环境温度、光照、对焦状态不一致。更关键的是D435i的RGB镜头对温度比较敏感相机开机一段时间后焦距和主点位置都会发生微小的变化。如果你的相机是开机即用从冷机到热机的过程中内参可能已经偏了不少。内参不准对后续手眼标定的影响是间接但致命的。solvePnP的本质是用内参把2D角点反投影成3D方向线内参的fx、fy、cx、cy稍有偏差求出来的tvec平移向量就会成比例地偏尤其深度方向的距离分量对fx、fy非常敏感。手眼标定里的target2cam如果整体带着偏差后续calibrateHandEye解出来的手眼矩阵肯定也不对。所以我的建议是到手第一件事用OpenCV的calibrateCamera把内参重新标一遍别直接信出厂值。2.2 D435i内参读取与OpenCV标定流程先通过pyrealsense2把当前的出厂内参读出来作为OpenCV标定的初始参考import pyrealsense2 as rs import numpy as np pipeline rs.pipeline() config rs.config() config.enable_stream(rs.stream.color, 1280, 720, rs.format.bgr8, 30) pipeline.start(config) # 等相机预热让内参稳定下来 for _ in range(30): pipeline.wait_for_frames() profile pipeline.get_active_profile() stream profile.get_stream(rs.stream.color) intr stream.as_video_stream_profile().get_intrinsics() camera_matrix np.array([[intr.fx, 0, intr.ppx], [0, intr.fy, intr.ppy], [0, 0, 1]], dtypenp.float64) # rs2 intrinsics 的 coeffs 顺序为 [k1, k2, p1, p2, k3] # 和 OpenCV distCoeffs 的前5项一致可以直接用 dist_coeffs np.array(intr.coeffs, dtypenp.float64) print(Camera Matrix:\n, camera_matrix) print(Distortion:, dist_coeffs)注意两个细节一是分辨率一定要固定住如果你在1280x720下标定内参后续的识别、solvePnP也必须在1280x720下跑换分辨率内参就变了二是D435i的RGB内参和深度内参是两套手眼标定如果只用RGB图就只标RGB内参如果用深度图做定位那还得做RGB和深度对齐那个是另一个话题。然后用OpenCV的calibrateCamera重新标定。这一步我用的是常见的张正友标定法拍摄20到30张棋盘格图像覆盖画面的中心和边缘姿态要有倾斜、旋转、远近变化import cv2 import numpy as np import glob pattern (9, 6) # 内角点数量 square_size 0.03 # 方格边长单位米必须用卡尺实测 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 [] images sorted(glob.glob(calib_images/*.jpg)) for fname in images: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern, None) if not ret: print(跳过, fname) continue criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners_sub cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners_sub) ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) # 评估重投影误差 total_error 0 for i in range(len(obj_points)): proj, _ cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], mtx, dist) error cv2.norm(img_points[i], proj, cv2.NORM_L2) / len(proj) total_error error print(平均重投影误差:, total_error / len(obj_points)) print(标定后的内参矩阵:\n, mtx) print(标定后的畸变系数:, dist.ravel()) np.save(camera_matrix.npy, mtx) np.save(dist_coeffs.npy, dist.ravel())2.3 标定板怎么做才靠谱我把标定板单独拿出来说是因为这个环节太容易被忽略而且一旦出错后面所有步骤全白搭。首先不要用打印店打出来的那种普通纸直接贴在墙上。打印纸本身有弹性贴不平角点位置会随着纸张的微小起伏而偏移。我当时第一次就是因为图省事用A4纸打印了一张9x6的棋盘格贴在纸箱上结果solvePnP的重投影误差怎么都降不下去一直卡在0.5像素左右。后来我换成了让打印店用哑光相纸打印然后贴在一块8mm厚的铝板上用玻璃板压平之后再用美纹纸固定边缘问题立刻解决了。其次方格尺寸必须用游标卡尺实测。打印机的缩放误差通常在1%到2%看起来不起眼但30mm的方格如果实际打印出来是29.5mm标定板物方坐标整体就错了1.7%这个误差会直接传递到手眼矩阵里。我习惯量10个格子的总宽度再除以10取平均这样比单个格子量更准。另外棋盘格打印要选哑光面不要覆亮膜。亮膜在光线照射下会反光反光区域的角点提取会偏移亚像素级别的精度直接废掉。光照方面尽量用柔和的漫射光避免强光直射标定板产生镜面反射。2.4 怎么判断内参标得好不好判断标准不是看calibrateCamera返回的内参矩阵和出厂值差多少而是看重投影误差。我内参标完之后的平均重投影误差在0.05到0.1像素之间如果超过0.2像素先检查标定板平不平、角点提取有没有错位、采集图像够不够多样。这里还有个容易被忽略的细节D435i的RGB图像默认带一定程度的镜头畸变尤其是画面边缘桶形畸变比较明显。如果内参不准solvePnP在画面边缘的角点反投影误差会很大。所以重新标定之后我推荐把calibrateCamera多跑几轮每次剔除重投影误差最大的那几张图迭代2到3次让内参更干净。这个思路后面在手眼标定的数据筛选里也会用到。3. 手眼标定的数据采集与筛选真正的精度分水岭3.1 一个“能跑”但“不准”的标定流程长什么样内参弄好了接下来就是手眼标定本身。老实说OpenCV的calibrateHandEye用起来很简单喂两组位姿序列进去几十行代码就能跑出结果。但“能跑”和“跑得准”是两个世界。我第一版流程是这样的机械臂随便走十几个位置在每个位置停下记录末端位姿同时拍一张标定板照片然后直接跑solvePnP和calibrateHandEye。结果就是1cm误差。后来我发现问题出在三个地方一是机械臂走的姿态太保守基本在同一个平面内平移旋转分量很少导致手眼矩阵的旋转部分约束不足二是没有对solvePnP的结果做质量检查有些照片角点提取本身就偏了重投影误差高达0.5像素也照样送进calibrateHandEye三是机械臂末端位姿从控制器读出来之后直接按默认的欧拉角约定转成旋转矩阵没有确认这个约定跟OpenCV的期望是否一致。这三件事每一件都足以毁掉最终精度。3.2 姿态多样性为什么15组数据仍可能解出病态结果手眼标定本质上是用“机械臂末端的变化量”和“相机观察到的标定板的变化量”去求解两者之间的固定变换。如果机械臂只在水平面内平移不做俯仰、翻滚、偏航的变化那么旋转部分的约束就严重不足解出来的手眼矩阵旋转分量会非常脆弱换个姿态就失效。我建议的采集策略是至少采集15到20组数据每组数据之间机械臂末端的姿态要有明显的旋转差异包括绕X轴、Y轴、Z轴的旋转都要覆盖。具体操作上可以写一个机械臂的自动走点脚本让末端在某个工作空间内以不同的姿态到达一系列目标点每到一个点稳定1到2秒后记录数据和拍照。另一个容易被忽略的点是距离覆盖。相机到标定板的距离要覆盖你实际工作的距离范围。如果你的实际工作距离是300到500mm那就应该在这个距离范围内分布采样不要全在400mm处拍。距离单一会导致tvec的尺度约束不够解出来的手眼矩阵在非标定距离上的外推误差很大。自动化采集的示意逻辑大概是# 机械臂走点采集示意伪代码 target_poses generate_poses( n20, position_range[300, 500], # 相机到标定板距离范围单位mm rotation_range[-30, 30], # 相对基准姿态的转角范围单位度 ) records [] for pose in target_poses: robot.goto(pose) # 移动机械臂到目标位姿 time.sleep(1.0) # 等机械臂稳定 base_T_end robot.get_pose() # 读机械臂末端在基座下的位姿 color_image camera.read() # 拍一张RGB图 ret, corners_sub detect_chessboard(color_image, pattern) if not ret: continue # 拍不到完整棋盘格就丢弃 records.append({ base_T_end: base_T_end, image: color_image, corners: corners_sub, timestamp: time.time(), })每次拍照前先确认findChessboardCorners找到了完整棋盘格没找到就重新调整机械臂姿态再拍不要硬凑数。3.3 异常数据筛选重投影误差与手眼残差双阈值数据采集完不能立刻一股脑全部送进calibrateHandEye。我实际跑下来的经验是20组数据里总有几组是脏的——可能是机械臂到位瞬间还有微小抖动可能是快门瞬间光线变化导致图像边缘有模糊可能是标定板局部反光导致亚像素角点偏移。这些脏数据混进去求解结果会整体被拉偏。我用的筛选方法分两级。第一级是solvePnP重投影误差def solve_board_pose(corners_sub, mtx, dist, pattern, square_size): 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 ret, rvec, tvec cv2.solvePnP(objp, corners_sub, mtx, dist, flagscv2.SOLVEPNP_ITERATIVE) if not ret: return None, None, None # 将3D角点按当前解投影回图像算平均重投影误差 proj, _ cv2.projectPoints(objp, rvec, tvec, mtx, dist) reproj_error np.mean(np.linalg.norm(proj.reshape(-1, 2) - corners_sub.reshape(-1, 2), axis1)) R, _ cv2.Rodrigues(rvec) tvec tvec.reshape(3) return R, tvec, reproj_error对每一组数据算一次重投影误差误差大于0.15像素的直接剔除。这个阈值不是拍脑袋定的我测试下来质量好的图像在亚像素角点提取后的solvePnP重投影误差通常能到0.05像素以内超过0.15像素基本就是图像有问题。第二级是手眼残差筛选。先拿全部数据跑一次calibrateHandEye得到一个初始手眼矩阵然后利用手眼关系T_base_end T_end_cam T_cam_target对每组数据都计算出标定板在基座坐标系下的位姿。因为标定板是固定不动的理论上每组数据算出来的T_base_target应该完全一致。残差就是每组数据算出来的T_base_target和所有组平均值的偏差。偏差大于某个阈值的组剔除掉用剩下的干净数据重新跑一遍calibrateHandEye如此迭代2到3次。这一步的代码我会在下一章给出因为涉及完整的求解流程。3.4 机械臂末端位姿坐标系约定比算法更容易翻车机械臂末端位姿的读取看起来是最简单的环节实际上是最容易翻车的。每家的机器人控制器返回位姿的格式不一样有的返回四元数有的返回欧拉角欧拉角还有不同的旋转顺序约定ZYX、ZYZ、XYZ甚至同样是ZYX还分内旋和外旋。读出来之后如果直接按默认方式转成旋转矩阵一旦约定和OpenCV期望的不一致手眼标定结果会偏差得离谱。我的建议是尽量从控制器获取旋转矩阵或者四元数不要碰欧拉角。四元数和旋转矩阵之间的转换是唯一的不会产生歧义。如果控制器只提供欧拉角那就先拿一个已知的末端姿态对照一下比如让机械臂末端绕某个轴转90度看读出来的角度变化是否符合预期确认清楚旋转约定之后再写转换代码。另外还有一个细节确认你读到的末端位姿是法兰中心还是工具中心点TCP。如果机械臂配置了工具坐标系控制器默认返回的可能是TCP相对于基座的位姿。如果是这样手眼标定解出来的T_end_cam其实是“工具坐标系到相机坐标系”的变换跟法兰中心会差一个固定的工具偏移。这个偏移如果在后续使用手眼矩阵的时候没处理干净同样会造成毫米级误差。我习惯在标定前把工具坐标系重置一下让控制器直接返回法兰中心位姿少一层转换就少一个出错的可能。4. 完整Python实现从采集到求解再到验证4.1 整体流程梳理这里我给出一个完整可跑的标定流程骨架包含数据读取、角点提取、solvePnP、calibrateHandEye、残差计算和迭代筛选。实际使用的时候机械臂位姿的读取部分需要按照自己的机器人品牌适配我这边用robot_base_T_end这个函数占位它返回的是3x3旋转矩阵和3x1平移向量。整体流程分四步第一步遍历所有记录对图像做角点提取和solvePnP第二步用全部数据跑一次calibrateHandEye第三步用手眼矩阵计算每组数据的残差剔除离群组第四步用筛选后的数据重新求解迭代2到3次后输出最终手眼矩阵。验证部分放在5.1节。4.2 solvePnP求标定板位姿对每一组采集记录我们需要求出T_cam_target也就是标定板坐标系在相机坐标系下的位姿。注意cv2.solvePnP返回的旋转向量和平移向量旋转向量通过cv2.Rodrigues转成旋转矩阵import cv2 import numpy as np from scipy.spatial.transform import Rotation pattern (9, 6) square_size 0.03 def build_object_points(pattern, square_size): 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 return objp def solve_target_to_cam(corners_sub, mtx, dist, pattern, square_size): objp build_object_points(pattern, square_size) ret, rvec, tvec cv2.solvePnP( objp, corners_sub, mtx, dist, flagscv2.SOLVEPNP_ITERATIVE ) if not ret: return None R, _ cv2.Rodrigues(rvec) tvec tvec.reshape(3) # 重投影误差 proj, _ cv2.projectPoints(objp, rvec, tvec, mtx, dist) error float(np.mean(np.linalg.norm(proj.reshape(-1, 2) - corners_sub.reshape(-1, 2), axis1))) T_cam_target np.eye(4) T_cam_target[:3, :3] R T_cam_target[:3, 3] tvec return T_cam_target, error这一步有个容易踩的坑mgrid生成物方坐标的顺序必须和findChessboardCorners返回的角点顺序一致。findChessboardCorners的返回顺序是自左上角起逐行扫描行内从左到右上面mgrid[0:pattern[0], 0:pattern[1]].T.reshape(-1, 2)的写法正好对应这个顺序实测没有问题。4.3 calibrateHandEye求解手眼矩阵cv2.calibrateHandEye接收四个参数R_gripper2base、t_gripper2base、R_target2cam、t_target2cam形状分别是(N,3,3)和(N,3)。对于Eye-in-Handgripper2base就是机械臂末端在基座坐标系下的位姿也就是你从控制器读到的位姿target2cam是标定板在相机坐标系下的位姿也就是solvePnP求出来的位姿def run_handeye_calibration(records, mtx, dist, pattern, square_size, methodcv2.CALIB_HAND_EYE_TSAI): R_gripper2base_list [] t_gripper2base_list [] R_target2cam_list [] t_target2cam_list [] valid_records [] for rec in records: R_base_end rec[R_base_end] # 3x3旋转矩阵 t_base_end rec[t_base_end] # 3x1平移向量 # 读取图像提取角点 gray cv2.cvtColor(rec[image], cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern, None) if not ret: continue criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners_sub cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) result solve_target_to_cam(corners_sub, mtx, dist, pattern, square_size) if result is None: continue T_cam_target, reproj_error result # 第一级筛选重投影误差 if reproj_error 0.15: continue R_gripper2base_list.append(R_base_end) t_gripper2base_list.append(t_base_end) R_target2cam_list.append(T_cam_target[:3, :3]) t_target2cam_list.append(T_cam_target[:3, 3]) valid_records.append(rec) R_cam2gripper, t_cam2gripper cv2.calibrateHandEye( R_gripper2basenp.array(R_gripper2base_list), t_gripper2basenp.array(t_gripper2base_list), R_target2camnp.array(R_target2cam_list), t_target2camnp.array(t_target2cam_list), methodmethod, ) T_cam2gripper np.eye(4) T_cam2gripper[:3, :3] R_cam2gripper T_cam2gripper[:3, 3] t_cam2gripper.reshape(3) return T_cam2gripper, valid_records这里我默认是Eye-in-Hand场景。如果你是Eye-to-Hand相机固定、标定板装在机械臂末端calibrateHandEye的入参需要交换一下把从控制器读到的末端位姿作为target2cam把solvePnP求出的标定板位姿作为gripper2base传进去解出来的就是相机在基座坐标系下的位姿。这块不要搞混OpenCV官方文档里也强调过。4.4 手眼残差计算与迭代剔除拿到初始手眼矩阵之后用固定标定板的约束来做第二级筛选。由于标定板在物理世界里是不动的对每一组数据理论上T_base_target T_base_end T_end_cam T_cam_target应该是一个常量矩阵。残差计算就是把这个常量的平均值算出来再看每一组偏离平均值多少def compute_residuals(T_base_end_list, T_cam_target_list, T_cam2gripper): T_end2cam np.linalg.inv(T_cam2gripper) T_base_target_list [] for T_base_end, T_cam_target in zip(T_base_end_list, T_cam_target_list): T_base_target T_base_end T_end2cam T_cam_target T_base_target_list.append(T_base_target) # 对平移部分取平均旋转部分用四元数平均 t_mean np.mean([T[:3, 3] for T in T_base_target_list], axis0) quats [] for T in T_base_target_list: quats.append(Rotation.from_matrix(T[:3, :3]).as_quat()) q_mean Rotation.from_quat(np.mean(quats, axis0)).as_matrix() residuals [] for T in T_base_target_list: t_err np.linalg.norm(T[:3, 3] - t_mean) r_err np.degrees( np.linalg.norm(Rotation.from_matrix(T[:3, :3].T q_mean).as_rotvec()) ) residuals.append((t_err, r_err)) # 返回平移残差mm和旋转残差deg以及平均T_base_target T_base_target_avg np.eye(4) T_base_target_avg[:3, :3] q_mean T_base_target_avg[:3, 3] t_mean return residuals, T_base_target_avg迭代剔除的逻辑是把平移残差大于2mm的组剔除用剩余数据重新跑calibrateHandEye再算残差直到没有新的组被剔除。我实测下来第一次剔除能干掉2到3组脏数据第二次一般就没有了。4.5 最终的完整标定入口def calibrate_with_refinement(records, mtx, dist, pattern, square_size, max_iter3, t_threshold_mm2.0): current_records records T_cam2gripper None valid_records [] for iteration in range(max_iter): T_cam2gripper, valid_records run_handeye_calibration( current_records, mtx, dist, pattern, square_size ) # 收集当前保留数据 R_base_end_list [] t_base_end_list [] R_cam_target_list [] t_cam_target_list [] for rec in valid_records: T_cam_target, _ solve_target_to_cam( rec[corners_sub], mtx, dist, pattern, square_size ) if T_cam_target is None: continue R_base_end_list.append(rec[R_base_end]) t_base_end_list.append(rec[t_base_end]) R_cam_target_list.append(T_cam_target[:3, :3]) t_cam_target_list.append(T_cam_target[:3, 3]) residuals, T_base_target_avg compute_residuals( [np.eye(4)] # 占位实际使用时需要组装T_base_end ) # 实际使用时要先组装T_base_end矩阵再传入 # 这里略过矩阵组装逻辑和上面的compute_residuals一致 # 筛掉平移残差超过阈值的组 kept [] for rec, (t_err, r_err) in zip(valid_records, residuals): if t_err t_threshold_mm: kept.append(rec) current_records kept print(fIteration {iteration 1}: 保留 {len(kept)} 组数据) if len(kept) len(valid_records): break np.save(handeye_matrix.npy, T_cam2gripper) return T_cam2gripper, valid_records需要说明的是上面这段是流程示意compute_residuals里T_base_end的矩阵组装需要你根据自己数据格式补齐。思路就是先求全量数据的T_base_target均值再按残差阈值筛选迭代求解。5. 实测对比与踩坑复盘从1cm到1mm做完的事5.1 各环节优化前后的量化对比全部流程跑完我来给一个透明的实测对比。同一台D435i、同一个机械臂、同一块标定板只是处理方式从“脚本一键跑通”变成“按上面这套流程走”结果差异非常明显。环节优化前做法优化后做法误差变化相机内参SDK出厂值OpenCV重新标定solvePnP重投影误差从0.3-0.5px降到0.05-0.1px角点精度findChessboardCorners直接用加cornerSubPix亚像素角点定位精度从1px提到0.1px以内标定板A4打印纸贴纸箱哑光相纸贴铝板卡尺实测方格物方坐标误差从1%-2%降到0.1%以内数据筛选全部送进去算重投影误差手眼残差双阈值的迭代剔除手眼残差从5-8mm降到0.5-1.2mm姿态多样性机械臂随意走覆盖距离300-500mm、旋转±30°自动走点旋转分量约束足矩阵不再病态坐标系约定默认欧拉角转旋转矩阵控制器返回旋转矩阵/四元数消除欧拉角顺序歧义带来的系统性偏差最终的实际对点测试我的操作方式是在标定板上选一个特定的内角点先通过整条坐标变换链路把这个角点在机械臂基座坐标系下的坐标预测出来然后让机械臂末端安装的尖锥去接触这个点用千分尺量出实际到达位置和预测位置的偏差。跑了几十个点结果稳定在0.8到1.2mm之间好的时候能到0.6mm。这就是从1cm到1mm做完的事。不是说哪个环节单独起作用而是每个环节都压掉一点最后叠加起来误差就到了mm级别。5.2 踩过的坑和对应的解决方式第一个坑棋盘格打印纸不贴平。这个问题最容易出现在步骤最前面但影响会贯穿整个标定过程。当时我遇到的典型现象是findChessboardCorners能找到角点calibrateCamera的重投影误差也不算离谱但手眼标定的残差就是降不下来。多方排查后发现把标定板按在玻璃板下和直接贴纸箱上solvePnP的结果能差出好几个毫米。后来统一改成哑光相纸贴铝板问题才彻底消失。第二个坑cornerSubPix一定不能省。findChessboardCorners返回的角点坐标是像素级的虽然很多情况下看起来位置差不多但在亚像素层面上偏差可能在0.2到0.5像素之间。这个偏差经过solvePnP反投影到3D空间在300mm的工作距离上可能会造成1到2mm的位姿误差。解决方案就一行代码cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)但是漏掉这一行精度上限就被锁死了。第三个坑机械臂末端位姿的欧拉角顺序。当时用的是一款国产协作臂控制器返回的是欧拉角文档里写的是ZYX顺序。我照着文档转成旋转矩阵标出来的手眼矩阵残差特别大后来发现文档写的是外旋ZYX而我用的转换函数默认是内旋ZYX两者转出来的旋转矩阵差了十万八千里。从那以后我坚持从控制器底层拿原始四元数或者先做一次已知角度的验证再写代码。第四个坑数据姿态太单一。当时有一版标定数据都是在同一高度、同一朝向拍的我以为是够的结果calibrateHandEye跑出来的结果在标定数据上残差挺小一换姿态就崩。这个问题的根源是姿态集合里旋转变化量太小矩阵求解的病态性被掩盖了。后来增加姿态多样性后残差和实际测试结果都明显改善。第五个坑cv2.solvePnP的参数选择。用默认的SOLVEPNP_ITERATIVE在内参准确的前提下没问题。但如果内参标得不好可以尝试SOLVEPNP_AP3P它对方差没那么敏感。不过最终我不太依赖换求解器而是把重心放在把内参和角点质量做上去。输入干净了哪个求解器结果都差不多。还有一个很多人问的问题OpenCV、Halcon、VisionMaster谁的手眼标定更准我的体会是这些工具的核心算法都是解AXXB数学上没有本质差别。Halcon和VisionMaster的优势在于数据管理和可视化能帮你很快发现问题出在哪一组数据上但如果你在OpenCV这套流程里自己做了数据筛选和残差分析效果不会比它们差。5.3 这套方法能推广到什么程度这套优化思路不只适用于Realsense D435i换成其他品牌彩色相机、工业相机流程完全一样。核心就三句话内参重新标定角点提到亚像素数据按残差迭代筛选。只要相机是针孔模型标定板是棋盘格这套流程就能直接迁移。但有两个边界要说清楚。第一如果你的机器人本身绝对定位精度差比如重复定位精度好但绝对定位精度在5mm以上那手眼标定做得再精细实际对点误差也受限于机械臂本身的定位能力。手眼矩阵只能补偿传感器两套坐标系间的固定变换补偿不了机械臂的运动学误差。第二如果相机用D435i的深度图做引导别忘了深度图和RGB图是两套内参且存在深度误差一般都需要先做深度标定或者至少做RGB-D对齐验证。我这边主要用RGB深度相关的不展开。这套东西做完之后后续还能扩展的方向是自动化标定流程把机械臂走点、拍照、筛选、求解全部串成一个脚本每次换相机或者换安装位置后一键重标。我在实际项目里已经这么用了每次标定从原来的半天缩短到10分钟。这也是我觉得OpenCV这条路比纯手工用Halcon标定更顺手的地方——全流程可编程数据可复现。
RELATED READING

延伸阅读

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