An autonomy stack for the TurtleBot3 Burger on ROS 2 Humble. It does three things:
- Explores and maps an unknown environment on its own, using
slam_toolboxfor SLAM and frontier-based exploration to decide where to drive next. - Plans paths with a custom RRT / RRT* planner, running both as a Nav2 global-planner plugin and as a standalone node you can poke at in RViz.
- Takes natural-language commands ("go to the chair") and drives there, via an additive semantic-navigation layer that tags what the robot sees and reasons over it later.
Everything runs in Gazebo, is visualized in RViz, and is reproducible through a single pixi environment built on RoboStack.
| What you want to see | Run | What happens |
|---|---|---|
| Autonomous exploration + SLAM | pixi run explore |
The robot drives itself around the house, the occupancy map fills in, and it stops when there's nothing left to explore. |
| The custom RRT planner inside Nav2 | pixi run bringup, then use RViz's Nav2 Goal tool |
The RRT tree grows toward your goal, a path appears, and the controller follows it. |
| Localize on a known map | pixi run localize-gt, then send goals |
AMCL localizes against a saved occupancy map; no mapping. |
| Natural-language navigation | pixi run -e ai ai-navigation (optional AI env) |
Send "go to the chair" and the robot queries its semantic map, picks the pose, and navigates there. |
Cloning: there's a git submodule (
frontier_exploration_ros2), so clone withgit clone --recursive <url>. If you already cloned without it, rungit submodule update --init --recursive.
You need pixi and git on a Linux x86-64 machine. That's it for the core stack; ROS 2 Humble and every dependency come from the pixi environment. (The optional AI layer in Semantic navigation wants an NVIDIA GPU, but nothing else does.)
The repo expects to live in the src/ directory of a colcon workspace, e.g. ~/ros2_ws/src/BurgerStack. The build tasks place build/, install/, and log/ in the workspace root (two levels up), keeping them out of the repo. If you put it somewhere else, set COLCON_WS to your workspace root.
# from ~/ros2_ws/src/BurgerStack
pixi install # solve + fetch ROS 2 Humble, Nav2, slam_toolbox, Gazebo, ...
pixi run build # colcon build --symlink-install
pixi run explore # full stack + autonomous exploration in the small_house worldGazebo and RViz windows come up; the robot starts driving itself a few seconds after Nav2 activates. Watch the map grow in RViz. When you've mapped enough, save it from a second terminal:
ros2 run nav2_map_server map_saver_cli -f burger_bringup/maps/mappixi run with no task name lists everything available. The full set is in Task reference.
The whole stack is organized around one invariant: everything shares a single map frame and a single occupancy-grid view of the world. SLAM produces the map and the map → odom correction; exploration reads that map to pick goals; the planner plans over it; Nav2's controller drives the robot there.
flowchart LR
gazebo["Gazebo: sim, sensors, diff-drive"]
slam["slam_toolbox"]
costmap["Nav2 global costmap"]
explorer["frontier_exploration_ros2"]
nav2["Nav2: plan with RRT, then drive"]
gazebo -->|"/scan"| slam
gazebo -->|"/odom, odom → base TF"| nav2
slam -->|"/map"| costmap
slam -->|"/map"| explorer
slam -->|"map → odom TF"| nav2
costmap --> nav2
explorer -->|"NavigateToPose goal"| nav2
nav2 -->|"/cmd_vel"| gazebo
When you drive manually instead (pixi run bringup and a Nav2 Goal in RViz), the explorer is just absent, and Nav2 still plans every goal with the RRT planner and follows it. For localization-only runs (pixi run localize), AMCL replaces slam_toolbox and map_server serves a static map; nothing else changes.
Reused, mostly unmodified:
| Component | Role | Why it's reused |
|---|---|---|
slam_toolbox |
2D SLAM, producing /map and the map → odom correction |
Writing a SLAM backend (scan matching, pose graph, loop closure) is a separate multi-month project. It's tuned here, not reimplemented. |
| Nav2 | Costmaps, controller (DWB), recovery behaviors, behavior tree, lifecycle | The RRT planner plugs into it and the rest is configured, not rewritten. The orchestration and control machinery is stock and battle-tested. |
nav2_map_server + AMCL |
Serve a saved map, localize against it | Standard localization for the no-SLAM runs. |
frontier_exploration_ros2 |
Frontier detection + exploration policy | Integrated as a git submodule (mertgulerx/frontier_exploration_ros2, pinned). A capable open-source explorer already existed; it's wired into the Nav2 stack rather than rebuilding frontier detection. |
Written from scratch (this repo), each with its own README for the design and parameter detail:
| Package | What it is |
|---|---|
rrt_core |
The RRT / RRT* algorithm as a pure C++ library: no ROS, no grids, unit-tested in isolation. |
rrt_planner |
The ROS 2 integration: a Nav2 GlobalPlanner plugin and a standalone planning node, both driven by rrt_core. |
burger_bringup |
The composition layer (no README of its own): launch files (sim, slam, nav2, localization_amcl, and the unified bringup), tuned parameter files, RViz configs, the URDF (including the RGB-D burger variant semantic_nav uses), and saved maps. bringup.launch.py is the single entry point everything else routes through. |
burger_worlds |
Vendored Gazebo worlds (small_house, office) with their models and reference occupancy maps. |
semantic_nav/ |
A six-package layer adding semantic perception, a spatial semantic memory, and agentic natural-language navigation. Purely additive: it touches none of the navigation code above. |
The two pieces worth understanding in depth, the RRT planner and the semantic layer, are summarized below.
The planner is the main piece written from scratch, so here is the core idea; the algorithm walkthrough, parameter tables, and API live in rrt_core/README.md and rrt_planner/README.md.
The standalone planner (pixi run plan-demo) on the saved small_house map: no Gazebo, just /map plus a start and a goal, producing a nav_msgs/Path. The RRT/RRT* tree (blue) explores the free space while the path (red) re-plans to each new goal.
The single load-bearing decision is that the algorithm knows nothing about ROS, costmaps, or occupancy grids. rrt_core::RRT plans in metric world coordinates and consults a CollisionChecker interface for two questions: is this point free, and is this segment free? That one abstraction lets a single tested algorithm drive two very different data sources. The standalone node feeds it a GridCollisionChecker built from a nav_msgs/OccupancyGrid; the Nav2 plugin feeds it a CostmapCollisionChecker over the live nav2_costmap_2d::Costmap2D, which already carries inflation. Because the core has no ROS dependency, it is also unit-tested without a robot, against hand-built grids. The sampling, spatial-hash nearest-neighbour lookup, RRT* rewiring, and greedy smoothing are all covered in rrt_core/README.md.
The plugin is already the configured Nav2 global planner (nav2.yaml sets GridBased.plugin: rrt_planner/RRTGlobalPlanner), so every goal you send in any non-exploration run is planned by RRT:
pixi run bringup # sim + SLAM + Nav2 (RRT) + RViz, no exploration
# then in RViz, use the "Nav2 Goal" tool to send a goalYou can also run the planner standalone on the saved map with no Gazebo, the Section-3 "occupancy grid + start + goal -> nav_msgs/Path" demo:
pixi run plan-demo # map_server (saved map) + rrt_planner_node + RViz
# then in RViz use the "2D Goal Pose" tool to send a goalThe standalone node's topics and parameters are documented in rrt_planner/README.md.
Exploration is the interplay of three things: slam_toolbox building the map, frontier_exploration_ros2 choosing where to go, and Nav2 getting the robot there. The interesting part is the tuning and the sequencing.
pixi run explore: frontier-driven autonomous exploration of small_house. Gazebo (left) and, in RViz (right), the SLAM map filling in with inflated costmaps, frontier markers, and the live Nav2 path as the robot drives itself around.
SLAM tuning. A couple of slam_toolbox defaults are wrong for an explorer that makes short hops. minimum_travel_distance is lowered so small moves still fold fresh scans into the map, and map_update_interval is shortened so newly seen area shows up promptly instead of the explorer acting on a stale map. The tuned values are in slam.yaml.
Startup sequencing. Bringing everything up at once is a race: the explorer will crash Nav2's lifecycle manager if it starts before Nav2 has activated. So bringup.launch.py gates each stage on a real readiness signal rather than a fixed sleep. Gazebo comes first, then SLAM/Nav2 once the robot is actually publishing /scan, then the explorer once /navigate_to_pose is advertised (meaning Nav2 has fully activated). Each wait is bounded by a timeout so a failed bring-up can't deadlock. This is why a heavy world like small_house "just works" without you fiddling with delays.
Modes. The slam_mode argument picks the map behavior:
pixi run explore # mapping from scratch (small_house)
pixi run explore-resume # continue mapping on the saved map (slam_mode:=continue)
pixi run localize # AMCL on YOUR saved map.yaml (no mapping)
pixi run localize-gt # AMCL on the vendored ground-truth map
pixi run explore-debug # frontier debug overlays, run alongside `explore`
# explore the office world instead (no dedicated task; pass the args directly):
ros2 launch burger_bringup bringup.launch.py explore:=true world:=office x_pose:=-6.0 y_pose:=8.0Save the map at any point with map_saver_cli (see Quickstart). One gotcha worth knowing: colcon symlink-installs the maps directory, so a brand-new map file isn't linked into the install tree until you pixi run build once. Save, rebuild, then use it.
explore-resumeresumes a serializedslam_toolboxpose-graph (burger_bringup/maps/map.posegraph+.data), which is a different artifact from the.pgm/.yamloccupancy map and is not shipped in the repo. Create one during anexplorerun via slam_toolbox's Serialize Map RViz panel (orros2 service call /slam_toolbox/serialize_map ...), saved at base nameburger_bringup/maps/map, thenexplore-resumewill continue from it.
This layer answers a different question than the rest of the stack: not "how do I get there" but "where is the chair?" It is deliberately additive, introducing no new planning or control code and reusing the same Nav2 NavigateToPose action the explorer already uses. The design (how labels are generated, stored, and queried) lives in semantic_nav/README.md; the full run guide, the mock-to-real switch, and the Claude/MCP setup live in semantic_nav_bringup/README.md.
Natural-language navigation driven by Claude through the MCP server: "find the trash can in the robot's semantic map and drive the robot to it," then "now take the robot to the refrigerator." Claude loads the tool schemas, queries the semantic map (matching the object by label/embedding), and dispatches a real Nav2 goal that the custom RRT planner solves:
Claude reasoning over MCP![]() |
RViz: semantic map, RRT path, robot![]() |
Gazebo: the house![]() |
Robot onboard camera![]() |
The four panels are one synchronized run: Claude's tool calls (top-left) drive the robot to the trash can, then the refrigerator, with the plan and drive shown in RViz (top-right), Gazebo (bottom-left), and the robot's camera (bottom-right).
It runs in two phases that share the SLAM map frame:
- Phase 1, build a semantic map while exploring.
semantic_perceptionturns RGB-D frames into 3D detections in themapframe;semantic_mappingfuses them into one entity per real object (description, embedding, position) and persists both the semantic map and the occupancy map on a finalize step. - Phase 2, drive by natural language. The robot localizes (AMCL) against the saved occupancy map, loads the semantic map, and exposes an
ExecuteTaskaction (or the MCP server for Claude). A reasoner turns a command into tool calls and dispatches a real Nav2 goal.
# Phase 1: build the semantic map while exploring
ros2 launch semantic_nav_bringup semantic_mapping.launch.py world:=small_house explore:=true rviz:=true
# Phase 2: drive by natural language
ros2 launch semantic_nav_bringup semantic_navigation.launch.py world:=small_house
ros2 action send_goal /execute_task_node/execute_task \
semantic_nav_msgs/action/ExecuteTask "{command: 'go to the chair'}" --feedbackEverything runs mock-first out of the box (no GPU, LLM, or network), so the architecture and data flow can be exercised and tested anywhere. The real backends (YOLO-World detection, an ollama VLM describer, CLIP embeddings, an ollama tool-calling agent, and the MCP server for Claude) slot in behind the same interfaces with no launch changes, via the optional ai pixi environment. Running each phase against the real backends is covered in semantic_nav_bringup/README.md.
The reasoning layer has two interchangeable frontends on one shared tool layer: the local ollama agent (ExecuteTask) above, and an MCP server that lets Claude drive the robot directly over the Model Context Protocol. Start a Phase 2 stack (ideally the real-backend pixi run -e ai ai-navigation, so CLIP text embeddings make "the chair" match by meaning), then start the server in the ai environment, where the mcp dependency lives:
pixi run -e ai mcp-server # = ros2 run semantic_reasoning mcp_serverIt speaks MCP over stdio, so register it like any stdio server. In Claude Code, from the repo root:
claude mcp add semantic-nav -- pixi run -e ai mcp-serverIn Claude Desktop, add it to claude_desktop_config.json with cwd pointing at the repo so pixi can find the manifest:
{
"mcpServers": {
"semantic-nav": {
"command": "pixi",
"args": ["run", "-e", "ai", "mcp-server"],
"cwd": "/path/to/BurgerStack"
}
}
}With the server connected, ask Claude in plain language ("where is the chair, and take me there"); it queries the map, picks a pose, and sends a real Nav2 goal through the running stack, the same path the ollama agent drives. Server internals are in the semantic_nav MCP notes.
ROS 2 Humble officially targets Ubuntu 22.04, but this was developed on 24.04 (whose native ROS is Jazzy). Rather than a Docker image or a VM, the whole toolchain is a pixi workspace on the RoboStack channels, which package all of ROS 2 as conda packages. The entire stack (ROS, Nav2, slam_toolbox, Gazebo, the build tools) becomes one pixi install, pinned by a lockfile and isolated from the system. pixi.toml is the source of truth.
Two details are load-bearing:
-
The Gazebo model-path fixup. RoboStack's
turtlebot3_gazebodoesn't register its models with Gazebo the way the Debian package does, so worlds would load empty. An activation script prepends the models directory toGAZEBO_MODEL_PATHand disables the retired online model database (which otherwise hangsgzserveron startup). -
Two environments, two install trees. The optional AI backends (CUDA PyTorch, YOLO-World, CLIP, ollama, MCP) are heavy and GPU-bound, so they live behind a pixi feature. The default environment stays mock-only and GPU/network-free; the
aienvironment adds the ML stack on top of all of ROS. Crucially, each environment builds into and runs from its own colcon tree (install/vsinstall_ai/). The reason is subtle:colconbakes the building environment's Python interpreter into each node's launch shebang, and a shared tree run under the other environment would load the wrong interpreter and the node would die before it could even log why. Separate trees mean a node is always launched by the very interpreter that built it. The long comment inpixi.tomldocuments this in full.
# the optional AI layer (one-time, GB-scale download, needs an NVIDIA GPU)
pixi install -e ai
pixi run -e ai ai-build
pixi run -e ai ai-navigationpixi run <task> runs in the default (mock) environment:
| Task | What it does |
|---|---|
build |
colcon build --symlink-install into install/. |
rebuild |
Clean build (removes build/ install/ log/ first). |
test |
colcon test + colcon test-result --verbose. |
sim-small-house |
Gazebo + robot only, residential house world. |
sim-office |
Gazebo + robot only, office world (spawns at the office pose). |
bringup |
Full stack (sim + SLAM + Nav2 + RViz), no exploration; drive manually with Nav2 goals. |
plan-demo |
Standalone RRT planner on the saved map (no Gazebo): map_server + rrt_planner_node + RViz. Set a goal with RViz's 2D Goal Pose; watch the tree + path. |
explore |
Full stack + autonomous frontier exploration (small_house). |
explore-resume |
Resume exploration from a serialized slam_toolbox pose-graph (slam_mode:=continue). Requires maps/map.posegraph+.data, which aren't shipped, so serialize one first (see Exploration & SLAM). |
localize |
AMCL + map_server on your saved map.yaml. Send goals from RViz. |
localize-gt |
AMCL on the vendored ground-truth occupancy map. |
explore-debug |
Frontier debug overlays (raw/optimized frontiers, scores); run alongside explore. |
pixi run -e ai <task> runs in the optional AI environment (separate install_ai/ tree):
| Task | What it does |
|---|---|
ai-build / ai-rebuild |
Build into install_ai/ with the AI environment's interpreter. |
ai-mapping |
Phase 1 with real perception + enrichment. |
ai-navigation |
Phase 2 with CLIP-based queries and the ollama reasoning agent. |
mcp-server |
Run the MCP server exposing the semantic tools to Claude. |
pixi run testThe testing strategy is to validate at the lowest level that can catch a given class of bug, because the cheap levels are where most bugs actually are:
- Algorithm correctness, pure unit tests, no ROS.
rrt_core(grid conversions, inflation, paths around walls, determinism, RRT* path quality) and the Pythonsemantic_store/ perception / mapping / reasoning logic all test as plain libraries: no graph, no simulator, no GPU. - Single-node ROS behavior, scripted probes. Publish a synthetic map + start + goal and assert a sensible path comes back; call
ComputePathToPoseand assert a non-empty path. These catch QoS, interface, and costmap-policy bugs without a simulator. - Whole-system behavior, a headless sim run. The exploration livelock and the SLAM/explorer parameter interaction only show up when the full stack runs end to end, so that's the level you run to trust the integration.





