diff --git a/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs new file mode 100644 index 0000000000..43609367ed --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor.cs @@ -0,0 +1,46 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Processing.Processors.Normalization; + +/// +/// Applies a luminance histogram equilization to the image. +/// +public class AutoLevelProcessor : HistogramEqualizationProcessor +{ + /// + /// Initializes a new instance of the class. + /// It uses the exact minimum and maximum values found in the luminance channel, as the BlackPoint and WhitePoint to linearly stretch the colors + /// (and histogram) of the image. + /// + /// The number of different luminance levels. Typical values are 256 for 8-bit grayscale images + /// or 65536 for 16-bit grayscale images. + /// Indicating whether to clip the histogram bins at a specific value. + /// The histogram clip limit. Histogram bins which exceed this limit, will be capped at this value. + /// Whether to apply a synchronized luminance value to each color channel. + public AutoLevelProcessor( + int luminanceLevels, + bool clipHistogram, + int clipLimit, + bool syncChannels) + : base(luminanceLevels, clipHistogram, clipLimit) + { + this.SyncChannels = syncChannels; + } + + /// + /// Gets a value indicating whether to apply a synchronized luminance value to each color channel. + /// + public bool SyncChannels { get; } + + /// + public override IImageProcessor CreatePixelSpecificProcessor(Configuration configuration, Image source, Rectangle sourceRectangle) + => new AutoLevelProcessor( + configuration, + this.LuminanceLevels, + this.ClipHistogram, + this.ClipLimit, + this.SyncChannels, + 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..856fba3dcb --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Normalization/AutoLevelProcessor{TPixel}.cs @@ -0,0 +1,204 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Normalization; + +/// +/// Applies a luminance histogram equalization to the image. +/// +/// The pixel format. +internal class AutoLevelProcessor : HistogramEqualizationProcessor + where TPixel : unmanaged, IPixel +{ + /// + /// Initializes a new instance of the class. + /// + /// The configuration which allows altering default behaviour or extending the library. + /// + /// The number of different luminance levels. Typical values are 256 for 8-bit grayscale images + /// or 65536 for 16-bit grayscale images. + /// + /// Indicating whether to clip the histogram bins at a specific value. + /// The histogram clip limit. Histogram bins which exceed this limit, will be capped at this value. + /// The source for the current processor instance. + /// The source area to process for the current processor instance. + /// Whether to apply a synchronized luminance value to each color channel. + public AutoLevelProcessor( + Configuration configuration, + int luminanceLevels, + bool clipHistogram, + int clipLimit, + bool syncChannels, + Image source, + Rectangle sourceRectangle) + : base(configuration, luminanceLevels, clipHistogram, clipLimit, source, sourceRectangle) + { + this.SyncChannels = syncChannels; + } + + /// + /// Gets a value indicating whether to apply a synchronized luminance value to each color channel. + /// + private bool SyncChannels { get; } + + /// + protected override void OnFrameApply(ImageFrame source) + { + MemoryAllocator memoryAllocator = this.Configuration.MemoryAllocator; + int numberOfPixels = source.Width * source.Height; + var interest = Rectangle.Intersect(this.SourceRectangle, source.Bounds()); + + using IMemoryOwner histogramBuffer = memoryAllocator.Allocate(this.LuminanceLevels, AllocationOptions.Clean); + + // Build the histogram of the grayscale levels. + var grayscaleOperation = new GrayscaleLevelsRowOperation(interest, histogramBuffer, source.PixelBuffer, this.LuminanceLevels); + ParallelRowIterator.IterateRows( + this.Configuration, + interest, + in grayscaleOperation); + + Span histogram = histogramBuffer.GetSpan(); + if (this.ClipHistogramEnabled) + { + this.ClipHistogram(histogram, this.ClipLimit); + } + + using IMemoryOwner cdfBuffer = memoryAllocator.Allocate(this.LuminanceLevels, AllocationOptions.Clean); + + // Calculate the cumulative distribution function, which will map each input pixel to a new value. + int cdfMin = CalculateCdf( + ref MemoryMarshal.GetReference(cdfBuffer.GetSpan()), + ref MemoryMarshal.GetReference(histogram), + histogram.Length - 1); + + float numberOfPixelsMinusCdfMin = numberOfPixels - cdfMin; + + if (this.SyncChannels) + { + var cdfOperation = new SynchronizedChannelsRowOperation(interest, cdfBuffer, source.PixelBuffer, this.LuminanceLevels, numberOfPixelsMinusCdfMin); + ParallelRowIterator.IterateRows( + this.Configuration, + interest, + in cdfOperation); + } + else + { + var cdfOperation = new SeperateChannelsRowOperation(interest, cdfBuffer, source.PixelBuffer, this.LuminanceLevels, numberOfPixelsMinusCdfMin); + ParallelRowIterator.IterateRows( + this.Configuration, + interest, + in cdfOperation); + } + } + + /// + /// A implementing the cdf logic for synchronized color channels. + /// + private readonly struct SynchronizedChannelsRowOperation : IRowOperation + { + private readonly Rectangle bounds; + private readonly IMemoryOwner cdfBuffer; + private readonly Buffer2D source; + private readonly int luminanceLevels; + private readonly float numberOfPixelsMinusCdfMin; + + [MethodImpl(InliningOptions.ShortMethod)] + public SynchronizedChannelsRowOperation( + Rectangle bounds, + IMemoryOwner cdfBuffer, + Buffer2D source, + int luminanceLevels, + float numberOfPixelsMinusCdfMin) + { + this.bounds = bounds; + this.cdfBuffer = cdfBuffer; + this.source = source; + this.luminanceLevels = luminanceLevels; + this.numberOfPixelsMinusCdfMin = numberOfPixelsMinusCdfMin; + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public void Invoke(int y) + { + ref int cdfBase = ref MemoryMarshal.GetReference(this.cdfBuffer.GetSpan()); + var sourceAccess = new PixelAccessor(this.source); + Span pixelRow = sourceAccess.GetRowSpan(y); + int levels = this.luminanceLevels; + float noOfPixelsMinusCdfMin = this.numberOfPixelsMinusCdfMin; + + for (int x = 0; 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, levels); + float scaledLuminance = Unsafe.Add(ref cdfBase, luminance) / noOfPixelsMinusCdfMin; + float scalingFactor = scaledLuminance * levels / luminance; + Vector4 scaledVector = new Vector4(scalingFactor * vector.X, scalingFactor * vector.Y, scalingFactor * vector.Z, vector.W); + pixel.FromVector4(scaledVector); + } + } + } + + /// + /// A implementing the cdf logic for separate color channels. + /// + private readonly struct SeperateChannelsRowOperation : IRowOperation + { + private readonly Rectangle bounds; + private readonly IMemoryOwner cdfBuffer; + private readonly Buffer2D source; + private readonly int luminanceLevels; + private readonly float numberOfPixelsMinusCdfMin; + + [MethodImpl(InliningOptions.ShortMethod)] + public SeperateChannelsRowOperation( + Rectangle bounds, + IMemoryOwner cdfBuffer, + Buffer2D source, + int luminanceLevels, + float numberOfPixelsMinusCdfMin) + { + this.bounds = bounds; + this.cdfBuffer = cdfBuffer; + this.source = source; + this.luminanceLevels = luminanceLevels; + this.numberOfPixelsMinusCdfMin = numberOfPixelsMinusCdfMin; + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public void Invoke(int y) + { + ref int cdfBase = ref MemoryMarshal.GetReference(this.cdfBuffer.GetSpan()); + var sourceAccess = new PixelAccessor(this.source); + Span pixelRow = sourceAccess.GetRowSpan(y); + int levelsMinusOne = this.luminanceLevels - 1; + float noOfPixelsMinusCdfMin = this.numberOfPixelsMinusCdfMin; + + for (int x = 0; x < this.bounds.Width; x++) + { + // TODO: We should bulk convert here. + ref TPixel pixel = ref pixelRow[x]; + var vector = pixel.ToVector4() * levelsMinusOne; + + uint originalX = (uint)MathF.Round(vector.X); + float scaledX = Unsafe.Add(ref cdfBase, originalX) / noOfPixelsMinusCdfMin; + uint originalY = (uint)MathF.Round(vector.Y); + float scaledY = Unsafe.Add(ref cdfBase, originalY) / noOfPixelsMinusCdfMin; + uint originalZ = (uint)MathF.Round(vector.Z); + float scaledZ = Unsafe.Add(ref cdfBase, originalZ) / noOfPixelsMinusCdfMin; + pixel.FromVector4(new Vector4(scaledX, scaledY, scaledZ, vector.W)); + } + } + } +} diff --git a/src/ImageSharp/Processing/Processors/Normalization/GlobalHistogramEqualizationProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Normalization/GlobalHistogramEqualizationProcessor{TPixel}.cs index 59c37373ea..d506777be0 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/GlobalHistogramEqualizationProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/GlobalHistogramEqualizationProcessor{TPixel}.cs @@ -51,7 +51,7 @@ protected override void OnFrameApply(ImageFrame source) using IMemoryOwner histogramBuffer = memoryAllocator.Allocate(this.LuminanceLevels, AllocationOptions.Clean); // Build the histogram of the grayscale levels. - var grayscaleOperation = new GrayscaleLevelsRowOperation(interest, histogramBuffer, source.PixelBuffer, this.LuminanceLevels); + var grayscaleOperation = new GrayscaleLevelsRowOperation(interest, histogramBuffer, source.PixelBuffer, this.LuminanceLevels); ParallelRowIterator.IterateRows( this.Configuration, interest, @@ -81,47 +81,6 @@ ref MemoryMarshal.GetReference(histogram), in cdfOperation); } - /// - /// A implementing the grayscale levels logic for . - /// - private readonly struct GrayscaleLevelsRowOperation : IRowOperation - { - private readonly Rectangle bounds; - private readonly IMemoryOwner histogramBuffer; - private readonly Buffer2D source; - private readonly int luminanceLevels; - - [MethodImpl(InliningOptions.ShortMethod)] - public GrayscaleLevelsRowOperation( - Rectangle bounds, - IMemoryOwner histogramBuffer, - Buffer2D source, - int luminanceLevels) - { - this.bounds = bounds; - this.histogramBuffer = histogramBuffer; - this.source = source; - this.luminanceLevels = luminanceLevels; - } - - /// - [MethodImpl(InliningOptions.ShortMethod)] - public void Invoke(int y) - { - ref int histogramBase = ref MemoryMarshal.GetReference(this.histogramBuffer.GetSpan()); - Span pixelRow = this.source.DangerousGetRowSpan(y); - int levels = this.luminanceLevels; - - for (int x = 0; x < this.bounds.Width; x++) - { - // TODO: We should bulk convert here. - var vector = pixelRow[x].ToVector4(); - int luminance = ColorNumerics.GetBT709Luminance(ref vector, levels); - Interlocked.Increment(ref Unsafe.Add(ref histogramBase, luminance)); - } - } - } - /// /// A implementing the cdf application levels logic for . /// diff --git a/src/ImageSharp/Processing/Processors/Normalization/GrayscaleLevelsRowOperation{TPixel}.cs b/src/ImageSharp/Processing/Processors/Normalization/GrayscaleLevelsRowOperation{TPixel}.cs new file mode 100644 index 0000000000..f4fcd15782 --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Normalization/GrayscaleLevelsRowOperation{TPixel}.cs @@ -0,0 +1,53 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Normalization; + +/// +/// A implementing the grayscale levels logic as . +/// +internal readonly struct GrayscaleLevelsRowOperation : IRowOperation + where TPixel : unmanaged, IPixel +{ + private readonly Rectangle bounds; + private readonly IMemoryOwner histogramBuffer; + private readonly Buffer2D source; + private readonly int luminanceLevels; + + [MethodImpl(InliningOptions.ShortMethod)] + public GrayscaleLevelsRowOperation( + Rectangle bounds, + IMemoryOwner histogramBuffer, + Buffer2D source, + int luminanceLevels) + { + this.bounds = bounds; + this.histogramBuffer = histogramBuffer; + this.source = source; + this.luminanceLevels = luminanceLevels; + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public void Invoke(int y) + { + ref int histogramBase = ref MemoryMarshal.GetReference(this.histogramBuffer.GetSpan()); + Span pixelRow = this.source.DangerousGetRowSpan(y); + int levels = this.luminanceLevels; + + for (int x = 0; x < this.bounds.Width; x++) + { + // TODO: We should bulk convert here. + var vector = pixelRow[x].ToVector4(); + int luminance = ColorNumerics.GetBT709Luminance(ref vector, levels); + Interlocked.Increment(ref Unsafe.Add(ref histogramBase, luminance)); + } + } +} diff --git a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationMethod.cs b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationMethod.cs index c8fb361398..e104734820 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationMethod.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationMethod.cs @@ -22,4 +22,10 @@ public enum HistogramEqualizationMethod : int /// Adaptive histogram equalization using sliding window. Slower then the tile interpolation mode, but can yield to better results. /// AdaptiveSlidingWindow, + + /// + /// Adjusts the brightness levels of a particular image by scaling the + /// minimum and maximum values to the full brightness range. + /// + AutoLevel } diff --git a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs index 6343788425..1736a067ae 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationOptions.cs @@ -42,4 +42,11 @@ public class HistogramEqualizationOptions /// Defaults to 8. /// public int NumberOfTiles { get; set; } = 8; + + /// + /// Gets or sets a value indicating whether to synchronize the scaling factor over all color channels. + /// This parameter is only applicable to AutoLevel and is ignored for all others. + /// Defaults to true. + /// + public bool SyncChannels { get; set; } = true; } diff --git a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationProcessor.cs b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationProcessor.cs index f90a810790..8a9056b1f3 100644 --- a/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationProcessor.cs +++ b/src/ImageSharp/Processing/Processors/Normalization/HistogramEqualizationProcessor.cs @@ -60,6 +60,9 @@ public abstract IImageProcessor CreatePixelSpecificProcessor(Con HistogramEqualizationMethod.AdaptiveSlidingWindow => new AdaptiveHistogramEqualizationSlidingWindowProcessor(options.LuminanceLevels, options.ClipHistogram, options.ClipLimit, options.NumberOfTiles), + HistogramEqualizationMethod.AutoLevel + => new AutoLevelProcessor(options.LuminanceLevels, options.ClipHistogram, options.ClipLimit, options.SyncChannels), + _ => new GlobalHistogramEqualizationProcessor(options.LuminanceLevels, options.ClipHistogram, options.ClipLimit), }; } diff --git a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs index 09ba486a6f..60e33835af 100644 --- a/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs +++ b/tests/ImageSharp.Tests/Processing/Normalization/HistogramEqualizationTests.cs @@ -134,6 +134,44 @@ public void Adaptive_TileInterpolation_10Tiles_WithClipping(TestImagePro } } + [Theory] + [WithFile(TestImages.Jpeg.Baseline.ForestBridgeDifferentComponentsQuality, PixelTypes.Rgba32)] + public void AutoLevel_SeparateChannels_CompareToReferenceOutput(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = provider.GetImage()) + { + var options = new HistogramEqualizationOptions + { + Method = HistogramEqualizationMethod.AutoLevel, + LuminanceLevels = 256, + SyncChannels = false + }; + image.Mutate(x => x.HistogramEqualization(options)); + image.DebugSave(provider); + image.CompareToReferenceOutput(ValidatorComparer, provider, extension: "png"); + } + } + + [Theory] + [WithFile(TestImages.Jpeg.Baseline.ForestBridgeDifferentComponentsQuality, PixelTypes.Rgba32)] + public void AutoLevel_SynchronizedChannels_CompareToReferenceOutput(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using (Image image = provider.GetImage()) + { + var options = new HistogramEqualizationOptions + { + Method = HistogramEqualizationMethod.AutoLevel, + LuminanceLevels = 256, + SyncChannels = true + }; + image.Mutate(x => x.HistogramEqualization(options)); + image.DebugSave(provider); + image.CompareToReferenceOutput(ValidatorComparer, provider, extension: "png"); + } + } + /// /// This is regression test for a bug with the calculation of the y-start positions, /// where it could happen that one too much start position was calculated in some cases. diff --git a/tests/ImageSharp.Tests/Processing/Normalization/MagickCompareTests.cs b/tests/ImageSharp.Tests/Processing/Normalization/MagickCompareTests.cs new file mode 100644 index 0000000000..5fb0a4e934 --- /dev/null +++ b/tests/ImageSharp.Tests/Processing/Normalization/MagickCompareTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Normalization; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; + +using ImageMagick; + +namespace SixLabors.ImageSharp.Tests.Processing.Normalization; + +// ReSharper disable InconsistentNaming +[Trait("Category", "Processors")] +public class MagickCompareTests +{ + [Theory] + [WithFile(TestImages.Jpeg.Baseline.ForestBridgeDifferentComponentsQuality, PixelTypes.Rgba32)] + public void AutoLevel_CompareToMagick(TestImageProvider provider) + where TPixel : unmanaged, ImageSharp.PixelFormats.IPixel + { + Image imageFromMagick; + using (Stream stream = LoadAsStream(provider)) + { + var magickImage = new MagickImage(stream); + + // Apply Auto Level using the Grey (BT.709) channel. + magickImage.AutoLevel(Channels.Gray); + imageFromMagick = ConvertImageFromMagick(magickImage); + } + + using (Image image = provider.GetImage()) + { + var options = new HistogramEqualizationOptions + { + Method = HistogramEqualizationMethod.AutoLevel, + LuminanceLevels = 256, + SyncChannels = true + }; + image.Mutate(x => x.HistogramEqualization(options)); + image.DebugSave(provider); + ExactImageComparer.Instance.CompareImages(imageFromMagick, image); + } + } + + private Stream LoadAsStream(TestImageProvider provider) + where TPixel : unmanaged, ImageSharp.PixelFormats.IPixel + { + string path = TestImageProvider.GetFilePathOrNull(provider); + if (path == null) + { + throw new InvalidOperationException("CompareToMagick() works only with file providers!"); + } + + var testFile = TestFile.Create(path); + return new FileStream(testFile.FullPath, FileMode.Open); + } + + private Image ConvertImageFromMagick(MagickImage magickImage) + where TPixel : unmanaged, ImageSharp.PixelFormats.IPixel + { + Configuration configuration = Configuration.Default.Clone(); + configuration.PreferContiguousImageBuffers = true; + var result = new Image(configuration, magickImage.Width, magickImage.Height); + + Assert.True(result.DangerousTryGetSinglePixelMemory(out Memory resultPixels)); + + using (IUnsafePixelCollection pixels = magickImage.GetPixelsUnsafe()) + { + byte[] data = pixels.ToByteArray(PixelMapping.RGBA); + + PixelOperations.Instance.FromRgba32Bytes( + configuration, + data, + resultPixels.Span, + resultPixels.Length); + } + + return result; + } +} diff --git a/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SeparateChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png b/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SeparateChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png new file mode 100644 index 0000000000..de79ec729c --- /dev/null +++ b/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SeparateChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aada4a2ccf45de24f2a591a18d9bc0260ceb3829e104fee6982061013ed87282 +size 14107709 diff --git a/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SynchronizedChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png b/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SynchronizedChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png new file mode 100644 index 0000000000..ff5b35a5f7 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/HistogramEqualizationTests/AutoLevel_SynchronizedChannels_CompareToReferenceOutput_Rgba32_forest_bridge.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dca9b5b890d3a79b0002b7093d254d484ada4207e5010d1f0c6248d4dd6e22db +size 13909894