Prevent traversability places from leaking through walls (configurable 4-connectivity + minimum passage width)#160
Conversation
…ersability places
… (plus for 4-conn, square for 8-conn)
nathanhhughes
left a comment
There was a problem hiding this comment.
Thanks! I like the idea of being able to toggle between 4 and 8 connected, but there are better ways to handle erosion/dilation than in the clustering directly. I kinda gave up adding direct code suggestions about halfway through, there's more cleanup (that should be pretty straightforward) involved with dropping the erosion/min-width code.
| //! Use 8-connectivity (incl. diagonals) when true; 4-connectivity (orthogonal | ||
| //! only) when false. 4-connectivity stops region growth leaking through 1-voxel | ||
| //! diagonal gaps in walls. |
There was a problem hiding this comment.
| //! Use 8-connectivity (incl. diagonals) when true; 4-connectivity (orthogonal | |
| //! only) when false. 4-connectivity stops region growth leaking through 1-voxel | |
| //! diagonal gaps in walls. | |
| //! Toggles between 8-connected and 4-connected expansion |
| * @param num_neighbors 4 (orthogonal only) or 8 (incl. diagonals) entries of | ||
| * neighbors_ to use. |
There was a problem hiding this comment.
| * @param num_neighbors 4 (orthogonal only) or 8 (incl. diagonals) entries of | |
| * neighbors_ to use. |
| VoxelSet RegionGrowingTraversabilityClustering::growConnectedWithMinWidth( | ||
| const VoxelSet& candidates, | ||
| const VoxelSet& core, | ||
| const VoxelIndex& seed, | ||
| size_t num_neighbors) { | ||
| VoxelSet result; | ||
| if (candidates.find(seed) == candidates.end()) { | ||
| return result; | ||
| } | ||
| VoxelSet visited; | ||
| std::queue<VoxelIndex> queue; | ||
| queue.push(seed); | ||
| visited.insert(seed); | ||
| while (!queue.empty()) { | ||
| const auto current = queue.front(); | ||
| queue.pop(); | ||
| result.insert(current); | ||
| if (core.find(current) == core.end()) { | ||
| continue; // narrow voxel: include but do not propagate through it | ||
| } | ||
| for (size_t k = 0; k < num_neighbors && k < neighbors_.size(); ++k) { | ||
| const VoxelIndex n = current + neighbors_[k]; | ||
| if (candidates.find(n) != candidates.end() && visited.insert(n).second) { | ||
| queue.push(n); | ||
| } | ||
| } | ||
| } | ||
| return result; | ||
| } |
There was a problem hiding this comment.
| VoxelSet RegionGrowingTraversabilityClustering::growConnectedWithMinWidth( | |
| const VoxelSet& candidates, | |
| const VoxelSet& core, | |
| const VoxelIndex& seed, | |
| size_t num_neighbors) { | |
| VoxelSet result; | |
| if (candidates.find(seed) == candidates.end()) { | |
| return result; | |
| } | |
| VoxelSet visited; | |
| std::queue<VoxelIndex> queue; | |
| queue.push(seed); | |
| visited.insert(seed); | |
| while (!queue.empty()) { | |
| const auto current = queue.front(); | |
| queue.pop(); | |
| result.insert(current); | |
| if (core.find(current) == core.end()) { | |
| continue; // narrow voxel: include but do not propagate through it | |
| } | |
| for (size_t k = 0; k < num_neighbors && k < neighbors_.size(); ++k) { | |
| const VoxelIndex n = current + neighbors_[k]; | |
| if (candidates.find(n) != candidates.end() && visited.insert(n).second) { | |
| queue.push(n); | |
| } | |
| } | |
| } | |
| return result; | |
| } |
There's erosion / dilation preprocessing operators that operate on the traversability layer before places are extracted that are probably a better way to do this; I would be less opposed to a solution like this if it wasn't a separate function from growRegion
| VoxelSet RegionGrowingTraversabilityClustering::erodeCandidates( | ||
| const VoxelSet& candidates, int radius, bool use_diagonal) { | ||
| if (radius <= 0) { | ||
| return candidates; | ||
| } | ||
| VoxelSet eroded; | ||
| for (const auto& v : candidates) { | ||
| bool keep = true; | ||
| for (int dx = -radius; dx <= radius && keep; ++dx) { | ||
| for (int dy = -radius; dy <= radius; ++dy) { | ||
| if (dx == 0 && dy == 0) { | ||
| continue; | ||
| } | ||
| // Structuring element follows the connectivity: Chebyshev (square) ball for | ||
| // 8-connected, Manhattan (plus) ball for 4-connected. | ||
| const int reach = use_diagonal ? std::max(std::abs(dx), std::abs(dy)) | ||
| : std::abs(dx) + std::abs(dy); | ||
| if (reach > radius) { | ||
| continue; | ||
| } | ||
| if (candidates.find(v + VoxelIndex(dx, dy, 0)) == candidates.end()) { | ||
| keep = false; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (keep) { | ||
| eroded.insert(v); | ||
| } | ||
| } | ||
| return eroded; | ||
| } |
There was a problem hiding this comment.
See comment about growConnectedWithMinWidth, I'd just drop this and use the erosion/dilation code that already exists (enabled by config)
|
|
||
| void RegionGrowingTraversabilityClustering::growRegions(VoxelSet& all_voxels, | ||
| VoxelMap& assigned_voxels) { | ||
| const size_t num_neighbors = config.use_diagonal_connectivity ? 8u : 4u; |
There was a problem hiding this comment.
| const size_t num_neighbors = config.use_diagonal_connectivity ? 8u : 4u; | |
| const size_t num_neighbors = config.use_diagonal_connectivity ? 8 : 4; |
(minor style) Not a huge deal but size_t will handle converting the constants to be unsigned and there are very few cases where the compiler can't implicitly cast the constant to unsigned when necessary (e.g., I think you'd need it if you were directly comparing against the ternary operator result in the for loop)
| #include <spark_dsg/edge_attributes.h> | ||
| #include <spark_dsg/node_symbol.h> | ||
|
|
||
| #include <cstdlib> |
There was a problem hiding this comment.
not necessary I think
| // Test shim: expose the protected static helpers. | ||
| struct RegionGrowingTest : public RegionGrowingTraversabilityClustering { | ||
| using RegionGrowingTraversabilityClustering::erodeCandidates; | ||
| using RegionGrowingTraversabilityClustering::growConnectedWithMinWidth; | ||
| using RegionGrowingTraversabilityClustering::growRegion; | ||
| using RegionGrowingTraversabilityClustering::RegionGrowingTraversabilityClustering; | ||
| }; |
There was a problem hiding this comment.
The static methods should be public if they're being tested (I don't like this particular style choice of Lukas's and usually prefer functions that are local to the source file)
RegionGrowingTraversabilityClustering's robot-seeded flood fill can leak across thinINTRAVERSABLEbarriers, creating traversability places behind walls with no physical connection to the traversed free space. Downstream planners then treat those as reachable. This adds two opt-in guards; defaults preserve current behavior.1. Configurable connectivity —
use_diagonal_connectivity(defaulttrue= current 8-connected).With 8-connectivity the flood fill jumps diagonally through 1-voxel corner gaps in walls into the next room. Setting it
falseuses 4-connectivity (orthogonal only), which cannot cross a corner-only contact.2. Minimum passage width —
min_connection_width_voxels(default1= off).4-connectivity still walks through a straight 1–2 voxel (sensor-noise / thin-gap) opening. With value W, the candidate set is eroded by radius W−1 (structuring element follows the connectivity: Manhattan/plus for 4-conn, Chebyshev/square for 8-conn) and connectivity is grown only through the eroded "core"; voxels reachable but narrower are included as non-expanding leaves, so room geometry is preserved while sub-W-voxel gaps are severed. Real doorways (~0.8–1 m ≈ 8–10 voxels at 0.1 m) pass easily.
Both guards act only on the robot-seeded BFS in
initializeVoxels; region lifecycle/edges are untouched.Validation: on an internal building-floor map, traversability places sitting off the reconstructed mesh footprint dropped 25.8% → 7.7% with
use_diagonal_connectivity: false+min_connection_width_voxels: 2, with the graph pulled tight to the real floor.Tests: new
tests/places/test_region_growing_connectivity.cpp— diagonal-gap blocked by 4-conn, thin-bridge severed while the room is preserved, wide doorway still connects, and erosion SE follows the connectivity mode.Draft — feedback on the approach and defaults welcome.