Skip to content

Commit eb837b4

Browse files
authored
Merge pull request #777 from colour-science/feature/rosch-macadam
PR: Implement support to generate the "Rösch-MacAdam" colour solid hue lines.
2 parents 362ccee + d1cca65 commit eb837b4

File tree

4 files changed

+206
-47
lines changed

4 files changed

+206
-47
lines changed

colour/volume/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .mesh import is_within_mesh_volume
77
from .pointer_gamut import is_within_pointer_gamut
88
from .spectrum import (generate_pulse_waves, XYZ_outer_surface,
9-
is_within_visible_spectrum)
9+
solid_RoschMacAdam, is_within_visible_spectrum)
1010
from .rgb import (RGB_colourspace_limits, RGB_colourspace_volume_MonteCarlo,
1111
RGB_colourspace_volume_coverage_MonteCarlo,
1212
RGB_colourspace_pointer_gamut_coverage_MonteCarlo,
@@ -18,7 +18,8 @@
1818
__all__ += ['is_within_mesh_volume']
1919
__all__ += ['is_within_pointer_gamut']
2020
__all__ += [
21-
'generate_pulse_waves', 'XYZ_outer_surface', 'is_within_visible_spectrum'
21+
'generate_pulse_waves', 'XYZ_outer_surface', 'solid_RoschMacAdam',
22+
'is_within_visible_spectrum'
2223
]
2324
__all__ += [
2425
'RGB_colourspace_limits', 'RGB_colourspace_volume_MonteCarlo',

colour/volume/spectrum.py

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# -*- coding: utf-8 -*-
22
"""
3-
Visible Spectrum Volume Computations
4-
====================================
3+
Rösch-MacAdam colour solid - Visible Spectrum Volume Computations
4+
=================================================================
55
6-
Defines objects related to visible spectrum volume computations.
6+
Defines objects related to *Rösch-MacAdam* colour solid, visible spectrum
7+
volume computations.
78
89
References
910
----------
@@ -13,11 +14,16 @@
1314
- :cite:`Mansencal2018` : Mansencal, T. (2018). How is the visible gamut
1415
bounded? Retrieved August 19, 2018, from
1516
https://stackoverflow.com/a/48396021/931625
17+
- :cite:`Martinez-Verdu2007` : Martínez-Verdú, F., Perales, E., Chorro, E.,
18+
de Fez, D., Viqueira, V., & Gilabert, E. (2007). Computation and
19+
visualization of the MacAdam limits for any lightness, hue angle, and light
20+
source. Journal of the Optical Society of America A, 24(6), 1501.
21+
doi:10.1364/JOSAA.24.001501
1622
"""
1723

1824
import numpy as np
1925

20-
from colour.colorimetry import (MSDS_CMFS, msds_to_XYZ, SpectralShape, sd_ones)
26+
from colour.colorimetry import MSDS_CMFS, msds_to_XYZ, SpectralShape, sd_ones
2127
from colour.constants import DEFAULT_FLOAT_DTYPE
2228
from colour.volume import is_within_mesh_volume
2329
from colour.utilities import zeros
@@ -31,7 +37,7 @@
3137

3238
__all__ = [
3339
'SPECTRAL_SHAPE_OUTER_SURFACE_XYZ', 'generate_pulse_waves',
34-
'XYZ_outer_surface', 'is_within_visible_spectrum'
40+
'XYZ_outer_surface', 'solid_RoschMacAdam', 'is_within_visible_spectrum'
3541
]
3642

3743
SPECTRAL_SHAPE_OUTER_SURFACE_XYZ = SpectralShape(360, 780, 5)
@@ -46,10 +52,11 @@
4652
_CACHE_OUTER_SURFACE_XYZ_POINTS = {}
4753

4854

49-
def generate_pulse_waves(bins):
55+
def generate_pulse_waves(bins, pulse_order='Bins', filter_jagged_pulses=False):
5056
"""
5157
Generates the pulse waves of given number of bins necessary to totally
52-
stimulate the colour matching functions.
58+
stimulate the colour matching functions and produce the *Rösch-MacAdam*
59+
colour solid.
5360
5461
Assuming 5 bins, a first set of SPDs would be as follows::
5562
@@ -81,6 +88,29 @@ def generate_pulse_waves(bins):
8188
----------
8289
bins : int
8390
Number of bins of the pulse waves.
91+
pulse_order : unicode, optional
92+
**{'Bins', 'Pulse Wave Width'}**,
93+
Method for ordering the pulse waves. *Bins* is the default order, with
94+
*Pulse Wave Width* ordering, instead of iterating over the pulse wave
95+
widths first, iteration occurs over the bins, producing blocks of pulse
96+
waves with increasing width.
97+
filter_jagged_pulses : bool, optional
98+
Whether to filter jagged pulses. When ``pulse_order`` is set to
99+
*Pulse Wave Width*, the pulses are ordered by increasing width. Because
100+
of the discrete nature of the underlying signal, the resulting pulses
101+
will be jagged. For example assuming 5 bins, the center block with
102+
the two extreme values added would be as follows::
103+
104+
0 0 0 0 0
105+
0 0 1 0 0
106+
0 0 1 1 0 <--
107+
0 1 1 1 0
108+
0 1 1 1 1 <--
109+
1 1 1 1 1
110+
111+
Setting the ``filter_jagged_pulses`` parameter to `True` will result
112+
in the removal of the two marked pulses above which avoid jagged lines
113+
when plotting and having to resort to excessive ``bins`` values.
84114
85115
Returns
86116
-------
@@ -89,7 +119,7 @@ def generate_pulse_waves(bins):
89119
90120
References
91121
----------
92-
:cite:`Lindbloom2015`, :cite:`Mansencal2018`
122+
:cite:`Lindbloom2015`, :cite:`Mansencal2018`, :cite:`Martinez-Verdu2007`
93123
94124
Examples
95125
--------
@@ -116,14 +146,59 @@ def generate_pulse_waves(bins):
116146
[ 1., 1., 0., 1., 1.],
117147
[ 1., 1., 1., 0., 1.],
118148
[ 1., 1., 1., 1., 1.]])
149+
>>> generate_pulse_waves(5, 'Pulse Wave Width')
150+
array([[ 0., 0., 0., 0., 0.],
151+
[ 1., 0., 0., 0., 0.],
152+
[ 1., 1., 0., 0., 0.],
153+
[ 1., 1., 0., 0., 1.],
154+
[ 1., 1., 1., 0., 1.],
155+
[ 0., 1., 0., 0., 0.],
156+
[ 0., 1., 1., 0., 0.],
157+
[ 1., 1., 1., 0., 0.],
158+
[ 1., 1., 1., 1., 0.],
159+
[ 0., 0., 1., 0., 0.],
160+
[ 0., 0., 1., 1., 0.],
161+
[ 0., 1., 1., 1., 0.],
162+
[ 0., 1., 1., 1., 1.],
163+
[ 0., 0., 0., 1., 0.],
164+
[ 0., 0., 0., 1., 1.],
165+
[ 0., 0., 1., 1., 1.],
166+
[ 1., 0., 1., 1., 1.],
167+
[ 0., 0., 0., 0., 1.],
168+
[ 1., 0., 0., 0., 1.],
169+
[ 1., 0., 0., 1., 1.],
170+
[ 1., 1., 0., 1., 1.],
171+
[ 1., 1., 1., 1., 1.]])
172+
>>> generate_pulse_waves(5, 'Pulse Wave Width', True)
173+
array([[ 0., 0., 0., 0., 0.],
174+
[ 1., 0., 0., 0., 0.],
175+
[ 1., 1., 0., 0., 1.],
176+
[ 0., 1., 0., 0., 0.],
177+
[ 1., 1., 1., 0., 0.],
178+
[ 0., 0., 1., 0., 0.],
179+
[ 0., 1., 1., 1., 0.],
180+
[ 0., 0., 0., 1., 0.],
181+
[ 0., 0., 1., 1., 1.],
182+
[ 0., 0., 0., 0., 1.],
183+
[ 1., 0., 0., 1., 1.],
184+
[ 1., 1., 1., 1., 1.]])
119185
"""
120186

121187
square_waves = []
122188
square_waves_basis = np.tril(
123189
np.ones((bins, bins), dtype=DEFAULT_FLOAT_DTYPE))[0:-1, :]
124-
for square_wave_basis in square_waves_basis:
190+
191+
if pulse_order.lower() == 'bins':
192+
for square_wave_basis in square_waves_basis:
193+
for i in range(bins):
194+
square_waves.append(np.roll(square_wave_basis, i))
195+
else:
125196
for i in range(bins):
126-
square_waves.append(np.roll(square_wave_basis, i))
197+
for j, square_wave_basis in enumerate(square_waves_basis):
198+
square_waves.append(np.roll(square_wave_basis, i - j // 2))
199+
200+
if filter_jagged_pulses:
201+
square_waves = square_waves[::2]
127202

128203
return np.vstack([
129204
zeros(bins),
@@ -135,18 +210,44 @@ def generate_pulse_waves(bins):
135210
def XYZ_outer_surface(cmfs=MSDS_CMFS['CIE 1931 2 Degree Standard Observer']
136211
.copy().align(SPECTRAL_SHAPE_OUTER_SURFACE_XYZ),
137212
illuminant=sd_ones(SPECTRAL_SHAPE_OUTER_SURFACE_XYZ),
213+
point_order='Bins',
214+
filter_jagged_points=False,
138215
**kwargs):
139216
"""
140-
Generates the *CIE XYZ* colourspace outer surface for given colour matching
141-
functions using multi-spectral conversion of pulse waves to *CIE XYZ*
142-
tristimulus values.
217+
Generates the *Rösch-MacAdam* colour solid, i.e. *CIE XYZ* colourspace
218+
outer surface, for given colour matching functions using multi-spectral
219+
conversion of pulse waves to *CIE XYZ* tristimulus values.
143220
144221
Parameters
145222
----------
146223
cmfs : XYZ_ColourMatchingFunctions, optional
147224
Standard observer colour matching functions.
148225
illuminant : SpectralDistribution, optional
149226
Illuminant spectral distribution.
227+
point_order : unicode, optional
228+
**{'Bins', 'Pulse Wave Width'}**,
229+
Method for ordering the underlying pulse waves used to generate the
230+
*Rösch-MacAdam* colour solid. *Bins* is the default order, with
231+
*Pulse Wave Width* ordering, instead of iterating over the pulse wave
232+
widths first, iteration occurs over the bins, producing blocks of pulse
233+
waves with increasing width.
234+
filter_jagged_points : bool, optional
235+
Whether to filter the underlying jagged pulses. When ``point_order`` is
236+
set to *Pulse Wave Width*, the pulses are ordered by increasing width.
237+
Because of the discrete nature of the underlying signal, the resulting
238+
pulses will be jagged. For example assuming 5 bins, the center block
239+
with the two extreme values added would be as follows::
240+
241+
0 0 0 0 0
242+
0 0 1 0 0
243+
0 0 1 1 0 <--
244+
0 1 1 1 0
245+
0 1 1 1 1 <--
246+
1 1 1 1 1
247+
248+
Setting the ``filter_jagged_points`` parameter to `True` will result
249+
in the removal of the two marked pulses above which avoid jagged lines
250+
when plotting and having to resort to excessive ``bins`` values.
150251
151252
Other Parameters
152253
----------------
@@ -157,11 +258,12 @@ def XYZ_outer_surface(cmfs=MSDS_CMFS['CIE 1931 2 Degree Standard Observer']
157258
Returns
158259
-------
159260
ndarray
160-
Outer surface *CIE XYZ* tristimulus values.
261+
*Rösch-MacAdam* colour solid, *CIE XYZ* outer surface tristimulus
262+
values.
161263
162264
References
163265
----------
164-
:cite:`Lindbloom2015`, :cite:`Mansencal2018`
266+
:cite:`Lindbloom2015`, :cite:`Mansencal2018`, :cite:`Martinez-Verdu2007`
165267
166268
Examples
167269
--------
@@ -207,18 +309,23 @@ def XYZ_outer_surface(cmfs=MSDS_CMFS['CIE 1931 2 Degree Standard Observer']
207309
settings = {'method': 'Integration', 'shape': cmfs.shape}
208310
settings.update(kwargs)
209311

210-
key = (hash(cmfs), hash(illuminant), str(settings))
312+
key = (hash(cmfs), hash(illuminant), point_order, filter_jagged_points,
313+
str(settings))
211314
XYZ = _CACHE_OUTER_SURFACE_XYZ.get(key)
212315

213316
if XYZ is None:
214-
pulse_waves = generate_pulse_waves(len(cmfs.wavelengths))
317+
pulse_waves = generate_pulse_waves(
318+
len(cmfs.wavelengths), point_order, filter_jagged_points)
215319
XYZ = msds_to_XYZ(pulse_waves, cmfs, illuminant, **settings) / 100
216320

217321
_CACHE_OUTER_SURFACE_XYZ[key] = XYZ
218322

219323
return XYZ
220324

221325

326+
solid_RoschMacAdam = XYZ_outer_surface
327+
328+
222329
def is_within_visible_spectrum(
223330
XYZ,
224331
cmfs=MSDS_CMFS['CIE 1931 2 Degree Standard Observer']
@@ -227,8 +334,9 @@ def is_within_visible_spectrum(
227334
tolerance=None,
228335
**kwargs):
229336
"""
230-
Returns if given *CIE XYZ* tristimulus values are within visible spectrum
231-
volume / given colour matching functions volume.
337+
Returns if given *CIE XYZ* tristimulus values are within the visible
338+
spectrum volume, i.e. *Rösch-MacAdam* colour solid, for given colour
339+
matching functions and illuminant.
232340
233341
Parameters
234342
----------
@@ -250,7 +358,8 @@ def is_within_visible_spectrum(
250358
Returns
251359
-------
252360
bool
253-
Is within visible spectrum.
361+
Are *CIE XYZ* tristimulus values within the visible spectrum volume,
362+
i.e. *Rösch-MacAdam* colour solid.
254363
255364
Notes
256365
-----
@@ -276,7 +385,7 @@ def is_within_visible_spectrum(
276385
vertices = _CACHE_OUTER_SURFACE_XYZ_POINTS.get(key)
277386

278387
if vertices is None:
279-
_CACHE_OUTER_SURFACE_XYZ_POINTS[key] = vertices = (XYZ_outer_surface(
388+
_CACHE_OUTER_SURFACE_XYZ_POINTS[key] = vertices = (solid_RoschMacAdam(
280389
cmfs, illuminant, **kwargs))
281390

282391
return is_within_mesh_volume(XYZ, vertices, tolerance)

colour/volume/tests/test_spectrum.py

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,30 +41,78 @@ def test_generate_pulse_waves(self):
4141
np.testing.assert_array_equal(
4242
generate_pulse_waves(5),
4343
np.array([
44-
[0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000],
45-
[1.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000],
46-
[0.00000000, 1.00000000, 0.00000000, 0.00000000, 0.00000000],
47-
[0.00000000, 0.00000000, 1.00000000, 0.00000000, 0.00000000],
48-
[0.00000000, 0.00000000, 0.00000000, 1.00000000, 0.00000000],
49-
[0.00000000, 0.00000000, 0.00000000, 0.00000000, 1.00000000],
50-
[1.00000000, 1.00000000, 0.00000000, 0.00000000, 0.00000000],
51-
[0.00000000, 1.00000000, 1.00000000, 0.00000000, 0.00000000],
52-
[0.00000000, 0.00000000, 1.00000000, 1.00000000, 0.00000000],
53-
[0.00000000, 0.00000000, 0.00000000, 1.00000000, 1.00000000],
54-
[1.00000000, 0.00000000, 0.00000000, 0.00000000, 1.00000000],
55-
[1.00000000, 1.00000000, 1.00000000, 0.00000000, 0.00000000],
56-
[0.00000000, 1.00000000, 1.00000000, 1.00000000, 0.00000000],
57-
[0.00000000, 0.00000000, 1.00000000, 1.00000000, 1.00000000],
58-
[1.00000000, 0.00000000, 0.00000000, 1.00000000, 1.00000000],
59-
[1.00000000, 1.00000000, 0.00000000, 0.00000000, 1.00000000],
60-
[1.00000000, 1.00000000, 1.00000000, 1.00000000, 0.00000000],
61-
[0.00000000, 1.00000000, 1.00000000, 1.00000000, 1.00000000],
62-
[1.00000000, 0.00000000, 1.00000000, 1.00000000, 1.00000000],
63-
[1.00000000, 1.00000000, 0.00000000, 1.00000000, 1.00000000],
64-
[1.00000000, 1.00000000, 1.00000000, 0.00000000, 1.00000000],
65-
[1.00000000, 1.00000000, 1.00000000, 1.00000000, 1.00000000],
44+
[0.0, 0.0, 0.0, 0.0, 0.0],
45+
[1.0, 0.0, 0.0, 0.0, 0.0],
46+
[0.0, 1.0, 0.0, 0.0, 0.0],
47+
[0.0, 0.0, 1.0, 0.0, 0.0],
48+
[0.0, 0.0, 0.0, 1.0, 0.0],
49+
[0.0, 0.0, 0.0, 0.0, 1.0],
50+
[1.0, 1.0, 0.0, 0.0, 0.0],
51+
[0.0, 1.0, 1.0, 0.0, 0.0],
52+
[0.0, 0.0, 1.0, 1.0, 0.0],
53+
[0.0, 0.0, 0.0, 1.0, 1.0],
54+
[1.0, 0.0, 0.0, 0.0, 1.0],
55+
[1.0, 1.0, 1.0, 0.0, 0.0],
56+
[0.0, 1.0, 1.0, 1.0, 0.0],
57+
[0.0, 0.0, 1.0, 1.0, 1.0],
58+
[1.0, 0.0, 0.0, 1.0, 1.0],
59+
[1.0, 1.0, 0.0, 0.0, 1.0],
60+
[1.0, 1.0, 1.0, 1.0, 0.0],
61+
[0.0, 1.0, 1.0, 1.0, 1.0],
62+
[1.0, 0.0, 1.0, 1.0, 1.0],
63+
[1.0, 1.0, 0.0, 1.0, 1.0],
64+
[1.0, 1.0, 1.0, 0.0, 1.0],
65+
[1.0, 1.0, 1.0, 1.0, 1.0],
6666
]))
6767

68+
np.testing.assert_array_equal(
69+
generate_pulse_waves(5, 'Pulse Wave Width'),
70+
np.array([
71+
[0.0, 0.0, 0.0, 0.0, 0.0],
72+
[1.0, 0.0, 0.0, 0.0, 0.0],
73+
[1.0, 1.0, 0.0, 0.0, 0.0],
74+
[1.0, 1.0, 0.0, 0.0, 1.0],
75+
[1.0, 1.0, 1.0, 0.0, 1.0],
76+
[0.0, 1.0, 0.0, 0.0, 0.0],
77+
[0.0, 1.0, 1.0, 0.0, 0.0],
78+
[1.0, 1.0, 1.0, 0.0, 0.0],
79+
[1.0, 1.0, 1.0, 1.0, 0.0],
80+
[0.0, 0.0, 1.0, 0.0, 0.0],
81+
[0.0, 0.0, 1.0, 1.0, 0.0],
82+
[0.0, 1.0, 1.0, 1.0, 0.0],
83+
[0.0, 1.0, 1.0, 1.0, 1.0],
84+
[0.0, 0.0, 0.0, 1.0, 0.0],
85+
[0.0, 0.0, 0.0, 1.0, 1.0],
86+
[0.0, 0.0, 1.0, 1.0, 1.0],
87+
[1.0, 0.0, 1.0, 1.0, 1.0],
88+
[0.0, 0.0, 0.0, 0.0, 1.0],
89+
[1.0, 0.0, 0.0, 0.0, 1.0],
90+
[1.0, 0.0, 0.0, 1.0, 1.0],
91+
[1.0, 1.0, 0.0, 1.0, 1.0],
92+
[1.0, 1.0, 1.0, 1.0, 1.0],
93+
]))
94+
95+
np.testing.assert_equal(
96+
np.sort(generate_pulse_waves(5), axis=0),
97+
np.sort(generate_pulse_waves(5, 'Pulse Wave Width'), axis=0))
98+
99+
np.testing.assert_array_equal(
100+
generate_pulse_waves(5, 'Pulse Wave Width', True),
101+
np.array([
102+
[0.0, 0.0, 0.0, 0.0, 0.0],
103+
[1.0, 0.0, 0.0, 0.0, 0.0],
104+
[1.0, 1.0, 0.0, 0.0, 1.0],
105+
[0.0, 1.0, 0.0, 0.0, 0.0],
106+
[1.0, 1.0, 1.0, 0.0, 0.0],
107+
[0.0, 0.0, 1.0, 0.0, 0.0],
108+
[0.0, 1.0, 1.0, 1.0, 0.0],
109+
[0.0, 0.0, 0.0, 1.0, 0.0],
110+
[0.0, 0.0, 1.0, 1.0, 1.0],
111+
[0.0, 0.0, 0.0, 0.0, 1.0],
112+
[1.0, 0.0, 0.0, 1.0, 1.0],
113+
[1.0, 1.0, 1.0, 1.0, 1.0],
114+
]))
115+
68116

69117
class TestXYZOuterSurface(unittest.TestCase):
70118
"""

0 commit comments

Comments
 (0)