Coverage for io/fichet2021.py: 76%

1"""

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

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

4

5 Define spectral image input/output functionality based on the

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

7

8 References

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

15 from __future__ import annotations

16

17 import re

18 import typing

19 from collections import defaultdict

20 from collections.abc import ValuesView

21 from dataclasses import dataclass, field

22

23 import numpy as np

24

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

34 from colour.constants import CONSTANT_LIGHT_SPEED

35

36 if typing.TYPE_CHECKING:

37 from colour.hints import (

38 Any,

39 Callable,

40 Literal,

41 PathLike,

42 )

43

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

45 from colour.io.image import (

46 MAPPING_BIT_DEPTH,

47 Image_Specification_Attribute,

48 add_attributes_to_image_specification_OpenImageIO,

49)

50 from colour.models import RGB_COLOURSPACE_sRGB, XYZ_to_RGB

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

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

101 Unit conversion mapping.

102

103 References

104----------

105:cite:`Fichet2021`

106"""

107

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

112 Regex pattern for numbers and quantities.

113

114 References

115----------

116:cite:`Fichet2021`

117"""

118

119

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

121

122

123 def 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

201 def 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

240 def 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

290 class 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

431 SPECIFICATION_FICHET2021_DEFAULT: Specification_Fichet2021 = Specification_Fichet2021()

432"""

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

434"""

435

436

437@typing.overload

438@required("OpenImageIO")

439 def 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")

448 def 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")

458 def 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")

466 def 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

554 def 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")

626 def 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")

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