Coverage for colour/io/fichet2021.py: 100%

191 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 23:01 +1300

1""" 

2OpenEXR Layout for Spectral Images - Fichet, Pacanowski and Wilkie (2021) 

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

4 

5Define spectral image input/output functionality based on the 

6*Fichet et al. (2021)* OpenEXR layout specification. 

7 

8References 

9---------- 

10- :cite:`Fichet2021` : Fichet, A., Pacanowski, R., & Wilkie, A. (2021). An 

11 OpenEXR Layout for Spectral Images. 10(3). Retrieved April 26, 2024, from 

12 http://jcgt.org/published/0010/03/01/ 

13""" 

14 

15from __future__ import annotations 

16 

17import re 

18import typing 

19from collections import defaultdict 

20from collections.abc import ValuesView 

21from dataclasses import dataclass, field 

22 

23import numpy as np 

24 

25from colour.colorimetry import ( 

26 MSDS_CMFS, 

27 SDS_ILLUMINANTS, 

28 MultiSpectralDistributions, 

29 SpectralDistribution, 

30 SpectralShape, 

31 msds_to_XYZ, 

32 sds_and_msds_to_msds, 

33) 

34from colour.constants import CONSTANT_LIGHT_SPEED 

35 

36if typing.TYPE_CHECKING: 

37 from colour.hints import ( 

38 Any, 

39 Callable, 

40 Literal, 

41 PathLike, 

42 ) 

43 

44from colour.hints import Dict, NDArrayFloat, Sequence, Tuple 

45from colour.io.image import ( 

46 MAPPING_BIT_DEPTH, 

47 Image_Specification_Attribute, 

48 add_attributes_to_image_specification_OpenImageIO, 

49) 

50from colour.models import RGB_COLOURSPACE_sRGB, XYZ_to_RGB 

51from colour.utilities import ( 

52 as_float_array, 

53 interval, 

54 required, 

55 usage_warning, 

56 validate_method, 

57) 

58 

59__author__ = "Colour Developers" 

60__copyright__ = "Copyright 2013 Colour Developers" 

61__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

62__maintainer__ = "Colour Developers" 

63__email__ = "colour-developers@colour-science.org" 

64__status__ = "Production" 

65 

66__all__ = [ 

67 "MAPPING_UNIT_CONVERSION", 

68 "PATTERN_FICHET2021", 

69 "ComponentsFichet2021", 

70 "match_groups_to_nm", 

71 "sd_to_spectrum_attribute_Fichet2021", 

72 "spectrum_attribute_to_sd_Fichet2021", 

73 "Specification_Fichet2021", 

74 "SPECIFICATION_FICHET2021_DEFAULT", 

75 "read_spectral_image_Fichet2021", 

76 "sds_and_msds_to_components_Fichet2021", 

77 "components_to_sRGB_Fichet2021", 

78 "write_spectral_image_Fichet2021", 

79] 

80 

81MAPPING_UNIT_CONVERSION: dict = { 

82 "Y": 1e24, 

83 "Z": 1e21, 

84 "E": 1e18, 

85 "P": 1e15, 

86 "T": 1e12, 

87 "G": 1e9, 

88 "M": 1e6, 

89 "k": 1e3, 

90 "h": 1e2, 

91 "da": 1e1, 

92 "": 1, 

93 "d": 1e-1, 

94 "c": 1e-2, 

95 "m": 1e-3, 

96 "u": 1e-6, 

97 "n": 1e-9, 

98 "p": 1e-12, 

99} 

100""" 

101Unit conversion mapping. 

102 

103References 

104---------- 

105:cite:`Fichet2021` 

106""" 

107 

108PATTERN_FICHET2021: str = ( 

109 r"(\d*,?\d*([eE][-+]?\d+)?)(Y|Z|E|P|T|G|M|k|h|da|d|c|m|u|n|p|f|a|z|y)?(m|Hz)" 

110) 

111""" 

112Regex pattern for numbers and quantities. 

113 

114References 

115---------- 

116:cite:`Fichet2021` 

117""" 

118 

119 

120ComponentsFichet2021 = Dict[str | float, Tuple[NDArrayFloat, NDArrayFloat]] 

121 

122 

123def match_groups_to_nm( 

124 number: str, 

125 multiplier: Literal[ 

126 "Y", 

127 "Z", 

128 "E", 

129 "P", 

130 "T", 

131 "G", 

132 "M", 

133 "k", 

134 "h", 

135 "da", 

136 "", 

137 "d", 

138 "c", 

139 "m", 

140 "u", 

141 "n", 

142 "p", 

143 ] 

144 | str, 

145 units: Literal["m", "Hz"] | str, 

146) -> float: 

147 """ 

148 Convert wavelength or frequency match groups to nanometre values. 

149 

150 Parameters 

151 ---------- 

152 number 

153 Wavelength (or frequency) number to convert. 

154 multiplier 

155 Unit multiplier. 

156 units 

157 Frequency or wavelength. 

158 

159 Returns 

160 ------- 

161 :class:`float` 

162 Nanometre value. 

163 

164 Raises 

165 ------ 

166 ValueError 

167 If the multiplier or units are not supported. 

168 

169 Examples 

170 -------- 

171 >>> match_groups_to_nm("555.5", "n", "m") 

172 555.5 

173 >>> match_groups_to_nm("555.5", "", "m") 

174 555500000000.0 

175 >>> from colour.constants import CONSTANT_LIGHT_SPEED 

176 >>> match_groups_to_nm(str(CONSTANT_LIGHT_SPEED / (555 * 1e-9)), "", "Hz") 

177 ... # doctest: +ELLIPSIS 

178 555.0000000... 

179 """ 

180 

181 multiplier = validate_method( 

182 multiplier, tuple(MAPPING_UNIT_CONVERSION), as_lowercase=False 

183 ) 

184 units = validate_method(units, ("m", "Hz"), as_lowercase=False) 

185 

186 v = float(number.replace(",", ".")) 

187 

188 if multiplier == "n" and units == "m": 

189 return v 

190 

191 v *= MAPPING_UNIT_CONVERSION[multiplier] 

192 

193 if units == "m": 

194 v *= 1e9 

195 elif units == "Hz": 

196 v = CONSTANT_LIGHT_SPEED / v * 1e9 

197 

198 return v 

199 

200 

201def sd_to_spectrum_attribute_Fichet2021( 

202 sd: SpectralDistribution, decimals: int = 7 

203) -> str: 

204 """ 

205 Convert the specified spectral distribution to a spectrum attribute value 

206 according to *Fichet et al. (2021)*. 

207 

208 The conversion produces a string representation of the spectral 

209 distribution suitable for use in rendering systems, with wavelength-value 

210 pairs formatted as a semicolon-delimited list. 

211 

212 Parameters 

213 ---------- 

214 sd 

215 Spectral distribution to convert. 

216 decimals 

217 Formatting decimals. 

218 

219 Returns 

220 ------- 

221 :class:`str` 

222 Spectrum attribute value. 

223 

224 References 

225 ---------- 

226 :cite:`Fichet2021` 

227 

228 Examples 

229 -------- 

230 >>> sd_to_spectrum_attribute_Fichet2021(SDS_ILLUMINANTS["D65"], 2)[:56] 

231 '300.00nm:0.03;305.00nm:1.66;310.00nm:3.29;315.00nm:11.77' 

232 """ 

233 

234 return ";".join( 

235 f"{wavelength:.{decimals}f}nm:{value:.{decimals}f}" 

236 for wavelength, value in zip(sd.wavelengths, sd.values, strict=True) 

237 ) 

238 

239 

240def spectrum_attribute_to_sd_Fichet2021( 

241 spectrum_attribute: str, 

242) -> SpectralDistribution: 

243 """ 

244 Convert the specified spectrum attribute value to a spectral distribution 

245 according to *Fichet et al. (2021)*. 

246 

247 Parameters 

248 ---------- 

249 spectrum_attribute 

250 Spectrum attribute value to convert. 

251 

252 Returns 

253 ------- 

254 :class:`SpectralDistribution` 

255 Spectral distribution. 

256 

257 References 

258 ---------- 

259 :cite:`Fichet2021` 

260 

261 Examples 

262 -------- 

263 >>> spectrum_attribute_to_sd_Fichet2021( 

264 ... "300.00nm:0.03;305.00nm:1.66;310.00nm:3.29;315.00nm:11.77" 

265 ... ) # doctest: +SKIP 

266 SpectralDistribution([[ 3.0000000...e+02, 3.0000000...e-02], 

267 [ 3.0500000...e+02, 1.6600000...e+00], 

268 [ 3.1000000...e+02, 3.2900000...e+00], 

269 [ 3.1500000...e+02, 1.1770000...e+01]], 

270 SpragueInterpolator, 

271 {}, 

272 Extrapolator, 

273 {'method': 'Constant', 'left': None, 'right': None}) 

274 """ 

275 

276 data = {} 

277 pattern = re.compile(PATTERN_FICHET2021) 

278 parts = spectrum_attribute.split(";") 

279 for part in parts: 

280 domain, range_ = part.split(":") 

281 if (match := pattern.match(domain.replace(".", ","))) is not None: 

282 multiplier, units = match.group(3, 4) 

283 wavelength = match_groups_to_nm(match.group(1), multiplier, units) 

284 data[wavelength] = float(range_) 

285 

286 return SpectralDistribution(data) 

287 

288 

289@dataclass 

290class Specification_Fichet2021: 

291 """ 

292 Define the *Fichet et al. (2021)* spectral image specification. 

293 

294 Parameters 

295 ---------- 

296 path 

297 Path of the spectral image. 

298 components 

299 Components of the spectral image, e.g., *S0*, *S1*, *S2*, *S3*, *T*, 

300 or any wavelength number for bi-spectral images. 

301 is_emissive 

302 Whether the image is emissive, i.e., using the *S0* component. 

303 is_polarised 

304 Whether the image is polarised, i.e., using the *S0*, *S1*, *S2*, 

305 and *S3* components. 

306 is_bispectral 

307 Whether the image is bi-spectral, i.e., using the *T*, and any 

308 wavelength number. 

309 attributes 

310 An array of :class:`colour.io.Image_Specification_Attribute` class 

311 instances used to set attributes of the image. 

312 

313 Methods 

314 ------- 

315 - :meth:`~colour.Specification_Fichet2021.from_spectral_image` 

316 

317 References 

318 ---------- 

319 :cite:`Fichet2021` 

320 """ 

321 

322 path: str | None = field(default_factory=lambda: None) 

323 components: defaultdict = field(default_factory=lambda: defaultdict(dict)) 

324 is_emissive: bool = field(default_factory=lambda: False) 

325 is_polarised: bool = field(default_factory=lambda: False) 

326 is_bispectral: bool = field(default_factory=lambda: False) 

327 attributes: Tuple = field(default_factory=lambda: ()) 

328 

329 @staticmethod 

330 @required("OpenImageIO") 

331 def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: 

332 """ 

333 Create a *Fichet et al. (2021)* spectral image specification from the 

334 specified image path. 

335 

336 Parameters 

337 ---------- 

338 path 

339 Image path. 

340 

341 Returns 

342 ------- 

343 :class:`Specification_Fichet2021` 

344 *Fichet et al. (2021)* spectral image specification. 

345 

346 Examples 

347 -------- 

348 >>> import os 

349 >>> import colour 

350 >>> path = os.path.join( 

351 ... colour.__path__[0], 

352 ... "io", 

353 ... "tests", 

354 ... "resources", 

355 ... "D65.exr", 

356 ... ) 

357 >>> specification = Specification_Fichet2021.from_spectral_image(path) 

358 ... # doctest: +SKIP 

359 >>> specification.is_emissive # doctest: +SKIP 

360 True 

361 """ 

362 

363 from OpenImageIO import ImageInput # noqa: PLC0415 

364 

365 path = str(path) 

366 

367 components = defaultdict(dict) 

368 is_emissive = False 

369 is_polarised = False 

370 is_bispectral = False 

371 

372 pattern_emissive = re.compile(rf"^(S[0-3])\.*{PATTERN_FICHET2021}$") 

373 pattern_reflective = re.compile(rf"^T\.*{PATTERN_FICHET2021}$") 

374 pattern_bispectral = re.compile( 

375 rf"^T\.*{PATTERN_FICHET2021}\.*{PATTERN_FICHET2021}$" 

376 ) 

377 

378 image_input = ImageInput.open(path) 

379 image_specification = image_input.spec() 

380 channels = image_specification.channelnames 

381 

382 for i, channel in enumerate(channels): 

383 if (match := pattern_emissive.match(channel)) is not None: 

384 is_emissive = True 

385 

386 component = match.group(1) 

387 multiplier, units = match.group(4, 5) 

388 wavelength = match_groups_to_nm(match.group(2), multiplier, units) 

389 components[component][wavelength] = i 

390 

391 if len(components) > 1: 

392 is_polarised = True 

393 

394 if (match := pattern_bispectral.match(channel)) is not None: 

395 is_bispectral = True 

396 

397 input_multiplier, input_units = match.group(3, 4) 

398 input_wavelength = match_groups_to_nm( 

399 match.group(1), input_multiplier, input_units 

400 ) 

401 output_multiplier, output_units = match.group(7, 8) 

402 output_wavelength = match_groups_to_nm( 

403 match.group(5), output_multiplier, output_units 

404 ) 

405 components[input_wavelength][output_wavelength] = i 

406 

407 if (match := pattern_reflective.match(channel)) is not None: 

408 multiplier, units = match.group(3, 4) 

409 wavelength = match_groups_to_nm(match.group(1), multiplier, units) 

410 components["T"][wavelength] = i 

411 

412 attributes = [ 

413 Image_Specification_Attribute( 

414 attribute.name, attribute.value, attribute.type 

415 ) 

416 for attribute in image_specification.extra_attribs 

417 ] 

418 

419 image_input.close() 

420 

421 return Specification_Fichet2021( 

422 path, 

423 components, 

424 is_emissive, 

425 is_polarised, 

426 is_bispectral, 

427 tuple(attributes), 

428 ) 

429 

430 

431SPECIFICATION_FICHET2021_DEFAULT: Specification_Fichet2021 = Specification_Fichet2021() 

432""" 

433Default *Fichet et al. (2021)* spectral image specification. 

434""" 

435 

436 

437@typing.overload 

438@required("OpenImageIO") 

439def read_spectral_image_Fichet2021( 

440 path: str | PathLike, 

441 bit_depth: Literal["float16", "float32"] = ..., 

442 additional_data: Literal[True] = True, 

443) -> Tuple[ComponentsFichet2021, Specification_Fichet2021]: ... 

444 

445 

446@typing.overload 

447@required("OpenImageIO") 

448def read_spectral_image_Fichet2021( 

449 path: str | PathLike, 

450 bit_depth: Literal["float16", "float32"] = ..., 

451 *, 

452 additional_data: Literal[False], 

453) -> ComponentsFichet2021: ... 

454 

455 

456@typing.overload 

457@required("OpenImageIO") 

458def read_spectral_image_Fichet2021( 

459 path: str | PathLike, 

460 bit_depth: Literal["float16", "float32"], 

461 additional_data: bool = False, 

462) -> ComponentsFichet2021: ... 

463 

464 

465@required("OpenImageIO") 

466def read_spectral_image_Fichet2021( 

467 path: str | PathLike, 

468 bit_depth: Literal["float16", "float32"] = "float32", 

469 additional_data: bool = False, 

470) -> ComponentsFichet2021 | Tuple[ComponentsFichet2021, Specification_Fichet2021]: 

471 """ 

472 Read the *Fichet et al. (2021)* spectral image at the specified path 

473 using *OpenImageIO*. 

474 

475 Parameters 

476 ---------- 

477 path 

478 Image path. 

479 bit_depth 

480 Returned image bit-depth. 

481 additional_data 

482 Whether to return additional data. 

483 

484 Returns 

485 ------- 

486 :class:`dict` or :class:`tuple` 

487 Dictionary of component names and their corresponding tuple of 

488 wavelengths and values or tuple of the aforementioned dictionary 

489 and :class:`colour.Specification_Fichet2021` class instance. 

490 

491 Notes 

492 ----- 

493 - Spectrum attributes are not parsed but can be converted to 

494 spectral distribution using the 

495 :func:`colour.io.spectrum_attribute_to_sd_Fichet2021` definition. 

496 

497 References 

498 ---------- 

499 :cite:`Fichet2021` 

500 

501 Examples 

502 -------- 

503 >>> import os 

504 >>> import colour 

505 >>> path = os.path.join( 

506 ... colour.__path__[0], 

507 ... "io", 

508 ... "tests", 

509 ... "resources", 

510 ... "D65.exr", 

511 ... ) 

512 >>> msds, specification = read_spectral_image_Fichet2021( 

513 ... path, additional_data=True 

514 ... ) # doctest: +SKIP 

515 >>> components.keys() # doctest: +SKIP 

516 dict_keys(['S0']) 

517 >>> components["S0"][0].shape # doctest: +SKIP 

518 (97,) 

519 >>> components["S0"][1].shape # doctest: +SKIP 

520 (1, 1, 97) 

521 >>> specification.is_emissive # doctest: +SKIP 

522 True 

523 """ 

524 

525 from OpenImageIO import ImageInput # noqa: PLC0415 

526 

527 path = str(path) 

528 

529 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] 

530 

531 specification = Specification_Fichet2021.from_spectral_image(path) 

532 image_input = ImageInput.open(path) 

533 image = image_input.read_image(bit_depth_specification.openimageio) 

534 image_input.close() 

535 

536 components = {} 

537 for component, wavelengths_indexes in specification.components.items(): 

538 wavelengths, indexes = zip(*wavelengths_indexes.items(), strict=True) 

539 values = as_float_array( 

540 image[:, :, indexes], # pyright: ignore 

541 dtype=bit_depth_specification.numpy, 

542 ) 

543 components[component] = ( 

544 as_float_array(wavelengths), 

545 np.array(values, dtype=bit_depth_specification.numpy), 

546 ) 

547 

548 if additional_data: 

549 return components, specification 

550 

551 return components 

552 

553 

554def sds_and_msds_to_components_Fichet2021( 

555 sds: Sequence[SpectralDistribution | MultiSpectralDistributions] 

556 | SpectralDistribution 

557 | MultiSpectralDistributions 

558 | ValuesView, 

559 specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, 

560 **kwargs: Any, 

561) -> ComponentsFichet2021: 

562 """ 

563 Convert specified spectral and multi-spectral distributions to 

564 *Fichet et al. (2021)* components. 

565 

566 Align the spectral and multi-spectral distributions to the intersection 

567 of their spectral shapes before conversion. 

568 

569 Parameters 

570 ---------- 

571 sds 

572 Spectral and multi-spectral distributions to convert to 

573 *Fichet et al. (2021)* components. 

574 specification 

575 *Fichet et al. (2021)* spectral image specification, used to 

576 determine the proper component type, i.e., emissive or other. 

577 

578 Other Parameters 

579 ---------------- 

580 shape 

581 Optional shape the *Fichet et al. (2021)* components should take. 

582 Used when converting spectral distributions of a colour rendition 

583 chart to create a rectangular image rather than a single line of 

584 values. 

585 

586 Returns 

587 ------- 

588 :class:`dict` 

589 Dictionary of component names and their corresponding tuple of 

590 wavelengths and values. 

591 

592 References 

593 ---------- 

594 :cite:`Fichet2021` 

595 

596 Examples 

597 -------- 

598 >>> components = sds_and_msds_to_components_Fichet2021(SDS_ILLUMINANTS["D65"]) 

599 >>> components.keys() 

600 dict_keys(['T']) 

601 >>> components = sds_and_msds_to_components_Fichet2021( 

602 ... SDS_ILLUMINANTS["D65"], Specification_Fichet2021(is_emissive=True) 

603 ... ) 

604 >>> components.keys() 

605 dict_keys(['S0']) 

606 >>> components["S0"][0].shape 

607 (97,) 

608 >>> components["S0"][1].shape 

609 (1, 1, 97) 

610 """ 

611 

612 msds = sds_and_msds_to_msds(sds) 

613 component = "S0" if specification.is_emissive else "T" 

614 

615 wavelengths = msds.wavelengths 

616 values = np.transpose(msds.values) 

617 values = np.reshape(values, (1, -1, values.shape[-1])) 

618 

619 if "shape" in kwargs: 

620 values = np.reshape(values, kwargs["shape"]) 

621 

622 return {component: (wavelengths, values)} 

623 

624 

625@required("OpenImageIO") 

626def components_to_sRGB_Fichet2021( 

627 components: ComponentsFichet2021, 

628 specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, 

629) -> Tuple[NDArrayFloat | None, Sequence[Image_Specification_Attribute]]: 

630 """ 

631 Convert the specified *Fichet et al. (2021)* components to *sRGB* 

632 colourspace values. 

633 

634 Parameters 

635 ---------- 

636 components 

637 *Fichet et al. (2021)* components to convert. 

638 specification 

639 *Fichet et al. (2021)* spectral image specification, used to perform 

640 the proper conversion to *sRGB* colourspace values. 

641 

642 Returns 

643 ------- 

644 :class:`tuple` 

645 Tuple of *sRGB* colourspace values and list of 

646 :class:`colour.io.Image_Specification_Attribute` class instances. 

647 

648 Warnings 

649 -------- 

650 - This definition currently assumes a uniform wavelength interval. 

651 - This definition currently does not support integration of 

652 bi-spectral component. 

653 

654 Notes 

655 ----- 

656 - When an emissive component is specified, its exposure will be 

657 normalised so that its median is 0.18. 

658 

659 References 

660 ---------- 

661 :cite:`Fichet2021` 

662 

663 Examples 

664 -------- 

665 >>> specification = Specification_Fichet2021(is_emissive=True) 

666 >>> components = sds_and_msds_to_components_Fichet2021( 

667 ... SDS_ILLUMINANTS["D65"], 

668 ... specification, 

669 ... ) 

670 >>> RGB, attributes = components_to_sRGB_Fichet2021( 

671 ... components["S0"], specification 

672 ... ) # doctest: +SKIP 

673 >>> RGB # doctest: +SKIP 

674 array([[[ 0.1799829..., 0.1800080..., 0.1800090...]]]) 

675 >>> for attribute in attributes: 

676 ... print(attribute.name) # doctest: +SKIP 

677 X 

678 Y 

679 Z 

680 illuminant 

681 chromaticities 

682 EV 

683 """ 

684 

685 from OpenImageIO import TypeDesc # noqa: PLC0415 

686 

687 component = components.get("S0", components.get("T")) 

688 

689 if component is None: 

690 return None, [] 

691 

692 # TODO: Implement support for integration of bi-spectral component. 

693 if specification.is_bispectral: 

694 usage_warning( 

695 "Bi-spectral components conversion to *sRGB* colourspace values " 

696 "is unsupported!" 

697 ) 

698 

699 # TODO: Implement support for re-binning component with non-uniform interval. 

700 if len(interval(component[0])) != 1: # pragma: no cover 

701 usage_warning( 

702 "Components have a non-uniform interval, unexpected results might occur!" 

703 ) 

704 

705 msds = component[1] 

706 shape = SpectralShape(component[0][0], component[0][-1], interval(component[0])[0]) 

707 

708 cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

709 colourspace = RGB_COLOURSPACE_sRGB 

710 

711 if specification.is_emissive: 

712 illuminant = SDS_ILLUMINANTS["E"] 

713 

714 XYZ = msds_to_XYZ(msds, cmfs=cmfs, method="Integration", shape=shape) 

715 else: 

716 illuminant = SDS_ILLUMINANTS["D65"] 

717 

718 XYZ = ( 

719 msds_to_XYZ( 

720 msds, 

721 cmfs=cmfs, 

722 illuminant=illuminant, 

723 method="Integration", 

724 shape=shape, 

725 ) 

726 / 100 

727 ) 

728 

729 RGB = XYZ_to_RGB(XYZ, colourspace) 

730 

731 chromaticities = np.ravel( 

732 np.vstack([colourspace.primaries, colourspace.whitepoint]) 

733 ).tolist() 

734 

735 attributes = [ 

736 Image_Specification_Attribute( 

737 "X", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["x_bar"]) 

738 ), 

739 Image_Specification_Attribute( 

740 "Y", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["y_bar"]) 

741 ), 

742 Image_Specification_Attribute( 

743 "Z", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["z_bar"]) 

744 ), 

745 Image_Specification_Attribute( 

746 "illuminant", sd_to_spectrum_attribute_Fichet2021(illuminant) 

747 ), 

748 Image_Specification_Attribute( 

749 "chromaticities", chromaticities, TypeDesc("float[8]") 

750 ), 

751 ] 

752 

753 if specification.is_emissive: 

754 EV = np.mean(RGB) / 0.18 

755 RGB /= EV 

756 attributes.append( 

757 Image_Specification_Attribute("EV", np.log2(EV)), 

758 ) 

759 

760 return RGB, attributes 

761 

762 

763@required("OpenImageIO") 

764def write_spectral_image_Fichet2021( 

765 components: Sequence[SpectralDistribution | MultiSpectralDistributions] 

766 | SpectralDistribution 

767 | MultiSpectralDistributions 

768 | ComponentsFichet2021 

769 | ValuesView, 

770 path: str | PathLike, 

771 bit_depth: Literal["float16", "float32"] = "float32", 

772 specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, 

773 components_to_RGB_callable: Callable = components_to_sRGB_Fichet2021, 

774 **kwargs: Any, 

775) -> bool: 

776 """ 

777 Write the specified *Fichet et al. (2021)* components to the specified 

778 path using *OpenImageIO*. 

779 

780 Parameters 

781 ---------- 

782 components 

783 *Fichet et al. (2021)* components. 

784 path 

785 Image path. 

786 bit_depth 

787 Bit-depth to write the image at, the bit-depth conversion behaviour 

788 is ruled directly by *OpenImageIO*. 

789 specification 

790 *Fichet et al. (2021)* spectral image specification. 

791 components_to_RGB_callable 

792 Callable converting the components to a preview *RGB* image. 

793 

794 Other Parameters 

795 ---------------- 

796 shape 

797 Optional shape the *Fichet et al. (2021)* components should take: 

798 Used when converting spectral distributions of a colour rendition 

799 chart to create a rectangular image rather than a single line of 

800 values. 

801 

802 Returns 

803 ------- 

804 :class:`bool` 

805 Definition success. 

806 

807 Examples 

808 -------- 

809 >>> import os 

810 >>> import colour 

811 >>> path = os.path.join( 

812 ... colour.__path__[0], 

813 ... "io", 

814 ... "tests", 

815 ... "resources", 

816 ... "BabelColorAverage.exr", 

817 ... ) 

818 >>> msds = list(colour.SDS_COLOURCHECKERS["BabelColor Average"].values()) 

819 >>> specification = Specification_Fichet2021(is_emissive=False) 

820 >>> write_spectral_image_Fichet2021( 

821 ... msds, 

822 ... path, 

823 ... "float16", 

824 ... specification, 

825 ... shape=(4, 6, len(msds[0].shape.wavelengths)), 

826 ... ) # doctest: +SKIP 

827 True 

828 """ 

829 

830 from OpenImageIO import ImageBuf, ImageBufAlgo # noqa: PLC0415 

831 

832 path = str(path) 

833 

834 if isinstance( 

835 components, 

836 (Sequence, SpectralDistribution, MultiSpectralDistributions, ValuesView), 

837 ): 

838 components = sds_and_msds_to_components_Fichet2021( 

839 components, specification, **kwargs 

840 ) 

841 

842 if specification.attributes is None: 

843 specification.attributes = [ 

844 Image_Specification_Attribute("spectralLayoutVersion", "1.0") 

845 ] 

846 

847 if specification.is_emissive: 

848 specification.attributes.extend( 

849 [ 

850 Image_Specification_Attribute("polarisationHandedness", "right"), 

851 Image_Specification_Attribute("emissiveUnits", "W.m^-2.sr^-1"), 

852 ] 

853 ) 

854 

855 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] 

856 

857 channels = {} 

858 

859 RGB, attributes = components_to_RGB_callable(components, specification) 

860 if RGB is not None: 

861 channels.update({"R": RGB[..., 0], "G": RGB[..., 1], "B": RGB[..., 2]}) 

862 

863 for component, wavelengths_values in components.items(): 

864 wavelengths, values = wavelengths_values 

865 for i, wavelength in enumerate(wavelengths): 

866 component_type = str(component)[0] 

867 if component_type == "S": # Emissive Component Type # noqa: SIM114 

868 channel_name = f"{component}.{str(wavelength).replace('.', ',')}nm" 

869 elif component_type == "T": # Reflectance et al. Component Type 

870 channel_name = f"{component}.{str(wavelength).replace('.', ',')}nm" 

871 else: # Bi-spectral Component Type 

872 channel_name = ( 

873 f"T.{str(component).replace('.', ',')}nm." 

874 f"{str(wavelength).replace('.', ',')}nm" 

875 ) 

876 

877 channels[channel_name] = values[..., i] 

878 

879 image_buffer = ImageBuf() 

880 for channel_name, channel_data in channels.items(): 

881 channel_buffer = ImageBuf(channel_data.astype(bit_depth_specification.numpy)) 

882 channel_specification = channel_buffer.specmod() 

883 channel_specification.channelnames = [channel_name] # pyright: ignore 

884 image_buffer = ImageBufAlgo.channel_append(image_buffer, channel_buffer) 

885 

886 add_attributes_to_image_specification_OpenImageIO( 

887 image_buffer.specmod(), # pyright: ignore 

888 [*specification.attributes, *attributes], 

889 ) 

890 

891 return image_buffer.write(path)