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
« 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=========================================================================
5Define spectral image input/output functionality based on the
6*Fichet et al. (2021)* OpenEXR layout specification.
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"""
15from __future__ import annotations
17import re
18import typing
19from collections import defaultdict
20from collections.abc import ValuesView
21from dataclasses import dataclass, field
23import numpy as np
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
36if typing.TYPE_CHECKING:
37 from colour.hints import (
38 Any,
39 Callable,
40 Literal,
41 PathLike,
42 )
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)
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"
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]
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.
103References
104----------
105:cite:`Fichet2021`
106"""
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.
114References
115----------
116:cite:`Fichet2021`
117"""
120ComponentsFichet2021 = Dict[str | float, Tuple[NDArrayFloat, NDArrayFloat]]
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.
150 Parameters
151 ----------
152 number
153 Wavelength (or frequency) number to convert.
154 multiplier
155 Unit multiplier.
156 units
157 Frequency or wavelength.
159 Returns
160 -------
161 :class:`float`
162 Nanometre value.
164 Raises
165 ------
166 ValueError
167 If the multiplier or units are not supported.
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 """
181 multiplier = validate_method(
182 multiplier, tuple(MAPPING_UNIT_CONVERSION), as_lowercase=False
183 )
184 units = validate_method(units, ("m", "Hz"), as_lowercase=False)
186 v = float(number.replace(",", "."))
188 if multiplier == "n" and units == "m":
189 return v
191 v *= MAPPING_UNIT_CONVERSION[multiplier]
193 if units == "m":
194 v *= 1e9
195 elif units == "Hz":
196 v = CONSTANT_LIGHT_SPEED / v * 1e9
198 return v
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)*.
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.
212 Parameters
213 ----------
214 sd
215 Spectral distribution to convert.
216 decimals
217 Formatting decimals.
219 Returns
220 -------
221 :class:`str`
222 Spectrum attribute value.
224 References
225 ----------
226 :cite:`Fichet2021`
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 """
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 )
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)*.
247 Parameters
248 ----------
249 spectrum_attribute
250 Spectrum attribute value to convert.
252 Returns
253 -------
254 :class:`SpectralDistribution`
255 Spectral distribution.
257 References
258 ----------
259 :cite:`Fichet2021`
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 """
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_)
286 return SpectralDistribution(data)
289@dataclass
290class Specification_Fichet2021:
291 """
292 Define the *Fichet et al. (2021)* spectral image specification.
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.
313 Methods
314 -------
315 - :meth:`~colour.Specification_Fichet2021.from_spectral_image`
317 References
318 ----------
319 :cite:`Fichet2021`
320 """
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: ())
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.
336 Parameters
337 ----------
338 path
339 Image path.
341 Returns
342 -------
343 :class:`Specification_Fichet2021`
344 *Fichet et al. (2021)* spectral image specification.
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 """
363 from OpenImageIO import ImageInput # noqa: PLC0415
365 path = str(path)
367 components = defaultdict(dict)
368 is_emissive = False
369 is_polarised = False
370 is_bispectral = False
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 )
378 image_input = ImageInput.open(path)
379 image_specification = image_input.spec()
380 channels = image_specification.channelnames
382 for i, channel in enumerate(channels):
383 if (match := pattern_emissive.match(channel)) is not None:
384 is_emissive = True
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
391 if len(components) > 1:
392 is_polarised = True
394 if (match := pattern_bispectral.match(channel)) is not None:
395 is_bispectral = True
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
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
412 attributes = [
413 Image_Specification_Attribute(
414 attribute.name, attribute.value, attribute.type
415 )
416 for attribute in image_specification.extra_attribs
417 ]
419 image_input.close()
421 return Specification_Fichet2021(
422 path,
423 components,
424 is_emissive,
425 is_polarised,
426 is_bispectral,
427 tuple(attributes),
428 )
431SPECIFICATION_FICHET2021_DEFAULT: Specification_Fichet2021 = Specification_Fichet2021()
432"""
433Default *Fichet et al. (2021)* spectral image specification.
434"""
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]: ...
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: ...
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: ...
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*.
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.
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.
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.
497 References
498 ----------
499 :cite:`Fichet2021`
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 """
525 from OpenImageIO import ImageInput # noqa: PLC0415
527 path = str(path)
529 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth]
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()
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 )
548 if additional_data:
549 return components, specification
551 return components
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.
566 Align the spectral and multi-spectral distributions to the intersection
567 of their spectral shapes before conversion.
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.
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.
586 Returns
587 -------
588 :class:`dict`
589 Dictionary of component names and their corresponding tuple of
590 wavelengths and values.
592 References
593 ----------
594 :cite:`Fichet2021`
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 """
612 msds = sds_and_msds_to_msds(sds)
613 component = "S0" if specification.is_emissive else "T"
615 wavelengths = msds.wavelengths
616 values = np.transpose(msds.values)
617 values = np.reshape(values, (1, -1, values.shape[-1]))
619 if "shape" in kwargs:
620 values = np.reshape(values, kwargs["shape"])
622 return {component: (wavelengths, values)}
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.
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.
642 Returns
643 -------
644 :class:`tuple`
645 Tuple of *sRGB* colourspace values and list of
646 :class:`colour.io.Image_Specification_Attribute` class instances.
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.
654 Notes
655 -----
656 - When an emissive component is specified, its exposure will be
657 normalised so that its median is 0.18.
659 References
660 ----------
661 :cite:`Fichet2021`
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 """
685 from OpenImageIO import TypeDesc # noqa: PLC0415
687 component = components.get("S0", components.get("T"))
689 if component is None:
690 return None, []
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 )
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 )
705 msds = component[1]
706 shape = SpectralShape(component[0][0], component[0][-1], interval(component[0])[0])
708 cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
709 colourspace = RGB_COLOURSPACE_sRGB
711 if specification.is_emissive:
712 illuminant = SDS_ILLUMINANTS["E"]
714 XYZ = msds_to_XYZ(msds, cmfs=cmfs, method="Integration", shape=shape)
715 else:
716 illuminant = SDS_ILLUMINANTS["D65"]
718 XYZ = (
719 msds_to_XYZ(
720 msds,
721 cmfs=cmfs,
722 illuminant=illuminant,
723 method="Integration",
724 shape=shape,
725 )
726 / 100
727 )
729 RGB = XYZ_to_RGB(XYZ, colourspace)
731 chromaticities = np.ravel(
732 np.vstack([colourspace.primaries, colourspace.whitepoint])
733 ).tolist()
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 ]
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 )
760 return RGB, attributes
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*.
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.
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.
802 Returns
803 -------
804 :class:`bool`
805 Definition success.
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 """
830 from OpenImageIO import ImageBuf, ImageBufAlgo # noqa: PLC0415
832 path = str(path)
834 if isinstance(
835 components,
836 (Sequence, SpectralDistribution, MultiSpectralDistributions, ValuesView),
837 ):
838 components = sds_and_msds_to_components_Fichet2021(
839 components, specification, **kwargs
840 )
842 if specification.attributes is None:
843 specification.attributes = [
844 Image_Specification_Attribute("spectralLayoutVersion", "1.0")
845 ]
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 )
855 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth]
857 channels = {}
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]})
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 )
877 channels[channel_name] = values[..., i]
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)
886 add_attributes_to_image_specification_OpenImageIO(
887 image_buffer.specmod(), # pyright: ignore
888 [*specification.attributes, *attributes],
889 )
891 return image_buffer.write(path)