简介:这份面向JavaScript与前端AI开发者的TensorFlow.js实践包,围绕6轴机械臂的强化学习控制展开,适合希望把三维空间目标点求解、奖励函数设计与浏览器端模型训练串起来的学习者,对强化学习入门者、机器人控制爱好者及前端AI实验人群较友好。包内共11个文件,以html与js承载2D、3D测试页面和交互脚本,md与json补充说明及依赖配置,glb提供机械臂骨骼模型,css负责页面样式,压缩包约1.88MB。已有1262人学习。内容从10×10二维网格寻路起步,扩展到10×10×10三维空间,通过逐步奖励缩短当前位置与目标点距离,让模型学习多轴旋转策略;读者可据此理解状态表示、奖励设计、训练循环与可视化调试的组织方式,并借助现成页面和模型资源复现实验、改造地图规模或替换机械臂模型。目录将二维与三维试验分开,便于按阶段阅读和比较,形成可继续迭代的强化学习小项目。
1. 从浏览器里的机械臂说起:tensorflow-robot-arm 到底测什么
tensorflow-robot-arm 这个资源的核心,是把 6 轴机器人手臂的强化学习测试放进 JavaScript 技术栈里跑:关节角、末端位姿、目标点和奖励函数都在一个可读的 JS 环境里,策略网络用 TensorFlow.js 训练或加载。它适合两类人:一类是想验证奖励设计和动作空间,但不想先搭一套重型仿真的人;另一类是做机器人上位机、教学演示或算法回归测试的开发者。它不是工业级控制器,别指望直接上产线;它的价值在于把“状态怎么定义、动作怎么限幅、策略怎么收敛”这些前置问题快速跑通。你拿到后可以先在 Node 端训练小规模策略,再把模型丢到浏览器里做回放和可视化,整条链路不用换语言。
2. 把 6 轴运动学拆成 JavaScript 能算的状态:环境建模与动作空间
2.1 为什么用 TensorFlow.js 而不是直接上 Python 训练栈
这个项目关键词是 JavaScript,不是随便选的。6 轴机械臂强化学习测试里,环境步进、奖励计算、模型推理和可视化如果分属两种语言,调试时最痛苦的是状态对不上:Python 端算出的关节角顺序、弧度单位、归一化参数,和前端拿到的数组经常差一位。用 TensorFlow.js 的好处是环境、策略、日志、回放都在同一套 JS 数据结构和调试链路里;Node 端跑训练,浏览器端用同一份环境代码做可视化,排查问题时不用来回翻译。
缺点也要说清楚。JS 没有 Python 生态里那么多现成 RL 库,训练吞吐低,复杂算法要自己写;所以它适合中小规模状态空间、低维连续动作和验证型任务,不适合大规模并行采样。常见做法是先在 Node 端用@tensorflow/tfjs-node跑通,再决定是否迁移到更重的训练栈。下面这段初始化命令没有版本号,装的是当前环境可用的包:
mkdir robot-arm-rl && cd robot-arm-rl npm init -y npm i @tensorflow/tfjs-node@tensorflow/tfjs-node在 Node 环境提供原生后端,适合训练循环;如果只在浏览器里跑推理和回放,换成@tensorflow/tfjs即可。要注意 Windows 上原生依赖可能编译失败,退回纯 JS 后端或放到 Linux 子系统里跑,别一上来就怀疑模型有问题。
提示:先把环境代码写成不依赖 TensorFlow 的纯 JavaScript 类,训练脚本再引用它。这样换后端时环境不用重写。
2.2 六轴角度、末端位姿与奖励函数怎么落到代码里
状态设计不要一上来塞 6 个绝对角度就完事。六轴机械臂的关节角有周期性和限位,直接给网络绝对弧度,靠近 ±π 时会出现跳变。我一般会把关节角除以该关节最大绝对值做归一化,再拼接末端位置和目标位置。正运动学用简化 DH 参数算,下面这组参数只是模拟项目X的示例,不要直接映射到真实机械臂:
| 关节 i | a(m) | alpha(rad) | d(m) | offset(rad) |
|---|---|---|---|---|
| 1 | 0.12 | -1.5708 | 0.08 | 0 |
| 2 | 0.18 | 0 | 0.02 | 0 |
| 3 | 0.16 | 1.5708 | 0.04 | 0 |
| 4 | 0.04 | -1.5708 | 0.10 | 0 |
| 5 | 0.03 | 1.5708 | 0.02 | 0 |
| 6 | 0.02 | 0 | 0.06 | 0 |
代码里先实现 4×4 矩阵乘和 DH 变换,再串起 6 个关节。这里不追求工业级精度,只要求输入输出顺序稳定:
// 四阶矩阵乘:用于串联 DH 变换 function matMul(A, B) { return A.map((row, i) => B[0].map((_, j) => row.reduce((sum, v, k) => sum + v * B[k][j], 0) )); } // 单个 DH 变换:theta 为关节角加零位偏置 function dhTransform(a, alpha, d, theta) { const ct = Math.cos(theta), st = Math.sin(theta); const ca = Math.cos(alpha), sa = Math.sin(alpha); return [ [ct, -st * ca, st * sa, a * ct], [st, ct * ca, -ct * sa, a * st], [0, sa, ca, d], [0, 0, 0, 1] ]; } // 6 轴正运动学:q 是 6 个弧度值,dh 是上面的参数数组 function forwardKinematics(q, dh) { let T = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]; for (let i = 0; i < 6; i++) { const { a, alpha, d, offset } = dh[i]; T = matMul(T, dhTransform(a, alpha, d, q[i] + offset)); } return { x: T[0][3], y: T[1][3], z: T[2][3] }; } function distance(p, q) { return Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z); }q用弧度,dh的offset负责对齐零位;alpha是相邻关节轴的扭角,d是连杆偏距。调试时先让q全零,看末端位置是否符合预期,再逐个关节加 0.1 弧度,确认运动方向没反。方向反了不一定是算法问题,常见是 DH 表的alpha符号或零位偏置写错。
奖励函数比网络结构更容易翻车。距离奖励不要只用最终距离,否则稀疏得很难学;用“靠近目标的进度”做塑形奖励,再给动作代价和碰撞粗筛:
// prev/next 是末端位置,target 是目标点,action 是 6 维关节增量 function reward(prev, next, target, action) { const distPrev = distance(prev, target); const distNext = distance(next, target); const progress = distPrev - distNext; // 靠近目标为正 const actionCost = 0.01 * action.reduce((s, v) => s + v * v, 0); const energyCost = 0.001 * Math.abs(action.reduce((s, v) => s + v, 0)); const collisionPenalty = next.z < 0.02 ? -1 : 0; // 粗筛:末端低于台面 const success = distNext < 0.02 ? 5 : 0; return progress * 10 - actionCost - energyCost + collisionPenalty + success; }progress * 10把靠近目标的收益放大;actionCost的 0.01 抑制抖动,太大策略会变懒;collisionPenalty只做粗筛,不要当成真实碰撞检测;success阈值 0.02 是模拟尺度,换真实机械臂要按工作空间改。奖励尺度要统一,否则 critic loss 会爆。
2.3 用 JavaScript 写一个可复现的 Gym 风格环境
环境接口保持reset、step、getState三件套,方便后面换算法。动作用 6 维关节增量,不用绝对角;每步裁剪到 ±0.05 弧度,太小训练慢,太大直接飞车。下面这段可以直接放进项目里跑:
class RobotArmEnv { constructor(dh, jointLimits, target) { this.dh = dh; this.jointLimits = jointLimits; this.target = target; this.q = new Array(6).fill(0); } reset() { // 只随机初始 20% 范围,避免一上来就撞限位 this.q = this.q.map((_, i) => { const [lo, hi] = this.jointLimits[i]; return lo + Math.random() * (hi - lo) * 0.2; }); return this.getState(); } getState() { const ee = forwardKinematics(this.q, this.dh); // 关节角按最大绝对值归一化,再拼末端位置和目标位置 return [ ...this.q.map((v, i) => v / Math.max(Math.abs(this.jointLimits[i][0]), Math.abs(this.jointLimits[i][1])) ), ee.x, ee.y, ee.z, this.target.x, this.target.y, this.target.z ]; } step(action) { const prev = forwardKinematics(this.q, this.dh); const clipped = action.map((a, i) => { const delta = Math.max(-0.05, Math.min(0.05, a)); // 单步最大增量 const [lo, hi] = this.jointLimits[i]; return Math.max(lo, Math.min(hi, this.q[i] + delta)); }); this.q = clipped; const next = forwardKinematics(this.q, this.dh); const r = reward(prev, next, this.target, action); const dist = distance(next, this.target); const done = dist < 0.02; return { state: this.getState(), reward: r, done }; } }reset只随机 20% 范围,是为了让随机策略有机会靠近目标;getState里归一化能减少网络输入尺度差异;step先裁剪动作再裁剪关节限位,顺序不要反。参数0.05是每步最大关节增量,换真实机械臂要把它降得更小,再乘安全系数。
3. 强化学习训练回路:从随机策略到 PPO/DQN 的取舍
3.1 离散动作还是连续控制:6 轴机械臂的动作空间选型
6 轴同时输出连续增量,动作维度是 6。如果每个关节离散成 -1、0、+1 三档,动作组合是 3^6=729 维,DQN 输出层能接,但样本效率低,探索也难。离散化的好处是代码短、调试直观,适合先验证奖励函数和状态设计;连续控制更贴近机械臂实际使用,PPO、DDPG 这类算法输出高斯策略的均值和方差,动作范围更细。
我一般会先用离散入口跑通 200 到 500 个回合,确认随机策略有少量成功、奖励不是全负,再换连续控制。下面这段把离散动作索引解码成 6 维关节增量:
const ACTION_LEVELS = [-1, 0, 1]; // 输入 0~728 的整数,输出 6 维弧度增量 function decodeDiscrete(actionIndex) { const action = new Array(6); let idx = actionIndex; for (let i = 5; i >= 0; i--) { action[i] = ACTION_LEVELS[idx % 3]; idx = Math.floor(idx / 3); } return action.map(v => v * 0.05); // 映射到最大关节增量 }actionIndex是 DQN 的输出动作编号;循环从后往前拆,保证第 1 关节在数组第 0 位;乘0.05和环境的步长保持一致。离散化跑通后,如果成功率和轨迹平滑度不够,再换连续策略,不要一开始就上复杂算法。
3.2 训练循环、经验回放与模型保存的 JavaScript 实现
经验回放用普通数组存状态、动作、奖励、下一状态和 done,不要存 tensor。TensorFlow.js 的 tensor 需要手动释放,回放池里存 tensor 很容易把内存吃满。下面是一个最小回放池:
class ReplayBuffer { constructor(capacity) { this.capacity = capacity; this.data = []; this.pos = 0; } push(s, a, r, ns, done) { const item = { s, a, r, ns, done }; if (this.data.length < this.capacity) this.data.push(item); else this.data[this.pos] = item; this.pos = (this.pos + 1) % this.capacity; } sample(batchSize) { const batch = []; for (let i = 0; i < batchSize; i++) { batch.push(this.data[Math.floor(Math.random() * this.data.length)]); } return batch; } }capacity按内存定,JS 里 5 万条以上就要观察内存;push用循环覆盖,避免数组无限增长;sample没有做优先级,适合先跑通。下面是一个简化的 actor-critic 训练步骤,重点看 tensor 创建和释放顺序:
async function trainActorCritic(actor, critic, actorOpt, criticOpt, batch, gamma = 0.99) { const states = tf.tensor2d(batch.map(x => x.s)); const actions = tf.tensor2d(batch.map(x => x.a)); const rewards = tf.tensor1d(batch.map(x => x.r)); const nextStates = tf.tensor2d(batch.map(x => x.ns)); const dones = tf.tensor1d(batch.map(x => x.done ? 0 : 1)); const nextValues = critic.predict(nextStates).squeeze(); const targets = rewards.add(nextValues.mul(dones).mul(gamma)); const values = critic.predict(states).squeeze(); const criticLoss = tf.losses.meanSquaredError(targets, values); // 简化版优势:target - value,stopGradient 防止 actor 更新 critic const advantages = targets.sub(values).stopGradient(); const mu = actor.predict(states); const actorLoss = mu.sub(actions).square().mean().mul(advantages.mean().abs()); const criticGrads = criticOpt.computeGradients(criticLoss, critic.trainableWeights.map(w => w.val)); criticOpt.applyGradients(criticGrads); const actorGrads = actorOpt.computeGradients(actorLoss, actor.trainableWeights.map(w => w.val)); actorOpt.applyGradients(actorGrads); [states, actions, rewards, nextStates, dones, nextValues, targets, values, advantages, mu] .forEach(t => t.dispose()); criticGrads.dispose(); actorGrads.dispose(); }gamma是折扣因子,0.99 适合回合不长的情况;dones把终止状态的价值截断;advantages用 stopGradient 防止策略梯度污染价值网络。这段是教学级示意,不是完整 PPO,正式训练还要加 clip、熵奖励和旧策略概率。模型保存分环境:
// Node 端保存到文件系统 await actor.save('file://./models/actor'); await critic.save('file://./models/critic'); // 浏览器端保存到 IndexedDB // await actor.save('indexeddb://robot-arm-actor');Node 端用file://,浏览器端用indexeddb://;保存时把状态归一化参数、关节限位和 DH 参数一起写成metadata.json,否则换机器加载后输出全错。这个坑在后面会展开。
3.3 用 TensorBoard 或自定义日志看收敛:指标怎么读
JS 环境里 TensorBoard 不如 Python 顺手,通常先用 CSV 日志。不要只看 episode reward,reward 震荡是正常的,关键看成功率、平均距离和动作幅度。下面这些指标够判断训练有没有跑偏:
| 指标 | 计算方式 | 观察重点 |
|---|---|---|
| episode reward | 每回合奖励累加 | 长期不涨先查奖励尺度和随机基线 |
| success rate | 成功回合/总回合 | 比 reward 更直观,固定种子下应稳定 |
| average distance | 回合结束末端到目标距离 | 突然变大可能是策略绕路或动作限幅变了 |
| action norm | 每步动作平方和均值 | 突然增大说明策略发散或奖励动作代价太小 |
| critic loss | 价值网络均方误差 | 爆炸时优先检查奖励是否过大 |
const fs = require('fs'); function logEpisode(ep, reward, success, dist, actionNorm) { const line = `${ep},${reward.toFixed(4)},${success},${dist.toFixed(4)},${actionNorm.toFixed(4)}\n`; fs.appendFileSync('training-log.csv', line); }ep是回合编号,success用 0/1 记录,dist用米或归一化单位都行,但整条链路要统一;actionNorm是动作平方和均值,用来观察抖动。日志每回合写一次,不要每步写,否则文件会很大。
4. 把训练结果跑回机械臂:推理、可视化和安全边界
4.1 从模型输出到关节指令:限幅、平滑与碰撞粗筛
模型输出不是关节角,是增量。推理后先做一阶低通平滑,再裁剪到安全范围,最后交给环境步进。下面这段是推理包装:
async function act(model, state, prevAction, alpha = 0.2) { const input = tf.tensor2d([state]); const output = model.predict(input); const raw = Array.from(await output.data()); input.dispose(); output.dispose(); // 一阶低通:alpha 越小越稳,但响应越慢 const smoothed = raw.map((v, i) => alpha * v + (1 - alpha) * (prevAction[i] || 0)); return smoothed.map(v => Math.max(-0.05, Math.min(0.05, v))); }alpha=0.2是常用起点,实物第一次跑可以降到 0.1;0.05是单步最大增量,必须和训练时一致。碰撞粗筛不要塞进模型,放在环境step里:末端 z 低于台面、关节角接近软限位、动作变化率超过阈值,都直接给惩罚或终止。模型只负责输出,安全边界由外层代码兜底。
4.2 浏览器端 Three.js 可视化和 Node.js 端推理的差异
浏览器端用requestAnimationFrame驱动,推理频率受帧率影响;Node 端用固定循环或setInterval,适合批量评估。TensorFlow.js 后端不同,同一个模型输出可能有微小数值差异,通常不影响动作,但评估时要固定后端。加载模型的方式也不一样:
// 浏览器端:先设后端,再从 IndexedDB 加载 tf.setBackend('webgl'); const model = await tf.loadLayersModel('indexeddb://robot-arm-actor'); // Node 端:从文件系统加载,路径要包含 model.json // const model = await tf.loadLayersModel('file://./models/actor/model.json');tf.setBackend('webgl')适合浏览器可视化;Node 端tfjs-node后端名通常是tensorflow。路径不要写成目录,loadLayersModel要的是model.json。浏览器不能直接读本地file://,要么走 HTTP 静态服务,要么用 IndexedDB。混用保存格式和加载格式是常见翻车点。
4.3 用回放轨迹验证策略:成功率、超时与抖动统计
训练完不要只看一条轨迹。固定目标点、固定初始状态,跑 50 到 100 回合,统计成功率、平均步数和动作抖动。下面这段评估代码可以直接改:
async function evaluate(env, model, episodes = 50) { let success = 0, totalSteps = 0, jerk = 0; for (let e = 0; e < episodes; e++) { let state = env.reset(); let prevAction = new Array(6).fill(0); for (let t = 0; t < 200; t++) { const action = await act(model, state, prevAction); const out = env.step(action); jerk += action.reduce((s, v, i) => s + (v - prevAction[i]) ** 2, 0); prevAction = action; state = out.state; if (out.done) { success++; totalSteps += t + 1; break; } } } return { successRate: success / episodes, avgSteps: success ? totalSteps / success : 0, jerk: jerk / episodes }; }episodes是评估回合数,50 起步;200是最大步数,按任务改;jerk是相邻动作差的平方和,越大越抖。评估时关掉探索噪声,固定随机种子。成功率突然掉、平均步数突然涨、jerk 变大,三者同时出现基本就是策略退化或环境参数被改过。
5. 避坑排查:六个轴的项目最容易翻车的五件事
这几个坑我踩过不止一次,有些是玄学,有些是纯工程疏忽。下面每条按“现象 → 原因 → 解决”拆开,拿到资源后可以先对照检查。
5.1 现象:训练奖励一直不涨
现象:跑了上千回合,episode reward 在 -200 到 -180 之间抖,success rate 始终为 0。
原因:奖励尺度不对,距离奖励太小、碰撞惩罚太大;或者状态没归一化,网络输入里关节角是 ±3.14,末端位置是 0.1 量级,梯度被大数吃掉;也可能是动作空间太大,随机探索根本到不了目标。
解决
本文还有配套的精品资源,点击获取