Coverage for volume/spectrum.py: 56%

1"""

2 Rösch-MacAdam Colour Solid - Visible Spectrum Volume Computations

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

4

5 Define objects for computing and analyzing the *Rösch-MacAdam* colour

6 solid and visible spectrum volume boundaries.

7

8 References

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` : Martínez-Verdú, 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"""

22

23 from __future__ import annotations

24

25 import typing

26

27 import numpy as np

28

29 from colour.colorimetry import (

30 MultiSpectralDistributions,

31 SpectralDistribution,

32 SpectralShape,

33 handle_spectral_arguments,

34 msds_to_XYZ,

35)

36 from colour.constants import DTYPE_FLOAT_DEFAULT, EPSILON

37

38 if typing.TYPE_CHECKING:

39 from colour.hints import (

40 Any,

41 ArrayLike,

42 Literal,

43 NDArrayFloat,

44 )

45

46 from colour.utilities import CACHE_REGISTRY, is_caching_enabled, validate_method, zeros

47 from colour.volume import is_within_mesh_volume

48

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"

55

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]

63

64 SPECTRAL_SHAPE_OUTER_SURFACE_XYZ: SpectralShape = SpectralShape(360, 780, 5)

65"""

66 Default spectral shape according to *ASTM E308-15* practise shape but using an

67 interval of 5.

68"""

69

70 _CACHE_OUTER_SURFACE_XYZ: dict = CACHE_REGISTRY.register_cache(

71 f"{__name__}._CACHE_OUTER_SURFACE_XYZ"

72)

73

74 _CACHE_OUTER_SURFACE_XYZ_POINTS: dict = CACHE_REGISTRY.register_cache(

75 f"{__name__}._CACHE_OUTER_SURFACE_XYZ_POINTS"

76)

77

78

79 def 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.

88

89 Assuming 5 bins, a first set of SPDs would be as follows::

90

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

96

97 The second one::

98

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

104

105 The third:

106

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

112

113 Etc...

114

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::

131

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

138

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.

143

144 Returns

145 -------

146 :class:`numpy.ndarray`

147 Pulse waves.

148

149 References

150 ----------

151 :cite:`Lindbloom2015`, :cite:`Mansencal2018`,

152 :cite:`Martinez-Verdu2007`

153

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 """

216

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 )

222

223 square_waves = []

224 square_waves_basis = np.tril(np.ones((bins, bins), dtype=DTYPE_FLOAT_DEFAULT))[

225 0:-1, :

226 ]

227

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

236

237 if filter_jagged_pulses:

238 square_waves = square_waves[::2]

239

240 return np.vstack(

241 [

242 zeros(bins),

243 np.vstack(square_waves),

244 np.ones(bins, dtype=DTYPE_FLOAT_DEFAULT),

245 ]

246 )

247

248

249 def 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.

261

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::

282

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

289

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.

294

295 Other Parameters

296 ----------------

297 kwargs

298 {:func:`colour.msds_to_XYZ`},

299 See the documentation of the previously listed definition.

300

301 Returns

302 -------

303 :class:`numpy.ndarray`

304 *Rösch-MacAdam* colour solid, *CIE XYZ* outer surface

305 tristimulus values.

306

307 References

308 ----------

309 :cite:`Lindbloom2015`, :cite:`Mansencal2018`,

310 :cite:`Martinez-Verdu2007`

311

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 """

353

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 )

361

362 settings = dict(kwargs)

363 settings.update({"shape": cmfs.shape})

364

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)

373

374 if is_caching_enabled() and XYZ is not None: # pragma: no cover

375 return XYZ

376

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 )

384

385 _CACHE_OUTER_SURFACE_XYZ[key] = XYZ

386

387 return XYZ

388

389

390 solid_RoschMacAdam = XYZ_outer_surface

391

392

393 def 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.

404

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.

416

417 Other Parameters

418 ----------------

419 kwargs

420 {:func:`colour.msds_to_XYZ`},

421 See the documentation of the previously listed definition.

422

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).

428

429 Notes

430 -----

431 +------------+-----------------------+---------------+

432 | **Domain** | **Scale - Reference** | **Scale - 1** |

433 +============+=======================+===============+

434 | ``XYZ`` | 1 | 1 |

435 +------------+-----------------------+---------------+

436

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 """

446

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 )

454

455 key = (hash(cmfs), hash(illuminant), str(kwargs))

456 vertices = _CACHE_OUTER_SURFACE_XYZ_POINTS.get(key)

457

458 if vertices is None:

459 _CACHE_OUTER_SURFACE_XYZ_POINTS[key] = vertices = solid_RoschMacAdam(

460 cmfs, illuminant, **kwargs

461 )

462

463 return is_within_mesh_volume(XYZ, vertices, tolerance)