Skip to content

Prevent traversability places from leaking through walls (configurable 4-connectivity + minimum passage width)#160

Draft
harelb wants to merge 3 commits into
MIT-SPARK:developfrom
harelb:fix/traversability-connectivity
Draft

Prevent traversability places from leaking through walls (configurable 4-connectivity + minimum passage width)#160
harelb wants to merge 3 commits into
MIT-SPARK:developfrom
harelb:fix/traversability-connectivity

Conversation

@harelb

@harelb harelb commented Jul 23, 2026

Copy link
Copy Markdown

RegionGrowingTraversabilityClustering's robot-seeded flood fill can leak across thin INTRAVERSABLE barriers, 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 (default true = current 8-connected).
With 8-connectivity the flood fill jumps diagonally through 1-voxel corner gaps in walls into the next room. Setting it false uses 4-connectivity (orthogonal only), which cannot cross a corner-only contact.

2. Minimum passage width — min_connection_width_voxels (default 1 = 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.

@nathanhhughes nathanhhughes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +58
//! 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! 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

Comment on lines +180 to +181
* @param num_neighbors 4 (orthogonal only) or 8 (incl. diagonals) entries of
* neighbors_ to use.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param num_neighbors 4 (orthogonal only) or 8 (incl. diagonals) entries of
* neighbors_ to use.

Comment on lines +507 to +535
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

Comment on lines +474 to +505
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary I think

Comment on lines +10 to +16
// Test shim: expose the protected static helpers.
struct RegionGrowingTest : public RegionGrowingTraversabilityClustering {
using RegionGrowingTraversabilityClustering::erodeCandidates;
using RegionGrowingTraversabilityClustering::growConnectedWithMinWidth;
using RegionGrowingTraversabilityClustering::growRegion;
using RegionGrowingTraversabilityClustering::RegionGrowingTraversabilityClustering;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants