Source code for colour.io.fichet2021

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

Define spectral image input/output functionality based on the
*Fichet et al. (2021)* OpenEXR layout specification.

References
----------
-   :cite:`Fichet2021` : Fichet, A., Pacanowski, R., & Wilkie, A. (2021). An
    OpenEXR Layout for Spectral Images. 10(3). Retrieved April 26, 2024, from
    http://jcgt.org/published/0010/03/01/
"""

from __future__ import annotations

import re
import typing
from collections import defaultdict
from collections.abc import ValuesView
from dataclasses import dataclass, field

import numpy as np

from colour.colorimetry import (
    MSDS_CMFS,
    SDS_ILLUMINANTS,
    MultiSpectralDistributions,
    SpectralDistribution,
    SpectralShape,
    msds_to_XYZ,
    sds_and_msds_to_msds,
)
from colour.constants import CONSTANT_LIGHT_SPEED

if typing.TYPE_CHECKING:
    from colour.hints import (
        Any,
        Callable,
        Literal,
        PathLike,
    )

from colour.hints import Dict, NDArrayFloat, Sequence, Tuple
from colour.io.image import (
    MAPPING_BIT_DEPTH,
    Image_Specification_Attribute,
    add_attributes_to_image_specification_OpenImageIO,
)
from colour.models import RGB_COLOURSPACE_sRGB, XYZ_to_RGB
from colour.utilities import (
    as_float_array,
    interval,
    required,
    usage_warning,
    validate_method,
)

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "MAPPING_UNIT_CONVERSION",
    "PATTERN_FICHET2021",
    "ComponentsFichet2021",
    "match_groups_to_nm",
    "sd_to_spectrum_attribute_Fichet2021",
    "spectrum_attribute_to_sd_Fichet2021",
    "Specification_Fichet2021",
    "SPECIFICATION_FICHET2021_DEFAULT",
    "read_spectral_image_Fichet2021",
    "sds_and_msds_to_components_Fichet2021",
    "components_to_sRGB_Fichet2021",
    "write_spectral_image_Fichet2021",
]

MAPPING_UNIT_CONVERSION: dict = {
    "Y": 1e24,
    "Z": 1e21,
    "E": 1e18,
    "P": 1e15,
    "T": 1e12,
    "G": 1e9,
    "M": 1e6,
    "k": 1e3,
    "h": 1e2,
    "da": 1e1,
    "": 1,
    "d": 1e-1,
    "c": 1e-2,
    "m": 1e-3,
    "u": 1e-6,
    "n": 1e-9,
    "p": 1e-12,
}
"""
Unit conversion mapping.

References
----------
:cite:`Fichet2021`
"""

PATTERN_FICHET2021: str = (
    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)"
)
"""
Regex pattern for numbers and quantities.

References
----------
:cite:`Fichet2021`
"""


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


def match_groups_to_nm(
    number: str,
    multiplier: Literal[
        "Y",
        "Z",
        "E",
        "P",
        "T",
        "G",
        "M",
        "k",
        "h",
        "da",
        "",
        "d",
        "c",
        "m",
        "u",
        "n",
        "p",
    ]
    | str,
    units: Literal["m", "Hz"] | str,
) -> float:
    """
    Convert wavelength or frequency match groups to nanometre values.

    Parameters
    ----------
    number
        Wavelength (or frequency) number to convert.
    multiplier
        Unit multiplier.
    units
        Frequency or wavelength.

    Returns
    -------
    :class:`float`
        Nanometre value.

    Raises
    ------
    ValueError
        If the multiplier or units are not supported.

    Examples
    --------
    >>> match_groups_to_nm("555.5", "n", "m")
    555.5
    >>> match_groups_to_nm("555.5", "", "m")
    555500000000.0
    >>> from colour.constants import CONSTANT_LIGHT_SPEED
    >>> match_groups_to_nm(str(CONSTANT_LIGHT_SPEED / (555 * 1e-9)), "", "Hz")
    ... # doctest: +ELLIPSIS
    555.0000000...
    """

    multiplier = validate_method(
        multiplier, tuple(MAPPING_UNIT_CONVERSION), as_lowercase=False
    )
    units = validate_method(units, ("m", "Hz"), as_lowercase=False)

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

    if multiplier == "n" and units == "m":
        return v

    v *= MAPPING_UNIT_CONVERSION[multiplier]

    if units == "m":
        v *= 1e9
    elif units == "Hz":
        v = CONSTANT_LIGHT_SPEED / v * 1e9

    return v


[docs] def sd_to_spectrum_attribute_Fichet2021( sd: SpectralDistribution, decimals: int = 7 ) -> str: """ Convert the specified spectral distribution to a spectrum attribute value according to *Fichet et al. (2021)*. The conversion produces a string representation of the spectral distribution suitable for use in rendering systems, with wavelength-value pairs formatted as a semicolon-delimited list. Parameters ---------- sd Spectral distribution to convert. decimals Formatting decimals. Returns ------- :class:`str` Spectrum attribute value. References ---------- :cite:`Fichet2021` Examples -------- >>> sd_to_spectrum_attribute_Fichet2021(SDS_ILLUMINANTS["D65"], 2)[:56] '300.00nm:0.03;305.00nm:1.66;310.00nm:3.29;315.00nm:11.77' """ return ";".join( f"{wavelength:.{decimals}f}nm:{value:.{decimals}f}" for wavelength, value in zip(sd.wavelengths, sd.values, strict=True) )
[docs] def spectrum_attribute_to_sd_Fichet2021( spectrum_attribute: str, ) -> SpectralDistribution: """ Convert the specified spectrum attribute value to a spectral distribution according to *Fichet et al. (2021)*. Parameters ---------- spectrum_attribute Spectrum attribute value to convert. Returns ------- :class:`SpectralDistribution` Spectral distribution. References ---------- :cite:`Fichet2021` Examples -------- >>> spectrum_attribute_to_sd_Fichet2021( ... "300.00nm:0.03;305.00nm:1.66;310.00nm:3.29;315.00nm:11.77" ... ) # doctest: +SKIP SpectralDistribution([[ 3.0000000...e+02, 3.0000000...e-02], [ 3.0500000...e+02, 1.6600000...e+00], [ 3.1000000...e+02, 3.2900000...e+00], [ 3.1500000...e+02, 1.1770000...e+01]], SpragueInterpolator, {}, Extrapolator, {'method': 'Constant', 'left': None, 'right': None}) """ data = {} pattern = re.compile(PATTERN_FICHET2021) parts = spectrum_attribute.split(";") for part in parts: domain, range_ = part.split(":") if (match := pattern.match(domain.replace(".", ","))) is not None: multiplier, units = match.group(3, 4) wavelength = match_groups_to_nm(match.group(1), multiplier, units) data[wavelength] = float(range_) return SpectralDistribution(data)
[docs] @dataclass class Specification_Fichet2021: """ Define the *Fichet et al. (2021)* spectral image specification. Parameters ---------- path Path of the spectral image. components Components of the spectral image, e.g., *S0*, *S1*, *S2*, *S3*, *T*, or any wavelength number for bi-spectral images. is_emissive Whether the image is emissive, i.e., using the *S0* component. is_polarised Whether the image is polarised, i.e., using the *S0*, *S1*, *S2*, and *S3* components. is_bispectral Whether the image is bi-spectral, i.e., using the *T*, and any wavelength number. attributes An array of :class:`colour.io.Image_Specification_Attribute` class instances used to set attributes of the image. Methods ------- - :meth:`~colour.Specification_Fichet2021.from_spectral_image` References ---------- :cite:`Fichet2021` """ path: str | None = field(default_factory=lambda: None) components: defaultdict = field(default_factory=lambda: defaultdict(dict)) is_emissive: bool = field(default_factory=lambda: False) is_polarised: bool = field(default_factory=lambda: False) is_bispectral: bool = field(default_factory=lambda: False) attributes: Tuple = field(default_factory=lambda: ()) @staticmethod @required("OpenImageIO") def from_spectral_image(path: str | PathLike) -> Specification_Fichet2021: """ Create a *Fichet et al. (2021)* spectral image specification from the specified image path. Parameters ---------- path Image path. Returns ------- :class:`Specification_Fichet2021` *Fichet et al. (2021)* spectral image specification. Examples -------- >>> import os >>> import colour >>> path = os.path.join( ... colour.__path__[0], ... "io", ... "tests", ... "resources", ... "D65.exr", ... ) >>> specification = Specification_Fichet2021.from_spectral_image(path) ... # doctest: +SKIP >>> specification.is_emissive # doctest: +SKIP True """ from OpenImageIO import ImageInput # noqa: PLC0415 path = str(path) components = defaultdict(dict) is_emissive = False is_polarised = False is_bispectral = False pattern_emissive = re.compile(rf"^(S[0-3])\.*{PATTERN_FICHET2021}$") pattern_reflective = re.compile(rf"^T\.*{PATTERN_FICHET2021}$") pattern_bispectral = re.compile( rf"^T\.*{PATTERN_FICHET2021}\.*{PATTERN_FICHET2021}$" ) image_input = ImageInput.open(path) image_specification = image_input.spec() channels = image_specification.channelnames for i, channel in enumerate(channels): if (match := pattern_emissive.match(channel)) is not None: is_emissive = True component = match.group(1) multiplier, units = match.group(4, 5) wavelength = match_groups_to_nm(match.group(2), multiplier, units) components[component][wavelength] = i if len(components) > 1: is_polarised = True if (match := pattern_bispectral.match(channel)) is not None: is_bispectral = True input_multiplier, input_units = match.group(3, 4) input_wavelength = match_groups_to_nm( match.group(1), input_multiplier, input_units ) output_multiplier, output_units = match.group(7, 8) output_wavelength = match_groups_to_nm( match.group(5), output_multiplier, output_units ) components[input_wavelength][output_wavelength] = i if (match := pattern_reflective.match(channel)) is not None: multiplier, units = match.group(3, 4) wavelength = match_groups_to_nm(match.group(1), multiplier, units) components["T"][wavelength] = i attributes = [ Image_Specification_Attribute( attribute.name, attribute.value, attribute.type ) for attribute in image_specification.extra_attribs ] image_input.close() return Specification_Fichet2021( path, components, is_emissive, is_polarised, is_bispectral, tuple(attributes), )
SPECIFICATION_FICHET2021_DEFAULT: Specification_Fichet2021 = Specification_Fichet2021() """ Default *Fichet et al. (2021)* spectral image specification. """ @typing.overload @required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., additional_data: Literal[True] = True, ) -> Tuple[ComponentsFichet2021, Specification_Fichet2021]: ... @typing.overload @required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = ..., *, additional_data: Literal[False], ) -> ComponentsFichet2021: ... @typing.overload @required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"], additional_data: bool = False, ) -> ComponentsFichet2021: ...
[docs] @required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | PathLike, bit_depth: Literal["float16", "float32"] = "float32", additional_data: bool = False, ) -> ComponentsFichet2021 | Tuple[ComponentsFichet2021, Specification_Fichet2021]: """ Read the *Fichet et al. (2021)* spectral image at the specified path using *OpenImageIO*. Parameters ---------- path Image path. bit_depth Returned image bit-depth. additional_data Whether to return additional data. Returns ------- :class:`dict` or :class:`tuple` Dictionary of component names and their corresponding tuple of wavelengths and values or tuple of the aforementioned dictionary and :class:`colour.Specification_Fichet2021` class instance. Notes ----- - Spectrum attributes are not parsed but can be converted to spectral distribution using the :func:`colour.io.spectrum_attribute_to_sd_Fichet2021` definition. References ---------- :cite:`Fichet2021` Examples -------- >>> import os >>> import colour >>> path = os.path.join( ... colour.__path__[0], ... "io", ... "tests", ... "resources", ... "D65.exr", ... ) >>> msds, specification = read_spectral_image_Fichet2021( ... path, additional_data=True ... ) # doctest: +SKIP >>> components.keys() # doctest: +SKIP dict_keys(['S0']) >>> components["S0"][0].shape # doctest: +SKIP (97,) >>> components["S0"][1].shape # doctest: +SKIP (1, 1, 97) >>> specification.is_emissive # doctest: +SKIP True """ from OpenImageIO import ImageInput # noqa: PLC0415 path = str(path) bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] specification = Specification_Fichet2021.from_spectral_image(path) image_input = ImageInput.open(path) image = image_input.read_image(bit_depth_specification.openimageio) image_input.close() components = {} for component, wavelengths_indexes in specification.components.items(): wavelengths, indexes = zip(*wavelengths_indexes.items(), strict=True) values = as_float_array( image[:, :, indexes], # pyright: ignore dtype=bit_depth_specification.numpy, ) components[component] = ( as_float_array(wavelengths), np.array(values, dtype=bit_depth_specification.numpy), ) if additional_data: return components, specification return components
def sds_and_msds_to_components_Fichet2021( sds: Sequence[SpectralDistribution | MultiSpectralDistributions] | SpectralDistribution | MultiSpectralDistributions | ValuesView, specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, **kwargs: Any, ) -> ComponentsFichet2021: """ Convert specified spectral and multi-spectral distributions to *Fichet et al. (2021)* components. Align the spectral and multi-spectral distributions to the intersection of their spectral shapes before conversion. Parameters ---------- sds Spectral and multi-spectral distributions to convert to *Fichet et al. (2021)* components. specification *Fichet et al. (2021)* spectral image specification, used to determine the proper component type, i.e., emissive or other. Other Parameters ---------------- shape Optional shape the *Fichet et al. (2021)* components should take. Used when converting spectral distributions of a colour rendition chart to create a rectangular image rather than a single line of values. Returns ------- :class:`dict` Dictionary of component names and their corresponding tuple of wavelengths and values. References ---------- :cite:`Fichet2021` Examples -------- >>> components = sds_and_msds_to_components_Fichet2021(SDS_ILLUMINANTS["D65"]) >>> components.keys() dict_keys(['T']) >>> components = sds_and_msds_to_components_Fichet2021( ... SDS_ILLUMINANTS["D65"], Specification_Fichet2021(is_emissive=True) ... ) >>> components.keys() dict_keys(['S0']) >>> components["S0"][0].shape (97,) >>> components["S0"][1].shape (1, 1, 97) """ msds = sds_and_msds_to_msds(sds) component = "S0" if specification.is_emissive else "T" wavelengths = msds.wavelengths values = np.transpose(msds.values) values = np.reshape(values, (1, -1, values.shape[-1])) if "shape" in kwargs: values = np.reshape(values, kwargs["shape"]) return {component: (wavelengths, values)} @required("OpenImageIO") def components_to_sRGB_Fichet2021( components: ComponentsFichet2021, specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, ) -> Tuple[NDArrayFloat | None, Sequence[Image_Specification_Attribute]]: """ Convert the specified *Fichet et al. (2021)* components to *sRGB* colourspace values. Parameters ---------- components *Fichet et al. (2021)* components to convert. specification *Fichet et al. (2021)* spectral image specification, used to perform the proper conversion to *sRGB* colourspace values. Returns ------- :class:`tuple` Tuple of *sRGB* colourspace values and list of :class:`colour.io.Image_Specification_Attribute` class instances. Warnings -------- - This definition currently assumes a uniform wavelength interval. - This definition currently does not support integration of bi-spectral component. Notes ----- - When an emissive component is specified, its exposure will be normalised so that its median is 0.18. References ---------- :cite:`Fichet2021` Examples -------- >>> specification = Specification_Fichet2021(is_emissive=True) >>> components = sds_and_msds_to_components_Fichet2021( ... SDS_ILLUMINANTS["D65"], ... specification, ... ) >>> RGB, attributes = components_to_sRGB_Fichet2021( ... components["S0"], specification ... ) # doctest: +SKIP >>> RGB # doctest: +SKIP array([[[ 0.1799829..., 0.1800080..., 0.1800090...]]]) >>> for attribute in attributes: ... print(attribute.name) # doctest: +SKIP X Y Z illuminant chromaticities EV """ from OpenImageIO import TypeDesc # noqa: PLC0415 component = components.get("S0", components.get("T")) if component is None: return None, [] # TODO: Implement support for integration of bi-spectral component. if specification.is_bispectral: usage_warning( "Bi-spectral components conversion to *sRGB* colourspace values " "is unsupported!" ) # TODO: Implement support for re-binning component with non-uniform interval. if len(interval(component[0])) != 1: # pragma: no cover usage_warning( "Components have a non-uniform interval, unexpected results might occur!" ) msds = component[1] shape = SpectralShape(component[0][0], component[0][-1], interval(component[0])[0]) cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] colourspace = RGB_COLOURSPACE_sRGB if specification.is_emissive: illuminant = SDS_ILLUMINANTS["E"] XYZ = msds_to_XYZ(msds, cmfs=cmfs, method="Integration", shape=shape) else: illuminant = SDS_ILLUMINANTS["D65"] XYZ = ( msds_to_XYZ( msds, cmfs=cmfs, illuminant=illuminant, method="Integration", shape=shape, ) / 100 ) RGB = XYZ_to_RGB(XYZ, colourspace) chromaticities = np.ravel( np.vstack([colourspace.primaries, colourspace.whitepoint]) ).tolist() attributes = [ Image_Specification_Attribute( "X", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["x_bar"]) ), Image_Specification_Attribute( "Y", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["y_bar"]) ), Image_Specification_Attribute( "Z", sd_to_spectrum_attribute_Fichet2021(cmfs.signals["z_bar"]) ), Image_Specification_Attribute( "illuminant", sd_to_spectrum_attribute_Fichet2021(illuminant) ), Image_Specification_Attribute( "chromaticities", chromaticities, TypeDesc("float[8]") ), ] if specification.is_emissive: EV = np.mean(RGB) / 0.18 RGB /= EV attributes.append( Image_Specification_Attribute("EV", np.log2(EV)), ) return RGB, attributes
[docs] @required("OpenImageIO") def write_spectral_image_Fichet2021( components: Sequence[SpectralDistribution | MultiSpectralDistributions] | SpectralDistribution | MultiSpectralDistributions | ComponentsFichet2021 | ValuesView, path: str | PathLike, bit_depth: Literal["float16", "float32"] = "float32", specification: Specification_Fichet2021 = SPECIFICATION_FICHET2021_DEFAULT, components_to_RGB_callable: Callable = components_to_sRGB_Fichet2021, **kwargs: Any, ) -> bool: """ Write the specified *Fichet et al. (2021)* components to the specified path using *OpenImageIO*. Parameters ---------- components *Fichet et al. (2021)* components. path Image path. bit_depth Bit-depth to write the image at, the bit-depth conversion behaviour is ruled directly by *OpenImageIO*. specification *Fichet et al. (2021)* spectral image specification. components_to_RGB_callable Callable converting the components to a preview *RGB* image. Other Parameters ---------------- shape Optional shape the *Fichet et al. (2021)* components should take: Used when converting spectral distributions of a colour rendition chart to create a rectangular image rather than a single line of values. Returns ------- :class:`bool` Definition success. Examples -------- >>> import os >>> import colour >>> path = os.path.join( ... colour.__path__[0], ... "io", ... "tests", ... "resources", ... "BabelColorAverage.exr", ... ) >>> msds = list(colour.SDS_COLOURCHECKERS["BabelColor Average"].values()) >>> specification = Specification_Fichet2021(is_emissive=False) >>> write_spectral_image_Fichet2021( ... msds, ... path, ... "float16", ... specification, ... shape=(4, 6, len(msds[0].shape.wavelengths)), ... ) # doctest: +SKIP True """ from OpenImageIO import ImageBuf, ImageBufAlgo # noqa: PLC0415 path = str(path) if isinstance( components, (Sequence, SpectralDistribution, MultiSpectralDistributions, ValuesView), ): components = sds_and_msds_to_components_Fichet2021( components, specification, **kwargs ) if specification.attributes is None: specification.attributes = [ Image_Specification_Attribute("spectralLayoutVersion", "1.0") ] if specification.is_emissive: specification.attributes.extend( [ Image_Specification_Attribute("polarisationHandedness", "right"), Image_Specification_Attribute("emissiveUnits", "W.m^-2.sr^-1"), ] ) bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] channels = {} RGB, attributes = components_to_RGB_callable(components, specification) if RGB is not None: channels.update({"R": RGB[..., 0], "G": RGB[..., 1], "B": RGB[..., 2]}) for component, wavelengths_values in components.items(): wavelengths, values = wavelengths_values for i, wavelength in enumerate(wavelengths): component_type = str(component)[0] if component_type == "S": # Emissive Component Type # noqa: SIM114 channel_name = f"{component}.{str(wavelength).replace('.', ',')}nm" elif component_type == "T": # Reflectance et al. Component Type channel_name = f"{component}.{str(wavelength).replace('.', ',')}nm" else: # Bi-spectral Component Type channel_name = ( f"T.{str(component).replace('.', ',')}nm." f"{str(wavelength).replace('.', ',')}nm" ) channels[channel_name] = values[..., i] image_buffer = ImageBuf() for channel_name, channel_data in channels.items(): channel_buffer = ImageBuf(channel_data.astype(bit_depth_specification.numpy)) channel_specification = channel_buffer.specmod() channel_specification.channelnames = [channel_name] # pyright: ignore image_buffer = ImageBufAlgo.channel_append(image_buffer, channel_buffer) add_attributes_to_image_specification_OpenImageIO( image_buffer.specmod(), # pyright: ignore [*specification.attributes, *attributes], ) return image_buffer.write(path)