diff --git a/core/java/com/android/internal/graphics/cam/Cam.java b/core/java/com/android/internal/graphics/cam/Cam.java index 1ac5e5056c1e2..1df85c3893226 100644 --- a/core/java/com/android/internal/graphics/cam/Cam.java +++ b/core/java/com/android/internal/graphics/cam/Cam.java @@ -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); } diff --git a/core/java/com/android/internal/graphics/cam/CamUtils.java b/core/java/com/android/internal/graphics/cam/CamUtils.java index 13dafdba71f46..f54172996168a 100644 --- a/core/java/com/android/internal/graphics/cam/CamUtils.java +++ b/core/java/com/android/internal/graphics/cam/CamUtils.java @@ -73,11 +73,123 @@ 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) { @@ -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. + * + *

L* in L*a*b* and Y in XYZ measure the same quantity, luminance. + * + *

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; } } diff --git a/core/java/com/android/internal/graphics/cam/Frame.java b/core/java/com/android/internal/graphics/cam/Frame.java index c422ad13c6520..0ac7cbc2f60e4 100644 --- a/core/java/com/android/internal/graphics/cam/Frame.java +++ b/core/java/com/android/internal/graphics/cam/Frame.java @@ -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 diff --git a/core/java/com/android/internal/graphics/cam/HctSolver.java b/core/java/com/android/internal/graphics/cam/HctSolver.java new file mode 100644 index 0000000000000..d7a869185cd7a --- /dev/null +++ b/core/java/com/android/internal/graphics/cam/HctSolver.java @@ -0,0 +1,721 @@ +/* + * Copyright (C) 2022 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; + +/** + * An efficient algorithm for determining the closest sRGB color to a set of HCT coordinates, + * based on geometrical insights for finding intersections in linear RGB, CAM16, and L*a*b*. + * + * Algorithm identified and implemented by Tianguang Zhang. + * Copied from //java/com/google/ux/material/libmonet/hct on May 22 2022. + * ColorUtils/MathUtils functions that were required were added to CamUtils. + */ +public class HctSolver { + private HctSolver() {} + + // Matrix used when converting from linear RGB to CAM16. + static final double[][] SCALED_DISCOUNT_FROM_LINRGB = + new double[][] { + new double[] { + 0.001200833568784504, 0.002389694492170889, 0.0002795742885861124, + }, + new double[] { + 0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398, + }, + new double[] { + 0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076, + }, + }; + + // Matrix used when converting from CAM16 to linear RGB. + static final double[][] LINRGB_FROM_SCALED_DISCOUNT = + new double[][] { + new double[] { + 1373.2198709594231, -1100.4251190754821, -7.278681089101213, + }, + new double[] { + -271.815969077903, 559.6580465940733, -32.46047482791194, + }, + new double[] { + 1.9622899599665666, -57.173814538844006, 308.7233197812385, + }, + }; + + // Weights for transforming a set of linear RGB coordinates to Y in XYZ. + static final double[] Y_FROM_LINRGB = new double[] {0.2126, 0.7152, 0.0722}; + + // Lookup table for plane in XYZ's Y axis (relative luminance) that corresponds to a given + // L* in L*a*b*. HCT's T is L*, and XYZ's Y is directly correlated to linear RGB, this table + // allows us to thus find the intersection between HCT and RGB, giving a solution to the + // RGB coordinates that correspond to a given set of HCT coordinates. + static final double[] CRITICAL_PLANES = + new double[] { + 0.015176349177441876, + 0.045529047532325624, + 0.07588174588720938, + 0.10623444424209313, + 0.13658714259697685, + 0.16693984095186062, + 0.19729253930674434, + 0.2276452376616281, + 0.2579979360165119, + 0.28835063437139563, + 0.3188300904430532, + 0.350925934958123, + 0.3848314933096426, + 0.42057480301049466, + 0.458183274052838, + 0.4976837250274023, + 0.5391024159806381, + 0.5824650784040898, + 0.6277969426914107, + 0.6751227633498623, + 0.7244668422128921, + 0.775853049866786, + 0.829304845476233, + 0.8848452951698498, + 0.942497089126609, + 1.0022825574869039, + 1.0642236851973577, + 1.1283421258858297, + 1.1946592148522128, + 1.2631959812511864, + 1.3339731595349034, + 1.407011200216447, + 1.4823302800086415, + 1.5599503113873272, + 1.6398909516233677, + 1.7221716113234105, + 1.8068114625156377, + 1.8938294463134073, + 1.9832442801866852, + 2.075074464868551, + 2.1693382909216234, + 2.2660538449872063, + 2.36523901573795, + 2.4669114995532007, + 2.5710888059345764, + 2.6777882626779785, + 2.7870270208169257, + 2.898822059350997, + 3.0131901897720907, + 3.1301480604002863, + 3.2497121605402226, + 3.3718988244681087, + 3.4967242352587946, + 3.624204428461639, + 3.754355295633311, + 3.887192587735158, + 4.022731918402185, + 4.160988767090289, + 4.301978482107941, + 4.445716283538092, + 4.592217266055746, + 4.741496401646282, + 4.893568542229298, + 5.048448422192488, + 5.20615066083972, + 5.3666897647573375, + 5.5300801301023865, + 5.696336044816294, + 5.865471690767354, + 6.037501145825082, + 6.212438385869475, + 6.390297286737924, + 6.571091626112461, + 6.7548350853498045, + 6.941541251256611, + 7.131223617812143, + 7.323895587840543, + 7.5195704746346665, + 7.7182615035334345, + 7.919981813454504, + 8.124744458384042, + 8.332562408825165, + 8.543448553206703, + 8.757415699253682, + 8.974476575321063, + 9.194643831691977, + 9.417930041841839, + 9.644347703669503, + 9.873909240696694, + 10.106627003236781, + 10.342513269534024, + 10.58158024687427, + 10.8238400726681, + 11.069304815507364, + 11.317986476196008, + 11.569896988756009, + 11.825048221409341, + 12.083451977536606, + 12.345119996613247, + 12.610063955123938, + 12.878295467455942, + 13.149826086772048, + 13.42466730586372, + 13.702830557985108, + 13.984327217668513, + 14.269168601521828, + 14.55736596900856, + 14.848930523210871, + 15.143873411576273, + 15.44220572664832, + 15.743938506781891, + 16.04908273684337, + 16.35764934889634, + 16.66964922287304, + 16.985093187232053, + 17.30399201960269, + 17.62635644741625, + 17.95219714852476, + 18.281524751807332, + 18.614349837764564, + 18.95068293910138, + 19.290534541298456, + 19.633915083172692, + 19.98083495742689, + 20.331304511189067, + 20.685334046541502, + 21.042933821039977, + 21.404114048223256, + 21.76888489811322, + 22.137256497705877, + 22.50923893145328, + 22.884842241736916, + 23.264076429332462, + 23.6469514538663, + 24.033477234264016, + 24.42366364919083, + 24.817520537484558, + 25.21505769858089, + 25.61628489293138, + 26.021211842414342, + 26.429848230738664, + 26.842203703840827, + 27.258287870275353, + 27.678110301598522, + 28.10168053274597, + 28.529008062403893, + 28.96010235337422, + 29.39497283293396, + 29.83362889318845, + 30.276079891419332, + 30.722335150426627, + 31.172403958865512, + 31.62629557157785, + 32.08401920991837, + 32.54558406207592, + 33.010999283389665, + 33.4802739966603, + 33.953417292456834, + 34.430438229418264, + 34.911345834551085, + 35.39614910352207, + 35.88485700094671, + 36.37747846067349, + 36.87402238606382, + 37.37449765026789, + 37.87891309649659, + 38.38727753828926, + 38.89959975977785, + 39.41588851594697, + 39.93615253289054, + 40.460400508064545, + 40.98864111053629, + 41.520882981230194, + 42.05713473317016, + 42.597404951718396, + 43.141702194811224, + 43.6900349931913, + 44.24241185063697, + 44.798841244188324, + 45.35933162437017, + 45.92389141541209, + 46.49252901546552, + 47.065252796817916, + 47.64207110610409, + 48.22299226451468, + 48.808024568002054, + 49.3971762874833, + 49.9904556690408, + 50.587870934119984, + 51.189430279724725, + 51.79514187861014, + 52.40501387947288, + 53.0190544071392, + 53.637271562750364, + 54.259673423945976, + 54.88626804504493, + 55.517063457223934, + 56.15206766869424, + 56.79128866487574, + 57.43473440856916, + 58.08241284012621, + 58.734331877617365, + 59.39049941699807, + 60.05092333227251, + 60.715611475655585, + 61.38457167773311, + 62.057811747619894, + 62.7353394731159, + 63.417162620860914, + 64.10328893648692, + 64.79372614476921, + 65.48848194977529, + 66.18756403501224, + 66.89098006357258, + 67.59873767827808, + 68.31084450182222, + 69.02730813691093, + 69.74813616640164, + 70.47333615344107, + 71.20291564160104, + 71.93688215501312, + 72.67524319850172, + 73.41800625771542, + 74.16517879925733, + 74.9167682708136, + 75.67278210128072, + 76.43322770089146, + 77.1981124613393, + 77.96744375590167, + 78.74122893956174, + 79.51947534912904, + 80.30219030335869, + 81.08938110306934, + 81.88105503125999, + 82.67721935322541, + 83.4778813166706, + 84.28304815182372, + 85.09272707154808, + 85.90692527145302, + 86.72564993000343, + 87.54890820862819, + 88.3767072518277, + 89.2090541872801, + 90.04595612594655, + 90.88742016217518, + 91.73345337380438, + 92.58406282226491, + 93.43925555268066, + 94.29903859396902, + 95.16341895893969, + 96.03240364439274, + 96.9059996312159, + 97.78421388448044, + 98.6670533535366, + 99.55452497210776, + }; + + /** + * Sanitizes a small enough angle in radians. + * + * @param angle An angle in radians; must not deviate too much from 0. + * @return A coterminal angle between 0 and 2pi. + */ + static double sanitizeRadians(double angle) { + return (angle + Math.PI * 8) % (Math.PI * 2); + } + + /** + * Delinearizes an RGB component, returning a floating-point number. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space + */ + static double trueDelinearized(double rgbComponent) { + double normalized = rgbComponent / 100.0; + double delinearized; + if (normalized <= 0.0031308) { + delinearized = normalized * 12.92; + } else { + delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055; + } + return delinearized * 255.0; + } + + static double chromaticAdaptation(double component) { + double af = Math.pow(Math.abs(component), 0.42); + return CamUtils.signum(component) * 400.0 * af / (af + 27.13); + } + + /** + * Returns the hue of a linear RGB color in CAM16. + * + * @param linrgb The linear RGB coordinates of a color. + * @return The hue of the color in CAM16, in radians. + */ + static double hueOf(double[] linrgb) { + // Calculate scaled discount components using in-lined matrix multiplication to avoid + // an array allocation. + double[][] matrix = SCALED_DISCOUNT_FROM_LINRGB; + double[] row = linrgb; + double rD = linrgb[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2]; + double gD = linrgb[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2]; + double bD = linrgb[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2]; + + double rA = chromaticAdaptation(rD); + double gA = chromaticAdaptation(gD); + double bA = chromaticAdaptation(bD); + // redness-greenness + double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; + // yellowness-blueness + double b = (rA + gA - 2.0 * bA) / 9.0; + return Math.atan2(b, a); + } + + /** + * Cyclic order is the idea that 330° → 5° → 200° is in order, but, 180° → 270° → 210° is not. + * Visually, A B and C are angles, and they are in cyclic order if travelling from A to C + * in a way that increases angle (ex. counter-clockwise if +x axis = 0 degrees and +y = 90) + * means you must cross B. + * @param a first angle in possibly cyclic triplet + * @param b second angle in possibly cyclic triplet + * @param c third angle in possibly cyclic triplet + * @return true if B is between A and C + */ + static boolean areInCyclicOrder(double a, double b, double c) { + double deltaAB = sanitizeRadians(b - a); + double deltaAC = sanitizeRadians(c - a); + return deltaAB < deltaAC; + } + + /** + * Find an intercept using linear interpolation. + * + * @param source The starting number. + * @param mid The number in the middle. + * @param target The ending number. + * @return A number t such that lerp(source, target, t) = mid. + */ + static double intercept(double source, double mid, double target) { + if (target == source) { + return target; + } + return (mid - source) / (target - source); + } + + /** + * Linearly interpolate between two points in three dimensions. + * + * @param source three dimensions representing the starting point + * @param t the percentage to travel between source and target, from 0 to 1 + * @param target three dimensions representing the end point + * @return three dimensions representing the point t percent from source to target. + */ + static double[] lerpPoint(double[] source, double t, double[] target) { + return new double[] { + source[0] + (target[0] - source[0]) * t, + source[1] + (target[1] - source[1]) * t, + source[2] + (target[2] - source[2]) * t, + }; + } + + /** + * Intersects a segment with a plane. + * + * @param source The coordinates of point A. + * @param coordinate The R-, G-, or B-coordinate of the plane. + * @param target The coordinates of point B. + * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) + * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, + * or B=coordinate + */ + static double[] setCoordinate(double[] source, double coordinate, double[] target, int axis) { + double t = intercept(source[axis], coordinate, target[axis]); + return lerpPoint(source, t, target); + } + + /** Ensure X is between 0 and 100. */ + static boolean isBounded(double x) { + return 0.0 <= x && x <= 100.0; + } + + /** + * Returns the nth possible vertex of the polygonal intersection. + * + * @param y The Y value of the plane. + * @param n The zero-based index of the point. 0 <= n <= 11. + * @return The nth possible vertex of the polygonal intersection of the y plane and the RGB cube + * in linear RGB coordinates, if it exists. If the possible vertex lies outside of the cube, + * [-1.0, -1.0, -1.0] is returned. + */ + static double[] nthVertex(double y, int n) { + double kR = Y_FROM_LINRGB[0]; + double kG = Y_FROM_LINRGB[1]; + double kB = Y_FROM_LINRGB[2]; + double coordA = n % 4 <= 1 ? 0.0 : 100.0; + double coordB = n % 2 == 0 ? 0.0 : 100.0; + if (n < 4) { + double g = coordA; + double b = coordB; + double r = (y - g * kG - b * kB) / kR; + if (isBounded(r)) { + return new double[] {r, g, b}; + } else { + return new double[] {-1.0, -1.0, -1.0}; + } + } else if (n < 8) { + double b = coordA; + double r = coordB; + double g = (y - r * kR - b * kB) / kG; + if (isBounded(g)) { + return new double[] {r, g, b}; + } else { + return new double[] {-1.0, -1.0, -1.0}; + } + } else { + double r = coordA; + double g = coordB; + double b = (y - r * kR - g * kG) / kB; + if (isBounded(b)) { + return new double[] {r, g, b}; + } else { + return new double[] {-1.0, -1.0, -1.0}; + } + } + } + + /** + * Finds the segment containing the desired color. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of + * the segment containing the desired color. + */ + static double[][] bisectToSegment(double y, double targetHue) { + double[] left = new double[] {-1.0, -1.0, -1.0}; + double[] right = left; + double leftHue = 0.0; + double rightHue = 0.0; + boolean initialized = false; + boolean uncut = true; + for (int n = 0; n < 12; n++) { + double[] mid = nthVertex(y, n); + if (mid[0] < 0) { + continue; + } + double midHue = hueOf(mid); + if (!initialized) { + left = mid; + right = mid; + leftHue = midHue; + rightHue = midHue; + initialized = true; + continue; + } + if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { + uncut = false; + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid; + rightHue = midHue; + } else { + left = mid; + leftHue = midHue; + } + } + } + return new double[][] {left, right}; + } + + static int criticalPlaneBelow(double x) { + return (int) Math.floor(x - 0.5); + } + + static int criticalPlaneAbove(double x) { + return (int) Math.ceil(x - 0.5); + } + + /** + * Finds a color with the given Y and hue on the boundary of the cube. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return The desired color, in linear RGB coordinates. + */ + static int bisectToLimit(double y, double targetHue) { + double[][] segment = bisectToSegment(y, targetHue); + double[] left = segment[0]; + double leftHue = hueOf(left); + double[] right = segment[1]; + for (int axis = 0; axis < 3; axis++) { + if (left[axis] != right[axis]) { + int lPlane = -1; + int rPlane = 255; + if (left[axis] < right[axis]) { + lPlane = criticalPlaneBelow(trueDelinearized(left[axis])); + rPlane = criticalPlaneAbove(trueDelinearized(right[axis])); + } else { + lPlane = criticalPlaneAbove(trueDelinearized(left[axis])); + rPlane = criticalPlaneBelow(trueDelinearized(right[axis])); + } + for (int i = 0; i < 8; i++) { + if (Math.abs(rPlane - lPlane) <= 1) { + break; + } else { + int mPlane = (int) Math.floor((lPlane + rPlane) / 2.0); + double midPlaneCoordinate = CRITICAL_PLANES[mPlane]; + double[] mid = setCoordinate(left, midPlaneCoordinate, right, axis); + double midHue = hueOf(mid); + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid; + rPlane = mPlane; + } else { + left = mid; + leftHue = midHue; + lPlane = mPlane; + } + } + } + } + } + return CamUtils.argbFromLinrgbComponents((left[0] + right[0]) / 2, + (left[1] + right[1]) / 2, (left[2] + right[2]) / 2); + } + + /** Equation used in CAM16 conversion that removes the effect of chromatic adaptation. */ + static double inverseChromaticAdaptation(double adapted) { + double adaptedAbs = Math.abs(adapted); + double base = Math.max(0, 27.13 * adaptedAbs / (400.0 - adaptedAbs)); + return CamUtils.signum(adapted) * Math.pow(base, 1.0 / 0.42); + } + + /** + * Finds a color with the given hue, chroma, and Y. + * + * @param hueRadians The desired hue in radians. + * @param chroma The desired chroma. + * @param y The desired Y. + * @return The desired color as a hexadecimal integer, if found; 0 otherwise. + */ + static int findResultByJ(double hueRadians, double chroma, double y) { + // Initial estimate of j. + double j = Math.sqrt(y) * 11.0; + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + Frame viewingConditions = Frame.DEFAULT; + double tInnerCoeff = 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73); + double eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8); + double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() + * viewingConditions.getNcb(); + double hSin = Math.sin(hueRadians); + double hCos = Math.cos(hueRadians); + for (int iterationRound = 0; iterationRound < 5; iterationRound++) { + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + double jNormalized = j / 100.0; + double alpha = chroma == 0.0 || j == 0.0 ? 0.0 : chroma / Math.sqrt(jNormalized); + double t = Math.pow(alpha * tInnerCoeff, 1.0 / 0.9); + double acExponent = 1.0 / viewingConditions.getC() / viewingConditions.getZ(); + double ac = viewingConditions.getAw() * Math.pow(jNormalized, acExponent); + double p2 = ac / viewingConditions.getNbb(); + double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin); + double a = gamma * hCos; + double b = gamma * hSin; + double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; + double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; + double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; + double rCScaled = inverseChromaticAdaptation(rA); + double gCScaled = inverseChromaticAdaptation(gA); + double bCScaled = inverseChromaticAdaptation(bA); + double[][] matrix = LINRGB_FROM_SCALED_DISCOUNT; + double linrgbR = rCScaled * matrix[0][0] + gCScaled * matrix[0][1] + + bCScaled * matrix[0][2]; + double linrgbG = rCScaled * matrix[1][0] + gCScaled * matrix[1][1] + + bCScaled * matrix[1][2]; + double linrgbB = rCScaled * matrix[2][0] + gCScaled * matrix[2][1] + + bCScaled * matrix[2][2]; + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + if (linrgbR < 0 || linrgbG < 0 || linrgbB < 0) { + return 0; + } + double kR = Y_FROM_LINRGB[0]; + double kG = Y_FROM_LINRGB[1]; + double kB = Y_FROM_LINRGB[2]; + double fnj = kR * linrgbR + kG * linrgbG + kB * linrgbB; + if (fnj <= 0) { + return 0; + } + if (iterationRound == 4 || Math.abs(fnj - y) < 0.002) { + if (linrgbR > 100.01 || linrgbG > 100.01 || linrgbB > 100.01) { + return 0; + } + return CamUtils.argbFromLinrgbComponents(linrgbR, linrgbG, linrgbB); + } + // Iterates with Newton method, + // Using 2 * fn(j) / j as the approximation of fn'(j) + j = j - (fnj - y) * j / (2 * fnj); + } + return 0; + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return A hexadecimal representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + public static int solveToInt(double hueDegrees, double chroma, double lstar) { + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { + return CamUtils.argbFromLstar(lstar); + } + hueDegrees = sanitizeDegreesDouble(hueDegrees); + double hueRadians = Math.toRadians(hueDegrees); + double y = CamUtils.yFromLstar(lstar); + int exactAnswer = findResultByJ(hueRadians, chroma, y); + if (exactAnswer != 0) { + return exactAnswer; + } + return bisectToLimit(y, hueRadians); + } + + /** + * Sanitizes a degree measure as a floating-point number. + * + * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). + */ + public static double sanitizeDegreesDouble(double degrees) { + degrees = degrees % 360.0; + if (degrees < 0) { + degrees = degrees + 360.0; + } + return degrees; + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return An CAM16 object representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + public static Cam solveToCam(double hueDegrees, double chroma, double lstar) { + return Cam.fromInt(solveToInt(hueDegrees, chroma, lstar)); + } +} diff --git a/core/tests/coretests/src/android/colormodel/CamTest.java b/core/tests/coretests/src/android/colormodel/CamTest.java index a70ecd720dde8..5bcc5930e9a65 100644 --- a/core/tests/coretests/src/android/colormodel/CamTest.java +++ b/core/tests/coretests/src/android/colormodel/CamTest.java @@ -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); + } + } } diff --git a/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt b/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt index 0c82022609074..beba0ee61dfef 100644 --- a/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt +++ b/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt @@ -21,8 +21,8 @@ import android.app.WallpaperColors import android.graphics.Color import com.android.internal.graphics.ColorUtils import com.android.internal.graphics.cam.Cam +import com.android.internal.graphics.cam.CamUtils import kotlin.math.absoluteValue -import kotlin.math.max import kotlin.math.roundToInt const val TAG = "ColorScheme" @@ -43,13 +43,13 @@ internal interface Hue { * second item in the pair is a hue rotation that should be applied */ fun getHueRotation(sourceHue: Float, hueAndRotations: List>): Double { - for (i in 0..hueAndRotations.size) { - val previousIndex = if (i == 0) hueAndRotations.size - 1 else i - 1 - val thisHue = hueAndRotations[i].first - val previousHue = hueAndRotations[previousIndex].first - if (ColorScheme.angleIsBetween(sourceHue, thisHue, previousHue)) { - return ColorScheme.wrapDegreesDouble(sourceHue.toDouble() + - hueAndRotations[previousIndex].second) + val sanitizedSourceHue = (if (sourceHue < 0 || sourceHue >= 360) 0 else sourceHue).toFloat() + for (i in 0..hueAndRotations.size - 2) { + val thisHue = hueAndRotations[i].first.toFloat() + val nextHue = hueAndRotations[i + 1].first.toFloat() + if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) { + return ColorScheme.wrapDegreesDouble(sanitizedSourceHue.toDouble() + + hueAndRotations[i].second) } } @@ -79,7 +79,7 @@ internal class HueSubtract(val amountDegrees: Double) : Hue { internal class HueVibrantSecondary() : Hue { val hueToRotations = listOf(Pair(0, 18), Pair(41, 15), Pair(61, 10), Pair(101, 12), - Pair(131, 15), Pair(181, 18), Pair(251, 15), Pair(301, 12)) + Pair(131, 15), Pair(181, 18), Pair(251, 15), Pair(301, 12), Pair(360, 12)) override fun get(sourceColor: Cam): Double { return getHueRotation(sourceColor.hue, hueToRotations) } @@ -87,7 +87,7 @@ internal class HueVibrantSecondary() : Hue { internal class HueVibrantTertiary() : Hue { val hueToRotations = listOf(Pair(0, 35), Pair(41, 30), Pair(61, 20), Pair(101, 25), - Pair(131, 30), Pair(181, 35), Pair(251, 30), Pair(301, 25)) + Pair(131, 30), Pair(181, 35), Pair(251, 30), Pair(301, 25), Pair(360, 25)) override fun get(sourceColor: Cam): Double { return getHueRotation(sourceColor.hue, hueToRotations) } @@ -95,7 +95,7 @@ internal class HueVibrantTertiary() : Hue { internal class HueExpressiveSecondary() : Hue { val hueToRotations = listOf(Pair(0, 45), Pair(21, 95), Pair(51, 45), Pair(121, 20), - Pair(141, 45), Pair(191, 90), Pair(271, 45), Pair(321, 45)) + Pair(151, 45), Pair(191, 90), Pair(271, 45), Pair(321, 45), Pair(360, 45)) override fun get(sourceColor: Cam): Double { return getHueRotation(sourceColor.hue, hueToRotations) } @@ -103,7 +103,7 @@ internal class HueExpressiveSecondary() : Hue { internal class HueExpressiveTertiary() : Hue { val hueToRotations = listOf(Pair(0, 120), Pair(21, 120), Pair(51, 20), Pair(121, 45), - Pair(141, 20), Pair(191, 15), Pair(271, 20), Pair(321, 120)) + Pair(151, 20), Pair(191, 15), Pair(271, 20), Pair(321, 120), Pair(360, 120)) override fun get(sourceColor: Cam): Double { return getHueRotation(sourceColor.hue, hueToRotations) } @@ -111,34 +111,13 @@ internal class HueExpressiveTertiary() : Hue { internal interface Chroma { fun get(sourceColor: Cam): Double - - /** - * Given a hue, and a mapping of hues to hue rotations, find which hues in the mapping the - * hue fall betweens, and use the hue rotation of the lower hue. - * - * @param sourceHue hue of source color - * @param hueAndChromas list of pairs, where the first item in a pair is a hue, and the - * second item in the pair is a chroma that should be applied - */ - fun getSpecifiedChroma(sourceHue: Float, hueAndChromas: List>): Double { - for (i in 0..hueAndChromas.size) { - val previousIndex = if (i == 0) hueAndChromas.size - 1 else i - 1 - val thisHue = hueAndChromas[i].first - val previousHue = hueAndChromas[previousIndex].first - if (ColorScheme.angleIsBetween(sourceHue, thisHue, previousHue)) { - return hueAndChromas[i].second.toDouble() - } - } - - // If this statement executes, something is wrong, there should have been a rotation - // found using the arrays. - return sourceHue.toDouble() - } } -internal class ChromaMinimum(val chroma: Double) : Chroma { +internal class ChromaMaxOut : Chroma { override fun get(sourceColor: Cam): Double { - return max(sourceColor.chroma.toDouble(), chroma) + // Intentionally high. Gamut mapping from impossible HCT to sRGB will ensure that + // the maximum chroma is reached, even if lower than this constant. + return 130.0 } } @@ -192,11 +171,11 @@ enum class Style(internal val coreSpec: CoreSpec) { n2 = TonalSpec(HueSource(), ChromaConstant(8.0)) )), VIBRANT(CoreSpec( - a1 = TonalSpec(HueSource(), ChromaMinimum(48.0)), + a1 = TonalSpec(HueSource(), ChromaMaxOut()), a2 = TonalSpec(HueVibrantSecondary(), ChromaConstant(24.0)), a3 = TonalSpec(HueVibrantTertiary(), ChromaConstant(32.0)), - n1 = TonalSpec(HueSource(), ChromaConstant(12.0)), - n2 = TonalSpec(HueSource(), ChromaConstant(14.0)) + n1 = TonalSpec(HueSource(), ChromaConstant(8.0)), + n2 = TonalSpec(HueSource(), ChromaConstant(12.0)) )), EXPRESSIVE(CoreSpec( a1 = TonalSpec(HueAdd(240.0), ChromaConstant(40.0)), @@ -229,7 +208,7 @@ enum class Style(internal val coreSpec: CoreSpec) { } class ColorScheme( - @ColorInt seed: Int, + @ColorInt val seed: Int, val darkTheme: Boolean, val style: Style = Style.TONAL_SPOT ) { @@ -293,12 +272,14 @@ class ColorScheme( override fun toString(): String { return "ColorScheme {\n" + - " neutral1: ${humanReadable(neutral1)}\n" + - " neutral2: ${humanReadable(neutral2)}\n" + - " accent1: ${humanReadable(accent1)}\n" + - " accent2: ${humanReadable(accent2)}\n" + - " accent3: ${humanReadable(accent3)}\n" + + " seed color: ${stringForColor(seed)}\n" + " style: $style\n" + + " palettes: \n" + + " ${humanReadable("PRIMARY", accent1)}\n" + + " ${humanReadable("SECONDARY", accent2)}\n" + + " ${humanReadable("TERTIARY", accent3)}\n" + + " ${humanReadable("NEUTRAL", neutral1)}\n" + + " ${humanReadable("NEUTRAL VARIANT", neutral2)}\n" + "}" } @@ -416,13 +397,6 @@ class ColorScheme( return seeds } - internal fun angleIsBetween(angle: Float, a: Int, b: Int): Boolean { - if (a < b) { - return a <= angle && angle <= b - } - return a <= angle || angle <= b - } - private fun wrapDegrees(degrees: Int): Int { return when { degrees < 0 -> { @@ -455,8 +429,20 @@ class ColorScheme( return 180f - ((a - b).absoluteValue - 180f).absoluteValue } - private fun humanReadable(colors: List): String { - return colors.joinToString { "#" + Integer.toHexString(it) } + private fun stringForColor(color: Int): String { + val width = 4 + val hct = Cam.fromInt(color) + val h = "H${hct.hue.roundToInt().toString().padEnd(width)}" + val c = "C${hct.chroma.roundToInt().toString().padEnd(width)}" + val t = "T${CamUtils.lstarFromInt(color).roundToInt().toString().padEnd(width)}" + val hex = Integer.toHexString(color).replaceRange(0, 2, "").uppercase() + return "$h$c$t = #$hex" + } + + private fun humanReadable(paletteName: String, colors: List): String { + return "$paletteName\n" + colors.map { + stringForColor(it) + }.joinToString(separator = "\n") { it } } private fun score(cam: Cam, proportion: Double): Double { diff --git a/packages/SystemUI/tests/src/com/android/systemui/monet/ColorSchemeTest.java b/packages/SystemUI/tests/src/com/android/systemui/monet/ColorSchemeTest.java index 890e4de118e3f..63dca3bafc5b0 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/monet/ColorSchemeTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/monet/ColorSchemeTest.java @@ -69,7 +69,7 @@ public class ColorSchemeTest extends SysuiTestCase { // Expressive applies hue rotations to the theme color. The input theme color has hue // 117, ensuring the hue changed significantly is a strong signal styles are being applied. ColorScheme colorScheme = new ColorScheme(wallpaperColors, false, Style.EXPRESSIVE); - Assert.assertEquals(Cam.fromInt(colorScheme.getAccent1().get(6)).getHue(), 357.46, 0.1); + Assert.assertEquals(357.77, Cam.fromInt(colorScheme.getAccent1().get(6)).getHue(), 0.1); }