Coverage for colour/volume/spectrum.py: 100%
50 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
1"""
2Rösch-MacAdam Colour Solid - Visible Spectrum Volume Computations
3=================================================================
5Define objects for computing and analyzing the *Rösch-MacAdam* colour
6solid and visible spectrum volume boundaries.
8References
9----------
10- :cite:`Lindbloom2015` : Lindbloom, B. (2015). About the Lab Gamut.
11 Retrieved August 20, 2018, from
12 http://www.brucelindbloom.com/LabGamutDisplayHelp.html
13- :cite:`Mansencal2018` : Mansencal, T. (2018). How is the visible gamut
14 bounded? Retrieved August 19, 2018, from
15 https://stackoverflow.com/a/48396021/931625
16- :cite:`Martinez-Verdu2007` : Martínez-Verdú, F., Perales, E., Chorro, E.,
17 de Fez, D., Viqueira, V., & Gilabert, E. (2007). Computation and
18 visualization of the MacAdam limits for any lightness, hue angle, and light
19 source. Journal of the Optical Society of America A, 24(6), 1501.
20 doi:10.1364/JOSAA.24.001501
21"""
23from __future__ import annotations
25import typing
27import numpy as np
29from colour.colorimetry import (
30 MultiSpectralDistributions,
31 SpectralDistribution,
32 SpectralShape,
33 handle_spectral_arguments,
34 msds_to_XYZ,
35)
36from colour.constants import DTYPE_FLOAT_DEFAULT, EPSILON
38if typing.TYPE_CHECKING:
39 from colour.hints import (
40 Any,
41 ArrayLike,
42 Literal,
43 NDArrayFloat,
44 )
46from colour.utilities import CACHE_REGISTRY, is_caching_enabled, validate_method, zeros
47from colour.volume import is_within_mesh_volume
49__author__ = "Colour Developers"
50__copyright__ = "Copyright 2013 Colour Developers"
51__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
52__maintainer__ = "Colour Developers"
53__email__ = "colour-developers@colour-science.org"
54__status__ = "Production"
56__all__ = [
57 "SPECTRAL_SHAPE_OUTER_SURFACE_XYZ",
58 "generate_pulse_waves",
59 "XYZ_outer_surface",
60 "solid_RoschMacAdam",
61 "is_within_visible_spectrum",
62]
64SPECTRAL_SHAPE_OUTER_SURFACE_XYZ: SpectralShape = SpectralShape(360, 780, 5)
65"""
66Default spectral shape according to *ASTM E308-15* practise shape but using an
67interval of 5.
68"""
70_CACHE_OUTER_SURFACE_XYZ: dict = CACHE_REGISTRY.register_cache(
71 f"{__name__}._CACHE_OUTER_SURFACE_XYZ"
72)
74_CACHE_OUTER_SURFACE_XYZ_POINTS: dict = CACHE_REGISTRY.register_cache(
75 f"{__name__}._CACHE_OUTER_SURFACE_XYZ_POINTS"
76)
79def generate_pulse_waves(
80 bins: int,
81 pulse_order: Literal["Bins", "Pulse Wave Width"] | str = "Bins",
82 filter_jagged_pulses: bool = False,
83) -> NDArrayFloat:
84 """
85 Generate pulse waves of the specified number of bins necessary to fully
86 stimulate the colour matching functions and produce the *Rösch-MacAdam*
87 colour solid.
89 Assuming 5 bins, a first set of SPDs would be as follows::
91 1 0 0 0 0
92 0 1 0 0 0
93 0 0 1 0 0
94 0 0 0 1 0
95 0 0 0 0 1
97 The second one::
99 1 1 0 0 0
100 0 1 1 0 0
101 0 0 1 1 0
102 0 0 0 1 1
103 1 0 0 0 1
105 The third:
107 1 1 1 0 0
108 0 1 1 1 0
109 0 0 1 1 1
110 1 0 0 1 1
111 1 1 0 0 1
113 Etc...
115 Parameters
116 ----------
117 bins
118 Number of bins of the pulse waves.
119 pulse_order
120 Method for ordering the pulse waves. *Bins* is the default order,
121 with *Pulse Wave Width* ordering, instead of iterating over the
122 pulse wave widths first, iteration occurs over the bins, producing
123 blocks of pulse waves with increasing width.
124 filter_jagged_pulses
125 Whether to filter jagged pulses. When ``pulse_order`` is set to
126 *Pulse Wave Width*, the pulses are ordered by increasing width.
127 Because of the discrete nature of the underlying signal, the
128 resulting pulses will be jagged. For example assuming 5 bins, the
129 center block with the two extreme values added would be as
130 follows::
132 0 0 0 0 0
133 0 0 1 0 0
134 0 0 1 1 0 <--
135 0 1 1 1 0
136 0 1 1 1 1 <--
137 1 1 1 1 1
139 Setting the ``filter_jagged_pulses`` parameter to `True` will
140 result in the removal of the two marked pulse waves above thus
141 avoiding jagged lines when plotting and having to resort to
142 excessive ``bins`` values.
144 Returns
145 -------
146 :class:`numpy.ndarray`
147 Pulse waves.
149 References
150 ----------
151 :cite:`Lindbloom2015`, :cite:`Mansencal2018`,
152 :cite:`Martinez-Verdu2007`
154 Examples
155 --------
156 >>> generate_pulse_waves(5)
157 array([[ 0., 0., 0., 0., 0.],
158 [ 1., 0., 0., 0., 0.],
159 [ 0., 1., 0., 0., 0.],
160 [ 0., 0., 1., 0., 0.],
161 [ 0., 0., 0., 1., 0.],
162 [ 0., 0., 0., 0., 1.],
163 [ 1., 1., 0., 0., 0.],
164 [ 0., 1., 1., 0., 0.],
165 [ 0., 0., 1., 1., 0.],
166 [ 0., 0., 0., 1., 1.],
167 [ 1., 0., 0., 0., 1.],
168 [ 1., 1., 1., 0., 0.],
169 [ 0., 1., 1., 1., 0.],
170 [ 0., 0., 1., 1., 1.],
171 [ 1., 0., 0., 1., 1.],
172 [ 1., 1., 0., 0., 1.],
173 [ 1., 1., 1., 1., 0.],
174 [ 0., 1., 1., 1., 1.],
175 [ 1., 0., 1., 1., 1.],
176 [ 1., 1., 0., 1., 1.],
177 [ 1., 1., 1., 0., 1.],
178 [ 1., 1., 1., 1., 1.]])
179 >>> generate_pulse_waves(5, "Pulse Wave Width")
180 array([[ 0., 0., 0., 0., 0.],
181 [ 1., 0., 0., 0., 0.],
182 [ 1., 1., 0., 0., 0.],
183 [ 1., 1., 0., 0., 1.],
184 [ 1., 1., 1., 0., 1.],
185 [ 0., 1., 0., 0., 0.],
186 [ 0., 1., 1., 0., 0.],
187 [ 1., 1., 1., 0., 0.],
188 [ 1., 1., 1., 1., 0.],
189 [ 0., 0., 1., 0., 0.],
190 [ 0., 0., 1., 1., 0.],
191 [ 0., 1., 1., 1., 0.],
192 [ 0., 1., 1., 1., 1.],
193 [ 0., 0., 0., 1., 0.],
194 [ 0., 0., 0., 1., 1.],
195 [ 0., 0., 1., 1., 1.],
196 [ 1., 0., 1., 1., 1.],
197 [ 0., 0., 0., 0., 1.],
198 [ 1., 0., 0., 0., 1.],
199 [ 1., 0., 0., 1., 1.],
200 [ 1., 1., 0., 1., 1.],
201 [ 1., 1., 1., 1., 1.]])
202 >>> generate_pulse_waves(5, "Pulse Wave Width", True)
203 array([[ 0., 0., 0., 0., 0.],
204 [ 1., 0., 0., 0., 0.],
205 [ 1., 1., 0., 0., 1.],
206 [ 0., 1., 0., 0., 0.],
207 [ 1., 1., 1., 0., 0.],
208 [ 0., 0., 1., 0., 0.],
209 [ 0., 1., 1., 1., 0.],
210 [ 0., 0., 0., 1., 0.],
211 [ 0., 0., 1., 1., 1.],
212 [ 0., 0., 0., 0., 1.],
213 [ 1., 0., 0., 1., 1.],
214 [ 1., 1., 1., 1., 1.]])
215 """
217 pulse_order = validate_method(
218 pulse_order,
219 ("Bins", "Pulse Wave Width"),
220 '"{0}" pulse order is invalid, it must be one of {1}!',
221 )
223 square_waves = []
224 square_waves_basis = np.tril(np.ones((bins, bins), dtype=DTYPE_FLOAT_DEFAULT))[
225 0:-1, :
226 ]
228 if pulse_order.lower() == "bins":
229 for square_wave_basis in square_waves_basis:
230 for i in range(bins):
231 square_waves.append(np.roll(square_wave_basis, i)) # noqa: PERF401
232 else:
233 for i in range(bins):
234 for j, square_wave_basis in enumerate(square_waves_basis):
235 square_waves.append(np.roll(square_wave_basis, i - j // 2))
237 if filter_jagged_pulses:
238 square_waves = square_waves[::2]
240 return np.vstack(
241 [
242 zeros(bins),
243 np.vstack(square_waves),
244 np.ones(bins, dtype=DTYPE_FLOAT_DEFAULT),
245 ]
246 )
249def XYZ_outer_surface(
250 cmfs: MultiSpectralDistributions | None = None,
251 illuminant: SpectralDistribution | None = None,
252 point_order: Literal["Bins", "Pulse Wave Width"] | str = "Bins",
253 filter_jagged_points: bool = False,
254 **kwargs: Any,
255) -> NDArrayFloat:
256 """
257 Generate the *Rösch-MacAdam* colour solid, i.e., *CIE XYZ*
258 colourspace outer surface, for the specified colour matching functions
259 using multi-spectral conversion of pulse waves to *CIE XYZ*
260 tristimulus values.
262 Parameters
263 ----------
264 cmfs
265 Standard observer colour matching functions, default to the
266 *CIE 1931 2 Degree Standard Observer*.
267 illuminant
268 Illuminant spectral distribution, default to *CIE Illuminant E*.
269 point_order
270 Method for ordering the underlying pulse waves used to generate
271 the *Rösch-MacAdam* colour solid. *Bins* is the default order,
272 with *Pulse Wave Width* ordering, instead of iterating over the
273 pulse wave widths first, iteration occurs over the bins,
274 producing blocks of pulse waves with increasing width.
275 filter_jagged_points
276 Whether to filter the underlying jagged pulses. When
277 ``point_order`` is set to *Pulse Wave Width*, the pulses are
278 ordered by increasing width. Because of the discrete nature of the
279 underlying signal, the resulting pulses will be jagged. For
280 example assuming 5 bins, the center block with the two extreme
281 values added would be as follows::
283 0 0 0 0 0
284 0 0 1 0 0
285 0 0 1 1 0 <--
286 0 1 1 1 0
287 0 1 1 1 1 <--
288 1 1 1 1 1
290 Setting the ``filter_jagged_points`` parameter to `True` will
291 result in the removal of the two marked pulse waves above thus
292 avoiding jagged lines when plotting and having to resort to
293 excessive ``bins`` values.
295 Other Parameters
296 ----------------
297 kwargs
298 {:func:`colour.msds_to_XYZ`},
299 See the documentation of the previously listed definition.
301 Returns
302 -------
303 :class:`numpy.ndarray`
304 *Rösch-MacAdam* colour solid, *CIE XYZ* outer surface
305 tristimulus values.
307 References
308 ----------
309 :cite:`Lindbloom2015`, :cite:`Mansencal2018`,
310 :cite:`Martinez-Verdu2007`
312 Examples
313 --------
314 >>> from colour import MSDS_CMFS, SPECTRAL_SHAPE_DEFAULT
315 >>> shape = SpectralShape(
316 ... SPECTRAL_SHAPE_DEFAULT.start, SPECTRAL_SHAPE_DEFAULT.end, 84
317 ... )
318 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
319 >>> XYZ_outer_surface(cmfs.copy().align(shape)) # doctest: +ELLIPSIS
320 array([[ 0.0000000...e+00, 0.0000000...e+00, 0.0000000...e+00],
321 [ 9.6361381...e-05, 2.9056776...e-06, 4.4961226...e-04],
322 [ 2.5910529...e-01, 2.1031298...e-02, 1.3207468...e+00],
323 [ 1.0561021...e-01, 6.2038243...e-01, 3.5423571...e-02],
324 [ 7.2647980...e-01, 3.5460869...e-01, 2.1005149...e-04],
325 [ 1.0971874...e-02, 3.9635453...e-03, 0.0000000...e+00],
326 [ 3.0792572...e-05, 1.1119762...e-05, 0.0000000...e+00],
327 [ 2.5920165...e-01, 2.1034203...e-02, 1.3211965...e+00],
328 [ 3.6471551...e-01, 6.4141373...e-01, 1.3561704...e+00],
329 [ 8.3209002...e-01, 9.7499113...e-01, 3.5633622...e-02],
330 [ 7.3745167...e-01, 3.5857224...e-01, 2.1005149...e-04],
331 [ 1.1002667...e-02, 3.9746651...e-03, 0.0000000...e+00],
332 [ 1.2715395...e-04, 1.4025439...e-05, 4.4961226...e-04],
333 [ 3.6481187...e-01, 6.4141663...e-01, 1.3566200...e+00],
334 [ 1.0911953...e+00, 9.9602242...e-01, 1.3563805...e+00],
335 [ 8.4306189...e-01, 9.7895467...e-01, 3.5633622...e-02],
336 [ 7.3748247...e-01, 3.5858336...e-01, 2.1005149...e-04],
337 [ 1.1099028...e-02, 3.9775708...e-03, 4.4961226...e-04],
338 [ 2.5923244...e-01, 2.1045323...e-02, 1.3211965...e+00],
339 [ 1.0912916...e+00, 9.9602533...e-01, 1.3568301...e+00],
340 [ 1.1021671...e+00, 9.9998597...e-01, 1.3563805...e+00],
341 [ 8.4309268...e-01, 9.7896579...e-01, 3.5633622...e-02],
342 [ 7.3757883...e-01, 3.5858626...e-01, 6.5966375...e-04],
343 [ 2.7020432...e-01, 2.5008868...e-02, 1.3211965...e+00],
344 [ 3.6484266...e-01, 6.4142775...e-01, 1.3566200...e+00],
345 [ 1.1022635...e+00, 9.9998888...e-01, 1.3568301...e+00],
346 [ 1.1021979...e+00, 9.9999709...e-01, 1.3563805...e+00],
347 [ 8.4318905...e-01, 9.7896870...e-01, 3.6083235...e-02],
348 [ 9.9668412...e-01, 3.7961756...e-01, 1.3214065...e+00],
349 [ 3.7581454...e-01, 6.4539130...e-01, 1.3566200...e+00],
350 [ 1.0913224...e+00, 9.9603645...e-01, 1.3568301...e+00],
351 [ 1.1022943...e+00, 1.0000000...e+00, 1.3568301...e+00]])
352 """
354 cmfs, illuminant = handle_spectral_arguments(
355 cmfs,
356 illuminant,
357 "CIE 1931 2 Degree Standard Observer",
358 "E",
359 SPECTRAL_SHAPE_OUTER_SURFACE_XYZ,
360 )
362 settings = dict(kwargs)
363 settings.update({"shape": cmfs.shape})
365 key = (
366 hash(cmfs),
367 hash(illuminant),
368 point_order,
369 filter_jagged_points,
370 str(settings),
371 )
372 XYZ = _CACHE_OUTER_SURFACE_XYZ.get(key)
374 if is_caching_enabled() and XYZ is not None: # pragma: no cover
375 return XYZ
377 pulse_waves = generate_pulse_waves(
378 len(cmfs.wavelengths), point_order, filter_jagged_points
379 )
380 XYZ = (
381 msds_to_XYZ(pulse_waves, cmfs, illuminant, method="Integration", **settings)
382 / 100
383 )
385 _CACHE_OUTER_SURFACE_XYZ[key] = XYZ
387 return XYZ
390solid_RoschMacAdam = XYZ_outer_surface
393def is_within_visible_spectrum(
394 XYZ: ArrayLike,
395 cmfs: MultiSpectralDistributions | None = None,
396 illuminant: SpectralDistribution | None = None,
397 tolerance: float = 100 * EPSILON,
398 **kwargs: Any,
399) -> NDArrayFloat:
400 """
401 Determine whether the specified *CIE XYZ* tristimulus values are within the
402 visible spectrum volume (*Rösch-MacAdam* colour solid) for the specified
403 colour matching functions and illuminant.
405 Parameters
406 ----------
407 XYZ
408 *CIE XYZ* tristimulus values.
409 cmfs
410 Standard observer colour matching functions, default to the
411 *CIE 1931 2 Degree Standard Observer*.
412 illuminant
413 Illuminant spectral distribution, default to *CIE Illuminant E*.
414 tolerance
415 Tolerance allowed in the inside-triangle check.
417 Other Parameters
418 ----------------
419 kwargs
420 {:func:`colour.msds_to_XYZ`},
421 See the documentation of the previously listed definition.
423 Returns
424 -------
425 :class:`numpy.ndarray`
426 Boolean array indicating whether *CIE XYZ* tristimulus values are
427 within the visible spectrum volume (*Rösch-MacAdam* colour solid).
429 Notes
430 -----
431 +------------+-----------------------+---------------+
432 | **Domain** | **Scale - Reference** | **Scale - 1** |
433 +============+=======================+===============+
434 | ``XYZ`` | 1 | 1 |
435 +------------+-----------------------+---------------+
437 Examples
438 --------
439 >>> import numpy as np
440 >>> is_within_visible_spectrum(np.array([0.3205, 0.4131, 0.51]))
441 array(True, dtype=bool)
442 >>> a = np.array([[0.3205, 0.4131, 0.51], [-0.0005, 0.0031, 0.001]])
443 >>> is_within_visible_spectrum(a)
444 array([ True, False], dtype=bool)
445 """
447 cmfs, illuminant = handle_spectral_arguments(
448 cmfs,
449 illuminant,
450 "CIE 1931 2 Degree Standard Observer",
451 "E",
452 SPECTRAL_SHAPE_OUTER_SURFACE_XYZ,
453 )
455 key = (hash(cmfs), hash(illuminant), str(kwargs))
456 vertices = _CACHE_OUTER_SURFACE_XYZ_POINTS.get(key)
458 if vertices is None:
459 _CACHE_OUTER_SURFACE_XYZ_POINTS[key] = vertices = solid_RoschMacAdam(
460 cmfs, illuminant, **kwargs
461 )
463 return is_within_mesh_volume(XYZ, vertices, tolerance)