做视觉抓取项目,最让我意外的不是模型训练,也不是机械臂运动规划,而是相机和机械臂之间那道看似不起眼的"坐标系鸿沟"。相机说"物体在图像中心偏右200个像素、深度0.62米",机械臂却只认"基座坐标系下X=0.42、Y=-0.13、Z=0.31"。你当然可以把识别到的像素坐标硬编码给机械臂,但换个位置摆放物体,整个逻辑就全废了。手眼标定解决的正是这件事:它求出一个固定的4x4变换矩阵,把相机坐标系下的点转换到机械臂基座坐标系,让视觉系统真正能指挥机械臂去抓取。
这次我用的是Intel Realsense D435深度相机和一台睿尔曼RM65六轴协作机械臂,全程用Python实现。整个过程没有购买任何商业标定软件,主要依赖OpenCV、pyrealsense2和睿尔曼的Python SDK。这篇文章把从硬件安装、数学原理、数据采集到代码实现和精度验证的完整链路记录下来。代码可以直接作为参考模板,对照你自己手里的SDK改一改接口名就能用。
1. 手眼标定解决的根本问题:让两个坐标系"对齐"
1.1 视觉抓取背后那条坐标变换链
我先梳理一条完整的坐标链路:目标物体从标定板坐标系出发,先被相机"看到",得到它在相机坐标系下的三维坐标。如果相机安装在机械臂末端法兰上,那么这些坐标还要经过一个"相机坐标系到末端坐标系"的变换,再和机械臂控制器给出的末端位姿合并,最终转换到机械臂基座坐标系。整条链路是:
标定板/目标物体坐标系 → 相机坐标系 → 机械臂末端坐标系 → 机械臂基座坐标系
手眼标定要求的,正是"相机坐标系到末端坐标系"这个环节的变换矩阵。D435输出的原始数据是像素和深度,通过相机内参和深度恢复出的三维点都在相机坐标系下;如果缺了手眼矩阵,机械臂即使"看到"了物体,也不知道自己该往哪个方向伸手。
这里可以打一个生活化的比方:你在一个陌生的房间里用手机拍照,想指挥站在门口的机器人去拿桌上的水杯。手机照片里水杯的位置和距离你都能看出来,但机器人不知道手机在空间里怎么摆放,也就没法根据照片去拿水杯。手眼标定就是把"手机坐标系"和"机器人坐标系"之间的关系先固定下来,之后的一切换算才有依据。
1.2 Eye-in-Hand与Eye-to-Hand怎么选
根据相机的安装位置,手眼标定被分成两大类,这一点在项目一开始就要确定,因为后续所有数据采集方案都依赖这个选择。
| 类型 | 相机安装位置 | 求解目标 | 适用场景 |
|---|---|---|---|
| Eye-in-Hand | 固定在机械臂末端 | 相机到末端法兰的变换 | 视觉抓取、近距识别、视野灵活 |
| Eye-to-Hand | 固定在外部支架 | 相机到机械臂基座的变换 | 大范围定位、静态监测、视野固定 |
我做视觉抓取时把D435装在机械臂末端法兰上,用的是Eye-in-Hand。这种模式的好处是相机可以跟着机械臂靠近物体,识别精度高,视野也灵活。缺点是运动过程中图像会晃动,对采集时机有要求。Eye-to-Hand则适合相机架在工位上方、机械臂在下面活动的情况,视野固定,数据采集更稳定,但标定板的摆放位置受相机视野限制。
值得说明的是,OpenCV的cv2.calibrateHandEye函数同时支持两种模式,区别在于喂进去的机械臂位姿数据不同。Eye-in-Hand传入的是末端在基座下的位姿,Eye-to-Hand传入的是末端在相机坐标系里观测到的位姿。理解了后面AX=XB的推导,其实两种模式就是同一套数学框架下的不同输入组合。
2. 硬件搭建与环境准备:D435的固定、通信和Python依赖
2.1 把D435固定到睿尔曼法兰上的安装细节
D435底部有一个1/4英寸标准螺纹孔,理论上可以直接拧在相机支架上,但用在机械臂末端时,这个安装方式不够可靠。机械臂高速运动时,悬臂式的安装会产生不小的振动,直接影响图像质量。
我这次用3D打印了一个转接板,一端通过螺丝固定在睿尔曼法兰上,另一端锁住D435的螺纹孔。设计转接板时有三个细节值得注意:第一,D435的镜片中心线尽量与机械臂法兰轴线重合,减少偏心带来的额外力臂;第二,相机重心离法兰越近越好,太远会导致运动时末端抖动明显增加;第三,相机前方不能被机械臂本体以及排线遮挡,尤其是Eye-in-Hand场景下,要保证在工作空间内相机能看到标定板。
如果暂时没有条件3D打印,也可以用市售的快拆板和法兰转接件组合,但务必检查螺丝长度。D435的外壳很薄,螺纹孔深度有限,螺丝太长会顶到内部电路板,轻则开不了机,重则直接损坏设备。
2.2 供电与USB连接的稳定性问题
D435对USB带宽和供电稳定性比较敏感,这是很多新手上来就踩的坑。我第一次调试时用了一根USB延长线,结果彩色图像每几十秒丢一帧,深度图也会时不时全黑。换成主板原生USB 3.0接口直连后,问题彻底消失。
如果必须延长线,建议用质量好的屏蔽USB 3.0线,长度控制在1米以内,同时不能和机械臂的电源线、伺服电机线捆在一起。机械臂启停瞬间会产生较强的电磁干扰,这是USB丢包最常见的诱因之一。我实测过,把相机数据线和机械臂线缆分开走线后,图像稳定性明显提升。
顺带一提,D435的图像尺寸建议设置成1280x720、30帧,这个参数在分辨率和带宽占用之间比较平衡。如果开到1920x1080,USB带宽占用会明显上升,部分老主板可能扛不住。实时性要求高的话可以降到640x480,但手眼标定过程中建议用1280x720,角点提取精度更好。
2.3 Python环境与依赖安装
我的运行环境是Python 3.8,直接创建虚拟环境安装依赖:
pip install opencv-python opencv-contrib-python pyrealsense2 numpy睿尔曼的Python SDK从官网下载,不同型号、不同固件版本对应的SDK包可能不一样。安装完成后,先写一段简单代码验证机械臂通信是否正常:
from realman_arm import RealmanArm # 以你实际SDK的导入方式为准 arm = RealmanArm("192.168.1.18") # 机械臂的IP地址 pose = arm.get_tcp_pose() # 读取当前末端位姿 print(pose)我这里用的接口名不一定和你手里的SDK完全相同,但逻辑是一致的:初始化连接,读取TCP位姿。能打印出一组[x, y, z, rx, ry, rz]格式的数据,说明SDK、网络和机械臂本体都已经正常工作。接下来就可以开始标定了。
3. AX=XB标定方程拆解:数学原理背后的物理含义
3.1 齐次变换矩阵快速复习
三维空间中的刚体变换可以用4x4齐次矩阵表示,左上3x3是旋转矩阵,右上3x1是平移向量。比如"机械臂末端坐标系到基座坐标系"的变换记作T_base_to_gripper,它同时包含了旋转R_base_to_gripper和平移t_base_to_gripper。当我们要把末端坐标系下的点p_gripper变换到基座坐标系,只需要做矩阵乘法:
p_base = T_base_to_gripper * p_gripper
齐次矩阵的逆同样有明确的物理意义:T_gripper_to_base = inv(T_base_to_gripper),它表示反方向变换。A * X = X * B这个方程里,A和B本身都是由齐次变换矩阵构造出来的,所以对矩阵乘法和逆运算都不陌生的人,理解起来会顺很多。
3.2 从两段观测推导出AX=XB
假设标定板固定不动,相机固定在机械臂末端。对任意两个机械臂位姿i和j,标定板原点在机械臂基座坐标系下的位置都不会变。沿着坐标系链路分别展开:
p_base = T_base_to_gripper_i * T_gripper_to_cam * T_cam_to_target_i * p_target p_base = T_base_to_gripper_j * T_gripper_to_cam * T_cam_to_target_j * p_target
因为p_target是标定板原点,两个表达式右边相等。把相同项整理归并,就得到:
inv(T_base_to_gripper_j) * T_base_to_gripper_i * T_gripper_to_cam = T_gripper_to_cam * T_cam_to_target_j * inv(T_cam_to_target_i)
令A = inv(T_base_to_gripper_j) * T_base_to_gripper_i,B = T_cam_to_target_j * inv(T_cam_to_target_i),X = T_gripper_to_cam,就得到经典方程:
A * X = X * B
这里的A完全来自机械臂自身的读数,表示两次末端位姿之间的相对运动;B完全来自视觉测量,表示两次拍摄过程中标定板相对相机运动的逆变换;X就是我们需要的手眼矩阵。只要采集足够多组不同位姿的数据,就能构成超定方程组,通过优化方法求解出最优的X。
3.3 OpenCV的calibrateHandEye在做什么
cv2.calibrateHandEye内置了几种经典求解算法:Tsai-Lenz、Park、Horaud、Daniilidis等。我们不需要自己实现繁琐的数值优化,只需要按照它的接口要求喂数据。函数的输入输出结构很清晰:
- 输入:
R_gripper2base,t_gripper2base,来自机械臂SDK的末端位姿; - 输入:
R_target2cam,t_target2cam,来自视觉求解的标定板位姿; - 输出:
R_cam2gripper,t_cam2gripper,相机到机械臂末端的变换。
方法选择上,我习惯默认用cv2.CALIB_HAND_EYE_TSAI,它在噪声适中的数据上表现稳定。如果你的标定位姿旋转比较丰富,也可以对比一下PARK和DANIILIDIS的结果。几种方法的结果如果差异在几毫米内,基本正常;如果差异很大,说明数据本身有问题,不要急着换算法,先回头检查数据采集。
4. 数据采集位姿设计与执行:采什么样的数据才有用
4.1 为什么不能只在同一个位置拍十张
标定的本质是从不同角度观测同一个固定变换。如果机械臂每次运动只有平移、没有旋转,或者旋转角度很小,方程组的约束就会很弱。用线性代数的话说,A矩阵族集中在某个低维子空间里,数值上接近病态,求解出来的手眼矩阵在噪声影响下会非常不稳定。
这次的采集经验是:总共16组数据,每组之间保持约20到40度的姿态旋转,而且绕轴方向要尽可能不同。有的绕Z轴转,有的绕Y轴转,还有的组合轴旋转,这样能让末端姿态"铺开",覆盖足够广的旋转空间。同时,标定板在画面里的位置也要变化,尽量覆盖相机的中心和边缘区域,这样对畸变校正也有帮助。
标定板距离不能太远也不能太近。D435的彩色摄像头有对焦范围,棋盘格占画面面积的1/4到1/2比较合适。如果棋盘格太大,边缘角点容易超出视野;如果太小,角点提取精度会下降。我用的是11x8个内角点的棋盘格,单格边长30mm,在0.3到0.6米工作距离下效果很好。
4.2 机械臂运动控制的通用做法
每移动到一个新位姿,先确认机械臂已到位、没有碰撞风险、相机视野里能看到完整标定板,再执行采集。我自己会预先在机械臂工作空间的安全区域内规划一组位姿,标定过程中避免手臂与周边设备发生碰撞。
机械臂运动到位后,需要等待1到2秒,让末端抖动平息再拍照。机械臂从运动到静止的过程中会有微小振荡,如果立刻拍照,图像难免模糊,角点检测精度会受影响。这里可以通过SDK的到位判断接口,或者简单粗暴地time.sleep(1.5),工程上都够用。
4.3 图像采集与标定板角点提取
D435采集彩色图并提取棋盘格角点的流程如下。这份代码同时负责读取相机内参,因为D435的出厂内参可以从设备固件里直接读取:
import cv2 import numpy as np import pyrealsense2 as rs pattern_cols, pattern_rows = 11, 8 square_size = 0.03 # 单格边长,单位米 object_pts = np.zeros((pattern_cols * pattern_rows, 3), np.float32) object_pts[:, :2] = np.mgrid[0:pattern_cols, 0:pattern_rows].T.reshape(-1, 2) * square_size pipe = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 1280, 720, rs.format.bgr8, 30) profile = pipe.start(config) color_profile = profile.get_stream(rs.stream.color) intr = color_profile.as_video_stream_profile().get_intrinsics() camera_matrix = np.array([ [intr.fx, 0, intr.ppx], [0, intr.fy, intr.pyy], [0, 0, 1] ], dtype=np.float64) dist_coeffs = np.array(intr.coeffs, dtype=np.float64) def capture_color(pipe): frames = pipe.wait_for_frames() color = frames.get_color_frame() return np.asanyarray(color.get_data()) def detect_board(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, (pattern_cols, pattern_rows), None) if not ret: return None criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) return corners关于相机内参,再补充一点:如果只是做手眼标定,读取D435出厂内参是可以接受的。但如果你的D435被碰撞过、镜片受过力,或者你对最终的视觉定位精度有很高要求,建议单独用OpenCV的cv2.calibrateCamera做一次完整相机标定,重新解算内参和畸变系数。稍微多花半小时,后面省掉很多排查精度问题的麻烦。
5. 完整标定代码:从多组数据到手眼矩阵
5.1 主循环:控制机械臂、拍照、提取位姿
下面的代码把整个数据采集主循环串起来。预先规划好一组末端位姿,逐位姿运动、拍照、提取角点、求解标定板位姿,同时记录机械臂自身的末端位姿:
import time import cv2 import numpy as np def solve_board_pose(corners, object_pts, camera_matrix, dist_coeffs): ret, rvec, tvec = cv2.solvePnP(object_pts, corners, camera_matrix, dist_coeffs) if not ret: return None, None R_target2cam, _ = cv2.Rodrigues(rvec) t_target2cam = tvec.reshape(3, 1) return R_target2cam, t_target2cam dataset = [] target_poses = [...] # 你预先规划的一组末端位姿,格式与SDK一致 for idx, pose in enumerate(target_poses): # 1. 控制机械臂运动到位并等待稳定 arm.move_to_pose(pose) time.sleep(1.5) # 2. 采集图像并检测棋盘格 img = capture_color(pipe) corners = detect_board(img) if corners is None: print(f"第{idx}组角点提取失败,跳过") continue # 3. 求解标定板在相机坐标系下的位姿 R_target2cam, t_target2cam = solve_board_pose(corners, object_pts, camera_matrix, dist_coeffs) if R_target2cam is None: continue # 4. 读取机械臂末端位姿,注意单位是弧度还是度,务必和SDK确认 current_pose = arm.get_tcp_pose() x, y, z, rx, ry, rz = current_pose # 这里先用旋转向量转旋转矩阵;如果SDK返回四元数,用四元数公式 R_gripper2base, _ = cv2.Rodrigues(np.array([rx, ry, rz], dtype=np.float64)) t_gripper2base = np.array([x, y, z], dtype=np.float64).reshape(3, 1) dataset.append((R_gripper2base, t_gripper2base, R_target2cam, t_target2cam)) print(f"第{idx}组数据采集完成")这里特别提醒一下:如果SDK返回的是欧拉角,直接拿三个欧拉角当旋转向量是常见的错误。cv2.Rodrigues要求输入的是旋转向量,不是欧拉角。最稳妥的做法是优先使用SDK返回的四元数,转换成旋转矩阵;如果没有四元数接口,必须弄清楚SDK文档里欧拉角的旋转顺序(R/P/Y顺序)再手写转换公式。我在调试过程中就因为单位没确认,前两次标定结果里的平移向量数值差了好几倍,检查半天才发现是弧度制问题了。
5.2 标定求解
采集数据完成后,直接调用cv2.calibrateHandEye求解:
R_g2b = [d[0] for d in dataset] t_g2b = [d[1] for d in dataset] R_t2c = [d[2] for d in dataset] t_t2c = [d[3] for d in dataset] R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_g2b, t_g2b, R_t2c, t_t2c, method=cv2.CALIB_HAND_EYE_TSAI ) T_cam2gripper = np.eye(4) T_cam2gripper[:3, :3] = R_cam2gripper T_cam2gripper[:3, 3] = t_cam2gripper.flatten() print("手眼矩阵 T_cam2gripper:\n", T_cam2gripper) np.save("T_cam2gripper.npy", T_cam2gripper)标定结果出来之后,先看平移向量是否在合理物理范围内。如果D435装在法兰前方,那么平移向量的Z分量应该大致等于相机安装的前伸距离,通常为正;X和Y分量取决于安装是否有偏心,通常接近零或某个固定值。如果算出来的平移量是几十米,或者符号完全反了,基本可以断定是单位问题、坐标轴方向问题或者SDK位姿表示理解错了。
5.3 保存与加载标定结果
实际项目中,手眼矩阵只需要标定一次,得到后用.npy文件保存,后续启动程序时直接加载:
import numpy as np T_cam2gripper = np.load("T_cam2gripper.npy")在工程落地时,我习惯把相机内参、畸变系数和手眼矩阵统一打包成一个标定配置文件,比如JSON格式,这样部署到现场时只需加载一份配置,不需要重新标定。
6. 精度验证与踩坑记录
6.1 固定点一致性验证
标定精度最直接的验证方式,是利用"标定板固定不动"这个前提。保持标定板位置不变,让机械臂运动到几个不同位姿,分别拍摄同一块标定板,用标定结果把标定板原点变换到基座坐标系。理论上,无论机械臂处在哪个位姿,变换出的基座坐标都应该重合:
origins = [] for R_g2b, t_g2b, R_t2c, t_t2c in dataset: T_g2b = np.eye(4) T_g2b[:3, :3] = R_g2b T_g2b[:3, 3] = t_g2b.flatten() T_t2c = np.eye(4) T_t2c[:3, :3] = R_t2c T_t2c[:3, 3] = t_t2c.flatten() origin_base = T_g2b @ T_cam2gripper @ T_t2c @ np.array([0, 0, 0, 1.0]) origins.append(origin_base[:3]) origins = np.array(origins) print("X向标准差: {:.3f} mm".format(origins[:, 0].std() * 1000)) print("Y向标准差: {:.3f} mm".format(origins[:, 1].std() * 1000)) print("Z向标准差: {:.3f} mm".format(origins[:, 2].std() * 1000))如果三个方向的标准差都只有几毫米,说明标定结果在工程上基本可用。如果标准差达到厘米级,先排除机械臂绝对定位精度差的问题,再检查标定板角点提取和位姿求解是否有异常数据。
6.2 视觉引导抓取验证
数字指标再好,最终要回到抓取任务里验证。我常用的方法是在机械臂末端装一根尖锥,用它去"指点"视野里某个特征点。流程是:先用D435识别出特征点在相机坐标系下的三维坐标,再通过手眼矩阵变换到基座坐标系,最后控制机械臂运动到该点,观察尖端和特征点的实际偏差。
这个验证方式比单纯看标定板一致性更真实,因为它覆盖了完整链路:相机内参、深度恢复或单目位姿估计、手眼变换、机械臂运动学。如果指尖偏差在5毫米以内,对于大多数抓取场景已经够用。如果误差明显偏大,可以用前面固定点一致性的代码逐项排查,看问题出在手眼标定环节还是相机坐标恢复环节。
6.3 几个容易被忽略的实操细节
第一个坑是自动曝光。D435在自动曝光模式下,画面亮度会随环境光变化,棋盘格角点提取的坐标会发生细微漂移。标定过程中我建议把曝光固定下来,找一个光线稳定、亮度适中的环境,然后关闭自动曝光并手动设定曝光时间。关闭方法是通过pyrealsense2的sensor选项:
sensor = profile.get_device().query_sensors()[1] sensor.set_option(rs.option.enable_auto_exposure, 0) sensor.set_option(rs.option.exposure, 150)曝光时间的合理值取决于实际环境亮度,100到200通常在室内日光灯下效果不错。调试时可以实时看画面,把曝光调到棋盘格黑白格对比清晰、没有过曝反光为止。
第二个坑是棋盘格表面反光。D435的视角下,如果棋盘格表面覆了塑封膜,反光会导致角点提取出现周期性偏移。建议使用哑光打印的棋盘格,并且标定时不要让强光源直接照在棋盘格上。我试过用普通打印纸贴硬纸板,效果比塑封棋盘格好了不止一点。
第三个坑是数据有效性检查。每次采集完一组数据,都把角点检测结果叠加到图像上保存下来,快速目视检查。如果某组数据中有几个角点明显错位,尽早剔除,不要等到所有数据采集完才处理。标定是一个对噪声敏感的过程,一组坏数据可能把整体精度从毫米级拉到厘米级。
6.4 关于标定频率的工程经验
整个项目上线后,手眼标定不需要每次开机都做。只要相机和机械臂的相对位置没有发生变化,标定结果就一直有效。需要重新标定的情况主要有三种:相机被拆卸重装、机械臂末端或相机发生碰撞、长时间使用后误差明显增大。我建议在项目里加一个简单的定期校验脚本:让机械臂运动到预设位姿,拍摄固定位置标定板,用一致性验证方法输出误差指标,一旦超过设定阈值就提示重新标定。
这也是我做完这个项目之后最大的感触:手眼标定不是一个"做完就一劳永逸"的步骤,它更像是给视觉系统做一次"坐标系校准",既要一次性做好,也要在日常运行中留好校验手段。如果你准备做类似的视觉抓取项目,先把这篇文章里的数据采集和验证逻辑跑通,再开始写业务逻辑,后面会省下大量排查问题的时间。