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