commit
56a48b4e25
@ -39,6 +39,7 @@ SLAM_PARAMS_FILE = '/config/slam_toolbox_params.yaml'
|
||||
def generate_launch_description():
|
||||
bringup_share = get_package_share_directory('saltybot_bringup')
|
||||
slam_share = get_package_share_directory('slam_toolbox')
|
||||
mapping_share = get_package_share_directory('saltybot_mapping')
|
||||
|
||||
sensors_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
@ -56,7 +57,15 @@ def generate_launch_description():
|
||||
}.items(),
|
||||
)
|
||||
|
||||
# Map persistence service for SLAM Toolbox
|
||||
persistence_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(mapping_share, 'launch', 'slam_toolbox_persistence.launch.py')
|
||||
),
|
||||
)
|
||||
|
||||
return LaunchDescription([
|
||||
sensors_launch,
|
||||
slam_launch,
|
||||
persistence_launch,
|
||||
])
|
||||
|
||||
@ -27,6 +27,12 @@ install(PROGRAMS
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
ament_python_install_scripts(
|
||||
"saltybot_mapping/map_manager_node.py"
|
||||
"saltybot_mapping/slam_toolbox_persistence.py"
|
||||
DESTINATION lib/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
install(DIRECTORY launch config
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
"""
|
||||
slam_toolbox_persistence.launch.py — Launch map persistence service for slam_toolbox.
|
||||
|
||||
Usage:
|
||||
ros2 launch saltybot_mapping slam_toolbox_persistence.launch.py
|
||||
|
||||
Parameters:
|
||||
maps_dir: Directory for saving/loading .posegraph files
|
||||
exports_dir: Directory for exported PGM/YAML files
|
||||
autosave_interval: Interval between auto-saves (seconds)
|
||||
autoload_on_startup: Whether to load most recent map on startup
|
||||
keep_autosaves_n: Number of autosave files to keep
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch_ros.actions import Node
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
pkg_dir = get_package_share_directory('saltybot_mapping')
|
||||
maps_dir = str(Path.home() / 'saltybot-data' / 'maps')
|
||||
exports_dir = str(Path.home() / 'saltybot-data' / 'maps' / 'exports')
|
||||
|
||||
return LaunchDescription([
|
||||
DeclareLaunchArgument(
|
||||
'maps_dir',
|
||||
default_value=maps_dir,
|
||||
description='Directory for saving/loading maps',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'exports_dir',
|
||||
default_value=exports_dir,
|
||||
description='Directory for exported maps (PGM/YAML/PLY)',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'autosave_interval',
|
||||
default_value='300.0',
|
||||
description='Auto-save interval in seconds (default 5 min)',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'autoload_on_startup',
|
||||
default_value='true',
|
||||
description='Auto-load most recent map on startup',
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
'keep_autosaves_n',
|
||||
default_value='5',
|
||||
description='Number of autosaves to keep',
|
||||
),
|
||||
|
||||
Node(
|
||||
package='saltybot_mapping',
|
||||
executable='slam_toolbox_persistence.py',
|
||||
name='slam_toolbox_persistence',
|
||||
output='screen',
|
||||
parameters=[
|
||||
{
|
||||
'maps_dir': LaunchConfiguration('maps_dir'),
|
||||
'exports_dir': LaunchConfiguration('exports_dir'),
|
||||
'autosave_interval': LaunchConfiguration('autosave_interval'),
|
||||
'autoload_on_startup': LaunchConfiguration('autoload_on_startup'),
|
||||
'keep_autosaves_n': LaunchConfiguration('keep_autosaves_n'),
|
||||
},
|
||||
],
|
||||
),
|
||||
])
|
||||
@ -19,6 +19,7 @@
|
||||
<depend>sensor_msgs</depend>
|
||||
<depend>nav_msgs</depend>
|
||||
<depend>builtin_interfaces</depend>
|
||||
<depend>slam_toolbox</depend>
|
||||
|
||||
<exec_depend>python3-numpy</exec_depend>
|
||||
<exec_depend>python3-yaml</exec_depend>
|
||||
|
||||
@ -0,0 +1,330 @@
|
||||
"""
|
||||
slam_toolbox_persistence.py — SLAM Toolbox map serialization and persistence.
|
||||
|
||||
Responsibilities:
|
||||
1. Auto-save SLAM Toolbox map to ~/saltybot-data/maps/ with timestamp (5 min intervals)
|
||||
2. Auto-load most recent map on startup if available
|
||||
3. Serialize/deserialize via slam_toolbox built-in ROS services
|
||||
4. Export maps to Nav2-compatible YAML+PGM format on demand
|
||||
5. Manage map files and metadata
|
||||
|
||||
Map lifecycle with slam_toolbox:
|
||||
- slam_toolbox publishes /map (OccupancyGrid) during mapping
|
||||
- This node calls /slam_toolbox/serialize to save the map state
|
||||
- On startup, auto-loads the most recent serialized map via /slam_toolbox/deserialize
|
||||
- Maps are stored as .posegraph (binary serialization format)
|
||||
|
||||
ROS2 services exposed:
|
||||
/saltybot/save_map (saltybot_mapping/SaveMap) — Manual save with name
|
||||
/saltybot/load_map (saltybot_mapping/LoadMap) — Manual load by name
|
||||
/saltybot/list_maps (saltybot_mapping/ListMaps) — List available maps
|
||||
/saltybot/export_occupancy (saltybot_mapping/ExportOccupancy) — Export to PGM+YAML
|
||||
|
||||
ROS2 topics published:
|
||||
/mapping/slam_status (std_msgs/String, JSON) — Map metadata & status
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
|
||||
|
||||
from std_msgs.msg import String
|
||||
from nav_msgs.msg import OccupancyGrid
|
||||
from slam_toolbox.srv import SerializePose, DeserializePose
|
||||
|
||||
from saltybot_mapping.srv import SaveMap, LoadMap, ListMaps, ExportOccupancy
|
||||
from .map_exporter import write_occupancy_export
|
||||
|
||||
|
||||
class SlamToolboxPersistenceNode(Node):
|
||||
"""Manage SLAM Toolbox map serialization and persistence."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('slam_toolbox_persistence')
|
||||
|
||||
# ── Parameters ──────────────────────────────────────────────────────
|
||||
self.declare_parameter('maps_dir', str(Path.home() / 'saltybot-data' / 'maps'))
|
||||
self.declare_parameter('exports_dir', str(Path.home() / 'saltybot-data' / 'maps' / 'exports'))
|
||||
self.declare_parameter('autosave_interval', 300.0) # 5 minutes
|
||||
self.declare_parameter('autoload_on_startup', True)
|
||||
self.declare_parameter('keep_autosaves_n', 5)
|
||||
|
||||
self._maps_dir = Path(self.get_parameter('maps_dir').value)
|
||||
self._exports_dir = Path(self.get_parameter('exports_dir').value)
|
||||
self._autosave_interval = self.get_parameter('autosave_interval').value
|
||||
self._autoload_on_startup = self.get_parameter('autoload_on_startup').value
|
||||
self._keep_autosaves_n = self.get_parameter('keep_autosaves_n').value
|
||||
|
||||
# Create directories if they don't exist
|
||||
self._maps_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# State tracking
|
||||
self._last_occupancy_msg: Optional[OccupancyGrid] = None
|
||||
self._last_autosave_time = time.time()
|
||||
self._slam_ready = False
|
||||
|
||||
# ── Wait for slam_toolbox services ──────────────────────────────────
|
||||
self.get_logger().info('Waiting for slam_toolbox services...')
|
||||
self._serialize_client = self.create_client(SerializePose, '/slam_toolbox/serialize_map')
|
||||
self._deserialize_client = self.create_client(DeserializePose, '/slam_toolbox/deserialize_map')
|
||||
|
||||
# Wait with timeout
|
||||
if not self._serialize_client.wait_for_service(timeout_sec=30.0):
|
||||
self.get_logger().error('slam_toolbox/serialize service not available!')
|
||||
if not self._deserialize_client.wait_for_service(timeout_sec=30.0):
|
||||
self.get_logger().error('slam_toolbox/deserialize service not available!')
|
||||
|
||||
self._slam_ready = True
|
||||
self.get_logger().info(f'slam_toolbox services ready. maps_dir={self._maps_dir}')
|
||||
|
||||
# ── Subscriptions ────────────────────────────────────────────────────
|
||||
transient_local = QoSProfile(
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
)
|
||||
|
||||
self.create_subscription(
|
||||
OccupancyGrid, '/map',
|
||||
self._on_occupancy_grid, transient_local)
|
||||
|
||||
# ── Publishers ───────────────────────────────────────────────────────
|
||||
self._pub_status = self.create_publisher(String, '/mapping/slam_status', 10)
|
||||
|
||||
# ── Services ─────────────────────────────────────────────────────────
|
||||
self.create_service(SaveMap, '/saltybot/save_map', self._handle_save_map)
|
||||
self.create_service(LoadMap, '/saltybot/load_map', self._handle_load_map)
|
||||
self.create_service(ListMaps, '/saltybot/list_maps', self._handle_list_maps)
|
||||
self.create_service(ExportOccupancy, '/saltybot/export_occupancy',
|
||||
self._handle_export_occupancy)
|
||||
|
||||
# ── Timers ───────────────────────────────────────────────────────────
|
||||
self.create_timer(1.0, self._status_tick)
|
||||
self.create_timer(self._autosave_interval, self._autosave_tick)
|
||||
|
||||
# ── Auto-load most recent map on startup ────────────────────────────
|
||||
if self._autoload_on_startup:
|
||||
recent_map = self._find_most_recent_map()
|
||||
if recent_map:
|
||||
self.get_logger().info(f'Auto-loading most recent map: {recent_map}')
|
||||
self._call_deserialize(recent_map)
|
||||
else:
|
||||
self.get_logger().info('No saved maps found for auto-load.')
|
||||
|
||||
# ── Subscription callbacks ─────────────────────────────────────────────
|
||||
|
||||
def _on_occupancy_grid(self, msg: OccupancyGrid) -> None:
|
||||
"""Receive occupancy grid updates from slam_toolbox."""
|
||||
self._last_occupancy_msg = msg
|
||||
|
||||
# ── Status tick ───────────────────────────────────────────────────────
|
||||
|
||||
def _status_tick(self) -> None:
|
||||
"""Publish status every second."""
|
||||
maps_list = self._list_maps()
|
||||
status = {
|
||||
'maps_count': len(maps_list),
|
||||
'most_recent': maps_list[0]['name'] if maps_list else None,
|
||||
'slam_ready': self._slam_ready,
|
||||
'has_occupancy': self._last_occupancy_msg is not None,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
msg = String()
|
||||
msg.data = json.dumps(status)
|
||||
self._pub_status.publish(msg)
|
||||
|
||||
# ── Autosave tick ─────────────────────────────────────────────────────
|
||||
|
||||
def _autosave_tick(self) -> None:
|
||||
"""Auto-save map every N seconds."""
|
||||
if not self._slam_ready or not self._last_occupancy_msg:
|
||||
return
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
name = f'autosave_{timestamp}'
|
||||
|
||||
if self._call_serialize(name):
|
||||
self._prune_autosaves()
|
||||
self.get_logger().info(f'Auto-saved: {name}')
|
||||
else:
|
||||
self.get_logger().warn(f'Auto-save failed: {name}')
|
||||
|
||||
# ── Service handlers ───────────────────────────────────────────────────
|
||||
|
||||
def _handle_save_map(self, request: SaveMap.Request,
|
||||
response: SaveMap.Response) -> SaveMap.Response:
|
||||
"""Handle manual map save request."""
|
||||
map_name = request.map_name or self._auto_name()
|
||||
|
||||
if self._call_serialize(map_name):
|
||||
path = self._maps_dir / f'{map_name}.posegraph'
|
||||
response.success = True
|
||||
response.message = f'Map saved: {map_name}'
|
||||
response.saved_path = str(path)
|
||||
self.get_logger().info(response.message)
|
||||
else:
|
||||
response.success = False
|
||||
response.message = f'Failed to save map: {map_name}'
|
||||
response.saved_path = ''
|
||||
self.get_logger().warn(response.message)
|
||||
|
||||
return response
|
||||
|
||||
def _handle_load_map(self, request: LoadMap.Request,
|
||||
response: LoadMap.Response) -> LoadMap.Response:
|
||||
"""Handle map load request."""
|
||||
if self._call_deserialize(request.map_name):
|
||||
path = self._maps_dir / f'{request.map_name}.posegraph'
|
||||
response.success = True
|
||||
response.message = f'Map loaded: {request.map_name}'
|
||||
response.loaded_path = str(path)
|
||||
self.get_logger().info(response.message)
|
||||
else:
|
||||
response.success = False
|
||||
response.message = f'Failed to load map: {request.map_name}'
|
||||
response.loaded_path = ''
|
||||
self.get_logger().warn(response.message)
|
||||
|
||||
return response
|
||||
|
||||
def _handle_list_maps(self, request: ListMaps.Request,
|
||||
response: ListMaps.Response) -> ListMaps.Response:
|
||||
"""List all saved maps."""
|
||||
maps = self._list_maps()
|
||||
response.map_names = [m['name'] for m in maps]
|
||||
response.map_paths = [m['path'] for m in maps]
|
||||
response.sizes_bytes = [m['size'] for m in maps]
|
||||
response.modified_times = [m['mtime'] for m in maps]
|
||||
response.count = len(maps)
|
||||
return response
|
||||
|
||||
def _handle_export_occupancy(self, request: ExportOccupancy.Request,
|
||||
response: ExportOccupancy.Response) -> ExportOccupancy.Response:
|
||||
"""Export current occupancy grid to PGM+YAML."""
|
||||
if not self._last_occupancy_msg:
|
||||
response.success = False
|
||||
response.message = 'No occupancy grid available'
|
||||
return response
|
||||
|
||||
try:
|
||||
out_dir = request.output_dir or str(self._exports_dir)
|
||||
pgm_path, yaml_path = write_occupancy_export(
|
||||
self._last_occupancy_msg, out_dir, request.map_name or '')
|
||||
response.success = True
|
||||
response.pgm_path = pgm_path
|
||||
response.yaml_path = yaml_path
|
||||
response.message = f'Exported to {pgm_path}'
|
||||
self.get_logger().info(response.message)
|
||||
except Exception as e:
|
||||
response.success = False
|
||||
response.message = str(e)
|
||||
self.get_logger().error(f'Export failed: {e}')
|
||||
|
||||
return response
|
||||
|
||||
# ── slam_toolbox service wrappers ──────────────────────────────────────
|
||||
|
||||
def _call_serialize(self, map_name: str) -> bool:
|
||||
"""Call slam_toolbox serialize service to save map."""
|
||||
if not self._slam_ready:
|
||||
return False
|
||||
|
||||
try:
|
||||
request = SerializePose.Request()
|
||||
request.filename = str(self._maps_dir / map_name)
|
||||
future = self._serialize_client.call_async(request)
|
||||
# Wait for result with timeout
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=10.0)
|
||||
if future.result() is not None:
|
||||
return future.result().success
|
||||
return False
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Serialize call failed: {e}')
|
||||
return False
|
||||
|
||||
def _call_deserialize(self, map_name: str) -> bool:
|
||||
"""Call slam_toolbox deserialize service to load map."""
|
||||
if not self._slam_ready:
|
||||
return False
|
||||
|
||||
try:
|
||||
request = DeserializePose.Request()
|
||||
request.filename = str(self._maps_dir / map_name)
|
||||
future = self._deserialize_client.call_async(request)
|
||||
# Wait for result with timeout
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=10.0)
|
||||
if future.result() is not None:
|
||||
return future.result().success
|
||||
return False
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Deserialize call failed: {e}')
|
||||
return False
|
||||
|
||||
# ── Utility methods ────────────────────────────────────────────────────
|
||||
|
||||
def _list_maps(self) -> list:
|
||||
"""List all saved maps, sorted by modification time (newest first)."""
|
||||
maps = []
|
||||
for file in sorted(self._maps_dir.glob('*.posegraph'),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
maps.append({
|
||||
'name': file.stem,
|
||||
'path': str(file),
|
||||
'size': file.stat().st_size,
|
||||
'mtime': int(file.stat().st_mtime),
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
return maps
|
||||
|
||||
def _find_most_recent_map(self) -> Optional[str]:
|
||||
"""Find the most recent map file (by modification time)."""
|
||||
maps = self._list_maps()
|
||||
return maps[0]['name'] if maps else None
|
||||
|
||||
def _prune_autosaves(self) -> None:
|
||||
"""Keep only N most recent autosaves."""
|
||||
autosaves = sorted(
|
||||
self._maps_dir.glob('autosave_*.posegraph'),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
for old_file in autosaves[self._keep_autosaves_n:]:
|
||||
try:
|
||||
old_file.unlink()
|
||||
self.get_logger().debug(f'Pruned old autosave: {old_file.name}')
|
||||
except OSError as e:
|
||||
self.get_logger().warn(f'Failed to prune {old_file.name}: {e}')
|
||||
|
||||
def _auto_name(self) -> str:
|
||||
"""Generate auto-name with timestamp."""
|
||||
return datetime.now().strftime('map_%Y%m%d_%H%M%S')
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = SlamToolboxPersistenceNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user