From c3cffc8bb6e6fd664dd2358bee1cba592b3f691b Mon Sep 17 00:00:00 2001 From: James O'Leary Date: Thu, 26 May 2022 19:30:52 +0000 Subject: [PATCH] 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 --- .../android/internal/graphics/cam/Cam.java | 7 + .../internal/graphics/cam/CamUtils.java | 157 +++- .../android/internal/graphics/cam/Frame.java | 20 +- .../internal/graphics/cam/HctSolver.java | 721 ++++++++++++++++++ .../src/android/colormodel/CamTest.java | 33 +- 5 files changed, 914 insertions(+), 24 deletions(-) create mode 100644 core/java/com/android/internal/graphics/cam/HctSolver.java 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); + } + } }