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