diff --git a/src/ImageSharp/Advanced/AotCompilerTools.cs b/src/ImageSharp/Advanced/AotCompilerTools.cs index be2e964fc0..fbebd055f2 100644 --- a/src/ImageSharp/Advanced/AotCompilerTools.cs +++ b/src/ImageSharp/Advanced/AotCompilerTools.cs @@ -307,6 +307,7 @@ private static void AotCompileImageProcessors() AotCompileImageProcessor(); AotCompileImageProcessor(); AotCompileImageProcessor(); + AotCompileImageProcessor(); AotCompileImageProcessor(); AotCompileImageProcessor(); AotCompileImageProcessor(); diff --git a/src/ImageSharp/Processing/Extensions/Normalization/HistogramEqualizationExtensions.cs b/src/ImageSharp/Processing/Extensions/Normalization/HistogramEqualizationExtensions.cs index 175c7648ae..10f619b897 100644 --- a/src/ImageSharp/Processing/Extensions/Normalization/HistogramEqualizationExtensions.cs +++ b/src/ImageSharp/Processing/Extensions/Normalization/HistogramEqualizationExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. using SixLabors.ImageSharp.Processing.Processors.Normalization; @@ -28,5 +28,14 @@ public static IImageProcessingContext HistogramEqualization( this IImageProcessingContext source, HistogramEqualizationOptions options) => source.ApplyProcessor(HistogramEqualizationProcessor.FromOptions(options)); + + /// + /// Normalize an image by stretching the dynamic range to full contrast + /// + /// The image this method extends. + /// The to allow chaining of operations. + public static IImageProcessingContext AutoLevel( + this IImageProcessingContext source) => + source.ApplyProcessor(new AutoLevelProcessor()); } -} \ No newline at end of file +} diff --git a/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs new file mode 100644 index 0000000000..5120a20f37 --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs @@ -0,0 +1,19 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Normalization +{ + /// + /// Normalize an image by stretching the dynamic range to full contrast + /// Applicable to an . + /// + public class AutoLevelProcessor : IImageProcessor + { + /// + public IImageProcessor CreatePixelSpecificProcessor(Configuration configuration, Image source, Rectangle sourceRectangle) + where TPixel : unmanaged, IPixel + => new AutoLevelProcessor(configuration, source, sourceRectangle); + } +} diff --git a/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor{TPixel}.cs new file mode 100644 index 0000000000..c935b482f1 --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor{TPixel}.cs @@ -0,0 +1,177 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Normalization +{ + /// + /// Normalize an image by stretching the dynamic range to full contrast + /// Applicable to an . + /// + /// The pixel format. + internal class AutoLevelProcessor : ImageProcessor + where TPixel : unmanaged, IPixel + { + private readonly int luminanceLevels; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration which allows altering default behaviour or extending the library. + /// The source for the current processor instance. + /// The source area to process for the current processor instance. + public AutoLevelProcessor( + Configuration configuration, + Image source, + Rectangle sourceRectangle) + : base(configuration, source, sourceRectangle) + { + // TODO I don't know how to get channel bit depth for non-grayscale types + if (!(typeof(TPixel) == typeof(L16) || typeof(TPixel) == typeof(L8))) + { + throw new ArgumentException("AutoLevelHistogramProcessor only works for L8 or L16 pixel types"); + } + + this.luminanceLevels = ColorNumerics.GetColorCountForBitDepth(source.PixelType.BitsPerPixel); + } + + protected override void OnFrameApply(ImageFrame source) + { + var interest = Rectangle.Intersect(this.SourceRectangle, source.Bounds()); + + int[] rowMinimums = new int[source.Height]; + int[] rowMaximums = new int[source.Height]; + + var grayscaleOperation = new GrayscaleLevelsMinMaxRowOperation(interest, source, this.luminanceLevels, rowMinimums, rowMaximums); + ParallelRowIterator.IterateRows( + this.Configuration, + interest, + in grayscaleOperation); + + int minLuminance = rowMinimums.Min(); + int maxLuminance = rowMaximums.Max(); + + if (minLuminance == 0 && maxLuminance == this.luminanceLevels - 1) + { + return; + } + + var contrastStretchOperation = new GrayscaleLevelsContrastStretchOperation(interest, source, this.luminanceLevels, minLuminance, maxLuminance); + ParallelRowIterator.IterateRows( + this.Configuration, + interest, + in contrastStretchOperation); + } + + /// + /// A to calculate the min and max luminance of a row for . + /// + private readonly struct GrayscaleLevelsMinMaxRowOperation : IRowOperation + { + private readonly Rectangle bounds; + private readonly ImageFrame source; + private readonly int luminanceLevels; + private readonly int[] rowMinimums; + private readonly int[] rowMaximums; + + [MethodImpl(InliningOptions.ShortMethod)] + public GrayscaleLevelsMinMaxRowOperation( + Rectangle bounds, + ImageFrame source, + int luminanceLevels, + int[] rowMinimums, + int[] rowMaximums) + { + this.bounds = bounds; + this.source = source; + this.luminanceLevels = luminanceLevels; + this.rowMinimums = rowMinimums; + this.rowMaximums = rowMaximums; + } + + /// +#if NETSTANDARD2_0 + // https://github.com/SixLabors/ImageSharp/issues/1204 + [MethodImpl(MethodImplOptions.NoOptimization)] +#else + [MethodImpl(InliningOptions.ShortMethod)] +#endif + public void Invoke(int y) + { + Span pixelRow = this.source.GetPixelRowSpan(y); + int levels = this.luminanceLevels; + + int minLuminance = int.MaxValue; + int maxLuminance = int.MinValue; + + for (int x = this.bounds.X; x < this.bounds.Width; x++) + { + // TODO: We should bulk convert here. + var vector = pixelRow[x].ToVector4(); + int luminance = ColorNumerics.GetBT709Luminance(ref vector, levels); + minLuminance = Math.Min(luminance, minLuminance); + maxLuminance = Math.Max(luminance, maxLuminance); + } + + this.rowMinimums[y] = minLuminance; + this.rowMaximums[y] = maxLuminance; + } + } + + /// + /// A to contrast stretch a row for . + /// + private readonly struct GrayscaleLevelsContrastStretchOperation : IRowOperation + { + private readonly Rectangle bounds; + private readonly ImageFrame source; + private readonly int luminanceLevels; + private readonly int minLuminance; + private readonly int maxLuminance; + + [MethodImpl(InliningOptions.ShortMethod)] + public GrayscaleLevelsContrastStretchOperation( + Rectangle bounds, + ImageFrame source, + int luminanceLevels, + int minLuminance, + int maxLuminance) + { + this.bounds = bounds; + this.source = source; + this.luminanceLevels = luminanceLevels; + this.minLuminance = minLuminance; + this.maxLuminance = maxLuminance; + } + + /// +#if NETSTANDARD2_0 + // https://github.com/SixLabors/ImageSharp/issues/1204 + [MethodImpl(MethodImplOptions.NoOptimization)] +#else + [MethodImpl(InliningOptions.ShortMethod)] +#endif + public void Invoke(int y) + { + Span pixelRow = this.source.GetPixelRowSpan(y); + float dynamicRange = this.maxLuminance - this.minLuminance; + + for (int x = this.bounds.X; x < this.bounds.Width; x++) + { + // TODO: We should bulk convert here. + ref TPixel pixel = ref pixelRow[x]; + var vector = pixel.ToVector4(); + int luminance = ColorNumerics.GetBT709Luminance(ref vector, this.luminanceLevels); + float luminanceConstrastStretched = (luminance - this.minLuminance) / dynamicRange; + pixel.FromVector4(new Vector4(luminanceConstrastStretched, luminanceConstrastStretched, luminanceConstrastStretched, vector.W)); + } + } + } + } +} diff --git a/tests/ImageSharp.Benchmarks/Processing/HistogramEqualization.cs b/tests/ImageSharp.Benchmarks/Processing/HistogramEqualization.cs index cfcb69a0a9..722dc452a1 100644 --- a/tests/ImageSharp.Benchmarks/Processing/HistogramEqualization.cs +++ b/tests/ImageSharp.Benchmarks/Processing/HistogramEqualization.cs @@ -48,5 +48,9 @@ public void AdaptiveHistogramEqualization() LuminanceLevels = 256, Method = HistogramEqualizationMethod.AdaptiveTileInterpolation })); + + [Benchmark(Description = "AutoLevel (Min/Max Contrast Stretch)")] + public void AutoLevelHistogram() + => this.image.Mutate(img => img.AutoLevel()); } } diff --git a/tests/ImageSharp.Tests/Processing/Normalization/AutoLevelTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/AutoLevelTests.cs new file mode 100644 index 0000000000..082493ed29 --- /dev/null +++ b/tests/ImageSharp.Tests/Processing/Normalization/AutoLevelTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System.Linq; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using Xunit; + +namespace SixLabors.ImageSharp.Tests.Processing.Normalization +{ + // ReSharper disable InconsistentNaming + public class AutoLevelTests + { + private static readonly ImageComparer ValidatorComparer = ImageComparer.TolerantPercentage(0.0456F); + + [Fact] + public void AutoLevel_WhenL16_StretchesBetweenMinAndMax() + { + // Arrange + ushort[] pixels = new ushort[] + { + 100, 120, 140, 160, 180, 200, + }; + + ushort step = (ushort)(ushort.MaxValue / (pixels.Length - 1)); + + using var image = new Image(pixels.Length, 1); + for (int x = 0; x < pixels.Length; x++) + { + image[x, 0] = new L16(pixels[x]); + } + + ushort[] expected = + Enumerable.Range(0, pixels.Length) + .Select(e => (ushort)(e * step)) + .ToArray(); + + // Act + image.Mutate(x => x.AutoLevel()); + + ushort[] actual = image.GetPixelRowSpan(0).ToArray().Select(e => e.PackedValue).ToArray(); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void AutoLevel_WhenL8_StretchesBetweenMinAndMax() + { + // Arrange + byte[] pixels = new byte[] + { + 100, 120, 140, 160, 180, 200, + }; + + byte step = (byte)(byte.MaxValue / (pixels.Length - 1)); + + using var image = new Image(pixels.Length, 1); + for (int x = 0; x < pixels.Length; x++) + { + image[x, 0] = new L8(pixels[x]); + } + + byte[] expected = + Enumerable.Range(0, pixels.Length) + .Select(e => (byte)(e * step)) + .ToArray(); + + // Act + image.Mutate(x => x.AutoLevel()); + + byte[] actual = image.GetPixelRowSpan(0).ToArray().Select(e => e.PackedValue).ToArray(); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void AutoLevel_WhenTwoRows_StretchesBetweenMinAndMax() + { + // Arrange + byte[] pixels = new byte[] + { + 100, 200 + }; + + using var image = new Image(1, 2); + image[0, 0] = new L8(pixels[0]); + image[0, 1] = new L8(pixels[1]); + + // Act + image.Mutate(x => x.AutoLevel()); + + // Assert + Assert.Equal(0, image[0, 0].PackedValue); + Assert.Equal(byte.MaxValue, image[0, 1].PackedValue); + } + + [Theory] + [WithFile(TestImages.Jpeg.Baseline.HistogramEqImage, PixelTypes.L8)] + public void AutoLevel_CompareToReferenceOutput(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(); + image.Mutate(x => x.AutoLevel()); + image.DebugSave(provider); + image.CompareToReferenceOutput(ValidatorComparer, provider); + } + } +} diff --git a/tests/Images/External/ReferenceOutput/AutoLevelTests/AutoLevel_CompareToReferenceOutput_L8_640px-Unequalized_Hawkes_Bay_NZ.png b/tests/Images/External/ReferenceOutput/AutoLevelTests/AutoLevel_CompareToReferenceOutput_L8_640px-Unequalized_Hawkes_Bay_NZ.png new file mode 100644 index 0000000000..40dabc44c5 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/AutoLevelTests/AutoLevel_CompareToReferenceOutput_L8_640px-Unequalized_Hawkes_Bay_NZ.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df24d12fa8b9819654c3ddc9e519465c5407770a08c503d93460847707582f9b +size 144921