1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.graphics.cts.utils;
18 
19 /**
20  * Copied from frameworks/base/core to support SystemPalette CTS test
21  *
22  * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and
23  * coupled to a gamut mapping algorithm. Creates a color system, enables a digital design system.
24  */
25 public class Cam {
26     // The maximum difference between the requested L* and the L* returned.
27     private static final float DL_MAX = 0.2f;
28     // The maximum color distance, in CAM16-UCS, between a requested color and the color returned.
29     private static final float DE_MAX = 1.0f;
30     // When the delta between the floor & ceiling of a binary search for chroma is less than this,
31     // the binary search terminates.
32     private static final float CHROMA_SEARCH_ENDPOINT = 0.4f;
33     // When the delta between the floor & ceiling of a binary search for J, lightness in CAM16,
34     // is less than this, the binary search terminates.
35     private static final float LIGHTNESS_SEARCH_ENDPOINT = 0.01f;
36 
37     // CAM16 color dimensions, see getters for documentation.
38     private final float mHue;
39     private final float mChroma;
40     private final float mJ;
41     private final float mQ;
42     private final float mM;
43     private final float mS;
44 
45     // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*.
46     private final float mJstar;
47     private final float mAstar;
48     private final float mBstar;
49 
50     /** Hue in CAM16 */
getHue()51     public float getHue() {
52         return mHue;
53     }
54 
55     /** Chroma in CAM16 */
getChroma()56     public float getChroma() {
57         return mChroma;
58     }
59 
60     /** Lightness in CAM16 */
getJ()61     public float getJ() {
62         return mJ;
63     }
64 
65     /**
66      * Brightness in CAM16.
67      *
68      * <p>Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper
69      * is much brighter viewed in sunlight than in indoor light, but it is the lightest object under
70      * any lighting.
71      */
getQ()72     public float getQ() {
73         return mQ;
74     }
75 
76     /**
77      * Colorfulness in CAM16.
78      *
79      * <p>Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much
80      * more colorful outside than inside, but it has the same chroma in both environments.
81      */
getM()82     public float getM() {
83         return mM;
84     }
85 
86     /**
87      * Saturation in CAM16.
88      *
89      * <p>Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness
90      * relative to the color's own brightness, where chroma is colorfulness relative to white.
91      */
getS()92     public float getS() {
93         return mS;
94     }
95 
96     /** Lightness coordinate in CAM16-UCS */
getJstar()97     public float getJstar() {
98         return mJstar;
99     }
100 
101     /** a* coordinate in CAM16-UCS */
getAstar()102     public float getAstar() {
103         return mAstar;
104     }
105 
106     /** b* coordinate in CAM16-UCS */
getBstar()107     public float getBstar() {
108         return mBstar;
109     }
110 
111     /** Construct a CAM16 color */
Cam(float hue, float chroma, float j, float q, float m, float s, float jstar, float astar, float bstar)112     Cam(float hue, float chroma, float j, float q, float m, float s, float jstar, float astar,
113             float bstar) {
114         mHue = hue;
115         mChroma = chroma;
116         mJ = j;
117         mQ = q;
118         mM = m;
119         mS = s;
120         mJstar = jstar;
121         mAstar = astar;
122         mBstar = bstar;
123     }
124 
125     /**
126      * Given a hue & chroma in CAM16, L* in L*a*b*, return an ARGB integer. The chroma of the color
127      * returned may, and frequently will, be lower than requested. Assumes the color is viewed in
128      * the
129      * frame defined by the sRGB standard.
130      */
getInt(float hue, float chroma, float lstar)131     public static int getInt(float hue, float chroma, float lstar) {
132         return getInt(hue, chroma, lstar, Frame.DEFAULT);
133     }
134 
135     /**
136      * Create a color appearance model from a ARGB integer representing a color. It is assumed the
137      * color was viewed in the frame defined in the sRGB standard.
138      */
fromInt(int argb)139     public static Cam fromInt(int argb) {
140         return fromIntInFrame(argb, Frame.DEFAULT);
141     }
142 
143     /**
144      * Create a color appearance model from a ARGB integer representing a color, specifying the
145      * frame in which the color was viewed. Prefer Cam.fromInt.
146      */
fromIntInFrame(int argb, Frame frame)147     public static Cam fromIntInFrame(int argb, Frame frame) {
148         // Transform ARGB int to XYZ
149         float[] xyz = CamUtils.xyzFromInt(argb);
150 
151         // Transform XYZ to 'cone'/'rgb' responses
152         float[][] matrix = CamUtils.XYZ_TO_CAM16RGB;
153         float rT = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]);
154         float gT = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]);
155         float bT = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]);
156 
157         // Discount illuminant
158         float rD = frame.getRgbD()[0] * rT;
159         float gD = frame.getRgbD()[1] * gT;
160         float bD = frame.getRgbD()[2] * bT;
161 
162         // Chromatic adaptation
163         float rAF = (float) Math.pow(frame.getFl() * Math.abs(rD) / 100.0, 0.42);
164         float gAF = (float) Math.pow(frame.getFl() * Math.abs(gD) / 100.0, 0.42);
165         float bAF = (float) Math.pow(frame.getFl() * Math.abs(bD) / 100.0, 0.42);
166         float rA = Math.signum(rD) * 400.0f * rAF / (rAF + 27.13f);
167         float gA = Math.signum(gD) * 400.0f * gAF / (gAF + 27.13f);
168         float bA = Math.signum(bD) * 400.0f * bAF / (bAF + 27.13f);
169 
170         // redness-greenness
171         float a = (float) (11.0 * rA + -12.0 * gA + bA) / 11.0f;
172         // yellowness-blueness
173         float b = (float) (rA + gA - 2.0 * bA) / 9.0f;
174 
175         // auxiliary components
176         float u = (20.0f * rA + 20.0f * gA + 21.0f * bA) / 20.0f;
177         float p2 = (40.0f * rA + 20.0f * gA + bA) / 20.0f;
178 
179         // hue
180         float atan2 = (float) Math.atan2(b, a);
181         float atanDegrees = atan2 * 180.0f / (float) Math.PI;
182         float hue =
183                 atanDegrees < 0
184                         ? atanDegrees + 360.0f
185                         : atanDegrees >= 360 ? atanDegrees - 360.0f : atanDegrees;
186         float hueRadians = hue * (float) Math.PI / 180.0f;
187 
188         // achromatic response to color
189         float ac = p2 * frame.getNbb();
190 
191         // CAM16 lightness and brightness
192         float j = 100.0f * (float) Math.pow(ac / frame.getAw(), frame.getC() * frame.getZ());
193         float q =
194                 4.0f
195                         / frame.getC()
196                         * (float) Math.sqrt(j / 100.0f)
197                         * (frame.getAw() + 4.0f)
198                         * frame.getFlRoot();
199 
200         // CAM16 chroma, colorfulness, and saturation.
201         float huePrime = (hue < 20.14) ? hue + 360 : hue;
202         float eHue = 0.25f * (float) (Math.cos(huePrime * Math.PI / 180.0 + 2.0) + 3.8);
203         float p1 = 50000.0f / 13.0f * eHue * frame.getNc() * frame.getNcb();
204         float t = p1 * (float) Math.sqrt(a * a + b * b) / (u + 0.305f);
205         float alpha =
206                 (float) Math.pow(t, 0.9) * (float) Math.pow(1.64 - Math.pow(0.29, frame.getN()),
207                         0.73);
208         // CAM16 chroma, colorfulness, saturation
209         float c = alpha * (float) Math.sqrt(j / 100.0);
210         float m = c * frame.getFlRoot();
211         float s = 50.0f * (float) Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4.0f));
212 
213         // CAM16-UCS components
214         float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j);
215         float mstar = 1.0f / 0.0228f * (float) Math.log(1.0f + 0.0228f * m);
216         float astar = mstar * (float) Math.cos(hueRadians);
217         float bstar = mstar * (float) Math.sin(hueRadians);
218 
219         return new Cam(hue, c, j, q, m, s, jstar, astar, bstar);
220     }
221 
222     /**
223      * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates
224      * were measured in the sRGB standard frame.
225      */
fromJch(float j, float c, float h)226     private static Cam fromJch(float j, float c, float h) {
227         return fromJchInFrame(j, c, h, Frame.DEFAULT);
228     }
229 
230     /**
231      * Create a CAM from lightness, chroma, and hue coordinates, and also specify the frame in which
232      * the color is being viewed.
233      */
fromJchInFrame(float j, float c, float h, Frame frame)234     private static Cam fromJchInFrame(float j, float c, float h, Frame frame) {
235         float q =
236                 4.0f
237                         / frame.getC()
238                         * (float) Math.sqrt(j / 100.0)
239                         * (frame.getAw() + 4.0f)
240                         * frame.getFlRoot();
241         float m = c * frame.getFlRoot();
242         float alpha = c / (float) Math.sqrt(j / 100.0);
243         float s = 50.0f * (float) Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4.0f));
244 
245         float hueRadians = h * (float) Math.PI / 180.0f;
246         float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j);
247         float mstar = 1.0f / 0.0228f * (float) Math.log(1.0 + 0.0228 * m);
248         float astar = mstar * (float) Math.cos(hueRadians);
249         float bstar = mstar * (float) Math.sin(hueRadians);
250         return new Cam(h, c, j, q, m, s, jstar, astar, bstar);
251     }
252 
253     /**
254      * Distance in CAM16-UCS space between two colors.
255      *
256      * <p>Much like L*a*b* was designed to measure distance between colors, the CAM16 standard
257      * defined a color space called CAM16-UCS to measure distance between CAM16 colors.
258      */
distance(Cam other)259     public float distance(Cam other) {
260         float dJ = getJstar() - other.getJstar();
261         float dA = getAstar() - other.getAstar();
262         float dB = getBstar() - other.getBstar();
263         double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB);
264         double dE = 1.41 * Math.pow(dEPrime, 0.63);
265         return (float) dE;
266     }
267 
268     /** Returns perceived color as an ARGB integer, as viewed in standard sRGB frame. */
viewedInSrgb()269     public int viewedInSrgb() {
270         return viewed(Frame.DEFAULT);
271     }
272 
273     /** Returns color perceived in a frame as an ARGB integer. */
viewed(Frame frame)274     public int viewed(Frame frame) {
275         float alpha =
276                 (getChroma() == 0.0 || getJ() == 0.0)
277                         ? 0.0f
278                         : getChroma() / (float) Math.sqrt(getJ() / 100.0);
279 
280         float t =
281                 (float) Math.pow(alpha / Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73),
282                         1.0 / 0.9);
283         float hRad = getHue() * (float) Math.PI / 180.0f;
284 
285         float eHue = 0.25f * (float) (Math.cos(hRad + 2.0) + 3.8);
286         float ac = frame.getAw() * (float) Math.pow(getJ() / 100.0,
287                 1.0 / frame.getC() / frame.getZ());
288         float p1 = eHue * (50000.0f / 13.0f) * frame.getNc() * frame.getNcb();
289         float p2 = (ac / frame.getNbb());
290 
291         float hSin = (float) Math.sin(hRad);
292         float hCos = (float) Math.cos(hRad);
293 
294         float gamma =
295                 23.0f * (p2 + 0.305f) * t / (23.0f * p1 + 11.0f * t * hCos + 108.0f * t * hSin);
296         float a = gamma * hCos;
297         float b = gamma * hSin;
298         float rA = (460.0f * p2 + 451.0f * a + 288.0f * b) / 1403.0f;
299         float gA = (460.0f * p2 - 891.0f * a - 261.0f * b) / 1403.0f;
300         float bA = (460.0f * p2 - 220.0f * a - 6300.0f * b) / 1403.0f;
301 
302         float rCBase = (float) Math.max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA)));
303         float rC = Math.signum(rA) * (100.0f / frame.getFl()) * (float) Math.pow(rCBase,
304                 1.0 / 0.42);
305         float gCBase = (float) Math.max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA)));
306         float gC = Math.signum(gA) * (100.0f / frame.getFl()) * (float) Math.pow(gCBase,
307                 1.0 / 0.42);
308         float bCBase = (float) Math.max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA)));
309         float bC = Math.signum(bA) * (100.0f / frame.getFl()) * (float) Math.pow(bCBase,
310                 1.0 / 0.42);
311         float rF = rC / frame.getRgbD()[0];
312         float gF = gC / frame.getRgbD()[1];
313         float bF = bC / frame.getRgbD()[2];
314 
315 
316         float[][] matrix = CamUtils.CAM16RGB_TO_XYZ;
317         float x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]);
318         float y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]);
319         float z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]);
320 
321         int argb = ColorUtils.xyzToColor(x, y, z);
322         return argb;
323     }
324 
325     /**
326      * Given a hue & chroma in CAM16, L* in L*a*b*, and the frame in which the color will be
327      * viewed,
328      * return an ARGB integer.
329      *
330      * <p>The chroma of the color returned may, and frequently will, be lower than requested. This
331      * is
332      * a fundamental property of color that cannot be worked around by engineering. For example, a
333      * red
334      * hue, with high chroma, and high L* does not exist: red hues have a maximum chroma below 10
335      * in
336      * light shades, creating pink.
337      */
getInt(float hue, float chroma, float lstar, Frame frame)338     public static int getInt(float hue, float chroma, float lstar, Frame frame) {
339         // This is a crucial routine for building a color system, CAM16 itself is not sufficient.
340         //
341         // * Why these dimensions?
342         // Hue and chroma from CAM16 are used because they're the most accurate measures of those
343         // quantities. L* from L*a*b* is used because it correlates with luminance, luminance is
344         // used to measure contrast for a11y purposes, thus providing a key constraint on what
345         // colors
346         // can be used.
347         //
348         // * Why is this routine required to build a color system?
349         // In all perceptually accurate color spaces (i.e. L*a*b* and later), `chroma` may be
350         // impossible for a given `hue` and `lstar`.
351         // For example, a high chroma light red does not exist - chroma is limited to below 10 at
352         // light red shades, we call that pink. High chroma light green does exist, but not dark
353         // Also, when converting from another color space to RGB, the color may not be able to be
354         // represented in RGB. In those cases, the conversion process ends with RGB values
355         // outside 0-255
356         // The vast majority of color libraries surveyed simply round to 0 to 255. That is not an
357         // option for this library, as it distorts the expected luminance, and thus the expected
358         // contrast needed for a11y
359         //
360         // * What does this routine do?
361         // Dealing with colors in one color space not fitting inside RGB is, loosely referred to as
362         // gamut mapping or tone mapping. These algorithms are traditionally idiosyncratic, there is
363         // no universal answer. However, because the intent of this library is to build a system for
364         // digital design, and digital design uses luminance to measure contrast/a11y, we have one
365         // very important constraint that leads to an objective algorithm: the L* of the returned
366         // color _must_ match the requested L*.
367         //
368         // Intuitively, if the color must be distorted to fit into the RGB gamut, and the L*
369         // requested *must* be fulfilled, than the hue or chroma of the returned color will need
370         // to be different from the requested hue/chroma.
371         //
372         // After exploring both options, it was more intuitive that if the requested chroma could
373         // not be reached, it used the highest possible chroma. The alternative was finding the
374         // closest hue where the requested chroma could be reached, but that is not nearly as
375         // intuitive, as the requested hue is so fundamental to the color description.
376 
377         // If the color doesn't have meaningful chroma, return a gray with the requested Lstar.
378         //
379         // Yellows are very chromatic at L = 100, and blues are very chromatic at L = 0. All the
380         // other hues are white at L = 100, and black at L = 0. To preserve consistency for users of
381         // this system, it is better to simply return white at L* > 99, and black and L* < 0.
382         if (chroma < 1.0 || Math.round(lstar) <= 0.0 || Math.round(lstar) >= 100.0) {
383             return CamUtils.intFromLstar(lstar);
384         }
385 
386         hue = hue < 0 ? 0 : Math.min(360, hue);
387 
388         // The highest chroma possible. Updated as binary search proceeds.
389         float high = chroma;
390 
391         // The guess for the current binary search iteration. Starts off at the highest chroma,
392         // thus, if a color is possible at the requested chroma, the search can stop after one try.
393         float mid = chroma;
394         float low = 0.0f;
395         boolean isFirstLoop = true;
396 
397         Cam answer = null;
398 
399         while (Math.abs(low - high) >= CHROMA_SEARCH_ENDPOINT) {
400             // Given the current chroma guess, mid, and the desired hue, find J, lightness in
401             // CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* color space.
402             Cam possibleAnswer = findCamByJ(hue, mid, lstar);
403 
404             if (isFirstLoop) {
405                 if (possibleAnswer != null) {
406                     return possibleAnswer.viewed(frame);
407                 } else {
408                     // If this binary search iteration was the first iteration, and this point
409                     // has been reached, it means the requested chroma was not available at the
410                     // requested hue and L*.
411                     // Proceed to a traditional binary search that starts at the midpoint between
412                     // the requested chroma and 0.
413                     isFirstLoop = false;
414                     mid = low + (high - low) / 2.0f;
415                     continue;
416                 }
417             }
418 
419             if (possibleAnswer == null) {
420                 // There isn't a CAM16 J that creates a color with L* `lstar`. Try a lower chroma.
421                 high = mid;
422             } else {
423                 answer = possibleAnswer;
424                 // It is possible to create a color. Try higher chroma.
425                 low = mid;
426             }
427 
428             mid = low + (high - low) / 2.0f;
429         }
430 
431         // There was no answer: meaning, for the desired hue, there was no chroma low enough to
432         // generate a color with the desired L*.
433         // All values of L* are possible when there is 0 chroma. Return a color with 0 chroma, i.e.
434         // a shade of gray, with the desired L*.
435         if (answer == null) {
436             return CamUtils.intFromLstar(lstar);
437         }
438 
439         return answer.viewed(frame);
440     }
441 
442     // Find J, lightness in CAM16 color space, that creates a color with L* = `lstar` in the L*a*b*
443     // color space.
444     //
445     // Returns null if no J could be found that generated a color with L* `lstar`.
findCamByJ(float hue, float chroma, float lstar)446     private static Cam findCamByJ(float hue, float chroma, float lstar) {
447         float low = 0.0f;
448         float high = 100.0f;
449         float mid = 0.0f;
450         float bestdL = 1000.0f;
451         float bestdE = 1000.0f;
452 
453         Cam bestCam = null;
454         while (Math.abs(low - high) > LIGHTNESS_SEARCH_ENDPOINT) {
455             mid = low + (high - low) / 2;
456             // Create the intended CAM color
457             Cam camBeforeClip = Cam.fromJch(mid, chroma, hue);
458             // Convert the CAM color to RGB. If the color didn't fit in RGB, during the conversion,
459             // the initial RGB values will be outside 0 to 255. The final RGB values are clipped to
460             // 0 to 255, distorting the intended color.
461             int clipped = camBeforeClip.viewedInSrgb();
462             float clippedLstar = CamUtils.lstarFromInt(clipped);
463             float dL = Math.abs(lstar - clippedLstar);
464 
465             // If the clipped color's L* is within error margin...
466             if (dL < DL_MAX) {
467                 // ...check if the CAM equivalent of the clipped color is far away from intended CAM
468                 // color. For the intended color, use lightness and chroma from the clipped color,
469                 // and the intended hue. Callers are wondering what the lightness is, they know
470                 // chroma may be distorted, so the only concern here is if the hue slipped too far.
471                 Cam camClipped = Cam.fromInt(clipped);
472                 float dE = camClipped.distance(
473                         Cam.fromJch(camClipped.getJ(), camClipped.getChroma(), hue));
474                 if (dE <= DE_MAX) {
475                     bestdL = dL;
476                     bestdE = dE;
477                     bestCam = camClipped;
478                 }
479             }
480 
481             // If there's no error at all, there's no need to search more.
482             //
483             // Note: this happens much more frequently than expected, but this is a very delicate
484             // property which relies on extremely precise sRGB <=> XYZ calculations, as well as fine
485             // tuning of the constants that determine error margins and when the binary search can
486             // terminate.
487             if (bestdL == 0 && bestdE == 0) {
488                 break;
489             }
490 
491             if (clippedLstar < lstar) {
492                 low = mid;
493             } else {
494                 high = mid;
495             }
496         }
497 
498         return bestCam;
499     }
500 
501 }
502