car/arm_plane_calib/collect_plane_calib.py
2026-08-13 14:35:17 +08:00

185 lines
No EOL
6.4 KiB
Python

#!/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()