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