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

1""" 

2Optical Society of America Uniform Colour Scales (OSA UCS) 

3========================================================== 

4 

5Define the *OSA UCS* colourspace transformations. 

6 

7- :func:`colour.XYZ_to_OSA_UCS` 

8- :func:`colour.OSA_UCS_to_XYZ` 

9 

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""" 

22 

23from __future__ import annotations 

24 

25import typing 

26 

27import numpy as np 

28 

29from colour.algebra import sdiv, sdiv_mode, spow, vecmul 

30 

31if typing.TYPE_CHECKING: 

32 from colour.hints import NDArrayFloat 

33 

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) 

46 

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" 

53 

54__all__ = [ 

55 "XYZ_to_OSA_UCS", 

56 "OSA_UCS_to_XYZ", 

57] 

58 

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""" 

70 

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""" 

76 

77 

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. 

82 

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]. 

87 

88 Parameters 

89 ---------- 

90 XYZ 

91 *CIE XYZ* tristimulus values under the 

92 *CIE 1964 10 Degree Standard Observer*. 

93 

94 Returns 

95 ------- 

96 :class:`numpy.ndarray` 

97 *OSA UCS* :math:`Ljg` lightness, jaune (yellowness), and greenness. 

98 

99 Notes 

100 ----- 

101 +------------+-----------------------+--------------------+ 

102 | **Domain** | **Scale - Reference** | **Scale - 1** | 

103 +============+=======================+====================+ 

104 | ``XYZ`` | 100 | 1 | 

105 +------------+-----------------------+--------------------+ 

106 

107 +------------+-----------------------+--------------------+ 

108 | **Range** | **Scale - Reference** | **Scale - 1** | 

109 +============+=======================+====================+ 

110 | ``Ljg`` | 100 | 1 | 

111 +------------+-----------------------+--------------------+ 

112 

113 - *OSA UCS* uses the *CIE 1964 10 Degree Standard Observer*. 

114 

115 References 

116 ---------- 

117 :cite:`Cao2013`, :cite:`Moroney2003` 

118 

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 """ 

126 

127 XYZ = to_domain_100(XYZ) 

128 x, y, Y = tsplit(XYZ_to_xyY(XYZ)) 

129 

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 ) 

133 

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)) 

139 

140 RGB = vecmul(MATRIX_XYZ_TO_RGB_OSA_UCS, XYZ) 

141 RGB_3 = spow(RGB, 1 / 3) 

142 

143 with sdiv_mode(): 

144 C = sdiv(Lambda, 5.9 * Y_0_es) 

145 

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])) 

149 

150 Ljg = tstack([L, j, g]) 

151 

152 return from_range_100(Ljg) 

153 

154 

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*. 

159 

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]. 

164 

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: 

171 

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). 

175 

176 Returns 

177 ------- 

178 :class:`numpy.ndarray` 

179 *CIE XYZ* tristimulus values under the 

180 *CIE 1964 10 Degree Standard Observer*. 

181 

182 Notes 

183 ----- 

184 +------------+-----------------------+--------------------+ 

185 | **Domain** | **Scale - Reference** | **Scale - 1** | 

186 +============+=======================+====================+ 

187 | ``Ljg`` | 100 | 1 | 

188 +------------+-----------------------+--------------------+ 

189 

190 +------------+-----------------------+--------------------+ 

191 | **Range** | **Scale - Reference** | **Scale - 1** | 

192 +============+=======================+====================+ 

193 | ``XYZ`` | 100 | 1 | 

194 +------------+-----------------------+--------------------+ 

195 

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. 

200 

201 References 

202 ---------- 

203 :cite:`Cao2013`, :cite:`Moroney2003`, :cite:`Schlomer2019` 

204 

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 """ 

212 

213 Ljg = to_domain_100(Ljg) 

214 shape = Ljg.shape 

215 Ljg = np.atleast_1d(np.reshape(Ljg, (-1, 3))) 

216 

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) 

225 

226 L, j, g = tsplit(Ljg) 

227 

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 

234 

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 

240 

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 

246 

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) 

250 

251 # Cardano's formula 

252 discriminant = (q / 2) ** 2 + (p / 3) ** 3 

253 

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 ) 

260 

261 Y0 = t**3 

262 

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) 

268 

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]]) 

272 

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) 

276 

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)) 

280 

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) 

286 

287 RGB = RGB_cbrt**3 

288 

289 XYZ = vecmul(MATRIX_RGB_TO_XYZ_OSA_UCS, RGB) 

290 X, Y, Z = tsplit(XYZ) 

291 

292 with sdiv_mode(): 

293 sum_XYZ = X + Y + Z 

294 x = sdiv(X, sum_XYZ) 

295 y = sdiv(Y, sum_XYZ) 

296 

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 

306 

307 error = Y0_computed - Y0 

308 if np.all(np.abs(error) < settings["tolerance"]): 

309 break 

310 

311 # Newton step: compute derivative and update w 

312 # Derivative is computed numerically for robustness 

313 epsilon = settings["epsilon"] 

314 w_plus = w + epsilon 

315 

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) 

321 

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) 

326 

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 

336 

337 with sdiv_mode(): 

338 derivative = sdiv(Y0_computed_plus - Y0_computed, epsilon) 

339 w = w - sdiv(error, derivative) 

340 

341 return from_range_100(np.reshape(XYZ, shape))