feat(visualization): render vehicle steering feedback
This commit is contained in:
@@ -31,7 +31,8 @@ from rclpy.node import Node
|
|||||||
from rclpy.parameter import Parameter
|
from rclpy.parameter import Parameter
|
||||||
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
||||||
from rclpy.time import Time
|
from rclpy.time import Time
|
||||||
from sensor_msgs.msg import LaserScan, PointCloud2
|
from sensor_msgs.msg import JointState, LaserScan, PointCloud2
|
||||||
|
from std_msgs.msg import Float64
|
||||||
from tf2_msgs.msg import TFMessage
|
from tf2_msgs.msg import TFMessage
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -129,6 +130,10 @@ class VelocityPayload(BaseModel):
|
|||||||
angular: float = 0.0
|
angular: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class SteeringPayload(BaseModel):
|
||||||
|
angle: float
|
||||||
|
|
||||||
|
|
||||||
class NavigationGoalPayload(BaseModel):
|
class NavigationGoalPayload(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
@@ -158,6 +163,9 @@ class SlamNode(Node):
|
|||||||
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
||||||
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
||||||
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan")
|
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan")
|
||||||
|
self.joint_states_topic = os.environ.get("JOINT_STATES_TOPIC", "/joint_states")
|
||||||
|
self.steering_topic = os.environ.get("STEERING_TOPIC", "/mkmini/steering_command")
|
||||||
|
self.steering_feedback_topic = os.environ.get("STEERING_FEEDBACK_TOPIC", "/mkmini/steering_angle")
|
||||||
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
|
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")
|
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
@@ -190,6 +198,9 @@ class SlamNode(Node):
|
|||||||
self._icp_odom: Optional[Odometry] = None
|
self._icp_odom: Optional[Odometry] = None
|
||||||
self._map: Optional[OccupancyGrid] = None
|
self._map: Optional[OccupancyGrid] = None
|
||||||
self._plan: Optional[NavPath] = None
|
self._plan: Optional[NavPath] = None
|
||||||
|
self._joint_positions = {}
|
||||||
|
self._steering_feedback = None
|
||||||
|
self._simulator_steering_angle = None
|
||||||
self._tf = {}
|
self._tf = {}
|
||||||
self._odom_count = 0
|
self._odom_count = 0
|
||||||
self._scan_count = 0
|
self._scan_count = 0
|
||||||
@@ -212,6 +223,7 @@ class SlamNode(Node):
|
|||||||
self._topic_status_last_refresh = 0.0
|
self._topic_status_last_refresh = 0.0
|
||||||
self._navigation = {"status": "idle", "goal_id": None}
|
self._navigation = {"status": "idle", "goal_id": None}
|
||||||
self._active_goal_handle = None
|
self._active_goal_handle = None
|
||||||
|
self._requested_initial_pose: Optional[dict] = None
|
||||||
self._lock = threading.Lock()
|
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, reliable_qos)
|
||||||
@@ -228,9 +240,12 @@ class SlamNode(Node):
|
|||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_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(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(NavPath, self.plan_topic, self._on_plan, reliable_qos)
|
||||||
|
self.create_subscription(JointState, self.joint_states_topic, self._on_joint_states, reliable_qos)
|
||||||
|
self.create_subscription(Float64, self.steering_feedback_topic, self._on_steering_feedback, reliable_qos)
|
||||||
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
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.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._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
||||||
|
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
|
||||||
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 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._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
||||||
|
|
||||||
@@ -269,6 +284,18 @@ class SlamNode(Node):
|
|||||||
message.pose.covariance[0] = 0.25
|
message.pose.covariance[0] = 0.25
|
||||||
message.pose.covariance[7] = 0.25
|
message.pose.covariance[7] = 0.25
|
||||||
message.pose.covariance[35] = 0.0685
|
message.pose.covariance[35] = 0.0685
|
||||||
|
with self._lock:
|
||||||
|
# Use this only until AMCL starts publishing map -> odom. It is a
|
||||||
|
# short-lived viewer fallback, not a replacement for live TF.
|
||||||
|
self._requested_initial_pose = {
|
||||||
|
"x": float(x),
|
||||||
|
"y": float(y),
|
||||||
|
"z": 0.0,
|
||||||
|
"yaw": float(yaw),
|
||||||
|
"frame_id": frame_id or "map",
|
||||||
|
"t": time.time(),
|
||||||
|
"expires_at": time.monotonic() + 2.0,
|
||||||
|
}
|
||||||
self._initial_pose_pub.publish(message)
|
self._initial_pose_pub.publish(message)
|
||||||
|
|
||||||
def save_map(self, name: Optional[str] = None) -> dict:
|
def save_map(self, name: Optional[str] = None) -> dict:
|
||||||
@@ -425,6 +452,22 @@ class SlamNode(Node):
|
|||||||
self._plan_count += 1
|
self._plan_count += 1
|
||||||
self._last_plan_wall_time = time.time()
|
self._last_plan_wall_time = time.time()
|
||||||
|
|
||||||
|
def _clear_plan_locked(self):
|
||||||
|
"""Discard a path that belongs to a completed or replaced navigation goal."""
|
||||||
|
self._plan = None
|
||||||
|
self._last_plan_wall_time = None
|
||||||
|
|
||||||
|
def _on_joint_states(self, msg: JointState):
|
||||||
|
with self._lock:
|
||||||
|
self._joint_positions = {
|
||||||
|
name: position
|
||||||
|
for name, position in zip(msg.name, msg.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _on_steering_feedback(self, msg: Float64):
|
||||||
|
with self._lock:
|
||||||
|
self._steering_feedback = float(msg.data)
|
||||||
|
|
||||||
def topic_status(self) -> dict:
|
def topic_status(self) -> dict:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -563,6 +606,21 @@ class SlamNode(Node):
|
|||||||
msg.angular.z = float(angular)
|
msg.angular.z = float(angular)
|
||||||
self._cmd_vel_pub.publish(msg)
|
self._cmd_vel_pub.publish(msg)
|
||||||
|
|
||||||
|
def set_simulator_steering(self, angle: float) -> None:
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
||||||
|
if not math.isfinite(angle):
|
||||||
|
raise RuntimeError("조향각이 올바르지 않습니다.")
|
||||||
|
steering_angle = max(-0.5, min(0.5, float(angle)))
|
||||||
|
command = Float64()
|
||||||
|
command.data = steering_angle
|
||||||
|
with self._lock:
|
||||||
|
self._simulator_steering_angle = steering_angle
|
||||||
|
self._steering_pub.publish(command)
|
||||||
|
drive_command = Twist()
|
||||||
|
drive_command.angular.z = steering_angle
|
||||||
|
self._cmd_vel_pub.publish(drive_command)
|
||||||
|
|
||||||
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
|
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
|
||||||
if self.read_only:
|
if self.read_only:
|
||||||
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
|
||||||
@@ -596,6 +654,7 @@ class SlamNode(Node):
|
|||||||
|
|
||||||
goal_id = bytes(goal_handle.goal_id.uuid).hex()
|
goal_id = bytes(goal_handle.goal_id.uuid).hex()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._clear_plan_locked()
|
||||||
self._navigation = {"status": "running", "goal_id": goal_id}
|
self._navigation = {"status": "running", "goal_id": goal_id}
|
||||||
self._active_goal_handle = goal_handle
|
self._active_goal_handle = goal_handle
|
||||||
goal_handle.get_result_async().add_done_callback(
|
goal_handle.get_result_async().add_done_callback(
|
||||||
@@ -631,11 +690,13 @@ class SlamNode(Node):
|
|||||||
return
|
return
|
||||||
self._navigation = {**self._navigation, "status": result_status}
|
self._navigation = {**self._navigation, "status": result_status}
|
||||||
self._active_goal_handle = None
|
self._active_goal_handle = None
|
||||||
|
self._clear_plan_locked()
|
||||||
|
|
||||||
def cancel_navigation(self, wait: bool = False) -> bool:
|
def cancel_navigation(self, wait: bool = False) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
goal_handle = self._active_goal_handle
|
goal_handle = self._active_goal_handle
|
||||||
self._navigation = {**self._navigation, "status": "canceled"}
|
self._navigation = {**self._navigation, "status": "canceled"}
|
||||||
|
self._clear_plan_locked()
|
||||||
if goal_handle is None:
|
if goal_handle is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -662,6 +723,10 @@ class SlamNode(Node):
|
|||||||
scan = self._scan
|
scan = self._scan
|
||||||
grid = self._map
|
grid = self._map
|
||||||
plan = self._plan
|
plan = self._plan
|
||||||
|
amcl_pose = self._amcl_pose
|
||||||
|
requested_initial_pose = dict(self._requested_initial_pose) if self._requested_initial_pose else None
|
||||||
|
joint_positions = dict(self._joint_positions)
|
||||||
|
steering_feedback = self._steering_feedback
|
||||||
tf_snapshot = dict(self._tf)
|
tf_snapshot = dict(self._tf)
|
||||||
navigation = dict(self._navigation)
|
navigation = dict(self._navigation)
|
||||||
effective_map = self._effective_map(grid, scan, odom)
|
effective_map = self._effective_map(grid, scan, odom)
|
||||||
@@ -671,6 +736,7 @@ class SlamNode(Node):
|
|||||||
pose_map = None
|
pose_map = None
|
||||||
scan_pose = None
|
scan_pose = None
|
||||||
scan_pose_map = None
|
scan_pose_map = None
|
||||||
|
vehicle = {}
|
||||||
if odom is not None:
|
if odom is not None:
|
||||||
pos = odom.pose.pose.position
|
pos = odom.pose.pose.position
|
||||||
ori = odom.pose.pose.orientation
|
ori = odom.pose.pose.orientation
|
||||||
@@ -703,6 +769,10 @@ class SlamNode(Node):
|
|||||||
"source_frame_id": scan.header.frame_id,
|
"source_frame_id": scan.header.frame_id,
|
||||||
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
||||||
}
|
}
|
||||||
|
# map -> base is the live localization transform and must win over
|
||||||
|
# /amcl_pose. AMCL may publish a pose only when it changes, while
|
||||||
|
# TF continues updating with odometry; preferring the message made
|
||||||
|
# the viewer appear to stop between AMCL updates.
|
||||||
map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
|
map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
|
||||||
if map_to_base is not None:
|
if map_to_base is not None:
|
||||||
pose_map = {
|
pose_map = {
|
||||||
@@ -712,10 +782,79 @@ class SlamNode(Node):
|
|||||||
"source_frame_id": odom.header.frame_id,
|
"source_frame_id": odom.header.frame_id,
|
||||||
"t": pose["t"],
|
"t": pose["t"],
|
||||||
}
|
}
|
||||||
|
elif amcl_pose is not None and amcl_pose.header.frame_id.lstrip("/") == "map":
|
||||||
|
amcl_position = amcl_pose.pose.pose.position
|
||||||
|
pose_map = {
|
||||||
|
"x": amcl_position.x,
|
||||||
|
"y": amcl_position.y,
|
||||||
|
"z": amcl_position.z,
|
||||||
|
"yaw": yaw_from_quaternion(amcl_pose.pose.pose.orientation),
|
||||||
|
"frame_id": "map",
|
||||||
|
"child_frame_id": odom.child_frame_id,
|
||||||
|
"source_frame_id": odom.header.frame_id,
|
||||||
|
"t": amcl_pose.header.stamp.sec + amcl_pose.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
requested_initial_pose
|
||||||
|
and requested_initial_pose["frame_id"].lstrip("/") == "map"
|
||||||
|
and time.monotonic() <= requested_initial_pose.get("expires_at", 0.0)
|
||||||
|
):
|
||||||
|
pose_map = {
|
||||||
|
**requested_initial_pose,
|
||||||
|
"child_frame_id": odom.child_frame_id,
|
||||||
|
"source_frame_id": odom.header.frame_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Gazebo MK-mini uses independent front steering joints. Keep
|
||||||
|
# both values so the viewer renders the Ackermann inner/outer turn.
|
||||||
|
steering_left_angle = next(
|
||||||
|
(
|
||||||
|
position
|
||||||
|
for name, position in joint_positions.items()
|
||||||
|
if name == "front_left_visual_steering_joint" or name.endswith("/front_left_visual_steering_joint")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
steering_right_angle = next(
|
||||||
|
(
|
||||||
|
position
|
||||||
|
for name, position in joint_positions.items()
|
||||||
|
if name == "front_right_visual_steering_joint" or name.endswith("/front_right_visual_steering_joint")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
steering_angle = None
|
||||||
|
if steering_left_angle is not None and steering_right_angle is not None:
|
||||||
|
vehicle["steering_left_angle"] = steering_left_angle
|
||||||
|
vehicle["steering_right_angle"] = steering_right_angle
|
||||||
|
steering_angle = (steering_left_angle + steering_right_angle) * 0.5
|
||||||
|
else:
|
||||||
|
steering_angle = next(
|
||||||
|
(
|
||||||
|
position
|
||||||
|
for name, position in joint_positions.items()
|
||||||
|
if name == "front_steering_joint" or name.endswith("/front_steering_joint")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if steering_angle is None and steering_feedback is not None:
|
||||||
|
steering_angle = steering_feedback
|
||||||
|
if steering_angle is None:
|
||||||
|
front_fork = resolve_2d_transform(
|
||||||
|
tf_snapshot,
|
||||||
|
odom.child_frame_id or "base_link",
|
||||||
|
"front_fork",
|
||||||
|
)
|
||||||
|
steering_angle = front_fork["yaw"] if front_fork is not None else None
|
||||||
|
if steering_angle is None:
|
||||||
|
steering_angle = self._simulator_steering_angle
|
||||||
|
if steering_angle is not None:
|
||||||
|
vehicle["steering_angle"] = steering_angle
|
||||||
|
|
||||||
if scan is not None and pose_map is not None:
|
if scan is not None and pose_map is not None:
|
||||||
# Resolve map -> sensor directly through the published TF graph so
|
# Prefer direct map -> sensor TF for the current scan. It carries
|
||||||
# the scan uses the same localization correction as the map.
|
# the current odometry timestamp and keeps the laser synchronized
|
||||||
|
# with the moving robot.
|
||||||
map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
|
map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
|
||||||
if map_to_scan is not None:
|
if map_to_scan is not None:
|
||||||
scan_pose_map = {
|
scan_pose_map = {
|
||||||
@@ -726,6 +865,21 @@ class SlamNode(Node):
|
|||||||
"source_frame_id": scan.header.frame_id,
|
"source_frame_id": scan.header.frame_id,
|
||||||
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
base_to_scan = resolve_2d_transform(
|
||||||
|
tf_snapshot,
|
||||||
|
odom.child_frame_id if odom is not None else "base_link",
|
||||||
|
scan.header.frame_id,
|
||||||
|
)
|
||||||
|
if base_to_scan is not None:
|
||||||
|
scan_pose_map = {
|
||||||
|
**compose_2d(pose_map, base_to_scan),
|
||||||
|
"roll": 0.0,
|
||||||
|
"pitch": 0.0,
|
||||||
|
"frame_id": "map",
|
||||||
|
"source_frame_id": scan.header.frame_id,
|
||||||
|
"t": pose_map["t"],
|
||||||
|
}
|
||||||
|
|
||||||
pose_3d = None
|
pose_3d = None
|
||||||
if icp_odom is not None:
|
if icp_odom is not None:
|
||||||
@@ -805,6 +959,7 @@ class SlamNode(Node):
|
|||||||
"scan_pose": scan_pose,
|
"scan_pose": scan_pose,
|
||||||
"scan_pose_map": scan_pose_map,
|
"scan_pose_map": scan_pose_map,
|
||||||
"pose_3d": pose_3d,
|
"pose_3d": pose_3d,
|
||||||
|
"vehicle": vehicle,
|
||||||
"scan": scan_payload,
|
"scan": scan_payload,
|
||||||
"point_cloud": point_cloud_payload,
|
"point_cloud": point_cloud_payload,
|
||||||
"map": map_payload,
|
"map": map_payload,
|
||||||
@@ -1036,6 +1191,17 @@ def cmd_vel(payload: VelocityPayload):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/steering")
|
||||||
|
def steering(payload: SteeringPayload):
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
try:
|
||||||
|
slam_node.set_simulator_steering(payload.angle)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/stop")
|
@app.post("/stop")
|
||||||
def stop():
|
def stop():
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
|
|||||||
Reference in New Issue
Block a user