From 0b685ab0348638d61a601daf97e0a254ac5f9744 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Fri, 10 Jul 2026 19:57:54 +1000 Subject: [PATCH] Fix convolution sampling for bounds smaller than the kernel radius KernelSamplingMap.CorrectBorder sliced the offset span by radius * kernelSize, so bounds narrower than the kernel radius threw ArgumentOutOfRangeException (GaussianBlur of a 130x8 region with sigma 10, for example), and bounds exactly equal to the radius let Bounce offsets escape the sampling range by folding an overshoot of a full extent to min - 1. Route bounds no larger than the radius through an exact per-mode correction that folds each offset into range: clamp for Repeat, periodic reflection for Mirror and Bounce, and true modulo for Wrap. The single-pass corrections are unchanged for larger bounds. --- .../Convolution/KernelSamplingMap.cs | 64 ++++++++++++++++++- .../Convolution/GaussianBlurTest.cs | 30 +++++++++ .../Convolution/KernelSamplingMapTests.cs | 50 +++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/ImageSharp.Tests/Processing/Processors/Convolution/KernelSamplingMapTests.cs diff --git a/src/ImageSharp/Processing/Processors/Convolution/KernelSamplingMap.cs b/src/ImageSharp/Processing/Processors/Convolution/KernelSamplingMap.cs index 7653aeaa96..2e42928900 100644 --- a/src/ImageSharp/Processing/Processors/Convolution/KernelSamplingMap.cs +++ b/src/ImageSharp/Processing/Processors/Convolution/KernelSamplingMap.cs @@ -104,7 +104,15 @@ internal sealed class KernelSamplingMap : IDisposable { int affectedSize = (kernelSize >> 1) * kernelSize; ref int spanBase = ref MemoryMarshal.GetReference(span); - if (affectedSize > 0) + if (affectedSize >= span.Length && span.Length > 0) + { + // The bounds are no larger than the kernel radius: every offset can overshoot the + // borders by a full sampling extent or more, which the single-pass head and tail + // corrections below cannot fold back into range (and for bounds strictly smaller + // than the radius they cannot even slice the span). Fold each offset exactly. + CorrectBorderExact(span, min, max, borderMode); + } + else if (affectedSize > 0) { switch (borderMode) { @@ -180,4 +188,58 @@ internal sealed class KernelSamplingMap : IDisposable } } } + + private static void CorrectBorderExact(Span span, int min, int max, BorderWrappingMode borderMode) + { + int extent = max - min + 1; + switch (borderMode) + { + case BorderWrappingMode.Repeat: + Numerics.Clamp(span, min, max); + break; + + case BorderWrappingMode.Mirror: + // Reflection about the half-sample border: ..., min+1, min | min, min+1, ... + int mirrorPeriod = 2 * extent; + for (int i = 0; i < span.Length; i++) + { + int offset = Modulo(span[i] - min, mirrorPeriod); + span[i] = min + (offset < extent ? offset : mirrorPeriod - 1 - offset); + } + + break; + + case BorderWrappingMode.Bounce: + // Reflection about the border sample itself: ..., min+2, min+1 | min, min+1, ... + if (extent == 1) + { + span.Fill(min); + break; + } + + int bouncePeriod = (2 * extent) - 2; + for (int i = 0; i < span.Length; i++) + { + int offset = Modulo(span[i] - min, bouncePeriod); + span[i] = min + (offset < extent ? offset : bouncePeriod - offset); + } + + break; + + case BorderWrappingMode.Wrap: + for (int i = 0; i < span.Length; i++) + { + span[i] = min + Modulo(span[i] - min, extent); + } + + break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int Modulo(int value, int period) + { + int remainder = value % period; + return remainder < 0 ? remainder + period : remainder; + } } diff --git a/tests/ImageSharp.Tests/Processing/Processors/Convolution/GaussianBlurTest.cs b/tests/ImageSharp.Tests/Processing/Processors/Convolution/GaussianBlurTest.cs index 0728bb5d84..f9f10cb70f 100644 --- a/tests/ImageSharp.Tests/Processing/Processors/Convolution/GaussianBlurTest.cs +++ b/tests/ImageSharp.Tests/Processing/Processors/Convolution/GaussianBlurTest.cs @@ -1,7 +1,9 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Convolution; namespace SixLabors.ImageSharp.Tests.Processing.Processors.Convolution; @@ -13,4 +15,32 @@ public class GaussianBlurTest : Basic1ParameterConvolutionTests protected override void Apply(IImageProcessingContext ctx, int value, Rectangle bounds) => ctx.GaussianBlur(bounds, value); + + [Theory] + [InlineData(BorderWrappingMode.Repeat)] + [InlineData(BorderWrappingMode.Mirror)] + [InlineData(BorderWrappingMode.Bounce)] + [InlineData(BorderWrappingMode.Wrap)] + public void OnImageSmallerThanKernelRadius_LeavesSolidColorUnchanged(BorderWrappingMode mode) + { + // 8 rows against sigma 10 (61-tap kernel, radius 30). A normalized blur of a solid + // image must remain that color no matter how the border sampling folds. + Rgba32 red = new(255, 0, 0); + using Image image = new(130, 8, red); + image.Mutate(x => x.GaussianBlur(image.Bounds, 10F, mode, mode)); + + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < accessor.Height; y++) + { + foreach (Rgba32 pixel in accessor.GetRowSpan(y)) + { + Assert.InRange(pixel.R, 254, 255); + Assert.InRange(pixel.G, 0, 1); + Assert.InRange(pixel.B, 0, 1); + Assert.Equal(255, pixel.A); + } + } + }); + } } diff --git a/tests/ImageSharp.Tests/Processing/Processors/Convolution/KernelSamplingMapTests.cs b/tests/ImageSharp.Tests/Processing/Processors/Convolution/KernelSamplingMapTests.cs new file mode 100644 index 0000000000..5194fa0010 --- /dev/null +++ b/tests/ImageSharp.Tests/Processing/Processors/Convolution/KernelSamplingMapTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Processing.Processors.Convolution; + +namespace SixLabors.ImageSharp.Tests.Processing.Processors.Convolution; + +[Trait("Category", "Processors")] +[GroupOutput("Convolution")] +public class KernelSamplingMapTests +{ + public static readonly TheoryData BoundsSmallerThanKernel = new() + { + { BorderWrappingMode.Repeat, 8, 5 }, + { BorderWrappingMode.Mirror, 8, 5 }, + { BorderWrappingMode.Bounce, 8, 5 }, + { BorderWrappingMode.Wrap, 8, 5 }, + { BorderWrappingMode.Repeat, 1, 1 }, + { BorderWrappingMode.Mirror, 1, 1 }, + { BorderWrappingMode.Bounce, 1, 1 }, + { BorderWrappingMode.Wrap, 1, 1 }, + { BorderWrappingMode.Repeat, 30, 30 }, + { BorderWrappingMode.Mirror, 30, 30 }, + { BorderWrappingMode.Bounce, 30, 30 }, + { BorderWrappingMode.Wrap, 30, 30 }, + }; + + [Theory] + [MemberData(nameof(BoundsSmallerThanKernel))] + public void BuildSamplingOffsetMap_BoundsSmallerThanKernelRadius_OffsetsStayInBounds(BorderWrappingMode mode, int width, int height) + { + // A 61-tap kernel has radius 30, so these bounds are covered entirely by the border + // regions and offsets can overshoot the bounds by more than one sampling extent. + const int kernelSize = 61; + Rectangle bounds = new(100, 200, width, height); + + using KernelSamplingMap map = new(Configuration.Default.MemoryAllocator); + map.BuildSamplingOffsetMap(kernelSize, kernelSize, bounds, mode, mode); + + foreach (int x in map.GetColumnOffsetSpan()) + { + Assert.InRange(x, bounds.Left, bounds.Right - 1); + } + + foreach (int y in map.GetRowOffsetSpan()) + { + Assert.InRange(y, bounds.Top, bounds.Bottom - 1); + } + } +}