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