Coverage for colour/quality/cqs.py: 100%

143 statements  

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

1""" 

2Colour Quality Scale 

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

4 

5Define the *Colour Quality Scale* (CQS) computation objects. 

6 

7- :class:`colour.quality.ColourRendering_Specification_CQS` 

8- :func:`colour.colour_quality_scale` 

9 

10References 

11---------- 

12- :cite:`Davis2010a` : Davis, W., & Ohno, Y. (2010). Color quality scale. 

13 Optical Engineering, 49(3), 033602. doi:10.1117/1.3360335 

14- :cite:`Ohno2008a` : Ohno, Yoshiro, & Davis, W. (2008). NIST CQS simulation 

15 (Version 7.4) [Computer software]. 

16 https://drive.google.com/file/d/1PsuU6QjUJjCX6tQyCud6ul2Tbs8rYWW9/view?\ 

17usp=sharing 

18- :cite:`Ohno2013` : Ohno, Yoshiro, & Davis, W. (2008). NIST CQS simulation 

19 (Version 7.4) [Computer software]. 

20 https://drive.google.com/file/d/1PsuU6QjUJjCX6tQyCud6ul2Tbs8rYWW9/view?\ 

21usp=sharing 

22""" 

23 

24from __future__ import annotations 

25 

26import typing 

27from dataclasses import dataclass 

28 

29import numpy as np 

30 

31from colour.adaptation import chromatic_adaptation_VonKries 

32from colour.algebra import euclidean_distance, sdiv, sdiv_mode 

33from colour.colorimetry import ( 

34 CCS_ILLUMINANTS, 

35 MSDS_CMFS, 

36 SPECTRAL_SHAPE_DEFAULT, 

37 MultiSpectralDistributions, 

38 SpectralDistribution, 

39 reshape_msds, 

40 reshape_sd, 

41 sd_blackbody, 

42 sd_CIE_illuminant_D_series, 

43 sd_to_XYZ, 

44) 

45 

46if typing.TYPE_CHECKING: 

47 from colour.hints import ( 

48 ArrayLike, 

49 Dict, 

50 Literal, 

51 NDArrayFloat, 

52 Tuple, 

53 ) 

54 

55from colour.hints import cast 

56from colour.models import Lab_to_LCHab # pyright: ignore 

57from colour.models import UCS_to_uv, XYZ_to_Lab, XYZ_to_UCS, XYZ_to_xy, xy_to_XYZ 

58from colour.quality.datasets.vs import INDEXES_TO_NAMES_VS, SDS_VS 

59from colour.temperature import CCT_to_xy_CIE_D, uv_to_CCT_Ohno2013 

60from colour.utilities import as_float_array, domain_range_scale, tsplit, validate_method 

61from colour.utilities.documentation import DocstringTuple, is_documentation_building 

62 

63__author__ = "Colour Developers" 

64__copyright__ = "Copyright 2013 Colour Developers" 

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

66__maintainer__ = "Colour Developers" 

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

68__status__ = "Production" 

69 

70__all__ = [ 

71 "GAMUT_AREA_D65", 

72 "DataColorimetry_VS", 

73 "DataColourQualityScale_VS", 

74 "ColourRendering_Specification_CQS", 

75 "COLOUR_QUALITY_SCALE_METHODS", 

76 "colour_quality_scale", 

77 "gamut_area", 

78 "vs_colorimetry_data", 

79 "CCT_factor", 

80 "scale_conversion", 

81 "delta_E_RMS", 

82 "colour_quality_scales", 

83] 

84 

85GAMUT_AREA_D65: int = 8210 

86"""Gamut area for *CIE Illuminant D Series D65*.""" 

87 

88 

89@dataclass 

90class DataColorimetry_VS: 

91 """ 

92 Store colorimetry data for *VS test colour samples*. 

93 

94 This dataclass encapsulates the colorimetric measurements and derived 

95 values for Visual Spectrum (VS) test colour samples used in colour 

96 quality evaluation. 

97 

98 Attributes 

99 ---------- 

100 name 

101 Sample identifier or designation. 

102 XYZ 

103 Tristimulus values under the test illuminant. 

104 Lab 

105 *CIE L\\*a\\*b\\** colour space coordinates. 

106 C 

107 Chroma values calculated from the *CIE L\\*a\\*b\\** coordinates. 

108 """ 

109 

110 name: str 

111 XYZ: NDArrayFloat 

112 Lab: NDArrayFloat 

113 C: NDArrayFloat 

114 

115 

116@dataclass 

117class DataColourQualityScale_VS: 

118 """ 

119 Store colour quality scale data for *VS test colour samples*. 

120 

121 This dataclass encapsulates the colour quality metrics computed for VS 

122 (Visual Samples) test colour samples, including quality assessment and 

123 colour difference measurements used in colour rendering evaluations. 

124 

125 Attributes 

126 ---------- 

127 name 

128 Identifier or descriptor for the test colour sample. 

129 Q_a 

130 Colour quality scale value for the sample. 

131 D_C_ab 

132 Chroma difference in *CIE L\\*a\\*b\\** colourspace. 

133 D_E_ab 

134 Total colour difference in *CIE L\\*a\\*b\\** colourspace. 

135 """ 

136 

137 name: str 

138 Q_a: float 

139 D_C_ab: float 

140 D_E_ab: float 

141 D_Ep_ab: float 

142 

143 

144@dataclass 

145class ColourRendering_Specification_CQS: 

146 """ 

147 Define the *Colour Quality Scale* (CQS) colour rendering (quality) 

148 specification. 

149 

150 Parameters 

151 ---------- 

152 name 

153 Name of the test spectral distribution. 

154 Q_a 

155 Colour quality scale :math:`Q_a`. 

156 Q_f 

157 Colour fidelity scale :math:`Q_f` intended to evaluate the 

158 fidelity of object colour appearances (compared to the reference 

159 illuminant of the same correlated colour temperature and 

160 illuminance). 

161 Q_p 

162 Colour preference scale :math:`Q_p` similar to colour quality 

163 scale :math:`Q_a` but placing additional weight on preference of 

164 object colour appearance, set to *None* in *NIST CQS 9.0* method. 

165 This metric is based on the notion that increases in chroma are 

166 generally preferred and should be rewarded. 

167 Q_g 

168 Gamut area scale :math:`Q_g` representing the relative gamut 

169 formed by the (:math:`a^*`, :math:`b^*`) coordinates of the 15 

170 samples illuminated by the test light source in the 

171 *CIE L\\*a\\*b\\** object colourspace. 

172 Q_d 

173 Relative gamut area scale :math:`Q_d`, set to *None* in 

174 *NIST CQS 9.0* method. 

175 Q_as 

176 Individual *Colour Quality Scale* (CQS) data for each sample. 

177 colorimetry_data 

178 Colorimetry data for the test and reference computations. 

179 

180 References 

181 ---------- 

182 :cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013` 

183 """ 

184 

185 name: str 

186 Q_a: float 

187 Q_f: float 

188 Q_p: float | None 

189 Q_g: float 

190 Q_d: float | None 

191 Q_as: Dict[int, DataColourQualityScale_VS] 

192 colorimetry_data: Tuple[ 

193 Tuple[DataColorimetry_VS, ...], Tuple[DataColorimetry_VS, ...] 

194 ] 

195 

196 

197COLOUR_QUALITY_SCALE_METHODS: tuple = ("NIST CQS 7.4", "NIST CQS 9.0") 

198if is_documentation_building(): # pragma: no cover 

199 COLOUR_QUALITY_SCALE_METHODS = DocstringTuple(COLOUR_QUALITY_SCALE_METHODS) 

200 COLOUR_QUALITY_SCALE_METHODS.__doc__ = """ 

201Supported *Colour Quality Scale* (CQS) computation methods. 

202 

203References 

204---------- 

205:cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013` 

206""" 

207 

208 

209@typing.overload 

210def colour_quality_scale( 

211 sd_test: SpectralDistribution, 

212 *, 

213 additional_data: Literal[False], 

214 method: Literal["NIST CQS 7.4", "NIST CQS 9.0"] | str = ..., 

215) -> float: ... 

216 

217 

218@typing.overload 

219def colour_quality_scale( 

220 sd_test: SpectralDistribution, 

221 additional_data: Literal[True] = True, 

222 method: Literal["NIST CQS 7.4", "NIST CQS 9.0"] | str = ..., 

223) -> ColourRendering_Specification_CQS: ... 

224 

225 

226@typing.overload 

227def colour_quality_scale( 

228 sd_test: SpectralDistribution, 

229 additional_data: Literal[False], 

230 method: Literal["NIST CQS 7.4", "NIST CQS 9.0"] | str = ..., 

231) -> float: ... 

232 

233 

234def colour_quality_scale( 

235 sd_test: SpectralDistribution, 

236 additional_data: bool = False, 

237 method: Literal["NIST CQS 7.4", "NIST CQS 9.0"] | str = "NIST CQS 9.0", 

238) -> float | ColourRendering_Specification_CQS: 

239 """ 

240 Compute the *Colour Quality Scale* (CQS) of the specified spectral 

241 distribution using the specified method. 

242 

243 Parameters 

244 ---------- 

245 sd_test 

246 Test spectral distribution. 

247 additional_data 

248 Whether to output additional data. 

249 method 

250 Computation method. 

251 

252 Returns 

253 ------- 

254 :class:`float` or :class:`colour.quality.ColourRendering_Specification_CQS` 

255 *Colour Quality Scale* (CQS). 

256 

257 References 

258 ---------- 

259 :cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013` 

260 

261 Examples 

262 -------- 

263 >>> from colour import SDS_ILLUMINANTS 

264 >>> sd = SDS_ILLUMINANTS["FL2"] 

265 >>> colour_quality_scale(sd) # doctest: +ELLIPSIS 

266 64.1118220... 

267 """ 

268 

269 method = validate_method(method, tuple(COLOUR_QUALITY_SCALE_METHODS)) 

270 

271 cmfs = reshape_msds( 

272 MSDS_CMFS["CIE 1931 2 Degree Standard Observer"], 

273 SPECTRAL_SHAPE_DEFAULT, 

274 copy=False, 

275 ) 

276 

277 shape = cmfs.shape 

278 sd_test = reshape_sd(sd_test, shape, copy=False) 

279 vs_sds = { 

280 sd.name: reshape_sd(sd, shape, copy=False) for sd in SDS_VS[method].values() 

281 } 

282 

283 with domain_range_scale("1"): 

284 XYZ = sd_to_XYZ(sd_test, cmfs) 

285 

286 uv = UCS_to_uv(XYZ_to_UCS(XYZ)) 

287 CCT, _D_uv = uv_to_CCT_Ohno2013(uv) 

288 

289 if CCT < 5000: 

290 sd_reference = sd_blackbody(CCT, shape) 

291 else: 

292 xy = CCT_to_xy_CIE_D(CCT) 

293 sd_reference = sd_CIE_illuminant_D_series(xy) 

294 sd_reference.align(shape) 

295 

296 test_vs_colorimetry_data = vs_colorimetry_data( 

297 sd_test, sd_reference, vs_sds, cmfs, chromatic_adaptation=True 

298 ) 

299 

300 reference_vs_colorimetry_data = vs_colorimetry_data( 

301 sd_reference, sd_reference, vs_sds, cmfs 

302 ) 

303 

304 CCT_f: float 

305 if method == "nist cqs 9.0": 

306 CCT_f = 1 

307 scaling_f = 3.2 

308 else: 

309 XYZ_r = sd_to_XYZ(sd_reference, cmfs) 

310 XYZ_r /= XYZ_r[1] 

311 CCT_f = CCT_factor(reference_vs_colorimetry_data, XYZ_r) 

312 scaling_f = 3.104 

313 

314 Q_as = colour_quality_scales( 

315 test_vs_colorimetry_data, 

316 reference_vs_colorimetry_data, 

317 scaling_f, 

318 CCT_f, 

319 ) 

320 

321 D_E_RMS = delta_E_RMS(Q_as, "D_E_ab") 

322 D_Ep_RMS = delta_E_RMS(Q_as, "D_Ep_ab") 

323 

324 Q_a = scale_conversion(D_Ep_RMS, CCT_f, scaling_f) 

325 

326 scaling_f = 2.93 * 1.0343 if method == "nist cqs 9.0" else 2.928 

327 

328 Q_f = scale_conversion(D_E_RMS, CCT_f, scaling_f) 

329 

330 G_t = gamut_area([vs_CQS_data.Lab for vs_CQS_data in test_vs_colorimetry_data]) 

331 G_r = gamut_area([vs_CQS_data.Lab for vs_CQS_data in reference_vs_colorimetry_data]) 

332 

333 Q_g = G_t / GAMUT_AREA_D65 * 100 

334 

335 if method == "nist cqs 9.0": 

336 Q_p = Q_d = None 

337 else: 

338 p_delta_C = cast( 

339 "float", 

340 np.average([max(0, sample_data.D_C_ab) for sample_data in Q_as.values()]), 

341 ) 

342 Q_p = 100 - 3.6 * (D_Ep_RMS - p_delta_C) 

343 Q_d = G_t / G_r * CCT_f * 100 

344 

345 if additional_data: 

346 return ColourRendering_Specification_CQS( 

347 sd_test.name, 

348 Q_a, 

349 Q_f, 

350 Q_p, 

351 Q_g, 

352 Q_d, 

353 Q_as, 

354 (test_vs_colorimetry_data, reference_vs_colorimetry_data), 

355 ) 

356 

357 return Q_a 

358 

359 

360def gamut_area(Lab: ArrayLike) -> float: 

361 """ 

362 Compute the gamut area :math:`G` covered by the specified 

363 *CIE L\\*a\\*b\\** colourspace matrices. 

364 

365 Parameters 

366 ---------- 

367 Lab 

368 *CIE L\\*a\\*b\\** colourspace matrices. 

369 

370 Returns 

371 ------- 

372 :class:`float` 

373 Gamut area :math:`G`. 

374 

375 Examples 

376 -------- 

377 >>> Lab = [ 

378 ... np.array([39.94996006, 34.59018231, -19.86046321]), 

379 ... np.array([38.88395498, 21.44348519, -34.87805301]), 

380 ... np.array([36.60576301, 7.06742454, -43.21461177]), 

381 ... np.array([46.60142558, -15.90481586, -34.64616865]), 

382 ... np.array([56.50196523, -29.54655550, -20.50177194]), 

383 ... np.array([55.73912101, -43.39520959, -5.08956953]), 

384 ... np.array([56.20776870, -53.68997662, 20.21134410]), 

385 ... np.array([66.16683122, -38.64600327, 42.77396631]), 

386 ... np.array([76.72952110, -23.92148210, 61.04740432]), 

387 ... np.array([82.85370708, -3.98679065, 75.43320144]), 

388 ... np.array([69.26458861, 13.11066359, 68.83858372]), 

389 ... np.array([69.63154351, 28.24532497, 59.45609803]), 

390 ... np.array([61.26281449, 40.87950839, 44.97606172]), 

391 ... np.array([41.62567821, 57.34129516, 27.46718170]), 

392 ... np.array([40.52565174, 48.87449192, 3.45121680]), 

393 ... ] 

394 >>> gamut_area(Lab) # doctest: +ELLIPSIS 

395 8335.9482018... 

396 """ 

397 

398 Lab = as_float_array(Lab) 

399 Lab_s = np.roll(np.copy(Lab), -3) 

400 

401 _L, a, b = tsplit(Lab) 

402 _L_s, a_s, b_s = tsplit(Lab_s) 

403 

404 A = np.linalg.norm(Lab[..., 1:3], axis=-1) 

405 B = np.linalg.norm(Lab_s[..., 1:3], axis=-1) 

406 C = np.linalg.norm(np.dstack([a_s - a, b_s - b]), axis=-1) 

407 t = (A + B + C) / 2 

408 S = np.sqrt(t * (t - A) * (t - B) * (t - C)) 

409 

410 return np.sum(S) 

411 

412 

413def vs_colorimetry_data( 

414 sd_test: SpectralDistribution, 

415 sd_reference: SpectralDistribution, 

416 sds_vs: Dict[str, SpectralDistribution], 

417 cmfs: MultiSpectralDistributions, 

418 chromatic_adaptation: bool = False, 

419) -> Tuple[DataColorimetry_VS, ...]: 

420 """ 

421 Compute the *VS test colour samples* colorimetry data. 

422 

423 Parameters 

424 ---------- 

425 sd_test 

426 Test spectral distribution. 

427 sd_reference 

428 Reference spectral distribution. 

429 sds_vs 

430 *VS test colour samples* spectral reflectance distributions. 

431 cmfs 

432 Standard observer colour matching functions. 

433 chromatic_adaptation 

434 Whether to perform chromatic adaptation. 

435 

436 Returns 

437 ------- 

438 :class:`tuple` 

439 *VS test colour samples* colorimetry data. 

440 """ 

441 

442 XYZ_t = sd_to_XYZ(sd_test, cmfs) 

443 

444 with sdiv_mode(): 

445 XYZ_t = sdiv(XYZ_t, XYZ_t[1]) 

446 

447 XYZ_r = sd_to_XYZ(sd_reference, cmfs) 

448 

449 with sdiv_mode(): 

450 XYZ_r = sdiv(XYZ_r, XYZ_r[1]) 

451 

452 xy_r = XYZ_to_xy(XYZ_r) 

453 

454 vs_data = [] 

455 for _key, value in sorted(INDEXES_TO_NAMES_VS.items()): 

456 sd_vs = sds_vs[value] 

457 

458 with domain_range_scale("1"): 

459 XYZ_vs = sd_to_XYZ(sd_vs, cmfs, sd_test) 

460 

461 if chromatic_adaptation: 

462 XYZ_vs = chromatic_adaptation_VonKries( 

463 XYZ_vs, XYZ_t, XYZ_r, transform="CMCCAT2000" 

464 ) 

465 

466 Lab_vs = XYZ_to_Lab(XYZ_vs, illuminant=xy_r) 

467 _L_vs, C_vs, _Hab = Lab_to_LCHab(Lab_vs) 

468 

469 vs_data.append(DataColorimetry_VS(sd_vs.name, XYZ_vs, Lab_vs, C_vs)) 

470 

471 return tuple(vs_data) 

472 

473 

474def CCT_factor( 

475 reference_data: Tuple[DataColorimetry_VS, ...], XYZ_r: ArrayLike 

476) -> float: 

477 """ 

478 Compute the correlated colour temperature factor that penalizes lamps 

479 with extremely low correlated colour temperatures. 

480 

481 Parameters 

482 ---------- 

483 reference_data 

484 Reference colorimetry data. 

485 XYZ_r 

486 *CIE XYZ* tristimulus values for reference. 

487 

488 Returns 

489 ------- 

490 :class:`float` 

491 Correlated colour temperature factor. 

492 """ 

493 

494 xy_w = CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D65"] 

495 XYZ_w = xy_to_XYZ(xy_w) 

496 

497 Lab = XYZ_to_Lab( 

498 chromatic_adaptation_VonKries( 

499 [colorimetry_data.XYZ for colorimetry_data in reference_data], 

500 XYZ_r, 

501 XYZ_w, 

502 transform="CMCCAT2000", 

503 ), 

504 illuminant=xy_w, 

505 ) 

506 

507 G_r = gamut_area(Lab) / GAMUT_AREA_D65 

508 

509 return min(G_r, 1) 

510 

511 

512def scale_conversion(D_E_ab: float, CCT_f: float, scaling_f: float) -> float: 

513 """ 

514 Compute the *Colour Quality Scale* (CQS) for the specified 

515 :math:`\\Delta E_{ab}` value and correlated colour temperature 

516 penalizing factor. 

517 

518 Parameters 

519 ---------- 

520 D_E_ab 

521 :math:`\\Delta E_{ab}` value. 

522 CCT_f 

523 Correlated colour temperature penalizing factor. 

524 scaling_f 

525 Scaling factor constant. 

526 

527 Returns 

528 ------- 

529 :class:`float` 

530 *Colour Quality Scale* (CQS). 

531 """ 

532 

533 return 10 * np.log1p(np.exp((100 - scaling_f * D_E_ab) / 10)) * CCT_f 

534 

535 

536def delta_E_RMS( 

537 CQS_data: Dict[int, DataColourQualityScale_VS], attribute: str 

538) -> float: 

539 """ 

540 Compute the root-mean-square average for the specified *Colour Quality 

541 Scale* (CQS) data using the specified colorimetry attribute. 

542 

543 Parameters 

544 ---------- 

545 CQS_data 

546 *Colour Quality Scale* (CQS) data. 

547 attribute 

548 Colorimetry data attribute to use for computing the 

549 root-mean-square average. 

550 

551 Returns 

552 ------- 

553 :class:`float` 

554 Root-mean-square average. 

555 """ 

556 

557 return np.sqrt( 

558 1 

559 / len(CQS_data) 

560 * np.sum( 

561 [getattr(sample_data, attribute) ** 2 for sample_data in CQS_data.values()] 

562 ) 

563 ) 

564 

565 

566def colour_quality_scales( 

567 test_data: Tuple[DataColorimetry_VS, ...], 

568 reference_data: Tuple[DataColorimetry_VS, ...], 

569 scaling_f: float, 

570 CCT_f: float, 

571) -> Dict[int, DataColourQualityScale_VS]: 

572 """ 

573 Compute the *VS test colour samples* rendering scales. 

574 

575 Parameters 

576 ---------- 

577 test_data 

578 Test data for the VS colour samples. 

579 reference_data 

580 Reference data for the VS colour samples. 

581 scaling_f 

582 Scaling factor constant for normalizing the colour rendering 

583 scales. 

584 CCT_f 

585 Factor penalizing light sources with extremely low correlated 

586 colour temperatures. 

587 

588 Returns 

589 ------- 

590 :class:`dict` 

591 *VS test colour samples* colour rendering scales. 

592 """ 

593 

594 Q_as = {} 

595 for i in range(len(test_data)): 

596 D_C_ab = cast("float", test_data[i].C - reference_data[i].C) 

597 D_E_ab = cast( 

598 "float", euclidean_distance(test_data[i].Lab, reference_data[i].Lab) 

599 ) 

600 D_Ep_ab = cast( 

601 "float", np.sqrt(D_E_ab**2 - D_C_ab**2) if D_C_ab > 0 else D_E_ab 

602 ) 

603 

604 Q_a = scale_conversion(D_Ep_ab, CCT_f, scaling_f) 

605 Q_as[i + 1] = DataColourQualityScale_VS( 

606 test_data[i].name, Q_a, D_C_ab, D_E_ab, D_Ep_ab 

607 ) 

608 

609 return Q_as