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