Files
lms-fe/resources/slam-ros-bridge/bridge.py
T

1133 lines
44 KiB
Python

#!/usr/bin/env python3
import asyncio
import array
import base64
import json
import logging
import math
import os
import shutil
import sqlite3
import struct
import threading
import time
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import rclpy
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
from action_msgs.msg import GoalStatus
from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav2_msgs.action import NavigateToPose
from pydantic import BaseModel
from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.parameter import Parameter
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import LaserScan, PointCloud2
from tf2_msgs.msg import TFMessage
import uvicorn
logging.basicConfig(level=logging.INFO)
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
def yaw_from_quaternion(q) -> float:
return math.atan2(
2.0 * (q.w * q.z + q.x * q.y),
1.0 - 2.0 * (q.y * q.y + q.z * q.z),
)
def quaternion_from_yaw(yaw: float) -> dict:
half = yaw * 0.5
return {
"x": 0.0,
"y": 0.0,
"z": math.sin(half),
"w": math.cos(half),
}
def euler_from_quaternion(q) -> dict:
sinr_cosp = 2.0 * (q.w * q.x + q.y * q.z)
cosr_cosp = 1.0 - 2.0 * (q.x * q.x + q.y * q.y)
roll = math.atan2(sinr_cosp, cosr_cosp)
sinp = 2.0 * (q.w * q.y - q.z * q.x)
if abs(sinp) >= 1.0:
pitch = math.copysign(math.pi / 2.0, sinp)
else:
pitch = math.asin(sinp)
return {
"roll": roll,
"pitch": pitch,
"yaw": yaw_from_quaternion(q),
}
def compose_2d(a: dict, b: dict) -> dict:
c = math.cos(a["yaw"])
s = math.sin(a["yaw"])
return {
"x": a["x"] + c * b["x"] - s * b["y"],
"y": a["y"] + s * b["x"] + c * b["y"],
"z": a.get("z", 0.0) + b.get("z", 0.0),
"yaw": a["yaw"] + b["yaw"],
}
def invert_2d(transform: dict) -> dict:
"""Return the inverse of a planar TF transform."""
yaw = -transform["yaw"]
c = math.cos(yaw)
s = math.sin(yaw)
return {
"x": c * -transform["x"] - s * -transform["y"],
"y": s * -transform["x"] + c * -transform["y"],
"z": -transform.get("z", 0.0),
"yaw": yaw,
}
def resolve_2d_transform(transforms: dict, target_frame: str, source_frame: str) -> Optional[dict]:
"""Resolve target <- source through the currently published TF graph."""
if not target_frame or not source_frame:
return None
if target_frame == source_frame:
return {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0}
pending = [(target_frame, {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0})]
visited = set()
while pending:
frame, accumulated = pending.pop(0)
if frame in visited:
continue
visited.add(frame)
if frame == source_frame:
return accumulated
for (parent, child), transform in transforms.items():
if parent == frame and child not in visited:
pending.append((child, compose_2d(accumulated, transform)))
elif child == frame and parent not in visited:
pending.append((parent, compose_2d(accumulated, invert_2d(transform))))
return None
class VelocityPayload(BaseModel):
linear: float = 0.0
angular: float = 0.0
class NavigationGoalPayload(BaseModel):
x: float
y: float
yaw: float = 0.0
frame_id: str = "map"
class InitialPosePayload(NavigationGoalPayload):
pass
class SaveMapPayload(BaseModel):
name: Optional[str] = None
class SlamNode(Node):
def __init__(self):
super().__init__("slam_web_bridge")
self.set_parameters([Parameter("use_sim_time", value=True)])
self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom")
self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan")
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan")
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
best_effort_qos = QoSProfile(
depth=1,
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
)
reliable_qos = QoSProfile(
depth=10,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.VOLATILE,
)
map_qos = QoSProfile(
depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
tf_static_qos = QoSProfile(
depth=10,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
self._odom: Optional[Odometry] = None
self._scan: Optional[LaserScan] = None
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
self._point_cloud: Optional[PointCloud2] = None
self._fallback_point_cloud: Optional[PointCloud2] = None
self._icp_odom: Optional[Odometry] = None
self._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None
self._tf = {}
self._odom_count = 0
self._scan_count = 0
self._amcl_pose_count = 0
self._point_cloud_count = 0
self._fallback_point_cloud_count = 0
self._icp_odom_count = 0
self._map_count = 0
self._tf_count = 0
self._last_odom_wall_time = None
self._last_scan_wall_time = None
self._last_amcl_pose_wall_time = None
self._last_point_cloud_wall_time = None
self._last_fallback_point_cloud_wall_time = None
self._last_icp_odom_wall_time = None
self._last_map_wall_time = None
self._plan_count = 0
self._last_plan_wall_time = None
self._topic_status_cache = {}
self._topic_status_last_refresh = 0.0
self._navigation = {"status": "idle", "goal_id": None}
self._active_goal_handle = None
self._lock = threading.Lock()
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
if self.fallback_point_cloud_topic != self.point_cloud_topic:
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, best_effort_qos)
self.create_subscription(NavPath, self.plan_topic, self._on_plan, reliable_qos)
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
self.get_logger().info(
f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, map={self.map_topic}, read_only={self.read_only}"
)
def _on_odom(self, msg: Odometry):
with self._lock:
self._odom = msg
self._odom_count += 1
self._last_odom_wall_time = time.time()
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None:
# AMCL transforms the initial pose at the message timestamp. On a real
# platform the bridge clock can be a few milliseconds ahead of the
# latest odom/TF sample, which makes AMCL reject the pose as a future
# extrapolation. Anchor it to the latest received odometry instead.
with self._lock:
odom_stamp = self._odom.header.stamp if self._odom is not None else None
message = PoseWithCovarianceStamped()
if odom_stamp:
# The odom callback can lead the TF buffer by a few milliseconds.
# Keep a small margin so AMCL can always transform map -> odom.
message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg()
else:
message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = frame_id
message.pose.pose.position.x = x
message.pose.pose.position.y = y
orientation = quaternion_from_yaw(yaw)
message.pose.pose.orientation.x = orientation["x"]
message.pose.pose.orientation.y = orientation["y"]
message.pose.pose.orientation.z = orientation["z"]
message.pose.pose.orientation.w = orientation["w"]
message.pose.covariance[0] = 0.25
message.pose.covariance[7] = 0.25
message.pose.covariance[35] = 0.0685
self._initial_pose_pub.publish(message)
def save_map(self, name: Optional[str] = None) -> dict:
with self._lock:
grid = self._map
if grid is None or not grid.data:
raise ValueError("저장할 SLAM 맵이 없습니다.")
width = grid.info.width
height = grid.info.height
resolution = grid.info.resolution
origin = grid.info.origin
data = list(grid.data)
if width <= 0 or height <= 0 or len(data) < width * height:
raise ValueError("저장할 SLAM 맵 데이터가 올바르지 않습니다.")
safe_name = "".join(char if char.isalnum() or char in "-_" else "-" for char in (name or "").strip())
if not safe_name:
safe_name = time.strftime("slam-map-%Y%m%d-%H%M%S")
maps_dir = Path(os.environ.get("SAVED_MAPS_PATH", "/workspace/maps"))
maps_dir.mkdir(parents=True, exist_ok=True)
pgm_path = maps_dir / f"{safe_name}.pgm"
yaml_path = maps_dir / f"{safe_name}.yaml"
# OccupancyGrid starts at the lower-left origin; PGM rows start at the top.
pixels = bytearray()
for row in range(height - 1, -1, -1):
for column in range(width):
value = data[row * width + column]
pixels.append(0 if value >= 50 else 254 if value == 0 else 205)
pgm_path.write_bytes(f"P5\n{width} {height}\n255\n".encode("ascii") + pixels)
# Keep a compact copy with the map record so the LMS can render an
# actual saved-map preview without accessing the student's workspace.
preview_limit = 192
preview_scale = max(1, math.ceil(width / preview_limit), math.ceil(height / preview_limit))
preview_width = math.ceil(width / preview_scale)
preview_height = math.ceil(height / preview_scale)
preview_pixels = bytearray()
for preview_row in range(preview_height):
source_top = preview_row * preview_scale
source_bottom = min(height, source_top + preview_scale)
for preview_column in range(preview_width):
source_left = preview_column * preview_scale
source_right = min(width, source_left + preview_scale)
# Preserve occupied cells while reducing the grid: black is
# lower than unknown/free in the generated PGM palette.
preview_pixels.append(min(
pixels[row * width + column]
for row in range(source_top, source_bottom)
for column in range(source_left, source_right)
))
preview_pgm = f"P5\n{preview_width} {preview_height}\n255\n".encode("ascii") + preview_pixels
yaml_path.write_text(
"\n".join([
f"image: {pgm_path.name}",
"mode: trinary",
f"resolution: {resolution}",
f"origin: [{origin.position.x}, {origin.position.y}, {yaw_from_quaternion(origin.orientation)}]",
"negate: 0",
"occupied_thresh: 0.65",
"free_thresh: 0.25",
"",
]),
encoding="utf-8",
)
# Keep the RTAB-Map database beside the Nav2-compatible PGM/YAML map.
# The live database is reset when a new SLAM session starts, so each
# explicit save receives its own SQLite-consistent snapshot.
database_path = None
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file():
database_snapshot = maps_dir / f"{safe_name}.db"
source_connection = None
destination_connection = None
try:
source_connection = sqlite3.connect(f"file:{database_source}?mode=ro", uri=True)
destination_connection = sqlite3.connect(str(database_snapshot))
source_connection.backup(destination_connection)
except sqlite3.Error:
if destination_connection is not None:
destination_connection.close()
destination_connection = None
shutil.copy2(database_source, database_snapshot)
finally:
if destination_connection is not None:
destination_connection.close()
if source_connection is not None:
source_connection.close()
database_path = str(database_snapshot)
return {
"name": safe_name,
"yamlPath": str(yaml_path),
"pgmPath": str(pgm_path),
"databasePath": database_path,
"resolution": resolution,
"width": width,
"height": height,
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
}
def _on_scan(self, msg: LaserScan):
with self._lock:
self._scan = msg
self._scan_count += 1
self._last_scan_wall_time = time.time()
def _on_amcl_pose(self, msg: PoseWithCovarianceStamped):
with self._lock:
self._amcl_pose = msg
self._amcl_pose_count += 1
self._last_amcl_pose_wall_time = time.time()
def _on_point_cloud(self, msg: PointCloud2):
with self._lock:
self._point_cloud = msg
self._point_cloud_count += 1
self._last_point_cloud_wall_time = time.time()
def _on_fallback_point_cloud(self, msg: PointCloud2):
with self._lock:
self._fallback_point_cloud = msg
self._fallback_point_cloud_count += 1
self._last_fallback_point_cloud_wall_time = time.time()
def _active_point_cloud(self) -> Optional[PointCloud2]:
with self._lock:
primary = self._point_cloud
fallback = self._fallback_point_cloud
# A global RTAB-Map cloud is the visualization source. Raw LiDAR is
# only a startup fallback before the first global map is received.
if primary is not None:
return primary
return fallback
def _on_icp_odom(self, msg: Odometry):
with self._lock:
self._icp_odom = msg
self._icp_odom_count += 1
self._last_icp_odom_wall_time = time.time()
def _on_map(self, msg: OccupancyGrid):
with self._lock:
self._map = msg
self._map_count += 1
self._last_map_wall_time = time.time()
def _on_plan(self, msg: NavPath):
with self._lock:
self._plan = msg
self._plan_count += 1
self._last_plan_wall_time = time.time()
def topic_status(self) -> dict:
now = time.time()
with self._lock:
if now - self._topic_status_last_refresh < 1.0:
return dict(self._topic_status_cache)
scan_last = self._last_scan_wall_time
odom_last = self._last_odom_wall_time
amcl_pose_last = self._last_amcl_pose_wall_time
point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
map_count = self._map_count
plan_count = self._plan_count
def received_recently(last_received: Optional[float]) -> bool:
return last_received is not None and now - last_received <= 3.0
try:
plan_available = plan_count > 0 or self.count_publishers(self.plan_topic) > 0
cmd_vel_available = self.count_subscribers(self.cmd_vel_topic) > 0
navigation_available = self._navigate_to_pose_client.server_is_ready()
except Exception:
plan_available = plan_count > 0
cmd_vel_available = False
navigation_available = False
status = {
"scan": {"name": self.scan_topic, "available": received_recently(scan_last)},
"odom": {"name": self.odom_topic, "available": received_recently(odom_last)},
"map": {"name": self.map_topic, "available": map_count > 0},
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
"point_cloud": {
"name": self.point_cloud_topic,
"available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last),
},
"plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
"navigate_to_pose": {"name": self.navigate_to_pose_action, "available": navigation_available},
}
with self._lock:
self._topic_status_cache = status
self._topic_status_last_refresh = now
return dict(status)
@staticmethod
def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool:
return bool(grid and grid.info.width > 0 and grid.info.height > 0 and grid.data)
@staticmethod
def _initial_scan_map(scan: Optional[LaserScan], odom: Optional[Odometry]) -> Optional[dict]:
if scan is None or not scan.ranges:
return None
# Show the first LiDAR sweep before slam_toolbox has produced an occupancy grid.
resolution = 0.05
width = 160
height = 160
origin_x = (odom.pose.pose.position.x if odom else 0.0) - (width * resolution / 2.0)
origin_y = (odom.pose.pose.position.y if odom else 0.0) - (height * resolution / 2.0)
robot_x = odom.pose.pose.position.x if odom else 0.0
robot_y = odom.pose.pose.position.y if odom else 0.0
robot_yaw = yaw_from_quaternion(odom.pose.pose.orientation) if odom else 0.0
data = array.array("b", [-1]) * (width * height)
def to_cell(x: float, y: float) -> tuple[int, int]:
return int((x - origin_x) / resolution), int((y - origin_y) / resolution)
for index, distance in enumerate(scan.ranges):
if not math.isfinite(distance) or distance < scan.range_min or distance >= scan.range_max:
continue
angle = robot_yaw + scan.angle_min + index * scan.angle_increment
steps = max(1, int(distance / resolution))
for step in range(0, steps, 2):
x, y = to_cell(
robot_x + math.cos(angle) * step * resolution,
robot_y + math.sin(angle) * step * resolution,
)
if 0 <= x < width and 0 <= y < height:
data[y * width + x] = 0
x, y = to_cell(
robot_x + math.cos(angle) * distance,
robot_y + math.sin(angle) * distance,
)
if 0 <= x < width and 0 <= y < height:
data[y * width + x] = 100
return {
"frame_id": odom.header.frame_id if odom else "odom",
"width": width,
"height": height,
"resolution": resolution,
"origin": {"x": origin_x, "y": origin_y, "z": 0.0, "yaw": 0.0},
"data": data,
"t": time.time(),
}
def _effective_map(self, grid: Optional[OccupancyGrid], scan: Optional[LaserScan], odom: Optional[Odometry]) -> Optional[dict]:
if self._has_populated_map(grid):
origin = grid.info.origin
return {
"frame_id": grid.header.frame_id,
"width": grid.info.width,
"height": grid.info.height,
"resolution": grid.info.resolution,
"origin": {
"x": origin.position.x,
"y": origin.position.y,
"z": origin.position.z,
"yaw": yaw_from_quaternion(origin.orientation),
},
"data": array.array("b", grid.data),
"t": grid.header.stamp.sec + grid.header.stamp.nanosec * 1e-9,
}
return self._initial_scan_map(scan, odom)
def _on_tf(self, msg: TFMessage):
with self._lock:
for transform in msg.transforms:
t = transform.transform.translation
r = transform.transform.rotation
self._tf[(transform.header.frame_id, transform.child_frame_id)] = {
"parent": transform.header.frame_id,
"child": transform.child_frame_id,
"x": t.x,
"y": t.y,
"z": t.z,
"yaw": yaw_from_quaternion(r),
"t": transform.header.stamp.sec + transform.header.stamp.nanosec * 1e-9,
}
self._tf_count += 1
def publish_cmd_vel(self, linear: float, angular: float):
if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
msg = Twist()
msg.linear.x = float(linear)
msg.angular.z = float(angular)
self._cmd_vel_pub.publish(msg)
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
if not self._navigate_to_pose_client.wait_for_server(timeout_sec=2.0):
raise RuntimeError(f"NavigateToPose action server is not available: {self.navigate_to_pose_action}")
goal_msg = NavigateToPose.Goal()
goal_msg.pose = PoseStamped()
goal_msg.pose.header.frame_id = frame_id or "map"
goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
goal_msg.pose.pose.position.x = float(x)
goal_msg.pose.pose.position.y = float(y)
goal_msg.pose.pose.position.z = 0.0
q = quaternion_from_yaw(float(yaw))
goal_msg.pose.pose.orientation.x = q["x"]
goal_msg.pose.pose.orientation.y = q["y"]
goal_msg.pose.pose.orientation.z = q["z"]
goal_msg.pose.pose.orientation.w = q["w"]
future = self._navigate_to_pose_client.send_goal_async(goal_msg)
deadline = time.time() + 3.0
while not future.done() and time.time() < deadline:
time.sleep(0.02)
if not future.done():
raise RuntimeError("NavigateToPose goal request timed out")
goal_handle = future.result()
if not goal_handle.accepted:
return {"ok": False, "accepted": False, "message": "Navigation goal was rejected"}
goal_id = bytes(goal_handle.goal_id.uuid).hex()
with self._lock:
self._navigation = {"status": "running", "goal_id": goal_id}
self._active_goal_handle = goal_handle
goal_handle.get_result_async().add_done_callback(
lambda result_future, expected_goal_id=goal_id: self._on_navigation_result(
result_future, expected_goal_id
)
)
return {
"ok": True,
"accepted": True,
"goal_id": goal_id,
}
def _on_navigation_result(self, future, goal_id: str):
try:
status = future.result().status
except Exception:
self.get_logger().warning("NavigateToPose result could not be read")
return
if status == GoalStatus.STATUS_SUCCEEDED:
result_status = "succeeded"
elif status == GoalStatus.STATUS_CANCELED:
result_status = "canceled"
else:
result_status = "failed"
with self._lock:
# A previous goal can finish after a newer goal has been accepted.
# Do not let that completion overwrite the current navigation state.
if self._navigation.get("goal_id") != goal_id:
return
self._navigation = {**self._navigation, "status": result_status}
self._active_goal_handle = None
def cancel_navigation(self, wait: bool = False) -> bool:
with self._lock:
goal_handle = self._active_goal_handle
self._navigation = {**self._navigation, "status": "canceled"}
if goal_handle is None:
return True
cancel_future = goal_handle.cancel_goal_async()
if not wait:
return True
deadline = time.time() + 2.0
while not cancel_future.done() and time.time() < deadline:
time.sleep(0.02)
if not cancel_future.done():
return False
try:
cancel_future.result()
return True
except Exception:
self.get_logger().warning("NavigateToPose goal cancellation could not be confirmed")
return False
def snapshot(self, include_map: bool = True) -> dict:
with self._lock:
odom = self._odom
icp_odom = self._icp_odom
scan = self._scan
grid = self._map
plan = self._plan
tf_snapshot = dict(self._tf)
navigation = dict(self._navigation)
effective_map = self._effective_map(grid, scan, odom)
point_cloud = self._active_point_cloud()
pose = None
pose_map = None
scan_pose = None
scan_pose_map = None
if odom is not None:
pos = odom.pose.pose.position
ori = odom.pose.pose.orientation
vel = odom.twist.twist
euler = euler_from_quaternion(ori)
pose = {
"frame_id": odom.header.frame_id,
"child_frame_id": odom.child_frame_id,
"x": pos.x,
"y": pos.y,
"z": pos.z,
"roll": euler["roll"],
"pitch": euler["pitch"],
"yaw": euler["yaw"],
"linear": vel.linear.x,
"angular": vel.angular.z,
"t": odom.header.stamp.sec + odom.header.stamp.nanosec * 1e-9,
}
odom_to_scan = (
resolve_2d_transform(tf_snapshot, odom.header.frame_id, scan.header.frame_id)
if scan is not None
else None
)
if odom_to_scan is not None:
scan_pose = {
**odom_to_scan,
"roll": 0.0,
"pitch": 0.0,
"frame_id": pose["frame_id"],
"source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
}
map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
if map_to_base is not None:
pose_map = {
**map_to_base,
"frame_id": "map",
"child_frame_id": odom.child_frame_id,
"source_frame_id": odom.header.frame_id,
"t": pose["t"],
}
if scan is not None and pose_map is not None:
# Resolve map -> sensor directly through the published TF graph so
# the scan uses the same localization correction as the map.
map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
if map_to_scan is not None:
scan_pose_map = {
**map_to_scan,
"roll": 0.0,
"pitch": 0.0,
"frame_id": "map",
"source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
}
pose_3d = None
if icp_odom is not None:
pos = icp_odom.pose.pose.position
ori = icp_odom.pose.pose.orientation
euler = euler_from_quaternion(ori)
pose_3d = {
"frame_id": icp_odom.header.frame_id,
"child_frame_id": icp_odom.child_frame_id,
"x": pos.x,
"y": pos.y,
"z": pos.z,
"roll": euler["roll"],
"pitch": euler["pitch"],
"yaw": euler["yaw"],
"t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9,
}
cloud_frame = point_cloud.header.frame_id if point_cloud is not None else ""
map_to_icp_odom = tf_snapshot.get((cloud_frame, icp_odom.header.frame_id)) if cloud_frame else None
if map_to_icp_odom is not None:
# RTAB-Map publishes /cloud_map in map, while ICP odometry is
# expressed in icp_odom. Render both in the cloud frame.
pose_3d = {
**pose_3d,
**compose_2d(map_to_icp_odom, pose_3d),
"frame_id": cloud_frame,
"source_frame_id": icp_odom.header.frame_id,
}
scan_payload = None
if scan is not None:
scan_payload = {
"frame_id": scan.header.frame_id,
"angle_min": scan.angle_min,
"angle_max": scan.angle_max,
"angle_increment": scan.angle_increment,
"range_min": scan.range_min,
"range_max": scan.range_max,
"count": len(scan.ranges),
"encoding": "binary-float32-scan1",
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
}
map_payload = None
if include_map and effective_map is not None:
map_payload = {
"frame_id": effective_map["frame_id"],
"width": effective_map["width"],
"height": effective_map["height"],
"resolution": effective_map["resolution"],
"origin": effective_map["origin"],
"encoding": "binary-int8-map1",
"t": effective_map["t"],
}
point_cloud_payload = None
if point_cloud is not None:
point_cloud_payload = {
"frame_id": point_cloud.header.frame_id,
"count": point_cloud.width * point_cloud.height,
"t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9,
}
return {
"ok": True,
"topics": {
"odom": self.odom_topic,
"scan": self.scan_topic,
"point_cloud": self.point_cloud_topic,
"icp_odom": self.icp_odom_topic,
"map": self.map_topic,
"cmd_vel": self.cmd_vel_topic,
"navigate_to_pose": self.navigate_to_pose_action,
},
"pose": pose,
"pose_map": pose_map,
"scan_pose": scan_pose,
"scan_pose_map": scan_pose_map,
"pose_3d": pose_3d,
"scan": scan_payload,
"point_cloud": point_cloud_payload,
"map": map_payload,
"tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
},
"navigation": navigation,
"topic_status": self.topic_status(),
"plan": {
"frame_id": plan.header.frame_id,
"poses": [
{
"x": pose.pose.position.x,
"y": pose.pose.position.y,
"yaw": yaw_from_quaternion(pose.pose.orientation),
}
for pose in plan.poses
],
} if plan is not None else None,
"server_time": time.time(),
}
def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]:
cloud = self._active_point_cloud()
if cloud is None or cloud.point_step <= 0 or not cloud.data:
return None
offsets = {field.name: field.offset for field in cloud.fields}
if not all(axis in offsets for axis in ("x", "y", "z")):
return None
total = cloud.width * cloud.height
if total <= 0:
return None
stride = max(1, math.ceil(total / max(1, limit)))
prefix = ">" if cloud.is_bigendian else "<"
data = cloud.data
points = []
for index in range(0, total, stride):
offset = index * cloud.point_step
if offset + cloud.point_step > len(data):
break
try:
x = struct.unpack_from(f"{prefix}f", data, offset + offsets["x"])[0]
y = struct.unpack_from(f"{prefix}f", data, offset + offsets["y"])[0]
z = struct.unpack_from(f"{prefix}f", data, offset + offsets["z"])[0]
except struct.error:
break
if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):
points.append([x, y, z])
return {
"frame_id": cloud.header.frame_id,
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
"points": points,
}
def map_binary(self) -> Optional[bytes]:
with self._lock:
grid = self._map
scan = self._scan
odom = self._odom
effective_map = self._effective_map(grid, scan, odom)
if effective_map is None:
return None
header = struct.pack("<4sII", b"MAP1", effective_map["width"], effective_map["height"])
data = effective_map["data"].tobytes()
return header + data
def scan_binary(self) -> Optional[bytes]:
with self._lock:
scan = self._scan
if scan is None:
return None
values = array.array(
"f",
[
math.nan if math.isinf(v) or math.isnan(v) else float(v)
for v in scan.ranges
],
)
if values.itemsize != 4:
values = array.array("f", values.tolist())
header = struct.pack("<4sI", b"SCN1", len(values))
return header + values.tobytes()
def topic_debug(self) -> dict:
topics = {}
for name, types in self.get_topic_names_and_types():
if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.map_topic, self.cmd_vel_topic):
topics[name] = types
return {
"configured": {
"odom": self.odom_topic,
"scan": self.scan_topic,
"point_cloud": self.point_cloud_topic,
"fallback_point_cloud": self.fallback_point_cloud_topic,
"icp_odom": self.icp_odom_topic,
"map": self.map_topic,
"cmd_vel": self.cmd_vel_topic,
"navigate_to_pose": self.navigate_to_pose_action,
},
"visible": topics,
"scan_publishers": self.count_publishers(self.scan_topic),
"point_cloud_publishers": self.count_publishers(self.point_cloud_topic),
"fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic),
"icp_odom_publishers": self.count_publishers(self.icp_odom_topic),
"odom_publishers": self.count_publishers(self.odom_topic),
"map_publishers": self.count_publishers(self.map_topic),
"cmd_vel_subscribers": self.count_subscribers(self.cmd_vel_topic),
"received": {
"scan_count": self._scan_count,
"point_cloud_count": self._point_cloud_count,
"fallback_point_cloud_count": self._fallback_point_cloud_count,
"icp_odom_count": self._icp_odom_count,
"odom_count": self._odom_count,
"map_count": self._map_count,
"tf_count": self._tf_count,
"last_scan_wall_time": self._last_scan_wall_time,
"last_point_cloud_wall_time": self._last_point_cloud_wall_time,
"last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time,
"last_icp_odom_wall_time": self._last_icp_odom_wall_time,
"last_odom_wall_time": self._last_odom_wall_time,
"last_map_wall_time": self._last_map_wall_time,
},
}
slam_node: Optional[SlamNode] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global slam_node
rclpy.init()
slam_node = SlamNode()
def spin():
executor = MultiThreadedExecutor(num_threads=2)
executor.add_node(slam_node)
try:
executor.spin()
except Exception:
logging.error(traceback.format_exc())
thread = threading.Thread(target=spin, daemon=True)
thread.start()
yield
if rclpy.ok():
rclpy.shutdown()
app = FastAPI(title="SLAM ROS Bridge", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
snap = slam_node.snapshot() if slam_node else {}
return {
"ok": True,
"has_pose": bool(snap.get("pose")),
"has_scan": bool(snap.get("scan")),
"has_point_cloud": bool(snap.get("point_cloud")),
"has_map": bool(snap.get("map")),
"topics": snap.get("topics", {}),
"topic_status": snap.get("topic_status", {}),
}
@app.get("/state")
def state():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
return slam_node.snapshot()
@app.get("/point-cloud")
def point_cloud():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
payload = slam_node.point_cloud_payload()
if payload is None:
raise HTTPException(status_code=404, detail="포인트클라우드를 아직 수신하지 못했습니다.")
return payload
@app.get("/world-layout")
def world_layout():
path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json"))
try:
parsed = json.loads(path.read_text(encoding="utf-8"))
objects = parsed.get("objects", []) if isinstance(parsed, dict) else []
return {"version": 1, "objects": objects if isinstance(objects, list) else []}
except (FileNotFoundError, OSError, json.JSONDecodeError):
return {"version": 1, "objects": []}
@app.post("/save-map")
def save_map(payload: SaveMapPayload):
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
try:
return {"ok": True, "map": slam_node.save_map(payload.name)}
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.get("/debug/topics")
def debug_topics():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
return slam_node.topic_debug()
@app.post("/cmd_vel")
def cmd_vel(payload: VelocityPayload):
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
slam_node.publish_cmd_vel(payload.linear, payload.angular)
return {"ok": True}
@app.post("/stop")
def stop():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
try:
slam_node.publish_cmd_vel(0.0, 0.0)
except RuntimeError as error:
raise HTTPException(status_code=403, detail=str(error)) from error
return {"ok": True}
@app.post("/navigate_to_pose")
def navigate_to_pose(payload: NavigationGoalPayload):
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
try:
result = slam_node.send_navigation_goal(payload.x, payload.y, payload.yaw, payload.frame_id)
except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
return result
@app.post("/cancel_navigation")
def cancel_navigation():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
if not slam_node.cancel_navigation(wait=True):
raise HTTPException(status_code=504, detail="기존 자율주행 목표 취소 시간이 초과되었습니다.")
return {"ok": True}
@app.post("/initialpose")
def initial_pose(payload: InitialPosePayload):
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
if slam_node.read_only:
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
return {"ok": True}
@app.websocket("/ws/state")
async def ws_state(websocket: WebSocket):
await websocket.accept()
last_map_sent = 0.0
try:
while True:
if slam_node:
now = time.time()
include_map = now - last_map_sent >= 1.0
await websocket.send_json(slam_node.snapshot(include_map=include_map))
scan_bytes = slam_node.scan_binary()
if scan_bytes is not None:
await websocket.send_bytes(scan_bytes)
if include_map:
map_bytes = slam_node.map_binary()
if map_bytes is not None:
await websocket.send_bytes(map_bytes)
last_map_sent = now
await asyncio.sleep(1.0 / 15.0)
except (WebSocketDisconnect, Exception):
pass
@app.websocket("/ws/control")
async def ws_control(websocket: WebSocket):
await websocket.accept()
try:
while True:
payload = await websocket.receive_json()
if not slam_node or payload.get("type") != "cmd_vel":
continue
try:
velocity = VelocityPayload(
linear=payload.get("linear", 0.0),
angular=payload.get("angular", 0.0),
)
except (TypeError, ValueError):
continue
try:
slam_node.publish_cmd_vel(velocity.linear, velocity.angular)
except RuntimeError:
continue
except WebSocketDisconnect:
pass
if __name__ == "__main__":
uvicorn.run(
app,
host="0.0.0.0",
port=int(os.environ.get("PORT", "8765")),
log_level=os.environ.get("LOG_LEVEL", "info"),
)