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