From 8944857d454237c7de648681e3bda738b98e9bbd Mon Sep 17 00:00:00 2001 From: RexWang Date: Fri, 6 Feb 2026 13:21:15 +0800 Subject: [PATCH 1/5] New robot: galaxealite-follower-ros2 Features: 1. Based on robodriver-robot-galaxealite-follower-ros2 create a new robot: robodriver-robot-galaxealite-follower-ros2 2. Remove all leader components and concentrate on follower control 3. Add a new function robot.reset() which is necessary for batch evalutation task --- .../.gitignore | 209 +++++++++++ .../LICENSE | 201 ++++++++++ .../README-EN.md | 230 ++++++++++++ .../README.md | 208 ++++++++++ .../__init__.py | 1 + .../pyproject.toml | 25 ++ .../__init__.py | 3 + .../config.py | 73 ++++ .../node.py | 233 ++++++++++++ .../robot.py | 354 ++++++++++++++++++ .../status.py | 136 +++++++ 11 files changed, 1673 insertions(+) create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/.gitignore create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/LICENSE create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README-EN.md create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README.md create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/lerobot_robot_galaxealite_follower_ros2/__init__.py create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/pyproject.toml create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/__init__.py create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/config.py create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py create mode 100644 robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/status.py diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/.gitignore b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/.gitignore new file mode 100644 index 0000000..9244879 --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/.gitignore @@ -0,0 +1,209 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +**out/ diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/LICENSE b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README-EN.md b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README-EN.md new file mode 100644 index 0000000..61511cf --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README-EN.md @@ -0,0 +1,230 @@ +# robodriver-robot-galaxealite-follower-ros2 +## Quick Start +### Access Requirements +#### 1. Hardware Requirements +Refer to the document: [https://jwolpxeehx.feishu.cn/wiki/LYcNwC2rBirg4Dk9CoScemx3n8f?from=from_copylink](https://jwolpxeehx.feishu.cn/wiki/LYcNwC2rBirg4Dk9CoScemx3n8f?from=from_copylink) + +#### 2. Environment & Network Requirements +- Galaxea teleoperation function is normal; +- ROS2 (Humble/Iron version) is installed on the host, which can receive galaxea topic data; +- The host and galaxea main controller are connected to the same local area network (Ethernet connection is recommended); +- Host is connected to the internet and can access the web normally; +- Set ROS Domain ID (example): + ```bash + export ROS_DOMAIN_ID=1 + ``` + +### Prerequisites (Execute first if not completed) +1. Embodied Platform Application: [https://ei2data.baai.ac.cn/home](https://ei2data.baai.ac.cn/home) +2. Edge Service Installation: [https://github.com/FlagOpen/RoboDriver-Server.git](https://github.com/FlagOpen/RoboDriver-Server.git) + +### Clone Code Repositories +#### 1. Clone RoboDriver Core Repository (Skip if already cloned) +```bash +git clone https://github.com/FlagOpen/RoboDriver.git +``` + +#### 2. Enter Galaxea ROS2 folder +```bash +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-galaxealite-follower-ros2 +``` + +### Create Miniconda Virtual Environment +#### 1. Install Miniconda (Execute if not installed, compatible with Linux/macOS) +```bash +# Download installation package +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +# Execute installation (follow prompts, recommended to agree to conda init) +bash Miniconda3-latest-Linux-x86_64.sh +# Refresh terminal to activate conda +source ~/.bashrc +``` + +#### 2. Install Dependencies +```bash +# Create and activate Python 3.10 environment (run if environment not created) +conda create -n robodriver python=3.10 -y +conda activate robodriver + +# Install RoboDriver core dependencies (run if dependencies not installed) +cd /path/to/your/RoboDriver +pip install -e . + +# Install galaxea robot hardware dependencies +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-galaxealite-follower-ros2 +pip install -e . +``` + +### Configure node.py (Adapt to Robot's Actual Topics) +This script implements core functions such as multi-topic synchronous subscription, data conversion, and command publishing for the GALAXEALITE robot. You need to modify the following configurations according to the actual topic names of the robot (core location: node initialization function `__init__`). + +#### 1. Publisher Topics (Motion Command Output) +| Publisher Variable | Default Topic | Function | +|--------------------------|--------------------------------------------|---------------------------| +| `publisher_left_arm` | `/motion_target/target_joint_state_arm_left` | Publish left arm joint target values | +| `publisher_right_arm` | `/motion_target/target_joint_state_arm_right` | Publish right arm joint target values | +| `publisher_left_gripper` | `/motion_target/target_position_gripper_left` | Publish left gripper position target values | +| `publisher_right_gripper`| `/motion_target/target_position_gripper_right` | Publish right gripper position target values | +| `publisher_state_torso` | `/motion_target/target_joint_state_torso` | Publish torso joint target values | + +#### 2. Follow Feedback Subscription Topics (Robot State Input) +| Subscriber Variable | Default Topic | Function | +|-------------------------|------------------------------|---------------------------| +| `sub_arm_left` | `/hdas/feedback_arm_left` | Subscribe to left arm joint feedback | +| `sub_arm_right` | `/hdas/feedback_arm_right` | Subscribe to right arm joint feedback | +| `sub_gripper_left` | `/hdas/feedback_gripper_left` | Subscribe to left gripper feedback | +| `sub_gripper_right` | `/hdas/feedback_gripper_right` | Subscribe to right gripper feedback | +| `sub_torso` | `/hdas/feedback_torso` | Subscribe to torso joint feedback | + +#### 3. Image Topic Subscription (Camera Data Input) +| Subscriber Variable | Default Topic | Function | +|---------------------------|----------------------------------------------------|---------------------------| +| `sub_camera_top_left` | `/hdas/camera_head/left_raw/image_raw_color/compressed` | Subscribe to top-left camera image | +| `sub_camera_top_right` | `/hdas/camera_head/right_raw/image_raw_color/compressed` | Subscribe to top-right camera image | +| `sub_camera_wrist_left` | `/hdas/camera_wrist_left/color/image_raw/compressed` | Subscribe to left wrist camera image | +| `sub_camera_wrist_right` | `/hdas/camera_wrist_right/color/image_raw/compressed` | Subscribe to right wrist camera image | + +**Modification Example**: Change the top-left camera subscription topic to a custom path +```python +sub_camera_top_left = Subscriber(self, CompressedImage, '/my_robot/camera/top_left/compressed') +``` + +#### 4. Key Parameter Adjustments +##### (1) QoS Configuration (Network Transmission Strategy) +```python +# Reliable transmission (default for publishers/critical feedback) +self.qos = QoSProfile( + durability=DurabilityPolicy.VOLATILE, # Non-persistent + reliability=ReliabilityPolicy.RELIABLE, # Ensure message delivery + history=HistoryPolicy.KEEP_LAST, # Keep last N messages + depth=10 # Queue depth +) + +# Best-effort transmission (non-critical commands, prioritize speed) +self.qos_best_effort = QoSProfile( + reliability=ReliabilityPolicy.BEST_EFFORT, + depth=10 +) +``` + +##### (2) Multi-Topic Synchronization Parameters +```python +self.sync = ApproximateTimeSynchronizer( + [sub_arm_left, sub_arm_right, sub_gripper_left, sub_gripper_right, sub_torso], + queue_size=10, # Larger queue = higher fault tolerance, more memory usage + slop=0.1 # Time tolerance (seconds): maximum allowed timestamp difference between topics +) +``` + +##### (3) Publish Frequency Limit +```python +# Default 30Hz, example to modify to 10Hz +self.min_interval_ns = 1e9 / 10 +``` + +### Configure config.py (Hardware Collection Template) +This script defines the robot hardware configuration based on the `lerobot` framework. Adjust according to actual collection requirements (e.g., joints, cameras, recording mode). + +#### 1. Motor Configuration Modification Example (Add 4th Torso Joint) +```python +follower_motors: Dict[str, Motor] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "follower_arms":{ + # Original configuration + "torso_joint_3":Motor(17, "sts3215", norm_mode_body), + # Add new joint + "torso_joint_4":Motor(18, "sts3215", norm_mode_body), + } + } +) +``` + +#### 2. Video Recording Switch (use_videos) +| Configuration Value | Behavior Description | Additional Operations | +|---------------------|--------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------| +| `False` | Images are encoded into videos after collection (delayed encoding) | Need to modify `lerobot` source code:
Edit `/path/to/miniconda3/envs/robodriver/lib/python3.10/site-packages/lerobot/datasets/pipeline_features.py`
Comment out:
`# if is_image and not use_videos:`
`# continue` | +| `True` | Images are encoded into videos in real-time during collection (encoding time depends on host performance) | No need to modify source code, use directly | + +## Start Data Collection +### 1. Activate Environment and Start Basic Services +```bash +# Start nginx service +sudo systemctl start nginx +# Enter RoboDriver directory and activate environment +cd /path/to/your/RoboDriver +conda activate robodriver +``` + +### 2. Start Galaxealite Topics +Execute the robot's built-in startup script to ensure ROS2 topics are published normally. + +### 3. Start RoboDriver +```bash +python -m robodriver.scripts.evaluate --robot.type=galaxealite-follower-ros2 +``` + +## Common Issue Fixes +1. **Arm and gripper don't move during evaluating** + Unplug network of teleops device + +2. **OpenCV cvShowImage error on startup or need to remove image popups on startup** + Comment out the following two lines in `robodriver/scripts/run.py`: + ```python + # cv2.imshow(key, img) + # cv2.waitKey(1) + ``` + +3. **Cannot access http://localhost:5805/hmi/** + Restart the nginx service: + ```bash + sudo systemctl restart nginx + ``` + +4. **Robot response timeout** + Check the network connectivity between the host and galaxea main controller, and verify that the RoboDriver startup script parameters are correct. + +## Data Description +### Storage Path +Collected data is stored in the `/home/yourname/DoRobot` directory by default, organized into folders named by task. + +### Directory Structure +``` +TaskName_TaskId/ +├── audio/ # Audio data +│ └── chunk-000/ +│ ├── observation.audio.audio_left/ # Left channel audio (WAV format) +│ └── observation.audio.audio_right/ # Right channel audio (WAV format) +├── data/ # Motion command/feedback data (Parquet format) +│ └── chunk-000/ +├── depth/ # Depth images (AVI format) +│ └── chunk-000/ +│ ├── observation.images.image_depth_right/ +│ └── observation.images.image_depth_top/ +├── device/ # Device information +│ └── device_info.json +├── label/ # Annotation data (generated after annotation) +│ └── data_annotation.json +├── meta/ # Metadata +│ ├── common_record.json # Collection task information +│ ├── episodes.jsonl # Task description and frame length +│ ├── episodes_stats.jsonl # Normalized statistical information +│ ├── info.json # Feature schema, frame rate, version +│ ├── op_dataid.jsonl # Device number +│ └── tasks.jsonl +└── videos/ # Visible light images (MP4 format) + └── chunk-000/ + ├── observation.images.image_left/ # Left camera + ├── observation.images.image_left_tac_l/ # Left tactile left camera + ├── observation.images.image_left_tac_r/ # Left tactile right camera + ├── observation.images.image_right/ # Right camera + ├── observation.images.image_right_tac_l/ # Right tactile left camera + ├── observation.images.image_right_tac_r/ # Right tactile right camera + └── observation.images.image_top/ # Top camera +``` + +## Acknowledgment + +- Thanks to LeRobot team 🤗, [LeRobot](https://github.com/huggingface/lerobot). +- Thanks to dora-rs 🤗, [dora](https://github.com/dora-rs/dora). + +## Cite diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README.md b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README.md new file mode 100644 index 0000000..b82a47b --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/README.md @@ -0,0 +1,208 @@ +# robodriver-robot-galaxealite-follower-ros2 +## 快速开始 +### 接入要求 +#### 1. 硬件要求 +参考文档:[https://jwolpxeehx.feishu.cn/wiki/LYcNwC2rBirg4Dk9CoScemx3n8f?from=from_copylink](https://jwolpxeehx.feishu.cn/wiki/LYcNwC2rBirg4Dk9CoScemx3n8f?from=from_copylink) + +#### 2. 环境与网络要求 +- galaxea 遥操作功能正常; +- 主机已安装 ROS2(Humble/Iron 版本),可接收 galaxea 话题数据; +- 主机与 galaxea 主控接入同一局域网(推荐网线直连); +- 主机连接网络,可以正常上网; +- 设置 ROS 域 ID(示例): + ```bash + export ROS_DOMAIN_ID=1 + ``` + +### 前置准备(未完成需先执行) +1. 具身平台申请:[https://ei2data.baai.ac.cn/home](https://ei2data.baai.ac.cn/home) +2. 端侧服务安装:[https://github.com/FlagOpen/RoboDriver-Server.git](https://github.com/FlagOpen/RoboDriver-Server.git) + + +### 克隆代码仓库 +#### 1. 克隆 RoboDriver 核心仓库(已克隆可跳过) +```bash +git clone https://github.com/FlagOpen/RoboDriver.git +``` + +#### 2. 进入 galaxea ros2 文件夹 +```bash +git /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-galaxealite-follower-ros2 +``` + +### 创建 Miniconda 虚拟环境 +#### 1. 安装 Miniconda(未安装时执行,适配 Linux/macOS) +```bash +# 下载安装包 +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +# 执行安装(按提示操作,建议同意 conda init) +bash Miniconda3-latest-Linux-x86_64.sh +# 刷新终端使 conda 生效 +source ~/.bashrc +``` + +#### 2. 安装依赖 +```bash +# 创建并激活 Python 3.10 环境 (未创建环境时运行) +conda create -n robodriver python=3.10 -y +conda activate robodriver + +# 安装 RoboDriver 核心依赖 (未安装依赖时运行) +cd /path/to/your/RoboDriver +pip install -e . + +# 安装 galaxea 机器人硬件依赖 +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-galaxealite-follower-ros2 +pip install -e . +``` + +### 配置 node.py(适配机器人实际话题) +该脚本实现 GALAXEALITE 机器人多话题同步订阅、数据转换、指令发布等核心功能,需根据机器人实际话题名称修改以下配置(核心位置:节点初始化函数 `__init__`)。 + +#### 1. 发布器话题(运动指令输出) +| 发布器变量 | 默认话题 | 功能 | +|--------------------------|-------------------------------------------|--------------------------| +| `publisher_left_arm` | `/motion_target/target_joint_state_arm_left` | 左臂关节目标值发布 | +| `publisher_right_arm` | `/motion_target/target_joint_state_arm_right` | 右臂关节目标值发布 | +| `publisher_left_gripper` | `/motion_target/target_position_gripper_left` | 左夹爪位置目标值发布 | +| `publisher_right_gripper`| `/motion_target/target_position_gripper_right` | 右夹爪位置目标值发布 | +| `publisher_state_torso` | `/motion_target/target_joint_state_torso` | 躯干关节目标值发布 | + +#### 2. 跟随反馈订阅话题(机器人状态输入) +| 订阅器变量 | 默认话题 | 功能 | +|------------------|------------------------|----------------------| +| `sub_arm_left` | `/hdas/feedback_arm_left` | 左臂关节反馈订阅 | +| `sub_arm_right` | `/hdas/feedback_arm_right` | 右臂关节反馈订阅 | +| `sub_gripper_left` | `/hdas/feedback_gripper_left` | 左夹爪反馈订阅 | +| `sub_gripper_right` | `/hdas/feedback_gripper_right` | 右夹爪反馈订阅 | +| `sub_torso` | `/hdas/feedback_torso` | 躯干关节反馈订阅 | + +#### 3. 图像话题订阅(相机数据输入) +| 订阅器变量 | 默认话题 | 功能 | +|----------------------|-----------------------------------------------|----------------------| +| `sub_camera_top_left` | `/hdas/camera_head/left_raw/image_raw_color/compressed` | 顶部左相机图像订阅 | +| `sub_camera_top_right` | `/hdas/camera_head/right_raw/image_raw_color/compressed` | 顶部右相机图像订阅 | +| `sub_camera_wrist_left` | `/hdas/camera_wrist_left/color/image_raw/compressed` | 左手腕相机图像订阅 | +| `sub_camera_wrist_right` | `/hdas/camera_wrist_right/color/image_raw/compressed` | 右手腕相机图像订阅 | + +**修改示例**:将顶部左相机订阅话题改为自定义路径 +```python +sub_camera_top_left = Subscriber(self, CompressedImage, '/my_robot/camera/top_left/compressed') +``` + +#### 4. 关键参数调整 +##### (1)QoS 配置(网络传输策略) +```python +# 可靠传输(默认用于发布器/关键反馈) +self.qos = QoSProfile( + durability=DurabilityPolicy.VOLATILE, # 不持久化 + reliability=ReliabilityPolicy.RELIABLE, # 确保消息到达 + history=HistoryPolicy.KEEP_LAST, # 保留最后N条 + depth=10 # 队列深度 +) + +# 尽力传输(非关键指令,优先速度) +self.qos_best_effort = QoSProfile( + reliability=ReliabilityPolicy.BEST_EFFORT, + depth=10 +) +``` + +##### (2)多话题同步参数 +```python +self.sync = ApproximateTimeSynchronizer( + [sub_arm_left, sub_arm_right, sub_gripper_left, sub_gripper_right, sub_torso], + queue_size=10, # 队列越大容错越高,内存占用越多 + slop=0.1 # 时间容差(秒):允许话题时间戳最大差值 +) +``` + +##### (3)发布频率限制 +```python +# 默认 30Hz,修改为 10Hz 示例 +self.min_interval_ns = 1e9 / 10 +``` + +### 配置 config.py(硬件采集模板) +该脚本基于 `lerobot` 框架定义机器人硬件配置,需根据实际采集需求调整(如关节、相机、录制模式)。 + +#### 1. 电机配置修改示例(新增躯干第4关节) +```python +follower_motors: Dict[str, Motor] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "follower_arms":{ + # 原有配置 + "torso_joint_3":Motor(17, "sts3215", norm_mode_body), + # 新增关节 + "torso_joint_4":Motor(18, "sts3215", norm_mode_body), + } + } +) +``` + +#### 2. 视频录制开关(use_videos) +| 配置值 | 行为说明 | 额外操作 | +|--------|--------------------------------------------------------------------------|--------------------------------------------------------------------------| +| `False`| 图像采集后滞后编码为视频 | 需修改 `lerobot` 源码:
编辑 `/path/to/miniconda3/envs/robodriver/lib/python3.10/site-packages/lerobot/datasets/pipeline_features.py`
注释:
`# if is_image and not use_videos:`
`# continue` | +| `True` | 图像采集时实时编码为视频(编码时长依赖主机性能)| 无需修改源码,直接使用 | + +## 开始推理 +### 1. 激活环境并启动基础服务 +```bash +# 启动 nginx 服务 +sudo systemctl start nginx +# 进入 RoboDriver 目录并激活环境 +cd /path/to/your/RoboDriver +conda activate robodriver +``` + +### 2. 启动 Galaxealite 话题 +执行机器人自带的启动脚本,确保 ROS2 话题正常发布。 + +### 3. 启动 RoboDriver +```bash +python -m robodriver.scripts.evaluate --robot.type=galaxealite-follower-ros2 +``` + +## 常见问题修复 +1. **机器臂不执行 action** + 确保摇操模块创建的ros2 node完全下线,最稳妥的方法是拔掉摇操模块的网线 + +## 数据说明 +### 存储路径 +采集数据默认存储于 `/home/yourname/DoRobot` 目录,按任务命名划分文件夹。 + +### 目录结构 +``` +TaskName_TaskId/ +├── audio/ # 音频数据 +│ └── chunk-000/ +│ ├── observation.audio.audio_left/ # 左声道音频(WAV格式) +│ └── observation.audio.audio_right/ # 右声道音频(WAV格式) +├── data/ # 运动指令/反馈数据(Parquet格式) +│ └── chunk-000/ +├── depth/ # 深度图像(AVI格式) +│ └── chunk-000/ +│ ├── observation.images.image_depth_right/ +│ └── observation.images.image_depth_top/ +├── device/ # 设备信息 +│ └── device_info.json +├── label/ # 标注数据(标注后生成) +│ └── data_annotation.json +├── meta/ # 元数据 +│ ├── common_record.json # 采集任务信息 +│ ├── episodes.jsonl # 任务描述与帧长 +│ ├── episodes_stats.jsonl # 归一化统计信息 +│ ├── info.json # 特征schema、帧率、版本 +│ ├── op_dataid.jsonl # 设备编号 +│ └── tasks.jsonl +└── videos/ # 可见光图像(MP4格式) + └── chunk-000/ + ├── observation.images.image_left/ # 左侧相机 + ├── observation.images.image_left_tac_l/ # 左侧触觉左相机 + ├── observation.images.image_left_tac_r/ # 左侧触觉右相机 + ├── observation.images.image_right/ # 右侧相机 + ├── observation.images.image_right_tac_l/ # 右侧触觉左相机 + ├── observation.images.image_right_tac_r/ # 右侧触觉右相机 + └── observation.images.image_top/ # 顶部相机 +``` diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/lerobot_robot_galaxealite_follower_ros2/__init__.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/lerobot_robot_galaxealite_follower_ros2/__init__.py new file mode 100644 index 0000000..49c9d7a --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/lerobot_robot_galaxealite_follower_ros2/__init__.py @@ -0,0 +1 @@ +from robodriver_robot_galaxealite_follower_ros2 import * diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/pyproject.toml b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/pyproject.toml new file mode 100644 index 0000000..92cdafb --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "robodriver_robot_galaxealite_follower_ros2" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.8" +license = "Apache-2.0" +authors = [ + {name = "Xi Wang", email = "wangxi@baai.ac.cn"}, +] +keywords = ["robotics", "lerobot", "galaxea"] +dependencies = [ + "dora-rs-cli", + "logging_mp", + "opencv-python", +] + +[tool.setuptools.packages.find] +include = [ + "robodriver_robot_galaxealite_follower_ros2", + "lerobot_robot_galaxealite_follower_ros2" +] diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/__init__.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/__init__.py new file mode 100644 index 0000000..5a63e39 --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/__init__.py @@ -0,0 +1,3 @@ +from .config import GALAXEALITEFollowerRos2RobotConfig +from .robot import GALAXEALITEFollowerRos2Robot +from .status import GALAXEALITEFollowerRos2RobotStatus diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/config.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/config.py new file mode 100644 index 0000000..49e613b --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/config.py @@ -0,0 +1,73 @@ +from typing import Dict +from dataclasses import dataclass, field + +from lerobot.robots.config import RobotConfig +from lerobot.cameras import CameraConfig +from lerobot.cameras.opencv import OpenCVCameraConfig +from lerobot.motors import Motor, MotorNormMode + + +@RobotConfig.register_subclass("galaxealite-follower-ros2") +@dataclass +class GALAXEALITEFollowerRos2RobotConfig(RobotConfig): + use_degrees = True + norm_mode_body = ( + MotorNormMode.DEGREES if use_degrees else MotorNormMode.RANGE_M100_100 + ) + follower_motors: Dict[str, Motor] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "follower_arms":{ + "left_arm_joint_1_rad": Motor(1, "sts3215", norm_mode_body), + "left_arm_joint_2_rad": Motor(2, "sts3215", norm_mode_body), + "left_arm_joint_3_rad": Motor(3, "sts3215", norm_mode_body), + "left_arm_joint_4_rad": Motor(4, "sts3215", norm_mode_body), + "left_arm_joint_5_rad": Motor(5, "sts3215", norm_mode_body), + "left_arm_joint_6_rad": Motor(6, "sts3215", norm_mode_body), + "left_gripper_degree_mm": Motor(7, "sts3215", MotorNormMode.RANGE_0_100), + "right_arm_joint_1_rad": Motor(8, "sts3215", norm_mode_body), + "right_arm_joint_2_rad": Motor(9, "sts3215", norm_mode_body), + "right_arm_joint_3_rad": Motor(10, "sts3215", norm_mode_body), + "right_arm_joint_4_rad": Motor(11, "sts3215", norm_mode_body), + "right_arm_joint_5_rad": Motor(12, "sts3215", norm_mode_body), + "right_arm_joint_6_rad": Motor(13, "sts3215", norm_mode_body), + "right_gripper_degree_mm": Motor(14, "sts3215", MotorNormMode.RANGE_0_100), + "torso_joint_1":Motor(15, "sts3215", norm_mode_body), + "torso_joint_2":Motor(16, "sts3215", norm_mode_body), + "torso_joint_3":Motor(17, "sts3215", norm_mode_body), + } + } + ) + + cameras: Dict[str, CameraConfig] = field( + default_factory=lambda: { + "image_top_left": OpenCVCameraConfig( + index_or_path=1, + fps=30, + width=1280, + height=720, + ), + "image_top_right": OpenCVCameraConfig( + index_or_path=2, + fps=30, + width=1280, + height=720, + ), + "image_wrist_left": OpenCVCameraConfig( + index_or_path=3, + fps=30, + width=640, + height=360, + ), + "image_wrist_right": OpenCVCameraConfig( + index_or_path=4, + fps=30, + width=640, + height=360, + ), + } + ) + + use_videos: bool = False + + microphones: Dict[str, int] = field(default_factory=lambda: {}) + diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py new file mode 100644 index 0000000..3cf2c1e --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py @@ -0,0 +1,233 @@ +import threading +import time +from typing import Dict + +import numpy as np +import cv2 +import rclpy +from rclpy.node import Node as ROS2Node +from sensor_msgs.msg import JointState, CompressedImage +from message_filters import Subscriber, ApproximateTimeSynchronizer +from geometry_msgs.msg import PoseStamped +from rclpy.qos import QoSProfile, DurabilityPolicy, ReliabilityPolicy, HistoryPolicy + +import logging_mp + +CONNECT_TIMEOUT_FRAME = 10 +logger = logging_mp.get_logger(__name__) + +class GALAXEALITEFollowerRos2RobotNode(ROS2Node): + def __init__(self): + super().__init__('ros2_recv_pub_driver') + self.stop_spin = False # 初始化停止标志 + self.qos = QoSProfile( + durability=DurabilityPolicy.VOLATILE, + reliability=ReliabilityPolicy.RELIABLE, + history=HistoryPolicy.KEEP_LAST, + depth=10 + ) + + self.qos_best_effort = QoSProfile( + durability=DurabilityPolicy.VOLATILE, + reliability=ReliabilityPolicy.BEST_EFFORT, + history=HistoryPolicy.KEEP_LAST, + depth=10 + ) + + # 统一QoS配置(修复原问题) + self.publisher_left_arm = self.create_publisher( + JointState, "/motion_target/target_joint_state_arm_left", self.qos + ) + self.publisher_right_arm = self.create_publisher( + JointState, "/motion_target/target_joint_state_arm_right", self.qos + ) + self.publisher_left_gripper = self.create_publisher( + JointState, "/motion_target/target_position_gripper_left", self.qos + ) + self.publisher_right_gripper = self.create_publisher( + JointState, "/motion_target/target_position_gripper_right", self.qos + ) + # self.publisher_state_torso = self.create_publisher( + # JointState, "/motion_target/target_joint_state_torso", self.qos + # ) + + self.last_main_send_time_ns = 0 + self.last_follow_send_time_ns = 0 + self.min_interval_ns = 1e9 / 30 # 30Hz + + self._init_message_follow_filters() + self._init_image_message_filters() + + self.recv_images: Dict[str, float] = {} + self.recv_follower: Dict[str, float] = {} + self.recv_images_status: Dict[str, int] = {} + self.recv_follower_status: Dict[str, int] = {} + + self.lock = threading.Lock() + + def _init_message_follow_filters(self): + sub_arm_left = Subscriber(self, JointState, '/hdas/feedback_arm_left') + sub_arm_right = Subscriber(self, JointState, '/hdas/feedback_arm_right') + sub_gripper_left = Subscriber(self, JointState, '/hdas/feedback_gripper_left') + sub_gripper_right = Subscriber(self, JointState, '/hdas/feedback_gripper_right') + sub_torso = Subscriber(self, JointState, '/hdas/feedback_torso') + + self.sync = ApproximateTimeSynchronizer( + [sub_arm_left, sub_arm_right, sub_gripper_left, sub_gripper_right, sub_torso], + queue_size=10, + slop=0.1 + ) + self.sync.registerCallback(self.synchronized_follow_callback) + + def synchronized_follow_callback(self, arm_left, arm_right, gripper_left, gripper_right, torso): + try: + current_time_ns = time.time_ns() + if (current_time_ns - self.last_follow_send_time_ns) < self.min_interval_ns: + return + self.last_follow_send_time_ns = current_time_ns + + left_pos = np.array(arm_left.position, dtype=np.float32) + right_pos = np.array(arm_right.position, dtype=np.float32) + left_arm_data = left_pos + right_arm_data = right_pos + + gripper_left_pos = np.array(gripper_left.position, dtype=np.float32) + gripper_right_pos = np.array(gripper_right.position, dtype=np.float32) + torso_pos = np.array(torso.position, dtype=np.float32) + torso_pos = torso_pos[:-1] + + merged_data = np.concatenate([left_arm_data, gripper_left_pos, right_arm_data, gripper_right_pos, torso_pos]) + with self.lock: + self.recv_follower['follower_arms'] = merged_data + self.recv_follower_status['follower_arms'] = CONNECT_TIMEOUT_FRAME + except Exception as e: + self.get_logger().error(f"Synchronized follow callback error: {e}") + + def _init_image_message_filters(self): + sub_camera_top_left = Subscriber(self, CompressedImage, '/hdas/camera_head/left_raw/image_raw_color/compressed') + sub_camera_top_right = Subscriber(self, CompressedImage, '/hdas/camera_head/right_raw/image_raw_color/compressed') + sub_camera_wrist_left = Subscriber(self, CompressedImage, '/hdas/camera_wrist_left/color/image_raw/compressed') + sub_camera_wrist_right = Subscriber(self, CompressedImage, '/hdas/camera_wrist_right/color/image_raw/compressed') + + self.image_sync = ApproximateTimeSynchronizer( + [sub_camera_top_left, sub_camera_top_right, sub_camera_wrist_left, sub_camera_wrist_right], + queue_size=5, + slop=0.1 + ) + self.image_sync.registerCallback(self.image_synchronized_callback) + + def image_synchronized_callback(self, top_left, top_right, wrist_left, wrist_right): + try: + self.images_recv(top_left, "image_top_left", 1280, 720) + self.images_recv(top_right, "image_top_right", 1280, 720) + self.images_recv(wrist_left, "image_wrist_left", 640, 360) + self.images_recv(wrist_right, "image_wrist_right", 640, 360) + except Exception as e: + self.get_logger().error(f"Image synchronized callback error: {e}") + + def images_recv(self, msg, event_id, width, height, encoding="jpeg"): + try: + if 'image' in event_id: + img_array = np.frombuffer(msg.data, dtype=np.uint8) + if encoding == "bgr8": + channels = 3 + frame = img_array.reshape((height, width, channels)).copy() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + elif encoding == "rgb8": + channels = 3 + frame = img_array.reshape((height, width, channels)) + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + elif encoding in ["jpeg", "jpg", "jpe", "bmp", "webp", "png"]: + channels = 3 + frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + elif encoding == "depth16": + frame = np.frombuffer(msg.data, dtype=np.uint16).reshape(height, width, 1) + + if frame is not None: + with self.lock: + self.recv_images[event_id] = frame + self.recv_images_status[event_id] = CONNECT_TIMEOUT_FRAME + except Exception as e: + logger.error(f"recv image error: {e}") + + def ros_replay(self, array): + try: + def normalize_precision(val, decimals=3): + val = float(val) + if np.isnan(val) or np.isinf(val): + self.get_logger().warning(f"检测到非法值 {val},替换为0.0") + return 0.0 + return round(val, decimals) + + left_arm = [normalize_precision(v) for v in array[:6]] + left_gripper = [normalize_precision(v) for v in array[6:7]] + right_arm = [normalize_precision(v) for v in array[7:13]] + right_gripper = [normalize_precision(v) for v in array[13:14]] + torso = [normalize_precision(v) for v in array[14:17]] + + msg = JointState() + msg.position = left_arm + self.publisher_left_arm.publish(msg) + + msg = JointState() + msg.position = right_arm + self.publisher_right_arm.publish(msg) + + msg = JointState() + msg.position = left_gripper + self.publisher_left_gripper.publish(msg) + + msg = JointState() + msg.position = right_gripper + self.publisher_right_gripper.publish(msg) + + # msg = JointState() + # msg.position = torso + # self.publisher_state_torso.publish(msg) + + except Exception as e: + self.get_logger().error(f"Error during replay at frame: {e}") + raise + + def destroy(self): + self.stop_spin = True + super().destroy_node() + + def _add_debug_subscribers(self): + self.create_subscription( + JointState, + '/motion_target/target_joint_state_arm_right', + lambda msg: self.get_logger().info(f"独立订阅-左臂关节: position={msg.position}"), + self.qos_best_effort + ) + self.create_subscription( + PoseStamped, + '/motion_target/target_pose_arm_right', + lambda msg: self.get_logger().info(f"独立订阅-左臂位姿: x={msg.pose.position.x}, y={msg.pose.position.y}"), + self.qos_best_effort + ) + self.create_subscription( + JointState, + '/motion_target/target_position_gripper_right', + lambda msg: self.get_logger().info(f"独立订阅-左夹爪: position={msg.position}"), + self.qos_best_effort + ) + self.create_subscription( + PoseStamped, + '/motion_target/target_pose_torso', + lambda msg: self.get_logger().info(f"独立订阅-躯干pose: x={msg.pose.position.x}"), + self.qos_best_effort + ) + self.create_subscription( + JointState, + '/motion_target/target_joint_state_torso', + lambda msg: self.get_logger().info(f"独立订阅-躯干joint: position={msg.position}"), + self.qos_best_effort + ) + +# 保留ros_spin_thread函数(供外部调用) +def ros_spin_thread(node): + while rclpy.ok() and not getattr(node, "stop_spin", False): + rclpy.spin_once(node, timeout_sec=0.01) \ No newline at end of file diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py new file mode 100644 index 0000000..1c3d26c --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py @@ -0,0 +1,354 @@ +import threading +import time +from typing import Any + +import logging_mp +import numpy as np +from lerobot.cameras import make_cameras_from_configs +from lerobot.robots.robot import Robot +from lerobot.utils.errors import DeviceNotConnectedError, DeviceAlreadyConnectedError +from functools import cached_property + +import rclpy + +from .config import GALAXEALITEFollowerRos2RobotConfig +from .status import GALAXEALITEFollowerRos2RobotStatus +from .node import GALAXEALITEFollowerRos2RobotNode, ros_spin_thread + + +logger = logging_mp.get_logger(__name__) + + + +class GALAXEALITEFollowerRos2Robot(Robot): + config_class = GALAXEALITEFollowerRos2RobotConfig + name = "galaxealite-follower-ros2" + + def __init__(self, config: GALAXEALITEFollowerRos2RobotConfig): + super().__init__(config) + self.config = config + self.robot_type = self.config.type + self.use_videos = self.config.use_videos + self.microphones = self.config.microphones + + self.follower_motors = config.follower_motors + self.cameras = make_cameras_from_configs(self.config.cameras) + + self.connect_excluded_cameras = ["image_pika_pose"] + + self.status = GALAXEALITEFollowerRos2RobotStatus() + if not rclpy.ok(): + rclpy.init() + self.robot_ros2_node = GALAXEALITEFollowerRos2RobotNode() # 创建节点实例 + self.ros_spin_thread = threading.Thread( + target=ros_spin_thread, + args=(self.robot_ros2_node,), + daemon=True + ) + self.ros_spin_thread.start() + + self.connected = False + self.logs = {} + + @property + def _follower_motors_ft(self) -> dict[str, type]: + return { + f"follower_{joint_name}.pos": float + for comp_name, joints in self.follower_motors.items() + for joint_name in joints.keys() + } + + @property + def _cameras_ft(self) -> dict[str, tuple]: + return { + cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras + } + + @cached_property + def observation_features(self) -> dict[str, type | tuple]: + return {**self._follower_motors_ft, **self._cameras_ft} + + @cached_property + def action_features(self) -> dict[str, type]: + return self._follower_motors_ft + + @property + def is_connected(self) -> bool: + return self.connected + + def connect(self): + timeout = 20 # 统一的超时时间(秒) + start_time = time.perf_counter() + + if self.connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + # 定义所有需要等待的条件及其错误信息 + conditions = [ + ( + lambda: all( + name in self.robot_ros2_node.recv_images + for name in self.cameras + if name not in self.connect_excluded_cameras + ), + lambda: [name for name in self.cameras if name not in self.robot_ros2_node.recv_images], + "等待摄像头图像超时", + ), + ( + lambda: all( + any(name in key for key in self.robot_ros2_node.recv_follower) + for name in self.follower_motors + ), + lambda: [ + name + for name in self.follower_motors + if not any(name in key for key in self.robot_ros2_node.recv_follower) + ], + "等待从臂关节角度超时", + ), + ] + + # 跟踪每个条件是否已完成 + completed = [False] * len(conditions) + + while True: + # 检查每个未完成的条件 + for i in range(len(conditions)): + if not completed[i]: + condition_func = conditions[i][0] + if condition_func(): + completed[i] = True + + # 如果所有条件都已完成,退出循环 + if all(completed): + break + + # 检查是否超时 + if time.perf_counter() - start_time > timeout: + failed_messages = [] + for i, (cond, get_missing, base_msg) in enumerate(conditions): + if completed[i]: + continue + + missing = get_missing() + if cond() or not missing: + completed[i] = True + continue + + if i == 0: + received = [ + name + for name in self.cameras + if name not in missing + ] + else: + received = [ + name + for name in self.follower_motors + if name not in missing + ] + + msg = ( + f"{base_msg}: 未收到 [{', '.join(missing)}]; " + f"已收到 [{', '.join(received)}]" + ) + failed_messages.append(msg) + + if not failed_messages: + break + + raise TimeoutError( + f"连接超时,未满足的条件: {'; '.join(failed_messages)}" + ) + + # 减少 CPU 占用 + time.sleep(0.01) + + # ===== 新增成功打印逻辑 ===== + success_messages = [] + + if conditions[0][0](): + cam_received = [ + name + for name in self.cameras + if name in self.robot_ros2_node.recv_images + and name not in self.connect_excluded_cameras + ] + success_messages.append(f"摄像头: {', '.join(cam_received)}") + + if conditions[1][0](): + follower_received = [ + name + for name in self.follower_motors + if any(name in key for key in self.robot_ros2_node.recv_follower) + ] + success_messages.append(f"从臂数据: {', '.join(follower_received)}") + + + log_message = "\n[连接成功] 所有设备已就绪:\n" + log_message += "\n".join(f" - {msg}" for msg in success_messages) + log_message += f"\n 总耗时: {time.perf_counter() - start_time:.2f} 秒\n" + logger.info(log_message) + # =========================== + + for i in range(self.status.specifications.camera.number): + self.status.specifications.camera.information[i].is_connect = True + for i in range(self.status.specifications.arm.number): + self.status.specifications.arm.information[i].is_connect = True + + self.connected = True + + @property + def is_calibrated(self) -> bool: + """Whether the robot is currently calibrated or not. Should be always `True` if not applicable""" + return True + + def calibrate(self) -> None: + """ + Calibrate the robot if applicable. If not, this should be a no-op. + + This method should collect any necessary data (e.g., motor offsets) and update the + :pyattr:`calibration` dictionary accordingly. + """ + pass + + def configure(self) -> None: + """ + Apply any one-time or runtime configuration to the robot. + This may include setting motor parameters, control modes, or initial state. + """ + pass + + def get_observation(self) -> dict[str, Any]: + if not self.connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + start = time.perf_counter() + obs_dict: dict[str, Any] = {} + for comp_name, joints in self.follower_motors.items(): + for follower_name, follower in self.robot_ros2_node.recv_follower.items(): + if follower_name == comp_name: + for i, joint_name in enumerate(joints.keys()): + obs_dict[f"follower_{joint_name}.pos"] = float(follower[i]) + + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read follower state: {dt_ms:.1f} ms") + + # ---- 摄像头图像保持不变 ---- + for cam_key, _cam in self.cameras.items(): + start = time.perf_counter() + for name, val in self.robot_ros2_node.recv_images.items(): + if cam_key == name or cam_key in name: + obs_dict[cam_key] = val + break + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read {cam_key}: {dt_ms:.1f} ms") + + return obs_dict + + + def send_action(self, action: dict[str, Any]): + """The provided action is expected to be a vector.""" + if not self.is_connected: + raise DeviceNotConnectedError( + "KochRobot is not connected. You need to run `robot.connect()`." + ) + goal_joint = [ val for key, val in action.items()] + goal_joint_numpy = np.array([ + t.item() if hasattr(t, 'item') else t + for t in goal_joint + ], dtype=np.float32) + try: + if goal_joint_numpy.shape != (14,): + raise ValueError(f"Action vector must be 38-dimensional, got {goal_joint_numpy.shape[0]}") + + # 调用ROS2节点的ros_replay方法发布动作 + self.robot_ros2_node.ros_replay(goal_joint_numpy) + + except Exception as e: + logger.error(f"Failed to send action: {e}") + raise + + def update_status(self) -> str: + for i in range(self.status.specifications.camera.number): + match_name = self.status.specifications.camera.information[i].name + for name in self.robot_ros2_node.recv_images_status: + if match_name in name: + self.status.specifications.camera.information[i].is_connect = ( + True if self.robot_ros2_node.recv_images_status[name] > 0 else False + ) + + for i in range(self.status.specifications.arm.number): + match_name = self.status.specifications.arm.information[i].name + for name in self.robot_ros2_node.recv_leader_status: + if match_name in name: + self.status.specifications.arm.information[i].is_connect = ( + True if self.robot_ros2_node.recv_leader_status[name] > 0 else False + ) + + for i in range(self.status.specifications.arm.number): + match_name = self.status.specifications.arm.information[i].name + for name in self.robot_ros2_node.recv_follower_status: + if match_name in name: + self.status.specifications.arm.information[i].is_connect = ( + True if self.robot_ros2_node.recv_follower_status[name] > 0 else False + ) + + return self.status.to_json() + + def disconnect(self): + if not self.is_connected: + raise DeviceNotConnectedError( + "robot is not connected. You need to run `robot.connect()` before disconnecting." + ) + if hasattr(self, "ros_node"): + self.robot_ros2_node.destroy() + if rclpy.ok(): + rclpy.shutdown() + + self.connected = False + + def __del__(self): + try: + if getattr(self, "is_connected", False): + self.disconnect() + except Exception: + pass + + def reset(self, + state = [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100, + -0.8600000143051147, 1.6863000392913818, 0.7631000280380249 + ], + duration: float = 3.0): + if not self.is_connected: + raise DeviceNotConnectedError( + "Robot is not connected. You need to run `robot.connect()`." + ) + + # Get robot current states + curr: list[float] = [] + for comp_name, joints in self.follower_motors.items(): + follower = self.robot_ros2_node.recv_follower.get(comp_name) + if follower is not None: + for i, _ in enumerate(joints): + curr.append(float(follower[i])) + curr_arr = np.array(curr, dtype=np.float32) + goal_arr = np.array(state, dtype=np.float32) + + # Interpolate actions within the given duration + try: + FPS = 30.0 + steps = max(1, int(round(duration * FPS))) # at least execute once + for i in range(1, steps + 1): + start_episode_t = time.perf_counter() + ratio = i / steps + action = curr_arr + ratio * (goal_arr - curr_arr) + self.robot_ros2_node.ros_replay(action) + dt_s = time.perf_counter() - start_episode_t + time.sleep(1 / FPS - dt_s) + + except Exception as e: + logger.error(f"Failed to send action: {e}") + raise \ No newline at end of file diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/status.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/status.py new file mode 100644 index 0000000..0d23e2e --- /dev/null +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/status.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass, field, asdict +from typing import List, Optional +import json +import abc +import draccus + + +@dataclass +class CameraInfo: + name: str = "" + chinese_name: str = "" + type: str = "" + width: int = 0 + height: int = 0 + is_connect: bool = False + +@dataclass +class CameraStatus: + number: int = 0 + information: List[CameraInfo] = field(default_factory=list) + + def __post_init__(self): + if not self.information: # 如果 information 为空,则 number 设为 0 + self.number = 0 + else: + self.number = len(self.information) + +@dataclass +class ArmInfo: + name: str = "" + type: str = "" + start_pose: List[float] = field(default_factory=list) + joint_p_limit: List[float] = field(default_factory=list) + joint_n_limit: List[float] = field(default_factory=list) + is_connect: bool = False + +@dataclass +class ArmStatus: + number: int = 0 + information: List[ArmInfo] = field(default_factory=list) + + def __post_init__(self): + if not self.information: # 如果 information 为空,则 number 设为 0 + self.number = 0 + else: + self.number = len(self.information) + +@dataclass +class Specifications: + end_type: str = "Default" + fps: int = 30 + camera: Optional[CameraStatus] = None + arm: Optional[ArmStatus] = None + +@dataclass +class RobotStatus(draccus.ChoiceRegistry, abc.ABC): + device_name: str = "Default" + device_body: str = "Default" + specifications: Specifications = field(default_factory=Specifications) + + @property + def type(self) -> str: + return self.get_choice_name(self.__class__) + + def to_dict(self) -> dict: + return asdict(self) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False) + +RobotStatus.register_subclass("galaxealite-follower-ros2") +@dataclass +class GALAXEALITEFollowerRos2RobotStatus(RobotStatus): + device_name: str = "星海图R1-Lite" + device_body: str = "星海图" + + def __post_init__(self): + self.specifications.end_type = "二指夹爪" + self.specifications.fps = 30 + self.specifications.camera = CameraStatus( + information=[ + CameraInfo( + name="image_top_left", + chinese_name="头部左摄像头", + type="纯双目视觉相机", + width=1280, + height=720, + is_connect=False + ), + CameraInfo( + name="image_top_right", + chinese_name="头部右摄像头", + type="纯双目视觉相机", + width=1280, + height=720, + is_connect=False + ), + CameraInfo( + name="image_wrist_left", + chinese_name="腕部左摄像头", + type="单目深度相机", + width=640, + height=360, + is_connect=False + ), + CameraInfo( + name="image_wrist_right", + chinese_name="腕部右摄像头", + type="单目深度相机", + width=640, + height=360, + is_connect=False + ) + ] + ) + + self.specifications.arm = ArmStatus( + information=[ + ArmInfo( + name="piper_left", + type="Galaxea A1X + Galaxea G1 - 7DOF", + start_pose=[], + joint_p_limit=[165.0,180.0,0.0,90.0,90.0,165.0], + joint_n_limit=[-165.0,0.0,-190.0,-90.0,-90.0,-165.0], + is_connect=False + ), + ArmInfo( + name="piper_right", + type="Galaxea A1X + Galaxea G1 - 7DOF", + start_pose=[], + joint_p_limit=[165.0,180.0,0.0,90.0,90.0,165.0], + joint_n_limit=[-165.0,0.0,-190.0,-90.0,-90.0,-165.0], + is_connect=False + ), + ] + ) From 1000c6cf56447f79f7b9c8df5e806aacfc9adb28 Mon Sep 17 00:00:00 2001 From: SunSunSun689 Date: Mon, 23 Mar 2026 14:08:05 +0800 Subject: [PATCH 2/5] feat: add pico-ultra4-dora teleoperator for piper arm --- Robodriver.txt | 1897 +++++++++++++++++ components/arms/dora-arm-piper/pyproject.toml | 2 +- .../dora_camera_orbbec_v1/main.py | 280 +-- .../dora-camera-orbbec-v1/pyproject.toml | 2 +- .../dora_camera_realsense/main.py | 87 +- .../dora-camera-realsense/pyproject.toml | 2 +- robodriver/core/coordinator.py | 2 +- robodriver/core/coordinator_need_test.py | 2 +- robodriver/core/recorder.py | 2 +- robodriver/core/replayer.py | 4 +- robodriver/core/ros2thread.py | 2 +- robodriver/core/simulator.py | 2 +- robodriver/dataset/dorobot_dataset.py | 2 +- robodriver/robots/daemon.py | 2 +- .../robodriver_robot_a2d_aio_ros2/node.py | 2 +- .../robodriver_robot_a2d_aio_ros2/robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../robodriver_robot_franka_aio_dora/node.py | 2 +- .../robodriver_robot_franka_aio_dora/robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../robodriver_robot_realman_aio_ros1/node.py | 2 +- .../robot.py | 2 +- .../ros1_to_zmq.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- .../dora/dataflow.yml | 12 +- .../robodriver_robot_so101_aio_dora/node.py | 2 +- .../robodriver_robot_so101_aio_dora/robot.py | 2 +- .../robodriver_robot_so101_aio_ros2/node.py | 2 +- .../robodriver_robot_so101_aio_ros2/robot.py | 2 +- .../node.py | 2 +- .../robot.py | 2 +- robodriver/robots/utils.py | 2 +- robodriver/scripts/record.py | 4 +- robodriver/scripts/replay.py | 4 +- robodriver/scripts/run.py | 12 +- robodriver/scripts/sim.py | 4 +- .../node.py | 2 +- .../sim.py | 2 +- .../robodriver_teleoperator_autotask/node.py | 2 +- .../teleoperator.py | 2 +- .../.gitignore | 182 ++ .../README.md | 150 ++ .../dora/dataflow.yml | 67 + .../__init__.py | 2 + .../pyproject.toml | 44 + .../__init__.py | 9 + .../config.py | 42 + .../controller.py | 198 ++ .../dora_node_piper.py | 276 +++ .../interface/__init__.py | 3 + .../interface/piper.py | 306 +++ .../node.py | 238 +++ .../status.py | 75 + .../teleoperator.py | 171 ++ .../pyproject.toml | 2 +- .../node.py | 2 +- .../teleoperator.py | 2 +- robodriver/teleoperators/utils.py | 2 +- .../tools/AutoDriver/GUI/generate_ros2_aio.py | 4 +- robodriver/utils/import_utils.py | 2 +- robodriver/utils/parser.py | 2 +- robodriver/utils/utils.py | 2 +- 77 files changed, 3831 insertions(+), 346 deletions(-) create mode 100644 Robodriver.txt create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.gitignore create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/lerobot_teleoperator_pico_ultra4_dora/__init__.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/pyproject.toml create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/__init__.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/config.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/controller.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/__init__.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/piper.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/node.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/status.py create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/teleoperator.py diff --git a/Robodriver.txt b/Robodriver.txt new file mode 100644 index 0000000..89c2440 --- /dev/null +++ b/Robodriver.txt @@ -0,0 +1,1897 @@ +# DeepWiki Q&A with Code Context for Repository: FlagOpen/RoboDriver +## Q1 +帮助我快速了解这个项目 +### Answer +RoboDriver 是一个面向具身智能的标准化机器人驱动与数据采集框架,作为 CoRobot 数据体系的设备端驱动适配层,兼容 LeRobot 接口,支持 ROS/ROS2/Dora 多种接入方式,并提供改进的单条存储数据集格式与仿真集成(Genesis/MuJoCo) RoboDriver:11-20 RoboDriver:11-20 。 + +--- + +## 三层架构 +- 云端:RoboXStudio(总控台与数据管理) RoboDriver:18-19 +- 桥接层:RoboDriver-Server(数据/控制路由,HTTP/SocketIO) RoboDriver:31-44 +- 设备层:RoboDriver(机器人控制、数据采集、仿真) RoboDriver:18-19 + +## 核心组件 +- Coordinator:异步 SocketIO 客户端,与服务器通信并处理命令 RoboDriver:31-44 。 +- Monitor:后台线程,每 60s 上报设备信息 RoboDriver:23-36 。 +- Simulator:MuJoCo/Genesis 仿真,双线程(物理+渲染) RoboDriver:163-185 。 +- Recorder:数据录制,支持单条存储与多线程图像写入 RoboDriver:14-15 。 +- 入口脚本:robodriver-run/robodriver-sim/robodriver-visual RoboDriver:31-35 。 + +## 机器人接入方式 +| 类型 | 示例机器人 | 说明 | +|------|------------|------| +| ROS1 | Realman | 基于 ROS Topic RoboDriver:98-102 | +| ROS2 | GALAXEALITE、SO101 | 基于 rclpy RoboDriver:103-108 | +| Dora | Realman、SO101、Franka | 基于 Dora 数据流,支持多虚拟环境隔离 RoboDriver:109-115 | + +## 数据格式 +- 采集端:单条存储(每帧一行),便于编辑与传输 RoboDriver:42-43 。 +- 训练端:可聚合成多集合格式,兼容 LeRobot RoboDriver:41-43 。 + +## 快速上手 +```bash +git clone https://github.com/FlagOpen/RoboDriver.git && cd RoboDriver +pip install uv +uv venv -p 3.10 +uv pip install -e . +source .venv/bin/activate +robodriver-run -h +``` RoboDriver:51-79 + +## Notes +- 完整文档:[RoboDriver-Doc](https://flagopen.github.io/RoboDriver-Doc) RoboDriver:20-21 。 +- 仿真示例:robodriver/simulations/robodriver-sim-genesis-franka-aio-dora RoboDriver:89-94 。 +- 各机器人详细接入指南见 robodriver/robots/robodriver-robot-*/README.md RoboDriver:81-82 。 + +Wiki pages you might want to explore: +- [Overview (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#1) +- [Dora Dataflow Integration (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#3.2) +### Citations +**File:** README.md (L11-21) +```markdown +# RoboDriver +RoboDriver是DataCollect的核心驱动层组件,也是[CoRobot](https://github.com/FlagOpen/CoRobot)数据体系中的标准化机器人接入模块。 + +

+ RoboDriver 架构图 +

+ +如图所示,RoboDriver为“设备端驱动适配层”,[RoboDriver-Server](https://github.com/FlagOpen/RoboDriver-Server)是“数据/控制的桥接层与通道路由器”,[RoboXStudio](https://ei2data.baai.ac.cn/home)是“云端或平台侧总控制台与数据管理中心”。 + +RoboDriver使用文档: [RoboDriver-Doc](https://flagopen.github.io/RoboDriver-Doc) +## 最新消息 +``` +**File:** README.md (L41-43) +```markdown +- **LeRobot兼容**: RoboDriver 的机器人接口直接使用了LeRobot的`Robot`类,这意味着RoboDriver与LeRobot可以互相兼容。 +- **改进的LeRobot数据集格式**:在不同数据阶段采取不同数据结构。在采集端单条储存,更易编辑和传输。还扩展了LeRobot格式的内容。 + +``` +**File:** README.md (L51-79) +```markdown +首先将 RoboDriver 项目代码clone下来,然后进入项目目录: + +``` +git clone https://github.com/FlagOpen/RoboDriver.git && cd RoboDriver +``` + +安装 `uv`,不要激活任何环境 + +``` +pip install uv +``` + +创建uv环境 + +``` +uv venv -p 3.10 +``` + +安装项目: + +``` +uv pip install -e . +``` + +使用: +``` +source .venv/bin/activate +robodriver-run -h +``` +``` +**File:** README.md (L81-82) +```markdown +要使用对应的机器人,请安装对应的机器人,并参考其文档完成部署和启动。路径:robodriver/robots/robodriver-robot-xxx-xxx-xxx/README.md + +``` +**File:** README.md (L89-94) +```markdown +### 🪞 Genesis + +| 机器人型号 | 简介 | 仓库链接 | 贡献人 | +|------------|------|--------------|------------------------| +| Franka 机械臂 | 一台Franka机械臂抓取方块 | [robodriver/simulations/robodriver-sim-genesis-franka-aio-dora](./robodriver/simulations/robodriver-sim-genesis-franka-aio-dora) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | + +``` +**File:** README.md (L98-102) +```markdown +### 🔌 ROS1 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|------------|------|--------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman-aio-ros1](./robodriver/robots/robodriver-robot-realman-aio-ros1) | [](https://github.com/zhanglei-web) | + +``` +**File:** README.md (L103-108) +```markdown +### 🔌 ROS2 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| GALAXEALITE | 基于Galaxealite,双臂6DOF+末端夹爪,4*RealSense相机 | [robodriver/robots/robodriver-robot-galaxealite-aio-ros2](./robodriver/robots/robodriver-robot-galaxealite-aio-ros2) | [![liuyou1103](https://avatars.githubusercontent.com/liuyou1103?s=50)](https://github.com/liuyou1103) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-ros2](./robodriver/robots/robodriver-robot-so101-aio-ros2) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | + +``` +**File:** README.md (L109-115) +```markdown +### 🔌 Dora(SDK)接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman1-aio-dora](./robodriver/robots/robodriver-robot-realman1-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-dora](./robodriver/robots/robodriver-robot-so101-aio-dora) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | +| Franka | 工业级机械臂,6DOF+末端夹爪,1*RealSense相机 | [robodriver/robots/robodriver-robot-franka-aio-dora](./robodriver/robots/robodriver-robot-franka-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | + +``` +**File:** README_en.md (L11-20) +```markdown +## Overview + +RoboDriver is the core driver-layer component of DataCollect and serves as the standardized robot access module within the [CoRobot](https://github.com/FlagOpen/CoRobot) data stack. + +

+ RoboDriver Architecture Diagram +

+ +As shown above, RoboDriver acts as the device-side driver adaptation layer. [RoboDriver-Server](https://github.com/FlagOpen/RoboDriver-Server) is the data/control bridge layer and channel router, and [RoboXStudio](https://ei2data.baai.ac.cn/home) is the cloud- or platform-side console and data management center. + +``` +**File:** robodriver/core/coordinator.py (L14-15) +```python +from robodriver.core.recorder import Record, RecordConfig +from robodriver.core.replayer import DatasetReplayConfig, ReplayConfig, replay +``` +**File:** robodriver/core/coordinator.py (L31-44) +```python +class Coordinator: + def __init__( + self, + daemon: Daemon, + teleop: Optional[Teleoperator], + server_url="http://localhost:8088", + ): + self.server_url = server_url + # 异步客户端 + self.sio = socketio.AsyncClient() + self.session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=10, limit_per_host=10) + ) + +``` +**File:** robodriver/core/monitor.py (L23-36) +```python + def send_machine_info_periodically(self, interval_seconds=60): + """定时发送设备信息(线程安全)""" + while self._running: + time.sleep(interval_seconds) + try: + with self._lock: + # if self.daemon.robot.status is None: + # continue + json_data = self.daemon.get_status() + # json_data = json.dumps(self._machine_info_dict, ensure_ascii=False) + + response = requests.post( + self.url, data=json_data, headers=self.headers, timeout=10 + ) +``` +**File:** robodriver/core/simulator.py (L163-185) +```python +class Simulator: + def __init__(self, config: SimulatorConfig): + self.config = config + + self.model = mujoco.MjModel.from_xml_path(self.config.xml_path) + self.data = mujoco.MjData(self.model) + + self.model.opt.timestep = config.timestep + + self.running_event = threading.Event() + self.lock = threading.Lock() + + self.sim_thread = SimulationThread(self.model, self.data, self.config, self.running_event, self.lock) + self.view_thread = ViewerRendererThread(self.model, self.data, self.config, self.running_event, self.lock) + # self._last_update_time = None + # self._max_steps_per_update = 10 # Limit steps to prevent freezing + + def start(self): + """启动模拟器线程""" + self.running_event.set() + self.sim_thread.start() + self.view_thread.start() + +``` +**File:** pyproject.toml (L31-35) +```text +[project.scripts] +robodriver-run="robodriver.scripts.run:main" +robodriver-sim="robodriver.scripts.sim:main" +robodriver-visual="robodriver.scripts.visual:main" + +``` +## Q2 +我想接入新机械臂该怎样接入? +### Answer +接入新机械臂可按以下步骤进行,选择 ROS1/ROS2/Dora 三种方式之一,并参考已有示例的目录结构与实现。 + +--- + +## 1 选择接入方式并创建目录 +- 命名规范:`robodriver-robot-[机器人型号]-[遥操方式]-[接入类型]`,例如 `robodriver-robot-myarm-aio-dora` RoboDriver:116-119 。 +- 在 `robodriver/robots/` 下新建同名目录,并包含 `pyproject.toml`、`README.md`、`robodriver_robot_xxx/` 包。 + +## 2 实现核心文件 +### 2.1 配置类(config.py) +- 继承 `RobotConfig` 并用 `@RobotConfig.register_subclass("myarm_aio_dora")` 注册 RoboDriver:11-16 。 +- 定义 `follower_motors`/`leader_motors`(电机与归一化模式)和 `cameras`(相机配置) RoboDriver:18-36 。 + +### 2.2 机器人类(robot.py) +- 继承 `lerobot.robots.robot.Robot`,实现 `connect`、`get_observation`、`send_action` RoboDriver:30-96 。 +- 在 `__init__` 中初始化通信节点(如 ROS2 节点或 Dora 动态节点)并启动 RoboDriver:24-42 。 +- 实现 `observation_features` 与 `action_features` 属性,描述观测与动作结构 RoboDriver:75-83 。 + +### 2.3 通信节点(按接入方式) +- **ROS2**:实现 rclpy 节点,订阅/发布关节与图像话题(参考 `A2DAioRos2Node`) RoboDriver:40-42 。 +- **Dora**:实现继承 `DoraRobotNode` 的动态节点,管理 `send_queue` 与 `dora_run` 线程,处理 PyArrow 消息 RoboDriver:38-40 。 +- **ROS1**:实现 ZMQ 桥接脚本,将 ROS Topic 转为 ZMQ 供主进程使用(见 realman-aio-ros1 示例) RoboDriver:41-49 。 + +### 2.4 数据流定义(仅 Dora) +- 在 `dora/dataflow.yml` 中定义相机节点、机械臂节点与动态节点,配置 `VIRTUAL_ENV`、`DEVICE_SERIAL`、`ARM_IP` 等环境变量 RoboDriver:1-15 。 + +## 3 编写文档与安装配置 +- 在 `README.md` 中写明环境准备、依赖安装、配置修改与启动步骤,参考 realman1-aio-dora 的 README RoboDriver:17-39 。 +- 在 `pyproject.toml` 中定义包名与入口,确保可被 `robodriver-run` 动态发现。 + +## 4 启动与验证 +```bash +# 安装插件 +uv pip install -e ./robodriver/robots/robodriver-robot-myarm-aio-dora + +# 启动 Dora 数据流(如为 Dora 接入) +dora start dora/dataflow.yml --uv + +# 启动 RoboDriver +robodriver-run --robot.type=myarm_aio_dora +``` + +## Notes +- 各接入方式的完整示例目录:`robodriver-robot-realman-aio-ros1`、`robodriver-robot-a2d-aio-ros2`、`robodriver-robot-agilex-aloha-aio-dora`、`robodriver-robot-realman1-aio-dora`、`robodriver-robot-so101-aio-dora` RoboDriver:98-115 。 +- 插件会被 `import_utils` 自动发现,确保包名以 `robodriver_robot_` 开头 RoboDriver:64-66 。 +- 如遇 OpenCV 显示报错,可注释 `run.py` 中的 `cv2.imshow` 与 `cv2.waitKey` RoboDriver:68-71 。 + +Wiki pages you might want to explore: +- [Overview (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#1) +- [Dora Dataflow Integration (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#3.2) +### Citations +**File:** README.md (L98-115) +```markdown +### 🔌 ROS1 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|------------|------|--------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman-aio-ros1](./robodriver/robots/robodriver-robot-realman-aio-ros1) | [](https://github.com/zhanglei-web) | + +### 🔌 ROS2 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| GALAXEALITE | 基于Galaxealite,双臂6DOF+末端夹爪,4*RealSense相机 | [robodriver/robots/robodriver-robot-galaxealite-aio-ros2](./robodriver/robots/robodriver-robot-galaxealite-aio-ros2) | [![liuyou1103](https://avatars.githubusercontent.com/liuyou1103?s=50)](https://github.com/liuyou1103) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-ros2](./robodriver/robots/robodriver-robot-so101-aio-ros2) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | + +### 🔌 Dora(SDK)接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman1-aio-dora](./robodriver/robots/robodriver-robot-realman1-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-dora](./robodriver/robots/robodriver-robot-so101-aio-dora) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | +| Franka | 工业级机械臂,6DOF+末端夹爪,1*RealSense相机 | [robodriver/robots/robodriver-robot-franka-aio-dora](./robodriver/robots/robodriver-robot-franka-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | + +``` +**File:** README.md (L116-119) +```markdown +> ✨ 说明: +> 1. 接入方式命名规范:`robodriver-robot-[机器人型号]-[遥操方式]-[接入类型]`(如 `aio`/`follwer`/`teleoperate`, `ros2`/`dora`); +> 2. 每个适配仓库内包含**环境搭建、配置修改、采集/控制验证**等完整接入指南; +> 3. 持续新增适配机器人,可关注本列表或项目更新。 +``` +**File:** robodriver/robots/configs.py (L11-16) +```python +@dataclass +class RobotConfig(draccus.ChoiceRegistry, abc.ABC): + @property + def type(self) -> str: + return self.get_choice_name(self.__class__) + +``` +**File:** robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/config.py (L18-36) +```python + leader_motors: Dict[str, Motor] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "joint1_right": Motor(1, "piper-motor", norm_mode_body), + "joint2_right": Motor(2, "piper-motor", norm_mode_body), + "joint3_right": Motor(3, "piper-motor", norm_mode_body), + "joint4_right": Motor(4, "piper-motor", norm_mode_body), + "joint5_right": Motor(5, "piper-motor", norm_mode_body), + "joint6_right": Motor(6, "piper-motor", norm_mode_body), + "gripper_right": Motor(7, "piper-gripper", MotorNormMode.RANGE_0_100), + + "joint1_left": Motor(8, "piper-motor", norm_mode_body), + "joint2_left": Motor(9, "piper-motor", norm_mode_body), + "joint3_left": Motor(10, "piper-motor", norm_mode_body), + "joint4_left": Motor(11, "piper-motor", norm_mode_body), + "joint5_left": Motor(12, "piper-motor", norm_mode_body), + "joint6_left": Motor(13, "piper-motor", norm_mode_body), + "gripper_left": Motor(14, "piper-gripper", MotorNormMode.RANGE_0_100), + } + ) +``` +**File:** robodriver/_simulators/robot.py (L30-96) +```python +class Robot(abc.ABC): + """ + The base abstract class for all LeRobot-compatible robots. + + This class provides a standardized interface for interacting with physical robots. + Subclasses must implement all abstract methods and properties to be usable. + + Attributes: + config_class (RobotConfig): The expected configuration class for this robot. + name (str): The unique robot name used to identify this robot type. + """ + + # Set these in ALL subclasses + config_class: builtins.type[RobotConfig] + name: str + + def __init__(self, config: RobotConfig): + self.robot_type = self.name + self.id = config.id + self.calibration_dir = ( + config.calibration_dir if config.calibration_dir else HF_LEROBOT_CALIBRATION / ROBOTS / self.name + ) + self.calibration_dir.mkdir(parents=True, exist_ok=True) + self.calibration_fpath = self.calibration_dir / f"{self.id}.json" + self.calibration: dict[str, MotorCalibration] = {} + if self.calibration_fpath.is_file(): + self._load_calibration() + + def __str__(self) -> str: + return f"{self.id} {self.__class__.__name__}" + + # TODO(aliberts): create a proper Feature class for this that links with datasets + @property + @abc.abstractmethod + def observation_features(self) -> dict: + """ + A dictionary describing the structure and types of the observations produced by the robot. + Its structure (keys) should match the structure of what is returned by :pymeth:`get_observation`. + Values for the dict should either be: + - The type of the value if it's a simple value, e.g. `float` for single proprioceptive value (a joint's position/velocity) + - A tuple representing the shape if it's an array-type value, e.g. `(height, width, channel)` for images + + Note: this property should be able to be called regardless of whether the robot is connected or not. + """ + pass + + @property + @abc.abstractmethod + def action_features(self) -> dict: + """ + A dictionary describing the structure and types of the actions expected by the robot. Its structure + (keys) should match the structure of what is passed to :pymeth:`send_action`. Values for the dict + should be the type of the value if it's a simple value, e.g. `float` for single proprioceptive value + (a joint's goal position/velocity) + + Note: this property should be able to be called regardless of whether the robot is connected or not. + """ + pass + + @property + @abc.abstractmethod + def is_connected(self) -> bool: + """ + Whether the robot is currently connected or not. If `False`, calling :pymeth:`get_observation` or + :pymeth:`send_action` should raise an error. + """ + pass +``` +**File:** robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py (L24-42) +```python + def __init__(self, config: A2DAioRos2RobotConfig): + rclpy.init() + super().__init__(config) + self.config = config + self.robot_type = self.config.type + self.use_videos = self.config.use_videos + self.microphones = self.config.microphones + + # A2D 通常没有 leader(遥操作主手),所以 leader_motors 为空 + self.leader_motors = config.leader_motors # 应为 {} + self.follower_motors = config.follower_motors # 包含 14 arm + 2 gripper + self.cameras = make_cameras_from_configs(self.config.cameras) + + # 排除某些虚拟相机(如 pose 图) + self.connect_excluded_cameras = ["image_pika_pose"] + + # 启动 ROS2 节点 + self.robot_ros2_node = A2DAioRos2Node() + self.robot_ros2_node.start() +``` +**File:** robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py (L75-83) +```python + @cached_property + def observation_features(self) -> dict[str, Any]: + return {**self._follower_motors_ft, **self._cameras_ft} + + @cached_property + def action_features(self) -> dict[str, Any]: + # A2D 是 follower-only 控制,action 即目标 follower joint + # 所以 action_features 应与 follower 一致(用于模仿学习) + return self._follower_motors_ft +``` +**File:** robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/robot.py (L38-40) +```python + self.status = AgilexAlohaAIODoraRobotStatus() + self.robot_dora_node = AgilexAlohaAIODoraRobotNode() + self.robot_dora_node.start() +``` +**File:** robodriver/robots/robodriver-robot-realman-aio-ros1/README.md (L41-49) +```markdown +### 2.3 Start dataflow (ROS1 → ZMQ bridge) / 启动 ROS1 → ZMQ 数据流 + +In this repository directory: +在本仓库目录中运行: + +```bash +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-realman-aio-ros1 +python -m robodriver_robot_realman_aio_ros1.ros1_zmq_bridge +``` +``` +**File:** robodriver/robots/robodriver-robot-realman-aio-ros1/README.md (L68-71) +```markdown +- If you encounter OpenCV `cvShowImage` related errors when launching RoboXStudio, + you can comment out `cv2.imshow` / `cv2.waitKey` in `robodriver/scripts/run.py`. + 如果在启动 RoboXStudio 时遇到 OpenCV `cvShowImage` 的报错,可以在 + `robodriver/scripts/run.py` 中注释掉 `cv2.imshow` 和 `cv2.waitKey` 调用。 +``` +**File:** robodriver/robots/robodriver-robot-realman1-aio-dora/dora/dataflow.yml (L1-15) +```yaml +nodes: + - id: camera_top + path: ../../../../components/cameras/dora-camera-realsense/dora_camera_realsense/main.py + inputs: + tick: dora/timer/millis/33 + outputs: + - image + - image_depth + env: + VIRTUAL_ENV: ../.venv + DEVICE_SERIAL: 150622072280 + IMAGE_WIDTH: 640 + IMAGE_HEIGHT: 480 + + - id: camera_left_wrist +``` +**File:** robodriver/robots/robodriver-robot-realman1-aio-dora/README.md (L17-39) +```markdown +安装 robodriver-robot-realman1-aio-dora 模块 环境 +``` +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-realman1-aio-dora +uv venv .venv -p 3.9 +source .venv/bin/activate +uv pip install -e . +``` + +配置 dataflow.yml 文件 +``` +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-realman1-aio-dora +cd dora +``` + +打开 dataflow.yml 文件,修改其中的 VIRTUAL_ENV 路径,以及 DEVICE_SERIAL、ARM_IP 和 ARM_PORT 参数。 +### 参数说明: + +- VIRTUAL_ENV:realman dora 节点使用的虚拟环境路径 +- DEVICE_SERIAL:RealSense 相机的序列号 +- ARM_IP:机械臂的 IP 地址 +- ARM_PORT:机械臂的端口号 + +## 开始数据采集 +``` +**File:** robodriver/_simulators/utils.py (L64-66) +```python + try: + return cast(Robot, make_device_from_device_class(config)) + except Exception as e: +``` +## Q3 +可以pip install直接加机械臂吗? +### Answer +可以。只要你的包名以 `robodriver_robot_` 开头并通过 pip/uv 安装到当前 Python 环境,RoboDriver 启动时会自动发现并注册该机械臂插件,无需将代码放入主仓库 RoboDriver:100-136 。 + +--- + +## 原理 +- `register_third_party_devices` 在 `robodriver-run` 启动时扫描 `sys.path` 中所有以 `robodriver_robot_` 等前缀开头的模块并导入,使其配置类注册到 `ChoiceRegistry` RoboDriver:127-136 。 +- `make_device_from_device_class` 根据配置的 `type` 动态实例化对应的机器人类,支持从外部包加载 RoboDriver:27-97 。 + +## 步骤 +1. 按规范创建独立包,包名如 `robodriver_robot_myarm_aio_dora`,内部实现 `RobotConfig` 子类并注册,例如 `@RobotConfig.register_subclass("myarm_aio_dora")` RoboDriver:11-16 。 +2. 在 RoboDriver 主环境中安装该包: + ```bash + pip install ./robodriver_robot_myarm_aio_dora # 或 uv pip install + ``` +3. 启动时指定类型: + ```bash + robodriver-run --robot.type=myarm_aio_dora + ``` + +## 示例参考 +- `robodriver-robot-realman1-aio-dora` 等插件即采用此方式,在其目录内 `uv pip install -e .` 后,主环境可直接使用 `--robot.type=realman1_aio_dora` RoboDriver:17-39 。 + +## Notes +- 确保包名前缀为 `robodriver_robot_`,否则不会被自动扫描 RoboDriver:107-113 。 +- 插件安装后无需修改主仓库代码,重启 `robodriver-run` 即生效。 + +Wiki pages you might want to explore: +- [Overview (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#1) +### Citations +**File:** robodriver/utils/import_utils.py (L27-97) +```python +def make_device_from_device_class(config: ChoiceRegistry) -> Any: + """ + Dynamically instantiates an object from its `ChoiceRegistry` configuration. + + This factory uses the module path and class name from the `config` object's + type to locate and instantiate the corresponding device class (not the config). + It derives the device class name by removing a trailing 'Config' from the config + class name and tries a few candidate modules where the device implementation is + commonly located. + """ + if not isinstance(config, ChoiceRegistry): + raise ValueError( + f"Config should be an instance of `ChoiceRegistry`, got {type(config)}" + ) + + config_cls = config.__class__ + module_path = ( + config_cls.__module__ + ) # typical: lerobot_teleop_mydevice.config_mydevice + config_name = config_cls.__name__ # typical: MyDeviceConfig + + # Derive device class name (strip "Config") + if not config_name.endswith("Config"): + raise ValueError( + f"Config class name '{config_name}' does not end with 'Config'" + ) + + device_class_name = config_name[:-6] # typical: MyDeviceConfig -> MyDevice + + # Build candidate modules to search for the device class + parts = module_path.split(".") + parent_module = ".".join(parts[:-1]) if len(parts) > 1 else module_path + candidates = [ + parent_module, # typical: lerobot_teleop_mydevice + parent_module + + "." + + device_class_name.lower(), # typical: lerobot_teleop_mydevice.mydevice + ] + + # handle modules named like "config_xxx" -> try replacing that piece with "xxx" + last = parts[-1] if parts else "" + if last.startswith("config_"): + candidates.append(".".join(parts[:-1] + [last.replace("config_", "")])) + + # de-duplicate while preserving order + seen: set[str] = set() + candidates = [c for c in candidates if not (c in seen or seen.add(c))] + + tried: list[str] = [] + for candidate in candidates: + tried.append(candidate) + try: + module = importlib.import_module(candidate) + except ImportError: + continue + + if hasattr(module, device_class_name): + cls = getattr(module, device_class_name) + if callable(cls): + try: + return cls(config) + except TypeError as e: + raise TypeError( + f"Failed to instantiate '{device_class_name}' from module '{candidate}': {e}" + ) from e + + raise ImportError( + f"Could not locate device class '{device_class_name}' for config '{config_name}'. " + f"Tried modules: {tried}. Ensure your device class name is the config class name without " + f"'Config' and that it's importable from one of those modules." + ) +``` +**File:** robodriver/utils/import_utils.py (L100-136) +```python +def register_third_party_devices() -> None: + """ + Discover and import third-party robodriver_* plugins so they can register themselves. + + Scans top-level modules on sys.path for packages starting with + 'robodriver_robot_', 'robodriver_camera_', "robodriver_policy_" or 'robodriver_teleoperator_' and imports them. + """ + prefixes = ( + "robodriver_robot_", + "robodriver_camera_", + "robodriver_sim", + "robodriver_policy_", + "robodriver_teleoperator", + ) + imported: list[str] = [] + failed: list[str] = [] + + for module_info in pkgutil.iter_modules(): + name = module_info.name + if name.startswith(prefixes): + _attempt_import(name, imported, failed) + + editable_pattern = re.compile(r"__editable___(.+?)_\d+_\d+_\d+_finder") + for module_info in pkgutil.iter_modules(): + match = editable_pattern.match(module_info.name) + if match: + original_name = match.group(1).replace( + "_", "-" + ) # Convert to canonical package name + if any( + original_name.startswith(prefix.replace("_", "-")) + for prefix in prefixes + ): + # Convert back to module name format (replace - with _) + module_name = original_name.replace("-", "_") + _attempt_import(module_name, imported, failed) + +``` +**File:** robodriver/scripts/run.py (L127-136) +```python +def main(): + git_branch_log() + + register_third_party_devices() + logger.info(f"Registered robot types: {list(RobotConfig._choice_registry.keys())}") + logger.info( + f"Registered teleoperator types: {list(TeleoperatorConfig._choice_registry.keys())}" + ) + + asyncio.run(async_main()) +``` +**File:** robodriver/robots/robodriver-robot-realman1-aio-dora/README.md (L17-39) +```markdown +安装 robodriver-robot-realman1-aio-dora 模块 环境 +``` +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-realman1-aio-dora +uv venv .venv -p 3.9 +source .venv/bin/activate +uv pip install -e . +``` + +配置 dataflow.yml 文件 +``` +cd /path/to/your/RoboDriver/robodriver/robots/robodriver-robot-realman1-aio-dora +cd dora +``` + +打开 dataflow.yml 文件,修改其中的 VIRTUAL_ENV 路径,以及 DEVICE_SERIAL、ARM_IP 和 ARM_PORT 参数。 +### 参数说明: + +- VIRTUAL_ENV:realman dora 节点使用的虚拟环境路径 +- DEVICE_SERIAL:RealSense 相机的序列号 +- ARM_IP:机械臂的 IP 地址 +- ARM_PORT:机械臂的端口号 + +## 开始数据采集 +``` +## Q4 +运行robodriver-run -h 时INFO:root:Environment variable 'ROBODRIVER_HOME' not set. Using defaul + t path: /home/dora/DoRobot 这个地址是什么意思? +### Answer +`ROBODRIVER_HOME` 是 RoboDriver 用于存放采集数据、数据集与运行时文件的根目录。未设置时默认为 `~/DoRobot`(当前用户主目录下的 DoRobot),程序会自动创建该目录 RoboDriver:26-38 。 + +--- + +## 用途 +- 数据采集默认存储位置:任务数据按任务名存为子目录,包含 data、videos、depth、meta、audio 等子文件夹 RoboDriver:200-237 。 +- 数据集默认根路径:`DOROBOT_DATASET = ROBODRIVER_HOME / "dataset"`,用于 DoRobotDataset 读取与回放 RoboDriver:49-49 。 + +## 如何自定义 +```bash +export ROBODRIVER_HOME=/path/to/my_storage +robodriver-run -h +``` +设置后日志会显示已使用自定义路径 RoboDriver:30-36 。 + +## 目录结构示例(采集后) +``` +/home/dora/DoRobot/ +└─ TaskName_TaskId/ + ├─ data/ # Parquet 运动数据 + ├─ videos/ # MP4 图像 + ├─ depth/ # AVI 深度图 + ├─ audio/ # WAV 音频 + ├─ meta/ # 元信息 JSON + └─ device/ # 设备信息 +``` RoboDriver:214-250 + +## Notes +- 若无写权限,程序会报错并提示设置 ROBODRIVER_HOME 到可写位置 RoboDriver:40-44 。 +- 回放与可视化工具默认也基于该路径查找数据集,除非用 --root 参数覆盖 RoboDriver:21-22 RoboDriver:420-424 。 + +Wiki pages you might want to explore: +- [ROS2 Integration (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#3.3) +### Citations +**File:** robodriver/utils/constants.py (L26-38) +```python +user_home = Path.home() +ROBODRIVER_HOME = Path(os.getenv("ROBODRIVER_HOME", str(user_home / "DoRobot"))).expanduser().resolve() + + +if "ROBODRIVER_HOME" not in os.environ: + logging.info( + f"Environment variable 'ROBODRIVER_HOME' not set. Using default path: {ROBODRIVER_HOME}" + ) +else: + logging.info(f"Environment variable 'ROBODRIVER_HOME' is set to: {ROBODRIVER_HOME}") + +try: + ROBODRIVER_HOME.mkdir(parents=True, exist_ok=True) +``` +**File:** robodriver/utils/constants.py (L40-44) +```python +except PermissionError as e: + logging.error(f"Permission denied! Cannot create {ROBODRIVER_HOME}") + logging.error(f"Please check permissions for: {user_home}") + logging.error(f"Or set ROBODRIVER_HOME environment variable to a writable location") + raise +``` +**File:** robodriver/utils/constants.py (L49-49) +```python +DOROBOT_DATASET = ROBODRIVER_HOME / "dataset" +``` +**File:** robodriver/robots/robodriver-robot-galaxealite-aio-ros1/README-EN.md (L200-237) +```markdown +## Data Description +### Storage Path +Collected data is stored in the `/home/yourname/DoRobot` directory by default, organized into folders named by task. + +### Directory Structure +``` +TaskName_TaskId/ +├── audio/ # Audio data +│ └── chunk-000/ +│ ├── observation.audio.audio_left/ # Left channel audio (WAV format) +│ └── observation.audio.audio_right/ # Right channel audio (WAV format) +├── data/ # Motion command/feedback data (Parquet format) +│ └── chunk-000/ +├── depth/ # Depth images (AVI format) +│ └── chunk-000/ +│ ├── observation.images.image_depth_right/ +│ └── observation.images.image_depth_top/ +├── device/ # Device information +│ └── device_info.json +├── label/ # Annotation data (generated after annotation) +│ └── data_annotation.json +├── meta/ # Metadata +│ ├── common_record.json # Collection task information +│ ├── episodes.jsonl # Task description and frame length +│ ├── episodes_stats.jsonl # Normalized statistical information +│ ├── info.json # Feature schema, frame rate, version +│ ├── op_dataid.jsonl # Device number +│ └── tasks.jsonl +└── videos/ # Visible light images (MP4 format) + └── chunk-000/ + ├── observation.images.image_left/ # Left camera + ├── observation.images.image_left_tac_l/ # Left tactile left camera + ├── observation.images.image_left_tac_r/ # Left tactile right camera + ├── observation.images.image_right/ # Right camera + ├── observation.images.image_right_tac_l/ # Right tactile left camera + ├── observation.images.image_right_tac_r/ # Right tactile right camera + └── observation.images.image_top/ # Top camera +``` +``` +**File:** robodriver/robots/robodriver-robot-galaxealite-aio-ros2/README.md (L214-250) +```markdown +### 存储路径 +采集数据默认存储于 `/home/yourname/DoRobot` 目录,按任务命名划分文件夹。 + +### 目录结构 +``` +TaskName_TaskId/ +├── audio/ # 音频数据 +│ └── chunk-000/ +│ ├── observation.audio.audio_left/ # 左声道音频(WAV格式) +│ └── observation.audio.audio_right/ # 右声道音频(WAV格式) +├── data/ # 运动指令/反馈数据(Parquet格式) +│ └── chunk-000/ +├── depth/ # 深度图像(AVI格式) +│ └── chunk-000/ +│ ├── observation.images.image_depth_right/ +│ └── observation.images.image_depth_top/ +├── device/ # 设备信息 +│ └── device_info.json +├── label/ # 标注数据(标注后生成) +│ └── data_annotation.json +├── meta/ # 元数据 +│ ├── common_record.json # 采集任务信息 +│ ├── episodes.jsonl # 任务描述与帧长 +│ ├── episodes_stats.jsonl # 归一化统计信息 +│ ├── info.json # 特征schema、帧率、版本 +│ ├── op_dataid.jsonl # 设备编号 +│ └── tasks.jsonl +└── videos/ # 可见光图像(MP4格式) + └── chunk-000/ + ├── observation.images.image_left/ # 左侧相机 + ├── observation.images.image_left_tac_l/ # 左侧触觉左相机 + ├── observation.images.image_left_tac_r/ # 左侧触觉右相机 + ├── observation.images.image_right/ # 右侧相机 + ├── observation.images.image_right_tac_l/ # 右侧触觉左相机 + ├── observation.images.image_right_tac_r/ # 右侧触觉右相机 + └── observation.images.image_top/ # 顶部相机 +``` +``` +**File:** robodriver/core/replayer.py (L21-22) +```python + # Root directory where the dataset will be stored (e.g. 'dataset/path'). + root: str | Path | None = None +``` +**File:** robodriver/dataset/visual/visualize_dataset_html.py (L420-424) +```python + "--root", + type=Path, + default=None, + help="Root directory for a dataset stored locally (e.g. `--root data`). By default, the dataset will be loaded from hugging face cache folder, or downloaded from the hub if available.", + ) +``` +## Q5 +怎样进行的数据保存? +### Answer +RoboDriver 通过 Coordinator 接收采集命令,使用 Record 组件将观测与动作以改进的 LeRobot 单条存储格式写入磁盘,支持图像(PNG/MP4)、音频(WAV)与元数据(JSON),并在保存后进行完整性校验 RoboDriver:113-205 RoboDriver:26-49 。 + +--- + +## 保存触发与路径构建 +- 触发:Coordinator 收到 `start_collection` 后检查磁盘空间,构造 `RecordConfig` 与 `Record` 实例并调用 `record.start()`;收到 `finish_collection` 后调用 `record.stop()` 与 `record.save()` RoboDriver:113-219 。 +- 路径:默认根目录为 `ROBODRIVER_HOME/dataset`,采集目录按 `日期/user/任务名_任务ID/任务名_任务ID_数据ID` 组织,支持通过 `ROBODRIVER_HOME` 环境变量自定义根目录 RoboDriver:26-49 。 + +## 数据格式与文件组织 +- Parquet:每帧一行(单条存储),路径模板 `data/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet` RoboDriver:42-44 。 +- 图像:可选 PNG(逐帧)或 MP4(视频),路径模板见 `DEFAULT_IMAGE_PATH` 与 `DEFAULT_VIDEO_PATH` RoboDriver:45-38 。 +- 音频:WAV 格式,由 `AsyncAudioWriter` 多设备异步写入 RoboDriver:12-30 。 +- 元信息:`meta/` 目录下包含 info.json、episodes.jsonl、episodes_stats.jsonl、tasks.jsonl、common_record.json 等 RoboDriver:214-250 。 + +## 并发写入与线程模型 +- 图像:ImageWriter 线程池(每相机一线程)异步写入 PNG 或封装 MP4 RoboDriver:1-258 。 +- 音频:AsyncAudioWriter 为每个麦克风启动独立录制任务,通过 soundfile 写 WAV RoboDriver:31-73 。 +- 主线程:在控制循环中调用 `add_frame` 将观测/动作加入 episode 缓冲,最终由 Record 线程落盘 Parquet。 + +## 保存后校验 +- 文件完整性:检查 Parquet 可读、videos/images 目录存在与帧数匹配 RoboDriver:403-434 。 +- 帧率校验:对比视频帧率与配置 FPS,生成校验报告 RoboDriver:780-821 。 + +## 回放与可视化 +- DoRobotDataset 读取本地数据集,默认从 `DOROBOT_DATASET` 加载,支持 `--root` 覆盖 RoboDriver:21-22 RoboDriver:420-424 。 +- 可视化工具支持本地与远程模式,通过 Rerun 或 Web 界面回放视频与时序数据 RoboDriver:242-338 。 + +## 目录结构示例 +``` +/home/dora/DoRobot/dataset/20250827/user/TaskA_001/TaskA_001_abc123/ +├─ data/chunk-000/episode_000000.parquet +├─ videos/chunk-000/image_top/episode_000000.mp4 +├─ audio/chunk-000/audio_left/episode_000000.wav +├─ meta/info.json +├─ meta/episodes.jsonl +└─ device/device_info.json +``` + +## Notes +- 单条存储便于逐帧编辑与传输,训练时可聚合为多集合格式 RoboDriver:42-43 。 +- 若磁盘空间不足(<2GB),采集会被拒绝 RoboDriver:117-120 。 +- 完整数据保存流程见 Record 类实现与 Coordinator 的 `__on_robot_command_handle` RoboDriver:1-258 。 + +Wiki pages you might want to explore: +- [Overview (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#1) +### Citations +**File:** robodriver/core/coordinator.py (L113-219) +```python + elif data.get("cmd") == "start_collection": + logger.info("处理开始采集命令...") + msg = data.get("msg") + + if not check_disk_space(min_gb=2): + logger.warning("存储空间不足,小于2GB,取消采集!") + await self.send_response("start_collection", "存储空间不足,小于2GB") + return + + if self.replaying == True: + logger.warning("Replay is running, cannot start collection.") + await self.send_response("start_collection", "fail") + return + + if self.recording == True: + self.record.stop() + self.record.discard() + self.recording = False + + self.recording = True + + task_id = msg.get("task_id") + task_name = msg.get("task_name") + task_data_id = msg.get("task_data_id") + countdown_seconds = msg.get("countdown_seconds", 3) + task_dir = f"{task_name}_{task_id}" + repo_id = f"{task_name}_{task_id}_{task_data_id}" + + date_str = datetime.now().strftime("%Y%m%d") + + # 构建目标目录路径 + dataset_path = DOROBOT_DATASET + + git_branch_name = get_current_git_branch() + target_dir = dataset_path / date_str / "user" / task_dir / repo_id + # if "release" in git_branch_name or "main" in git_branch_name: + # target_dir = dataset_path / date_str / "user" / task_dir / repo_id + # elif "dev" in git_branch_name: + # target_dir = dataset_path / date_str / "dev" / task_dir / repo_id + # else: + # target_dir = dataset_path / date_str / "dev" / task_dir / repo_id + + # 判断是否存在对应文件夹以决定是否启用恢复模式 + resume = False + + # 检查数据集目录是否存在 + if not dataset_path.exists(): + logger.info( + f"Dataset directory '{dataset_path}' does not exist. Cannot resume." + ) + else: + # 检查目标文件夹是否存在且为目录 + if target_dir.exists() and target_dir.is_dir(): + # resume = True + # logging.info(f"Found existing directory for repo_id '{repo_id}'. Resuming operation.") + + logger.info( + f"Found existing directory for repo_id '{repo_id}'. Delete directory." + ) + shutil.rmtree(target_dir) + time.sleep(0.5) # make sure delete success. + else: + logger.info( + f"No directory found for repo_id '{repo_id}'. Starting fresh." + ) + + # resume 变量现在可用于后续逻辑 + logger.info(f"Resume mode: {'Enabled' if resume else 'Disabled'}") + + record_cfg = RecordConfig( + fps=DEFAULT_FPS, + single_task=task_name, + repo_id=repo_id, + video=self.daemon.robot.use_videos, + resume=resume, + root=target_dir, + ) + self.record = Record( + fps=DEFAULT_FPS, + robot=self.daemon.robot, + daemon=self.daemon, + teleop=self.teleop, + record_cfg=record_cfg, + record_cmd=msg, + ) + # 发送响应 + await self.send_response("start_collection", "success") + # 开始采集倒计时 + logger.info(f"开始采集倒计时{countdown_seconds}s...") + time.sleep(countdown_seconds) + + # 开始采集 + self.record.start() + + elif data.get("cmd") == "finish_collection": + logger.info("处理完成采集命令...") + if self.replaying == True: + logger.warning("Replay is running, cannot finish collection.") + await self.send_response("finish_collection", "fail") + return + + if not self.saveing and self.record.save_data is None: + # 如果不在保存状态,立即停止记录并保存 + self.saveing = True + self.record.stop() + self.record.save() + self.recording = False +``` +**File:** robodriver/utils/constants.py (L26-49) +```python +user_home = Path.home() +ROBODRIVER_HOME = Path(os.getenv("ROBODRIVER_HOME", str(user_home / "DoRobot"))).expanduser().resolve() + + +if "ROBODRIVER_HOME" not in os.environ: + logging.info( + f"Environment variable 'ROBODRIVER_HOME' not set. Using default path: {ROBODRIVER_HOME}" + ) +else: + logging.info(f"Environment variable 'ROBODRIVER_HOME' is set to: {ROBODRIVER_HOME}") + +try: + ROBODRIVER_HOME.mkdir(parents=True, exist_ok=True) + logging.info(f"Directory ready: {ROBODRIVER_HOME}") +except PermissionError as e: + logging.error(f"Permission denied! Cannot create {ROBODRIVER_HOME}") + logging.error(f"Please check permissions for: {user_home}") + logging.error(f"Or set ROBODRIVER_HOME environment variable to a writable location") + raise +except Exception as e: + logging.error(f"Failed to create directory: {e}") + raise + +DOROBOT_DATASET = ROBODRIVER_HOME / "dataset" +``` +**File:** robodriver/utils/dataset.py (L42-44) +```python +DEFAULT_PARQUET_PATH = ( + "data/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet" +) +``` +**File:** robodriver/utils/dataset.py (L45-38) +```python + +``` +**File:** robodriver/dataset/audio_writer.py (L12-30) +```python +class AsyncAudioWriter: + """异步多设备音频录制器 + + 示例: + writer = AsyncAudioWriter( + microphones={ + "left": 0, # 设备ID 0 + "right": 1 # 设备ID 1 + }, + savepath={ + "left": Path("./cache/audio/left.wav"), # 使用Path对象 + "right": Path("./cache/audio/right.wav") # 使用Path对象 + } + ) + writer.start() + # ... 录制中 ... + writer.stop() + writer.wait_until_done() # 确保所有数据写入完成 + """ +``` +**File:** robodriver/dataset/audio_writer.py (L31-73) +```python + + def __init__( + self, + microphones: Dict[str, int], + savepath: Dict[str, Path], # 修改为Path对象字典 + samplerate: Optional[int] = None, + channels: int = 1, + subtype: Optional[str] = "PCM_16", + ): + """ + 初始化音频录制器 + + Args: + microphones: 设备ID映射字典 (设备名称 -> 设备ID) + savepath: 保存路径映射字典 (设备名称 -> Path对象) + samplerate: 采样率 (None表示使用设备默认值) + channels: 声道数 (默认1) + subtype: 音频格式 (如 "PCM_24", None表示使用soundfile默认) + """ + # 验证每个microphones键都能在savepath中找到对应的键(通过后缀匹配) + for mic_key in microphones.keys(): + # 查找包含mic_key的savepath键 + matching_keys = [k for k in savepath.keys() if k.endswith(mic_key)] + + if not matching_keys: + raise ValueError( + f"savepath中找不到与microphones键 '{mic_key}' 对应的键\n" + f"microphones keys: {list(microphones.keys())}\n" + f"savepath keys: {list(savepath.keys())}" + ) + + self.microphones = microphones + self.savepath = savepath # 现在是一个字典 {设备名: Path对象} + self.samplerate = samplerate + self.channels = channels + self.subtype = subtype + + # 内部状态 + self._tasks: List[Dict] = [] # 存储每个任务的资源 + self._is_started = False + self._stop_event = threading.Event() + self._stop_timeout = 5.0 # 停止超时时间(秒) + +``` +**File:** robodriver/utils/data_file.py (L403-434) +```python + img_session_dir = images_dir + + if not parquet_path.exists(): + verification_result["file_integrity"] = "no pass" + verification_result["file_integrity_comment"] = ( + f"检测不到Parquet文件: {parquet_path}" + ) + return {"verification": verification_result} + + try: + df = pd.read_parquet(parquet_path) + except Exception as e: + verification_result["file_integrity"] = "no pass" + verification_result["file_integrity_comment"] = f"读取Parquet文件失败: {str(e)}" + return {"verification": verification_result} + + # 2. 确定媒体目录和类型(核心:优先videos,后images) + media_dir = None + media_type = None + if videos_dir.exists(): + media_dir = videos_dir + media_type = "video" + print(f"[INFO] 检测到videos目录,优先使用该目录校验: {videos_dir}") + elif images_dir.exists(): + media_dir = images_dir + media_type = "image" + print(f"[INFO] 未检测到videos目录,使用images目录校验: {images_dir}") + else: + verification_result["camera_frame_rate"] = "no pass" + verification_result["file_integrity"] = "no pass" + verification_result["file_integrity_comment"] = "未检测到videos/images目录" + return {"verification": verification_result} +``` +**File:** robodriver/utils/data_file.py (L780-821) +```python +def update_common_record_json(path, data): + opdata_path = os.path.join(path, "meta", "common_record.json") + os.makedirs(os.path.dirname(opdata_path), exist_ok=True) + if os.path.isfile(opdata_path): + with open(opdata_path, "r", encoding="utf-8") as f: + data = json.load(f) + if "task_id" in data: + return + overwrite_data = { + "task_id": str(data["task_id"]), + "task_name": str(data["task_name"]), + "machine_id": str(data["machine_id"]), + } + # 假设data变量已提前定义(包含所需的所有键值对) + # overwrite_data = { + # "task_id": str(data["task_id"]), + # "task_name": str(data["task_name"]), + # "machine_id": str(data["machine_id"]), + # "scene_type": str(data["scene_type"]), # 场景标签 + # "task_description": str(data["task_description"]), # 任务描述 + # "tele_type": str(data["tele_type"]), # 遥操作类型 + # "task_instruction": str(data["task_instruction"]) # 任务步骤 + # } + + # 以追加模式打开文件(如果不存在则创建) + with open(opdata_path, "w", encoding="utf-8") as f: + # 写入一行 JSON 数据(每行一个 JSON 对象) + f.write(json.dumps(overwrite_data, ensure_ascii=False) + "\n") + + +def check_disk_space(min_gb=1): + # 获取根目录(/)的磁盘使用情况(Docker 默认挂载点) + total, used, free = shutil.disk_usage("/") + # 转换为 GB + free_gb = free // (2**30) # 1 GB = 2^30 bytes + if free_gb < min_gb: + print(f"警告:剩余存储空间不足 {min_gb}GB(当前剩余 {free_gb}GB)") + return False + else: + print(f"存储空间充足(剩余 {free_gb}GB)") + return True + +``` +**File:** robodriver/dataset/visual/visualize_dataset_html.py (L420-424) +```python + "--root", + type=Path, + default=None, + help="Root directory for a dataset stored locally (e.g. `--root data`). By default, the dataset will be loaded from hugging face cache folder, or downloaded from the hub if available.", + ) +``` +**File:** robodriver/dataset/visual/visual_dataset.py (L242-338) +```python +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument( + "--repo-id", + type=str, + required=True, + help="Name of hugging face repository containing a DoRobotDataset dataset (e.g. `lerobot/pusht`).", + ) + parser.add_argument( + "--episode-index", + type=int, + required=True, + help="Episode to visualize.", + ) + parser.add_argument( + "--root", + type=Path, + default=None, + help="Root directory for the dataset stored locally (e.g. `--root data`). By default, the dataset will be loaded from hugging face cache folder, or downloaded from the hub if available.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Directory path to write a .rrd file when `--save 1` is set.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=32, + help="Batch size loaded by DataLoader.", + ) + parser.add_argument( + "--num-workers", + type=int, + default=4, + help="Number of processes of Dataloader for loading the data.", + ) + parser.add_argument( + "--mode", + type=str, + default="local", + help=( + "Mode of viewing between 'local' or 'distant'. " + "'local' requires data to be on a local machine. It spawns a viewer to visualize the data locally. " + "'distant' creates a server on the distant machine where the data is stored. " + "Visualize the data by connecting to the server with `rerun ws://localhost:PORT` on the local machine." + ), + ) + parser.add_argument( + "--web-port", + type=int, + default=9195, + help="Web port for rerun.io when `--mode distant` is set.", + ) + parser.add_argument( + "--ws-port", + type=int, + default=9285, + help="Web socket port for rerun.io when `--mode distant` is set.", + ) + parser.add_argument( + "--save", + type=int, + default=0, + help=( + "Save a .rrd file in the directory provided by `--output-dir`. " + "It also deactivates the spawning of a viewer. " + "Visualize the data by running `rerun path/to/file.rrd` on your local machine." + ), + ) + + parser.add_argument( + "--tolerance-s", + type=float, + default=1e-4, + help=( + "Tolerance in seconds used to ensure data timestamps respect the dataset fps value" + "This is argument passed to the constructor of DoRobotDataset and maps to its tolerance_s constructor argument" + "If not given, defaults to 1e-4." + ), + ) + + args = parser.parse_args() + kwargs = vars(args) + repo_id = kwargs.pop("repo_id") + root = kwargs.pop("root") + tolerance_s = kwargs.pop("tolerance_s") + + logging.info("Loading dataset") + + stop_event = Event() + dataset = DoRobotDataset(repo_id, root=root, tolerance_s=tolerance_s) + + # try: + visualize_dataset(dataset, **vars(args), stop_event=stop_event) +``` +**File:** README.md (L42-43) +```markdown +- **改进的LeRobot数据集格式**:在不同数据阶段采取不同数据结构。在采集端单条储存,更易编辑和传输。还扩展了LeRobot格式的内容。 + +``` +## Q6 +我是说我采集完一组数据后怎样做才能保存这一组数据? +### Answer +在采集完成后,你需要通过 RoboXStudio 点击“完成采集”或向 Coordinator 发送 `finish_collection` 命令,系统会自动停止录制并保存数据到磁盘 RoboDriver:207-233 。 + +--- + +## 保存触发流程 +- **触发方式**:RoboXStudio 界面点击“完成采集”,或直接发送 SocketIO 命令 `{"cmd": "finish_collection"}` 到 Coordinator RoboDriver:207-213 。 +- **内部执行**: + 1. Coordinator 将 `self.saveing` 置为 true,调用 `self.record.stop()` 停止录制线程 RoboDriver:216-218 。 + 2. 调用 `self.record.save()` 将内存中的 episode 缓冲写入 Parquet,并等待图像/音频线程完成写入 RoboDriver:218-220 。 + 3. 保存完成后将 `self.recording` 与 `self.saveing` 置为 false,并将 `self.record.save_data`(包含元信息)返回给服务器 RoboDriver:220-233 。 + +## 行为细节 +- 若同名目录已存在,当前实现会在开始采集时删除旧目录并重新采集,因此“完成采集”总是完整保存新数据,不支持断点续存的保存模式(resume=False) RoboDriver:169-177 。 +- 保存路径为 `ROBODRIVER_HOME/dataset/日期/user/任务名_任务ID/任务名_任务ID_数据ID`,可在环境变量 `ROBODRIVER_HOME` 中自定义根目录 RoboDriver:26-49 。 + +## Notes +- 保存过程可能较久(图像转 MP4/写入 WAV),期间 UI 会显示保存中,请勿关闭程序 RoboDriver:222-225 。 +- 若想放弃本次采集,可发送 `discard_collection` 命令,系统会调用 `record.discard()` 并删除临时数据 RoboDriver:235-249 。 + +Wiki pages you might want to explore: +- [Overview (FlagOpen/RoboDriver)](/wiki/FlagOpen/RoboDriver#1) +### Citations +**File:** robodriver/core/coordinator.py (L169-177) +```python + logger.info( + f"Found existing directory for repo_id '{repo_id}'. Delete directory." + ) + shutil.rmtree(target_dir) + time.sleep(0.5) # make sure delete success. + else: + logger.info( + f"No directory found for repo_id '{repo_id}'. Starting fresh." + ) +``` +**File:** robodriver/core/coordinator.py (L207-233) +```python + elif data.get("cmd") == "finish_collection": + logger.info("处理完成采集命令...") + if self.replaying == True: + logger.warning("Replay is running, cannot finish collection.") + await self.send_response("finish_collection", "fail") + return + + if not self.saveing and self.record.save_data is None: + # 如果不在保存状态,立即停止记录并保存 + self.saveing = True + self.record.stop() + self.record.save() + self.recording = False + self.saveing = False + + # 如果正在保存,循环等待直到 self.record.save_data 有数据 + while self.saveing: + time.sleep(0.1) # 避免CPU过载,适当延迟 + # 此时无论 saveing 状态如何,self.record.save_data 已有有效数据 + response_data = { + "msg": "success", + "data": self.record.save_data, + } + # 发送响应 + await self.send_response( + "finish_collection", response_data["msg"], response_data + ) +``` +**File:** robodriver/core/coordinator.py (L235-249) +```python + elif data.get("cmd") == "discard_collection": + # 模拟处理丢弃采集 + logger.info("处理丢弃采集命令...") + + if self.replaying == True: + logger.warning("Replay is running, cannot discard collection.") + await self.send_response("discard_collection", "fail") + return + + self.record.stop() + self.record.discard() + self.recording = False + + # 发送响应 + await self.send_response("discard_collection", "success") +``` +**File:** robodriver/utils/constants.py (L26-49) +```python +user_home = Path.home() +ROBODRIVER_HOME = Path(os.getenv("ROBODRIVER_HOME", str(user_home / "DoRobot"))).expanduser().resolve() + + +if "ROBODRIVER_HOME" not in os.environ: + logging.info( + f"Environment variable 'ROBODRIVER_HOME' not set. Using default path: {ROBODRIVER_HOME}" + ) +else: + logging.info(f"Environment variable 'ROBODRIVER_HOME' is set to: {ROBODRIVER_HOME}") + +try: + ROBODRIVER_HOME.mkdir(parents=True, exist_ok=True) + logging.info(f"Directory ready: {ROBODRIVER_HOME}") +except PermissionError as e: + logging.error(f"Permission denied! Cannot create {ROBODRIVER_HOME}") + logging.error(f"Please check permissions for: {user_home}") + logging.error(f"Or set ROBODRIVER_HOME environment variable to a writable location") + raise +except Exception as e: + logging.error(f"Failed to create directory: {e}") + raise + +DOROBOT_DATASET = ROBODRIVER_HOME / "dataset" +``` + +# DeepWiki Q&A with Code Context for Repository: FlagOpen/RoboDriver +## Q1 +支持哪些机械臂,怎样添加新的机械臂 +### Answer +# RoboDriver 支持的机械臂及如何添加新机械臂 + +## 一、当前支持的机械臂 + +RoboDriver 按接入方式分类,目前支持以下机械臂: RoboDriver:96-119 + +### 按目录结构整理如下: + +| 机器人型号 | 接入方式 | 目录名 | Robot Type 标识 | +|---|---|---|---| +| **Realman 机械臂** | ROS1 | `robodriver-robot-realman-aio-ros1` | `realman_aio_ros1` | +| **Realman 机械臂** | Dora (SDK) | `robodriver-robot-realman1-aio-dora` | `realman1_aio_dora` | +| **SO101 机械臂** | ROS2 | `robodriver-robot-so101-aio-ros2` | `so101_aio_ros2` | +| **SO101 机械臂** | Dora (SDK) | `robodriver-robot-so101-aio-dora` | — | +| **SO101 机械臂** | Dora (Follower模式) | `robodriver-robot-so101-follower-dora` | — | +| **Franka 机械臂** | Dora (SDK) | `robodriver-robot-franka-aio-dora` | `franka_aio_dora` | +| **GALAXEALITE 双臂** | ROS2 | `robodriver-robot-galaxealite-aio-ros2` | `galaxealite-aio-ros2` | +| **GALAXEALITE 双臂** | ROS1 | `robodriver-robot-galaxealite-aio-ros1` | — | +| **GALAXEALITE 双臂** | ROS1 (Follower模式) | `robodriver-robot-galaxealite-follower-ros1` | — | +| **Agilex Aloha 双臂** | Dora (SDK) | `robodriver-robot-agilex-aloha-aio-dora` | `agilex_aloha_aio_dora` | +| **宇树 A2D (G1)** | ROS2 | `robodriver-robot-a2d-aio-ros2` | `a2d_aio_ros2` | + +仿真环境中还支持 **Franka 机械臂**(Genesis 仿真): RoboDriver:91-93 + +--- + +## 二、每个机械臂适配包的结构 + +以 Franka(Dora接入)为例,每个机器人适配包都包含以下核心文件: + +``` +robodriver_robot_xxx_xxx_xxx/ +├── __init__.py # 导出 Robot 类和 Config 类 +├── config.py # 机器人配置(电机、相机等) +├── node.py # 通信节点(Dora/ROS) +├── robot.py # Robot 类实现 +└── status.py # 状态定义(可选) +``` RoboDriver:1-3 + +--- + +## 三、如何添加新的机械臂 + +### 步骤总览 + +```mermaid +graph TD + "1. 在 robots/ 目录下新建适配包" --> "2. 实现 config.py(注册 RobotConfig 子类)" + "2. 实现 config.py(注册 RobotConfig 子类)" --> "3. 实现 node.py(通信节点)" + "3. 实现 node.py(通信节点)" --> "4. 实现 robot.py(继承 Robot 基类)" + "4. 实现 robot.py(继承 Robot 基类)" --> "5. pip install -e . 安装适配包" + "5. pip install -e . 安装适配包" --> "6. 用 --robot.type=xxx 启动" +``` + +--- + +### 第1步:命名与目录结构 + +按规范命名:`robodriver-robot-[机器人型号]-[遥操方式]-[接入类型]`,在 `robodriver/robots/` 下新建目录: RoboDriver:116-127 + +--- + +### 第2步:实现 `config.py` + +需要用 `@RobotConfig.register_subclass("your_robot_type")` 装饰器注册你的机器人类型,并在 dataclass 中声明电机(`motors`/`leader_motors`/`follower_motors`)和相机(`cameras`): + +参考 SO101 ROS2 版本的 config: RoboDriver:10-50 + +参考 Franka Dora 版本的 config: RoboDriver:27-78 + +--- + +### 第3步:实现 `node.py`(通信节点) + +根据接入方式不同,有两种主要实现方式: + +**ROS2 接入**:继承 `rclpy.node.Node`,订阅关节状态和图像 topic,并在 `NODE_CONFIG` 中配置 topic 名称: RoboDriver:24-58 + +**Dora 接入**:使用 `dora.Node` 接收数据,在独立线程处理发送/接收: RoboDriver:23-60 + +--- + +### 第4步:实现 `robot.py` + +继承 LeRobot 的 `Robot` 基类,实现以下必要方法: + +- `connect()` / `disconnect()` +- `get_observation()` +- `get_action()` +- `send_action()` +- `is_connected` / `is_calibrated` 属性 + +参考 SO101 ROS2 版本 robot 类: RoboDriver:20-44 + +--- + +### 第5步:自动注册机制 + +RoboDriver 启动时会自动扫描所有以 `robodriver_robot_` 前缀开头的已安装包,并自动导入注册: RoboDriver:100-139 + +在 `run.py` 中的 `main()` 函数调用了上述自动注册逻辑: RoboDriver:127-136 + +因此,只需将适配包以 `pip install -e .` 安装后,RoboDriver 就能自动发现并注册新的机器人类型。 + +--- + +### 第6步:启动验证 + +安装后用 `--robot.type=你注册的类型名` 启动: + +参考 Franka 的启动方式: RoboDriver:36-40 + +参考 Agilex Aloha 的启动方式: RoboDriver:233-240 + +--- + +## Notes + +1. **命名约定**:Python 包名需以 `robodriver_robot_` 开头(下划线分隔),这是自动注册机制的扫描前缀。目录名可用连字符(如 `robodriver-robot-xxx`),包名用下划线(如 `robodriver_robot_xxx`)。 +2. **LeRobot 兼容**:所有 Robot 类直接继承自 LeRobot 的 `Robot` 基类,因此 RoboDriver 与 LeRobot 生态互相兼容。 RoboDriver:40-42 +3. **贡献流程**:开发完成后向主仓库 `dev` 分支提交 PR,代码需放在 `robodriver/robots/` 目录下。 RoboDriver:121-127 +4. **Dora 接入**的适配包还需要配置 `dora/dataflow.yml` 文件来定义数据流图,ROS 接入则需要配置对应的 ROS topic 名称(在 `node.py` 的 `NODE_CONFIG` 中)。 +### Citations +**File:** README.md (L40-42) +```markdown +- **多种机器人接入方式**: RoboDriver 考虑了除SDK外,使用ROS、Dora的接入方式。 +- **LeRobot兼容**: RoboDriver 的机器人接口直接使用了LeRobot的`Robot`类,这意味着RoboDriver与LeRobot可以互相兼容。 +- **改进的LeRobot数据集格式**:在不同数据阶段采取不同数据结构。在采集端单条储存,更易编辑和传输。还扩展了LeRobot格式的内容。 +``` +**File:** README.md (L91-93) +```markdown +| 机器人型号 | 简介 | 仓库链接 | 贡献人 | +|------------|------|--------------|------------------------| +| Franka 机械臂 | 一台Franka机械臂抓取方块 | [robodriver/simulations/robodriver-sim-genesis-franka-aio-dora](./robodriver/simulations/robodriver-sim-genesis-franka-aio-dora) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | +``` +**File:** README.md (L96-127) +```markdown +RoboDriver 已完成多款主流机器人的适配,按接入方式示例如下(各仓库包含对应机器人的接入步骤、环境配置、指令适配等完整指南): + +### 🔌 ROS1 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|------------|------|--------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman-aio-ros1](./robodriver/robots/robodriver-robot-realman-aio-ros1) | [](https://github.com/zhanglei-web) | + +### 🔌 ROS2 接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| GALAXEALITE | 基于Galaxealite,双臂6DOF+末端夹爪,4*RealSense相机 | [robodriver/robots/robodriver-robot-galaxealite-aio-ros2](./robodriver/robots/robodriver-robot-galaxealite-aio-ros2) | [![liuyou1103](https://avatars.githubusercontent.com/liuyou1103?s=50)](https://github.com/liuyou1103) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-ros2](./robodriver/robots/robodriver-robot-so101-aio-ros2) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | + +### 🔌 Dora(SDK)接入 +| 机器人型号 | 简介 | 代码链接 | 贡献人 | +|--------------|--------------------------------------------------------------|------------------------------------------------------------------------------------------|------------------------| +| Realman 机械臂 | 基于Realman,6DOF+力控模块,3*RealSense相机 | [robodriver/robots/robodriver-robot-realman1-aio-dora](./robodriver/robots/robodriver-robot-realman1-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | +| SO101 机械臂 | 开源轻量级机械臂,6DOF+末端夹爪,1*RealSense相机,1*RGB相机模块 | [robodriver/robots/robodriver-robot-so101-aio-dora](./robodriver/robots/robodriver-robot-so101-aio-dora) | [![Ryu-Yang](https://avatars.githubusercontent.com/Ryu-Yang?s=50)](https://github.com/Ryu-Yang) | +| Franka | 工业级机械臂,6DOF+末端夹爪,1*RealSense相机 | [robodriver/robots/robodriver-robot-franka-aio-dora](./robodriver/robots/robodriver-robot-franka-aio-dora) | [![XuRuntian](https://avatars.githubusercontent.com/XuRuntian?s=50)](https://github.com/XuRuntian) | + +> ✨ 说明: +> 1. 接入方式命名规范:`robodriver-robot-[机器人型号]-[遥操方式]-[接入类型]`(如 `aio`/`follwer`/`teleoperate`, `ros2`/`dora`); +> 2. 每个适配仓库内包含**环境搭建、配置修改、采集/控制验证**等完整接入指南; +> 3. 持续新增适配机器人,可关注本列表或项目更新。 + +我们非常欢迎社区开发者贡献更多机器人的实现!可按以下方式参与: +1. 参考已适配机器人的代码结构和 README 模板,按接入类型(ROS1/ROS2/Dora)完成适配开发; +2. 将适配代码新增至主仓库的 `robodriver/robots/` 目录下(命名规范与已适配机器人保持一致); +3. 确保代码规范、文档完整(包含环境准备、配置步骤、功能验证); +4. 提交代码 PR 至主仓库的 `dev` 分支,我们将及时审核并合并。 + +期待与您一起丰富 RoboDriver 的机器人生态! 🤝 +``` +**File:** robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/__init__.py (L1-3) +```python +from .robot import FrankaAioDoraRobot +from .config import FrankaAioDoraRobotConfig +from .status import FrankaAioDoraRobotStatus +``` +**File:** robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/config.py (L10-50) +```python +@RobotConfig.register_subclass("so101_aio_ros2") +@dataclass +class So101AioRos2RobotConfig(RobotConfig): + use_degrees = True + norm_mode_body = ( + MotorNormMode.DEGREES if use_degrees else MotorNormMode.RANGE_M100_100 + ) + + # 按组件分组:{ comp_id: { joint_name: Motor, ... }, ... } + leader_motors: Dict[str, Dict[str, Motor]] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "shoulder_pan": Motor(1, "sts3215", norm_mode_body), + "shoulder_lift": Motor(2, "sts3215", norm_mode_body), + "elbow_flex": Motor(3, "sts3215", norm_mode_body), + "wrist_flex": Motor(4, "sts3215", norm_mode_body), + "wrist_roll": Motor(5, "sts3215", norm_mode_body), + "gripper": Motor(6, "sts3215", MotorNormMode.RANGE_0_100), + } + ) + + follower_motors: Dict[str, Dict[str, Motor]] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "shoulder_pan": Motor(1, "sts3215", norm_mode_body), + "shoulder_lift": Motor(2, "sts3215", norm_mode_body), + "elbow_flex": Motor(3, "sts3215", norm_mode_body), + "wrist_flex": Motor(4, "sts3215", norm_mode_body), + "wrist_roll": Motor(5, "sts3215", norm_mode_body), + "gripper": Motor(6, "sts3215", MotorNormMode.RANGE_0_100), + } + ) + + cameras: Dict[str, CameraConfig] = field( + default_factory=lambda: { + "image_top": OpenCVCameraConfig(index_or_path=0, fps=30, width=640, height=480), + } + ) + + use_videos: bool = False + + microphones: Dict[str, int] = field( + default_factory=lambda: {} +``` +**File:** robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/config.py (L27-78) +```python +@RobotConfig.register_subclass("franka_aio_dora") +@dataclass +class FrankaAioDoraRobotConfig(RobotConfig): + use_degrees = True + norm_mode_body = MotorNormMode.DEGREES if use_degrees else MotorNormMode.RANGE_M100_100 + + motors: Dict[str, Motor] = field( + default_factory=lambda norm_mode_body=norm_mode_body: { + "follower_arm": { + "joint_1": Motor(1, "franka", norm_mode_body), + "joint_2": Motor(2, "franka", norm_mode_body), + "joint_3": Motor(3, "franka", norm_mode_body), + "joint_4": Motor(4, "franka", norm_mode_body), + "joint_5": Motor(5, "franka", norm_mode_body), + "joint_6": Motor(6, "franka", norm_mode_body), + "joint_7": Motor(7, "franka", norm_mode_body), + "gripper": Motor(8, "franka", norm_mode_body), + "pose_x": Motor(9, "franka", norm_mode_body), + "pose_y": Motor(10, "franka", norm_mode_body), + "pose_z": Motor(11, "franka", norm_mode_body), + "pose_rx": Motor(12, "franka", norm_mode_body), + "pose_ry": Motor(13, "franka", norm_mode_body), + "pose_rz": Motor(14, "franka", norm_mode_body), + }, + } + ) + + cameras: Dict[str, CameraConfig] = field( + default_factory=lambda: { + "image_wrist": OpenCVCameraConfig( + index_or_path=1, + fps=30, + width=640, + height=480, + ), + "image_wrist_depth": OpenCVCameraConfig( + index_or_path=2, + fps=30, + width=640, + height=480, + ), + + } + ) + + use_videos: bool = False + + microphones: Dict[str, int] = field( + default_factory=lambda: { + } + ) + +``` +**File:** robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/node.py (L24-58) +```python +NODE_CONFIG = { + "leader_joint_topics": { + "leader_arm": { + "topic": "/joint_states", + "msg": "JointState" + } + }, + "follower_joint_topics": { + "follower_arm": { + "topic": "/f_joint_states", + "msg": "JointState" + } + }, + "camera_topics": { + "image_top": { + "topic": "/camera/camera/color/image_raw", + "msg": "Image" + } + } +} + + +class So101AioRos2Node(Node): + """ + ROS2 → 本地数据存储(无 ZMQ,无 Dora) + leader / follower / camera 多 topic,按 JSON 配置自动订阅。 + """ + + def __init__( + self, + leader_joint_topics: Dict[str, Dict[str, str]] = NODE_CONFIG["leader_joint_topics"], + follower_joint_topics: Dict[str, Dict[str, str]] = NODE_CONFIG["follower_joint_topics"], + camera_topics: Dict[str, Dict[str, str]] = NODE_CONFIG["camera_topics"], + ): + super().__init__("so101_aio_ros2_direct") +``` +**File:** robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/node.py (L23-60) +```python +class FrankaAioDoraRobotNode(DoraRobotNode): + def __init__(self): + self.node = Node("franka_aio_dora") + + self.send_queue = queue.Queue(maxsize=100) + self.recv_images: Dict[str, float] = {} + self.recv_joint: Dict[str, float] = {} + self.recv_images_status: Dict[str, int] = {} + self.recv_joint_status: Dict[str, int] = {} + self.lock = threading.Lock() + + self.thread = threading.Thread(target=self.dora_recv, daemon=True) + self.running = False + + # 使用字典记录不同事件类型的最后发送时间 + self.last_send_times: Dict[str, float] = {} + + def dora_send(self, event_id, data): + """线程安全的发送方法 - 只负责入队""" + # logger.debug(f"{self} queue send event_id:{event_id}, value:{data}") + try: + # 限制发送频率 (可选) - 针对不同事件类型分别限制 + current_time = time.time() + last_time = self.last_send_times.get(event_id, 0) + if current_time - last_time < 0.005: # 200Hz上限 + return + + # 转换为列表确保类型安全 + data_list = [float(x) for x in data] + self.send_queue.put_nowait((event_id, data_list)) + + # 更新该事件类型的最后发送时间 + self.last_send_times[event_id] = current_time + except queue.Full: + logger.warning(f"Send queue full! Dropping {event_id} event") + except Exception as e: + logger.error(f"Queue error: {str(e)}") + +``` +**File:** robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/robot.py (L20-44) +```python +class So101AioRos2Robot(Robot): + config_class = So101AioRos2RobotConfig + name = "so101_aio_ros2" + + def __init__(self, config: So101AioRos2RobotConfig): + rclpy.init() + super().__init__(config) + self.config = config + self.robot_type = self.config.type + self.use_videos = self.config.use_videos + self.microphones = self.config.microphones + + # 这里的 leader_motors / follower_motors 可以是按组件分组的 dict + # (比如 {"leader_arm": {...}, "left_arm": {...}}) + self.leader_motors = config.leader_motors + self.follower_motors = config.follower_motors + self.cameras = make_cameras_from_configs(self.config.cameras) + + self.connect_excluded_cameras = ["image_pika_pose"] + + self.robot_ros2_node = So101AioRos2Node() + self.robot_ros2_node.start() + + self.connected = False + self.logs = {} +``` +**File:** robodriver/utils/import_utils.py (L100-139) +```python +def register_third_party_devices() -> None: + """ + Discover and import third-party robodriver_* plugins so they can register themselves. + + Scans top-level modules on sys.path for packages starting with + 'robodriver_robot_', 'robodriver_camera_', "robodriver_policy_" or 'robodriver_teleoperator_' and imports them. + """ + prefixes = ( + "robodriver_robot_", + "robodriver_camera_", + "robodriver_sim", + "robodriver_policy_", + "robodriver_teleoperator", + ) + imported: list[str] = [] + failed: list[str] = [] + + for module_info in pkgutil.iter_modules(): + name = module_info.name + if name.startswith(prefixes): + _attempt_import(name, imported, failed) + + editable_pattern = re.compile(r"__editable___(.+?)_\d+_\d+_\d+_finder") + for module_info in pkgutil.iter_modules(): + match = editable_pattern.match(module_info.name) + if match: + original_name = match.group(1).replace( + "_", "-" + ) # Convert to canonical package name + if any( + original_name.startswith(prefix.replace("_", "-")) + for prefix in prefixes + ): + # Convert back to module name format (replace - with _) + module_name = original_name.replace("-", "_") + _attempt_import(module_name, imported, failed) + + logger.debug( + "Third-party plugin import summary: imported=%s failed=%s", imported, failed + ) +``` +**File:** robodriver/scripts/run.py (L127-136) +```python +def main(): + git_branch_log() + + register_third_party_devices() + logger.info(f"Registered robot types: {list(RobotConfig._choice_registry.keys())}") + logger.info( + f"Registered teleoperator types: {list(TeleoperatorConfig._choice_registry.keys())}" + ) + + asyncio.run(async_main()) +``` +**File:** robodriver/robots/robodriver-robot-franka-aio-dora/README.md (L36-40) +```markdown +``` +cd /path/to/your/RoboDriver +python robodriver/scripts/run.py \ + --robot.type=franka_aio_dora +``` +``` +**File:** robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/README.md (L233-240) +```markdown +运行: + +```bash +robodriver-run \ + --robot.type=agilex_aloha_aio_dora \ + --sim.xml_path=descriptions/agilex_aloha/scene.xml \ + --sim.from_unit=rad +``` +``` diff --git a/components/arms/dora-arm-piper/pyproject.toml b/components/arms/dora-arm-piper/pyproject.toml index a465831..1d5cfbc 100644 --- a/components/arms/dora-arm-piper/pyproject.toml +++ b/components/arms/dora-arm-piper/pyproject.toml @@ -12,7 +12,7 @@ authors = [ readme = "README.md" requires-python = ">=3.8,<3.14" dependencies = [ - "dora-rs (>=0.3.11,<0.4.0)", + "dora-rs (>=0.4.0,<0.5.0)", "piper_sdk >= 0.0.8", "numpy>=1.24.4", ] diff --git a/components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py b/components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py index 2704455..1287ff3 100644 --- a/components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py +++ b/components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py @@ -1,261 +1,65 @@ -"""TODO: Add docstring.""" +"""Orbbec Gemini 335 color camera node — V4L2 backend (OpenCV). -import os +Uses the kernel uvcvideo driver via /dev/videoN instead of pyorbbecsdk/libusb, +because libob_usb.so fails to parse UVC payload headers on this device, +producing all-constant (green) frames regardless of scene content. +""" +import os import cv2 import numpy as np import pyarrow as pa from dora import Node -import time - - -try: - from pyorbbecsdk import ( - Config, - Context, - FrameSet, - OBError, - OBFormat, - OBSensorType, - Pipeline, - VideoFrame, - VideoStreamProfile, - ) -except ImportError as err: - print( - "Please install pyorbbecsdk first by following the instruction at: https://github.com/orbbec/pyorbbecsdk", - ) - raise err - - -class TemporalFilter: - """TODO: Add docstring.""" - - def __init__(self, alpha): - """TODO: Add docstring.""" - self.alpha = alpha - self.previous_frame = None - - def process(self, frame): - """TODO: Add docstring.""" - if self.previous_frame is None: - result = frame - else: - result = cv2.addWeighted( - frame, - self.alpha, - self.previous_frame, - 1 - self.alpha, - 0, - ) - self.previous_frame = result - return result - - -def yuyv_to_bgr(frame: np.ndarray, width: int, height: int) -> np.ndarray: - """TODO: Add docstring.""" - yuyv = frame.reshape((height, width, 2)) - return cv2.cvtColor(yuyv, cv2.COLOR_YUV2BGR_YUY2) - - -def uyvy_to_bgr(frame: np.ndarray, width: int, height: int) -> np.ndarray: - """TODO: Add docstring.""" - uyvy = frame.reshape((height, width, 2)) - return cv2.cvtColor(uyvy, cv2.COLOR_YUV2BGR_UYVY) - - -def i420_to_bgr(frame: np.ndarray, width: int, height: int) -> np.ndarray: - """TODO: Add docstring.""" - y = frame[0:height, :] - u = frame[height : height + height // 4].reshape(height // 2, width // 2) - v = frame[height + height // 4 :].reshape(height // 2, width // 2) - yuv_image = cv2.merge([y, u, v]) - return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_I420) - - -def nv21_to_bgr(frame: np.ndarray, width: int, height: int) -> np.ndarray: - """TODO: Add docstring.""" - y = frame[0:height, :] - uv = frame[height : height + height // 2].reshape(height // 2, width) - yuv_image = cv2.merge([y, uv]) - return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV21) - - -def nv12_to_bgr(frame: np.ndarray, width: int, height: int) -> np.ndarray: - """TODO: Add docstring.""" - y = frame[0:height, :] - uv = frame[height : height + height // 2].reshape(height // 2, width) - yuv_image = cv2.merge([y, uv]) - return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV12) - - -def frame_to_bgr_image(frame: VideoFrame): - """TODO: Add docstring.""" - width = frame.get_width() - height = frame.get_height() - color_format = frame.get_format() - data = np.asanyarray(frame.get_data()) - image = np.zeros((height, width, 3), dtype=np.uint8) - if color_format == OBFormat.RGB: - image = np.resize(data, (height, width, 3)) - image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) - elif color_format == OBFormat.BGR: - image = np.resize(data, (height, width, 3)) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - elif color_format == OBFormat.YUYV: - image = np.resize(data, (height, width, 2)) - image = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_YUYV) - elif color_format == OBFormat.MJPG: - image = cv2.imdecode(data, cv2.IMREAD_COLOR) - elif color_format == OBFormat.I420: - return i420_to_bgr(data, width, height) - elif color_format == OBFormat.NV12: - return nv12_to_bgr(data, width, height) - elif color_format == OBFormat.NV21: - return nv21_to_bgr(data, width, height) - elif color_format == OBFormat.UYVY: - image = np.resize(data, (height, width, 2)) - image = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_UYVY) - else: - print(f"Unsupported color format: {color_format}") - return None - return image - - -MIN_DEPTH_METERS = 0.01 -MAX_DEPTH_METERS = 15.0 - -GET_DEVICE_FROM = os.getenv("GET_DEVICE_FROM", "SN") # SN or INDEX -DEVICE_SN = os.getenv("DEVICE_SN") -DEVICE_INDEX = int(os.getenv("DEVICE_INDEX", "0")) +V4L2_DEVICE = os.getenv("V4L2_DEVICE", "/dev/video16") +IMAGE_WIDTH = int(os.getenv("IMAGE_WIDTH", "640")) +IMAGE_HEIGHT = int(os.getenv("IMAGE_HEIGHT", "480")) def main(): - """TODO: Add docstring.""" node = Node() - ctx = Context() - device_list = ctx.query_devices() - - if GET_DEVICE_FROM == "SN": - if DEVICE_SN is not None: - curr_device_cnt = device_list.get_count() - if curr_device_cnt == 0: - print("No device connected") - return - for i in range(curr_device_cnt): - device = device_list.get_device_by_index(i) - device_info = device.get_device_info() - # get device serial number - serial_number = device_info.get_serial_number() - if serial_number == DEVICE_SN: - device = device_list.get_device_by_index(i) - break - else: - raise ValueError - elif GET_DEVICE_FROM == "INDEX": - device = device_list.get_device_by_index(int(DEVICE_INDEX)) - - # temporal_filter = TemporalFilter(alpha=0.5) - pipeline = Pipeline(device) - - profile_list = pipeline.get_stream_profile_list(OBSensorType.COLOR_SENSOR) - try: - color_profile: VideoStreamProfile = profile_list.get_video_stream_profile( - 640, - 480, - OBFormat.MJPG, - 30, - ) - except OBError as e: - print(e) - color_profile = profile_list.get_default_video_stream_profile() - print("color profile: ", color_profile) - - profile_list = pipeline.get_stream_profile_list(OBSensorType.DEPTH_SENSOR) - try: - depth_profile: VideoStreamProfile = profile_list.get_video_stream_profile( - 640, - 400, - OBFormat.Y11, - 30, - ) - except OBError as e: - print(e) - depth_profile = profile_list.get_default_video_stream_profile() - print("depth profile: ", depth_profile) - - config = Config() - config.enable_stream(color_profile) - config.enable_stream(depth_profile) - pipeline.start(config) - - for _event in node: - # while True: - - try: - frames: FrameSet = pipeline.wait_for_frames(100) - if frames is None: + cap = cv2.VideoCapture(V4L2_DEVICE, cv2.CAP_V4L2) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, IMAGE_WIDTH) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, IMAGE_HEIGHT) + cap.set(cv2.CAP_PROP_FPS, 30) + # 请求 MJPG,带宽低且解码质量好 + cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) + + if not cap.isOpened(): + print(f"[CAM_TOP] ERROR: cannot open {V4L2_DEVICE}", flush=True) + return + + print(f"[CAM_TOP] opened {V4L2_DEVICE} " + f"{int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x" + f"{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} " + f"@ {cap.get(cv2.CAP_PROP_FPS):.0f}fps", flush=True) + + for event in node: + if event["type"] == "INPUT" and event["id"] == "tick": + ret, bgr_image = cap.read() + if not ret or bgr_image is None: + print("[CAM_TOP] frame read failed", flush=True) continue - # Get Color image - color_frame = frames.get_color_frame() - if color_frame is None: - continue + mean_val = bgr_image.mean() + print(f"[CAM_TOP] mean={mean_val:.1f} std={bgr_image.std():.1f} size={bgr_image.shape}", flush=True) - # convert to RGB format - color_image = frame_to_bgr_image(color_frame) - if color_image is None: - print("failed to convert frame to image") + if mean_val < 5: continue - - # # Send Color Image - # ret, frame = cv2.imencode("." + "jpeg", color_image) - # if ret: - node.send_output("image", pa.array(color_image.ravel()), {"encoding": "bgr8", "width": int(640), "height": int(480)}) - # Get Depth data - depth_frame = frames.get_depth_frame() - if depth_frame is None: + ret2, jpeg_buf = cv2.imencode(".jpg", bgr_image) + if not ret2: continue - width = depth_frame.get_width() - height = depth_frame.get_height() - scale = depth_frame.get_depth_scale() - depth_data = np.frombuffer(depth_frame.get_data(), dtype=np.uint16) - depth_data = depth_data.reshape((height, width)) - depth_data = depth_data.astype(np.float32) * scale * 0.001 - depth_data = np.where( - (depth_data > MIN_DEPTH_METERS) & (depth_data < MAX_DEPTH_METERS), - depth_data, - 0, - ) - - # Send Depth data - storage = pa.array(depth_data.ravel()) - node.send_output("depth", storage, {"width": int(width), "height": int(height)}) - - # Convert to Image - depth_image = cv2.normalize( - depth_data, - None, - 0, - 255, - cv2.NORM_MINMAX, - dtype=cv2.CV_8U, - ) - # Send Depth Image - depth_image = cv2.applyColorMap(depth_image, cv2.COLORMAP_JET) - ret, frame = cv2.imencode("." + "jpeg", depth_image) - if ret: - node.send_output("image_depth", pa.array(frame), {"encoding": "jpeg", "width": int(640), "height": int(400)}) - # cv2.imshow("0", color_image) - # cv2.waitKey(40) + jpeg_array = np.ascontiguousarray(jpeg_buf).ravel() + node.send_output("image", pa.array(jpeg_array), { + "encoding": "jpeg", + "width": IMAGE_WIDTH, + "height": IMAGE_HEIGHT, + }) - except KeyboardInterrupt: - break - pipeline.stop() + cap.release() if __name__ == "__main__": diff --git a/components/cameras/dora-camera-orbbec-v1/pyproject.toml b/components/cameras/dora-camera-orbbec-v1/pyproject.toml index 60249f7..bd053f8 100644 --- a/components/cameras/dora-camera-orbbec-v1/pyproject.toml +++ b/components/cameras/dora-camera-orbbec-v1/pyproject.toml @@ -12,7 +12,7 @@ authors = [ readme = "README.md" requires-python = ">=3.10,<3.11" dependencies = [ - "dora-rs (>=0.3.11,<0.4.0)", + "dora-rs (>=0.4.0,<0.5.0)", "sb-pyorbbecsdk", "opencv-python", "numpy (<2.0)" diff --git a/components/cameras/dora-camera-realsense/dora_camera_realsense/main.py b/components/cameras/dora-camera-realsense/dora_camera_realsense/main.py index c41d38b..6246059 100644 --- a/components/cameras/dora-camera-realsense/dora_camera_realsense/main.py +++ b/components/cameras/dora-camera-realsense/dora_camera_realsense/main.py @@ -17,35 +17,49 @@ def main(): device_serial = os.getenv("DEVICE_SERIAL", "") image_height = int(os.getenv("IMAGE_HEIGHT", "480")) image_width = int(os.getenv("IMAGE_WIDTH", "640")) - encoding = os.getenv("ENCODING", "rgb8") - ctx = rs.context() - devices = ctx.query_devices() - if devices.size() == 0: - raise ConnectionError("No realsense camera connected.") - - # Serial list - serials = [device.get_info(rs.camera_info.serial_number) for device in devices] - if device_serial and (device_serial not in serials): - raise ConnectionError( - f"Device with serial {device_serial} not found within: {serials}.", - ) + encoding = os.getenv("ENCODING", "jpeg") # 改为 jpeg 以降低带宽 pipeline = rs.pipeline() config = rs.config() - config.enable_device(device_serial) + # 如果指定了 device_serial,尝试使用它,否则使用任何可用设备 + if device_serial: + try: + config.enable_device(device_serial) + print(f"Attempting to connect to device {device_serial}") + except Exception as e: + print(f"Warning: Could not enable specific device {device_serial}: {e}") + print("Connecting to any available RealSense device...") + config.enable_stream(rs.stream.color, image_width, image_height, rs.format.rgb8, 30) - config.enable_stream(rs.stream.depth, image_width, image_height, rs.format.z16, 30) + # 注释掉深度流配置 - 只输出彩色图 + # config.enable_stream(rs.stream.depth, image_width, image_height, rs.format.z16, 30) - align_to = rs.stream.color - align = rs.align(align_to) + # 不需要对齐,因为只有彩色流 + # align_to = rs.stream.color + # align = rs.align(align_to) - profile = pipeline.start(config) + try: + profile = pipeline.start(config) + except RuntimeError as e: + print(f"Error starting pipeline: {e}") + raise rgb_profile = profile.get_stream(rs.stream.color) - depth_profile = profile.get_stream(rs.stream.depth) - _depth_intr = depth_profile.as_video_stream_profile().get_intrinsics() + # 不需要获取深度配置 + # depth_profile = profile.get_stream(rs.stream.depth) + # _depth_intr = depth_profile.as_video_stream_profile().get_intrinsics() rgb_intr = rgb_profile.as_video_stream_profile().get_intrinsics() + + # Warm up the camera - wait for a few frames to stabilize + print("Warming up RealSense camera...") + for _ in range(30): + try: + pipeline.wait_for_frames(timeout_ms=1000) + except RuntimeError: + pass + print("RealSense camera ready") + node = Node() start_time = time.time() @@ -63,15 +77,21 @@ def main(): event_id = event["id"] if event_id == "tick": - frames = pipeline.wait_for_frames() - aligned_frames = align.process(frames) - aligned_depth_frame = aligned_frames.get_depth_frame() - color_frame = aligned_frames.get_color_frame() - if not aligned_depth_frame or not color_frame: + try: + frames = pipeline.wait_for_frames(timeout_ms=10000) + except RuntimeError as e: + print(f"Warning: Failed to get frames: {e}") + continue + # 不需要对齐,直接获取彩色帧 + # aligned_frames = align.process(frames) + # aligned_depth_frame = aligned_frames.get_depth_frame() + color_frame = frames.get_color_frame() + if not color_frame: continue - depth_image = np.asanyarray(aligned_depth_frame.get_data()) - scaled_depth_image = depth_image + # 只处理彩色图像 + # depth_image = np.asanyarray(aligned_depth_frame.get_data()) + # scaled_depth_image = depth_image frame = np.asanyarray(color_frame.get_data()) ## Change rgb to bgr @@ -92,6 +112,7 @@ def main(): if encoding == "bgr8": frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) elif encoding in ["jpeg", "jpg", "jpe", "bmp", "webp", "png"]: + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) # imdecode expects BGR ret, frame = cv2.imencode("." + encoding, frame) if not ret: print("Error encoding image...") @@ -104,13 +125,13 @@ def main(): # metadata["principal_point"] = [int(rgb_intr.ppx), int(rgb_intr.ppy)] metadata["timestamp"] = time.time_ns() node.send_output("image", storage, metadata) - metadata["encoding"] = "mono16" - scaled_depth_image[scaled_depth_image > 5000] = 0 - node.send_output( - "image_depth", - pa.array(scaled_depth_image.ravel()), - metadata, - ) +# metadata["encoding"] = "mono16" +# scaled_depth_image[scaled_depth_image > 5000] = 0 +# node.send_output( +# "image_depth", +# pa.array(scaled_depth_image.ravel()), +# metadata, +# ) elif event_type == "ERROR": raise RuntimeError(event["error"]) diff --git a/components/cameras/dora-camera-realsense/pyproject.toml b/components/cameras/dora-camera-realsense/pyproject.toml index 7df945d..11d5acd 100644 --- a/components/cameras/dora-camera-realsense/pyproject.toml +++ b/components/cameras/dora-camera-realsense/pyproject.toml @@ -13,7 +13,7 @@ authors = [ readme = "README.md" requires-python = ">=3.9,<3.11" dependencies = [ - "dora-rs (>=0.3.11,<0.4.0)", + "dora-rs (>=0.4.0,<0.5.0)", "opencv-python", "pyrealsense2", "numpy" diff --git a/robodriver/core/coordinator.py b/robodriver/core/coordinator.py index 9fa814e..444b1b4 100644 --- a/robodriver/core/coordinator.py +++ b/robodriver/core/coordinator.py @@ -25,7 +25,7 @@ from robodriver.utils.data_file import check_disk_space, find_epindex_from_dataid_json from robodriver.utils.utils import cameras_to_stream_json, get_current_git_branch -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class Coordinator: diff --git a/robodriver/core/coordinator_need_test.py b/robodriver/core/coordinator_need_test.py index c72dc60..1040685 100644 --- a/robodriver/core/coordinator_need_test.py +++ b/robodriver/core/coordinator_need_test.py @@ -27,7 +27,7 @@ from robodriver.utils.data_file import check_disk_space, find_epindex_from_dataid_json from robodriver.utils.utils import cameras_to_stream_json, get_current_git_branch -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class Coordinator: diff --git a/robodriver/core/recorder.py b/robodriver/core/recorder.py index 4f82d9d..958d8a3 100644 --- a/robodriver/core/recorder.py +++ b/robodriver/core/recorder.py @@ -30,7 +30,7 @@ validate_session, ) -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) def sanity_check_dataset_robot_compatibility( diff --git a/robodriver/core/replayer.py b/robodriver/core/replayer.py index a4153a0..0afb221 100644 --- a/robodriver/core/replayer.py +++ b/robodriver/core/replayer.py @@ -9,7 +9,7 @@ from robodriver.robots.utils import Robot, busy_wait from robodriver.utils.utils import log_say -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) @dataclass @@ -34,7 +34,7 @@ class ReplayConfig: @draccus.wrap() def replay(cfg: ReplayConfig): - logging_mp.basic_config(level=logging_mp.INFO) + logging_mp.basicConfig(level=logging_mp.INFO) # logging.info(pformat(asdict(cfg))) # robot = make_robot_from_config(cfg.robot) diff --git a/robodriver/core/ros2thread.py b/robodriver/core/ros2thread.py index 7b95c56..c4ebbed 100644 --- a/robodriver/core/ros2thread.py +++ b/robodriver/core/ros2thread.py @@ -8,7 +8,7 @@ import logging_mp -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class ROS2_NodeManager(): diff --git a/robodriver/core/simulator.py b/robodriver/core/simulator.py index e90053f..04d2a31 100644 --- a/robodriver/core/simulator.py +++ b/robodriver/core/simulator.py @@ -10,7 +10,7 @@ from dataclasses import dataclass -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) @dataclass diff --git a/robodriver/dataset/dorobot_dataset.py b/robodriver/dataset/dorobot_dataset.py index 52fdfad..45e8d5c 100644 --- a/robodriver/dataset/dorobot_dataset.py +++ b/robodriver/dataset/dorobot_dataset.py @@ -68,7 +68,7 @@ DOROBOT_DATASET_VERSION = "v1.0" -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class DoRobotDatasetMetadata: diff --git a/robodriver/robots/daemon.py b/robodriver/robots/daemon.py index 4f25084..795cbab 100644 --- a/robodriver/robots/daemon.py +++ b/robodriver/robots/daemon.py @@ -13,7 +13,7 @@ safe_update_status, ) -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) def log_control_info( diff --git a/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/node.py b/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/node.py index cde67f9..4ad8fdb 100644 --- a/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/node.py +++ b/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/node.py @@ -14,7 +14,7 @@ from nav_msgs.msg import Odometry from typing import Dict, Any -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) # 尝试导入厂家自定义消息 try: diff --git a/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py b/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py index 0e48e52..ed7dbd5 100644 --- a/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py +++ b/robodriver/robots/robodriver-robot-a2d-aio-ros2/robodriver_robot_a2d_aio_ros2/robot.py @@ -14,7 +14,7 @@ from .node import A2DAioRos2Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class A2DAioRos2Robot(Robot): diff --git a/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/node.py b/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/node.py index cc3641d..049cf63 100644 --- a/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/node.py +++ b/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/robot.py b/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/robot.py index 322ba45..bf14fcc 100644 --- a/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/robot.py +++ b/robodriver/robots/robodriver-robot-agilex-aloha-aio-dora/robodriver_robot_agilex_aloha_aio_dora/robot.py @@ -15,7 +15,7 @@ from .node import AgilexAlohaAIODoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class AgilexAlohaAIODoraRobot(Robot): diff --git a/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/node.py b/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/node.py index db5246c..c5c38a6 100644 --- a/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/node.py +++ b/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/robot.py b/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/robot.py index 07f8d05..32e8058 100644 --- a/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/robot.py +++ b/robodriver/robots/robodriver-robot-franka-aio-dora/robodriver_robot_franka_aio_dora/robot.py @@ -16,7 +16,7 @@ from .node import FrankaAioDoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class FrankaAioDoraRobot(Robot): diff --git a/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/node.py b/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/node.py index f9fac96..bf1b198 100755 --- a/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/node.py +++ b/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/node.py @@ -16,7 +16,7 @@ CONNECT_TIMEOUT_FRAME = 10 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GalaxeaLiteEEposeROS2RobotNode(Node): diff --git a/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/robot.py b/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/robot.py index dce3e6b..c37defb 100755 --- a/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/robot.py +++ b/robodriver/robots/robodriver-robot-galaxea-lite-eepose-ros2/robodriver_robot_galaxea_lite_eepose_ros2/robot.py @@ -15,7 +15,7 @@ from .node import GalaxeaLiteEEposeROS2RobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) diff --git a/robodriver/robots/robodriver-robot-galaxealite-aio-ros1/robodriver_robot_galaxealite_aio_ros1/robot.py b/robodriver/robots/robodriver-robot-galaxealite-aio-ros1/robodriver_robot_galaxealite_aio_ros1/robot.py index 2045524..d0e5d15 100755 --- a/robodriver/robots/robodriver-robot-galaxealite-aio-ros1/robodriver_robot_galaxealite_aio_ros1/robot.py +++ b/robodriver/robots/robodriver-robot-galaxealite-aio-ros1/robodriver_robot_galaxealite_aio_ros1/robot.py @@ -17,7 +17,7 @@ from .node import GALAXEALITEAIORos1RobotNode, ros_spin_thread # 导入ROS1节点和spin函数 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALAXEALITEAIORos1Robot(Robot): # 类名改为ROS1标识 diff --git a/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/node.py b/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/node.py index ac8f0be..1ba659d 100755 --- a/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/node.py +++ b/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/node.py @@ -14,7 +14,7 @@ import logging_mp CONNECT_TIMEOUT_FRAME = 10 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALAXEALITEAIORos2RobotNode(ROS2Node): def __init__(self): diff --git a/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/robot.py b/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/robot.py index 6199e58..624aca9 100755 --- a/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/robot.py +++ b/robodriver/robots/robodriver-robot-galaxealite-aio-ros2/robodriver_robot_galaxealite_aio_ros2/robot.py @@ -16,7 +16,7 @@ from .node import GALAXEALITEAIORos2RobotNode, ros_spin_thread -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros1/robodriver_robot_galaxealite_follower_ros1/robot.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros1/robodriver_robot_galaxealite_follower_ros1/robot.py index 5bd0cef..639e292 100755 --- a/robodriver/robots/robodriver-robot-galaxealite-follower-ros1/robodriver_robot_galaxealite_follower_ros1/robot.py +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros1/robodriver_robot_galaxealite_follower_ros1/robot.py @@ -17,7 +17,7 @@ from .node import GALAXEALITEFollowerRos1RobotNode, ros_spin_thread # 导入ROS1节点和spin函数 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALAXEALITEFollowerRos1Robot(Robot): # 类名改为ROS1标识 diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py index 3cf2c1e..d78218e 100644 --- a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/node.py @@ -14,7 +14,7 @@ import logging_mp CONNECT_TIMEOUT_FRAME = 10 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALAXEALITEFollowerRos2RobotNode(ROS2Node): def __init__(self): diff --git a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py index 1c3d26c..0793504 100644 --- a/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py +++ b/robodriver/robots/robodriver-robot-galaxealite-follower-ros2/robodriver_robot_galaxealite_follower_ros2/robot.py @@ -16,7 +16,7 @@ from .node import GALAXEALITEFollowerRos2RobotNode, ros_spin_thread -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/node.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/node.py index ab9fdf3..6a6393a 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/node.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/node.py @@ -15,7 +15,7 @@ from typing import Dict, Any -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/robot.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/robot.py index deacde0..a13dcea 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/robot.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-ros2-dds/robodriver_robot_galbot_g1_aio_sdk/robot.py @@ -14,7 +14,7 @@ from .node import GALBOTAioRos2Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALBOTAioRos2Robot(Robot): diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/node.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/node.py index ab9fdf3..6a6393a 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/node.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/node.py @@ -15,7 +15,7 @@ from typing import Dict, Any -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/robot.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/robot.py index deacde0..a13dcea 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/robot.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-py/robodriver_robot_galbot_g1_aio_sdk/robot.py @@ -14,7 +14,7 @@ from .node import GALBOTAioRos2Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GALBOTAioRos2Robot(Robot): diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/node.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/node.py index f1c968b..075584e 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/node.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/node.py @@ -26,7 +26,7 @@ from galbot.tf2_proto import tf2_message_pb2 -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/robot.py b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/robot.py index 72e8e34..c2ad45b 100644 --- a/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/robot.py +++ b/robodriver/robots/robodriver-robot-galbot-g1-aio-sdk-rc/robodriver_robot_galbot_g1_aio_sdk_rc/robot.py @@ -13,7 +13,7 @@ from .node import GalbotG1AIOSDKRCRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class GalbotG1AIOSDKRCRobot(Robot): diff --git a/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/node.py b/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/node.py index 76d5065..5f991ec 100644 --- a/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/node.py +++ b/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/node.py @@ -10,7 +10,7 @@ import logging_mp -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/robot.py b/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/robot.py index 96acfb4..a0901fa 100644 --- a/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/robot.py +++ b/robodriver/robots/robodriver-robot-realman-aio-ros1/robodriver_robot_realman_aio_ros1/robot.py @@ -15,7 +15,7 @@ from .node import RealmanAioRos1Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class RealmanAioRos1Robot(Robot): diff --git a/robodriver/robots/robodriver-robot-realman-aio-ros1/ros1_to_zmq.py b/robodriver/robots/robodriver-robot-realman-aio-ros1/ros1_to_zmq.py index 02c2833..a168ee7 100644 --- a/robodriver/robots/robodriver-robot-realman-aio-ros1/ros1_to_zmq.py +++ b/robodriver/robots/robodriver-robot-realman-aio-ros1/ros1_to_zmq.py @@ -24,7 +24,7 @@ import logging_mp -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/node.py b/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/node.py index 29c71c5..77c3bcb 100644 --- a/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/node.py +++ b/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/robot.py b/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/robot.py index 5c094be..78f86d7 100644 --- a/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/robot.py +++ b/robodriver/robots/robodriver-robot-realman1-aio-dora/robodriver_robot_realman1_aio_dora/robot.py @@ -15,7 +15,7 @@ from .node import RealManFollowerDoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class RealManFollowerDoraRobot(Robot): diff --git a/robodriver/robots/robodriver-robot-so101-aio-dora/dora/dataflow.yml b/robodriver/robots/robodriver-robot-so101-aio-dora/dora/dataflow.yml index c2a700d..6d95afc 100644 --- a/robodriver/robots/robodriver-robot-so101-aio-dora/dora/dataflow.yml +++ b/robodriver/robots/robodriver-robot-so101-aio-dora/dora/dataflow.yml @@ -7,7 +7,7 @@ nodes: outputs: - image env: - VIRTUAL_ENV: camera.venv + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/camera.venv CAPTURE_PATH: 4 IMAGE_WIDTH: 640 IMAGE_HEIGHT: 480 @@ -20,8 +20,8 @@ nodes: outputs: - image env: - VIRTUAL_ENV: camera.venv - CAPTURE_PATH: 2 + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/camera.venv + CAPTURE_PATH: 0 IMAGE_WIDTH: 640 IMAGE_HEIGHT: 480 @@ -34,7 +34,7 @@ nodes: outputs: - image env: - VIRTUAL_ENV: camera.venv + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/camera.venv CAPTURE_PATH: 6 IMAGE_WIDTH: 640 IMAGE_HEIGHT: 480 @@ -53,7 +53,7 @@ nodes: - joint_gripper - all_joint env: - VIRTUAL_ENV: arm.venv + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/arm.venv GET_DEVICE_FROM: PORT PORT: /dev/ttyACM0 ARM_NAME: SO101-leader @@ -75,7 +75,7 @@ nodes: - joint_wrist_roll - joint_gripper env: - VIRTUAL_ENV: arm.venv + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/arm.venv GET_DEVICE_FROM: PORT PORT: /dev/ttyACM1 ARM_NAME: SO101-follower diff --git a/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/node.py b/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/node.py index 0d420c9..ae8f0ca 100644 --- a/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/node.py +++ b/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/robot.py b/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/robot.py index aa22049..edaae70 100644 --- a/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/robot.py +++ b/robodriver/robots/robodriver-robot-so101-aio-dora/robodriver_robot_so101_aio_dora/robot.py @@ -15,7 +15,7 @@ from .node import SO101AIODoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class SO101AIODoraRobot(Robot): diff --git a/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/node.py b/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/node.py index de4aeda..ebb16ba 100644 --- a/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/node.py +++ b/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/node.py @@ -15,7 +15,7 @@ from typing import Dict, Any -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/robot.py b/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/robot.py index b5bb95f..f0a938a 100644 --- a/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/robot.py +++ b/robodriver/robots/robodriver-robot-so101-aio-ros2/robodriver_robot_so101_aio_ros2/robot.py @@ -14,7 +14,7 @@ from .node import So101AioRos2Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class So101AioRos2Robot(Robot): diff --git a/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/node.py b/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/node.py index 0d28f6d..20373da 100644 --- a/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/node.py +++ b/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/robot.py b/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/robot.py index 85af665..d876037 100644 --- a/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/robot.py +++ b/robodriver/robots/robodriver-robot-so101-follower-dora/robodriver_robot_so101_follower_dora/robot.py @@ -15,7 +15,7 @@ from .node import SO101FollowerDoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class SO101FollowerDoraRobot(Robot): diff --git a/robodriver/robots/utils.py b/robodriver/robots/utils.py index 4694d0f..46b9581 100644 --- a/robodriver/robots/utils.py +++ b/robodriver/robots/utils.py @@ -8,7 +8,7 @@ from robodriver.robots.statuses import RobotStatus from robodriver.utils.import_utils import make_device_from_device_class -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) def busy_wait(seconds): diff --git a/robodriver/scripts/record.py b/robodriver/scripts/record.py index 4c51243..e76dcc7 100644 --- a/robodriver/scripts/record.py +++ b/robodriver/scripts/record.py @@ -5,7 +5,7 @@ import logging_mp from deepdiff import DeepDiff -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) from robodriver.core.recorder import RecordConfig @@ -52,7 +52,7 @@ def __get_path_fields__(cls) -> list[str]: @parser.wrap() def record(cfg: ControlPipelineConfig): - logging_mp.basic_config(level=logging_mp.INFO) + logging_mp.basicConfig(level=logging_mp.INFO) git_branch_log() # daemon = Daemon(fps=DEFAULT_FPS) diff --git a/robodriver/scripts/replay.py b/robodriver/scripts/replay.py index eebe140..cd0be62 100644 --- a/robodriver/scripts/replay.py +++ b/robodriver/scripts/replay.py @@ -11,8 +11,8 @@ from robodriver.robot.robots.utils import Robot, busy_wait from robodriver.utils.utils import log_say -logging_mp.basic_config(level=logging_mp.INFO) -logger = logging_mp.get_logger(__name__) +logging_mp.basicConfig(level=logging_mp.INFO) +logger = logging_mp.getLogger(__name__) @dataclass diff --git a/robodriver/scripts/run.py b/robodriver/scripts/run.py index 90ba74a..7c9a9d3 100644 --- a/robodriver/scripts/run.py +++ b/robodriver/scripts/run.py @@ -5,6 +5,7 @@ import cv2 import logging_mp +logging_mp.basicConfig(level=logging_mp.INFO) from lerobot.robots import RobotConfig from lerobot.teleoperators import TeleoperatorConfig @@ -24,8 +25,7 @@ # from lerobot.teleoperators import make_teleoperator_from_config -logging_mp.basic_config(level=logging_mp.INFO) -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) @dataclass @@ -109,12 +109,12 @@ async def async_main(cfg: ControlPipelineConfig): img = cv2.cvtColor(observation[key], cv2.COLOR_RGB2BGR) # name = key[len("observation.images."):] tasks.append(coordinator.update_stream_async(key, img)) - # cv2.imshow(key, img) + cv2.imshow(key, img) if observation_sim is not None: observation_sim = cv2.cvtColor(observation_sim, cv2.COLOR_RGB2BGR) tasks.append(coordinator.update_stream_async("image_sim", observation_sim)) - # cv2.imshow("image_sim", observation_sim) + cv2.imshow("image_sim", observation_sim) if tasks: try: @@ -127,8 +127,8 @@ async def async_main(cfg: ControlPipelineConfig): else: logger.warning("observation is none") - - # cv2.waitKey(1) + + cv2.waitKey(1) await asyncio.sleep(0) except KeyboardInterrupt: logger.info("coordinator and daemon stop") diff --git a/robodriver/scripts/sim.py b/robodriver/scripts/sim.py index cc21387..84f6f1d 100644 --- a/robodriver/scripts/sim.py +++ b/robodriver/scripts/sim.py @@ -13,8 +13,8 @@ from robodriver.utils.import_utils import register_third_party_devices from robodriver.utils.utils import git_branch_log -logging_mp.basic_config(level=logging_mp.INFO) -logger = logging_mp.get_logger(__name__) +logging_mp.basicConfig(level=logging_mp.INFO) +logger = logging_mp.getLogger(__name__) @dataclass diff --git a/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/node.py b/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/node.py index d082d18..8c50ad9 100644 --- a/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/node.py +++ b/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/node.py @@ -10,7 +10,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/sim.py b/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/sim.py index aaca5d8..391cba9 100644 --- a/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/sim.py +++ b/robodriver/simulations/robodriver-sim-genesis-franka-aio-dora/robodriver_sim_genesis_franka_aio_dora/sim.py @@ -15,7 +15,7 @@ from .node import SimFrankaAIODoraRobotNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class SimFrankaAIODoraRobot(Robot): diff --git a/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/node.py b/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/node.py index 3e1fa1e..4156e8e 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/node.py +++ b/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/node.py @@ -14,7 +14,7 @@ from geometry_msgs.msg import PoseStamped -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class AutoTaskTeleoperatorNode(ROS2Node): diff --git a/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/teleoperator.py b/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/teleoperator.py index 9ccd842..cd22a92 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/teleoperator.py +++ b/robodriver/teleoperators/robodriver-teleoperator-autotask/robodriver_teleoperator_autotask/teleoperator.py @@ -14,7 +14,7 @@ from .node import AutoTaskTeleoperatorNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class AutoTaskTeleoperator(Teleoperator): diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.gitignore b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.gitignore new file mode 100644 index 0000000..0bc98f9 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.gitignore @@ -0,0 +1,182 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# .python-version + +# pipenv +#Pipfile.lock + +# UV +uv.lock + +# poetry +#poetry.lock + +# pdm +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +#.idea/ + +# Abstra +.abstra/ + +# Visual Studio Code +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +.cursorignore +.cursorindexingignore + +**/out/ +**/Log/ +**/log/ +data/ +/dataset +docker/*.tar* +**/cache/ + +**/poetry.lock + +**/result +*.parquet + +*.STL +.pids +.vscode +*.build + +# Local notes +ISSUES_.md +*.txt diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md new file mode 100644 index 0000000..ebe6c87 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md @@ -0,0 +1,150 @@ +# robodriver-teleoperator-pico-ultra4-dora + +## 项目说明 + +本项目将 Pico Ultra4 VR 控制器的遥操作集成进 dora dataflow 框架,支持: +- **Pico Ultra4** VR 控制器(通过 XRoboToolkit SDK) +- **Piper** 机械臂(CAN 总线控制,含 IK 求解) +- **RealSense D405** 手腕相机 +- **Orbbec Gemini 335** 顶部相机 + +核心节点 `dora_node_piper.py` 将 `PiperTeleopController` 适配为 dora 事件驱动模式,在 50Hz tick 下执行 IK + 控制循环。 + +--- + +## 快速启动 + +### 前置条件 + +```bash +# 1. 启动 CAN 总线 +sudo ip link set can0 up type can bitrate 1000000 + +# 2. 确认 XRoboToolkit PC Service 正在运行(监听 127.0.0.1:60061) +ss -tlnp | grep 60061 +# 应看到 RoboticsService 进程 + +# 3. Pico 头显连接同一局域网,打开 XRoboToolkit App +``` + +### 启动遥操 + +```bash +cd RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora + +# 清理残留进程(重要!避免相机设备被占用) +pkill -f "dora run\|dora_node_piper\|dora_camera\|dora_arm_piper" 2>/dev/null; sleep 1 + +# 重置 Orbbec 相机 USB(重要!dora 被强杀后 Orbbec SDK 的 UVC 锁不会自动释放) +# 不加这步会报 uvc_open res-6 / Device or resource busy +sudo usbreset 2bc5:0800; sleep 1 + +# 激活虚拟环境并启动 +source .venv/bin/activate +dora run dora/dataflow.yml +``` + +### 操作说明 + +- 按住 **右手 grip 键** 激活控制(`right_arm is activated` 出现后生效) +- 移动手柄控制 Piper 末端位姿(IK 实时求解) +- 松开 grip 键暂停控制 +- **右手扳机键** 控制夹爪开合 +- `Ctrl+C` 停止 + +--- + +## 依赖安装(首次配置) + +XR Robotics中下载XRoboToolkit-PC-Service、XRoboToolkit-Teleop-Sample-Python,pico中安装xrobotoolkits +```bash +# Pico 遥操依赖(本地安装) +pip install XRoboToolkit-Teleop-Sample-Python/dependencies/XRoboToolkit-PC-Service-Pybind/ +pip install -e XRoboToolkit-Teleop-Sample-Python/ +pip install placo meshcat + +# 验证 +python -c "import xrobotoolkit_sdk, xrobotoolkit_teleop, placo, meshcat; print('all ok')" +``` + +--- + +### 步骤 4:创建新的统一虚拟环境 + +```bash +# 创建 Python 3.10 虚拟环境 +uv venv .venv -p 3.10 + +# 激活虚拟环境 +source .venv/bin/activate +``` + +### 步骤 5:安装依赖 + +```bash +# 安装基础依赖 +uv pip install -e . + +# 安装硬件依赖(RealSense、Orbbec、Piper) +uv pip install -e .[hardware] +``` + +这将安装以下硬件驱动: +- `pyrealsense2` (2.56.5.9235) - Intel RealSense D405 相机驱动 +- `sb-pyorbbecsdk` (1.3.1) - Orbbec Gemini 335 相机驱动 +- `piper-sdk` (0.6.1) - Piper 机械臂 SDK + +### 步骤 6:验证安装 + +```bash +# 验证 Python 可执行文件位置 +python -c "import sys; print('Python:', sys.executable)" + +# 验证硬件 SDK 是否正确安装 +python -c "import pyrealsense2; print('✅ RealSense SDK 已安装')" +python -c "import piper_sdk; print('✅ Piper SDK 已安装')" +python -c "import pyorbbecsdk; print('✅ Orbbec SDK 已安装')" +``` + +预期输出: +``` +Python: /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.venv/bin/python +✅ RealSense SDK 已安装 +✅ Piper SDK 已安装 +✅ Orbbec SDK 已安装 +``` + + +## 启动数据采集(仅支持单臂遥操) +## 1.激活piper机械臂 +```bash +cd SDK/piper_sdk/piper_sdk +bash can_config.sh +``` +- pc端打开XRobotoolkits-pc-server,pico中启动xrobotoolkits(安装后在资源库中,与pc端连接到一个网段上,Head,Controller、Hand、Send、Switch W/A Button,Trcking、Vision都选择上) +### 1. 激活虚拟环境 + +```bash +cd /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora +source .venv/bin/activate +``` + +### 2. 启动 Dora 服务 + +```bash +dora up +``` + +### 3. 启动数据流 + +```bash +dora start dora/dataflow.yml +``` + +## 相关文档 + +- [RoboDriver 主文档](../../README.md) +- [Dora 框架文档](https://dora-rs.ai/) +- [RealSense SDK 文档](https://github.com/IntelRealSense/librealsense) +- [Orbbec SDK 文档](https://github.com/orbbec/pyorbbecsdk) +- [Piper SDK 文档](https://github.com/agilexrobotics/piper_sdk) diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml new file mode 100644 index 0000000..8ded3f8 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml @@ -0,0 +1,67 @@ +nodes: + # ========== 深度相机节点 ========== + # Top相机:奥比中光 Gemini335(仅彩色图) + - id: camera_top + path: ../../../../components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py + inputs: + tick: dora/timer/millis/33 + outputs: + - image + env: + VIRTUAL_ENV: ../.venv + V4L2_DEVICE: /dev/video16 + IMAGE_WIDTH: 640 + IMAGE_HEIGHT: 480 + + # Wrist相机:Intel RealSense D405(仅彩色图) + - id: camera_wrist + path: ../../../../components/cameras/dora-camera-realsense/dora_camera_realsense/main.py + inputs: + tick: dora/timer/millis/33 + outputs: + - image + env: + VIRTUAL_ENV: ../.venv + DEVICE_SERIAL: "130322271127" # RealSense D405 序列号 + IMAGE_WIDTH: 640 + IMAGE_HEIGHT: 480 + + # ========== Piper 机械臂节点 ========== + # 注意:piper_arm 节点已注释掉,由 pico_ultra4_dora 直接控制硬件 + # - id: piper_arm + # path: ../../../../components/arms/dora-arm-piper/dora_arm_piper/main.py + # build: pip install -e ../../../../components/arms/dora-arm-piper + # inputs: + # tick: dora/timer/millis/20 # 50Hz 控制频率 + # outputs: + # - leader_jointstate + # - follower_jointstate + # - follower_endpose + # env: + # VIRTUAL_ENV: ../.venv + # CAN_BUS: can0 + # TEACH_MODE: "True" + + # ========== Pico Ultra4 遥操器节点(集成版本)========== + # 需要先启动 CAN 总线:sudo ip link set can0 up type can bitrate 1000000 + - id: pico_ultra4_dora + path: ../robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py + inputs: + tick: dora/timer/millis/20 # 50Hz 控制 + camera_top_image: camera_top/image + camera_wrist_image: camera_wrist/image + outputs: + - follower_jointstate + env: + VIRTUAL_ENV: ../.venv + PYTHONPATH: /home/dora/RoboDriver + DISPLAY: ":0" + QT_QPA_PLATFORM: "xcb" + CAN_BUS: can0 + SCALE_FACTOR: "1.5" + CONTROL_RATE_HZ: "50" + URDF_PATH: /home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/assets/piper/piper.urdf + RECORD_DIR: /home/dora/recordings/pico_piper + REPO_ID: pico_piper + TASK: "teleoperation" + USE_VIDEOS: "true" diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/lerobot_teleoperator_pico_ultra4_dora/__init__.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/lerobot_teleoperator_pico_ultra4_dora/__init__.py new file mode 100644 index 0000000..5904dae --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/lerobot_teleoperator_pico_ultra4_dora/__init__.py @@ -0,0 +1,2 @@ +# LeRobot compatibility layer +from robodriver_teleoperator_pico_ultra4_dora import * diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/pyproject.toml b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/pyproject.toml new file mode 100644 index 0000000..4a69dae --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "robodriver_teleoperator_pico_ultra4_dora" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.10,<3.11" +license = {text = "Apache License 2.0"} +authors = [ + {name = "RoboDriver Team"}, +] +keywords = ["robotics", "lerobot", "pico", "vr", "teleoperation"] +dependencies = [ + "dora-rs-cli (>=0.4.1,<0.5.0)", + "dora-rs (>=0.4.1,<0.5.0)", + "logging_mp", + "numpy", + "pyarrow", + "scipy", + "opencv-python", +] + +[project.optional-dependencies] +hardware = [ + "pyrealsense2", + "sb-pyorbbecsdk", + "piper_sdk>=0.0.8", +] +pico = [ + "placo", + "meshcat", + # xrobotoolkit_sdk: 从本地编译好的 .so 安装 + # pip install /home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/dependencies/XRoboToolkit-PC-Service-Pybind/ + # xrobotoolkit_teleop: 可编辑模式安装 + # pip install -e /home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/ +] + +[tool.setuptools.packages.find] +include = [ + "robodriver_teleoperator_pico_ultra4_dora*", + "lerobot_teleoperator_pico_ultra4_dora*", +] diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/__init__.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/__init__.py new file mode 100644 index 0000000..999213d --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/__init__.py @@ -0,0 +1,9 @@ +from .teleoperator import PicoUltra4DoraTeleoperator +from .config import PicoUltra4DoraTeleoperatorConfig +from .status import PicoUltra4DoraTeleoperatorStatus + +__all__ = [ + "PicoUltra4DoraTeleoperator", + "PicoUltra4DoraTeleoperatorConfig", + "PicoUltra4DoraTeleoperatorStatus", +] diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/config.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/config.py new file mode 100644 index 0000000..6e9eb23 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/config.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, field +from typing import Dict, Any +import numpy as np + +from lerobot.teleoperators.config import TeleoperatorConfig + + +@TeleoperatorConfig.register_subclass("pico_ultra4_dora") +@dataclass +class PicoUltra4DoraTeleoperatorConfig(TeleoperatorConfig): + """ + Pico Ultra4 VR 遥操器配置 + + 使用 VR 控制器进行末端位姿控制,通过 IK 求解转换为关节角度 + """ + + # VR 控制器配置 + vr_controller: str = "right_controller" # 使用右手控制器 + control_trigger: str = "right_grip" # 右手握持键激活控制 + gripper_trigger: str = "right_trigger" # 右手扳机键控制夹爪 + + # 机械臂配置 + robot_urdf_path: str = "" # Piper URDF 文件路径 + end_effector_link: str = "link6" # 末端执行器链接名 + + # IK 求解器配置 + scale_factor: float = 1.5 # VR 控制器移动缩放因子 + control_rate_hz: int = 50 # 控制频率 + + # 坐标系转换 + # VR 头显坐标系到世界坐标系的旋转矩阵 + R_headset_world: np.ndarray = field(default_factory=lambda: np.eye(3)) + + # 夹爪配置 + gripper_open_pos: float = 0.85 # 完全打开位置 + gripper_close_pos: float = 0.0 # 完全闭合位置 + + # Piper 机械臂关节配置 + num_joints: int = 6 + joint_names: list = field(default_factory=lambda: [ + "joint1", "joint2", "joint3", "joint4", "joint5", "joint6" + ]) diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/controller.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/controller.py new file mode 100644 index 0000000..e050613 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/controller.py @@ -0,0 +1,198 @@ +""" +Pico Ultra4 遥操 Piper 机械臂的完整控制器 + +集成了 VR 数据获取、IK 求解和 Piper 硬件控制 +""" + +import time +import logging_mp +import numpy as np +from typing import Optional + +from .interface import PiperInterface +from .node import PicoUltra4DoraTeleoperatorNode +from .config import PicoUltra4DoraTeleoperatorConfig + + +logger = logging_mp.getLogger(__name__) + + +class PicoPiperController: + """ + Pico Ultra4 遥操 Piper 机械臂的完整控制器 + + 整合了: + 1. VR 数据获取(XRoboToolkit) + 2. IK 求解(Placo) + 3. Piper 硬件控制(piper_sdk) + """ + + def __init__( + self, + config: PicoUltra4DoraTeleoperatorConfig, + can_port: str = "can0", + control_rate_hz: int = 50, + ): + """ + 初始化控制器 + + Args: + config: 遥操器配置 + can_port: Piper CAN 端口 + control_rate_hz: 控制频率 + """ + self.config = config + self.can_port = can_port + self.control_rate_hz = control_rate_hz + self.dt = 1.0 / control_rate_hz + + # 创建节点配置 + node_config = { + "vr_controller": config.vr_controller, + "control_trigger": config.control_trigger, + "gripper_trigger": config.gripper_trigger, + "end_effector_link": config.end_effector_link, + "scale_factor": config.scale_factor, + "joint_names": config.joint_names, + } + + # 初始化 VR + IK 节点 + self.teleop_node = PicoUltra4DoraTeleoperatorNode( + robot_urdf_path=config.robot_urdf_path, + config=node_config + ) + + # 初始化 Piper 接口 + self.piper = PiperInterface( + can_port=can_port, + dt=self.dt + ) + + self.running = False + + logger.info("PicoPiperController initialized") + + def start(self): + """启动控制器""" + logger.info("Starting PicoPiperController...") + + # 启动 VR 节点 + self.teleop_node.start() + + # Piper 移动到 Home 位置 + logger.info("Moving Piper to home position...") + self.piper.go_home() + time.sleep(2) + + self.running = True + logger.info("PicoPiperController started successfully") + + def run(self): + """运行控制循环""" + if not self.running: + logger.error("Controller not started. Call start() first.") + return + + logger.info("Starting control loop...") + logger.info("Press and hold the grip button to activate control") + logger.info("Press Ctrl+C to stop") + + try: + while self.running: + loop_start = time.perf_counter() + + # 获取当前关节位置 + current_joints = self.piper.get_joint_positions() + + # 更新 IK 求解器的当前状态 + self.teleop_node.update_current_joints(current_joints) + + # 获取 VR 控制动作 + action = self.teleop_node.get_action() + + if action is not None: + # 提取关节角度 + joint_positions = np.array([ + action.get(f"{joint}.pos", 0.0) + for joint in self.config.joint_names + ]) + + # 提取夹爪位置 + gripper_pos = action.get("gripper.pos", 0.0) + + # 发送到 Piper + self.piper.set_joint_positions(joint_positions) + self.piper.set_gripper_position(gripper_pos) + + logger.debug(f"Sent command - Joints: {joint_positions}, Gripper: {gripper_pos:.2f}") + else: + logger.debug("Control not active (grip button not pressed)") + + # 控制频率 + elapsed = time.perf_counter() - loop_start + sleep_time = max(0, self.dt - elapsed) + if sleep_time > 0: + time.sleep(sleep_time) + + except KeyboardInterrupt: + logger.info("User interrupted, stopping...") + except Exception as e: + logger.error(f"Error in control loop: {e}") + raise + finally: + self.stop() + + def stop(self): + """停止控制器""" + logger.info("Stopping PicoPiperController...") + + self.running = False + + # 停止 VR 节点 + self.teleop_node.stop() + + # Piper 回到 Home 位置 + logger.info("Moving Piper to home position...") + self.piper.go_home() + time.sleep(1) + + # 失能 Piper + self.piper.disable_robot() + + logger.info("PicoPiperController stopped") + + +def main(): + """主函数示例""" + import os + from pathlib import Path + + # 配置 + urdf_path = os.path.join( + Path(__file__).parent.parent.parent.parent.parent, + "assets", "piper", "piper.urdf" + ) + + config = PicoUltra4DoraTeleoperatorConfig( + robot_urdf_path=urdf_path, + vr_controller="right_controller", + control_trigger="right_grip", + gripper_trigger="right_trigger", + scale_factor=1.5, + control_rate_hz=50, + ) + + # 创建控制器 + controller = PicoPiperController( + config=config, + can_port="can0", + control_rate_hz=50, + ) + + # 启动并运行 + controller.start() + controller.run() + + +if __name__ == "__main__": + main() diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py new file mode 100644 index 0000000..ced75c7 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py @@ -0,0 +1,276 @@ +""" +Pico Ultra4 遥操 Piper 的 Dora 节点 + +将 PiperTeleopController 适配为 Dora 节点, +在 tick 事件驱动下执行一次 IK + 控制循环, +同时在 grip 激活时以 DoRobotDataset(LeRobot 兼容)格式保存数据。 +""" +import os +os.environ.setdefault("QT_QPA_PLATFORM", "xcb") +import time +from pathlib import Path +import numpy as np +import pyarrow as pa +import cv2 +from dora import Node + +from xrobotoolkit_teleop.hardware.piper_teleop_controller import ( + PiperTeleopController, + DEFAULT_PIPER_MANIPULATOR_CONFIG, +) +from xrobotoolkit_teleop.utils.path_utils import ASSET_PATH + +from robodriver.dataset.dorobot_dataset import DoRobotDataset, DoRobotDatasetMetadata + +URDF_PATH = os.getenv("URDF_PATH", os.path.join(ASSET_PATH, "piper/piper.urdf")) +CAN_PORT = os.getenv("CAN_BUS", "can0") +SCALE_FACTOR = float(os.getenv("SCALE_FACTOR", "1.5")) +CONTROL_RATE_HZ = int(os.getenv("CONTROL_RATE_HZ", "50")) +RECORD_DIR = os.getenv("RECORD_DIR", os.path.expanduser("~/recordings/pico_piper")) +RECORD_FPS = int(os.getenv("RECORD_FPS", "30")) +REPO_ID = os.getenv("REPO_ID", "pico_piper") +TASK = os.getenv("TASK", "teleoperation") +USE_VIDEOS = os.getenv("USE_VIDEOS", "false").lower() == "true" + +# Piper 7 维状态:joint1-6 + gripper +STATE_DIM = 7 +IMAGE_HEIGHT = 480 +IMAGE_WIDTH = 640 + +FEATURES = { + "observation.state": { + "dtype": "float32", + "shape": (STATE_DIM,), + "names": ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "gripper"], + }, + "observation.images.camera_top": { + "dtype": "video" if USE_VIDEOS else "image", + "shape": (IMAGE_HEIGHT, IMAGE_WIDTH, 3), + "names": ["height", "width", "channel"], + }, + "observation.images.camera_wrist": { + "dtype": "video" if USE_VIDEOS else "image", + "shape": (IMAGE_HEIGHT, IMAGE_WIDTH, 3), + "names": ["height", "width", "channel"], + }, + "action": { + "dtype": "float32", + "shape": (STATE_DIM,), + "names": ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "gripper"], + }, +} + + +def make_dataset() -> DoRobotDataset: + """创建或续接 DoRobotDataset,绕过 robot.microphones 依赖。""" + obj = DoRobotDataset.__new__(DoRobotDataset) + meta = DoRobotDatasetMetadata.__new__(DoRobotDatasetMetadata) + meta.repo_id = REPO_ID + meta.root = Path(RECORD_DIR) + + info_path = meta.root / "meta" / "info.json" + if info_path.exists(): + # 续接已有数据集 + meta.load_metadata() + else: + # 全新数据集 + meta = DoRobotDatasetMetadata.create( + repo_id=REPO_ID, + fps=RECORD_FPS, + root=RECORD_DIR, + robot_type="piper", + features=FEATURES, + use_videos=USE_VIDEOS, + use_audios=False, + ) + + obj.meta = meta + obj.repo_id = obj.meta.repo_id + obj.root = obj.meta.root + obj.revision = None + obj.tolerance_s = 1e-4 + obj.image_writer = None + obj.audio_writer = None + obj.episode_buffer = obj.create_episode_buffer() + obj.episodes = None + obj.hf_dataset = obj.create_hf_dataset() + obj.image_transforms = None + obj.delta_timestamps = None + obj.delta_indices = None + obj.episode_data_index = None + obj.video_backend = "pyav" + return obj + + +def decode_image(data: pa.Array, metadata: dict) -> np.ndarray | None: + encoding = metadata.get("encoding", "bgr8") + width = metadata.get("width", IMAGE_WIDTH) + height = metadata.get("height", IMAGE_HEIGHT) + buf = data.to_numpy(zero_copy_only=False).astype(np.uint8) + if encoding == "bgr8": + img = buf.reshape((height, width, 3)) + elif encoding in ("jpeg", "jpg", "png", "bmp", "webp"): + img = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if img is None: + return None + else: + return None + # DoRobotDataset 期望 RGB + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +def main(): + import threading + + node = Node() + + dataset_ref = [None] + controller_ref = [None] + robot_ready = threading.Event() + + def setup(): + dataset_ref[0] = make_dataset() + ctrl = PiperTeleopController( + robot_urdf_path=URDF_PATH, + manipulator_config=DEFAULT_PIPER_MANIPULATOR_CONFIG, + can_port=CAN_PORT, + scale_factor=SCALE_FACTOR, + control_rate_hz=CONTROL_RATE_HZ, + enable_log_data=False, + enable_camera=False, + visualize_placo=False, + ) + ctrl._robot_setup() + controller_ref[0] = ctrl + robot_ready.set() + + threading.Thread(target=setup, daemon=True).start() + + latest_images: dict[str, np.ndarray] = {} + was_active = False + + for event in node: + if event["type"] != "INPUT": + continue + + eid = event["id"] + + # 缓存相机图像 + if eid in ("camera_top_image", "camera_wrist_image"): + meta = event["metadata"] + val = event["value"] + cam_key = "camera_top" if eid == "camera_top_image" else "camera_wrist" + if cam_key == "camera_top": + try: + buf_dbg = val.to_numpy(zero_copy_only=False).astype(np.uint8) + print(f"[DBG] {cam_key}: len={len(buf_dbg)}, first4={buf_dbg[:4].tolist()}") + except Exception as e: + print(f"[DBG] {cam_key} to_numpy error: {e}") + img = decode_image(val, meta) + if img is not None: + latest_images[cam_key] = img + else: + print(f"[DBG] {cam_key} decode failed: encoding={meta.get('encoding')}, len={len(val)}") + continue + + if eid != "tick": + continue + + # robot 未就绪时只显示图像,跳过控制 + if not robot_ready.is_set(): + if latest_images: + frames_to_show = [] + for cam_key in ("camera_top", "camera_wrist"): + img = latest_images.get(cam_key) + if img is not None: + bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + cv2.putText(bgr, f"{cam_key.upper()} | INIT", (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 165, 255), 2) + frames_to_show.append(bgr) + if frames_to_show: + try: + combined = np.hstack(frames_to_show) if len(frames_to_show) > 1 else frames_to_show[0] + cv2.imshow("Pico Teleop", combined) + cv2.waitKey(1) + except Exception as e: + print(f"[imshow error] {e}") + continue + + # IK + 控制 + controller = controller_ref[0] + dataset = dataset_ref[0] + controller._update_robot_state() + controller._update_gripper_target() + controller._update_ik() + controller._send_command() + + q = controller.piper.get_joint_positions() + gripper = controller.piper.get_gripper_position() + state = np.append(q, gripper).astype(np.float32) + + is_active = controller.active.get("right_arm", False) + + # grip 激活时录制(有哪路图像用哪路,缺失的用上一帧或零填充) + if is_active and latest_images: + top = latest_images.get( + "camera_top", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8) + ) + wrist = latest_images.get( + "camera_wrist", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8) + ) + frame = { + "observation.state": state, + "observation.images.camera_top": top, + "observation.images.camera_wrist": wrist, + "action": state, + "task": TASK, + } + dataset.add_frame(frame) + + # grip 松开时保存 episode + if was_active and not is_active: + n_frames = dataset.episode_buffer["size"] + if n_frames > 0: + ep_idx = dataset.save_episode() + print(f"[Recorder] Episode {ep_idx} saved ({n_frames} frames)") + dataset.episode_buffer = dataset.create_episode_buffer() + else: + print("[Recorder] No frames recorded, discarding.") + + was_active = is_active + + # 实时显示相机画面 + if latest_images: + frames_to_show = [] + for cam_key in ("camera_top", "camera_wrist"): + img = latest_images.get(cam_key) + if img is not None: + bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + label = f"{'TOP' if cam_key == 'camera_top' else 'WRIST'} | {'REC' if is_active else 'IDLE'}" + cv2.putText(bgr, label, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, + (0, 0, 255) if is_active else (0, 255, 0), 2) + frames_to_show.append(bgr) + if frames_to_show: + try: + combined = np.hstack(frames_to_show) if len(frames_to_show) > 1 else frames_to_show[0] + cv2.imshow("Pico Teleop", combined) + key = cv2.waitKey(1) + if key == ord("q"): + break + except Exception as e: + print(f"[imshow error] {e}") + + # 发布关节状态 + metadata = event["metadata"] + metadata["timestamp"] = time.time_ns() + node.send_output( + "follower_jointstate", + pa.array(state), + metadata, + ) + + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/__init__.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/__init__.py new file mode 100644 index 0000000..93e6fbd --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/__init__.py @@ -0,0 +1,3 @@ +from .piper import PiperInterface + +__all__ = ["PiperInterface"] diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/piper.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/piper.py new file mode 100644 index 0000000..6c29998 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/interface/piper.py @@ -0,0 +1,306 @@ +""" +松灵 Piper 机械臂硬件接口 + +提供与 Piper 机械臂的底层通信接口,包括: +- 关节位置控制 +- 关节状态读取 +- 夹爪控制 +- 安全保护功能 + +基于 Piper SDK V2 API +""" + +import time +import numpy as np +from typing import List, Optional, Union +from piper_sdk import C_PiperInterface_V2 + + +class PiperInterface: + """ + 松灵 Piper 机械臂硬件接口类 + + 使用 CAN 总线与 Piper 机械臂通信 + """ + + def __init__( + self, + can_port: str = "can0", + dt: float = 0.02, + filter_alpha: float = 0.6, + ): + """ + 初始化 Piper 接口 + + Args: + can_port: CAN 端口名称(如 "can0", "can1") + dt: 控制周期(秒),默认 50Hz + filter_alpha: 一阶低通滤波器系数 (0-1),越小越平滑但延迟越大,默认 0.6 + """ + self.can_port = can_port + self.dt = dt + self.num_joints = 6 # Piper 有 6 个关节 + + # 角度转换因子:SDK 使用的单位转换 + # factor = 1000 * 180 / π ≈ 57295.7795 + self.angle_factor = 57295.7795 + + # 夹爪转换因子:SDK 使用 μm 单位 + self.gripper_factor = 1000 * 1000 # m to μm + + # 一阶低通滤波器 + self.filter_alpha = filter_alpha + self.filtered_positions = None # 滤波后的关节位置 + self.filtered_gripper = None # 滤波后的夹爪位置 + + # 初始化 Piper SDK + print(f"Initializing Piper on CAN port: {can_port}") + self.piper = C_PiperInterface_V2(can_port) + + # 连接端口 + self.piper.ConnectPort() + print("Piper CAN port connected") + + # 使能机械臂 + self._enable_and_wait() + + def _enable_and_wait(self, timeout: float = 5.0): + """ + 使能机械臂并等待就绪 + + Args: + timeout: 超时时间(秒) + """ + print("Enabling Piper robot...") + start_time = time.time() + + while not self.piper.EnablePiper(): + if time.time() - start_time > timeout: + raise TimeoutError("Failed to enable Piper robot within timeout") + time.sleep(0.01) + + print("Piper robot enabled successfully") + + def go_home(self) -> bool: + """ + 移动到预定义的 Home 位置(零位) + + Returns: + bool: 成功返回 True + """ + print("Moving Piper to home position...") + + # Home 位置:所有关节归零 + home_position = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + # 设置运动模式:位置控制模式 + # ModeCtrl(enable, mode, speed_factor, unused) + # mode: 0x01 = 位置控制模式 + self.piper.ModeCtrl(0x01, 0x01, 30, 0x00) + + # 发送关节位置命令 + self.set_joint_positions(home_position) + + # 夹爪归零 + self.set_gripper_position(0.0) + + # 等待到达 + time.sleep(2.0) + + return True + + def get_joint_positions(self) -> np.ndarray: + """ + 获取当前关节位置 + + Returns: + np.ndarray: 关节位置数组(弧度),shape: (6,) + """ + # 获取关节消息 + joint_msgs = self.piper.GetArmJointMsgs() + + # 提取关节位置并转换为弧度 + # SDK 返回的单位需要除以 angle_factor 转换为弧度 + positions = np.array([ + joint_msgs.joint_state.joint_1 / self.angle_factor, + joint_msgs.joint_state.joint_2 / self.angle_factor, + joint_msgs.joint_state.joint_3 / self.angle_factor, + joint_msgs.joint_state.joint_4 / self.angle_factor, + joint_msgs.joint_state.joint_5 / self.angle_factor, + joint_msgs.joint_state.joint_6 / self.angle_factor, + ]) + + return positions + + def get_joint_velocities(self) -> np.ndarray: + """ + 获取当前关节速度 + + Returns: + np.ndarray: 关节速度数组(弧度/秒),shape: (6,) + + Note: + Piper SDK 不直接提供速度数据,这里返回零数组 + 如需速度信息,可以通过位置数值微分计算 + """ + # Piper SDK 的 GetArmJointMsgs() 不包含速度信息 + # 返回零数组 + return np.zeros(self.num_joints) + + def set_joint_positions( + self, + positions: Union[List[float], np.ndarray], + speed_factor: int = 100, + **kwargs + ) -> bool: + """ + 设置目标关节位置 + + Args: + positions: 目标关节位置(弧度),shape: (6,) + speed_factor: 速度因子 (0-100),默认 100 + **kwargs: 额外参数 + + Returns: + bool: 成功返回 True + """ + if len(positions) != self.num_joints: + print(f"Error: Expected {self.num_joints} joints, got {len(positions)}") + return False + + # 关节限位检查(弧度) + joint_limits = [ + (-3.14, 3.14), # joint1 + (-2.0, 2.0), # joint2 + (-2.5, 2.5), # joint3 + (-3.14, 3.14), # joint4 + (-2.0, 2.0), # joint5 + (-3.14, 3.14), # joint6 + ] + + # 限制关节位置在安全范围内 + clipped_positions = [] + for i, (pos, (min_val, max_val)) in enumerate(zip(positions, joint_limits)): + if pos < min_val or pos > max_val: + clipped_pos = np.clip(pos, min_val, max_val) + print(f"Warning: Joint {i+1} position {pos:.3f} clipped to {clipped_pos:.3f}") + clipped_positions.append(clipped_pos) + else: + clipped_positions.append(pos) + + # 一阶低通滤波:y = alpha * x + (1 - alpha) * y_prev + clipped_positions = np.array(clipped_positions) + if self.filtered_positions is None: + self.filtered_positions = clipped_positions.copy() + else: + self.filtered_positions = ( + self.filter_alpha * clipped_positions + + (1.0 - self.filter_alpha) * self.filtered_positions + ) + clipped_positions = self.filtered_positions + + # 转换为 SDK 单位 + joint_0 = round(clipped_positions[0] * self.angle_factor) + joint_1 = round(clipped_positions[1] * self.angle_factor) + joint_2 = round(clipped_positions[2] * self.angle_factor) + joint_3 = round(clipped_positions[3] * self.angle_factor) + joint_4 = round(clipped_positions[4] * self.angle_factor) + joint_5 = round(clipped_positions[5] * self.angle_factor) + + # 设置运动控制模式 + # MotionCtrl_2(enable, mode, speed_factor, unused) + self.piper.MotionCtrl_2(0x01, 0x01, speed_factor, 0x00) + + # 发送关节控制命令 + self.piper.JointCtrl(joint_0, joint_1, joint_2, joint_3, joint_4, joint_5) + + return True + + def set_gripper_position(self, position: float, speed: int = 1000) -> bool: + """ + 设置夹爪位置 + + Args: + position: 夹爪开合度 (0.0=完全闭合, 1.0=完全打开) + speed: 夹爪速度 (默认 1000) + + Returns: + bool: 成功返回 True + """ + # 限制在 0-1 范围内 + position = np.clip(position, 0.0, 1.0) + + # 一阶低通滤波 + if self.filtered_gripper is None: + self.filtered_gripper = position + else: + self.filtered_gripper = ( + self.filter_alpha * position + + (1.0 - self.filter_alpha) * self.filtered_gripper + ) + position = self.filtered_gripper + + # 将 0-1 映射到 Piper 夹爪的实际范围(单位:μm) + # Piper 夹爪最大开合约 0.08m = 80000μm + gripper_pos_um = round(position * 80000) + + # 发送夹爪控制命令 + # GripperCtrl(position_um, speed, enable, force) + self.piper.GripperCtrl(abs(gripper_pos_um), speed, 0x01, 0) + + return True + + def get_gripper_position(self) -> float: + """ + 获取当前夹爪位置 + + Returns: + float: 夹爪开合度 (0.0-1.0) + """ + gripper_msgs = self.piper.GetArmGripperMsgs() + + # grippers_angle 单位是 0.001 度 + # 转换为 0-1 范围(假设最大开合角度约 80 度) + angle_degrees = gripper_msgs.gripper_state.grippers_angle / 1000.0 + gripper_pos = abs(angle_degrees) / 80.0 # 归一化到 0-1 + + return np.clip(gripper_pos, 0.0, 1.0) + + def enable_robot(self) -> bool: + """使能机械臂""" + print("Enabling Piper robot...") + success = self.piper.EnablePiper() + if success: + print("Piper robot enabled") + return success + + def disable_robot(self) -> bool: + """失能机械臂""" + print("Disabling Piper robot...") + # Piper SDK 没有直接的 disable 方法 + # 可以通过设置模式为 0 来停止控制 + self.piper.ModeCtrl(0x00, 0x00, 0, 0x00) + print("Piper robot disabled") + return True + + def emergency_stop(self) -> bool: + """紧急停止""" + print("Piper emergency stop triggered!") + # 立即停止所有运动 + self.piper.ModeCtrl(0x00, 0x00, 0, 0x00) + return True + + def get_arm_status(self): + """ + 获取机械臂状态信息 + + Returns: + 机械臂状态对象 + """ + return self.piper.GetArmStatus() + + def __del__(self): + """析构函数,断开连接""" + if hasattr(self, 'piper'): + print("Disconnecting Piper...") + self.disable_robot() diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/node.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/node.py new file mode 100644 index 0000000..73ed6c9 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/node.py @@ -0,0 +1,238 @@ +import logging_mp +import threading +import time +import numpy as np +import pyarrow as pa +from dora import Node +from typing import Any, Dict, Optional + +# 导入 XRoboToolkit SDK +try: + import xrobotoolkit_sdk as xrt + XRT_AVAILABLE = True +except ImportError: + XRT_AVAILABLE = False + logging_mp.warning("xrobotoolkit_sdk not available") + +# 导入 Placo IK 求解器 +try: + import placo + PLACO_AVAILABLE = True +except ImportError: + PLACO_AVAILABLE = False + logging_mp.warning("placo not available") + + +logger = logging_mp.getLogger(__name__) +CONNECT_TIMEOUT_FRAME = 10 + + +class TeleoperatorNode: + pass + + +class DoraTeleoperatorNode(TeleoperatorNode): + pass + + +class PicoUltra4DoraTeleoperatorNode(DoraTeleoperatorNode): + """ + Pico Ultra4 VR 遥操器 Dora 节点 + + 功能: + 1. 从 XRoboToolkit SDK 获取 VR 控制器位姿 + 2. 使用 Placo 进行 IK 求解 + 3. 通过 Dora 发送关节角度命令 + """ + + def __init__(self, robot_urdf_path: str, config: Dict[str, Any]): + self.node = Node("pico_ultra4_dora") + self.config = config + + # VR 数据 + self.vr_pose: Optional[np.ndarray] = None # [x, y, z, qx, qy, qz, qw] + self.gripper_value: float = 0.0 + self.control_active: bool = False + + # IK 求解器 + self.robot_urdf_path = robot_urdf_path + self.placo_robot = None + self.placo_solver = None + + # 关节数据 + self.current_joint_positions: Optional[np.ndarray] = None + self.target_joint_positions: Optional[np.ndarray] = None + + self.lock = threading.Lock() + self.running = False + + # 初始化 XRoboToolkit + if XRT_AVAILABLE: + xrt.init() + logger.info("XRoboToolkit SDK initialized") + else: + logger.error("XRoboToolkit SDK not available") + + # 初始化 Placo IK 求解器 + if PLACO_AVAILABLE and robot_urdf_path: + self._setup_placo() + else: + logger.error("Placo not available or URDF path not provided") + + # 启动 VR 数据读取线程 + self.vr_thread = threading.Thread(target=self._vr_update_loop, daemon=True) + + def _setup_placo(self): + """设置 Placo IK 求解器""" + try: + # 加载机器人模型 + self.placo_robot = placo.RobotWrapper(self.robot_urdf_path) + + # 创建求解器 + self.placo_solver = placo.KinematicsSolver(self.placo_robot) + + # 获取关节索引 + joint_names = self.config.get("joint_names", [f"joint{i}" for i in range(1, 7)]) + self.placo_arm_joint_slice = slice( + self.placo_robot.get_joint_offset(joint_names[0]), + self.placo_robot.get_joint_offset(joint_names[-1]) + 1, + ) + + # 初始化关节位置为零位 + self.current_joint_positions = np.zeros(len(joint_names)) + self.placo_robot.state.q[self.placo_arm_joint_slice] = self.current_joint_positions + + logger.info("Placo IK solver initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Placo: {e}") + self.placo_robot = None + self.placo_solver = None + + def _vr_update_loop(self): + """VR 数据更新循环""" + if not XRT_AVAILABLE: + return + + while self.running: + try: + # 获取 VR 控制器位姿 + controller_name = self.config.get("vr_controller", "right_controller") + pose = xrt.get_right_controller_pose() if "right" in controller_name else xrt.get_left_controller_pose() + + # 获取握持键状态(激活控制) + grip_trigger = self.config.get("control_trigger", "right_grip") + grip_value = xrt.get_right_grip() if "right" in grip_trigger else xrt.get_left_grip() + + # 获取扳机键状态(夹爪控制) + gripper_trigger = self.config.get("gripper_trigger", "right_trigger") + trigger_value = xrt.get_right_trigger() if "right" in gripper_trigger else xrt.get_left_trigger() + + with self.lock: + self.vr_pose = pose + self.control_active = grip_value > 0.5 # 握持键按下激活控制 + self.gripper_value = trigger_value + + except Exception as e: + logger.error(f"Error reading VR data: {e}") + + time.sleep(0.01) # 100Hz 更新频率 + + def _solve_ik(self, target_pose: np.ndarray) -> Optional[np.ndarray]: + """ + 使用 Placo 求解 IK + + Args: + target_pose: 目标末端位姿 [x, y, z, qx, qy, qz, qw] + + Returns: + 关节角度数组,如果求解失败返回 None + """ + if not PLACO_AVAILABLE or self.placo_solver is None: + return None + + try: + # 更新当前关节状态 + if self.current_joint_positions is not None: + self.placo_robot.state.q[self.placo_arm_joint_slice] = self.current_joint_positions + + # 设置末端执行器目标 + end_effector_link = self.config.get("end_effector_link", "link6") + + # 提取位置和四元数 + position = target_pose[:3] + quaternion = target_pose[3:] # [qx, qy, qz, qw] + + # 转换为旋转矩阵 + from scipy.spatial.transform import Rotation as R + rotation_matrix = R.from_quat(quaternion).as_matrix() + + # 设置任务 + task = self.placo_solver.add_frame_task(end_effector_link, position, rotation_matrix) + task.configure(end_effector_link, "soft", 1.0, 1.0) + + # 求解 + self.placo_solver.solve(True) + + # 获取求解结果 + q_des = self.placo_robot.state.q[self.placo_arm_joint_slice].copy() + + return q_des + + except Exception as e: + logger.error(f"IK solve failed: {e}") + return None + + def start(self): + """启动节点""" + if self.running: + logger.warning(f"{self} is already running.") + return + + self.running = True + self.vr_thread.start() + + logger.info(f"{self} started. Waiting for VR data...") + + def get_action(self) -> Optional[Dict[str, float]]: + """ + 获取当前动作(关节角度) + + Returns: + 关节角度字典,如果未激活或求解失败返回 None + """ + with self.lock: + if not self.control_active or self.vr_pose is None: + return None + + # 应用缩放因子 + scale_factor = self.config.get("scale_factor", 1.5) + scaled_pose = self.vr_pose.copy() + scaled_pose[:3] *= scale_factor # 缩放位置 + + # 求解 IK + joint_positions = self._solve_ik(scaled_pose) + + if joint_positions is None: + return None + + # 构造动作字典 + joint_names = self.config.get("joint_names", [f"joint{i}" for i in range(1, 7)]) + action = {f"{name}.pos": pos for name, pos in zip(joint_names, joint_positions)} + + # 添加夹爪 + action["gripper.pos"] = self.gripper_value + + return action + + def update_current_joints(self, joint_positions: np.ndarray): + """更新当前关节位置(用于 IK 求解的初始状态)""" + with self.lock: + self.current_joint_positions = joint_positions.copy() + + def stop(self): + """停止节点""" + self.running = False + if XRT_AVAILABLE: + xrt.close() + logger.info(f"{self} stopped.") + diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/status.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/status.py new file mode 100644 index 0000000..2c7e4f3 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/status.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass, field, asdict +from typing import List, Optional +import json +import abc +import draccus + + +@dataclass +class ArmInfo: + name: str = "" + type: str = "" + start_pose: List[float] = field(default_factory=list) + joint_p_limit: List[float] = field(default_factory=list) + joint_n_limit: List[float] = field(default_factory=list) + is_connect: bool = False + + +@dataclass +class ArmStatus: + number: int = 0 + information: List[ArmInfo] = field(default_factory=list) + + def __post_init__(self): + if not self.information: + self.number = 0 + else: + self.number = len(self.information) + + +@dataclass +class Specifications: + end_type: str = "Default" + fps: int = 30 + arm: Optional[ArmStatus] = None + + +@dataclass +class TeleoperatorStatus(draccus.ChoiceRegistry, abc.ABC): + device_name: str = "Default" + device_body: str = "Default" + specifications: Specifications = field(default_factory=Specifications) + + @property + def type(self) -> str: + return self.get_choice_name(self.__class__) + + def to_dict(self) -> dict: + return asdict(self) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False) + + +@TeleoperatorStatus.register_subclass("pico_ultra4_dora") +@dataclass +class PicoUltra4DoraTeleoperatorStatus(TeleoperatorStatus): + device_name: str = "Pico_Ultra4" + device_body: str = "Pico_VR" + + def __post_init__(self): + self.specifications.end_type = "VR 控制器" + self.specifications.fps = 50 + + self.specifications.arm = ArmStatus( + information=[ + ArmInfo( + name="pico_vr_controller", + type="Pico Ultra4 VR 控制器", + start_pose=[], + joint_p_limit=[], + joint_n_limit=[], + is_connect=False + ), + ] + ) diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/teleoperator.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/teleoperator.py new file mode 100644 index 0000000..c283617 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/teleoperator.py @@ -0,0 +1,171 @@ +import time +import logging_mp +import numpy as np +from typing import Any, Dict + +from lerobot.utils.errors import DeviceNotConnectedError, DeviceAlreadyConnectedError +from lerobot.teleoperators.teleoperator import Teleoperator + +from .config import PicoUltra4DoraTeleoperatorConfig +from .status import PicoUltra4DoraTeleoperatorStatus +from .node import PicoUltra4DoraTeleoperatorNode + + +logger = logging_mp.getLogger(__name__) + + +class PicoUltra4DoraTeleoperator(Teleoperator): + """ + Pico Ultra4 VR 遥操器 + + 使用 Pico Ultra4 VR 控制器进行末端位姿控制, + 通过 IK 求解转换为关节角度,控制 Piper 机械臂 + """ + + config_class = PicoUltra4DoraTeleoperatorConfig + name = "pico_ultra4_dora" + + def __init__(self, config: PicoUltra4DoraTeleoperatorConfig): + super().__init__(config) + self.config = config + self.teleoperator_type = self.config.type + + # 状态 + self.status = PicoUltra4DoraTeleoperatorStatus() + + # 创建节点配置 + node_config = { + "vr_controller": self.config.vr_controller, + "control_trigger": self.config.control_trigger, + "gripper_trigger": self.config.gripper_trigger, + "end_effector_link": self.config.end_effector_link, + "scale_factor": self.config.scale_factor, + "joint_names": self.config.joint_names, + } + + # 创建 Dora 节点 + self.teleoperator_node = PicoUltra4DoraTeleoperatorNode( + robot_urdf_path=self.config.robot_urdf_path, + config=node_config + ) + + self.connected = False + self.logs = {} + + @property + def action_features(self) -> dict[str, type]: + """定义动作特征""" + features = {f"{joint}.pos": float for joint in self.config.joint_names} + features["gripper.pos"] = float + return features + + @property + def feedback_features(self) -> dict[str, type]: + """定义反馈特征(暂不支持)""" + return {} + + @property + def is_connected(self) -> bool: + return self.connected + + def connect(self): + """连接 VR 控制器""" + if self.connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + logger.info("Connecting Pico Ultra4 VR controller...") + + # 启动节点 + self.teleoperator_node.start() + + # 等待 VR 数据就绪 + timeout = 10 + start_time = time.perf_counter() + + while True: + # 检查是否有 VR 数据 + action = self.teleoperator_node.get_action() + if action is not None: + logger.info("VR controller data received") + break + + if time.perf_counter() - start_time > timeout: + raise TimeoutError("Failed to connect to VR controller within timeout") + + time.sleep(0.1) + + # 更新状态 + self.status.specifications.arm.information[0].is_connect = True + self.connected = True + + logger.info(f"{self} connected successfully") + + @property + def is_calibrated(self) -> bool: + """VR 控制器不需要校准""" + return True + + def calibrate(self) -> None: + """VR 控制器不需要校准""" + pass + + def configure(self) -> None: + """配置遥操器""" + pass + + def get_action(self) -> dict[str, float]: + """ + 获取当前动作 + + Returns: + 关节角度字典 + """ + if not self.connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + start = time.perf_counter() + + # 从节点获取动作 + action = self.teleoperator_node.get_action() + + if action is None: + # 如果未激活控制,返回零动作 + action = {f"{joint}.pos": 0.0 for joint in self.config.joint_names} + action["gripper.pos"] = 0.0 + + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} get action: {dt_ms:.1f} ms") + + return action + + def send_feedback(self, feedback: dict[str, Any]) -> None: + """发送反馈(暂不支持)""" + if not self.connected: + raise DeviceNotConnectedError( + f"{self} is not connected. You need to run `teleoperator.connect()`." + ) + + logger.warning(f"{self}: send_feedback() not implemented.") + + def update_status(self) -> str: + """更新状态""" + # 检查 VR 控制器连接状态 + action = self.teleoperator_node.get_action() + self.status.specifications.arm.information[0].is_connect = action is not None + + return self.status.to_json() + + def disconnect(self): + """断开连接""" + if not self.connected: + raise DeviceNotConnectedError( + "Teleoperator is not connected. You need to run `teleoperator.connect()` before disconnecting." + ) + + self.teleoperator_node.stop() + self.connected = False + logger.info(f"{self} disconnected.") + + def __del__(self): + if getattr(self, "connected", False): + self.disconnect() diff --git a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/pyproject.toml b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/pyproject.toml index ff19111..3967a0f 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/pyproject.toml +++ b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] diff --git a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/node.py b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/node.py index c586ffd..5096c05 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/node.py +++ b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/node.py @@ -6,7 +6,7 @@ from typing import Any, Dict -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/teleoperator.py b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/teleoperator.py index 07432cb..21068b8 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/teleoperator.py +++ b/robodriver/teleoperators/robodriver-teleoperator-so101-leader-dora/robodriver_teleoperator_so101_leader_dora/teleoperator.py @@ -13,7 +13,7 @@ from .node import SO101LeaderDoraTeleoperatorNode -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class SO101LeaderDoraTeleoperator(Teleoperator): diff --git a/robodriver/teleoperators/utils.py b/robodriver/teleoperators/utils.py index 2bf4d86..901d12e 100644 --- a/robodriver/teleoperators/utils.py +++ b/robodriver/teleoperators/utils.py @@ -5,7 +5,7 @@ from robodriver.utils.import_utils import make_device_from_device_class -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator: diff --git a/robodriver/tools/AutoDriver/GUI/generate_ros2_aio.py b/robodriver/tools/AutoDriver/GUI/generate_ros2_aio.py index 71c776f..e7a8843 100644 --- a/robodriver/tools/AutoDriver/GUI/generate_ros2_aio.py +++ b/robodriver/tools/AutoDriver/GUI/generate_ros2_aio.py @@ -274,7 +274,7 @@ def generate_robot_py(base_dir: Path, robot_name: str): from .node import __CAMEL_NAME__Node -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) class __CAMEL_NAME__Robot(Robot): @@ -646,7 +646,7 @@ def _to_camel(name: str) -> str: import logging_mp -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) CONNECT_TIMEOUT_FRAME = 10 diff --git a/robodriver/utils/import_utils.py b/robodriver/utils/import_utils.py index 4998e15..2a00a6f 100644 --- a/robodriver/utils/import_utils.py +++ b/robodriver/utils/import_utils.py @@ -21,7 +21,7 @@ import logging_mp from draccus.choice_types import ChoiceRegistry -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) def make_device_from_device_class(config: ChoiceRegistry) -> Any: diff --git a/robodriver/utils/parser.py b/robodriver/utils/parser.py index 5e35844..a1b46aa 100644 --- a/robodriver/utils/parser.py +++ b/robodriver/utils/parser.py @@ -24,7 +24,7 @@ import draccus import logging_mp -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) from robodriver.utils.utils import has_method diff --git a/robodriver/utils/utils.py b/robodriver/utils/utils.py index 10065a1..c13c3c6 100644 --- a/robodriver/utils/utils.py +++ b/robodriver/utils/utils.py @@ -10,7 +10,7 @@ import numpy as np import torch -logger = logging_mp.get_logger(__name__) +logger = logging_mp.getLogger(__name__) # import colored_logging as clog From 596d008dc193e051c2cc4f05917654b180704c2f Mon Sep 17 00:00:00 2001 From: SunSunSun689 Date: Wed, 1 Apr 2026 15:59:07 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E6=88=90=E5=8A=9Fpico4=E5=8F=8C=E8=87=82?= =?UTF-8?q?=E9=81=A5=E6=93=8Dpiper,=E6=95=B0=E6=8D=AE=E7=9A=84=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=A0=BC=E5=BC=8F=E5=BE=85=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/add_so102.md | 806 ++++++++++++++++++ docs/collection.md | 380 +++++++++ docs/dora-Architecture.md | 478 +++++++++++ docs/pico-piper-completion-report.md | 418 +++++++++ docs/pico-piper-implementation.md | 128 +++ docs/pico-piper.md | 287 +++++++ docs/robodriver-roboserver-communication.md | 563 ++++++++++++ .../README.md | 7 + .../dora/dataflow.yml | 42 +- .../dora_node_piper.py | 195 +++-- .../scripts/home_positions.json | 24 + .../scripts/read_joints.py | 131 +++ 12 files changed, 3361 insertions(+), 98 deletions(-) create mode 100644 docs/add_so102.md create mode 100644 docs/collection.md create mode 100644 docs/dora-Architecture.md create mode 100644 docs/pico-piper-completion-report.md create mode 100644 docs/pico-piper-implementation.md create mode 100644 docs/pico-piper.md create mode 100644 docs/robodriver-roboserver-communication.md create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/home_positions.json create mode 100644 robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/read_joints.py diff --git a/docs/add_so102.md b/docs/add_so102.md new file mode 100644 index 0000000..cdf0870 --- /dev/null +++ b/docs/add_so102.md @@ -0,0 +1,806 @@ +好的,我已经将添加 SO102 机器人的完整步骤总结并整理到了 `/home/dora/RoboDriver/docs/add_so102.md` 文档中。 + +以下是文档的具体内容: + +```markdown +# 添加新机器人 SO102-AIO-Dora 到 RoboDriver + +本文档说明如何将一个新的机器人实现(以SO102为例)添加到RoboDriver项目中。 + +## 📚 目录 + +- [核心原理](#核心原理) +- [前置条件](#前置条件) +- [步骤1:修改SO102代码](#步骤1修改so102代码) +- [步骤2:安装SO102包](#步骤2安装so102包) +- [步骤3:验证注册](#步骤3验证注册) +- [步骤4:配置Dora数据流](#步骤4配置dora数据流) +- [步骤5:启动测试](#步骤5启动测试) +- [故障排查](#故障排查) +- [检查清单](#检查清单) + +--- + +## 🎯 核心原理 + +RoboDriver使用**自动发现机制**来加载机器人插件: + +1. **包命名规则**:任何以 `robodriver_robot_` 开头的Python包都会被自动导入 +2. **注册机制**:通过 `@RobotConfig.register_subclass("robot_name")` 注册机器人类型 +3. **启动方式**:使用 `--robot.type=robot_name` 启动指定机器人 + +**关键文件**: +- `robodriver/utils/import_utils.py` - 自动发现和导入插件 +- `config.py` - 注册机器人类型 +- `robot.py` - 机器人主类实现 +- `node.py` - Dora节点通信 +- `dora/dataflow.yml` - Dora数据流配置 + +### 🔍 自动检测机制详解 + +**为什么新安装的机器人会被自动检测到?** + +RoboDriver 采用标准的 **Python 插件系统**设计,每个机器人都是一个独立的 Python 包: + +#### 1. 机器人作为 Python 包 + +``` +robodriver-robot-so102-aio-dora/ # 项目目录(连字符) +├── pyproject.toml # 包定义文件 +└── robodriver_robot_so102_aio_dora/ # Python 包(下划线) + ├── __init__.py # 包入口 + ├── config.py # 配置类(含注册装饰器) + ├── robot.py # 机器人实现 + └── node.py # Dora 节点 +``` + +#### 2. 安装注册到 Python 系统 + +```bash +pip install -e /path/to/robodriver-robot-so102-aio-dora +``` + +执行后: +- Python 将 `robodriver_robot_so102_aio_dora` 注册到模块系统 +- 包名被添加到可导入模块列表 +- 可以通过 `import robodriver_robot_so102_aio_dora` 导入 + +#### 3. daemon 启动时自动扫描 + +当 RoboDriver 启动时,会调用 `register_third_party_devices()` 函数: + +```python +# robodriver/utils/import_utils.py +def register_third_party_devices(): + # 扫描所有已安装的 Python 包 + for module_info in pkgutil.iter_modules(): + name = module_info.name + # 匹配以 robodriver_robot_ 开头的包 + if name.startswith("robodriver_robot_"): + # 自动导入包 + importlib.import_module(name) +``` + +**关键点**: +- `pkgutil.iter_modules()` 返回所有已安装的包(包括新安装的) +- 只要包名符合 `robodriver_robot_*` 模式,就会被自动导入 +- 支持可编辑安装(`-e` 模式) + +#### 4. 导入触发注册 + +当包被导入时,会执行 `config.py` 中的装饰器: + +```python +# config.py +@RobotConfig.register_subclass("so102_aio_dora") # 装饰器自动执行 +@dataclass +class SO102AIODoraRobotConfig(RobotConfig): + pass +``` + +装饰器将机器人配置注册到全局注册表: +- `"so102_aio_dora"` → `SO102AIODoraRobotConfig` 映射被添加 +- 后续可以通过 `--robot.type=so102_aio_dora` 使用 + +#### 5. 工作流程总结 + +``` +安装包 (pip install -e .) + ↓ +Python 注册包名到模块系统 + ↓ +daemon 启动时调用 register_third_party_devices() + ↓ +pkgutil.iter_modules() 扫描所有包 + ↓ +匹配 robodriver_robot_* 前缀 + ↓ +importlib.import_module() 自动导入 + ↓ +执行 __init__.py 和 config.py + ↓ +@RobotConfig.register_subclass() 装饰器执行 + ↓ +机器人注册到全局注册表 + ↓ +可以通过 --robot.type 使用 +``` + +**重要提示**: +- ✅ 无需修改核心代码,只需安装包 +- ✅ 无需重启系统,只需重启 daemon +- ✅ 支持热插拔,随时安装/卸载机器人包 +- ⚠️ 包名必须以 `robodriver_robot_` 开头 +- ⚠️ 必须有 `@RobotConfig.register_subclass()` 装饰器 + +--- + +## 📋 前置条件 + +假设你已经: +- ✅ 复制了SO101的代码到 `robodriver-robot-so102-aio-dora` 目录 +- ✅ 了解SO102的硬件配置(电机、相机、USB端口等) +- ✅ 准备好SO102的硬件设备 + +--- + +## ✅ 步骤1:修改SO102代码 + +### 1.1 重命名Python包目录 + +```bash +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora + +# 重命名主包 +mv robodriver_robot_so101_aio_dora robodriver_robot_so102_aio_dora + +# 重命名LeRobot兼容包 +mv lerobot_robot_so101_aio_dora lerobot_robot_so102_aio_dora +``` + +### 1.2 修改 `pyproject.toml` + +```toml +[project] +name = "robodriver_robot_so102_aio_dora" # ← 改为so102 +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.8" +license = "Apache-2.0" +authors = [ + {name = "Your Name", email = "your.email@example.com"}, +] +keywords = ["robotics", "lerobot", "so102"] # ← 改为so102 +dependencies = [ + "dora-rs", + "logging_mp", + "opencv-python", +] + +[tool.setuptools.packages.find] +include = [ + "robodriver_robot_so102_aio_dora", # ← 改为so102 + "lerobot_robot_so102_aio_dora" # ← 改为so102 +] +``` + +### 1.3 修改 `config.py` + +**关键修改**:注册名称必须改为 `so102_aio_dora` + +```python +from typing import Dict +from dataclasses import dataclass, field + +from lerobot.robots.config import RobotConfig +from lerobot.cameras import CameraConfig +from lerobot.cameras.opencv import OpenCVCameraConfig +from lerobot.motors import Motor, MotorNormMode + + +@RobotConfig.register_subclass("so102_aio_dora") # ← 改为so102 +@dataclass +class SO102AIODoraRobotConfig(RobotConfig): # ← 改为SO102 + use_degrees = True + norm_mode_body = ( + MotorNormMode.DEGREES if use_degrees else MotorNormMode.RANGE_M100_100 + ) + + # 根据你的SO102硬件配置修改电机和相机 + leader_motors: Dict[str, Motor] = field(...) + follower_motors: Dict[str, Motor] = field(...) + cameras: Dict[str, CameraConfig] = field(...) + + use_videos: bool = False + microphones: Dict[str, int] = field(default_factory=lambda: {}) +``` + +### 1.4 修改 `robot.py` + +```python +from .config import SO102AIODoraRobotConfig # ← 改为SO102 +from .status import SO102AIODoraRobotStatus # ← 改为SO102 +from .node import SO102AIODoraRobotNode # ← 改为SO102 + +class SO102AIODoraRobot(Robot): # ← 改为SO102 + config_class = SO102AIODoraRobotConfig # ← 改为SO102 + name = "so102_aio_dora" # ← 改为so102 + + def __init__(self, config: SO102AIODoraRobotConfig): # ← 改为SO102 + super().__init__(config) + # ... 其他代码 ... + self.status = SO102AIODoraRobotStatus() # ← 改为SO102 + self.robot_dora_node = SO102AIODoraRobotNode() # ← 改为SO102 +``` + +### 1.5 修改 `node.py` + +```python +class SO102AIODoraRobotNode(DoraRobotNode): # ← 改为SO102 + def __init__(self): + self.node = Node("so102_aio_dora") # ← 改为so102,必须与dataflow.yml中的id一致 + # ... 其他代码 ... +``` + +### 1.6 修改 `status.py` + +```python +class SO102AIODoraRobotStatus: # ← 改为SO102 + # ... 代码 ... +``` + +### 1.7 修改 `dora/dataflow.yml` + +**关键修改**:节点ID必须与`node.py`中的Node名称一致 + +```yaml +nodes: + - id: camera_top + # ... 相机配置 ... + + - id: arm_so102_leader # ← 改为so102 + build: pip install dora-arm-so101 # 如果SO102使用相同的驱动,保持不变 + path: dora-arm-so101 + inputs: + get_joint: dora/timer/millis/33 + outputs: + - joint_shoulder_pan + # ... 其他输出 ... + env: + UV_PROJECT_ENVIRONMENT: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/arm.venv + PORT: /dev/ttyACM0 + ARM_NAME: SO102-leader # ← 改为SO102 + ARM_ROLE: leader + + - id: arm_so102_follower # ← 改为so102 + # ... 类似配置 ... + env: + ARM_NAME: SO102-follower # ← 改为SO102 + + - id: so102_aio_dora # ← 改为so102,必须与node.py中的Node名称一致 + path: dynamic + inputs: + image_top: camera_top/image + # ... 其他输入 ... + leader_joint_shoulder_pan: arm_so102_leader/joint_shoulder_pan # ← 改为so102 + follower_joint_shoulder_pan: arm_so102_follower/joint_shoulder_pan # ← 改为so102 + outputs: + - action_joint +``` + +--- + +## 📦 步骤2:安装SO102包 + +在RoboDriver主环境下安装新的插件包: + +```bash +cd /home/dora/RoboDriver +source .venv/bin/activate + +# 进入SO102目录 +cd robodriver/robots/robodriver-robot-so102-aio-dora + +# 以可编辑模式安装 +uv pip install -e . +``` + +验证安装: + +```bash +python -c "import robodriver_robot_so102_aio_dora; print('✅ SO102 package imported successfully')" +``` + +--- + +## 🔍 步骤3:验证注册 + +检查SO102是否已成功注册到RoboDriver: + +```bash +cd /home/dora/RoboDriver +source .venv/bin/activate + +# 查看所有已注册的机器人类型 +python -c " +from lerobot.robots import RobotConfig +from robodriver.utils.import_utils import register_third_party_devices + +register_third_party_devices() +print('Registered robot types:') +for robot_type in sorted(RobotConfig._choice_registry.keys()): + print(f' - {robot_type}') +" +``` + +**期望输出**: +``` +✅ Successfully imported plugin: robodriver_robot_so102_aio_dora +Registered robot types: + - so101_aio_dora + - so102_aio_dora ← 你的新机器人 + - ... +``` + +--- + +## ⚙️ 步骤4:配置Dora数据流 + +在运行之前,需要配置Dora环境并连接硬件。 + +```bash +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora + +# 创建虚拟环境 +uv venv camera.venv +uv venv arm.venv + +# 安装依赖 +dora build dataflow.yml --uv + +# 连接硬件(按顺序) +# 1. 断开所有USB +# 2. 插入相机 +# 3. 插入SO102主臂 → /dev/ttyACM0 +# 4. 插入SO102从臂 → /dev/ttyACM1 +# 5. 赋予权限 +sudo chmod 666 /dev/ttyACM0 +sudo chmod 666 /dev/ttyACM1 + +# 启动Dora +dora up +dora start dataflow.yml --uv +``` + +--- + +## 🚀 步骤5:启动测试 + +一切准备就绪后,启动RoboDriver进行测试。 + +**新开一个终端**: + +```bash +cd /home/dora/RoboDriver +source .venv/bin/activate + +# 使用SO102启动 +robodriver-run --robot.type=so102_aio_dora +``` + +**成功标志**: +``` +[连接成功] 所有设备已就绪: + - 摄像头: image_top, image_wrist, image_top_dep + - 主臂关节角度: shoulder_pan, shoulder_lift, ... + - 从臂关节角度: shoulder_pan, shoulder_lift, ... +总耗时: 2.34 秒 +``` + +--- + +## 🔧 故障排查 + +### 问题1:找不到机器人类型 + +**错误**: +`ValueError: Invalid choice 'so102_aio_dora' for field 'type'` + +**解决**: +1. 检查 `config.py` 中的注册名称: + `@RobotConfig.register_subclass("so102_aio_dora") # 必须正确` +2. 确认包已安装: + `pip list | grep so102` +3. 检查包名是否以 `robodriver_robot_` 开头。 + +### 问题2:Dora节点连接失败 + +**错误**: +`等待主臂关节角度超时` + +**解决**: +1. 检查 `node.py` 中的Node名称与 `dataflow.yml` 中的节点ID是否一致: + ```python + # node.py + self.node = Node("so102_aio_dora") + ``` + ```yaml + # dataflow.yml + - id: so102_aio_dora # 必须一致 + ``` +2. 检查USB设备是否正确连接: + `ls /dev/ttyACM*` +3. 查看Dora日志: + `cat dora/out/dora-coordinator.txt` + +### 问题3:导入错误 + +**错误**: +`ModuleNotFoundError: No module named 'robodriver_robot_so102_aio_dora'` + +**解决**: +1. 确认目录名和包名一致。 +2. 重新安装: + ```bash + cd robodriver/robots/robodriver-robot-so102-aio-dora + uv pip install -e . + ``` + +--- + +## 📝 检查清单 + +在启动前,确认以下所有项: + +- [ ] **目录名**:`robodriver-robot-so102-aio-dora` +- [ ] **Python包名**:`robodriver_robot_so102_aio_dora` +- [ ] **注册名称**:`@RobotConfig.register_subclass("so102_aio_dora")` +- [ ] **类名**:`SO102AIODoraRobot`, `SO102AIODoraRobotConfig`, `SO102AIODoraRobotNode` +- [ ] **Dora节点ID**:`so102_aio_dora`(与`node.py`中的Node名称一致) +- [ ] **包已安装**:`uv pip install -e .` +- [ ] **Dora环境已创建**:`camera.venv`, `arm.venv` +- [ ] **硬件已连接并赋权** +``` + +--- + +## 🔧 步骤6:创建 dora-arm-so102 包 + +如果 SO102 使用不同的硬件驱动(不能直接使用 dora-arm-so101),需要创建专用的 dora-arm-so102 包。 + +### 6.1 复制并重命名包 + +```bash +# 1. 复制 so101 包作为模板 +cd /home/dora/RoboDriver/components/arms +cp -r dora-arm-so101 dora-arm-so102 + +# 2. 进入新包目录 +cd dora-arm-so102 +``` + +### 6.2 重命名 Python 包目录 + +```bash +# 重命名包目录 +mv dora_arm_so101 dora_arm_so102 +``` + +### 6.3 修改 pyproject.toml + +编辑 `/home/dora/RoboDriver/components/arms/dora-arm-so102/pyproject.toml`: + +```toml +[build-system] +requires = ["uv>=0.1.0", "setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "dora-arm-so102" # ← 改为 so102 +version = "0.0.2" +description = "" +authors = [ + {name = "Xiang Yang",email = "ryu-yang@qq.com"} +] +readme = "README.md" +requires-python = ">=3.8,<3.14" +dependencies = [ + "dora-rs (>=0.3.11,<0.4.0)", + "feetech-servo-sdk>=1.0.0", + "numpy", + "pyarrow", + "deepdiff", + "tqdm", + "pygame", + "draccus", + "pynput" +] + +[project.scripts] +dora-arm-so102 = "dora_arm_so102.main:main" # ← 改为 so102 + +[tool.setuptools.packages.find] +where = ["."] +include = ["dora_arm_so102"] # ← 改为 so102 +exclude = [] +``` + +### 6.4 安装新包到虚拟环境 + +```bash +# 进入 SO102 的 dora 目录 +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora + +# 安装 dora-arm-so102 包到 arm.venv +uv pip install --python arm.venv/bin/python /home/dora/RoboDriver/components/arms/dora-arm-so102 +``` + +### 6.5 验证安装 + +```bash +# 检查可执行文件是否创建 +ls -la arm.venv/bin/ | grep dora-arm +``` + +**期望输出**: +``` +-rwxrwxr-x 1 dora dora 381 dora-arm-so102 +``` + +### 6.6 更新 dataflow.yml + +确保 `dataflow.yml` 中使用正确的包名: + +```yaml + - id: arm_so102_leader + build: pip install dora-arm-so102 # ← 使用 so102 + path: dora-arm-so102 # ← 使用 so102 + # ... 其他配置 ... + + - id: arm_so102_follower + build: pip install dora-arm-so102 # ← 使用 so102 + path: dora-arm-so102 # ← 使用 so102 + # ... 其他配置 ... +``` + +### 6.7 测试启动 + +```bash +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora +dora start dataflow.yml --uv +``` + +**成功标志**: +- 不再出现 `Failed to spawn: dora-arm-so102` 错误 +- arm_so102_leader 和 arm_so102_follower 节点正常启动 + +--- + +## 📝 完整检查清单(更新版) + +在启动前,确认以下所有项: + +- [ ] **目录名**:`robodriver-robot-so102-aio-dora` +- [ ] **Python包名**:`robodriver_robot_so102_aio_dora` +- [ ] **注册名称**:`@RobotConfig.register_subclass("so102_aio_dora")` +- [ ] **类名**:`SO102AIODoraRobot`, `SO102AIODoraRobotConfig`, `SO102AIODoraRobotNode` +- [ ] **Dora节点ID**:`so102_aio_dora`(与`node.py`中的Node名称一致) +- [ ] **包已安装**:`uv pip install -e .` +- [ ] **Dora环境已创建**:`camera.venv`, `arm.venv` +- [ ] **dora-arm-so102 包已创建并安装** +- [ ] **硬件已连接并赋权** +``` + +--- + +## 🐛 常见错误及修复步骤(实战记录) + +### 错误1:ModuleNotFoundError: No module named 'dora_arm_so102.motors' + +**错误信息**: +``` +ModuleNotFoundError: No module named 'dora_arm_so102.motors' +``` + +**原因**:`pyproject.toml` 中的包配置不完整,子包没有被包含。 + +**修复步骤**: + +1. 修改 `/home/dora/RoboDriver/components/arms/dora-arm-so102/pyproject.toml`: + +```toml +[tool.setuptools.packages.find] +where = ["."] +include = ["dora_arm_so102*"] # ← 添加通配符 * 以包含所有子包 +exclude = [] +``` + +2. 强制重新安装包: + +```bash +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora +uv pip install --python arm.venv/bin/python --force-reinstall /home/dora/RoboDriver/components/arms/dora-arm-so102 +``` + +3. 验证安装: + +```bash +arm.venv/bin/python -c "from dora_arm_so102.motors.feetech import FeetechMotorsBus; print('✅ motors 模块导入成功')" +``` + +--- + +### 错误2:FileNotFoundError: 校准文件路径不存在 + +**错误信息**: +``` +FileNotFoundError: 校准文件路径不存在: /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/SO102-leader.json +``` + +**原因**:缺少 SO102 的校准文件。 + +**修复步骤**: + +```bash +# 从 SO101 复制校准文件作为初始配置 +cp /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/.calibration/SO101-leader.json \ + /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/SO102-leader.json + +cp /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so101-aio-dora/dora/.calibration/SO101-follower.json \ + /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/SO102-follower.json + +# 验证文件已创建 +ls -la /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/ +``` + +**注意**:如果 SO102 的电机配置与 SO101 不同,需要修改校准文件中的电机 ID 和参数。 + +--- + +### 错误3:PermissionError: Permission denied: '/dev/ttyACM1' + +**错误信息**: +``` +PermissionError: [Errno 13] Permission denied: '/dev/ttyACM1' +``` + +**原因**:USB 端口没有读写权限。 + +**修复步骤**: + +```bash +# 赋予 USB 端口权限 +sudo chmod 666 /dev/ttyACM0 +sudo chmod 666 /dev/ttyACM1 + +# 验证权限 +ls -la /dev/ttyACM* +``` + +**永久解决方案**(可选): + +```bash +# 将用户添加到 dialout 组 +sudo usermod -a -G dialout $USER + +# 注销并重新登录后生效 +``` + +--- + +### 错误4:ConnectionError: Failed to write 'Torque_Enable' on id_=3 + +**错误信息**: +``` +ConnectionError: Failed to write 'Torque_Enable' on id_=3 with '0' after 1 tries. [TxRxResult] Incorrect status packet! +``` + +**原因**:硬件通信失败,可能是: +- SO102 的电机 ID 配置与 SO101 不同 +- 硬件没有正确连接或供电 +- 校准文件中的电机配置不匹配 + +**诊断步骤**: + +```bash +# 1. 检查 USB 设备是否正确连接 +ls -la /dev/ttyACM* + +# 2. 查看校准文件中的电机 ID 配置 +cat /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/SO102-leader.json +cat /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora/.calibration/SO102-follower.json + +# 3. 检查硬件供电和连接 +# - 确保机械臂已通电 +# - 确保 USB 线缆连接稳定 +# - 尝试重新插拔 USB +``` + +**修复步骤**: + +如果 SO102 的电机 ID 与 SO101 不同,需要修改校准文件。例如: + +```json +{ + "motors": { + "shoulder_pan": { + "id": 1, // ← 根据实际硬件修改 + "offset": 0, + "orientation": "direct" + }, + "shoulder_lift": { + "id": 2, + "offset": 0, + "orientation": "direct" + } + // ... 其他电机配置 + } +} +``` + +--- + +## 🔍 调试技巧 + +### 1. 查看 Dora 日志 + +```bash +# 查看最近一次运行的日志 +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora +ls -lt out/ | head -5 + +# 查看特定节点的日志 +cat out//dora-coordinator.txt +cat out//arm_so102_leader.txt +``` + +### 2. 测试单个组件 + +```bash +# 测试 arm 包是否能正常导入 +cd /home/dora/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora +arm.venv/bin/python -c "from dora_arm_so102.main import main; print('✅ 导入成功')" + +# 测试相机 +camera.venv/bin/python -c "import cv2; print('✅ OpenCV 可用')" +``` + +### 3. 检查包安装 + +```bash +# 查看已安装的包 +arm.venv/bin/pip list | grep dora +camera.venv/bin/pip list | grep dora + +# 查看包的安装位置 +arm.venv/bin/pip show dora-arm-so102 +``` + +--- + +## 📋 完整工作流程总结 + +1. **创建 dora-arm-so102 包** + - 复制 dora-arm-so101 → dora-arm-so102 + - 重命名 Python 包目录 + - 修改 pyproject.toml(包名、脚本入口点、packages.find) + - 安装到虚拟环境 + +2. **准备校准文件** + - 从 SO101 复制校准文件 + - 根据 SO102 硬件修改电机 ID 和参数 + +3. **配置权限** + - 赋予 USB 端口读写权限 + - 或将用户添加到 dialout 组 + +4. **测试启动** + - 启动 dora dataflow + - 查看日志排查问题 + - 根据错误信息逐步修复 + +5. **验证功能** + - 确认所有节点正常启动 + - 测试机械臂控制 + - 测试相机采集 +``` diff --git a/docs/collection.md b/docs/collection.md new file mode 100644 index 0000000..1929535 --- /dev/null +++ b/docs/collection.md @@ -0,0 +1,380 @@ +# RoboDriver 数据采集指南 + +本文档说明如何使用 RoboDriver 进行多轮数据采集。 + +## 📚 目录 + +- [系统架构](#系统架构) +- [前置条件](#前置条件) +- [启动流程](#启动流程) +- [数据采集操作](#数据采集操作) +- [数据管理](#数据管理) +- [故障排查](#故障排查) + +--- + +## 🏗️ 系统架构 + +RoboDriver 数据采集系统由三个主要组件组成: + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Dora Dataflow │ ───> │ RoboDriver │ ───> │ RoboDriver-Server │ +│ (硬件驱动层) │ │ (数据采集客户端) │ │ (Web控制台+存储) │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ + ↓ ↓ ↓ + 机械臂+相机 实时数据流处理 Web界面控制+数据保存 +``` + +**组件说明**: +- **Dora Dataflow**: 与硬件直接通信,采集相机图像和机械臂关节数据 +- **RoboDriver**: 处理数据流,显示实时画面,与服务器通信 +- **RoboDriver-Server**: 提供 Web 控制界面,管理数据采集任务,保存数据 + +--- + +## 📋 前置条件 + +### 1. 硬件连接 + +按照以下顺序连接硬件(**顺序很重要**): + +```bash +# 1. 断开所有 USB 设备 +# 2. 插入顶部相机(RealSense 435 或其他) +# 3. 插入腕部相机 +# 4. 插入主臂 USB(带扳机,使用 5V 电源) +# 5. 插入从臂 USB +``` + +验证设备连接: + +```bash +# 检查相机设备 +ls /dev/video* +# 应该看到: /dev/video0 ... /dev/video7 + +# 检查机械臂设备 +ls /dev/ttyACM* +# 应该看到: /dev/ttyACM0 /dev/ttyACM1 + +# 赋予机械臂权限 +sudo chmod 666 /dev/ttyACM0 +sudo chmod 666 /dev/ttyACM1 +``` + +### 2. 软件环境 + +确保已安装: +- RoboDriver 主程序 +- RoboDriver-Server +- dora-rs-cli +- 对应机器人的驱动包(如 robodriver-robot-so102-aio-dora) + +--- + +## 🚀 启动流程 + +数据采集需要启动三个服务,建议使用三个独立的终端窗口。 + +### 终端 1: 启动 Dora Dataflow + +```bash +# 进入机器人的 dora 目录 +cd ~/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora + +# 清理之前的 dataflow(如果有) +dora destroy + +# 启动 dora 守护进程 +dora up + +# 启动 dataflow +dora start dataflow.yml --uv +``` + +**成功标志**: +``` +INFO arm_so102_leader: daemon node is ready +INFO arm_so102_follower: daemon node is ready +INFO camera_top: daemon node is ready +INFO camera_wrist: daemon node is ready +INFO camera_top_dep: daemon node is ready +INFO daemon all nodes are ready, starting dataflow +``` + +**保持此终端运行,不要关闭!** + +### 终端 2: 启动 RoboDriver + +```bash +# 进入 RoboDriver 主目录 +cd ~/RoboDriver + +# 激活虚拟环境 +source .venv/bin/activate + +# 启动 RoboDriver(替换为你的机器人类型) +robodriver-run --robot.type=so102_aio_dora +``` + +**成功标志**: +``` +INFO ✅ Successfully imported plugin: robodriver_robot_so102_aio_dora +INFO 成功连接到服务器 +INFO [连接成功] 所有设备已就绪: + - 摄像头: image_top, image_wrist, image_top_dep + - 主臂关节角度: shoulder_pan, shoulder_lift, ... + - 从臂关节角度: shoulder_pan, shoulder_lift, ... +``` + +**应该会弹出 OpenCV 窗口显示实时相机画面** + +**保持此终端运行,不要关闭!** + +### 终端 3: 启动 RoboDriver-Server + +```bash +# 进入 RoboDriver-Server 目录 +cd ~/RoboDriver-Server # 或你的服务器安装路径 + +# 启动服务器 +python operating_platform_server_test.py +``` + +**成功标志**: +``` +Server running on http://localhost:8088 +``` + +**保持此终端运行,不要关闭!** + +--- + +## 📊 数据采集操作 + +### 1. 打开 Web 控制界面 + +在浏览器中访问: +``` +http://localhost:8088 +``` + +### 2. 查看实时视频流 + +Web 界面应该显示: +- **image_top**: 顶部相机视图 +- **image_wrist**: 腕部相机视图 +- **image_top_dep**: 顶部深度相机视图 + +### 3. 开始第一轮采集 + +**步骤**: + +1. **点击"开始采集"按钮** + +2. **填写任务信息**: + - 任务名称(task_name):例如 `pick_cube` + - 任务 ID(task_id):自动生成或手动输入 + - 数据 ID(task_data_id):自动递增 + +3. **等待倒计时**: + - 系统会倒计时 3 秒 + - 准备好演示动作 + +4. **执行演示**: + - 移动**主臂**(leader arm,带扳机的那个) + - **从臂**(follower arm)会自动跟随 + - 系统自动记录: + - 所有相机图像(30 FPS) + - 主臂和从臂的关节角度 + - 时间戳 + +5. **完成采集**: + - 演示完成后,点击"完成采集"按钮 + - 数据自动保存到本地 + +### 4. 采集多轮数据 + +重复步骤 3,每次采集会创建一个新的数据集: + +``` +第 1 轮: pick_cube_task123_data001 +第 2 轮: pick_cube_task123_data002 +第 3 轮: pick_cube_task123_data003 +... +``` + +**建议**: +- 每个任务采集 10-50 轮数据 +- 变化起始位置和物体摆放 +- 包含成功和失败的案例 + +--- + +## 💾 数据管理 + +### 数据保存位置 + +数据默认保存在: +``` +~/DoRobot/data/YYYYMMDD/user/任务名_任务ID/数据集ID/ +``` + +**目录结构示例**: +``` +~/DoRobot/data/ +└── 20260303/ # 日期 + └── user/ # 用户数据 + └── pick_cube_task123/ # 任务目录 + ├── pick_cube_task123_data001/ # 第1轮数据 + │ ├── meta.json + │ ├── episode_0000.parquet + │ └── videos/ + ├── pick_cube_task123_data002/ # 第2轮数据 + └── pick_cube_task123_data003/ # 第3轮数据 +``` + +### 查看采集的数据 + +```bash +# 查看所有数据 +ls -lh ~/DoRobot/data/ + +# 查看今天的数据 +ls -lh ~/DoRobot/data/$(date +%Y%m%d)/ + +# 查看特定任务的数据 +ls -lh ~/DoRobot/data/20260303/user/pick_cube_task123/ + +# 查看数据集详情 +cat ~/DoRobot/data/20260303/user/pick_cube_task123/pick_cube_task123_data001/meta.json +``` + +### 数据格式 + +每个数据集包含: +- **meta.json**: 元数据(机器人类型、FPS、相机配置等) +- **episode_XXXX.parquet**: 单条数据(图像、关节角度、时间戳) +- **videos/**: 视频文件(如果启用视频模式) + +--- + +## 🔧 故障排查 + +### 问题 1: 无法连接到服务器 + +**错误信息**: +``` +socketio.exceptions.ConnectionError: Cannot connect to host localhost:8088 +``` + +**解决方案**: +1. 确认 RoboDriver-Server 正在运行 +2. 检查端口 8088 是否被占用: + ```bash + lsof -i :8088 + ``` +3. 重启 RoboDriver-Server + +### 问题 2: OpenCV 窗口不显示 + +**原因**: 代码中 `cv2.imshow` 被注释 + +**解决方案**: 已在 `/home/dora/RoboDriver/robodriver/scripts/run.py` 中启用 + +### 问题 3: 相机画面黑屏或错误 + +**检查相机索引**: +```bash +# 测试相机 +ffplay /dev/video4 +ffplay /dev/video6 +``` + +**修改配置**: +编辑 `dataflow.yml` 中的 `CAPTURE_PATH` 参数 + +### 问题 4: 机械臂无响应 + +**检查权限**: +```bash +sudo chmod 666 /dev/ttyACM0 +sudo chmod 666 /dev/ttyACM1 +``` + +**检查连接**: +```bash +# 运行诊断脚本 +cd ~/RoboDriver/robodriver/robots/robodriver-robot-so102-aio-dora/dora +arm.venv/bin/python diagnose_motors.py +``` + +### 问题 5: 磁盘空间不足 + +**错误信息**: +``` +存储空间不足,小于2GB,取消采集! +``` + +**解决方案**: +```bash +# 检查磁盘空间 +df -h ~/DoRobot + +# 清理旧数据 +rm -rf ~/DoRobot/data/旧日期目录/ +``` + +--- + +## 📝 最佳实践 + +### 数据采集建议 + +1. **采集前准备**: + - 确保所有硬件连接稳定 + - 检查相机画面清晰 + - 测试机械臂响应 + +2. **采集过程**: + - 动作要流畅自然 + - 避免突然停顿或抖动 + - 保持合理的速度 + +3. **数据质量**: + - 每个任务至少 20 轮 + - 包含不同起始位置 + - 记录失败案例 + +4. **数据管理**: + - 定期备份数据 + - 使用有意义的任务名称 + - 记录采集日志 + +### 停止服务 + +采集完成后,按以下顺序停止服务: + +```bash +# 1. 在终端 2 按 Ctrl+C 停止 RoboDriver +# 2. 在终端 3 按 Ctrl+C 停止 RoboDriver-Server +# 3. 在终端 1 按 Ctrl+C 停止 Dora Dataflow +``` + +--- + +## 🔗 相关文档 + +- [RoboDriver 主文档](https://flagopen.github.io/RoboDriver-Doc) +- [机器人配置指南](./add_so102.md) +- [数据格式说明](https://flagopen.github.io/RoboDriver-Doc/docs/dataset) + +--- + +## 📞 获取帮助 + +如遇问题,请: +1. 查看本文档的故障排查部分 +2. 检查终端输出的错误信息 +3. 访问 [GitHub Issues](https://github.com/FlagOpen/RoboDriver/issues) diff --git a/docs/dora-Architecture.md b/docs/dora-Architecture.md new file mode 100644 index 0000000..6837957 --- /dev/null +++ b/docs/dora-Architecture.md @@ -0,0 +1,478 @@ +# RoboDriver Dora 架构分析 + +## 目录 +- [架构概述](#架构概述) +- [两层架构关系](#两层架构关系) +- [架构模式分类](#架构模式分类) +- [核心优势](#核心优势) +- [架构对比](#架构对比) +- [实际应用场景](#实际应用场景) +- [类似架构实例](#类似架构实例) + +--- + +## 架构概述 + +RoboDriver 采用**分层微服务架构 + 数据流编排**的设计模式,通过 Dora 框架实现组件化的机器人系统开发。 + +### 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 应用层 (Python代码) │ +│ - 录制数据集 │ +│ - 训练模型 │ +│ - 执行策略 │ +└────────────────┬────────────────────────────────────────────┘ + │ 调用 Robot API + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 机器人包层 (robodriver-robot-xxx-aio-dora) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ robot.py (XXXAIODoraRobot) │ │ +│ │ - connect() │ │ +│ │ - capture_observation() ← 采集所有传感器数据 │ │ +│ │ - send_action() ← 发送控制指令 │ │ +│ │ - teleop_step() ← 遥操作 │ │ +│ └──────────────┬──────────────────────────────────────┘ │ +│ │ 通过 Dora Node 通信 │ +│ ┌──────────────▼──────────────────────────────────────┐ │ +│ │ node.py (XXXAIODoraRobotNode) │ │ +│ │ - 接收来自底层组件的数据 │ │ +│ │ - 发送控制指令到底层组件 │ │ +│ └──────────────┬──────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────▼──────────────────────────────────────┐ │ +│ │ dora/dataflow.yml │ │ +│ │ - 定义数据流拓扑 │ │ +│ │ - 连接各个底层组件 │ │ +│ └─────────────────────────────────────────────────────┘ │ +└────────────────┬────────────────────────────────────────────┘ + │ Dora 数据流 + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 底层组件层 (独立的 Dora 节点) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ dora-arm- │ │ dora-arm- │ │ dora-camera- │ │ +│ │ xxx │ │ xxx │ │ opencv │ │ +│ │ (leader) │ │ (follower) │ │ (top) │ │ +│ │ │ │ │ │ │ │ +│ │ main.py │ │ main.py │ │ │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +└─────────┼─────────────────┼─────────────────┼──────────────┘ + │ │ │ + ↓ ↓ ↓ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ CAN总线 │ │ CAN总线 │ │ USB相机 │ + │ (主臂) │ │ (从臂) │ │ │ + └─────────┘ └─────────┘ └─────────┘ +``` + +--- + +## 两层架构关系 + +### 层次定义 + +#### 1. 底层组件层 (components/arms/dora-arm-xxx) +- **定位**: 硬件驱动层 +- **职责**: 直接与硬件通信(CAN总线、串口、USB等) +- **特点**: + - 独立的 Dora 节点(独立进程) + - 可复用于不同的机器人配置 + - 单一职责:只负责一个硬件设备 +- **示例**: + - `dora-arm-piper`: Piper机械臂驱动 + - `dora-arm-so101`: SO101机械臂驱动 + - `dora-camera-opencv`: OpenCV相机驱动 + +#### 2. 机器人包层 (robodriver/robots/robodriver-robot-xxx-aio-dora) +- **定位**: 系统集成层 +- **职责**: + - 通过 `dataflow.yml` 编排多个底层组件 + - 提供统一的 Python API(继承 lerobot.Robot) + - 处理数据聚合:把分散的传感器数据组合成 observation + - 处理数据分发:把 action 指令分发给对应的执行器 +- **核心文件**: + - `robot.py`: 主机器人类 + - `node.py`: Dora节点封装 + - `config.py`: 配置类 + - `status.py`: 状态类 + - `dora/dataflow.yml`: 数据流配置 + +### 数据流示例 + +以 SO101 为例(Piper 类似): + +```yaml +# dataflow.yml 定义的数据流 + +# 1. 底层组件:arm_so101_leader +arm_so101_leader: + 输入: get_joint (定时触发) + 输出: joint_shoulder_pan, joint_shoulder_lift, ... (6个关节值) + 功能: 读取主臂硬件的关节角度 + +# 2. 底层组件:arm_so101_follower +arm_so101_follower: + 输入: + - get_joint (定时触发,读取状态) + - action_joint (来自主臂的控制指令) + 输出: joint_shoulder_pan, joint_shoulder_lift, ... (6个关节值) + 功能: 控制从臂硬件 + 读取从臂状态 + +# 3. 底层组件:camera_top +camera_top: + 输入: tick (定时触发) + 输出: image (图像数据) + 功能: 采集相机图像 + +# 4. 机器人包层:so101_aio_dora (动态节点) +so101_aio_dora: + 输入: + - image_top (来自 camera_top) + - leader_joint_* (来自 arm_so101_leader) + - follower_joint_* (来自 arm_so101_follower) + 输出: + - action_joint (发送给 arm_so101_follower) + 功能: 聚合所有传感器数据,提供统一的Robot接口 +``` + +### 通信机制 + +``` +Python代码 (robot.py) + ↕ (Python对象) +node.py (Dora Node封装) + ↕ (PyArrow数据) +Dora Runtime (进程间通信) + ↕ (PyArrow数据) +底层组件 (dora-arm-xxx/main.py) + ↕ (硬件协议) +硬件设备 (CAN总线/串口) +``` + +### 实际例子:遥操作流程 + +```python +# 应用层代码 +robot.teleop_step() + +# ↓ 在 robot.py 中 +def teleop_step(self): + # 1. 从node.py获取主臂数据 + leader_pos = self.robot_dora_node.recv_joint_leader + + # 2. 通过node.py发送给从臂 + self.robot_dora_node.dora_send("action_joint", leader_pos) + +# ↓ 在 node.py 中 +def dora_send(self, event_id, data): + # 转换为PyArrow格式 + arrow_array = pa.array(data, type=pa.float32()) + # 发送到Dora + self.node.send_output(event_id, arrow_array) + +# ↓ Dora Runtime 路由消息 +# dataflow.yml 定义: action_joint → arm_so101_follower + +# ↓ 在 dora-arm-so101/main.py 中 +if event["id"] == "action_joint": + position = event["value"].to_numpy() + # 写入硬件 + arm_bus.sync_write("Goal_Position", goal_pos) + +# ↓ 硬件层 +# CAN总线发送控制指令到舵机 +``` + +--- + +## 架构模式分类 + +### 1. 分层架构 (Layered Architecture) + +``` +应用层 (Application Layer) + ↓ +机器人抽象层 (Robot Abstraction Layer) + ↓ +硬件驱动层 (Hardware Driver Layer) + ↓ +硬件层 (Hardware Layer) +``` + +### 2. 微服务架构 (Microservices) + +每个底层组件都是独立的进程/服务: +- `dora-arm-piper` (独立进程) +- `dora-camera-opencv` (独立进程) +- `dora-arm-so101` (独立进程) + +### 3. 数据流架构 (Dataflow Architecture) + +通过 Dora 实现的发布-订阅模式: +```yaml +camera → [image] → robot_node +arm_leader → [joint_data] → robot_node → [action] → arm_follower +``` + +--- + +## 核心优势 + +### 1. 模块化与复用性 + +**示例**:同一个 `dora-arm-so101` 组件可以被多个机器人包复用: +- `robodriver-robot-so101-aio-dora` (双臂+相机) +- `robodriver-robot-so101-follower-dora` (单臂) +- `robodriver-robot-so102-aio-dora` (SO102变体) + +**优势**:不需要重复编写硬件驱动代码! + +### 2. 进程隔离与容错性 + +``` +如果相机节点崩溃: + ✓ 机械臂节点继续运行 + ✓ 可以单独重启相机节点 + ✗ 传统单进程:整个程序崩溃 + +如果某个传感器响应慢: + ✓ 不会阻塞其他传感器 + ✗ 传统单线程:所有操作被阻塞 +``` + +### 3. 灵活的配置与组合 + +通过修改 `dataflow.yml` 就能改变系统配置,无需修改代码: + +```yaml +# 配置1: 双臂 + 2个相机 +nodes: + - arm_leader + - arm_follower + - camera_top + - camera_wrist + +# 配置2: 单臂 + 1个相机 (只需修改YAML) +nodes: + - arm_follower + - camera_top + +# 配置3: 双臂 + 3个相机 + 深度相机 +nodes: + - arm_leader + - arm_follower + - camera_top + - camera_wrist + - camera_depth +``` + +### 4. 语言无关性 + +底层组件可以用不同语言实现: +- `dora-arm-piper`: Python (使用piper_sdk) +- `dora-camera-opencv`: Python +- 某个高性能组件: Rust/C++ (Dora原生支持) + +只要遵循 Dora 的数据接口,就能互相通信。 + +### 5. 并行处理与性能 + +``` +并行执行示例: +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ camera_top │ │camera_wrist │ │ arm_leader │ +│ 30 FPS │ │ 30 FPS │ │ 30 Hz │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + └────────────────┴────────────────┘ + ↓ + 所有数据并行采集,互不阻塞 + +传统单线程: +camera_top (33ms) → camera_wrist (33ms) → arm (33ms) = 99ms延迟 + +Dora架构: +max(33ms, 33ms, 33ms) = 33ms延迟 +``` + +### 6. 开发与测试便利性 + +```bash +# 测试单个组件(不需要启动整个系统) +$ cd components/arms/dora-arm-piper +$ dora start test_dataflow.yml # 只测试机械臂 + +# 模拟数据测试(不需要真实硬件) +$ dora start dataflow_with_mock.yml # 用模拟节点替换硬件节点 + +# 调试特定组件 +$ dora logs arm_piper_leader # 只看机械臂日志 +``` + +### 7. 版本管理与依赖隔离 + +每个组件可以有独立的虚拟环境: +```yaml +env: + UV_PROJECT_ENVIRONMENT: /path/to/camera.venv # opencv-python==4.8.0 + UV_PROJECT_ENVIRONMENT: /path/to/arm.venv # piper_sdk==1.2.0 + UV_PROJECT_ENVIRONMENT: /path/to/robot.venv # lerobot==0.5.0 +``` + +**优势**:避免依赖冲突! + +--- + +## 架构对比 + +### 传统单体架构 + +```python +class MonolithicRobot: + def __init__(self): + self.camera_top = Camera(4) # 阻塞 + self.camera_wrist = Camera(6) # 阻塞 + self.arm_leader = Arm("/dev/ttyACM0") + self.arm_follower = Arm("/dev/ttyACM1") + + def capture(self): + img1 = self.camera_top.read() # 串行 + img2 = self.camera_wrist.read() # 串行 + joint = self.arm_leader.read() # 串行 + return img1, img2, joint +``` + +**问题**: +- ❌ 一个设备故障 → 整个程序崩溃 +- ❌ 无法并行采集 +- ❌ 代码耦合严重 +- ❌ 难以测试单个设备 + +### Dora 微服务架构 + +```yaml +nodes: + - camera_top (独立进程) + - camera_wrist (独立进程) + - arm_leader (独立进程) + - robot_node (聚合节点) +``` + +**优势**: +- ✅ 设备故障隔离 +- ✅ 并行采集 +- ✅ 松耦合 +- ✅ 易于测试 + +--- + +## 实际应用场景 + +### 场景1:快速原型开发 + +```yaml +# 第一版:只有单臂 +nodes: + - arm_follower + - camera_top + +# 第二版:加入遥操作(只需添加节点) +nodes: + - arm_leader # 新增 + - arm_follower + - camera_top + +# 第三版:加入更多传感器 +nodes: + - arm_leader + - arm_follower + - camera_top + - camera_wrist # 新增 + - force_sensor # 新增 +``` + +### 场景2:多机器人支持 + +``` +components/arms/ + ├── dora-arm-piper ← 复用 + ├── dora-arm-so101 ← 复用 + └── dora-arm-realman ← 复用 + +robodriver/robots/ + ├── robot-piper-single/ (用 dora-arm-piper) + ├── robot-piper-dual/ (用 2个 dora-arm-piper) + ├── robot-so101-aloha/ (用 dora-arm-so101) + └── robot-hybrid/ (用 dora-arm-piper + dora-arm-so101) +``` + +### 场景3:分布式部署 + +``` +机器人本体 (树莓派): + - arm_leader + - arm_follower + +工作站 (高性能PC): + - camera_top (USB延长) + - camera_wrist + - robot_node (策略推理) + +通过网络连接Dora节点! +``` + +--- + +## 类似架构实例 + +这种架构在机器人领域很常见: + +### 1. ROS (Robot Operating System) +- **节点** = Dora节点 +- **Topic** = Dora数据流 +- 同样的微服务+数据流架构 + +### 2. Isaac SDK (NVIDIA) +- **Codelets** = Dora节点 +- **图结构** = dataflow.yml + +### 3. Apollo (自动驾驶) +- **Cyber RT** = 类似Dora的数据流框架 + +--- + +## 总结 + +### 核心价值 + +| 特性 | 说明 | +|------|------| +| ✅ **模块化** | 组件可复用,降低开发成本 | +| ✅ **容错性** | 故障隔离,提高系统稳定性 | +| ✅ **灵活性** | 配置驱动,快速调整系统 | +| ✅ **性能** | 并行处理,降低延迟 | +| ✅ **可测试** | 独立测试,提高开发效率 | +| ✅ **可扩展** | 易于添加新设备 | +| ✅ **语言无关** | 多语言混合开发 | + +### 类比理解 + +- **底层组件层** = 设备驱动程序(如显卡驱动、声卡驱动) +- **机器人包层** = 操作系统(把各个驱动整合起来,提供统一API) +- **应用层** = 应用程序(使用操作系统API,不关心底层细节) + +### 代价 + +增加了一定的复杂度(需要理解 Dora 框架),但对于复杂的机器人系统来说,这些好处远超过成本。 + +--- + +## 参考资料 + +- [Dora-rs 官方文档](https://github.com/dora-rs/dora) +- [LeRobot 项目](https://github.com/huggingface/lerobot) +- ROS 架构设计 diff --git a/docs/pico-piper-completion-report.md b/docs/pico-piper-completion-report.md new file mode 100644 index 0000000..3ec338f --- /dev/null +++ b/docs/pico-piper-completion-report.md @@ -0,0 +1,418 @@ +# Pico Ultra4 遥操 Piper 实现完成报告 + +## 📋 项目概述 + +成功将 `/home/dora/teleop_pico` 中的 Pico Ultra4 VR 遥操 Piper 机械臂实现迁移到 RoboDriver 框架。 + +**实现日期**: 2026-03-16 +**状态**: ✅ 核心功能已完成 + +--- + +## ✅ 已完成的工作 + +### 1. 目录结构创建 + +``` +robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/ +├── pyproject.toml # 包配置 +├── README.md # 项目说明 +├── USAGE.md # 详细使用指南 +├── QUICKSTART.md # 快速开始指南 +├── dora/ +│ ├── dataflow.yml # Dora 数据流配置 +│ └── dataflow_simple.yml # 简化版配置 +├── robodriver_teleoperator_pico_ultra4_dora/ +│ ├── __init__.py # 包初始化 +│ ├── config.py # 配置类 ✅ +│ ├── status.py # 状态类 ✅ +│ ├── node.py # Dora 节点(VR + IK)✅ +│ ├── teleoperator.py # 遥操器主类 ✅ +│ ├── controller.py # 完整控制器 ✅ +│ └── interface/ +│ ├── __init__.py +│ └── piper.py # Piper 硬件接口 ✅ +├── lerobot_teleoperator_pico_ultra4_dora/ +│ └── __init__.py # LeRobot 兼容层 +└── scripts/ + ├── pico_piper_teleop.py # 启动脚本 ✅ + └── test_components.py # 测试脚本 ✅ +``` + +### 2. 核心组件实现 + +#### ✅ PicoUltra4DoraTeleoperatorConfig (config.py) +- VR 控制器配置(控制器选择、按键映射) +- IK 求解器配置(URDF 路径、缩放因子) +- 控制参数配置(频率、关节名称) + +#### ✅ PicoUltra4DoraTeleoperatorStatus (status.py) +- 设备状态管理 +- 连接状态跟踪 +- JSON 序列化支持 + +#### ✅ PicoUltra4DoraTeleoperatorNode (node.py) +**核心功能**: +- XRoboToolkit SDK 集成:获取 VR 控制器位姿 +- Placo IK 求解器集成:末端位姿 → 关节角度 +- 多线程架构:VR 数据在独立线程中更新(100Hz) +- 控制激活机制:握持键控制 +- 夹爪控制:扳机键控制 + +**关键方法**: +- `_vr_update_loop()`: VR 数据读取循环 +- `_solve_ik()`: IK 求解 +- `get_action()`: 获取关节角度动作 + +#### ✅ PicoUltra4DoraTeleoperator (teleoperator.py) +- 实现 LeRobot Teleoperator 接口 +- 连接/断开管理 +- 动作获取接口 +- 状态更新 + +#### ✅ PicoPiperController (controller.py) +**完整控制器**,整合: +- VR 数据获取 +- IK 求解 +- Piper 硬件控制 +- 控制循环(50Hz) + +#### ✅ PiperInterface (interface/piper.py) +从原始实现复制,提供: +- CAN 总线通信 +- 关节位置控制 +- 夹爪控制 +- 一阶低通滤波 +- 安全限位检查 + +### 3. 工具和脚本 + +#### ✅ pico_piper_teleop.py +启动脚本,支持命令行参数: +- `--urdf-path`: URDF 文件路径 +- `--can-port`: CAN 端口 +- `--scale-factor`: 缩放因子 +- `--control-rate`: 控制频率 +- `--vr-controller`: 控制器选择 + +#### ✅ test_components.py +组件测试脚本,测试: +- 模块导入 +- XRoboToolkit SDK +- Placo IK 求解器 +- Piper SDK +- 配置类和状态类 + +### 4. 文档 + +#### ✅ README.md +- 项目概述 +- 功能特性 +- 基本使用方法 + +#### ✅ USAGE.md +- 详细安装指南 +- 三种使用方式 +- 配置参数说明 +- 故障排除 +- 性能优化 +- 安全注意事项 + +#### ✅ QUICKSTART.md +- 5分钟快速开始 +- 步骤化指南 +- 常用参数 +- 快速故障排除 + +#### ✅ docs/pico-piper-implementation.md +- 实现状态 +- 架构说明 +- 技术细节 +- 下一步工作 + +### 5. Dora 数据流配置 + +#### ✅ dataflow.yml +完整的 Dora 数据流配置,包含: +- Pico VR 控制器节点 +- IK 求解节点 +- Piper 机械臂节点 + +#### ✅ dataflow_simple.yml +简化版配置,使用集成节点 + +--- + +## 🔄 数据流架构 + +``` +┌─────────────────────┐ +│ Pico VR 控制器 │ +│ (XRoboToolkit SDK) │ +└──────────┬──────────┘ + │ VR 位姿 [x,y,z,qx,qy,qz,qw] + │ 握持键值 (0-1) + │ 扳机键值 (0-1) + ↓ +┌─────────────────────┐ +│ IK 求解器 │ +│ (Placo) │ +│ - 缩放位姿 │ +│ - 求解关节角度 │ +└──────────┬──────────┘ + │ 关节角度 [j1...j6] + │ 夹爪位置 (0-1) + ↓ +┌─────────────────────┐ +│ Piper 机械臂 │ +│ (piper_sdk) │ +│ - CAN 总线通信 │ +│ - 关节控制 │ +│ - 夹爪控制 │ +└─────────────────────┘ +``` + +--- + +## 🎯 核心特性 + +### 1. VR 数据获取 +- **SDK**: XRoboToolkit SDK +- **频率**: 100Hz 更新 +- **数据**: 位姿 + 按键状态 +- **线程**: 独立线程运行 + +### 2. IK 求解 +- **库**: Placo +- **输入**: 末端位姿 (7D: x,y,z,qx,qy,qz,qw) +- **输出**: 关节角度 (6D) +- **特性**: + - 考虑当前关节状态 + - 支持缩放因子 + - 实时求解 + +### 3. 硬件控制 +- **接口**: CAN 总线 +- **频率**: 50Hz 控制 +- **滤波**: 一阶低通滤波 +- **安全**: 关节限位检查 + +### 4. 控制机制 +- **激活**: 握持键按下激活 +- **移动**: 控制器位姿 → 机械臂末端 +- **夹爪**: 扳机键控制开合 +- **停止**: 松开握持键或 Ctrl+C + +--- + +## 📦 依赖项 + +### Python 包 +``` +dora-rs-cli (>=0.3.11,<0.4.0) +logging_mp +numpy +pyarrow +scipy +xrobotoolkit_sdk +placo +piper_sdk +``` + +### 系统依赖 +- CAN 总线驱动 +- Pico VR 头显驱动 +- XRoboToolkit 服务 + +--- + +## 🚀 使用方法 + +### 方法 1: 使用启动脚本(推荐) + +```bash +python robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/pico_piper_teleop.py \ + --urdf-path /path/to/piper.urdf \ + --can-port can0 \ + --scale-factor 1.5 +``` + +### 方法 2: Python 代码 + +```python +from robodriver_teleoperator_pico_ultra4_dora import ( + PicoPiperController, + PicoUltra4DoraTeleoperatorConfig +) + +config = PicoUltra4DoraTeleoperatorConfig( + robot_urdf_path="/path/to/piper.urdf", + scale_factor=1.5, +) + +controller = PicoPiperController(config=config, can_port="can0") +controller.start() +controller.run() +``` + +### 方法 3: RoboDriver 框架集成 + +```bash +python -m robodriver.scripts.run \ + --teleoperator pico_ultra4_dora \ + --robot piper_aio_dora +``` + +--- + +## 🧪 测试 + +### 运行组件测试 + +```bash +python robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/test_components.py +``` + +测试内容: +- ✅ 模块导入 +- ✅ XRoboToolkit SDK +- ✅ Placo IK 求解器 +- ✅ Piper SDK +- ✅ 配置类 +- ✅ 状态类 + +--- + +## 📊 性能指标 + +| 指标 | 目标 | 实现 | +|------|------|------| +| VR 更新频率 | 100Hz | ✅ 100Hz | +| 控制频率 | 50Hz | ✅ 50Hz | +| IK 求解延迟 | <20ms | ✅ 取决于 Placo | +| 端到端延迟 | <50ms | ✅ 需实测 | + +--- + +## ⚠️ 已知限制 + +1. **IK 求解** + - 依赖 Placo 库 + - 需要准确的 URDF 文件 + - 可能存在奇异点 + +2. **硬件依赖** + - 需要 Pico Ultra4 VR 头显 + - 需要 Piper 机械臂硬件 + - 需要 CAN 总线连接 + +3. **平台限制** + - 仅支持 Linux + - 需要 XRoboToolkit 服务运行 + +--- + +## 🔜 后续工作 + +### 待完成 + +1. **端到端测试** + - [ ] 实际硬件测试 + - [ ] 性能基准测试 + - [ ] 延迟测量 + +2. **优化** + - [ ] IK 求解性能优化 + - [ ] 滤波参数调优 + - [ ] 内存使用优化 + +3. **功能增强** + - [ ] 支持双臂控制 + - [ ] 添加力反馈 + - [ ] 碰撞检测 + +4. **文档** + - [ ] 添加视频教程 + - [ ] API 文档 + - [ ] 故障排除指南扩展 + +### 可选增强 + +- [ ] 支持其他 VR 头显 +- [ ] 支持其他机械臂 +- [ ] 添加 GUI 界面 +- [ ] 数据记录和回放 +- [ ] 远程遥操支持 + +--- + +## 📝 代码统计 + +``` +总文件数: 16 +Python 文件: 10 +配置文件: 4 +文档文件: 4 + +代码行数(估算): +- config.py: ~60 行 +- status.py: ~70 行 +- node.py: ~200 行 +- teleoperator.py: ~170 行 +- controller.py: ~150 行 +- interface/piper.py: ~310 行 +- 脚本: ~300 行 +总计: ~1260 行 +``` + +--- + +## 🎓 技术亮点 + +1. **模块化设计** + - 清晰的组件分离 + - 易于测试和维护 + - 可复用的接口 + +2. **多线程架构** + - VR 数据独立线程 + - 线程安全的数据访问 + - 高效的并发处理 + +3. **实时性能** + - 100Hz VR 更新 + - 50Hz 控制循环 + - 低延迟 IK 求解 + +4. **安全机制** + - 控制激活机制 + - 关节限位检查 + - 一阶低通滤波 + - 急停支持 + +5. **良好的文档** + - 多层次文档 + - 代码注释完整 + - 使用示例丰富 + +--- + +## 🙏 致谢 + +- 原始实现: `/home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/` +- XRoboToolkit SDK +- Placo IK 求解器 +- Piper SDK +- RoboDriver 框架 + +--- + +## 📞 联系方式 + +如有问题或建议,请联系 RoboDriver 开发团队。 + +--- + +**报告生成时间**: 2026-03-16 +**版本**: v0.1.0 +**状态**: ✅ 核心功能完成,待实际测试 diff --git a/docs/pico-piper-implementation.md b/docs/pico-piper-implementation.md new file mode 100644 index 0000000..9f9f45c --- /dev/null +++ b/docs/pico-piper-implementation.md @@ -0,0 +1,128 @@ +# Pico Ultra4 遥操 Piper 实现总结 + +## 实现完成情况 + +### ✅ 已完成 +1. **遥操器包结构** - `robodriver-teleoperator-pico-ultra4-dora` +2. **核心类实现**: + - `config.py`: 配置类,定义 VR 控制器和 IK 参数 + - `status.py`: 状态类,管理设备连接状态 + - `node.py`: Dora 节点类,集成 VR 数据获取和 IK 求解 + - `teleoperator.py`: 遥操器主类,实现 Teleoperator 接口 + +3. **关键功能**: + - XRoboToolkit SDK 集成:获取 Pico VR 控制器位姿 + - Placo IK 求解器集成:将末端位姿转换为关节角度 + - 多线程架构:VR 数据读取在独立线程中运行 + - 控制激活机制:通过握持键激活/停止控制 + - 夹爪控制:通过扳机键控制夹爪开合 + +## 架构说明 + +### 数据流 +``` +Pico VR 控制器 + ↓ (XRoboToolkit SDK) +末端位姿 [x, y, z, qx, qy, qz, qw] + ↓ (缩放因子) +缩放后的位姿 + ↓ (Placo IK 求解器) +关节角度 [joint1...joint6] + ↓ (Teleoperator 接口) +Piper 机械臂控制 +``` + +### 核心组件 + +#### 1. PicoUltra4DoraTeleoperatorNode +- 管理 VR 数据获取(100Hz 更新频率) +- 执行 IK 求解 +- 提供线程安全的数据访问 + +#### 2. PicoUltra4DoraTeleoperator +- 实现 LeRobot Teleoperator 接口 +- 管理连接/断开流程 +- 提供动作获取接口 + +## 待完成任务 + +### 🔄 需要进一步工作 + +1. **Piper 硬件接口适配** + - 将 `/home/dora/teleop_pico` 中的 `PiperInterface` 类集成到 robodriver + - 创建 Piper 的 Dora 节点(如果需要) + - 或直接在遥操器中使用 PiperInterface + +2. **Dora 数据流配置** + - 创建 `dataflow.yml` + - 定义节点间的数据流 + - 配置 Piper 机械臂节点 + +3. **依赖项安装** + - xrobotoolkit_sdk + - placo + - piper_sdk + - scipy (用于四元数转换) + +4. **测试和调试** + - 单元测试 + - 集成测试 + - 性能优化 + +## 使用方法(计划) + +```bash +# 安装依赖 +pip install -e robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora + +# 启动遥操(需要先完成 Piper 集成) +python -m robodriver.scripts.run \ + --teleoperator pico_ultra4_dora \ + --robot piper_aio_dora +``` + +## 配置示例 + +```python +from robodriver_teleoperator_pico_ultra4_dora import PicoUltra4DoraTeleoperatorConfig + +config = PicoUltra4DoraTeleoperatorConfig( + robot_urdf_path="/path/to/piper.urdf", + vr_controller="right_controller", + control_trigger="right_grip", + gripper_trigger="right_trigger", + scale_factor=1.5, + control_rate_hz=50, +) +``` + +## 技术细节 + +### IK 求解 +- 使用 Placo 库进行实时 IK 求解 +- 支持软约束和硬约束 +- 考虑当前关节状态作为初始值 + +### VR 数据处理 +- 位姿格式:[x, y, z, qx, qy, qz, qw] +- 坐标系转换:支持自定义头显到世界坐标系的转换 +- 缩放因子:可调整 VR 移动到机械臂移动的比例 + +### 安全特性 +- 控制激活机制:必须按住握持键才能控制 +- 关节限位检查(在 Piper 接口层) +- 超时检测 + +## 下一步工作 + +1. 复制 `PiperInterface` 到 robodriver +2. 创建 Piper 的 Dora 集成 +3. 编写 dataflow.yml +4. 进行端到端测试 +5. 性能优化和调试 + +## 参考文件 + +- 原始实现:`/home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/` +- Piper 接口:`/home/dora/teleop_pico/.../hardware/interface/piper.py` +- 遥操控制器:`/home/dora/teleop_pico/.../hardware/piper_teleop_controller.py` diff --git a/docs/pico-piper.md b/docs/pico-piper.md new file mode 100644 index 0000000..b9fdab9 --- /dev/null +++ b/docs/pico-piper.md @@ -0,0 +1,287 @@ +# Pico Ultra4 遥操 Piper 实现需求文档 + +## 概述 +本文档描述了在 RoboDriver 框架中添加 Pico Ultra4 遥操控制 Piper 机械臂所需实现的组件和功能。 + +## 1. 核心组件结构 + +### 1.1 遥操器包 (Teleoperator Package) +需要创建新的遥操器包:`robodriver-teleoperator-pico-ultra4-dora` + +**目录结构:** +``` +robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/ +├── pyproject.toml # 包配置文件 +├── README.md # 说明文档 +├── dora/ +│ └── dataflow.yml # Dora 数据流配置 +├── robodriver_teleoperator_pico_ultra4_dora/ +│ ├── __init__.py # 导出主要类 +│ ├── config.py # 配置类 +│ ├── status.py # 状态类 +│ ├── node.py # Dora 节点类 +│ ├── teleoperator.py # 遥操器主类 +│ └── calibrate.py # 校准功能(可选) +└── lerobot_teleoperator_pico_ultra4_dora/ + └── __init__.py # LeRobot 兼容层 +``` + +### 1.2 Pico Ultra4 硬件组件 +需要在 `components/arms/` 下创建或使用现有的 Pico Ultra4 驱动组件。 + +**可能需要创建:** +``` +components/arms/dora-arm-pico-ultra4/ +├── pyproject.toml +├── dora_arm_pico_ultra4/ +│ ├── __init__.py +│ ├── main.py # Dora 节点主程序 +│ └── read_data.py # 数据读取工具 +└── examples/ + └── read_pico_ultra4.yml # 示例配置 +``` + +## 2. 需要实现的核心类 + +### 2.1 配置类 (config.py) +```python +@TeleoperatorConfig.register_subclass("pico_ultra4_dora") +@dataclass +class PicoUltra4DoraTeleoperatorConfig(TeleoperatorConfig): + # 定义 Pico Ultra4 的电机配置 + motors: Dict[str, Motor] + # 其他配置参数 +``` + +**关键配置项:** +- 电机数量和类型 +- 归一化模式(角度/范围) +- 通信参数(端口、波特率等) + +### 2.2 状态类 (status.py) +```python +@TeleoperatorStatus.register_subclass("pico_ultra4_dora") +@dataclass +class PicoUltra4DoraTeleoperatorStatus(TeleoperatorStatus): + device_name: str = "Pico_Ultra4" + device_body: str = "Pico" + # 定义手臂规格、自由度、关节限位等 +``` + +**关键状态信息:** +- 设备名称和型号 +- 手臂数量和类型 +- 关节限位 +- 连接状态 +- FPS 等性能参数 + +### 2.3 Dora 节点类 (node.py) +```python +class PicoUltra4DoraTeleoperatorNode(DoraTeleoperatorNode): + def __init__(self): + # 初始化 Dora 节点 + # 创建数据接收缓冲区 + # 启动接收线程 + + def dora_recv(self, timeout: float): + # 接收来自 Pico Ultra4 的关节数据 + + def dora_send(self, event_id, data): + # 发送控制命令(如果需要反馈) +``` + +**关键功能:** +- 异步接收 Pico Ultra4 的关节角度数据 +- 管理数据缓冲和超时检测 +- 线程安全的数据访问 + +### 2.4 遥操器主类 (teleoperator.py) +```python +class PicoUltra4DoraTeleoperator(Teleoperator): + config_class = PicoUltra4DoraTeleoperatorConfig + name = "pico_ultra4_dora" + + def connect(self): + # 连接 Pico Ultra4 设备 + # 等待数据流就绪 + + def get_action(self) -> dict[str, float]: + # 读取当前关节位置作为动作 + + def send_feedback(self, feedback: dict[str, Any]): + # 发送力反馈(如果支持) + + def update_status(self) -> str: + # 更新设备状态 + + def disconnect(self): + # 断开连接 +``` + +**关键方法:** +- `connect()`: 建立与设备的连接,等待数据流 +- `get_action()`: 获取遥操动作(关节位置) +- `send_feedback()`: 发送反馈信号(可选) +- `update_status()`: 更新连接状态 +- `disconnect()`: 清理资源 + +## 3. Dora 数据流配置 + +### 3.1 dataflow.yml 结构 +```yaml +nodes: + - id: pico_ultra4_leader + path: nodes/dora_arm_pico_ultra4/dora_arm_pico_ultra4/main.py + build: pip install -e nodes/dora_arm_pico_ultra4 + inputs: + get_joint: dora/timer/millis/33 # 30Hz 采样 + outputs: + - joint_1 + - joint_2 + - joint_3 + - joint_4 + - joint_5 + - joint_6 + - joint_gripper + env: + DEVICE_PORT: /dev/ttyUSB0 # 根据实际设备调整 + ARM_NAME: Pico-Ultra4-leader + ARM_ROLE: leader + + - id: pico_ultra4_dora + path: dynamic + inputs: + leader_joint_1: pico_ultra4_leader/joint_1 + leader_joint_2: pico_ultra4_leader/joint_2 + # ... 其他关节 + outputs: + - action_joint +``` + +**关键配置:** +- 定义 Pico Ultra4 硬件节点 +- 配置数据采样频率(通常 30Hz) +- 映射关节输入/输出 +- 设置环境变量(设备端口、名称等) + +## 4. 硬件驱动实现 + +### 4.1 Pico Ultra4 SDK 集成 +需要集成 Pico Ultra4 的 Python SDK 或通信协议。 + +**参考 Piper 实现:** +- 使用 `piper_sdk.C_PiperInterface` 类似的接口 +- 实现关节角度读取 +- 实现夹爪控制(如果有) + +### 4.2 数据转换 +- 原始数据 → 标准化关节角度(弧度) +- 处理数据格式转换和单位换算 +- 实现数据滤波(如果需要) + +## 5. 与 Piper 机械臂的集成 + +### 5.1 使用现有 Piper 组件 +可以复用现有的 `components/arms/dora-arm-piper` 组件作为从臂(follower)。 + +### 5.2 数据映射 +需要建立 Pico Ultra4 关节到 Piper 关节的映射关系: +- 关节数量匹配(Pico Ultra4 vs Piper) +- 关节角度范围映射 +- 夹爪控制映射 + +## 6. 依赖项 + +### 6.1 Python 包依赖 +在 `pyproject.toml` 中添加: +```toml +dependencies = [ + "dora-rs-cli (>=0.3.11,<0.4.0)", + "logging_mp", + "numpy", + "pyarrow", + # Pico Ultra4 SDK(根据实际情况) +] +``` + +### 6.2 系统依赖 +- Dora-rs 运行时 +- 硬件驱动(USB/串口驱动) +- 可能需要的权限配置(udev rules) + +## 7. 测试和验证 + +### 7.1 单元测试 +- 测试配置加载 +- 测试数据接收 +- 测试动作生成 + +### 7.2 集成测试 +- 测试 Pico Ultra4 连接 +- 测试数据流完整性 +- 测试与 Piper 的协同工作 + +### 7.3 性能测试 +- 延迟测试(< 50ms) +- 频率测试(30Hz 稳定) +- 长时间运行稳定性 + +## 8. 实现步骤建议 + +1. **准备阶段** + - 研究 Pico Ultra4 硬件规格和 SDK + - 确认关节数量、自由度、通信协议 + +2. **硬件驱动层** + - 创建 `dora-arm-pico-ultra4` 组件 + - 实现基本的数据读取功能 + - 测试硬件通信 + +3. **遥操器层** + - 创建遥操器包结构 + - 实现配置、状态、节点类 + - 实现主遥操器类 + +4. **Dora 集成** + - 编写 dataflow.yml + - 测试数据流 + - 调试节点通信 + +5. **与 Piper 集成** + - 配置 Piper 作为从臂 + - 建立数据映射 + - 测试遥操控制 + +6. **优化和调试** + - 性能优化 + - 延迟优化 + - 错误处理完善 + +## 9. 参考实现 + +可以参考以下现有实现: +- `robodriver-teleoperator-so101-leader-dora`: 完整的遥操器实现示例 +- `components/arms/dora-arm-piper`: Piper 硬件驱动 +- `components/arms/dora-arm-so101`: SO101 硬件驱动 + +## 10. 注意事项 + +1. **安全性** + - 实现急停功能 + - 关节限位保护 + - 通信超时处理 + +2. **性能** + - 保持 30Hz 控制频率 + - 最小化延迟 + - 避免数据丢失 + +3. **可维护性** + - 遵循现有代码风格 + - 添加充分的日志 + - 编写清晰的文档 + +4. **兼容性** + - 与 LeRobot 框架兼容 + - 支持标准的 Teleoperator 接口 + - 可与其他组件组合使用 diff --git a/docs/robodriver-roboserver-communication.md b/docs/robodriver-roboserver-communication.md new file mode 100644 index 0000000..307dbc3 --- /dev/null +++ b/docs/robodriver-roboserver-communication.md @@ -0,0 +1,563 @@ +# RoboDriver 与 RoboServer 通信机制 + +## 目录 +- [架构概述](#架构概述) +- [通信组件](#通信组件) +- [通信协议](#通信协议) +- [数据流向](#数据流向) +- [API接口](#api接口) +- [消息类型](#消息类型) + +--- + +## 架构概述 + +RoboDriver 作为**客户端**,通过网络与 RoboServer(服务端)进行通信。通信采用**混合模式**: +- **Socket.IO** (WebSocket) - 用于实时双向通信(心跳、命令下发) +- **HTTP REST API** - 用于数据上传(视频流、状态信息) + +### 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RoboDriver (客户端) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Daemon │ │ Monitor │ │ +│ │ (机器人控制) │ │ (状态监控) │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ └────────┬───────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Coordinator │ │ +│ │ (协调器) │ │ +│ │ │ │ +│ │ - Socket.IO │ ◄─── 实时双向通信 │ +│ │ - HTTP Client │ ◄─── 数据上传 │ +│ └────────┬────────┘ │ +└──────────────────┼─────────────────────────────────────────┘ + │ + │ 网络通信 + │ +┌──────────────────▼─────────────────────────────────────────┐ +│ RoboServer (服务端) │ +│ 默认地址: http://localhost:8088 │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Socket.IO Server │ │ +│ │ - 接收心跳 (HEARTBEAT) │ │ +│ │ - 发送命令 (robot_command) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ HTTP REST API │ │ +│ │ - /robot/stream_info (摄像头信息) │ │ +│ │ - /robot/update_stream/:id (视频流) │ │ +│ │ - /robot/response (命令响应) │ │ +│ │ - /robot/update_machine_information (设备状态) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 通信组件 + +### 1. Daemon (机器人守护进程) + +**文件**: `robodriver/robots/daemon.py` + +**职责**: +- 管理机器人实例的生命周期 +- 采集观测数据 (observation) +- 发送动作指令 (action) +- 维护机器人状态 (status) + +**关键方法**: +```python +class Daemon: + def __init__(self, config: RobotConfig, fps: int = None) + def start(self) # 连接机器人 + def update(self) # 主循环:采集数据、发送指令 + def get_observation(self) # 获取观测数据 + def get_status(self) # 获取机器人状态 + def set_pre_action(self, action) # 设置待执行动作 +``` + +### 2. Monitor (状态监控器) + +**文件**: `robodriver/core/monitor.py` + +**职责**: +- 定期向服务器上报机器人状态 +- 使用 HTTP POST 请求 + +**通信方式**: +```python +# 每60秒发送一次设备信息 +URL: http://localhost:8088/robot/update_machine_information +Method: POST +Content-Type: application/json +Body: {机器人状态JSON} +``` + +### 3. Coordinator (协调器) + +**文件**: `robodriver/core/coordinator.py` + +**职责**: +- 管理与服务器的所有通信 +- 处理服务器下发的命令 +- 上传视频流和数据 +- 协调录制、回放等任务 + +**核心属性**: +```python +class Coordinator: + server_url = "http://localhost:8088" # 服务器地址 + sio: socketio.AsyncClient # Socket.IO客户端 + session: aiohttp.ClientSession # HTTP客户端 + daemon: Daemon # 机器人守护进程 + teleop: Teleoperator # 遥操作器 +``` + +--- + +## 通信协议 + +### 1. Socket.IO 实时通信 + +#### 客户端 → 服务器 + +**心跳消息** (每2秒发送一次) +```python +Event: "HEARTBEAT" +Data: None +``` + +#### 服务器 → 客户端 + +**命令消息** +```python +Event: "robot_command" +Data: { + "cmd": "命令类型", + "msg": {命令参数} +} +``` + +**心跳响应** +```python +Event: "HEARTBEAT_RESPONSE" +Data: {响应数据} +``` + +### 2. HTTP REST API + +#### 上传摄像头信息 +```http +POST /robot/stream_info +Content-Type: application/json + +{ + "stream_count": 2, + "streams": [ + {"id": 1, "name": "image_top"}, + {"id": 2, "name": "image_wrist"} + ] +} +``` + +#### 上传视频流 +```http +POST /robot/update_stream/:camera_id +Content-Type: image/jpeg + + +``` + +#### 命令响应 +```http +POST /robot/response +Content-Type: application/json + +{ + "cmd": "start_collection", + "msg": "success", + "data": {额外数据} +} +``` + +#### 上传设备状态 +```http +POST /robot/update_machine_information +Content-Type: application/json + +{机器人状态JSON} +``` + +--- + +## 数据流向 + +### 启动流程 + +``` +1. 应用启动 (run.py) + ↓ +2. 创建 Daemon (连接机器人) + ↓ +3. 创建 Monitor (启动状态监控线程) + ↓ +4. 创建 Coordinator (连接服务器) + ↓ +5. Coordinator.start() + ├─ 连接 Socket.IO + ├─ 启动心跳循环 + └─ 注册命令回调 + ↓ +6. 上传摄像头信息到服务器 + ↓ +7. 进入主循环 + ├─ Daemon.update() (采集数据) + ├─ Monitor 定期上报状态 + └─ Coordinator 处理服务器命令 +``` + +### 主循环数据流 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 主循环 (run.py) │ +│ │ +│ while True: │ +│ daemon.update() ◄─── 采集观测数据 │ +│ observation = daemon.get_observation() │ +│ action = teleop.get_action() │ +│ daemon.set_pre_action(action) │ +│ daemon.set_obs_action(action) │ +└─────────────────────────────────────────────────────────┘ + │ │ + │ 观测数据 │ 动作指令 + ↓ ↓ +┌──────────────────┐ ┌──────────────────┐ +│ Coordinator │ │ Robot Hardware │ +│ (可选上传视频流) │ │ (执行动作) │ +└──────────────────┘ └──────────────────┘ +``` + +--- + +## API接口 + +### Coordinator 主要方法 + +#### 1. 启动与停止 +```python +async def start(self) + # 连接到服务器 + # 启动心跳循环 + +async def stop(self) + # 断开连接 + # 清理资源 +``` + +#### 2. 心跳管理 +```python +async def send_heartbeat_loop(self) + # 每2秒发送一次心跳 + while self.running: + await self.sio.emit("HEARTBEAT") + await asyncio.sleep(2) +``` + +#### 3. 命令响应 +```python +async def send_response(self, cmd, msg, data=None) + # 向服务器发送命令执行结果 + POST /robot/response + { + "cmd": cmd, + "msg": msg, + "data": data + } +``` + +#### 4. 视频流上传 +```python +async def update_stream_async(self, name, frame) + # 将图像编码为JPEG + # 上传到服务器 + POST /robot/update_stream/{camera_id} +``` + +#### 5. 摄像头信息同步 +```python +def stream_info(self, info: Dict[str, int]) + # 更新本地摄像头信息 + +async def update_stream_info_to_server(self) + # 同步摄像头信息到服务器 + POST /robot/stream_info +``` + +--- + +## 消息类型 + +### 服务器命令 (robot_command) + +#### 1. 查询视频流列表 +```json +{ + "cmd": "video_list" +} +``` + +**响应**: +```json +{ + "stream_count": 2, + "streams": [ + {"id": 1, "name": "image_top"}, + {"id": 2, "name": "image_wrist"} + ] +} +``` + +#### 2. 开始数据采集 +```json +{ + "cmd": "start_collection", + "msg": { + "task_id": "task_001", + "task_name": "pick_apple", + "task_data_id": "data_001", + "countdown_seconds": 3 + } +} +``` + +**处理流程**: +1. 检查磁盘空间(至少2GB) +2. 检查是否正在回放(不能同时进行) +3. 停止之前的录制(如果有) +4. 创建数据集目录 +5. 初始化 Record 对象 +6. 倒计时后开始录制 + +**响应**: +```json +{ + "cmd": "start_collection", + "msg": "success" +} +``` + +#### 3. 完成数据采集 +```json +{ + "cmd": "finish_collection" +} +``` + +**处理流程**: +1. 停止录制 +2. 保存数据到磁盘 +3. 返回保存结果 + +**响应**: +```json +{ + "cmd": "finish_collection", + "msg": "success", + "data": { + "episodes": 10, + "frames": 3000, + "path": "/path/to/dataset" + } +} +``` + +#### 4. 丢弃数据采集 +```json +{ + "cmd": "discard_collection" +} +``` + +**处理流程**: +1. 停止录制 +2. 删除临时数据 +3. 不保存到磁盘 + +**响应**: +```json +{ + "cmd": "discard_collection", + "msg": "success" +} +``` + +#### 5. 开始数据回放 +```json +{ + "cmd": "start_replay", + "msg": { + "repo_id": "dataset_001", + "episode_index": 0 + } +} +``` + +**处理流程**: +1. 检查是否正在录制(不能同时进行) +2. 加载数据集 +3. 开始回放指定episode + +**响应**: +```json +{ + "cmd": "start_replay", + "msg": "success" +} +``` + +#### 6. 停止数据回放 +```json +{ + "cmd": "stop_replay" +} +``` + +**响应**: +```json +{ + "cmd": "stop_replay", + "msg": "success" +} +``` + +--- + +## 通信时序图 + +### 启动连接时序 + +``` +RoboDriver RoboServer + | | + |--- Socket.IO Connect ------->| + |<---- connect event ----------| + | | + |--- HEARTBEAT --------------->| + |<--- HEARTBEAT_RESPONSE ------| + | | + |--- POST /robot/stream_info ->| + |<--- 200 OK ------------------| + | | + |--- POST /robot/update_machine_information ->| + |<--- 200 OK ------------------| +``` + +### 数据采集时序 + +``` +RoboDriver RoboServer + | | + |<--- robot_command -----------| + | {cmd: "start_collection"}| + | | + |--- POST /robot/response ---->| + | {msg: "success"} | + | | + |=== 开始录制 === | + | | + |--- POST /robot/update_stream ->| (持续) + |<--- 200 OK ------------------| + | | + |<--- robot_command -----------| + | {cmd: "finish_collection"}| + | | + |=== 停止录制并保存 === | + | | + |--- POST /robot/response ---->| + | {msg: "success", data: {...}}| +``` + +--- + +## 配置说明 + +### 服务器地址配置 + +默认服务器地址在 `Coordinator` 中硬编码: +```python +server_url = "http://localhost:8088" +``` + +### 心跳间隔配置 + +```python +heartbeat_interval = 2 # 秒 +``` + +### 状态上报间隔配置 + +```python +interval_seconds = 60 # Monitor每60秒上报一次 +``` + +--- + +## 错误处理 + +### 1. 连接失败 +```python +# Socket.IO 连接失败会触发 disconnect 事件 +async def __on_disconnect_handle(self): + logger.info("与服务器断开连接") +``` + +### 2. 请求超时 +```python +# HTTP 请求设置超时 +timeout=aiohttp.ClientTimeout(total=2) # 2秒超时 +``` + +### 3. 命令执行失败 +```python +# 返回失败响应 +await self.send_response("start_collection", "fail") +``` + +--- + +## 总结 + +### 通信特点 + +| 特性 | 说明 | +|------|------| +| **双向通信** | Socket.IO 支持服务器主动推送命令 | +| **异步处理** | 使用 asyncio 和 aiohttp 实现高性能异步通信 | +| **混合协议** | Socket.IO (实时) + HTTP (数据上传) | +| **容错机制** | 超时重试、错误日志、状态检查 | +| **线程安全** | 使用锁保护共享数据 | + +### 关键文件 + +- `robodriver/core/coordinator.py` - 通信协调器(核心) +- `robodriver/core/monitor.py` - 状态监控器 +- `robodriver/robots/daemon.py` - 机器人守护进程 +- `robodriver/scripts/run.py` - 主程序入口 + +### 扩展建议 + +1. **配置化服务器地址**: 通过环境变量或配置文件设置 +2. **重连机制**: Socket.IO 断线自动重连 +3. **消息队列**: 处理网络抖动时的消息缓存 +4. **压缩传输**: 视频流使用更高效的压缩算法 +5. **安全认证**: 添加 Token 或证书认证机制 diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md index ebe6c87..c226b9e 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md @@ -15,6 +15,13 @@ ## 快速启动 ### 前置条件 +#### 激活can通信 +```bash +# 查找can对应的USB port +bash find_all_can_port.sh +激活can0 +bash can_activate.sh can0 1000000 USB-port # bash can_activate.sh can0 1000000 3-8.4.4:1.0 +``` ```bash # 1. 启动 CAN 总线 diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml index 8ded3f8..7eb129d 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/dora/dataflow.yml @@ -1,6 +1,4 @@ nodes: - # ========== 深度相机节点 ========== - # Top相机:奥比中光 Gemini335(仅彩色图) - id: camera_top path: ../../../../components/cameras/dora-camera-orbbec-v1/dora_camera_orbbec_v1/main.py inputs: @@ -8,12 +6,12 @@ nodes: outputs: - image env: - VIRTUAL_ENV: ../.venv + VIRTUAL_ENV: /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.venv + UV_LINK_MODE: copy V4L2_DEVICE: /dev/video16 IMAGE_WIDTH: 640 IMAGE_HEIGHT: 480 - # Wrist相机:Intel RealSense D405(仅彩色图) - id: camera_wrist path: ../../../../components/cameras/dora-camera-realsense/dora_camera_realsense/main.py inputs: @@ -21,43 +19,29 @@ nodes: outputs: - image env: - VIRTUAL_ENV: ../.venv - DEVICE_SERIAL: "130322271127" # RealSense D405 序列号 + VIRTUAL_ENV: /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.venv + UV_LINK_MODE: copy + DEVICE_SERIAL: "130322272232" IMAGE_WIDTH: 640 IMAGE_HEIGHT: 480 - # ========== Piper 机械臂节点 ========== - # 注意:piper_arm 节点已注释掉,由 pico_ultra4_dora 直接控制硬件 - # - id: piper_arm - # path: ../../../../components/arms/dora-arm-piper/dora_arm_piper/main.py - # build: pip install -e ../../../../components/arms/dora-arm-piper - # inputs: - # tick: dora/timer/millis/20 # 50Hz 控制频率 - # outputs: - # - leader_jointstate - # - follower_jointstate - # - follower_endpose - # env: - # VIRTUAL_ENV: ../.venv - # CAN_BUS: can0 - # TEACH_MODE: "True" - - # ========== Pico Ultra4 遥操器节点(集成版本)========== - # 需要先启动 CAN 总线:sudo ip link set can0 up type can bitrate 1000000 - id: pico_ultra4_dora path: ../robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py inputs: - tick: dora/timer/millis/20 # 50Hz 控制 + tick: dora/timer/millis/20 camera_top_image: camera_top/image camera_wrist_image: camera_wrist/image outputs: - - follower_jointstate + - right_follower_jointstate + - left_follower_jointstate env: - VIRTUAL_ENV: ../.venv - PYTHONPATH: /home/dora/RoboDriver + VIRTUAL_ENV: /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/.venv + UV_LINK_MODE: copy + PYTHONPATH: /home/dora/RoboDriver:/home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python DISPLAY: ":0" QT_QPA_PLATFORM: "xcb" - CAN_BUS: can0 + RIGHT_CAN_BUS: can0 + LEFT_CAN_BUS: can1 SCALE_FACTOR: "1.5" CONTROL_RATE_HZ: "50" URDF_PATH: /home/dora/teleop_pico/XRoboToolkit-Teleop-Sample-Python/assets/piper/piper.urdf diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py index ced75c7..9b12fd9 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/robodriver_teleoperator_pico_ultra4_dora/dora_node_piper.py @@ -1,9 +1,9 @@ """ -Pico Ultra4 遥操 Piper 的 Dora 节点 +Pico Ultra4 双臂遥操 Piper 的 Dora 节点 -将 PiperTeleopController 适配为 Dora 节点, -在 tick 事件驱动下执行一次 IK + 控制循环, -同时在 grip 激活时以 DoRobotDataset(LeRobot 兼容)格式保存数据。 +两个独立的 PiperTeleopController(右臂 can0 + 左臂 can1), +在 tick 事件驱动下同时执行 IK + 控制, +分别输出 right_follower_jointstate 和 left_follower_jointstate。 """ import os os.environ.setdefault("QT_QPA_PLATFORM", "xcb") @@ -17,13 +17,15 @@ from xrobotoolkit_teleop.hardware.piper_teleop_controller import ( PiperTeleopController, DEFAULT_PIPER_MANIPULATOR_CONFIG, + DEFAULT_PIPER_LEFT_MANIPULATOR_CONFIG, ) from xrobotoolkit_teleop.utils.path_utils import ASSET_PATH from robodriver.dataset.dorobot_dataset import DoRobotDataset, DoRobotDatasetMetadata URDF_PATH = os.getenv("URDF_PATH", os.path.join(ASSET_PATH, "piper/piper.urdf")) -CAN_PORT = os.getenv("CAN_BUS", "can0") +RIGHT_CAN_PORT = os.getenv("RIGHT_CAN_BUS", os.getenv("CAN_BUS", "can0")) +LEFT_CAN_PORT = os.getenv("LEFT_CAN_BUS", "can1") SCALE_FACTOR = float(os.getenv("SCALE_FACTOR", "1.5")) CONTROL_RATE_HZ = int(os.getenv("CONTROL_RATE_HZ", "50")) RECORD_DIR = os.getenv("RECORD_DIR", os.path.expanduser("~/recordings/pico_piper")) @@ -32,7 +34,7 @@ TASK = os.getenv("TASK", "teleoperation") USE_VIDEOS = os.getenv("USE_VIDEOS", "false").lower() == "true" -# Piper 7 维状态:joint1-6 + gripper +# 每臂 7 维:joint1-6 + gripper,双臂共 14 维 STATE_DIM = 7 IMAGE_HEIGHT = 480 IMAGE_WIDTH = 640 @@ -40,8 +42,13 @@ FEATURES = { "observation.state": { "dtype": "float32", - "shape": (STATE_DIM,), - "names": ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "gripper"], + "shape": (STATE_DIM * 2,), + "names": [ + "right_joint1", "right_joint2", "right_joint3", + "right_joint4", "right_joint5", "right_joint6", "right_gripper", + "left_joint1", "left_joint2", "left_joint3", + "left_joint4", "left_joint5", "left_joint6", "left_gripper", + ], }, "observation.images.camera_top": { "dtype": "video" if USE_VIDEOS else "image", @@ -55,14 +62,19 @@ }, "action": { "dtype": "float32", - "shape": (STATE_DIM,), - "names": ["joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "gripper"], + "shape": (STATE_DIM * 2,), + "names": [ + "right_joint1", "right_joint2", "right_joint3", + "right_joint4", "right_joint5", "right_joint6", "right_gripper", + "left_joint1", "left_joint2", "left_joint3", + "left_joint4", "left_joint5", "left_joint6", "left_gripper", + ], }, } def make_dataset() -> DoRobotDataset: - """创建或续接 DoRobotDataset,绕过 robot.microphones 依赖。""" + """创建或续接 DoRobotDataset。""" obj = DoRobotDataset.__new__(DoRobotDataset) meta = DoRobotDatasetMetadata.__new__(DoRobotDatasetMetadata) meta.repo_id = REPO_ID @@ -70,15 +82,13 @@ def make_dataset() -> DoRobotDataset: info_path = meta.root / "meta" / "info.json" if info_path.exists(): - # 续接已有数据集 meta.load_metadata() else: - # 全新数据集 meta = DoRobotDatasetMetadata.create( repo_id=REPO_ID, fps=RECORD_FPS, root=RECORD_DIR, - robot_type="piper", + robot_type="piper_dual", features=FEATURES, use_videos=USE_VIDEOS, use_audios=False, @@ -115,39 +125,76 @@ def decode_image(data: pa.Array, metadata: dict) -> np.ndarray | None: return None else: return None - # DoRobotDataset 期望 RGB return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) +def get_arm_state(ctrl: PiperTeleopController) -> np.ndarray: + """读取单臂 7D 状态(joint1-6 + gripper)。""" + q = ctrl.piper.get_joint_positions() + gripper = ctrl.piper.get_gripper_position() + return np.append(q, gripper).astype(np.float32) + + def main(): import threading node = Node() dataset_ref = [None] - controller_ref = [None] + right_ctrl_ref = [None] + left_ctrl_ref = [None] robot_ready = threading.Event() def setup(): - dataset_ref[0] = make_dataset() - ctrl = PiperTeleopController( - robot_urdf_path=URDF_PATH, - manipulator_config=DEFAULT_PIPER_MANIPULATOR_CONFIG, - can_port=CAN_PORT, - scale_factor=SCALE_FACTOR, - control_rate_hz=CONTROL_RATE_HZ, - enable_log_data=False, - enable_camera=False, - visualize_placo=False, - ) - ctrl._robot_setup() - controller_ref[0] = ctrl - robot_ready.set() + try: + dataset_ref[0] = make_dataset() + print(f"[SETUP] Dataset ready: {RECORD_DIR}") + + print(f"[SETUP] Init RIGHT arm on {RIGHT_CAN_PORT} ...") + right_ctrl = PiperTeleopController( + robot_urdf_path=URDF_PATH, + manipulator_config=DEFAULT_PIPER_MANIPULATOR_CONFIG, + can_port=RIGHT_CAN_PORT, + scale_factor=SCALE_FACTOR, + control_rate_hz=CONTROL_RATE_HZ, + enable_log_data=False, + enable_camera=False, + visualize_placo=False, + ) + right_ctrl._robot_setup() + right_ctrl_ref[0] = right_ctrl + print(f"[SETUP] RIGHT arm OK, active keys: {list(right_ctrl.active.keys())}") + + print(f"[SETUP] Init LEFT arm on {LEFT_CAN_PORT} ...") + left_ctrl = PiperTeleopController( + robot_urdf_path=URDF_PATH, + manipulator_config=DEFAULT_PIPER_LEFT_MANIPULATOR_CONFIG, + can_port=LEFT_CAN_PORT, + scale_factor=SCALE_FACTOR, + control_rate_hz=CONTROL_RATE_HZ, + enable_log_data=False, + enable_camera=False, + visualize_placo=False, + ) + left_ctrl._robot_setup() + left_ctrl_ref[0] = left_ctrl + print(f"[SETUP] LEFT arm OK, active keys: {list(left_ctrl.active.keys())}") + + print(f"[SETUP] right xr_client id={id(right_ctrl.xr_client)}, left xr_client id={id(left_ctrl.xr_client)}") + print(f"[SETUP] XrClient._initialized={right_ctrl.xr_client.__class__._initialized}") + + robot_ready.set() + print("[SETUP] Both arms ready, robot_ready set.") + except Exception as e: + import traceback + print(f"[ERROR] Robot setup failed: {e}") + traceback.print_exc() threading.Thread(target=setup, daemon=True).start() latest_images: dict[str, np.ndarray] = {} was_active = False + _dbg_tick = 0 for event in node: if event["type"] != "INPUT": @@ -157,26 +204,16 @@ def setup(): # 缓存相机图像 if eid in ("camera_top_image", "camera_wrist_image"): - meta = event["metadata"] - val = event["value"] cam_key = "camera_top" if eid == "camera_top_image" else "camera_wrist" - if cam_key == "camera_top": - try: - buf_dbg = val.to_numpy(zero_copy_only=False).astype(np.uint8) - print(f"[DBG] {cam_key}: len={len(buf_dbg)}, first4={buf_dbg[:4].tolist()}") - except Exception as e: - print(f"[DBG] {cam_key} to_numpy error: {e}") - img = decode_image(val, meta) + img = decode_image(event["value"], event["metadata"]) if img is not None: latest_images[cam_key] = img - else: - print(f"[DBG] {cam_key} decode failed: encoding={meta.get('encoding')}, len={len(val)}") continue if eid != "tick": continue - # robot 未就绪时只显示图像,跳过控制 + # robot 未就绪时只显示图像 if not robot_ready.is_set(): if latest_images: frames_to_show = [] @@ -196,33 +233,56 @@ def setup(): print(f"[imshow error] {e}") continue - # IK + 控制 - controller = controller_ref[0] + right_ctrl = right_ctrl_ref[0] + left_ctrl = left_ctrl_ref[0] dataset = dataset_ref[0] - controller._update_robot_state() - controller._update_gripper_target() - controller._update_ik() - controller._send_command() - - q = controller.piper.get_joint_positions() - gripper = controller.piper.get_gripper_position() - state = np.append(q, gripper).astype(np.float32) - - is_active = controller.active.get("right_arm", False) - # grip 激活时录制(有哪路图像用哪路,缺失的用上一帧或零填充) + # 右臂控制 + try: + right_ctrl._update_robot_state() + right_ctrl._update_gripper_target() + right_ctrl._update_ik() + right_ctrl._send_command() + except Exception as e: + print(f"[ERROR] RIGHT arm control failed: {e}") + + # 左臂控制 + try: + left_ctrl._update_robot_state() + left_ctrl._update_gripper_target() + left_ctrl._update_ik() + left_ctrl._send_command() + except Exception as e: + print(f"[ERROR] LEFT arm control failed: {e}") + + # 读取状态 + right_state = get_arm_state(right_ctrl) + left_state = get_arm_state(left_ctrl) + combined_state = np.concatenate([right_state, left_state]) + + right_active = right_ctrl.active.get("right_arm", False) + left_active = left_ctrl.active.get("left_arm", False) + is_active = right_active or left_active + + # DEBUG 每50帧打印一次 + _dbg_tick += 1 + if _dbg_tick % 50 == 0: + grip_r = right_ctrl.xr_client.get_key_value_by_name("right_grip") + grip_l = left_ctrl.xr_client.get_key_value_by_name("left_grip") + print(f"[DBG] tick={_dbg_tick} right_grip={grip_r:.3f} left_grip={grip_l:.3f} " + f"right_active={right_active} left_active={left_active}") + print(f"[DBG] right_active_keys={dict(right_ctrl.active)} left_active_keys={dict(left_ctrl.active)}") + print(f"[DBG] right_state={np.round(right_state, 3)} left_state={np.round(left_state, 3)}") + + # 录制 if is_active and latest_images: - top = latest_images.get( - "camera_top", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8) - ) - wrist = latest_images.get( - "camera_wrist", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8) - ) + top = latest_images.get("camera_top", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8)) + wrist = latest_images.get("camera_wrist", np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.uint8)) frame = { - "observation.state": state, + "observation.state": combined_state, "observation.images.camera_top": top, "observation.images.camera_wrist": wrist, - "action": state, + "action": combined_state, "task": TASK, } dataset.add_frame(frame) @@ -239,7 +299,7 @@ def setup(): was_active = is_active - # 实时显示相机画面 + # 实时显示 if latest_images: frames_to_show = [] for cam_key in ("camera_top", "camera_wrist"): @@ -263,11 +323,8 @@ def setup(): # 发布关节状态 metadata = event["metadata"] metadata["timestamp"] = time.time_ns() - node.send_output( - "follower_jointstate", - pa.array(state), - metadata, - ) + node.send_output("right_follower_jointstate", pa.array(right_state), metadata) + node.send_output("left_follower_jointstate", pa.array(left_state), metadata) cv2.destroyAllWindows() diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/home_positions.json b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/home_positions.json new file mode 100644 index 0000000..9760d51 --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/home_positions.json @@ -0,0 +1,24 @@ +{ + "right": { + "joints": [ + 0.04120722364899495, + -0.028187067426144366, + 0.03984586683212853, + -0.14243632028777967, + 0.3064972700127066, + 2.2519285211923856 + ], + "gripper": 0.46875 + }, + "left": { + "joints": [ + 0.0, + 0.0, + 0.021205750416573004, + 0.16371188387444838, + 0.3451912195382559, + 1.2747012194851106 + ], + "gripper": 0.008749999999999999 + } +} \ No newline at end of file diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/read_joints.py b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/read_joints.py new file mode 100644 index 0000000..055ddff --- /dev/null +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/scripts/read_joints.py @@ -0,0 +1,131 @@ +""" +读取双臂关节位置 +用法: + python read_joints.py # 持续刷新 + python read_joints.py --once # 只读一次 + python read_joints.py --set-home # 将当前位置保存为 home + python read_joints.py --go-home # 发送已保存的 home 位置 +""" +import sys +import json +import time +from pathlib import Path +import numpy as np +from piper_sdk import C_PiperInterface_V2 + +RIGHT_CAN = "can0" +LEFT_CAN = "can1" +ANGLE_FACTOR = 57295.7795 # SDK 单位 → 弧度 +HOME_FILE = Path(__file__).parent / "home_positions.json" + + +def read_joints(piper) -> np.ndarray: + j = piper.GetArmJointMsgs().joint_state + return np.array([ + j.joint_1, j.joint_2, j.joint_3, + j.joint_4, j.joint_5, j.joint_6, + ]) / ANGLE_FACTOR + + +def read_gripper(piper) -> float: + angle_deg = piper.GetArmGripperMsgs().gripper_state.grippers_angle / 1000.0 + return np.clip(abs(angle_deg) / 80.0, 0.0, 1.0) + + +def connect(can_port: str): + p = C_PiperInterface_V2(can_port) + p.ConnectPort() + return p + + +def print_state(label: str, joints: np.ndarray, gripper: float): + j_str = " ".join(f"{v:+.4f}" for v in joints) + print(f"[{label}] joints: {j_str} gripper: {gripper:.3f}") + + +def save_home(right_q: np.ndarray, right_g: float, left_q: np.ndarray, left_g: float): + data = { + "right": {"joints": right_q.tolist(), "gripper": right_g}, + "left": {"joints": left_q.tolist(), "gripper": left_g}, + } + HOME_FILE.write_text(json.dumps(data, indent=2)) + print(f"Home positions saved to {HOME_FILE}") + + +def load_home(): + if not HOME_FILE.exists(): + print(f"[ERROR] No home file found at {HOME_FILE}. Run --set-home first.") + sys.exit(1) + data = json.loads(HOME_FILE.read_text()) + right_q = np.array(data["right"]["joints"]) + right_g = data["right"]["gripper"] + left_q = np.array(data["left"]["joints"]) + left_g = data["left"]["gripper"] + return right_q, right_g, left_q, left_g + + +def send_joints(piper, joints: np.ndarray, speed_factor: int = 30): + piper.MotionCtrl_2(0x01, 0x01, speed_factor, 0x00) + vals = [round(v * ANGLE_FACTOR) for v in joints] + piper.JointCtrl(*vals) + + +def send_gripper(piper, position: float, speed: int = 1000): + pos_um = round(np.clip(position, 0.0, 1.0) * 80000) + piper.GripperCtrl(abs(pos_um), speed, 0x01, 0) + + +def main(): + once = "--once" in sys.argv + set_home = "--set-home" in sys.argv + go_home = "--go-home" in sys.argv + + print(f"Connecting to right arm ({RIGHT_CAN}) ...") + right = connect(RIGHT_CAN) + print(f"Connecting to left arm ({LEFT_CAN}) ...") + left = connect(LEFT_CAN) + print("Connected.\n") + + if set_home: + right_q = read_joints(right) + right_g = read_gripper(right) + left_q = read_joints(left) + left_g = read_gripper(left) + print_state("RIGHT", right_q, right_g) + print_state("LEFT ", left_q, left_g) + save_home(right_q, right_g, left_q, left_g) + return + + if go_home: + right_q, right_g, left_q, left_g = load_home() + print("Sending home positions:") + print_state("RIGHT", right_q, right_g) + print_state("LEFT ", left_q, left_g) + send_joints(right, right_q) + send_gripper(right, right_g) + send_joints(left, left_q) + send_gripper(left, left_g) + print("Done.") + return + + print("Reading joint positions...\n") + try: + while True: + right_q = read_joints(right) + right_g = read_gripper(right) + left_q = read_joints(left) + left_g = read_gripper(left) + + print_state("RIGHT", right_q, right_g) + print_state("LEFT ", left_q, left_g) + print() + + if once: + break + time.sleep(0.5) + except KeyboardInterrupt: + print("Stopped.") + + +if __name__ == "__main__": + main() From d69eab12653776900b5fc7a4c6af130ba5c18eb7 Mon Sep 17 00:00:00 2001 From: SunSunSun689 Date: Wed, 1 Apr 2026 16:21:01 +0800 Subject: [PATCH 4/5] =?UTF-8?q?pico-piper=E5=8F=8C=E8=87=82=E9=81=A5?= =?UTF-8?q?=E6=93=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../README.md | 98 ++++++++----------- 1 file changed, 39 insertions(+), 59 deletions(-) diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md index c226b9e..cecbe05 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md @@ -5,63 +5,26 @@ 本项目将 Pico Ultra4 VR 控制器的遥操作集成进 dora dataflow 框架,支持: - **Pico Ultra4** VR 控制器(通过 XRoboToolkit SDK) - **Piper** 机械臂(CAN 总线控制,含 IK 求解) -- **RealSense D405** 手腕相机 +- **RealSense D405** 手腕相机(使用的相机序列号,需要修改) - **Orbbec Gemini 335** 顶部相机 核心节点 `dora_node_piper.py` 将 `PiperTeleopController` 适配为 dora 事件驱动模式,在 50Hz tick 下执行 IK + 控制循环。 ---- - -## 快速启动 - -### 前置条件 -#### 激活can通信 -```bash -# 查找can对应的USB port -bash find_all_can_port.sh -激活can0 -bash can_activate.sh can0 1000000 USB-port # bash can_activate.sh can0 1000000 3-8.4.4:1.0 -``` - +## 步骤1:前置条件 +### 激活can通信 +- 安装piper的SDK [git@github.com:agilexrobotics/piper_sdk.git] +- XR Robotics中下载XRoboToolkit-PC-Service、XRoboToolkit-Teleop-Sample-Python,pico中安装xrobotoolkits[] ```bash -# 1. 启动 CAN 总线 -sudo ip link set can0 up type can bitrate 1000000 - -# 2. 确认 XRoboToolkit PC Service 正在运行(监听 127.0.0.1:60061) -ss -tlnp | grep 60061 -# 应看到 RoboticsService 进程 - -# 3. Pico 头显连接同一局域网,打开 XRoboToolkit App -``` - -### 启动遥操 - -```bash -cd RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora - -# 清理残留进程(重要!避免相机设备被占用) -pkill -f "dora run\|dora_node_piper\|dora_camera\|dora_arm_piper" 2>/dev/null; sleep 1 - -# 重置 Orbbec 相机 USB(重要!dora 被强杀后 Orbbec SDK 的 UVC 锁不会自动释放) -# 不加这步会报 uvc_open res-6 / Device or resource busy -sudo usbreset 2bc5:0800; sleep 1 +# Pico 遥操依赖(本地安装) +pip install XRoboToolkit-Teleop-Sample-Python/dependencies/XRoboToolkit-PC-Service-Pybind/ +pip install -e XRoboToolkit-Teleop-Sample-Python/ +pip install placo meshcat -# 激活虚拟环境并启动 -source .venv/bin/activate -dora run dora/dataflow.yml +# 验证 +python -c "import xrobotoolkit_sdk, xrobotoolkit_teleop, placo, meshcat; print('all ok')" ``` -### 操作说明 - -- 按住 **右手 grip 键** 激活控制(`right_arm is activated` 出现后生效) -- 移动手柄控制 Piper 末端位姿(IK 实时求解) -- 松开 grip 键暂停控制 -- **右手扳机键** 控制夹爪开合 -- `Ctrl+C` 停止 - ---- - -## 依赖安装(首次配置) +### 依赖安装(首次配置) XR Robotics中下载XRoboToolkit-PC-Service、XRoboToolkit-Teleop-Sample-Python,pico中安装xrobotoolkits ```bash @@ -76,7 +39,7 @@ python -c "import xrobotoolkit_sdk, xrobotoolkit_teleop, placo, meshcat; print(' --- -### 步骤 4:创建新的统一虚拟环境 +## 步骤2:创建新的统一虚拟环境 ```bash # 创建 Python 3.10 虚拟环境 @@ -86,7 +49,7 @@ uv venv .venv -p 3.10 source .venv/bin/activate ``` -### 步骤 5:安装依赖 +## 步骤3:安装依赖 ```bash # 安装基础依赖 @@ -101,7 +64,7 @@ uv pip install -e .[hardware] - `sb-pyorbbecsdk` (1.3.1) - Orbbec Gemini 335 相机驱动 - `piper-sdk` (0.6.1) - Piper 机械臂 SDK -### 步骤 6:验证安装 +### 步骤4:验证安装 ```bash # 验证 Python 可执行文件位置 @@ -122,32 +85,49 @@ Python: /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-p ``` -## 启动数据采集(仅支持单臂遥操) -## 1.激活piper机械臂 +## 启动数据采集(双臂遥操) +### 1.激活piper机械臂 ```bash -cd SDK/piper_sdk/piper_sdk -bash can_config.sh +# 查找can对应的USB port +bash find_all_can_port.sh +# 激活can0、can1 +bash can_activate.sh can0 1000000 USB-port # bash can_activate.sh can0 1000000 3-8.4.4:1.0 +bash can_activate.sh can1 1000000 USB-port # bash can_activate.sh can1 1000000 3-8.4.3:1.0 ``` - pc端打开XRobotoolkits-pc-server,pico中启动xrobotoolkits(安装后在资源库中,与pc端连接到一个网段上,Head,Controller、Hand、Send、Switch W/A Button,Trcking、Vision都选择上) -### 1. 激活虚拟环境 +### 2. 激活虚拟环境 ```bash cd /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora source .venv/bin/activate ``` -### 2. 启动 Dora 服务 +### 3. 启动 Dora 服务 ```bash dora up ``` -### 3. 启动数据流 +### 4. 启动数据流 ```bash dora start dora/dataflow.yml ``` +### 操作说明 +- Realsense相机绑定序列号,得根据硬件连接修改序列号。(Realsense的图像画面是通过sdk读取的,奥比中光的相机是通过uvc协议获取的,即/dev/video8.[V4L2 = Linux 下获取视频的编程接口/框架、UVC = USB 摄像头的通信协议标准] +- Pico 头显连接同一局域网,打开 XRoboToolkit App与xrobotoolkits +- 按住 **左右手 grip 键** 激活控制,移动手柄控制 Piper 末端位姿,松开 grip 键暂停控制,保存一组数据; +- **左右手扳机键** 控制夹爪开合 + +## 注意事项 +# 清理残留进程(重要!避免相机设备被占用) +pkill -f "dora run\|dora_node_piper\|dora_camera\|dora_arm_piper" 2>/dev/null; sleep 1 + +# 重置 Orbbec 相机 USB(重要!dora 被强杀后 Orbbec SDK 的 UVC 锁不会自动释放) +# 不加这步会报 uvc_open res-6 / Device or resource busy +sudo usbreset 2bc5:0800; sleep 1 + ## 相关文档 - [RoboDriver 主文档](../../README.md) From 18a96af2f74c0f40fb10bd7fecffa824a1d88296 Mon Sep 17 00:00:00 2001 From: SunSunSun689 Date: Thu, 2 Apr 2026 10:55:41 +0800 Subject: [PATCH 5/5] docs: add read_piper_joints.py usage to Home position setup section Co-Authored-By: Claude Sonnet 4.6 --- .../README.md | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md index cecbe05..e48e905 100644 --- a/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md +++ b/robodriver/teleoperators/robodriver-teleoperator-pico-ultra4-dora/README.md @@ -102,13 +102,39 @@ cd /home/dora/RoboDriver/robodriver/teleoperators/robodriver-teleoperator-pico-u source .venv/bin/activate ``` -### 3. 启动 Dora 服务 +### 3.设置机械臂的Home位置 + +使用 `scripts/read_joints.py` 脚本管理双臂的 Home 位置(右臂 `can0`,左臂 `can1`)。 + +```bash +# 持续刷新显示双臂关节状态(每 0.5s 一次,Ctrl+C 停止) +python scripts/read_joints.py + +# 只读一次就退出 +python scripts/read_joints.py --once + +# 将当前位置保存为 home(写入 scripts/home_positions.json) +python scripts/read_joints.py --set-home + +# 将双臂发送到已保存的 home 位置 +python scripts/read_joints.py --go-home +``` + +输出格式示例: +``` +[RIGHT] joints: +0.0123 -0.4567 +0.1234 -0.2345 +0.3456 -0.4567 gripper: 0.250 +[LEFT ] joints: +0.0456 -0.3210 +0.1111 -0.2222 +0.3333 -0.4444 gripper: 0.100 +``` + +**典型流程**:手动将双臂摆到合适的初始姿态 → `--set-home` 保存 → 每次启动前 `--go-home` 复位。 + +### 4. 启动 Dora 服务 ```bash dora up ``` -### 4. 启动数据流 +### 5. 启动数据流 ```bash dora start dora/dataflow.yml