Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c3cffc8b authored by James O'Leary's avatar James O'Leary
Browse files

Match Android's colors to design intent

May 2021 HCT code in Android ("CAM") gives results inconsistent with
HCT in design tooling. Using HCT solver ensures they match, allowing
design to verify their specs against the results in Android, derisking
the launch of theme variants.

HCT Solver is a standalone class that is easily integrated with Android
& Cam: when Cam.get(hue,chroma,tone) is called, it now calls
HctSolver.get(hue, chroma, tone).

HCT Solver was imported from google3, where it is tested against _all_
hex codes, both forward and reverse conversions. A number
of other tests cover all parts of the theming system, giving us high
confidence that there won't be a regression.

Test: atest ColorSchemeTest (unit test); atest SystemPaletteTest (CTS).
Spent ~4 hours trying a variety of wallpapers, and seed colors, and
used dumpsys to verify results match UX spec. Verify hex codes and HCT
values output by Android match those in design tooling via putting
hex codes into Monet Studio, and confirming the resulting HCT values
match the HCT values generated by Android.
Bug: 213314628
Change-Id: I95da1efacc2d61f19ce7f20efa3e7f70736258a9
parent abfe2878
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -386,6 +386,13 @@ public class Cam {
        // 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 (frame == Frame.DEFAULT) {
            // If the viewing conditions are the same as the default sRGB-like viewing conditions,
            // skip to using HctSolver: it uses geometrical insights to find the closest in-gamut
            // match to hue/chroma/lstar.
            return HctSolver.solveToInt(hue, chroma, lstar);
        }

        if (chroma < 1.0 || Math.round(lstar) <= 0.0 || Math.round(lstar) >= 100.0) {
            return CamUtils.intFromLstar(lstar);
        }
+140 −17
Original line number Diff line number Diff line
@@ -73,12 +73,124 @@ public final class CamUtils {
    // 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 final double[][] SRGB_TO_XYZ =
            new double[][] {
                    new double[] {0.41233895, 0.35762064, 0.18051042},
                    new double[] {0.2126, 0.7152, 0.0722},
                    new double[] {0.01932141, 0.11916382, 0.95034478},
            };

    static final double[][] XYZ_TO_SRGB =
            new double[][] {
                    new double[] {
                            3.2413774792388685, -1.5376652402851851, -0.49885366846268053,
                    },
                    new double[] {
                            -0.9691452513005321, 1.8758853451067872, 0.04156585616912061,
                    },
                    new double[] {
                            0.05562093689691305, -0.20395524564742123, 1.0571799111220335,
                    },
            };

    /**
     * The signum function.
     *
     * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0
     */
    public static int signum(double num) {
        if (num < 0) {
            return -1;
        } else if (num == 0) {
            return 0;
        } else {
            return 1;
        }
    }

    /**
     * Converts an L* value to an ARGB representation.
     *
     * @param lstar L* in L*a*b*
     * @return ARGB representation of grayscale color with lightness matching L*
     */
    public static int argbFromLstar(double lstar) {
        double fy = (lstar + 16.0) / 116.0;
        double fz = fy;
        double fx = fy;
        double kappa = 24389.0 / 27.0;
        double epsilon = 216.0 / 24389.0;
        boolean lExceedsEpsilonKappa = lstar > 8.0;
        double y = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa;
        boolean cubeExceedEpsilon = fy * fy * fy > epsilon;
        double x = cubeExceedEpsilon ? fx * fx * fx : lstar / kappa;
        double z = cubeExceedEpsilon ? fz * fz * fz : lstar / kappa;
        float[] whitePoint = WHITE_POINT_D65;
        return argbFromXyz(x * whitePoint[0], y * whitePoint[1], z * whitePoint[2]);
    }

    /** Converts a color from ARGB to XYZ. */
    public static int argbFromXyz(double x, double y, double z) {
        double[][] matrix = XYZ_TO_SRGB;
        double linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z;
        double linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z;
        double linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z;
        int r = delinearized(linearR);
        int g = delinearized(linearG);
        int b = delinearized(linearB);
        return argbFromRgb(r, g, b);
    }

    /** Converts a color from linear RGB components to ARGB format. */
    public static int argbFromLinrgb(double[] linrgb) {
        int r = delinearized(linrgb[0]);
        int g = delinearized(linrgb[1]);
        int b = delinearized(linrgb[2]);
        return argbFromRgb(r, g, b);
    }

    /** Converts a color from linear RGB components to ARGB format. */
    public static int argbFromLinrgbComponents(double r, double g, double b) {
        return argbFromRgb(delinearized(r), delinearized(g), delinearized(b));
    }

    /**
     * Delinearizes an RGB component.
     *
     * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel
     * @return 0 <= output <= 255, color channel converted to regular RGB space
     */
    public static int delinearized(double rgbComponent) {
        double normalized = rgbComponent / 100.0;
        double delinearized = 0.0;
        if (normalized <= 0.0031308) {
            delinearized = normalized * 12.92;
        } else {
            delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055;
        }
        return clampInt(0, 255, (int) Math.round(delinearized * 255.0));
    }

    /**
     * Clamps an integer between two integers.
     *
     * @return input when min <= input <= max, and either min or max otherwise.
     */
    public static int clampInt(int min, int max, int input) {
        if (input < min) {
            return min;
        } else if (input > max) {
            return max;
        }

        return input;
    }

    /** Converts a color from RGB components to ARGB format. */
    public static int argbFromRgb(int red, int green, int blue) {
        return (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255);
    }

    static int intFromLstar(float lstar) {
        if (lstar < 1) {
            return 0xff000000;
@@ -126,9 +238,9 @@ public final class CamUtils {
        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;
        double[][] matrix = SRGB_TO_XYZ;
        double y = (r * matrix[1][0]) + (g * matrix[1][1]) + (b * matrix[1][2]);
        return (float) y;
    }

    @NonNull
@@ -137,19 +249,30 @@ public final class CamUtils {
        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};
        double[][] matrix = SRGB_TO_XYZ;
        double x = (r * matrix[0][0]) + (g * matrix[0][1]) + (b * matrix[0][2]);
        double y = (r * matrix[1][0]) + (g * matrix[1][1]) + (b * matrix[1][2]);
        double z = (r * matrix[2][0]) + (g * matrix[2][1]) + (b * matrix[2][2]);
        return new float[]{(float) x, (float) y, (float) z};
    }

    static float yFromLstar(float lstar) {
        float ke = 8.0f;
    /**
     * Converts an L* value to a Y value.
     *
     * <p>L* in L*a*b* and Y in XYZ measure the same quantity, luminance.
     *
     * <p>L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a
     * logarithmic scale.
     *
     * @param lstar L* in L*a*b*
     * @return Y in XYZ
     */
    public static double yFromLstar(double lstar) {
        double ke = 8.0;
        if (lstar > ke) {
            return (float) Math.pow(((lstar + 16.0) / 116.0), 3) * 100f;
            return Math.pow((lstar + 16.0) / 116.0, 3.0) * 100.0;
        } else {
            return lstar / (24389f / 27f) * 100f;
            return lstar / (24389.0 / 27.0) * 100.0;
        }
    }

+14 −6
Original line number Diff line number Diff line
@@ -19,6 +19,8 @@ package com.android.internal.graphics.cam;
import android.annotation.NonNull;
import android.util.MathUtils;

import com.android.internal.annotations.VisibleForTesting;

/**
 * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a
 * color appearance model representing the color.
@@ -68,15 +70,18 @@ public final class Frame {
    private final float mFlRoot;
    private final float mZ;

    float getAw() {
    @VisibleForTesting
    public float getAw() {
        return mAw;
    }

    float getN() {
    @VisibleForTesting
    public float getN() {
        return mN;
    }

    float getNbb() {
    @VisibleForTesting
    public float getNbb() {
        return mNbb;
    }

@@ -92,8 +97,9 @@ public final class Frame {
        return mNc;
    }

    @VisibleForTesting
    @NonNull
    float[] getRgbD() {
    public float[] getRgbD() {
        return mRgbD;
    }

@@ -101,7 +107,9 @@ public final class Frame {
        return mFl;
    }

    float getFlRoot() {
    @VisibleForTesting
    @NonNull
    public float getFlRoot() {
        return mFlRoot;
    }

@@ -167,7 +175,7 @@ public final class Frame {
                5.0 * adaptingLuminance));

        // Intermediate factor, ratio of background relative luminance to white relative luminance
        float n = CamUtils.yFromLstar(backgroundLstar) / whitepoint[1];
        float n = (float) CamUtils.yFromLstar(backgroundLstar) / whitepoint[1];

        // Base exponential nonlinearity
        // note Schlomer 2018 has a typo and uses 1.58, the correct factor is 1.48
+721 −0

File added.

Preview size limit exceeded, changes collapsed.

+32 −1
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ package com.android.internal.graphics.cam;

import static org.junit.Assert.assertEquals;

import android.platform.test.annotations.LargeTest;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -69,7 +72,7 @@ public final class CamTest {
    public void camFromGreen() {
        Cam cam = Cam.fromInt(GREEN);
        assertEquals(79.331f, cam.getJ(), 0.001f);
        assertEquals(108.409f, cam.getChroma(), 0.001f);
        assertEquals(108.410f, cam.getChroma(), 0.001f);
        assertEquals(142.139f, cam.getHue(), 0.001f);
        assertEquals(85.587f, cam.getM(), 0.001f);
        assertEquals(78.604f, cam.getS(), 0.001f);
@@ -193,4 +196,32 @@ public final class CamTest {
    public void deltaERedToBlue() {
        assertEquals(21.415f, Cam.fromInt(RED).distance(Cam.fromInt(BLUE)), 0.001f);
    }

    @Test
    public void viewingConditions_default() {
        Frame vc = Frame.DEFAULT;

        Assert.assertEquals(0.184, vc.getN(), 0.001);
        Assert.assertEquals(29.981, vc.getAw(), 0.001);
        Assert.assertEquals(1.016, vc.getNbb(), 0.001);
        Assert.assertEquals(1.021, vc.getRgbD()[0], 0.001);
        Assert.assertEquals(0.986, vc.getRgbD()[1], 0.001);
        Assert.assertEquals(0.933, vc.getRgbD()[2], 0.001);
        Assert.assertEquals(0.789, vc.getFlRoot(), 0.001);
    }

    @LargeTest
    @Test
    public void testHctReflexivity() {
        for (int i = 0; i <= 0x00ffffff; i++) {
            int color = 0xFF000000 | i;
            Cam hct = Cam.fromInt(color);
            int reconstructedFromHct = Cam.getInt(hct.getHue(), hct.getChroma(),
                    CamUtils.lstarFromInt(color));

            Assert.assertEquals("input was " + Integer.toHexString(color)
                            + "; output was " + Integer.toHexString(reconstructedFromHct),
                    reconstructedFromHct, reconstructedFromHct);
        }
    }
}