From c3cffc8bb6e6fd664dd2358bee1cba592b3f691b Mon Sep 17 00:00:00 2001 From: James O'Leary Date: Thu, 26 May 2022 19:30:52 +0000 Subject: [PATCH 1/3] 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); + } + } } From 49fa723e9ca14de06a1789577c9e4f113365133d Mon Sep 17 00:00:00 2001 From: James O'Leary Date: Thu, 26 May 2022 19:52:30 +0000 Subject: [PATCH 2/3] Match latest variant spec; update hue rotation logic - Update a couple rotations to match spec (ex. rotation occurs at 151 degrees instead of 141) - If hue rotation specs have an entry at 0 degrees, they also need an entry at 360 degrees. There's no way to determine that, for example, 321 is between 315 and 0, 360 must be listed. - Find hue rotation in a way that ensures ex. an angle isn't tested for being between 0 and 360 degrees, as that will always return true. Instead, use first element, (always 0 degrees), check if angle is between and the next element, and stop once the next element is 360 degrees (always the last element in the array) - Remove code that became unused after the above change. 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. Create spreadsheet for UX to fill out their spec, then another spreadsheet that contains the dumpsys results for blue/red/yellow/green seed colors across all theme variants, and the preset colors for Ice Cream and Fruit Salad variants. Confirmed the runtime results, as reported by dumpsys, match the spec in all cases. Bug: 213314628 Change-Id: Id35faa294bf82c0e81c2690c65c42218a87c236a --- .../com/android/systemui/monet/ColorScheme.kt | 65 +++++-------------- .../systemui/monet/ColorSchemeTest.java | 2 +- 2 files changed, 19 insertions(+), 48 deletions(-) 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..9df65e3417f52 100644 --- a/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt +++ b/packages/SystemUI/monet/src/com/android/systemui/monet/ColorScheme.kt @@ -22,7 +22,6 @@ import android.graphics.Color import com.android.internal.graphics.ColorUtils import com.android.internal.graphics.cam.Cam import kotlin.math.absoluteValue -import kotlin.math.max import kotlin.math.roundToInt const val TAG = "ColorScheme" @@ -43,13 +42,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 +78,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 +86,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 +94,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 +102,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 +110,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 +170,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)), @@ -416,13 +394,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 -> { 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); } From c4e3d76a03ebaf2d9b5c0a0f24547cef63c2d1e5 Mon Sep 17 00:00:00 2001 From: James O'Leary Date: Thu, 26 May 2022 19:31:19 +0000 Subject: [PATCH 3/3] Update ColorScheme.toString() formatting ThemeOverlayController's is being used by designers for verification. To make that easier, the dumpsys now includes HCT coordinates, hex codes w/o alpha channel, and the seed color, which ColorScheme now stores in an instance variable. Test: adb shell dumpsys activity service com.android.systemui/.SystemUIService ThemeOverlayController; verify format works as intended. Shared with design, got feedback and confirmation this includes all needed info. Change-Id: I058d390b109a27e2277a2a74c1519c7b15a49400 --- .../com/android/systemui/monet/ColorScheme.kt | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 9df65e3417f52..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,6 +21,7 @@ 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.roundToInt @@ -207,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 ) { @@ -271,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" + "}" } @@ -426,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 {