ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

六轴机械臂正向运动学FK:从DH参数到工程落地

六轴机械臂正向运动学FK:从DH参数到工程落地

调六轴机械臂的人大概都有过这种体验:轨迹规划算完了,碰撞检测过了,结果机械臂往下一扎,末端停在了离目标十几厘米的地方。回头一看,问题不在规划器,而在最前面那一步——把六个关节角变成末端位姿的正向运动学(Forward Kinematics,FK)。这一步看着简单,无非是几个矩阵连乘,但它把关节空间和笛卡尔空间这两个完全不同的世界缝在了一起,缝歪一针,后面全歪。这篇就围绕关节空间转笛卡尔空间这件事,把我自己在机械臂项目里踩过的坑、验证的手段、以及从“能算对”到“敢上机”之间那段路,完整讲一遍。适合刚接触机器人运动学、手上有模型但算出来的结果对不上、或者想给自家设备补一套自洽 FK 模块的朋友看,不管你是做协作臂、SCARA、还是自己搭的舵机玩具臂,底层逻辑都是同一套。

1. 关节空间与笛卡尔空间:先把两个坐标系的关系捋顺

1.1 关节角列表到底缺了什么信息

一串关节角,比如[0.1, -0.5, 1.2, 0, 0.8, 0],对控制器来说是完备的——每个电机知道该转到哪。但对任何想“用起来”的上层应用来说,这串数字几乎没有意义。你不知道末端在哪、朝哪、离桌子多高、会不会撞到夹具。这就是关节空间的原罪:它是执行层的语言,不是任务层的语言。

笛卡尔空间则是另一套话语体系,用三维位置加三维姿态描述末端。人脑天然理解笛卡尔空间——“把螺丝刀挪到左上角、垂直朝下”是笛卡尔描述,“J2 转 -37 度”只有标定工程师听得懂。正向运动学干的事情,就是把前一种表达翻译成后一种,而且是单向的、必定有解的翻译。注意“必定有解”这四个字,这是 FK 和逆运动学(IK)最本质的区别:给定一组关节角,末端位姿唯一确定,没有多解、没有无解,只有算错。这个性质决定了 FK 是可测试、可验证、可以写成单元测试的,而 IK 只能做收敛性验证。

我见过不少新手把这两件事混在一起纠结。记住一条:FK 是函数,f(θ) = T,确定性映射;IK 是反函数,f⁻¹(T) = θ,可能多解、可能无解、依赖初值。本文只谈前者,但后面讲标定和雅可比时会顺带用到后者的思维。

1.2 正向运动学在控制链路里的位置

把整条链路摊开看,FK 处在非常靠前的位置:任务层给出末端目标位姿 → 轨迹规划在笛卡尔空间插补出一串中间点 → 逆运动学把每个点解成关节角 → 伺服驱动执行。在这个方向上,FK 似乎不是必需的。那它什么时候出场?

答案是反馈回路和一致性校验。真实系统里,伺服反馈回来的是编码器值,也就是关节角。控制器要知道“我现在到底在哪”,就必须跑一次 FK 把关节角换成末端位姿,再和规划中的期望位姿比对。另外,笛卡尔空间的速度控制、力控、视觉伺服,全都要实时的末端位姿输入。所以 FK 不是“算一次就完事”的离线工具,它往往要跑在控制周期里,几百赫兹甚至上千赫兹。

这就带来两个工程约束:一是必须够快,二是必须够稳。够快意味着你不能在里面搞字符串拼接、动态内存分配、日志打印;够稳意味着遇到奇异构型、参数越界,也不能抛异常把整个控制循环炸掉。我见过一个项目,FK 里用了个会分配临时对象的矩阵库,1kHz 跑下来 GC 抖动直接把周期打乱,查了两周才定位到。这种教训,后面第 5 节还会展开。

1.3 三类绕不开 FK 的场景

第一类是可视化与调试。任何机械臂上位机都有个三维视图,动一下关节滑块,模型跟着动。这个“跟着动”就是每帧跑一次 FK,把结果喂给渲染层。别小看这个功能,它是排查 IK 问题最有效的手段——IK 解出来的姿态不对,你可以在三维视图里直接看到是哪个关节拧反了。

第二类是碰撞检测。包围盒、点云、胶囊体,不管用哪种几何体做碰撞,前提都是知道每个连杆在世界坐标系里的位姿。而连杆位姿恰恰是 FK 连乘过程中的中间产物,顺手就能拿到,不用额外算。我通常会在 FK 函数里同时返回所有中间变换矩阵,调用方按需取,比事后重算省一半时间。

第三类是标定与精度补偿。纸面 DH 参数和实物永远有偏差,连杆加工公差、装配偏心、减速器背隙,都会让 FK 算出来的位置偏离真实位置。要修正这些偏差,第一步就是建立一个“参数 → 位姿”的映射,而 FK 就是这个映射本身。标定的本质,是找一组修正后的 DH 参数,让 FK 的输出和激光跟踪仪测到的真值尽量吻合。不理解 FK,标定就无从谈起。

1.4 一个容易被忽略的前提:坐标系约定

在写第一行代码之前,必须先钉死几个约定,否则后面全是玄学。原点定义在哪、Z 轴朝哪、零位姿态是什么、角度用弧度还是度、长度用米还是毫米。这些看起来是琐事,实际上是 FK 出错的头号来源。

我的习惯是:统一用米和弧度做内部计算,只在人机界面做单位转换;基座坐标系原点定在一轴回转中心与安装面的交点,Z 轴垂直向上;零位定义为所有关节角为零时机械臂自然伸直或折叠的构型,具体看厂家定义,但必须和参数表对应的构型一致。这一条极其重要——很多参数表给的 θ 偏置,是相对于厂家定义的机械零位,而你的编码器零点可能装的时候被调过,两者差一个常量。这个常量不补进去,FK 出来的结果会整体绕某个轴旋转一个固定角度,看起来“形状对但朝向不对”,特别迷惑人。

2. 数学骨架:齐次变换矩阵与 DH 参数

2.1 4×4 矩阵的四个分块分别在管什么

齐次变换矩阵是个 4×4 的方阵,我习惯把它拆成四块来看:

T = [ R(3×3) p(3×1) ] [ 0(1×3) 1(1×1) ]

左上角 3×3 的 R 是旋转矩阵,描述末端坐标系三个轴在世界坐标系下的方向,每一列就是一个单位轴向量;右上角 3×1 的 p 是平移向量,描述末端原点在基座坐标系下的位置;最下面一行固定是[0 0 0 1],把仿射变换塞进线性代数的框架里,好处是连续的变换可以写成矩阵连乘,不用分开处理旋转和平移。

很多人第一次看到这个结构会问:为什么要多一行一列,直接用 R 和 p 不行吗?行是行,但两次变换复合的时候要写成R = R1·R2、p = R1·p2 + p1,公式丑且容易写错。用齐次矩阵,复合就是纯粹的矩阵乘法,代码里一个@就完事。这是典型的“用一点存储换大量正确性”的设计。

再补一个直觉:旋转矩阵的每一列是末端坐标轴在基座系下的投影。所以如果你拿到一个算出来“位置对、姿态怪”的结果,直接去看 R 的三列,哪一列不对,就说明对应的那个轴方向搞错了。这个检查方法比对着欧拉角猜要快得多。

2.2 标准DH和改进DH,选错就全盘皆输

DH 参数(Denavit-Hartenberg)是把相邻两个连杆坐标系之间的关系压缩成四个数的方法:连杆长度 a、连杆扭角 α、连杆偏距 d、关节角 θ。四个参数、四个基本变换,拼起来就是一个关节的变换矩阵。

问题在于,经典教材里有两种拼法,而且都叫 DH。

标准 DH(Standard / Distal)把连杆坐标系固定在关节远端(也就是下一个关节的轴上),变换顺序是:

T = Rz(θ) · Tz(d) · Tx(a) · Rx(α)

改进 DH(Modified / Proximal)把连杆坐标系固定在关节近端,变换顺序是:

T = Rx(α) · Tx(a) · Rz(θ) · Tz(d)

两者的区别不只是顺序,坐标系的定义位置根本不同。用错的结果是:单个关节看起来都合理,连乘起来整体错位,而且错得“似是而非”,很难一眼看出。

选择建议很直接:跟你的参数表来源保持一致。厂家给的参数表标注了是哪种就用哪种,别自作主张转换。如果是从论文或开源模型里扒的参数,先确认它用的是哪种约定,再决定代码怎么写。我个人的偏好是用改进 DH,因为它的参数下标对齐更自然(αᵢ₋₁、aᵢ₋₁ 对应第 i 个关节的入端),参数表读起来不容易串行。但这不是硬性规定,团队统一才是关键。

下面这张表把两种约定下四个参数的物理含义对照一下,方便你判断手上的表属于哪种:

参数标准 DH 含义改进 DH 含义
a沿 Xᵢ 轴,Zᵢ₋₁ 到 Zᵢ 的距离沿 Xᵢ₋₁ 轴,Zᵢ₋₁ 到 Zᵢ 的距离
α绕 Xᵢ 轴,Zᵢ₋₁ 转到 Zᵢ 的角度绕 Xᵢ₋₁ 轴,Zᵢ₋₁ 转到 Zᵢ 的角度
d沿 Zᵢ₋₁ 轴,Xᵢ₋₁ 到 Xᵢ 的距离沿 Zᵢ 轴,Xᵢ₋₁ 到 Xᵢ 的距离
θ绕 Zᵢ₋₁ 轴,Xᵢ₋₁ 转到 Xᵢ 的角度绕 Zᵢ 轴,Xᵢ₋₁ 转到 Xᵢ 的角度

2.3 从单个关节矩阵到末端位姿的连乘

有了单个关节的变换,整条链就是连乘:

T_0_n = T_0_1 · T_1_2 · T_2_3 · ... · T_(n-1)_n

注意下标的写法:T_0_1表示“从坐标系 1 到坐标系 0 的变换”,也就是把坐标系 1 下的点变换到坐标系 0 下。这个方向约定一定要统一,数学上两种写法都对,但混用必炸。我习惯从左往右读:左边是结果坐标系,右边是源坐标系,T_0_n就是“末端在基座系下的位姿”。

如果末端还接了工具,最后再右乘一个工具变换T_n_tool:

T_0_tool = T_0_n · T_n_tool

这个T_n_tool描述的是法兰到工具工作点(TCP)的偏移,包括工具长度、安装角度。很多人算出来的位置差几厘米,就是忘了这一步——FK 算的是法兰中心,不是焊枪尖或者夹爪指尖。我一般会在参数配置里强制要求填 TCP,哪怕填全零,也要显式写出来,避免“以为不用填”的惯性思维。

矩阵连乘的顺序不能交换。旋转矩阵不满足交换律,平移和旋转复合更不满足。所以T_0_1 · T_1_2和T_1_2 · T_0_1完全是两个东西。写代码的时候我强烈建议把连乘写成从左到右的循环,别写成一堆嵌套的函数调用,那样调试起来非常痛苦。

3. 手把手实现:六轴臂FK从参数表到末端位姿

3.1 参数表整理与单位统一的三个铁律

拿到一份参数表,先别急着敲代码,做三件事。

第一,确认单位并统一到米和弧度。参数表里 a 和 d 经常是毫米,α 和 θ 可能是度。如果直接扔进np.cos,会得到一堆乱七八糟的结果。转换放在读取配置的那一层做完,计算层只认米和弧度。

第二,确认 θ 的零位偏置。很多参数表里 θ 那一列写的是“关节角范围”而不是偏置,真正的变换角是θ_actual = θ_encoder + θ_offset。这个偏置必须明确,否则整体姿态会绕某个轴固定偏转。

第三,确认 α 的符号约定。绕 X 轴旋转的正方向,右手系和左手系不一样,不同厂家的习惯也不同。验证方法是找一组已知构型(通常是全零位)的末端位姿真值,对一下。

下面给一组示例参数,是我在做某六轴协作构型验证时用的一组值,纯虚构,只用于演示格式(改进 DH,单位:毫米 / 度):

iaᵢ₋₁ (mm)αᵢ₋₁ (°)dᵢ (mm)θ 偏置 (°)关节范围 (°)
1001500±170
230-900-90-120 ~ 120
3340000-170 ~ 75
440-903200±180
509000±120
60-90800±360

注意第 2 关节的 -90 度偏置,这是让机械臂在零位时呈竖直伸展姿态的常见处理。如果漏掉它,零位时大臂会是水平躺着的——形状变了,但很多人第一反应会以为是 DH 参数错了,白折腾半天。

3.2 核心代码:三十行搞定连乘

按改进 DH 的变换顺序Rx(α)·Tx(a)·Rz(θ)·Tz(d)展开,可以写成这样一个函数:

import numpy as np def dh_modified(a, alpha, d, theta): """改进DH单关节变换矩阵,输入单位: 米 / 弧度""" ca, sa = np.cos(alpha), np.sin(alpha) ct, st = np.cos(theta), np.sin(theta) return np.array([ [ct, -st, 0, a], [st * ca, ct * ca, -sa, -d * sa], [st * sa, ct * sa, ca, d * ca], [0, 0, 0, 1] ], dtype=np.float64)

这个矩阵不是凭空写的,把三个基本变换乘一遍就能推出来:Rx(α)·Tx(a)先得到“绕 X 转 α,再沿新 X 移 a”,Rz(θ)·Tz(d)得到“绕 Z 转 θ,再沿新 Z 移 d”,两者相乘化简即得。自己动手推一次,比背公式靠谱得多,而且推的过程中你会真正理解为什么 a 和 d 出现在那些位置。

有了单关节矩阵,整条链的 FK 就是一个循环:

def forward_kinematics(q, dh_params, tcp=None): """ q: 关节角数组,长度 n,单位弧度 dh_params: [(a, alpha, d, theta_offset), ...],长度 n tcp: 4x4 工具变换矩阵,可为 None """ T = np.eye(4, dtype=np.float64) frames = [T.copy()] # 保存每个连杆坐标系,供碰撞检测使用 for i, (a, alpha, d, off) in enumerate(dh_params): T = T @ dh_modified(a, alpha, d, q[i] + off) frames.append(T.copy()) if tcp is not None: T = T @ tcp frames.append(T.copy()) return T, frames

返回frames这个设计值得说一下。它把中间每个连杆的位姿都留下了,上层做包围盒碰撞检测时直接拿来用,不用重新算一遍。代价是每帧多几次内存拷贝,对于 6 轴来说完全可以接受。如果跑在资源紧张的嵌入式板子上,可以改成只算末端,或者用静态数组复用内存。

调用示例:

dh_params = [ (0.000, 0.0, 0.150, 0.0), (0.030, -np.pi/2, 0.000, -np.pi/2), (0.340, 0.0, 0.000, 0.0), (0.040, -np.pi/2, 0.320, 0.0), (0.000, np.pi/2, 0.000, 0.0), (0.000, -np.pi/2, 0.080, 0.0), ] q = np.zeros(6) T, frames = forward_kinematics(q, dh_params) print("位置(mm):", np.round(T[:3, 3] * 1000, 2)) print("姿态矩阵:\n", np.round(T[:3, :3], 4))

零位时,这套参数算出来的位置大概在[0, ~-40, ~900]毫米这个量级(具体值取决于参数细节),Y 方向有个小偏移是因为第 2 关节的 d 或 a 不为零造成的。如果你算出来 Z 是负的,多半是 α 符号反了或者 Z 轴定义朝下了。

3.3 姿态怎么输出:欧拉角、四元数、旋转矩阵的取舍

FK 直接给出的是旋转矩阵,但上层往往要欧拉角或四元数。这三者各有各的适用场景,别混着用。

旋转矩阵适合内部计算、矩阵连乘、碰撞检测。不直观,但数值稳定,没有奇异问题。

欧拉角适合人机界面、参数配置、人工调试。直观,但存在万向锁,且同一个姿态有多种表示(比如(0, 0, 0)和(180, 180, 180)可能是同一个),做插值和比对时容易踩坑。

四元数适合插值、姿态融合、网络传输。紧凑(4 个数),插值平滑(球面线性插值),无奇异,但人看不懂,且存在双覆盖问题(q和-q表示同一姿态)。

从旋转矩阵提取 ZYX 欧拉角的代码:

def rot_to_euler_zyx(R): """返回 (roll, pitch, yaw),单位弧度,对应 R = Rz(yaw)Ry(pitch)Rx(roll)""" sy = np.sqrt(R[0, 0]**2 + R[1, 0]**2) if sy > 1e-6: roll = np.arctan2(R[2, 1], R[2, 2]) pitch = np.arctan2(-R[2, 0], sy) yaw = np.arctan2(R[1, 0], R[0, 0]) else: # 万向锁:pitch = ±90° roll = np.arctan2(-R[1, 2], R[1, 1]) pitch = np.arctan2(-R[2, 0], sy) yaw = 0.0 return roll, pitch, yaw

那个sy > 1e-6的判断就是万向锁分支,绝对不能省。省了的话,在 pitch 接近 ±90 度时atan2的两个参数同时趋近于零,结果会剧烈跳变。我在一个抓取项目里就吃过这个亏:机械臂垂直向下抓取时,界面上的姿态角突然从(0, 90, 0)跳到(180, 90, 180),看起来像是 FK 算错了,实际是欧拉角表示的固有缺陷。排查了半天才反应过来,后来统一改成界面显示欧拉角、内部全用四元数,问题消失。

从矩阵转四元数:

def rot_to_quat(R): tr = np.trace(R) if tr > 0: s = np.sqrt(tr + 1.0) * 2 w = 0.25 * s x = (R[2, 1] - R[1, 2]) / s y = (R[0, 2] - R[2, 0]) / s z = (R[1, 0] - R[0, 1]) / s elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]: s = np.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2]) * 2 w = (R[2, 1] - R[1, 2]) / s x = 0.25 * s y = (R[0, 1] + R[1, 0]) / s z = (R[0, 2] + R[2, 0]) / s else: # 省略另外两个分支,核心是按最大对角元选主元保证数值稳定 ... return np.array([w, x, y, z])

关键点是按对角线最大元素选分支,而不是只用tr > 0一个判断。只用第一个分支的话,当 trace 接近 -1 时开根号误差会被放大,四元数精度崩塌。这个细节很多教程不讲,但做高精度标定时会明显暴露出来。

3.4 用几何法做交叉验证

代码写完,绝不能只看“看起来像那么回事”。必须验证。最有效的方法是用一个能手工算的简化构型对拍。

拿最简单的平面 2R 臂举例:连杆 L1 = 0.4 m,L2 = 0.3 m,θ1 = 30°,θ2 = 60°,都在平面内。手算:

x = L1·cos(θ1) + L2·cos(θ1+θ2) = 0.4×0.8660 + 0.3×cos(90°) = 0.3464 + 0 = 0.3464 y = L1·sin(θ1) + L2·sin(θ1+θ2) = 0.4×0.5 + 0.3×1.0 = 0.2 + 0.3 = 0.5

用 DH 参数跑一遍,a1=0.4, a2=0.3, α=0, d=0,得到的结果应该严格等于(0.3464, 0.5)。误差超过 1e-9 就说明有 bug。

再往上加难度:单轴 90 度。把六轴臂的第 1 关节转 90 度,其余归零,末端位置应该整体绕 Z 轴旋转 90 度,也就是(x, y)变成(-y, x),Z 不变。这种“只动一个关节,看整体怎么变”的检查,比对比最终位姿更能定位问题——如果第 1 关节转了但末端没绕 Z 转,说明基座变换写错了;如果绕的是别的轴,说明 α 或坐标系轴向错了。

我自己的习惯是给每个新模型写三组测试:全零位、单轴 ±90 度、以及一组随机角对拍(用另一个独立实现的库或者手算的稀疏构型)。这三组过了,FK 基本可以放心用。

4. 排查实录:FK结果不对时按这个顺序查

4.1 高频错误速查表

FK 出错的表现就那么几种,按现象反推原因能省大量时间。下面这张表是我这些年攒下来的,基本覆盖了 90% 的情况:

现象最可能的原因快速验证方法
位置整体偏一个固定向量基座安装高度/偏移漏了,或 d1 填错零位时看末端 Z 是否等于基座到法兰的理论高度
位置随关节角变化但轨迹形状不对a 和 d 填反了,或标准/改进 DH 用混了单轴转动,看末端轨迹是不是圆弧、圆心对不对
姿态整体绕某轴固定旋转θ 零位偏置未加,或工具变换漏了全零位时对比姿态矩阵与厂家给的零位姿态
姿态轴方向反了α 符号错,或旋转正方向约定反了只改 α 的符号重算,看 R 的列向量是否对上
某些构型下位置跳变万向锁分支缺失,或角度未归一化让某关节扫过 ±180°,看输出是否连续
毫米级随机误差用了 float32,或矩阵库精度问题换 float64 重算,对比差值
和仿真对不上但自己也自洽两边的 DH 约定不同交换两边的参数表,确认约定一致

用这张表的时候有个技巧:先改一个变量,看现象怎么变。别一次改五个参数,改完发现对了也不知道为什么对,下次照样错。

4.2 单关节逐级放大法

这是我最常用的调试手段,专门对付“整体错位但看不出哪错”的情况。

做法是:把所有关节归零,确认末端位姿等于理论零位。然后只动第 1 关节,转 30 度,检查末端是否绕基座 Z 轴旋转了 30 度,位置半径是否保持不变。对了,就归零,再动第 2 关节,检查末端是否绕第 1 关节坐标系下的某个轴旋转。以此类推,一级一级往上走。

这个方法的精髓在于:每一步只引入一个新的变换矩阵,如果第 k 步出错,那问题一定在第 k 个矩阵或者第 k-1 到第 k 的坐标关系里,范围立刻缩小到一个。比起对着最终结果瞎猜,效率高一个数量级。

具体到代码,可以在 FK 函数里加个断点或者打印,把T_0_k的平移部分打出来:

for i in range(n): T_k = frames[i+1] print(f"关节{i+1}后位置: {np.round(T_k[:3,3]*1000, 2)} mm")

正常情况下,随着 i 增大,位置应该沿着机械臂的物理形态逐级“爬”出去。如果某个中间位置突然跳到离谱的地方,那一步就是嫌疑点。

还有一种变体我称之为“镜像检查”:把机械臂想象成一个骨架,每个中间坐标系的原点应该落在关节轴线上。如果你打印出的某个中间原点明显偏离了轴线(比如跑到连杆外侧去了),那基本就是 a 和 d 的对应关系搞错了。这个检查不需要任何外部真值,纯靠几何直觉,特别适合手头没有测量设备的场合。

4.3 数值精度与奇异构型

精度问题在 FK 里不像 IK 那么突出,但也不能忽视。几个常见的坑:

float32 不够用。单精度浮点的有效位数约 7 位十进制,对于一个臂展 1 米、要求 0.1 毫米精度的机械臂来说,1e-4 的相对精度刚好卡在边缘。连续乘六次矩阵之后误差会累积。全部用 float64,别省这点内存。

角度归一化。关节角累加久了可能到几万度,np.cos在这个量级上精度会掉。每次进 FK 之前先对 2π 取模,是廉价且有效的保险。

矩阵正交性漂移。连续连乘几十万次后,旋转矩阵的正交性会因为浮点误差缓慢退化。对单次 FK 来说影响可以忽略(每次都是从单位阵重新开始乘),但如果你的代码里做了增量更新(在上一帧基础上乘一个微分变换),那就要定期重新正交化,或者干脆每帧从头算。我倾向前者更稳妥:从头算,CPU 完全扛得住。

奇异构型。FK 本身在奇异构型下不会崩,因为它是正向映射,不存在除零。但奇异构型会让雅可比降秩,进而影响依赖雅可比的上层模块(速度控制、力控)。判断方法后面第 5 节讲。这里只提醒一句:不要在 FK 里加“奇异检测并报错”的逻辑,那是雅可比该干的事。FK 就该老老实实算位姿,职责单一。

5. 从能跑对到能用:标定、雅可比与工程落地

5.1 DH参数标定:纸面模型和实物差在哪

纸面 FK 算得再准,和实物之间通常也有几毫米的偏差。来源主要有四类:连杆加工公差(a 和 d 有偏差)、装配误差(关节轴线不平行,等价于 α 有偏差)、编码器零位误差(θ 有偏置)、减速器背隙和柔性变形(这部分不是静态参数,标定解决不了)。

标定的基本思路是把 DH 参数看成待优化变量,用外部测量设备(激光跟踪仪、视觉测量、或者简单的接触式标定板)采集一批“关节角 → 实际末端位姿”的数据对,然后最小化 FK 预测位姿与实测位姿之间的残差:

min Σ || f(θᵢ, params) - T_measured_i ||

优化变量是那 4n 个 DH 参数(实际只需要标定其中一部分,比如 a、d、α 和 θ 偏置,共 4n 个,但 θ 偏置和编码器零位是一回事,通常合并处理)。求解用非线性最小二乘,Levenberg-Marquardt 就够。

这里有个实操经验:参数不是越多越好标。六轴臂 24 个参数一起优化,很容易陷入局部极小,尤其是 α 和 θ 偏置之间存在耦合。我一般分两步走:先固定 α 只优化 a、d、θ 偏置,收敛后再放开 α 微调。另外,采样点要覆盖工作空间的不同区域,别全挤在一个角落,否则标出来的参数只在那一片准,换个区域又偏了。

还有一个容易忽略的点:标定用的测量点应该在工具法兰上,而不是 TCP 上。因为 TCP 本身的偏移也是待标定项,混在一起会互相干扰。先标法兰,再单独标 TCP,两步分开,逻辑清晰。

5.2 雅可比矩阵:FK的导数,也是速度控制的入口

雅可比矩阵 J 描述的是关节速度到末端速度的映射:v = J·q̇。它和 FK 的关系是导数关系——J 的第 i 列,就是第 i 个关节以单位角速度转动时,末端产生的线速度和角速度。

数值上可以这样求(简单粗暴但够用):

def numerical_jacobian(q, fk_func, eps=1e-6): T0, _ = fk_func(q) p0 = T0[:3, 3] J = np.zeros((6, len(q))) for i in range(len(q)): dq = q.copy() dq[i] += eps T1, _ = fk_func(dq) J[:3, i] = (T1[:3, 3] - p0) / eps # 角速度部分:用旋转矩阵的微分求,此处略 return J

这是数值差分法,精度取决于 eps 的选取,通常 1e-6 到 1e-8 之间。优点是通用——换个模型不用改代码;缺点是比解析法慢,而且在奇异点附近数值不稳定。工程上如果要跑实时速度控制,还是老老实实推导解析雅可比,或者用自动微分工具生成。

雅可比的实际价值在于奇异判断。六轴臂的雅可比是 6×6,当它降秩(行列式趋零,或最小奇异值趋零)时,机械臂处于奇异构型,某些方向动不了。常见的三个奇异位置:腕部奇异(第 4、6 轴共线)、肩部奇异(腕心落在第 1 轴轴线上)、肘部奇异(手臂完全伸直)。判断方法不是算行列式(数值上不可靠),而是做奇异值分解,看最小奇异值:

U, S, Vt = np.linalg.svd(J) if S[-1] < 1e-3: print("接近奇异,最小奇异值:", S[-1])

阈值取多少要看量纲。线速度和角速度混在一个矩阵里,量纲不一致,最小奇异值本身没有明确的物理意义。实用做法是对 J 做归一化,或者分别看平移部分和旋转部分的条件数。这块展开能写一整篇,这里只给结论:别用行列式判奇异,用条件数或最小奇异值,并且阈值要靠实测调。

5.3 实时性与嵌入式实现的取舍

最后聊聊工程落地。FK 跑在控制循环里,最关心的是周期稳定性和确定性。

在 PC 上用 numpy 完全没问题,六轴 FK 一次大概几微秒到几十微秒,1kHz 毫无压力。但要注意别在热路径里做动态分配——np.array每次调用都分配内存,如果循环频率高,会产生大量小对象。优化方式有几种:预先分配输出数组、用out=参数、或者干脆把矩阵展开成标量运算。我做过一个对比,把 4×4 矩阵乘法手写成 64 个标量乘法,在低端 ARM 上比调用通用矩阵库快 3 倍以上,因为省掉了循环开销和边界检查。

在 MCU 上跑,策略又不一样。资源紧张时,可以用定点数或者单精度加补偿;姿态表示可以用旋转向量(3 个数)代替矩阵(9 个数),省内存也省乘法。但要注意旋转向量的合成需要用到 Rodrigues 公式,计算量比矩阵乘法大,只有在存储受限时才划算。

还有一个实际经验:把 FK 和它的验证逻辑分开。生产代码里只保留最精简的计算路径,各种断言、打印、中间量保存都放到调试构建里,用编译开关控制。我见过有人在生产 FK 里留了一句if not np.isfinite(T).all(): raise,结果某个传感器偶发丢帧导致关节角变成 NaN,整个控制循环直接挂掉。正确做法是检测到异常后用上一帧的位姿顶替,同时上报故障码,让上层决定要不要停机,而不是在底层直接抛异常。

另外,如果 FK 的结果要跨进程或跨设备传输,建议序列化成(位置3 + 四元数4)共 7 个数,而不是 16 个数的矩阵。理由:省带宽、省反序列化开销、且四元数归一化之后不会有正交性漂移问题。接收方要用的时候再转回矩阵,一次转换的开销远小于传输 16 个数。

我在实际项目里最后悔的一件事,是早期没有给 FK 写单元测试,全靠“跑起来看看对不对”。结果有一次改了个参数表的读取顺序,把 α 和 a 两列读反了,直到两周后做抓取精度测试才发现,中间所有的调试都建立在一个错误的基础上。后来补了一套测试:2R 平面臂手算对拍、六轴零位真值对拍、单轴扫描连续性检查、以及一组随机角的雅可比数值验证。这四组测试跑一遍不到一秒,但拦住的 bug 比任何代码审查都多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进