Skip to content

Orientation picking — the phantom wall direction, 2026-08-11

Verdict: the declutter pass picks wall orientations by their height above the angular energy's broadband floor (peak_rel_threshold 0.15 of the residual maximum), not by their height above zero (0.45 of the raw maximum). The phantom direction that forced a hand-tuned threshold on the 2026-08-02 flat map is gone, and the shipped defaults now reproduce that hand-tuned output exactly. This doc is the measurement behind the change. Data: 2026-08-11-orientation-picking/.

The defect

_pick_directions kept any local maximum of the angular energy reaching peak_rel_threshold (0.45) of the global maximum, after suppressing peaks within 12° of a stronger one.

Clutter, speckle and ragged wall edges put energy at every orientation, so a real map's angular energy is a modest set of wall peaks riding on a broad pedestal — measured at 0.49 of the global maximum on the 2026-08-02 flat map. A threshold expressed as a fraction of the raw maximum therefore spends more than half its range below the pedestal, where nothing is a direction and everything clears the gate.

What survives that gate is whatever the 12° suppression radius happens to leave: on that map, a direction at 73.8°, which is the shoulder of the real 86.8° wall family 13.0° away — just outside the radius. It reaches 0.50 of the raw maximum against the weakest real family's 0.64, a ratio of 1.3, and no threshold placed between two such numbers is a measurement. The operator's workaround was --peak-rel 0.55, threaded between them by hand.

It is not one map's curiosity. Of the 13 genuine occupancy maps on this box, 6 carried such a shoulder, every one of them 12.0–13.5° from a stronger peak.

before and after

What ROSE itself does, and why this departs from it

Worth stating plainly, because this module cites ROSE: the shipped relative-height rule was never ROSE's rule, and neither is the one replacing it. ROSE (Luperto et al., arXiv:2004.08794, §3.1) selects directions by topographic prominence, thresholded at 50% of the curve's peak-to-trough range — literally, in the reference implementation (aislabunimi/ROSE2, src/rose_v1_repo/fft_structure_extraction.py):

find_peaks(pol_h, prominence=(np.max(pol_h) - np.min(pol_h)) * 0.5)

Run over our corpus (rose-rule.txt), that criterion is more conservative than either of ours: on all 13 maps it returns exactly the two strongest, near-orthogonal directions, and never a phantom. It also drops the real off-axis families along with them — 11.8° and 110.2° on the flat map — which is the two-direction result the operator rejected at --peak-rel 0.65 for visibly eroding walls.

That is a difference in the maps, not a mistake in the paper. ROSE scores large, overwhelmingly rectilinear building floor plans, where two directions is the right answer and a third is usually clutter; and its output is an abstracted floor plan. A small flat mapped by a 2-D lidar has genuine off-axis wall families, and this pass keeps observed pixels gated to real walls, so a dropped family costs real wall rather than detail.

Two fixes that do not work

Literal topographic prominence, at a threshold loose enough to keep the four families, cannot exclude the phantom. The real off-axis family at 11.8° scores 0.033 of the maximum against the phantom's 0.028 — that family sits on the tail of the dominant one and is a shoulder too, in exactly the same sense. So prominence has no setting that expresses what is wanted here: the paper's threshold gives two directions, and any threshold that admits the third and fourth admits the phantom with them. Thresholding it that low is worse still, because prominence rewards isolation and starts promoting lone bumps in the noise floor that carry no structural energy at all (47.2° on the tuning map; 43.2° and 137.2° on the replay maps).

A wider suppression radius cannot separate them either. The phantom is 13.0° from its parent; the real off-axis family is 14.5° from its own. Any radius that suppresses the one suppresses the other.

The fix

Subtract the broadband floor before thresholding — the curve smoothed over FLOOR_HALFWIDTH_DEG (45°), i.e. what is left once anything with a wall's angular sharpness is averaged away. This module already computed that residual for angular_stats; the declutter picker was simply not using it.

On the same two peaks, the residual reads 0.012 for the phantom and 0.296 for the weakest real family — 1.3× apart before, 24× apart after.

The threshold moves with the curve: 0.15 of the residual maximum, the same number angular_stats already used on the same kind of curve. Both halves have to travel together. 0.45 on the residual would drop real families; 0.15 on the raw curve returns four directions on a clean synthetic rectilinear map, two of them flank samples. A test pins that combination.

The threshold's window is measured, and is not wide:

bound map direction residual
from below sim/mote_world 137.2° phantom, admitted at 0.10 0.109
from above tuning/input_map 16.2°, a real family the shipped rule kept 0.189

What changed, over every map on the box

corpus.txt — 13 genuine ROS occupancy PNGs (the tuning input, the three sim world maps, and eight map_raw_notraj.png outputs of the bag-replay harness; bag_replay_results/*/*/map.png is a rendered figure, not a map, and is not in the corpus).

  • 7 maps unchanged, including the whole tuning set and all three sim worlds.
  • 6 maps lost exactly one direction each, and every one of them was a shoulder 12.0–13.0° from a stronger peak, with residual 0.012–0.105 against a weakest-kept of 0.204–0.285.
  • No map gained a direction.

Does it reproduce the hand-tuned map?

Yes, byte for byte. The 2026-08-02 flat map cleaned with the new defaults is identical to the --peak-rel 0.55 output the operator hand-threaded and shipped (0 differing cells of 43 259), and differs from the old default's output by 664 cells. flat-map-output.txt.

Room segmentation, which picks directions through the same function to find the map's dominant rotation, is unchanged on the sim ladder — 30/33 hospital, 10/10 office, 1/1 mote, zero merges, and the same again with the map turned +17° and −31°.

What this does not fix

The picker still returns directions, not families, and max_directions (5) is still a cap rather than a decision: a building genuinely using six wall directions loses one, silently. Nothing here estimates how many directions a map should have — angular_stats's frame table is the diagnostic that reads the answer back, and it is a diagnostic, not a gate.