Skip to content

Add wavelengths_strip to plotting functions with wavelengths on x axis.#1401

Open
lassefschmidt wants to merge 2 commits into
colour-science:developfrom
lassefschmidt:feature/plot_wavelength_on_xaxis
Open

Add wavelengths_strip to plotting functions with wavelengths on x axis.#1401
lassefschmidt wants to merge 2 commits into
colour-science:developfrom
lassefschmidt:feature/plot_wavelength_on_xaxis

Conversation

@lassefschmidt
Copy link
Copy Markdown
Contributor

Summary

Add a boolean parameter to plotting functions with wavelength on x-axis called wavelengths_strip to add the visible spectrum to the xaxis. If wavelengths go beyond visible interval (UV or IR), those areas are shown for visual cleanliness with a striped gray filling.

See wavelengths_strip_plots.html for how plots would look like with the argument activated.

As you can see e.g. for the very first plot (below minimal code example), the hues of the wavelengths strip might be slightly different to that of the spectrum itself as I wanted to desaturate the RGB colours of the wavelengths strip a bit. This can be easily changed but I felt that this made the plots visually too heavy. Happy to revert if wanted.

import numpy as np

def gaussian(x, mu, sigma):
    x = np.asarray(x, dtype=float)
    return np.exp(-0.5 * ((x - mu) / sigma) ** 2)

def make_red_reflectance(start=300, stop=1000, step=10):
    wl = np.arange(start, stop + step, step)

    # Low reflectance in blue/green, rising toward red
    r = (
        0.03
        + 0.82 * gaussian(wl, mu=660, sigma=40)
    )
    r = np.clip(r, 0.0, 1.0)

    return {int(w): float(v) for w, v in zip(wl, r)}

data_1 = make_red_reflectance()

from colour import SpectralDistribution
sd_1 = SpectralDistribution(data_1, name="Custom 1")

from colour.plotting import plot_single_sd

plot_single_sd(sd_1, wavelengths_strip=True)

Preflight

Code Style and Quality

  • Unit tests have been implemented and passed.
  • Pyright static checking has been run and passed.
  • Pre-commit hooks have been run and passed.
  • [N/A] New transformations have been added to the Automatic Colour Conversion Graph.
  • [N/A] New transformations have been exported to the relevant namespaces, e.g. colour, colour.models.

Documentation

  • [N/A] New features are documented along with examples if relevant.
  • The documentation is Sphinx and numpydoc compliant.

@KelSolaar
Copy link
Copy Markdown
Member

Hi @lassefschmidt,

Looking at this, and given how we tend to compose plots together, how about something like this:

from __future__ import annotations

import typing
from typing import Any

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Polygon
from mpl_toolkits.axes_grid1 import make_axes_locatable

from colour.algebra import LinearInterpolator, normalise_maximum, sdiv, sdiv_mode
from colour.colorimetry import (
    CCS_ILLUMINANTS,
    SDS_ILLUMINANTS,
    MultiSpectralDistributions,
    SpectralDistribution,
    sd_ones,
    wavelength_to_XYZ,
)
from colour.plotting import (
    CONSTANTS_COLOUR_STYLE,
    XYZ_to_plotting_colourspace,
    artist,
    filter_cmfs,
    override_style,
    render,
)
from colour.colorimetry import sd_to_XYZ, sds_and_msds_to_sds
from colour.plotting import filter_illuminants, update_settings_collection
from colour.utilities import domain_range_scale, first_item, ones, tstack

if typing.TYPE_CHECKING:
    from collections.abc import Sequence

    from matplotlib.axes import Axes
    from matplotlib.figure import Figure


def wavelengths_to_RGB(
        wavelengths,
        cmfs: MultiSpectralDistributions,
        out_of_gamut_clipping: bool = True,
):
    RGB = XYZ_to_plotting_colourspace(
        wavelength_to_XYZ(wavelengths, cmfs),
        CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["E"],
        apply_cctf_encoding=False,
    )

    if not out_of_gamut_clipping:
        RGB += np.abs(np.min(RGB))

    return normalise_maximum(RGB)


@override_style()
def plot_visible_spectrum_colours(
        cmfs: (
                MultiSpectralDistributions | str | Sequence[
            MultiSpectralDistributions | str]
        ) = "CIE 1931 2 Degree Standard Observer",
        out_of_gamut_clipping: bool = True,
        **kwargs: Any,
):
    _figure, axes = artist(**kwargs)

    cmfs = typing.cast(
        "MultiSpectralDistributions",
        first_item(filter_cmfs(cmfs).values()),
    )

    x_min, x_max, y_min, y_max = kwargs.get(
        "bounding_box",
        (
            float(np.min(cmfs.wavelengths)),
            float(np.max(cmfs.wavelengths)),
            0.0,
            1.0,
        ),
    )

    x_visible_min = max(x_min, float(np.min(cmfs.wavelengths)))
    x_visible_max = min(x_max, float(np.max(cmfs.wavelengths)))

    for x_start, x_end in (
            (x_min, min(x_max, x_visible_min)),
            (max(x_min, x_visible_max), x_max),
    ):
        if x_start >= x_end:
            continue
        axes.bar(
            x_start,
            y_max - y_min,
            width=x_end - x_start,
            bottom=y_min,
            color=CONSTANTS_COLOUR_STYLE.colour.bright,
            alpha=0.5,
            align="edge",
            hatch=CONSTANTS_COLOUR_STYLE.hatch.patterns[-1],
            edgecolor=CONSTANTS_COLOUR_STYLE.colour.light,
            linewidth=0,
            zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
        )

    if x_visible_min < x_visible_max:
        edges = np.linspace(
            x_visible_min,
            x_visible_max,
            max(int(np.ceil(x_visible_max - x_visible_min)), 1) + 1,
        )
        wavelengths = 0.5 * (edges[:-1] + edges[1:])
        RGB = CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding(
            wavelengths_to_RGB(wavelengths, cmfs, out_of_gamut_clipping)
        )

        axes.bar(
            edges[:-1],
            np.full(wavelengths.shape, y_max - y_min),
            width=np.diff(edges),
            bottom=y_min,
            color=RGB,
            align="edge",
            antialiased=False,
            linewidth=0,
            edgecolor="none",
            zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
        )

    axes.set_yticks([])

    settings: dict[str, Any] = {
        "axes": axes,
        "bounding_box": (x_min, x_max, y_min, y_max),
        "y_label": None,
    }
    settings.update(kwargs)

    return render(**settings)


@override_style()
def plot_visible_spectrum(
        cmfs: (
                MultiSpectralDistributions | str | Sequence[
            MultiSpectralDistributions | str]
        ) = "CIE 1931 2 Degree Standard Observer",
        out_of_gamut_clipping: bool = True,
        **kwargs: Any,
) -> tuple[Figure, Axes]:
    cmfs = typing.cast(
        "MultiSpectralDistributions", first_item(filter_cmfs(cmfs).values())
    )

    bounding_box = (min(cmfs.wavelengths), max(cmfs.wavelengths), 0, 1)

    settings: dict[str, Any] = {"bounding_box": bounding_box, "y_label": None}
    settings.update(kwargs)
    settings["show"] = False
    settings["show_visible_spectrum"] = False

    _figure, axes = plot_single_sd(
        sd_ones(cmfs.shape),
        cmfs=cmfs,
        out_of_gamut_clipping=out_of_gamut_clipping,
        **settings,
    )

    settings = {
        "axes": axes,
        "show": True,
        "title": f"The Visible Spectrum - {cmfs.display_name}",
        "x_label": "Wavelength $\\lambda$ (nm)",
    }
    settings.update(kwargs)

    return render(**settings)


@override_style()
def plot_single_sd(
        sd: SpectralDistribution,
        cmfs="CIE 1931 2 Degree Standard Observer",
        out_of_gamut_clipping: bool = True,
        modulate_colours_with_sd_amplitude: bool = False,
        equalize_sd_amplitude: bool = False,
        show_visible_spectrum: bool = False,
        **kwargs: Any,
):
    _figure, axes = artist(**kwargs)

    cmfs = typing.cast(
        "MultiSpectralDistributions",
        first_item(filter_cmfs(cmfs).values()),
    )

    sd = sd.copy()
    sd.interpolator = LinearInterpolator
    wavelengths = cmfs.wavelengths[
        np.logical_and(
            cmfs.wavelengths >= max(min(cmfs.wavelengths),
                                    min(sd.wavelengths)),
            cmfs.wavelengths <= min(max(cmfs.wavelengths),
                                    max(sd.wavelengths)),
        )
    ]
    values = sd[wavelengths]

    RGB = wavelengths_to_RGB(wavelengths, cmfs, out_of_gamut_clipping)

    if modulate_colours_with_sd_amplitude:
        with sdiv_mode():
            RGB *= sdiv(values, np.max(values))[..., None]

    RGB = CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding(RGB)

    if equalize_sd_amplitude:
        values = ones(values.shape)

    margin = 0 if equalize_sd_amplitude else 0.05

    x_min, x_max = min(wavelengths), max(wavelengths)
    y_min, y_max = 0, max(values) + max(values) * margin

    polygon = Polygon(
        np.vstack(
            [
                (x_min, 0),
                tstack([wavelengths, values]),
                (x_max, 0),
            ]
        ),
        facecolor="none",
        edgecolor="none",
        zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
    )
    axes.add_patch(polygon)

    padding = 0.1
    axes.bar(
        x=wavelengths - padding,
        height=max(values),
        width=1 + padding,
        color=RGB,
        align="edge",
        clip_path=polygon,
        zorder=CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
    )

    axes.plot(
        wavelengths,
        values,
        color=CONSTANTS_COLOUR_STYLE.colour.dark,
        zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_line,
    )

    settings: dict[str, Any] = {
        "axes": axes,
        "bounding_box": (x_min, x_max, y_min, y_max),
        "title": f"{sd.display_name} - {cmfs.display_name}",
        "x_label": "Wavelength $\\lambda$ (nm)",
        "y_label": "Spectral Distribution",
    }
    settings.update(kwargs)

    if show_visible_spectrum:
        visible_spectrum_axes = make_axes_locatable(axes).append_axes(
            "bottom", size="5%", pad=0, sharex=axes
        )
        plot_visible_spectrum_colours(
            cmfs=cmfs,
            out_of_gamut_clipping=out_of_gamut_clipping,
            axes=visible_spectrum_axes,
            bounding_box=(x_min, x_max, 0, 1),
            x_label=settings.pop("x_label", None),
            show=False,
        )
        axes.tick_params(
            axis="x", bottom=False, labelbottom=False, which="both"
        )
        axes.set_xlabel(None)

    return render(**settings)


@override_style()
def plot_multi_sds(
        sds,
        plot_kwargs: dict | list[dict] | None = None,
        show_visible_spectrum: bool = False,
        **kwargs: Any,
):
    _figure, axes = artist(**kwargs)

    sds_converted = sds_and_msds_to_sds(sds)

    plot_settings_collection = [
        {
            "label": f"{sd.display_name}",
            "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_line,
            "cmfs": "CIE 1931 2 Degree Standard Observer",
            "illuminant": SDS_ILLUMINANTS["E"],
            "use_sd_colours": False,
            "normalise_sd_colours": False,
        }
        for sd in sds_converted
    ]

    if plot_kwargs is not None:
        update_settings_collection(
            plot_settings_collection, plot_kwargs, len(sds_converted)
        )

    x_limit_min, x_limit_max, y_limit_min, y_limit_max = [], [], [], []
    for i, sd in enumerate(sds_converted):
        plot_settings = plot_settings_collection[i]

        cmfs = typing.cast(
            "MultiSpectralDistributions",
            first_item(filter_cmfs(plot_settings.pop("cmfs")).values()),
        )
        illuminant = typing.cast(
            "SpectralDistribution",
            first_item(
                filter_illuminants(plot_settings.pop("illuminant")).values()),
        )
        normalise_sd_colours = plot_settings.pop("normalise_sd_colours")
        use_sd_colours = plot_settings.pop("use_sd_colours")

        wavelengths, values = sd.wavelengths, sd.values

        shape = sd.shape
        x_limit_min.append(shape.start)
        x_limit_max.append(shape.end)
        y_limit_min.append(min(values))
        y_limit_max.append(max(values))

        if use_sd_colours:
            with domain_range_scale("1"):
                XYZ = sd_to_XYZ(sd, cmfs, illuminant)

            if normalise_sd_colours:
                XYZ /= XYZ[..., 1]

            plot_settings["color"] = np.clip(
                XYZ_to_plotting_colourspace(XYZ), 0, 1
            )

        axes.plot(wavelengths, values, **plot_settings)

    bounding_box = (
        min(x_limit_min),
        max(x_limit_max),
        min(y_limit_min),
        max(y_limit_max) * 1.05,
    )
    settings: dict[str, Any] = {
        "axes": axes,
        "bounding_box": bounding_box,
        "legend": True,
        "x_label": "Wavelength $\\lambda$ (nm)",
        "y_label": "Spectral Distribution",
    }
    settings.update(kwargs)

    if show_visible_spectrum:
        visible_spectrum_axes = make_axes_locatable(axes).append_axes(
            "bottom", size="5%", pad=0, sharex=axes
        )
        plot_visible_spectrum_colours(
            cmfs=cmfs,
            axes=visible_spectrum_axes,
            bounding_box=(bounding_box[0], bounding_box[1], 0, 1),
            x_label=settings.pop("x_label", None),
            show=False,
        )
        axes.tick_params(
            axis="x", bottom=False, labelbottom=False, which="both"
        )
        axes.set_xlabel(None)

    return render(**settings)


def gaussian(x, mu, sigma):
    x = np.asarray(x, dtype=float)
    return np.exp(-0.5 * ((x - mu) / sigma) ** 2)


def make_red_reflectance(start=300, stop=1000, step=10):
    wavelengths = np.arange(start, stop + step, step)
    values = np.clip(
        0.03 + 0.82 * gaussian(wavelengths, mu=660, sigma=40), 0.0, 1.0
    )
    return {int(w): float(v) for w, v in zip(wavelengths, values)}


if __name__ == "__main__":
    sd = SpectralDistribution(make_red_reflectance(), name="Red Reflectance")

    plot_single_sd(sd, show=False)
    plt.savefig("/tmp/pr1401_baseline.png", dpi=120, bbox_inches="tight")
    plt.close()

    plot_single_sd(sd, show_visible_spectrum=True, show=False)
    plt.savefig("/tmp/pr1401_with_strip.png", dpi=120, bbox_inches="tight")
    plt.close()

    sd_visible = SpectralDistribution(
        SDS_ILLUMINANTS["D65"].copy().normalise().align(
            SDS_ILLUMINANTS["D65"].shape
        ),
        name="D65 (visible domain)",
    )
    plot_single_sd(sd_visible, show_visible_spectrum=True, show=False)
    plt.savefig("/tmp/pr1401_visible_only.png", dpi=120, bbox_inches="tight")
    plt.close()

    plot_visible_spectrum(show=False)
    plt.savefig("/tmp/pr1401_visible_spectrum.png", dpi=120,
                bbox_inches="tight")
    plt.close()

    sd_extended = SpectralDistribution(
        make_red_reflectance(start=300, stop=1000, step=10),
        name="Red (300-1000 nm)",
    )
    sd_visible_only = SpectralDistribution(
        {
            int(w): float(0.4 + 0.4 * gaussian(w, mu=480, sigma=30))
            for w in np.arange(400, 701, 10)
        },
        name="Blue (400-700 nm)",
    )

    plot_multi_sds(
        [sd_extended, sd_visible_only],
        plot_kwargs={"linewidth": 2},
        show_visible_spectrum=True,
        show=False,
    )
    plt.savefig("/tmp/pr1401_multi_sds.png", dpi=120, bbox_inches="tight")
    plt.close()

    print("wrote:")
    print("  /tmp/pr1401_baseline.png")
    print("  /tmp/pr1401_with_strip.png")
    print("  /tmp/pr1401_visible_only.png")
    print("  /tmp/pr1401_visible_spectrum.png")
    print("  /tmp/pr1401_multi_sds.png")  
PR #1401 Proposal
API shape Two new private helpers (_wavelengths_strip returning RGB, _plot_wavelengths_strip drawing the bar) buried in the module One public building block (plot_visible_spectrum_colours) matching the plot_chromaticity_diagram_colours precedent + one tiny shared helper
Layering Strip drawn into the host axes; needs min(y_min, strip_y_min) arithmetic to extend the bounding box Strip on its own sub-axes via make_axes_locatable, physically separate, no y-axis arithmetic needed
Negative-y data overlap Broken: with Wright & Guild / Stiles & Burch RGB CMFs (min ≈ −0.45), the strip is drawn at a fixed -strip_padding offset and overlaps the negative-going r̄(λ) curve Sub-axes physically below the host, overlap is impossible by construction
show_* kwarg style wavelengths_strip: bool = False (kwarg names the implementation, not the intent) show_visible_spectrum: bool = False, consistent with show_diagram_colours, show_spectral_locus
Per-SD plot_kwargs plumbing in plot_multi_illuminant_sds Pushes wavelengths_strip=True into every per-SD dict, then OR-aggregates back at the strip-drawing site Top-level kwarg, single artefact, no per-SD propagation
Out-of-visible (UV/IR) hatching Optional flag, but author's logic always invokes it Automatic, silent no-op when bounding box ≡ cmfs domain (e.g. plot_visible_spectrum itself); hatched only when there's actually something to hatch
Code volume ~80 lines of new helpers + ~15 lines per host function (4 host functions) ~50 lines for the building block + ~10 lines per host function (3 host functions)
Reuse opportunity Helpers stay private to colorimetry.py plot_visible_spectrum_colours is part of the public API, available to anyone composing wavelength-axis plots
plot_visible_spectrum impact Unchanged; the duplicated primitive remains Unchanged in body; only a one-line recursion guard added (so the inverted layering with plot_single_sd is preserved)

@lassefschmidt lassefschmidt force-pushed the feature/plot_wavelength_on_xaxis branch from 896d106 to 0e3187d Compare May 6, 2026 11:35
@lassefschmidt
Copy link
Copy Markdown
Contributor Author

@KelSolaar Thanks for the feedback. I agree it is much better to e.g. use a new axes for the visible spectrum ! Never saw make_axes_locatable(axes) before, thanks for the trick :)

I made one adjustment compared to your implementation. I set pad in make_axes_locatable(axes) to 0.08 to have some distance between the plot and the spectrum. I find it confusing otherwise, see below examples.

Without any padding
pr1401_with_strip

With pad=0.08
image

This also avoids that the spectrum bar has an outline that might "spill over" into the plot. See here
image

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants