mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 10:16:09 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb47b91a5 | |||
| 7957d4e2dc | |||
| 4c17a7be2b | |||
| c8c37bd339 | |||
| 0ccc08e347 | |||
| 7b207d44a0 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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)}
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user