feat: init sim_smart_car_bridge — ROS2 bridge relay for Origincar HTTP API

Bridge image (GET /image → sensor_msgs/Image), IMU (GET /imu → sensor_msgs/Imu),
and cmd_vel (Twist → POST /cmd) between Origincar simulation and ROS2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cyy
2026-06-16 22:57:18 +08:00
commit 954ca7f8a7
9 changed files with 557 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""
Origincar ROS2 桥接中转节点
功能:
1. 从 HTTP API 拉取图像 (GET /image) → 发布 sensor_msgs/Image 到 ROS2
2. 从 HTTP API 拉取 IMU (GET /imu) → 发布 sensor_msgs/Imu 到 ROS2
3. 订阅 ROS2 /cmd_vel (geometry_msgs/Twist) → HTTP POST /cmd 控制小车
可配置参数:
- http_host: 仿真机 IP 地址
- http_port: HTTP 服务端口 (默认 8765)
- image_topic: 图像发布话题 (默认 /image)
- imu_topic: IMU 发布话题 (默认 /imu)
- cmd_vel_topic: 控制指令订阅话题 (默认 /cmd_vel)
- image_rate: 图像拉取频率 Hz (默认 10.0)
- imu_rate: IMU 拉取频率 Hz (默认 50.0)
- timeout: HTTP 请求超时秒数 (默认 1.0)
使用方式:
ros2 run origincar_bridge bridge_node --ros-args -p http_host:=192.168.1.100
ros2 launch origincar_bridge bridge.launch.py http_host:=192.168.1.100
"""
import time
import traceback
import cv2
import numpy as np
import requests
import rclpy
from rclpy.node import Node
from rclpy.parameter import Parameter
# ROS2 消息
from sensor_msgs.msg import Image as RosImage
from sensor_msgs.msg import Imu
from geometry_msgs.msg import Twist
from cv_bridge import CvBridge
class OrigincarBridge(Node):
"""将 Origincar 仿真 HTTP API 与 ROS2 话题双向桥接。"""
def __init__(self):
super().__init__("origincar_bridge")
# ---------- 声明参数 ----------
self.declare_parameter("http_host", "192.168.1.100")
self.declare_parameter("http_port", 8765)
self.declare_parameter("image_topic", "/image")
self.declare_parameter("imu_topic", "/imu")
self.declare_parameter("cmd_vel_topic", "/cmd_vel")
self.declare_parameter("image_rate", 10.0)
self.declare_parameter("imu_rate", 50.0)
self.declare_parameter("timeout", 1.0)
# 缓读参数(允许运行时通过 ros2 param set 动态调整)
self._read_params()
# ---------- cv_bridge ----------
self.bridge = CvBridge()
# ---------- 发布者 ----------
self.image_pub = self.create_publisher(RosImage, self.image_topic, 10)
self.imu_pub = self.create_publisher(Imu, self.imu_topic, 10)
# ---------- 订阅者 ----------
self.cmd_sub = self.create_subscription(
Twist, self.cmd_vel_topic, self._cmd_vel_callback, 10
)
# ---------- 定时器 ----------
image_period = 1.0 / self.image_rate
imu_period = 1.0 / self.imu_rate
self.image_timer = self.create_timer(image_period, self._fetch_and_publish_image)
self.imu_timer = self.create_timer(imu_period, self._fetch_and_publish_imu)
# 参数变更回调(运行时动态调参)
self.add_on_set_parameters_callback(self._on_param_change)
# 统计
self._image_seq = 0
self._imu_seq = 0
self._cmd_count = 0
# HTTP Session连接复用降低延迟
self._session = requests.Session()
self.get_logger().info(
f"桥接启动: http://{self.http_host}:{self.http_port}\n"
f" 图像 [{self.image_topic}] @ {self.image_rate} Hz\n"
f" IMU [{self.imu_topic}] @ {self.imu_rate} Hz\n"
f" 控制 [{self.cmd_vel_topic}] → /cmd"
)
# ------------------------------------------------------------------
# 参数管理
# ------------------------------------------------------------------
def _read_params(self):
self.http_host = self.get_parameter("http_host").value
self.http_port = self.get_parameter("http_port").value
self.image_topic = self.get_parameter("image_topic").value
self.imu_topic = self.get_parameter("imu_topic").value
self.cmd_vel_topic = self.get_parameter("cmd_vel_topic").value
self.image_rate = self.get_parameter("image_rate").value
self.imu_rate = self.get_parameter("imu_rate").value
self.timeout = self.get_parameter("timeout").value
def _on_param_change(self, params):
"""运行时参数变更回调。允许动态调整 rate / timeout / host 等。"""
for p in params:
if p.name == "http_host":
self.http_host = p.value
elif p.name == "http_port":
self.http_port = p.value
elif p.name == "image_rate":
self.image_rate = p.value
self.image_timer.timer_period_ns = int(1e9 / self.image_rate)
elif p.name == "imu_rate":
self.imu_rate = p.value
self.imu_timer.timer_period_ns = int(1e9 / self.imu_rate)
elif p.name == "timeout":
self.timeout = p.value
elif p.name == "image_topic":
self.image_topic = p.value
elif p.name == "imu_topic":
self.imu_topic = p.value
elif p.name == "cmd_vel_topic":
self.cmd_vel_topic = p.value
return rclpy.parameter.SetParametersResult(successful=True)
# ------------------------------------------------------------------
# HTTP 基础方法
# ------------------------------------------------------------------
@property
def _base_url(self) -> str:
return f"http://{self.http_host}:{self.http_port}"
def _get(self, endpoint: str):
"""GET 请求,失败返回 None打日志。"""
try:
r = self._session.get(
f"{self._base_url}{endpoint}", timeout=self.timeout
)
r.raise_for_status()
return r
except requests.ConnectionError:
self.get_logger().warn(
f"连接仿真机 {self._base_url} 失败,请确认仿真已启动",
throttle_duration_sec=5.0,
)
except requests.Timeout:
self.get_logger().warn(
f"请求 {endpoint} 超时 ({self.timeout}s)",
throttle_duration_sec=5.0,
)
except requests.RequestException as e:
self.get_logger().warn(
f"请求 {endpoint} 异常: {e}", throttle_duration_sec=5.0
)
return None
def _post(self, endpoint: str, json_data: dict):
"""POST 请求(不关心返回值)。"""
try:
self._session.post(
f"{self._base_url}{endpoint}",
json=json_data,
timeout=self.timeout,
)
except requests.RequestException as e:
self.get_logger().warn(
f"POST {endpoint} 异常: {e}", throttle_duration_sec=5.0
)
# ------------------------------------------------------------------
# 图像 — GET /image → sensor_msgs/Image
# ------------------------------------------------------------------
def _fetch_and_publish_image(self):
r = self._get("/image")
if r is None or r.status_code != 200:
return
try:
arr = np.frombuffer(r.content, np.uint8)
cv_img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if cv_img is None:
self.get_logger().warn("图像解码失败", throttle_duration_sec=5.0)
return
msg = self.bridge.cv2_to_imgmsg(cv_img, encoding="bgr8")
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = "camera_front"
self.image_pub.publish(msg)
except Exception:
self.get_logger().warn(
f"图像处理异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ------------------------------------------------------------------
# IMU — GET /imu → sensor_msgs/Imu
# ------------------------------------------------------------------
def _fetch_and_publish_imu(self):
r = self._get("/imu")
if r is None or r.status_code != 200:
return
try:
data = r.json()
msg = Imu()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = "imu_link"
# 姿态四元数
ori = data.get("orientation", {})
msg.orientation.w = ori.get("w", 1.0)
msg.orientation.x = ori.get("x", 0.0)
msg.orientation.y = ori.get("y", 0.0)
msg.orientation.z = ori.get("z", 0.0)
# 角速度 (rad/s)
av = data.get("angular_velocity", {})
msg.angular_velocity.x = av.get("x", 0.0)
msg.angular_velocity.y = av.get("y", 0.0)
msg.angular_velocity.z = av.get("z", 0.0)
# 线加速度 (m/s²)
la = data.get("linear_acceleration", {})
msg.linear_acceleration.x = la.get("x", 0.0)
msg.linear_acceleration.y = la.get("y", 0.0)
msg.linear_acceleration.z = la.get("z", 0.0)
# 协方差置 -1表示无估计
msg.orientation_covariance[0] = -1.0
msg.angular_velocity_covariance[0] = -1.0
msg.linear_acceleration_covariance[0] = -1.0
self.imu_pub.publish(msg)
except Exception:
self.get_logger().warn(
f"IMU 处理异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ------------------------------------------------------------------
# 控制 — ROS2 /cmd_vel → POST /cmd
# ------------------------------------------------------------------
def _cmd_vel_callback(self, msg: Twist):
"""将 Twist 消息转换为 HTTP /cmd 控制指令。
Twist.linear.x → speed (m/s)
Twist.angular.z → steer (rad)
"""
speed = float(msg.linear.x)
steer = float(msg.angular.z)
self._post("/cmd", {"speed": speed, "steer": steer})
self._cmd_count += 1
self.get_logger().debug(
f"发送控制: speed={speed:.2f}, steer={steer:.2f}"
f" (总计 {self._cmd_count})"
)
def destroy_node(self):
"""关闭 HTTP Session 后再销毁节点。"""
self._session.close()
super().destroy_node()
# ------------------------------------------------------------------
# entry point
# ------------------------------------------------------------------
def main():
rclpy.init()
node = OrigincarBridge()
try:
rclpy.spin(node)
except KeyboardInterrupt:
node.get_logger().info("键盘中断,停止桥接")
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()