From ff2bf6fd3c401c69bacd2402332eeb03485fab85 Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sun, 18 Oct 2026 09:16:04 +1000 Subject: [PATCH 1/2] Keep the adaptive threshold window non-empty for regions narrower than 16 pixels The cluster size is the region width divided by 16, truncated, so it was 0 for regions narrower than 16 pixels. Every averaging window was then empty and each pixel took the lower color. Use a cluster size of at least 1, as regions 16 to 31 pixels wide already do. Wider regions are unchanged. --- .../AdaptiveThresholdProcessor{TPixel}.cs | 3 +- .../Binarization/AdaptiveThresholdTests.cs | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs index e17de49d74..c3543053b2 100644 --- a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs @@ -40,7 +40,8 @@ protected override void OnFrameApply(ImageFrame source) // 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); + // A cluster size of 0 would make every averaging window empty, so regions narrower than 16 pixels use 1. + byte clusterSize = (byte)Math.Clamp(interest.Width / 16F, 1, 255); using Buffer2D intImage = source.CalculateIntegralImage(interest); RowOperation operation = new(configuration, interest, source.PixelBuffer, intImage, upper, lower, thresholdLimit, clusterSize); diff --git a/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs b/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs index c797677860..e696144eba 100644 --- a/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs +++ b/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs @@ -113,6 +113,35 @@ public void AdaptiveThreshold_Works(TestImageProvider provider) } } + [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); + } + + [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); + } + [Theory] [WithFile(TestImages.Png.Bradley02, PixelTypes.Rgba32)] public void AdaptiveThreshold_WithRectangle_Works(TestImageProvider provider) @@ -125,4 +154,27 @@ 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(); + + // The last column and row are skipped. With the smallest cluster size their averaging windows hold + // no pixels, so they always take the lower color, matching regions 16 to 31 pixels wide. + for (int y = region.Top; y < region.Bottom - 1; y++) + { + for (int x = region.Left; x < region.Right - 1; x++) + { + Assert.Equal(x == dark.X && y == dark.Y ? black : white, image[x, y]); + } + } + } } From 65d3cc34cd4132c6c03b5c68205c76d64992db98 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 27 Oct 2026 11:14:00 +1000 Subject: [PATCH 2/2] Correct centered adaptive threshold windows and border sums Include the current pixel and both window endpoints when querying the inclusive integral image. Reuse integral row spans and hoist vertical bounds out of the pixel loop. Check all border pixels and single-pixel dimensions against direct neighborhood sums. Independent direct summation reproduced every old reference with the old shifted windows and matched every corrected output with centered windows. Update those five proven incorrect references; exact comparisons remain unchanged. --- .../AdaptiveThresholdProcessor.cs | 4 + .../AdaptiveThresholdProcessor{TPixel}.cs | 69 ++++++++++----- .../Binarization/AdaptiveThresholdTests.cs | 84 ++++++++++++++++++- ...d_WithRectangle_Works_Rgba32_Bradley02.png | 4 +- ...aptiveThreshold_Works_Rgba32_Bradley01.png | 4 +- ...aptiveThreshold_Works_Rgba32_Bradley02.png | 4 +- ..._Issue_2217_AdaptiveThresholdProcessor.png | 4 +- .../AdaptiveThreshold_Works_Rgba32_ducky.png | 4 +- 8 files changed, 141 insertions(+), 36 deletions(-) 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 c3543053b2..4e2f49cb66 100644 --- a/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Binarization/AdaptiveThresholdProcessor{TPixel}.cs @@ -38,19 +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' - // A cluster size of 0 would make every averaging window empty, so regions narrower than 16 pixels use 1. - byte clusterSize = (byte)Math.Clamp(interest.Width / 16F, 1, 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; @@ -62,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, @@ -73,7 +83,7 @@ public RowOperation( TPixel upper, TPixel lower, float thresholdLimit, - byte clusterSize) + byte windowRadius) { this.configuration = configuration; this.bounds = bounds; @@ -84,7 +94,7 @@ public RowOperation( this.upper = upper; this.lower = lower; this.thresholdLimit = thresholdLimit; - this.clusterSize = clusterSize; + this.windowRadius = windowRadius; } /// @@ -99,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 e696144eba..743b58d79a 100644 --- a/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs +++ b/tests/ImageSharp.Tests/Processing/Binarization/AdaptiveThresholdTests.cs @@ -113,6 +113,11 @@ 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)] @@ -129,6 +134,9 @@ public void AdaptiveThreshold_NarrowImage_SeparatesDarkPixel(int width, int heig 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() { @@ -142,6 +150,39 @@ public void AdaptiveThreshold_NarrowRectangle_SeparatesDarkPixel() 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) @@ -167,14 +208,49 @@ private static void AssertOnlyDarkPixelIsLower(Image image, Rectangle re Rgba32 black = Color.Black.ToPixel(); Rgba32 white = Color.White.ToPixel(); - // The last column and row are skipped. With the smallest cluster size their averaging windows hold - // no pixels, so they always take the lower color, matching regions 16 to 31 pixels wide. - for (int y = region.Top; y < region.Bottom - 1; y++) + for (int y = region.Top; y < region.Bottom; y++) { - for (int x = region.Left; x < region.Right - 1; x++) + 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