Coverage for appearance/scam.py: 73%

1"""

2 sCAM Colour Appearance Model

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

4

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

6 attributes 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

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

15 Uniform Colour Space).

16

17 References

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

24 from __future__ import annotations

25

26 from dataclasses import astuple, dataclass, field

27

28 import numpy as np

29

30 from colour.adaptation import chromatic_adaptation_Li2025

31 from colour.algebra import sdiv, sdiv_mode, spow

32 from colour.hints import ( # noqa: TC001

33 Annotated,

34 ArrayLike,

35 Domain100,

36 NDArrayFloat,

37 Range100,

38)

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

45 from 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

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

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

81

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

91 class 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

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

121 VIEWING_CONDITIONS_sCAM.__doc__ = """

122 Define the reference *sCAM* colour appearance model

123 viewing conditions.

124

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

126 their corresponding induction factors that characterize chromatic

127 adaptation and perceptual non-linearities under different viewing

128 environments.

129"""

130

131

132@dataclass

133 class 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

181 def 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..., \

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

272 K=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

339 def 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

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