
How OOMWOO cleaning algorithms works
In simulation, our open-source robot vacuum kept grinding to a halt in front of the sofa. The obstacle turned out to be a phantom on the costmap. Here is how we found it by measuring, and the two-part fix that roughly doubled coverage near obstacles.
OOMWOO is our open-source, ROS 2, 3D-printable robot vacuum. In simulation it cleaned open floor just fine – but the moment it approached a wall or the front of the sofa, it would stop and grind in place, replanning over and over, sometimes looping for fifteen minutes. It looked hopelessly stuck. It turned out it wasn’t stuck at all. This is the story of a bug that was easy to misread by eye and obvious the moment we measured it – and the two-part fix that roughly doubled coverage near obstacles.
The symptom
Watching a run, the robot would drive toward furniture, slow down, and then wedge – working its wheels, cancelling and re-issuing navigation goals, burning minutes without making progress. Every instinct said it was physically trapped against the sofa and needed a better escape maneuver.
Diagnosis: measure, don’t guess
Instead of designing an escape behavior on a hunch, we instrumented a stall and logged what was actually happening. Two numbers settled it:
- 0 bumper contacts during the entire stall – the robot was never physically touching anything.
- ~90 “collision ahead” aborts from the controller in the same window.
That combination is decisive. The navigation controller (Nav2’s Regulated Pure Pursuit) was aborting on a predicted collision that never physically happened. Its forward collision check projects the robot’s footprint ahead and refuses to enter cells marked lethal on the costmap. Because our obstacle inflation is tuned tight (more on that below), the robot halted about six centimetres before its body would have touched – close enough to look stuck, far enough that the bumper never fired. A phantom obstacle.
Why the costmap is deliberately tight
The robot’s inscribed radius is 0.1745 m. The obstacle-inflation radius is a trade-off, and for a vacuum it wants to be small:
- Inflate at or above ~0.175 m and every gap narrower than about twice that gets sealed off – the robot never threads between furniture legs, so it never cleans there.
- Inflate down near 0.02 m and the controller can’t find valid trajectories at all.
- At 0.10 m it threads the gaps nicely – but grazes obstacles, which is exactly what exposed the phantom.
A vacuum genuinely wants to run tight to walls and furniture – that’s the whole job. So the tight inflation is correct. What’s wrong for this robot is the collision-averse controller behavior on top of it.
The fix, in two parts
The two parts are gated: the first has to happen for the second to do anything, because until the robot actually reaches contact there’s nothing for a bumper-driven behavior to react to.
1. Let the robot reach contact
Turn off the controller’s forward collision check. A vacuum is contact-tolerant: it has a bumper and is meant to touch things. Let it drive right up to walls and furniture, and let the bumper own near-obstacle safety.
# nav2_params.yaml (RegulatedPurePursuitController)
use_collision_detection: false # was: trueThe effect was immediate: collision aborts dropped to zero, and the bumpers started firing – the robot was finally reaching the obstacles it had been hovering in front of.
2. Peel off the held bumper
Reaching contact is only useful if the robot then reacts well. We added a small reactive behavior to the coverage planner, layered under the coverage goals. When a bumper stays pressed continuously for about 1.5 seconds, the planner:
- cancels the active navigation goal immediately, instead of waiting for Nav2 to give up while the robot leans on the obstacle;
- turns away from the pressed side – left bumper held, rotate right; right bumper held, rotate left – rather than a blind straight reverse;
- records a no-go pocket so the coverage sweep doesn’t immediately route straight back in;
- backs and peels for a moment, then hands control back to Nav2.
This is the classic robot-vacuum “bumper held, panic turn” reflex, keyed on which bumper is pressed rather than on the costmap.
Results
Measured on the same living-room world over a fixed 135-second window:
| Configuration | Coverage | Collision aborts | Physical contact |
|---|---|---|---|
| Baseline (stock Nav2) | ~20% | ~90–105 | never touches (phantom) |
| Combined fix | ~36% | 0 | reaches contact, peels off cleanly |
To be clear: ~36% coverage in a 135-second window is not consumer-vacuum performance – this is early simulation work, on one world, and there’s plenty left to tune. The point isn’t the absolute number. It’s that the robot now cleans near obstacles instead of grinding against phantom ones, the improvement is repeatable, and the approach is the right shape to build on.
Why a vacuum should be allowed to touch things
Real robot vacuums don’t escape near-obstacle situations with navigation-stack recoveries. Nav2’s built-in spin and backup recoveries are collision-averse – they check the costmap and refuse to move into the very cells the robot is wedged against, so they stall exactly when you need them. A vacuum has a bumper and is supposed to make contact. The right architecture is a small reactive, bumper-driven behavior layer that runs open-loop, ignores the costmap, and overrides the planner while in contact. Coverage plans the open floor; this reflex handles the last few centimetres. (Anyone who has read iRobot’s coverage patents will recognize the pattern: the escape logic keys on the bumper, not the map.)
Try it yourself
Everything here runs headless in the OOMWOO dev container. Run it with GUI – no robot needed:
docker pull makerspet/oomwoo:jazzy-dev
docker run -d --name oomwoo makerspet/oomwoo:jazzy-dev sleep infinity
docker exec -it oomwoo bash
# inside the container:
GZ=$(ros2 pkg prefix oomwoo_gazebo)/share/oomwoo_gazebo
SIM=$(ros2 pkg prefix oomwoo_sim_support)/share/oomwoo_sim_support
ros2 launch oomwoo_sim_support coverage_regression.launch.py gui:=true \
world:=$GZ/worlds/living_room.world \
map:=$SIM/maps/living_room.yaml \
x_pose:=0.32 y_pose:=1.59Run it headless – no display, no GPU needed:
ros2 launch oomwoo_sim_support coverage_regression.launch.py \
world:=$GZ/worlds/living_room.world \
map:=$SIM/maps/living_room.yaml \
x_pose:=0.32 y_pose:=1.59Watch the log for bumper held ... peel off lines (the escape firing) and echo /coverage_meter/ratio from a second shell to see coverage climb. To see the “before”, relaunch with contact_aware:=false robot_radius:=0.1 and set use_collision_detection: true back in the params file oomwoo-ros2-tools\src\oomwoo_sim_support\config\nav2_params.yaml – the robot will grind in front of the sofa again. Sim variance is real, so run each side a few times. The code lives in oomwoo-ros2-tools.
Update: reactive wall-follow bump-out, and a tactile “bump map”
The peel-off above lets the coverage planner survive contact. Since publishing we took the same idea further and gave the bumper a job of its own: a dedicated reactive wall-follow bump-out cleaner, and a tactile bump map built from bumper hits alone – no Nav2, no LiDAR, no costmap.
Why a second, tactile map at all? A LiDAR reads a couch skirt, a bed valance or a hanging curtain as a solid wall and routes around it – but a vacuum is supposed to clean under and through those. Only a physical bump proves something is truly solid. So the bump_map node accumulates bumper contacts into /bump_map (an OccupancyGrid keep-out layer) and /bump_map/walls (wall segments for RViz), keyed on which of the two bumper switches fired and on the robot’s approach heading. The LiDAR map is only good for localizing; this tactile layer is the one a coverage planner should actually keep out of.
The wall_clean_bump_out behavior is a deliberate cleaning mode (not the confined-escape reflex below): it drives the robot into a wall, and on each bump backs “out” the way it drove “in” – retracing its path rather than reversing blindly into somewhere new – then peels along the surface. It’s pure bumper -> /cmd_vel: point the vacuum at a wall and let it feel its way around the room.

/bump_map/walls segments and the green /bump_map keep-out layer over the SLAM map.Everything runs in the same dev container. Start the sim, localize so the map frame exists (navigation.launch.py now auto-seeds the known start pose in simulation – no manual RViz “2D Pose Estimate” needed), and point a single RViz window at bump_map.rviz to watch both the drive and the growing bump map at once:
# terminal 1 - simulator
ros2 launch oomwoo_gazebo world.launch.py
# terminal 2 - localize + one RViz window (navigation + bump map)
ros2 launch oomwoo_bringup navigation.launch.py use_sim_time:=true map:=/ros_ws/src/oomwoo_gazebo/maps/living_room.yaml rviz_config:=bump_map.rviz
# terminal 3 - point the vacuum at a wall, then let it bump-out clean
# (wall_clean_bump_out starts the bump_map node for you; bump_map:=false to skip)
ros2 run kaiaai_teleop teleop_keyboard
ros2 launch oomwoo_clean wall_clean_bump_out.launch.py use_sim_time:=trueWatch the red /bump_map/walls segments fill in over the SLAM map; the semi-transparent /bump_map keep-out overlay ships off by default to keep the view clean – tick it on in the Displays panel if you want to see it. Both nodes are live-tunable with kaia set clean.<name> (cruise speed, peel-off and corner turn angles) and kaia set bump_map.<name> (contact radius, segment gap) – change a value and relaunch that one node.
How to make localization more accurate
The bump map above has a catch: it places each wall segment at the robot’s estimated pose at the moment of contact. With the stack’s default localizer — AMCL — that estimate drifts a good ten centimetres sideways and a little in heading, so the tactile wall segments came out crooked against the LiDAR map: the live scan points landed cleanly in a line, the map showed the wall cleanly, yet the two stayed visibly apart. Fine for a delivery robot; not for a vacuum that has to know which wall it is hugging.
Why is AMCL loose? It is a particle filter: it keeps a cloud of pose guesses, scores each against a blurred copy of the map, and publishes the cloud’s weighted mean. A mean of a scattered cloud is inherently decimetre-class and jittery — no amount of tuning turns it into a millimetre.
The fix is a different kind of localizer: scan matching (slam_toolbox in localization mode). Instead of averaging particles, it slides and rotates the live LiDAR scan until it best overlaps the map geometry, optimizing the pose directly to sub-cell (centimetre) precision. Point it at the map’s saved pose-graph and the sensed wall lands on the map wall — and the bump-map segments straighten out with it.
The clip runs both localizers side by side: Gazebo on the left, RViz on the right, and live linear and angular error plots as the vacuum bumps out two living-room walls — you can watch each bump register and the wall estimate build. slam_toolbox’s error hugs the floor at a couple of centimetres; AMCL’s — here with the deliberately loose stock settings — rides higher and wobbles. Tightening AMCL’s measurement model narrows the gap, but never closes it: a particle mean cannot match a pose optimizer.
The takeaway is not “always chase centimetre localization.” You don’t need it to clean — right next to a wall the bumper is ground truth, and the reactive cleaner never consults the map. But the bump map is a persistent artifact, and an artifact wants a pose good enough that its walls line up with reality. So the design is: scan matching for the map-frame pose, cleaning still driven by contact. (One honest trade-off for later: scan matching is weak at recovering when the robot is picked up and set down somewhere else — “kidnapped” — where AMCL’s knack for scattering guesses across the whole map wins. So the plan keeps both, and calls the global one only when the robot notices it is lost.)
Reproduce the comparison
A small harness runs the two localizers together and scores each against the simulator’s ground truth, so you can watch the gap live:
# terminal 1 - simulator (robot_wheels = realistic wheel-encoder odom)
ros2 launch oomwoo_gazebo world.launch.py odom_source:=robot_wheels
# terminal 2 - AMCL + slam_toolbox localization + two error meters + RViz.
# nav_params picks the AMCL tuning; navigation_loose.yaml matches the video.
ros2 launch oomwoo_sim_support localization_compare.launch.py use_sim_time:=true \
map:=/ros_ws/src/oomwoo_gazebo/maps/living_room.yaml \
nav_params:=/ros_ws/src/oomwoo_one/config/etc/navigation_loose.yaml
# terminal 3 - drive the walls
ros2 launch oomwoo_clean wall_clean_bump_out.launch.py use_sim_time:=true
# plot both errors live in Foxglove Studio (runs in your browser)
ros2 run foxglove_bridge foxglove_bridge
# open Foxglove -> ws://localhost:8765 -> Plot panel ->
# /loc_err_amcl/pos_err_m and /loc_err_slam/pos_err_m (+ the yaw_err_deg pair)Swap navigation_loose.yaml for navigation_tight.yaml to see how far tuning takes AMCL, and pass odom_source:=ground_truth to remove wheel drift and isolate the pure scan-registration gap. The code lives in oomwoo-ros2-tools.
What’s next – and how to help
We’ve implemented one of the three classic escape reflexes (bumper held, panic turn). Two more are still open: “frequent bumps means confined, so edge-follow out”, and “no bumps over a long travel means high-centered, so spiral”. Beyond that: tuning the escape across more worlds, running to completion rather than a fixed window, and eventually re-validating on real hardware once the physical bumper exists.
OOMWOO is built in the open, module by module. If reactive behaviors, coverage planning, or robot navigation are your thing, come say hello in the GitHub Discussions or on Discord – there’s a whole board of modules waiting for a builder.

