Files
car_simulation/README.md
2026-06-20 21:46:03 +08:00

317 lines
8.2 KiB
Markdown
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.
# Origincar Simulation
基于 MotrixSim 的阿克曼小车仿真,支持键盘控制和 HTTP API 局域网共享。
![仿真截图](doc/image.png)
## 依赖
| 依赖 | 说明 | 安装 |
|------|------|------|
| Python ≥3.13 | 运行环境 | `brew install python` 或 [python.org](https://python.org) |
| [uv](https://docs.astral.sh/uv/) | 包管理器 | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `motrixsim-core` | 物理仿真引擎 | `uv sync` 自动安装 |
| `pillow` | 摄像头图像转 JPEG | `uv sync` 自动安装 |
```bash
# 克隆后一键安装
uv sync
```
> `mxpython` 是 motrixsim-core 自带的启动器,**macOS 上必须使用**主线程需保留给渲染Linux / Windows 可直接用 `python`。
## 启动
```bash
# macOS
uv run mxpython main.py
# Linux / Windows
uv run python main.py
```
## HTTP API端口 8765
仿真启动后,局域网内其他设备可通过 HTTP 获取摄像头图像、IMU 数据和发送控制指令。
### GET `/image`
获取前置摄像头最新 JPEG 图像。浏览器可直接打开。
```bash
# 浏览器
http://<仿真机IP>:8765/image
# curl
curl http://1{IP}:8765/image -o frame.jpg
# Python
import requests
r = requests.get("http://{IP}:8765/image")
with open("frame.jpg", "wb") as f:
f.write(r.content)
```
### GET `/state`
获取小车当前状态JSON
```bash
curl http://{IP}:8765/state
```
返回示例:
```json
{
"x": -2.0, "y": -2.3, "z": 0.08, "yaw_deg": 0.0,
"vx": 0.98, "vy": 0.0, "wx": 0.0, "wy": 0.0, "wz": 0.01,
"speed_target": 1.0, "speed_actual": 0.98, "steer_target": 0.0,
"speed_amp": 1.0, "steer_amp": 0.35
}
```
### GET `/scan`
模拟 LSLIDAR N10 2D 激光雷达360°, 450 点)。
```bash
curl http://{IP}:8765/scan
```
返回示例:
```json
{
"angle_min": -3.14, "angle_max": 3.14, "angle_increment": 0.014,
"range_min": 0.15, "range_max": 3.0,
"ranges": [0.52, 0.51, 0.53, ...]
}
```
### GET `/odom`
里程计数据(匹配 `nav_msgs/Odometry`)。
```bash
curl http://{IP}:8765/odom
```
返回示例:
```json
{
"x": -2.0, "y": -2.3, "yaw": 3.14,
"vx": 0.98, "vy": 0.0, "wz": 0.01,
"orientation": {"w": 0.0, "x": 0.0, "y": 0.0, "z": 1.0}
}
```
### GET `/power`
电池电压(模拟 12V
```bash
curl http://{IP}:8765/power
# → {"voltage": 12.0}
```
### GET `/imu`
获取 IMU 数据(含 MPU6050 噪声JSON。
```bash
curl http://{IP}:8765/imu
```
返回示例:
```json
{
"orientation": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"angular_velocity": {"x": 0.001, "y": -0.003, "z": 0.008},
"linear_acceleration": {"x": 0.02, "y": -0.01, "z": 9.81}
}
```
噪声参数MPU6050 级别):
- 陀螺仪白噪声 σ = 0.01 rad/sbias 随机游走
- 加速度计白噪声 σ = 0.05 m/s²bias 随机游走
### POST `/cmd`
发送控制指令JSON
```bash
curl -X POST http://{IP}:8765/cmd \
-H "Content-Type: application/json" \
-d '{"speed": 1.0, "steer": 0.3}'
```
**Ackermann 模式**
| 字段 | 类型 | 说明 |
|------|------|------|
| `speed` | float | 目标速度 m/s正=前进,负=后退 |
| `steer` | float | 目标转向角 rad正=左转,负=右转 |
**差速模式**(兼容 `cmd_vel/Twist`
| 字段 | 类型 | 说明 |
|------|------|------|
| `vx` | float | 线速度 m/s |
| `vy` | float | 横向速度 m/s忽略 |
| `wz` | float | 角速度 rad/s自动转为转向角 |
```bash
# 差速模式
curl -X POST http://{IP}:8765/cmd \
-H "Content-Type: application/json" \
-d '{"vx": 1.0, "wz": 0.5}'
```
**Python 客户端示例**
```python
import time
import requests
IP = "192.168.1.100"
# 获取图像
r = requests.get(f"http://{IP}:8765/image")
with open("frame.jpg", "wb") as f:
f.write(r.content)
# 获取状态
state = requests.get(f"http://{IP}:8765/state").json()
print(f"位置: ({state['x']}, {state['y']}), 速度: {state['speed_actual']} m/s")
# 发送控制指令
requests.post(f"http://{IP}:8765/cmd", json={"speed": 1.0, "steer": 0.2})
time.sleep(2)
requests.post(f"http://{IP}:8765/cmd", json={"speed": 0.0, "steer": 0.0})
```
**ROS2 桥接示例**(仿真机 HTTP → ROS2 局域网):
```python
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, Imu, LaserScan
from nav_msgs.msg import Odometry
from geometry_msgs.msg import Twist, Quaternion
from std_msgs.msg import Float32
import requests, math, numpy as np, cv2
IP = "192.168.1.100"
class OrigincarBridge(Node):
def __init__(self):
super().__init__("origincar_bridge")
self.pub_scan = self.create_publisher(LaserScan, "/scan", 10)
self.pub_odom = self.create_publisher(Odometry, "/odom", 10)
self.pub_imu = self.create_publisher(Imu, "/imu/data_raw", 10)
self.pub_pwr = self.create_publisher(Float32, "/PowerVoltage", 10)
self.sub = self.create_subscription(Twist, "/cmd_vel", self.cmd_cb, 10)
self.timer = self.create_timer(0.033, self.sync) # ~30Hz
def sync(self):
# LaserScan
data = requests.get(f"http://{IP}:8765/scan", timeout=1).json()
msg = LaserScan()
msg.header.frame_id = "laser"; msg.header.stamp = self.get_clock().now().to_msg()
msg.angle_min = data["angle_min"]; msg.angle_max = data["angle_max"]
msg.angle_increment = data["angle_increment"]
msg.range_min = data["range_min"]; msg.range_max = data["range_max"]
msg.ranges = data["ranges"]
self.pub_scan.publish(msg)
# Odometry
odom = requests.get(f"http://{IP}:8765/odom", timeout=1).json()
o = Odometry()
o.header.frame_id = "odom"; o.child_frame_id = "base_link"
o.pose.pose.position.x = odom["x"]; o.pose.pose.position.y = odom["y"]
o.pose.pose.orientation = Quaternion(**odom["orientation"])
o.twist.twist.linear.x = odom["vx"]; o.twist.twist.angular.z = odom["wz"]
self.pub_odom.publish(o)
# IMU
imu_d = requests.get(f"http://{IP}:8765/imu", timeout=1).json()
imu = Imu()
imu.header.frame_id = "gyro_link"
imu.orientation = Quaternion(**imu_d["orientation"])
imu.angular_velocity.x = imu_d["angular_velocity"]["x"]
imu.angular_velocity.y = imu_d["angular_velocity"]["y"]
imu.angular_velocity.z = imu_d["angular_velocity"]["z"]
imu.linear_acceleration.x = imu_d["linear_acceleration"]["x"]
imu.linear_acceleration.y = imu_d["linear_acceleration"]["y"]
imu.linear_acceleration.z = imu_d["linear_acceleration"]["z"]
self.pub_imu.publish(imu)
# Power
p = Float32(data=requests.get(f"http://{IP}:8765/power", timeout=1).json()["voltage"])
self.pub_pwr.publish(p)
def cmd_cb(self, msg: Twist):
requests.post(f"http://{IP}:8765/cmd", json={
"vx": msg.linear.x, "wz": msg.angular.z
})
rclpy.init()
node = OrigincarBridge()
rclpy.spin(node)
```
## 键盘控制
| 按键 | 方式 | 作用 |
|------|------|------|
| W/S | 按住 | 前进/后退 |
| A/D | 按住 | 左转/右转 |
| ↑/↓ | 点按 | 速度幅度 ±0.2 m/s0~2.0 |
| ←/→ | 点按 | 转向幅度 ±0.05 rad0~0.5 |
| Space | 点按 | 截图保存到 `captures/` |
键盘与 HTTP `/cmd` 可同时使用:按住 W/A/S/D 时键盘优先,松开后自动切回 HTTP 控制。
## 锥桶编辑器
可视化在地图上放置锥桶,保存到 `cones.json`,仿真启动时自动加载。
```bash
uv run python cone_editor.py
```
| 操作 | 说明 |
|------|------|
| 左键点击地图 | 放置锥桶 |
| 右键点击地图 | 删除最近锥桶 |
| 鼠标移动 | 查看世界坐标 |
| 保存按钮 | 写入 `cones.json` |
地图为 5m×5m左下角 (-2.5, -2.5),右上角 (2.5, 2.5)。
`cones.json` 格式:
```json
[
{"x": -1.45, "y": -2.29},
{"x": -0.06, "y": -1.56},
{"x": 1.82, "y": -0.91}
]
```
也可直接编辑此文件增删锥桶。
## 场景结构
```
scene.xml — 场景描述(地图 + 围栏 + 小车,锥桶动态注入)
main.py — 仿真控制 + HTTP API 服务
cone_editor.py — 锥桶可视化编辑器
cones.json — 锥桶位置配置
cone.obj — 锥体网格底20cm→顶5cm高27cm
cone_white.obj — 白色反光条网格
origincar.urdf / origincar.xacro — 小车模型(参考)
```