Compare commits

..

6 Commits

Author SHA1 Message Date
Martino Russi dfb47b91a5 Merge branch 'main' into mrussi/glove_visualizer 2026-07-05 17:35:05 +02:00
Steven Palma 7957d4e2dc chore(docs): update readme + gr00t libero results (#3941)
* chore(docs): update readme + gr00t libero results

* chore(docs): update template and in-tree policy steps
2026-07-05 15:11:46 +02:00
pre-commit-ci[bot] 4c17a7be2b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-09-01 09:48:07 +00:00
nepyope c8c37bd339 added visualizer 2025-09-01 11:47:45 +02:00
pre-commit-ci[bot] 0ccc08e347 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-08-12 12:16:07 +00:00
nepyope 7b207d44a0 added glove visualizer 2025-08-12 14:12:13 +02:00
20 changed files with 1751 additions and 1142 deletions
+8 -8
View File
@@ -87,7 +87,7 @@ Learn more about it in the [LeRobotDataset Documentation](https://huggingface.co
## SoTA Models
LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, and Vision-Language-Action (VLA) models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, Vision-Language-Action (VLA) models, World Models, and Reward Models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
<p align="center">
<img alt="Gr00t Architecture" src="./media/readme/VLA_architecture.jpg" width="640px">
@@ -101,13 +101,13 @@ lerobot-train \
--dataset.repo_id=lerobot/aloha_mobile_cabinet
```
| Category | Models |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.7](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx) |
| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx) (more coming soon) |
| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
| Category | Models |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.7](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx), [EVO1](./docs/source/evo1.mdx) |
| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx), [LingBot-VA](./docs/source/lingbot_va.mdx), [FastWAM](./docs/source/fastwam.mdx) |
| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
Similarly to the hardware, you can easily implement your own policy & leverage LeRobot's data collection, training, and visualization tools, and share your model to the HF Hub
+4 -1
View File
@@ -295,11 +295,12 @@ The file names are load-bearing: the factory does lazy imports by name, and the
### Wiring
Three places need to know about your policy. All by name.
Four places need to know about your policy. All by name.
1. **`policies/__init__.py`** — re-export `MyPolicyConfig` and add it to `__all__`. **Don't** re-export the modeling class; it loads lazily through the factory (so `import lerobot` stays fast).
2. **`factory.py:get_policy_class`** — add a branch returning `MyPolicy` from a lazy import.
3. **`factory.py:make_policy_config`** and **`factory.py:make_pre_post_processors`** — same idea, two more branches.
4. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what `push_model_to_hub` renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
Mirror an existing policy that's structurally similar to yours; the diff is small.
@@ -371,6 +372,8 @@ The general expectations are in [`CONTRIBUTING.md`](https://github.com/huggingfa
- [ ] Optional deps live behind a `[project.optional-dependencies]` extra and the `TYPE_CHECKING + require_package` guard.
- [ ] `tests/policies/` updated; backward-compat artifact committed & policy-specific tests.
- [ ] `src/lerobot/policies/<name>/README.md` symlinked into `docs/source/policy_<name>_README.md`; user-facing `docs/source/<name>.mdx` written and added to `_toctree.yml`.
- [ ] `templates/lerobot_modelcard_template.md` has a description entry and a `policy_docs` link for your policy.
- [ ] The models table in the root `README.md` lists your policy in the right category, linking to your doc page.
- [ ] At least one reproducible benchmark eval in the policy MDX with a published checkpoint (sim benchmark, or real-robot dataset + checkpoint).
The fastest way to get a clean PR is to copy the directory of the existing policy closest to yours, rename, and replace contents method by method. Don't wait until everything is polished — open a draft PR early and iterate with us; reviewers would much rather give feedback on a half-finished branch than a fully-merged one.
+7 -7
View File
@@ -160,13 +160,13 @@ This will follow the recipe found [here](https://github.com/NVIDIA/Isaac-GR00T/b
Preliminary LeRobot integration results (GR00T-LeRobot, `eval.n_episodes >= 50` per suite):
| Suite | Success rate |
| ---------------- | -----------: |
| LIBERO Spatial | 94% |
| LIBERO Object | 98% |
| LIBERO Goal | 93% |
| LIBERO 10 (Long) | 90% |
| **Average** | **93.75%** |
| Suite | Success rate | Checkpoint |
| ---------------- | -----------: | ------------------------------------------------------------------------------------------------------------- |
| LIBERO Spatial | 91% | [nvidia/gr00t17-lerobot-libero_spatial-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_spatial-640) |
| LIBERO Object | 81% | [nvidia/gr00t17-lerobot-libero_object-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_object-640) |
| LIBERO Goal | 97% | [nvidia/gr00t17-lerobot-libero_goal-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_goal-640) |
| LIBERO 10 (Long) | 84% | [nvidia/gr00t17-lerobot-libero_10-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_10-640) |
| **Average** | **88.25%** | |
```bash
export MODEL_ID=your_trained_model_on_huggingface
+8
View File
@@ -117,6 +117,14 @@ middle_dip | 1484 | 1500 | 1547
Once calibration is complete, the system will save the calibration to `/Users/your_username/.cache/huggingface/lerobot/calibration/teleoperators/homunculus_glove/red.json`
#### Visualizing Teleoperator Glove
After calibration, you can visualize the glove movements in real-time. Open the visualizer by navigating to the visualizer directory and opening the HTML file in your browser:
```bash
open examples/hopejr/visualizer/index.html
```
### 1.3 Calibrate Robot Arm
```bash
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Hand Joint Visualizer</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
.controls {
padding: 15px;
background-color: #f5f5f5;
z-index: 100;
}
.status {
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
#canvas-container {
flex: 3;
position: relative;
}
#sidebar {
flex: 1;
padding: 15px;
background-color: #f8f9fa;
overflow-y: auto;
max-width: 300px;
border-left: 1px solid #ddd;
}
.joint-info {
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.joint-name {
font-weight: bold;
}
.joint-value {
font-family: monospace;
}
.bar-container {
width: 100%;
background-color: #e0e0e0;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-top: 5px;
}
.bar {
height: 100%;
background-color: #4CAF50;
width: 0%;
transition: width 0.2s ease-in-out;
}
.log-container {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 150px;
overflow-y: auto;
font-family: monospace;
background-color: #f8f9fa;
}
.view-controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
}
.view-button {
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 5px 10px;
margin-right: 5px;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="controls">
<button id="connectButton">Connect to Device</button>
<button id="disconnectButton" disabled>Disconnect</button>
<select id="baudRate">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
</select>
<span id="statusIndicator" class="status disconnected">Status: Disconnected</span>
</div>
<div class="container">
<div id="canvas-container">
<!-- 3D canvas will be inserted here -->
<div class="view-controls">
<button class="view-button" id="frontView">Front</button>
<button class="view-button" id="sideView">Side</button>
<button class="view-button" id="topView">Top</button>
<button class="view-button" id="resetView">Reset</button>
</div>
</div>
<div id="sidebar">
<h3>Joint Values</h3>
<div id="jointsContainer">
<!-- Joint info will be added here -->
</div>
<div class="log-container" id="logContainer">
<!-- Log messages will be added here -->
</div>
</div>
</div>
<!-- Import Three.js -->
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
<script src="script.js"></script>
</body>
</html>
+669
View File
@@ -0,0 +1,669 @@
// === Hand Visualizer with Pre-Connect Sliders + Per-Joint Angle Limits ===
// Assumes your HTML already has elements with the following IDs:
// connectButton, disconnectButton, baudRate, statusIndicator, jointsContainer, logContainer,
// canvas-container, frontView, sideView, topView, resetView
// Requires Three.js + OrbitControls loaded on the page.
// -------------------- Config --------------------
const MAX_JOINTS = 16;
const RAW_MIN = 0, RAW_MAX = 4096;
const RAW_CENTER = (RAW_MIN + RAW_MAX) / 2;
const DEG = Math.PI / 180;
const UI_DEG_MIN = -90, UI_DEG_MAX = 90; // UI sliders for angle limits
// -------------------- State --------------------
let port;
let reader;
let keepReading = false;
let isConnected = false;
const decoder = new TextDecoder();
let inputBuffer = '';
let jointValues = new Array(MAX_JOINTS).fill(RAW_CENTER);
// Auto-calibration: track observed min/max per joint
let observedMin = new Array(MAX_JOINTS).fill(Infinity);
let observedMax = new Array(MAX_JOINTS).fill(-Infinity);
let calibrationEnabled = true;
// Three.js
let scene, camera, renderer, controls;
let hand = { palm: null, fingers: [] };
// DOM
const connectButton = document.getElementById('connectButton');
const disconnectButton = document.getElementById('disconnectButton');
const baudRateSelect = document.getElementById('baudRate');
const statusIndicator = document.getElementById('statusIndicator');
const jointsContainer = document.getElementById('jointsContainer');
const logContainer = document.getElementById('logContainer');
const canvasContainer = document.getElementById('canvas-container');
const frontViewBtn = document.getElementById('frontView');
const sideViewBtn = document.getElementById('sideView');
const topViewBtn = document.getElementById('topView');
const resetViewBtn = document.getElementById('resetView');
// Helpers
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const invLerp = (a, b, x) => clamp((x - a) / (b - a), 0, 1);
// -------------------- Joint Map with per-joint angle limits --------------------
const fingerJointMap = [
// Thumb (4)
{ finger:0, joint:0, type:'CMC_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:1, type:'CMC_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:2, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
{ finger:0, joint:3, type:'IP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Index (3)
{ finger:1, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:1, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:1, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Middle (3)
{ finger:2, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Ring (3)
{ finger:3, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:3, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:3, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false }, // +45° only
// Pinky (3)
{ finger:4, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false } // +45° only
];
// Assign angle limits (radians) per joint (default ±45°, exceptions: +45° only)
for (const j of fingerJointMap) {
const isThumb = j.finger === 0;
const isPIP = j.type === 'PIP_FLEXION';
let minA = -45 * DEG, maxA = +45 * DEG;
if ((isThumb && (j.type === 'MCP_FLEXION' || j.type === 'IP_FLEXION')) || (!isThumb && isPIP)) {
minA = 0;
maxA = +45 * DEG;
}
j.angleMin = minA;
j.angleMax = maxA;
}
// -------------------- UI: Joint Panel --------------------
const uiRefs = []; // per joint: { valueLabel, bar, barWrap, slider, invertChk, minDeg, maxDeg }
function initializeJointElements() {
jointsContainer.innerHTML = '';
uiRefs.length = 0;
for (let i = 0; i < MAX_JOINTS; i++) {
const wrap = document.createElement('div');
wrap.className = 'joint-info';
const fingerIndex = i < 4 ? 0 : Math.floor((i - 4) / 3) + 1;
const jointInfo = fingerJointMap[i];
const jointType = jointInfo?.type || 'Unknown';
const fingerName = ['Thumb', 'Index', 'Middle', 'Ring', 'Pinky'][fingerIndex];
// Header
const nameEl = document.createElement('div');
nameEl.className = 'joint-name';
nameEl.textContent = `${fingerName} ${jointType}`;
// Value + bar
const valueEl = document.createElement('div');
valueEl.className = 'joint-value';
valueEl.textContent = `Value: ${jointValues[i]}`;
const barWrap = document.createElement('div');
barWrap.className = 'bar-container';
const barEl = document.createElement('div');
barEl.className = 'bar';
barWrap.appendChild(barEl);
// Slider for pre-connect manual control
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(RAW_MIN);
slider.max = String(RAW_MAX);
slider.value = String(jointValues[i]);
slider.step = '1';
slider.className = 'joint-slider';
slider.addEventListener('input', () => {
if (isConnected) return; // ignore while connected
let v = parseInt(slider.value, 10);
if (jointInfo?.inverted) v = (jointInfo.min + jointInfo.max) - v;
jointValues[i] = clamp(jointInfo ? v : 0, RAW_MIN, RAW_MAX);
updateJointDisplay(i, jointValues[i]);
updateHandModel();
});
// Invert checkbox
const invertLbl = document.createElement('label');
invertLbl.className = 'invert-toggle';
const invertChk = document.createElement('input');
invertChk.type = 'checkbox';
invertChk.checked = !!jointInfo?.inverted;
invertChk.addEventListener('change', () => {
if (jointInfo) jointInfo.inverted = invertChk.checked;
addLogMessage(`${fingerName} ${jointType} inversion ${invertChk.checked ? 'enabled' : 'disabled'}`);
});
invertLbl.appendChild(invertChk);
invertLbl.appendChild(document.createTextNode('Invert Values'));
// Angle limits (deg) controls
const limitsRow = document.createElement('div');
limitsRow.className = 'limits-row';
const minDeg = document.createElement('input');
minDeg.type = 'number';
minDeg.min = String(UI_DEG_MIN);
minDeg.max = String(UI_DEG_MAX);
minDeg.step = '1';
minDeg.value = String(Math.round((jointInfo.angleMin || 0) / DEG));
minDeg.className = 'limit-num';
const maxDeg = document.createElement('input');
maxDeg.type = 'number';
maxDeg.min = String(UI_DEG_MIN);
maxDeg.max = String(UI_DEG_MAX);
maxDeg.step = '1';
maxDeg.value = String(Math.round((jointInfo.angleMax || 0) / DEG));
maxDeg.className = 'limit-num';
const minLbl = document.createElement('span'); minLbl.textContent = 'min°';
const maxLbl = document.createElement('span'); maxLbl.textContent = 'max°';
minLbl.className = 'limit-label'; maxLbl.className = 'limit-label';
function syncLimits() {
let mn = parseFloat(minDeg.value);
let mx = parseFloat(maxDeg.value);
if (isNaN(mn)) mn = -45;
if (isNaN(mx)) mx = +45;
if (mn > mx) [mn, mx] = [mx, mn];
jointInfo.angleMin = clamp(mn, UI_DEG_MIN, UI_DEG_MAX) * DEG;
jointInfo.angleMax = clamp(mx, UI_DEG_MIN, UI_DEG_MAX) * DEG;
minDeg.value = String(Math.round(jointInfo.angleMin / DEG));
maxDeg.value = String(Math.round(jointInfo.angleMax / DEG));
updateHandModel();
}
minDeg.addEventListener('change', syncLimits);
maxDeg.addEventListener('change', syncLimits);
limitsRow.appendChild(minLbl);
limitsRow.appendChild(minDeg);
limitsRow.appendChild(maxLbl);
limitsRow.appendChild(maxDeg);
// Calibration controls
const calibRow = document.createElement('div');
calibRow.className = 'calib-row';
const resetCalibBtn = document.createElement('button');
resetCalibBtn.textContent = 'Reset Calib';
resetCalibBtn.className = 'calib-btn';
resetCalibBtn.addEventListener('click', () => {
observedMin[i] = Infinity;
observedMax[i] = -Infinity;
addLogMessage(`Reset calibration for ${fingerName} ${jointType}`);
});
const calibStatus = document.createElement('span');
calibStatus.className = 'calib-status';
calibStatus.textContent = `Range: --`;
calibRow.appendChild(resetCalibBtn);
calibRow.appendChild(calibStatus);
// Compose
wrap.appendChild(nameEl);
wrap.appendChild(valueEl);
wrap.appendChild(barWrap);
wrap.appendChild(slider);
wrap.appendChild(invertLbl);
wrap.appendChild(limitsRow);
wrap.appendChild(calibRow);
jointsContainer.appendChild(wrap);
uiRefs[i] = { valueLabel: valueEl, bar: barEl, barWrap, slider, invertChk, minDeg, maxDeg, nameEl, calibStatus };
}
setConnectedUI(false); // initial state: sliders active
}
// Toggle UI between pre-connect SLIDERS vs post-connect BARS
function setConnectedUI(connected) {
isConnected = connected;
for (let i = 0; i < uiRefs.length; i++) {
const ui = uiRefs[i];
if (!ui) continue;
// Show bars when connected; sliders disabled/hidden
ui.barWrap.style.display = connected ? '' : 'none';
ui.slider.disabled = connected;
ui.slider.style.display = connected ? 'none' : '';
}
// Reset calibration when connecting
if (connected) {
observedMin.fill(Infinity);
observedMax.fill(-Infinity);
addLogMessage('Calibration reset - move joints through full range for best results');
}
}
// Update joint display (value text + bar color/width + slider position if needed)
function updateJointDisplay(jointIndex, value) {
const ui = uiRefs[jointIndex];
const info = fingerJointMap[jointIndex];
if (!ui || !info) return;
ui.valueLabel.textContent = `Value: ${value}`;
// bar
const min = info.min, max = info.max;
const pct = clamp((value - min) / (max - min), 0, 1) * 100;
ui.bar.style.width = `${pct}%`;
const hue = Math.floor(pct * 1.2); // 0..120
ui.bar.style.backgroundColor = `hsl(${hue}, 80%, 50%)`;
// slider (only meaningful when not connected; keep in sync anyway)
const rawForSlider = info.inverted ? (info.min + info.max) - value : value;
if (!isConnected) ui.slider.value = String(clamp(Math.round(rawForSlider), RAW_MIN, RAW_MAX));
}
// -------------------- Serial I/O --------------------
async function readSerialData() {
while (port?.readable && keepReading) {
reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) processData(decoder.decode(value));
}
} catch (err) {
console.error('Error reading:', err);
addLogMessage(`Error: ${err.message}`);
break;
} finally {
reader.releaseLock();
}
}
}
function processData(chunk) {
inputBuffer += chunk;
let idx;
while ((idx = inputBuffer.indexOf('\n')) !== -1) {
const line = inputBuffer.slice(0, idx).trim();
inputBuffer = inputBuffer.slice(idx + 1);
const vals = line.split(/\s+/).map(v => parseInt(v, 10));
if (vals.length === MAX_JOINTS && vals.every(v => Number.isFinite(v))) {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
let rawValue = vals[i];
// Update calibration tracking
if (calibrationEnabled) {
observedMin[i] = Math.min(observedMin[i], rawValue);
observedMax[i] = Math.max(observedMax[i], rawValue);
// Update calibration display
const ui = uiRefs[i];
if (ui && ui.calibStatus) {
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity) {
ui.calibStatus.textContent = `Range: ${observedMin[i]}-${observedMax[i]}`;
}
}
// Remap observed range to target range
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity && observedMax[i] > observedMin[i]) {
const observedRange = observedMax[i] - observedMin[i];
const targetRange = info.max - info.min;
const normalizedValue = (rawValue - observedMin[i]) / observedRange;
rawValue = info.min + (normalizedValue * targetRange);
}
}
let v = clamp(rawValue, info.min, info.max);
if (info.inverted) v = (info.min + info.max) - v;
jointValues[i] = v;
updateJointDisplay(i, v);
}
updateHandModel();
} else {
addLogMessage(`Received: ${line}`);
}
}
}
async function connectToDevice() {
try {
port = await navigator.serial.requestPort();
const baudRate = parseInt(baudRateSelect.value, 10) || 115200;
await port.open({ baudRate });
keepReading = true;
setConnectedUI(true);
statusIndicator.textContent = 'Status: Connected';
statusIndicator.className = 'status connected';
connectButton.disabled = true;
disconnectButton.disabled = false;
baudRateSelect.disabled = true;
addLogMessage(`Connected at ${baudRate} baud`);
readSerialData();
} catch (e) {
console.error('Connect error:', e);
addLogMessage(`Connection error: ${e.message}`);
}
}
async function disconnectFromDevice() {
try {
keepReading = false;
if (reader) {
try { reader.cancel(); } catch {}
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
addLogMessage(`Disconnection error: ${e.message}`);
} finally {
setConnectedUI(false);
statusIndicator.textContent = 'Status: Disconnected';
statusIndicator.className = 'status disconnected';
connectButton.disabled = false;
disconnectButton.disabled = true;
baudRateSelect.disabled = false;
addLogMessage('Disconnected');
}
}
// -------------------- Three.js Scene --------------------
function initThreeJS() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
camera = new THREE.PerspectiveCamera(
75,
canvasContainer.clientWidth / canvasContainer.clientHeight,
0.1, 1000
);
camera.position.set(0, 15, 15);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.25;
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.5);
dir1.position.set(1, 1, 1);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-1, 1, -1);
scene.add(dir2);
const gridHelper = new THREE.GridHelper(20, 20);
scene.add(gridHelper);
createHandModel();
window.addEventListener('resize', onWindowResize);
animate();
}
function createHandModel() {
const palmMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const fingerMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const jointMaterial = new THREE.MeshPhongMaterial({ color: 0xe3a977 });
const palmGeometry = new THREE.BoxGeometry(7, 1, 8);
hand.palm = new THREE.Mesh(palmGeometry, palmMaterial);
hand.palm.position.set(0, 0, 0);
hand.palm.rotation.x = Math.PI / 2; // hand vertical, palm facing forward
scene.add(hand.palm);
const fingerWidth = 1, fingerHeight = 0.8;
const fingerSegmentLengths = [3, 2, 1.5];
const thumbSegmentLengths = [2, 2, 1.5];
const fingerBasePositions = [
[ 3, 0, -2], // Thumb
[ 1.5,-0.5,-4], // Index
[ 0, -0.5,-4], // Middle
[-1.5,-0.5,-4], // Ring
[-3, -0.5,-4], // Pinky
];
const fingerBaseRot = [
{ x:0, y:-Math.PI/3, z: Math.PI/3 }, // Thumb
{ x:0, y:-Math.PI/48, z: 0 },
{ x:0, y: Math.PI/48, z: 0 },
{ x:0, y: Math.PI/32, z: 0 },
{ x:0, y: Math.PI/24, z: 0 }
];
for (let fIdx = 0; fIdx < 5; fIdx++) {
const finger = { name:['Thumb','Index','Middle','Ring','Pinky'][fIdx], segments:[], joints:[] };
const isThumb = fIdx === 0;
const segLens = isThumb ? thumbSegmentLengths : fingerSegmentLengths;
finger.group = new THREE.Group();
finger.group.position.set(...fingerBasePositions[fIdx]);
finger.group.rotation.x = fingerBaseRot[fIdx].x;
finger.group.rotation.y = fingerBaseRot[fIdx].y;
finger.group.rotation.z = fingerBaseRot[fIdx].z;
finger.group.userData.baseRot = {
x:finger.group.rotation.x,
y:finger.group.rotation.y,
z:finger.group.rotation.z
};
hand.palm.add(finger.group);
let parent = finger.group;
for (let s = 0; s < segLens.length; s++) {
const segGroup = new THREE.Group();
const jGeom = new THREE.SphereGeometry(fingerWidth * 0.6, 8, 8);
const joint = new THREE.Mesh(jGeom, jointMaterial);
segGroup.add(joint);
const segGeom = new THREE.BoxGeometry(fingerWidth, fingerHeight, segLens[s]);
const seg = new THREE.Mesh(segGeom, fingerMaterial);
seg.position.z = -segLens[s] / 2;
segGroup.add(seg);
parent.add(segGroup);
finger.segments.push(segGroup);
finger.joints.push(joint);
if (s < segLens.length - 1) {
const connector = new THREE.Group();
connector.position.z = -segLens[s];
segGroup.add(connector);
parent = connector;
}
}
hand.fingers.push(finger);
}
addFingerLabels();
addHandLabel();
}
function addFingerLabels() {
const names = ['Thumb','Index','Middle','Ring','Pinky'];
for (let i = 0; i < hand.fingers.length; i++) {
const finger = hand.fingers[i];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 128; canvas.height = 32;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(names[i], canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(2, 0.5);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -1.5, -2);
label.rotation.x = Math.PI / 2;
finger.group.add(label);
}
}
function addHandLabel() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256; canvas.height = 64;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('RIGHT HAND (VERTICAL)', canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(7, 1.75);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -2, 0);
label.rotation.x = Math.PI / 2;
scene.add(label);
}
function updateHandModel() {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
const { finger, joint, type, min, max, angleMin, angleMax } = info;
const raw = jointValues[i];
const f = hand.fingers[finger];
if (!f) continue;
const center = (min + max) / 2;
let angle = 0;
if (type.includes('ABDUCTION')) {
// symmetric around neutral
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
if (finger === 0 && joint === 0) {
// Thumb: abduction about Z (toward/away from palm)
f.group.rotation.z = base.z + angle;
} else {
// Other fingers: side-to-side about Y
f.group.rotation.y = base.y + angle;
}
} else if (type.includes('FLEXION')) {
const isThumb = finger === 0;
const isMCP = type === 'MCP_FLEXION';
const isPIP = type === 'PIP_FLEXION';
const positiveOnly = (isThumb && (type === 'MCP_FLEXION' || type === 'IP_FLEXION')) || (!isThumb && isPIP);
if (positiveOnly) {
const t = raw <= center ? 0 : invLerp(center, max, raw); // 0..1
angle = angleMin + t * (angleMax - angleMin); // 0..+limit
} else {
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
}
if (isMCP) {
// MCP flexion applies to the finger base group (same as abduction)
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
f.group.rotation.x = base.x + angle;
} else if (f.segments[joint]) {
// PIP/DIP/IP flexion applies to individual segments
f.segments[joint].rotation.x = angle;
}
}
}
}
// -------------------- Render Loop --------------------
function onWindowResize() {
camera.aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// -------------------- Misc UI --------------------
function addLogMessage(msg) {
const el = document.createElement('div');
el.textContent = msg;
logContainer.appendChild(el);
logContainer.scrollTop = logContainer.scrollHeight;
while (logContainer.children.length > 100) {
logContainer.removeChild(logContainer.firstChild);
}
}
// Camera view controls
frontViewBtn?.addEventListener('click', () => { camera.position.set(0, 0, 20); camera.lookAt(0,0,0); controls.update(); });
sideViewBtn?.addEventListener('click', () => { camera.position.set(20, 0, 0); camera.lookAt(0,0,0); controls.update(); });
topViewBtn?.addEventListener('click', () => { camera.position.set(0, 20, 0); camera.lookAt(0,0,0); controls.update(); });
resetViewBtn?.addEventListener('click', () => { camera.position.set(10,10,10); camera.lookAt(0,0,0); controls.update(); });
// Serial connect buttons
connectButton?.addEventListener('click', connectToDevice);
disconnectButton?.addEventListener('click', disconnectFromDevice);
// Web Serial support check
if (!navigator.serial) {
statusIndicator.textContent = 'Status: Web Serial API not supported in this browser';
connectButton.disabled = true;
addLogMessage('ERROR: Web Serial API is not supported in this browser. Try Chrome or Edge.');
}
// -------------------- Boot --------------------
initThreeJS();
initializeJointElements();
// -------------------- Styles (inline) --------------------
const styleElement = document.createElement('style');
styleElement.textContent = `
.joint-info { border-bottom: 1px solid #eee; padding: 8px 0; }
.joint-name { font-weight: 600; margin-bottom: 4px; }
.joint-value { font-size: 12px; color: #333; margin-bottom: 4px; }
.bar-container { width: 100%; height: 8px; background: #ddd; border-radius: 4px; overflow: hidden; }
.bar { height: 100%; width: 0%; background: #4caf50; }
.joint-slider { width: 100%; margin: 6px 0; }
.invert-toggle { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: #555; }
.limits-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.limit-label { font-size: 11px; color: #666; }
.limit-num { width: 60px; }
.calib-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.calib-btn { padding: 2px 6px; font-size: 11px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; }
.calib-btn:hover { background: #d32f2f; }
.calib-status { font-size: 11px; color: #666; }
.status.connected { color: #0a0; }
.status.disconnected { color: #a00; }
`;
document.head.appendChild(styleElement);
-49
View File
@@ -1,49 +0,0 @@
# lerobot_robot_ax_arm
A third-party [LeRobot](https://github.com/huggingface/lerobot) robot: a 4-DoF arm driven by Dynamixel
AX-series servos (e.g. AX-12A) over **Protocol 1.0**.
| Motor ID | Joint | Normalization |
| -------- | --------------- | ------------- |
| 1 | `shoulder_pan` | [-100, 100] |
| 2 | `shoulder_lift` | [-100, 100] |
| 3 | `elbow_flex` | [-100, 100] |
| 4 | `gripper` | [0, 100] |
## Protocol 1.0 notes
AX-series motors differ from the X-series (Protocol 2.0) used elsewhere in LeRobot:
- **No Sync Read** — positions are read sequentially (`get_observation` / calibration). Sync Write is still
used for `Goal_Position`.
- **No `Operating_Mode` / PID registers** — `configure()` only lowers the return delay time.
- **No homing offset register** — calibration records the range of motion only (`homing_offset = 0`) and is
stored via the CW/CCW angle limits.
## Install
```bash
pip install -e .
```
This is discovered automatically by LeRobot thanks to the `lerobot_robot_` package prefix.
## Usage
```bash
lerobot-record \
--robot.type=ax_arm \
--robot.port=/dev/tty.usbserial-AL02L1E0 \
# ... other arguments
```
Or from Python:
```python
from lerobot_robot_ax_arm import AXArm, AXArmConfig
robot = AXArm(AXArmConfig(port="/dev/tty.usbserial-AL02L1E0"))
robot.connect()
obs = robot.get_observation()
robot.disconnect()
```
@@ -1,165 +0,0 @@
#!/usr/bin/env python
"""Hardware-free 3D simulation of the AX-arm keyboard EE teleop.
Reuses the real IK (``_build_kinematics`` / ``_joint_velocity`` from ``teleoperate_ee_keyboard``)
and a synthetic calibration, driving a simulated (ideal) servo bus instead of a real one. Lets you
sanity-check the solver/frame behaviour and joint limits in a matplotlib window.
Controls (focus the plot window):
- w / s : +X / -X - a / d : +Y / -Y - r / f : +Z / -Z
- o / c : open / close gripper
- t : toggle global <-> local frame
- m : toggle dq <-> pos solver
- q / esc : quit
Run:
python examples/simulate_ee_keyboard.py [--solver pos] [--frame global] [--speed 0.06]
"""
import argparse
import importlib.util
import os
import tempfile
from pathlib import Path
os.environ.setdefault("MPLCONFIGDIR", tempfile.gettempdir())
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from lerobot.motors import MotorCalibration
from lerobot_robot_ax_arm.urdf_mapping import (
ARM_JOINTS,
REFERENCE_URDF_DEG,
SCALE,
URDF_LIMITS_DEG,
ticks_to_urdf_vector,
urdf_vector_to_ticks,
)
# Reuse the real teleop IK helpers without duplicating them.
_EE = importlib.util.spec_from_file_location(
"_ee_teleop", str(Path(__file__).with_name("teleoperate_ee_keyboard.py"))
)
ee = importlib.util.module_from_spec(_EE)
_EE.loader.exec_module(ee)
TICK_REF = 512 # tick chosen to sit at each joint's URDF reference angle
GRIP_RANGE = (350, 600)
def _synthetic_calibration() -> dict[str, MotorCalibration]:
"""Calibration consistent with urdf_mapping: reference tick + travel limits per joint."""
calib: dict[str, MotorCalibration] = {}
for i, j in enumerate(ARM_JOINTS):
lo_deg, hi_deg = URDF_LIMITS_DEG[j]
ticks = sorted(int(round(TICK_REF + (d - REFERENCE_URDF_DEG[j]) / SCALE)) for d in (lo_deg, hi_deg))
calib[j] = MotorCalibration(id=i + 1, drive_mode=0, homing_offset=TICK_REF,
range_min=ticks[0], range_max=ticks[1])
calib["gripper"] = MotorCalibration(id=4, drive_mode=0, homing_offset=0,
range_min=GRIP_RANGE[0], range_max=GRIP_RANGE[1])
return calib
class _SimBus:
"""Ideal servo bus: Present_Position instantly follows the last commanded Goal_Position."""
def __init__(self, ticks: dict[str, float]):
self.ticks = ticks
def read(self, _reg, motor, normalize=False):
return self.ticks[motor]
def write(self, _reg, motor, value, normalize=False):
self.ticks[motor] = float(value)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--speed", type=float, default=ee.CART_STEP_M)
parser.add_argument("--frame", choices=("local", "global"), default="local")
parser.add_argument("--solver", choices=("dq", "pos"), default="dq")
args = parser.parse_args()
from importlib.resources import files
urdf_path = str(files("lerobot_robot_ax_arm") / "urdf" / "ax_arm.urdf")
kin = ee._build_kinematics(urdf_path)
link_fks = ee.build_link_fks(urdf_path)
calib = _synthetic_calibration()
q_start_deg = np.array([REFERENCE_URDF_DEG[j] for j in ARM_JOINTS]) # reference "zero" pose (0, 45, 90)
start_ticks = urdf_vector_to_ticks(q_start_deg, calib)
ticks = {j: float(start_ticks[j]) for j in ARM_JOINTS}
ticks["gripper"] = float(sum(GRIP_RANGE) / 2)
bus = _SimBus(ticks)
pending = {"x": 0.0, "y": 0.0, "z": 0.0, "g": 0.0}
state = {"frame": args.frame, "solver": args.solver}
keymap = {"w": ("x", 1), "s": ("x", -1), "a": ("y", 1), "d": ("y", -1), "r": ("z", 1), "f": ("z", -1),
"o": ("g", 1), "c": ("g", -1)}
fig = plt.figure(figsize=(7, 6))
ax = fig.add_subplot(projection="3d")
(chain_line,) = ax.plot([], [], [], "-o", lw=3, color="tab:blue")
(ee_pt,) = ax.plot([], [], [], "o", ms=10, color="tab:red")
reach = 0.32
ax.set_xlim(-reach, reach); ax.set_ylim(-reach, reach); ax.set_zlim(-0.05, reach)
ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z")
def on_key(event):
k = (event.key or "").lower()
if k in ("q", "escape"):
plt.close(fig)
elif k == "t":
state["frame"] = "global" if state["frame"] == "local" else "local"
elif k == "m":
state["solver"] = "pos" if state["solver"] == "dq" else "dq"
elif k in keymap:
axis, direction = keymap[k]
pending[axis] += direction
fig.canvas.mpl_connect("key_press_event", on_key)
def update(_frame):
q_rad = np.deg2rad(ticks_to_urdf_vector({j: ticks[j] for j in ARM_JOINTS}, calib))
cmd = np.array([np.sign(pending["x"]), np.sign(pending["y"]), np.sign(pending["z"])], dtype=float)
pending["x"] = pending["y"] = pending["z"] = 0.0
if np.any(cmd):
q_dot = ee._joint_velocity(kin, q_rad, cmd, state["frame"], state["solver"])
ee_speed = float(np.linalg.norm(np.array(kin["pos_jac"](q_rad)) @ q_dot))
if ee_speed > 1e-6:
q_step = q_dot * (args.speed / ee_speed)
step_norm = np.linalg.norm(q_step)
if step_norm > ee.MAX_JOINT_STEP_RAD:
q_step *= ee.MAX_JOINT_STEP_RAD / step_norm
q_target = np.clip(q_rad + q_step, kin["lower"], kin["upper"])
target_ticks = urdf_vector_to_ticks(np.rad2deg(q_target), calib)
for j in ARM_JOINTS:
c = calib[j]
bus.write("Goal_Position", j, int(np.clip(target_ticks[j], c.range_min, c.range_max)))
g = np.sign(pending["g"]); pending["g"] = 0.0
if g:
gc = calib["gripper"]
bus.write("Goal_Position", "gripper",
int(np.clip(ticks["gripper"] + g * ee.GRIP_STEP_TICK, gc.range_min, gc.range_max)))
pts = ee.chain_points(link_fks, np.deg2rad(ticks_to_urdf_vector({j: ticks[j] for j in ARM_JOINTS}, calib)))
chain_line.set_data(pts[:, 0], pts[:, 1]); chain_line.set_3d_properties(pts[:, 2])
ee_pt.set_data(pts[-1:, 0], pts[-1:, 1]); ee_pt.set_3d_properties(pts[-1:, 2])
grip_frac = (ticks["gripper"] - GRIP_RANGE[0]) / (GRIP_RANGE[1] - GRIP_RANGE[0])
ax.set_title(f"frame={state['frame']} solver={state['solver']} gripper={grip_frac:.0%}\n"
f"w/s/a/d/r/f=move o/c=grip t=frame m=solver q=quit")
return chain_line, ee_pt
anim = FuncAnimation(fig, update, interval=int(1000 / ee.FPS), blit=False, cache_frame_data=False)
fig._anim = anim # keep a reference so it isn't garbage-collected
plt.show()
if __name__ == "__main__":
main()
@@ -1,321 +0,0 @@
#!/usr/bin/env python
"""Keyboard end-effector teleoperation for the 4-DoF AX arm (velocity IK, position output).
Adapted from a resolved-rate (twist) servoing controller: instead of commanding joint
velocities, the per-tick joint velocity is integrated into a joint *position* target and sent as
``Goal_Position`` (the AX arm runs in position mode).
Each frame we read the raw motor ticks, map them to URDF joint angles, solve for a joint velocity
that produces the requested Cartesian motion, scale it to a fixed end-effector Cartesian step
``CART_STEP_M`` per tick (capped in joint space near singularities), and command ``Goal_Position``.
Two IK solvers are selectable at runtime (``--solver`` / ``m`` key):
- "dq" : dual-quaternion resolved-rate (matches the full pose velocity via ``scipy.least_squares``;
faithful to the source controller, but rotation/translation coupling on a 3-DoF arm
causes axis leakage),
- "pos" : position-only Jacobian ``dEE_pos/dq`` solved with least-squares (crisp axis-aligned
Cartesian motion).
The motion frame is also selectable (``--frame`` / ``t`` key): "local" moves along the
end-effector's own axes, "global" along the fixed world axes. Global always uses the position
Jacobian (the dq solver's held-orientation constraint leaks axes on this underactuated arm).
The tick<->URDF mapping is established once by ``lerobot-calibrate`` (reference pose + travel
limits), so no separate alignment step is needed here.
Controls (letter keys; hold to keep moving via terminal key-repeat):
- w / s : +X / -X (forward / back)
- a / d : +Y / -Y (left / right)
- r / f : +Z / -Z (up / down)
- o / c : open / close gripper
- t : toggle global <-> local frame
- m : toggle dq <-> pos solver
- ESC / q : stop
Run:
python examples/teleoperate_ee_keyboard.py --port /dev/tty.usbserial-XXXX --id my_ax_arm
"""
import argparse
import time
from importlib.resources import files
import casadi as cs
import numpy as np
import scipy as sp
from urdf2casadi import urdfparser as u2c
from urdf2casadi.geometry import dual_quaternion, quaternion
from lerobot.utils.keyboard_input import create_key_listener
from lerobot.utils.robot_utils import precise_sleep
from lerobot_robot_ax_arm import AXArm, AXArmConfig
from lerobot_robot_ax_arm.urdf_mapping import (
ARM_JOINTS,
REFERENCE_URDF_DEG,
ticks_to_urdf_vector,
urdf_vector_to_ticks,
)
FPS = 30
HOME_TIME_S = 2.0 # duration of the ramped move to the reference pose at startup
CART_STEP_M = 0.008 # default end-effector Cartesian motion per tick, meters (override with --speed)
MAX_JOINT_STEP_RAD = 0.15 # safety cap on joint motion per tick (keeps motion bounded near singularities)
DLS_LAMBDA = 0.02 # damping factor for the position IK, well-behaved near singularities
GRIP_STEP_TICK = 15 # gripper ticks per press
FIT_THRESHOLD = 0.1 # only fit dual-quaternion derivative components above this magnitude
ROOT_LINK = "base_link"
TIP_LINK = "gripper_link"
CHAIN_LINKS = ("robot_link_1", "robot_link_2", "robot_link_3", "gripper_link")
def _skew(x: np.ndarray) -> np.ndarray:
return np.array([[0, -x[2], x[1]], [x[2], 0, -x[0]], [-x[1], x[0], 0]])
def _build_kinematics(urdf_path: str) -> dict:
"""FK + Jacobians (dual-quaternion and position-only) and joint limits from the URDF."""
parser = u2c.URDFparser()
parser.from_file(urdf_path)
fk = parser.get_forward_kinematics(ROOT_LINK, TIP_LINK)
q_sym = fk["q"]
fk_dq = fk["dual_quaternion_fk"]
fk_T = fk["T_fk"]
return {
"fk_dq": fk_dq,
"fk_T": fk_T,
"dq_jac": cs.Function("dq_jac", [q_sym], [cs.jacobian(fk_dq(q_sym), q_sym)]),
"pos_jac": cs.Function("pos_jac", [q_sym], [cs.jacobian(fk_T(q_sym)[:3, 3], q_sym)]),
"lower": np.array(fk["lower"], dtype=float).flatten(),
"upper": np.array(fk["upper"], dtype=float).flatten(),
}
def build_link_fks(urdf_path: str):
"""Casadi T_fk functions base->each link in CHAIN_LINKS, for drawing the arm."""
fks = []
for tip in CHAIN_LINKS:
parser = u2c.URDFparser()
parser.from_file(urdf_path)
fk = parser.get_forward_kinematics(ROOT_LINK, tip)
fks.append((fk["T_fk"], fk["q"].shape[0]))
return fks
def chain_points(link_fks, q_rad: np.ndarray) -> np.ndarray:
"""3D positions of the base and each link origin along the kinematic chain."""
pts = [np.zeros(3)]
for T, n in link_fks:
pts.append(np.array(T(q_rad[:n]))[:3, 3])
return np.array(pts)
def open_live_view(link_fks, reach: float = 0.42):
"""Open a non-blocking 3D plot; returns an update(q_rad, title) callback (False once closed)."""
import os
import tempfile
os.environ.setdefault("MPLCONFIGDIR", tempfile.gettempdir())
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(7, 6))
ax = fig.add_subplot(projection="3d")
(chain_line,) = ax.plot([], [], [], "-o", lw=3, color="tab:blue")
(ee_pt,) = ax.plot([], [], [], "o", ms=10, color="tab:red")
ax.set_xlim(-reach, reach); ax.set_ylim(-reach, reach); ax.set_zlim(-0.05, reach)
ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z")
plt.ion(); plt.show(block=False)
def update(q_rad: np.ndarray, title: str) -> bool:
if not plt.fignum_exists(fig.number):
return False
pts = chain_points(link_fks, q_rad)
chain_line.set_data(pts[:, 0], pts[:, 1]); chain_line.set_3d_properties(pts[:, 2])
ee_pt.set_data(pts[-1:, 0], pts[-1:, 1]); ee_pt.set_3d_properties(pts[-1:, 2])
ax.set_title(title)
fig.canvas.draw_idle(); fig.canvas.flush_events()
return True
return update
def _world_twist_to_dq_dot(pose: np.ndarray, twist_world: np.ndarray) -> np.ndarray:
"""World-frame twist [v, w] -> dual-quaternion derivative x_dot at the current pose."""
T = dual_quaternion.to_numpy_transformation_matrix(pose)
adj = np.zeros((6, 6))
adj[:3, :3] = T[:3, :3]
adj[3:, 3:] = T[:3, :3]
adj[:3, 3:] = _skew(T[:3, -1]) @ T[:3, :3]
twist = adj @ twist_world
v = np.append(twist[:3], 0)
w = np.append(twist[3:], 0)
primal = pose[:4]
dual = pose[4:]
primal_conj = np.append(-primal[:3], primal[3])
p = 2 * quaternion.numpy_product(dual, primal_conj)
xi = np.zeros(8)
xi[:4] = np.append(0, twist[3:])
xi[4:] = v + (quaternion.numpy_product(p, w) - quaternion.numpy_product(w, p)) / 2
return 0.5 * dual_quaternion.numpy_product(xi, pose)
def _fitness(q_dot, jacobian, x_dot):
index = np.where(np.abs(x_dot) > FIT_THRESHOLD)
delta = (np.dot(jacobian, q_dot) - x_dot)[index]
return np.dot(delta.T, delta)
def _fitness_jacobian(q_dot, jacobian, x_dot):
return 2 * np.dot(jacobian.T, np.dot(jacobian, q_dot) - x_dot)
def _joint_velocity(kin: dict, q_rad: np.ndarray, cmd: np.ndarray, frame: str, solver: str) -> np.ndarray:
"""Joint velocity producing the requested unit Cartesian motion ``cmd`` (x, y, z)."""
rot = np.array(kin["fk_T"](q_rad))[:3, :3]
# World-frame ("global") translation on this 3-DoF (no-wrist) arm is only reliable via the position
# Jacobian: the dq resolved-rate tries to hold EE orientation fixed, which an underactuated arm
# cannot do, leaking the motion onto other axes as the arm reorients. So global always uses pos.
if solver == "pos" or frame == "global":
v_world = cmd.astype(float) if frame == "global" else rot @ cmd
J = np.array(kin["pos_jac"](q_rad))
# Damped least squares: bounded, well-conditioned joint velocity even near singularities.
return J.T @ np.linalg.solve(J @ J.T + DLS_LAMBDA**2 * np.eye(3), v_world)
# Dual-quaternion resolved-rate (local frame only): move along the end-effector's own axes.
lin = cmd.astype(float)
pose = np.array(kin["fk_dq"](q_rad)).flatten()
x_dot = _world_twist_to_dq_dot(pose, np.concatenate([lin, np.zeros(3)]))
jacobian = np.array(kin["dq_jac"](q_rad))
sol = sp.optimize.least_squares(
_fitness, np.zeros(len(ARM_JOINTS)), args=(jacobian, x_dot), xtol=1e-4, jac=_fitness_jacobian,
)
return sol.x if sol.success else np.zeros(len(ARM_JOINTS))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", required=True, help="Serial port of the AX arm")
parser.add_argument("--id", default="my_ax_arm", help="Robot id used for calibration files")
parser.add_argument("--speed", type=float, default=CART_STEP_M,
help="End-effector Cartesian motion per tick in meters (higher = faster)")
parser.add_argument("--frame", choices=("local", "global"), default="local",
help="Motion frame: 'local' = end-effector axes, 'global' = world axes")
parser.add_argument("--solver", choices=("dq", "pos"), default="dq",
help="IK solver: 'dq' = dual-quaternion resolved-rate, 'pos' = position Jacobian")
parser.add_argument("--view", action="store_true",
help="Show a live 3D plot of the real arm (digital twin) alongside teleop")
args = parser.parse_args()
cart_step = args.speed
robot = AXArm(AXArmConfig(port=args.port, id=args.id, use_degrees=True))
robot.connect(calibrate=False)
if not robot.calibration:
raise RuntimeError(f"No calibration found for id '{args.id}'. Run lerobot-calibrate first.")
urdf_path = str(files("lerobot_robot_ax_arm") / "urdf" / "ax_arm.urdf")
kin = _build_kinematics(urdf_path)
q_lower, q_upper = kin["lower"], kin["upper"]
view_update = open_live_view(build_link_fks(urdf_path)) if args.view else None
grip_calib = robot.calibration["gripper"]
grip_tick = int(robot.bus.read("Present_Position", "gripper", normalize=False))
# Slowly ramp to the reference ("zero") pose (0, 45, 90) before starting teleop.
home_deg = np.array([REFERENCE_URDF_DEG[j] for j in ARM_JOINTS])
home_ticks = urdf_vector_to_ticks(home_deg, robot.calibration)
start_ticks = {j: float(robot.bus.read("Present_Position", j, normalize=False)) for j in ARM_JOINTS}
print("Homing to zero pose (0, 45, 90)...")
steps = max(1, int(HOME_TIME_S * FPS))
for i in range(1, steps + 1):
alpha = i / steps
for j in ARM_JOINTS:
c = robot.calibration[j]
tick = (1 - alpha) * start_ticks[j] + alpha * home_ticks[j]
robot.bus.write("Goal_Position", j, int(np.clip(tick, c.range_min, c.range_max)), normalize=False)
precise_sleep(1.0 / FPS)
pending = {"x": 0.0, "y": 0.0, "z": 0.0, "g": 0.0}
state = {"quit": False, "frame": args.frame, "solver": args.solver}
keymap = {"w": ("x", 1), "s": ("x", -1), "a": ("y", 1), "d": ("y", -1), "r": ("z", 1), "f": ("z", -1),
"o": ("g", 1), "c": ("g", -1)}
def on_key(name: str) -> None:
k = name.lower()
if k in ("esc", "q"):
state["quit"] = True
elif k == "t":
state["frame"] = "global" if state["frame"] == "local" else "local"
elif k == "m":
state["solver"] = "pos" if state["solver"] == "dq" else "dq"
elif k in keymap:
axis, direction = keymap[k]
pending[axis] += direction
listener = create_key_listener(
on_key, controls_help="w/s a/d r/f = XYZ, o/c = gripper, t = frame, m = solver, esc = stop"
)
if listener is None:
raise RuntimeError("Needs an interactive terminal with a usable key listener.")
print("Keyboard EE teleop (velocity IK -> position). w/s=X a/d=Y r/f=Z o/c=gripper, t=frame, m=solver, ESC=stop.")
try:
while not state["quit"]:
t0 = time.perf_counter()
ticks = {j: float(robot.bus.read("Present_Position", j, normalize=False)) for j in ARM_JOINTS}
q_deg = ticks_to_urdf_vector(ticks, robot.calibration)
q_rad = np.deg2rad(q_deg)
lin = np.array([np.sign(pending["x"]), np.sign(pending["y"]), np.sign(pending["z"])])
cmd_disp = lin.copy()
pending["x"] = pending["y"] = pending["z"] = 0.0
q_target_rad = q_rad
if np.any(lin):
q_dot = _joint_velocity(kin, q_rad, lin.astype(float), state["frame"], state["solver"])
# Scale for a consistent end-effector Cartesian speed (uniform across axes/poses),
# then cap the joint step so motion stays bounded near singularities.
ee_speed = float(np.linalg.norm(np.array(kin["pos_jac"](q_rad)) @ q_dot))
if ee_speed > 1e-6:
q_step = q_dot * (cart_step / ee_speed)
step_norm = np.linalg.norm(q_step)
if step_norm > MAX_JOINT_STEP_RAD:
q_step *= MAX_JOINT_STEP_RAD / step_norm
q_target_rad = np.clip(q_rad + q_step, q_lower, q_upper)
target_ticks = urdf_vector_to_ticks(np.rad2deg(q_target_rad), robot.calibration)
for j in ARM_JOINTS:
c = robot.calibration[j]
tick = int(np.clip(target_ticks[j], c.range_min, c.range_max))
robot.bus.write("Goal_Position", j, tick, normalize=False)
g = np.sign(pending["g"])
pending["g"] = 0.0
if g:
grip_tick = int(np.clip(grip_tick + g * GRIP_STEP_TICK, grip_calib.range_min, grip_calib.range_max))
robot.bus.write("Goal_Position", "gripper", grip_tick, normalize=False)
urdf_str = " ".join(f"{j}={v:+6.1f}" for j, v in zip(ARM_JOINTS, np.rad2deg(q_target_rad)))
print(f"[{state['frame']:>6}|{state['solver']}] cmd[x={cmd_disp[0]:+.0f} y={cmd_disp[1]:+.0f}"
f" z={cmd_disp[2]:+.0f} g={g:+.0f}] -> urdf[{urdf_str}] gripper={grip_tick}",
end="\r", flush=True)
if view_update is not None:
# Draw the arm from the measured joint angles (real state, not the command).
view_update(q_rad, f"REAL arm | frame={state['frame']} solver={state['solver']}")
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
except KeyboardInterrupt:
pass
finally:
listener.stop()
print()
robot.disconnect()
if __name__ == "__main__":
main()
@@ -1,4 +0,0 @@
from .ax_arm import AXArm
from .config_ax_arm import AXArmConfig
__all__ = ["AXArm", "AXArmConfig"]
@@ -1,266 +0,0 @@
import logging
import time
from functools import cached_property
from lerobot.cameras import make_cameras_from_configs
from lerobot.motors import Motor, MotorCalibration, MotorNormMode
from lerobot.motors.dynamixel import DynamixelMotorsBus
from lerobot.robots import Robot
from lerobot.robots.utils import ensure_safe_goal_position
from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
from lerobot.utils.keyboard_input import create_key_listener
from .config_ax_arm import AXArmConfig
from .urdf_mapping import ARM_JOINTS, REFERENCE_URDF_DEG, URDF_LIMITS_DEG
logger = logging.getLogger(__name__)
class AXArm(Robot):
"""A 4-DoF arm driven by Dynamixel AX-series servos over Protocol 1.0.
Protocol 1.0 has no Sync Read, no Operating_Mode register and no homing offset, so this robot reads
positions sequentially and calibrates by recording the range of motion only.
"""
config_class = AXArmConfig
name = "ax_arm"
def __init__(self, config: AXArmConfig):
super().__init__(config)
self.config = config
norm_mode_body = MotorNormMode.DEGREES if config.use_degrees else MotorNormMode.RANGE_M100_100
self.bus = DynamixelMotorsBus(
port=self.config.port,
# NOTE: dict order (pan, lift, elbow, gripper) must stay aligned with the URDF joints
# (robot_joint_1/2/3) and keep the gripper last for the kinematics pipeline. Only the
# motor IDs below reflect the physical bus wiring.
motors={
"shoulder_pan": Motor(3, "ax-12a", norm_mode_body),
"shoulder_lift": Motor(4, "ax-12a", norm_mode_body),
"elbow_flex": Motor(2, "ax-12a", norm_mode_body),
"gripper": Motor(1, "ax-12a", MotorNormMode.RANGE_0_100),
},
calibration=self.calibration,
protocol_version=1,
)
self.cameras = make_cameras_from_configs(config.cameras)
@property
def _motors_ft(self) -> dict[str, type]:
return {f"{motor}.pos": float for motor in self.bus.motors}
@property
def _cameras_ft(self) -> dict[str, tuple]:
return {cam: (self.cameras[cam].height, self.cameras[cam].width, 3) for cam in self.cameras}
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
return self._motors_ft
@property
def is_connected(self) -> bool:
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info("No matching calibration found, running calibration.")
self.calibrate()
for cam in self.cameras.values():
cam.connect()
self.configure()
logger.info(f"{self} connected.")
@property
def is_calibrated(self) -> bool:
# Protocol 1.0 cannot read back homing_offset/drive_mode (we repurpose them to store the
# URDF mapping: tick_ref and sign), so only the range limits are actually stored on the
# servos. Compare just those to decide whether the on-file calibration matches the hardware.
if not self.calibration:
return False
hw = self.bus.read_calibration()
return all(
self.calibration[m].range_min == hw[m].range_min
and self.calibration[m].range_max == hw[m].range_max
for m in self.bus.motors
)
def calibrate(self) -> None:
if self.calibration:
user_input = input(
f"Press ENTER to use provided calibration file associated with the id {self.id}, or type 'c' and press ENTER to run calibration: "
)
if user_input.strip().lower() != "c":
logger.info(f"Writing calibration file associated with the id {self.id} to the motors")
self.bus.write_calibration(self.calibration)
return
logger.info(f"\nRunning calibration of {self}")
# This arm cannot be backdriven with torque off, so each joint is jogged with the keyboard
# (torque on). Arm joints capture a reference pose (known URDF angle) plus the lower/upper
# travel limits; the reference tick and mounting sign encode the URDF mapping (see urdf_mapping).
captured = self._record_calibration()
self.calibration = {}
for motor, m in self.bus.motors.items():
c = captured[motor]
if motor in ARM_JOINTS:
lower, upper = c["lower limit"], c["upper limit"]
# sign: +1 if jogging toward the URDF-upper limit increased ticks, else -1.
drive_mode = 0 if upper >= lower else 1
self.calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=drive_mode,
homing_offset=c["reference"], # tick at the URDF reference pose
range_min=min(lower, upper),
range_max=max(lower, upper),
)
else: # gripper: plain range, no URDF mapping
lo, hi = c["min"], c["max"]
self.calibration[motor] = MotorCalibration(
id=m.id, drive_mode=0, homing_offset=0, range_min=min(lo, hi), range_max=max(lo, hi)
)
self.bus.write_calibration(self.calibration)
self._save_calibration()
print("Calibration saved to", self.calibration_fpath)
# Raw ticks the selected joint moves per keypress (~3° on an AX-12A).
_JOG_STEP = 10
def _capture_plan(self) -> list[tuple[str, str, float | None]]:
"""Ordered (motor, label, target_urdf_deg) captures. Arm joints: reference + lower/upper
URDF limits; gripper: plain min/max. ``target_urdf_deg`` is None when there is no URDF angle."""
plan: list[tuple[str, str, float | None]] = []
for motor in self.bus.motors:
if motor in ARM_JOINTS:
lower, upper = URDF_LIMITS_DEG[motor]
plan.append((motor, "reference", REFERENCE_URDF_DEG[motor]))
plan.append((motor, "lower limit", lower))
plan.append((motor, "upper limit", upper))
else:
plan += [(motor, "min", None), (motor, "max", None)]
return plan
def _record_calibration(self) -> dict[str, dict[str, int]]:
# Protocol 1.0 has no Sync Read, so positions are read sequentially, one motor at a time.
motor_names = list(self.bus.motors)
max_pos = min(self.bus.model_resolution_table[m.model] for m in self.bus.motors.values()) - 1
# Temporarily widen the angle limits to full travel so pre-existing (possibly narrow) limits do
# not cap the reachable range; write_calibration() sets them to the recorded min/max afterwards.
for motor in motor_names:
self.bus.write("CW_Angle_Limit", motor, 0)
self.bus.write("CCW_Angle_Limit", motor, max_pos)
self.bus.enable_torque()
plan = self._capture_plan()
captured: dict[str, dict[str, int]] = {motor: {} for motor in motor_names}
state = {"i": 0, "target": 0, "capture": False, "done": False}
state["target"] = int(self.bus.read("Present_Position", plan[0][0], normalize=False))
def on_key(name: str) -> None:
key = name.lower()
if key == "up":
state["target"] = min(max_pos, state["target"] + self._JOG_STEP)
elif key == "down":
state["target"] = max(0, state["target"] - self._JOG_STEP)
elif key == "enter":
state["capture"] = True
listener = create_key_listener(on_key, controls_help="up/down=jog, enter=capture")
if listener is None:
raise RuntimeError(
"Keyboard calibration requires an interactive terminal with a usable key listener."
)
print(
"Jog each joint to the requested pose and press ENTER to capture it.\n"
" up/down = jog | enter = capture"
)
try:
while not state["done"]:
motor, label, target_deg = plan[state["i"]]
self.bus.write("Goal_Position", motor, state["target"], normalize=False)
pos = int(self.bus.read("Present_Position", motor, normalize=False))
hint = f" (URDF {target_deg:+.0f} deg)" if target_deg is not None else ""
print(
f" [{state['i'] + 1}/{len(plan)}] jog '{motor}' to {label}{hint}: {pos:4d} tick ",
end="\r",
flush=True,
)
if state["capture"]:
state["capture"] = False
captured[motor][label] = pos
print(f" [{state['i'] + 1}/{len(plan)}] {motor} {label}{hint} = {pos} tick")
state["i"] += 1
state["done"] = state["i"] >= len(plan)
if not state["done"]:
state["target"] = int(
self.bus.read("Present_Position", plan[state["i"]][0], normalize=False)
)
time.sleep(0.02)
finally:
listener.stop()
print()
for motor, c in captured.items():
if len(set(c.values())) < len(c):
raise ValueError(f"Motor '{motor}' has duplicate captured ticks: {c}")
return captured
def configure(self) -> None:
# AX-series has no Operating_Mode/PID registers; configure_motors only lowers the return delay time.
with self.bus.torque_disabled():
self.bus.configure_motors()
@check_if_not_connected
def get_observation(self) -> RobotObservation:
start = time.perf_counter()
# Protocol 1.0 has no Sync Read, so read each motor sequentially.
obs_dict = {f"{motor}.pos": self.bus.read("Present_Position", motor) for motor in self.bus.motors}
dt_ms = (time.perf_counter() - start) * 1e3
logger.debug(f"{self} read state: {dt_ms:.1f}ms")
for cam_key, cam in self.cameras.items():
obs_dict[cam_key] = cam.async_read()
return obs_dict
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
goal_pos = {
key.removesuffix(".pos"): val
for key, val in action.items()
if isinstance(key, str) and key.endswith(".pos")
}
if not goal_pos:
return {}
if self.config.max_relative_target is not None:
present_pos = {motor: self.bus.read("Present_Position", motor) for motor in goal_pos}
goal_present_pos = {key: (g_pos, present_pos[key]) for key, g_pos in goal_pos.items()}
goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target)
# Sync Write is available on Protocol 1.0 (only Sync Read and Broadcast Ping are not).
self.bus.sync_write("Goal_Position", goal_pos)
return {f"{motor}.pos": val for motor, val in goal_pos.items()}
@check_if_not_connected
def disconnect(self) -> None:
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
logger.info(f"{self} disconnected.")
@@ -1,24 +0,0 @@
from dataclasses import dataclass, field
from lerobot.cameras import CameraConfig
from lerobot.robots import RobotConfig
@RobotConfig.register_subclass("ax_arm")
@dataclass
class AXArmConfig(RobotConfig):
"""Configuration for a 4-DoF Dynamixel AX-series (Protocol 1.0) arm."""
# Serial port the arm is connected to (e.g. "/dev/tty.usbserial-AL02L1E0").
port: str
disable_torque_on_disconnect: bool = True
# Limits the magnitude of relative positional targets for safety. Scalar (same for all motors) or a
# dict mapping motor names to their own cap.
max_relative_target: float | dict[str, float] | None = None
cameras: dict[str, CameraConfig] = field(default_factory=dict)
# Normalize body joints to [-100, 100] (and gripper to [0, 100]) when False, or to degrees when True.
use_degrees: bool = False
@@ -1,61 +0,0 @@
"""Mapping between AX motor ticks and the URDF joint convention.
The AX-12A reports raw ticks (0-1023) over ~300 deg of travel, with a zero and direction
unrelated to the URDF. We map ticks <-> URDF joint degrees with a per-joint affine transform
anchored at a reference pose captured during calibration::
q_urdf_deg = q_ref + sign * (tick - tick_ref) * SCALE
- ``SCALE`` : AX-12A mechanical travel per tick (300 deg / 1023 ticks).
- ``q_ref`` : URDF angle of the reference pose (:data:`REFERENCE_URDF_DEG`).
- ``tick_ref`` : tick captured at that reference pose, stored as ``MotorCalibration.homing_offset``.
- ``sign`` : mounting direction (+1/-1), stored as ``MotorCalibration.drive_mode`` (0 -> +1, 1 -> -1).
The ``homing_offset``/``drive_mode`` fields are unused by the Dynamixel Protocol-1.0 normalization
path, so repurposing them here does not affect anything else.
"""
from __future__ import annotations
import numpy as np
from lerobot.motors import MotorCalibration
# Arm joints in URDF order (base yaw, shoulder pitch, elbow pitch). The gripper is not remapped.
ARM_JOINTS = ("shoulder_pan", "shoulder_lift", "elbow_flex")
URDF_JOINT_NAMES = ["robot_joint_1", "robot_joint_2", "robot_joint_3"]
AX_TRAVEL_DEG = 300.0 # AX-12A mechanical travel over the full 0-1023 tick range
AX_MAX_TICK = 1023.0
SCALE = AX_TRAVEL_DEG / AX_MAX_TICK # URDF degrees per motor tick
# URDF joint angle (deg) of each arm joint at the calibration reference pose.
REFERENCE_URDF_DEG = {"shoulder_pan": 0.0, "shoulder_lift": 45.0, "elbow_flex": 90.0}
# URDF joint limits (deg) from ax_arm.urdf, used to guide the lower/upper jog during calibration.
URDF_LIMITS_DEG = {
"shoulder_pan": (-90.0, 90.0),
"shoulder_lift": (0.0, 90.0),
"elbow_flex": (0.0, 180.0),
}
def _sign(calib: MotorCalibration) -> float:
return -1.0 if calib.drive_mode else 1.0
def tick_to_urdf_deg(joint: str, tick: float, calib: MotorCalibration) -> float:
return REFERENCE_URDF_DEG[joint] + _sign(calib) * (tick - calib.homing_offset) * SCALE
def urdf_deg_to_tick(joint: str, q_deg: float, calib: MotorCalibration) -> int:
return int(round(calib.homing_offset + _sign(calib) * (q_deg - REFERENCE_URDF_DEG[joint]) / SCALE))
def ticks_to_urdf_vector(ticks: dict[str, float], calibration: dict[str, MotorCalibration]) -> np.ndarray:
"""Arm joint ticks -> URDF joint angles (deg), in :data:`ARM_JOINTS` order."""
return np.array([tick_to_urdf_deg(j, ticks[j], calibration[j]) for j in ARM_JOINTS], dtype=float)
def urdf_vector_to_ticks(q_deg: np.ndarray, calibration: dict[str, MotorCalibration]) -> dict[str, int]:
"""URDF joint angles (deg), in :data:`ARM_JOINTS` order -> arm joint ticks."""
return {j: urdf_deg_to_tick(j, float(q_deg[i]), calibration[j]) for i, j in enumerate(ARM_JOINTS)}
-19
View File
@@ -1,19 +0,0 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "lerobot_robot_ax_arm"
version = "0.1.0"
description = "Third-party LeRobot robot: 4-DoF Dynamixel AX-series (Protocol 1.0) arm"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"lerobot[dynamixel]",
]
[tool.setuptools.packages.find]
include = ["lerobot_robot_ax_arm*"]
[tool.setuptools.package-data]
lerobot_robot_ax_arm = ["urdf/*.urdf"]
+11 -96
View File
@@ -25,12 +25,7 @@ from typing import TYPE_CHECKING
from lerobot.utils.import_utils import _dynamixel_sdk_available, require_package
from ..encoding_utils import (
decode_sign_magnitude,
decode_twos_complement,
encode_sign_magnitude,
encode_twos_complement,
)
from ..encoding_utils import decode_twos_complement, encode_twos_complement
from ..motors_bus import Motor, MotorCalibration, NameOrID, SerialMotorsBus, Value, get_address
from .tables import (
AVAILABLE_BAUDRATES,
@@ -38,7 +33,6 @@ from .tables import (
MODEL_CONTROL_TABLE,
MODEL_ENCODING_TABLE,
MODEL_NUMBER_TABLE,
MODEL_PROTOCOL,
MODEL_RESOLUTION,
)
@@ -47,7 +41,7 @@ if TYPE_CHECKING or _dynamixel_sdk_available:
else:
dxl = None
PROTOCOL_VERSION = 2
PROTOCOL_VERSION = 2.0
DEFAULT_BAUDRATE = 1_000_000
DEFAULT_TIMEOUT_MS = 1000
@@ -119,47 +113,23 @@ class DynamixelMotorsBus(SerialMotorsBus):
port: str,
motors: dict[str, Motor],
calibration: dict[str, MotorCalibration] | None = None,
protocol_version: int = PROTOCOL_VERSION,
):
require_package("dynamixel-sdk", extra="dynamixel", import_name="dynamixel_sdk")
super().__init__(port, motors, calibration)
self.protocol_version = protocol_version
self._assert_same_protocol()
self.port_handler = dxl.PortHandler(self.port)
self.packet_handler = dxl.PacketHandler(protocol_version)
logger.debug(f"Using protocol version {protocol_version}")
self.packet_handler = dxl.PacketHandler(PROTOCOL_VERSION)
self.sync_reader = dxl.GroupSyncRead(self.port_handler, self.packet_handler, 0, 0)
self.sync_writer = dxl.GroupSyncWrite(self.port_handler, self.packet_handler, 0, 0)
self._comm_success = dxl.COMM_SUCCESS
self._no_error = 0x00
def _assert_same_protocol(self) -> None:
if any(MODEL_PROTOCOL[model] != self.protocol_version for model in self.models):
raise ValueError(
f"Some motors are incompatible with protocol_version={self.protocol_version}. "
f"Expected per-model protocols: "
f"{ {model: MODEL_PROTOCOL[model] for model in self.models} }"
)
def _assert_protocol_is_compatible(self, instruction_name: str) -> None:
if instruction_name == "sync_read" and self.protocol_version == 1:
raise NotImplementedError(
"'Sync Read' is not available with Dynamixel motors using Protocol 1. Use 'Read' sequentially instead."
)
if instruction_name == "broadcast_ping" and self.protocol_version == 1:
raise NotImplementedError(
"'Broadcast Ping' is not available with Dynamixel motors using Protocol 1. Use 'Ping' sequentially instead."
)
pass
def _handshake(self) -> None:
self._assert_motors_exist()
def _find_single_motor(self, motor: str, initial_baudrate: int | None = None) -> tuple[int, int]:
if self.protocol_version == 1:
return self._find_single_motor_p1(motor, initial_baudrate)
return self._find_single_motor_p2(motor, initial_baudrate)
def _find_single_motor_p2(self, motor: str, initial_baudrate: int | None = None) -> tuple[int, int]:
model = self.motors[motor].model
search_baudrates = (
[initial_baudrate] if initial_baudrate is not None else self.model_baudrate_table[model]
@@ -181,29 +151,6 @@ class DynamixelMotorsBus(SerialMotorsBus):
raise RuntimeError(f"Motor '{motor}' (model '{model}') was not found. Make sure it is connected.")
def _find_single_motor_p1(self, motor: str, initial_baudrate: int | None = None) -> tuple[int, int]:
# Protocol 1.0 has no Broadcast Ping, so scan IDs sequentially with individual pings.
model = self.motors[motor].model
search_baudrates = (
[initial_baudrate] if initial_baudrate is not None else self.model_baudrate_table[model]
)
expected_model_nb = self.model_number_table[model]
for baudrate in search_baudrates:
self.set_baudrate(baudrate)
for id_ in range(dxl.MAX_ID + 1):
found_model = self.ping(id_)
if found_model is not None:
if found_model != expected_model_nb:
raise RuntimeError(
f"Found one motor on {baudrate=} with id={id_} but it has a "
f"model number '{found_model}' different than the one expected: '{expected_model_nb}'. "
f"Make sure you are connected only connected to the '{motor}' motor (model '{model}')."
)
return baudrate, id_
raise RuntimeError(f"Motor '{motor}' (model '{model}') was not found. Make sure it is connected.")
def configure_motors(self, return_delay_time=0) -> None:
# By default, Dynamixel motors have a 500µs delay response time (corresponding to a value of 250 on
# the 'Return_Delay_Time' address). We ensure this is reduced to the minimum of 2µs (value of 0).
@@ -215,20 +162,6 @@ class DynamixelMotorsBus(SerialMotorsBus):
return self.calibration == self.read_calibration()
def read_calibration(self) -> dict[str, MotorCalibration]:
if self.protocol_version == 1:
# AX-series (Protocol 1.0) has no Homing_Offset/Drive_Mode registers, uses CW/CCW angle
# limits as its position range, and does not support Sync Read.
calibration = {}
for motor, m in self.motors.items():
calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=0,
homing_offset=0,
range_min=int(self.read("CW_Angle_Limit", motor, normalize=False)),
range_max=int(self.read("CCW_Angle_Limit", motor, normalize=False)),
)
return calibration
offsets = self.sync_read("Homing_Offset", normalize=False)
mins = self.sync_read("Min_Position_Limit", normalize=False)
maxes = self.sync_read("Max_Position_Limit", normalize=False)
@@ -248,13 +181,9 @@ class DynamixelMotorsBus(SerialMotorsBus):
def write_calibration(self, calibration_dict: dict[str, MotorCalibration], cache: bool = True) -> None:
for motor, calibration in calibration_dict.items():
if self.protocol_version == 1:
self.write("CW_Angle_Limit", motor, calibration.range_min)
self.write("CCW_Angle_Limit", motor, calibration.range_max)
else:
self.write("Homing_Offset", motor, calibration.homing_offset)
self.write("Min_Position_Limit", motor, calibration.range_min)
self.write("Max_Position_Limit", motor, calibration.range_max)
self.write("Homing_Offset", motor, calibration.homing_offset)
self.write("Min_Position_Limit", motor, calibration.range_min)
self.write("Max_Position_Limit", motor, calibration.range_max)
if cache:
self.calibration = calibration_dict
@@ -276,12 +205,8 @@ class DynamixelMotorsBus(SerialMotorsBus):
model = self._id_to_model(id_)
encoding_table = self.model_encoding_table.get(model)
if encoding_table and data_name in encoding_table:
param = encoding_table[data_name]
# Protocol 1.0 (AX-series) uses sign-magnitude; Protocol 2.0 (X-series) two's complement.
if self.protocol_version == 1:
ids_values[id_] = encode_sign_magnitude(ids_values[id_], param)
else:
ids_values[id_] = encode_twos_complement(ids_values[id_], param)
n_bytes = encoding_table[data_name]
ids_values[id_] = encode_twos_complement(ids_values[id_], n_bytes)
return ids_values
@@ -290,12 +215,8 @@ class DynamixelMotorsBus(SerialMotorsBus):
model = self._id_to_model(id_)
encoding_table = self.model_encoding_table.get(model)
if encoding_table and data_name in encoding_table:
param = encoding_table[data_name]
# Protocol 1.0 (AX-series) uses sign-magnitude; Protocol 2.0 (X-series) two's complement.
if self.protocol_version == 1:
ids_values[id_] = decode_sign_magnitude(ids_values[id_], param)
else:
ids_values[id_] = decode_twos_complement(ids_values[id_], param)
n_bytes = encoding_table[data_name]
ids_values[id_] = decode_twos_complement(ids_values[id_], n_bytes)
return ids_values
@@ -304,11 +225,6 @@ class DynamixelMotorsBus(SerialMotorsBus):
On Dynamixel Motors:
Present_Position = Actual_Position + Homing_Offset
"""
if self.protocol_version == 1:
raise NotImplementedError(
"AX-series motors (Protocol 1.0) have no Homing_Offset register; "
"calibrate the position range via CW/CCW angle limits instead."
)
half_turn_homings: dict[NameOrID, Value] = {}
for motor, pos in positions.items():
model = self._get_motor_model(motor)
@@ -332,7 +248,6 @@ class DynamixelMotorsBus(SerialMotorsBus):
return data
def broadcast_ping(self, num_retry: int = 0, raise_on_error: bool = False) -> dict[int, int] | None:
self._assert_protocol_is_compatible("broadcast_ping")
for n_try in range(1 + num_retry):
data_list, comm = self.packet_handler.broadcastPing(self.port_handler)
if self._is_comm_success(comm):
-86
View File
@@ -33,58 +33,6 @@
# 2. We can change the value of the MyControlTableKey enums without impacting the client code
# {data_name: (address, size_byte)}
# https://emanual.robotis.com/docs/en/dxl/ax/{MODEL}/#control-table
AX_SERIES_CONTROL_TABLE = {
# EEPROM Area
"Model_Number": (0, 2),
"Firmware_Version": (2, 1),
"ID": (3, 1),
"Baud_Rate": (4, 1),
"Return_Delay_Time": (5, 1),
"CW_Angle_Limit": (6, 2),
"CCW_Angle_Limit": (8, 2),
"Temperature_Limit": (11, 1),
"Min_Voltage_Limit": (12, 1),
"Max_Voltage_Limit": (13, 1),
"Max_Torque": (14, 2),
"Status_Return_Level": (16, 1),
"Alarm_LED": (17, 1),
"Shutdown": (18, 1),
# RAM Area
"Torque_Enable": (24, 1),
"LED": (25, 1),
"CW_Compliance_Margin": (26, 1),
"CCW_Compliance_Margin": (27, 1),
"CW_Compliance_Slope": (28, 1),
"CCW_Compliance_Slope": (29, 1),
"Goal_Position": (30, 2),
"Moving_Speed": (32, 2),
"Torque_Limit": (34, 2),
"Present_Position": (36, 2),
"Present_Speed": (38, 2),
"Present_Load": (40, 2),
"Present_Voltage": (42, 1),
"Present_Temperature": (43, 1),
"Registered": (44, 1),
"Moving": (46, 1),
"Lock": (47, 1),
"Punch": (48, 2),
}
# https://emanual.robotis.com/docs/en/dxl/ax/{MODEL}/#baud-rate4
AX_SERIES_BAUDRATE_TABLE = {
9_600: 207,
19_200: 103,
57_600: 34,
115_200: 16,
200_000: 9,
250_000: 7,
400_000: 4,
500_000: 3,
1_000_000: 1,
}
# {data_name: (address, size_byte)}
# https://emanual.robotis.com/docs/en/dxl/x/{MODEL}/#control-table
X_SERIES_CONTROL_TABLE = {
@@ -166,15 +114,6 @@ X_SERIES_ENCODINGS_TABLE = {
"Present_Velocity": X_SERIES_CONTROL_TABLE["Present_Velocity"][1],
}
# {data_name: sign_bit_index}
# AX-series speed/load use sign-magnitude encoding with the direction stored in bit 10.
# Position registers (0-1023) are unsigned and therefore need no sign handling.
AX_SERIES_ENCODINGS_TABLE = {
"Moving_Speed": 10,
"Present_Speed": 10,
"Present_Load": 10,
}
MODEL_ENCODING_TABLE = {
"x_series": X_SERIES_ENCODINGS_TABLE,
"xl330-m077": X_SERIES_ENCODINGS_TABLE,
@@ -183,8 +122,6 @@ MODEL_ENCODING_TABLE = {
"xm430-w350": X_SERIES_ENCODINGS_TABLE,
"xm540-w270": X_SERIES_ENCODINGS_TABLE,
"xc430-w150": X_SERIES_ENCODINGS_TABLE,
"ax_series": AX_SERIES_ENCODINGS_TABLE,
"ax-12a": AX_SERIES_ENCODINGS_TABLE,
}
# {model: model_resolution}
@@ -197,8 +134,6 @@ MODEL_RESOLUTION = {
"xm430-w350": 4096,
"xm540-w270": 4096,
"xc430-w150": 4096,
"ax_series": 1024,
"ax-12a": 1024,
}
# {model: model_number}
@@ -210,13 +145,10 @@ MODEL_NUMBER_TABLE = {
"xm430-w350": 1020,
"xm540-w270": 1120,
"xc430-w150": 1070,
"ax-12a": 12,
}
# {model: available_operating_modes}
# https://emanual.robotis.com/docs/en/dxl/x/{MODEL}/#operating-mode11
# Note: AX-series (Protocol 1.0) has no Operating_Mode register; joint vs wheel mode is
# selected via the CW/CCW angle limits, so it is intentionally absent from this table.
MODEL_OPERATING_MODES = {
"xl330-m077": [0, 1, 3, 4, 5, 16],
"xl330-m288": [0, 1, 3, 4, 5, 16],
@@ -234,8 +166,6 @@ MODEL_CONTROL_TABLE = {
"xm430-w350": X_SERIES_CONTROL_TABLE,
"xm540-w270": X_SERIES_CONTROL_TABLE,
"xc430-w150": X_SERIES_CONTROL_TABLE,
"ax_series": AX_SERIES_CONTROL_TABLE,
"ax-12a": AX_SERIES_CONTROL_TABLE,
}
MODEL_BAUDRATE_TABLE = {
@@ -246,22 +176,6 @@ MODEL_BAUDRATE_TABLE = {
"xm430-w350": X_SERIES_BAUDRATE_TABLE,
"xm540-w270": X_SERIES_BAUDRATE_TABLE,
"xc430-w150": X_SERIES_BAUDRATE_TABLE,
"ax_series": AX_SERIES_BAUDRATE_TABLE,
"ax-12a": AX_SERIES_BAUDRATE_TABLE,
}
# {model: protocol_version}
# AX series communicate over Protocol 1.0, X series over Protocol 2.0.
MODEL_PROTOCOL = {
"x_series": 2,
"xl330-m077": 2,
"xl330-m288": 2,
"xl430-w250": 2,
"xm430-w350": 2,
"xm540-w270": 2,
"xc430-w150": 2,
"ax_series": 1,
"ax-12a": 1,
}
AVAILABLE_BAUDRATES = [
@@ -30,13 +30,19 @@ This is a Gaussian Actor policy (Gaussian policy with a tanh squash) — the pol
{% elif model_name == "eo1" %}
[EO-1](https://huggingface.co/papers/2508.21112) is a Vision-Language-Action model for general robot control. It pairs a Qwen2.5-VL backbone for vision-language understanding with a continuous flow-matching action head that denoises action chunks.
{% elif model_name == "groot" %}
[GR00T N1.5](https://github.com/NVIDIA/Isaac-GR00T) is an open, cross-embodiment foundation model from NVIDIA for generalized humanoid robot reasoning and skills. It takes language and images as input and uses a flow-matching action transformer to predict actions conditioned on vision, language, and proprioception.
[GR00T N1.7](https://github.com/NVIDIA/Isaac-GR00T) is an open, cross-embodiment foundation model from NVIDIA for generalized humanoid robot reasoning and skills. It uses a Cosmos-Reason2/Qwen3-VL backbone and a flow-matching action transformer to predict actions conditioned on vision, language, and proprioception.
{% elif model_name == "multi_task_dit" %}
[Multi-Task Diffusion Transformer (DiT)](https://huggingface.co/papers/2507.05331) extends Diffusion Policy with a large Diffusion Transformer and text + vision conditioning for multi-task robot learning. It supports both diffusion and flow-matching objectives and reaches high dexterity with only ~450M parameters.
{% elif model_name == "wall_x" %}
[WALL-OSS](https://huggingface.co/papers/2509.11766) is an open-source foundation model for embodied intelligence from XSquare Robot. Built on Qwen2.5-VL, it uses a tightly-coupled multimodal architecture with flow matching to unify semantic reasoning and high-frequency action generation for cross-embodiment control.
{% elif model_name == "xvla" %}
[X-VLA](https://huggingface.co/papers/2510.10274) is a soft-prompted, flow-matching Vision-Language-Action framework that treats each robot or hardware setup as a "task" encoded with a small set of learnable Soft Prompt embeddings, letting a single model reconcile diverse robot morphologies, sensors, and action spaces.
{% elif model_name == "evo1" %}
[EVO1](https://github.com/MINT-SJTU/Evo-1) is a Vision-Language-Action policy built around an InternVL3 backbone and a continuous flow-matching action head. It embeds camera images and the language instruction with InternVL3 and predicts future action chunks via flow matching.
{% elif model_name == "fastwam" %}
[FastWAM](https://arxiv.org/abs/2603.16666) is a World Action Model policy that keeps video world-modeling during training but predicts actions directly at inference time, initializing its visual world-model components from the Wan2.2 video-diffusion stack.
{% elif model_name == "lingbot_va" %}
[LingBot-VA](https://github.com/Robbyant/lingbot-va) is an autoregressive video-action world-model policy built on the Wan2.2 video-diffusion stack. It interleaves the prediction of future video latents and robot actions in a single autoregressive sequence, feeding observed keyframes back into its KV cache for closed-loop world modeling.
{% else %}
This is a **{{ model_name }}** policy trained with [LeRobot](https://github.com/huggingface/lerobot).
{% endif %}
@@ -75,7 +81,10 @@ This policy has been trained and pushed to the Hub using [LeRobot](https://githu
"groot": "groot",
"xvla": "xvla",
"multi_task_dit": "multi_task_dit",
"wall_x": "walloss"
"wall_x": "walloss",
"evo1": "evo1",
"fastwam": "fastwam",
"lingbot_va": "lingbot_va"
} %}
{% if policy_docs.get(model_name) %}Learn how to train and run it in the [LeRobot {{ model_name }} guide](https://huggingface.co/docs/lerobot/main/en/{{ policy_docs[model_name] }}), or browse the [full documentation](https://huggingface.co/docs/lerobot/index).
{% else %}See the [full LeRobot documentation](https://huggingface.co/docs/lerobot/index).
-33
View File
@@ -107,39 +107,6 @@ def test_abc_implementation(dummy_motors):
DynamixelMotorsBus(port="/dev/dummy-port", motors=dummy_motors)
def test_assert_same_protocol_rejects_mismatch():
"""AX-series motors (Protocol 1.0) cannot be used with the default Protocol 2.0."""
motors = {"ax": Motor(5, "ax-12a", MotorNormMode.RANGE_M100_100)}
with pytest.raises(ValueError, match="incompatible with protocol_version"):
DynamixelMotorsBus(port="/dev/dummy-port", motors=motors)
@pytest.mark.parametrize("instruction", ["sync_read", "broadcast_ping"])
def test_protocol1_unsupported_instructions(instruction):
motors = {"ax": Motor(5, "ax-12a", MotorNormMode.RANGE_M100_100)}
bus = DynamixelMotorsBus(port="/dev/dummy-port", motors=motors, protocol_version=1)
with pytest.raises(NotImplementedError):
bus._assert_protocol_is_compatible(instruction)
@pytest.mark.parametrize(
"protocol_version, model, data_name, value",
[
(1, "ax-12a", "Present_Speed", -300),
(1, "ax-12a", "Moving_Speed", 300),
(2, "xl430-w250", "Present_Velocity", -300),
],
ids=["ax-sign-magnitude-neg", "ax-sign-magnitude-pos", "x-twos-complement-neg"],
)
def test_sign_encoding_roundtrip(protocol_version, model, data_name, value):
motors = {"m": Motor(5, model, MotorNormMode.RANGE_M100_100)}
bus = DynamixelMotorsBus(port="/dev/dummy-port", motors=motors, protocol_version=protocol_version)
encoded = bus._encode_sign(data_name, {5: value})
assert encoded[5] >= 0
decoded = bus._decode_sign(data_name, encoded)
assert decoded[5] == value
@pytest.mark.parametrize("id_", [1, 2, 3])
def test_ping(id_, mock_motors, dummy_motors):
expected_model_nb = MODEL_NUMBER_TABLE[dummy_motors[f"dummy_{id_}"].model]
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Hand Joint Visualizer</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
.controls {
padding: 15px;
background-color: #f5f5f5;
z-index: 100;
}
.status {
padding: 10px;
border-radius: 5px;
margin: 10px 0;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
#canvas-container {
flex: 3;
position: relative;
}
#sidebar {
flex: 1;
padding: 15px;
background-color: #f8f9fa;
overflow-y: auto;
max-width: 300px;
border-left: 1px solid #ddd;
}
.joint-info {
margin-bottom: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.joint-name {
font-weight: bold;
}
.joint-value {
font-family: monospace;
}
.bar-container {
width: 100%;
background-color: #e0e0e0;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-top: 5px;
}
.bar {
height: 100%;
background-color: #4CAF50;
width: 0%;
transition: width 0.2s ease-in-out;
}
.log-container {
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 150px;
overflow-y: auto;
font-family: monospace;
background-color: #f8f9fa;
}
.view-controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 10;
}
.view-button {
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 5px 10px;
margin-right: 5px;
border-radius: 3px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="controls">
<button id="connectButton">Connect to Device</button>
<button id="disconnectButton" disabled>Disconnect</button>
<select id="baudRate">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
</select>
<span id="statusIndicator" class="status disconnected">Status: Disconnected</span>
</div>
<div class="container">
<div id="canvas-container">
<!-- 3D canvas will be inserted here -->
<div class="view-controls">
<button class="view-button" id="frontView">Front</button>
<button class="view-button" id="sideView">Side</button>
<button class="view-button" id="topView">Top</button>
<button class="view-button" id="resetView">Reset</button>
</div>
</div>
<div id="sidebar">
<h3>Joint Values</h3>
<div id="jointsContainer">
<!-- Joint info will be added here -->
</div>
<div class="log-container" id="logContainer">
<!-- Log messages will be added here -->
</div>
</div>
</div>
<!-- Import Three.js -->
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script>
<script src="script.js"></script>
</body>
</html>
+669
View File
@@ -0,0 +1,669 @@
// === Hand Visualizer with Pre-Connect Sliders + Per-Joint Angle Limits ===
// Assumes your HTML already has elements with the following IDs:
// connectButton, disconnectButton, baudRate, statusIndicator, jointsContainer, logContainer,
// canvas-container, frontView, sideView, topView, resetView
// Requires Three.js + OrbitControls loaded on the page.
// -------------------- Config --------------------
const MAX_JOINTS = 16;
const RAW_MIN = 0, RAW_MAX = 4096;
const RAW_CENTER = (RAW_MIN + RAW_MAX) / 2;
const DEG = Math.PI / 180;
const UI_DEG_MIN = -90, UI_DEG_MAX = 90; // UI sliders for angle limits
// -------------------- State --------------------
let port;
let reader;
let keepReading = false;
let isConnected = false;
const decoder = new TextDecoder();
let inputBuffer = '';
let jointValues = new Array(MAX_JOINTS).fill(RAW_CENTER);
// Auto-calibration: track observed min/max per joint
let observedMin = new Array(MAX_JOINTS).fill(Infinity);
let observedMax = new Array(MAX_JOINTS).fill(-Infinity);
let calibrationEnabled = true;
// Three.js
let scene, camera, renderer, controls;
let hand = { palm: null, fingers: [] };
// DOM
const connectButton = document.getElementById('connectButton');
const disconnectButton = document.getElementById('disconnectButton');
const baudRateSelect = document.getElementById('baudRate');
const statusIndicator = document.getElementById('statusIndicator');
const jointsContainer = document.getElementById('jointsContainer');
const logContainer = document.getElementById('logContainer');
const canvasContainer = document.getElementById('canvas-container');
const frontViewBtn = document.getElementById('frontView');
const sideViewBtn = document.getElementById('sideView');
const topViewBtn = document.getElementById('topView');
const resetViewBtn = document.getElementById('resetView');
// Helpers
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const invLerp = (a, b, x) => clamp((x - a) / (b - a), 0, 1);
// -------------------- Joint Map with per-joint angle limits --------------------
const fingerJointMap = [
// Thumb (4)
{ finger:0, joint:0, type:'CMC_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:1, type:'CMC_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:0, joint:2, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
{ finger:0, joint:3, type:'IP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Index (3)
{ finger:1, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:1, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:1, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Middle (3)
{ finger:2, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:2, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:true }, // +45° only
// Ring (3)
{ finger:3, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:true },
{ finger:3, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:3, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false }, // +45° only
// Pinky (3)
{ finger:4, joint:0, type:'MCP_ABDUCTION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:1, type:'MCP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false },
{ finger:4, joint:2, type:'PIP_FLEXION', min:RAW_MIN, max:RAW_MAX, inverted:false } // +45° only
];
// Assign angle limits (radians) per joint (default ±45°, exceptions: +45° only)
for (const j of fingerJointMap) {
const isThumb = j.finger === 0;
const isPIP = j.type === 'PIP_FLEXION';
let minA = -45 * DEG, maxA = +45 * DEG;
if ((isThumb && (j.type === 'MCP_FLEXION' || j.type === 'IP_FLEXION')) || (!isThumb && isPIP)) {
minA = 0;
maxA = +45 * DEG;
}
j.angleMin = minA;
j.angleMax = maxA;
}
// -------------------- UI: Joint Panel --------------------
const uiRefs = []; // per joint: { valueLabel, bar, barWrap, slider, invertChk, minDeg, maxDeg }
function initializeJointElements() {
jointsContainer.innerHTML = '';
uiRefs.length = 0;
for (let i = 0; i < MAX_JOINTS; i++) {
const wrap = document.createElement('div');
wrap.className = 'joint-info';
const fingerIndex = i < 4 ? 0 : Math.floor((i - 4) / 3) + 1;
const jointInfo = fingerJointMap[i];
const jointType = jointInfo?.type || 'Unknown';
const fingerName = ['Thumb', 'Index', 'Middle', 'Ring', 'Pinky'][fingerIndex];
// Header
const nameEl = document.createElement('div');
nameEl.className = 'joint-name';
nameEl.textContent = `${fingerName} ${jointType}`;
// Value + bar
const valueEl = document.createElement('div');
valueEl.className = 'joint-value';
valueEl.textContent = `Value: ${jointValues[i]}`;
const barWrap = document.createElement('div');
barWrap.className = 'bar-container';
const barEl = document.createElement('div');
barEl.className = 'bar';
barWrap.appendChild(barEl);
// Slider for pre-connect manual control
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(RAW_MIN);
slider.max = String(RAW_MAX);
slider.value = String(jointValues[i]);
slider.step = '1';
slider.className = 'joint-slider';
slider.addEventListener('input', () => {
if (isConnected) return; // ignore while connected
let v = parseInt(slider.value, 10);
if (jointInfo?.inverted) v = (jointInfo.min + jointInfo.max) - v;
jointValues[i] = clamp(jointInfo ? v : 0, RAW_MIN, RAW_MAX);
updateJointDisplay(i, jointValues[i]);
updateHandModel();
});
// Invert checkbox
const invertLbl = document.createElement('label');
invertLbl.className = 'invert-toggle';
const invertChk = document.createElement('input');
invertChk.type = 'checkbox';
invertChk.checked = !!jointInfo?.inverted;
invertChk.addEventListener('change', () => {
if (jointInfo) jointInfo.inverted = invertChk.checked;
addLogMessage(`${fingerName} ${jointType} inversion ${invertChk.checked ? 'enabled' : 'disabled'}`);
});
invertLbl.appendChild(invertChk);
invertLbl.appendChild(document.createTextNode('Invert Values'));
// Angle limits (deg) controls
const limitsRow = document.createElement('div');
limitsRow.className = 'limits-row';
const minDeg = document.createElement('input');
minDeg.type = 'number';
minDeg.min = String(UI_DEG_MIN);
minDeg.max = String(UI_DEG_MAX);
minDeg.step = '1';
minDeg.value = String(Math.round((jointInfo.angleMin || 0) / DEG));
minDeg.className = 'limit-num';
const maxDeg = document.createElement('input');
maxDeg.type = 'number';
maxDeg.min = String(UI_DEG_MIN);
maxDeg.max = String(UI_DEG_MAX);
maxDeg.step = '1';
maxDeg.value = String(Math.round((jointInfo.angleMax || 0) / DEG));
maxDeg.className = 'limit-num';
const minLbl = document.createElement('span'); minLbl.textContent = 'min°';
const maxLbl = document.createElement('span'); maxLbl.textContent = 'max°';
minLbl.className = 'limit-label'; maxLbl.className = 'limit-label';
function syncLimits() {
let mn = parseFloat(minDeg.value);
let mx = parseFloat(maxDeg.value);
if (isNaN(mn)) mn = -45;
if (isNaN(mx)) mx = +45;
if (mn > mx) [mn, mx] = [mx, mn];
jointInfo.angleMin = clamp(mn, UI_DEG_MIN, UI_DEG_MAX) * DEG;
jointInfo.angleMax = clamp(mx, UI_DEG_MIN, UI_DEG_MAX) * DEG;
minDeg.value = String(Math.round(jointInfo.angleMin / DEG));
maxDeg.value = String(Math.round(jointInfo.angleMax / DEG));
updateHandModel();
}
minDeg.addEventListener('change', syncLimits);
maxDeg.addEventListener('change', syncLimits);
limitsRow.appendChild(minLbl);
limitsRow.appendChild(minDeg);
limitsRow.appendChild(maxLbl);
limitsRow.appendChild(maxDeg);
// Calibration controls
const calibRow = document.createElement('div');
calibRow.className = 'calib-row';
const resetCalibBtn = document.createElement('button');
resetCalibBtn.textContent = 'Reset Calib';
resetCalibBtn.className = 'calib-btn';
resetCalibBtn.addEventListener('click', () => {
observedMin[i] = Infinity;
observedMax[i] = -Infinity;
addLogMessage(`Reset calibration for ${fingerName} ${jointType}`);
});
const calibStatus = document.createElement('span');
calibStatus.className = 'calib-status';
calibStatus.textContent = `Range: --`;
calibRow.appendChild(resetCalibBtn);
calibRow.appendChild(calibStatus);
// Compose
wrap.appendChild(nameEl);
wrap.appendChild(valueEl);
wrap.appendChild(barWrap);
wrap.appendChild(slider);
wrap.appendChild(invertLbl);
wrap.appendChild(limitsRow);
wrap.appendChild(calibRow);
jointsContainer.appendChild(wrap);
uiRefs[i] = { valueLabel: valueEl, bar: barEl, barWrap, slider, invertChk, minDeg, maxDeg, nameEl, calibStatus };
}
setConnectedUI(false); // initial state: sliders active
}
// Toggle UI between pre-connect SLIDERS vs post-connect BARS
function setConnectedUI(connected) {
isConnected = connected;
for (let i = 0; i < uiRefs.length; i++) {
const ui = uiRefs[i];
if (!ui) continue;
// Show bars when connected; sliders disabled/hidden
ui.barWrap.style.display = connected ? '' : 'none';
ui.slider.disabled = connected;
ui.slider.style.display = connected ? 'none' : '';
}
// Reset calibration when connecting
if (connected) {
observedMin.fill(Infinity);
observedMax.fill(-Infinity);
addLogMessage('Calibration reset - move joints through full range for best results');
}
}
// Update joint display (value text + bar color/width + slider position if needed)
function updateJointDisplay(jointIndex, value) {
const ui = uiRefs[jointIndex];
const info = fingerJointMap[jointIndex];
if (!ui || !info) return;
ui.valueLabel.textContent = `Value: ${value}`;
// bar
const min = info.min, max = info.max;
const pct = clamp((value - min) / (max - min), 0, 1) * 100;
ui.bar.style.width = `${pct}%`;
const hue = Math.floor(pct * 1.2); // 0..120
ui.bar.style.backgroundColor = `hsl(${hue}, 80%, 50%)`;
// slider (only meaningful when not connected; keep in sync anyway)
const rawForSlider = info.inverted ? (info.min + info.max) - value : value;
if (!isConnected) ui.slider.value = String(clamp(Math.round(rawForSlider), RAW_MIN, RAW_MAX));
}
// -------------------- Serial I/O --------------------
async function readSerialData() {
while (port?.readable && keepReading) {
reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) processData(decoder.decode(value));
}
} catch (err) {
console.error('Error reading:', err);
addLogMessage(`Error: ${err.message}`);
break;
} finally {
reader.releaseLock();
}
}
}
function processData(chunk) {
inputBuffer += chunk;
let idx;
while ((idx = inputBuffer.indexOf('\n')) !== -1) {
const line = inputBuffer.slice(0, idx).trim();
inputBuffer = inputBuffer.slice(idx + 1);
const vals = line.split(/\s+/).map(v => parseInt(v, 10));
if (vals.length === MAX_JOINTS && vals.every(v => Number.isFinite(v))) {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
let rawValue = vals[i];
// Update calibration tracking
if (calibrationEnabled) {
observedMin[i] = Math.min(observedMin[i], rawValue);
observedMax[i] = Math.max(observedMax[i], rawValue);
// Update calibration display
const ui = uiRefs[i];
if (ui && ui.calibStatus) {
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity) {
ui.calibStatus.textContent = `Range: ${observedMin[i]}-${observedMax[i]}`;
}
}
// Remap observed range to target range
if (observedMin[i] !== Infinity && observedMax[i] !== -Infinity && observedMax[i] > observedMin[i]) {
const observedRange = observedMax[i] - observedMin[i];
const targetRange = info.max - info.min;
const normalizedValue = (rawValue - observedMin[i]) / observedRange;
rawValue = info.min + (normalizedValue * targetRange);
}
}
let v = clamp(rawValue, info.min, info.max);
if (info.inverted) v = (info.min + info.max) - v;
jointValues[i] = v;
updateJointDisplay(i, v);
}
updateHandModel();
} else {
addLogMessage(`Received: ${line}`);
}
}
}
async function connectToDevice() {
try {
port = await navigator.serial.requestPort();
const baudRate = parseInt(baudRateSelect.value, 10) || 115200;
await port.open({ baudRate });
keepReading = true;
setConnectedUI(true);
statusIndicator.textContent = 'Status: Connected';
statusIndicator.className = 'status connected';
connectButton.disabled = true;
disconnectButton.disabled = false;
baudRateSelect.disabled = true;
addLogMessage(`Connected at ${baudRate} baud`);
readSerialData();
} catch (e) {
console.error('Connect error:', e);
addLogMessage(`Connection error: ${e.message}`);
}
}
async function disconnectFromDevice() {
try {
keepReading = false;
if (reader) {
try { reader.cancel(); } catch {}
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
addLogMessage(`Disconnection error: ${e.message}`);
} finally {
setConnectedUI(false);
statusIndicator.textContent = 'Status: Disconnected';
statusIndicator.className = 'status disconnected';
connectButton.disabled = false;
disconnectButton.disabled = true;
baudRateSelect.disabled = false;
addLogMessage('Disconnected');
}
}
// -------------------- Three.js Scene --------------------
function initThreeJS() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
camera = new THREE.PerspectiveCamera(
75,
canvasContainer.clientWidth / canvasContainer.clientHeight,
0.1, 1000
);
camera.position.set(0, 15, 15);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.25;
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.5);
dir1.position.set(1, 1, 1);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-1, 1, -1);
scene.add(dir2);
const gridHelper = new THREE.GridHelper(20, 20);
scene.add(gridHelper);
createHandModel();
window.addEventListener('resize', onWindowResize);
animate();
}
function createHandModel() {
const palmMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const fingerMaterial = new THREE.MeshPhongMaterial({ color: 0xf5c396 });
const jointMaterial = new THREE.MeshPhongMaterial({ color: 0xe3a977 });
const palmGeometry = new THREE.BoxGeometry(7, 1, 8);
hand.palm = new THREE.Mesh(palmGeometry, palmMaterial);
hand.palm.position.set(0, 0, 0);
hand.palm.rotation.x = Math.PI / 2; // hand vertical, palm facing forward
scene.add(hand.palm);
const fingerWidth = 1, fingerHeight = 0.8;
const fingerSegmentLengths = [3, 2, 1.5];
const thumbSegmentLengths = [2, 2, 1.5];
const fingerBasePositions = [
[ 3, 0, -2], // Thumb
[ 1.5,-0.5,-4], // Index
[ 0, -0.5,-4], // Middle
[-1.5,-0.5,-4], // Ring
[-3, -0.5,-4], // Pinky
];
const fingerBaseRot = [
{ x:0, y:-Math.PI/3, z: Math.PI/3 }, // Thumb
{ x:0, y:-Math.PI/48, z: 0 },
{ x:0, y: Math.PI/48, z: 0 },
{ x:0, y: Math.PI/32, z: 0 },
{ x:0, y: Math.PI/24, z: 0 }
];
for (let fIdx = 0; fIdx < 5; fIdx++) {
const finger = { name:['Thumb','Index','Middle','Ring','Pinky'][fIdx], segments:[], joints:[] };
const isThumb = fIdx === 0;
const segLens = isThumb ? thumbSegmentLengths : fingerSegmentLengths;
finger.group = new THREE.Group();
finger.group.position.set(...fingerBasePositions[fIdx]);
finger.group.rotation.x = fingerBaseRot[fIdx].x;
finger.group.rotation.y = fingerBaseRot[fIdx].y;
finger.group.rotation.z = fingerBaseRot[fIdx].z;
finger.group.userData.baseRot = {
x:finger.group.rotation.x,
y:finger.group.rotation.y,
z:finger.group.rotation.z
};
hand.palm.add(finger.group);
let parent = finger.group;
for (let s = 0; s < segLens.length; s++) {
const segGroup = new THREE.Group();
const jGeom = new THREE.SphereGeometry(fingerWidth * 0.6, 8, 8);
const joint = new THREE.Mesh(jGeom, jointMaterial);
segGroup.add(joint);
const segGeom = new THREE.BoxGeometry(fingerWidth, fingerHeight, segLens[s]);
const seg = new THREE.Mesh(segGeom, fingerMaterial);
seg.position.z = -segLens[s] / 2;
segGroup.add(seg);
parent.add(segGroup);
finger.segments.push(segGroup);
finger.joints.push(joint);
if (s < segLens.length - 1) {
const connector = new THREE.Group();
connector.position.z = -segLens[s];
segGroup.add(connector);
parent = connector;
}
}
hand.fingers.push(finger);
}
addFingerLabels();
addHandLabel();
}
function addFingerLabels() {
const names = ['Thumb','Index','Middle','Ring','Pinky'];
for (let i = 0; i < hand.fingers.length; i++) {
const finger = hand.fingers[i];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 128; canvas.height = 32;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(names[i], canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(2, 0.5);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -1.5, -2);
label.rotation.x = Math.PI / 2;
finger.group.add(label);
}
}
function addHandLabel() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256; canvas.height = 64;
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#000000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('RIGHT HAND (VERTICAL)', canvas.width/2, canvas.height/2);
const texture = new THREE.CanvasTexture(canvas);
const geom = new THREE.PlaneGeometry(7, 1.75);
const mat = new THREE.MeshBasicMaterial({ map:texture, transparent:true, side:THREE.DoubleSide });
const label = new THREE.Mesh(geom, mat);
label.position.set(0, -2, 0);
label.rotation.x = Math.PI / 2;
scene.add(label);
}
function updateHandModel() {
for (let i = 0; i < MAX_JOINTS; i++) {
const info = fingerJointMap[i];
if (!info) continue;
const { finger, joint, type, min, max, angleMin, angleMax } = info;
const raw = jointValues[i];
const f = hand.fingers[finger];
if (!f) continue;
const center = (min + max) / 2;
let angle = 0;
if (type.includes('ABDUCTION')) {
// symmetric around neutral
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
if (finger === 0 && joint === 0) {
// Thumb: abduction about Z (toward/away from palm)
f.group.rotation.z = base.z + angle;
} else {
// Other fingers: side-to-side about Y
f.group.rotation.y = base.y + angle;
}
} else if (type.includes('FLEXION')) {
const isThumb = finger === 0;
const isMCP = type === 'MCP_FLEXION';
const isPIP = type === 'PIP_FLEXION';
const positiveOnly = (isThumb && (type === 'MCP_FLEXION' || type === 'IP_FLEXION')) || (!isThumb && isPIP);
if (positiveOnly) {
const t = raw <= center ? 0 : invLerp(center, max, raw); // 0..1
angle = angleMin + t * (angleMax - angleMin); // 0..+limit
} else {
const k = clamp((raw - center) / ((max - min) / 2), -1, 1);
angle = angleMin + (k + 1) * 0.5 * (angleMax - angleMin);
}
if (isMCP) {
// MCP flexion applies to the finger base group (same as abduction)
const base = f.group.userData.baseRot || {x:0,y:0,z:0};
f.group.rotation.x = base.x + angle;
} else if (f.segments[joint]) {
// PIP/DIP/IP flexion applies to individual segments
f.segments[joint].rotation.x = angle;
}
}
}
}
// -------------------- Render Loop --------------------
function onWindowResize() {
camera.aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// -------------------- Misc UI --------------------
function addLogMessage(msg) {
const el = document.createElement('div');
el.textContent = msg;
logContainer.appendChild(el);
logContainer.scrollTop = logContainer.scrollHeight;
while (logContainer.children.length > 100) {
logContainer.removeChild(logContainer.firstChild);
}
}
// Camera view controls
frontViewBtn?.addEventListener('click', () => { camera.position.set(0, 0, 20); camera.lookAt(0,0,0); controls.update(); });
sideViewBtn?.addEventListener('click', () => { camera.position.set(20, 0, 0); camera.lookAt(0,0,0); controls.update(); });
topViewBtn?.addEventListener('click', () => { camera.position.set(0, 20, 0); camera.lookAt(0,0,0); controls.update(); });
resetViewBtn?.addEventListener('click', () => { camera.position.set(10,10,10); camera.lookAt(0,0,0); controls.update(); });
// Serial connect buttons
connectButton?.addEventListener('click', connectToDevice);
disconnectButton?.addEventListener('click', disconnectFromDevice);
// Web Serial support check
if (!navigator.serial) {
statusIndicator.textContent = 'Status: Web Serial API not supported in this browser';
connectButton.disabled = true;
addLogMessage('ERROR: Web Serial API is not supported in this browser. Try Chrome or Edge.');
}
// -------------------- Boot --------------------
initThreeJS();
initializeJointElements();
// -------------------- Styles (inline) --------------------
const styleElement = document.createElement('style');
styleElement.textContent = `
.joint-info { border-bottom: 1px solid #eee; padding: 8px 0; }
.joint-name { font-weight: 600; margin-bottom: 4px; }
.joint-value { font-size: 12px; color: #333; margin-bottom: 4px; }
.bar-container { width: 100%; height: 8px; background: #ddd; border-radius: 4px; overflow: hidden; }
.bar { height: 100%; width: 0%; background: #4caf50; }
.joint-slider { width: 100%; margin: 6px 0; }
.invert-toggle { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: #555; }
.limits-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.limit-label { font-size: 11px; color: #666; }
.limit-num { width: 60px; }
.calib-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.calib-btn { padding: 2px 6px; font-size: 11px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; }
.calib-btn:hover { background: #d32f2f; }
.calib-status { font-size: 11px; color: #666; }
.status.connected { color: #0a0; }
.status.disconnected { color: #a00; }
`;
document.head.appendChild(styleElement);