diff --git a/README.md b/README.md index 466b7c9..21ac648 100644 --- a/README.md +++ b/README.md @@ -63,18 +63,58 @@ curl http://{IP}:8765/state ```json { - "x": -2.0, - "y": -2.3, - "z": 0.08, - "yaw_deg": 0.0, - "speed_target": 1.0, - "speed_actual": 0.98, - "steer_target": 0.0, - "speed_amp": 1.0, - "steer_amp": 0.35 + "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。 @@ -107,11 +147,28 @@ curl -X POST http://{IP}:8765/cmd \ -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 @@ -135,40 +192,69 @@ time.sleep(2) requests.post(f"http://{IP}:8765/cmd", json={"speed": 0.0, "steer": 0.0}) ``` -**ROS2 桥接示例**: +**ROS2 桥接示例**(仿真机 HTTP → ROS2 局域网): ```python import rclpy from rclpy.node import Node -from sensor_msgs.msg import Image as RosImage -from geometry_msgs.msg import Twist -from cv_bridge import CvBridge -import requests -import numpy as np -import cv2 +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.bridge = CvBridge() - self.pub = self.create_publisher(RosImage, "/image", 10) + 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.1, self.fetch_and_publish) + self.timer = self.create_timer(0.033, self.sync) # ~30Hz - def fetch_and_publish(self): - r = requests.get(f"http://{IP}:8765/image", timeout=1) - if r.status_code == 200: - arr = np.frombuffer(r.content, np.uint8) - cv_img = cv2.imdecode(arr, cv2.IMREAD_COLOR) - msg = self.bridge.cv2_to_imgmsg(cv_img, "bgr8") - self.pub.publish(msg) + 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={ - "speed": msg.linear.x, - "steer": msg.angular.z + "vx": msg.linear.x, "wz": msg.angular.z }) rclpy.init() diff --git a/main.py b/main.py index 54eef59..592119c 100644 --- a/main.py +++ b/main.py @@ -9,10 +9,11 @@ from collections import deque from http.server import BaseHTTPRequestHandler, HTTPServer import numpy as np +import qrcode from PIL import Image as PILImage from motrixsim import SceneData, load_model, run, step -from motrixsim.render import CaptureTask, Layout, RenderApp +from motrixsim.render import CaptureTask, Color, Layout, RenderApp # ── Ackermann geometry ────────────────────────────────────────────── L = 0.1682 # wheelbase: front-rear axle distance @@ -20,6 +21,60 @@ T = 0.189 # track width: left-right wheel distance PORT = 8765 +# ── Laser scan parameters ──────────────────────────────────────────── +SCAN_POINTS = 450 +SCAN_ANGLE_MIN = -math.pi # -180° +SCAN_ANGLE_MAX = math.pi # +180° +SCAN_RANGE_MIN = 0.15 # m +SCAN_RANGE_MAX = 3.0 # m + + +def raycast_scan(car_x, car_y, car_yaw, obstacles): + """Simulate 2D laser scan. obstacles = [(ox, oy, radius), ...].""" + N = SCAN_POINTS + angles = np.linspace(SCAN_ANGLE_MIN, SCAN_ANGLE_MAX, N) + ranges = np.full(N, SCAN_RANGE_MAX) + cos_a = np.cos(angles + car_yaw) + sin_a = np.sin(angles + car_yaw) + + for ox, oy, r in obstacles: + dx = ox - car_x + dy = oy - car_y + # Project obstacle to ray direction: t = dx*cos + dy*sin + t = dx * cos_a + dy * sin_a + # Perpendicular distance squared + d2 = dx * dx + dy * dy - t * t + # Intersection: ray hits circle if d2 < r^2 and t > 0 + mask = (d2 < r * r) & (t > 0) + if not mask.any(): + continue + # Distance along ray to circle intersection + hit_dist = t[mask] - np.sqrt(r * r - d2[mask]) + hit_dist = np.maximum(hit_dist, SCAN_RANGE_MIN) + ranges[mask] = np.minimum(ranges[mask], hit_dist) + + # Add Gaussian noise (~1cm) + ranges += np.random.normal(0, 0.01, N) + ranges = np.clip(ranges, SCAN_RANGE_MIN, SCAN_RANGE_MAX) + return angles, ranges + + +def build_obstacle_list(cones, fences=True): + """cones: list of {"x":, "y":}; fences: include map boundary walls.""" + obs = [] + for c in cones: + obs.append((c["x"], c["y"], 0.10)) # cone radius ~10cm + if fences: + # Fence walls as small circles along the perimeter + for x in np.linspace(-2.5, 2.5, 20): + obs.append((x, 2.5, 0.02)) + obs.append((x, -2.5, 0.02)) + for y in np.linspace(-2.5, 2.5, 20): + obs.append((2.5, y, 0.02)) + obs.append((-2.5, y, 0.02)) + return obs + + # ── MPU6050 noise parameters ──────────────────────────────────────── GYRO_NOISE_STD = 0.01 # rad/s ACCEL_NOISE_STD = 0.05 # m/s² @@ -70,6 +125,9 @@ shared = { "cmd_steer": 0.0, # from POST /cmd "imu_json": b"{}", "state_json": b"{}", + "scan_json": b"{}", + "odom_json": b"{}", + "power_json": b"{}", "key_active": False, # True when W/A/S/D pressed } shared_lock = threading.Lock() @@ -120,6 +178,30 @@ class HTTPHandler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) + elif self.path == "/scan": + with shared_lock: + body = shared["scan_json"] + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif self.path == "/odom": + with shared_lock: + body = shared["odom_json"] + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif self.path == "/power": + with shared_lock: + body = shared["power_json"] + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) else: self.send_response(404) self.end_headers() @@ -129,8 +211,20 @@ class HTTPHandler(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) with shared_lock: - shared["cmd_speed"] = float(body.get("speed", 0)) - shared["cmd_steer"] = float(body.get("steer", 0)) + # Ackermann mode: {"speed": v, "steer": angle} + if "speed" in body or "steer" in body: + shared["cmd_speed"] = float(body.get("speed", 0)) + shared["cmd_steer"] = float(body.get("steer", 0)) + # Differential mode: {"vx":, "vy":, "wz":} → mapped to speed/steer + elif "vx" in body: + vx = float(body.get("vx", 0)) + wz = float(body.get("wz", 0)) + shared["cmd_speed"] = vx + # Convert angular.z to steer via bike model (wheelbase ≈ 0.17m) + if abs(vx) > 0.01: + shared["cmd_steer"] = math.atan(wz * L / vx) + else: + shared["cmd_steer"] = 0.0 self.send_response(200) self.end_headers() else: @@ -152,9 +246,21 @@ def main(): with RenderApp() as render: render.opt.set_left_panel_vis(True) + # Generate QR code (random 1~9999) + qr_num = random.randint(1, 9999) + qr_img = qrcode.make(str(qr_num), border=1).get_image() + qr_img = qr_img.resize((200, 200), PILImage.LANCZOS) + qr_img.save("qr_code.png") + print(f"QR code: {qr_num}") + # Load cone positions and inject into scene with open("cones.json") as f: cones = json.load(f) + + # Build obstacle list for laser scan simulation + obstacles = build_obstacle_list(cones) + obstacles.append((2.25, -0.72, 0.12)) # signboard + cone_xml = "" for i, c in enumerate(cones): cone_xml += ( @@ -205,6 +311,7 @@ def main(): speed_amp = 1.0 steer_amp = 0.35 frame = 0 + scan_points = [] # cached hit points for gizmo drawing [(x, y, z), ...] base_link = model.get_link("base_link") @@ -287,7 +394,16 @@ def main(): }).encode() def render_step(): - nonlocal speed, steer_cmd, capture_index, frame, speed_amp, steer_amp + nonlocal speed, steer_cmd, capture_index, frame, speed_amp, steer_amp, scan_points + + # Draw scan rays BEFORE sync (origin at top of car) + if scan_points: + pose = base_link.get_pose(data) + cx, cy, cz = pose[0], pose[1], pose[2] + 0.12 + green = Color.rgb(0.0, 1.0, 0.0) + green.a = 0.5 + for px, py, pz in scan_points: + render.gizmos.draw_line([cx, cy, cz], [px, py, pz], green) render.sync(data) inp = render.input @@ -327,6 +443,29 @@ def main(): with shared_lock: shared["key_active"] = key_active + # ── Laser scan (~30 Hz) ── + if frame % 2 == 0: + pose = base_link.get_pose(data) + x, y = pose[0], pose[1] + qw, qx, qy, qz = pose[3], pose[4], pose[5], pose[6] + yaw = math.atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz)) + z_scan = pose[2] + 0.12 # lidar on top of car body + angles, ranges = raycast_scan(x, y, yaw, obstacles) + scan = { + "angle_min": float(angles[0]), "angle_max": float(angles[-1]), + "angle_increment": float(angles[1] - angles[0]), + "range_min": SCAN_RANGE_MIN, "range_max": SCAN_RANGE_MAX, + "ranges": [round(float(r), 3) for r in ranges], + } + with shared_lock: + shared["scan_json"] = json.dumps(scan).encode() + # Cache hit points for gizmo (downsample 10x) + scan_points = [] + for i in range(0, len(angles), 10): + a = angles[i] + yaw + r = ranges[i] + scan_points.append((x + r * math.cos(a), y + r * math.sin(a), z_scan)) + # ── Continuous image capture (~30 Hz) ── if frame % 2 == 0: rcam = render.get_camera(0) @@ -363,15 +502,29 @@ def main(): v = base_link.get_linear_velocity(data) actual_spd = math.hypot(v[0], v[1]) with shared_lock: + omega = base_link.get_angular_velocity(data) shared["state_json"] = json.dumps({ "x": round(float(x), 3), "y": round(float(y), 3), "z": round(float(z), 3), "yaw_deg": round(math.degrees(yaw), 1), + "vx": round(float(v[0]), 3), "vy": round(float(v[1]), 3), + "wx": round(float(omega[0]), 3), "wy": round(float(omega[1]), 3), "wz": round(float(omega[2]), 3), "speed_target": round(speed, 2), "speed_actual": round(float(actual_spd), 2), "steer_target": round(steer_cmd, 3), "speed_amp": round(speed_amp, 1), "steer_amp": round(steer_amp, 3), }).encode() + # Odometry (matches nav_msgs/Odometry) + shared["odom_json"] = json.dumps({ + "x": round(float(x), 3), "y": round(float(y), 3), + "yaw": round(float(yaw), 4), + "vx": round(float(v[0]), 3), "vy": round(float(v[1]), 3), + "wz": round(float(omega[2]), 3), + "orientation": {"w": round(float(qw), 4), "x": round(float(qx), 4), + "y": round(float(qy), 4), "z": round(float(qz), 4)}, + }).encode() + # Power (simulated battery ~12V) + shared["power_json"] = json.dumps({"voltage": 12.0}).encode() print(f"[pos] x={x:+.3f} y={y:+.3f} z={z:+.3f} | yaw={math.degrees(yaw):+.1f}° " f"spd={speed:.1f}/{actual_spd:.2f} steer={steer_cmd:+.2f}", flush=True) diff --git a/pyproject.toml b/pyproject.toml index da56330..cdcd9d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,4 +7,5 @@ requires-python = ">=3.13" dependencies = [ "motrixsim-core", "pillow", + "qrcode[pil]>=8.2", ] diff --git a/qr_code.png b/qr_code.png new file mode 100644 index 0000000..e4c424f Binary files /dev/null and b/qr_code.png differ diff --git a/scene.xml b/scene.xml index 1b79334..14ea378 100644 --- a/scene.xml +++ b/scene.xml @@ -22,6 +22,9 @@ + + + @@ -54,6 +57,12 @@ material="map_mat" friction="0.6 0.1 0.1" condim="3"/> + + + + + + @@ -81,6 +90,11 @@ rgba="0.965 0.596 0.596 1"/> + + + + + diff --git a/uv.lock b/uv.lock index 3d7d841..6bc57e5 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ad/e0d3c824784ff121c03cc031f944bc7e139a8f1870ffd2845cc2dd76f6c4/absl_py-2.1.0-py3-none-any.whl", hash = "sha256:526a04eadab8b4ee719ce68f204172ead1027549089702d99b9059f129ff1308", size = 133706, upload-time = "2024-01-16T22:14:24.055Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "motrixsim-core" version = "0.8.2" @@ -33,12 +42,14 @@ source = { virtual = "." } dependencies = [ { name = "motrixsim-core" }, { name = "pillow" }, + { name = "qrcode", extra = ["pil"] }, ] [package.metadata] requires-dist = [ { name = "motrixsim-core" }, { name = "pillow" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, ] [[package]] @@ -148,3 +159,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +]