Coverage for models/osa_ucs.py: 79%
97 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Optical Society of America Uniform Colour Scales (OSA UCS)
3==========================================================
5Define the *OSA UCS* colourspace transformations.
7- :func:`colour.XYZ_to_OSA_UCS`
8- :func:`colour.OSA_UCS_to_XYZ`
10References
11----------
12- :cite:`Cao2013` : Cao, R., Trussell, H. J., & Shamey, R. (2013). Comparison
13 of the performance of inverse transformation methods from OSA-UCS to
14 CIEXYZ. Journal of the Optical Society of America A, 30(8), 1508.
15 doi:10.1364/JOSAA.30.001508
16- :cite:`Moroney2003` : Moroney, N. (2003). A Radial Sampling of the OSA
17 Uniform Color Scales. Color and Imaging Conference, 2003(1), 175-180.
18 ISSN:2166-9635
19- :cite:`Schlomer2019` : Schlömer, N. (2019). On the conversion from OSA-UCS
20 to CIEXYZ (Version 2). arXiv. doi:10.48550/ARXIV.1911.08323
21"""
23from __future__ import annotations
25import typing
27import numpy as np
29from colour.algebra import sdiv, sdiv_mode, spow, vecmul
31if typing.TYPE_CHECKING:
32 from colour.hints import NDArrayFloat
34from colour.hints import ( # noqa: TC001
35 Domain100,
36 NDArrayFloat,
37 Range100,
38)
39from colour.models import XYZ_to_xyY
40from colour.utilities import (
41 from_range_100,
42 to_domain_100,
43 tsplit,
44 tstack,
45)
47__author__ = "Colour Developers"
48__copyright__ = "Copyright 2013 Colour Developers"
49__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
50__maintainer__ = "Colour Developers"
51__email__ = "colour-developers@colour-science.org"
52__status__ = "Production"
54__all__ = [
55 "XYZ_to_OSA_UCS",
56 "OSA_UCS_to_XYZ",
57]
59MATRIX_XYZ_TO_RGB_OSA_UCS: NDArrayFloat = np.array(
60 [
61 [0.799, 0.4194, -0.1648],
62 [-0.4493, 1.3265, 0.0927],
63 [-0.1149, 0.3394, 0.717],
64 ]
65)
66"""
67*OSA UCS* matrix converting from *CIE XYZ* tristimulus values to *RGB*
68colourspace.
69"""
71MATRIX_RGB_TO_XYZ_OSA_UCS: NDArrayFloat = np.linalg.inv(MATRIX_XYZ_TO_RGB_OSA_UCS)
72"""
73*OSA UCS* matrix converting from *RGB* colourspace to *CIE XYZ* tristimulus
74values (inverse of MATRIX_XYZ_TO_RGB_OSA_UCS).
75"""
78def XYZ_to_OSA_UCS(XYZ: Domain100) -> Range100:
79 """
80 Convert from *CIE XYZ* tristimulus values under the
81 *CIE 1964 10 Degree Standard Observer* to *OSA UCS* colourspace.
83 The lightness axis, *L*, is typically in range [-9, 5] and centered
84 around middle gray (Munsell N/6). The yellow-blue axis, *j*, is
85 typically in range [-15, 15]. The red-green axis, *g*, is typically in
86 range [-20, 15].
88 Parameters
89 ----------
90 XYZ
91 *CIE XYZ* tristimulus values under the
92 *CIE 1964 10 Degree Standard Observer*.
94 Returns
95 -------
96 :class:`numpy.ndarray`
97 *OSA UCS* :math:`Ljg` lightness, jaune (yellowness), and greenness.
99 Notes
100 -----
101 +------------+-----------------------+--------------------+
102 | **Domain** | **Scale - Reference** | **Scale - 1** |
103 +============+=======================+====================+
104 | ``XYZ`` | 100 | 1 |
105 +------------+-----------------------+--------------------+
107 +------------+-----------------------+--------------------+
108 | **Range** | **Scale - Reference** | **Scale - 1** |
109 +============+=======================+====================+
110 | ``Ljg`` | 100 | 1 |
111 +------------+-----------------------+--------------------+
113 - *OSA UCS* uses the *CIE 1964 10 Degree Standard Observer*.
115 References
116 ----------
117 :cite:`Cao2013`, :cite:`Moroney2003`
119 Examples
120 --------
121 >>> import numpy as np
122 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952]) * 100
123 >>> XYZ_to_OSA_UCS(XYZ) # doctest: +ELLIPSIS
124 array([-3.0049979..., 2.9971369..., -9.6678423...])
125 """
127 XYZ = to_domain_100(XYZ)
128 x, y, Y = tsplit(XYZ_to_xyY(XYZ))
130 Y_0 = Y * (
131 4.4934 * x**2 + 4.3034 * y**2 - 4.276 * x * y - 1.3744 * x - 2.5643 * y + 1.8103
132 )
134 o_3 = 1 / 3
135 Y_0_es = spow(Y_0, o_3) - 2 / 3
136 # Gracefully handles Y_0 < 30.
137 Y_0_s = Y_0 - 30
138 Lambda = 5.9 * (Y_0_es + 0.042 * spow(Y_0_s, o_3))
140 RGB = vecmul(MATRIX_XYZ_TO_RGB_OSA_UCS, XYZ)
141 RGB_3 = spow(RGB, 1 / 3)
143 with sdiv_mode():
144 C = sdiv(Lambda, 5.9 * Y_0_es)
146 L = (Lambda - 14.4) / spow(2, 1 / 2)
147 j = C * np.dot(RGB_3, np.array([1.7, 8, -9.7]))
148 g = C * np.dot(RGB_3, np.array([-13.7, 17.7, -4]))
150 Ljg = tstack([L, j, g])
152 return from_range_100(Ljg)
155def OSA_UCS_to_XYZ(Ljg: Domain100, optimisation_kwargs: dict | None = None) -> Range100:
156 """
157 Convert from *OSA UCS* colourspace to *CIE XYZ* tristimulus values under
158 the *CIE 1964 10 Degree Standard Observer*.
160 The lightness axis, *L*, is typically in range [-9, 5] and centered
161 around middle gray (Munsell N/6). The yellow-blue axis, *j*, is
162 typically in range [-15, 15]. The red-green axis, *g*, is typically in
163 range [-20, 15].
165 Parameters
166 ----------
167 Ljg
168 *OSA UCS* :math:`Ljg` lightness, jaune (yellowness), and greenness.
169 optimisation_kwargs
170 Parameters for Newton iteration. Supported parameters:
172 - *iterations_maximum*: Maximum number of iterations (default: 20).
173 - *tolerance*: Convergence tolerance (default: 1e-10).
174 - *epsilon*: Step size for numerical derivative (default: 1e-8).
176 Returns
177 -------
178 :class:`numpy.ndarray`
179 *CIE XYZ* tristimulus values under the
180 *CIE 1964 10 Degree Standard Observer*.
182 Notes
183 -----
184 +------------+-----------------------+--------------------+
185 | **Domain** | **Scale - Reference** | **Scale - 1** |
186 +============+=======================+====================+
187 | ``Ljg`` | 100 | 1 |
188 +------------+-----------------------+--------------------+
190 +------------+-----------------------+--------------------+
191 | **Range** | **Scale - Reference** | **Scale - 1** |
192 +============+=======================+====================+
193 | ``XYZ`` | 100 | 1 |
194 +------------+-----------------------+--------------------+
196 - *OSA UCS* uses the *CIE 1964 10 Degree Standard Observer*.
197 - This implementation uses the improved algorithm from :cite:`Schlomer2019`
198 which employs Cardano's formula for solving the cubic equation and
199 Newton's method for the remaining nonlinear system.
201 References
202 ----------
203 :cite:`Cao2013`, :cite:`Moroney2003`, :cite:`Schlomer2019`
205 Examples
206 --------
207 >>> import numpy as np
208 >>> Ljg = np.array([-3.00499790, 2.99713697, -9.66784231])
209 >>> OSA_UCS_to_XYZ(Ljg) # doctest: +ELLIPSIS
210 array([ 20.654008..., 12.197225..., 5.1369520...])
211 """
213 Ljg = to_domain_100(Ljg)
214 shape = Ljg.shape
215 Ljg = np.atleast_1d(np.reshape(Ljg, (-1, 3)))
217 # Default optimization settings
218 settings: dict[str, typing.Any] = {
219 "iterations_maximum": 20,
220 "tolerance": 1e-10,
221 "epsilon": 1e-8,
222 }
223 if optimisation_kwargs is not None:
224 settings.update(optimisation_kwargs)
226 L, j, g = tsplit(Ljg)
228 # Step 1: Compute L' from L
229 # Forward: L = (Lambda - 14.4) / sqrt(2)
230 # Backward: Lambda = L * sqrt(2) + 14.4
231 # But L' = Lambda in the intermediate calculation
232 sqrt_2 = np.sqrt(2)
233 L_prime = L * sqrt_2 + 14.4
235 # Step 2: Solve for Y0 using Cardano's formula
236 # Equation: 0 = f(t) = (L'/5.9 + 2/3 - t)³ - 0.042^3(t^3 - 30)
237 # where t = Y0^(1/3)
238 u = L_prime / 5.9 + 2.0 / 3.0
239 v = 0.042**3
241 # Cubic equation: at^3 + bt^2 + ct + d = 0
242 a = -(v + 1)
243 b = 3 * u
244 c = -3 * u**2
245 d = u**3 + 30 * v
247 # Convert to depressed cubic: x³ + px + q = 0
248 p = (3 * a * c - b**2) / (3 * a**2)
249 q = (2 * b**3 - 9 * a * b * c + 27 * a**2 * d) / (27 * a**3)
251 # Cardano's formula
252 discriminant = (q / 2) ** 2 + (p / 3) ** 3
254 with sdiv_mode():
255 t = (
256 -b / (3 * a)
257 + spow(-q / 2 + np.sqrt(discriminant), 1.0 / 3.0)
258 + spow(-q / 2 - np.sqrt(discriminant), 1.0 / 3.0)
259 )
261 Y0 = t**3
263 # Step 3: Compute C, a, b
264 with sdiv_mode():
265 C = sdiv(L_prime, 5.9 * (t - 2.0 / 3.0))
266 a_coef = sdiv(g, C)
267 b_coef = sdiv(j, C)
269 # Step 4: Solve for RGB using Newton iteration
270 # Matrix A from equation (4)
271 A = np.array([[-13.7, 17.7, -4.0], [1.7, 8.0, -9.7]])
273 # Augment A with [1, 0, 0] to make it non-singular (set w = cbrt(R))
274 A_augmented = np.vstack([A, [1.0, 0.0, 0.0]])
275 A_inv = np.linalg.inv(A_augmented)
277 # Initial guess for w (corresponds to cbrt(R))
278 # w0 = cbrt(79.9 + 41.94) from paper
279 w = np.full_like(L, (79.9 + 41.94) ** (1.0 / 3.0))
281 # Newton iteration
282 for _iteration in range(settings["iterations_maximum"]):
283 # Solve for [cbrt(R), cbrt(G), cbrt(B)] given current w
284 ab_w = np.array([a_coef, b_coef, w]).T
285 RGB_cbrt = np.dot(ab_w, A_inv.T)
287 RGB = RGB_cbrt**3
289 XYZ = vecmul(MATRIX_RGB_TO_XYZ_OSA_UCS, RGB)
290 X, Y, Z = tsplit(XYZ)
292 with sdiv_mode():
293 sum_XYZ = X + Y + Z
294 x = sdiv(X, sum_XYZ)
295 y = sdiv(Y, sum_XYZ)
297 K = (
298 4.4934 * x**2
299 + 4.3034 * y**2
300 - 4.276 * x * y
301 - 1.3744 * x
302 - 2.5643 * y
303 + 1.8103
304 )
305 Y0_computed = Y * K
307 error = Y0_computed - Y0
308 if np.all(np.abs(error) < settings["tolerance"]):
309 break
311 # Newton step: compute derivative and update w
312 # Derivative is computed numerically for robustness
313 epsilon = settings["epsilon"]
314 w_plus = w + epsilon
316 ab_w_plus = np.array([a_coef, b_coef, w_plus]).T
317 RGB_cbrt_plus = np.dot(ab_w_plus, A_inv.T)
318 RGB_plus = RGB_cbrt_plus**3
319 XYZ_plus = vecmul(MATRIX_RGB_TO_XYZ_OSA_UCS, RGB_plus)
320 X_plus, Y_plus, Z_plus = tsplit(XYZ_plus)
322 with sdiv_mode():
323 sum_XYZ_plus = X_plus + Y_plus + Z_plus
324 x_plus = sdiv(X_plus, sum_XYZ_plus)
325 y_plus = sdiv(Y_plus, sum_XYZ_plus)
327 K_plus = (
328 4.4934 * x_plus**2
329 + 4.3034 * y_plus**2
330 - 4.276 * x_plus * y_plus
331 - 1.3744 * x_plus
332 - 2.5643 * y_plus
333 + 1.8103
334 )
335 Y0_computed_plus = Y_plus * K_plus
337 with sdiv_mode():
338 derivative = sdiv(Y0_computed_plus - Y0_computed, epsilon)
339 w = w - sdiv(error, derivative)
341 return from_range_100(np.reshape(XYZ, shape))