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

colour-science / colour / 18370061804

09 Oct 2025 08:19AM UTC coverage: 76.753% (-22.6%) from 99.349%
18370061804

push

github

KelSolaar
Merge branch 'feature/v0.4.7' into develop

32663 of 42556 relevant lines covered (76.75%)

0.77 hits per line

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

69.44
/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

28
from __future__ import annotations
×
29

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

38
import matplotlib.cm
×
39
import matplotlib.font_manager
×
40
import matplotlib.pyplot as plt
×
41
import matplotlib.ticker
×
42
import numpy as np
×
43
from cycler import cycler
×
44
from matplotlib.colors import LinearSegmentedColormap
×
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

52
from colour.characterisation import CCS_COLOURCHECKERS, ColourChecker
×
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
        Generator,
67
        Literal,
68
        LiteralChromaticAdaptationTransform,
69
        LiteralFontScaling,
70
        LiteralRGBColourspace,
71
        Mapping,
72
        NDArrayFloat,
73
        PathLike,
74
        Real,
75
        Sequence,
76
        Tuple,
77
    )
78

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

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

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

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

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

228
del _scaling, _value
×
229

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

240

241
def colour_style(use_style: bool = True) -> dict:
×
242
    """
243
    Return the *Colour* plotting style configuration.
244

245
    Parameters
246
    ----------
247
    use_style
248
        Whether to apply the style configuration to *Matplotlib*.
249

250
    Returns
251
    -------
252
    :class:`dict`
253
        *Colour* plotting style configuration dictionary.
254
    """
255

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

318
    if use_style:
1✔
319
        plt.rcParams.update(style)
1✔
320

321
    return style
1✔
322

323

324
def override_style(**kwargs: Any) -> Callable:
×
325
    """
326
    Decorate a function to override *Matplotlib* style.
327

328
    Other Parameters
329
    ----------------
330
    kwargs
331
        Keywords arguments for *Matplotlib* style configuration.
332

333
    Returns
334
    -------
335
    Callable
336
        Decorated function with overridden *Matplotlib* style.
337

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

347
    keywords = dict(kwargs)
1✔
348

349
    def wrapper(function: Callable) -> Callable:
1✔
350
        """Wrap specified function wrapper."""
351

352
        @functools.wraps(function)
1✔
353
        def wrapped(*args: Any, **kwargs: Any) -> Any:
1✔
354
            """Wrap specified function."""
355

356
            keywords.update(kwargs)
1✔
357

358
            style_overrides = {
1✔
359
                key: value for key, value in keywords.items() if key in plt.rcParams
360
            }
361

362
            with plt.style.context(style_overrides):
1✔
363
                return function(*args, **kwargs)
1✔
364

365
        return wrapped
1✔
366

367
    return wrapper
1✔
368

369

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

375
    Parameters
376
    ----------
377
    scaling
378
        Font scaling to temporarily set.
379
    value
380
        Value to temporarily set the font scaling with.
381

382
    Yields
383
    ------
384
    Generator.
385

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

395
    current_value = matplotlib.font_manager.font_scalings[scaling]
1✔
396

397
    matplotlib.font_manager.font_scalings[scaling] = value
1✔
398

399
    yield
1✔
400

401
    matplotlib.font_manager.font_scalings[scaling] = current_value
1✔
402

403

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

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

429
    Returns
430
    -------
431
    :class:`numpy.ndarray`
432
        Default plotting colourspace colour array.
433

434
    Examples
435
    --------
436
    >>> import numpy as np
437
    >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])
438
    >>> XYZ_to_plotting_colourspace(XYZ)  # doctest: +ELLIPSIS
439
    array([ 0.7057393...,  0.1924826...,  0.2235416...])
440
    """
441

442
    return XYZ_to_RGB(
1✔
443
        XYZ,
444
        CONSTANTS_COLOUR_STYLE.colour.colourspace,
445
        illuminant,
446
        chromatic_adaptation_transform,
447
        apply_cctf_encoding,
448
    )
449

450

451
@dataclass
×
452
class ColourSwatch:
×
453
    """
454
    Define a data structure for a colour swatch.
455

456
    Parameters
457
    ----------
458
    RGB
459
        RGB colour values representing the swatch.
460
    name
461
        Name identifier for the colour swatch.
462
    """
463

464
    RGB: ArrayLike
×
465
    name: str | None = field(default_factory=lambda: None)
1✔
466

467

468
def colour_cycle(**kwargs: Any) -> itertools.cycle:
×
469
    """
470
    Create a colour cycle iterator using the specified colour map.
471

472
    Other Parameters
473
    ----------------
474
    colour_cycle_map
475
        Matplotlib colourmap name.
476
    colour_cycle_count
477
        Colours count to pick in the colourmap.
478

479
    Returns
480
    -------
481
    :class:`itertools.cycle`
482
        Colour cycle iterator.
483
    """
484

485
    settings = Structure(
1✔
486
        colour_cycle_map=CONSTANTS_COLOUR_STYLE.colour.map,
487
        colour_cycle_count=len(CONSTANTS_COLOUR_STYLE.colour.cycle),
488
    )
489
    settings.update(kwargs)
1✔
490

491
    samples = np.linspace(0, 1, settings.colour_cycle_count)
1✔
492
    if isinstance(settings.colour_cycle_map, LinearSegmentedColormap):
1✔
493
        cycle = settings.colour_cycle_map(samples)
1✔
494
    else:
495
        cycle = getattr(plt.cm, settings.colour_cycle_map)(samples)
1✔
496

497
    return itertools.cycle(cycle)
1✔
498

499

500
class KwargsArtist(TypedDict):
×
501
    """
502
    Define keyword argument types for the :func:`colour.plotting.artist`
503
    definition.
504

505
    Parameters
506
    ----------
507
    axes
508
        Axes that will be passed through without creating a new figure.
509
    uniform
510
        Whether to create the figure with an equal aspect ratio.
511
    """
512

513
    axes: Axes
×
514
    uniform: bool
×
515

516

517
def artist(**kwargs: KwargsArtist | Any) -> Tuple[Figure, Axes]:
×
518
    """
519
    Return the current figure and its axes or create a new one.
520

521
    Other Parameters
522
    ----------------
523
    kwargs
524
        {:func:`colour.plotting.common.KwargsArtist`},
525
        See the documentation of the previously listed class.
526

527
    Returns
528
    -------
529
    :class:`tuple`
530
        Current figure and axes.
531
    """
532

533
    width, height = plt.rcParams["figure.figsize"]
1✔
534

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

537
    axes = kwargs.get("axes")
1✔
538
    if axes is None:
1✔
539
        figure = plt.figure(figsize=figure_size)
1✔
540

541
        return figure, figure.gca()
1✔
542

543
    axes = cast("Axes", axes)
1✔
544
    figure = axes.figure
1✔
545

546
    if isinstance(figure, SubFigure):
1✔
547
        figure = figure.get_figure()
1✔
548

549
    return cast("Figure", figure), axes
1✔
550

551

552
class KwargsCamera(TypedDict):
×
553
    """
554
    Define the keyword argument types for the
555
    :func:`colour.plotting.camera` definition.
556

557
    Parameters
558
    ----------
559
    figure
560
        Figure to apply the render elements onto.
561
    axes
562
        Axes to apply the render elements onto.
563
    azimuth
564
        Camera azimuth.
565
    elevation
566
        Camera elevation.
567
    camera_aspect
568
        Matplotlib axes aspect. Default is *equal*.
569
    """
570

571
    figure: Figure
×
572
    axes: Axes
×
573
    azimuth: float | None
×
574
    elevation: float | None
×
575
    camera_aspect: Literal["equal"] | str
×
576

577

578
def camera(**kwargs: KwargsCamera | Any) -> Tuple[Figure, Axes3D]:
×
579
    """
580
    Configure camera settings for the current 3D visualization.
581

582
    Other Parameters
583
    ----------------
584
    kwargs
585
        {:func:`colour.plotting.common.KwargsCamera`},
586
        See the documentation of the previously listed class.
587

588
    Returns
589
    -------
590
    :class:`tuple`
591
        Current figure and axes.
592
    """
593

594
    figure = cast("Figure", kwargs.get("figure", plt.gcf()))
1✔
595
    axes = cast("Axes3D", kwargs.get("axes", plt.gca()))
1✔
596

597
    settings = Structure(camera_aspect="equal", elevation=None, azimuth=None)
1✔
598
    settings.update(kwargs)
1✔
599

600
    if settings.camera_aspect == "equal":
1✔
601
        uniform_axes3d(axes=axes)
1✔
602

603
    axes.view_init(elev=settings.elevation, azim=settings.azimuth)
1✔
604

605
    return figure, axes
1✔
606

607

608
class KwargsRender(TypedDict):
×
609
    """
610
    Define the keyword argument types for the
611
    :func:`colour.plotting.render` definition.
612

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

662
    figure: Figure
×
663
    axes: Axes
×
664
    filename: str | PathLike
×
665
    show: bool
×
666
    block: bool
×
667
    aspect: Literal["auto", "equal"] | float
×
668
    axes_visible: bool
×
669
    bounding_box: ArrayLike
×
670
    tight_layout: bool
×
671
    legend: bool
×
672
    legend_columns: int
×
673
    transparent_background: bool
×
674
    title: str
×
675
    wrap_title: bool
×
676
    x_label: str
×
677
    y_label: str
×
678
    x_ticker: bool
×
679
    y_ticker: bool
×
680

681

682
def render(
×
683
    **kwargs: KwargsRender | Any,
684
) -> Tuple[Figure, Axes] | Tuple[Figure, Axes3D]:
685
    """
686
    Render the current figure while adjusting various settings such as the
687
    bounding box, title, or background transparency.
688

689
    Other Parameters
690
    ----------------
691
    kwargs
692
        {:func:`colour.plotting.common.KwargsRender`},
693
        See the documentation of the previously listed class.
694

695
    Returns
696
    -------
697
    :class:`tuple`
698
        Current figure and axes.
699
    """
700

701
    figure = cast("Figure", kwargs.get("figure", plt.gcf()))
1✔
702
    axes = cast("Axes", kwargs.get("axes", plt.gca()))
1✔
703

704
    kwargs = handle_arguments_deprecation(
1✔
705
        {
706
            "ArgumentRenamed": [["standalone", "show"]],
707
        },
708
        **kwargs,
709
    )
710

711
    settings = Structure(
1✔
712
        filename=None,
713
        show=True,
714
        block=True,
715
        aspect=None,
716
        axes_visible=True,
717
        bounding_box=None,
718
        tight_layout=True,
719
        legend=False,
720
        legend_columns=1,
721
        transparent_background=True,
722
        title=None,
723
        wrap_title=True,
724
        x_label=None,
725
        y_label=None,
726
        x_ticker=True,
727
        y_ticker=True,
728
    )
729
    settings.update(kwargs)
1✔
730

731
    if settings.aspect:
1✔
732
        axes.set_aspect(settings.aspect)
1✔
733
    if not settings.axes_visible:
1✔
734
        axes.set_axis_off()
1✔
735
    if settings.bounding_box:
1✔
736
        axes.set_xlim(settings.bounding_box[0], settings.bounding_box[1])
1✔
737
        axes.set_ylim(settings.bounding_box[2], settings.bounding_box[3])
1✔
738

739
    if settings.title:
1✔
740
        axes.set_title(settings.title, wrap=settings.wrap_title)
1✔
741
    if settings.x_label:
1✔
742
        axes.set_xlabel(settings.x_label)
1✔
743
    if settings.y_label:
1✔
744
        axes.set_ylabel(settings.y_label)
1✔
745
    if not settings.x_ticker:
1✔
746
        axes.set_xticks([])
1✔
747
    if not settings.y_ticker:
1✔
748
        axes.set_yticks([])
1✔
749
    if settings.legend:
1✔
750
        axes.legend(ncol=settings.legend_columns)
1✔
751

752
    if settings.tight_layout:
1✔
753
        figure.tight_layout()
1✔
754

755
    if settings.transparent_background:
1✔
756
        figure.patch.set_alpha(0)
1✔
757

758
    if settings.filename is not None:
1✔
759
        figure.savefig(str(settings.filename))
1✔
760

761
    if settings.show:
1✔
762
        plt.show(block=settings.block)
1✔
763

764
    return figure, axes
1✔
765

766

767
def label_rectangles(
×
768
    labels: Sequence[str | Real],
769
    rectangles: Sequence[Patch],
770
    rotation: Literal["horizontal", "vertical"] | str = "vertical",
771
    text_size: float = CONSTANTS_COLOUR_STYLE.font.scaling.medium,
772
    offset: ArrayLike | None = None,
773
    **kwargs: Any,
774
) -> Tuple[Figure, Axes]:
775
    """
776
    Add labels above specified rectangles.
777

778
    Parameters
779
    ----------
780
    labels
781
        Text labels to display above the rectangles.
782
    rectangles
783
        Rectangle patches used to determine label positions and values.
784
    rotation
785
        Orientation of the labels.
786
    text_size
787
        Font size for the labels.
788
    offset
789
        Label offset as percentages of the largest rectangle dimensions.
790

791
    Other Parameters
792
    ----------------
793
    figure
794
        Figure to apply the render elements onto.
795
    axes
796
        Axes to apply the render elements onto.
797

798
    Returns
799
    -------
800
    :class:`tuple`
801
        Current figure and axes.
802
    """
803

804
    rotation = validate_method(
1✔
805
        rotation,
806
        ("horizontal", "vertical"),
807
        '"{0}" rotation is invalid, it must be one of {1}!',
808
    )
809

810
    figure = kwargs.get("figure", plt.gcf())
1✔
811
    axes = kwargs.get("axes", plt.gca())
1✔
812

813
    offset = as_float_array(optional(offset, (0.0, 0.025)))
1✔
814

815
    x_m, y_m = 0, 0
1✔
816
    for rectangle in rectangles:
1✔
817
        x_m = max(x_m, rectangle.get_width())  # pyright: ignore
1✔
818
        y_m = max(y_m, rectangle.get_height())  # pyright: ignore
1✔
819

820
    for i, rectangle in enumerate(rectangles):
1✔
821
        x = rectangle.get_x()  # pyright: ignore
1✔
822
        height = rectangle.get_height()  # pyright: ignore
1✔
823
        width = rectangle.get_width()  # pyright: ignore
1✔
824
        axes.text(
1✔
825
            x + width / 2 + offset[0] * width,
826
            height + offset[1] * y_m,
827
            labels[i],
828
            ha="center",
829
            va="bottom",
830
            rotation=rotation,
831
            fontsize=text_size,
832
            clip_on=True,
833
            zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_label,
834
        )
835

836
    return figure, axes
1✔
837

838

839
def uniform_axes3d(**kwargs: Any) -> Tuple[Figure, Axes3D]:
×
840
    """
841
    Set equal aspect ratio to the specified 3D axes.
842

843
    Other Parameters
844
    ----------------
845
    figure
846
        Figure to apply the render elements onto.
847
    axes
848
        Axes to apply the render elements onto.
849

850
    Returns
851
    -------
852
    :class:`tuple`
853
        Current figure and axes.
854
    """
855

856
    figure = kwargs.get("figure", plt.gcf())
1✔
857
    axes = kwargs.get("axes", plt.gca())
1✔
858

859
    with contextlib.suppress(NotImplementedError):  # pragma: no cover
860
        # TODO: Reassess according to
861
        # https://github.com/matplotlib/matplotlib/issues/1077
862
        axes.set_aspect("equal")
863

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

866
    centers = np.mean(extents, axis=1)
1✔
867
    extent = np.max(np.abs(extents[..., 1] - extents[..., 0]))
1✔
868

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

872
    return figure, axes
1✔
873

874

875
def filter_passthrough(
876
    mapping: Mapping,
877
    filterers: Any | str | Sequence[Any | str],
878
    allow_non_siblings: bool = True,
879
) -> dict:
880
    """
881
    Filter mapping objects matching specified filterers while passing through
882
    class instances whose type is one of the mapping element types.
883

884
    Enable passing custom but compatible objects to plotting definitions that
885
    by default expect keys from dataset elements.
886

887
    For example, a typical call to the
888
    :func:`colour.plotting.plot_multi_illuminant_sds` definition is as
889
    follows:
890

891
    >>> import colour
892
    >>> colour.plotting.plot_multi_illuminant_sds(["A"])
893
    ... # doctest: +SKIP
894

895
    With the previous example, it is also possible to pass a custom spectral
896
    distribution as follows:
897

898
    >>> data = {
899
    ...     500: 0.0651,
900
    ...     520: 0.0705,
901
    ...     540: 0.0772,
902
    ...     560: 0.0870,
903
    ...     580: 0.1128,
904
    ...     600: 0.1360,
905
    ... }
906
    >>> colour.plotting.plot_multi_illuminant_sds(
907
    ...     ["A", colour.SpectralDistribution(data)]
908
    ... )
909
    ... # doctest: +SKIP
910

911
    Similarly, a typical call to the
912
    :func:`colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931`
913
    definition is as follows:
914

915
    >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(["A"])
916
    ... # doctest: +SKIP
917

918
    But it is also possible to pass a custom whitepoint as follows:
919

920
    >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(
921
    ...     ["A", {"Custom": np.array([1 / 3 + 0.05, 1 / 3 + 0.05])}]
922
    ... )
923
    ... # doctest: +SKIP
924

925
    Parameters
926
    ----------
927
    mapping
928
        Mapping to filter.
929
    filterers
930
        Filterer or object class instance (which is passed through directly
931
        if its type is one of the mapping element types) or list of
932
        filterers.
933
    allow_non_siblings
934
        Whether to allow non-siblings to be also passed through.
935

936
    Returns
937
    -------
938
    :class:`dict`
939
        Filtered mapping.
940

941
    Notes
942
    -----
943
    -   If the mapping passed is a :class:`colour.utilities.CanonicalMapping`
944
        class instance, then the lower, slugified and canonical keys are
945
        also used for matching.
946
    """
947

948
    if isinstance(filterers, str) or not isinstance(filterers, (list, tuple)):
949
        filterers = [filterers]
950

951
    string_filterers: List[str] = [
952
        filterer for filterer in filterers if isinstance(filterer, str)
953
    ]
954

955
    object_filterers: List[Any] = [
956
        filterer for filterer in filterers if is_sibling(filterer, mapping)
957
    ]
958

959
    if allow_non_siblings:
960
        non_siblings = [
961
            filterer
962
            for filterer in filterers
963
            if filterer not in string_filterers and filterer not in object_filterers
964
        ]
965

966
        if non_siblings:
967
            runtime_warning(
968
                f'Non-sibling elements are passed-through: "{non_siblings}"'
969
            )
970

971
            object_filterers.extend(non_siblings)
972

973
    filtered_mapping = filter_mapping(mapping, string_filterers)
974

975
    for filterer in object_filterers:
976
        # TODO: Consider using "MutableMapping" here.
977
        if isinstance(filterer, (dict, CanonicalMapping)):
978
            for key, value in filterer.items():
979
                filtered_mapping[key] = value
980
        else:
981
            try:
982
                name = filterer.name
983
            except AttributeError:
984
                try:
985
                    name = filterer.__name__
986
                except AttributeError:
987
                    name = str(id(filterer))
988

989
            filtered_mapping[name] = filterer
990

991
    return filtered_mapping
992

993

994
def filter_RGB_colourspaces(
×
995
    filterers: (
996
        RGB_Colourspace
997
        | LiteralRGBColourspace
998
        | str
999
        | Sequence[RGB_Colourspace | LiteralRGBColourspace | str]
1000
    ),
1001
    allow_non_siblings: bool = True,
1002
) -> Dict[str, RGB_Colourspace]:
1003
    """
1004
    Filter the *RGB* colourspaces matching the specified filterers.
1005

1006
    Parameters
1007
    ----------
1008
    filterers
1009
        Filterer, :class:`colour.RGB_Colourspace` class instance (which is
1010
        passed through directly if its type is one of the mapping element
1011
        types), or list of filterers. The ``filterers`` elements can also
1012
        be of any form supported by the
1013
        :func:`colour.plotting.common.filter_passthrough` definition.
1014
    allow_non_siblings
1015
        Whether to allow non-siblings to be also passed through.
1016

1017
    Returns
1018
    -------
1019
    :class:`dict`
1020
        Filtered *RGB* colourspaces.
1021
    """
1022

1023
    return filter_passthrough(RGB_COLOURSPACES, filterers, allow_non_siblings)
1024

1025

1026
def filter_cmfs(
×
1027
    filterers: (
1028
        MultiSpectralDistributions | str | Sequence[MultiSpectralDistributions | str]
1029
    ),
1030
    allow_non_siblings: bool = True,
1031
) -> Dict[str, MultiSpectralDistributions]:
1032
    """
1033
    Filter the colour matching functions matching the specified filterers.
1034

1035
    Parameters
1036
    ----------
1037
    filterers
1038
        Filterer or :class:`colour.LMS_ConeFundamentals`,
1039
        :class:`colour.RGB_ColourMatchingFunctions` or
1040
        :class:`colour.XYZ_ColourMatchingFunctions` class instance (which is
1041
        passed through directly if its type is one of the mapping element
1042
        types) or list of filterers. ``filterers`` elements can also be of
1043
        any form supported by the
1044
        :func:`colour.plotting.common.filter_passthrough` definition.
1045
    allow_non_siblings
1046
        Whether to allow non-siblings to be also passed through.
1047

1048
    Returns
1049
    -------
1050
    :class:`dict`
1051
        Filtered colour matching functions.
1052
    """
1053

1054
    return filter_passthrough(MSDS_CMFS, filterers, allow_non_siblings)
1055

1056

1057
def filter_illuminants(
×
1058
    filterers: SpectralDistribution | str | Sequence[SpectralDistribution | str],
1059
    allow_non_siblings: bool = True,
1060
) -> Dict[str, SpectralDistribution]:
1061
    """
1062
    Filter the illuminants matching the specified filterers.
1063

1064
    Parameters
1065
    ----------
1066
    filterers
1067
        Filterer or :class:`colour.SpectralDistribution` class instance
1068
        (which is passed through directly if its type is one of the
1069
        mapping element types) or list of filterers. ``filterers``
1070
        elements can also be of any form supported by the
1071
        :func:`colour.plotting.common.filter_passthrough` definition.
1072
    allow_non_siblings
1073
        Whether to allow non-siblings to be also passed through.
1074

1075
    Returns
1076
    -------
1077
    :class:`dict`
1078
        Filtered illuminants.
1079
    """
1080

1081
    illuminants = {}
1✔
1082

1083
    illuminants.update(
1084
        filter_passthrough(SDS_ILLUMINANTS, filterers, allow_non_siblings)
1085
    )
1086

1087
    illuminants.update(
1088
        filter_passthrough(SDS_LIGHT_SOURCES, filterers, allow_non_siblings)
1089
    )
1090

1091
    return illuminants
1✔
1092

1093

1094
def filter_colour_checkers(
×
1095
    filterers: ColourChecker | str | Sequence[ColourChecker | str],
1096
    allow_non_siblings: bool = True,
1097
) -> Dict[str, ColourChecker]:
1098
    """
1099
    Filter the colour checkers matching the specified filterers.
1100

1101
    Parameters
1102
    ----------
1103
    filterers
1104
        Filterer or :class:`colour.characterisation.ColourChecker` class
1105
        instance (which is passed through directly if its type is one of
1106
        the mapping element types) or list of filterers. ``filterers``
1107
        elements can also be of any form supported by the
1108
        :func:`colour.plotting.common.filter_passthrough` definition.
1109
    allow_non_siblings
1110
        Whether to allow non-siblings to be also passed through.
1111

1112
    Returns
1113
    -------
1114
    :class:`dict`
1115
        Filtered colour checkers.
1116
    """
1117

1118
    return filter_passthrough(CCS_COLOURCHECKERS, filterers, allow_non_siblings)
1119

1120

1121
def update_settings_collection(
×
1122
    settings_collection: dict | List[dict],
1123
    keyword_arguments: dict | List[dict],
1124
    expected_count: int,
1125
) -> None:
1126
    """
1127
    Update the specified settings collection *in-place* with the specified
1128
    keyword arguments and expected count of settings collection elements.
1129

1130
    Parameters
1131
    ----------
1132
    settings_collection
1133
        Settings collection to update.
1134
    keyword_arguments
1135
        Keyword arguments to update the settings collection.
1136
    expected_count
1137
        Expected count of settings collection elements.
1138

1139
    Examples
1140
    --------
1141
    >>> settings_collection = [{1: 2}, {3: 4}]
1142
    >>> keyword_arguments = {5: 6}
1143
    >>> update_settings_collection(settings_collection, keyword_arguments, 2)
1144
    >>> print(settings_collection)
1145
    [{1: 2, 5: 6}, {3: 4, 5: 6}]
1146
    >>> settings_collection = [{1: 2}, {3: 4}]
1147
    >>> keyword_arguments = [{5: 6}, {7: 8}]
1148
    >>> update_settings_collection(settings_collection, keyword_arguments, 2)
1149
    >>> print(settings_collection)
1150
    [{1: 2, 5: 6}, {3: 4, 7: 8}]
1151
    """
1152

1153
    if not isinstance(keyword_arguments, dict):
1✔
1154
        attest(
1✔
1155
            len(keyword_arguments) == expected_count,
1156
            "Multiple keyword arguments defined, but they do not "
1157
            "match the expected count!",
1158
        )
1159

1160
    for i, settings in enumerate(settings_collection):
1✔
1161
        if isinstance(keyword_arguments, dict):
1✔
1162
            settings.update(keyword_arguments)
1✔
1163
        else:
1164
            settings.update(keyword_arguments[i])
1✔
1165

1166

1167
@override_style(
×
1168
    **{
1169
        "axes.grid": False,
1170
        "xtick.bottom": False,
1171
        "ytick.left": False,
1172
        "xtick.labelbottom": False,
1173
        "ytick.labelleft": False,
1174
    }
1175
)
1176
def plot_single_colour_swatch(
×
1177
    colour_swatch: ArrayLike | ColourSwatch, **kwargs: Any
1178
) -> Tuple[Figure, Axes]:
1179
    """
1180
    Plot a single colour swatch.
1181

1182
    Parameters
1183
    ----------
1184
    colour_swatch
1185
        Colour swatch to plot, either a regular `ArrayLike` or a
1186
        :class:`colour.plotting.ColourSwatch` class instance.
1187

1188
    Other Parameters
1189
    ----------------
1190
    kwargs
1191
        {:func:`colour.plotting.artist`,
1192
        :func:`colour.plotting.plot_multi_colour_swatches`,
1193
        :func:`colour.plotting.render`},
1194
        See the documentation of the previously listed definitions.
1195

1196
    Returns
1197
    -------
1198
    :class:`tuple`
1199
        Current figure and axes.
1200

1201
    Examples
1202
    --------
1203
    >>> RGB = ColourSwatch((0.45620519, 0.03081071, 0.04091952))
1204
    >>> plot_single_colour_swatch(RGB)  # doctest: +ELLIPSIS
1205
    (<Figure size ... with 1 Axes>, <...Axes...>)
1206

1207
    .. image:: ../_static/Plotting_Plot_Single_Colour_Swatch.png
1208
        :align: center
1209
        :alt: plot_single_colour_swatch
1210
    """
1211

1212
    return plot_multi_colour_swatches((colour_swatch,), **kwargs)
1✔
1213

1214

1215
@override_style(
×
1216
    **{
1217
        "axes.grid": False,
1218
        "xtick.bottom": False,
1219
        "ytick.left": False,
1220
        "xtick.labelbottom": False,
1221
        "ytick.labelleft": False,
1222
    }
1223
)
1224
def plot_multi_colour_swatches(
×
1225
    colour_swatches: ArrayLike | Sequence[ArrayLike | ColourSwatch],
1226
    width: float = 1,
1227
    height: float = 1,
1228
    spacing: float = 0,
1229
    columns: int | None = None,
1230
    direction: Literal["+y", "-y"] | str = "+y",
1231
    text_kwargs: dict | None = None,
1232
    background_colour: ArrayLike = (1.0, 1.0, 1.0),
1233
    compare_swatches: Literal["Diagonal", "Stacked"] | str | None = None,
1234
    **kwargs: Any,
1235
) -> Tuple[Figure, Axes]:
1236
    """
1237
    Plot colour swatches with configurable layout and comparison options.
1238

1239
    Parameters
1240
    ----------
1241
    colour_swatches
1242
        Colour swatch sequence, either a regular `ArrayLike` or a sequence
1243
        of :class:`colour.plotting.ColourSwatch` class instances.
1244
    width
1245
        Colour swatch width.
1246
    height
1247
        Colour swatch height.
1248
    spacing
1249
        Colour swatches spacing.
1250
    columns
1251
        Colour swatches columns count, defaults to the colour swatch count
1252
        or half of it if comparing.
1253
    direction
1254
        Row stacking direction.
1255
    text_kwargs
1256
        Keyword arguments for the :func:`matplotlib.pyplot.text`
1257
        definition. The following special keywords can also be used:
1258

1259
        -   ``offset``: Sets the text offset.
1260
        -   ``visible``: Sets the text visibility.
1261
    background_colour
1262
        Background colour.
1263
    compare_swatches
1264
        Whether to compare the swatches, in which case the colour swatch
1265
        count must be an even number with alternating reference colour
1266
        swatches and test colour swatches. *Stacked* will draw the test
1267
        colour swatch in the center of the reference colour swatch,
1268
        *Diagonal* will draw the reference colour swatch in the upper left
1269
        diagonal area and the test colour swatch in the bottom right
1270
        diagonal area.
1271

1272
    Other Parameters
1273
    ----------------
1274
    kwargs
1275
        {:func:`colour.plotting.artist`,
1276
        :func:`colour.plotting.render`},
1277
        See the documentation of the previously listed definitions.
1278

1279
    Returns
1280
    -------
1281
    :class:`tuple`
1282
        Current figure and axes.
1283

1284
    Examples
1285
    --------
1286
    >>> RGB_1 = ColourSwatch((0.45293517, 0.31732158, 0.26414773))
1287
    >>> RGB_2 = ColourSwatch((0.77875824, 0.57726450, 0.50453169))
1288
    >>> plot_multi_colour_swatches([RGB_1, RGB_2])  # doctest: +ELLIPSIS
1289
    (<Figure size ... with 1 Axes>, <...Axes...>)
1290

1291
    .. image:: ../_static/Plotting_Plot_Multi_Colour_Swatches.png
1292
        :align: center
1293
        :alt: plot_multi_colour_swatches
1294
    """
1295

1296
    direction = validate_method(
1✔
1297
        direction,
1298
        ("+y", "-y"),
1299
        '"{0}" direction is invalid, it must be one of {1}!',
1300
    )
1301

1302
    if compare_swatches is not None:
1✔
1303
        compare_swatches = validate_method(
1✔
1304
            compare_swatches,
1305
            ("Diagonal", "Stacked"),
1306
            '"{0}" compare swatches method is invalid, it must be one of {1}!',
1307
        )
1308

1309
    _figure, axes = artist(**kwargs)
1✔
1310

1311
    # Handling case where `colour_swatches` is a regular *ArrayLike*.
1312
    colour_swatches = list(colour_swatches)  # pyright: ignore
1✔
1313
    colour_swatches_converted = []
1✔
1314
    if not isinstance(first_item(colour_swatches), ColourSwatch):
1✔
1315
        for _i, colour_swatch in enumerate(
1✔
1316
            np.reshape(
1317
                as_float_array(cast("ArrayLike", colour_swatches))[..., :3], (-1, 3)
1318
            )
1319
        ):
1320
            colour_swatches_converted.append(ColourSwatch(colour_swatch))
1✔
1321
    else:
1322
        colour_swatches_converted = cast("List[ColourSwatch]", colour_swatches)
1✔
1323

1324
    colour_swatches = colour_swatches_converted
1✔
1325

1326
    if compare_swatches is not None:
1✔
1327
        attest(
1✔
1328
            len(colour_swatches) % 2 == 0,
1329
            "Cannot compare an odd number of colour swatches!",
1330
        )
1331

1332
        colour_swatches_reference = colour_swatches[0::2]
1✔
1333
        colour_swatches_test = colour_swatches[1::2]
1✔
1334
    else:
1335
        colour_swatches_reference = colour_swatches_test = colour_swatches
1✔
1336

1337
    columns = optional(columns, len(colour_swatches_reference))
1✔
1338

1339
    text_settings = {
1✔
1340
        "offset": 0.05,
1341
        "visible": True,
1342
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1343
    }
1344
    if text_kwargs is not None:
1✔
1345
        text_settings.update(text_kwargs)
1✔
1346
    text_offset = text_settings.pop("offset")
1✔
1347

1348
    offset_X: float = 0
1✔
1349
    offset_Y: float = 0
1✔
1350
    x_min, x_max, y_min, y_max = 0, width, 0, height
1✔
1351
    y = 1 if direction == "+y" else -1
1✔
1352
    for i, colour_swatch in enumerate(colour_swatches_reference):
1✔
1353
        if i % columns == 0 and i != 0:
1✔
1354
            offset_X = 0
1✔
1355
            offset_Y += (height + spacing) * y
1✔
1356

1357
        x_0, x_1 = offset_X, offset_X + width
1✔
1358
        y_0, y_1 = offset_Y, offset_Y + height * y
1✔
1359

1360
        axes.fill(
1✔
1361
            (x_0, x_1, x_1, x_0),
1362
            (y_0, y_0, y_1, y_1),
1363
            color=np.clip(colour_swatch.RGB, 0, 1),
1364
            zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1365
        )
1366

1367
        if compare_swatches == "stacked":
1✔
1368
            margin_X = width * 0.25
1✔
1369
            margin_Y = height * 0.25
1✔
1370
            axes.fill(
1✔
1371
                (
1372
                    x_0 + margin_X,
1373
                    x_1 - margin_X,
1374
                    x_1 - margin_X,
1375
                    x_0 + margin_X,
1376
                ),
1377
                (
1378
                    y_0 + margin_Y * y,
1379
                    y_0 + margin_Y * y,
1380
                    y_1 - margin_Y * y,
1381
                    y_1 - margin_Y * y,
1382
                ),
1383
                color=np.clip(colour_swatches_test[i].RGB, 0, 1),
1384
                zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1385
            )
1386
        else:
1387
            axes.fill(
1✔
1388
                (x_0, x_1, x_1),
1389
                (y_0, y_0, y_1),
1390
                color=np.clip(colour_swatches_test[i].RGB, 0, 1),
1391
                zorder=CONSTANTS_COLOUR_STYLE.zorder.midground_polygon,
1392
            )
1393

1394
        if colour_swatch.name is not None and text_settings["visible"]:
1✔
1395
            axes.text(
1✔
1396
                x_0 + text_offset,
1397
                y_0 + text_offset * y,
1398
                colour_swatch.name,
1399
                verticalalignment="bottom" if y == 1 else "top",
1400
                clip_on=True,
1401
                **text_settings,
1402
            )
1403

1404
        offset_X += width + spacing
1✔
1405

1406
    x_max = min(len(colour_swatches), as_int_scalar(columns))
1✔
1407
    x_max = x_max * width + x_max * spacing - spacing
1✔
1408
    y_max = offset_Y
1✔
1409

1410
    axes.patch.set_facecolor(background_colour)  # pyright: ignore
1✔
1411

1412
    if y == 1:
1✔
1413
        bounding_box = [
1✔
1414
            x_min - spacing,
1415
            x_max + spacing,
1416
            y_min - spacing,
1417
            y_max + spacing + height,
1418
        ]
1419
    else:
1420
        bounding_box = [
1✔
1421
            x_min - spacing,
1422
            x_max + spacing,
1423
            y_max - spacing - height,
1424
            y_min + spacing,
1425
        ]
1426

1427
    settings: Dict[str, Any] = {
1✔
1428
        "axes": axes,
1429
        "bounding_box": bounding_box,
1430
        "aspect": "equal",
1431
    }
1432
    settings.update(kwargs)
1✔
1433

1434
    return render(**settings)
1✔
1435

1436

1437
@override_style()
×
1438
def plot_single_function(
×
1439
    function: Callable,
1440
    samples: ArrayLike | None = None,
1441
    log_x: int | None = None,
1442
    log_y: int | None = None,
1443
    plot_kwargs: dict | List[dict] | None = None,
1444
    **kwargs: Any,
1445
) -> Tuple[Figure, Axes]:
1446
    """
1447
    Plot the specified function.
1448

1449
    Parameters
1450
    ----------
1451
    function
1452
        Function to plot.
1453
    samples
1454
        Samples to evaluate the functions with.
1455
    log_x
1456
        Log base to use for the *x* axis scale, if *None*, the *x* axis
1457
        scale will be linear.
1458
    log_y
1459
        Log base to use for the *y* axis scale, if *None*, the *y* axis
1460
        scale will be linear.
1461
    plot_kwargs
1462
        Keyword arguments for the :func:`matplotlib.pyplot.plot`
1463
        definition, used to control the style of the plotted function.
1464

1465
    Other Parameters
1466
    ----------------
1467
    kwargs
1468
        {:func:`colour.plotting.artist`,
1469
        :func:`colour.plotting.plot_multi_functions`,
1470
        :func:`colour.plotting.render`},
1471
        See the documentation of the previously listed definitions.
1472

1473
    Returns
1474
    -------
1475
    :class:`tuple`
1476
        Current figure and axes.
1477

1478
    Examples
1479
    --------
1480
    >>> from colour.models import gamma_function
1481
    >>> plot_single_function(partial(gamma_function, exponent=1 / 2.2))
1482
    ... # doctest: +ELLIPSIS
1483
    (<Figure size ... with 1 Axes>, <...Axes...>)
1484

1485
    .. image:: ../_static/Plotting_Plot_Single_Function.png
1486
        :align: center
1487
        :alt: plot_single_function
1488
    """
1489

1490
    try:
1✔
1491
        name = function.__name__
1✔
1492
    except AttributeError:
1✔
1493
        name = "Unnamed"
1✔
1494

1495
    settings: Dict[str, Any] = {
1✔
1496
        "title": f"{name} - Function",
1497
        "legend": False,
1498
    }
1499
    settings.update(kwargs)
1✔
1500

1501
    return plot_multi_functions(
1✔
1502
        {name: function}, samples, log_x, log_y, plot_kwargs, **settings
1503
    )
1504

1505

1506
@override_style()
×
1507
def plot_multi_functions(
×
1508
    functions: Dict[str, Callable],
1509
    samples: ArrayLike | None = None,
1510
    log_x: int | None = None,
1511
    log_y: int | None = None,
1512
    plot_kwargs: dict | List[dict] | None = None,
1513
    **kwargs: Any,
1514
) -> Tuple[Figure, Axes]:
1515
    """
1516
    Plot specified functions.
1517

1518
    Parameters
1519
    ----------
1520
    functions
1521
        Functions to plot.
1522
    samples
1523
        Samples to evaluate the functions with.
1524
    log_x
1525
        Log base to use for the *x* axis scale, if *None*, the *x* axis
1526
        scale will be linear.
1527
    log_y
1528
        Log base to use for the *y* axis scale, if *None*, the *y* axis
1529
        scale will be linear.
1530
    plot_kwargs
1531
        Keyword arguments for the :func:`matplotlib.pyplot.plot`
1532
        definition, used to control the style of the plotted functions.
1533
        ``plot_kwargs`` can be either a single dictionary applied to all
1534
        the plotted functions with the same settings or a sequence of
1535
        dictionaries with different settings for each plotted function.
1536

1537
    Other Parameters
1538
    ----------------
1539
    kwargs
1540
        {:func:`colour.plotting.artist`,
1541
        :func:`colour.plotting.render`},
1542
        See the documentation of the previously listed definitions.
1543

1544
    Returns
1545
    -------
1546
    :class:`tuple`
1547
        Current figure and axes.
1548

1549
    Examples
1550
    --------
1551
    >>> functions = {
1552
    ...     "Gamma 2.2": lambda x: x ** (1 / 2.2),
1553
    ...     "Gamma 2.4": lambda x: x ** (1 / 2.4),
1554
    ...     "Gamma 2.6": lambda x: x ** (1 / 2.6),
1555
    ... }
1556
    >>> plot_multi_functions(functions)
1557
    ... # doctest: +ELLIPSIS
1558
    (<Figure size ... with 1 Axes>, <...Axes...>)
1559

1560
    .. image:: ../_static/Plotting_Plot_Multi_Functions.png
1561
        :align: center
1562
        :alt: plot_multi_functions
1563
    """
1564

1565
    settings: Dict[str, Any] = dict(kwargs)
1✔
1566

1567
    _figure, axes = artist(**settings)
1✔
1568

1569
    plot_settings_collection = [
1✔
1570
        {
1571
            "label": f"{name}",
1572
            "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1573
        }
1574
        for name in functions
1575
    ]
1576

1577
    if plot_kwargs is not None:
1✔
1578
        update_settings_collection(
1✔
1579
            plot_settings_collection, plot_kwargs, len(functions)
1580
        )
1581

1582
    if log_x is not None and log_y is not None:
1✔
1583
        attest(
1✔
1584
            log_x >= 2 and log_y >= 2,
1585
            "Log base must be equal or greater than 2.",
1586
        )
1587

1588
        plotting_function = axes.loglog
1✔
1589

1590
        axes.set_xscale("log", base=log_x)
1✔
1591
        axes.set_yscale("log", base=log_y)
1✔
1592
    elif log_x is not None:
1✔
1593
        attest(log_x >= 2, "Log base must be equal or greater than 2.")
1✔
1594

1595
        plotting_function = partial(axes.semilogx, base=log_x)
1✔
1596
    elif log_y is not None:
1✔
1597
        attest(log_y >= 2, "Log base must be equal or greater than 2.")
1✔
1598

1599
        plotting_function = partial(axes.semilogy, base=log_y)
1✔
1600
    else:
1601
        plotting_function = axes.plot
1✔
1602

1603
    samples = optional(samples, np.linspace(0, 1, 1000))
1✔
1604

1605
    for i, (_name, function) in enumerate(functions.items()):
1✔
1606
        plotting_function(samples, function(samples), **plot_settings_collection[i])
1✔
1607

1608
    x_label = f"x - Log Base {log_x} Scale" if log_x is not None else "x - Linear Scale"
1✔
1609
    y_label = f"y - Log Base {log_y} Scale" if log_y is not None else "y - Linear Scale"
1✔
1610
    settings = {
1✔
1611
        "axes": axes,
1612
        "legend": True,
1613
        "title": f"{', '.join(functions)} - Functions",
1614
        "x_label": x_label,
1615
        "y_label": y_label,
1616
    }
1617
    settings.update(kwargs)
1✔
1618

1619
    return render(**settings)
1✔
1620

1621

1622
@override_style()
×
1623
def plot_image(
×
1624
    image: ArrayLike,
1625
    imshow_kwargs: dict | None = None,
1626
    text_kwargs: dict | None = None,
1627
    **kwargs: Any,
1628
) -> Tuple[Figure, Axes]:
1629
    """
1630
    Plot the specified image using matplotlib.
1631

1632
    Parameters
1633
    ----------
1634
    image
1635
        Image array to plot, typically as RGB or grayscale data.
1636
    imshow_kwargs
1637
        Keyword arguments for the :func:`matplotlib.pyplot.imshow`
1638
        definition, controlling image display properties.
1639
    text_kwargs
1640
        Keyword arguments for the :func:`matplotlib.pyplot.text`
1641
        definition, controlling text overlay properties. The following
1642
        special keyword arguments can also be used:
1643

1644
        -   ``offset`` : Sets the text offset position.
1645

1646
    Other Parameters
1647
    ----------------
1648
    kwargs
1649
        {:func:`colour.plotting.artist`,
1650
        :func:`colour.plotting.render`}, See the documentation of the
1651
        previously listed definitions for additional plotting controls.
1652

1653
    Returns
1654
    -------
1655
    :class:`tuple`
1656
        Current figure and axes objects from matplotlib.
1657

1658
    Examples
1659
    --------
1660
    >>> import os
1661
    >>> import colour
1662
    >>> from colour import read_image
1663
    >>> path = os.path.join(
1664
    ...     colour.__path__[0],
1665
    ...     "examples",
1666
    ...     "plotting",
1667
    ...     "resources",
1668
    ...     "Ishihara_Colour_Blindness_Test_Plate_3.png",
1669
    ... )
1670
    >>> plot_image(read_image(path))  # doctest: +ELLIPSIS
1671
    (<Figure size ... with 1 Axes>, <...Axes...>)
1672

1673
    .. image:: ../_static/Plotting_Plot_Image.png
1674
        :align: center
1675
        :alt: plot_image
1676
    """
1677

1678
    _figure, axes = artist(**kwargs)
1✔
1679

1680
    imshow_settings = {
1✔
1681
        "interpolation": "nearest",
1682
        "cmap": matplotlib.colormaps["Greys_r"],
1683
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.background_polygon,
1684
    }
1685
    if imshow_kwargs is not None:
1✔
1686
        imshow_settings.update(imshow_kwargs)
1✔
1687

1688
    text_settings = {
1✔
1689
        "text": None,
1690
        "offset": 0.005,
1691
        "color": CONSTANTS_COLOUR_STYLE.colour.brightest,
1692
        "alpha": CONSTANTS_COLOUR_STYLE.opacity.high,
1693
        "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1694
    }
1695
    if text_kwargs is not None:
1✔
1696
        text_settings.update(text_kwargs)
1✔
1697
    text_offset = text_settings.pop("offset")
1✔
1698

1699
    image = as_float_array(image)
1✔
1700

1701
    axes.imshow(np.clip(image, 0, 1), **imshow_settings)
1✔
1702

1703
    if text_settings["text"] is not None:
1✔
1704
        text = text_settings.pop("text")
1✔
1705

1706
        axes.text(
1✔
1707
            text_offset,
1708
            text_offset,
1709
            text,
1710
            transform=axes.transAxes,
1711
            ha="left",
1712
            va="bottom",
1713
            **text_settings,
1714
        )
1715

1716
    settings: Dict[str, Any] = {
1✔
1717
        "axes": axes,
1718
        "axes_visible": False,
1719
    }
1720
    settings.update(kwargs)
1✔
1721

1722
    return render(**settings)
1✔
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