Browse Source

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.
pull/3152/head
James Jackson-South 1 week ago
parent
commit
0b685ab034
  1. 64
      src/ImageSharp/Processing/Processors/Convolution/KernelSamplingMap.cs
  2. 30
      tests/ImageSharp.Tests/Processing/Processors/Convolution/GaussianBlurTest.cs
  3. 50
      tests/ImageSharp.Tests/Processing/Processors/Convolution/KernelSamplingMapTests.cs

64
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<int> 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;
}
}

30
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<Rgba32> 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);
}
}
});
}
}

50
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<BorderWrappingMode, int, int> 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);
}
}
}
Loading…
Cancel
Save