
做视觉抓取项目最让我意外的不是模型训练也不是机械臂运动规划而是相机和机械臂之间那道看似不起眼的坐标系鸿沟。相机说物体在图像中心偏右200个像素、深度0.62米机械臂却只认基座坐标系下X0.42、Y-0.13、Z0.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传入的是末端在相机坐标系里观测到的位姿。理解了后面AXXB的推导其实两种模式就是同一套数学框架下的不同输入组合。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帧这个参数在分辨率和带宽占用之间比较平衡。如果开到1920x1080USB带宽占用会明显上升部分老主板可能扛不住。实时性要求高的话可以降到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. AXXB标定方程拆解数学原理背后的物理含义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 从两段观测推导出AXXB假设标定板固定不动相机固定在机械臂末端。对任意两个机械臂位姿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_iB 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] ], dtypenp.float64) dist_coeffs np.array(intr.coeffs, dtypenp.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], dtypenp.float64)) t_gripper2base np.array([x, y, z], dtypenp.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, methodcv2.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 关于标定频率的工程经验整个项目上线后手眼标定不需要每次开机都做。只要相机和机械臂的相对位置没有发生变化标定结果就一直有效。需要重新标定的情况主要有三种相机被拆卸重装、机械臂末端或相机发生碰撞、长时间使用后误差明显增大。我建议在项目里加一个简单的定期校验脚本让机械臂运动到预设位姿拍摄固定位置标定板用一致性验证方法输出误差指标一旦超过设定阈值就提示重新标定。这也是我做完这个项目之后最大的感触手眼标定不是一个做完就一劳永逸的步骤它更像是给视觉系统做一次坐标系校准既要一次性做好也要在日常运行中留好校验手段。如果你准备做类似的视觉抓取项目先把这篇文章里的数据采集和验证逻辑跑通再开始写业务逻辑后面会省下大量排查问题的时间。