feat(craic_localization): add localization, teach-in, and navigation nodes

Four ROS2 nodes:
- chassis_odometry: reads chassis MCU serial (32B packet), publishes /odom + TF odom->base_footprint
- teach_points: records map-frame waypoints from AMCL TF, saves to YAML with interactive CLI
- show_points: publishes visualization_msgs/MarkerArray to /taught_points for RViz display of waypoints with ARROW+TEXT markers
- navigate_to_point: holonomic P-controller that reads YAML waypoints and drives Mecanum chassis via UDP XYW commands, with /goto topic interface

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-19 06:30:37 +08:00
parent a546aff1a6
commit 8ecbfd759e
4 changed files with 871 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""底盘轮式里程计节点 (chassis_odometry)
直连底盘 MCU (lzucar 板) 串口 `/dev/ttylzucar`**只读**解析 32 字节里程计数据包,
发布 nav_msgs/Odometry (/odom) 与 TF (odom -> base_footprint)。
数据包协议(移植自 lzu_robot/src/cgbot/cgbot/seriallzucar.py已上真机验证的同款底盘
- 帧尾标志 3 字节: 'L', 'Z', 'U'
- 整包 32 字节,以 'LZU' 结尾对齐
- packet[0] = 校验和 = sum(packet[1:30]) % 256
- packet[1:29] = <fffffff> = x, y, yaw, posFR, posFL, posBL, posBR
(x/y 单位 mm需 /1000 转米; yaw 单位弧度)
- packet[29] = 模式字节, 'D' 表示航位推算有效
- packet[29:32] = 帧尾 'L','Z','U'(对齐用)
本节点不发送任何命令;既有底盘速度控制 (UDP->ESP32->UART) 不受影响,互不抢占串口。
若后续需把底盘命令也迁到直连串口,应在此节点内合并发送逻辑(参考 seriallzucar 的 pack_floats_send
"""
import math
import struct
import time
from threading import Lock, Thread
import rclpy
import serial
from geometry_msgs.msg import Quaternion, TransformStamped
from nav_msgs.msg import Odometry
from rclpy.node import Node
from tf2_ros import TransformBroadcaster
PACKET_SIZE = 32
MODE_DEAD_RECKON = ord('D')
def yaw_to_quaternion(yaw: float) -> Quaternion:
"""绕 Z 轴偏航角 -> 四元数。"""
q = Quaternion()
q.x = 0.0
q.y = 0.0
q.z = math.sin(yaw / 2.0)
q.w = math.cos(yaw / 2.0)
return q
class ChassisOdometry(Node):
def __init__(self) -> None:
super().__init__('chassis_odometry')
# ---- 参数 ----
self.port = self.declare_parameter('port', '/dev/ttylzucar').value
self.baudrate = int(self.declare_parameter('baudrate', 115200).value)
self.odom_frame_id = self.declare_parameter('odom_frame_id', 'odom').value
self.base_frame_id = self.declare_parameter('base_frame_id', 'base_footprint').value
self.odom_topic = self.declare_parameter('odom_topic', 'odom').value
self.publish_tf = bool(self.declare_parameter('publish_tf', True).value)
# 仅当模式字节为 'D'(航位推算有效) 时才发布;置 False 可在调试期放行所有包
self.require_mode_d = bool(self.declare_parameter('require_mode_d', True).value)
# 串口打开后下发的使能命令,让底盘板持续上报里程计;空字符串=不发。
# lzu_robot/seriallzucar.open() 与 move_try/serial_bridge 都在打开后发 "UP0LZU"
# 缺它则底盘上电后只发几帧就停odom TF 变陈旧、gmapping 丢弃所有 scan。
self.enable_cmd = str(self.declare_parameter('enable_cmd', 'UP0LZU').value)
# >0 时按该周期(秒)重发使能命令做保活0=仅打开时发一次(与参考实现一致)
self.enable_cmd_period = float(self.declare_parameter('enable_cmd_period', 0.0).value)
# ---- 发布器 ----
self.odom_pub = self.create_publisher(Odometry, self.odom_topic, 10)
self.tf_broadcaster = TransformBroadcaster(self) if self.publish_tf else None
# ---- 串口接收环形缓冲 ----
self.BUFFER_SIZE = 1024
self.buffer = bytearray(self.BUFFER_SIZE)
self.write_index = 0
self.buffer_lock = Lock()
# ---- 上一帧状态(用于差分求速度)----
self.prev_x = None
self.prev_y = None
self.prev_yaw = None
self.prev_t = None
# ---- 串口 + 接收线程 ----
self.ser = None
self._running = True
self._reader = Thread(target=self._receiver_thread, daemon=True)
self._reader.start()
# 可选:周期性重发使能命令做保活
if self.enable_cmd and self.enable_cmd_period > 0.0:
self.create_timer(self.enable_cmd_period, self._send_enable_cmd)
self.get_logger().info(
f'chassis_odometry 启动: port={self.port}@{self.baudrate} '
f'odom_frame={self.odom_frame_id} base_frame={self.base_frame_id} '
f'publish_tf={self.publish_tf}'
)
# ------------------------------------------------------------------ #
# 串口接收 / 重连
# ------------------------------------------------------------------ #
def _open_serial(self) -> bool:
try:
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
self.get_logger().info(f'串口已连接 {self.port}')
self._send_enable_cmd()
return True
except Exception as exc: # noqa: BLE001
self.get_logger().warning(f'串口打开失败 {self.port}: {exc}')
self.ser = None
return False
def _send_enable_cmd(self) -> None:
"""下发里程计使能命令(默认 UP0LZU);空命令或串口未开则跳过。"""
if not self.enable_cmd or self.ser is None or not self.ser.is_open:
return
try:
self.ser.write(self.enable_cmd.encode('utf-8'))
self.ser.flush()
self.get_logger().info(f'已下发里程计使能命令: {self.enable_cmd}')
except Exception as exc: # noqa: BLE001
self.get_logger().warning(f'使能命令发送失败: {exc}')
def _receiver_thread(self) -> None:
while self._running:
try:
if self.ser is None or not self.ser.is_open:
if not self._open_serial():
time.sleep(1.0)
continue
waiting = self.ser.in_waiting
if waiting > 0:
self._write_to_buffer(self.ser.read(waiting))
else:
time.sleep(0.005)
except serial.SerialException as exc:
self.get_logger().warning(f'串口中断,准备重连: {exc}')
self._close_serial()
time.sleep(1.0)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'接收线程异常: {exc}')
self._close_serial()
time.sleep(1.0)
def _close_serial(self) -> None:
if self.ser is not None:
try:
self.ser.close()
except Exception: # noqa: BLE001
pass
self.ser = None
# ------------------------------------------------------------------ #
# 帧解析(逐字节移植自 seriallzucar保持已验证行为
# ------------------------------------------------------------------ #
def _read_byte(self, index: int) -> int:
return self.buffer[(index + self.BUFFER_SIZE) % self.BUFFER_SIZE]
def _read_from_buffer(self, start_index: int, size: int) -> bytearray:
data = bytearray()
for i in range(size):
data.append(self.buffer[(start_index + i) % self.BUFFER_SIZE])
return data
def _write_to_buffer(self, data: bytes) -> None:
with self.buffer_lock:
for byte in data:
self.buffer[self.write_index] = byte
# 检测帧尾 'L','Z','U'
if (byte == ord('U')
and self._read_byte(self.write_index - 1) == ord('Z')
and self._read_byte(self.write_index - 2) == ord('L')):
start_index = (self.write_index - PACKET_SIZE + self.BUFFER_SIZE) % self.BUFFER_SIZE
packet = self._read_from_buffer(start_index, PACKET_SIZE)
self._process_packet(packet)
self.write_index = (self.write_index + 1) % self.BUFFER_SIZE
def _process_packet(self, packet: bytearray) -> None:
# 校验和
if (sum(packet[1:30]) % 256) != packet[0]:
return
try:
x, y, yaw, _fr, _fl, _bl, _br = struct.unpack('<fffffff', packet[1:29])
mode = packet[29]
except struct.error:
return
if self.require_mode_d and mode != MODE_DEAD_RECKON:
return
x /= 1000.0 # mm -> m
y /= 1000.0
self._publish(x, y, yaw)
# ------------------------------------------------------------------ #
# 发布 Odometry + TF
# ------------------------------------------------------------------ #
def _publish(self, x: float, y: float, yaw: float) -> None:
now = self.get_clock().now()
stamp = now.to_msg()
quat = yaw_to_quaternion(yaw)
# 差分求速度body 系);首帧或 dt 过小时置零
vx = vy = wz = 0.0
t = now.nanoseconds * 1e-9
if self.prev_t is not None:
dt = t - self.prev_t
if dt > 1e-3:
dx = x - self.prev_x
dy = y - self.prev_y
dyaw = math.atan2(math.sin(yaw - self.prev_yaw),
math.cos(yaw - self.prev_yaw))
# 世界系位移旋到 body 系
vx = (dx * math.cos(yaw) + dy * math.sin(yaw)) / dt
vy = (-dx * math.sin(yaw) + dy * math.cos(yaw)) / dt
wz = dyaw / dt
self.prev_x, self.prev_y, self.prev_yaw, self.prev_t = x, y, yaw, t
odom = Odometry()
odom.header.stamp = stamp
odom.header.frame_id = self.odom_frame_id
odom.child_frame_id = self.base_frame_id
odom.pose.pose.position.x = x
odom.pose.pose.position.y = y
odom.pose.pose.position.z = 0.0
odom.pose.pose.orientation = quat
odom.twist.twist.linear.x = vx
odom.twist.twist.linear.y = vy
odom.twist.twist.angular.z = wz
# 平面机器人协方差: x,y,yaw 给小值, z/roll/pitch 不可观给大值
odom.pose.covariance[0] = 0.01 # x
odom.pose.covariance[7] = 0.01 # y
odom.pose.covariance[14] = 1e6 # z
odom.pose.covariance[21] = 1e6 # roll
odom.pose.covariance[28] = 1e6 # pitch
odom.pose.covariance[35] = 0.02 # yaw
odom.twist.covariance[0] = 0.01
odom.twist.covariance[7] = 0.01
odom.twist.covariance[35] = 0.02
self.odom_pub.publish(odom)
if self.tf_broadcaster is not None:
tf = TransformStamped()
tf.header.stamp = stamp
tf.header.frame_id = self.odom_frame_id
tf.child_frame_id = self.base_frame_id
tf.transform.translation.x = x
tf.transform.translation.y = y
tf.transform.translation.z = 0.0
tf.transform.rotation = quat
self.tf_broadcaster.sendTransform(tf)
def destroy_node(self) -> bool:
self._running = False
self._close_serial()
return super().destroy_node()
def main(args=None) -> None:
rclpy.init(args=args)
node = ChassisOdometry()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""导航程序 (navigate_to_point):自动驱动麦轮底盘到示教记录的点位。
- 目标点:读取 teach_points 生成的 taught_points.yamlmap 系 x,y,yaw
- 反馈TF map->base_footprint= AMCL 实时定位),需 localization.launch.py 在运行。
- 控制holonomic P 控制器,把 map 系位姿误差投影到机体系,生成 (前进, 侧移, 转向)
速度,按 udp_teleop 的 XYW 约定经 UDP→ESP32 下发,到达容差内停车。
速度符号默认按 udp_teleop/keyboard_control 推导:
前进 W→chassis_y 取负, 左移 A→chassis_x 取负, 左转(CCW) Q→chassis_w 取负
=> sign_x = sign_y = sign_w = -1。若实车方向相反翻转对应 sign 参数即可。
用法:
# 交互选点
ros2 run craic_localization navigate_to_point --ros-args \
-p points_file:=$PWD/src/craic_localization/config/taught_points.yaml
# 一次性去某点
ros2 run craic_localization navigate_to_point --ros-args -p points_file:=... -p goal:=B_1
# 运行中用话题指定(供上层任务程序调用)
ros2 topic pub -1 /goto std_msgs/String "{data: 'C_1'}"
# 干跑(只打印命令不发,安全验证逻辑/符号)
ros2 run craic_localization navigate_to_point --ros-args -p points_file:=... -p dry_run:=true
安全:首次务必先 dry_run再低速单点测试手放 Ctrl+C退出会自动发停车
"""
import math
import socket
import threading
import rclpy
import yaml
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.time import Time
from std_msgs.msg import String
from tf2_ros import (
Buffer,
ConnectivityException,
ExtrapolationException,
LookupException,
TransformListener,
)
def quat_to_yaw(qx, qy, qz, qw):
return math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz))
def norm_angle(a):
return math.atan2(math.sin(a), math.cos(a))
def clamp(v, lo, hi):
return max(lo, min(hi, v))
class NavigateToPoint(Node):
def __init__(self):
super().__init__('navigate_to_point')
p = self.declare_parameter
self.points_file = p('points_file', 'taught_points.yaml').value
self.udp_ip = p('udp_ip', '192.168.4.1').value
self.udp_port = int(p('udp_port', 8888).value)
self.map_frame = p('map_frame', 'map').value
self.base_frame = p('base_frame', 'base_footprint').value
self.control_rate = float(p('control_rate', 20.0).value)
self.max_linear = float(p('max_linear', 60.0).value) # 麦轮线速度命令上限(同 keyboard ~100)
self.max_angular = float(p('max_angular', 30.0).value) # 角速度命令上限(同 keyboard ~45)
self.kp_linear = float(p('kp_linear', 150.0).value) # m -> 命令单位
self.kp_angular = float(p('kp_angular', 40.0).value) # rad -> 命令单位
self.pos_tol = float(p('pos_tolerance', 0.05).value) # 到位容差(米)
self.yaw_tol = float(p('yaw_tolerance', 0.05).value) # 到位容差(弧度)
self.sign_x = float(p('sign_x', -1.0).value) # 侧移符号(机体左为正)
self.sign_y = float(p('sign_y', -1.0).value) # 前进符号(机体前为正)
self.sign_w = float(p('sign_w', -1.0).value) # 转向符号(CCW 为正)
self.goal_timeout = float(p('goal_timeout', 30.0).value)
self.dry_run = bool(p('dry_run', False).value)
goal_arg = str(p('goal', '').value)
self.points = self._load_points()
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._lock = threading.Lock()
self.goal_name = None
self.goal = None # (x, y, yaw)
self.goal_deadline = None
self.one_shot = bool(goal_arg)
self.done = False # one_shot 完成标志
self.create_subscription(String, 'goto', self._goto_cb, 10)
self.timer = self.create_timer(1.0 / self.control_rate, self._control_tick)
self.get_logger().info(
f'navigate_to_point 就绪 (UDP {self.udp_ip}:{self.udp_port}, dry_run={self.dry_run})')
if goal_arg:
self.set_goal(goal_arg)
# -------- 点位 / 目标 -------- #
def _load_points(self):
try:
with open(self.points_file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
pts = dict(data.get('points', {}))
self.get_logger().info(f'载入 {len(pts)} 个点: {", ".join(pts) or "(空)"}')
return pts
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'读取点位文件失败 {self.points_file}: {exc}')
return {}
def set_goal(self, name):
pt = self.points.get(name)
if pt is None:
self.get_logger().warn(f'未知点位: {name}(可用: {", ".join(self.points)}')
return False
with self._lock:
self.goal_name = name
self.goal = (float(pt['x']), float(pt['y']), float(pt.get('yaw', 0.0)))
self.goal_deadline = self.get_clock().now() + Duration(seconds=self.goal_timeout)
self.get_logger().info(
f'前往 {name}: x={self.goal[0]:.3f} y={self.goal[1]:.3f} yaw={self.goal[2]:.3f}')
return True
def _goto_cb(self, msg):
self.set_goal(msg.data.strip())
# -------- 反馈 / 下发 -------- #
def _lookup(self):
try:
t = self.tf_buffer.lookup_transform(self.map_frame, self.base_frame, Time())
except (LookupException, ConnectivityException, ExtrapolationException):
return None
tr = t.transform.translation
q = t.transform.rotation
return (tr.x, tr.y, quat_to_yaw(q.x, q.y, q.z, q.w))
def _send(self, cx, cy, cw):
cmd = f'XYW:{int(round(cx))}:{int(round(cy))}:{int(round(cw))}:XZHY\n'.encode()
if self.dry_run:
self.get_logger().info(f'[dry] {cmd.decode().strip()}')
else:
self.sock.sendto(cmd, (self.udp_ip, self.udp_port))
def _stop(self):
self._send(0, 0, 0)
# -------- 控制环 -------- #
def _control_tick(self):
with self._lock:
goal, deadline, name = self.goal, self.goal_deadline, self.goal_name
if goal is None:
return
if self.get_clock().now() > deadline:
self.get_logger().warn(f'{name} 超时未到达,停车')
self._finish()
return
pose = self._lookup()
if pose is None:
self._stop()
self.get_logger().warn('无定位 (map->base_footprint),停车等待…',
throttle_duration_sec=2.0)
return
px, py, pyaw = pose
tx, ty, tyaw = goal
dx, dy = tx - px, ty - py
dist = math.hypot(dx, dy)
dyaw = norm_angle(tyaw - pyaw)
if dist < self.pos_tol and abs(dyaw) < self.yaw_tol:
self.get_logger().info(
f'到达 {name}(位置误差 {dist * 100:.1f}cm朝向误差 {math.degrees(dyaw):.1f}°)')
self._finish()
return
# map 系误差投影到机体系x 前, y 左, REP-103
e_fwd = dx * math.cos(pyaw) + dy * math.sin(pyaw)
e_left = -dx * math.sin(pyaw) + dy * math.cos(pyaw)
v_fwd = self.kp_linear * e_fwd
v_left = self.kp_linear * e_left
# 限制平移合速度(防止 x+y 叠加超限)
speed = math.hypot(v_fwd, v_left)
if speed > self.max_linear:
s = self.max_linear / speed
v_fwd *= s
v_left *= s
# 已到位置容差内则只修朝向
if dist < self.pos_tol:
v_fwd = v_left = 0.0
w = clamp(self.kp_angular * dyaw, -self.max_angular, self.max_angular)
self.get_logger().info(
f'{name}: 距离 {dist * 100:.1f}cm, 朝向差 {math.degrees(dyaw):.1f}°',
throttle_duration_sec=0.5)
# 机体速度 -> XYW侧移=chassis_x, 前进=chassis_y, 转向=chassis_w
self._send(self.sign_x * v_left, self.sign_y * v_fwd, self.sign_w * w)
def _finish(self):
self._stop()
with self._lock:
self.goal = self.goal_name = self.goal_deadline = None
if self.one_shot:
self.done = True
def destroy_node(self):
try:
self._stop()
except Exception: # noqa: BLE001
pass
return super().destroy_node()
def interactive(node: NavigateToPoint, stop_flag: threading.Event):
while not stop_flag.is_set() and rclpy.ok():
try:
line = input(f'去哪个点? ({", ".join(node.points) or ""} | q 退出) > ').strip()
except EOFError:
break
low = line.lower()
if low in ('q', 'quit', 'exit'):
stop_flag.set()
break
elif low in ('s', 'stop'):
node._finish()
elif line:
node.set_goal(line)
def main(args=None):
rclpy.init(args=args)
node = NavigateToPoint()
stop_flag = threading.Event()
if not node.one_shot:
threading.Thread(target=interactive, args=(node, stop_flag), daemon=True).start()
try:
while rclpy.ok() and not stop_flag.is_set() and not (node.one_shot and node.done):
rclpy.spin_once(node, timeout_sec=0.1)
except KeyboardInterrupt:
pass
finally:
node._stop()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""在 rviz 显示所有示教点位:读 taught_points.yaml发布 MarkerArray。
每个点画:① 箭头 = 位置 + 朝向(yaw);② 文字 = 点名。frame_id = map。
周期性重读文件并发布,因此示教(teach_points)时新增/删除的点会实时反映到 rviz。
rviz 里加 MarkerArray 显示、话题 /taught_pointslocalization.rviz 已内置该显示)。
用法:
ros2 run craic_localization show_points --ros-args \
-p points_file:=$PWD/src/craic_localization/config/taught_points.yaml
"""
import math
import os
import rclpy
import yaml
from geometry_msgs.msg import Quaternion
from rclpy.node import Node
from visualization_msgs.msg import Marker, MarkerArray
def yaw_to_quat(yaw):
q = Quaternion()
q.z = math.sin(yaw / 2.0)
q.w = math.cos(yaw / 2.0)
return q
class ShowPoints(Node):
def __init__(self):
super().__init__('show_points')
self.points_file = os.path.abspath(
self.declare_parameter('points_file', 'taught_points.yaml').value)
self.frame_id = self.declare_parameter('frame_id', 'map').value
self.rate = float(self.declare_parameter('rate', 1.0).value)
self.pub = self.create_publisher(MarkerArray, 'taught_points', 1)
self.timer = self.create_timer(1.0 / self.rate, self._tick)
self.get_logger().info(
f'show_points: {self.points_file} -> /taught_points (frame={self.frame_id})')
def _load(self):
try:
with open(self.points_file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
return dict(data.get('points', {}))
except FileNotFoundError:
return {}
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f'读取点位失败: {exc}', throttle_duration_sec=5.0)
return {}
def _tick(self):
pts = self._load()
now = self.get_clock().now().to_msg()
arr = MarkerArray()
# 先清除上一帧与新增放同一条消息rviz 一次性处理,无闪烁),自动反映删除
clear = Marker()
clear.header.frame_id = self.frame_id
clear.action = Marker.DELETEALL
arr.markers.append(clear)
for i, (name, p) in enumerate(pts.items()):
try:
x, y = float(p['x']), float(p['y'])
yaw = float(p.get('yaw', 0.0))
except (KeyError, TypeError, ValueError):
continue
# 箭头:位置 + 朝向
a = Marker()
a.header.frame_id = self.frame_id
a.header.stamp = now
a.ns = 'arrow'
a.id = i
a.type = Marker.ARROW
a.action = Marker.ADD
a.pose.position.x = x
a.pose.position.y = y
a.pose.position.z = 0.05
a.pose.orientation = yaw_to_quat(yaw)
a.scale.x = 0.25 # 杆长
a.scale.y = 0.04 # 杆径
a.scale.z = 0.04 # 头径
a.color.r, a.color.g, a.color.b, a.color.a = 0.1, 0.8, 1.0, 1.0 # 青色
arr.markers.append(a)
# 文字:点名
t = Marker()
t.header.frame_id = self.frame_id
t.header.stamp = now
t.ns = 'label'
t.id = i
t.type = Marker.TEXT_VIEW_FACING
t.action = Marker.ADD
t.pose.position.x = x
t.pose.position.y = y
t.pose.position.z = 0.22
t.pose.orientation.w = 1.0
t.scale.z = 0.12 # 字高
t.color.r, t.color.g, t.color.b, t.color.a = 1.0, 1.0, 1.0, 1.0
t.text = name
arr.markers.append(t)
self.pub.publish(arr)
def main(args=None):
rclpy.init(args=args)
node = ShowPoints()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""示教程序 (teach_points):记录机器人在 map 坐标系下的位姿,存为 YAML 供导航复用。
依赖定位栈在运行(localization.launch.py 提供 AMCL),位姿来源为 TF: map -> base_footprint
(= AMCL 实时定位结果)。用法是另开终端用 keyboard_control 把车开到目标点,回到本程序按回车记录。
预设要记录的点(可用参数 points 覆盖)B_1..B_6, C_1, D_1, E_1, F_1。
也可随时用 `name <名字>` 记录任意自定义点。
运行:
# 终端1定位栈
ros2 launch craic_localization localization.launch.py map:=.../craic.yaml
# 终端2遥控(开车到点)
ros2 run udp_teleop keyboard_control --ros-args --params-file src/udp_teleop/config/params.yaml
# 终端3示教(记录),建议把结果存到包的 config 下供导航读取
ros2 run craic_localization teach_points --ros-args \
-p output_file:=$PWD/src/craic_localization/config/taught_points.yaml
交互命令:
回车 / r 记录"当前待记录点"的位姿,并前进到下一个
p 只打印当前位姿,不记录
name <X> 把当前位姿记录为自定义名字 X(已存在则覆盖)
del <X> 删除已记录的点 X
list 列出已记录的点
skip / back 跳过 / 回退当前待记录点
save 立即保存到文件
h 帮助
q 保存并退出
"""
import math
import os
import threading
import rclpy
import yaml
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.time import Time
from tf2_ros import (
Buffer,
ConnectivityException,
ExtrapolationException,
LookupException,
TransformListener,
)
DEFAULT_POINTS = ['B_1', 'B_2', 'B_3', 'B_4', 'B_5', 'B_6', 'C_1', 'D_1', 'E_1', 'F_1']
def quat_to_yaw(qx, qy, qz, qw):
"""四元数 -> 绕 Z 偏航角(弧度)。"""
return math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz))
class TeachPoints(Node):
def __init__(self):
super().__init__('teach_points')
self.map_frame = self.declare_parameter('map_frame', 'map').value
self.base_frame = self.declare_parameter('base_frame', 'base_footprint').value
self.output_file = os.path.abspath(
self.declare_parameter('output_file', 'taught_points.yaml').value)
self.points = list(self.declare_parameter('points', DEFAULT_POINTS).value)
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.recorded = {} # name -> {x, y, yaw}
self._load_existing()
# -------- 位姿读取 -------- #
def lookup_pose(self):
"""读取 map->base_frame 的最新变换,返回 ({x,y,yaw}, None) 或 (None, 错误信息)。"""
try:
t = self.tf_buffer.lookup_transform(
self.map_frame, self.base_frame, Time(),
timeout=Duration(seconds=1.0))
except (LookupException, ConnectivityException, ExtrapolationException) as exc:
return None, str(exc)
tr = t.transform.translation
q = t.transform.rotation
yaw = quat_to_yaw(q.x, q.y, q.z, q.w)
return {'x': round(tr.x, 4), 'y': round(tr.y, 4), 'yaw': round(yaw, 4)}, None
# -------- 文件读写 -------- #
def _load_existing(self):
if os.path.isfile(self.output_file):
try:
with open(self.output_file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
self.recorded = dict(data.get('points', {}))
if self.recorded:
print(f'已载入已有记录 {len(self.recorded)} 个点:{self.output_file}')
except Exception as exc: # noqa: BLE001
print(f'载入已有文件失败(忽略){exc}')
def save(self):
os.makedirs(os.path.dirname(self.output_file) or '.', exist_ok=True)
header = (
'# 示教记录的点位map 坐标系x,y 单位米yaw 单位弧度)\n'
'# 由 craic_localization/teach_points 生成\n'
)
body = yaml.safe_dump({'points': self.recorded},
default_flow_style=False, sort_keys=False,
allow_unicode=True)
with open(self.output_file, 'w', encoding='utf-8') as f:
f.write(header + body)
HELP = __doc__.split('交互命令:', 1)[1]
def interactive(node: TeachPoints):
idx = 0
print('=' * 60)
print(' 示教程序 teach_points —— 记录 map 系下位姿')
print(f' 输出文件: {node.output_file}')
print(f' 预设点: {", ".join(node.points)}')
print(' (输入 h 查看命令)')
print('=' * 60)
while rclpy.ok():
target = node.points[idx] if idx < len(node.points) else None
tag = target if target else '(预设已完,用 name <X> 记录自定义点)'
try:
line = input(f'[已记录 {len(node.recorded)}] 下一个: {tag} > ').strip()
except EOFError:
break
cmd = line.lower()
if cmd in ('q', 'quit', 'exit'):
node.save()
print(f'已保存到 {node.output_file},退出。')
break
elif cmd in ('', 'r'):
if target is None:
print(' 预设点已记录完。用 "name <X>" 记录自定义点,或 q 退出。')
continue
pose, err = node.lookup_pose()
if err:
print(f' ✗ 读取位姿失败(定位栈在运行吗?): {err}')
continue
node.recorded[target] = pose
node.save()
print(f'{target} = x:{pose["x"]} y:{pose["y"]} '
f'yaw:{pose["yaw"]}rad ({math.degrees(pose["yaw"]):.1f}°) [已存盘]')
idx += 1
elif cmd == 'p':
pose, err = node.lookup_pose()
if err:
print(f' ✗ 读取位姿失败: {err}')
else:
print(f' 当前位姿 x:{pose["x"]} y:{pose["y"]} '
f'yaw:{pose["yaw"]}rad ({math.degrees(pose["yaw"]):.1f}°)')
elif cmd.startswith('name '):
name = line[5:].strip()
if not name:
print(' 用法: name <名字>')
continue
pose, err = node.lookup_pose()
if err:
print(f' ✗ 读取位姿失败: {err}')
continue
node.recorded[name] = pose
node.save()
print(f'{name} = x:{pose["x"]} y:{pose["y"]} '
f'yaw:{pose["yaw"]}rad [已存盘]')
elif cmd.startswith('del '):
name = line[4:].strip()
if node.recorded.pop(name, None) is not None:
node.save()
print(f' 已删除 {name} [已存盘]')
else:
print(f' 没有名为 {name} 的记录')
elif cmd == 'list':
if not node.recorded:
print(' (空)')
for k, v in node.recorded.items():
print(f' {k}: x:{v["x"]} y:{v["y"]} yaw:{v["yaw"]}')
elif cmd == 'skip':
idx += 1
elif cmd == 'back':
idx = max(0, idx - 1)
elif cmd == 'save':
node.save()
print(f' 已保存到 {node.output_file}')
elif cmd in ('h', 'help', '?'):
print(HELP)
else:
print(' 未知命令,输入 h 查看帮助。')
def main(args=None):
rclpy.init(args=args)
node = TeachPoints()
# 后台 spin 以填充 TF 缓冲
spin_thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True)
spin_thread.start()
try:
interactive(node)
except KeyboardInterrupt:
node.save()
print(f'\n中断,已保存到 {node.output_file}')
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()