Initial commit

This commit is contained in:
1 2026-08-13 14:35:17 +08:00
commit a4e596c3aa
234 changed files with 81101 additions and 0 deletions

BIN
arm_plane_calib/biaodi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import yaml
import cv2
import numpy as np
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, CameraInfo
from cv_bridge import CvBridge
class PlaneCalibCollector(Node):
def __init__(self):
super().__init__('plane_calib_collector')
self.declare_parameter('color_topic', '/camera/color/image_raw')
self.declare_parameter('depth_topic', '/camera/depth/image_raw')
self.declare_parameter('camera_info_topic', '/camera/depth/camera_info')
self.declare_parameter('save_path', 'samples.yaml')
self.declare_parameter('depth_scale', 0.001) # 深度单位转米,常见 mm -> m
self.declare_parameter('min_depth_m', 0.05)
self.declare_parameter('max_depth_m', 1.50)
self.declare_parameter('median_kernel', 5)
self.declare_parameter('target_count', 9) # 采满多少个点自动退出
self.color_topic = self.get_parameter('color_topic').value
self.depth_topic = self.get_parameter('depth_topic').value
self.camera_info_topic = self.get_parameter('camera_info_topic').value
self.save_path = self.get_parameter('save_path').value
self.depth_scale = float(self.get_parameter('depth_scale').value)
self.min_depth_m = float(self.get_parameter('min_depth_m').value)
self.max_depth_m = float(self.get_parameter('max_depth_m').value)
self.median_kernel = int(self.get_parameter('median_kernel').value)
self.target_count = int(self.get_parameter('target_count').value)
self.bridge = CvBridge()
self.color_img = None
self.depth_img = None
self.K = None
self.samples = []
self.should_exit = False
self.create_subscription(Image, self.color_topic, self.on_color, 10)
self.create_subscription(Image, self.depth_topic, self.on_depth, 10)
self.create_subscription(CameraInfo, self.camera_info_topic, self.on_info, 10)
self.window_name = 'plane_calib_click'
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.setMouseCallback(self.window_name, self.on_mouse)
self.get_logger().info('左键点击标定点,终端输入机械臂命令向量,例如: 500,620,410,530')
self.get_logger().info(f'每记录 1 个样本自动保存,采满 {self.target_count} 个样本自动退出')
self.get_logger().info('按 q 可手动退出')
def on_color(self, msg: Image):
self.color_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
def on_depth(self, msg: Image):
if msg.encoding in ('16UC1', 'mono16'):
self.depth_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding='passthrough')
else:
self.get_logger().warn(f'不支持的深度编码: {msg.encoding}')
def on_info(self, msg: CameraInfo):
self.K = np.array(msg.k, dtype=np.float64).reshape(3, 3)
def get_xyz_from_pixel(self, u: int, v: int):
if self.depth_img is None or self.K is None:
return None
h, w = self.depth_img.shape[:2]
if not (0 <= u < w and 0 <= v < h):
return None
k = self.median_kernel
r = k // 2
u0, u1 = max(0, u - r), min(w, u + r + 1)
v0, v1 = max(0, v - r), min(h, v + r + 1)
patch = self.depth_img[v0:v1, u0:u1].astype(np.float32)
valid = patch[patch > 0]
if valid.size == 0:
return None
z = np.median(valid) * self.depth_scale
if z < self.min_depth_m or z > self.max_depth_m:
return None
fx = self.K[0, 0]
fy = self.K[1, 1]
cx = self.K[0, 2]
cy = self.K[1, 2]
x = (u - cx) * z / fx
y = (v - cy) * z / fy
return float(x), float(y), float(z)
def save_samples(self):
data = {
'samples': self.samples,
'cmd_dim': len(self.samples[0]['arm_cmd']) if self.samples else 0
}
with open(self.save_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
print(f'[SAVE] 已保存到 {self.save_path}')
def on_mouse(self, event, x, y, flags, param):
if event != cv2.EVENT_LBUTTONDOWN:
return
xyz = self.get_xyz_from_pixel(x, y)
if xyz is None:
print(f'[WARN] 点击点 ({x}, {y}) 无有效深度')
return
X, Y, Z = xyz
print(f'\n点击像素: ({x}, {y})')
print(f'相机坐标: X={X:.4f} m, Y={Y:.4f} m, Z={Z:.4f} m')
print('请手动把机械臂末端移到这个点正上方,然后输入命令向量,例如 500,620,410,530')
cmd_str = input('arm_cmd> ').strip()
if not cmd_str:
print('[SKIP] 未输入,跳过')
return
try:
arm_cmd = [float(s) for s in cmd_str.split(',')]
except Exception:
print('[ERR] 输入格式错误,应类似 500,620,410,530')
return
item = {
'u': int(x),
'v': int(y),
'x_cam': X,
'y_cam': Y,
'z_cam': Z,
'arm_cmd': arm_cmd
}
self.samples.append(item)
print(f'[OK] 已记录第 {len(self.samples)} 个样本')
# 自动保存
self.save_samples()
# 采满自动退出
if len(self.samples) >= self.target_count:
print(f'[DONE] 已采满 {self.target_count} 个样本,自动保存并退出')
self.should_exit = True
def spin_loop(self):
while rclpy.ok() and not self.should_exit:
rclpy.spin_once(self, timeout_sec=0.03)
if self.color_img is not None:
show = self.color_img.copy()
for i, s in enumerate(self.samples):
cv2.circle(show, (s['u'], s['v']), 4, (0, 255, 0), -1)
cv2.putText(
show, str(i + 1), (s['u'] + 6, s['v'] - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1
)
cv2.imshow(self.window_name, show)
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
print('[EXIT] 用户退出')
break
cv2.destroyAllWindows()
def main():
rclpy.init()
node = PlaneCalibCollector()
try:
node.spin_loop()
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,344 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
import yaml
import cv2
import numpy as np
import serial
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, CameraInfo
from cv_bridge import CvBridge
class LeArmSerial:
def __init__(self, port="/dev/ttyUSB0", baudrate=9600):
self.port = port
self.baudrate = baudrate
self.ser = None
def open(self):
self.ser = serial.Serial()
self.ser.port = self.port
self.ser.baudrate = self.baudrate
self.ser.bytesize = serial.EIGHTBITS
self.ser.parity = serial.PARITY_NONE
self.ser.stopbits = serial.STOPBITS_ONE
self.ser.timeout = 0.1
self.ser.xonxoff = False
self.ser.rtscts = False
self.ser.dsrdtr = False
self.ser.dtr = False
self.ser.rts = False
self.ser.open()
print(f"[INFO] 串口已打开: {self.port} @ {self.baudrate}")
print("[INFO] 等待控制板稳定...")
time.sleep(3.0)
self.ser.reset_input_buffer()
self.ser.reset_output_buffer()
def close(self):
if self.ser and self.ser.is_open:
self.ser.close()
print("[INFO] 串口已关闭")
@staticmethod
def build_frame(cmd: int, params: bytes = b"") -> bytes:
# 协议: 55 55 Length CMD Params...
# Length = CMD(1) + Params长度
length = 1 + len(params)
return bytes([0x55, 0x55, length, cmd]) + params
def move_servos(self, servo_pairs, move_time_ms=800):
"""
servo_pairs: [(servo_id, pulse), ...]
CMD=3 多舵机控制
"""
if not servo_pairs:
print("[WARN] 空舵机列表")
return
params = bytearray()
params.append(len(servo_pairs))
params.append(move_time_ms & 0xFF)
params.append((move_time_ms >> 8) & 0xFF)
for sid, pulse in servo_pairs:
sid = int(sid)
pulse = int(pulse)
params.append(sid & 0xFF)
params.append(pulse & 0xFF)
params.append((pulse >> 8) & 0xFF)
frame = self.build_frame(0x03, bytes(params))
print("TX:", frame.hex(" "))
self.ser.write(frame)
self.ser.flush()
class DepthClickToLeArm(Node):
def __init__(self):
super().__init__("depth_click_to_learm_cmd")
# 相机话题
self.declare_parameter("depth_topic", "/camera/depth/image_raw")
self.declare_parameter("camera_info_topic", "/camera/depth/camera_info")
self.declare_parameter("color_topic", "/camera/color/image_raw")
# 模型
self.declare_parameter("model_path", "model.yaml")
# 深度参数
self.declare_parameter("depth_scale", 0.001) # 16UC1 常见 mm->m
self.declare_parameter("min_depth_m", 0.05)
self.declare_parameter("max_depth_m", 1.20)
self.declare_parameter("median_kernel", 5)
# 串口
self.declare_parameter("serial_port", "/dev/ttyUSB0")
self.declare_parameter("baud_rate", 9600)
self.declare_parameter("move_time_ms", 800)
# 视图
self.declare_parameter("show_color_view", True)
self.declare_parameter("draw_text", True)
# 命令向量 -> 舵机ID映射
# 这里的顺序必须和 model.yaml 输出的命令向量顺序一致
# 默认给一组常见顺序,你后面按自己机械臂实际改
self.declare_parameter("servo_ids", [6, 5, 4, 3])
# 每个舵机范围,按你给出的范围设置
self.declare_parameter("servo_min", [125, 125, 125, 125])
self.declare_parameter("servo_max", [875, 875, 875, 875])
# 如果想把夹爪加入抓取动作,可在点击后自己再加固定动作
self.declare_parameter("auto_send", True)
self.depth_topic = self.get_parameter("depth_topic").value
self.camera_info_topic = self.get_parameter("camera_info_topic").value
self.color_topic = self.get_parameter("color_topic").value
self.model_path = self.get_parameter("model_path").value
self.depth_scale = float(self.get_parameter("depth_scale").value)
self.min_depth_m = float(self.get_parameter("min_depth_m").value)
self.max_depth_m = float(self.get_parameter("max_depth_m").value)
self.median_kernel = int(self.get_parameter("median_kernel").value)
self.serial_port = self.get_parameter("serial_port").value
self.baud_rate = int(self.get_parameter("baud_rate").value)
self.move_time_ms = int(self.get_parameter("move_time_ms").value)
self.show_color_view = bool(self.get_parameter("show_color_view").value)
self.draw_text = bool(self.get_parameter("draw_text").value)
self.servo_ids = list(self.get_parameter("servo_ids").value)
self.servo_min = list(self.get_parameter("servo_min").value)
self.servo_max = list(self.get_parameter("servo_max").value)
self.auto_send = bool(self.get_parameter("auto_send").value)
# 读取模型
with open(self.model_path, "r", encoding="utf-8") as f:
model = yaml.safe_load(f)
self.W = np.array(model["weights"], dtype=np.float64) # [6, cmd_dim]
self.cmd_dim = int(model["cmd_dim"])
if self.cmd_dim != len(self.servo_ids):
raise RuntimeError(
f"model cmd_dim={self.cmd_dim} 与 servo_ids 数量={len(self.servo_ids)} 不一致"
)
self.bridge = CvBridge()
self.depth_img = None
self.color_img = None
self.K = None
self.last_click = None
self.window_name = "depth_click_to_learm_cmd"
self.arm = LeArmSerial(self.serial_port, self.baud_rate)
self.arm.open()
self.create_subscription(Image, self.depth_topic, self.on_depth, 10)
self.create_subscription(CameraInfo, self.camera_info_topic, self.on_info, 10)
self.create_subscription(Image, self.color_topic, self.on_color, 10)
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.setMouseCallback(self.window_name, self.on_mouse)
self.get_logger().info("节点已启动,左键点击目标点即可预测并发送 CMD=3。")
def destroy_node(self):
try:
self.arm.close()
except Exception:
pass
super().destroy_node()
def on_depth(self, msg: Image):
self.depth_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding="passthrough")
def on_color(self, msg: Image):
try:
self.color_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding="bgr8")
except Exception:
self.color_img = None
def on_info(self, msg: CameraInfo):
self.K = np.array(msg.k, dtype=np.float64).reshape(3, 3)
@staticmethod
def features(x, y):
return np.array([1.0, x, y, x * x, x * y, y * y], dtype=np.float64)
def pixel_to_xyz(self, u, v):
if self.depth_img is None or self.K is None:
return None
h, w = self.depth_img.shape[:2]
if not (0 <= u < w and 0 <= v < h):
return None
r = self.median_kernel // 2
u0, u1 = max(0, u - r), min(w, u + r + 1)
v0, v1 = max(0, v - r), min(h, v + r + 1)
patch = self.depth_img[v0:v1, u0:u1].astype(np.float32)
valid = patch[patch > 0]
if valid.size == 0:
return None
z = float(np.median(valid) * self.depth_scale)
if z < self.min_depth_m or z > self.max_depth_m:
return None
fx = self.K[0, 0]
fy = self.K[1, 1]
cx = self.K[0, 2]
cy = self.K[1, 2]
x = float((u - cx) * z / fx)
y = float((v - cy) * z / fy)
return x, y, z
def predict_cmd(self, x_cam, y_cam):
feat = self.features(x_cam, y_cam) # [6]
cmd = feat @ self.W # [cmd_dim]
return cmd
def clamp_cmd(self, cmd):
out = []
for i, v in enumerate(cmd):
lo = float(self.servo_min[i])
hi = float(self.servo_max[i])
out.append(int(round(max(lo, min(hi, v)))))
return out
def send_cmd_vector(self, cmd_vec):
servo_pairs = list(zip(self.servo_ids, cmd_vec))
self.arm.move_servos(servo_pairs, move_time_ms=self.move_time_ms)
def on_mouse(self, event, x, y, flags, param):
if event != cv2.EVENT_LBUTTONDOWN:
return
xyz = self.pixel_to_xyz(x, y)
if xyz is None:
print(f"[WARN] 点击点 ({x}, {y}) 无有效深度")
return
x_cam, y_cam, z_cam = xyz
pred = self.predict_cmd(x_cam, y_cam)
pred_clamped = self.clamp_cmd(pred)
self.last_click = {
"u": int(x),
"v": int(y),
"x_cam": x_cam,
"y_cam": y_cam,
"z_cam": z_cam,
"pred_raw": pred.tolist(),
"pred_cmd": pred_clamped,
}
print("\n========== 点击目标点 ==========")
print(f"像素: ({x}, {y})")
print(f"相机坐标: X={x_cam:.4f} m, Y={y_cam:.4f} m, Z={z_cam:.4f} m")
print("预测原始命令向量:", ", ".join([f"{v:.2f}" for v in pred.tolist()]))
print("限幅后命令向量:", pred_clamped)
print("舵机映射:", list(zip(self.servo_ids, pred_clamped)))
if self.auto_send:
self.send_cmd_vector(pred_clamped)
print("[OK] 已直接发送 CMD=3")
def make_depth_vis(self):
if self.depth_img is None:
return None
depth = self.depth_img.astype(np.float32) * self.depth_scale
depth = np.clip(depth, self.min_depth_m, self.max_depth_m)
norm = (depth - self.min_depth_m) / max(1e-6, (self.max_depth_m - self.min_depth_m))
norm = (norm * 255.0).astype(np.uint8)
vis = cv2.applyColorMap(255 - norm, cv2.COLORMAP_JET)
return vis
def spin_loop(self):
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.03)
show = None
if self.show_color_view and self.color_img is not None:
show = self.color_img.copy()
else:
vis = self.make_depth_vis()
if vis is not None:
show = vis
if show is not None and self.last_click is not None:
u = self.last_click["u"]
v = self.last_click["v"]
cv2.circle(show, (u, v), 6, (0, 255, 0), -1)
if self.draw_text:
txt1 = f"({u},{v})"
txt2 = f"X={self.last_click['x_cam']:.3f} Y={self.last_click['y_cam']:.3f} Z={self.last_click['z_cam']:.3f}"
txt3 = "CMD=" + ",".join([str(v) for v in self.last_click["pred_cmd"]])
cv2.putText(show, txt1, (u + 8, v - 24), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 1)
cv2.putText(show, txt2, (u + 8, v - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.50, (0, 255, 0), 1)
cv2.putText(show, txt3, (u + 8, v + 10), cv2.FONT_HERSHEY_SIMPLEX, 0.50, (0, 255, 0), 1)
cv2.imshow(self.window_name, show)
elif show is not None:
cv2.imshow(self.window_name, show)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
break
elif key == ord("s") and self.last_click is not None:
# 手动再发送一次
self.send_cmd_vector(self.last_click["pred_cmd"])
print("[OK] 手动再次发送 CMD=3")
elif key == ord("c"):
self.last_click = None
cv2.destroyAllWindows()
def main():
rclpy.init()
node = DepthClickToLeArm()
try:
node.spin_loop()
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
import yaml
import numpy as np
import sys
def features(x, y):
return np.array([1.0, x, y, x * x, x * y, y * y], dtype=np.float64)
def main():
sample_path = sys.argv[1] if len(sys.argv) > 1 else 'samples.yaml'
model_path = sys.argv[2] if len(sys.argv) > 2 else 'model.yaml'
with open(sample_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
samples = data['samples']
if len(samples) < 6:
print('样本太少,至少需要 6 个,建议 9~16 个')
return
X = []
Y = []
for s in samples:
X.append(features(s['x_cam'], s['y_cam']))
Y.append(s['arm_cmd'])
X = np.array(X, dtype=np.float64) # [N, 6]
Y = np.array(Y, dtype=np.float64) # [N, cmd_dim]
# 最小二乘
W, _, _, _ = np.linalg.lstsq(X, Y, rcond=None) # [6, cmd_dim]
Y_pred = X @ W
err = Y_pred - Y
mae = np.mean(np.abs(err), axis=0)
rmse = np.sqrt(np.mean(err ** 2, axis=0))
model = {
'feature_order': ['1', 'x', 'y', 'x2', 'xy', 'y2'],
'weights': W.tolist(),
'cmd_dim': int(Y.shape[1]),
'sample_count': int(len(samples)),
'mae_per_dim': mae.tolist(),
'rmse_per_dim': rmse.tolist(),
'z_ref_median': float(np.median([s['z_cam'] for s in samples]))
}
with open(model_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(model, f, allow_unicode=True, sort_keys=False)
print(f'模型已保存到: {model_path}')
print('每维 MAE:', mae)
print('每维 RMSE:', rmse)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
import serial
class LeArmSerial:
def __init__(self, port="/dev/ttyUSB0", baudrate=9600):
self.port = port
self.baudrate = baudrate
self.ser = None
def open(self):
self.ser = serial.Serial()
self.ser.port = self.port
self.ser.baudrate = self.baudrate
self.ser.bytesize = serial.EIGHTBITS
self.ser.parity = serial.PARITY_NONE
self.ser.stopbits = serial.STOPBITS_ONE
self.ser.timeout = 0.1
self.ser.xonxoff = False
self.ser.rtscts = False
self.ser.dsrdtr = False
self.ser.dtr = False
self.ser.rts = False
self.ser.open()
print(f"[INFO] 串口已打开: {self.port} @ {self.baudrate}")
print("[INFO] 等待控制板稳定...")
time.sleep(3.0)
self.ser.reset_input_buffer()
self.ser.reset_output_buffer()
def close(self):
if self.ser and self.ser.is_open:
self.ser.close()
print("[INFO] 串口已关闭")
@staticmethod
def build_frame(cmd: int, params: bytes = b"") -> bytes:
length = 2 + len(params)
return bytes([0x55, 0x55, length, cmd]) + params
def send_frame(self, cmd: int, params: bytes = b""):
frame = self.build_frame(cmd, params)
print("TX:", frame.hex(" "))
self.ser.write(frame)
self.ser.flush()
def read_frames(self, wait_s: float = 1.0):
deadline = time.time() + wait_s
buf = bytearray()
while time.time() < deadline:
n = self.ser.in_waiting
if n > 0:
buf.extend(self.ser.read(n))
time.sleep(0.01)
print("RAW RX:", bytes(buf).hex(" "))
return self.split_frames(bytes(buf))
@staticmethod
def split_frames(buf: bytes):
frames = []
i = 0
while i <= len(buf) - 3:
if buf[i] == 0x55 and buf[i + 1] == 0x55:
length = buf[i + 2]
frame_len = 2 + length
if i + frame_len <= len(buf):
frames.append(buf[i:i + frame_len])
i += frame_len
continue
i += 1
return frames
@staticmethod
def parse_fw_query(frame: bytes):
if len(frame) != 6 or frame[:4] != b"\x55\x55\x04\x01":
return None
servo_type = frame[4]
fw_ver = frame[5]
return {
"servo_type": servo_type,
"servo_type_text": {1: "PWM舵机", 2: "总线舵机"}.get(servo_type, f"未知({servo_type})"),
"firmware_version": fw_ver,
}
@staticmethod
def parse_servo_readback(frame: bytes):
if len(frame) < 6 or frame[0] != 0x55 or frame[1] != 0x55 or frame[3] != 0x0D:
return None
payload = frame[4:]
items = []
if len(payload) % 3 != 0:
usable = len(payload) - (len(payload) % 3)
payload = payload[:usable]
for i in range(0, len(payload), 3):
sid = payload[i]
pulse = payload[i + 1] | (payload[i + 2] << 8)
items.append({"id": sid, "pulse": pulse})
return items
def query_firmware(self):
self.ser.reset_input_buffer()
self.send_frame(0x01)
frames = self.read_frames(wait_s=1.0)
ok = False
for f in frames:
print("FRAME:", f.hex(" "))
info = self.parse_fw_query(f)
if info:
ok = True
print("[OK] 固件查询成功")
print("舵机类型:", info["servo_type_text"])
print("固件版本号:", info["firmware_version"])
if not ok:
print("[WARN] 未解析到有效固件查询返回")
def read_servo_angles(self):
self.ser.reset_input_buffer()
self.send_frame(0x0D)
frames = self.read_frames(wait_s=1.0)
ok = False
for f in frames:
print("FRAME:", f.hex(" "))
items = self.parse_servo_readback(f)
if items is not None:
ok = True
print("[OK] 舵机角度回读成功")
for item in items:
print(f" 舵机ID={item['id']}, 脉宽={item['pulse']}")
if not ok:
print("[WARN] 未解析到有效舵机角度回读返回")
def set_init_pose(self):
self.ser.reset_input_buffer()
self.send_frame(0x0C)
time.sleep(1.5)
print("[OK] 已发送初始位姿设置命令")
def move_servos(self, servo_pairs, move_time_ms=800):
"""
servo_pairs: [(servo_id, pulse), ...]
协议:
长度 = 舵机个数*3 + 5
params:
个数(1B), time_low(1B), time_high(1B),
[id, pulse_low, pulse_high] * N
"""
if not servo_pairs:
print("[WARN] 空舵机列表")
return
params = bytearray()
params.append(len(servo_pairs))
params.append(move_time_ms & 0xFF)
params.append((move_time_ms >> 8) & 0xFF)
for sid, pulse in servo_pairs:
pulse = int(pulse)
params.append(int(sid) & 0xFF)
params.append(pulse & 0xFF)
params.append((pulse >> 8) & 0xFF)
self.ser.reset_input_buffer()
self.send_frame(0x03, bytes(params))
time.sleep(max(move_time_ms / 1000.0, 0.5))
print("[OK] 已发送多舵机控制命令")
def interactive(self):
menu = """
================ LeArm 串口测试 =================
1. 固件查询
2. 舵机角度回读
3. 初始位姿设置
4. 控制单个舵机
5. 控制多个舵机
q. 退出
舵机范围
(1夹爪,200-700;2腕部,125-875;)
(3号,125-875;4号,125-875;)
(5号,125-875;6旋转,125-875;)
=================================================
"""
while True:
print(menu)
choice = input("请选择: ").strip().lower()
if choice == "1":
self.query_firmware()
elif choice == "2":
self.read_servo_angles()
elif choice == "3":
self.set_init_pose()
elif choice == "4":
sid = int(input("舵机ID: ").strip())
pulse = int(input("目标位置(例如 500): ").strip())
t = int(input("运行时间ms(例如 800): ").strip() or "800")
self.move_servos([(sid, pulse)], move_time_ms=t)
elif choice == "5":
print("输入格式示例: 1:500,2:600,3:450")
raw = input("舵机列表: ").strip()
t = int(input("运行时间ms(例如 800): ").strip() or "800")
pairs = []
for item in raw.split(","):
sid_s, pulse_s = item.split(":")
pairs.append((int(sid_s), int(pulse_s)))
self.move_servos(pairs, move_time_ms=t)
elif choice == "q":
break
else:
print("[WARN] 无效选项")
def main():
arm = LeArmSerial(port="/dev/ttyUSB0", baudrate=9600)
try:
arm.open()
arm.interactive()
finally:
arm.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,45 @@
feature_order:
- '1'
- x
- y
- x2
- xy
- y2
weights:
- - 511.28515454089643
- 821.2615901131778
- 265.21874233385535
- 398.5549181928104
- - -771.8329814607213
- 52.76710377195036
- 237.64443893556836
- -114.8132656337159
- - 362.52663422740244
- -580.4727764870705
- -26.751203474622418
- -1877.4142059960259
- - 680.0875101829623
- 7153.497267194028
- 5925.331927860114
- 1758.558407685675
- - -4993.565252238856
- 4809.674284044076
- 17834.265848385203
- -12695.665144123212
- - 5570.783049089005
- 3393.015647299803
- 30196.06155860567
- -33269.65533447665
cmd_dim: 4
sample_count: 9
mae_per_dim:
- 17.308270436659697
- 9.353204865258602
- 23.274954237277775
- 26.493102071291403
rmse_per_dim:
- 20.835859382625955
- 12.3444244473184
- 28.89964968573881
- 35.21007477027654
z_ref_median: 0.34400000000000003

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
import yaml
import cv2
import numpy as np
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, CameraInfo
from cv_bridge import CvBridge
def features(x, y):
return np.array([1.0, x, y, x * x, x * y, y * y], dtype=np.float64)
class PlaneCmdPredictor(Node):
def __init__(self):
super().__init__('plane_cmd_predictor')
self.declare_parameter('color_topic', '/camera/color/image_raw')
self.declare_parameter('depth_topic', '/camera/depth/image_raw')
self.declare_parameter('camera_info_topic', '/camera/depth/camera_info')
self.declare_parameter('model_path', 'model.yaml')
self.declare_parameter('depth_scale', 0.001)
self.color_topic = self.get_parameter('color_topic').value
self.depth_topic = self.get_parameter('depth_topic').value
self.camera_info_topic = self.get_parameter('camera_info_topic').value
self.model_path = self.get_parameter('model_path').value
self.depth_scale = float(self.get_parameter('depth_scale').value)
with open(self.model_path, 'r', encoding='utf-8') as f:
model = yaml.safe_load(f)
self.W = np.array(model['weights'], dtype=np.float64) # [6, cmd_dim]
self.bridge = CvBridge()
self.color_img = None
self.depth_img = None
self.K = None
# 你的模型输出顺序固定为:6号、5号、4号、3号
self.model_servo_order = [6, 5, 4, 3]
# 你想打印成:3号、4号、5号、6号
self.print_servo_order = [3, 4, 5, 6]
# 当前只处理 3/4/5/6 号舵机,范围都一致
self.servo_min = 125
self.servo_max = 875
self.create_subscription(Image, self.color_topic, self.on_color, 10)
self.create_subscription(Image, self.depth_topic, self.on_depth, 10)
self.create_subscription(CameraInfo, self.camera_info_topic, self.on_info, 10)
self.window_name = 'predict_plane_cmd'
cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL)
cv2.setMouseCallback(self.window_name, self.on_mouse)
def on_color(self, msg):
self.color_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
def on_depth(self, msg):
self.depth_img = self.bridge.imgmsg_to_cv2(msg, desired_encoding='passthrough')
def on_info(self, msg):
self.K = np.array(msg.k, dtype=np.float64).reshape(3, 3)
def pixel_to_xyz(self, u, v):
if self.depth_img is None or self.K is None:
return None
h, w = self.depth_img.shape[:2]
if not (0 <= u < w and 0 <= v < h):
return None
patch = self.depth_img[max(0, v - 2):min(h, v + 3), max(0, u - 2):min(w, u + 3)].astype(np.float32)
valid = patch[patch > 0]
if valid.size == 0:
return None
z = np.median(valid) * self.depth_scale
fx = self.K[0, 0]
fy = self.K[1, 1]
cx = self.K[0, 2]
cy = self.K[1, 2]
x = (u - cx) * z / fx
y = (v - cy) * z / fy
return float(x), float(y), float(z)
def clamp_servo(self, value):
value = int(round(value))
if value < self.servo_min:
value = self.servo_min
if value > self.servo_max:
value = self.servo_max
return value
def on_mouse(self, event, x, y, flags, param):
if event != cv2.EVENT_LBUTTONDOWN:
return
xyz = self.pixel_to_xyz(x, y)
if xyz is None:
print('[WARN] 无有效深度')
return
X, Y, Z = xyz
# 模型输出顺序:6,5,4,3
cmd_raw = features(X, Y) @ self.W
cmd_raw = cmd_raw.tolist()
# 先取整并限幅
cmd_int = [self.clamp_servo(v) for v in cmd_raw]
# 转成 {舵机ID: 值}
servo_map = {}
for sid, val in zip(self.model_servo_order, cmd_int):
servo_map[sid] = val
# 按你希望的顺序打印:3,4,5,6
ordered_pairs = [(sid, servo_map[sid]) for sid in self.print_servo_order]
print(f'\n点击像素: ({x}, {y})')
print(f'相机坐标: X={X:.4f}, Y={Y:.4f}, Z={Z:.4f}')
print('预测机械臂命令向量(按模型顺序 6,5,4,3):',
', '.join([str(servo_map[sid]) for sid in self.model_servo_order]))
print('舵机列表:', ','.join([f'{sid}:{val}' for sid, val in ordered_pairs]))
def spin_loop(self):
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.03)
if self.color_img is not None:
cv2.imshow(self.window_name, self.color_img)
if (cv2.waitKey(1) & 0xFF) == ord('q'):
break
cv2.destroyAllWindows()
def main():
rclpy.init()
node = PlaneCmdPredictor()
try:
node.spin_loop()
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

310
arm_plane_calib/readme.md Normal file
View file

@ -0,0 +1,310 @@
# arm_plane_calib 使用说明
## 1. 当前机器的实际设备映射
根据现场枚举结果,当前机器建议按下面的映射理解和使用:
### 1.1 串口设备
- **机械臂控制口**:`/dev/ttyUSB1`
- **机械臂默认波特率**:`9600`
现场串口枚举:
```bash
root@ubuntu:~# ls /dev/ttyUSB*
/dev/ttyUSB0 /dev/ttyUSB1
```
说明:
- `ttyUSB0` 当前用于底盘
- `ttyUSB1` 当前用于机械臂
### 1.2 深度相机
- **深度相机型号**:Orbbec Gemini 2
- **系统节点**:`/dev/video1`
但本工程里的深度数据 **不是直接按普通 UVC 摄像头去读 `/dev/video1`**,而是通过 `orbbec_camera` 驱动发布 ROS 2 话题:
- `/camera/color/image_raw`
- `/camera/depth/image_raw`
- `/camera/depth/camera_info`
所以在做标定前,需要先把 Gemini 2 的 ROS 话题跑起来。
---
## 2. 目录说明
```bash
~/arm_plane_calib
├── biaodi.png # 标定示意图
├── collect_plane_calib.py # 采样脚本
├── depth_click_to_learm_cmd_node.py # 点击深度图 -> 预测舵机值 -> 可选自动发送
├── fit_plane_calib.py # 拟合脚本
├── learm_test.py # 机械臂串口测试脚本
├── model.yaml # 拟合后的模型参数
├── predict_plane_cmd.py # 点击图像预测舵机命令
├── readme.md # 原始说明
└── samples.yaml # 采样数据
```
---
## 3. 平面标定的目的
平面标定的核心目的是:
> 当你在相机画面中点击一个桌面目标点时,系统能够根据深度相机测得的三维信息,预测出一组机械臂舵机控制值,使机械臂末端运动到该目标点上方。
当前这套标定使用的是:
- 输入特征:`x_cam`, `y_cam`
- 拟合模型:二次多项式
- 特征顺序:`[1, x, y, x², x*y, y²]`
注意:当前拟合 **不直接使用 `z_cam` 做建模**,但会记录 `z_cam`,并在 `model.yaml` 中给出 `z_ref_median` 作为参考。
---
## 4. 标定前准备
### 4.1 先启动 Gemini 2
```bash
cd ~/dev_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 launch origintank_bringup orbbec_gemini2.launch.py
```
### 4.2 再进入标定目录
```bash
cd ~/arm_plane_calib
```
---
## 5. 脚本说明
### 5.1 `collect_plane_calib.py` —— 采样脚本
作用:
- 订阅:
- `/camera/color/image_raw`
- `/camera/depth/image_raw`
- `/camera/depth/camera_info`
- 鼠标点击图像中的标定点
- 自动计算该点的相机坐标 `X_cam, Y_cam, Z_cam`
- 终端手动输入对应的机械臂命令向量
- 自动保存到 `samples.yaml`
- 采满设定数量后自动退出
默认参数要点:
- `save_path = samples.yaml`
- `depth_scale = 0.001`
- `min_depth_m = 0.05`
- `max_depth_m = 1.50`
- `median_kernel = 5`
- `target_count = 9`
运行:
```bash
cd ~/arm_plane_calib
python3 collect_plane_calib.py
```
采样时终端输入示例:
```bash
520,610,430,500
```
这 4 维命令向量的具体含义,需要与你的模型输出顺序保持一致。
---
### 5.2 `fit_plane_calib.py` —— 拟合脚本
作用:
- 读取 `samples.yaml`
- 构造二次多项式特征
- 对机械臂命令向量做最小二乘拟合
- 输出 `model.yaml`
- 同时打印每一维的 `MAE` 和 `RMSE`
运行:
```bash
cd ~/arm_plane_calib
python3 fit_plane_calib.py samples.yaml model.yaml
```
要求:
- 至少需要 `6` 个样本点才能拟合
- 实际建议 `9~16` 个点
- 点位尽量铺满工作区,不要只集中在中间
---
### 5.3 `predict_plane_cmd.py` —— 点击图像预测舵机值
作用:
- 运行时点击图像任意位置
- 自动求取该像素点对应的相机三维坐标
- 调用 `model.yaml` 预测机械臂命令向量
- 在终端输出预测结果
运行:
```bash
cd ~/arm_plane_calib
python3 predict_plane_cmd.py
```
当前脚本中的关键约定:
- 模型输出顺序固定为:**6、5、4、3**
- 终端打印时会整理为:**3、4、5、6**
- 当前默认舵机限幅范围:`125 ~ 875`
示例输出:
```text
点击像素: (694, 230)
相机坐标: X=0.0267, Y=-0.0681, Z=0.3600
预测机械臂命令向量(按模型顺序 6,5,4,3): 501, 874, 385, 393
舵机列表: 3:393,4:385,5:874,6:501
```
---
### 5.4 `depth_click_to_learm_cmd_node.py` —— 点击深度图后可直接发送控制
作用:
- 订阅深度图、彩色图和相机内参
- 点击图像后自动预测机械臂命令向量
- 可按参数决定是否自动通过串口发送给机械臂
关键默认参数:
- `serial_port = /dev/ttyUSB0`
- `baud_rate = 9600`
- `move_time_ms = 800`
- `servo_ids = [6, 5, 4, 3]`
- `servo_min = [125, 125, 125, 125]`
- `servo_max = [875, 875, 875, 875]`
- `auto_send = True`
**注意:这个脚本源码默认串口仍然写的是 `/dev/ttyUSB0`,与你当前机器的机械臂实际口 `/dev/ttyUSB1` 不一致。**
因此当前机器使用时,建议显式传参:
```bash
cd ~/arm_plane_calib
python3 depth_click_to_learm_cmd_node.py --ros-args -p serial_port:=/dev/ttyUSB1 -p baud_rate:=9600
```
---
### 5.5 `learm_test.py` —— 机械臂串口测试脚本
作用:
- 测试串口是否能连上机械臂
- 查询固件
- 回读舵机位置
- 发初始位姿命令
- 控制单个或多个舵机
当前脚本 `main()` 里默认写的是:
- `port = /dev/ttyUSB0`
- `baudrate = 9600`
但你当前机器的机械臂实际连接为 **`/dev/ttyUSB1`**,因此使用前建议先把源码最后一行改成:
```python
arm = LeArmSerial(port="/dev/ttyUSB1", baudrate=9600)
```
再运行:
```bash
cd ~/arm_plane_calib
python3 learm_test.py
```
---
## 6. 九点标定推荐流程
### 6.1 布点建议
建议用 3×3 共 9 个点,尽量铺满机械臂实际抓取区域:
```text
1 2 3
4 5 6
7 8 9
```
### 6.2 启动 Gemini 2
```bash
cd ~/dev_ws
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 launch origintank_bringup orbbec_gemini2.launch.py
```
### 6.3 运行采样脚本
```bash
cd ~/arm_plane_calib
python3 collect_plane_calib.py
```
### 6.4 对每个点重复以下动作
1. 鼠标点击图像中的桌面标定点
2. 手动把机械臂末端移动到该点正上方
3. 在终端输入这一点的机械臂命令向量
4. 脚本自动保存到 `samples.yaml`
例如输入:
```bash
520,610,430,500
```
说明:
- 当前标定默认以 **4 维命令向量** 为例
- 夹爪开合通常不参与平面标定
- 抓取时再单独附加夹爪动作即可
### 6.5 完成后拟合模型
```bash
cd ~/arm_plane_calib
python3 fit_plane_calib.py samples.yaml model.yaml
```
### 6.6 预测测试
```bash
cd ~/arm_plane_calib
python3 predict_plane_cmd.py
```

View file

@ -0,0 +1,92 @@
samples:
- u: 582
v: 240
x_cam: -0.03145534404311617
y_cam: -0.0625876597787623
z_cam: 0.358
arm_cmd:
- 540.0
- 885.0
- 421.0
- 375.0
- u: 681
v: 233
x_cam: 0.01995364206116322
y_cam: -0.06658371695309058
z_cam: 0.36
arm_cmd:
- 505.0
- 884.0
- 369.0
- 401.0
- u: 771
v: 243
x_cam: 0.06592038079659751
y_cam: -0.060522141786992736
z_cam: 0.355
arm_cmd:
- 467.0
- 872.0
- 363.0
- 422.0
- u: 566
v: 316
x_cam: -0.03830266258671575
y_cam: -0.022374943079671503
z_cam: 0.34500000000000003
arm_cmd:
- 514.0
- 847.0
- 284.0
- 419.0
- u: 681
v: 323
x_cam: 0.019066813525111522
y_cam: -0.018825745192638882
z_cam: 0.34400000000000003
arm_cmd:
- 468.0
- 815.0
- 332.0
- 363.0
- u: 761
v: 329
x_cam: 0.05855634668063165
y_cam: -0.015747077324389527
z_cam: 0.342
arm_cmd:
- 513.0
- 879.0
- 242.0
- 498.0
- u: 566
v: 412
x_cam: -0.03674835164116786
y_cam: 0.024512461886783923
z_cam: 0.331
arm_cmd:
- 566.0
- 816.0
- 274.0
- 347.0
- u: 681
v: 419
x_cam: 0.018179984989059823
y_cam: 0.027612575073894863
z_cam: 0.328
arm_cmd:
- 518.0
- 816.0
- 274.0
- 347.0
- u: 780
v: 419
x_cam: 0.06517939133605848
y_cam: 0.027612575073894863
z_cam: 0.328
arm_cmd:
- 450.0
- 843.0
- 384.0
- 265.0
cmd_dim: 4