Coverage for quality/cqs.py: 61%
137 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Colour Quality Scale
3====================
5Define the *Colour Quality Scale* (CQS) computation objects.
7- :class:`colour.quality.ColourRendering_Specification_CQS`
8- :func:`colour.colour_quality_scale`
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"""
24from __future__ import annotations
26import typing
27from dataclasses import dataclass
29import numpy as np
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)
46if typing.TYPE_CHECKING:
47 from colour.hints import (
48 ArrayLike,
49 Dict,
50 Literal,
51 NDArrayFloat,
52 Tuple,
53 )
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
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"
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]
85GAMUT_AREA_D65: int = 8210
86"""Gamut area for *CIE Illuminant D Series D65*."""
89@dataclass
90class DataColorimetry_VS:
91 """
92 Store colorimetry data for *VS test colour samples*.
94 This dataclass encapsulates the colorimetric measurements and derived
95 values for Visual Spectrum (VS) test colour samples used in colour
96 quality evaluation.
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 """
110 name: str
111 XYZ: NDArrayFloat
112 Lab: NDArrayFloat
113 C: NDArrayFloat
116@dataclass
117class DataColourQualityScale_VS:
118 """
119 Store colour quality scale data for *VS test colour samples*.
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.
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 """
137 name: str
138 Q_a: float
139 D_C_ab: float
140 D_E_ab: float
141 D_Ep_ab: float
144@dataclass
145class ColourRendering_Specification_CQS:
146 """
147 Define the *Colour Quality Scale* (CQS) colour rendering (quality)
148 specification.
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.
180 References
181 ----------
182 :cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013`
183 """
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 ]
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.
203References
204----------
205:cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013`
206"""
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: ...
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: ...
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: ...
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.
243 Parameters
244 ----------
245 sd_test
246 Test spectral distribution.
247 additional_data
248 Whether to output additional data.
249 method
250 Computation method.
252 Returns
253 -------
254 :class:`float` or :class:`colour.quality.ColourRendering_Specification_CQS`
255 *Colour Quality Scale* (CQS).
257 References
258 ----------
259 :cite:`Davis2010a`, :cite:`Ohno2008a`, :cite:`Ohno2013`
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 """
269 method = validate_method(method, tuple(COLOUR_QUALITY_SCALE_METHODS))
271 cmfs = reshape_msds(
272 MSDS_CMFS["CIE 1931 2 Degree Standard Observer"],
273 SPECTRAL_SHAPE_DEFAULT,
274 copy=False,
275 )
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 }
283 with domain_range_scale("1"):
284 XYZ = sd_to_XYZ(sd_test, cmfs)
286 uv = UCS_to_uv(XYZ_to_UCS(XYZ))
287 CCT, _D_uv = uv_to_CCT_Ohno2013(uv)
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)
296 test_vs_colorimetry_data = vs_colorimetry_data(
297 sd_test, sd_reference, vs_sds, cmfs, chromatic_adaptation=True
298 )
300 reference_vs_colorimetry_data = vs_colorimetry_data(
301 sd_reference, sd_reference, vs_sds, cmfs
302 )
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
314 Q_as = colour_quality_scales(
315 test_vs_colorimetry_data,
316 reference_vs_colorimetry_data,
317 scaling_f,
318 CCT_f,
319 )
321 D_E_RMS = delta_E_RMS(Q_as, "D_E_ab")
322 D_Ep_RMS = delta_E_RMS(Q_as, "D_Ep_ab")
324 Q_a = scale_conversion(D_Ep_RMS, CCT_f, scaling_f)
326 scaling_f = 2.93 * 1.0343 if method == "nist cqs 9.0" else 2.928
328 Q_f = scale_conversion(D_E_RMS, CCT_f, scaling_f)
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])
333 Q_g = G_t / GAMUT_AREA_D65 * 100
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
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 )
357 return Q_a
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.
365 Parameters
366 ----------
367 Lab
368 *CIE L\\*a\\*b\\** colourspace matrices.
370 Returns
371 -------
372 :class:`float`
373 Gamut area :math:`G`.
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 """
398 Lab = as_float_array(Lab)
399 Lab_s = np.roll(np.copy(Lab), -3)
401 _L, a, b = tsplit(Lab)
402 _L_s, a_s, b_s = tsplit(Lab_s)
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))
410 return np.sum(S)
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.
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.
436 Returns
437 -------
438 :class:`tuple`
439 *VS test colour samples* colorimetry data.
440 """
442 XYZ_t = sd_to_XYZ(sd_test, cmfs)
444 with sdiv_mode():
445 XYZ_t = sdiv(XYZ_t, XYZ_t[1])
447 XYZ_r = sd_to_XYZ(sd_reference, cmfs)
449 with sdiv_mode():
450 XYZ_r = sdiv(XYZ_r, XYZ_r[1])
452 xy_r = XYZ_to_xy(XYZ_r)
454 vs_data = []
455 for _key, value in sorted(INDEXES_TO_NAMES_VS.items()):
456 sd_vs = sds_vs[value]
458 with domain_range_scale("1"):
459 XYZ_vs = sd_to_XYZ(sd_vs, cmfs, sd_test)
461 if chromatic_adaptation:
462 XYZ_vs = chromatic_adaptation_VonKries(
463 XYZ_vs, XYZ_t, XYZ_r, transform="CMCCAT2000"
464 )
466 Lab_vs = XYZ_to_Lab(XYZ_vs, illuminant=xy_r)
467 _L_vs, C_vs, _Hab = Lab_to_LCHab(Lab_vs)
469 vs_data.append(DataColorimetry_VS(sd_vs.name, XYZ_vs, Lab_vs, C_vs))
471 return tuple(vs_data)
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.
481 Parameters
482 ----------
483 reference_data
484 Reference colorimetry data.
485 XYZ_r
486 *CIE XYZ* tristimulus values for reference.
488 Returns
489 -------
490 :class:`float`
491 Correlated colour temperature factor.
492 """
494 xy_w = CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D65"]
495 XYZ_w = xy_to_XYZ(xy_w)
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 )
507 G_r = gamut_area(Lab) / GAMUT_AREA_D65
509 return min(G_r, 1)
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.
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.
527 Returns
528 -------
529 :class:`float`
530 *Colour Quality Scale* (CQS).
531 """
533 return 10 * np.log1p(np.exp((100 - scaling_f * D_E_ab) / 10)) * CCT_f
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.
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.
551 Returns
552 -------
553 :class:`float`
554 Root-mean-square average.
555 """
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 )
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.
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.
588 Returns
589 -------
590 :class:`dict`
591 *VS test colour samples* colour rendering scales.
592 """
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 )
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 )
609 return Q_as