diff options
| author | James O'Leary <jamesoleary@google.com> | 2021-04-29 11:34:05 -0400 |
|---|---|---|
| committer | James O'Leary <jamesoleary@google.com> | 2021-04-29 11:40:47 -0400 |
| commit | a23d0548385ffabadbc4eaa1cf774fd5dde769eb (patch) | |
| tree | 82dabe63ec4a04e95d14e41d84483b59f641b39b /core/java | |
| parent | a54b71543a0101a6aeeda3f9b6df438ee0c983e6 (diff) | |
Color appearance model library
Create colors using CAM16 hue and chroma, L*a*b* L*,
and map them to RGB without distortion.
Test: atest FrameworksCoreTests:com.android.internal.graphics.cam
.CamTest, all passed. No link, atest/my build are broken and cannot
upload results.
Bug: 186640057
Change-Id: I64b605dd452d64e57be0047d2e4672917c01c2a8
Diffstat (limited to 'core/java')
4 files changed, 899 insertions, 0 deletions
diff --git a/core/java/com/android/internal/graphics/ColorUtils.java b/core/java/com/android/internal/graphics/ColorUtils.java index 8b2a2dc38e95..537e797c9bac 100644 --- a/core/java/com/android/internal/graphics/ColorUtils.java +++ b/core/java/com/android/internal/graphics/ColorUtils.java @@ -22,6 +22,8 @@ import android.annotation.IntRange; import android.annotation.NonNull; import android.graphics.Color; +import com.android.internal.graphics.cam.Cam; + /** * Copied from: frameworks/support/core-utils/java/android/support/v4/graphics/ColorUtils.java * @@ -333,6 +335,35 @@ public final class ColorUtils { } /** + * Convert the ARGB color to a color appearance model. + * + * The color appearance model is based on CAM16 hue and chroma, using L*a*b*'s L* as the + * third dimension. + * + * @param color the ARGB color to convert. The alpha component is ignored. + */ + public static Cam colorToCAM(@ColorInt int color) { + return Cam.fromInt(color); + } + + /** + * Convert a color appearance model representation to an ARGB color. + * + * Note: the returned color may have a lower chroma than requested. Whether a chroma is + * available depends on luminance. For example, there's no such thing as a high chroma light + * red, due to the limitations of our eyes and/or physics. If the requested chroma is + * unavailable, the highest possible chroma at the requested luminance is returned. + * + * @param hue hue, in degrees, in CAM coordinates + * @param chroma chroma in CAM coordinates. + * @param lstar perceptual luminance, L* in L*a*b* + */ + @ColorInt + public static int CAMToColor(float hue, float chroma, float lstar) { + return Cam.getInt(hue, chroma, lstar); + } + + /** * Set the alpha component of {@code color} to be {@code alpha}. */ @ColorInt diff --git a/core/java/com/android/internal/graphics/cam/Cam.java b/core/java/com/android/internal/graphics/cam/Cam.java new file mode 100644 index 000000000000..1ac5e5056c1e --- /dev/null +++ b/core/java/com/android/internal/graphics/cam/Cam.java @@ -0,0 +1,509 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.graphics.cam; + +import android.annotation.NonNull; +import android.annotation.Nullable; + +import com.android.internal.graphics.ColorUtils; + +/** + * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and + * coupled to a gamut mapping algorithm. Creates a color system, enables a digital design system. + */ +public class Cam { + // The maximum difference between the requested L* and the L* returned. + private static final float DL_MAX = 0.2f; + // The maximum color distance, in CAM16-UCS, between a requested color and the color returned. + private static final float DE_MAX = 1.0f; + // When the delta between the floor & ceiling of a binary search for chroma is less than this, + // the binary search terminates. + private static final float CHROMA_SEARCH_ENDPOINT = 0.4f; + // When the delta between the floor & ceiling of a binary search for J, lightness in CAM16, + // is less than this, the binary search terminates. + private static final float LIGHTNESS_SEARCH_ENDPOINT = 0.01f; + + // CAM16 color dimensions, see getters for documentation. + private final float mHue; + private final float mChroma; + private final float mJ; + private final float mQ; + private final float mM; + private final float mS; + + // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. + private final float mJstar; + private final float mAstar; + private final float mBstar; + + /** Hue in CAM16 */ + public float getHue() { + return mHue; + } + + /** Chroma in CAM16 */ + public float getChroma() { + return mChroma; + } + + /** Lightness in CAM16 */ + public float getJ() { + return mJ; + } + + /** + * Brightness in CAM16. + * + * <p>Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper + * is much brighter viewed in sunlight than in indoor light, but it is the lightest object under + * any lighting. + */ + public float getQ() { + return mQ; + } + + /** + * Colorfulness in CAM16. + * + * <p>Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much + * more colorful outside than inside, but it has the same chroma in both environments. + */ + public float getM() { + return mM; + } + + /** + * Saturation in CAM16. + * + * <p>Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness + * relative to the color's own brightness, where chroma is colorfulness relative to white. + */ + public float getS() { + return mS; + } + + /** Lightness coordinate in CAM16-UCS */ + public float getJstar() { + return mJstar; + } + + /** a* coordinate in CAM16-UCS */ + public float getAstar() { + return mAstar; + } + + /** b* coordinate in CAM16-UCS */ + public float getBstar() { + return mBstar; + } + + /** Construct a CAM16 color */ + Cam(float hue, float chroma, float j, float q, float m, float s, float jstar, float astar, + float bstar) { + mHue = hue; + mChroma = chroma; + mJ = j; + mQ = q; + mM = m; + mS = s; + mJstar = jstar; + mAstar = astar; + mBstar = bstar; + } + + /** + * Given a hue & chroma in CAM16, L* in L*a*b*, return an ARGB integer. The chroma of the color + * returned may, and frequently will, be lower than requested. Assumes the color is viewed in + * the + * frame defined by the sRGB standard. + */ + public static int getInt(float hue, float chroma, float lstar) { + return getInt(hue, chroma, lstar, Frame.DEFAULT); + } + + /** + * Create a color appearance model from a ARGB integer representing a color. It is assumed the + * color was viewed in the frame defined in the sRGB standard. + */ + @NonNull + public static Cam fromInt(int argb) { + return fromIntInFrame(argb, Frame.DEFAULT); + } + + /** + * Create a color appearance model from a ARGB integer representing a color, specifying the + * frame in which the color was viewed. Prefer Cam.fromInt. + */ + @NonNull + public static Cam fromIntInFrame(int argb, @NonNull Frame frame) { + // Transform ARGB int to XYZ + float[] xyz = CamUtils.xyzFromInt(argb); + + // Transform XYZ to 'cone'/'rgb' responses + float[][] matrix = CamUtils.XYZ_TO_CAM16RGB; + float rT = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]); + float gT = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]); + float bT = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]); + + // Discount illuminant + float rD = frame.getRgbD()[0] * rT; + float gD = frame.getRgbD()[1] * gT; + float bD = frame.getRgbD()[2] * bT; + + // Chromatic adaptation + float rAF = (float) Math.pow(frame.getFl() * Math.abs(rD) / 100.0, 0.42); + float gAF = (float) Math.pow(frame.getFl() * Math.abs(gD) / 100.0, 0.42); + float bAF = (float) Math.pow(frame.getFl() * Math.abs(bD) / 100.0, 0.42); + float rA = Math.signum(rD) * 400.0f * rAF / (rAF + 27.13f); + float gA = Math.signum(gD) * 400.0f * gAF / (gAF + 27.13f); + float bA = Math.signum(bD) * 400.0f * bAF / (bAF + 27.13f); + + // redness-greenness + float a = (float) (11.0 * rA + -12.0 * gA + bA) / 11.0f; + // yellowness-blueness + float b = (float) (rA + gA - 2.0 * bA) / 9.0f; + + // auxiliary components + float u = (20.0f * rA + 20.0f * gA + 21.0f * bA) / 20.0f; + float p2 = (40.0f * rA + 20.0f * gA + bA) / 20.0f; + + // hue + float atan2 = (float) Math.atan2(b, a); + float atanDegrees = atan2 * 180.0f / (float) Math.PI; + float hue = + atanDegrees < 0 + ? atanDegrees + 360.0f + : atanDegrees >= 360 ? atanDegrees - 360.0f : atanDegrees; + float hueRadians = hue * (float) Math.PI / 180.0f; + + // achromatic response to color + float ac = p2 * frame.getNbb(); + + // CAM16 lightness and brightness + float j = 100.0f * (float) Math.pow(ac / frame.getAw(), frame.getC() * frame.getZ()); + float q = + 4.0f + / frame.getC() + * (float) Math.sqrt(j / 100.0f) + * (frame.getAw() + 4.0f) + * frame.getFlRoot(); + + // CAM16 chroma, colorfulness, and saturation. + float huePrime = (hue < 20.14) ? hue + 360 : hue; + float eHue = 0.25f * (float) (Math.cos(huePrime * Math.PI / 180.0 + 2.0) + 3.8); + float p1 = 50000.0f / 13.0f * eHue * frame.getNc() * frame.getNcb(); + float t = p1 * (float) Math.sqrt(a * a + b * b) / (u + 0.305f); + float alpha = + (float) Math.pow(t, 0.9) * (float) Math.pow(1.64 - Math.pow(0.29, frame.getN()), + 0.73); + // CAM16 chroma, colorfulness, saturation + float c = alpha * (float) Math.sqrt(j / 100.0); + float m = c * frame.getFlRoot(); + float s = 50.0f * (float) Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4.0f)); + + // CAM16-UCS components + float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j); + float mstar = 1.0f / 0.0228f * (float) Math.log(1.0f + 0.0228f * m); + float astar = mstar * (float) Math.cos(hueRadians); + float bstar = mstar * (float) Math.sin(hueRadians); + + return new Cam(hue, c, j, q, m, s, jstar, astar, bstar); + } + + /** + * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates + * were measured in the sRGB standard frame. + */ + @NonNull + private static Cam fromJch(float j, float c, float h) { + return fromJchInFrame(j, c, h, Frame.DEFAULT); + } + + /** + * Create a CAM from lightness, chroma, and hue coordinates, and also specify the frame in which + * the color is being viewed. + */ + @NonNull + private static Cam fromJchInFrame(float j, float c, float h, Frame frame) { + float q = + 4.0f + / frame.getC() + * (float) Math.sqrt(j / 100.0) + * (frame.getAw() + 4.0f) + * frame.getFlRoot(); + float m = c * frame.getFlRoot(); + float alpha = c / (float) Math.sqrt(j / 100.0); + float s = 50.0f * (float) Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4.0f)); + + float hueRadians = h * (float) Math.PI / 180.0f; + float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j); + float mstar = 1.0f / 0.0228f * (float) Math.log(1.0 + 0.0228 * m); + float astar = mstar * (float) Math.cos(hueRadians); + float bstar = mstar * (float) Math.sin(hueRadians); + return new Cam(h, c, j, q, m, s, jstar, astar, bstar); + } + + /** + * Distance in CAM16-UCS space between two colors. + * + * <p>Much like L*a*b* was designed to measure distance between colors, the CAM16 standard + * defined a color space called CAM16-UCS to measure distance between CAM16 colors. + */ + public float distance(@NonNull Cam other) { + float dJ = getJstar() - other.getJstar(); + float dA = getAstar() - other.getAstar(); + float dB = getBstar() - other.getBstar(); + double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB); + double dE = 1.41 * Math.pow(dEPrime, 0.63); + return (float) dE; + } + + /** Returns perceived color as an ARGB integer, as viewed in standard sRGB frame. */ + public int viewedInSrgb() { + return viewed(Frame.DEFAULT); + } + + /** Returns color perceived in a frame as an ARGB integer. */ + public int viewed(@NonNull Frame frame) { + float alpha = + (getChroma() == 0.0 || getJ() == 0.0) + ? 0.0f + : getChroma() / (float) Math.sqrt(getJ() / 100.0); + + float t = + (float) Math.pow(alpha / Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73), + 1.0 / 0.9); + float hRad = getHue() * (float) Math.PI / 180.0f; + + float eHue = 0.25f * (float) (Math.cos(hRad + 2.0) + 3.8); + float ac = frame.getAw() * (float) Math.pow(getJ() / 100.0, + 1.0 / frame.getC() / frame.getZ()); + float p1 = eHue * (50000.0f / 13.0f) * frame.getNc() * frame.getNcb(); + float p2 = (ac / frame.getNbb()); + + float hSin = (float) Math.sin(hRad); + float hCos = (float) Math.cos(hRad); + + float gamma = + 23.0f * (p2 + 0.305f) * t / (23.0f * p1 + 11.0f * t * hCos + 108.0f * t * hSin); + float a = gamma * hCos; + float b = gamma * hSin; + float rA = (460.0f * p2 + 451.0f * a + 288.0f * b) / 1403.0f; + float gA = (460.0f * p2 - 891.0f * a - 261.0f * b) / 1403.0f; + float bA = (460.0f * p2 - 220.0f * a - 6300.0f * b) / 1403.0f; + + float rCBase = (float) Math.max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA))); + float rC = Math.signum(rA) * (100.0f / frame.getFl()) * (float) Math.pow(rCBase, + 1.0 / 0.42); + float gCBase = (float) Math.max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA))); + float gC = Math.signum(gA) * (100.0f / frame.getFl()) * (float) Math.pow(gCBase, + 1.0 / 0.42); + float bCBase = (float) Math.max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA))); + float bC = Math.signum(bA) * (100.0f / frame.getFl()) * (float) Math.pow(bCBase, + 1.0 / 0.42); + float rF = rC / frame.getRgbD()[0]; + float gF = gC / frame.getRgbD()[1]; + float bF = bC / frame.getRgbD()[2]; + + + float[][] matrix = CamUtils.CAM16RGB_TO_XYZ; + float x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]); + float y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]); + float z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]); + + int argb = ColorUtils.XYZToColor(x, y, z); + return argb; + } + + /** + * Given a hue & chroma in CAM16, L* in L*a*b*, and the frame in which the color will be + * viewed, + * return an ARGB integer. + * + * <p>The chroma of the color returned may, and frequently will, be lower than requested. This + * is + * a fundamental property of color that cannot be worked around by engineering. For example, a + * red + * hue, with high chroma, and high L* does not exist: red hues have a maximum chroma below 10 + * in + * light shades, creating pink. + */ + public static int getInt(float hue, float chroma, float lstar, @NonNull Frame frame) { + // This is a crucial routine for building a color system, CAM16 itself is not sufficient. + // + // * Why these dimensions? + // Hue and chroma from CAM16 are used because they're the most accurate measures of those + // quantities. L* from L*a*b* is used because it correlates with luminance, luminance is + // used to measure contrast for a11y purposes, thus providing a key constraint on what + // colors + // can be used. + // + // * Why is this routine required to build a color system? + // In all perceptually accurate color spaces (i.e. L*a*b* and later), `chroma` may be + // impossible for a given `hue` and `lstar`. + // For example, a high chroma light red does not exist - chroma is limited to below 10 at + // light red shades, we call that pink. High chroma light green does exist, but not dark + // Also, when converting from another color space to RGB, the color may not be able to be + // represented in RGB. In those cases, the conversion process ends with RGB values + // outside 0-255 + // The vast majority of color libraries surveyed simply round to 0 to 255. That is not an + // option for this library, as it distorts the expected luminance, and thus the expected + // contrast needed for a11y + // + // * What does this routine do? + // Dealing with colors in one color space not fitting inside RGB is, loosely referred to as + // gamut mapping or tone mapping. These algorithms are traditionally idiosyncratic, there is + // no universal answer. However, because the intent of this library is to build a system for + // digital design, and digital design uses luminance to measure contrast/a11y, we have one + // very important constraint that leads to an objective algorithm: the L* of the returned + // color _must_ match the requested L*. + // + // Intuitively, if the color must be distorted to fit into the RGB gamut, and the L* + // requested *must* be fulfilled, than the hue or chroma of the returned color will need + // to be different from the requested hue/chroma. + // + // After exploring both options, it was more intuitive that if the requested chroma could + // not be reached, it used the highest possible chroma. The alternative was finding the + // closest hue where the requested chroma could be reached, but that is not nearly as + // intuitive, as the requested hue is so fundamental to the color description. + + // If the color doesn't have meaningful chroma, return a gray with the requested Lstar. + // + // Yellows are very chromatic at L = 100, and blues are very chromatic at L = 0. All the + // other hues are white at L = 100, and black at L = 0. To preserve consistency for users of + // this system, it is better to simply return white at L* > 99, and black and L* < 0. + if (chroma < 1.0 || Math.round(lstar) <= 0.0 || Math.round(lstar) >= 100.0) { + return CamUtils.intFromLstar(lstar); + } + + hue = hue < 0 ? 0 : Math.min(360, hue); + + // The highest chroma possible. Updated as binary search proceeds. + float high = chroma; + + // The guess for the current binary search iteration. Starts off at the highest chroma, + // thus, if a color is possible at the requested chroma, the search can stop after one try. + float mid = chroma; + float low = 0.0f; + boolean isFirstLoop = true; + + Cam answer = null; + + while (Math.abs(low - high) >= CHROMA_SEARCH_ENDPOINT) { + // Given the current chroma guess, mid, and the desired hue, find J, lightness in + // CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* color space. + Cam possibleAnswer = findCamByJ(hue, mid, lstar); + + if (isFirstLoop) { + if (possibleAnswer != null) { + return possibleAnswer.viewed(frame); + } else { + // If this binary search iteration was the first iteration, and this point + // has been reached, it means the requested chroma was not available at the + // requested hue and L*. + // Proceed to a traditional binary search that starts at the midpoint between + // the requested chroma and 0. + isFirstLoop = false; + mid = low + (high - low) / 2.0f; + continue; + } + } + + if (possibleAnswer == null) { + // There isn't a CAM16 J that creates a color with L* `lstar`. Try a lower chroma. + high = mid; + } else { + answer = possibleAnswer; + // It is possible to create a color. Try higher chroma. + low = mid; + } + + mid = low + (high - low) / 2.0f; + } + + // There was no answer: meaning, for the desired hue, there was no chroma low enough to + // generate a color with the desired L*. + // All values of L* are possible when there is 0 chroma. Return a color with 0 chroma, i.e. + // a shade of gray, with the desired L*. + if (answer == null) { + return CamUtils.intFromLstar(lstar); + } + + return answer.viewed(frame); + } + + // Find J, lightness in CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* + // color space. + // + // Returns null if no J could be found that generated a color with L* `lstar`. + @Nullable + private static Cam findCamByJ(float hue, float chroma, float lstar) { + float low = 0.0f; + float high = 100.0f; + float mid = 0.0f; + float bestdL = 1000.0f; + float bestdE = 1000.0f; + + Cam bestCam = null; + while (Math.abs(low - high) > LIGHTNESS_SEARCH_ENDPOINT) { + mid = low + (high - low) / 2; + // Create the intended CAM color + Cam camBeforeClip = Cam.fromJch(mid, chroma, hue); + // Convert the CAM color to RGB. If the color didn't fit in RGB, during the conversion, + // the initial RGB values will be outside 0 to 255. The final RGB values are clipped to + // 0 to 255, distorting the intended color. + int clipped = camBeforeClip.viewedInSrgb(); + float clippedLstar = CamUtils.lstarFromInt(clipped); + float dL = Math.abs(lstar - clippedLstar); + + // If the clipped color's L* is within error margin... + if (dL < DL_MAX) { + // ...check if the CAM equivalent of the clipped color is far away from intended CAM + // color. For the intended color, use lightness and chroma from the clipped color, + // and the intended hue. Callers are wondering what the lightness is, they know + // chroma may be distorted, so the only concern here is if the hue slipped too far. + Cam camClipped = Cam.fromInt(clipped); + float dE = camClipped.distance( + Cam.fromJch(camClipped.getJ(), camClipped.getChroma(), hue)); + if (dE <= DE_MAX) { + bestdL = dL; + bestdE = dE; + bestCam = camClipped; + } + } + + // If there's no error at all, there's no need to search more. + // + // Note: this happens much more frequently than expected, but this is a very delicate + // property which relies on extremely precise sRGB <=> XYZ calculations, as well as fine + // tuning of the constants that determine error margins and when the binary search can + // terminate. + if (bestdL == 0 && bestdE == 0) { + break; + } + + if (clippedLstar < lstar) { + low = mid; + } else { + high = mid; + } + } + + return bestCam; + } + +} diff --git a/core/java/com/android/internal/graphics/cam/CamUtils.java b/core/java/com/android/internal/graphics/cam/CamUtils.java new file mode 100644 index 000000000000..13dafdba71f4 --- /dev/null +++ b/core/java/com/android/internal/graphics/cam/CamUtils.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.graphics.cam; + + +import android.annotation.NonNull; +import android.graphics.Color; + +import com.android.internal.graphics.ColorUtils; + +/** + * Collection of methods for transforming between color spaces. + * + * <p>Methods are named $xFrom$Y. For example, lstarFromInt() returns L* from an ARGB integer. + * + * <p>These methods, generally, convert colors between the L*a*b*, XYZ, and sRGB spaces. + * + * <p>L*a*b* is a perceptually accurate color space. This is particularly important in the L* + * dimension: it measures luminance and unlike lightness measures traditionally used in UI work via + * RGB or HSL, this luminance transitions smoothly, permitting creation of pleasing shades of a + * color, and more pleasing transitions between colors. + * + * <p>XYZ is commonly used as an intermediate color space for converting between one color space to + * another. For example, to convert RGB to L*a*b*, first RGB is converted to XYZ, then XYZ is + * convered to L*a*b*. + * + * <p>sRGB is a "specification originated from work in 1990s through cooperation by Hewlett-Packard + * and Microsoft, and it was designed to be a standard definition of RGB for the internet, which it + * indeed became...The standard is based on a sampling of computer monitors at the time...The whole + * idea of sRGB is that if everyone assumed that RGB meant the same thing, then the results would be + * consistent, and reasonably good. It worked." - Fairchild, Color Models and Systems: Handbook of + * Color Psychology, 2015 + */ +public final class CamUtils { + private CamUtils() { + } + + // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. + static final float[][] XYZ_TO_CAM16RGB = { + {0.401288f, 0.650173f, -0.051461f}, + {-0.250268f, 1.204414f, 0.045854f}, + {-0.002079f, 0.048952f, 0.953127f} + }; + + // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. + static final float[][] CAM16RGB_TO_XYZ = { + {1.86206786f, -1.01125463f, 0.14918677f}, + {0.38752654f, 0.62144744f, -0.00897398f}, + {-0.01584150f, -0.03412294f, 1.04996444f} + }; + + // Need this, XYZ coordinates in internal ColorUtils are private + + // sRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard + // Default Color Space for the Internet: sRGB, 1996 + static final float[] WHITE_POINT_D65 = {95.047f, 100.0f, 108.883f}; + + // This is a more precise sRGB to XYZ transformation matrix than traditionally + // used. It was derived using Schlomer's technique of transforming the xyY + // primaries to XYZ, then applying a correction to ensure mapping from sRGB + // 1, 1, 1 to the reference white point, D65. + static final float[][] SRGB_TO_XYZ = { + {0.41233895f, 0.35762064f, 0.18051042f}, + {0.2126f, 0.7152f, 0.0722f}, + {0.01932141f, 0.11916382f, 0.95034478f} + }; + + static int intFromLstar(float lstar) { + if (lstar < 1) { + return 0xff000000; + } else if (lstar > 99) { + return 0xffffffff; + } + + // XYZ to LAB conversion routine, assume a and b are 0. + float fy = (lstar + 16.0f) / 116.0f; + + // fz = fx = fy because a and b are 0 + float fz = fy; + float fx = fy; + + float kappa = 24389f / 27f; + float epsilon = 216f / 24389f; + boolean lExceedsEpsilonKappa = (lstar > 8.0f); + float yT = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa; + boolean cubeExceedEpsilon = (fy * fy * fy) > epsilon; + float xT = cubeExceedEpsilon ? fx * fx * fx : (116f * fx - 16f) / kappa; + float zT = cubeExceedEpsilon ? fz * fz * fz : (116f * fx - 16f) / kappa; + + return ColorUtils.XYZToColor(xT * CamUtils.WHITE_POINT_D65[0], + yT * CamUtils.WHITE_POINT_D65[1], zT * CamUtils.WHITE_POINT_D65[2]); + } + + /** Returns L* from L*a*b*, perceptual luminance, from an ARGB integer (ColorInt). */ + public static float lstarFromInt(int argb) { + return lstarFromY(yFromInt(argb)); + } + + static float lstarFromY(float y) { + y = y / 100.0f; + final float e = 216.f / 24389.f; + float yIntermediate; + if (y <= e) { + return ((24389.f / 27.f) * y); + } else { + yIntermediate = (float) Math.cbrt(y); + } + return 116.f * yIntermediate - 16.f; + } + + static float yFromInt(int argb) { + final float r = linearized(Color.red(argb)); + final float g = linearized(Color.green(argb)); + final float b = linearized(Color.blue(argb)); + float[][] matrix = SRGB_TO_XYZ; + float y = (r * matrix[1][0]) + (g * matrix[1][1]) + (b * matrix[1][2]); + return y; + } + + @NonNull + static float[] xyzFromInt(int argb) { + final float r = linearized(Color.red(argb)); + final float g = linearized(Color.green(argb)); + final float b = linearized(Color.blue(argb)); + + float[][] matrix = SRGB_TO_XYZ; + float x = (r * matrix[0][0]) + (g * matrix[0][1]) + (b * matrix[0][2]); + float y = (r * matrix[1][0]) + (g * matrix[1][1]) + (b * matrix[1][2]); + float z = (r * matrix[2][0]) + (g * matrix[2][1]) + (b * matrix[2][2]); + return new float[]{x, y, z}; + } + + static float yFromLstar(float lstar) { + float ke = 8.0f; + if (lstar > ke) { + return (float) Math.pow(((lstar + 16.0) / 116.0), 3) * 100f; + } else { + return lstar / (24389f / 27f) * 100f; + } + } + + static float linearized(int rgbComponent) { + float normalized = (float) rgbComponent / 255.0f; + + if (normalized <= 0.04045f) { + return (normalized / 12.92f) * 100.0f; + } else { + return (float) Math.pow(((normalized + 0.055f) / 1.055f), 2.4f) * 100.0f; + } + } +} diff --git a/core/java/com/android/internal/graphics/cam/Frame.java b/core/java/com/android/internal/graphics/cam/Frame.java new file mode 100644 index 000000000000..c422ad13c652 --- /dev/null +++ b/core/java/com/android/internal/graphics/cam/Frame.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.graphics.cam; + +import android.annotation.NonNull; +import android.util.MathUtils; + +/** + * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a + * color appearance model representing the color. + * + * <p>To convert a traditional color to a color appearance model, it requires knowing what + * conditions the color was observed in. Our perception of color depends on, for example, the tone + * of the light illuminating the color, how bright that light was, etc. + * + * <p>This class is modelled separately from the color appearance model itself because there are a + * number of calculations during the color => CAM conversion process that depend only on the viewing + * conditions. Caching those calculations in a Frame instance saves a significant amount of time. + */ +public final class Frame { + // Standard viewing conditions assumed in RGB specification - Stokes, Anderson, Chandrasekar, + // Motta - A Standard Default Color Space for the Internet: sRGB, 1996. + // + // White point = D65 + // Luminance of adapting field: 200 / Pi / 5, units are cd/m^2. + // sRGB ambient illuminance = 64 lux (per sRGB spec). However, the spec notes this is + // artificially low and based on monitors in 1990s. Use 200, the sRGB spec says this is the + // real average, and a survey of lux values on Wikipedia confirms this is a comfortable + // default: somewhere between a very dark overcast day and office lighting. + // Per CAM16 introduction paper (Li et al, 2017) Ew = pi * lw, and La = lw * Yb/Yw + // Ew = ambient environment luminance, in lux. + // Yb/Yw is taken to be midgray, ~20% relative luminance (XYZ Y 18.4, CIELAB L* 50). + // Therefore La = (Ew / pi) * .184 + // La = 200 / pi * .184 + // Image surround to 10 degrees = ~20% relative luminance = CIELAB L* 50 + // + // Not from sRGB standard: + // Surround = average, 2.0. + // Discounting illuminant = false, doesn't occur for self-luminous displays + public static final Frame DEFAULT = + Frame.make( + CamUtils.WHITE_POINT_D65, + (float) (200.0f / Math.PI * CamUtils.yFromLstar(50.0f) / 100.f), 50.0f, 2.0f, + false); + + private final float mAw; + private final float mNbb; + private final float mNcb; + private final float mC; + private final float mNc; + private final float mN; + private final float[] mRgbD; + private final float mFl; + private final float mFlRoot; + private final float mZ; + + float getAw() { + return mAw; + } + + float getN() { + return mN; + } + + float getNbb() { + return mNbb; + } + + float getNcb() { + return mNcb; + } + + float getC() { + return mC; + } + + float getNc() { + return mNc; + } + + @NonNull + float[] getRgbD() { + return mRgbD; + } + + float getFl() { + return mFl; + } + + float getFlRoot() { + return mFlRoot; + } + + float getZ() { + return mZ; + } + + private Frame(float n, float aw, float nbb, float ncb, float c, float nc, float[] rgbD, + float fl, float fLRoot, float z) { + mN = n; + mAw = aw; + mNbb = nbb; + mNcb = ncb; + mC = c; + mNc = nc; + mRgbD = rgbD; + mFl = fl; + mFlRoot = fLRoot; + mZ = z; + } + + /** Create a custom frame. */ + @NonNull + public static Frame make(@NonNull float[] whitepoint, float adaptingLuminance, + float backgroundLstar, float surround, boolean discountingIlluminant) { + // Transform white point XYZ to 'cone'/'rgb' responses + float[][] matrix = CamUtils.XYZ_TO_CAM16RGB; + float[] xyz = whitepoint; + float rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]); + float gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]); + float bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]); + + // Scale input surround, domain (0, 2), to CAM16 surround, domain (0.8, 1.0) + float f = 0.8f + (surround / 10.0f); + // "Exponential non-linearity" + float c = (f >= 0.9) ? MathUtils.lerp(0.59f, 0.69f, ((f - 0.9f) * 10.0f)) : MathUtils.lerp( + 0.525f, 0.59f, ((f - 0.8f) * 10.0f)); + // Calculate degree of adaptation to illuminant + float d = discountingIlluminant ? 1.0f : f * (1.0f - ((1.0f / 3.6f) * (float) Math.exp( + (-adaptingLuminance - 42.0f) / 92.0f))); + // Per Li et al, if D is greater than 1 or less than 0, set it to 1 or 0. + d = (d > 1.0) ? 1.0f : (d < 0.0) ? 0.0f : d; + // Chromatic induction factor + float nc = f; + + // Cone responses to the whitepoint, adjusted for illuminant discounting. + // + // Why use 100.0 instead of the white point's relative luminance? + // + // Some papers and implementations, for both CAM02 and CAM16, use the Y + // value of the reference white instead of 100. Fairchild's Color Appearance + // Models (3rd edition) notes that this is in error: it was included in the + // CIE 2004a report on CIECAM02, but, later parts of the conversion process + // account for scaling of appearance relative to the white point relative + // luminance. This part should simply use 100 as luminance. + float[] rgbD = new float[]{d * (100.0f / rW) + 1.0f - d, d * (100.0f / gW) + 1.0f - d, + d * (100.0f / bW) + 1.0f - d, }; + // Luminance-level adaptation factor + float k = 1.0f / (5.0f * adaptingLuminance + 1.0f); + float k4 = k * k * k * k; + float k4F = 1.0f - k4; + float fl = (k4 * adaptingLuminance) + (0.1f * k4F * k4F * (float) Math.cbrt( + 5.0 * adaptingLuminance)); + + // Intermediate factor, ratio of background relative luminance to white relative luminance + float n = CamUtils.yFromLstar(backgroundLstar) / whitepoint[1]; + + // Base exponential nonlinearity + // note Schlomer 2018 has a typo and uses 1.58, the correct factor is 1.48 + float z = 1.48f + (float) Math.sqrt(n); + + // Luminance-level induction factors + float nbb = 0.725f / (float) Math.pow(n, 0.2); + float ncb = nbb; + + // Discounted cone responses to the white point, adjusted for post-chromatic + // adaptation perceptual nonlinearities. + float[] rgbAFactors = new float[]{(float) Math.pow(fl * rgbD[0] * rW / 100.0, 0.42), + (float) Math.pow(fl * rgbD[1] * gW / 100.0, 0.42), (float) Math.pow( + fl * rgbD[2] * bW / 100.0, 0.42)}; + + float[] rgbA = new float[]{(400.0f * rgbAFactors[0]) / (rgbAFactors[0] + 27.13f), + (400.0f * rgbAFactors[1]) / (rgbAFactors[1] + 27.13f), + (400.0f * rgbAFactors[2]) / (rgbAFactors[2] + 27.13f), }; + + float aw = ((2.0f * rgbA[0]) + rgbA[1] + (0.05f * rgbA[2])) * nbb; + + return new Frame(n, aw, nbb, ncb, c, nc, rgbD, fl, (float) Math.pow(fl, 0.25), z); + } +} |
