public class

CamColor

extends java.lang.Object

 java.lang.Object

↳androidx.core.content.res.CamColor

Gradle dependencies

compile group: 'androidx.core', name: 'core', version: '1.15.0-alpha02'

  • groupId: androidx.core
  • artifactId: core
  • version: 1.15.0-alpha02

Artifact androidx.core:core:1.15.0-alpha02 it located at Google repository (https://maven.google.com/)

Androidx artifact mapping:

androidx.core:core com.android.support:support-compat

Overview

A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and coupled to a gamut mapping algorithm. Creates a color system, enables a digital design system.

Summary

Methods
public static voidgetM3HCTfromColor(int color, float[] outM3HCT[])

Get the values for M3HCT color from ARGB color.

public static inttoColor(float hue, float chroma, float lStar)

Given a hue & chroma in CAM16, L* in L*a*b*, return an ARGB integer.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public static int toColor(float hue, float chroma, float lStar)

Given a hue & chroma in CAM16, L* in L*a*b*, return an ARGB integer. The chroma of the color returned may, and frequently will, be lower than requested. Assumes the color is viewed in the default ViewingConditions.

public static void getM3HCTfromColor(int color, float[] outM3HCT[])

Get the values for M3HCT color from ARGB color. HCT color space is a new color space proposed in Material Design 3

Parameters:

color: is the ARGB color value we use to get its respective M3HCT values.
outM3HCT: 3-element array which holds the resulting M3HCT components (Hue, Chroma, Tone).

See also:

Source

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.core.content.res;

import androidx.annotation.ColorInt;
import androidx.annotation.FloatRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.Size;
import androidx.core.graphics.ColorUtils;

/**
 * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and
 * coupled to a gamut mapping algorithm. Creates a color system, enables a digital design system.
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class CamColor {
    // The maximum difference between the requested L* and the L* returned.
    private static final float DL_MAX = 0.2f;
    // The maximum color distance, in CAM16-UCS, between a requested color and the color returned.
    private static final float DE_MAX = 1.0f;
    // When the delta between the floor & ceiling of a binary search for chroma is less than this,
    // the binary search terminates.
    private static final float CHROMA_SEARCH_ENDPOINT = 0.4f;
    // When the delta between the floor & ceiling of a binary search for J, lightness in CAM16,
    // is less than this, the binary search terminates.
    private static final float LIGHTNESS_SEARCH_ENDPOINT = 0.01f;

    // CAM16 color dimensions, see getters for documentation.
    private final float mHue;
    private final float mChroma;
    private final float mJ;
    private final float mQ;
    private final float mM;
    private final float mS;

    // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*.
    private final float mJstar;
    private final float mAstar;
    private final float mBstar;

    /** Hue in CAM16 */
    @FloatRange(from = 0.0, to = 360.0, toInclusive = false)
    float getHue() {
        return mHue;
    }

    /** Chroma in CAM16 */
    @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
    float getChroma() {
        return mChroma;
    }

    /** Lightness in CAM16 */
    @FloatRange(from = 0.0, to = 100.0)
    float getJ() {
        return mJ;
    }

    /**
     * Brightness in CAM16.
     *
     * <p>Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper
     * is much brighter viewed in sunlight than in indoor light, but it is the lightest object under
     * any lighting.
     */
    @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
    float getQ() {
        return mQ;
    }

    /**
     * Colorfulness in CAM16.
     *
     * <p>Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much
     * more colorful outside than inside, but it has the same chroma in both environments.
     */
    @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
    float getM() {
        return mM;
    }

    /**
     * Saturation in CAM16.
     *
     * <p>Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness
     * relative to the color's own brightness, where chroma is colorfulness relative to white.
     */
    @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
    float getS() {
        return mS;
    }

    /** Lightness coordinate in CAM16-UCS */
    @FloatRange(from = 0.0, to = 100.0)
    float getJStar() {
        return mJstar;
    }

    /** a* coordinate in CAM16-UCS */
    @FloatRange(from = Double.NEGATIVE_INFINITY, to = Double.POSITIVE_INFINITY, fromInclusive =
            false, toInclusive = false)
    float getAStar() {
        return mAstar;
    }

    /** b* coordinate in CAM16-UCS */
    @FloatRange(from = Double.NEGATIVE_INFINITY, to = Double.POSITIVE_INFINITY, fromInclusive =
            false, toInclusive = false)
    float getBStar() {
        return mBstar;
    }

    /** Construct a CAM16 color */
    CamColor(float hue, float chroma, float j, float q, float m, float s, float jStar, float aStar,
            float bStar) {
        mHue = hue;
        mChroma = chroma;
        mJ = j;
        mQ = q;
        mM = m;
        mS = s;
        mJstar = jStar;
        mAstar = aStar;
        mBstar = bStar;
    }

    /**
     * Given a hue & chroma in CAM16, L* in L*a*b*, return an ARGB integer. The chroma of the color
     * returned may, and frequently will, be lower than requested. Assumes the color is viewed in
     * the default ViewingConditions.
     */
    public static int toColor(@FloatRange(from = 0.0, to = 360.0) float hue,
            @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
                    float chroma,
            @FloatRange(from = 0.0, to = 100.0) float lStar) {
        return toColor(hue, chroma, lStar, ViewingConditions.DEFAULT);
    }

    /**
     * Create a color appearance model from a ARGB integer representing a color. It is assumed the
     * color was viewed in the default ViewingConditions.
     *
     * The alpha component is ignored, CamColor only represents opaque colors.
     */
    @NonNull
    static CamColor fromColor(@ColorInt int color) {
        float[] outCamColor = new float[7];
        float[] outM3HCT = new float[3];
        fromColorInViewingConditions(color, ViewingConditions.DEFAULT, outCamColor, outM3HCT);
        return new CamColor(outM3HCT[0], outM3HCT[1], outCamColor[0], outCamColor[1],
                outCamColor[2], outCamColor[3], outCamColor[4], outCamColor[5], outCamColor[6]);
    }

    /**
     *
     * Get the values for M3HCT color from ARGB color.
     *
     * HCT color space is a new color space proposed in Material Design 3
     * @see
     * <a href="https://developer.android.com/design/ui/mobile/guides/styles/color#about-color-spaces">About Color Spaces</a>
     *
     *<ul>
     *<li>outM3HCT[0] is Hue in M3HCT [0, 360); invalid values are corrected.</li>
     *<li>outM3HCT[1] is Chroma in M3HCT [0, ?); Chroma may decrease because chroma has a
     *different maximum for any given hue and tone.</li>
     *<li>outM3HCT[2] is Tone in M3HCT [0, 100]; invalid values are corrected.</li>
     *</ul>
     *
     *@param color is the ARGB color value we use to get its respective M3HCT values.
     *@param outM3HCT 3-element array which holds the resulting M3HCT components (Hue,
     *      Chroma, Tone).
     */
    public static void getM3HCTfromColor(@ColorInt int color,
            @NonNull @Size(3) float[] outM3HCT) {
        fromColorInViewingConditions(color, ViewingConditions.DEFAULT, null, outM3HCT);
        outM3HCT[2] = CamUtils.lStarFromInt(color);
    }

    /**
     * Create a color appearance model from a ARGB integer representing a color, specifying the
     * ViewingConditions in which the color was viewed. Prefer Cam.fromColor.
     */
    static void fromColorInViewingConditions(@ColorInt int color,
            @NonNull ViewingConditions viewingConditions, @Nullable @Size(7) float[] outCamColor,
            @NonNull @Size(3) float[] outM3HCT) {
        // Transform ARGB int to XYZ, reusing outM3HCT array to avoid a new allocation.
        CamUtils.xyzFromInt(color, outM3HCT);
        float[] xyz = outM3HCT;

        // Transform XYZ to 'cone'/'rgb' responses
        float[][] matrix = CamUtils.XYZ_TO_CAM16RGB;
        float rT = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]);
        float gT = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]);
        float bT = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]);

        // Discount illuminant
        float rD = viewingConditions.getRgbD()[0] * rT;
        float gD = viewingConditions.getRgbD()[1] * gT;
        float bD = viewingConditions.getRgbD()[2] * bT;

        // Chromatic adaptation
        float rAF = (float) Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42);
        float gAF = (float) Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42);
        float bAF = (float) Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42);
        float rA = Math.signum(rD) * 400.0f * rAF / (rAF + 27.13f);
        float gA = Math.signum(gD) * 400.0f * gAF / (gAF + 27.13f);
        float bA = Math.signum(bD) * 400.0f * bAF / (bAF + 27.13f);

        // redness-greenness
        float a = (float) (11.0 * rA + -12.0 * gA + bA) / 11.0f;
        // yellowness-blueness
        float b = (float) (rA + gA - 2.0 * bA) / 9.0f;

        // auxiliary components
        float u = (20.0f * rA + 20.0f * gA + 21.0f * bA) / 20.0f;
        float p2 = (40.0f * rA + 20.0f * gA + bA) / 20.0f;

        // hue
        float atan2 = (float) Math.atan2(b, a);
        float atanDegrees = atan2 * 180.0f / (float) Math.PI;
        float hue =
                atanDegrees < 0
                        ? atanDegrees + 360.0f
                        : atanDegrees >= 360 ? atanDegrees - 360.0f : atanDegrees;
        float hueRadians = hue * (float) Math.PI / 180.0f;

        // achromatic response to color
        float ac = p2 * viewingConditions.getNbb();

        // CAM16 lightness and brightness
        float j = 100.0f * (float) Math.pow(ac / viewingConditions.getAw(),
                viewingConditions.getC() * viewingConditions.getZ());
        float q =
                4.0f
                        / viewingConditions.getC()
                        * (float) Math.sqrt(j / 100.0f)
                        * (viewingConditions.getAw() + 4.0f)
                        * viewingConditions.getFlRoot();

        // CAM16 chroma, colorfulness, and saturation.
        float huePrime = (hue < 20.14) ? hue + 360 : hue;
        float eHue = 0.25f * (float) (Math.cos(huePrime * Math.PI / 180.0 + 2.0) + 3.8);
        float p1 = 50000.0f / 13.0f * eHue * viewingConditions.getNc() * viewingConditions.getNcb();
        float t = p1 * (float) Math.sqrt(a * a + b * b) / (u + 0.305f);
        float alpha = (float) Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73)
                * (float) Math.pow(t, 0.9);
        // CAM16 chroma, colorfulness, saturation
        float c = alpha * (float) Math.sqrt(j / 100.0);
        float m = c * viewingConditions.getFlRoot();
        float s = 50.0f * (float) Math.sqrt((alpha * viewingConditions.getC()) / (
                viewingConditions.getAw() + 4.0f));

        // CAM16-UCS components
        float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j);
        float mstar = 1.0f / 0.0228f * (float) Math.log(1.0f + 0.0228f * m);
        float astar = mstar * (float) Math.cos(hueRadians);
        float bstar = mstar * (float) Math.sin(hueRadians);


        outM3HCT[0] = hue;
        outM3HCT[1] = c;

        if (outCamColor != null) {
            outCamColor[0] = j;
            outCamColor[1] = q;
            outCamColor[2] = m;
            outCamColor[3] = s;
            outCamColor[4] = jstar;
            outCamColor[5] = astar;
            outCamColor[6] = bstar;
        }
    }

    /**
     * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates
     * were measured in the default ViewingConditions.
     */
    @NonNull
    private static CamColor fromJch(@FloatRange(from = 0.0, to = 100.0) float j,
            @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false) float c,
            @FloatRange(from = 0.0, to = 360.0) float h) {
        return fromJchInFrame(j, c, h, ViewingConditions.DEFAULT);
    }

    /**
     * Create a CAM from lightness, chroma, and hue coordinates, and also specify the
     * ViewingConditions where the color was seen.
     */
    @NonNull
    private static CamColor fromJchInFrame(@FloatRange(from = 0.0, to = 100.0) float j,
            @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false) float c,
            @FloatRange(from = 0.0, to = 360.0) float h, ViewingConditions viewingConditions) {
        float q =
                4.0f
                        / viewingConditions.getC()
                        * (float) Math.sqrt(j / 100.0)
                        * (viewingConditions.getAw() + 4.0f)
                        * viewingConditions.getFlRoot();
        float m = c * viewingConditions.getFlRoot();
        float alpha = c / (float) Math.sqrt(j / 100.0);
        float s = 50.0f * (float) Math.sqrt((alpha * viewingConditions.getC()) / (
                viewingConditions.getAw() + 4.0f));

        float hueRadians = h * (float) Math.PI / 180.0f;
        float jstar = (1.0f + 100.0f * 0.007f) * j / (1.0f + 0.007f * j);
        float mstar = 1.0f / 0.0228f * (float) Math.log(1.0 + 0.0228 * m);
        float astar = mstar * (float) Math.cos(hueRadians);
        float bstar = mstar * (float) Math.sin(hueRadians);
        return new CamColor(h, c, j, q, m, s, jstar, astar, bstar);
    }

    /**
     * Distance in CAM16-UCS space between two colors.
     *
     * <p>Much like L*a*b* was designed to measure distance between colors, the CAM16 standard
     * defined a color space called CAM16-UCS to measure distance between CAM16 colors.
     */
    float distance(@NonNull CamColor other) {
        float dJ = getJStar() - other.getJStar();
        float dA = getAStar() - other.getAStar();
        float dB = getBStar() - other.getBStar();
        double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB);
        double dE = 1.41 * Math.pow(dEPrime, 0.63);
        return (float) dE;
    }

    /** Returns perceived color as an ARGB integer, as viewed in default ViewingConditions. */
    @ColorInt
    int viewedInSrgb() {
        return viewed(ViewingConditions.DEFAULT);
    }

    /** Returns color perceived in a ViewingConditions as an ARGB integer. */
    @ColorInt
    int viewed(@NonNull ViewingConditions viewingConditions) {
        float alpha =
                (getChroma() == 0.0 || getJ() == 0.0)
                        ? 0.0f
                        : getChroma() / (float) Math.sqrt(getJ() / 100.0);

        float t = (float) Math.pow(alpha / Math.pow(1.64
                - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9);
        float hRad = getHue() * (float) Math.PI / 180.0f;

        float eHue = 0.25f * (float) (Math.cos(hRad + 2.0) + 3.8);
        float ac = viewingConditions.getAw() * (float) Math.pow(getJ() / 100.0,
                1.0 / viewingConditions.getC() / viewingConditions.getZ());
        float p1 =
                eHue * (50000.0f / 13.0f) * viewingConditions.getNc() * viewingConditions.getNcb();
        float p2 = (ac / viewingConditions.getNbb());

        float hSin = (float) Math.sin(hRad);
        float hCos = (float) Math.cos(hRad);

        float gamma =
                23.0f * (p2 + 0.305f) * t / (23.0f * p1 + 11.0f * t * hCos + 108.0f * t * hSin);
        float a = gamma * hCos;
        float b = gamma * hSin;
        float rA = (460.0f * p2 + 451.0f * a + 288.0f * b) / 1403.0f;
        float gA = (460.0f * p2 - 891.0f * a - 261.0f * b) / 1403.0f;
        float bA = (460.0f * p2 - 220.0f * a - 6300.0f * b) / 1403.0f;

        float rCBase = (float) Math.max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA)));
        float rC = Math.signum(rA) * (100.0f / viewingConditions.getFl()) * (float) Math.pow(rCBase,
                1.0 / 0.42);
        float gCBase = (float) Math.max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA)));
        float gC = Math.signum(gA) * (100.0f / viewingConditions.getFl()) * (float) Math.pow(gCBase,
                1.0 / 0.42);
        float bCBase = (float) Math.max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA)));
        float bC = Math.signum(bA) * (100.0f / viewingConditions.getFl()) * (float) Math.pow(bCBase,
                1.0 / 0.42);
        float rF = rC / viewingConditions.getRgbD()[0];
        float gF = gC / viewingConditions.getRgbD()[1];
        float bF = bC / viewingConditions.getRgbD()[2];


        float[][] matrix = CamUtils.CAM16RGB_TO_XYZ;
        float x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]);
        float y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]);
        float z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]);

        int argb = ColorUtils.XYZToColor(x, y, z);
        return argb;
    }

    /**
     * Given a hue & chroma in CAM16, L* in L*a*b*, and the ViewingConditions in which the
     * color will be viewed, return an ARGB integer.
     *
     * <p>The chroma of the color returned may, and frequently will, be lower than requested. This
     * is a fundamental property of color that cannot be worked around by engineering. For example,
     * a red hue, with high chroma, and high L* does not exist: red hues have a maximum chroma
     * below 10 in light shades, creating pink.
     */
    static @ColorInt int toColor(@FloatRange(from = 0.0, to = 360.0) float hue,
            @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
                    float chroma,
            @FloatRange(from = 0.0, to = 100.0) float lstar,
            @NonNull ViewingConditions viewingConditions) {
        // This is a crucial routine for building a color system, CAM16 itself is not sufficient.
        //
        // * Why these dimensions?
        // Hue and chroma from CAM16 are used because they're the most accurate measures of those
        // quantities. L* from L*a*b* is used because it correlates with luminance, luminance is
        // used to measure contrast for a11y purposes, thus providing a key constraint on what
        // colors
        // can be used.
        //
        // * Why is this routine required to build a color system?
        // In all perceptually accurate color spaces (i.e. L*a*b* and later), `chroma` may be
        // impossible for a given `hue` and `lstar`.
        // For example, a high chroma light red does not exist - chroma is limited to below 10 at
        // light red shades, we call that pink. High chroma light green does exist, but not dark
        // Also, when converting from another color space to RGB, the color may not be able to be
        // represented in RGB. In those cases, the conversion process ends with RGB values
        // outside 0-255
        // The vast majority of color libraries surveyed simply round to 0 to 255. That is not an
        // option for this library, as it distorts the expected luminance, and thus the expected
        // contrast needed for a11y
        //
        // * What does this routine do?
        // Dealing with colors in one color space not fitting inside RGB is, loosely referred to as
        // gamut mapping or tone mapping. These algorithms are traditionally idiosyncratic, there is
        // no universal answer. However, because the intent of this library is to build a system for
        // digital design, and digital design uses luminance to measure contrast/a11y, we have one
        // very important constraint that leads to an objective algorithm: the L* of the returned
        // color _must_ match the requested L*.
        //
        // Intuitively, if the color must be distorted to fit into the RGB gamut, and the L*
        // requested *must* be fulfilled, than the hue or chroma of the returned color will need
        // to be different from the requested hue/chroma.
        //
        // After exploring both options, it was more intuitive that if the requested chroma could
        // not be reached, it used the highest possible chroma. The alternative was finding the
        // closest hue where the requested chroma could be reached, but that is not nearly as
        // intuitive, as the requested hue is so fundamental to the color description.

        // If the color doesn't have meaningful chroma, return a gray with the requested Lstar.
        //
        // Yellows are very chromatic at L = 100, and blues are very chromatic at L = 0. All the
        // other hues are white at L = 100, and black at L = 0. To preserve consistency for users of
        // this system, it is better to simply return white at L* > 99, and black and L* < 0.
        if (chroma < 1.0 || Math.round(lstar) <= 0.0 || Math.round(lstar) >= 100.0) {
            return CamUtils.intFromLStar(lstar);
        }

        hue = hue < 0 ? 0 : Math.min(360, hue);

        // The highest chroma possible. Updated as binary search proceeds.
        float high = chroma;

        // The guess for the current binary search iteration. Starts off at the highest chroma,
        // thus, if a color is possible at the requested chroma, the search can stop after one try.
        float mid = chroma;
        float low = 0.0f;
        boolean isFirstLoop = true;

        CamColor answer = null;

        while (Math.abs(low - high) >= CHROMA_SEARCH_ENDPOINT) {
            // Given the current chroma guess, mid, and the desired hue, find J, lightness in
            // CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* color space.
            CamColor possibleAnswer = findCamByJ(hue, mid, lstar);

            if (isFirstLoop) {
                if (possibleAnswer != null) {
                    return possibleAnswer.viewed(viewingConditions);
                } else {
                    // If this binary search iteration was the first iteration, and this point
                    // has been reached, it means the requested chroma was not available at the
                    // requested hue and L*.
                    // Proceed to a traditional binary search that starts at the midpoint between
                    // the requested chroma and 0.
                    isFirstLoop = false;
                    mid = low + (high - low) / 2.0f;
                    continue;
                }
            }

            if (possibleAnswer == null) {
                // There isn't a CAM16 J that creates a color with L* `lstar`. Try a lower chroma.
                high = mid;
            } else {
                answer = possibleAnswer;
                // It is possible to create a color. Try higher chroma.
                low = mid;
            }

            mid = low + (high - low) / 2.0f;
        }

        // There was no answer: meaning, for the desired hue, there was no chroma low enough to
        // generate a color with the desired L*.
        // All values of L* are possible when there is 0 chroma. Return a color with 0 chroma, i.e.
        // a shade of gray, with the desired L*.
        if (answer == null) {
            return CamUtils.intFromLStar(lstar);
        }

        return answer.viewed(viewingConditions);
    }

    // Find J, lightness in CAM16 color space, that creates a color with L* = `lstar` in the L*a*b*
    // color space.
    //
    // Returns null if no J could be found that generated a color with L* `lstar`.
    @Nullable
    private static CamColor findCamByJ(@FloatRange(from = 0.0, to = 360.0) float hue,
            @FloatRange(from = 0.0, to = Double.POSITIVE_INFINITY, toInclusive = false)
                    float chroma,
            @FloatRange(from = 0.0, to = 100.0) float lstar) {
        float low = 0.0f;
        float high = 100.0f;
        float mid = 0.0f;
        float bestdL = 1000.0f;
        float bestdE = 1000.0f;

        CamColor bestCam = null;
        while (Math.abs(low - high) > LIGHTNESS_SEARCH_ENDPOINT) {
            mid = low + (high - low) / 2;
            // Create the intended CAM color
            CamColor camBeforeClip = CamColor.fromJch(mid, chroma, hue);
            // Convert the CAM color to RGB. If the color didn't fit in RGB, during the conversion,
            // the initial RGB values will be outside 0 to 255. The final RGB values are clipped to
            // 0 to 255, distorting the intended color.
            int clipped = camBeforeClip.viewedInSrgb();
            float clippedLstar = CamUtils.lStarFromInt(clipped);
            float dL = Math.abs(lstar - clippedLstar);

            // If the clipped color's L* is within error margin...
            if (dL < DL_MAX) {
                // ...check if the CAM equivalent of the clipped color is far away from intended CAM
                // color. For the intended color, use lightness and chroma from the clipped color,
                // and the intended hue. Callers are wondering what the lightness is, they know
                // chroma may be distorted, so the only concern here is if the hue slipped too far.
                CamColor camClipped = CamColor.fromColor(clipped);
                float dE = camClipped.distance(
                        CamColor.fromJch(camClipped.getJ(), camClipped.getChroma(), hue));
                if (dE <= DE_MAX) {
                    bestdL = dL;
                    bestdE = dE;
                    bestCam = camClipped;
                }
            }

            // If there's no error at all, there's no need to search more.
            //
            // Note: this happens much more frequently than expected, but this is a very delicate
            // property which relies on extremely precise sRGB <=> XYZ calculations, as well as fine
            // tuning of the constants that determine error margins and when the binary search can
            // terminate.
            if (bestdL == 0 && bestdE == 0) {
                break;
            }

            if (clippedLstar < lstar) {
                low = mid;
            } else {
                high = mid;
            }
        }

        return bestCam;
    }

}