Source code for colorimetry.plotting

"""
Colorimetry plotting utilities.
"""

from __future__ import annotations

from typing import Literal

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import LineCollection
from matplotlib.path import Path
from scipy.interpolate import interp1d

import optiland.backend as be

from .constants import CIE_1931_2DEG, WAVELENGTHS_STD


def _to_numpy_safe(x):
    try:
        return be.to_numpy(x)
    except Exception:
        if hasattr(x, "detach") and hasattr(x, "cpu"):
            return x.detach().cpu().numpy()
        return x


def _to_float_safe(x) -> float:
    if hasattr(x, "detach") and hasattr(x, "cpu"):
        x = x.detach().cpu()
    if hasattr(x, "item"):
        return float(x.item())
    return float(x)


[docs] def plot_cie_1931_chromaticity_diagram( ax: plt.Axes | None = None, title: str = "CIE 1931 Chromaticity Diagram", color: Literal["no", "contour", "fill"] = "contour", show_legend: bool = False, ) -> tuple[plt.Figure, plt.Axes]: """ Plots the CIE 1931 chromaticity diagram (spectral locus). Generates the spectral locus by converting the standard observer CMFs to xy coordinates for each wavelength. Args: ax: Optional matplotlib Axes. If None, a new figure is created. title: Title of the plot. color: Coloring mode. - "no": Black and white outline. - "contour": Spectral locus colored by wavelength (default). - "fill": Gamut filled with approximate sRGB colors. show_legend: Whether to display the legend. Returns: Tuple (Figure, Axes). """ if ax is None: fig, ax = plt.subplots(figsize=(6, 6)) else: fig = ax.figure # 1. Upsample Data for smoothness # Extract raw data wls_raw = be.asarray(WAVELENGTHS_STD) cmfs_raw = be.asarray(CIE_1931_2DEG) x_bar_raw, y_bar_raw, z_bar_raw = cmfs_raw[:, 0], cmfs_raw[:, 1], cmfs_raw[:, 2] # Create fine wavelength grid (0.5nm steps) for smooth locus wl_fine = be.linspace(390, 700, 621) # Interpolate CMFs (Cubic) # We use scipy for cubic interpolation as requested def cubic_interp(x, y, x_new): f = interp1d( _to_numpy_safe(x), _to_numpy_safe(y), kind="cubic", fill_value="extrapolate", ) return be.asarray(f(_to_numpy_safe(x_new))) x_bar = cubic_interp(wls_raw, x_bar_raw, wl_fine) y_bar = cubic_interp(wls_raw, y_bar_raw, wl_fine) z_bar = cubic_interp(wls_raw, z_bar_raw, wl_fine) # Calculate xy coordinates for the spectral locus sum_xyz = x_bar + y_bar + z_bar # Avoid division by zero mask_valid = sum_xyz > 0 x_locus = x_bar[mask_valid] / sum_xyz[mask_valid] y_locus = y_bar[mask_valid] / sum_xyz[mask_valid] # Calculate RGB colors for the locus (normalized 0-1) # We use a local float conversion to avoid integer rounding artifacts def _get_rgb_float(X, Y, Z): # Normalize Xn, Yn, Zn = X / 100.0, Y / 100.0, Z / 100.0 # sRGB Matrix r_l = 3.2404542 * Xn - 1.5371385 * Yn - 0.4985314 * Zn g_l = -0.9692660 * Xn + 1.8760108 * Yn + 0.0415560 * Zn b_l = 0.0556434 * Xn - 0.2040259 * Yn + 1.0572252 * Zn # Gamma def gamma(v): v_safe = be.where(v < 0.0, 0.0, v) return be.where( v <= 0.0031308, 12.92 * v, 1.055 * be.power(v_safe, 1.0 / 2.4) - 0.055, ) # Clip 0-1 def clip(v): return be.where(v < 0.0, 0.0, be.where(v > 1.0, 1.0, v)) return ( clip(gamma(r_l)), clip(gamma(g_l)), clip(gamma(b_l)), ) # Colors for the spectral locus # Use fixed luminance Y=50 to ensure visibility across the spectrum # (Raw CMFs would result in black ends) Y_const = 50.0 y_locus_safe = be.where(y_locus <= 0, 1e-6, y_locus) X_loc = (x_locus / y_locus_safe) * Y_const Z_loc = ((1 - x_locus - y_locus) / y_locus_safe) * Y_const r_loc, g_loc, b_loc = _get_rgb_float(X_loc, be.ones_like(X_loc) * Y_const, Z_loc) rgb_locus = be.transpose(be.stack([r_loc, g_loc, b_loc], axis=0)) # Polygon for filling/masking (closed loop) x_poly = be.concatenate((x_locus, be.atleast_1d(x_locus[0]))) y_poly = be.concatenate((y_locus, be.atleast_1d(y_locus[0]))) # 2. Plotting based on color mode if color == "fill": # Generate grid for filling res = 500 # Increased resolution x_min, x_max = -0.05, 0.85 y_min, y_max = -0.05, 0.9 x_grid = be.linspace(x_min, x_max, res) y_grid = be.linspace(y_min, y_max, res) X_grid, Y_grid = be.meshgrid(x_grid, y_grid) xf = be._lib.reshape(X_grid, (-1,)) yf = be._lib.reshape(Y_grid, (-1,)) # Mask points inside locus verts = _to_numpy_safe(be.transpose(be.stack([x_poly, y_poly], axis=0))) path = Path(verts) points = _to_numpy_safe(be.transpose(be.stack([xf, yf], axis=0))) mask = path.contains_points(points) # Compute colors (Y=50 for reasonable brightness) Y_val = 50.0 yf_safe = be.where(yf <= 0, 1e-6, yf) X_C = (xf / yf_safe) * Y_val Z_C = ((1 - xf - yf) / yf_safe) * Y_val # Use float conversion for smooth gradients (no int rounding) R, G, B = _get_rgb_float(X_C, be.ones_like(X_C) * Y_val, Z_C) # Create RGBA image alpha = be.asarray(mask, dtype=float) img_flat = be.transpose(be.stack([R, G, B, alpha], axis=0)) img = be.reshape(img_flat, (res, res, 4)) ax.imshow( _to_numpy_safe(img), extent=[x_min, x_max, y_min, y_max], origin="lower", interpolation="bilinear", ) ax.plot(_to_numpy_safe(x_locus), _to_numpy_safe(y_locus), "k-", linewidth=1.5) # Purple line (black dashed for fill mode to define boundary) ax.plot( [_to_float_safe(x_locus[-1]), _to_float_safe(x_locus[0])], [_to_float_safe(y_locus[-1]), _to_float_safe(y_locus[0])], "k--", linewidth=1, ) elif color == "contour": # 1. Spectral Locus (Colored) points = _to_numpy_safe( be.reshape(be.transpose(be.stack([x_locus, y_locus], axis=0)), (-1, 1, 2)) ) segments = np.concatenate((points[:-1], points[1:]), axis=1) lc = LineCollection( segments, colors=_to_numpy_safe(rgb_locus[:-1]), linewidth=2 ) ax.add_collection(lc) # 2. Purple Line (Colored Gradient) # Interpolate between Red end and Blue end n_purple = 50 x_p = be.linspace(x_locus[-1], x_locus[0], n_purple) y_p = be.linspace(y_locus[-1], y_locus[0], n_purple) # Interpolate colors using xy and fixed luminance y_p_safe = be.where(y_p <= 0, 1e-6, y_p) X_p = (x_p / y_p_safe) * Y_const Z_p = ((1 - x_p - y_p) / y_p_safe) * Y_const r_p, g_p, b_p = _get_rgb_float(X_p, be.ones_like(X_p) * Y_const, Z_p) rgb_purple = be.transpose(be.stack([r_p, g_p, b_p], axis=0)) points_p = _to_numpy_safe( be.reshape(be.transpose(be.stack([x_p, y_p], axis=0)), (-1, 1, 2)) ) segments_p = np.concatenate((points_p[:-1], points_p[1:]), axis=1) lc_p = LineCollection( segments_p, colors=_to_numpy_safe(rgb_purple[:-1]), linewidth=2, linestyle="--", ) ax.add_collection(lc_p) # Dummy line for legend if show_legend: ax.plot([], [], "-", color="gray", linewidth=2, label="Spectral Locus") ax.plot([], [], "--", color="gray", linewidth=2, label="Purple Line") # Light fill ax.fill(_to_numpy_safe(x_poly), _to_numpy_safe(y_poly), "k", alpha=0.05) else: # "no" ax.plot( _to_numpy_safe(x_locus), _to_numpy_safe(y_locus), "k-", linewidth=1.5, label="Spectral Locus", ) ax.plot( [_to_float_safe(x_locus[-1]), _to_float_safe(x_locus[0])], [_to_float_safe(y_locus[-1]), _to_float_safe(y_locus[0])], "k--", linewidth=1, label="Purple Line", ) ax.fill(_to_numpy_safe(x_poly), _to_numpy_safe(y_poly), "k", alpha=0.05) # 3. Add Wavelength Markers target_wls = [450, 480, 520, 560, 600, 620, 700] for wl in target_wls: # Find closest index in fine grid idx = int(be.argmin(be.abs(wl_fine - wl))) # Check if we are close enough (within 1nm) if abs(wl_fine[idx] - wl) < 1.0: x, y = _to_float_safe(x_locus[idx]), _to_float_safe(y_locus[idx]) ax.plot(x, y, "ko", markersize=3) # Offset text slightly towards outside dx = 0.02 if x > 0.3 else -0.04 dy = 0.02 if y > 0.3 else -0.02 ax.text(x + dx, y + dy, f"{wl}", fontsize=8, ha="center") # Setup axes ax.set_xlim(-0.05, 0.85) ax.set_ylim(-0.05, 0.9) ax.set_aspect("equal") ax.grid(True, linestyle=":", alpha=0.6) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_title(title) if show_legend: ax.legend() return fig, ax