Skip to content

Commit 2f9cd4f

Browse files
Fix detection of canonical sRGB profiles
1 parent f758fad commit 2f9cd4f

File tree

12 files changed

+373
-41
lines changed

12 files changed

+373
-41
lines changed

src/ImageSharp/Formats/DecoderOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ internal bool TryGetIccProfileForColorConversion(IccProfile? profile, [NotNullWh
7878
return false;
7979
}
8080

81-
if (IccProfileHeader.IsLikelySrgb(profile.Header))
81+
if (profile.IsCanonicalSrgbMatrixTrc())
8282
{
8383
return false;
8484
}
@@ -99,7 +99,7 @@ internal bool CanRemoveIccProfile(IccProfile? profile)
9999
return false;
100100
}
101101

102-
if (this.ColorProfileHandling == ColorProfileHandling.Compact && IccProfileHeader.IsLikelySrgb(profile.Header))
102+
if (this.ColorProfileHandling == ColorProfileHandling.Compact && profile.IsCanonicalSrgbMatrixTrc())
103103
{
104104
return true;
105105
}
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Numerics;
5+
using SixLabors.ImageSharp.ColorProfiles;
6+
7+
namespace SixLabors.ImageSharp.Metadata.Profiles.Icc;
8+
9+
/// <content>
10+
/// Provides logic for identifying canonical IEC 61966-2-1 (sRGB) matrix-TRC ICC profiles,
11+
/// distinguishing them from appearance or device-specific variants.
12+
/// </content>
13+
public sealed partial class IccProfile
14+
{
15+
// sRGB v2 Preference
16+
private static readonly IccProfileId StandardRgbV2 = new(0x3D0EB2DE, 0xAE9397BE, 0x9B6726CE, 0x8C0A43CE);
17+
18+
// sRGB v4 Preference
19+
private static readonly IccProfileId StandardRgbV4 = new(0x34562ABF, 0x994CCD06, 0x6D2C5721, 0xD0D68C5D);
20+
21+
/// <summary>
22+
/// Detects canonical sRGB matrix+TRC profiles quickly and safely.
23+
/// Rules:
24+
/// 1) Accept known IEC sRGB v2 and v4 by profile ID.
25+
/// 2) Require RGB, PCS=XYZ, ICC v2 or v4, and no A2B*/B2A* LUTs.
26+
/// 3) Require rTRC, gTRC, bTRC to exist and be identical by parameters or sampled shape.
27+
/// 4) Accept if rXYZ/gXYZ/bXYZ already match the D50-adapted sRGB colorants within tolerance.
28+
/// 5) If white point ≈ D65, adapt only the colorant columns to D50 using Bradford
29+
/// via <see cref="VonKriesChromaticAdaptation.Transform(in CieXyz, ValueTuple{CieXyz, CieXyz}, Matrix4x4)"/> and then compare.
30+
/// This rejects channel-swapped and appearance profiles while allowing real sRGB.
31+
/// </summary>
32+
/// <remarks>
33+
/// Reference D50-adapted sRGB colorants from Bruce Lindbloom:
34+
/// <see href="http://brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html"/>
35+
/// R=(0.4360747, 0.2225045, 0.0139322)
36+
/// G=(0.3850649, 0.7168786, 0.0971045)
37+
/// B=(0.1430804, 0.0606169, 0.7141733)
38+
/// </remarks>
39+
internal bool IsCanonicalSrgbMatrixTrc()
40+
{
41+
IccProfileHeader h = this.Header;
42+
43+
// Fast path for known IEC sRGB profile IDs
44+
if (h.Id == StandardRgbV2 || h.Id == StandardRgbV4)
45+
{
46+
return true;
47+
}
48+
49+
// Header gating to avoid parsing work for obvious non-matches
50+
if (h.FileSignature != "acsp")
51+
{
52+
return false;
53+
}
54+
55+
if (h.DataColorSpace != IccColorSpaceType.Rgb)
56+
{
57+
return false;
58+
}
59+
60+
if (h.ProfileConnectionSpace != IccColorSpaceType.CieXyz)
61+
{
62+
return false;
63+
}
64+
65+
if (h.Version.Major is not 2 and not 4)
66+
{
67+
return false;
68+
}
69+
70+
this.InitializeEntries();
71+
IccTagDataEntry[] entries = this.entries;
72+
73+
// Reject device/display LUT profiles. We only accept matrix+TRC encodings.
74+
if (Has(entries, IccProfileTag.AToB0) || Has(entries, IccProfileTag.AToB1) || Has(entries, IccProfileTag.AToB2) ||
75+
Has(entries, IccProfileTag.BToA0) || Has(entries, IccProfileTag.BToA1) || Has(entries, IccProfileTag.BToA2))
76+
{
77+
return false;
78+
}
79+
80+
// Required matrix+TRC tags
81+
if (!TryGetXyz(entries, IccProfileTag.MediaWhitePoint, out Vector3 wtpt))
82+
{
83+
return false;
84+
}
85+
86+
if (!TryGetXyz(entries, IccProfileTag.RedMatrixColumn, out Vector3 rXYZ))
87+
{
88+
return false;
89+
}
90+
91+
if (!TryGetXyz(entries, IccProfileTag.GreenMatrixColumn, out Vector3 gXYZ))
92+
{
93+
return false;
94+
}
95+
96+
if (!TryGetXyz(entries, IccProfileTag.BlueMatrixColumn, out Vector3 bXYZ))
97+
{
98+
return false;
99+
}
100+
101+
// TRCs must exist and be identical across channels. This filters many trick profiles.
102+
if (!TryGetTrc(entries, IccProfileTag.RedTrc, out Trc tR))
103+
{
104+
return false;
105+
}
106+
107+
if (!TryGetTrc(entries, IccProfileTag.GreenTrc, out Trc tG))
108+
{
109+
return false;
110+
}
111+
112+
if (!TryGetTrc(entries, IccProfileTag.BlueTrc, out Trc tB))
113+
{
114+
return false;
115+
}
116+
117+
if (!tR.Equals(tG) || !tR.Equals(tB))
118+
{
119+
return false;
120+
}
121+
122+
// D50-adapted sRGB colorants (compare as columns: r,g,b), tight epsilon
123+
const float eps = 2e-3F;
124+
Vector3 rRef = new(0.4360747F, 0.2225045F, 0.0139322F);
125+
Vector3 gRef = new(0.3850649F, 0.7168786F, 0.0971045F);
126+
Vector3 bRef = new(0.1430804F, 0.0606169F, 0.7141733F);
127+
128+
// First, accept if the stored colorants are already the D50 sRGB primaries.
129+
// Many v2 sRGB profiles store D50-adapted colorants while declaring wtpt≈D65.
130+
if (Near(rXYZ, rRef, eps) && Near(gXYZ, gRef, eps) && Near(bXYZ, bRef, eps))
131+
{
132+
return true;
133+
}
134+
135+
// If the profile declares a D65 white, adapt the colorant columns to D50 and compare again.
136+
// We never adapt when they already match, to avoid compounding rounding.
137+
if (Near(wtpt, KnownIlluminants.D65.AsVector3Unsafe(), 2e-3F))
138+
{
139+
CieXyz fromWp = new(wtpt); // Declared white
140+
CieXyz toWp = KnownIlluminants.D50; // PCS white
141+
Matrix4x4 matrix = KnownChromaticAdaptationMatrices.Bradford;
142+
143+
rXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(rXYZ), (fromWp, toWp), matrix).AsVector3Unsafe();
144+
gXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(gXYZ), (fromWp, toWp), matrix).AsVector3Unsafe();
145+
bXYZ = VonKriesChromaticAdaptation.Transform(new CieXyz(bXYZ), (fromWp, toWp), matrix).AsVector3Unsafe();
146+
}
147+
148+
// Require identity mapping of primaries, no permutation
149+
if (!Near(rXYZ, rRef, eps) || !Near(gXYZ, gRef, eps) || !Near(bXYZ, bRef, eps))
150+
{
151+
return false;
152+
}
153+
154+
return true;
155+
156+
static bool Has(ReadOnlySpan<IccTagDataEntry> span, IccProfileTag tag)
157+
{
158+
for (int i = 0; i < span.Length; i++)
159+
{
160+
if (span[i].TagSignature == tag)
161+
{
162+
return true;
163+
}
164+
}
165+
166+
return false;
167+
}
168+
169+
static bool TryGetXyz(ReadOnlySpan<IccTagDataEntry> span, IccProfileTag tag, out Vector3 xyz)
170+
{
171+
for (int i = 0; i < span.Length; i++)
172+
{
173+
IccTagDataEntry e = span[i];
174+
if (e.TagSignature != tag)
175+
{
176+
continue;
177+
}
178+
179+
if (e is IccXyzTagDataEntry x && x.Data is { Length: >= 1 })
180+
{
181+
xyz = x.Data[0];
182+
return true;
183+
}
184+
185+
break;
186+
}
187+
188+
xyz = default;
189+
return false;
190+
}
191+
192+
static bool TryGetTrc(ReadOnlySpan<IccTagDataEntry> span, IccProfileTag tag, out Trc trc)
193+
{
194+
for (int i = 0; i < span.Length; i++)
195+
{
196+
IccTagDataEntry e = span[i];
197+
if (e.TagSignature != tag)
198+
{
199+
continue;
200+
}
201+
202+
if (e is IccParametricCurveTagDataEntry p)
203+
{
204+
trc = Trc.FromParametric(p.Curve);
205+
return true;
206+
}
207+
208+
if (e is IccCurveTagDataEntry c)
209+
{
210+
trc = Trc.FromCurveLut(c.CurveData);
211+
return true;
212+
}
213+
214+
break;
215+
}
216+
217+
trc = default;
218+
return false;
219+
}
220+
221+
static bool Near(in Vector3 a, in Vector3 b, float tol)
222+
=> MathF.Abs(a.X - b.X) <= tol &&
223+
MathF.Abs(a.Y - b.Y) <= tol &&
224+
MathF.Abs(a.Z - b.Z) <= tol;
225+
}
226+
227+
/// <summary>
228+
/// Compact, allocation-free descriptor of a TRC for equality and optional sRGB check.
229+
/// </summary>
230+
private readonly struct Trc : IEquatable<Trc>
231+
{
232+
private readonly byte kind; // 0 = none, 1 = parametric, 2 = sampled
233+
private readonly float g; // parametric payload or downsampled hash
234+
private readonly float a;
235+
private readonly float b;
236+
private readonly float c;
237+
private readonly float d;
238+
private readonly float e;
239+
private readonly float f;
240+
private readonly int n; // for sampled, length or a small signature
241+
242+
private Trc(byte kind, float g, float a, float b, float c, float d, float e, float f, int n)
243+
{
244+
this.kind = kind;
245+
this.g = g;
246+
this.a = a;
247+
this.b = b;
248+
this.c = c;
249+
this.d = d;
250+
this.e = e;
251+
this.f = f;
252+
this.n = n;
253+
}
254+
255+
public static Trc FromParametric(IccParametricCurve c)
256+
257+
// Normalize by curve type to a stable tuple
258+
// The types map to piecewise forms, but equality across channels is the key requirement here
259+
=> new(1, c.G, c.A, c.B, c.C, c.D, c.E, c.F, (int)c.Type);
260+
261+
public static Trc FromCurveLut(float[] data)
262+
{
263+
// Exact sequence equality is enforced by the calling code using the same Trc construction
264+
// Record a short signature to compare cheaply, avoid copying
265+
if (data == null)
266+
{
267+
return default;
268+
}
269+
270+
int n = data.Length;
271+
if (n == 0)
272+
{
273+
return default;
274+
}
275+
276+
// Downsample a few points to a robust fingerprint
277+
// Use fixed indices to avoid allocations
278+
float s0 = data[0];
279+
float s1 = data[n >> 2];
280+
float s2 = data[n >> 1];
281+
float s3 = data[(n * 3) >> 2];
282+
float s4 = data[n - 1];
283+
284+
return new Trc(
285+
2,
286+
s0,
287+
s1,
288+
s2,
289+
s3,
290+
s4,
291+
0F,
292+
0F,
293+
n);
294+
}
295+
296+
public override bool Equals(object? obj) => obj is Trc trc && this.Equals(trc);
297+
298+
public bool Equals(Trc other)
299+
{
300+
if (this.kind != other.kind)
301+
{
302+
return false;
303+
}
304+
305+
if (this.kind == 0)
306+
{
307+
return false;
308+
}
309+
310+
if (this.kind == 1)
311+
{
312+
// parametric: exact parameter match and type match
313+
return this.n == other.n &&
314+
this.g == other.g && this.a == other.a &&
315+
this.b == other.b && this.c == other.c &&
316+
this.d == other.d && this.e == other.e && this.f == other.f;
317+
}
318+
319+
// sampled: same length and same 5-point fingerprint
320+
return this.n == other.n &&
321+
this.g == other.g && this.a == other.a &&
322+
this.b == other.b && this.c == other.c && this.d == other.d;
323+
}
324+
325+
// Optional stricter sRGB check if you need it later
326+
public bool IsSrgbLike()
327+
{
328+
if (this.kind == 1)
329+
{
330+
// Accept common sRGB parametric encodings where type and parameters match
331+
// IEC 61966-2-1 maps to Type4 or Type5 forms in practice
332+
// Tighten only if you must exclude gamma~2.2 profiles that share primaries
333+
return true;
334+
}
335+
336+
return true;
337+
}
338+
339+
public override int GetHashCode()
340+
{
341+
int a = HashCode.Combine(this.kind, this.g, this.a, this.b, this.c, this.d, this.e);
342+
int b = HashCode.Combine(this.f, this.n);
343+
return HashCode.Combine(a, b);
344+
}
345+
}
346+
}

src/ImageSharp/Metadata/Profiles/ICC/IccProfile.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace SixLabors.ImageSharp.Metadata.Profiles.Icc;
99
/// <summary>
1010
/// Represents an ICC profile
1111
/// </summary>
12-
public sealed class IccProfile : IDeepCloneable<IccProfile>
12+
public sealed partial class IccProfile : IDeepCloneable<IccProfile>
1313
{
1414
/// <summary>
1515
/// The byte array to read the ICC profile from

0 commit comments

Comments
 (0)