From 66554cba676aa93edaec909f0d5cdd0e8537dcec Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 4 Jan 2023 23:53:02 +1000 Subject: [PATCH] Add ability to convert ICC profile on decode --- .../Implementation/Icc/IccProfileConverter.cs | 34 ++++++++-- .../Icc/SrgbV4Profile.Generated.cs | 45 +++++++++++++ .../Formats/ColorProfileHandling.cs | 4 +- src/ImageSharp/Formats/DecoderOptions.cs | 5 ++ src/ImageSharp/Formats/ImageDecoder.cs | 65 +++++++++++++++---- .../Formats/SpecializedImageDecoder{T}.cs | 42 ++++++++---- .../Icc/IccProfileConverterTests.cs | 16 +++-- .../TestUtilities/ImagingTestCaseUtility.cs | 8 +-- .../TestUtilities/TestImageExtensions.cs | 4 +- 9 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/SrgbV4Profile.Generated.cs diff --git a/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/IccProfileConverter.cs b/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/IccProfileConverter.cs index 6325c5ce1..8676f4afb 100644 --- a/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/IccProfileConverter.cs +++ b/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/IccProfileConverter.cs @@ -12,10 +12,19 @@ using SixLabors.ImageSharp.PixelFormats; namespace SixLabors.ImageSharp.ColorSpaces.Conversion.Implementation.Icc; /// -/// Allows the copnversion between ICC profiles. +/// Allows the conversion between ICC profiles. /// internal static class IccProfileConverter { + /// + /// Performs a conversion of the image pixels based on the input and output ICC profiles. + /// + /// The image to convert. + /// The input ICC profile. + /// The output ICC profile. + public static void Convert(Image image, IccProfile? inputIccProfile, IccProfile? outputIccProfile) + => image.AcceptVisitor(new IccProfileConverterVisitor(inputIccProfile, outputIccProfile)); + /// /// Performs a conversion of the image pixels based on the input and output ICC profiles. /// @@ -23,11 +32,10 @@ internal static class IccProfileConverter /// The image to convert. /// The input ICC profile. /// The output ICC profile. - public static void Convert(Image image, IccProfile inputIccProfile, IccProfile outputIccProfile) - where TPixel : unmanaged, IPixel + public static void Convert(Image image, IccProfile? inputIccProfile, IccProfile? outputIccProfile) + where TPixel : unmanaged, IPixel { - // TODO: Is this the correct property? - if (inputIccProfile.Header.Id.Equals(outputIccProfile.Header.Id)) + if (inputIccProfile is null || outputIccProfile is null) { return; } @@ -55,7 +63,21 @@ internal static class IccProfileConverter } }); - // TODO: Do not preserve the profile if we are converting to sRGB. image.Metadata.IccProfile = outputIccProfile; } + + private readonly struct IccProfileConverterVisitor : IImageVisitor + { + private readonly IccProfile? inputIccProfile; + private readonly IccProfile? outputIccProfile; + + public IccProfileConverterVisitor(IccProfile? inputIccProfile, IccProfile? outputIccProfile) + { + this.inputIccProfile = inputIccProfile; + this.outputIccProfile = outputIccProfile; + } + + public void Visit(Image image) + where TPixel : unmanaged, IPixel => Convert(image, this.inputIccProfile, this.outputIccProfile); + } } diff --git a/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/SrgbV4Profile.Generated.cs b/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/SrgbV4Profile.Generated.cs new file mode 100644 index 000000000..45c231aa6 --- /dev/null +++ b/src/ImageSharp/ColorSpaces/Conversion/Implementation/Icc/SrgbV4Profile.Generated.cs @@ -0,0 +1,45 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +// + +using SixLabors.ImageSharp.Metadata.Profiles.Icc; + +namespace SixLabors.ImageSharp.ColorSpaces.Conversion.Icc; + +internal static class SrgbV4Profile +{ + // Generated using the sRGB-v4.icc profile found at https://github.com/saucecontrol/Compact-ICC-Profiles + private static ReadOnlySpan Data => new byte[] + { + 0, 0, 1, 224, 108, 99, 109, 115, 4, 32, 0, 0, 109, 110, 116, 114, 82, 71, 66, 32, 88, 89, 90, 32, 7, 226, 0, 3, 0, + 20, 0, 9, 0, 14, 0, 29, 97, 99, 115, 112, 77, 83, 70, 84, 0, 0, 0, 0, 115, 97, 119, 115, 99, 116, 114, 108, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 214, 0, 1, 0, 0, 0, 0, 211, 45, 104, 97, 110, 100, 163, 178, 171, + 223, 92, 167, 3, 18, 168, 85, 164, 236, 53, 122, 209, 243, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 100, 101, 115, 99, 0, 0, 0, 252, 0, 0, 0, 36, 99, + 112, 114, 116, 0, 0, 1, 32, 0, 0, 0, 34, 119, 116, 112, 116, 0, 0, 1, 68, 0, 0, 0, 20, 99, 104, 97, 100, 0, 0, + 1, 88, 0, 0, 0, 44, 114, 88, 89, 90, 0, 0, 1, 132, 0, 0, 0, 20, 103, 88, 89, 90, 0, 0, 1, 152, 0, 0, 0, + 20, 98, 88, 89, 90, 0, 0, 1, 172, 0, 0, 0, 20, 114, 84, 82, 67, 0, 0, 1, 192, 0, 0, 0, 32, 103, 84, 82, 67, + 0, 0, 1, 192, 0, 0, 0, 32, 98, 84, 82, 67, 0, 0, 1, 192, 0, 0, 0, 32, 109, 108, 117, 99, 0, 0, 0, 0, 0, + 0, 0, 1, 0, 0, 0, 12, 101, 110, 85, 83, 0, 0, 0, 8, 0, 0, 0, 28, 0, 115, 0, 82, 0, 71, 0, 66, 109, 108, + 117, 99, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 12, 101, 110, 85, 83, 0, 0, 0, 6, 0, 0, 0, 28, 0, 67, 0, + 67, 0, 48, 0, 33, 88, 89, 90, 32, 0, 0, 0, 0, 0, 0, 246, 214, 0, 1, 0, 0, 0, 0, 211, 45, 115, 102, 51, 50, + 0, 0, 0, 0, 0, 1, 12, 63, 0, 0, 5, 221, 255, 255, 243, 38, 0, 0, 7, 144, 0, 0, 253, 146, 255, 255, 251, 161, 255, + 255, 253, 162, 0, 0, 3, 220, 0, 0, 192, 113, 88, 89, 90, 32, 0, 0, 0, 0, 0, 0, 111, 160, 0, 0, 56, 242, 0, 0, + 3, 143, 88, 89, 90, 32, 0, 0, 0, 0, 0, 0, 98, 150, 0, 0, 183, 137, 0, 0, 24, 218, 88, 89, 90, 32, 0, 0, 0, + 0, 0, 0, 36, 160, 0, 0, 15, 133, 0, 0, 182, 196, 112, 97, 114, 97, 0, 0, 0, 0, 0, 3, 0, 0, 0, 2, 102, 105, + 0, 0, 242, 167, 0, 0, 13, 89, 0, 0, 19, 208, 0, 0, 10, 91, + }; + + private static readonly Lazy LazyIccProfile = new(() => GetIccProfile()); + + public static IccProfile GetProfile() => LazyIccProfile.Value; + + private static IccProfile GetIccProfile() + { + byte[] buffer = new byte[Data.Length]; + Data.CopyTo(buffer); + return new IccProfile(buffer); + } +} + diff --git a/src/ImageSharp/Formats/ColorProfileHandling.cs b/src/ImageSharp/Formats/ColorProfileHandling.cs index de1a765a9..e6f4b0a6a 100644 --- a/src/ImageSharp/Formats/ColorProfileHandling.cs +++ b/src/ImageSharp/Formats/ColorProfileHandling.cs @@ -14,8 +14,8 @@ public enum ColorProfileHandling Preserve, /// - /// Transforms the pixels of the image based on the conversion of any embedded ICC color profiles to sRGB. - /// The original profile is then removed. + /// Transforms the pixels of the image based on the conversion of any embedded ICC color profiles to sRGB V4 profile. + /// The original profile is then replaced. /// Convert } diff --git a/src/ImageSharp/Formats/DecoderOptions.cs b/src/ImageSharp/Formats/DecoderOptions.cs index 989fc49fc..80c4f2933 100644 --- a/src/ImageSharp/Formats/DecoderOptions.cs +++ b/src/ImageSharp/Formats/DecoderOptions.cs @@ -44,4 +44,9 @@ public sealed class DecoderOptions /// Gets the maximum number of image frames to decode, inclusive. /// public uint MaxFrames { get => this.maxFrames; init => this.maxFrames = Math.Clamp(value, 1, int.MaxValue); } + + /// + /// Gets a value that controls how ICC profiles are handled during decode. + /// + public ColorProfileHandling ColorProfileHandling { get; init; } } diff --git a/src/ImageSharp/Formats/ImageDecoder.cs b/src/ImageSharp/Formats/ImageDecoder.cs index 591f85df2..30a87acd6 100644 --- a/src/ImageSharp/Formats/ImageDecoder.cs +++ b/src/ImageSharp/Formats/ImageDecoder.cs @@ -2,6 +2,8 @@ // Licensed under the Six Labors Split License. #nullable disable +using SixLabors.ImageSharp.ColorSpaces.Conversion.Icc; +using SixLabors.ImageSharp.ColorSpaces.Conversion.Implementation.Icc; using SixLabors.ImageSharp.IO; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; @@ -17,34 +19,54 @@ public abstract class ImageDecoder : IImageDecoder /// public Image Decode(DecoderOptions options, Stream stream) where TPixel : unmanaged, IPixel - => WithSeekableStream( - options, - stream, - s => this.Decode(options, s, default)); + { + Image image = WithSeekableStream( + options, + stream, + s => this.Decode(options, s, default)); + + TransformColorProfile(options, image); + return image; + } /// public Image Decode(DecoderOptions options, Stream stream) - => WithSeekableStream( - options, - stream, - s => this.Decode(options, s, default)); + { + Image image = WithSeekableStream( + options, + stream, + s => this.Decode(options, s, default)); + + TransformColorProfile(options, image); + return image; + } /// - public Task> DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) + public async Task> DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) where TPixel : unmanaged, IPixel - => WithSeekableMemoryStreamAsync( + { + Image image = await WithSeekableMemoryStreamAsync( options, stream, (s, ct) => this.Decode(options, s, ct), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + TransformColorProfile(options, image); + return image; + } /// - public Task DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) - => WithSeekableMemoryStreamAsync( + public async Task DecodeAsync(DecoderOptions options, Stream stream, CancellationToken cancellationToken = default) + { + Image image = await WithSeekableMemoryStreamAsync( options, stream, (s, ct) => this.Decode(options, s, ct), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + TransformColorProfile(options, image); + return image; + } /// public IImageInfo Identify(DecoderOptions options, Stream stream) @@ -123,6 +145,21 @@ public abstract class ImageDecoder : IImageDecoder } } + /// + /// Converts the decoded image color profile if present to a V4 sRGB profile. + /// + /// The decoder options. + /// The image. + protected static void TransformColorProfile(DecoderOptions options, Image image) + { + if (options.ColorProfileHandling == ColorProfileHandling.Preserve) + { + return; + } + + IccProfileConverter.Convert(image, image.Metadata?.IccProfile, SrgbV4Profile.GetProfile()); + } + /// /// Determines whether the decoded image should be resized. /// diff --git a/src/ImageSharp/Formats/SpecializedImageDecoder{T}.cs b/src/ImageSharp/Formats/SpecializedImageDecoder{T}.cs index fa6461464..ee602e7e2 100644 --- a/src/ImageSharp/Formats/SpecializedImageDecoder{T}.cs +++ b/src/ImageSharp/Formats/SpecializedImageDecoder{T}.cs @@ -17,34 +17,54 @@ public abstract class SpecializedImageDecoder : ImageDecoder, ISpecializedIma /// public Image Decode(T options, Stream stream) where TPixel : unmanaged, IPixel - => WithSeekableStream( - options.GeneralOptions, - stream, - s => this.Decode(options, s, default)); + { + Image image = WithSeekableStream( + options.GeneralOptions, + stream, + s => this.Decode(options, s, default)); + + TransformColorProfile(options.GeneralOptions, image); + return image; + } /// public Image Decode(T options, Stream stream) - => WithSeekableStream( + { + Image image = WithSeekableStream( options.GeneralOptions, stream, s => this.Decode(options, s, default)); + TransformColorProfile(options.GeneralOptions, image); + return image; + } + /// - public Task> DecodeAsync(T options, Stream stream, CancellationToken cancellationToken = default) + public async Task> DecodeAsync(T options, Stream stream, CancellationToken cancellationToken = default) where TPixel : unmanaged, IPixel - => WithSeekableMemoryStreamAsync( + { + Image image = await WithSeekableMemoryStreamAsync( options.GeneralOptions, stream, (s, ct) => this.Decode(options, s, ct), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + TransformColorProfile(options.GeneralOptions, image); + return image; + } /// - public Task DecodeAsync(T options, Stream stream, CancellationToken cancellationToken = default) - => WithSeekableMemoryStreamAsync( + public async Task DecodeAsync(T options, Stream stream, CancellationToken cancellationToken = default) + { + Image image = await WithSeekableMemoryStreamAsync( options.GeneralOptions, stream, (s, ct) => this.Decode(options, s, ct), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + TransformColorProfile(options.GeneralOptions, image); + return image; + } /// /// Decodes the image from the specified stream to an of a specific pixel type. diff --git a/tests/ImageSharp.Tests/Colorspaces/Icc/IccProfileConverterTests.cs b/tests/ImageSharp.Tests/Colorspaces/Icc/IccProfileConverterTests.cs index 67aa347b2..964b2b41e 100644 --- a/tests/ImageSharp.Tests/Colorspaces/Icc/IccProfileConverterTests.cs +++ b/tests/ImageSharp.Tests/Colorspaces/Icc/IccProfileConverterTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.ImageSharp.ColorSpaces.Conversion.Icc; using SixLabors.ImageSharp.ColorSpaces.Conversion.Implementation.Icc; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Metadata.Profiles.Icc; @@ -29,7 +30,7 @@ public class IccProfileConverterTests IccProfileConverter.Convert(image, profile, profile); - image.DebugSave(provider, Encoder); + image.DebugSave(provider, extension: "png", appendPixelTypeToFileName: false, appendSourceFileOrDescription: true, encoder: Encoder); TPixel actual = image[0, 0]; @@ -38,19 +39,22 @@ public class IccProfileConverterTests [Theory] [WithFile(TestImages.Jpeg.ICC.AdobeRgb, PixelTypes.Rgb24)] - public void CanConvertToWide(TestImageProvider provider) + [WithFile(TestImages.Jpeg.ICC.AppleRGB, PixelTypes.Rgb24)] + [WithFile(TestImages.Jpeg.ICC.ColorMatch, PixelTypes.Rgb24)] + [WithFile(TestImages.Jpeg.ICC.WideRGB, PixelTypes.Rgb24)] + [WithFile(TestImages.Jpeg.ICC.SRgb, PixelTypes.Rgb24)] + [WithFile(TestImages.Jpeg.ICC.ProPhoto, PixelTypes.Rgb24)] + public void CanConvertToSRGB(TestImageProvider provider) where TPixel : unmanaged, IPixel { using Image image = provider.GetImage(); IccProfile profile = image.Metadata.IccProfile; - string file = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, TestImages.Jpeg.ICC.SRgb); - IImageInfo i = Image.Identify(file); - IccProfile sRGBProfile = i.Metadata.IccProfile; + IccProfile sRGBProfile = SrgbV4Profile.GetProfile(); IccProfileConverter.Convert(image, profile, sRGBProfile); // TODO: Compare. - image.DebugSave(provider, Encoder); + image.DebugSave(provider, extension: "png", appendPixelTypeToFileName: false, appendSourceFileOrDescription: true, encoder: Encoder); } } diff --git a/tests/ImageSharp.Tests/TestUtilities/ImagingTestCaseUtility.cs b/tests/ImageSharp.Tests/TestUtilities/ImagingTestCaseUtility.cs index 460ecac85..b3927de41 100644 --- a/tests/ImageSharp.Tests/TestUtilities/ImagingTestCaseUtility.cs +++ b/tests/ImageSharp.Tests/TestUtilities/ImagingTestCaseUtility.cs @@ -49,8 +49,8 @@ public class ImagingTestCaseUtility } string fn = appendSourceFileOrDescription - ? Path.GetFileNameWithoutExtension(this.SourceFileOrDescription) - : string.Empty; + ? Path.GetFileNameWithoutExtension(this.SourceFileOrDescription) + : string.Empty; if (string.IsNullOrWhiteSpace(extension)) { @@ -62,7 +62,7 @@ public class ImagingTestCaseUtility extension = ".bmp"; } - extension = extension.ToLower(); + extension = extension.ToLowerInvariant(); if (extension[0] != '.') { @@ -86,7 +86,7 @@ public class ImagingTestCaseUtility } } - details = details ?? string.Empty; + details ??= string.Empty; if (details != string.Empty) { details = '_' + details; diff --git a/tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs b/tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs index 31c9f541e..30e76831d 100644 --- a/tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs +++ b/tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs @@ -67,10 +67,10 @@ public static class TestImageExtensions provider.Utility.SaveTestOutputFile( image, extension, + encoder: encoder, testOutputDetails: testOutputDetails, appendPixelTypeToFileName: appendPixelTypeToFileName, - appendSourceFileOrDescription: appendSourceFileOrDescription, - encoder: encoder); + appendSourceFileOrDescription: appendSourceFileOrDescription); return image; }