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

1""" 

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

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

4 

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

6solid and visible spectrum volume boundaries. 

7 

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

22 

23from __future__ import annotations 

24 

25import typing 

26 

27import numpy as np 

28 

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 

37 

38if typing.TYPE_CHECKING: 

39 from colour.hints import ( 

40 Any, 

41 ArrayLike, 

42 Literal, 

43 NDArrayFloat, 

44 ) 

45 

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

47from 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 

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

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 

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. 

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 

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. 

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 

390solid_RoschMacAdam = XYZ_outer_surface 

391 

392 

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. 

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)