手眼标定这四个字,第一次听到的人多半会猜是某种医学操作。在机器人圈子里,它指的是让机械臂"知道"相机看到的东西落在自己坐标系的什么位置——说白了,就是求相机与机械臂末端(或机械臂基座)之间那个固定不变的变换矩阵。真机上做这件事,成本高、复现难、翻车点多。我在三种不同构型的工位上折腾过手眼标定,最深的体会是:真正难的从来不是最后那几行求解代码,而是数据采得干不干净、坐标系有没有约定清楚。这也是我把手眼标定的第一站放进 CoppeliaSim(很多人更熟悉它改名前的老名字 v-REP)的原因。在仿真里我能拿到干净的位姿真值,能随时重置重来、随时换构型,把整条链路先跑通;等回到真机,只需要专心对付"现实误差"这一件事。这篇是系列的第一部分,主题是仿真环境下的机器人 3D 相机搭建、手眼标定的数学骨架梳理,以及标定数据的采集与筛选。如果你手里有机械臂、有深度相机、正准备做视觉引导抓取或者实时视觉追踪,这篇可以直接当施工图看。
1. 为什么把手眼标定的第一站放在 CoppeliaSim
1.1 真机标定的三个现实门槛
先说清楚真机标定到底卡在哪里。手眼标定的输入是一组"机械臂末端位姿 + 相机观测到的标定板位姿"的配对数据,理论上十几组数据就能解出结果。但实际做起来,门槛集中在三个地方。
第一是位姿精度不可控。机械臂末端位姿通常从控制器读取,读数受关节零位误差、连杆参数误差、减速器回程间隙影响,标称重复定位精度 0.02mm 的机器人在大臂展姿态下末端绝对误差可能到零点几毫米甚至更高。这些误差会原封不动地进入标定矩阵,最后表现为"标定结果看着收敛了,实际抓取偏了两毫米"。
第二是数据采集效率低。每换一个姿态就要等机械臂停稳、等相机曝光、等算法检测角点。一个姿态三十秒,二十个姿态就是十分钟,中间任何一次检测失败都要重来。碰上机械臂运动范围受限、工作空间里到处是遮挡的产线工位,凑够一组旋转角度足够分散的数据能耗掉半天。
第三是失败成本。真机上撞一下就是真金白银,标定板被机械臂蹭一下、相机线被拽一下,都得停机排查。所以真机标定的合理姿势是:先在仿真里把全流程跑顺,把代码调通,把参数定下来,真机上只做验证和微调。
1.2 仿真平台为什么选 CoppeliaSim
机器人仿真平台不少,Gazebo、Isaac Sim、MuJoCo、Webots 各有所长。做手眼标定这个具体任务,我最终固定在 CoppeliaSim 上有几个很实际的理由。
一是场景搭建快。CoppeliaSim 里拖一个视觉传感器就是一台相机,改两个参数就换镜头,不用写 URDF 也不用配 SJMS 插件。手眼标定要反复调整相机的安装位置和朝向做对比实验,这种改一次要重新编译整个仿真包的工作流,我实在受不了。
二是接口干净。CoppeliaSim 的 ZMQ Remote API 让 Python 脚本可以直接读写仿真里任意对象的位姿、关节角、传感器数据,采集一组标定数据的代码量大概几十行。对比之下,很多平台取一次传感器数据要写一大堆桥接代码。
三是它自带真值。仿真里我可以直接从场景图计算出"相机相对末端"的真实变换,拿它和标定算法的输出做对比,误差来源一眼可辨。这个能力在真机上是不存在的,也是仿真最大的价值——你有一个可以拿来对答案的标准答案。
注意:CoppeliaSim 从 V4.0 开始更名,网上大量教程还写着 v-REP,两者的脚本 API 大方向一致,但新版推荐用 ZMQ Remote API 而不是老的 legacy remote API。查资料时按手里的版本号去筛,混用容易踩坑。
1.3 仿真标定能验证什么、不能验证什么
这一点必须提前讲清楚,否则很容易产生错误预期。
仿真能验证的:坐标系的定义方向对不对(这个坑特别多)、手眼方程组的构造是否正确、求解算法的数值稳定性、采样姿态的分布是否合理、标定流程的代码是否跑得通、不同求解方法在同一批数据上的差异。这些是"逻辑层"的问题,仿真能百分之百暴露。
仿真不能验证的:相机内参的实际畸变、镜头与法兰的装配偏差、机械臂的实际运动学误差、标定板加工精度、光照变化对检测的影响。这些是"物理层"的问题,只能在真机上面对。
我一般把仿真当"标定算法的单元测试"用。仿真里跑出 0.01mm 级别的重投影误差不稀奇,别拿这个数字去对外宣称精度;它的意义是证明你的代码逻辑没问题,剩下的误差才轮到真机去贡献。
2. 从零搭一个带 3D 相机的标定工位
2.1 机械臂模型导入后必须先核对的三件事
CoppeliaSim 导入机械臂模型有两条路:一是用自带的模型浏览器里现成的机械臂,二是从 URDF 导入。URDF 导入的路径更通用,但导入后有三件事必须逐项核对,我踩过不止一次。
第一,关节零位与旋转方向。导入的模型里关节零位往往是 URDF 作者定义的,未必和你手里机器的零位一致。检查方法很简单:把所有关节角设为零,看末端姿态是不是你预期的姿态;再单独动一个关节,看旋转方向正负是否符合直觉。方向反了不会报错,但会让后续所有采样姿态变得莫名其妙。
第二,关节是运动学模式还是动力学模式。手眼标定采集只需要纯运动学,不需要动力学仿真,把关节设为运动学模式(Kinematic)可以避免重力导致的关节下垂和抖动,采集速度快一个量级。判断方式是在关节属性里看它有没有被动力学引擎接管——如果设了目标位置但关节慢慢飘过去还带振荡,那就是动力学模式。
第三,基座坐标系。机械臂基座在世界坐标系下的位姿决定了 T_world_base,这个矩阵后面会反复用到。导入后如果模型不是摆在世界原点,一定要把这个变换记下来,或者在场景树里把基座放到原点,省掉一堆麻烦。
-- 在 CoppeliaSim 的脚本里快速核对关节信息 local jointHandles = {} for i = 1, 6 do local h = sim.getObject('./joint' .. i) jointHandles[i] = h -- 打印关节当前角度与类型 print(string.format('joint%d pos=%.4f', i, sim.getJointPosition(h))) end提示:如果模型里关节命名不规整,别急着写死路径,用
sim.getObjectsInTree遍历场景树把所有关节句柄捞出来,按名字排序后再索引。模型换了也不用改代码。
2.2 3D 相机的两种建模方式:图像式与真值式
仿真里做视觉,最重要的一个决策是:你到底要不要渲染出真实图像。
方式一是真实渲染。在场景里放一个视觉传感器(Vision Sensor),设置分辨率、视场角、近远裁剪面,它可以输出 RGB 图像和深度图。然后再用 OpenCV 去检测标定板角点、做 PnP 求位姿。这条路最接近真机,因为它把"图像处理引入的位姿误差"也一起模拟进去了。
方式二是直接读真值。标定板在场景里是一个对象,你可以直接读它的世界位姿,相机也是对象,也能读世界位姿,两者一除就得到 T_cam_target。这条路完全跳过图像处理,得到的是理想数据。
我的建议是两条路都搭一遍,然后对比结果。真值路径用来验证求解代码,渲染路径用来评估图像处理环节引入了多少误差。如果两路结果的差距大得离谱,说明问题在图像处理,而不是标定算法。
对于 3D 相机(比如结构光、ToF、双目这类输出点云或深度图的设备),仿真里的建模方式是:视觉传感器负责出 RGB 和深度缓冲,再按相机内参把深度图反投影成点云。CoppeliaSim 的视觉传感器可以直接取深度缓冲,取值是 0 到 1 的归一化值,需要按近远裁剪面换算成实际距离。
# 深度值换算成实际米数 near = sim.getObjectFloatParam(cam_handle, sim.visionfloatparam_near_clipping) far = sim.getObjectFloatParam(cam_handle, sim.visionfloatparam_far_clipping) depth_m = near * far / ((far - near) * depth_norm + near) # 透视相机的非线性映射这里要注意一个容易忽略的点:不同引擎、不同相机类型,归一化深度到实际距离的换算公式不一致。正投影相机是线性的,透视相机是上面这种非线性关系。公式写错的话点云会被整体拉伸,但在视觉上又很难一眼看出来,因为场景里恰好有一块平面的时候看起来还是平的。我的做法是放一个已知尺寸的立方体在固定距离上,量一下点云里的边长,对得上再往下做。
2.3 手眼安装位姿与坐标系约定的第一原则
相机怎么装,直接决定后面标定方程的形式,这个必须在动手前定死。
Eye-in-Hand(眼在手上):相机固定在机械臂法兰或末端执行器上,跟着机械臂一起动。优点是相机视野随机械臂移动,可以近距离观察目标,标定精度相对好。缺点是相机线缆要跟着动,工作空间受限。这是绝大多数视觉引导抓取项目的选择。
Eye-to-Hand(眼在手外):相机固定在支架或龙门架上,位置不动,俯视整个工作台。优点是视野固定、线缆稳定、能看到全局。缺点是视野内一旦遮挡就没辙,而且标定精度对相机到工作台的距离敏感。
两种构型对应的标定方程不一样。眼在手上是标准的 AX=XB,眼在手外是 AX=ZB 的形式。这篇先集中讲眼在手上,它更通用,也是后面实时视觉追踪的基础。
坐标系约定这件事我要单独强调:**在写下任何一行标定代码之前,先把每个坐标系的名字、原点位置、三轴朝向写在纸上或注释里。**我见过太多标定失败的案例,最后查出来是某个坐标系的 Y 轴和 Z 轴写反了。CoppeliaSim 默认是 Z 轴向上的右手系,很多从 ROS 转过来的脚本默认 Z 轴向前,两边一混,标定结果能对上才有鬼。
推荐一套命名:
| 坐标系 | 记号 | 含义 |
|---|---|---|
| 世界系 | W | 仿真场景全局,CoppeliaSim 原生 |
| 基座系 | B | 机械臂基座法兰底面 |
| 末端系 | T | 法兰中心,通常取第六轴输出法兰面 |
| 相机系 | C | 光心为原点,Z 沿光轴向前 |
| 标定板系 | G | 标定板左上角第一个角点或板中心 |
有了这套记号,眼在手上的所有变换关系就是一句话:T_W_T 乘以 T_T_C 乘以 T_C_G 恒等于 T_W_G。其中 T_T_C 是你的待求量,其他三个矩阵都能测到或读出来。
3. 手眼标定到底在解什么方程
3.1 从一次观测到 AX=XB 的推导
很多人背下了 AX=XB 这个式子,但说不清 A 和 B 到底是什么。我用人话推一遍。
机械臂在两个不同位姿下观察同一个固定标定板。位姿 1 时,末端系到世界系的变换记为 T1;位姿 2 时记为 T2。相机装在末端上,所以相机到末端的变换 X 是固定的、未知的。标定板相对于相机的位置在两次观测中是不同的,分别记为 C1 和 C2。
从世界系角度看,标定板是静止的,所以:
T1 · X · C1 = T2 · X · C2把同侧项整理一下:
(T2⁻¹ · T1) · X = X · (C2 · C1⁻¹)令 A = T2⁻¹ · T1,B = C2 · C1⁻¹,就得到 AX = XB。
A 完全由机械臂的正运动学给出,是"纯真值";B 完全由相机观测给出,带着图像处理的全部误差。整条链路里,A 是干净的,B 是脏的,标定的质量上限基本由 B 决定。
这也解释了一个常见困惑:为什么换了好几组数据,标定结果还是不收敛?因为问题不在求解算法,而在 B 的噪声太大。图像里角点检测偏一个像素,在近距离下可能对应零点几毫米的位姿误差,累积到十几组数据里,算法就找不出一个自洽的 X 了。
3.2 采样姿态怎么选才不会退化
数据够不够,不是看组数,是看姿态分布的"张角"。
假设你只让机械臂绕第六轴转,末端位置基本不变,只有姿态在变。这时候 A 的旋转轴始终几乎是同一根轴,方程组在旋转部分会严重退化,解出来的 X 对这根轴上的分量极不敏感,表现为"某几个方向上的标定误差特别大"。这不是算法的问题,是数据的问题,神仙算法也救不回来。
我用的采样策略是这样的:
- 让末端在相机视野内尽量覆盖更大的空间,取 5 到 8 个不同的位置点,均匀分布在相机视野和机械臂可达空间的交集里。
- 在每个位置点上,让末端姿态绕至少三根不同的轴各转 30 度以上。转太少意味着两次观测的旋转轴区分度不够。
- 保持标定板始终完整出现在视野里、角点清晰。这一点在仿真是免费的,在真机上要用光照和曝光去换。
- 总组数落在 15 到 25 之间。低于 12 组基本不可信,超过 25 组收益迅速递减,还不如把时间花在提高单组数据质量上。
一组好的数据长什么样?任意两组的末端旋转角差大于 30 度,旋转轴之间的夹角大于 30 度。你可以写个几十行的脚本对采集到的姿态集合算一下这个指标,不达标就补采。这一步看起来麻烦,但比标定完发现精度不够再回来重新采要省事得多。
3.3 仿真里怎么拿到"干净"的标定数据
在 CoppeliaSim 里采数据,典型的流程是:给一组关节角 -> 关节运动到位 -> 读末端位姿 -> 读相机观测的标定板位姿 -> 存成一对。
前面说过有真值和渲染两条路,这里展开讲真值路怎么走。
标定板在场景树里是一个对象(比如从模型库里拖进来的棋盘格模型)。相机的世界位姿用sim.getObjectPose(camHandle, -1)拿到,标定板的世界位姿用同样方式拿到。那么相机观测到标定板的位姿就是:
T_C_G = inv(T_W_C) · T_W_G注意这里有个陷阱:sim.getObjectPose返回的是对象参考系的位姿,而标定板的"板面"未必和它的对象原点重合。如果标定板模型的原点在板中心,那没问题;如果原点在板的一角,你需要在标定板系里再乘一个固定偏移,才能得到"棋盘格角点阵"的位姿。这个偏移一旦搞错,标定结果会有个恒定的平移偏差,而且在所有姿态下表现一致,很容易被误判成相机安装位置的偏差。
import numpy as np def pose_to_T(pose): """CoppeliaSim 的 pose 是 [x,y,z,qx,qy,qz,qw],转成 4x4 齐次矩阵""" p = np.array(pose[:3]) q = np.array(pose[3:7]) x, y, z, w = q R = np.array([ [1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)], [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)], [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)], ]) T = np.eye(4) T[:3, :3] = R T[:3, 3] = p return T3.4 手眼标定到底要哪些数据
这是被问得最多的问题之一,我直接列个清单。
必须有的:机械臂末端位姿序列(每次观测时的 T_W_T)、相机观测的标定板位姿序列(T_C_G)、相机内参矩阵和畸变系数、标定板的物理参数(格点数、格边长)。
如果走渲染路径,还需要原始图像或至少是检测出的角点像素坐标,方便回溯排查。
可选但很有用的:每次观测的关节角(用于检查正运动学是否一致)、采集时的时间戳(用于排查同步问题)、渲染路径下的重投影误差(用于剔除外点)。
有一类数据特别容易被漏掉:标定板坐标系的定义方式。说白了就是"你说的 T_C_G 里的 G 到底在哪一点、Z 轴朝哪边"。不同库的约定不一样,OpenCV 的solvePnP输出的板系通常把原点放在第一个角点、Z 轴垂直于板面向外,但有些库把原点放在板中心。如果采数据用一个约定、求解用另一个约定,最后得到的 X 里会混进去一个固定偏移,看起来像是相机装歪了。
实操心得:我在每个项目的标定脚本开头都会写一段注释,把五个坐标系的定义、原点位置、轴向、单位全部写清楚,然后写两行自检代码——把已知量代回 T_W_T · X · T_C_G 看是否恒等于 T_W_G。自检不过就不往下走,比标定完再回头查要省太多时间。
4. 标定数据的采集、筛选与质量评估
4.1 数据采集脚本的整体结构
一套能用的采集脚本,骨架大概是四段:连接仿真、初始化、循环采集、落盘保存。
连接部分用 ZMQ Remote API,初始化部分把要用的对象句柄全取出来(机械臂关节、末端、相机、标定板),循环部分遍历预设的姿态列表,落盘部分把每个姿态的末端位姿和相机观测存成结构化的文件。
from coppeliasim_zmqremoteapi_client import RemoteAPIClient import numpy as np, json client = RemoteAPIClient('localhost', 23000) client.setStepping(True) sim = client.require('sim') joints = [sim.getObject(f'/UR5/joint{i}') for i in range(6)] tcp = sim.getObject('/UR5/tip') cam = sim.getObject('/UR5/tip/camera') board = sim.getObject('/calibBoard') samples = [] for q in preset_joint_configs: for i, h in enumerate(joints): sim.setJointPosition(h, q[i]) sim.step() # 推进一个仿真步,让场景更新 client.step() # 等待服务端返回,确保数据同步 T_W_T = pose_to_T(sim.getObjectPose(tcp, -1)) T_W_C = pose_to_T(sim.getObjectPose(cam, -1)) T_W_G = pose_to_T(sim.getObjectPose(board, -1)) T_C_G = np.linalg.inv(T_W_C) @ T_W_G samples.append({'T_W_T': T_W_T.tolist(), 'T_C_G': T_C_G.tolist()}) with open('handeye_samples.json', 'w') as f: json.dump(samples, f, indent=2)这里最关键的调用是sim.step()和client.step()的配合。前者推进仿真本身,后者处理通信往返。只调其中一个会出现"数据没更新"或者"脚本卡死"的现象,这是新手最常撞的墙。
4.2 采样点的自动生成
手写姿态列表既费时又难保证分布质量。我一般用自动生成:
第一步,在相机视野和机械臂可达空间的交集里,用拉丁超立方或简单的网格采样生成 6 到 8 个位置点。第二步,在每个位置点上,围绕三根近似正交的轴各生成若干旋转,旋转角在 25 到 45 度之间随机取值。
生成出来的候选姿态不能直接用,因为可能出现:机械臂自碰撞、末端碰到工作台、标定板跑出视野、关节超过限位。所以要加一层筛选——用逆运动学求解每个候选姿态的关节角,解不出来或者解出来超限位就丢弃;再把标定板的包围盒投影到相机像平面,确认完整可见;最后用碰撞检测排除自碰撞。
这一步在仿真里是纯计算,几百个候选姿态筛一遍也就几秒钟,但能给后面的标定省下大量返工。真机上想做同样的筛选,得实打实地动一遍机械臂,成本天差地别。
4.3 数据质量的三个量化指标
采完数据不能直接就扔给求解器,要先量化评估。
**指标一,旋转张角。**计算所有姿态对的相对旋转角,取最小值。如果最小值小于 20 度,说明存在过于接近的姿态对,对求解贡献很小,应该剔除。
**指标二,旋转轴分布。**把所有姿态对的相对旋转轴归一化后看它们的散布。理想情况下这些轴应该指向多个不同方向,形成一个近似球面覆盖。如果它们挤在一小片区域,说明采样集中在单一旋转模式上。
**指标三,标定板在视野中的位置分布。**如果所有观测里标定板都在图像正中央,那标定结果在图像边缘区域的可靠性会打折扣。理想情况是板子在视野里有明显的位置变化,覆盖中心、四角附近。这一点在仿真里可以精确控制,在真机上主要靠机械臂位姿来间接实现。
| 指标 | 合格线 | 不达标的表现 |
|---|---|---|
| 最小相对旋转角 | ≥ 20° | 存在近似重复姿态,解不稳定 |
| 旋转轴夹角最小值 | ≥ 30° | 采样退化,某方向误差大 |
| 图像位置覆盖 | 覆盖中心与四角 | 边缘区域标定精度差 |
| 有效姿态对数 | 12 ~ 25 | 太少欠定,太多收益递减 |
4.4 剔除外点的一个实用做法
采集数据里总会有那么几组是异常的:图像模糊、角点检测跳变、机械臂还没停稳就采了。这些外点对求解的影响远大于随机噪声,必须剔掉。
我用的做法是"留一交叉验证":先拿全部数据解一次 X,然后把每组数据单独代回 T_W_T · X · T_C_G,算出残差(理论上应该恒等于 T_W_G,所以残差就是偏差量)。把所有残差排序,剔除明显偏大的那几组,再重新求解。重复一到两轮,一般能把数据里的脏样本清理干净。
残差的度量要旋转和平移分开看:旋转残差取相对旋转矩阵对应的轴角,平移残差取欧氏距离。因为这两者的量纲不同,混在一起排序会掩盖旋转方向上的问题。仿真里如果用的是真值路径,残差应该接近机器精度;只要看到某个样本残差比其他大一个数量级,基本可以确定这组数据有问题。
5. ZMQ Remote API 实操:把采集回路跑通
5.1 环境准备与版本对齐
CoppeliaSim 的 ZMQ Remote API 从 V4.3 开始提供,客户端包是coppeliasim-zmqremoteapi-client。安装方式很简单,但版本对齐这个坑必须先说。
pip install coppeliasim-zmqremoteapi-client服务端默认监听 23000 端口,这个可以在 CoppeliaSim 的远程 API 设置里改。客户端连接时如果只写主机名不写端口,用的是默认值。多台仿真同时跑的时候端口会冲突,一定要显式指定。
还有一个特别隐蔽的问题:**Python 的 numpy 版本和 CoppeliaSim 自带的 Python 版本不一致时,位姿数据的精度会受影响。**从仿真端传过来的浮点数经过序列化再反序列化,本身就有精度损失。如果拿这些数据做高精度标定,建议把传输格式改成字符串再自己解析,虽然麻烦但精度可控。
注意:老教程里的
simxStart、simxGetObjectPosition那一套是 legacy remote API,和 ZMQ Remote API 的写法完全不兼容。照着老教程抄代码会直接跑不起来,报函数不存在的错。识别方法很简单:函数名带simx前缀的是旧版,用sim.getObjectPose这类的是新版。
5.2 同步模式:为什么必须开 Stepping
这是采集脚本里最容易出错的地方,我单独讲。
CoppeliaSim 有两种运行模式:连续运行和步进模式。连续模式下仿真自己按实时钟跑,你的脚本发一条指令,场景可能已经往前走了好几步,你读到的位姿和你设的关节角对不上。步进模式下,只有你调用一次sim.step(),仿真才往前走一步,什么时候读数据完全由你控制。
采集标定数据必须用步进模式。
client.setStepping(True) # 开启步进模式 for q in configs: for i, h in enumerate(joints): sim.setJointPosition(h, q[i]) sim.step() # 应用关节角,更新场景 client.step() # 与服务端同步一次 # 此后读到的一切都是与当前关节角严格对应的调用顺序是"先 sim.step 再 client.step",反过来会读到上一帧的数据。这个坑我第一次写的时候踩了一整个下午,症状是标定结果总有一个固定偏差,像是相机被平移了一段距离。
5.3 相机数据的读取与坐标系转换
从视觉传感器取数据,接口有两组:图像数据用sim.getVisionSensorImg,深度数据用sim.getVisionSensorDepth或直接取深度缓冲。
图像数据返回的是字节串加分辨率,需要按 BGR 顺序 reshape 成图像数组。深度缓冲返回的是浮点列表,需要按前面讲的非线性公式换算成实际距离。
img, res = sim.getVisionSensorImg(cam_handle) img = np.frombuffer(img, dtype=np.uint8).reshape(res[1], res[0], 3) img = img[::-1] # CoppeliaSim 的图像是上下翻转的,别忘了这行 depth = sim.getVisionSensorDepth(cam_handle, 1, [0, 0], res) depth = np.array(depth).reshape(res[1], res[0])上下翻转这行代码必须加。CoppeliaSim 输出的图像原点在左下角,而 OpenCV 和绝大多数图像处理库的原点在左上角。忘了翻转的话,检测出的角点会上下镜像,PnP 出来的位姿在俯仰角上直接反号,标定结果偏差巨大而且很难从数据上看出规律。
深度图反投影成点云需要相机内参。仿真里没有"内参矩阵"这个直接的概念,你得从视场角和分辨率推:
fov = sim.getObjectFloatParam(cam_handle, sim.visionfloatparam_perspective_angle) fx = res[0] / (2 * np.tan(fov / 2)) fy = fx # 方形像素,fx 和 fy 相同 cx, cy = res[0] / 2, res[1] / 2注意这里推出来的 fx 和 fy 是理想值,不含畸变。仿真里的虚拟相机默认是理想针孔模型,所以在仿真中走渲染路径时,用这组内参做 PnP 得到的位姿误差只来自角点检测,不来自畸变。这也是前面说的"仿真验证逻辑层"的一个体现。
5.4 落盘与复现
数据采集完成后,我把每一组样本存成一个独立的 JSON 文件,加上一个全局的元信息文件,记录采集时的配置、仿真版本、相机参数、标定板参数。这样做的好处是:任何一次标定实验都可以完整复现,包括当时用的什么参数。
复现这件事在调试阶段价值极大。有一次我的标定结果怎么调都不对,最后是靠回放三个月前的一份采集数据,逐组对比残差,才发现问题出在某一次改动中我不小心把标定板的尺寸参数从 30mm 改成了 25mm。如果没有完整的元信息记录,这个 bug 可能要查上一周。
文件结构建议这样组织:
calib_session_20240612/ ├── meta.json # 相机参数、标定板参数、仿真版本 ├── sample_000.json # 单组样本:T_W_T / T_C_G / 关节角 ├── sample_001.json └── ...5.5 从采集到求解的衔接
采集完的数据交给求解器之前,还有一步预处理容易被忽略:把求解器需要的矩阵格式对齐。
不同的标定库要求的输入形式不一样。有的要求你传入相邻两次的 A 和 B(也就是相对变换),有的要求你传入全部绝对位姿,由库内部自己算相对量。传错了不会报错,但结果会明显不对。
以常见的cv2.calibrateHandEye为例,它要求传入的是所有姿态下的旋转矩阵和平移向量(绝对量),内部自己构造相对变换。而有些自己实现的 Tsai-Lenz 求解器,要求你传入成对的相对变换。这两者的区别在接口文档里往往写得很简略,只能靠维度对不对、结果合不合理来判断。
我一般会在传参前打印一下 A 和 B 的维度以及它们的模长范围,确认量级正常。如果 A 的平移部分全为零,说明相对变换算错了(比如把所有姿态都传成了同一个);如果 B 的旋转部分全是单位矩阵,说明标定板位置在多次观测中没变化,数据本身有问题。这些小检查写起来几分钟,能省掉大量猜谜时间。
6. 常见问题与踩坑速查
6.1 症状与成因对照表
下面这张表是我这些年踩过的坑的浓缩版,遇到问题可以直接对号入座。
| 症状 | 可能成因 | 排查方法 |
|---|---|---|
| 标定结果有一个固定平移偏差 | 坐标系约定不一致,或标定板原点定义不同 | 打印各坐标系变换链,验证 T_W_T·X·T_C_G 是否恒等 |
| 结果总偏差一个角度 | 相机 Z 轴约定不同(朝前 vs 朝后) | 检查相机系定义,确认光轴方向 |
| 求解不收敛 | 采样姿态退化,旋转轴集中 | 算最小相对旋转角与轴夹角分布 |
| 数据读出来全是上一帧的值 | 同步顺序错了 | 确保 sim.step 在 client.step 之前 |
| 深度点云整体拉伸 | 深度归一化公式用错(线性 vs 非线性) | 用已知尺寸物体验证点云尺度 |
| 图像上下颠倒 | 没做图像翻转 | 加img[::-1] |
| 某几组样本残差异常大 | 外点,图像模糊或运动未停稳 | 留一交叉验证剔除外点 |
| 关节设了目标位置但不动 | 关节是动力学模式或仿真未推进 | 切运动学模式,确认调用了 step |
6.2 几个只有实操过才知道的细节
**关节运动到位不等于姿态稳定。**在动力学模式下,关节到达目标位置后还会有残余振荡,这时候读出来的末端位姿是抖的。解决办法是切运动学模式,或者采数据前多推进几个仿真步让振荡衰减。我习惯在设置关节角之后连推三步再读数据。
**标定板的物理尺寸必须和仿真里的模型尺寸完全一致。**仿真里拖进来的棋盘格模型,它的格边长是模型作者随便定的。你得量一下模型里两个角点之间的实际距离,把这个值作为标定板的物理参数传给求解器。用默认值或者目测值,标定出的平移分量会整体缩放。
**仿真速度和实时性无关。**步进模式下仿真会跑得飞快,别拿仿真里的耗时去估算真机上的采集时间。真机上采集一组数据的时间通常是仿真的几十倍。
**真值路径和渲染路径得到的 X 不该差太多。**如果差得多,先查图像处理环节(角点检测、PnP 参数、图像翻转),而不是怀疑标定算法。标定算法在两组数据上都不收敛,那才轮到怀疑算法或数据分布。
实操心得:我给自己定了一条规矩——每改一处坐标系的定义,就在纸上画一遍变换链,然后用两行代码验证一次。这个习惯看起来笨,但它让我避开了至少三次"调了三天发现是坐标系写反"的惨剧。标定这件事,80% 的时间花在坐标系的确认上都不算多。
6.3 下一步的方向
这篇把仿真环境的搭建、手眼方程的推导、数据采集与筛选这条链路走完了。接下来要面对的是求解:Tsai-Lenz 的闭式解、Park-Martin 的改进、Daniilidis 的对偶四元数方法各自适合什么数据、怎么在 Python 里调 OpenCV 的calibrateHandEye、标定结果怎么验证、误差怎么分解到各个方向。再往后就是拿标定好的变换去做实时视觉追踪,把相机看到的目标位置换算到机械臂坐标系里,配合视觉伺服把末端送过去。
我个人在仿真里做手眼标定最实在的收获,不是标出了多准的矩阵,而是终于能一眼看懂那些坐标系之间的变换关系。真机上的标定误差往往是物理装配和运动学误差的混合体,查起来毫无头绪;仿真里每一层误差都能被单独拎出来看,调通之后再回真机,你会发现很多曾经觉得玄学的问题,其实早在坐标系定义那一步就埋下了伏笔。整套采集脚本我一般会留一版最小可运行版本,不追求功能全,只保证从连接仿真到落盘数据这条链路能跑通,后面所有实验都从它上面长出来——这比一开始就写个功能齐全的大脚本,要可靠得多。