Coverage for colour/models/sucs.py: 100%

54 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-15 19:01 +1300

1""" 

2sUCS Colourspace 

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

4 

5Define the *sUCS* colourspace transformations. 

6 

7- :func:`colour.XYZ_to_sUCS` 

8- :func:`colour.sUCS_to_XYZ` 

9- :func:`colour.sUCS_chroma` 

10- :func:`colour.sUCS_hue_angle` 

11 

12The *sUCS* (Simple Uniform Colour Space) is designed for simplicity and 

13perceptual uniformity. This implementation is based on the work by 

14*Li & Luo (2024)*. 

15 

16References 

17---------- 

18- :cite:`Li2024` : Li, M., & Luo, M. R. (2024). Simple color appearance model 

19 (sCAM) based on simple uniform color space (sUCS). Optics Express, 32(3), 

20 3100. doi:10.1364/OE.510196 

21""" 

22 

23from __future__ import annotations 

24 

25from functools import partial 

26 

27import numpy as np 

28 

29from colour.algebra import spow 

30from colour.hints import ( # noqa: TC001 

31 Domain1, 

32 Domain100, 

33 Domain100_100_360, 

34 NDArrayFloat, 

35 Range1, 

36 Range100, 

37 Range100_100_360, 

38 Range360, 

39) 

40from colour.models import Iab_to_XYZ, XYZ_to_Iab 

41from colour.utilities import ( 

42 as_float, 

43 domain_range_scale, 

44 from_range_1, 

45 from_range_100, 

46 from_range_degrees, 

47 to_domain_1, 

48 to_domain_100, 

49 to_domain_degrees, 

50 tsplit, 

51 tstack, 

52) 

53 

54__author__ = "UltraMo114(Molin Li), Colour Developers" 

55__copyright__ = "Copyright 2024 Colour Developers" 

56__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

57__maintainer__ = "Colour Developers" 

58__email__ = "colour-developers@colour-science.org" 

59__status__ = "Production" 

60 

61__all__ = [ 

62 "MATRIX_SUCS_XYZ_TO_LMS", 

63 "MATRIX_SUCS_LMS_TO_XYZ", 

64 "MATRIX_SUCS_LMS_P_TO_IAB", 

65 "MATRIX_SUCS_IAB_TO_LMS_P", 

66 "XYZ_to_sUCS", 

67 "sUCS_to_XYZ", 

68 "sUCS_chroma", 

69 "sUCS_hue_angle", 

70 "sUCS_Iab_to_sUCS_ICh", 

71 "sUCS_ICh_to_sUCS_Iab", 

72] 

73 

74MATRIX_SUCS_XYZ_TO_LMS: NDArrayFloat = np.array( 

75 [ 

76 [0.4002, 0.7075, -0.0807], 

77 [-0.2280, 1.1500, 0.0612], 

78 [0.0000, 0.0000, 0.9184], 

79 ] 

80) 

81""" 

82*CIE XYZ* tristimulus values (*CIE Standard Illuminant D Series* *D65*-adapted, 

83Y=1 for white) to LMS-like cone responses matrix. 

84""" 

85 

86MATRIX_SUCS_LMS_TO_XYZ: NDArrayFloat = np.linalg.inv(MATRIX_SUCS_XYZ_TO_LMS) 

87""" 

88LMS-like cone responses to *CIE XYZ* tristimulus values 

89(*CIE Standard Illuminant D Series* *D65*-adapted, Y=1 for white) matrix. 

90""" 

91 

92MATRIX_SUCS_LMS_P_TO_IAB: NDArrayFloat = np.array( 

93 [ 

94 [200.0 / 3.05, 100.0 / 3.05, 5.0 / 3.05], 

95 [430.0, -470.0, 40.0], 

96 [49.0, 49.0, -98.0], 

97 ] 

98) 

99""" 

100Non-linear LMS-like responses :math:`LMS_p` to intermediate :math:`Iab` 

101colourspace matrix. 

102""" 

103 

104MATRIX_SUCS_IAB_TO_LMS_P: NDArrayFloat = np.linalg.inv(MATRIX_SUCS_LMS_P_TO_IAB) 

105""" 

106Intermediate :math:`Iab` colourspace to non-linear LMS-like responses 

107:math:`LMS_p` matrix. 

108""" 

109 

110 

111def XYZ_to_sUCS(XYZ: Domain1) -> Range100: 

112 """ 

113 Convert from *CIE XYZ* tristimulus values to *sUCS* colourspace. 

114 

115 Parameters 

116 ---------- 

117 XYZ 

118 *CIE XYZ* tristimulus values, adapted to 

119 *CIE Standard Illuminant D65* and in domain [0, 1] (where white 

120 :math:`Y` is 1.0). 

121 

122 Returns 

123 ------- 

124 :class:`numpy.ndarray` 

125 *sUCS* :math:`Iab` colourspace array. 

126 

127 Notes 

128 ----- 

129 +------------+-----------------------+-----------------+ 

130 | **Domain** | **Scale - Reference** | **Scale - 1** | 

131 +============+=======================+=================+ 

132 | ``XYZ`` | 1 | 1 | 

133 +------------+-----------------------+-----------------+ 

134 

135 +------------+-----------------------+------------------+ 

136 | **Range** | **Scale - Reference** | **Scale - 1** | 

137 +============+=======================+==================+ 

138 | ``Iab`` | 100 | 1 | 

139 +------------+-----------------------+------------------+ 

140 

141 - Input *CIE XYZ* tristimulus values must be adapted to 

142 *CIE Standard Illuminant D Series* *D65*. 

143 

144 References 

145 ---------- 

146 :cite:`Li2024` 

147 

148 Examples 

149 -------- 

150 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952]) 

151 >>> XYZ_to_sUCS(XYZ) # doctest: +ELLIPSIS 

152 array([ 42.6292365..., 36.9764683..., 14.1230135...]) 

153 """ 

154 

155 XYZ = to_domain_1(XYZ) 

156 

157 with domain_range_scale("ignore"): 

158 Iab = XYZ_to_Iab( 

159 XYZ, 

160 partial(spow, p=0.43), 

161 MATRIX_SUCS_XYZ_TO_LMS, 

162 MATRIX_SUCS_LMS_P_TO_IAB, 

163 ) 

164 

165 return from_range_100(Iab) 

166 

167 

168def sUCS_to_XYZ(Iab: Domain100) -> Range1: 

169 """ 

170 Convert from *sUCS* colourspace to *CIE XYZ* tristimulus values. 

171 

172 Parameters 

173 ---------- 

174 Iab 

175 *sUCS* :math:`Iab` colourspace array. 

176 

177 Returns 

178 ------- 

179 :class:`numpy.ndarray` 

180 *CIE XYZ* tristimulus values, adapted to 

181 *CIE Standard Illuminant D65* and in domain [0, 1] (where white 

182 :math:`Y` is 1.0). 

183 

184 Notes 

185 ----- 

186 +------------+-----------------------+------------------+ 

187 | **Domain** | **Scale - Reference** | **Scale - 1** | 

188 +============+=======================+==================+ 

189 | ``Iab`` | 100 | 1 | 

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

191 

192 +------------+-----------------------+-----------------+ 

193 | **Range** | **Scale - Reference** | **Scale - 1** | 

194 +============+=======================+=================+ 

195 | ``XYZ`` | 1 | 1 | 

196 +------------+-----------------------+-----------------+ 

197 

198 References 

199 ---------- 

200 :cite:`Li2024` 

201 

202 Examples 

203 -------- 

204 >>> Iab = np.array([42.62923653, 36.97646831, 14.12301358]) 

205 >>> sUCS_to_XYZ(Iab) # doctest: +ELLIPSIS 

206 array([ 0.2065400..., 0.1219722..., 0.0513695...]) 

207 """ 

208 

209 Iab = to_domain_100(Iab) 

210 

211 with domain_range_scale("ignore"): 

212 XYZ = Iab_to_XYZ( 

213 Iab, 

214 partial(spow, p=1 / 0.43), 

215 MATRIX_SUCS_IAB_TO_LMS_P, 

216 MATRIX_SUCS_LMS_TO_XYZ, 

217 ) 

218 

219 return from_range_1(XYZ) 

220 

221 

222def sUCS_chroma(Iab: Domain100) -> Range100: 

223 """ 

224 Compute the chroma component from the *sUCS* colourspace. 

225 

226 Parameters 

227 ---------- 

228 Iab 

229 *sUCS* :math:`Iab` colourspace array. 

230 

231 Returns 

232 ------- 

233 :class:`numpy.ndarray` 

234 Chroma component. 

235 

236 Notes 

237 ----- 

238 +------------+-----------------------+------------------+ 

239 | **Domain** | **Scale - Reference** | **Scale - 1** | 

240 +============+=======================+==================+ 

241 | ``Iab`` | 100 | 1 | 

242 +------------+-----------------------+------------------+ 

243 

244 +------------+-----------------------+-----------------+ 

245 | **Range** | **Scale - Reference** | **Scale - 1** | 

246 +============+=======================+=================+ 

247 | ``C`` | 100 | 1 | 

248 +------------+-----------------------+-----------------+ 

249 

250 References 

251 ---------- 

252 :cite:`Li2024` 

253 

254 Examples 

255 -------- 

256 >>> Iab = np.array([42.62923653, 36.97646831, 14.12301358]) 

257 >>> sUCS_chroma(Iab) # doctest: +ELLIPSIS 

258 40.4205110... 

259 """ 

260 

261 _I, a, b = tsplit(to_domain_100(Iab)) 

262 

263 C = 1 / 0.0252 * np.log(1 + 0.0447 * np.hypot(a, b)) 

264 

265 return as_float(from_range_100(C)) 

266 

267 

268def sUCS_hue_angle(Iab: Domain100) -> Range360: 

269 """ 

270 Compute the hue angle in degrees from the *sUCS* colourspace. 

271 

272 Parameters 

273 ---------- 

274 Iab 

275 *sUCS* :math:`Iab` colourspace array. 

276 

277 Returns 

278 ------- 

279 :class:`numpy.ndarray` 

280 Hue angle in degrees. 

281 

282 Notes 

283 ----- 

284 +------------+-----------------------+------------------+ 

285 | **Domain** | **Scale - Reference** | **Scale - 1** | 

286 +============+=======================+==================+ 

287 | ``Iab`` | 100 | 1 | 

288 +------------+-----------------------+------------------+ 

289 

290 +------------+-----------------------+-----------------+ 

291 | **Range** | **Scale - Reference** | **Scale - 1** | 

292 +============+=======================+=================+ 

293 | ``hue`` | 360 | 1 | 

294 +------------+-----------------------+-----------------+ 

295 

296 References 

297 ---------- 

298 :cite:`Li2024` 

299 

300 Examples 

301 -------- 

302 >>> Iab = np.array([42.62923653, 36.97646831, 14.12301358]) 

303 >>> sUCS_hue_angle(Iab) # doctest: +ELLIPSIS 

304 20.9041560... 

305 """ 

306 

307 _I, a, b = tsplit(to_domain_100(Iab)) 

308 

309 h = np.degrees(np.arctan2(b, a)) % 360 

310 

311 return as_float(from_range_degrees(h)) 

312 

313 

314def sUCS_Iab_to_sUCS_ICh( 

315 Iab: Domain100, 

316) -> Range100_100_360: 

317 """ 

318 Convert from *sUCS* :math:`Iab` rectangular coordinates to *sUCS* 

319 :math:`ICh` cylindrical coordinates. 

320 

321 Parameters 

322 ---------- 

323 Iab 

324 *sUCS* :math:`Iab` rectangular coordinates array. 

325 

326 Returns 

327 ------- 

328 :class:`numpy.ndarray` 

329 *sUCS* :math:`ICh` cylindrical coordinates array. 

330 

331 Notes 

332 ----- 

333 +------------+-----------------------+------------------+ 

334 | **Domain** | **Scale - Reference** | **Scale - 1** | 

335 +============+=======================+==================+ 

336 | ``Iab`` | 100 | 1 | 

337 +------------+-----------------------+------------------+ 

338 

339 +------------+-----------------------+------------------+ 

340 | **Range** | **Scale - Reference** | **Scale - 1** | 

341 +============+=======================+==================+ 

342 | ``ICh`` | ``I`` : 100 | ``I`` : 1 | 

343 | | | | 

344 | | ``C`` : 100 | ``C`` : 1 | 

345 | | | | 

346 | | ``h`` : 360 | ``h`` : 1 | 

347 +------------+-----------------------+------------------+ 

348 

349 References 

350 ---------- 

351 :cite:`Li2024` 

352 

353 Examples 

354 -------- 

355 >>> Iab = np.array([42.62923653, 36.97646831, 14.12301358]) 

356 >>> sUCS_Iab_to_sUCS_ICh(Iab) # doctest: +ELLIPSIS 

357 array([ 42.6292365..., 40.4205110..., 20.9041560...]) 

358 """ 

359 

360 I, a, b = tsplit(to_domain_100(Iab)) # noqa: E741 

361 

362 C = 1 / 0.0252 * np.log(1 + 0.0447 * np.hypot(a, b)) 

363 

364 h = np.degrees(np.arctan2(b, a)) % 360 

365 

366 return tstack([from_range_100(I), from_range_100(C), from_range_degrees(h)]) 

367 

368 

369def sUCS_ICh_to_sUCS_Iab( 

370 ICh: Domain100_100_360, 

371) -> Range100: 

372 """ 

373 Convert from *sUCS* :math:`ICh` cylindrical coordinates to *sUCS* 

374 :math:`Iab` rectangular coordinates. 

375 

376 Parameters 

377 ---------- 

378 ICh 

379 *sUCS* :math:`ICh` cylindrical coordinates array. 

380 

381 Returns 

382 ------- 

383 :class:`numpy.ndarray` 

384 *sUCS* :math:`Iab` rectangular coordinates array. 

385 

386 Notes 

387 ----- 

388 +------------+-----------------------+------------------+ 

389 | **Domain** | **Scale - Reference** | **Scale - 1** | 

390 +============+=======================+==================+ 

391 | ``ICh`` | ``I`` : 100 | ``I`` : 1 | 

392 | | | | 

393 | | ``C`` : 100 | ``C`` : 1 | 

394 | | | | 

395 | | ``h`` : 360 | ``h`` : 1 | 

396 +------------+-----------------------+------------------+ 

397 

398 +------------+-----------------------+------------------+ 

399 | **Range** | **Scale - Reference** | **Scale - 1** | 

400 +============+=======================+==================+ 

401 | ``Iab`` | 100 | 1 | 

402 +------------+-----------------------+------------------+ 

403 

404 References 

405 ---------- 

406 :cite:`Li2024` 

407 

408 Examples 

409 -------- 

410 >>> ICh = np.array([42.62923653, 40.42051103, 20.90415604]) 

411 >>> sUCS_ICh_to_sUCS_Iab(ICh) # doctest: +ELLIPSIS 

412 array([ 42.6292365..., 36.9764682..., 14.1230135...]) 

413 """ 

414 

415 I, C, h = tsplit(ICh) # noqa: E741 

416 I = to_domain_100(I) # noqa: E741 

417 C = to_domain_100(C) 

418 h = to_domain_degrees(h) 

419 

420 C = (np.exp(0.0252 * C) - 1) / 0.0447 

421 

422 a = C * np.cos(np.radians(h)) 

423 b = C * np.sin(np.radians(h)) 

424 

425 return from_range_100(tstack([I, a, b]))