Source code for colorimetry.core

"""
Colorimetry calculation engine.
Handles spectral interpolation, XYZ calculation, and sRGB conversions.
Uses the optiland backend for mathematical operations.
"""

from __future__ import annotations

from typing import Any

from scipy.interpolate import interp1d

import optiland.backend as be

from .constants import CIE_1931_2DEG, CIE_1964_10DEG, ILLUMINANT_D65, WAVELENGTHS_STD


def _interpolate_spectrum(
    x_vals: list[float], y_vals: list[float], x_target: list[float], kind: str = "cubic"
) -> list[float]:
    """
    Interpolate spectral data using scipy.

    Args:
        x_vals: Source wavelengths.
        y_vals: Source values.
        x_target: Target wavelengths.
        kind: Type of interpolation ('linear', 'cubic', etc.). Default is 'cubic'.

    Returns:
        Interpolated values at x_target.
    """
    # Create interpolation function
    # bounds_error=False and fill_value parameters handle extrapolation safely
    # by clamping to the nearest value (or 0 if preferred, but clamping is safer
    # for spectra)
    f = interp1d(
        x_vals,
        y_vals,
        kind=kind,
        bounds_error=False,
        fill_value=(y_vals[0], y_vals[-1]),
    )
    return f(x_target)


def _extract_xyz(X: Any, Y: Any = None, Z: Any = None) -> tuple[Any, Any, Any]:
    """
    Helper to extract X, Y, Z components from inputs.
    Supports separate arguments or a single packed array.
    Detects the dimension with size 3 automatically.
    """
    if Y is None and Z is None:
        # Packed input
        arr = be.asarray(X)
        shape = arr.shape

        if len(shape) == 0:
            raise ValueError("Scalar input requires X, Y, Z arguments")

        # Heuristic: check last dim (e.g. image HxWx3) then first dim (3xN)
        if shape[-1] == 3:
            return arr[..., 0], arr[..., 1], arr[..., 2]
        elif shape[0] == 3:
            return arr[0], arr[1], arr[2]

        # Search for any dimension with size 3
        found_axis = None
        for i, dim in enumerate(shape):
            if dim == 3:
                found_axis = i
                break

        if found_axis is not None:
            # Generic slicing
            indexer = [slice(None)] * len(shape)

            indexer[found_axis] = 0
            X_out = arr[tuple(indexer)]

            indexer[found_axis] = 1
            Y_out = arr[tuple(indexer)]

            indexer[found_axis] = 2
            Z_out = arr[tuple(indexer)]
            return X_out, Y_out, Z_out

        raise ValueError(
            "Input array must have a dimension of size 3 representing X, Y, Z"
        )
    else:
        return be.asarray(X), be.asarray(Y), be.asarray(Z)


[docs] def spectrum_to_xyz( wavelengths: list[float], values: list[float], illuminant: list[float] | None = None, observer: str = "2deg", ) -> tuple[float, float, float]: """ Converts a spectrum (R or T) to CIE XYZ coordinates. References: - CIE 15:2004 Colorimetry, 3rd Edition. - ISO 11664-3:2019 (CIE S 014-3/E:2011) Colorimetry — Part 3: CIE Tristimulus Values. Args: wavelengths: List of wavelengths of the measured spectrum (in nm). values: Spectral values (0.0 to 1.0). illuminant: Illuminant spectrum aligned with WAVELENGTHS_STD. Default: D65. observer: '2deg' (CIE 1931) or '10deg' (CIE 1964). Returns: (X, Y, Z) normalized (Y=100 for a perfect white). """ # Select constants std_wl = WAVELENGTHS_STD # Check spectral coverage # We ensure the input spectrum covers at least the standard range (380-780nm) # to avoid inaccurate color calculations due to missing data. min_wl_in = min(wavelengths) max_wl_in = max(wavelengths) min_wl_std = std_wl[0] max_wl_std = std_wl[-1] if min_wl_in > min_wl_std or max_wl_in < max_wl_std: raise ValueError( f"Input spectrum range ({min_wl_in:.1f}-{max_wl_in:.1f} nm) does not cover " f"the required visible range ({min_wl_std}-{max_wl_std} nm)." ) cmf = CIE_1964_10DEG if observer == "10deg" else CIE_1931_2DEG S = illuminant if illuminant is not None else ILLUMINANT_D65 # Check illuminant size if len(S) != len(std_wl): raise ValueError( f"Illuminant must have the same size as WAVELENGTHS_STD ({len(std_wl)})" ) # Interpolate user spectrum onto standard grid # Using cubic interpolation for better accuracy on spectral curves interp_values = _interpolate_spectrum(wavelengths, values, std_wl, kind="cubic") # Calculate integrals (Riemann Sum) X_sum = 0.0 Y_sum = 0.0 Z_sum = 0.0 k_sum = 0.0 # Normalization factor d_lambda = float(WAVELENGTHS_STD[1] - WAVELENGTHS_STD[0]) for i in range(len(std_wl)): r = interp_values[i] illuminant_val = S[i] x_bar, y_bar, z_bar = cmf[i] # Weighting X_sum += r * illuminant_val * x_bar * d_lambda Y_sum += r * illuminant_val * y_bar * d_lambda Z_sum += r * illuminant_val * z_bar * d_lambda # Normalization (perfect source) k_sum += illuminant_val * y_bar * d_lambda k = 100.0 / k_sum if k_sum != 0 else 0 return (X_sum * k, Y_sum * k, Z_sum * k)
[docs] def xyz_to_xyY( X: float | Any, Y: float | Any | None = None, Z: float | Any | None = None, ) -> tuple[Any, Any, Any]: """ Converts XYZ to xyY (chromaticity + luminance). Vectorized: supports scalars, separate arrays, or a single packed array with a dimension of size 3. """ X, Y, Z = _extract_xyz(X, Y, Z) sum_xyz = X + Y + Z # Handle division by zero (vectorized) mask = be.abs(sum_xyz) == 0 safe_sum = be.where(mask, 1.0, sum_xyz) x = X / safe_sum y = Y / safe_sum # Default white point (approx D65) if absolute black x = be.where(mask, 0.3127, x) y = be.where(mask, 0.3290, y) return x, y, Y
[docs] def xyz_to_srgb( X: float | Any, Y: float | Any | None = None, Z: float | Any | None = None, ) -> tuple[Any, Any, Any]: """ Converts XYZ (Y=100 max) to sRGB (0-255). Vectorized: supports scalars, separate arrays, or a single packed array with a dimension of size 3. Uses the standard sRGB matrix (D65) and sRGB Gamma correction. References: - IEC 61966-2-1:1999 Multimedia systems and equipment - Colour measurement and management - Part 2-1: Colour management - Default RGB colour space - sRGB. - Anderson, M., Motta, R., Chandrasekar, S., & Stokes, M. (1996). Proposal for a Standard Default Color Space for the Internet—sRGB. """ X, Y, Z = _extract_xyz(X, Y, Z) # 1. Normalize XYZ to 0-1 X_n = X / 100.0 Y_n = Y / 100.0 Z_n = Z / 100.0 # 2. Linear Transformation (sRGB D65 Matrix) # | R_l | | 3.2404542 -1.5371385 -0.4985314 | | X | # | G_l | = | -0.9692660 1.8760108 0.0415560 | * | Y | # | B_l | | 0.0556434 -0.2040259 1.0572252 | | Z | r_l = 3.2404542 * X_n - 1.5371385 * Y_n - 0.4985314 * Z_n g_l = -0.9692660 * X_n + 1.8760108 * Y_n + 0.0415560 * Z_n b_l = 0.0556434 * X_n - 0.2040259 * Y_n + 1.0572252 * Z_n # 3. Gamma Correction (sRGB Transfer Function) - Vectorized def gamma_correct(v): mask = v <= 0.0031308 linear_part = 12.92 * v # Protection against negative numbers before pow v_safe = be.where(v < 0.0, 0.0, v) power_part = 1.055 * be.power(v_safe, 1.0 / 2.4) - 0.055 return be.where(mask, linear_part, power_part) r = gamma_correct(r_l) g = gamma_correct(g_l) b = gamma_correct(b_l) # 4. Clipping [0, 1] and conversion 0-255 def clip_and_scale(v): # Clip to 0-1 v_clipped = be.where(v < 0.0, 0.0, be.where(v > 1.0, 1.0, v)) scaled = v_clipped * 255 # Try to cast to int if backend supports it (like numpy) try: return scaled.astype(int) except AttributeError: if hasattr(scaled, "to"): return scaled.to(dtype=be._lib.int64) return scaled return (clip_and_scale(r), clip_and_scale(g), clip_and_scale(b))