Add LinkerHand Isaac Sim bridge and G20 assets.

Includes G20 PLAN/cmd_u8 bridge, joint mapping, dual-hand sim scripts,
and G20/L20/O6 URDF with USD payloads for Isaac Sim integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
sunxianghui
2026-07-27 14:13:00 +08:00
commit 0b727785c6
134 changed files with 17662 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""LinkerHand O6 / G20 简易关节控制 UI。
向 Isaac Sim 桥接话题发布 sensor_msgs/JointState(position 为 SDK 0~255)。
依赖系统 ROS2 + tkinter(不要用 Isaac 的 python.sh 启动)。
用法:
source /opt/ros/jazzy/setup.bash
python3 linkerhand/hand_control_ui.py
# 或
bash linkerhand/run_ui.sh
"""
from __future__ import annotations
import threading
import time
import tkinter as tk
from tkinter import ttk
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
# ---------------------------------------------------------------------------
# 手型配置(与仿真话题 / SDK 一致)
# ---------------------------------------------------------------------------
HANDS = {
"O6": {
"cmd": "/o6/cb_left_hand_control_cmd",
"state": "/o6/cb_left_hand_state",
"labels": [
"大拇指弯曲",
"大拇指横摆",
"食指弯曲",
"中指弯曲",
"无名指弯曲",
"小拇指弯曲",
],
"init": [250, 250, 250, 250, 250, 250],
"presets": {
"张开": [250, 250, 250, 250, 250, 250],
"握拳": [102, 18, 0, 0, 0, 0],
"点赞": [250, 79, 0, 0, 0, 0],
"OK": [139, 91, 103, 250, 250, 250],
},
},
"G20": {
"cmd": "/cb_left_hand_control_cmd",
"state": "/cb_left_hand_state",
"labels": [
"拇指根部",
"食指根部",
"中指根部",
"无名指根部",
"小指根部",
"拇指侧摆",
"食指侧摆",
"中指侧摆",
"无名指侧摆",
"小指侧摆",
"拇指横摆",
"预留11",
"预留12",
"预留13",
"预留14",
"拇指尖部",
"食指末端",
"中指末端",
"无名指末端",
"小指末端",
],
"init": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"presets": {
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
},
# 预留电机不可控
"disabled": {11, 12, 13, 14},
},
}
PUBLISH_HZ = 20.0
class HandControlNode(Node):
def __init__(self) -> None:
super().__init__("linker_hand_control_ui")
self._pubs = {
name: self.create_publisher(JointState, cfg["cmd"], 10) for name, cfg in HANDS.items()
}
self._latest_cmd: dict[str, list[float]] = {
name: list(cfg["init"]) for name, cfg in HANDS.items()
}
self._latest_state: dict[str, list[float]] = {
name: list(cfg["init"]) for name, cfg in HANDS.items()
}
for name, cfg in HANDS.items():
self.create_subscription(
JointState,
cfg["state"],
lambda msg, n=name: self._on_state(n, msg),
10,
)
self.create_timer(1.0 / PUBLISH_HZ, self._publish_all)
def _on_state(self, hand: str, msg: JointState) -> None:
if not msg.position:
return
n = len(self._latest_state[hand])
self._latest_state[hand] = [float(v) for v in msg.position[:n]]
if len(msg.position) < n:
self._latest_state[hand].extend([0.0] * (n - len(msg.position)))
def set_cmd(self, hand: str, values: list[float]) -> None:
n = len(self._latest_cmd[hand])
self._latest_cmd[hand] = [float(max(0, min(255, v))) for v in values[:n]]
def set_joint(self, hand: str, index: int, value: float) -> None:
if 0 <= index < len(self._latest_cmd[hand]):
self._latest_cmd[hand][index] = float(max(0, min(255, value)))
def get_state(self, hand: str) -> list[float]:
return list(self._latest_state[hand])
def _publish_all(self) -> None:
now = self.get_clock().now().to_msg()
for name, pub in self._pubs.items():
msg = JointState()
msg.header.stamp = now
msg.name = [f"motor_{i}" for i in range(len(self._latest_cmd[name]))]
msg.position = list(self._latest_cmd[name])
pub.publish(msg)
class HandTab(ttk.Frame):
def __init__(self, master: tk.Misc, hand: str, cfg: dict, node: HandControlNode) -> None:
super().__init__(master)
self.hand = hand
self.cfg = cfg
self.node = node
self.vars: list[tk.IntVar] = []
self.value_labels: list[ttk.Label] = []
self.state_labels: list[ttk.Label] = []
self._building = True
top = ttk.Frame(self)
top.pack(fill=tk.X, padx=8, pady=6)
ttk.Label(top, text=f"话题: {cfg['cmd']}", font=("Sans", 9)).pack(side=tk.LEFT)
self.status = ttk.Label(top, text="状态: —", foreground="#555")
self.status.pack(side=tk.RIGHT)
preset_row = ttk.Frame(self)
preset_row.pack(fill=tk.X, padx=8, pady=4)
ttk.Label(preset_row, text="预设:").pack(side=tk.LEFT)
for name, pose in cfg.get("presets", {}).items():
ttk.Button(preset_row, text=name, command=lambda p=pose: self.apply_preset(p)).pack(
side=tk.LEFT, padx=3
)
canvas = tk.Canvas(self, borderwidth=0, highlightthickness=0)
scroll = ttk.Scrollbar(self, orient=tk.VERTICAL, command=canvas.yview)
inner = ttk.Frame(canvas)
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0), pady=4)
scroll.pack(side=tk.RIGHT, fill=tk.Y, padx=(0, 8), pady=4)
disabled = cfg.get("disabled", set())
header = ttk.Frame(inner)
header.pack(fill=tk.X, pady=(0, 4))
ttk.Label(header, text="关节", width=14).pack(side=tk.LEFT)
ttk.Label(header, text="指令", width=6).pack(side=tk.LEFT)
ttk.Label(header, text="反馈", width=6).pack(side=tk.LEFT)
for i, label in enumerate(cfg["labels"]):
row = ttk.Frame(inner)
row.pack(fill=tk.X, pady=1)
ttk.Label(row, text=f"{i:02d} {label}", width=14).pack(side=tk.LEFT)
var = tk.IntVar(value=int(cfg["init"][i]))
self.vars.append(var)
val_lbl = ttk.Label(row, text=f"{var.get():3d}", width=4)
val_lbl.pack(side=tk.LEFT)
self.value_labels.append(val_lbl)
state_lbl = ttk.Label(row, text="—", width=4, foreground="#06c")
state_lbl.pack(side=tk.LEFT)
self.state_labels.append(state_lbl)
scale = ttk.Scale(
row,
from_=0,
to=255,
orient=tk.HORIZONTAL,
variable=var,
command=lambda _v, idx=i: self._on_slide(idx),
)
scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
if i in disabled:
scale.state(["disabled"])
val_lbl.configure(foreground="#999")
self._building = False
self.after(200, self._refresh_state)
def _on_slide(self, index: int) -> None:
if self._building:
return
value = int(self.vars[index].get())
self.value_labels[index].configure(text=f"{value:3d}")
self.node.set_joint(self.hand, index, value)
def apply_preset(self, pose: list[int]) -> None:
self._building = True
for i, v in enumerate(pose):
if i >= len(self.vars):
break
if i in self.cfg.get("disabled", set()):
continue
self.vars[i].set(int(v))
self.value_labels[i].configure(text=f"{int(v):3d}")
self._building = False
self.node.set_cmd(self.hand, [float(v.get()) for v in self.vars])
def _refresh_state(self) -> None:
state = self.node.get_state(self.hand)
for i, lbl in enumerate(self.state_labels):
if i < len(state):
lbl.configure(text=f"{int(round(state[i])):3d}")
self.status.configure(text=f"状态: 已收 {len(state)} DOF @ {PUBLISH_HZ:.0f}Hz")
self.after(200, self._refresh_state)
class App(tk.Tk):
def __init__(self, node: HandControlNode) -> None:
super().__init__()
self.node = node
self.title("LinkerHand 关节控制 (默认 G20)")
self.geometry("720x780")
self.minsize(560, 480)
nb = ttk.Notebook(self)
nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
# 默认只展示 G20;需要 O6 时可改 HANDS 顺序或加回 O6
for hand in ("G20",):
tab = HandTab(nb, hand, HANDS[hand], node)
nb.add(tab, text=hand)
tip = ttk.Label(
self,
text="先启动仿真: bash linkerhand/run_sim.sh\n"
"(默认位置驱动:稳,拇指可达;可见轻微穿模属正常)\n"
"拖动滑条即发布;0≈弯曲,255≈张开",
justify=tk.LEFT,
foreground="#444",
)
tip.pack(fill=tk.X, padx=10, pady=(0, 8))
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _on_close(self) -> None:
self.destroy()
def main() -> None:
rclpy.init()
node = HandControlNode()
spin_thread = threading.Thread(target=lambda: rclpy.spin(node), daemon=True)
spin_thread.start()
app = App(node)
try:
app.mainloop()
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()