Fix one-pixel shift when smooth_edge_size is even - #5
Merged
Conversation
cv2.morphologyEx(MORPH_OPEN) applies a single anchor to both the erosion and the dilation it performs. That is only correct when the anchor lands on the structuring element's centre of symmetry: true for odd kernel sizes, false for even ones. For even smooth_edge_size the result was the correct opening translated one pixel down and to the right, and was not anti-extensive -- smoothing could add pixels to a class rather than only removing them. It was most visible at smooth_edge_size=2, where the opening is otherwise a no-op on a solid blob so the shift was the only effect. Run the erosion and dilation separately, anchoring the dilation at the reflection of the erosion's anchor. For odd sizes the two anchors coincide and output is bit-identical to before, including border handling; the Landsat regression fixture (smooth_edge_size=3) is unchanged. The dilation writes back into the erosion's buffer, as morphologyEx does internally. Allocating a second full-size buffer instead costs more than the morphology itself at small kernels -- 2.6x on a 4000x4000 mask at smooth_edge_size=2, where allocation dominates the trivial compute. As written, runtime and peak memory match the previous implementation. This changes output for even smooth_edge_size, including the default of 2. Tests: pin the opening against an independent implementation of the definition, exact at borders as well as in the interior, and cover the even sizes the parameter sweeps previously skipped (SWEEP_SMOOTH was [0, 1, 3]). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Even values of
smooth_edge_sizetranslated the output one pixel down and to the right. Most visible atsmooth_edge_size=2, where the opening should be a no-op on a solid blob, so the shift is the only effect:Confirmed on a 6x6 blob at rows/cols 4-9 in a 14x14 field:
smooth_edge_sizeCause
Not the kernel —
create_circle_kernelproduces kernels that are all 180-degree-rotation symmetric, including the even ones. It is the anchor.morphologyEx(MORPH_OPEN)erodes then dilates using the same anchor for both, defaulting toksize//2. In OpenCV's conventionerodeapplies the element as-is whiledilateapplies it reflected, so a true opening needs the dilation anchored atksize-1-a. For odd sizesa == ksize-1-a, so it is self-correcting and the bug never surfaced. For even sizes the two differ by one, and the result is the correct opening translated by(+1, +1)— and no longer anti-extensive, so smoothing could add pixels to a class instead of only removing them.Fix
Split the single call into an explicit erode + dilate with the reflected anchor. Output for odd sizes is bit-identical to before, including border handling — verified against
morphologyExon random masks for sizes 1-9 — so the Landsat regression fixture (smooth_edge_size=3) is untouched.Performance
No regression, but this needed care. The dilation writes back into the erosion's buffer, which is what
morphologyExdoes internally; allocating a second full-size buffer instead costs more than the morphology itself at small kernels. On a 4000x4000 mask, single-threaded, best of 7:morphologyExThe penalty was worst exactly where the fix matters most. End-to-end on the Landsat image (8011x7901, 4 classes), 5 runs each side,
clean_arraymedians: 3.03 s -> 2.98 s at ks=2 and 2.95 s -> 2.96 s at ks=3 — indistinguishable. Peak memory is unchanged (one transient buffer per concurrent worker).The in-place dilation is safe under the thread pool because the buffer is created per call; only the read-only kernels are shared.
Tests
573 pass. The parameter sweeps were the reason this survived:
SWEEP_SMOOTHwas[0, 1, 3], so no even kernel size appeared anywhere in the grid. Now[0, 1, 2, 3, 4].New tests, each mutation-checked to confirm it catches what it claims:
test_smoothing_matches_reference_opening— pins the exact result against_reference_opening, an explicit slide implementing the definition (union of every translate of B that fits inside A) sharing no machinery with the cv2 path. Properties alone cannot distinguish a correct opening from a differently-wrong one: rounding even sizes up to odd satisfies every other test here while quietly changing how much smoothing the caller asked for. That mutation fails this test on exactly[2]and[4], with all 555 others passing.test_smoothing_matches_reference_opening_at_borders— same equivalence with content flush to all four edges. The oracle models cv2's border convention (out-of-image pixels read as foreground during erosion), validated against cv2 for sizes 1-8 before being relied on. Swapping the two anchors — invisible in the interior, edge-only — fails only this test, on 3 of 8 sizes.test_smoothing_does_not_erode_content_at_the_image_edge— a band flush to three edges survives an opening untouched. A background border (borderValue=0, the classic form of this bug) fails 20 tests concentrated here.test_smoothing_does_not_translate_blobs,test_smoothing_preserves_symmetry,test_smoothing_never_grows_a_class— the direct regression properties. Symmetry is asserted against the smoothing stage rather thanclean_array, because the nearest-neighbour fill downstream breaks ties between equidistant sources arbitrarily and is asymmetric by design.test_output_is_independent_of_max_workers— nothing previously asserted that output does not depend on worker count, which is a real contract for tiled/parallel use.Compatibility
This changes output for even
smooth_edge_size, including the default of2. Odd values are unaffected. Cached results produced with an even size will disagree with new output by one pixel and should be regenerated.Worth deciding before release: the CHANGELOG entry sits under
[Unreleased], but since this changes default-path output it may warrant0.5.0rather than a patch bump.🤖 Generated with Claude Code