diff --git a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor.cs b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor.cs index aebfc3b464..14e53a28bf 100644 --- a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor.cs +++ b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor.cs @@ -9,8 +9,12 @@ namespace SixLabors.ImageSharp.Processing.Processors.Binarization; /// Performs Bradley Adaptive Threshold filter against an image. /// /// +/// Each pixel is compared with the mean of a centered window, clipped to the processing rectangle. +/// The window includes the current pixel, including when the rectangle is only one pixel wide or tall. +/// /// Implements "Adaptive Thresholding Using the Integral Image", /// see paper: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.420.7883&rep=rep1&type=pdf +/// /// public class AdaptiveThresholdProcessor : IImageProcessor { diff --git a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs index e17de49d74..4e2f49cb66 100644 --- a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs @@ -38,18 +38,18 @@ protected override void OnFrameApply(ImageFrame source) TPixel lower = this.definition.Lower.ToPixel(); float thresholdLimit = this.definition.ThresholdLimit; - // ClusterSize defines the size of cluster to used to check for average. - // Tweaked to support up to 4k wide pixels and not more. 4096 / 16 is 256 thus the '-1' - byte clusterSize = (byte)Math.Clamp(interest.Width / 16F, 0, 255); + // Use a centered window with at least one neighboring pixel on each side, where the bounds allow it. + // Retain the radius cap of 255 so the window does not keep growing for images wider than 4096 pixels. + byte windowRadius = (byte)Math.Clamp(interest.Width / 16, 1, 255); using Buffer2D intImage = source.CalculateIntegralImage(interest); - RowOperation operation = new(configuration, interest, source.PixelBuffer, intImage, upper, lower, thresholdLimit, clusterSize); - ParallelRowIterator.IterateRows( - configuration, - interest, - in operation); + RowOperation operation = new(configuration, interest, source.PixelBuffer, intImage, upper, lower, thresholdLimit, windowRadius); + ParallelRowIterator.IterateRows(configuration, interest, in operation); } + /// + /// Applies the local threshold using the inclusive sums stored in the integral image. + /// private readonly struct RowOperation : IRowOperation { private readonly Configuration configuration; @@ -61,8 +61,19 @@ protected override void OnFrameApply(ImageFrame source) private readonly float thresholdLimit; private readonly int startX; private readonly int startY; - private readonly byte clusterSize; + private readonly byte windowRadius; + /// + /// Initializes a new instance of the struct. + /// + /// The configuration used for bulk luminance conversion. + /// The image rectangle to process. + /// The pixel buffer to update. + /// The inclusive integral image, with coordinates relative to . + /// The output for samples above the local threshold. + /// The output for samples at or below the local threshold. + /// The fraction of the local mean used as the threshold. + /// The number of neighboring pixels to include on each side. [MethodImpl(InliningOptions.ShortMethod)] public RowOperation( Configuration configuration, @@ -72,7 +83,7 @@ public RowOperation( TPixel upper, TPixel lower, float thresholdLimit, - byte clusterSize) + byte windowRadius) { this.configuration = configuration; this.bounds = bounds; @@ -83,7 +94,7 @@ public RowOperation( this.upper = upper; this.lower = lower; this.thresholdLimit = thresholdLimit; - this.clusterSize = clusterSize; + this.windowRadius = windowRadius; } /// @@ -98,22 +109,37 @@ public void Invoke(int y, Span span) Span rowSpan = this.source.DangerousGetRowSpan(y).Slice(this.startX, span.Length); PixelOperations.Instance.ToL8(this.configuration, rowSpan, span); - int startY = this.startY; int maxX = this.bounds.Width - 1; - int maxY = this.bounds.Height - 1; - int clusterSize = this.clusterSize; + int windowRadius = this.windowRadius; float thresholdLimit = this.thresholdLimit; - Buffer2D image = this.intImage; + + // Integral coordinates are relative to the processing rectangle. Include the current pixel + // and clip each side of its centered window to that rectangle, including one-pixel dimensions. + int relativeY = y - this.startY; + int top = Math.Max(relativeY - windowRadius, 0); + int bottom = Math.Min(relativeY + windowRadius, this.bounds.Height - 1); + uint windowHeight = (uint)(bottom - top + 1); + + // I(x,y) includes row y and column x. Subtract the prefix ending just before the window. + // An empty prefix span represents the image's top boundary. Acquire both spans once per row. + ReadOnlySpan bottomRow = this.intImage.DangerousGetRowSpan(bottom); + ReadOnlySpan precedingRow = top > 0 ? this.intImage.DangerousGetRowSpan(top - 1) : default; + for (int x = 0; x < rowSpan.Length; x++) { - int x1 = Math.Clamp(x - clusterSize + 1, 0, maxX); - int x2 = Math.Min(x + clusterSize + 1, maxX); - int y1 = Math.Clamp(y - startY - clusterSize + 1, 0, maxY); - int y2 = Math.Min(y - startY + clusterSize + 1, maxY); + int left = Math.Max(x - windowRadius, 0); + int right = Math.Min(x + windowRadius, maxX); + uint count = (uint)(right - left + 1) * windowHeight; - uint count = (uint)((x2 - x1) * (y2 - y1)); - ulong sum = Math.Min(image[x2, y2] - image[x1, y2] - image[x2, y1] + image[x1, y1], ulong.MaxValue); + ulong sum = bottomRow[right] - (precedingRow.IsEmpty ? 0 : precedingRow[right]); + if (left > 0) + { + // Remove the columns before the window from the same vertical band. Subtracting + // these nonnegative band sums avoids unsigned underflow between integral corners. + sum -= bottomRow[left - 1] - (precedingRow.IsEmpty ? 0 : precedingRow[left - 1]); + } + // The inclusive window always contains the current pixel, so count cannot be zero. if (span[x].PackedValue * count <= sum * thresholdLimit) { rowSpan[x] = this.lower; diff --git a/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs b/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs index c797677860..743b58d79a 100644 --- a/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs +++ b/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs @@ -113,6 +113,76 @@ public void AdaptiveThreshold_Works(TestImageProvider provider) } } + /// + /// Checks every pixel, including the last row and column, in a narrow image. + /// + /// The image width. + /// The image height. + [Theory] + [InlineData(3, 3)] + [InlineData(8, 8)] + [InlineData(15, 15)] + [InlineData(15, 3)] + public void AdaptiveThreshold_NarrowImage_SeparatesDarkPixel(int width, int height) + { + using Image image = new(width, height, new Rgba32(200, 200, 200)); + Point dark = new(width / 2, height / 2); + image[dark.X, dark.Y] = new Rgba32(10, 10, 10); + + image.Mutate(img => img.AdaptiveThreshold()); + + AssertOnlyDarkPixelIsLower(image, new Rectangle(0, 0, width, height), dark); + } + + /// + /// Checks a narrow processing rectangle that does not start at the image origin. + /// + [Fact] + public void AdaptiveThreshold_NarrowRectangle_SeparatesDarkPixel() + { + using Image image = new(100, 100, new Rgba32(200, 200, 200)); + Rectangle rectangle = new(10, 10, 8, 50); + Point dark = new(14, 30); + image[dark.X, dark.Y] = new Rgba32(10, 10, 10); + + image.Mutate(img => img.AdaptiveThreshold(Color.White, Color.Black, rectangle)); + + AssertOnlyDarkPixelIsLower(image, rectangle, dark); + } + + /// + /// Checks centered windows against direct sums, including single-pixel dimensions and rectangle borders. + /// + /// The processing rectangle width. + /// The processing rectangle height. + /// The expected window radius. + [Theory] + [InlineData(1, 1, 1)] + [InlineData(1, 8, 1)] + [InlineData(8, 1, 1)] + [InlineData(15, 7, 1)] + [InlineData(16, 9, 1)] + [InlineData(31, 11, 1)] + [InlineData(32, 17, 2)] + public void AdaptiveThreshold_Rectangle_MatchesLocalMean(int width, int height, int radius) + { + Rectangle rectangle = new(3, 4, width, height); + using Image source = new(width + 6, height + 8); + for (int y = 0; y < source.Height; y++) + { + for (int x = 0; x < source.Width; x++) + { + // An asymmetric pattern detects windows shifted away from the current pixel. + source[x, y] = new L8((byte)((17 + (37 * x) + (61 * y)) % 256)); + } + } + + const float thresholdLimit = .85F; + using Image actual = source.Clone(context => context.AdaptiveThreshold(Color.White, Color.Black, thresholdLimit, rectangle)); + + AssertMatchesLocalMean(source, actual, rectangle, radius, thresholdLimit); + } + [Theory] [WithFile(TestImages.Png.Bradley02, PixelTypes.Rgba32)] public void AdaptiveThreshold_WithRectangle_Works(TestImageProvider provider) @@ -125,4 +195,62 @@ public void AdaptiveThreshold_WithRectangle_Works(TestImageProvider + /// Asserts that a thresholded region of uniform light pixels with a single dark pixel + /// maps the dark pixel to black and every other pixel to white. + /// + /// The thresholded image. + /// The region that was thresholded. + /// The location of the dark pixel. + private static void AssertOnlyDarkPixelIsLower(Image image, Rectangle region, Point dark) + { + Rgba32 black = Color.Black.ToPixel(); + Rgba32 white = Color.White.ToPixel(); + + for (int y = region.Top; y < region.Bottom; y++) + { + for (int x = region.Left; x < region.Right; x++) + { + Assert.Equal(x == dark.X && y == dark.Y ? black : white, image[x, y]); + } + } + } + + /// + /// Checks the local mean without using an integral image, and checks that pixels outside the rectangle are unchanged. + /// + /// The unchanged grayscale input. + /// The processed image. + /// The processing rectangle. + /// The expected window radius. + /// The fraction of the local mean used as the threshold. + private static void AssertMatchesLocalMean(Image source, Image actual, Rectangle rectangle, int radius, float thresholdLimit) + { + for (int y = 0; y < source.Height; y++) + { + for (int x = 0; x < source.Width; x++) + { + L8 expected = source[x, y]; + if (rectangle.Contains(x, y)) + { + int sum = 0; + int count = 0; + for (int sampleY = Math.Max(y - radius, rectangle.Top); sampleY <= Math.Min(y + radius, rectangle.Bottom - 1); sampleY++) + { + for (int sampleX = Math.Max(x - radius, rectangle.Left); sampleX <= Math.Min(x + radius, rectangle.Right - 1); sampleX++) + { + sum += source[sampleX, sampleY].PackedValue; + count++; + } + } + + // Include both window endpoints. A one-pixel window therefore has count = 1. + expected = new L8(source[x, y].PackedValue * count <= sum * thresholdLimit ? (byte)0 : (byte)255); + } + + Assert.Equal(expected, actual[x, y]); + } + } + } } diff --git a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_WithRectangle_Works_Rgba32_Bradley02.png b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_WithRectangle_Works_Rgba32_Bradley02.png index 94d50e1ad8..dcb047533e 100644 --- a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_WithRectangle_Works_Rgba32_Bradley02.png +++ b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_WithRectangle_Works_Rgba32_Bradley02.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5745f61e9b8cd49066b347605deee6dcde17690b9dc0f675466df6b2db706bd6 -size 22348 +oid sha256:3f850f3896506ab81a6652f7a5eeed365bc307a7bb128454e06ea13e03fb0232 +size 46587 diff --git a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley01.png b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley01.png index 5e53399d73..b203bdb3c7 100644 --- a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley01.png +++ b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley01.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a767913020c3924f0a7ae95b20c064993a2fcdc3007610df6abe6f34c194ef8 -size 1644 +oid sha256:f5189c87da002b2a871cbd4852e69d9e8ef95ebfaa8c1952bbfe2bd1d51f29a9 +size 5193 diff --git a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley02.png b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley02.png index 97a594cf61..c91e14759e 100644 --- a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley02.png +++ b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Bradley02.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e02f5e94b9251be80250926678a2d8bc05318f40c3eff98204e74312ffbca138 -size 2239 +oid sha256:94495e7ebabaa79fbaeafe8bbe067f381961685615fa98b7341074d2dad117cf +size 5204 diff --git a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Issue_2217_AdaptiveThresholdProcessor.png b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Issue_2217_AdaptiveThresholdProcessor.png index 046dec8e55..af61d1e2a0 100644 --- a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Issue_2217_AdaptiveThresholdProcessor.png +++ b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_Issue_2217_AdaptiveThresholdProcessor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9e47d328e9d6d37a5bf090d2f8d748b80d78be936df06f4b3afec0fd9712f39 -size 412 +oid sha256:d0d01167410f33da4517f982b3b5a7117cfe975b032cddd5b9cc57fbc06ea621 +size 358 diff --git a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_ducky.png b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_ducky.png index 5c19a84215..46a8fa2ed0 100644 --- a/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_ducky.png +++ b/tests/Images/External/ReferenceOutput/AdaptiveThresholdTests/AdaptiveThreshold_Works_Rgba32_ducky.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdd84a24f616d7f06f78ebca01540b59cf1cf8564f442548fe4c8ede6dc1d412 -size 757 +oid sha256:3137411302e1749d472df15d061f555fb3d676873837019369bd1ac362d2d0ad +size 2065