Source code for colour_visuals.diagrams

# !/usr/bin/env python
"""
Chromaticity Diagram Visuals
============================

Defines the *Chromaticity Diagram* visuals:

-   :class:`colour_visuals.VisualSpectralLocus2D`
-   :class:`colour_visuals.VisualSpectralLocus3D`
-   :class:`colour_visuals.VisualChromaticityDiagram`
-   :class:`colour_visuals.VisualChromaticityDiagramCIE1931`
-   :class:`colour_visuals.VisualChromaticityDiagramCIE1960UCS`
-   :class:`colour_visuals.VisualChromaticityDiagramCIE1976UCS`
"""

from __future__ import annotations

import numpy as np
import pygfx as gfx
from colour.algebra import euclidean_distance, normalise_maximum
from colour.colorimetry import MultiSpectralDistributions
from colour.hints import (
    ArrayLike,
    Literal,
    LiteralColourspaceModel,
    Sequence,
    Type,
    cast,
)
from colour.models import XYZ_to_RGB
from colour.plotting import (
    CONSTANTS_COLOUR_STYLE,
    LABELS_CHROMATICITY_DIAGRAM_DEFAULT,
    METHODS_CHROMATICITY_DIAGRAM,
    XYZ_to_plotting_colourspace,
    colourspace_model_axis_reorder,
    filter_cmfs,
)
from colour.plotting.diagrams import lines_spectral_locus
from colour.utilities import (
    first_item,
    full,
    optional,
    tstack,
    validate_method,
)
from scipy.spatial import Delaunay

from colour_visuals.common import (
    DEFAULT_FLOAT_DTYPE_WGPU,
    DEFAULT_INT_DTYPE_WGPU,
    XYZ_to_colourspace_model,
    append_channel,
    as_contiguous_array,
)

__author__ = "Colour Developers"
__copyright__ = "Copyright 2023 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__ = [
    "VisualSpectralLocus2D",
    "VisualSpectralLocus3D",
    "VisualChromaticityDiagram",
    "VisualChromaticityDiagramCIE1931",
    "VisualChromaticityDiagramCIE1960UCS",
    "VisualChromaticityDiagramCIE1976UCS",
]


[docs] class VisualSpectralLocus2D(gfx.Group): """ Create a 2D *Spectral Locus* visual. Parameters ---------- cmfs Standard observer colour matching functions used for computing the spectrum domain and colours. ``cmfs`` can be of any type or form supported by the :func:`colour.plotting.common.filter_cmfs` definition. method *Chromaticity Diagram* method. labels Array of wavelength labels used to customise which labels will be drawn around the spectral locus. Passing an empty array will result in no wavelength labels being drawn. colours Colours of the visual, if *None*, the colours are computed from the visual geometry. opacity Opacity of the visual. thickness Thickness of the visual lines. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualSpectralLocus2D() ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualSpectralLocus2D.png :align: center :alt: visual-spectral-locus-2d """
[docs] def __init__( self, cmfs: MultiSpectralDistributions | str | Sequence[ MultiSpectralDistributions | str ] = "CIE 1931 2 Degree Standard Observer", method: Literal["CIE 1931", "CIE 1960 UCS", "CIE 1976 UCS"] | str = "CIE 1931", labels: Sequence | None = None, colours: ArrayLike | None = None, opacity: float = 1, thickness: float = 1, ): super().__init__() cmfs = cast( MultiSpectralDistributions, first_item(filter_cmfs(cmfs).values()) ) method = validate_method(method, tuple(METHODS_CHROMATICITY_DIAGRAM)) labels = cast( Sequence, optional(labels, LABELS_CHROMATICITY_DIAGRAM_DEFAULT[method]), ) lines_sl, lines_w = lines_spectral_locus(cmfs, labels, method) # Spectral Locus positions = np.concatenate( [lines_sl["position"][:-1], lines_sl["position"][1:]], axis=1 ).reshape([-1, 2]) positions = np.hstack( [ positions, np.full((positions.shape[0], 1), 0, DEFAULT_FLOAT_DTYPE_WGPU), ] ) if colours is None: colours_sl = np.concatenate( [lines_sl["colour"][:-1], lines_sl["colour"][1:]], axis=1 ).reshape([-1, 3]) else: colours_sl = np.tile(colours, (positions.shape[0], 1)) self._spectral_locus = gfx.Line( gfx.Geometry( positions=as_contiguous_array(positions), colors=as_contiguous_array( append_channel(colours_sl, opacity) ), ), gfx.LineSegmentMaterial(thickness=thickness, color_mode="vertex"), ) self.add(self._spectral_locus) # Wavelengths positions = lines_w["position"] positions = np.hstack( [ positions, np.full((positions.shape[0], 1), 0, DEFAULT_FLOAT_DTYPE_WGPU), ] ) if colours is None: colours_w = lines_w["colour"] else: colours_w = np.tile(colours, (positions.shape[0], 1)) self._wavelengths = gfx.Line( gfx.Geometry( positions=as_contiguous_array(positions), colors=as_contiguous_array(append_channel(colours_w, opacity)), ), gfx.LineSegmentMaterial(thickness=thickness, color_mode="vertex"), ) self.add(self._wavelengths) # Labels self._labels = [] for i, label in enumerate( [label for label in labels if label in cmfs.wavelengths] ): positions = lines_w["position"][::2] normals = lines_w["normal"][::2] text = gfx.Text( gfx.TextGeometry( str(label), font_size=CONSTANTS_COLOUR_STYLE.font_size.medium, screen_space=True, anchor="Center-Left" if lines_w["normal"][::2][i, 0] >= 0 else "Center-Right", ), gfx.TextMaterial(color=CONSTANTS_COLOUR_STYLE.colour.light), ) text.local.position = np.array( [ positions[i, 0] + normals[i, 0] * 1.5, positions[i, 1] + normals[i, 1] * 1.5, 0, ] ) self._labels.append(text) self.add(text) positions = np.hstack( [ lines_w["position"][::2], np.full( (lines_w["position"][::2].shape[0], 1), 0, DEFAULT_FLOAT_DTYPE_WGPU, ), ] ) if colours is None: colours_lp = lines_w["colour"][::2] else: colours_lp = np.tile(colours, (positions.shape[0], 1)) self._points = gfx.Points( gfx.Geometry( positions=as_contiguous_array(positions), sizes=as_contiguous_array( full(lines_w["position"][::2].shape[0], thickness * 3) ), colors=as_contiguous_array( append_channel(colours_lp, opacity) ), ), gfx.PointsMaterial(color_mode="vertex", vertex_sizes=True), ) self.add(self._points)
[docs] class VisualSpectralLocus3D(gfx.Line): """ Create a 3D *Spectral Locus* visual. Parameters ---------- cmfs Standard observer colour matching functions used for computing the spectrum domain and colours. ``cmfs`` can be of any type or form supported by the :func:`colour.plotting.common.filter_cmfs` definition. model Colourspace model, see :attr:`colour.COLOURSPACE_MODELS` attribute for the list of supported colourspace models. labels Array of wavelength labels used to customise which labels will be drawn around the spectral locus. Passing an empty array will result in no wavelength labels being drawn. colours Colours of the visual, if *None*, the colours are computed from the visual geometry. opacity Opacity of the visual. thickness Thickness of the visual lines. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualSpectralLocus3D(model="CIE XYZ") ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualSpectralLocus3D.png :align: center :alt: visual-spectral-locus-3d """
[docs] def __init__( self, cmfs: MultiSpectralDistributions | str | Sequence[ MultiSpectralDistributions | str ] = "CIE 1931 2 Degree Standard Observer", model: LiteralColourspaceModel | str = "CIE xyY", colours: ArrayLike | None = None, opacity: float = 1, thickness: float = 1, ): super().__init__() cmfs = cast( MultiSpectralDistributions, first_item(filter_cmfs(cmfs).values()) ) colourspace = CONSTANTS_COLOUR_STYLE.colour.colourspace positions = colourspace_model_axis_reorder( XYZ_to_colourspace_model( cmfs.values, colourspace.whitepoint, model ), model, ) positions = np.concatenate( [positions[:-1], positions[1:]], axis=1 ).reshape([-1, 3]) if colours is None: colours = XYZ_to_RGB(cmfs.values, colourspace) colours = np.concatenate( [colours[:-1], colours[1:]], axis=1 ).reshape([-1, 3]) else: colours = np.tile(colours, (positions.shape[0], 1)) super().__init__( gfx.Geometry( positions=as_contiguous_array(positions), colors=as_contiguous_array(append_channel(colours, opacity)), ), gfx.LineSegmentMaterial(thickness=thickness, color_mode="vertex"), )
[docs] class VisualChromaticityDiagram(gfx.Mesh): """ Create a *Chromaticity Diagram* visual. Parameters ---------- cmfs Standard observer colour matching functions used for computing the spectrum domain and colours. ``cmfs`` can be of any type or form supported by the :func:`colour.plotting.common.filter_cmfs` definition. method *Chromaticity Diagram* method. colours Colours of the visual, if *None*, the colours are computed from the visual geometry. opacity Opacity of the visual. material Material used to surface the visual geomeetry. wireframe Whether to render the visual as a wireframe, i.e., only render edges. samples Samples count used for generating the *Chromaticity Diagram* Delaunay tesselation. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualChromaticityDiagram() ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualChromaticityDiagram.png :align: center :alt: visual-chromaticity-diagram """
[docs] def __init__( self, cmfs: MultiSpectralDistributions | str | Sequence[ MultiSpectralDistributions | str ] = "CIE 1931 2 Degree Standard Observer", method: Literal["CIE 1931", "CIE 1960 UCS", "CIE 1976 UCS"] | str = "CIE 1931", colours: ArrayLike | None = None, opacity: float = 1, material: Type[gfx.MeshAbstractMaterial] = gfx.MeshBasicMaterial, wireframe: bool = False, samples: int = 64, ): cmfs = cast( MultiSpectralDistributions, first_item(filter_cmfs(cmfs).values()) ) illuminant = CONSTANTS_COLOUR_STYLE.colour.colourspace.whitepoint XYZ_to_ij = METHODS_CHROMATICITY_DIAGRAM[method]["XYZ_to_ij"] ij_to_XYZ = METHODS_CHROMATICITY_DIAGRAM[method]["ij_to_XYZ"] # CMFS ij_l = XYZ_to_ij(cmfs.values, illuminant) # Line of Purples d = euclidean_distance(ij_l[0], ij_l[-1]) ij_p = tstack( [ np.linspace(ij_l[0][0], ij_l[-1][0], int(d * samples)), np.linspace(ij_l[0][1], ij_l[-1][1], int(d * samples)), ] ) # Grid triangulation = Delaunay(ij_l, qhull_options="QJ") xi = np.linspace(0, 1, samples) ii_g, jj_g = np.meshgrid(xi, xi) ij_g = tstack([ii_g, jj_g]) ij_g = ij_g[triangulation.find_simplex(ij_g) > 0] ij = np.vstack([ij_l, illuminant, ij_p, ij_g]) triangulation = Delaunay(ij, qhull_options="QJ") positions = np.hstack( [ij, np.full((ij.shape[0], 1), 0, DEFAULT_FLOAT_DTYPE_WGPU)] ) if colours is None: colours = normalise_maximum( XYZ_to_plotting_colourspace( ij_to_XYZ(positions[..., :2], illuminant), illuminant ), axis=-1, ) else: colours = np.tile(colours, (positions.shape[0], 1)) geometry = gfx.Geometry( positions=as_contiguous_array(positions), indices=as_contiguous_array( triangulation.simplices, DEFAULT_INT_DTYPE_WGPU ), colors=as_contiguous_array(append_channel(colours, opacity)), ) super().__init__( geometry, material(color_mode="vertex", wireframe=wireframe) if wireframe else material(color_mode="vertex"), )
[docs] class VisualChromaticityDiagramCIE1931(gfx.Group): """ Create the *CIE 1931* *Chromaticity Diagram* visual. Parameters ---------- kwargs_visual_spectral_locus Keyword arguments for the underlying :class:`colour_visuals.VisualSpectralLocus2D` class. kwargs_visual_chromaticity_diagram Keyword arguments for the underlying :class:`colour_visuals.VisualChromaticityDiagram` class. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualChromaticityDiagramCIE1931( ... kwargs_visual_chromaticity_diagram={"opacity": 0.25} ... ) ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualChromaticityDiagramCIE1931.png :align: center :alt: visual-chromaticity-diagram-cie-1931 """
[docs] def __init__( self, kwargs_visual_spectral_locus: dict | None = None, kwargs_visual_chromaticity_diagram: dict | None = None, ): super().__init__() self._spectral_locus = VisualSpectralLocus2D( method="CIE 1931", **(optional(kwargs_visual_spectral_locus, {})) ) self.add(self._spectral_locus) self._chromaticity_diagram = VisualChromaticityDiagram( method="CIE 1931", **(optional(kwargs_visual_chromaticity_diagram, {})), ) self.add(self._chromaticity_diagram)
[docs] class VisualChromaticityDiagramCIE1960UCS(gfx.Group): """ Create the *CIE 1960 UCS* *Chromaticity Diagram* visual. Parameters ---------- kwargs_visual_spectral_locus Keyword arguments for the underlying :class:`colour_visuals.VisualSpectralLocus2D` class. kwargs_visual_chromaticity_diagram Keyword arguments for the underlying :class:`colour_visuals.VisualChromaticityDiagram` class. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualChromaticityDiagramCIE1960UCS( ... kwargs_visual_chromaticity_diagram={"opacity": 0.25} ... ) ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualChromaticityDiagramCIE1960UCS.png :align: center :alt: visual-chromaticity-diagram-cie-1960-ucs """
[docs] def __init__( self, kwargs_visual_spectral_locus: dict | None = None, kwargs_visual_chromaticity_diagram: dict | None = None, ): super().__init__() self._spectral_locus = VisualSpectralLocus2D( method="CIE 1960 UCS", **(optional(kwargs_visual_spectral_locus, {})), ) self.add(self._spectral_locus) self._chromaticity_diagram = VisualChromaticityDiagram( method="CIE 1960 UCS", **(optional(kwargs_visual_chromaticity_diagram, {})), ) self.add(self._chromaticity_diagram)
[docs] class VisualChromaticityDiagramCIE1976UCS(gfx.Group): """ Create the *CIE 1976 UCS* *Chromaticity Diagram* visual. Parameters ---------- kwargs_visual_spectral_locus Keyword arguments for the underlying :class:`colour_visuals.VisualSpectralLocus2D` class. kwargs_visual_chromaticity_diagram Keyword arguments for the underlying :class:`colour_visuals.VisualChromaticityDiagram` class. Examples -------- >>> import os >>> from colour.utilities import suppress_stdout >>> from wgpu.gui.auto import WgpuCanvas >>> with suppress_stdout(): ... canvas = WgpuCanvas(size=(960, 540)) ... scene = gfx.Scene() ... scene.add( ... gfx.Background( ... None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ... ) ... ) ... visual = VisualChromaticityDiagramCIE1976UCS( ... kwargs_visual_chromaticity_diagram={"opacity": 0.25} ... ) ... camera = gfx.PerspectiveCamera(50, 16 / 9) ... camera.show_object(visual, up=np.array([0, 0, 1]), scale=1.25) ... scene.add(visual) ... if os.environ.get("CI") is None: ... gfx.show(scene, camera=camera, canvas=canvas) ... .. image:: ../_static/Plotting_VisualChromaticityDiagramCIE1976UCS.png :align: center :alt: visual-chromaticity-diagram-cie-1976-ucs """
[docs] def __init__( self, kwargs_visual_spectral_locus: dict | None = None, kwargs_visual_chromaticity_diagram: dict | None = None, ): super().__init__() self._spectral_locus = VisualSpectralLocus2D( method="CIE 1976 UCS", **(optional(kwargs_visual_spectral_locus, {})), ) self.add(self._spectral_locus) self._chromaticity_diagram = VisualChromaticityDiagram( method="CIE 1976 UCS", **(optional(kwargs_visual_chromaticity_diagram, {})), ) self.add(self._chromaticity_diagram)
if __name__ == "__main__": scene = gfx.Scene() scene.add( gfx.Background( None, gfx.BackgroundMaterial(np.array([0.18, 0.18, 0.18])) ) ) visual_1 = VisualChromaticityDiagramCIE1931() scene.add(visual_1) visual_2 = VisualChromaticityDiagramCIE1931( kwargs_visual_chromaticity_diagram={"wireframe": True, "opacity": 0.5} ) visual_2.local.position = np.array([1, 0, 0]) scene.add(visual_2) visual_3 = VisualChromaticityDiagramCIE1931( kwargs_visual_chromaticity_diagram={"colours": [0.5, 0.5, 0.5]} ) visual_3.local.position = np.array([2, 0, 0]) scene.add(visual_3) visual_4 = VisualChromaticityDiagramCIE1960UCS() visual_4.local.position = np.array([3, 0, 0]) scene.add(visual_4) visual_5 = VisualChromaticityDiagramCIE1976UCS() visual_5.local.position = np.array([4, 0, 0]) scene.add(visual_5) visual_6 = VisualSpectralLocus2D(colours=[0.5, 0.5, 0.5]) visual_6.local.position = np.array([5, 0, 0]) scene.add(visual_6) visual_7 = VisualSpectralLocus3D() scene.add(visual_7) visual_8 = VisualSpectralLocus3D(colours=[0.5, 0.5, 0.5]) visual_8.local.position = np.array([5, 0, 0]) scene.add(visual_8) visual_9 = VisualSpectralLocus3D(model="CIE XYZ") visual_9.local.position = np.array([6, 0, 0]) scene.add(visual_9) gfx.show(scene, up=np.array([0, 0, 1]))