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