☰
DH表转URDF:JavaScript实现坐标系转换与避坑指南
2026/10/24 17:42:46 网站建设 项目流程

简介:DH2URDF是一款面向机器人开发者与ROS用户的实用工具,核心功能是将Denavit-Hartenberg参数表自动转换为URDF统一机器人描述格式,并在对应坐标系上生成圆柱体轴模型,帮助用户快速搭建完整的机器人描述文件。压缩包共9个文件,大小约138KB,包含4个JavaScript脚本、1个HTML演示页面、1个URDF示例、1个litcoffee源码说明及文档等,其中脚本覆盖数据转换、数学运算、模板渲染与XML美化等功能,结构清晰,便于直接部署或二次开发。目前已有1049人学习下载。通过内置的转换脚本与blank_robot.urdf基础模板,配合可交互的网页演示,用户可以在浏览器中查看生成的URDF结构并调节各关节值,直观理解DH参数与URDF的对应关系。该工具适合需要从DH表出发构建机器人模型的初学者,也方便ROS开发者快速生成初始URDF骨架后再细化碰撞与惯性参数,整体实用性和可读性都很强。

1. DH表到URDF:绕不开的坐标系转换

手里有一张写好的DH参数表,想把它变成URDF,这事听起来就是“照着填数据”,实际操作却经常让人怀疑人生。DH表是一套极简的运动学速记,URDF想要的是另一套坐标系描述,中间隔着旋转顺序、单位、关节类型好几个坑。我见过不少开发者(包括当年的我)兴冲冲把DH表的四个参数抄进URDF,结果模型加载出来link乱飞、关节反转、甚至整个机械臂折成直角。这篇笔记用JavaScript写一个DH2URDF转换脚本,说清楚DH参数到底落在URDF的哪个字段,踩坑点在哪,怎么验证结果没做错。适合写机器人驱动、搭仿真模型、或者被运动学正解搞到头秃的开发者。

2. DH参数与URDF的映射逻辑:先搞清楚四个量去哪了

2.1 标准DH与改进DH:先分清你手里是哪张表

DH表不是只有一种。最常见的是标准DH(SDH)和改进DH(MDH)两套约定,它们对“坐标系挂在哪个关节上”的定义不一样。SDH把坐标系i固定在link i的远端,而MDH把坐标系i固定在link i的近端。同一个机械臂,用两套约定写出来的参数完全不一样,转URDF的结果也不一样。

我自己的习惯是拿到表先问一句:你这张表是哪本书、哪个讲义来的?如果对方说不上来,就看参数特征。SDH的变换顺序通常是先从上一坐标系沿x轴平移a、绕x轴转alpha,再沿当前z轴平移d、绕z轴转theta;MDH则是先绕z轴转theta、沿z轴平移d,再沿x轴平移a、绕x轴转alpha。这个顺序直接决定了你把参数填进URDF之前,构造变换矩阵时谁乘谁在前。

很多转换工具默认只支持SDH,你拿一张MDH表硬套,结果就是模型在仿真里反着转。所以代码里我一般会把变换顺序做成可配置的,至少留一个参数说明当前用的是哪种约定。不要觉得这是小事,我见过有人在MDH表上按SDH顺序算,折腾一晚上,最后只是把乘的顺序换了一下就对了。

2.2 URDF的joint origin与axis:theta为什么不写进origin

URDF里一个link的位姿是靠joint来表达的,joint下面有origin和axis两个关键字段。origin是child link坐标系在parent link坐标系里的固定变换,用xyz和rpy描述;axis是关节的运动轴,用[x,y,z]描述。这是理解DH转URDF的核心:DH里的a、alpha、d是固定几何量,而theta是关节变量(对旋转关节来说)。

很多人把theta也写进origin的rpy,这是不对的。如果theta是变量,URDF里的joint本身会动,你再把theta写死在origin里,等于动了两次。正确的做法是把theta=0时的固定姿态写进origin,关节运动交给axis和限位去表达。如果你的DH表里theta给的不是0(比如机械臂有一个初始关节角度),那就把这个初始角度当作零位偏置,转成rpy的一部分写进origin。

还有一个细节:URDF里origin的rpy是绕固定轴的旋转,也就是说先绕x轴转roll,再绕y轴转pitch,最后绕z轴转yaw。DH变换里绕x轴和绕z轴的两次旋转是复合关系,不能直接把alpha填进roll、把theta填进yaw了事。必须先把旋转矩阵算出来,再从矩阵里拆出rpy。

2.3 从DH变换矩阵到xyz与rpy:一个手算例子

之前的思路是:把DH的四个参数构造成齐次变换矩阵,提取平移向量做xyz,提取旋转子矩阵拆rpy。拿SDH的标准顺序举例,从父坐标系到子坐标系的变换矩阵是:

T = RotX(alpha) * TransX(a) * RotZ(theta) * TransZ(d)

展开后,平移部分的三维向量是:

x = a y = -d * sin(theta + alpha) z = d * cos(theta + alpha)

等等,这个公式有前提——它依赖教科书里“alpha、a、 d、theta”各自的正方向定义。不同教材对alpha的符号约定有细微差异,直接背公式容易翻车。我个人的习惯是:不背这个展开式,而是直接用矩阵乘法代码算,谁乘谁按你手里那本教材的约定来。矩阵是黑匣子,但至少它不会记错符号。

手算验证的时候拿单link去算:假设alpha=0、a=0.5米、d=0.2米、theta=0(rad),代入矩阵得到xyz=[0.5, 0, 0.2],旋转矩阵是单位矩阵,rpy=[0,0,0]。如果算出来的y方向有值,说明你的alpha或者d的符号反了。这个手算例子是我每次写完转换脚本之后必跑的测试用例,成本一分钟,能拦住后面一整晚的调试。

3. 用JavaScript实现DH2URDF:核心类与转换脚本

3.1 先把DH表建模成JavaScript数据结构

写转换脚本之前,先把DH表的结构定义清楚。我习惯把一行DH参数映射成一个对象,字段名直接用a、alpha、d、theta,外加几个URDF需要的元信息:joint名、parent link名、child link名、关节类型、限位。

// 一行DH参数 + URDF关节元信息 const dhRow = { a: 0.5, // 连杆长度,单位:米 alpha: Math.PI / 2, // 连杆扭转,单位:弧度 d: 0.2, // 连杆偏距,单位:米 theta: 0, // 关节角(零位值),单位:弧度 jointType: "revolute", // 关节类型:revolute / continuous / fixed / prismatic jointName: "joint2", parentLink: "link1", childLink: "link2", limitLower: -Math.PI, limitUpper: Math.PI, axis: [0, 0, 1] // 旋转轴,DH约定下通常绕z轴 };

逻辑说明:alpha和d是固定量,theta是变量但在DH表里通常给一个初始值,转换时把它当作零位姿态。a的单位必须统一成米,URDF不认毫米。轴的方向默认是[0,0,1],因为标准DH约定旋转绕当前z轴。如果你手里的DH表用的是改进DH,这个axis可能需要调整。

参数说明:jointType为“prismatic”时,DH里的d是变量、theta是常数,转换逻辑要反过来处理,axis仍为[0,0,1]但运动是平移。这一步容易搞混,建议在代码里分开处理平移关节和旋转关节。

3.2 旋转矩阵转RPY:先算矩阵再拆角

URDF写rpy时用的是固定轴X-Y-Z顺序,也就是先绕固定x轴转、再绕固定y轴转、最后绕固定z轴转。不要试图用欧拉角直接拼,正确做法是把旋转矩阵算出来,然后用下面的函数拆成rpy。

function rpyFromMatrix(R) { // R 是 3x3 旋转矩阵,按行存储 const pitch = Math.atan2(-R[2][0], Math.sqrt(R[0][0] ** 2 + R[1][0] ** 2)); if (Math.abs(pitch) > Math.PI / 2 - 1e-6) { // 万向锁:直接取 roll=0,yaw 从矩阵里算 const yaw = Math.atan2(-R[0][1], R[1][1]); return [0, pitch, yaw]; } const roll = Math.atan2(R[2][1], R[2][2]); const yaw = Math.atan2(R[1][0], R[0][0]); return [roll, pitch, yaw]; }

逻辑说明:先算pitch,因为固定轴RPY顺序下pitch等于绕y轴的转角,可以用R[2][0]直接求。再判断是不是接近90度,接近时会丢一个自由度,这是欧拉角的通病。最后分别用atan2提取roll和yaw。注意JavaScript里Math.atan2(y, x)的第一个参数是y。

参数说明:这个函数不关心DH定义,只关心URDF的rpy约定。任何来源的旋转矩阵,只要你想写进URDF,都可以走这个函数。我在转换脚本里所有关节的rpy都经过它,不在任何地方手动拼角度字符串。格式化输出时记得把弧度转成小数,URDF可以直接写小数,但保留六位小数比较稳妥。

3.3 完整转换脚本:DH表输入,URDF字符串输出

下面把完整流程串起来。输入是一个DH表数组,输出是一段URDF的link和joint定义字符串。这里用齐次矩阵乘法构造变换,避免手推公式出错。

function rotX(a) { return [[1,0,0,0],[0,Math.cos(a),-Math.sin(a),0],[0,Math.sin(a),Math.cos(a),0],[0,0,0,1]]; } function rotZ(t) { return [[Math.cos(t),-Math.sin(t),0,0],[Math.sin(t),Math.cos(t),0,0],[0,0,1,0],[0,0,0,1]]; } function transX(x) { return [[1,0,0,x],[0,1,0,0],[0,0,1,0],[0,0,0,1]]; } function transZ(z) { return [[1,0,0,0],[0,1,0,0],[0,0,1,z],[0,0,0,1]]; } function matMul(A, B) { const C = Array.from({length: 4}, () => new Array(4).fill(0)); for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) C[i][j] += A[i][k] * B[k][j]; return C; } function extractPose(T) { const xyz = [T[0][3], T[1][3], T[2][3]]; const R = [T[0].slice(0,3), T[1].slice(0,3), T[2].slice(0,3)]; const rpy = rpyFromMatrix(R); return { xyz, rpy }; } function dhRowToUrdf(row, prevT) { // 按SDH顺序构造变换:RotX(alpha) → TransX(a) → RotZ(theta) → TransZ(d) let T = matMul(rotX(row.alpha), transX(row.a)); T = matMul(T, rotZ(row.theta)); T = matMul(T, transZ(row.d)); const { xyz, rpy } = extractPose(matMul(prevT, T)); // 拼出 joint 的 origin 字符串 const originStr = `xyz="${xyz.map(n => n.toFixed(6)).join(" ")}" rpy="${rpy.map(n => n.toFixed(6)).join(" ")}"`; return { T, originStr }; }

逻辑说明:prevT是父坐标系累积变换。对每一个joint,先计算当前DH段相对父坐标系的变换T,再乘上prevT得到全局位姿,这样URDF里每个joint的origin都直接写成相对父link的位姿。注意由于URDF天然是树状结构,这里的prevT其实只需要父link自己的origin,不需要从基座一直累积。但累积计算有一个额外的好处:能顺便输出每个link的全局位姿,用来对运动学正解做校验。

参数说明:打印时用toFixed(6)避免浮点误差带来的一长串小数。如果你的DH表用了MDH顺序,把dhRowToUrdf里四行matMul的调用顺序换成RotZ(theta) → TransZ(d) → TransX(a) → RotX(alpha)即可。这个脚本只生成joint片段,link的视觉和碰撞模型建议单独写,不要从DH表自动生成。

4. 避坑:DH表转URDF时最容易翻车的细节

4.1 方向反了:alpha正负号与右手定则

现象:生成的URDF在仿真里加载成功,但转动第一个关节,后面的link朝相反方向偏转,或者明明该往上摆的连杆往下沉。

原因:DH表里的alpha是绕x轴的旋转,正方向必须按右手定则判定。画图时很容易把“从y轴转向z轴”的方向看反。另外不少教材的DH参数截图里,alpha标的是锐角,没有标注正负号,抄表时直接把负号丢了。

解决:转换脚本跑完之后,拿单link做手算验证。alpha=0、a=0.5、d=0.2时,xyz必然是[0.5, 0, 0.2]。如果y方向不是0,先检查alpha,再检查d的符号。从那以后我每次转换都先跑这段单link用例。

4.2 角度乱了:URDF的rpy是固定轴旋转

现象:URDF里写rpy="0 1.5707 0",模型加载出来link的姿态跟预期完全对不上,旋转轴明显不对。

原因:rpyFromMatrix拆出来的roll/pitch/yaw是固定轴X-Y-Z外旋的表示,不是数学课上学的那种欧拉角内旋。直接用DH里alpha当roll、theta当yaw拼进rpy,等于把两次旋转的复合写成了两次独立旋转,结果必然错。

解决:所有rpy一律从旋转矩阵拆,禁止手工拼接。我在rpyFromMatrix函数里加了万向锁分支,避免pitch接近正负90度时出现NaN。如果拆出来的rpy数值特别怪异,先打印旋转矩阵看看正交性是否成立。

4.3 单位炸了:毫米当成米

现象:URDF加载后整个模型尺度大得离谱,或者碰撞检测时零件之间隔着一大截空气。

原因:机械臂厂家给的DH表最常见单位是毫米,而URDF里长度单位强制要求米。有人把a=500直接填进去,模型瞬间变成500米高。

解决:转换脚本入口处统一做单位换算,所有数值乘0.001。我给dhRow加了一个units字段,默认"m",填"mm"就自动换算。URDF里不要出现单位后缀,xyz的值就是纯小数。

4.4 关节瘫了:joint类型和限位没配对

现象:模型加载正常,但某个关节在仿真里完全转不动,或者转起来停不下来。

原因:把本应是revolute的关节写成了fixed;或者写了revolute但limit里面lower和upper一致,等于焊死;又或者用了continuous类型的关节,仿真里PID控制时它一直朝一个方向转下去。

解决:DH表里theta是变量就是revolute,theta固定就是fixed。连续旋转关节只适合理论上无限旋转的轴,大多数工业机械臂不是,应该用revolute加限位。限位的上下值先用DH表里的机械限位,没有就按正负pi。

4.5 父子反了:模型折叠和坐标系倒挂

现象:URDF看起来每个joint都对,但整体结构像折纸一样叠在一起,link之间互相穿透。

原因:URDF里joint的parent必须靠近基座,child必须远离基座。DH表描述的是坐标系i到坐标系i+1的变换,有人把parent和child写反了,变换方向反了,模型自然折叠。

解决:生成joint时强制约定parentLink是当前行对应的父link,childLink是下一行对应的子link。六轴机械臂从基座往上数,joint1的parent是base_link。检查方法:把每个joint的origin打印出来,xyz的z值应该随着往末端走逐渐增大,如果出现负增长,大概率父子关系反了。

5. 进阶:让转换脚本真正落地到你的机器人工作流

5.1 批量生成多关节机械臂的URDF

单个joint的转换跑通后,把整个DH表循环一遍就能生成完整机械臂。这里有一个通用做法:把link的视觉模型和碰撞模型单独维护,不要跟DH表混在一起。

function dhTableToUrdf(dhTable) { let prevT = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]; let out = ""; for (const row of dhTable) { const { T, originStr } = dhRowToUrdf(row, prevT); out += `<joint name="${row.jointName}" type="${row.jointType}">\n`; out += ` <origin ${originStr}/>\n`; out += ` <parent link="${row.parentLink}"/>\n`; out += ` <child link="${row.childLink}"/>\n`; out += ` <axis xyz="${row.axis.join(" ")}"/>\n`; if (row.jointType === "revolute" || row.jointType === "prismatic") { out += ` <limit lower="${row.limitLower}" upper="${row.limitUpper}" effort="10" velocity="1"/>\n`; } out += `</joint>\n`; prevT = T; } return out; }

逻辑说明:代码里prevT每循环一次就更新,这样即便DH表不是严格的链式结构,只要你能保证遍历顺序是从基座到末端,输出就正确。对于树状结构的机器人,也就是一个父link下面挂多个子link的情况,不能简单用这个循环,需要先对DH表做拓扑排序。

5.2 转换后的验证三步走

脚本写完别急着拿去用,先跑三遍验证。第一步是手算单link的xyz和rpy,比对代码输出。第二步是把生成的URDF加载进仿真器,逐个关节手动转动,确认每个link的运动方向跟直觉一致。第三步是拿运动学正解对一下:把每个关节设成特定角度,用正解算末端位置,再跟你在仿真里读到的末端位姿对比,误差应该在毫米以内。

这三步看起来废话,实际救了我很多次。有一次第二步就发现第三个关节方向反了,回去查表发现alpha少了个负号;还有一次第三步发现末端坐标差了好几毫米,最后定位到是在拼接URDF时把两个link的origin重复写了一遍。

从那以后,我每次转换DH表都强制自己按这三步走一遍,尤其是第一步单link手算,成本不到一分钟,却能挡住绝大多数低级错误。希望帮到你,也欢迎你带着项目里具体的DH表来交流。

本文还有配套的精品资源,点击获取

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

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

立即咨询