summaryrefslogtreecommitdiff
path: root/core/java
diff options
context:
space:
mode:
authorJames O'Leary <jamesoleary@google.com>2021-04-29 11:34:05 -0400
committerJames O'Leary <jamesoleary@google.com>2021-04-29 11:40:47 -0400
commita23d0548385ffabadbc4eaa1cf774fd5dde769eb (patch)
tree82dabe63ec4a04e95d14e41d84483b59f641b39b /core/java
parenta54b71543a0101a6aeeda3f9b6df438ee0c983e6 (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')
-rw-r--r--core/java/com/android/internal/graphics/ColorUtils.java31
-rw-r--r--core/java/com/android/internal/graphics/cam/Cam.java509
-rw-r--r--core/java/com/android/internal/graphics/cam/CamUtils.java165
-rw-r--r--core/java/com/android/internal/graphics/cam/Frame.java194
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);
+ }
+}