Files
sim_smart_car_bridge/origincar_bridge/bridge_node.py
2026-06-20 21:54:04 +08:00

431 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Origincar ROS2 桥接中转节点
将 Origincar 仿真 HTTP API 全面桥接到 ROS2 话题,双向中转。
上行HTTP → ROS2
GET /image → sensor_msgs/Image (独立定时器,可配置频率)
GET /imu → sensor_msgs/Imu (统一同步定时器)
GET /scan → sensor_msgs/LaserScan (统一同步定时器)
GET /odom → nav_msgs/Odometry (统一同步定时器)
GET /power → std_msgs/Float32 (统一同步定时器)
下行ROS2 → HTTP
geometry_msgs/Twist → POST /cmd (差速模式: vx/wz)
可配置参数:
http_host, http_port, timeout
image_topic, imu_topic, scan_topic, odom_topic, power_topic, cmd_vel_topic
sync_rate — 统一同步频率 Hzscan/odom/imu/power默认 30
image_rate — 图像拉取频率 Hz默认 10
使用:
ros2 run origincar_bridge bridge_node --ros-args -p http_host:=192.168.11.159
ros2 launch origincar_bridge bridge.launch.py http_host:=192.168.11.159
"""
import traceback
import cv2
import numpy as np
import requests
import rclpy
from rclpy.node import Node
# ROS2 消息
from sensor_msgs.msg import Image as RosImage
from sensor_msgs.msg import Imu
from sensor_msgs.msg import LaserScan
from nav_msgs.msg import Odometry
from geometry_msgs.msg import Twist, Quaternion
from std_msgs.msg import Float32
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.11.159")
self.declare_parameter("http_port", 8765)
self.declare_parameter("timeout", 1.0)
self.declare_parameter("image_topic", "/image")
self.declare_parameter("imu_topic", "/imu/data_raw")
self.declare_parameter("scan_topic", "/scan")
self.declare_parameter("odom_topic", "/odom")
self.declare_parameter("power_topic", "/PowerVoltage")
self.declare_parameter("cmd_vel_topic", "/cmd_vel")
self.declare_parameter("sync_rate", 30.0) # scan/odom/imu/power 频率
self.declare_parameter("image_rate", 10.0) # 图像独立频率
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.scan_pub = self.create_publisher(LaserScan, self.scan_topic, 10)
self.odom_pub = self.create_publisher(Odometry, self.odom_topic, 10)
self.power_pub = self.create_publisher(Float32, self.power_topic, 10)
# ---------- 订阅者 ----------
self.cmd_sub = self.create_subscription(
Twist, self.cmd_vel_topic, self._cmd_vel_callback, 10
)
# ---------- 定时器 ----------
# image 独立定时器JPEG 解码较重,用较低频率)
self.image_timer = self.create_timer(
1.0 / self.image_rate, self._fetch_and_publish_image
)
# 统一同步定时器scan / odom / imu / power 在同一拍内完成)
self.sync_timer = self.create_timer(
1.0 / self.sync_rate, self._sync_fast_data
)
# ---------- 参数变更 ----------
self.add_on_set_parameters_callback(self._on_param_change)
# ---------- 统计 & Session ----------
self._cmd_count = 0
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}] }} sync @ {self.sync_rate} Hz\n"
f" Scan [{self.scan_topic}] }}\n"
f" Odom [{self.odom_topic}] }}\n"
f" Power[{self.power_topic}] }}\n"
f" 下行: 控制 [{self.cmd_vel_topic}] → POST /cmd (vx/wz)"
)
# ------------------------------------------------------------------
# 参数
# ------------------------------------------------------------------
def _read_params(self):
self.http_host = self.get_parameter("http_host").value
self.http_port = self.get_parameter("http_port").value
self.timeout = self.get_parameter("timeout").value
self.image_topic = self.get_parameter("image_topic").value
self.imu_topic = self.get_parameter("imu_topic").value
self.scan_topic = self.get_parameter("scan_topic").value
self.odom_topic = self.get_parameter("odom_topic").value
self.power_topic = self.get_parameter("power_topic").value
self.cmd_vel_topic = self.get_parameter("cmd_vel_topic").value
self.sync_rate = self.get_parameter("sync_rate").value
self.image_rate = self.get_parameter("image_rate").value
def _on_param_change(self, params):
for p in params:
name = p.name
if name == "http_host": self.http_host = p.value
elif name == "http_port": self.http_port = p.value
elif name == "timeout": self.timeout = p.value
elif name == "sync_rate":
self.sync_rate = p.value
self.sync_timer.timer_period_ns = int(1e9 / self.sync_rate)
elif name == "image_rate":
self.image_rate = p.value
self.image_timer.timer_period_ns = int(1e9 / self.image_rate)
elif name == "image_topic": self.image_topic = p.value
elif name == "imu_topic": self.imu_topic = p.value
elif name == "scan_topic": self.scan_topic = p.value
elif name == "odom_topic": self.odom_topic = p.value
elif name == "power_topic": self.power_topic = p.value
elif 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):
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 _get_json(self, endpoint: str) -> dict | None:
r = self._get(endpoint)
if r is not None:
try:
return r.json()
except Exception:
self.get_logger().warn(
f"解析 {endpoint} JSON 失败", throttle_duration_sec=5.0
)
return None
def _post(self, endpoint: str, json_data: dict):
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,
)
# ------------------------------------------------------------------
# 同步快速数据 — scan / odom / imu / power (统一定时器)
# ------------------------------------------------------------------
def _sync_fast_data(self):
"""在同一拍内拉取并发布 scan / odom / imu / power。"""
now = self.get_clock().now().to_msg()
# ---- LaserScan ----
scan_data = self._get_json("/scan")
if scan_data:
try:
self._publish_scan(scan_data, now)
except Exception:
self.get_logger().warn(
f"LaserScan 异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ---- Odometry ----
odom_data = self._get_json("/odom")
if odom_data:
try:
self._publish_odom(odom_data, now)
except Exception:
self.get_logger().warn(
f"Odometry 异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ---- IMU ----
imu_data = self._get_json("/imu")
if imu_data:
try:
self._publish_imu(imu_data, now)
except Exception:
self.get_logger().warn(
f"IMU 异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ---- Power ----
power_data = self._get_json("/power")
if power_data:
try:
self._publish_power(power_data)
except Exception:
self.get_logger().warn(
f"Power 异常:\n{traceback.format_exc()}",
throttle_duration_sec=5.0,
)
# ---- 各数据发布方法 ----
def _publish_scan(self, data: dict, now):
"""GET /scan → sensor_msgs/LaserScan
HTTP 返回格式:
angle_min, angle_max, angle_increment, range_min, range_max, ranges[]
"""
msg = LaserScan()
msg.header.stamp = now
msg.header.frame_id = "laser"
msg.angle_min = float(data["angle_min"])
msg.angle_max = float(data["angle_max"])
msg.angle_increment = float(data["angle_increment"])
msg.range_min = float(data.get("range_min", 0.15))
msg.range_max = float(data.get("range_max", 3.0))
msg.ranges = [float(r) for r in data["ranges"]]
# intensities 置空
msg.intensities = []
self.scan_pub.publish(msg)
def _publish_odom(self, data: dict, now):
"""GET /odom → nav_msgs/Odometry
HTTP 返回格式:
x, y, yaw, vx, vy, wz, orientation{w,x,y,z}
"""
msg = Odometry()
msg.header.stamp = now
msg.header.frame_id = "odom"
msg.child_frame_id = "base_link"
msg.pose.pose.position.x = float(data["x"])
msg.pose.pose.position.y = float(data["y"])
msg.pose.pose.position.z = 0.0
ori = data.get("orientation", {})
msg.pose.pose.orientation = Quaternion(
w=float(ori.get("w", 1.0)),
x=float(ori.get("x", 0.0)),
y=float(ori.get("y", 0.0)),
z=float(ori.get("z", 0.0)),
)
msg.twist.twist.linear.x = float(data.get("vx", 0.0))
msg.twist.twist.linear.y = float(data.get("vy", 0.0))
msg.twist.twist.angular.z = float(data.get("wz", 0.0))
# 协方差置 -1无估计
msg.pose.covariance[0] = -1.0
msg.twist.covariance[0] = -1.0
self.odom_pub.publish(msg)
def _publish_imu(self, data: dict, now):
"""GET /imu → sensor_msgs/Imu
HTTP 返回格式:
orientation{w,x,y,z}, angular_velocity{x,y,z}, linear_acceleration{x,y,z}
"""
msg = Imu()
msg.header.stamp = now
msg.header.frame_id = "gyro_link"
ori = data.get("orientation", {})
msg.orientation = Quaternion(
w=float(ori.get("w", 1.0)),
x=float(ori.get("x", 0.0)),
y=float(ori.get("y", 0.0)),
z=float(ori.get("z", 0.0)),
)
av = data.get("angular_velocity", {})
msg.angular_velocity.x = float(av.get("x", 0.0))
msg.angular_velocity.y = float(av.get("y", 0.0))
msg.angular_velocity.z = float(av.get("z", 0.0))
la = data.get("linear_acceleration", {})
msg.linear_acceleration.x = float(la.get("x", 0.0))
msg.linear_acceleration.y = float(la.get("y", 0.0))
msg.linear_acceleration.z = float(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)
def _publish_power(self, data: dict):
"""GET /power → std_msgs/Float32
HTTP 返回格式:
{"voltage": 12.0}
"""
msg = Float32()
msg.data = float(data.get("voltage", 0.0))
self.power_pub.publish(msg)
# ------------------------------------------------------------------
# 控制 — ROS2 /cmd_vel (Twist) → POST /cmd (差速模式 vx/wz)
# ------------------------------------------------------------------
def _cmd_vel_callback(self, msg: Twist):
"""将 Twist 转为差速控制指令。
Twist.linear.x → vx (线速度 m/s)
Twist.angular.z → wz (角速度 rad/s)
HTTP /cmd 只接受差速模式 {"vx": vx, "wz": wz}
服务端内部通过 bicycle model 将 wz 转成阿克曼转向角。
"""
vx = float(msg.linear.x)
wz = float(msg.angular.z)
self._post("/cmd", {"vx": vx, "wz": wz})
self._cmd_count += 1
self.get_logger().debug(
f"cmd_vel → vx={vx:.2f}, wz={wz:.2f} (#{self._cmd_count})"
)
# ------------------------------------------------------------------
# 清理
# ------------------------------------------------------------------
def destroy_node(self):
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()