Source code for colour.io.fichet2021

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

Define the *Fichet et al. (2021)* spectral image input / output objects.

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
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path

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
from colour.hints import (
    Callable,
    Dict,
    List,
    Literal,
    NDArrayFloat,
    Sequence,
    Tuple,
    Union,
)
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",
    "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[Union[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 match groups of a wavelength (or frequency) to the nanometer value.

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

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

    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 a spectral distribution to a spectrum attribute value according to *Fichet et al. (2021)*. 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) )
[docs] def spectrum_attribute_to_sd_Fichet2021( spectrum_attribute: str, ) -> SpectralDistribution: """ Convert a 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(":") match = pattern.match(domain.replace(".", ",")) if match 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` """ # noqa: D405, D407, D410, D411 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: List | None = field(default_factory=lambda: None) @staticmethod @required("OpenImageIO") def from_spectral_image(path: str | Path) -> Specification_Fichet2021: """ Create a *Fichet et al. (2021)* spectral image specification from given 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 # pyright: ignore 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_specification = ImageInput.open(path).spec() channels = image_specification.channelnames for i, channel in enumerate(channels): match = pattern_emissive.match(channel) if match: 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 match = pattern_bispectral.match(channel) if match: 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 match = pattern_reflective.match(channel) if match: multiplier, units = match.group(3, 4) wavelength = match_groups_to_nm(match.group(1), multiplier, units) components["T"][wavelength] = i attributes = [] for attribute in image_specification.extra_attribs: attributes.append( Image_Specification_Attribute( attribute.name, attribute.value, attribute.type ) ) return Specification_Fichet2021( path, components, is_emissive, is_polarised, is_bispectral, attributes, )
[docs] @required("OpenImageIO") def read_spectral_image_Fichet2021( path: str | Path, bit_depth: Literal["float16", "float32"] = "float32", additional_data: bool = False, ) -> ComponentsFichet2021 | Tuple[ComponentsFichet2021, Specification_Fichet2021]: """ Read the *Fichet et al. (2021)* spectral image at given 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 # pyright: ignore path = str(path) bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] specification = Specification_Fichet2021.from_spectral_image(path) image = ImageInput.open(path).read_image(bit_depth_specification.openimageio) components = {} for component, wavelengths_indexes in specification.components.items(): wavelengths, indexes = zip(*wavelengths_indexes.items()) values = as_float_array( image[:, :, indexes], 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 else: return components
def sds_and_msds_to_components_Fichet2021( sds: Sequence[SpectralDistribution | MultiSpectralDistributions] | SpectralDistribution | MultiSpectralDistributions, specification: Specification_Fichet2021 = Specification_Fichet2021(), **kwargs, ) -> ComponentsFichet2021: """ Convert given spectral and multi-spectral distributions to *Fichet et al. (2021)* components. The spectral and multi-spectral distributions will be aligned to the intersection of their spectral shapes. 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 generate 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(), ) -> Tuple[NDArrayFloat | None, Sequence[Image_Specification_Attribute]]: """ Convert given *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 given, 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 # pyright: ignore 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: 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, path: str | Path, bit_depth: Literal["float16", "float32"] = "float32", specification: Specification_Fichet2021 = Specification_Fichet2021(), components_to_RGB_callable: Callable = components_to_sRGB_Fichet2021, **kwargs, ): """ Write given *Fichet et al. (2021)* components to given 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 # pyright: ignore path = str(path) if isinstance( components, (Sequence, SpectralDistribution, MultiSpectralDistributions) ): 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] image_buffer = ImageBufAlgo.channel_append(image_buffer, channel_buffer) add_attributes_to_image_specification_OpenImageIO( image_buffer.specmod(), [*specification.attributes, *attributes] ) image_buffer.write(path) return True