1 /*
2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.material3.internal.colorUtil
18
19 import androidx.annotation.VisibleForTesting
20 import androidx.compose.material3.internal.colorUtil.CamUtils.yFromLstar
21 import kotlin.math.PI
22 import kotlin.math.cbrt
23 import kotlin.math.exp
24 import kotlin.math.pow
25 import kotlin.math.sqrt
26
27 /**
28 * NOTICE: Fork and kotlin transpilation of
29 * frameworks/base/core/java/com/android/internal/graphics/cam/Frame.java Manual changes have not
30 * and should not be implemented except for compilation purposes between kotlin and java. Unused
31 * methods were also removed.
32 *
33 * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a
34 * color appearance model representing the color.
35 *
36 * To convert a traditional color to a color appearance model, it requires knowing what conditions
37 * the color was observed in. Our perception of color depends on, for example, the tone of the light
38 * illuminating the color, how bright that light was, etc.
39 *
40 * This class is modelled separately from the color appearance model itself because there are a
41 * number of calculations during the color => CAM conversion process that depend only on the viewing
42 * conditions. Caching those calculations in a Frame instance saves a significant amount of time.
43 */
44 internal class Frame
45 private constructor(
46 @get:VisibleForTesting val n: Float,
47 @get:VisibleForTesting val aw: Float,
48 @get:VisibleForTesting val nbb: Float,
49 val ncb: Float,
50 val c: Float,
51 val nc: Float,
52 @get:VisibleForTesting val rgbD: FloatArray,
53 val fl: Float,
54 @get:VisibleForTesting val flRoot: Float,
55 val z: Float
56 ) {
57 companion object {
58 // Standard viewing conditions assumed in RGB specification - Stokes, Anderson,
59 // Chandrasekar, Motta - A Standard Default Color Space for the Internet: sRGB, 1996.
60 //
61 // White point = D65
62 //
63 // Luminance of adapting field: 200 / Pi / 5, units are cd/m^2.
64 //
65 // sRGB ambient illuminance = 64 lux (per sRGB spec). However, the spec notes this is
66 // artificially low and based on monitors in 1990s. Use 200, the sRGB spec says this is the
67 // real average, and a survey of lux values on Wikipedia confirms this is a comfortable
68 // default: somewhere between a very dark overcast day and office lighting.
69 //
70 // Per CAM16 introduction paper (Li et al, 2017) Ew = pi * lw, and La = lw * Yb/Yw
71 // Ew = ambient environment luminance, in lux.
72 // Yb/Yw is taken to be midgray, ~20% relative luminance (XYZ Y 18.4, CIELAB L* 50).
73 // Therefore La = (Ew / pi) * .184
74 // La = 200 / pi * .184
75 // Image surround to 10 degrees = ~20% relative luminance = CIELAB L* 50
76 //
77 // Not from sRGB standard:
78 // Surround = average, 2.0.
79 // Discounting illuminant = false, doesn't occur for self-luminous displays
80 val Default: Frame =
81 make(
82 CamUtils.WHITE_POINT_D65,
83 (200.0f / PI * yFromLstar(50.0) / 100.0).toFloat(),
84 50.0f,
85 2.0f,
86 false
87 )
88
89 /** Create a custom frame. */
makenull90 fun make(
91 whitepoint: FloatArray,
92 adaptingLuminance: Float,
93 backgroundLstar: Float,
94 surround: Float,
95 discountingIlluminant: Boolean
96 ): Frame {
97 // Transform white point XYZ to 'cone'/'rgb' responses
98 val matrix = CamUtils.XYZ_TO_CAM16RGB
99 val rW =
100 (whitepoint[0] * matrix[0][0]) +
101 (whitepoint[1] * matrix[0][1]) +
102 (whitepoint[2] * matrix[0][2])
103 val gW =
104 (whitepoint[0] * matrix[1][0]) +
105 (whitepoint[1] * matrix[1][1]) +
106 (whitepoint[2] * matrix[1][2])
107 val bW =
108 (whitepoint[0] * matrix[2][0]) +
109 (whitepoint[1] * matrix[2][1]) +
110 (whitepoint[2] * matrix[2][2])
111
112 // Scale input surround, domain (0, 2), to CAM16 surround, domain (0.8, 1.0)
113 val f = 0.8f + (surround / 10.0f)
114 // "Exponential non-linearity"
115 val c: Float =
116 if ((f >= 0.9)) lerp(0.59f, 0.69f, ((f - 0.9f) * 10.0f))
117 else lerp(0.525f, 0.59f, ((f - 0.8f) * 10.0f))
118 // Calculate degree of adaptation to illuminant
119 var d =
120 if (discountingIlluminant) 1.0f
121 else
122 f *
123 (1.0f -
124 ((1.0f / 3.6f) *
125 exp(((-adaptingLuminance - 42.0f) / 92.0f).toDouble()).toFloat()))
126 // Per Li et al, if D is greater than 1 or less than 0, set it to 1 or 0.
127 d = if ((d > 1.0)) 1.0f else if ((d < 0.0)) 0.0f else d
128 // Chromatic induction factor
129 val nc = f
130
131 // Cone responses to the whitepoint, adjusted for illuminant discounting.
132 //
133 // Why use 100.0 instead of the white point's relative luminance?
134 //
135 // Some papers and implementations, for both CAM02 and CAM16, use the Y value of the
136 // reference white instead of 100. Fairchild's Color Appearance Models (3rd edition)
137 // notes that this is in error: it was included in the CIE 2004a report on CIECAM02,
138 // but, later parts of the conversion process account for scaling of appearance relative
139 // to the white point relative luminance. This part should simply use 100 as luminance.
140 val rgbD =
141 floatArrayOf(
142 d * (100.0f / rW) + 1.0f - d,
143 d * (100.0f / gW) + 1.0f - d,
144 d * (100.0f / bW) + 1.0f - d,
145 )
146 // Luminance-level adaptation factor
147 val k = 1.0f / (5.0f * adaptingLuminance + 1.0f)
148 val k4 = k * k * k * k
149 val k4F = 1.0f - k4
150 val fl =
151 (k4 * adaptingLuminance) +
152 (0.1f * k4F * k4F * cbrt(5.0 * adaptingLuminance).toFloat())
153
154 // Intermediate factor, ratio of background relative luminance to white relative
155 // luminance
156 val n = yFromLstar(backgroundLstar.toDouble()).toFloat() / whitepoint[1]
157
158 // Base exponential nonlinearity note Schlomer 2018 has a typo and uses 1.58, the
159 // correct factor is 1.48
160 val z = 1.48f + sqrt(n)
161
162 // Luminance-level induction factors
163 val nbb = 0.725f / n.pow(0.2f)
164
165 // Discounted cone responses to the white point, adjusted for post-chromatic adaptation
166 // perceptual nonlinearities.
167 val rgbAFactors =
168 floatArrayOf(
169 (fl * rgbD[0] * rW / 100f).pow(0.42f),
170 (fl * rgbD[1] * gW / 100f).pow(0.42f),
171 (fl * rgbD[2] * bW / 100f).pow(0.42f)
172 )
173
174 val rgbA =
175 floatArrayOf(
176 (400.0f * rgbAFactors[0]) / (rgbAFactors[0] + 27.13f),
177 (400.0f * rgbAFactors[1]) / (rgbAFactors[1] + 27.13f),
178 (400.0f * rgbAFactors[2]) / (rgbAFactors[2] + 27.13f),
179 )
180
181 val aw = ((2.0f * rgbA[0]) + rgbA[1] + (0.05f * rgbA[2])) * nbb
182
183 return Frame(n, aw, nbb, nbb, c, nc, rgbD, fl, fl.pow(0.25f), z)
184 }
185 }
186 }
187
188 /**
189 * The linear interpolation function.
190 *
191 * @return start if amount = 0 and stop if amount = 1
192 */
lerpnull193 private fun lerp(start: Float, stop: Float, amount: Float): Float {
194 return (1.0f - amount) * start + amount * stop
195 }
196