diff --git a/src/ImageSharp/Formats/DecoderOptions.cs b/src/ImageSharp/Formats/DecoderOptions.cs index 2511cffdb..bb6c2a282 100644 --- a/src/ImageSharp/Formats/DecoderOptions.cs +++ b/src/ImageSharp/Formats/DecoderOptions.cs @@ -78,7 +78,7 @@ public sealed class DecoderOptions return false; } - if (IccProfileHeader.IsLikelySrgb(profile.Header)) + if (profile.IsCanonicalSrgbMatrixTrc()) { return false; } @@ -99,7 +99,7 @@ public sealed class DecoderOptions return false; } - if (this.ColorProfileHandling == ColorProfileHandling.Compact && IccProfileHeader.IsLikelySrgb(profile.Header)) + if (this.ColorProfileHandling == ColorProfileHandling.Compact && profile.IsCanonicalSrgbMatrixTrc()) { return true; } diff --git a/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.SRGB.cs b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.SRGB.cs new file mode 100644 index 000000000..bfa4ab9bd --- /dev/null +++ b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.SRGB.cs @@ -0,0 +1,346 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.ColorProfiles; + +namespace SixLabors.ImageSharp.Metadata.Profiles.Icc; + +/// +/// Provides logic for identifying canonical IEC 61966-2-1 (sRGB) matrix-TRC ICC profiles, +/// distinguishing them from appearance or device-specific variants. +/// +public sealed partial class IccProfile +{ + // sRGB v2 Preference + private static readonly IccProfileId StandardRgbV2 = new(0x3D0EB2DE, 0xAE9397BE, 0x9B6726CE, 0x8C0A43CE); + + // sRGB v4 Preference + private static readonly IccProfileId StandardRgbV4 = new(0x34562ABF, 0x994CCD06, 0x6D2C5721, 0xD0D68C5D); + + /// + /// Detects canonical sRGB matrix+TRC profiles quickly and safely. + /// Rules: + /// 1) Accept known IEC sRGB v2 and v4 by profile ID. + /// 2) Require RGB, PCS=XYZ, ICC v2 or v4, and no A2B*/B2A* LUTs. + /// 3) Require rTRC, gTRC, bTRC to exist and be identical by parameters or sampled shape. + /// 4) Accept if rXYZ/gXYZ/bXYZ already match the D50-adapted sRGB colorants within tolerance. + /// 5) If white point ≈ D65, adapt only the colorant columns to D50 using Bradford + /// via and then compare. + /// This rejects channel-swapped and appearance profiles while allowing real sRGB. + /// + /// + /// Reference D50-adapted sRGB colorants from Bruce Lindbloom: + /// + /// R=(0.4360747, 0.2225045, 0.0139322) + /// G=(0.3850649, 0.7168786, 0.0971045) + /// B=(0.1430804, 0.0606169, 0.7141733) + /// + internal bool IsCanonicalSrgbMatrixTrc() + { + IccProfileHeader h = this.Header; + + // Fast path for known IEC sRGB profile IDs + if (h.Id == StandardRgbV2 || h.Id == StandardRgbV4) + { + return true; + } + + // Header gating to avoid parsing work for obvious non-matches + if (h.FileSignature != "acsp") + { + return false; + } + + if (h.DataColorSpace != IccColorSpaceType.Rgb) + { + return false; + } + + if (h.ProfileConnectionSpace != IccColorSpaceType.CieXyz) + { + return false; + } + + if (h.Version.Major is not 2 and not 4) + { + return false; + } + + this.InitializeEntries(); + IccTagDataEntry[] entries = this.entries; + + // Reject device/display LUT profiles. We only accept matrix+TRC encodings. + if (Has(entries, IccProfileTag.AToB0) || Has(entries, IccProfileTag.AToB1) || Has(entries, IccProfileTag.AToB2) || + Has(entries, IccProfileTag.BToA0) || Has(entries, IccProfileTag.BToA1) || Has(entries, IccProfileTag.BToA2)) + { + return false; + } + + // Required matrix+TRC tags + if (!TryGetXyz(entries, IccProfileTag.MediaWhitePoint, out Vector3 wtpt)) + { + return false; + } + + if (!TryGetXyz(entries, IccProfileTag.RedMatrixColumn, out Vector3 rXYZ)) + { + return false; + } + + if (!TryGetXyz(entries, IccProfileTag.GreenMatrixColumn, out Vector3 gXYZ)) + { + return false; + } + + if (!TryGetXyz(entries, IccProfileTag.BlueMatrixColumn, out Vector3 bXYZ)) + { + return false; + } + + // TRCs must exist and be identical across channels. This filters many trick profiles. + if (!TryGetTrc(entries, IccProfileTag.RedTrc, out Trc tR)) + { + return false; + } + + if (!TryGetTrc(entries, IccProfileTag.GreenTrc, out Trc tG)) + { + return false; + } + + if (!TryGetTrc(entries, IccProfileTag.BlueTrc, out Trc tB)) + { + return false; + } + + if (!tR.Equals(tG) || !tR.Equals(tB)) + { + return false; + } + + // D50-adapted sRGB colorants (compare as columns: r,g,b), tight epsilon + const float eps = 2e-3F; + Vector3 rRef = new(0.4360747F, 0.2225045F, 0.0139322F); + Vector3 gRef = new(0.3850649F, 0.7168786F, 0.0971045F); + Vector3 bRef = new(0.1430804F, 0.0606169F, 0.7141733F); + + // First, accept if the stored colorants are already the D50 sRGB primaries. + // Many v2 sRGB profiles store D50-adapted colorants while declaring wtpt≈D65. + if (Near(rXYZ, rRef, eps) && Near(gXYZ, gRef, eps) && Near(bXYZ, bRef, eps)) + { + return true; + } + + // If the profile declares a D65 white, adapt the colorant columns to D50 and compare again. + // We never adapt when they already match, to avoid compounding rounding. + if (Near(wtpt, KnownIlluminants.D65.AsVector3Unsafe(), 2e-3F)) + { + CieXyz fromWp = new(wtpt); // Declared white + CieXyz toWp = KnownIlluminants.D50; // PCS white + Matrix4x4 matrix = KnownChromaticAdaptationMatrices.Bradford; + + rXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(rXYZ), (fromWp, toWp), matrix).AsVector3Unsafe(); + gXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(gXYZ), (fromWp, toWp), matrix).AsVector3Unsafe(); + bXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(bXYZ), (fromWp, toWp), matrix).AsVector3Unsafe(); + } + + // Require identity mapping of primaries, no permutation + if (!Near(rXYZ, rRef, eps) || !Near(gXYZ, gRef, eps) || !Near(bXYZ, bRef, eps)) + { + return false; + } + + return true; + + static bool Has(ReadOnlySpan span, IccProfileTag tag) + { + for (int i = 0; i < span.Length; i++) + { + if (span[i].TagSignature == tag) + { + return true; + } + } + + return false; + } + + static bool TryGetXyz(ReadOnlySpan span, IccProfileTag tag, out Vector3 xyz) + { + for (int i = 0; i < span.Length; i++) + { + IccTagDataEntry e = span[i]; + if (e.TagSignature != tag) + { + continue; + } + + if (e is IccXyzTagDataEntry x && x.Data is { Length: >= 1 }) + { + xyz = x.Data[0]; + return true; + } + + break; + } + + xyz = default; + return false; + } + + static bool TryGetTrc(ReadOnlySpan span, IccProfileTag tag, out Trc trc) + { + for (int i = 0; i < span.Length; i++) + { + IccTagDataEntry e = span[i]; + if (e.TagSignature != tag) + { + continue; + } + + if (e is IccParametricCurveTagDataEntry p) + { + trc = Trc.FromParametric(p.Curve); + return true; + } + + if (e is IccCurveTagDataEntry c) + { + trc = Trc.FromCurveLut(c.CurveData); + return true; + } + + break; + } + + trc = default; + return false; + } + + static bool Near(in Vector3 a, in Vector3 b, float tol) + => MathF.Abs(a.X - b.X) <= tol && + MathF.Abs(a.Y - b.Y) <= tol && + MathF.Abs(a.Z - b.Z) <= tol; + } + + /// + /// Compact, allocation-free descriptor of a TRC for equality and optional sRGB check. + /// + private readonly struct Trc : IEquatable + { + private readonly byte kind; // 0 = none, 1 = parametric, 2 = sampled + private readonly float g; // parametric payload or downsampled hash + private readonly float a; + private readonly float b; + private readonly float c; + private readonly float d; + private readonly float e; + private readonly float f; + private readonly int n; // for sampled, length or a small signature + + private Trc(byte kind, float g, float a, float b, float c, float d, float e, float f, int n) + { + this.kind = kind; + this.g = g; + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = e; + this.f = f; + this.n = n; + } + + public static Trc FromParametric(IccParametricCurve c) + + // Normalize by curve type to a stable tuple + // The types map to piecewise forms, but equality across channels is the key requirement here + => new(1, c.G, c.A, c.B, c.C, c.D, c.E, c.F, (int)c.Type); + + public static Trc FromCurveLut(float[] data) + { + // Exact sequence equality is enforced by the calling code using the same Trc construction + // Record a short signature to compare cheaply, avoid copying + if (data == null) + { + return default; + } + + int n = data.Length; + if (n == 0) + { + return default; + } + + // Downsample a few points to a robust fingerprint + // Use fixed indices to avoid allocations + float s0 = data[0]; + float s1 = data[n >> 2]; + float s2 = data[n >> 1]; + float s3 = data[(n * 3) >> 2]; + float s4 = data[n - 1]; + + return new Trc( + 2, + s0, + s1, + s2, + s3, + s4, + 0F, + 0F, + n); + } + + public override bool Equals(object? obj) => obj is Trc trc && this.Equals(trc); + + public bool Equals(Trc other) + { + if (this.kind != other.kind) + { + return false; + } + + if (this.kind == 0) + { + return false; + } + + if (this.kind == 1) + { + // parametric: exact parameter match and type match + return this.n == other.n && + this.g == other.g && this.a == other.a && + this.b == other.b && this.c == other.c && + this.d == other.d && this.e == other.e && this.f == other.f; + } + + // sampled: same length and same 5-point fingerprint + return this.n == other.n && + this.g == other.g && this.a == other.a && + this.b == other.b && this.c == other.c && this.d == other.d; + } + + // Optional stricter sRGB check if you need it later + public bool IsSrgbLike() + { + if (this.kind == 1) + { + // Accept common sRGB parametric encodings where type and parameters match + // IEC 61966-2-1 maps to Type4 or Type5 forms in practice + // Tighten only if you must exclude gamma~2.2 profiles that share primaries + return true; + } + + return true; + } + + public override int GetHashCode() + { + int a = HashCode.Combine(this.kind, this.g, this.a, this.b, this.c, this.d, this.e); + int b = HashCode.Combine(this.f, this.n); + return HashCode.Combine(a, b); + } + } +} diff --git a/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs index 392ccb306..05be3eb5d 100644 --- a/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs +++ b/src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs @@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.Icc; /// /// Represents an ICC profile /// -public sealed class IccProfile : IDeepCloneable +public sealed partial class IccProfile : IDeepCloneable { /// /// The byte array to read the ICC profile from diff --git a/src/ImageSharp/Metadata/Profiles/ICC/IccProfileHeader.cs b/src/ImageSharp/Metadata/Profiles/ICC/IccProfileHeader.cs index b50885d02..959668aaf 100644 --- a/src/ImageSharp/Metadata/Profiles/ICC/IccProfileHeader.cs +++ b/src/ImageSharp/Metadata/Profiles/ICC/IccProfileHeader.cs @@ -11,17 +11,6 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.Icc; /// public sealed class IccProfileHeader { - private static readonly Vector3 TruncatedD50 = new(0.9642029F, 1F, 0.8249054F); - - // sRGB v2 Preference - private static readonly IccProfileId StandardRgbV2 = new(0x3D0EB2DE, 0xAE9397BE, 0x9B6726CE, 0x8C0A43CE); - - // sRGB v4 Preference - private static readonly IccProfileId StandardRgbV4 = new(0x34562ABF, 0x994CCD06, 0x6D2C5721, 0xD0D68C5D); - - // sRGB v4 Appearance - private static readonly IccProfileId StandardRgbV4A = new(0xDF1132A1, 0x746E97B0, 0xAD85719, 0xBE711E08); - /// /// Gets or sets the profile size in bytes (will be ignored when writing a profile). /// @@ -108,31 +97,4 @@ public sealed class IccProfileHeader /// Gets or sets the profile ID (hash). /// public IccProfileId Id { get; set; } - - internal static bool IsLikelySrgb(IccProfileHeader header) - { - // Reject known perceptual-appearance profile - // This profile employs perceptual rendering intents to maintain color appearance across different - // devices and media, which can lead to variations from standard sRGB representations. - if (header.Id == StandardRgbV4A) - { - return false; - } - - // Accept known sRGB profile IDs - if (header.Id == StandardRgbV2 || header.Id == StandardRgbV4) - { - return true; - } - - // Fallback: best-guess heuristic - return - header.FileSignature == "acsp" && - header.DataColorSpace == IccColorSpaceType.Rgb && - (header.ProfileConnectionSpace == IccColorSpaceType.CieXyz || header.ProfileConnectionSpace == IccColorSpaceType.CieLab) && - (header.Class == IccProfileClass.DisplayDevice || header.Class == IccProfileClass.ColorSpace) && - header.PcsIlluminant == TruncatedD50 && - (header.Version.Major == 2 || header.Version.Major == 4) && - !string.Equals(header.CmmType, "ADBE", StringComparison.Ordinal); - } } diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index 2856abe5c..71753bf9c 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs @@ -402,6 +402,9 @@ public partial class JpegDecoderTests [WithFile(TestImages.Jpeg.ICC.ProPhoto, PixelTypes.Rgba32)] [WithFile(TestImages.Jpeg.ICC.WideRGB, PixelTypes.Rgba32)] [WithFile(TestImages.Jpeg.ICC.AppleRGB, PixelTypes.Rgba32)] + [WithFile(TestImages.Jpeg.ICC.SRgbGray, PixelTypes.Rgba32)] + [WithFile(TestImages.Jpeg.ICC.Perceptual, PixelTypes.Rgba32)] + [WithFile(TestImages.Jpeg.ICC.PerceptualcLUTOnly, PixelTypes.Rgba32)] public void Decode_RGB_ICC_Jpeg(TestImageProvider provider) where TPixel : unmanaged, IPixel { diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 3e5b3b712..af6148c87 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -216,6 +216,9 @@ public static class TestImages public const string AppleRGB = "Jpg/icc-profiles/Momiji-AppleRGB-yes.jpg"; public const string CMYK = "Jpg/icc-profiles/issue-129.jpg"; public const string YCCK = "Jpg/icc-profiles/issue_2723.jpg"; + public const string SRgbGray = "Jpg/icc-profiles/sRGB_Gray.jpg"; + public const string Perceptual = "Jpg/icc-profiles/Perceptual.jpg"; + public const string PerceptualcLUTOnly = "Jpg/icc-profiles/Perceptual-cLUT-only.jpg"; } public static class Progressive diff --git a/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual-cLUT-only.png b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual-cLUT-only.png new file mode 100644 index 000000000..a0b73d299 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual-cLUT-only.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe06798b92c9b476c167407e752b4379d50f1b1ad6329eceb368c8c36097b401 +size 95103 diff --git a/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual.png b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual.png new file mode 100644 index 000000000..99ae53f93 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_Perceptual.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21f8d54d4b789b783f3020402d4c1b91bb541de6565e2960976b569f60694631 +size 99385 diff --git a/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_sRGB_Gray.png b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_sRGB_Gray.png new file mode 100644 index 000000000..759b26a60 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/JpegDecoderTests/Decode_RGB_ICC_Jpeg_Rgba32_sRGB_Gray.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ad361f79b4ab26d452d5cc7ada4c121dfbf45d20da7c23a58f71a9497d17a2 +size 5341 diff --git a/tests/Images/Input/Jpg/icc-profiles/Perceptual-cLUT-only.jpg b/tests/Images/Input/Jpg/icc-profiles/Perceptual-cLUT-only.jpg new file mode 100644 index 000000000..7b2e57f65 --- /dev/null +++ b/tests/Images/Input/Jpg/icc-profiles/Perceptual-cLUT-only.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04e552f0bd68bddb40f35c456034b1bf1e590f37e990a28b2fe2e94753bbe685 +size 276191 diff --git a/tests/Images/Input/Jpg/icc-profiles/Perceptual.jpg b/tests/Images/Input/Jpg/icc-profiles/Perceptual.jpg new file mode 100644 index 000000000..879fd05ad --- /dev/null +++ b/tests/Images/Input/Jpg/icc-profiles/Perceptual.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74a0931e320ca938d7dc94c4ab7b27a15880732fc139718629a7234f34bdafba +size 297456 diff --git a/tests/Images/Input/Jpg/icc-profiles/sRGB_Gray.jpg b/tests/Images/Input/Jpg/icc-profiles/sRGB_Gray.jpg new file mode 100644 index 000000000..2abd97686 --- /dev/null +++ b/tests/Images/Input/Jpg/icc-profiles/sRGB_Gray.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22892d1b7965d973c7d8925ad7d749988c6a36b333b264a55d389f1e4faa0245 +size 36854