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