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