• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

colour-science / colour / 25544858007

08 May 2026 08:12AM UTC coverage: 77.584% (-21.2%) from 98.818%
25544858007

push

github

KelSolaar
Implement support for *Python Array API Standard*.

14022 of 14574 new or added lines in 367 files covered. (96.21%)

10005 existing lines in 360 files now uncovered.

37920 of 48876 relevant lines covered (77.58%)

0.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.75
/colour/plotting/common.py
1
"""
2
Common Plotting
3
===============
4

5
Define the common plotting objects.
6

7
-   :func:`colour.plotting.colour_style`
8
-   :func:`colour.plotting.override_style`
9
-   :func:`colour.plotting.font_scaling`
10
-   :func:`colour.plotting.XYZ_to_plotting_colourspace`
11
-   :class:`colour.plotting.ColourSwatch`
12
-   :func:`colour.plotting.colour_cycle`
13
-   :func:`colour.plotting.artist`
14
-   :func:`colour.plotting.camera`
15
-   :func:`colour.plotting.decorate`
16
-   :func:`colour.plotting.boundaries`
17
-   :func:`colour.plotting.display`
18
-   :func:`colour.plotting.render`
19
-   :func:`colour.plotting.label_rectangles`
20
-   :func:`colour.plotting.uniform_axes3d`
21
-   :func:`colour.plotting.plot_single_colour_swatch`
22
-   :func:`colour.plotting.plot_multi_colour_swatches`
23
-   :func:`colour.plotting.plot_single_function`
24
-   :func:`colour.plotting.plot_multi_functions`
25
-   :func:`colour.plotting.plot_image`
26
"""
27

UNCOV
28
from __future__ import annotations
×
29

UNCOV
30
import contextlib
×
UNCOV
31
import functools
×
UNCOV
32
import itertools
×
UNCOV
33
import typing
×
UNCOV
34
from contextlib import contextmanager
×
UNCOV
35
from dataclasses import dataclass, field
×
UNCOV
36
from functools import partial
×
37

UNCOV
38
import matplotlib.cm
×
UNCOV
39
import matplotlib.font_manager
×
UNCOV
40
import matplotlib.pyplot as plt
×
UNCOV
41
import matplotlib.ticker
×
UNCOV
42
import numpy as np
×
UNCOV
43
from cycler import cycler
×
UNCOV
44
from matplotlib.colors import LinearSegmentedColormap
×
UNCOV
45
from matplotlib.figure import Figure, SubFigure
×
46

47
if typing.TYPE_CHECKING:
48
    from matplotlib.axes import Axes
49
    from matplotlib.patches import Patch
50
    from mpl_toolkits.mplot3d.axes3d import Axes3D
51

UNCOV
52
from colour.characterisation import CCS_COLOURCHECKERS, ColourChecker
×
UNCOV
53
from colour.colorimetry import (
×
54
    MSDS_CMFS,
55
    SDS_ILLUMINANTS,
56
    SDS_LIGHT_SOURCES,
57
    MultiSpectralDistributions,
58
    SpectralDistribution,
59
)
60

61
if typing.TYPE_CHECKING:
62
    from colour.hints import (
63
        Any,
64
        Callable,
65
        Dict,
66
        Domain1,
67
        Generator,
68
        Literal,
69
        LiteralChromaticAdaptationTransform,
70
        LiteralFontScaling,
71
        LiteralRGBColourspace,
72
        Mapping,
73
        PathLike,
74
        Range1,
75
        Real,
76
        Sequence,
77
        Tuple,
78
    )
79

UNCOV
80
from colour.hints import ArrayLike, List, TypedDict, cast
×
UNCOV
81
from colour.models import RGB_COLOURSPACES, RGB_Colourspace, XYZ_to_RGB
×
UNCOV
82
from colour.utilities import (
×
83
    CanonicalMapping,
84
    Structure,
85
    as_float_array,
86
    as_int_scalar,
87
    attest,
88
    filter_mapping,
89
    first_item,
90
    is_sibling,
91
    optional,
92
    runtime_warning,
93
    validate_method,
94
)
UNCOV
95
from colour.utilities.deprecation import handle_arguments_deprecation
×
96

UNCOV
97
__author__ = "Colour Developers"
×
UNCOV
98
__copyright__ = "Copyright 2013 Colour Developers"
×
UNCOV
99
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
×
UNCOV
100
__maintainer__ = "Colour Developers"
×
UNCOV
101
__email__ = "colour-developers@colour-science.org"
×
UNCOV
102
__status__ = "Production"
×
103

104
__all__ = [
105
    "CONSTANTS_COLOUR_STYLE",
106
    "CONSTANTS_ARROW_STYLE",
107
    "colour_style",
108
    "override_style",
109
    "font_scaling",
110
    "XYZ_to_plotting_colourspace",
111
    "ColourSwatch",
112
    "colour_cycle",
113
    "KwargsArtist",
114
    "artist",
115
    "KwargsCamera",
116
    "camera",
117
    "KwargsRender",
118
    "render",
119
    "label_rectangles",
120
    "uniform_axes3d",
121
    "filter_passthrough",
122
    "filter_RGB_colourspaces",
123
    "filter_cmfs",
124
    "filter_illuminants",
125
    "filter_colour_checkers",
126
    "update_settings_collection",
127
    "plot_single_colour_swatch",
128
    "plot_multi_colour_swatches",
129
    "plot_single_function",
130
    "plot_multi_functions",
131
    "plot_image",
132
    "plot_ray",
133
]
134

UNCOV
135
CONSTANTS_COLOUR_STYLE: Structure = Structure(
×
136
    colour=Structure(
137
        darkest="#111111",
138
        darker="#222222",
139
        dark="#333333",
140
        dim="#505050",
141
        average="#808080",
142
        light="#D5D5D5",
143
        bright="#EEEEEE",
144
        brighter="#F0F0F0",
145
        brightest="#F5F5F5",
146
        cycle=(
147
            "#F44336",
148
            "#9C27B0",
149
            "#3F51B5",
150
            "#03A9F4",
151
            "#009688",
152
            "#8BC34A",
153
            "#FFEB3B",
154
            "#FF9800",
155
            "#795548",
156
            "#607D8B",
157
        ),
158
        map=LinearSegmentedColormap.from_list(
159
            "colour",
160
            (
161
                "#F44336",
162
                "#9C27B0",
163
                "#3F51B5",
164
                "#03A9F4",
165
                "#009688",
166
                "#8BC34A",
167
                "#FFEB3B",
168
                "#FF9800",
169
                "#795548",
170
                "#607D8B",
171
            ),
172
        ),
173
        cmap="inferno",
174
        colourspace=RGB_COLOURSPACES["sRGB"],
175
    ),
176
    font=Structure(
177
        {
178
            "size": 10,
179
            "scaling": Structure(
180
                xx_small=0.579,
181
                x_small=0.694,
182
                small=0.833,
183
                medium=1,
184
                large=1 / 0.579,
185
                x_large=1 / 0.694,
186
                xx_large=1 / 0.833,
187
            ),
188
        }
189
    ),
190
    opacity=Structure(high=0.75, medium=0.5, low=0.25),
191
    geometry=Structure(x_long=10, long=5, medium=2.5, short=1, x_short=0.5),
192
    hatch=Structure(
193
        patterns=(
194
            "\\\\",
195
            "o",
196
            "x",
197
            ".",
198
            "*",
199
            "//",
200
        )
201
    ),
202
    zorder=Structure(
203
        {
204
            "background_polygon": -140,
205
            "background_scatter": -130,
206
            "background_line": -120,
207
            "background_annotation": -110,
208
            "background_label": -100,
209
            "midground_polygon": -90,
210
            "midground_scatter": -80,
211
            "midground_line": -70,
212
            "midground_annotation": -60,
213
            "midground_label": -50,
214
            "foreground_polygon": -40,
215
            "foreground_scatter": -30,
216
            "foreground_line": -20,
217
            "foreground_annotation": -10,
218
            "foreground_label": 0,
219
        }
220
    ),
221
)
UNCOV
222
"""Various defaults settings used across the plotting sub-package."""
×
223

224
# NOTE: Adding our font scaling items so that they can be tweaked without
225
# affecting *Matplotplib* ones.
UNCOV
226
for _scaling, _value in CONSTANTS_COLOUR_STYLE.font.scaling.items():
×
UNCOV
227
    matplotlib.font_manager.font_scalings[
×
228
        f"{_scaling.replace('_', '-')}-colour-science"
229
    ] = _value
230

UNCOV
231
del _scaling, _value
×
232

UNCOV
233
CONSTANTS_ARROW_STYLE: Structure = Structure(
×
234
    color=CONSTANTS_COLOUR_STYLE.colour.dark,
235
    headwidth=CONSTANTS_COLOUR_STYLE.geometry.short * 4,
236
    headlength=CONSTANTS_COLOUR_STYLE.geometry.long,
237
    width=CONSTANTS_COLOUR_STYLE.geometry.short * 0.5,
238
    shrink=CONSTANTS_COLOUR_STYLE.geometry.short * 0.1,
239
    connectionstyle="arc3,rad=-0.2",
240
)
UNCOV
241
"""Annotation arrow settings used across the plotting sub-package."""
×
242

243

UNCOV
244
def colour_style(use_style: bool = True) -> dict:
×
245
    """
246
    Return the *Colour* plotting style configuration.
247

248
    Parameters
249
    ----------
250
    use_style
251
        Whether to apply the style configuration to *Matplotlib*.
252

253
    Returns
254
    -------
255
    :class:`dict`
256
        *Colour* plotting style configuration dictionary.
257
    """
258

259
    constants = CONSTANTS_COLOUR_STYLE
1✔
260
    style = {
1✔
261
        # Figure Size Settings
262
        "figure.figsize": (12.80, 7.20),
263
        "figure.dpi": 100,
264
        "savefig.dpi": 100,
265
        "savefig.bbox": "standard",
266
        # Font Settings
267
        # 'font.size': 12,
268
        "axes.titlesize": "x-large",
269
        "axes.labelsize": "larger",
270
        "legend.fontsize": "small",
271
        "xtick.labelsize": "medium",
272
        "ytick.labelsize": "medium",
273
        # Text Settings
274
        "text.color": constants.colour.darkest,
275
        # Tick Settings
276
        "xtick.top": False,
277
        "xtick.bottom": True,
278
        "ytick.right": False,
279
        "ytick.left": True,
280
        "xtick.minor.visible": True,
281
        "ytick.minor.visible": True,
282
        "xtick.direction": "out",
283
        "ytick.direction": "out",
284
        "xtick.major.size": constants.geometry.long * 1.25,
285
        "xtick.minor.size": constants.geometry.long * 0.75,
286
        "ytick.major.size": constants.geometry.long * 1.25,
287
        "ytick.minor.size": constants.geometry.long * 0.75,
288
        "xtick.major.width": constants.geometry.short,
289
        "xtick.minor.width": constants.geometry.short,
290
        "ytick.major.width": constants.geometry.short,
291
        "ytick.minor.width": constants.geometry.short,
292
        # Spine Settings
293
        "axes.linewidth": constants.geometry.short,
294
        "axes.edgecolor": constants.colour.dark,
295
        # Title Settings
296
        "axes.titlepad": plt.rcParams["font.size"] * 0.75,
297
        # Axes Settings
298
        "axes.facecolor": constants.colour.brightest,
299
        "axes.grid": True,
300
        "axes.grid.which": "major",
301
        "axes.grid.axis": "both",
302
        # Grid Settings
303
        "axes.axisbelow": True,
304
        "grid.linewidth": constants.geometry.short * 0.5,
305
        "grid.linestyle": "--",
306
        "grid.color": constants.colour.light,
307
        # Legend
308
        "legend.frameon": True,
309
        "legend.framealpha": constants.opacity.high,
310
        "legend.fancybox": False,
311
        "legend.facecolor": constants.colour.brighter,
312
        "legend.borderpad": constants.geometry.short * 0.5,
313
        # Lines
314
        "lines.linewidth": constants.geometry.short,
315
        "lines.markersize": constants.geometry.short * 3,
316
        "lines.markeredgewidth": constants.geometry.short * 0.75,
317
        # Cycle
318
        "axes.prop_cycle": cycler(color=constants.colour.cycle),
319
    }
320

321
    if use_style:
1✔
322
        plt.rcParams.update(style)
1✔
323

324
    return style
1✔
325

326

UNCOV
327
def override_style(**kwargs: Any) -> Callable:
×
328
    """
329
    Decorate a function to override *Matplotlib* style.
330

331
    Other Parameters
332
    ----------------
333
    kwargs
334
        Keywords arguments for *Matplotlib* style configuration.
335

336
    Returns
337
    -------
338
    Callable
339
        Decorated function with overridden *Matplotlib* style.
340

341
    Examples
342
    --------
343
    >>> @override_style(**{"text.color": "red"})
344
    ... def f(*args, **kwargs):
345
    ...     plt.text(0.5, 0.5, "This is a text!")
346
    ...     plt.show()
347
    >>> f()  # doctest: +SKIP
348
    """
349

350
    keywords = dict(kwargs)
1✔
351

352
    def wrapper(function: Callable) -> Callable:
1✔
353
        """Wrap specified function wrapper."""
354

355
        @functools.wraps(function)
1✔
356
        def wrapped(*args: Any, **kwargs: Any) -> Any:
1✔
357
            """Wrap specified function."""
358

359
            keywords.update(kwargs)
1✔
360

361
            style_overrides = {
1✔
362
                key: value for key, value in keywords.items() if key in plt.rcParams
363
            }
364

365
            with plt.style.context(style_overrides):
1✔
366
                return function(*args, **kwargs)
1✔
367

368
        return wrapped
1✔
369

370
    return wrapper
1✔
371

372

UNCOV
373
@contextmanager
×
UNCOV
374
def font_scaling(scaling: LiteralFontScaling, value: float) -> Generator:
×
375
    """
376
    Set a temporary *Matplotlib* font scaling using a context manager.
377

378
    Parameters
379
    ----------
380
    scaling
381
        Font scaling to temporarily set.
382
    value
383
        Value to temporarily set the font scaling with.
384

385
    Yields
386
    ------
387
    Generator.
388

389
    Examples
390
    --------
391
    >>> with font_scaling("medium-colour-science", 2):
392
    ...     print(matplotlib.font_manager.font_scalings["medium-colour-science"])
393
    2
394
    >>> print(matplotlib.font_manager.font_scalings["medium-colour-science"])
395
    1
396
    """
397

398
    current_value = matplotlib.font_manager.font_scalings[scaling]
1✔
399

400
    matplotlib.font_manager.font_scalings[scaling] = value
1✔
401

402
    yield
1✔
403

404
    matplotlib.font_manager.font_scalings[scaling] = current_value
1✔
405

406

UNCOV
407
def XYZ_to_plotting_colourspace(
×
408
    XYZ: Domain1,
409
    illuminant: ArrayLike = RGB_COLOURSPACES["sRGB"].whitepoint,
410
    chromatic_adaptation_transform: (
411
        LiteralChromaticAdaptationTransform | str | None
412
    ) = "CAT02",
413
    apply_cctf_encoding: bool = True,
414
) -> Range1:
415
    """
416
    Convert from *CIE XYZ* tristimulus values to the default plotting
417
    colourspace.
418

419
    Parameters
420
    ----------
421
    XYZ
422
        *CIE XYZ* tristimulus values.
423
    illuminant
424
        Source illuminant chromaticity coordinates.
425
    chromatic_adaptation_transform
426
        *Chromatic adaptation* transform.
427
    apply_cctf_encoding
428
        Apply the default plotting colourspace encoding colour
429
        component transfer function / opto-electronic transfer
430
        function.
431

432
    Returns
433
    -------
434
    :class:`numpy.ndarray`
435
        Default plotting colourspace colour array.
436

437
    Notes
438
    -----
439
    +------------+-----------------------+---------------+
440
    | **Domain** | **Scale - Reference** | **Scale - 1** |
441
    +============+=======================+===============+
442
    | ``XYZ``    | 1                     | 1             |
443
    +------------+-----------------------+---------------+
444

445
    +------------+-----------------------+---------------+
446
    | **Range**  | **Scale - Reference** | **Scale - 1** |
447
    +============+=======================+===============+
448
    | ``RGB``    | 1                     | 1             |
449
    +------------+-----------------------+---------------+
450

451
    Examples
452
    --------
453
    >>> import numpy as np
454
    >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
455
    >>> XYZ_to_plotting_colourspace(XYZ)  # doctest: +ELLIPSIS
456
    array([0.7057393..., 0.1924826..., 0.2235416...])
457
    """
458

459
    return XYZ_to_RGB(
1✔
460
        XYZ,
461
        CONSTANTS_COLOUR_STYLE.colour.colourspace,
462
        illuminant,
463
        chromatic_adaptation_transform,
464
        apply_cctf_encoding,
465
    )
466

467

UNCOV
468
@dataclass
×
UNCOV
469
class ColourSwatch:
×
470
    """
471
    Define a data structure for a colour swatch.
472

473
    Parameters
474
    ----------
475
    RGB
476
        RGB colour values representing the swatch.
477
    name
478
        Name identifier for the colour swatch.
479
    """
480

UNCOV
481
    RGB: ArrayLike
×
482
    name: str | None = field(default_factory=lambda: None)
1✔
483

484

UNCOV
485
def colour_cycle(**kwargs: Any) -> itertools.cycle:
×
486
    """
487
    Create a colour cycle iterator using the specified colour map.
488

489
    Other Parameters
490
    ----------------
491
    colour_cycle_map
492
        Matplotlib colourmap name.
493
    colour_cycle_count
494
        Colours count to pick in the colourmap.
495

496
    Returns
497
    -------
498
    :class:`itertools.cycle`
499
        Colour cycle iterator.
500
    """
501

502
    settings = Structure(
1✔
503
        colour_cycle_map=CONSTANTS_COLOUR_STYLE.colour.map,
504
        colour_cycle_count=len(CONSTANTS_COLOUR_STYLE.colour.cycle),
505
    )
506
    settings.update(kwargs)
1✔
507

508
    samples = np.linspace(0, 1, settings.colour_cycle_count)
1✔
509
    if isinstance(settings.colour_cycle_map, LinearSegmentedColormap):
1✔
510
        cycle = settings.colour_cycle_map(samples)
1✔
511
    else:
512
        cycle = getattr(plt.cm, settings.colour_cycle_map)(samples)
1✔
513

514
    return itertools.cycle(cycle)
1✔
515

516

UNCOV
517
class KwargsArtist(TypedDict):
×
518
    """
519
    Define keyword argument types for the :func:`colour.plotting.artist`
520
    definition.
521

522
    Parameters
523
    ----------
524
    axes
525
        Axes that will be passed through without creating a new figure.
526
    uniform
527
        Whether to create the figure with an equal aspect ratio.
528
    """
529

UNCOV
530
    axes: Axes
×
UNCOV
531
    uniform: bool
×
532

533

UNCOV
534
def artist(**kwargs: KwargsArtist | Any) -> Tuple[Figure, Axes]:
×
535
    """
536
    Return the current figure and its axes or create a new one.
537

538
    Other Parameters
539
    ----------------
540
    kwargs
541
        {:func:`colour.plotting.common.KwargsArtist`},
542
        See the documentation of the previously listed class.
543

544
    Returns
545
    -------
546
    :class:`tuple`
547
        Current figure and axes.
548
    """
549

550
    width, height = plt.rcParams["figure.figsize"]
1✔
551

552
    figure_size = (width, width) if kwargs.get("uniform") else (width, height)
1✔
553

554
    axes = kwargs.get("axes")
1✔
555
    if axes is None:
1✔
556
        figure = plt.figure(figsize=figure_size)
1✔
557

558
        return figure, figure.gca()
1✔
559

560
    axes = cast("Axes", axes)
1✔
561
    figure = axes.figure
1✔
562

563
    if isinstance(figure, SubFigure):
1✔
564
        figure = figure.get_figure()
1✔
565

566
    return cast("Figure", figure), axes
1✔
567

568

UNCOV
569
class KwargsCamera(TypedDict):
×
570
    """
571
    Define the keyword argument types for the
572
    :func:`colour.plotting.camera` definition.
573

574
    Parameters
575
    ----------
576
    figure
577
        Figure to apply the render elements onto.
578
    axes
579
        Axes to apply the render elements onto.
580
    azimuth
581
        Camera azimuth.
582
    elevation
583
        Camera elevation.
584
    camera_aspect
585
        Matplotlib axes aspect. Default is *equal*.
586
    """
587

UNCOV
588
    figure: Figure
×
UNCOV
589
    axes: Axes
×
UNCOV
590
    azimuth: float | None
×
UNCOV
591
    elevation: float | None
×
UNCOV
592
    camera_aspect: Literal["equal"] | str
×
593

594

UNCOV
595
def camera(**kwargs: KwargsCamera | Any) -> Tuple[Figure, Axes3D]:
×
596
    """
597
    Configure camera settings for the current 3D visualization.
598

599
    Other Parameters
600
    ----------------
601
    kwargs
602
        {:func:`colour.plotting.common.KwargsCamera`},
603
        See the documentation of the previously listed class.
604

605
    Returns
606
    -------
607
    :class:`tuple`
608
        Current figure and axes.
609
    """
610

611
    figure = cast("Figure", kwargs.get("figure", plt.gcf()))
1✔
612
    axes = cast("Axes3D", kwargs.get("axes", plt.gca()))
1✔
613

614
    settings = Structure(camera_aspect="equal", elevation=None, azimuth=None)
1✔
615
    settings.update(kwargs)
1✔
616

617
    if settings.camera_aspect == "equal":
1✔
618
        uniform_axes3d(axes=axes)
1✔
619

620
    axes.view_init(elev=settings.elevation, azim=settings.azimuth)
1✔
621

622
    return figure, axes
1✔
623

624

UNCOV
625
class KwargsRender(TypedDict):
×
626
    """
627
    Define the keyword argument types for the
628
    :func:`colour.plotting.render` definition.
629

630
    Parameters
631
    ----------
632
    figure
633
        Figure to apply the render elements onto.
634
    axes
635
        Axes to apply the render elements onto.
636
    filename
637
        Figure will be saved using the specified ``filename`` argument.
638
    show
639
        Whether to show the figure and call
640
        :func:`matplotlib.pyplot.show` definition.
641
    block
642
        Whether to wait for all figures to be closed before returning.
643
        If `True` block and run the GUI main loop until all figure
644
        windows are closed. If `False` ensure that all figure windows
645
        are displayed and return immediately. In this case, you are
646
        responsible for ensuring that the event loop is running to have
647
        responsive figures. Defaults to True in non-interactive mode and
648
        to False in interactive mode.
649
    aspect
650
        Matplotlib axes aspect.
651
    axes_visible
652
        Whether the axes are visible. Default is *True*.
653
    bounding_box
654
        Array defining current axes limits such as
655
        `bounding_box = (x min, x max, y min, y max)`.
656
    tight_layout
657
        Whether to invoke the :func:`matplotlib.pyplot.tight_layout`
658
        definition.
659
    legend
660
        Whether to display the legend. Default is *False*.
661
    legend_columns
662
        Number of columns in the legend. Default is *1*.
663
    transparent_background
664
        Whether to turn off the background patch. Default is *True*.
665
    title
666
        Figure title.
667
    wrap_title
668
        Whether to wrap the figure title. Default is *True*.
669
    x_label
670
        *X* axis label.
671
    y_label
672
        *Y* axis label.
673
    x_ticker
674
        Whether to display the *X* axis ticker. Default is *True*.
675
    y_ticker
676
        Whether to display the *Y* axis ticker. Default is *True*.
677
    """
678

UNCOV
679
    figure: Figure
×
UNCOV
680
    axes: Axes
×
UNCOV
681
    filename: str | PathLike
×
UNCOV
682
    show: bool
×
UNCOV
683
    block: bool
×
UNCOV
684
    aspect: Literal["auto", "equal"] | float
×
UNCOV
685
    axes_visible: bool
×
UNCOV
686
    bounding_box: ArrayLike
×
UNCOV
687
    tight_layout: bool
×
UNCOV
688
    legend: bool
×
UNCOV
689
    legend_columns: int
×
UNCOV
690
    transparent_background: bool
×
UNCOV
691
    title: str
×
UNCOV
692
    wrap_title: bool
×
UNCOV
693
    x_label: str
×
UNCOV
694
    y_label: str
×
UNCOV
695
    x_ticker: bool
×
UNCOV
696
    y_ticker: bool
×
697

698

UNCOV
699
def render(
×
700
    **kwargs: KwargsRender | Any,
701
) -> Tuple[Figure, Axes] | Tuple[Figure, Axes3D]:
702
    """
703
    Render the current figure while adjusting various settings such as the
704
    bounding box, title, or background transparency.
705

706
    Other Parameters
707
    ----------------
708
    kwargs
709
        {:func:`colour.plotting.common.KwargsRender`},
710
        See the documentation of the previously listed class.
711

712
    Returns
713
    -------
714
    :class:`tuple`
715
        Current figure and axes.
716
    """
717

718
    figure = cast("Figure", kwargs.get("figure", plt.gcf()))
1✔
719
    axes = cast("Axes", kwargs.get("axes", plt.gca()))
1✔
720

721
    kwargs = handle_arguments_deprecation(
1✔
722
        {
723
            "ArgumentRenamed": [["standalone", "show"]],
724
        },
725
        **kwargs,
726
    )
727

728
    settings = Structure(
1✔
729
        filename=None,
730
        show=True,
731
        block=True,
732
        aspect=None,
733
        axes_visible=True,
734
        bounding_box=None,
735
        tight_layout=True,
736
        legend=False,
737
        legend_columns=1,
738
        transparent_background=True,
739
        title=None,
740
        wrap_title=True,
741
        x_label=None,
742
        y_label=None,
743
        x_ticker=True,
744
        y_ticker=True,
745
    )
746
    settings.update(kwargs)
1✔
747

748
    if settings.aspect:
1✔
749
        axes.set_aspect(settings.aspect)
1✔
750
    if not settings.axes_visible:
1✔
751
        axes.set_axis_off()
1✔
752
    if settings.bounding_box:
1✔
753
        axes.set_xlim(settings.bounding_box[0], settings.bounding_box[1])
1✔
754
        axes.set_ylim(settings.bounding_box[2], settings.bounding_box[3])
1✔
755

756
    if settings.title:
1✔
757
        axes.set_title(settings.title, wrap=settings.wrap_title)
1✔
758
    if settings.x_label:
1✔
759
        axes.set_xlabel(settings.x_label)
1✔
760
    if settings.y_label:
1✔
761
        axes.set_ylabel(settings.y_label)
1✔
762
    if not settings.x_ticker:
1✔
763
        axes.set_xticks([])
1✔
764
    if not settings.y_ticker:
1✔
765
        axes.set_yticks([])
1✔
766
    if settings.legend:
1✔
767
        axes.legend(ncol=settings.legend_columns)
1✔
768

769
    if settings.tight_layout:
1✔
770
        figure.tight_layout()
1✔
771

772
    if settings.transparent_background:
1✔
773
        figure.patch.set_alpha(0)
1✔
774

775
    if settings.filename is not None:
1✔
776
        figure.savefig(str(settings.filename))
1✔
777

778
    if settings.show:
1✔
779
        plt.show(block=settings.block)
1✔
780

781
    return figure, axes
1✔
782

783

UNCOV
784
def label_rectangles(
×
785
    labels: Sequence[str | Real],
786
    rectangles: Sequence[Patch],
787
    rotation: Literal["horizontal", "vertical"] | str = "vertical",
788
    text_size: float = CONSTANTS_COLOUR_STYLE.font.scaling.medium,
789
    offset: ArrayLike | None = None,
790
    **kwargs: Any,
791
) -> Tuple[Figure, Axes]:
792
    """
793
    Add labels above specified rectangles.
794

795
    Parameters
796
    ----------
797
    labels
798
        Text labels to display above the rectangles.
799
    rectangles
800
        Rectangle patches used to determine label positions and values.
801
    rotation
802
        Orientation of the labels.
803
    text_size
804
        Font size for the labels.
805
    offset
806
        Label offset as percentages of the largest rectangle dimensions.
807

808
    Other Parameters
809
    ----------------
810
    figure
811
        Figure to apply the render elements onto.
812
    axes
813
        Axes to apply the render elements onto.
814

815
    Returns
816
    -------
817
    :class:`tuple`
818
        Current figure and axes.
819
    """
820

821
    rotation = validate_method(
1✔
822
        rotation,
823
        ("horizontal", "vertical"),
824
        '"{0}" rotation is invalid, it must be one of {1}!',
825
    )
826

827
    figure = kwargs.get("figure", plt.gcf())
1✔
828
    axes = kwargs.get("axes", plt.gca())
1✔
829

830
    offset = as_float_array(optional(offset, (0.0, 0.025)))
1✔
831

832
    x_m, y_m = 0, 0
1✔
833
    for rectangle in rectangles:
1✔
834
        x_m = max(x_m, rectangle.get_width())  # pyright: ignore
1✔
835
        y_m = max(y_m, rectangle.get_height())  # pyright: ignore
1✔
836

837
    for i, rectangle in enumerate(rectangles):
1✔
838
        x = rectangle.get_x()  # pyright: ignore
1✔
839
        height = rectangle.get_height()  # pyright: ignore
1✔
840
        width = rectangle.get_width()  # pyright: ignore
1✔
841
        axes.text(
1✔
842
            x + width / 2 + offset[0] * width,
843
            height + offset[1] * y_m,
844
            labels[i],
845
            ha="center",
846
            va="bottom",
847
            rotation=rotation,
848
            fontsize=text_size,
849
            clip_on=True,
850
            zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_label,
851
        )
852

853
    return figure, axes
1✔
854

855

UNCOV
856
def uniform_axes3d(**kwargs: Any) -> Tuple[Figure, Axes3D]:
×
857
    """
858
    Set equal aspect ratio to the specified 3D axes.
859

860
    Other Parameters
861
    ----------------
862
    figure
863
        Figure to apply the render elements onto.
864
    axes
865
        Axes to apply the render elements onto.
866

867
    Returns
868
    -------
869
    :class:`tuple`
870
        Current figure and axes.
871
    """
872

873
    figure = kwargs.get("figure", plt.gcf())
1✔
874
    axes = kwargs.get("axes", plt.gca())
1✔
875

876
    with contextlib.suppress(NotImplementedError):  # pragma: no cover
877
        # TODO: Reassess according to
878
        # https://github.com/matplotlib/matplotlib/issues/1077
879
        axes.set_aspect("equal")
880

881
    extents = np.array([getattr(axes, f"get_{axis}lim")() for axis in "xyz"])
1✔
882

883
    centers = np.mean(extents, axis=1)
1✔
884
    extent = np.max(np.abs(extents[..., 1] - extents[..., 0]))
1✔
885

886
    for center, axis in zip(centers, "xyz", strict=True):
1✔
887
        getattr(axes, f"set_{axis}lim")(center - extent / 2, center + extent / 2)
1✔
888

889
    return figure, axes
1✔
890

891

892
def filter_passthrough(
893
    mapping: Mapping,
894
    filterers: Any | str | Sequence[Any | str],
895
    allow_non_siblings: bool = True,
896
) -> dict:
897
    """
898
    Filter mapping objects matching specified filterers while passing through
899
    class instances whose type is one of the mapping element types.
900

901
    Enable passing custom but compatible objects to plotting definitions that
902
    by default expect keys from dataset elements.
903

904
    For example, a typical call to the
905
    :func:`colour.plotting.plot_multi_illuminant_sds` definition is as
906
    follows:
907

908
    >>> import colour
909
    >>> colour.plotting.plot_multi_illuminant_sds(["A"])
910
    ... # doctest: +SKIP
911

912
    With the previous example, it is also possible to pass a custom spectral
913
    distribution as follows:
914

915
    >>> data = {
916
    ...     500: 0.0651,
917
    ...     520: 0.0705,
918
    ...     540: 0.0772,
919
    ...     560: 0.0870,
920
    ...     580: 0.1128,
921
    ...     600: 0.1360,
922
    ... }
923
    >>> colour.plotting.plot_multi_illuminant_sds(
924
    ...     ["A", colour.SpectralDistribution(data)]
925
    ... )
926
    ... # doctest: +SKIP
927

928
    Similarly, a typical call to the
929
    :func:`colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931`
930
    definition is as follows:
931

932
    >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(["A"])
933
    ... # doctest: +SKIP
934

935
    But it is also possible to pass a custom whitepoint as follows:
936

937
    >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(
938
    ...     ["A", {"Custom": np.array([1 / 3 + 0.05, 1 / 3 + 0.05])}]
939
    ... )
940
    ... # doctest: +SKIP
941

942
    Parameters
943
    ----------
944
    mapping
945
        Mapping to filter.
946
    filterers
947
        Filterer or object class instance (which is passed through directly
948
        if its type is one of the mapping element types) or list of
949
        filterers.
950
    allow_non_siblings
951
        Whether to allow non-siblings to be also passed through.
952

953
    Returns
954
    -------
955
    :class:`dict`
956
        Filtered mapping.
957

958
    Notes
959
    -----
960
    -   If the mapping passed is a :class:`colour.utilities.CanonicalMapping`
961
        class instance, then the lower, slugified and canonical keys are
962
        also used for matching.
963
    """
964

965
    if isinstance(filterers, str) or not isinstance(filterers, (list, tuple)):
966
        filterers = [filterers]
967

968
    string_filterers: List[str] = [
969
        filterer for filterer in filterers if isinstance(filterer, str)
970
    ]
971

972
    object_filterers: List[Any] = [
973
        filterer for filterer in filterers if is_sibling(filterer, mapping)
974
    ]
975

976
    if allow_non_siblings:
977
        non_siblings = [
978
            filterer
979
            for filterer in filterers
980
            if filterer not in string_filterers and filterer not in object_filterers
981
        ]
982

983
        if non_siblings:
984
            runtime_warning(
985
                f'Non-sibling elements are passed-through: "{non_siblings}"'
986
            )
987

988
            object_filterers.extend(non_siblings)
989

990
    filtered_mapping = filter_mapping(mapping, string_filterers)
991

992
    for filterer in object_filterers:
993
        # TODO: Consider using "MutableMapping" here.
994
        if isinstance(filterer, (dict, CanonicalMapping)):
995
            for key, value in filterer.items():
996
                filtered_mapping[key] = value
997
        else:
998
            try:
999
                name = filterer.name
1000
            except AttributeError:
1001
                try:
1002
                    name = filterer.__name__
1003
                except AttributeError:
1004
                    name = str(id(filterer))
1005

1006
            filtered_mapping[name] = filterer
1007

1008
    return filtered_mapping
1009

1010

UNCOV
1011
def filter_RGB_colourspaces(
×
1012
    filterers: (
1013
        RGB_Colourspace
1014
        | LiteralRGBColourspace
1015
        | str
1016
        | Sequence[RGB_Colourspace | LiteralRGBColourspace | str]
1017
    ),
1018
    allow_non_siblings: bool = True,
1019
) -> Dict[str, RGB_Colourspace]:
1020
    """
1021
    Filter the *RGB* colourspaces matching the specified filterers.
1022

1023
    Parameters
1024
    ----------
1025
    filterers
1026
        Filterer, :class:`colour.RGB_Colourspace` class instance (which is
1027
        passed through directly if its type is one of the mapping element
1028
        types), or list of filterers. The ``filterers`` elements can also
1029
        be of any form supported by the
1030
        :func:`colour.plotting.common.filter_passthrough` definition.
1031
    allow_non_siblings
1032
        Whether to allow non-siblings to be also passed through.
1033

1034
    Returns
1035
    -------
1036
    :class:`dict`
1037
        Filtered *RGB* colourspaces.
1038
    """
1039

1040
    return filter_passthrough(RGB_COLOURSPACES, filterers, allow_non_siblings)
1041

1042

UNCOV
1043
def filter_cmfs(
×
1044
    filterers: (
1045
        MultiSpectralDistributions | str | Sequence[MultiSpectralDistributions | str]
1046
    ),
1047
    allow_non_siblings: bool = True,
1048
) -> Dict[str, MultiSpectralDistributions]:
1049
    """
1050
    Filter the colour matching functions matching the specified filterers.
1051

1052
    Parameters
1053
    ----------
1054
    filterers
1055
        Filterer or :class:`colour.LMS_ConeFundamentals`,
1056
        :class:`colour.RGB_ColourMatchingFunctions` or
1057
        :class:`colour.XYZ_ColourMatchingFunctions` class instance (which is
1058
        passed through directly if its type is one of the mapping element
1059
        types) or list of filterers. ``filterers`` elements can also be of
1060
        any form supported by the
1061
        :func:`colour.plotting.common.filter_passthrough` definition.
1062
    allow_non_siblings
1063
        Whether to allow non-siblings to be also passed through.
1064

1065
    Returns
1066
    -------
1067
    :class:`dict`
1068
        Filtered colour matching functions.
1069
    """
1070

1071
    return filter_passthrough(MSDS_CMFS, filterers, allow_non_siblings)
1072

1073

UNCOV
1074
def filter_illuminants(
×
1075
    filterers: SpectralDistribution | str | Sequence[SpectralDistribution | str],
1076
    allow_non_siblings: bool = True,
1077
) -> Dict[str, SpectralDistribution]:
1078
    """
1079
    Filter the illuminants matching the specified filterers.
1080

1081
    Parameters
1082
    ----------
1083
    filterers
1084
        Filterer or :class:`colour.SpectralDistribution` class instance
1085
        (which is passed through directly if its type is one of the
1086
        mapping element types) or list of filterers. ``filterers``
1087
        elements can also be of any form supported by the
1088
        :func:`colour.plotting.common.filter_passthrough` definition.
1089
    allow_non_siblings
1090
        Whether to allow non-siblings to be also passed through.
1091

1092
    Returns
1093
    -------
1094
    :class:`dict`
1095
        Filtered illuminants.
1096
    """
1097

1098
    illuminants = {}
1✔
1099

1100
    illuminants.update(
1101
        filter_passthrough(SDS_ILLUMINANTS, filterers, allow_non_siblings)
1102
    )
1103

1104
    illuminants.update(
1105
        filter_passthrough(SDS_LIGHT_SOURCES, filterers, allow_non_siblings)
1106
    )
1107

1108
    return illuminants
1✔
1109

1110

UNCOV
1111
def filter_colour_checkers(
×
1112
    filterers: ColourChecker | str | Sequence[ColourChecker | str],
1113
    allow_non_siblings: bool = True,
1114
) -> Dict[str, ColourChecker]:
1115
    """
1116
    Filter the colour checkers matching the specified filterers.
1117

1118
    Parameters
1119
    ----------
1120
    filterers
1121
        Filterer or :class:`colour.characterisation.ColourChecker` class
1122
        instance (which is passed through directly if its type is one of
1123
        the mapping element types) or list of filterers. ``filterers``
1124
        elements can also be of any form supported by the
1125
        :func:`colour.plotting.common.filter_passthrough` definition.
1126
    allow_non_siblings
1127
        Whether to allow non-siblings to be also passed through.
1128

1129
    Returns
1130
    -------
1131
    :class:`dict`
1132
        Filtered colour checkers.
1133
    """
1134

1135
    return filter_passthrough(CCS_COLOURCHECKERS, filterers, allow_non_siblings)
1136

1137

UNCOV
1138
def update_settings_collection(
×
1139
    settings_collection: dict | List[dict],
1140
    keyword_arguments: dict | List[dict],
1141
    expected_count: int,
1142
) -> None:
1143
    """
1144
    Update the specified settings collection *in-place* with the specified
1145
    keyword arguments and expected count of settings collection elements.
1146

1147
    Parameters
1148
    ----------
1149
    settings_collection
1150
        Settings collection to update.
1151
    keyword_arguments
1152
        Keyword arguments to update the settings collection.
1153
    expected_count
1154
        Expected count of settings collection elements.
1155

1156
    Examples
1157
    --------
1158
    >>> settings_collection = [{1: 2}, {3: 4}]
1159
    >>> keyword_arguments = {5: 6}
1160
    >>> update_settings_collection(settings_collection, keyword_arguments, 2)
1161
    >>> print(settings_collection)
1162
    [{1: 2, 5: 6}, {3: 4, 5: 6}]
1163
    >>> settings_collection = [{1: 2}, {3: 4}]
1164
    >>> keyword_arguments = [{5: 6}, {7: 8}]
1165
    >>> update_settings_collection(settings_collection, keyword_arguments, 2)
1166
    >>> print(settings_collection)
1167
    [{1: 2, 5: 6}, {3: 4, 7: 8}]
1168
    """
1169

1170
    if not isinstance(keyword_arguments, dict):
1✔
1171
        attest(
1✔
1172
            len(keyword_arguments) == expected_count,
1173
            "Multiple keyword arguments defined, but they do not "
1174
            "match the expected count!",
1175
        )
1176

1177
    for i, settings in enumerate(settings_collection):
1✔
1178
        if isinstance(keyword_arguments, dict):
1✔
1179
            settings.update(keyword_arguments)
1✔
1180
        else:
1181
            settings.update(keyword_arguments[i])
1✔
1182

1183

UNCOV
1184
@override_style(
×
1185
    **{
1186
        "axes.grid": False,
1187
        "xtick.bottom": False,
1188
        "ytick.left": False,
1189
        "xtick.labelbottom": False,
1190
        "ytick.labelleft": False,
1191
    }
1192
)
UNCOV
1193
def plot_single_colour_swatch(
×
1194
    colour_swatch: ArrayLike | ColourSwatch, **kwargs: Any
1195
) -> Tuple[Figure, Axes]:
1196
    """
1197
    Plot a single colour swatch.
1198

1199
    Parameters
1200
    ----------
1201
    colour_swatch
1202
        Colour swatch to plot, either a regular `ArrayLike` or a
1203
        :class:`colour.plotting.ColourSwatch` class instance.
1204

1205
    Other Parameters
1206
    ----------------
1207
    kwargs
1208
        {:func:`colour.plotting.artist`,
1209
        :func:`colour.plotting.plot_multi_colour_swatches`,
1210
        :func:`colour.plotting.render`},
1211
        See the documentation of the previously listed definitions.
1212

1213
    Returns
1214
    -------
1215
    :class:`tuple`
1216
        Current figure and axes.
1217

1218
    Examples
1219
    --------
1220
    >>> RGB = ColourSwatch((0.45620519, 0.03081071, 0.04091952))
1221
    >>> plot_single_colour_swatch(RGB)  # doctest: +ELLIPSIS
1222
    (<Figure size ... with 1 Axes>, <...Axes...>)
1223

1224
    .. image:: ../_static/Plotting_Plot_Single_Colour_Swatch.png
1225
        :align: center
1226
        :alt: plot_single_colour_swatch
1227
    """
1228

1229
    return plot_multi_colour_swatches((colour_swatch,), **kwargs)
1✔
1230

1231

UNCOV
1232
@override_style(
×
1233
    **{
1234
        "axes.grid": False,
1235
        "xtick.bottom": False,
1236
        "ytick.left": False,
1237
        "xtick.labelbottom": False,
1238
        "ytick.labelleft": False,
1239
    }
1240
)
UNCOV
1241
def plot_multi_colour_swatches(
×
1242
    colour_swatches: ArrayLike | Sequence[ArrayLike | ColourSwatch],
1243
    width: float = 1,
1244
    height: float = 1,
1245
    spacing: float = 0,
1246
    columns: int | None = None,
1247
    direction: Literal["+y", "-y"] | str = "+y",
1248
    text_kwargs: dict | None = None,
1249
    background_colour: ArrayLike = (1.0, 1.0, 1.0),
1250
    compare_swatches: Literal["Diagonal", "Stacked"] | str | None = None,
1251
    **kwargs: Any,
1252
) -> Tuple[Figure, Axes]:
1253
    """
1254
    Plot colour swatches with configurable layout and comparison options.
1255

1256
    Parameters
1257
    ----------
1258
    colour_swatches
1259
        Colour swatch sequence, either a regular `ArrayLike` or a sequence
1260
        of :class:`colour.plotting.ColourSwatch` class instances.
1261
    width
1262
        Colour swatch width.
1263
    height
1264
        Colour swatch height.
1265
    spacing
1266
        Colour swatches spacing.
1267
    columns
1268
        Colour swatches columns count, defaults to the colour swatch count
1269
        or half of it if comparing.
1270
    direction
1271
        Row stacking direction.
1272
    text_kwargs
1273
        Keyword arguments for the :func:`matplotlib.pyplot.text`
1274
        definition. The following special keywords can also be used:
1275

1276
        -   ``offset``: Sets the text offset.
1277
        -   ``visible``: Sets the text visibility.
1278
    background_colour
1279
        Background colour.
1280
    compare_swatches
1281
        Whether to compare the swatches, in which case the colour swatch
1282
        count must be an even number with alternating reference colour
1283
        swatches and test colour swatches. *Stacked* will draw the test
1284
        colour swatch in the center of the reference colour swatch,
1285
        *Diagonal* will draw the reference colour swatch in the upper left
1286
        diagonal area and the test colour swatch in the bottom right
1287
        diagonal area.
1288

1289
    Other Parameters
1290
    ----------------
1291
    kwargs
1292
        {:func:`colour.plotting.artist`,
1293
        :func:`colour.plotting.render`},
1294
        See the documentation of the previously listed definitions.
1295

1296
    Returns
1297
    -------
1298
    :class:`tuple`
1299
        Current figure and axes.
1300

1301
    Examples
1302
    --------
1303
    >>> RGB_1 = ColourSwatch((0.45293517, 0.31732158, 0.26414773))
1304
    >>> RGB_2 = ColourSwatch((0.77875824, 0.57726450, 0.50453169))
1305
    >>> plot_multi_colour_swatches([RGB_1, RGB_2])  # doctest: +ELLIPSIS
1306
    (<Figure size ... with 1 Axes>, <...Axes...>)
1307

1308
    .. image:: ../_static/Plotting_Plot_Multi_Colour_Swatches.png
1309
        :align: center
1310
        :alt: plot_multi_colour_swatches
1311
    """
1312

1313
    direction = validate_method(
1✔
1314
        direction,
1315
        ("+y", "-y"),
1316
        '"{0}" direction is invalid, it must be one of {1}!',
1317
    )
1318

1319
    if compare_swatches is not None:
1✔
1320
        compare_swatches = validate_method(
1✔
1321
            compare_swatches,
1322
            ("Diagonal", "Stacked"),
1323
            '"{0}" compare swatches method is invalid, it must be one of {1}!',
1324
        )
1325

1326
    _figure, axes = artist(**kwargs)
1✔
1327

1328
    # Handling case where `colour_swatches` is a regular *ArrayLike*.
1329
    colour_swatches = list(colour_swatches)  # pyright: ignore
1✔
1330
    colour_swatches_converted = []
1✔
1331
    if not isinstance(first_item(colour_swatches), ColourSwatch):
1✔
1332
        for _i, colour_swatch in enumerate(
1✔
1333
            np.reshape(
1334
                as_float_array(cast("ArrayLike", colour_swatches))[..., :3], (-1, 3)
1335
            )
1336
        ):
1337
            colour_swatches_converted.append(ColourSwatch(colour_swatch))
1✔
1338
    else:
1339
        colour_swatches_converted = cast("List[ColourSwatch]", colour_swatches)
1✔
1340

1341
    colour_swatches = colour_swatches_converted
1✔
1342

1343
    if compare_swatches is not None:
1✔
1344
        attest(
1✔
1345
            len(colour_swatches) % 2 == 0,
1346
            "Cannot compare an odd number of colour swatches!",
1347
        )
1348

1349
        colour_swatches_reference = colour_swatches[0::2]
1✔
1350
        colour_swatches_test = colour_swatches[1::2]
1✔
1351
    else:
1352
        colour_swatches_reference = colour_swatches_test = colour_swatches
1✔
1353

1354
    columns = optional(columns, len(colour_swatches_reference))
1✔
1355

1356
    text_settings = {
1✔
1357
        "offset": 0.05,
1358
        "visible": True,
1359
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1360
    }
1361
    if text_kwargs is not None:
1✔
1362
        text_settings.update(text_kwargs)
1✔
1363
    text_offset = text_settings.pop("offset")
1✔
1364

1365
    offset_X: float = 0
1✔
1366
    offset_Y: float = 0
1✔
1367
    x_min, x_max, y_min, y_max = 0, width, 0, height
1✔
1368
    y = 1 if direction == "+y" else -1
1✔
1369
    for i, colour_swatch in enumerate(colour_swatches_reference):
1✔
1370
        if i % columns == 0 and i != 0:
1✔
1371
            offset_X = 0
1✔
1372
            offset_Y += (height + spacing) * y
1✔
1373

1374
        x_0, x_1 = offset_X, offset_X + width
1✔
1375
        y_0, y_1 = offset_Y, offset_Y + height * y
1✔
1376

1377
        axes.fill(
1✔
1378
            (x_0, x_1, x_1, x_0),
1379
            (y_0, y_0, y_1, y_1),
1380
            color=np.clip(colour_swatch.RGB, 0, 1),
1381
            zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1382
        )
1383

1384
        if compare_swatches == "stacked":
1✔
1385
            margin_X = width * 0.25
1✔
1386
            margin_Y = height * 0.25
1✔
1387
            axes.fill(
1✔
1388
                (
1389
                    x_0 + margin_X,
1390
                    x_1 - margin_X,
1391
                    x_1 - margin_X,
1392
                    x_0 + margin_X,
1393
                ),
1394
                (
1395
                    y_0 + margin_Y * y,
1396
                    y_0 + margin_Y * y,
1397
                    y_1 - margin_Y * y,
1398
                    y_1 - margin_Y * y,
1399
                ),
1400
                color=np.clip(colour_swatches_test[i].RGB, 0, 1),
1401
                zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1402
            )
1403
        else:
1404
            axes.fill(
1✔
1405
                (x_0, x_1, x_1),
1406
                (y_0, y_0, y_1),
1407
                color=np.clip(colour_swatches_test[i].RGB, 0, 1),
1408
                zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1409
            )
1410

1411
        if colour_swatch.name is not None and text_settings["visible"]:
1✔
1412
            axes.text(
1✔
1413
                x_0 + text_offset,
1414
                y_0 + text_offset * y,
1415
                colour_swatch.name,
1416
                verticalalignment="bottom" if y == 1 else "top",
1417
                clip_on=True,
1418
                **text_settings,
1419
            )
1420

1421
        offset_X += width + spacing
1✔
1422

1423
    x_max = min(len(colour_swatches), as_int_scalar(columns))
1✔
1424
    x_max = x_max * width + x_max * spacing - spacing
1✔
1425
    y_max = offset_Y
1✔
1426

1427
    axes.patch.set_facecolor(background_colour)  # pyright: ignore
1✔
1428

1429
    if y == 1:
1✔
1430
        bounding_box = [
1✔
1431
            x_min - spacing,
1432
            x_max + spacing,
1433
            y_min - spacing,
1434
            y_max + spacing + height,
1435
        ]
1436
    else:
1437
        bounding_box = [
1✔
1438
            x_min - spacing,
1439
            x_max + spacing,
1440
            y_max - spacing - height,
1441
            y_min + spacing,
1442
        ]
1443

1444
    settings: Dict[str, Any] = {
1✔
1445
        "axes": axes,
1446
        "bounding_box": bounding_box,
1447
        "aspect": "equal",
1448
    }
1449
    settings.update(kwargs)
1✔
1450

1451
    return render(**settings)
1✔
1452

1453

UNCOV
1454
@override_style()
×
UNCOV
1455
def plot_single_function(
×
1456
    function: Callable,
1457
    samples: ArrayLike | None = None,
1458
    log_x: int | None = None,
1459
    log_y: int | None = None,
1460
    plot_kwargs: dict | List[dict] | None = None,
1461
    **kwargs: Any,
1462
) -> Tuple[Figure, Axes]:
1463
    """
1464
    Plot the specified function.
1465

1466
    Parameters
1467
    ----------
1468
    function
1469
        Function to plot.
1470
    samples
1471
        Samples to evaluate the functions with.
1472
    log_x
1473
        Log base to use for the *x* axis scale, if *None*, the *x* axis
1474
        scale will be linear.
1475
    log_y
1476
        Log base to use for the *y* axis scale, if *None*, the *y* axis
1477
        scale will be linear.
1478
    plot_kwargs
1479
        Keyword arguments for the :func:`matplotlib.pyplot.plot`
1480
        definition, used to control the style of the plotted function.
1481

1482
    Other Parameters
1483
    ----------------
1484
    kwargs
1485
        {:func:`colour.plotting.artist`,
1486
        :func:`colour.plotting.plot_multi_functions`,
1487
        :func:`colour.plotting.render`},
1488
        See the documentation of the previously listed definitions.
1489

1490
    Returns
1491
    -------
1492
    :class:`tuple`
1493
        Current figure and axes.
1494

1495
    Examples
1496
    --------
1497
    >>> from colour.models import gamma_function
1498
    >>> plot_single_function(partial(gamma_function, exponent=1 / 2.2))
1499
    ... # doctest: +ELLIPSIS
1500
    (<Figure size ... with 1 Axes>, <...Axes...>)
1501

1502
    .. image:: ../_static/Plotting_Plot_Single_Function.png
1503
        :align: center
1504
        :alt: plot_single_function
1505
    """
1506

1507
    try:
1✔
1508
        name = function.__name__
1✔
1509
    except AttributeError:
1✔
1510
        name = "Unnamed"
1✔
1511

1512
    settings: Dict[str, Any] = {
1✔
1513
        "title": f"{name} - Function",
1514
        "legend": False,
1515
    }
1516
    settings.update(kwargs)
1✔
1517

1518
    return plot_multi_functions(
1✔
1519
        {name: function}, samples, log_x, log_y, plot_kwargs, **settings
1520
    )
1521

1522

UNCOV
1523
@override_style()
×
UNCOV
1524
def plot_multi_functions(
×
1525
    functions: Dict[str, Callable],
1526
    samples: ArrayLike | None = None,
1527
    log_x: int | None = None,
1528
    log_y: int | None = None,
1529
    plot_kwargs: dict | List[dict] | None = None,
1530
    **kwargs: Any,
1531
) -> Tuple[Figure, Axes]:
1532
    """
1533
    Plot specified functions.
1534

1535
    Parameters
1536
    ----------
1537
    functions
1538
        Functions to plot.
1539
    samples
1540
        Samples to evaluate the functions with.
1541
    log_x
1542
        Log base to use for the *x* axis scale, if *None*, the *x* axis
1543
        scale will be linear.
1544
    log_y
1545
        Log base to use for the *y* axis scale, if *None*, the *y* axis
1546
        scale will be linear.
1547
    plot_kwargs
1548
        Keyword arguments for the :func:`matplotlib.pyplot.plot`
1549
        definition, used to control the style of the plotted functions.
1550
        ``plot_kwargs`` can be either a single dictionary applied to all
1551
        the plotted functions with the same settings or a sequence of
1552
        dictionaries with different settings for each plotted function.
1553

1554
    Other Parameters
1555
    ----------------
1556
    kwargs
1557
        {:func:`colour.plotting.artist`,
1558
        :func:`colour.plotting.render`},
1559
        See the documentation of the previously listed definitions.
1560

1561
    Returns
1562
    -------
1563
    :class:`tuple`
1564
        Current figure and axes.
1565

1566
    Examples
1567
    --------
1568
    >>> functions = {
1569
    ...     "Gamma 2.2": lambda x: x ** (1 / 2.2),
1570
    ...     "Gamma 2.4": lambda x: x ** (1 / 2.4),
1571
    ...     "Gamma 2.6": lambda x: x ** (1 / 2.6),
1572
    ... }
1573
    >>> plot_multi_functions(functions)
1574
    ... # doctest: +ELLIPSIS
1575
    (<Figure size ... with 1 Axes>, <...Axes...>)
1576

1577
    .. image:: ../_static/Plotting_Plot_Multi_Functions.png
1578
        :align: center
1579
        :alt: plot_multi_functions
1580
    """
1581

1582
    settings: Dict[str, Any] = dict(kwargs)
1✔
1583

1584
    _figure, axes = artist(**settings)
1✔
1585

1586
    plot_settings_collection = [
1✔
1587
        {
1588
            "label": f"{name}",
1589
            "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1590
        }
1591
        for name in functions
1592
    ]
1593

1594
    if plot_kwargs is not None:
1✔
1595
        update_settings_collection(
1✔
1596
            plot_settings_collection, plot_kwargs, len(functions)
1597
        )
1598

1599
    if log_x is not None and log_y is not None:
1✔
1600
        attest(
1✔
1601
            log_x >= 2 and log_y >= 2,
1602
            "Log base must be equal or greater than 2.",
1603
        )
1604

1605
        plotting_function = axes.loglog
1✔
1606

1607
        axes.set_xscale("log", base=log_x)
1✔
1608
        axes.set_yscale("log", base=log_y)
1✔
1609
    elif log_x is not None:
1✔
1610
        attest(log_x >= 2, "Log base must be equal or greater than 2.")
1✔
1611

1612
        plotting_function = partial(axes.semilogx, base=log_x)
1✔
1613
    elif log_y is not None:
1✔
1614
        attest(log_y >= 2, "Log base must be equal or greater than 2.")
1✔
1615

1616
        plotting_function = partial(axes.semilogy, base=log_y)
1✔
1617
    else:
1618
        plotting_function = axes.plot
1✔
1619

1620
    samples = optional(samples, np.linspace(0, 1, 1000))
1✔
1621

1622
    for i, (_name, function) in enumerate(functions.items()):
1✔
1623
        plotting_function(samples, function(samples), **plot_settings_collection[i])
1✔
1624

1625
    x_label = f"x - Log Base {log_x} Scale" if log_x is not None else "x - Linear Scale"
1✔
1626
    y_label = f"y - Log Base {log_y} Scale" if log_y is not None else "y - Linear Scale"
1✔
1627
    settings = {
1✔
1628
        "axes": axes,
1629
        "legend": True,
1630
        "title": f"{', '.join(functions)} - Functions",
1631
        "x_label": x_label,
1632
        "y_label": y_label,
1633
    }
1634
    settings.update(kwargs)
1✔
1635

1636
    return render(**settings)
1✔
1637

1638

UNCOV
1639
@override_style()
×
UNCOV
1640
def plot_image(
×
1641
    image: ArrayLike,
1642
    imshow_kwargs: dict | None = None,
1643
    text_kwargs: dict | None = None,
1644
    **kwargs: Any,
1645
) -> Tuple[Figure, Axes]:
1646
    """
1647
    Plot the specified image using matplotlib.
1648

1649
    Parameters
1650
    ----------
1651
    image
1652
        Image array to plot, typically as RGB or grayscale data.
1653
    imshow_kwargs
1654
        Keyword arguments for the :func:`matplotlib.pyplot.imshow`
1655
        definition, controlling image display properties.
1656
    text_kwargs
1657
        Keyword arguments for the :func:`matplotlib.pyplot.text`
1658
        definition, controlling text overlay properties. The following
1659
        special keyword arguments can also be used:
1660

1661
        -   ``offset`` : Sets the text offset position.
1662

1663
    Other Parameters
1664
    ----------------
1665
    kwargs
1666
        {:func:`colour.plotting.artist`,
1667
        :func:`colour.plotting.render`}, See the documentation of the
1668
        previously listed definitions for additional plotting controls.
1669

1670
    Returns
1671
    -------
1672
    :class:`tuple`
1673
        Current figure and axes objects from matplotlib.
1674

1675
    Examples
1676
    --------
1677
    >>> import os
1678
    >>> import colour
1679
    >>> from colour import read_image
1680
    >>> path = os.path.join(
1681
    ...     colour.__path__[0],
1682
    ...     "examples",
1683
    ...     "plotting",
1684
    ...     "resources",
1685
    ...     "Ishihara_Colour_Blindness_Test_Plate_3.png",
1686
    ... )
1687
    >>> plot_image(read_image(path))  # doctest: +ELLIPSIS
1688
    (<Figure size ... with 1 Axes>, <...Axes...>)
1689

1690
    .. image:: ../_static/Plotting_Plot_Image.png
1691
        :align: center
1692
        :alt: plot_image
1693
    """
1694

1695
    _figure, axes = artist(**kwargs)
1✔
1696

1697
    imshow_settings = {
1✔
1698
        "interpolation": "nearest",
1699
        "cmap": matplotlib.colormaps["Greys_r"],
1700
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
1701
    }
1702
    if imshow_kwargs is not None:
1✔
1703
        imshow_settings.update(imshow_kwargs)
1✔
1704

1705
    text_settings = {
1✔
1706
        "text": None,
1707
        "offset": 0.005,
1708
        "color": CONSTANTS_COLOUR_STYLE.colour.brightest,
1709
        "alpha": CONSTANTS_COLOUR_STYLE.opacity.high,
1710
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1711
    }
1712
    if text_kwargs is not None:
1✔
1713
        text_settings.update(text_kwargs)
1✔
1714
    text_offset = text_settings.pop("offset")
1✔
1715

1716
    image = as_float_array(image)
1✔
1717

1718
    axes.imshow(np.clip(image, 0, 1), **imshow_settings)
1✔
1719

1720
    if text_settings["text"] is not None:
1✔
1721
        text = text_settings.pop("text")
1✔
1722

1723
        axes.text(
1✔
1724
            text_offset,
1725
            text_offset,
1726
            text,
1727
            transform=axes.transAxes,
1728
            ha="left",
1729
            va="bottom",
1730
            **text_settings,
1731
        )
1732

1733
    settings: Dict[str, Any] = {
1✔
1734
        "axes": axes,
1735
        "axes_visible": False,
1736
    }
1737
    settings.update(kwargs)
1✔
1738

1739
    return render(**settings)
1✔
1740

1741

UNCOV
1742
def plot_ray(
×
1743
    axes: Axes,
1744
    x_coords: ArrayLike,
1745
    y_coords: ArrayLike,
1746
    style: Literal["solid", "dashed"] | str = "solid",
1747
    label: str | None = None,
1748
    show_arrow: bool = True,
1749
    show_dots: bool = False,
1750
) -> None:
1751
    """
1752
    Draw a ray path with optional arrow and interface dots.
1753

1754
    Parameters
1755
    ----------
1756
    axes
1757
        Axes to draw the ray on.
1758
    x_coords
1759
        X coordinates of the ray path.
1760
    y_coords
1761
        Y coordinates of the ray path.
1762
    style
1763
        Line style: 'solid' for transmitted rays, 'dashed' for reflected rays.
1764
    label
1765
        Label for the legend (only on first segment).
1766
    show_arrow
1767
        Whether to show directional arrow at midpoint.
1768
    show_dots
1769
        Whether to show dots at intermediate points.
1770

1771
    Examples
1772
    --------
1773
    >>> import matplotlib.pyplot as plt
1774
    >>> import numpy as np
1775
    >>> _fig, axes = plt.subplots()
1776
    >>> x = np.array([0, 1, 2])
1777
    >>> y = np.array([0, 1, 0])
1778
    >>> plot_ray(axes, x, y, style="solid", label="Ray")
1779
    >>> plt.close()
1780
    """
1781

1782
    x_coords = as_float_array(x_coords)
1✔
1783
    y_coords = as_float_array(y_coords)
1✔
1784

1785
    # Validate style
1786
    style = validate_method(style, ("solid", "dashed"))
1✔
1787

1788
    # Draw the ray line
1789
    linestyle = "-" if style == "solid" else "--"
1✔
1790
    axes.plot(
1✔
1791
        x_coords,
1792
        y_coords,
1793
        linestyle=linestyle,
1794
        color="black",
1795
        linewidth=2,
1796
        label=label,
1797
        zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_line,
1798
    )
1799

1800
    # Draw arrows on each segment
1801
    if show_arrow:
1✔
1802
        for i in range(len(x_coords) - 1):
1✔
1803
            x_start, x_end = x_coords[i], x_coords[i + 1]
1✔
1804
            y_start, y_end = y_coords[i], y_coords[i + 1]
1✔
1805

1806
            # Calculate midpoint
1807
            mid_x = (x_start + x_end) / 2
1✔
1808
            mid_y = (y_start + y_end) / 2
1✔
1809

1810
            # Calculate direction
1811
            dx = x_end - x_start
1✔
1812
            dy = y_end - y_start
1✔
1813

1814
            # Draw arrow at midpoint
1815
            axes.annotate(
1✔
1816
                "",
1817
                xy=(mid_x + dx * 0.1, mid_y + dy * 0.1),
1818
                xytext=(mid_x, mid_y),
1819
                arrowprops=dict(arrowstyle="->", color="black", lw=1.5),
1820
                zorder=CONSTANTS_COLOUR_STYLE.zorder.foreground_annotation,
1821
            )
1822

1823
    # Draw dots at intermediate points (exclude first and last)
1824
    if show_dots and len(x_coords) > 2:
1✔
1825
        axes.plot(
1✔
1826
            x_coords[1:-1],
1827
            y_coords[1:-1],
1828
            "ko",
1829
            markersize=6,
1830
            zorder=CONSTANTS_COLOUR_STYLE.zorder.foreground_scatter,
1831
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc