做机械臂视觉抓取的人,基本都会卡在手眼标定这一关。网上教程不少,但大多要么只讲数学推导,要么用的是虚拟数据,真正拿 Realsense D435 和睿尔曼机械臂完整跑通一遍的,反而不多。我最近刚好完成了一个这样的项目,把整个流程整理出来分享一下。
这篇文章聚焦的是最常见的 Eye-to-Hand 场景:D435 相机固定在工作区上方,睿尔曼机械臂末端装一块标定板,通过采集机械臂位姿和相机观察到的板子位姿,解算出相机坐标系到机械臂基座坐标系的固定变换。有了这个变换,后续做视觉引导、抓取定位,才能保证相机看到的点和机械臂实际能抓到的点对得上。
整个流程拆开就是 5 个关键步骤:方案选型与场景搭建、Python 工具链与环境准备、数据采集、标定计算、结果验证。每个步骤里都藏着经验值,尤其是数据采集和结果验证,往往决定了标定出来的矩阵是“能用”还是“只是算完了”。这篇文章适合已经在用睿尔曼机械臂做开发、准备接入视觉定位项目的工程师;如果你用的是其他机械臂,流程也完全一样,只需要替换 SDK 相关代码。
1. 手眼标定的总体思路与方案选型
1.1 先搞清楚你属于哪种手眼关系
手眼标定不是一套固定代码就能通吃的方案,动手之前第一件事是确认系统构型。相机装在机械臂法兰盘上是 Eye-in-Hand(眼在手上),相机固定在外部支架上是 Eye-to-Hand(眼在手外)。这两种构型标定出来的矩阵含义完全不一样,后面 AX=XB 方程里的 A 和 B 构造方式也不同。
Eye-in-Hand 的优势是相机跟着末端走,可以在不同角度观察目标,离得近、分辨率高,适合需要近距离精细操作的场景,比如螺丝锁付、插件插拔。缺点也很明显:相机跟着机械臂动,视野不稳定,标定结果对末端负载和机械臂自身精度更敏感,而且 D435 这种相机加支架的重量不轻,装到末端会影响有效负载。
Eye-to-Hand 更适合大多数“看全局再抓取”的场景。D435 固定在支架上,既不影响机械臂负载,也不容易在运动中撞到东西,安装简单,稳定性好。弊端是机械臂本身可能遮挡目标,布置工作区时要留出足够余量,而且对机械臂工作空间和相机视野的覆盖范围需要前期规划。
从数学上说,两种构型最后都归结为解 AX=XB,但 A 和 B 的定义不一样。如果直接套 OpenCV 的calibrateHandEye而不理解内部约定,算出来的矩阵看着像模像样,实际用起来会“差之毫厘,谬以千里”。我这个项目采用 Eye-to-Hand,原因是机械臂主要在一个固定台面上做抓取和摆放,相机从上往下拍全局,视野广,装得也简单。
1.2 标定板选型:为什么我更推荐 ChArUco 板
标定板的选择直接影响检测鲁棒性。棋盘格检测算法成熟,但有个致命弱点:标定板只要有一小部分出了视野或者被遮挡,整帧检测就失败。机械臂带着标定板在不同姿态之间切换时,会有部分时刻冲出画面,或者反光导致局部失焦,这在实际采集时很常见。
ChArUco 板是 ArUco 码和棋盘格的结合体,每个格子周围都有二维码,即使局部被遮挡,也能通过剩余的 ArUco 码恢复位置,再用棋盘格交叉点做子像素细化,精度比纯 ArUco 码高。我在实际采集 20 组数据的过程中,ChArUco 几乎不会因为肢体遮挡而导致整帧废掉,这是它对比棋盘格最大的优势。
ChArUco 板可以直接用 OpenCV 生成并打印。打印时要注意两个点:纸面要平整,最好贴在硬质铝板或亚克力板上;格子边长要和工作距离匹配。经验值大致是工作距离 400~600mm 时,格子边长 20~25mm 比较合适,格子在图像里大约占 30~50 像素。太小则亚像素检测精度不够,太大则容易出画面边界。
生成代码很简单:
import cv2 dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) board = cv2.aruco.CharucoBoard_create(8, 6, 0.04, 0.02, dictionary) board_image = board.generateImage((2000, 1500), None, 10, 1) cv2.imwrite("charuco_board.png", board_image)这里的参数依次是:列数 8、行数 6、格子边长 0.04 米、码边长 0.02 米。打印出来之后一定要用尺子量一下实际格子边长,因为打印机的缩放比例可能和理论值有偏差,标定时要以实测值重新生成 board 文件。
1.3 硬件与工具链总览
这个项目用到的硬件和软件组件并不复杂,关键是把每一环的职责理清楚。D435 在这里主要负责提供标定板检测所用的彩色图像,理论上标定过程用不到深度数据,但深度图在后面视觉引导阶段有大用。D435 采用全局快门传感器,这是它适合机械臂场景的重要原因:机械臂运动过程中抓图不容易出现卷帘快门那样的运动拖影。
从软件链路看,需要准备的东西如下:
| 角色 | 组件 | 职责 |
|---|---|---|
| 相机驱动 | pyrealsense2 | 取彩色/深度流、读取内参、对齐深度 |
| 视觉处理 | opencv-contrib-python | 标定板检测、PnP、手眼矩阵求解 |
| 数学库 | NumPy / SciPy | 矩阵运算、旋转表示转换 |
| 机械臂 SDK | 睿尔曼官方 Python SDK / TCP API | 读取末端位姿、控制机械臂运动 |
| 标定板 | ChArUco 板 | 作为视觉特征基准 |
环境方面建议用独立的 conda 环境或者虚拟环境,避免不同项目依赖冲突。Python 版本建议 3.9 或 3.10,目前 pyrealsense2 和 OpenCV 对这些版本的支持都很好。硬件上只要一台带 USB 3.0 接口的电脑、D435、睿尔曼机械臂、一块自制标定板就够了,不需要额外的专用设备。
2. 环境准备与 Python 工具链搭建
2.1 Python 环境与依赖安装
依赖安装本身不难,但有几个细节容易踩坑。pyrealsense2 和 OpenCV 的 wheel 包在不同 Python 版本、不同操作系统上有差异,建议直接用 pip 安装:
pip install pyrealsense2 opencv-contrib-python numpy scipy这里特别提醒:ArUco 相关接口在opencv-contrib-python包里,不在基础版opencv-python。只装了基础版,调用cv2.aruco时直接 AttributeError。如果之前装过 opencv-python,先卸载再装 contrib 版本,否则两个包同时存在,模块路径会被占掉,出现各种诡异问题。
装完之后做一个快速验证:
import cv2 import pyrealsense2 as rs import numpy as np print("OpenCV:", cv2.__version__) print("RealSense:", rs.__version__) print("NumPy:", np.__version__)如果 OpenCV 是 4.8 以上,cv2.aruco大部分 API 都兼容。唯一要注意的是CharucoBoard_create是旧接口,新版本虽然还保留着,但使用时会有类似于 deprecation warning 的提示,不影响功能,不用太紧张。
2.2 睿尔曼机械臂 Python 接口准备
睿尔曼机械臂的 SDK 在不同版本间差异不小。我这个项目走的是 TCP 远程连接方式,通过机械臂的 Python SDK 建立一个网络连接,之后既能读取当前位姿,也能发送运动指令。流程上基本是这样:
# 注意:接口名以你拿到的SDK版本为准 import rm_sdk # 睿尔曼官方SDK arm = rm_sdk.RmRobot("192.168.1.18") # 机械臂实际IP arm.connect() pose = arm.get_current_pose() print(pose)这里最容易踩坑的是位姿单位问题。睿尔曼机械臂的位置一般返回毫米,欧拉角单位有可能返回度,也有可能返回弧度,必须看 SDK 文档确认。我在项目里先打印一组位姿,再和机械臂示教器上的数值肉眼对比,确认单位和数值范围,这个步骤别跳过。
欧拉角的旋转顺序也一样重要。有的 SDK 返回的是 RPY 顺序(绕固定轴 X-Y-Z),有的是 ZYX 欧拉角,不同顺序转换出来的旋转矩阵完全不同。我的建议是:如果 SDK 能直接返回旋转矩阵或四元数,优先用旋转矩阵格式,从源头绕开欧拉角歧义。如果只能返回欧拉角,就统一用 scipy 的 Rotation 库来转换,别自己手写公式:
from scipy.spatial.transform import Rotation as R rpy = [rx, ry, rz] # 从机械臂SDK获取 R_tool2base = R.from_euler('xyz', rpy, degrees=True).as_matrix()这里的'xyz'要按你 SDK 实际的旋转顺序来改。不确定的话,用示教器让机械臂分别绕单个轴转动,观察返回值的正负和变化趋势,很快就能确定。
2.3 D435 相机数据流初始化与内参读取
D435 的彩色流和深度流默认不对齐,但手眼标定阶段只处理彩色图像,所以可以只开彩色流。不过为了后面视觉引导省事,我更建议从最开始就写一个相机类,内部封装 pipeline 和 align,统一返回对齐后的彩色图和深度图。这样标定完直接进入视觉引导模块,不用再重写一遍相机逻辑。
import pyrealsense2 as rs import numpy as np class CameraD435: def __init__(self, width=1280, height=720, fps=30): self.pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, width, height, rs.format.bgr8, fps) config.enable_stream(rs.stream.depth, width, height, rs.format.z16, fps) self.profile = self.pipeline.start(config) align_to = rs.stream.color self.align = rs.align(align_to) color_profile = self.profile.get_stream(rs.stream.color) intrinsics = color_profile.as_video_stream_profile().get_intrinsics() self.K = np.array([ [intrinsics.fx, 0, intrinsics.ppx], [0, intrinsics.fy, intrinsics.ppy], [0, 0, 1] ]) self.dist = np.array(intrinsics.coeffs) def get_frame(self): frames = self.pipeline.wait_for_frames() frames = self.align.process(frames) color = np.asanyarray(frames.get_color_frame().get_data()) depth = np.asanyarray(frames.get_depth_frame().get_data()) return color, depth相机内参 K 和畸变系数 dist 是后面标定板检测、PnP 求解的输入。D435 的内参大部分情况下直接用内置的就行。但如果项目对精度要求较高,比如要做毫米级的抓取定位,建议先用 OpenCV 的calibrateCamera对 D435 重新做一次内参标定,因为出厂内参和当前温度、焦距状态可能已经有偏移,内参误差会直接传导到手眼标定结果里。
3. 标定数据采集(决定成败的环节)
3.1 布置标定场景
标定场景布置看起来简单,实际上是最能拉开结果差距的一步。相机的高度和角度要同时满足两个条件:一是标定板在机械臂运动范围内的任意位姿下都能完整出现在画面中;二是标定板尺寸在画面里足够大,让角点检测有足够的像素分辨率。
相机固定好之后,先把机械臂分别移到工作空间的几个角点,在电脑上位机上看一眼实时画面,确认标定板不会出画面。接着要处理反光问题。D435 的彩色传感器对高光比较敏感,标定板表面一旦反光,ArUco 检测很容易失败。打印纸用哑光纸,不要用光面铜版纸,板子表面也不要贴亮面塑料膜。
光照是另一个老生常谈但总被忽略的问题。机械臂工作区上方如果有顶灯,不同角度下标定板上的亮度变化会很大,自动曝光会不断调整,导致检测不稳定。我的处理方式是关闭相机自动曝光,手动固定一组曝光值,让所有采样图像亮度保持一致。这个细节帮我省去了很多后续排查时间。
3.2 机械臂位姿规划:姿态丰富比数量多更重要
手眼标定解 AX=XB,对数据的要求是旋转部分要足够丰富。如果机械臂只在平移、姿态几乎不变,标定方程会退化,解出来的矩阵会严重发散。这是新手最容易犯的错误:明明采了 20 个位姿,结果全是机械臂平移到不同位置却不旋转,最后平移向量和旋转矩阵误差大得离谱。
正确做法是规划一组包含绕机械臂末端 X、Y、Z 轴旋转的运动。比如控制末端保持目标位置,先绕末端 X 轴旋转 -30°、0°、30°,再绕 Y 轴旋转 -30°、0°、30°,组合起来就是 9 个姿态。换几个位置重复一遍,总共 15~20 组,姿态覆盖就比较理想了。
我的采集习惯是 5 个不同位置,每个位置拍 3~5 个姿态,共 15~25 组。相邻两组之间的姿态变化不要太小,旋转轴不要趋近同一个平面。每采集完一组,在界面上看一眼检测结果,如果角点检测失败或者出现异常,立即补一条数据,别等到最后统一处理数据时才发现某组数据不能用。
触发机械臂运动可以直接用示教器手动摇,也可以写脚本让机械臂按轨迹自动走。项目里如果只标定这一次,手动摇就行;如果后面要反复标定,建议写一个自动采集脚本,让机械臂按预设的位姿序列自动运动、自动采集,省时省力。
3.3 同步采集与数据保存
采集流程的代码逻辑不复杂,关键是顺序和控制条件。我的推荐流程是:机械臂运动到目标位姿并稳定后,等待 0.5~1 秒让残余振动消失,再拍摄图像、检测标定板,最后读取机械臂位姿,一起写入数据文件。
为什么要等待?因为机械臂到位后会有短暂抖动,如果刚到位就拍照,标定板位置还没稳定,图像中的板子位姿和机械臂返回的位姿就不对应。这个误差会直接进入 AX=XB 方程,让结果不稳定。等待时间不用太长,协作机器人的残余振动几百毫秒基本消除。
下面是核心采集代码框架:
import cv2 import numpy as np from scipy.spatial.transform import Rotation as R def euler_to_rotation(rpy, degrees=True): # 按你SDK实际的顺序调整'xyz' return R.from_euler('xyz', rpy, degrees=degrees).as_matrix() def collect_sample(camera, arm, board, aruco_params, K, dist): color, _ = camera.get_frame() gray = cv2.cvtColor(color, cv2.COLOR_BGR2GRAY) corners, ids, _ = cv2.aruco.detectMarkers( gray, board.dictionary, parameters=aruco_params ) if ids is None or len(ids) < 4: return None ret, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco( corners, ids, gray, board ) if not ret or len(charuco_corners) < 8: return None ret, rvec, tvec = cv2.aruco.estimatePoseCharucoBoard( charuco_corners, charuco_ids, board, K, dist, None, None ) if not ret: return None # 机械臂位姿:形如 [x, y, z, rx, ry, rz] pose = arm.get_current_pose() R_tool2base = euler_to_rotation([pose[3], pose[4], pose[5]]) t_tool2base = np.array(pose[0:3], dtype=np.float64) return rvec, tvec, R_tool2base, t_tool2base采集到的数据可以直接保存成.npz文件,方便后续标定计算时反复加载:
np.savez('handeye_calib_data.npz', rvecs=rvecs, tvecs=tvecs, R_tool2base=R_tool2base, t_tool2base=t_tool2base)保存之前强烈建议在窗口里实时可视化 ChArUco 检测结果,把角点和坐标轴画出来。如果某一帧角点错乱,当场就删掉重采。另外打印每个位姿的角点数量和重投影误差,重投影误差超过 1 像素的数据直接丢弃,不用留到后面再取舍。
3.4 数据有效性检查
采集完 15~20 组数据后,不要急着解算,先做一个快速体检。统计所有机械臂位姿的旋转轴分布,看是否覆盖了 X、Y、Z 三个方向;统计标定板 tvec 的位置范围,看是否覆盖了相机视野的不同区域。如果某一轴的旋转角度全部接近 0,说明姿态自由度不够,需要补采。
单位统一也要在这个阶段做掉。机械臂返回的位置通常是毫米,而 OpenCV 的estimatePoseCharucoBoard输出 tvec 的单位取决于生成 board 时填写的格子边长单位。如果格子边长填0.04(单位米),tvec 就是米。两种单位混用,平移向量直接差三个数量级,这种错误在标定结果上会表现得非常明显:旋转矩阵看着正常,平移向量却大得离谱。
4. 手眼标定计算与结果验证
4.1 标定板位姿求解
在采集代码里,我们已经通过estimatePoseCharucoBoard得到了每个位姿下标定板相对于相机坐标系的 rvec 和 tvec。这个 rvec 和 tvec 组成齐次矩阵 H_cam_board。
有几个细节要说明一下:
- rvec 是旋转向量,要调用
cv2.Rodrigues转成旋转矩阵。 estimatePoseCharucoBoard返回的平移向量指向标定板原点在相机坐标系下的位置,原点通常在左上角第一个内角点。- 标定板坐标系的轴向方向由 OpenCV 内部约定决定,这不影响求解,只要所有数据一致即可。
把 rvec/tvec 转成齐次矩阵的函数可以这样写:
def rvec_tvec_to_homogeneous(rvec, tvec): R, _ = cv2.Rodrigues(np.asarray(rvec).flatten()) T = np.eye(4) T[:3, :3] = R T[:3, 3] = np.asarray(tvec).flatten() return T4.2 构造 AX=XB 问题并求解
手眼标定的核心数学问题可以写成:
A · X = X · B
其中 X 是我们要求解的手眼矩阵。在 Eye-to-Hand 场景下,X 就是相机坐标系到机械臂基座坐标系的变换矩阵,也就是后面视觉引导时把相机坐标转为机械臂坐标的关键。
用比较直观的方式解释这个方程:相机固定在某处,机械臂带着标定板先走位姿 1,再走位姿 2。机械臂自己知道两次位姿之间的相对变化,对应 A;相机观察到标定板两次位姿之间的相对变化,对应 B。要求解的 X,就是把“相机观察到的变化”和“机械臂自身的变化”联系起来的那座固定桥梁。
定义好变量:H_base_tool 表示机械臂末端在基座下的位姿,H_cam_board 表示标定板在相机坐标系下的位姿。标定板固定在机械臂末端,所以存在固定变换 H_tool_board 满足:
H_base_cam · H_cam_board = H_base_tool · H_tool_board
对两次位姿 i 和 j 分别列方程,消去 H_tool_board,整理后得到:
A = H_base_tool_j · inv(H_base_tool_i) B = H_cam_board_j · inv(H_cam_board_i) X = H_base_cam
也就是 A · X = X · B。
这里有个容易搞混的点:A 是“位姿 j 相对位姿 i”的变换,不要写成 inv(H1)·H2 的形式。B 同理。方向和顺序一旦弄反,解出来的矩阵就会很怪。
OpenCV 的calibrateHandEye本质上就是在解 AX=XB,但他的输入约定是 R_gripper2base / t_gripper2base 和 R_target2cam / t_target2cam,内部按 Eye-in-Hand 的约定构造方程。如果我们直接把 H_base_tool 作为 gripper2base、H_cam_board 作为 target2cam 传进去,解出来的 X 并不直接是 H_base_cam。
处理方法有个小技巧:把机械臂位姿取逆后作为 gripper2base 传入。H_gripper2base = inv(H_base_tool)。经过这个取逆构造,calibrateHandEye输出的结果就对应 H_base_cam。这也是 easy_handeye 等开源项目在 Eye-to-Hand 模式下的标准做法。
def solve_handeye(R_tool2base_list, t_tool2base_list, rvec_board2cam_list, tvec_board2cam_list, tool_pos_unit="mm"): R_gripper2base = [] t_gripper2base = [] R_target2cam = [] t_target2cam = [] for R_tb, t_tb, rvec_tc, tvec_tc in zip( R_tool2base_list, t_tool2base_list, rvec_board2cam_list, tvec_board2cam_list): # 构造H_base_tool,注意统一单位 H_bt = np.eye(4) H_bt[:3, :3] = R_tb t_tb = np.asarray(t_tb).flatten() if tool_pos_unit == "mm": t_tb = t_tb / 1000.0 H_bt[:3, 3] = t_tb # Eye-to-Hand下取逆,构造符合calibrateHandEye约定的输入 H_tb = np.linalg.inv(H_bt) R_gripper2base.append(H_tb[:3, :3]) t_gripper2base.append(H_tb[:3, 3]) R_tc, _ = cv2.Rodrigues(np.asarray(rvec_tc).flatten()) t_tc = np.asarray(tvec_tc).flatten() R_target2cam.append(R_tc) t_target2cam.append(t_tc) R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( np.array(R_gripper2base), np.array(t_gripper2base), np.array(R_target2cam), np.array(t_target2cam), method=cv2.CALIB_HAND_EYE_TSAI ) H_base_cam = np.eye(4) H_base_cam[:3, :3] = R_cam2gripper H_base_cam[:3, 3] = t_cam2gripper.flatten() return H_base_cam代码里有几个地方要注意。tool_pos_unit参数用来处理毫米和米混用的场景。生成 ChArUco 板时格子边长填的是米,所以 tvec 单位是米;机械臂位置如果返回毫米,就统一除以 1000 转成米,保持与 tvec 一致。method参数可以选cv2.CALIB_HAND_EYE_TSAI、PARK、DANIILIDIS等,我一般先用 TSAI,如果结果不稳定再换其他算法对比。
4.3 标定结果验证
算出手眼矩阵并不代表结束,验证环节比很多人想象的重要。一个最常用的验证思路是重投影验证:用标定得到的 H_base_cam,把某个位姿下机械臂末端在基座系中的位置转换到相机坐标系,再通过相机内参投影到图像平面,与图像中实际检出的标定板某个已知点比较,看误差是多少像素。误差应该在几个像素以内,如果偏差几十个像素,说明标定结果有问题。
更直观的验证是直接做一个视觉引导实验。放一个目标物到相机视野内,用视觉检测得到目标在相机坐标系下的坐标,用标定矩阵转换到机械臂基座坐标系,让机械臂末端移动到转换后的位置。如果机械臂能够准确对准目标,说明标定矩阵是真正可用的,而不只是数学上收敛。
第一次做视觉引导实验,建议让机械臂速度放慢,从多个不同位置分别验证。标定矩阵中的旋转矩阵往往比较容易准确,但平移向量如果单位出错或者标定数据质量差,一旦机械臂从远处运动过来,误差会被成倍放大。通过多位置验证可以快速暴露这种问题。
4.4 标定精度影响因素与多组数据交叉验证
标定精度的直接表现是标定矩阵在已知数据上的残差大小。但要注意,残差小不代表标定结果一定好,因为 AX=XB 本身是欠约束的,姿态数据不丰富时残差也可能很小。所以要用独立的数据做交叉验证。
我习惯的做法是把 20 组数据分成两份,前 15 组用于标定,后 5 组用于验证。用后 5 组的机械臂位姿和手眼矩阵,预测标定板在相机坐标系中的位姿,再与实际检测到的板子位姿比较,计算平移误差和旋转误差。如果平移误差在几个毫米、旋转误差在 1 度以内,标定质量就算不错。
还要理解标定精度的物理边界。手眼标定的输入之一是机械臂的位姿,而机械臂的绝对定位精度并不等于重复定位精度。协作机械臂绝对定位精度本来就不是特别高,手眼标定只能做到“相机视野范围内”的相对校正,如果机械臂本身绝对定位误差大,强求手眼矩阵误差到零点几毫米没有意义。睿尔曼这类协作机械臂,重复定位精度好、绝对精度次之,所以手眼标定后的最终误差是机械臂绝对精度、相机标定误差和手眼标定误差三者叠加的结果。
5. 常见问题与避坑指南
5.1 标定结果发散或明显不对,怎么排查
标定结果“看起来算出来了,但一验证就崩”,绝大多数情况是数据质量或者预处理逻辑出的问题。我整理了一个排查优先级表,遇到问题按这个顺序查,效率最高:
| 症状 | 可能原因 | 快速检查方法 |
|---|---|---|
| 旋转矩阵部分严重偏差 | 欧拉角顺序/单位理解错误 | 打印机械臂位姿,与示教器比对 |
| 平移向量数值大到离谱 | 毫米与米单位混用 | 检查 tvec 和机械臂位姿单位 |
| 标定结果对部分数据误差大 | 个别帧角点检测错误 | 回放检测结果图,检查误检点 |
| 整体残差大,结果不稳 | 姿态覆盖不够,某些轴无旋转 | 分析所有位姿的轴角,补采数据 |
| 结果随数据子集改动剧烈 | 数据量太少或姿态相关性太强 | 增加 5~10 组姿态差异大的位姿 |
其中欧拉角问题排第一位。很多 SDK 返回的 rx/ry/rz 并不是绕固定轴的 RPY,而是别的旋转顺序或单位。统一用 scipy 的 Rotation 对象来处理,按实际顺序和单位显式转换,能避开绝大多数手写公式带来的错误。
另一个高发点是 ChArUco 板的打印变形。如果打印分辨率不够或者尺寸被拉伸,个别 ArUco 码会误检。打印板子时优先用高分辨率图片,打印后量一下实际格子边长,用实测值而非理论值来生成 board 对象。这些细节看着琐碎,但都是直接影响结果的坑。
5.2 D435 相机在标定过程中的坑
D435 的深度流和彩色流时间戳不完全同步。手眼标定本身只用彩色流,影响不大,但如果后续视觉引导要用深度图定位,必须先用 align 把深度对齐到彩色坐标系,否则深度图和彩色图对不上,手眼标定的成果会在深度定位环节间接失效。
彩色相机的自动曝光在机械臂运动场景下也是主要问题。标定板从亮处转到暗处,自动曝光会频繁调整,图像亮度不稳定,导致检测到的角点位置产生微小偏移。建议固定曝光值和白平衡。可以先用 RealSense Viewer 手动调出一组合适的曝光值,再在代码里关闭自动调节,保证所有采样图像亮度一致。
还有一个容易被忽略的点:彩色流分辨率。如果只开 640x480 分辨率,标定板离相机稍远时,角点在图像里只剩十几个像素,子像素检测精度会大打折扣。标定阶段建议至少开到 1280x720,条件允许就开到 1920x1080。分辨率越高,角点在图像中的亚像素定位越准,手眼矩阵的最终精度也越好。
5.3 机械臂位姿同步与采集时序的坑
机械臂 SDK 读位姿指令一般会立刻返回当前状态,但如果机械臂还在运动过程中,读到的位姿和图像中的标定板位置可能对不上。我这里说的“同步”,倒不是要求毫秒级的硬同步,而是要让机械臂完全到位并稳定后再抓图和读位姿。
有一种比较特殊的情况是机械臂运动减速停止瞬间存在轻微漂移。如果刚到位就读取位姿,读到的可能是指令目标位置而不是实际物理位置。协作机械臂的重复定位精度不错,但关节柔性会导致减速停止时有微小残余运动,多等几百毫秒再采集就能避开。
采集顺序同样重要。我建议“先拍图、再读位姿、最后保存”,而不是先读位姿再拍图。因为拍图和读位姿之间的时间差越短,数据一致性越好,但机械臂已经静止时这个先后顺序影响不大。关键是,整条采集链路里不要插入其他耗时操作,比如在拍图和读位姿之间做图像处理或写 log,避免时间差拉大。
5.4 几个值得一试的精度提升技巧
最后分享几个能直接提升标定质量的技巧。
第一,标定板不要长期暴露在高温或潮湿环境,纸质板材容易变形。我一般用哑光纸打印后贴在铝板上,四边用透明胶固定,确保整个标定过程中板面完全平整。板子一旦弯曲,每个位姿下板面形状都在变化,与“标定板是刚体”的假设冲突,标定精度必然下降。
第二,如果要做更高精度,可以多采集几组“绕末端同一轴旋转不同角度”的数据。旋转部分的自由度对 AX=XB 解算的影响比平移更大,优先保证旋转丰富。平移量通过多个位置采样来覆盖,但旋转数据的质量直接决定标定能否收敛。
第三,对采集到的数据做多轮随机抽样标定。例如每次随机取 15 组数据计算标定结果,重复 50 次,观察结果的方差。方差小说明数据质量稳定,方差大说明数据里存在异常帧,需要检查具体是哪帧出了问题。
第四,采集时一定要现场可视化 ChArUco 角点。把cv2.aruco.drawDetectedCornersCharuco画出来的图实时显示,能第一时间发现检测错乱、遮挡、反光等问题。别等采集完回去处理数据时才懊恼,那时补采的成本会高很多。
写在最后
聊一点我自己的体会。手眼标定这个活儿,真正难的不是套公式或者调库,而是你能不能保证“采集到的一组数据里,机械臂位姿和相机观测位姿对应的是同一个物理状态”。所有标定失败的项目,几乎都能回溯到数据采集阶段的问题:板子动了、机械臂没停稳、打印板变形、单位看错、欧拉角顺序搞反。把这些细节逐个守住,标定矩阵基本不会差。
后续如果要扩展,可以把 D435 的深度信息接入,把标定好的手眼矩阵与深度图结合,实现真正的“看见坐标 -> 抓取”完整闭环。还可以做自动标定采集脚本,让机械臂按规划好的轨迹自动走到一系列位姿,自动拍照、自动检测、自动保存,这样整个标定过程就不用再人工盯着了。希望这篇笔记能帮你少踩几个坑,早日跑通自己的手眼标定。