Skip to content

BUG: Sort the full pixel buffer in AttributeMorphologyBase#6581

Merged
hjmjohnson merged 1 commit into
InsightSoftwareConsortium:mainfrom
physwkim:bug-attribute-morphology-sort-range
Jul 10, 2026
Merged

BUG: Sort the full pixel buffer in AttributeMorphologyBase#6581
hjmjohnson merged 1 commit into
InsightSoftwareConsortium:mainfrom
physwkim:bug-attribute-morphology-sort-range

Conversation

@physwkim

Copy link
Copy Markdown
Contributor

Addresses item B19 of #6575.

AttributeMorphologyBaseImageFilter (the engine behind AreaOpeningImageFilter / AreaClosingImageFilter) sorts the pixel indices by gray value before its union-find sweep:

std::stable_sort(&(m_SortPixels[0]), &(m_SortPixels[buffsize - 1]), m_CompareOffset);

The exclusive end iterator is off by one, so the sort covers only the first buffsize - 1 elements and the slot for the image's last raster-order pixel keeps its identity value — that pixel is exempt from sorting and is always swept last, regardless of its gray value. When the last raster pixel is a local extremum forming its own small component, its neighbors are processed first; a neighbor's union step sees the still-unvisited node's sentinel attribute (−1), which satisfies the attribute criterion and merges prematurely, and once the last pixel's own MakeSet runs it overwrites the premature parent link and survives as a permanent root. Net effect: an undersized component at the last raster position survives a Lambda that should remove it.

The fix sorts the full range: std::stable_sort(m_SortPixels.get(), m_SortPixels.get() + buffsize, m_CompareOffset);.

The new regression test puts an isolated single-pixel peak at the last raster position of a 5×1 row ([0, 0, 0, 0, 5]) and runs AreaOpeningImageFilter with Lambda = 2: the area-1 peak must be removed. A control case with the identical peak mid-row ([0, 0, 5, 0, 0]) is removed by both the fixed and unfixed code, pinning the semantics. The union-find trace of the unfixed failure is in the test's header comment.

Verified locally (Linux, GCC 13, Release): the new test fails on unmodified main (output [0, 0, 0, 0, 5] — the last-pixel peak survives) and passes with the fix; the full ITKReview suite plus the ITKImageStatistics, ITKLabelVoting, ITKDisplacementField, and ITKRegionGrowing suites pass with the fix applied.

PR Checklist

  • No API changes were made (or the changes have been approved)
  • No major design changes were made (or the changes have been approved)
  • Added test (or behavior not changed)
  • Updated API documentation (or API not changed)
  • Added license to new files (if any)
  • Added Python wrapping to new files (if any) as described in ITK Software Guide Section 9.5
  • Added ITK examples for all new major features (if any)
AI assistance
  • Tool: Claude Code (claude-fable-5, with claude-opus-4-8 subagents)
  • Role: implemented the fix and regression test from the analysis previously filed as item B19 of BUG: Findings collected while porting ~60 filters — reported together in one issue #6575; hand-traced the union-find sweep on the failing input before implementation
  • Testing: built locally and verified the regression test fails before / passes after the fix; five module test suites pass with the fix
  • Note: clang-format 19.1.7 was not available locally; formatting was hand-matched to ITK style and may need the CI formatter's touch-up

@github-actions github-actions Bot added type:Bug Inconsistencies or issues which will cause an incorrect result under some or all circumstances type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct labels Jul 10, 2026
@greptile-apps

This comment was marked as resolved.

Comment thread Modules/Nonunit/Review/test/itkAreaOpeningImageFilterLastRasterPixelTest.cxx Outdated
@hjmjohnson hjmjohnson marked this pull request as draft July 10, 2026 11:49
AttributeMorphologyBaseImageFilter sorted the pixel buffer with an
exclusive end iterator that was off by one:

  std::stable_sort(&(m_SortPixels[0]), &(m_SortPixels[buffsize - 1]), ...)

This covers only the first buffsize-1 entries, so the last slot keeps
its identity value and the image's last raster-order pixel is exempt
from the value sort and is always swept last, regardless of its gray
value. When that last pixel is an isolated extremum forming its own
undersized component, its neighbors are processed first; a neighbor's
union-find lookup sees the unvisited node's sentinel attribute (-1),
which satisfies the criterion and merges wrongly, and the last pixel's
own MakeSet then overwrites the premature parent link and survives as a
permanent root. The undersized component at the last raster pixel thus
survives an attribute criterion (Lambda) that should have removed it.

Use .get() pointer arithmetic for the range so the sort covers all
buffsize entries:

  std::stable_sort(m_SortPixels.get(), m_SortPixels.get() + buffsize, ...)

Add a regression test that runs an area opening on a single-row image
with a single-pixel peak at the last raster position; the fix removes
the peak while the unfixed code left it in place. A mid-row control
case guards the semantics.

Addresses item B19 of InsightSoftwareConsortium#6575.
@hjmjohnson hjmjohnson force-pushed the bug-attribute-morphology-sort-range branch from 4c7556e to 71b4ad9 Compare July 10, 2026 12:20
@hjmjohnson hjmjohnson marked this pull request as ready for review July 10, 2026 12:44
@hjmjohnson hjmjohnson self-requested a review July 10, 2026 12:45
@hjmjohnson

Copy link
Copy Markdown
Member

/azp run ITK.Linux.Python

@hjmjohnson hjmjohnson merged commit fe98237 into InsightSoftwareConsortium:main Jul 10, 2026
22 checks passed
physwkim added a commit to physwkim/sitk-rs that referenced this pull request Jul 12, 2026
itkAttributeMorphologyBaseImageFilter.hxx:123 sorted with an exclusive
end iterator one slot short of the buffer, so the last raster-order
pixel was exempt from the value sort and always swept last regardless
of its gray value. Combined with FindRoot not distinguishing
"never visited" (-1) from "genuine root" (-2), an undersized component
at that flat index could survive a Lambda it should have failed.

Matches fix PR InsightSoftwareConsortium/ITK#6581 (item B19 of #6575)
exactly: sort_pixels now sorts the whole array instead of
[..total - 1]. Once every pixel is sorted, the sweep's own invariant
guarantees a neighbor is only looked up via find_root after it has
already been swept, so find_root's INACTIVE/ACTIVE conflation becomes
dead code rather than a live defect -- no change to find_root itself
was needed, matching the upstream fix's scope.

Renamed and hand-recomputed the pinning test: an isolated area-1 peak
at the last raster pixel ([0, 0, 5], Lambda = 2.0) now correctly
collapses to [0, 0, 0], same as the identical peak swept mid-array in
the sibling test, traced through the corrected union-find sweep by
hand and cross-checked against the upstream fix PR's own regression
test (5-pixel row, peak at index 4).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:Bug Inconsistencies or issues which will cause an incorrect result under some or all circumstances type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants