Skip to content

Fix one-pixel shift when smooth_edge_size is even - #5

Merged
wrignj08 merged 1 commit into
mainfrom
fix/even-smooth-edge-size-shift
Aug 14, 2026
Merged

Fix one-pixel shift when smooth_edge_size is even#5
wrignj08 merged 1 commit into
mainfrom
fix/even-smooth-edge-size-shift

Conversation

@wrignj08

Copy link
Copy Markdown
Contributor

Even values of smooth_edge_size translated the output one pixel down and to the right. Most visible at smooth_edge_size=2, where the opening should be a no-op on a solid blob, so the shift is the only effect:

input (blob at rows/cols 4-8)     output before this PR
0 0 0 0 0 0 0 0 0 0               0 0 0 0 0 0 0 0 0 0
0 0 0 0 1 1 1 1 1 0               0 0 0 0 0 0 0 0 0 0
0 0 0 0 1 1 1 1 1 0               0 0 0 0 0 1 1 1 1 1
0 0 0 0 1 1 1 1 1 0               0 0 0 0 0 1 1 1 1 1

Confirmed on a 6x6 blob at rows/cols 4-9 in a 14x14 field:

smooth_edge_size output bbox anti-extensive?
2 rows/cols 5-10
3 rows/cols 4-9
4 rows/cols 5-10
5 rows/cols 4-9
6 rows/cols 5-10

Cause

Not the kernel — create_circle_kernel produces 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 to ksize//2. In OpenCV's convention erode applies the element as-is while dilate applies it reflected, so a true opening needs the dilation anchored at ksize-1-a. For odd sizes a == 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 morphologyEx on 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 morphologyEx does 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:

kernel morphologyEx split, separate buffer split, in-place (this PR)
2 2.07 ms 5.30 ms (2.6x) 2.11 ms
3 2.45 ms 2.98 ms 2.83 ms
4 3.34 ms 3.97 ms 3.61 ms
8 9.77 ms 10.15 ms 9.96 ms
12 19.86 ms 20.06 ms 19.87 ms

The penalty was worst exactly where the fix matters most. End-to-end on the Landsat image (8011x7901, 4 classes), 5 runs each side, clean_array medians: 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_SMOOTH was [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 than clean_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 of 2. 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 warrant 0.5.0 rather than a patch bump.

🤖 Generated with Claude Code

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>
@wrignj08
wrignj08 merged commit 830d492 into main Aug 14, 2026
2 checks passed
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.

1 participant