Coverage for colour/appearance/scam.py: 100%

123 statements  

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

1""" 

2sCAM Colour Appearance Model 

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

4 

5Define the *sCAM* colour appearance model for predicting perceptual colour 

6attributes under varying viewing conditions. 

7 

8- :class:`colour.appearance.InductionFactors_sCAM` 

9- :attr:`colour.VIEWING_CONDITIONS_sCAM` 

10- :class:`colour.CAM_Specification_sCAM` 

11- :func:`colour.XYZ_to_sCAM` 

12- :func:`colour.sCAM_to_XYZ` 

13 

14The *sCAM* (Simple Colour Appearance Model) is based on the *sUCS* (Simple 

15Uniform Colour Space). 

16 

17References 

18---------- 

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

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

21 3100. doi:10.1364/OE.510196 

22""" 

23 

24from __future__ import annotations 

25 

26from dataclasses import astuple, dataclass, field 

27 

28import numpy as np 

29 

30from colour.adaptation import chromatic_adaptation_Li2025 

31from colour.algebra import sdiv, sdiv_mode, spow 

32from colour.hints import ( # noqa: TC001 

33 Annotated, 

34 ArrayLike, 

35 Domain100, 

36 NDArrayFloat, 

37 Range100, 

38) 

39from colour.models.sucs import ( 

40 XYZ_to_sUCS, 

41 sUCS_Iab_to_sUCS_ICh, 

42 sUCS_ICh_to_sUCS_Iab, 

43 sUCS_to_XYZ, 

44) 

45from colour.utilities import ( 

46 CanonicalMapping, 

47 MixinDataclassArithmetic, 

48 MixinDataclassIterable, 

49 as_float, 

50 as_float_array, 

51 domain_range_scale, 

52 from_range_100, 

53 from_range_degrees, 

54 has_only_nan, 

55 to_domain_100, 

56 to_domain_degrees, 

57 tsplit, 

58 tstack, 

59) 

60 

61__author__ = "Colour Developers" 

62__copyright__ = "Copyright 2024 Colour Developers" 

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

64__maintainer__ = "Colour Developers" 

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

66__status__ = "Production" 

67 

68__all__ = [ 

69 "TVS_D65_sCAM", 

70 "HUE_DATA_FOR_HUE_QUADRATURE_sCAM", 

71 "InductionFactors_sCAM", 

72 "VIEWING_CONDITIONS_sCAM", 

73 "CAM_Specification_sCAM", 

74 "XYZ_to_sCAM", 

75 "sCAM_to_XYZ", 

76 "hue_quadrature", 

77] 

78 

79TVS_D65_sCAM = np.array([0.95047, 1.00000, 1.08883]) 

80"""*CIE XYZ* tristimulus values of *CIE Standard Illuminant D65* for *sCAM*.""" 

81 

82HUE_DATA_FOR_HUE_QUADRATURE_sCAM: dict = { 

83 "h_i": np.array([15.6, 80.3, 157.8, 219.7, 376.6]), 

84 "e_i": np.array([0.7, 0.6, 1.2, 0.9, 0.7]), 

85 "H_i": np.array([0.0, 100.0, 200.0, 300.0, 400.0]), 

86} 

87"""Hue quadrature data for *sCAM* colour appearance model.""" 

88 

89 

90@dataclass(frozen=True) 

91class InductionFactors_sCAM(MixinDataclassIterable): 

92 """ 

93 Define the *sCAM* colour appearance model induction factors. 

94 

95 Parameters 

96 ---------- 

97 F 

98 Maximum degree of adaptation :math:`F`. 

99 c 

100 Exponential non-linearity :math:`c`. 

101 Fm 

102 Factor for colourfulness :math:`F_m`. 

103 

104 References 

105 ---------- 

106 :cite:`Li2024` 

107 """ 

108 

109 F: float 

110 c: float 

111 Fm: float 

112 

113 

114VIEWING_CONDITIONS_sCAM: CanonicalMapping = CanonicalMapping( 

115 { 

116 "Average": InductionFactors_sCAM(F=1.0, c=0.52, Fm=1.0), 

117 "Dim": InductionFactors_sCAM(F=0.9, c=0.50, Fm=0.95), 

118 "Dark": InductionFactors_sCAM(F=0.8, c=0.39, Fm=0.85), 

119 } 

120) 

121VIEWING_CONDITIONS_sCAM.__doc__ = """ 

122Define the reference *sCAM* colour appearance model 

123viewing conditions. 

124 

125Provide standardized surround conditions (*Average*, *Dim*, *Dark*) with 

126their corresponding induction factors that characterize chromatic 

127adaptation and perceptual non-linearities under different viewing 

128environments. 

129""" 

130 

131 

132@dataclass 

133class CAM_Specification_sCAM(MixinDataclassArithmetic): 

134 """ 

135 Define the specification for the *sCAM* colour appearance model. 

136 

137 Parameters 

138 ---------- 

139 J 

140 Correlate of *lightness* :math:`J`. 

141 C 

142 Correlate of *chroma* :math:`C`. 

143 h 

144 *Hue* angle :math:`h` in degrees. 

145 Q 

146 Correlate of *brightness* :math:`Q`. 

147 M 

148 Correlate of *colourfulness* :math:`M`. 

149 H 

150 *Hue* :math:`h` composition :math:`H`. 

151 HC 

152 *Hue* :math:`h` composition :math:`H^C` (currently not 

153 implemented). 

154 V 

155 Correlate of *vividness* :math:`V`. 

156 K 

157 Correlate of *blackness* :math:`K`. 

158 W 

159 Correlate of *whiteness* :math:`W`. 

160 D 

161 Correlate of *depth* :math:`D`. 

162 

163 References 

164 ---------- 

165 :cite:`Li2024` 

166 """ 

167 

168 J: float | NDArrayFloat | None = field(default_factory=lambda: None) 

169 C: float | NDArrayFloat | None = field(default_factory=lambda: None) 

170 h: float | NDArrayFloat | None = field(default_factory=lambda: None) 

171 Q: float | NDArrayFloat | None = field(default_factory=lambda: None) 

172 M: float | NDArrayFloat | None = field(default_factory=lambda: None) 

173 H: float | NDArrayFloat | None = field(default_factory=lambda: None) 

174 HC: float | NDArrayFloat | None = field(default_factory=lambda: None) 

175 V: float | NDArrayFloat | None = field(default_factory=lambda: None) 

176 K: float | NDArrayFloat | None = field(default_factory=lambda: None) 

177 W: float | NDArrayFloat | None = field(default_factory=lambda: None) 

178 D: float | NDArrayFloat | None = field(default_factory=lambda: None) 

179 

180 

181def XYZ_to_sCAM( 

182 XYZ: Domain100, 

183 XYZ_w: Domain100, 

184 L_A: ArrayLike, 

185 Y_b: ArrayLike, 

186 surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"], 

187 discount_illuminant: bool = False, 

188) -> Annotated[ 

189 CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100) 

190]: 

191 """ 

192 Compute the *sCAM* colour appearance model correlates from the specified 

193 *CIE XYZ* tristimulus values. 

194 

195 Parameters 

196 ---------- 

197 XYZ 

198 *CIE XYZ* tristimulus values of test sample / stimulus. 

199 XYZ_w 

200 *CIE XYZ* tristimulus values of reference white. 

201 L_A 

202 Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often 

203 taken to be 20% of the luminance of a white object in the scene). 

204 Y_b 

205 Luminous factor of background :math:`Y_b` such as 

206 :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the 

207 luminance of the light source and :math:`L_b` is the luminance of 

208 the background. For viewing images, :math:`Y_b` can be the average 

209 :math:`Y` value for the pixels in the entire image, or frequently, 

210 a :math:`Y` value of 20, approximating an :math:`L^*` of 50 is 

211 used. 

212 surround 

213 Surround viewing conditions induction factors. 

214 discount_illuminant 

215 Truth value indicating if the illuminant should be discounted. 

216 

217 Returns 

218 ------- 

219 :class:`colour.CAM_Specification_sCAM` 

220 *sCAM* colour appearance model specification. 

221 

222 Notes 

223 ----- 

224 +---------------------+-----------------------+---------------+ 

225 | **Domain** | **Scale - Reference** | **Scale - 1** | 

226 +=====================+=======================+===============+ 

227 | ``XYZ`` | 100 | 1 | 

228 +---------------------+-----------------------+---------------+ 

229 | ``XYZ_w`` | 100 | 1 | 

230 +---------------------+-----------------------+---------------+ 

231 

232 +---------------------+-----------------------+---------------+ 

233 | **Range** | **Scale - Reference** | **Scale - 1** | 

234 +=====================+=======================+===============+ 

235 | ``specification.J`` | 100 | 1 | 

236 +---------------------+-----------------------+---------------+ 

237 | ``specification.C`` | 100 | 1 | 

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

239 | ``specification.h`` | 360 | 1 | 

240 +---------------------+-----------------------+---------------+ 

241 | ``specification.Q`` | 100 | 1 | 

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

243 | ``specification.M`` | 100 | 1 | 

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

245 | ``specification.H`` | 400 | 1 | 

246 +---------------------+-----------------------+---------------+ 

247 | ``specification.HC``| None | None | 

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

249 | ``specification.V`` | 100 | 1 | 

250 +---------------------+-----------------------+---------------+ 

251 | ``specification.K`` | 100 | 1 | 

252 +---------------------+-----------------------+---------------+ 

253 | ``specification.W`` | 100 | 1 | 

254 +---------------------+-----------------------+---------------+ 

255 | ``specification.D`` | 100 | 1 | 

256 +---------------------+-----------------------+---------------+ 

257 

258 References 

259 ---------- 

260 :cite:`Li2024` 

261 

262 Examples 

263 -------- 

264 >>> XYZ = np.array([19.01, 20.00, 21.78]) 

265 >>> XYZ_w = np.array([95.05, 100.00, 108.88]) 

266 >>> L_A = 318.31 

267 >>> Y_b = 20.0 

268 >>> surround = VIEWING_CONDITIONS_sCAM["Average"] 

269 >>> XYZ_to_sCAM(XYZ, XYZ_w, L_A, Y_b, surround) # doctest: +ELLIPSIS 

270 CAM_Specification_sCAM(J=49.9795668..., C=0.0140531..., h=328.2724924..., \ 

271Q=195.23024234..., M=0.0050244..., H=363.6013437..., HC=None, V=49.9795727..., \ 

272K=50.0204272..., W=34.9734327..., D=65.0265672...) 

273 """ 

274 

275 XYZ = to_domain_100(XYZ) 

276 XYZ_w = to_domain_100(XYZ_w) 

277 L_A = as_float_array(L_A) 

278 Y_b = as_float_array(Y_b) 

279 

280 Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1] 

281 

282 with sdiv_mode(): 

283 z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5) 

284 

285 F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A)) 

286 

287 with sdiv_mode(): 

288 L_A_D65 = sdiv(L_A * 100, Y_b) 

289 

290 XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None] 

291 

292 with domain_range_scale("ignore"): 

293 XYZ_D65 = chromatic_adaptation_Li2025( 

294 XYZ, XYZ_w, XYZ_w_D65, L_A, surround.F, discount_illuminant 

295 ) 

296 

297 with sdiv_mode(): 

298 XYZ_D65 = sdiv(XYZ_D65, Y_w[..., None]) 

299 

300 with domain_range_scale("ignore"): 

301 I, C, h = tsplit(sUCS_Iab_to_sUCS_ICh(XYZ_to_sUCS(XYZ_D65))) # noqa: E741 

302 

303 I_a = 100 * spow(I / 100, surround.c * z) 

304 

305 e_t = 1 + 0.06 * np.cos(np.radians(110 + h)) 

306 

307 with sdiv_mode(): 

308 M = (C * spow(F_L, 0.1) * sdiv(1, spow(I_a, 0.27)) * e_t) * surround.F 

309 # The original paper contained two inconsistent formulas for calculating Q: 

310 # Equation (15) on page 6 uses an exponent of 0.1, while page 10 uses 0.46. 

311 # After confirmation with the author, 0.1 is the recommended value. 

312 Q = sdiv(2, surround.c) * I_a * spow(F_L, 0.1) 

313 

314 H = hue_quadrature(h) 

315 

316 V = np.sqrt(I_a**2 + 3 * C**2) 

317 

318 K = 100 - V 

319 

320 D = 1.3 * np.sqrt((100 - I_a) ** 2 + 1.6 * C**2) 

321 

322 W = 100 - D 

323 

324 return CAM_Specification_sCAM( 

325 J=as_float(from_range_100(I_a)), 

326 C=as_float(from_range_100(C)), 

327 h=as_float(from_range_degrees(h)), 

328 Q=as_float(from_range_100(Q)), 

329 M=as_float(from_range_100(M)), 

330 H=as_float(from_range_degrees(H, 400)), 

331 HC=None, 

332 V=as_float(from_range_100(V)), 

333 K=as_float(from_range_100(K)), 

334 W=as_float(from_range_100(W)), 

335 D=as_float(from_range_100(D)), 

336 ) 

337 

338 

339def sCAM_to_XYZ( 

340 specification: Annotated[ 

341 CAM_Specification_sCAM, (100, 100, 360, 100, 100, 400, 100, 100, 100, 100) 

342 ], 

343 XYZ_w: Domain100, 

344 L_A: ArrayLike, 

345 Y_b: ArrayLike, 

346 surround: InductionFactors_sCAM = VIEWING_CONDITIONS_sCAM["Average"], 

347 discount_illuminant: bool = False, 

348) -> Range100: 

349 """ 

350 Convert the *sCAM* colour appearance model specification to *CIE XYZ* 

351 tristimulus values. 

352 

353 Parameters 

354 ---------- 

355 specification 

356 *sCAM* colour appearance model specification. 

357 XYZ_w 

358 *CIE XYZ* tristimulus values of reference white. 

359 L_A 

360 Adapting field *luminance* :math:`L_A` in :math:`cd/m^2`, (often 

361 taken to be 20% of the luminance of a white object in the scene). 

362 Y_b 

363 Luminous factor of background :math:`Y_b` such as 

364 :math:`Y_b = 100 \\times L_b / L_w` where :math:`L_w` is the 

365 luminance of the light source and :math:`L_b` is the luminance of 

366 the background. 

367 surround 

368 Surround viewing conditions induction factors. 

369 discount_illuminant 

370 Truth value indicating if the illuminant should be discounted. 

371 

372 Returns 

373 ------- 

374 :class:`numpy.ndarray` 

375 *CIE XYZ* tristimulus values. 

376 

377 Notes 

378 ----- 

379 +---------------------+-----------------------+---------------+ 

380 | **Domain** | **Scale - Reference** | **Scale - 1** | 

381 +=====================+=======================+===============+ 

382 | ``specification.J`` | 100 | 1 | 

383 +---------------------+-----------------------+---------------+ 

384 | ``specification.C`` | 100 | 1 | 

385 +---------------------+-----------------------+---------------+ 

386 | ``specification.h`` | 360 | 1 | 

387 +---------------------+-----------------------+---------------+ 

388 | ``specification.M`` | 100 | 1 | 

389 +---------------------+-----------------------+---------------+ 

390 | ``XYZ_w`` | 100 | 1 | 

391 +---------------------+-----------------------+---------------+ 

392 

393 +---------------------+-----------------------+---------------+ 

394 | **Range** | **Scale - Reference** | **Scale - 1** | 

395 +=====================+=======================+===============+ 

396 | ``XYZ`` | 100 | 1 | 

397 +---------------------+-----------------------+---------------+ 

398 

399 References 

400 ---------- 

401 :cite:`Li2024` 

402 

403 Examples 

404 -------- 

405 >>> specification = CAM_Specification_sCAM( 

406 ... J=49.979566801800047, C=0.014053112120697316, h=328.2724924444729 

407 ... ) 

408 >>> XYZ_w = np.array([95.05, 100.00, 108.88]) 

409 >>> L_A = 318.31 

410 >>> Y_b = 20 

411 >>> sCAM_to_XYZ(specification, XYZ_w, L_A, Y_b) # doctest: +ELLIPSIS 

412 array([ 19.01..., 20... , 21.78...]) 

413 """ 

414 

415 I_a, C, h, _Q, M, _H, _HC, _V, _K, _W, _D = astuple(specification) 

416 

417 I_a = to_domain_100(I_a) 

418 C = to_domain_100(C) if not has_only_nan(C) else None 

419 h = to_domain_degrees(h) 

420 M = to_domain_100(M) if not has_only_nan(M) else None 

421 

422 XYZ_w = to_domain_100(XYZ_w) 

423 L_A = as_float_array(L_A) 

424 Y_b = as_float_array(Y_b) 

425 

426 if has_only_nan(I_a) or has_only_nan(h): 

427 error = ( 

428 '"J" and "h" correlates must be defined in ' 

429 'the "CAM_Specification_sCAM" argument!' 

430 ) 

431 

432 raise ValueError(error) 

433 

434 if has_only_nan(C) and has_only_nan(M): # pyright: ignore 

435 error = ( 

436 'Either "C" or "M" correlate must be defined in ' 

437 'the "CAM_Specification_sCAM" argument!' 

438 ) 

439 

440 raise ValueError(error) 

441 

442 Y_w = XYZ_w[..., 1] if XYZ_w.ndim > 1 else XYZ_w[1] 

443 

444 with sdiv_mode(): 

445 z = 1.48 + spow(sdiv(Y_b, Y_w), 0.5) 

446 

447 if C is None and M is not None: 

448 F_L = 0.1710 * spow(L_A, 1 / 3) / (1 - 0.4934 * np.exp(-0.9934 * L_A)) 

449 e_t = 1 + 0.06 * np.cos(np.radians(110 + h)) 

450 

451 with sdiv_mode(): 

452 C = sdiv(M * spow(I_a, 0.27), spow(F_L, 0.1) * e_t * surround.F) 

453 

454 with sdiv_mode(): 

455 I = 100 * spow(sdiv(I_a, 100), sdiv(1, surround.c * z)) # noqa: E741 

456 

457 with domain_range_scale("ignore"): 

458 XYZ_D65 = sUCS_to_XYZ(sUCS_ICh_to_sUCS_Iab(tstack([I, C, h]))) 

459 

460 XYZ_D65 = XYZ_D65 * Y_w[..., None] 

461 

462 L_A_D65 = sdiv(L_A * 100, Y_b) 

463 XYZ_w_D65 = TVS_D65_sCAM * L_A_D65[..., None] 

464 

465 with domain_range_scale("ignore"): 

466 XYZ = chromatic_adaptation_Li2025( 

467 XYZ_D65, 

468 XYZ_w_D65, 

469 XYZ_w, 

470 L_A, 

471 surround.F, 

472 discount_illuminant, 

473 ) 

474 

475 return from_range_100(XYZ) 

476 

477 

478def hue_quadrature(h: ArrayLike) -> NDArrayFloat: 

479 """ 

480 Compute the *hue* quadrature :math:`H` from the specified *hue* angle 

481 :math:`h`. 

482 

483 Parameters 

484 ---------- 

485 h 

486 *Hue* angle :math:`h` in degrees. 

487 

488 Returns 

489 ------- 

490 :class:`numpy.ndarray` 

491 *Hue* quadrature :math:`H`. 

492 

493 Notes 

494 ----- 

495 +---------------------+-----------------------+---------------+ 

496 | **Domain** | **Scale - Reference** | **Scale - 1** | 

497 +=====================+=======================+===============+ 

498 | ``h`` | 360 | 1 | 

499 +---------------------+-----------------------+---------------+ 

500 

501 +---------------------+-----------------------+---------------+ 

502 | **Range** | **Scale - Reference** | **Scale - 1** | 

503 +=====================+=======================+===============+ 

504 | ``H`` | 400 | 1 | 

505 +---------------------+-----------------------+---------------+ 

506 

507 References 

508 ---------- 

509 :cite:`Li2024` 

510 

511 Examples 

512 -------- 

513 >>> h = np.array([0, 90, 180, 270]) 

514 >>> hue_quadrature(h) # doctest: +ELLIPSIS 

515 array([ 386.7962881..., 122.2477064..., 229.5474711..., 326.8471216...]) 

516 """ 

517 

518 h = as_float_array(h) 

519 h_n = as_float_array(h % 360) 

520 

521 h_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["h_i"] 

522 e_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["e_i"] 

523 H_i = HUE_DATA_FOR_HUE_QUADRATURE_sCAM["H_i"] 

524 

525 h_n[np.asarray(np.isnan(h_n))] = 0 

526 h_n = np.where(h_n < h_i[0], h_n + 360, h_n) 

527 

528 i = np.searchsorted(h_i, h_n, side="right") - 1 

529 i = np.clip(i, 0, len(h_i) - 2) 

530 

531 h1 = h_i[i] 

532 e1 = e_i[i] 

533 H1 = H_i[i] 

534 

535 h2_idx = (i + 1) % len(h_i) 

536 h2 = h_i[h2_idx] 

537 e2 = e_i[i + 1] 

538 

539 h2 = np.where(h2 < h1, h2 + 360, h2) 

540 

541 with sdiv_mode(): 

542 term1 = sdiv(h_n - h1, e1) 

543 term2 = sdiv(h2 - h_n, e2) 

544 

545 H = H1 + 100 * sdiv(term1, term1 + term2) 

546 

547 return as_float(H)