diff --git a/src/ImageSharp/ColorProfiles/Icc/Calculators/GrayTrcCalculator.cs b/src/ImageSharp/ColorProfiles/Icc/Calculators/GrayTrcCalculator.cs index 8d823c1e95..46a7471dbd 100644 --- a/src/ImageSharp/ColorProfiles/Icc/Calculators/GrayTrcCalculator.cs +++ b/src/ImageSharp/ColorProfiles/Icc/Calculators/GrayTrcCalculator.cs @@ -3,17 +3,70 @@ using System.Numerics; using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.ColorProfiles.Conversion.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Icc; namespace SixLabors.ImageSharp.ColorProfiles.Icc.Calculators; +/// +/// Converts between a grayscale device channel and the achromatic axis of its ICC PCS. +/// internal class GrayTrcCalculator : IVector4Calculator { - private readonly TrcCalculator calculator; + // Encode the fixed D50 white point once so each XYZ sample needs only the gray multiplication. + private static readonly Vector3 ScaledD50 = KnownIlluminants.D50Icc.ToScaledVector4().AsVector3(); - public GrayTrcCalculator(IccTagDataEntry grayTrc, bool toPcs) - => this.calculator = new TrcCalculator(new IccTagDataEntry[] { grayTrc }, !toPcs); + private readonly ISingleCalculator calculator; + private readonly bool toPcs; + private readonly bool isLab; + /// + /// Initializes a new instance of the class. + /// + /// The grayscale tone response curve. + /// The profile's XYZ or Lab connection space. + /// Whether to convert device gray to the PCS instead of the reverse. + public GrayTrcCalculator(IccTagDataEntry grayTrc, IccColorSpaceType pcsType, bool toPcs) + { + // A grayscale profile has one curve, so use its scalar calculator without channel arrays or traversal. + this.calculator = grayTrc switch + { + IccCurveTagDataEntry curve => new CurveCalculator(curve, !toPcs), + IccParametricCurveTagDataEntry parametricCurve => new ParametricCurveCalculator(parametricCurve, !toPcs), + _ => throw new InvalidIccProfileException("Invalid Entry."), + }; + + this.toPcs = toPcs; + this.isLab = pcsType == IccColorSpaceType.CieLab; + } + + /// + /// Converts a normalized gray channel to encoded PCS values, or encoded PCS values to device gray. + /// + /// The device value in X, or the encoded PCS components. + /// The encoded PCS value, or the device gray value in X. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Vector4 Calculate(Vector4 value) => this.calculator.Calculate(value); + public Vector4 Calculate(Vector4 value) + { + if (this.toPcs) + { + float gray = this.calculator.Calculate(value.X); + + // ICC monochrome TRCs describe the achromatic PCS axis: relative Y for XYZ or L*/100 for Lab. + // Construct all three PCS components from that one result; the unused device lanes are not XYZ. + if (this.isLab) + { + // L*/100 is already normalized; neutral a* and b* encode as (0 + 128)/255. + return new Vector4(gray, 128F / 255F, 128F / 255F, 1F); + } + + return new Vector4(ScaledD50 * gray, 1F); + } + + // Encoded Lab already stores L*/100 in X. XYZ must be decoded before selecting its Y component; + // applying the inverse curve to X would instead interpret the tristimulus X value as luminance. + // The 65535/32768 factor reverses the ICC XYZ encoding; the unused X and Z components need no scaling. + float luminance = this.isLab ? value.X : value.Y * (65535F / 32768F); + return new Vector4(this.calculator.Calculate(luminance)); + } } diff --git a/src/ImageSharp/ColorProfiles/Icc/IccConverterbase.Conversions.cs b/src/ImageSharp/ColorProfiles/Icc/IccConverterbase.Conversions.cs index 5875b74f13..1aecea61db 100644 --- a/src/ImageSharp/ColorProfiles/Icc/IccConverterbase.Conversions.cs +++ b/src/ImageSharp/ColorProfiles/Icc/IccConverterbase.Conversions.cs @@ -104,6 +104,6 @@ private static ColorTrcCalculator InitColorTrc(IccProfile profile, bool toPcs) private static GrayTrcCalculator InitGrayTrc(IccProfile profile, bool toPcs) { IccTagDataEntry entry = GetTag(profile, IccProfileTag.GrayTrc); - return new GrayTrcCalculator(entry, toPcs); + return new GrayTrcCalculator(entry, profile.Header.ProfileConnectionSpace, toPcs); } } diff --git a/tests/ImageSharp.Tests/ColorProfiles/Icc/Calculators/GrayTrcCalculatorTests.cs b/tests/ImageSharp.Tests/ColorProfiles/Icc/Calculators/GrayTrcCalculatorTests.cs new file mode 100644 index 0000000000..6a8f009acc --- /dev/null +++ b/tests/ImageSharp.Tests/ColorProfiles/Icc/Calculators/GrayTrcCalculatorTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.ColorProfiles; +using SixLabors.ImageSharp.ColorProfiles.Icc.Calculators; +using SixLabors.ImageSharp.Metadata.Profiles.Icc; + +namespace SixLabors.ImageSharp.Tests.ColorProfiles.Icc.Calculators; + +/// +/// Tests grayscale ICC conversion against the XYZ and Lab achromatic axes. +/// +[Trait("Color", "Conversion")] +public class GrayTrcCalculatorTests +{ + /// + /// Verifies that a gamma curve produces neutral PCS black, midtones, and white. + /// + /// The profile connection space. + /// The device gray value. + /// The expected normalized achromatic PCS value. + [Theory] + [InlineData(IccColorSpaceType.CieXyz, 0F, 0F)] + [InlineData(IccColorSpaceType.CieXyz, 0.5F, 0.25F)] + [InlineData(IccColorSpaceType.CieXyz, 1F, 1F)] + [InlineData(IccColorSpaceType.CieLab, 0F, 0F)] + [InlineData(IccColorSpaceType.CieLab, 0.5F, 0.25F)] + [InlineData(IccColorSpaceType.CieLab, 1F, 1F)] + internal void ToPcs_ProducesNeutralColor(IccColorSpaceType pcsType, float gray, float connection) + { + GrayTrcCalculator calculator = new(new IccCurveTagDataEntry(2F), pcsType, toPcs: true); + + // Only the first device channel is meaningful. Distinct unused lanes detect accidental pass-through. + Vector4 actual = calculator.Calculate(new Vector4(gray, 0.3F, 0.7F, 1F)); + Vector4 expected = pcsType == IccColorSpaceType.CieLab + ? new CieLab(connection * 100F, 0, 0).ToScaledVector4() + : new CieXyz(KnownIlluminants.D50Icc.ToVector3() * connection).ToScaledVector4(); + + VectorAssert.Equal(expected, actual, 5); + } + + /// + /// Verifies that the inverse curve uses PCS luminance or lightness, independently of chromatic components. + /// + /// The profile connection space. + /// The normalized achromatic PCS value. + /// The expected device gray value. + [Theory] + [InlineData(IccColorSpaceType.CieXyz, 0F, 0F)] + [InlineData(IccColorSpaceType.CieXyz, 0.25F, 0.5F)] + [InlineData(IccColorSpaceType.CieXyz, 1F, 1F)] + [InlineData(IccColorSpaceType.CieLab, 0F, 0F)] + [InlineData(IccColorSpaceType.CieLab, 0.25F, 0.5F)] + [InlineData(IccColorSpaceType.CieLab, 1F, 1F)] + internal void FromPcs_UsesAchromaticComponent(IccColorSpaceType pcsType, float connection, float gray) + { + GrayTrcCalculator calculator = new(new IccCurveTagDataEntry(2F), pcsType, toPcs: false); + + // Use non-neutral inputs so selecting XYZ.X instead of XYZ.Y cannot pass as a round-trip would. + Vector4 input = pcsType == IccColorSpaceType.CieLab + ? new CieLab(connection * 100F, 10F, 20F).ToScaledVector4() + : new CieXyz(0.3F, connection, 0.7F).ToScaledVector4(); + + Assert.Equal(gray, calculator.Calculate(input).X, 5); + } +} diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index 2f84a1a7b7..e1d1fc82b2 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs @@ -433,6 +433,26 @@ public void Decode_RGB_ICC_Jpeg_Issue3064(TestImageProvider prov image.CompareToReferenceOutput(provider); } + /// + /// Verifies that converting the grayscale ICC profile preserves the sample's distinct neutral tones. + /// + /// The pixel type. + /// The image provider. + [Theory] + [WithFile(TestImages.Jpeg.ICC.Issue3197, PixelTypes.Rgba32)] + public void Decode_Grayscale_ICC_Jpeg_Issue3197(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + JpegDecoderOptions options = new() + { + GeneralOptions = new DecoderOptions { ColorProfileHandling = ColorProfileHandling.Convert } + }; + + using Image image = provider.GetImage(JpegDecoder.Instance, options); + image.DebugSave(provider); + image.CompareToReferenceOutput(provider); + } + // https://github.com/SixLabors/ImageSharp/issues/2948 [Theory] [WithFile(TestImages.Jpeg.Issues.Issue2948, PixelTypes.Rgb24)] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 03937310f5..ae36d245de 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -250,6 +250,7 @@ public static class ICC public const string Perceptual = "Jpg/icc-profiles/Perceptual.jpg"; public const string PerceptualcLUTOnly = "Jpg/icc-profiles/Perceptual-cLUT-only.jpg"; public const string Issue3064 = "Jpg/icc-profiles/issue-3064.jpg"; + public const string Issue3197 = "Jpg/icc-profiles/issue-3197.jpg"; } public static class Progressive diff --git a/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_Grayscale_ICC_Jpeg_Issue3197_Rgba32_issue-3197.png b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_Grayscale_ICC_Jpeg_Issue3197_Rgba32_issue-3197.png new file mode 100644 index 0000000000..c322425960 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_Grayscale_ICC_Jpeg_Issue3197_Rgba32_issue-3197.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1309450549761dc5735d8f7b1ee67fab525410bf842e6ac4188486a6fd740d82 +size 1007 diff --git a/tests/Images/Input/Jpg/icc-profiles/issue-3197.jpg b/tests/Images/Input/Jpg/icc-profiles/issue-3197.jpg new file mode 100644 index 0000000000..f436a6ab48 --- /dev/null +++ b/tests/Images/Input/Jpg/icc-profiles/issue-3197.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1815503277887be38b67c0db19dc1d7ccac4a177ebf6969bce2a7cb12e70d04f +size 9526