Visualize those pesky rainbows (interactively)


In the previous post (On the unreasonable difficulty of plotting pretty rainbow, fast) I spent many words coming up1 with the Ultimate Rainbow™ for visible light spectra.

But then I had a shower thought – wouldn’t it be nice to have an interactive toy to explore various color channel boost coefficients, and see where clipping and banding actually come from?

Well, one whopping hour with Mr. Claude later (and some hand edits from yours truly, to make the result extra crispy awesome), and voilà:

interactive rainbow visualizer A screenshot of the rainbow visualizer app Python script

I know, it’s very underwhelming that it’s a Python script:

rainbow_v14_final_final.py (click to expand)

"""Interactive rainbow visualizer"""
from collections import Counter
import sys
import tkinter as tk

try:
    import numpy as np
except ImportError:
    print("Error: numpy is missing. Install it with: pip install numpy")
    sys.exit(1)

try:
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    from matplotlib.figure import Figure
except ImportError:
    print("Error: matplotlib is missing. Install it with: pip install matplotlib")
    sys.exit(1)

try:
    from colour import MSDS_CMFS, XYZ_to_sRGB
    from colour.models import eotf_inverse_sRGB
except ImportError:
    print("Error: colour-science is missing. Install it with: pip install colour-science")
    sys.exit(1)

def wavelength_to_rgb(wavelength, r_boost=1/1.4, g_boost=1/1.4, b_boost=1/1.4,
                      return_clipping=False):
    """Convert wavelength to rgb tuple; optionally with clipping info."""

    cmfs = MSDS_CMFS['CIE 2015 2 Degree Standard Observer']

    if wavelength < 360 or wavelength > 830:
        if return_clipping:
            return (0.0, 0.0, 0.0), False
        return (0.0, 0.0, 0.0)

    wl_range = cmfs.wavelengths
    x_bar = np.interp(wavelength, wl_range, cmfs.values[:, 0])
    y_bar = np.interp(wavelength, wl_range, cmfs.values[:, 1])
    z_bar = np.interp(wavelength, wl_range, cmfs.values[:, 2])

    X = x_bar
    Y = y_bar
    Z = z_bar

    XYZ = np.array([X, Y, Z])

    rgb_linear = XYZ_to_sRGB(XYZ, apply_cctf_encoding=False)

    r, g, b = rgb_linear

    if r < 0 or g < 0 or b < 0:
        min_component = min(r, g, b)
        if Y > 0:
            factor = Y / (Y - min_component)
            r = Y + factor * (r - Y)
            g = Y + factor * (g - Y)
            b = Y + factor * (b - Y)

    r *= r_boost
    g *= g_boost
    b *= b_boost

    was_clipped = False
    max_component = max(r, g, b)
    if max_component > 1.0:
        was_clipped = True
        r /= max_component
        g /= max_component
        b /= max_component

    r = max(0.0, min(1.0, r))
    g = max(0.0, min(1.0, g))
    b = max(0.0, min(1.0, b))

    # gamma correct me, baby
    r = eotf_inverse_sRGB(r)
    g = eotf_inverse_sRGB(g)
    b = eotf_inverse_sRGB(b)

    if return_clipping:
        return (r, g, b), was_clipped
    return (r, g, b)


def visualize_rainbow():
    """Interactive Rainbow visualizer."""

    root = tk.Tk()
    root.title("Rainbow Visualizer")
    root.geometry("1200x800")

    root.rowconfigure(0, weight=1)
    root.rowconfigure(1, weight=0)
    root.columnconfigure(0, weight=1)

    fig = Figure(figsize=(10, 10))
    gs = fig.add_gridspec(3, 1, height_ratios=[1, 1, 0.5])
    ax1 = fig.add_subplot(gs[0, 0])
    ax2 = fig.add_subplot(gs[1, 0])
    ax3 = fig.add_subplot(gs[2, 0])

    canvas = FigureCanvasTkAgg(fig, master=root)
    canvas_widget = canvas.get_tk_widget()
    canvas_widget.grid(row=0, column=0, sticky='nsew', padx=5, pady=5)

    def remove_canvas_padding():
        canvas_widget.grid_configure(padx=0, pady=0)

    control_frame = tk.Frame(root)
    control_frame.grid(row=1, column=0, sticky='ew', padx=10, pady=10)

    r_var = tk.DoubleVar(value=1/1.4)
    g_var = tk.DoubleVar(value=1/1.4)
    b_var = tk.DoubleVar(value=1/1.4)

    def on_mousewheel(event, var):
        current = var.get()
        increment = 0.1 if event.state & 0x0001 else 0.01
        if event.delta > 0 or event.num == 4:
            var.set(round(min(5.0, current + increment), 3))
        elif event.delta < 0 or event.num == 5:
            var.set(round(max(0.0, current - increment), 3))
        update_plot()

    def update_plot(*_args):
        r_boost = r_var.get()
        g_boost = g_var.get()
        b_boost = b_var.get()

        wavelengths = np.linspace(360, 800, 881)
        results = [wavelength_to_rgb(wl, r_boost, g_boost, b_boost, return_clipping=True)
                   for wl in wavelengths]
        rgb_values = np.array([rgb for rgb, _ in results])
        clipping_flags = np.array([clipped for _, clipped in results])

        ax1.clear()
        ax2.clear()
        ax3.clear()

        rainbow_array = rgb_values.reshape(1, -1, 3)

        ax1.imshow(rainbow_array, aspect='auto',
                   extent=[wavelengths[0], wavelengths[-1], 0, 1],
                   origin='lower', interpolation='nearest')

        ax1.set_xlim(360, 800)
        ax1.set_ylim(0, 1)
        ax1.set_xlabel('Wavelength (nm)')
        ax1.set_ylabel('Intensity')
        ax1.set_title('Visible Spectrum')

        ax2.plot(wavelengths, rgb_values[:, 0], 'r-', label='Red', linewidth=2)
        ax2.plot(wavelengths, rgb_values[:, 1], 'g-', label='Green', linewidth=2)
        ax2.plot(wavelengths, rgb_values[:, 2], 'b-', label='Blue', linewidth=2)

        ax2.set_xlim(360, 800)
        ax2.set_ylim(0, 1)
        ax2.set_xlabel('Wavelength (nm)')
        ax2.set_ylabel('RGB Component Value')
        ax2.set_title('RGB Components across Spectrum')
        ax2.legend()
        ax2.grid(True, alpha=0.3)

        rgb_tuples = [tuple(rgb) for rgb in rgb_values]
        rgb_counts = Counter(rgb_tuples)
        duplicate_wavelengths = [wavelengths[i] for i, rgb in enumerate(rgb_tuples)
                                if rgb_counts[rgb] > 1]

        banding_mask = np.zeros(len(wavelengths))
        clipping_mask = clipping_flags.astype(float)

        for i, rgb in enumerate(rgb_tuples):
            if rgb_counts[rgb] > 1:
                banding_mask[i] = 1.0

        ax3.fill_between(wavelengths, 0, clipping_mask, color='orange', alpha=0.6,
                        label=f'RGB Clipping ({int(np.sum(clipping_mask))} wavelengths)')
        ax3.fill_between(wavelengths, 0, banding_mask, color='red', alpha=0.6,
                        label=f'Duplicate RGB ({len(duplicate_wavelengths)} wavelengths)')
        ax3.set_xlim(360, 800)
        ax3.set_ylim(0, 1.1)
        ax3.set_xlabel('Wavelength (nm)')
        ax3.set_ylabel('Banding')
        ax3.set_title('Wavelengths with Duplicate RGB Values and Clipping')
        ax3.legend()
        ax3.grid(True, alpha=0.3)

        fig.set_layout_engine('compressed')
        canvas.draw()

    tk.Label(control_frame, text="Red Boost:", fg='red').grid(
            row=0, column=0, padx=5, sticky='e')
    r_spinbox = tk.Spinbox(control_frame, from_=0.0, to=5.0, increment=0.01,
                           textvariable=r_var, command=update_plot, width=10)
    r_spinbox.grid(row=0, column=1, padx=5)
    r_spinbox.bind('<Return>', update_plot)
    r_spinbox.bind('<FocusOut>', update_plot)
    r_spinbox.bind('<MouseWheel>', lambda e: on_mousewheel(e, r_var))
    r_spinbox.bind('<Button-4>', lambda e: on_mousewheel(e, r_var))
    r_spinbox.bind('<Button-5>', lambda e: on_mousewheel(e, r_var))

    tk.Label(control_frame, text="Green Boost:", fg='green').grid(
            row=0, column=2, padx=5, sticky='e')
    g_spinbox = tk.Spinbox(control_frame, from_=0.0, to=5.0, increment=0.01,
                           textvariable=g_var, command=update_plot, width=10)
    g_spinbox.grid(row=0, column=3, padx=5)
    g_spinbox.bind('<Return>', update_plot)
    g_spinbox.bind('<FocusOut>', update_plot)
    g_spinbox.bind('<MouseWheel>', lambda e: on_mousewheel(e, g_var))
    g_spinbox.bind('<Button-4>', lambda e: on_mousewheel(e, g_var))
    g_spinbox.bind('<Button-5>', lambda e: on_mousewheel(e, g_var))

    tk.Label(control_frame, text="Blue Boost:", fg='blue').grid(
            row=0, column=4, padx=5, sticky='e')
    b_spinbox = tk.Spinbox(control_frame, from_=0.0, to=5.0, increment=0.01,
                           textvariable=b_var, command=update_plot, width=10)
    b_spinbox.grid(row=0, column=5, padx=5)
    b_spinbox.bind('<Return>', update_plot)
    b_spinbox.bind('<FocusOut>', update_plot)
    b_spinbox.bind('<MouseWheel>', lambda e: on_mousewheel(e, b_var))
    b_spinbox.bind('<Button-4>', lambda e: on_mousewheel(e, b_var))
    b_spinbox.bind('<Button-5>', lambda e: on_mousewheel(e, b_var))

    for i in range(6):
        control_frame.columnconfigure(i, weight=1)

    update_plot()

    root.after(100, remove_canvas_padding)

    root.mainloop()


if __name__ == '__main__':
    visualize_rainbow()

and not something real you can feel, smell, taste, and see2 load up in your browser.

Still, if you felt the urge to play with it, or correct me3… have at it.

And when you come up with a better version, don’t be a stranger?

Closing words

Honestly, it’s amazing how far Claude has come in these past few months.

I don’t think I’d have been able to LLM the hell out of this task within an hour, without having to re-prompt, correct, goad, and twist its arm.

This is like gasoline on my creative fire.

  1. Well, stealing from Dr. Young, really.

  2. Guess the reference, win a prize?

  3. Or Dr. Andrew T. Young, for that matter.