☰
全自动手眼标定实战:Python驱动JAKA机械臂与RealSense D455
2026/10/5 1:14:04 网站建设 项目流程

做机器人和机器视觉集成的朋友,迟早都要碰手眼标定。我以前每次标定都是手动操作:示教器上点半天、回电脑拍一张、换角度再拍,20组数据折腾一上午,中间还经常因为角度不对、对焦发虚返工。后来我把自己实验室这套流程重写了一遍,用Python把JAKA机械臂和Intel RealSense D455相机串起来,做了一套全自动手眼标定工具,整个过程基本不用碰示教器,启动之后机械臂自己走位、相机自己拍照、程序自己解算,最后连验证报告都一起出了。今天把整套方案和完整代码思路梳理出来,包括方案选型、原理推导、代码结构,以及我实际踩过的坑,给同样在做机械臂抓取、视觉定位的朋友一个可以直接参考的模板。

1. 为什么我们需要全自动手眼标定

1.1 手眼标定到底在解什么

机械臂有自己描述空间的坐标系,相机也有自己描述空间的坐标系。要让“相机看到的物体位置”变成“机械臂能抓到的位置”,就必须知道相机坐标系到机械臂坐标系之间的变换关系,这个变换在机器人学里通常用一个4x4的齐次变换矩阵来表示,拆开就是3x3旋转矩阵加3x1平移向量,一共6个自由度。

这个关系在生活中可以类比成:你闭上一只眼去接别人抛过来的东西,刚开始接不准,多试几次之后大脑就自动建立了一套“眼睛看到的偏移”到“手怎么伸”的映射。工业场景下这套映射必须精确到毫米级,总不能靠机械臂试错去凑,所以要先标定出手眼矩阵,把视觉坐标转换到机械臂坐标。

手眼标定本质上是解一个 AX=XB 的矩阵方程问题,A来自机械臂自身的运动学,B来自相机对同一个标定物在不同视角下的观测,X就是我们要找的相机坐标系与机械臂坐标系之间的关系。后面的章节我会详细拆这个方程。

1.2 手动拍照的三大痛点

以前用手动方式标定,最直接的问题是效率低。一次标定至少需要15到20组有效数据,意味着要重复“操作示教器移动到某个位置、微调姿态、在电脑上拍照、人工记录当前末端坐标”这个循环。顺利的话一个小时打底,遇到标定板反光、相机没对上焦、标签纸翘边之类的意外,两三个小时都很正常。

第二个痛点是数据质量不好保证。手眼标定算法对姿态的多样性要求很高,如果拍的十几张图里机械臂末端位置和姿态都挤在一起,方程会趋于病态,算出来的矩阵看着有模有样,实际一验证就露馅。手动操作时很难保证姿态覆盖分散,经常是标完了才发现精度不对,只能从头再来。

第三是可复现性差。换个产线工位、调整一下相机位置、甚至重新装一次机械臂末端工具,都得重新标定。手动流程每次都要重新走一遍,效率损失很大。

1.3 自动化方案的核心思路

我的做法是把整个标定流程拆成五个环节:机械臂运动规划、相机自动采集、标定板位姿提取、数据自动记录、算法自动解算与验证。机械臂按预设的姿态序列自动走到拍照点,相机在到位后自动触发拍摄,程序检测到标定板就同时记录当前末端位姿和标定板在相机中的位姿,攒够数据后自动调OpenCV的标定函数解算,最后做重投影验证并输出报告。

这样做的好处是:总耗时能从一两个小时压缩到十分钟以内;整个过程机械臂不会乱走,数据格式统一,不会出现手误记录;而且换工位后重新标定只需要改几个运动参数,整套代码复用程度很高。

2. 方案选型与硬件准备

2.1 眼在手外还是眼在手上

手眼标定有两种经典构型,很多人一开始容易混淆。

眼在手上(eye-in-hand)是把相机安装在机械臂末端法兰上,相机跟着机械臂一起动,标定板固定在工作台。这种构型适合近距离观察工件、视野灵活的场景,视觉系统能跟着机械臂走到遮挡少的角度,但相机线缆会随机械臂运动,走线要特别注意。

眼在手外(eye-to-hand)是把相机固定在工作区上方或者侧面,机械臂末端装标定板,相机不动。这种构型视野稳定、线缆固定,对自动化标定流程最友好,因为相机和机械臂基座的相对关系在整个标定过程中不变,我们只需要求出一个固定变换矩阵即可。

我的方案选的是眼在手外。原因很简单:D455相机体积不算小,装在机械臂末端会明显改变末端的负载和惯量,对运动规划不友好;而把相机固定在工作区上方,每次拍照视野完全一致,标定板检测失败的概率更低。

2.2 为什么选D455相机

D455是Intel RealSense系列里的深度相机,但它在这个场景里最有价值的点反而不是深度,而是它的彩色图质量。D455配备全局快门,对机械臂运动过程中的振动不那么敏感;相机出厂自带内参标定,不需要我们自己先做一遍相机内参标定,省掉一个容易出错的环节。

另外D455的RGB分辨率和帧率足够应对ArUco标定板检测,USB接口即插即用,Linux环境下用pyrealsense2库就能直接拿图。后续如果想在标定完成后继续做深度引导抓取,这套手眼标定结果可以直接复用,不用换相机再标一次。

2.3 JAKA机械臂的控制方式

JAKA机械臂(节卡)提供Ethernet通讯接口,官方SDK支持Python远程控制。我们用到的核心接口其实就三个:连接并初始化机械臂、移动末端到指定目标位姿、读取当前末端位姿。

JAKA的SDK接口中,move_l是线性运动,move_j是关节运动。标定采样时我推荐用move_j,因为关节运动路径一般比直线运动更灵活,不容易触发奇异点。读取末端位姿时,JAKA默认返回的是当前TCP在基座坐标系下的位置和姿态,姿态部分可以用四元数或欧拉角表示。

这里有个非常关键的坑:JAKA的欧拉角默认旋转顺序是ZYX(即一般说的RPY,滚转、俯仰、偏航),如果你机械地按XYZ顺序去转旋转矩阵,姿态会完全不对。后面代码部分我会专门处理这个转换。

2.4 软件环境与依赖

我的运行环境是Ubuntu 22.04,Python 3.10。整个工具依赖以下库:

  • pyrealsense2:驱动D455取图
  • opencv-python:图像处理和基础矩阵运算
  • opencv-contrib-python:包含ArUco检测模块
  • numpy:矩阵运算
  • jaka官方SDK:机械臂通讯控制

安装命令很简单:

pip install pyrealsense2 opencv-python opencv-contrib-python numpy

JAKA的SDK根据具体型号安装包略有不同,建议去官方技术社区下载对应版本的Python库,一般是一个wheel文件,pip安装即可。

3. 手眼标定的数学原理

3.1 AX=XB的直观理解

在眼在手外的构型下,标定板固定在机械臂末端,D455相机固定在工作区。对任意一个拍照姿态,我们都能拿到两个变换:一个是机械臂给出的末端坐标系到基座坐标系的变换,记作T_base_gripper;另一个是视觉算法给出的标定板坐标系到相机坐标系的变换,记作T_cam_marker。

由于标定板固定在机械臂末端,标定板坐标系到末端坐标系之间有个固定变换T_gripper_marker;又因为我们要求的是相机坐标系到基座坐标系的固定变换T_base_cam。把这几个变换串起来,对任意姿态i都有:

T_cam_marker_i = T_base_cam的逆 × T_base_gripper_i × T_gripper_marker

把姿态i和姿态j的等式联立,消掉固定不变的T_gripper_marker和T_base_cam,剩下的就是标准AX=XB形式。也就是说:在不同姿态下,机械臂末端运动的相对变换和标定板在相机视野中运动的相对变换,手眼矩阵就是这两个运动之间的“旋转平移共轭关系”。这个方程解出来的X就是我们需要的手眼矩阵。

3.2 为什么至少需要三组姿态

AX=XB并不是一组姿态就能解出来的,因为一组姿态只能提供一个方程,其中未知数有6个自由度。把旋转部分和平移部分拆开分析,旋转方程实际上对每次运动提供两个独立约束,所以理论上至少需要三组非冗余姿态才能求出唯一解。

但理论最少值和实际可靠值差距很大。如果三组姿态之间的差异很小,方程就会趋于病态,微小的像素噪声会被放大成很大的标定误差。我实际测试下来,20组左右的数据解算结果最稳,少于12组精度就会明显波动。同时这些姿态的旋转方向要尽量分散,不能只是在同一个平面附近转,否则旋转自由度没有充分激励。

3.3 OpenCV里怎么算

OpenCV提供了现成的手眼标定函数cv2.calibrateHandEye,算法实现包含Tsai、Park、Daniilidis等多种方法,我们不需要自己推导AX=XB的求解过程。但在调用之前必须搞清楚输入矩阵的含义。

cv2.calibrateHandEye的输入是两组序列:一组是机械臂的末端姿态,在OpenCV的命名习惯里叫R_gripper2base、t_gripper2base;另一组是标定板在相机坐标系下的位姿,叫R_target2cam、t_target2cam。输出是R_cam2gripper、t_cam2gripper,也就是相机坐标系到机械臂末端坐标系的变换。

这里最容易踩坑的是命名容易造成误解:OpenCV里gripper指的是机械臂末端,base指的是机械臂基座。很多人把“gripper2base”理解成基座到末端的变换,直接传了机械臂给出的TCP位姿,其实需要做一次矩阵求逆。我在实际执行时统一使用齐次矩阵处理,传参前先确保矩阵方向正确,然后在整个验证环节用数据反推确认最终结果的真实语义,这样最稳妥。

标定板位姿则是通过solvePnP函数求解的。ArUco标定板的每个角点在三维空间中有已知坐标,在图像中有检测到的二维坐标,solvePnP就能解出标定板坐标系到相机坐标系的旋转和平移。

4. 完整代码实现:全自动标定工具

4.1 项目结构与代码总览

整个标定工具我按职责拆成了五个文件,这样每个模块都可以独立调试:

calib/ ├── main.py # 主流程,负责统筹调度 ├── rs_camera.py # D455相机封装 ├── jaka_robot.py # JAKA机械臂封装 ├── aruco_detector.py # 标定板检测与位姿提取 └── handeye.py # 手眼标定解算和验证

main.py控制整体流程,rs_camera.py只负责输出彩色图,jaka_robot.py只负责机械臂运动与位姿读取,aruco_detector.py把图像转成标定板位姿,handeye.py做最后的AX=XB求解和精度验证。下面逐个讲关键实现。

4.2 D455相机封装

相机部分最重要的不是怎么取流,而是怎么设置固定曝光。手眼标定过程中机械臂会移动,环境光线变化会让自动曝光下的ArUco检测极不稳定,经常同一块板子换个角度就检测不到了。所以我把自动曝光关掉,把曝光时间、增益、白平衡全部固定。

import pyrealsense2 as rs class RSCamera: 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) # 关闭自动曝光,固定参数保证标定板检测稳定 self.pipeline.start(config) sensor = self.pipeline.get_active_profile().get_device().query_sensors()[1] sensor.set_option(rs.option.enable_auto_exposure, 0) sensor.set_option(rs.option.exposure, 156) sensor.set_option(rs.option.gain, 24) sensor.set_option(rs.option.white_balance, 4500) def get_image(self): frames = self.pipeline.wait_for_frames() color_frame = frames.get_color_frame() return np.asanyarray(color_frame.get_data())

这里的曝光时间和增益数值需要根据实际光照条件调整,我在室内LED灯下一般用exposure=156、gain=24。判断标准是标定板黑色边框和白色底色对比明显,画面不能过曝。

固定白平衡同样重要,我遇到过在暖色灯光下标定板边缘发黄,角点检测偏移好几个像素的情况。

4.3 JAKA机械臂控制封装

JAKA官方SDK提供了Python接口,但不同型号之间略有差异。我这里用一个适配层封装,核心关注三个能力:连接机器人、移动到目标位姿、读取当前末端位姿。

class JAKARobot: def __init__(self, ip="192.168.1.10"): self.robot = JakaClient(ip) # 以官方SDK为准 self.robot.init_robot() def get_current_pose(self): # 返回当前TCP在基座坐标系下的位姿 # JAKA默认返回:位置xyz + 姿态四元数(x,y,z,w) pose = self.robot.get_end_pose() return pose def move_to_pose(self, position, quaternion): # position: [x, y, z] 单位米 # quaternion: [x, y, z, w] self.robot.move_j(position, quaternion) def set_tcp_offset(self, tcp_offset): # 加载工具坐标系,如果用了延长杆或夹具必须设置 self.robot.set_tcp_offset(tcp_offset)

有一个细节极易忽略:如果机械臂末端装了延长杆或夹爪,TCP坐标系和法兰坐标系不重合,必须在机械臂上配置正确的工具坐标系,否则读取出来的末端位姿全都带了一个固定偏差,这个偏差最终会直接进到手眼矩阵里,导致标定结果整体偏斜。

4.4 ArUco标定板检测与位姿提取

ArUco标定板我用的是DICT_4X4_250字典,一个marker,边长80mm,贴在一块亚克力板上。检测部分用cv2.aruco的detectMarkers找到四个角点,然后用solvePnP解标定板坐标系到相机坐标系的变换。

import cv2 import numpy as np ARUCO_DICT = cv2.aruco.DICT_4X4_250 MARKER_LENGTH = 0.08 # 单位:米 class ArUcoDetector: def __init__(self): self.dictionary = cv2.aruco.getPredefinedDictionary(ARUCO_DICT) self.parameters = cv2.aruco.DetectorParameters() def detect_marker_pose(self, image): corners, ids, _ = cv2.aruco.detectMarkers( image, self.dictionary, parameters=self.parameters) if ids is None or len(ids) != 1: return None # 定义标定板坐标系:原点在marker中心,Z轴垂直板面 obj_points = np.array([ [-MARKER_LENGTH/2, -MARKER_LENGTH/2, 0], [ MARKER_LENGTH/2, -MARKER_LENGTH/2, 0], [ MARKER_LENGTH/2, MARKER_LENGTH/2, 0], [-MARKER_LENGTH/2, MARKER_LENGTH/2, 0], ], dtype=np.float32) retval, rvec, tvec = cv2.solvePnP( obj_points, corners[0], self.camera_matrix, self.dist_coeffs) # 转成齐次矩阵 R, _ = cv2.Rodrigues(rvec) T_cam_marker = np.eye(4) T_cam_marker[:3, :3] = R T_cam_marker[:3, 3] = tvec.reshape(3) return T_cam_marker

这里有个小技巧:很多资料用marker的单个角点作为坐标系原点,我更习惯用marker中心作为原点,这样和实际抓取时使用的物体中心坐标更统一,后续转换少一步。

camera_matrix和dist_coeffs来自D455出厂标定数据,用pyrealsense2可以读取,也可以保存成配置文件加载。

4.5 主流程:自动采样与数据收集

主流程的核心是姿态采样规划。我没有用完全随机的姿态,而是在一个基础位置附近生成一组覆盖球面空间的目标点,让机械臂依次到达。每个目标点包含位置和姿态,姿态的偏航角、俯仰角、翻滚角在一定范围内均匀分布。

import numpy as np import time def generate_waypoints(base_pos, count=24, radius=0.12): waypoints = [] # 在半径为radius的球面上均匀采样位置 for i in range(count): # 均匀分布的方向,避免姿态扎堆 theta = np.random.uniform(-np.pi/6, np.pi/6) phi = np.random.uniform(-np.pi, np.pi) pos = np.array([ base_pos[0] + radius * np.cos(theta) * np.cos(phi), base_pos[1] + radius * np.cos(theta) * np.sin(phi), base_pos[2] + radius * np.sin(theta), ]) # 姿态:绕各轴差异化的旋转,保证旋转自由度充分激励 q = random_quaternion_from_euler( np.random.uniform(-0.6, 0.6), np.random.uniform(-0.6, 0.6), np.random.uniform(-0.6, 0.6)) waypoints.append((pos, q)) return waypoints

注意生成姿态之后必须做一次碰撞和奇异点检查,我直接在机械臂模拟环境里先跑一遍,确认所有点都能到达且不会撞到相机支架,再开始真实采样。

主采样循环的逻辑如下:

def run_auto_calibration(robot, camera, detector, waypoints): data = [] for idx, (pos, q) in enumerate(waypoints): # 移动机械臂到目标位姿 robot.move_to_pose(pos, q) time.sleep(1.5) # 等待机械臂完全稳定、相机自动曝光不再跳动 # 拍图并检测标定板 image = camera.get_image() T_cam_marker = detector.detect_marker_pose(image) if T_cam_marker is None: print(f"[{idx}] 检测失败,调小曝光重试或调整姿态") continue # 记录机械臂末端在基座下的位姿 pose = robot.get_current_pose() R_base_gripper, t_base_gripper = quat_to_matrix(pose.position, pose.quaternion) data.append((R_base_gripper, t_base_gripper, T_cam_marker)) print(f"[{idx}] 已采集,当前有效数据 {len(data)} 组") return data

每条数据同时记录了机械臂末端位姿和标定板在相机中的位姿。采集结束后统一交给handeye.py解算。

一个实际经验:机械臂到位后不要马上拍图,我一开始只等了0.5秒,结果机械臂末端的微小振动导致ArUco角点检测位置漂移,标定结果的平移误差偏大。后面改成等待1.5秒,整个标定过程多花不到一分钟,但精度提升非常明显。

4.6 求解手眼矩阵与精度验证

数据采集完成后,调用OpenCV的calibrateHandEye解算:

import cv2 import numpy as np def solve_handeye(data): R_gripper2base_all = [] t_gripper2base_all = [] R_target2cam_all = [] t_target2cam_all = [] for R_base_gripper, t_base_gripper, T_cam_marker in data: # OpenCV约定:gripper2base需要的是“末端在基座下”的变换 # 这里直接把机械臂返回的T_base_gripper传入,并注意与官方示例保持一致 R_gripper2base_all.append(R_base_gripper) t_gripper2base_all.append(t_base_gripper) R_target2cam_all.append(T_cam_marker[:3, :3]) t_target2cam_all.append(T_cam_marker[:3, 3]) R_cam2gripper, t_cam2gripper, _ = cv2.calibrateHandEye( R_gripper2base_all, t_gripper2base_all, R_target2cam_all, t_target2cam_all, method=cv2.CALIB_HAND_EYE_TSAI) X = np.eye(4) X[:3, :3] = R_cam2gripper X[:3, 3] = t_cam2gripper.reshape(3) return X

解算出的X是相机坐标系到机械臂末端的变换。由于我的构型是眼在手外、相机固定在外部,最终需要的是相机到机械臂基座的变换,用链式变换转换即可。

验证环节非常关键。我会把采集到的每一组数据重新带入手眼矩阵,预测标定板角点在图像中的位置,和实际检测到的角点位置做差,计算重投影误差:

def validate_handeye(data, X, camera_matrix, dist_coeffs): errors = [] for R_base_gripper, t_base_gripper, T_cam_marker in data: T_base_cam = inverse(T_base_gripper @ X) # 标定板四个角点在marker坐标系下的坐标 marker_corners = get_marker_corners_3d() projected, _ = cv2.projectPoints( marker_corners, rvec_from(T_base_cam), tvec_from(T_base_cam), camera_matrix, dist_coeffs) # 与图像检测到的角点计算像素误差(略) return np.mean(errors)

这个验证方法不需要额外设备,重投影误差基本能反映整个标定链路的准确性。如果重投影误差小于1个像素,手眼矩阵基本是可信的。

5. 实际运行中的坑与排查手册

5.1 ArUco检测漏检、误检

ArUco检测失败是最常见的问题。我总结下来主要原因是画面过曝、运动模糊和标定板太小。

D455在自动曝光模式下,如果标定板表面反光,局部会过曝变成一片白,角点直接消失。解决方法是固定曝光,我当时的调试顺序是:先把曝光时间调到画面整体偏暗但细节清晰,再逐步提高增益,让标定板边缘锐利即可。

运动模糊则和机械臂到位后等待时间有关,等待时间太短就会出现边缘重影。检测时还可以用aruco子像素细化参数,我这里直接用默认参数,只要图像清晰,检测精度就足够。

标定板大小要和工作距离匹配。D455距离标定板0.5米左右时,80mm的marker在1280x720画面里大概占据200像素宽,角点定位精度很高。如果marker太小,角点提取的亚像素精度会下降,标定结果自然变差。

5.2 姿态规划不当导致解算异常

姿态规划是自动化方案里差别最大的环节。我最初直接在某个位姿附近生成随机姿态,结果解出的手眼矩阵在验证时误差巨大。后来我发现是姿态差异不够,所有姿态的旋转轴几乎都指向同一个方向,等效于旋转自由度没有充分激励。

建议是:让机械臂末端在以基础点为中心、半径100到150毫米的球面上运动,同时每个姿态的横滚、俯仰、偏航角各自在正负30度范围内独立变化。这样旋转轴在空间中的分布足够分散,AX=XB方程的约束条件充分。

另外一定不要规划出需要机械臂经过奇异点的路径。JAKA的逆解在奇异点附近数值不稳定,虽然最终点位姿相同,但关节空间的中间路径可能抖动,导致末端定位出现瞬时误差,采样瞬间的记录值就会有偏差。

5.3 坐标系方向不清导致结果天差地别

这是手眼标定里最坑的一类问题,症状是:程序没报错,Ax=Xb也解出来了,但结果一验证完全不对。我遇到过的案例包括:把欧拉角按错误顺序解析、把末端位姿的方向搞反、标定板坐标系原点定义不一致。

JAKA返回的姿态默认是四元数,四元数和旋转矩阵的转换公式本身没有歧义,但如果你用欧拉角接口,必须搞清楚旋转顺序。JAKA的欧拉角顺序是ZYX,转旋转矩阵时先绕Z轴、再绕Y轴、最后绕X轴,转换错了手眼矩阵的旋转部分整个就是错乱的。

另一个容易搞混的是OpenCV里calibrateHandEye的参数命名。我在代码注释里写了“gripper2base需要的是末端在基座下的变换”,实际操作时我建议先把所有数据用单组验证脚本跑一遍:取一组采集数据,用手眼矩阵把标定板角点反投影回图像,如果投影点和检测点重合,说明矩阵方向传对了;如果完全对不上,检查一下是不是需要把末端位姿矩阵求逆。

5.4 标定精度上不去怎么排查

标定完成后如果发现机械臂抓取时存在固定的位置偏差,按下面的优先级排查。

先看重投影误差。如果重投影误差超过1.5像素,说明整个标定链路内部不一致,通常是图像检测环节的问题,优先检查对焦、曝光和标定板平整度。

如果重投影误差很小,但实际抓取还是偏,问题多半在姿态覆盖空间不够,或者标定板坐标系和实际抓取物体中心没有对齐。这种情况下需要增加姿态数量、扩大姿态分散范围。

还有一个常常忽略的点:标定板粘贴不平整会导致角点三维坐标不准确。我一开始用普通纸打印ArUco码,直接贴纸板书上,结果纸面边缘微微翘起,标定结果总是差那么几个毫米。后面换成亚克力板把标签贴平,问题立刻解决。

我还遇到过机械臂TCP设置不准确的问题。末端装了夹爪,但没有设置工具坐标系,机械臂内部计算的TCP位姿和实际末端差了夹爪长度的距离。这个偏差在标定过程中不会暴露,因为标定板固定在夹爪末端,手眼矩阵会把这个偏差一并吸收,但后续用这个矩阵抓取新物体时就会出问题。所以一定要先确认TCP配置准确,再开始标定。

结束语:给想直接复制的朋友几个建议

整套方案我用了大概三周时间在实验室调通,现在每周换工位调整相机位置后,重新标定只需要几分钟。如果你也想直接复制这套流程,我的体会是:前期的硬件固定方式比代码更影响标定精度,相机支架必须刚性连接,机械臂底座也要固定牢靠,任何标定过程中的微小位移都会直接污染数据;姿态采样尽量生成后人工检查一次,避免机械臂和相机支架干涉;标定完成后不要急着撤掉标定板,先拿一个已知位置的小工件做几次抓取测试,确认整体链路没问题再进入正式生产。

最后分享一个小技巧:标定数据里我通常会留出最后5组不参与解算,只用来做验证。这样得到的手眼矩阵是独立的验证结果,而不是在训练集上自说自话。这套方法帮我避开了好几次“看起来精度很高、实际抓取就偏”的假象,非常推荐你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询