/
Hct.java
266 lines (228 loc) · 8.2 KB
/
Hct.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
/*
* Copyright (C) 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 com.google.android.material.color.utilities;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import androidx.annotation.RestrictTo;
/**
* A color system built using CAM16 hue and chroma, and L* from L*a*b*.
*
* <p>Using L* creates a link between the color system, contrast, and thus accessibility. Contrast
* ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can
* be calculated from Y.
*
* <p>Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones.
*
* <p>Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A
* difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50
* guarantees a contrast ratio >= 4.5.
*/
/**
* HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color
* measurement system that can also accurately render what colors will appear as in different
* lighting environments.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
public final class Hct {
private float hue;
private float chroma;
private float tone;
/**
* Create an HCT color from hue, chroma, and tone.
*
* @param hue 0 <= hue < 360; invalid values are corrected.
* @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than
* the requested chroma. Chroma has a different maximum for any given hue and tone.
* @param tone 0 <= tone <= 100; invalid values are corrected.
* @return HCT representation of a color in default viewing conditions.
*/
public static Hct from(float hue, float chroma, float tone) {
return new Hct(hue, chroma, tone);
}
/**
* Create an HCT color from a color.
*
* @param argb ARGB representation of a color.
* @return HCT representation of a color in default viewing conditions
*/
public static Hct fromInt(int argb) {
Cam16 cam = Cam16.fromInt(argb);
return new Hct(cam.getHue(), cam.getChroma(), ColorUtils.lstarFromInt(argb));
}
private Hct(float hue, float chroma, float tone) {
setInternalState(gamutMap(hue, chroma, tone));
}
public float getHue() {
return hue;
}
public float getChroma() {
return chroma;
}
public float getTone() {
return tone;
}
public int toInt() {
return gamutMap(hue, chroma, tone);
}
/**
* Set the hue of this color. Chroma may decrease because chroma has a different maximum for any
* given hue and tone.
*
* @param newHue 0 <= newHue < 360; invalid values are corrected.
*/
public void setHue(float newHue) {
setInternalState(gamutMap(MathUtils.sanitizeDegrees(newHue), chroma, tone));
}
/**
* Set the chroma of this color. Chroma may decrease because chroma has a different maximum for
* any given hue and tone.
*
* @param newChroma 0 <= newChroma < ?
*/
public void setChroma(float newChroma) {
setInternalState(gamutMap(hue, newChroma, tone));
}
/**
* Set the tone of this color. Chroma may decrease because chroma has a different maximum for any
* given hue and tone.
*
* @param newTone 0 <= newTone <= 100; invalid valids are corrected.
*/
public void setTone(float newTone) {
setInternalState(gamutMap(hue, chroma, newTone));
}
private void setInternalState(int argb) {
Cam16 cam = Cam16.fromInt(argb);
float tone = ColorUtils.lstarFromInt(argb);
hue = cam.getHue();
chroma = cam.getChroma();
this.tone = tone;
}
/**
* When the delta between the floor & ceiling of a binary search for maximum chroma at a hue and
* tone is less than this, the binary search terminates.
*/
private static final float CHROMA_SEARCH_ENDPOINT = 0.4f;
/** The maximum color distance, in CAM16-UCS, between a requested color and the color returned. */
private static final float DE_MAX = 1.0f;
/** The maximum difference between the requested L* and the L* returned. */
private static final float DL_MAX = 0.2f;
/**
* The minimum color distance, in CAM16-UCS, between a requested color and an 'exact' match. This
* allows the binary search during gamut mapping to terminate much earlier when the error is
* infinitesimal.
*/
private static final float DE_MAX_ERROR = 0.000000001f;
/**
* 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;
/**
* @param hue a number, in degrees, representing ex. red, orange, yellow, etc. Ranges from 0 <=
* hue < 360.
* @param chroma Informally, colorfulness. Ranges from 0 to roughly 150. Like all perceptually
* accurate color systems, chroma has a different maximum for any given hue and tone, so the
* color returned may be lower than the requested chroma.
* @param tone Lightness. Ranges from 0 to 100.
* @return ARGB representation of a color in default viewing conditions
*/
private static int gamutMap(float hue, float chroma, float tone) {
return gamutMapInViewingConditions(hue, chroma, tone, ViewingConditions.DEFAULT);
}
/**
* @param hue CAM16 hue.
* @param chroma CAM16 chroma.
* @param tone L*a*b* lightness.
* @param viewingConditions Information about the environment where the color was observed.
*/
static int gamutMapInViewingConditions(
float hue, float chroma, float tone, ViewingConditions viewingConditions) {
if (chroma < 1.0 || Math.round(tone) <= 0.0 || Math.round(tone) >= 100.0) {
return ColorUtils.intFromLstar(tone);
}
hue = MathUtils.sanitizeDegrees(hue);
float high = chroma;
float mid = chroma;
float low = 0.0f;
boolean isFirstLoop = true;
Cam16 answer = null;
while (Math.abs(low - high) >= CHROMA_SEARCH_ENDPOINT) {
Cam16 possibleAnswer = findCamByJ(hue, mid, tone);
if (isFirstLoop) {
if (possibleAnswer != null) {
return possibleAnswer.viewed(viewingConditions);
} else {
isFirstLoop = false;
mid = low + (high - low) / 2.0f;
continue;
}
}
if (possibleAnswer == null) {
high = mid;
} else {
answer = possibleAnswer;
low = mid;
}
mid = low + (high - low) / 2.0f;
}
if (answer == null) {
return ColorUtils.intFromLstar(tone);
}
return answer.viewed(viewingConditions);
}
/**
* @param hue CAM16 hue
* @param chroma CAM16 chroma
* @param tone L*a*b* lightness
* @return CAM16 instance within error tolerance of the provided dimensions, or null.
*/
private static Cam16 findCamByJ(float hue, float chroma, float tone) {
float low = 0.0f;
float high = 100.0f;
float mid = 0.0f;
float bestdL = 1000.0f;
float bestdE = 1000.0f;
Cam16 bestCam = null;
while (Math.abs(low - high) > LIGHTNESS_SEARCH_ENDPOINT) {
mid = low + (high - low) / 2;
Cam16 camBeforeClip = Cam16.fromJch(mid, chroma, hue);
int clipped = camBeforeClip.getInt();
float clippedLstar = ColorUtils.lstarFromInt(clipped);
float dL = Math.abs(tone - clippedLstar);
if (dL < DL_MAX) {
Cam16 camClipped = Cam16.fromInt(clipped);
float dE =
camClipped.distance(Cam16.fromJch(camClipped.getJ(), camClipped.getChroma(), hue));
if (dE <= DE_MAX && dE <= bestdE) {
bestdL = dL;
bestdE = dE;
bestCam = camClipped;
}
}
if (bestdL == 0 && bestdE < DE_MAX_ERROR) {
break;
}
if (clippedLstar < tone) {
low = mid;
} else {
high = mid;
}
}
return bestCam;
}
}