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