Coverage for plotting/common.py: 73%
348 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Common Plotting
3===============
5Define the common plotting objects.
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"""
28from __future__ import annotations
30import contextlib
31import functools
32import itertools
33import typing
34from contextlib import contextmanager
35from dataclasses import dataclass, field
36from functools import partial
38import matplotlib.cm
39import matplotlib.font_manager
40import matplotlib.pyplot as plt
41import matplotlib.ticker
42import numpy as np
43from cycler import cycler
44from matplotlib.colors import LinearSegmentedColormap
45from matplotlib.figure import Figure, SubFigure
47if typing.TYPE_CHECKING:
48 from matplotlib.axes import Axes
49 from matplotlib.patches import Patch
50 from mpl_toolkits.mplot3d.axes3d import Axes3D
52from colour.characterisation import CCS_COLOURCHECKERS, ColourChecker
53from colour.colorimetry import (
54 MSDS_CMFS,
55 SDS_ILLUMINANTS,
56 SDS_LIGHT_SOURCES,
57 MultiSpectralDistributions,
58 SpectralDistribution,
59)
61if 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 )
80from colour.hints import ArrayLike, List, TypedDict, cast
81from colour.models import RGB_COLOURSPACES, RGB_Colourspace, XYZ_to_RGB
82from 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)
95from colour.utilities.deprecation import handle_arguments_deprecation
97__author__ = "Colour Developers"
98__copyright__ = "Copyright 2013 Colour Developers"
99__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
100__maintainer__ = "Colour Developers"
101__email__ = "colour-developers@colour-science.org"
102__status__ = "Production"
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]
135CONSTANTS_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)
222"""Various defaults settings used across the plotting sub-package."""
224# NOTE: Adding our font scaling items so that they can be tweaked without
225# affecting *Matplotplib* ones.
226for _scaling, _value in CONSTANTS_COLOUR_STYLE.font.scaling.items():
227 matplotlib.font_manager.font_scalings[
228 f"{_scaling.replace('_', '-')}-colour-science"
229 ] = _value
231del _scaling, _value
233CONSTANTS_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)
241"""Annotation arrow settings used across the plotting sub-package."""
244def colour_style(use_style: bool = True) -> dict:
245 """
246 Return the *Colour* plotting style configuration.
248 Parameters
249 ----------
250 use_style
251 Whether to apply the style configuration to *Matplotlib*.
253 Returns
254 -------
255 :class:`dict`
256 *Colour* plotting style configuration dictionary.
257 """
259 constants = CONSTANTS_COLOUR_STYLE
260 style = {
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 }
321 if use_style:
322 plt.rcParams.update(style)
324 return style
327def override_style(**kwargs: Any) -> Callable:
328 """
329 Decorate a function to override *Matplotlib* style.
331 Other Parameters
332 ----------------
333 kwargs
334 Keywords arguments for *Matplotlib* style configuration.
336 Returns
337 -------
338 Callable
339 Decorated function with overridden *Matplotlib* style.
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 """
350 keywords = dict(kwargs)
352 def wrapper(function: Callable) -> Callable:
353 """Wrap specified function wrapper."""
355 @functools.wraps(function)
356 def wrapped(*args: Any, **kwargs: Any) -> Any:
357 """Wrap specified function."""
359 keywords.update(kwargs)
361 style_overrides = {
362 key: value for key, value in keywords.items() if key in plt.rcParams
363 }
365 with plt.style.context(style_overrides):
366 return function(*args, **kwargs)
368 return wrapped
370 return wrapper
373@contextmanager
374def font_scaling(scaling: LiteralFontScaling, value: float) -> Generator:
375 """
376 Set a temporary *Matplotlib* font scaling using a context manager.
378 Parameters
379 ----------
380 scaling
381 Font scaling to temporarily set.
382 value
383 Value to temporarily set the font scaling with.
385 Yields
386 ------
387 Generator.
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 """
398 current_value = matplotlib.font_manager.font_scalings[scaling]
400 matplotlib.font_manager.font_scalings[scaling] = value
402 yield
404 matplotlib.font_manager.font_scalings[scaling] = current_value
407def 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.
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.
432 Returns
433 -------
434 :class:`numpy.ndarray`
435 Default plotting colourspace colour array.
437 Notes
438 -----
439 +------------+-----------------------+---------------+
440 | **Domain** | **Scale - Reference** | **Scale - 1** |
441 +============+=======================+===============+
442 | ``XYZ`` | 1 | 1 |
443 +------------+-----------------------+---------------+
445 +------------+-----------------------+---------------+
446 | **Range** | **Scale - Reference** | **Scale - 1** |
447 +============+=======================+===============+
448 | ``RGB`` | 1 | 1 |
449 +------------+-----------------------+---------------+
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 """
459 return XYZ_to_RGB(
460 XYZ,
461 CONSTANTS_COLOUR_STYLE.colour.colourspace,
462 illuminant,
463 chromatic_adaptation_transform,
464 apply_cctf_encoding,
465 )
468@dataclass
469class ColourSwatch:
470 """
471 Define a data structure for a colour swatch.
473 Parameters
474 ----------
475 RGB
476 RGB colour values representing the swatch.
477 name
478 Name identifier for the colour swatch.
479 """
481 RGB: ArrayLike
482 name: str | None = field(default_factory=lambda: None)
485def colour_cycle(**kwargs: Any) -> itertools.cycle:
486 """
487 Create a colour cycle iterator using the specified colour map.
489 Other Parameters
490 ----------------
491 colour_cycle_map
492 Matplotlib colourmap name.
493 colour_cycle_count
494 Colours count to pick in the colourmap.
496 Returns
497 -------
498 :class:`itertools.cycle`
499 Colour cycle iterator.
500 """
502 settings = Structure(
503 colour_cycle_map=CONSTANTS_COLOUR_STYLE.colour.map,
504 colour_cycle_count=len(CONSTANTS_COLOUR_STYLE.colour.cycle),
505 )
506 settings.update(kwargs)
508 samples = np.linspace(0, 1, settings.colour_cycle_count)
509 if isinstance(settings.colour_cycle_map, LinearSegmentedColormap):
510 cycle = settings.colour_cycle_map(samples)
511 else:
512 cycle = getattr(plt.cm, settings.colour_cycle_map)(samples)
514 return itertools.cycle(cycle)
517class KwargsArtist(TypedDict):
518 """
519 Define keyword argument types for the :func:`colour.plotting.artist`
520 definition.
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 """
530 axes: Axes
531 uniform: bool
534def artist(**kwargs: KwargsArtist | Any) -> Tuple[Figure, Axes]:
535 """
536 Return the current figure and its axes or create a new one.
538 Other Parameters
539 ----------------
540 kwargs
541 {:func:`colour.plotting.common.KwargsArtist`},
542 See the documentation of the previously listed class.
544 Returns
545 -------
546 :class:`tuple`
547 Current figure and axes.
548 """
550 width, height = plt.rcParams["figure.figsize"]
552 figure_size = (width, width) if kwargs.get("uniform") else (width, height)
554 axes = kwargs.get("axes")
555 if axes is None:
556 figure = plt.figure(figsize=figure_size)
558 return figure, figure.gca()
560 axes = cast("Axes", axes)
561 figure = axes.figure
563 if isinstance(figure, SubFigure):
564 figure = figure.get_figure()
566 return cast("Figure", figure), axes
569class KwargsCamera(TypedDict):
570 """
571 Define the keyword argument types for the
572 :func:`colour.plotting.camera` definition.
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 """
588 figure: Figure
589 axes: Axes
590 azimuth: float | None
591 elevation: float | None
592 camera_aspect: Literal["equal"] | str
595def camera(**kwargs: KwargsCamera | Any) -> Tuple[Figure, Axes3D]:
596 """
597 Configure camera settings for the current 3D visualization.
599 Other Parameters
600 ----------------
601 kwargs
602 {:func:`colour.plotting.common.KwargsCamera`},
603 See the documentation of the previously listed class.
605 Returns
606 -------
607 :class:`tuple`
608 Current figure and axes.
609 """
611 figure = cast("Figure", kwargs.get("figure", plt.gcf()))
612 axes = cast("Axes3D", kwargs.get("axes", plt.gca()))
614 settings = Structure(camera_aspect="equal", elevation=None, azimuth=None)
615 settings.update(kwargs)
617 if settings.camera_aspect == "equal":
618 uniform_axes3d(axes=axes)
620 axes.view_init(elev=settings.elevation, azim=settings.azimuth)
622 return figure, axes
625class KwargsRender(TypedDict):
626 """
627 Define the keyword argument types for the
628 :func:`colour.plotting.render` definition.
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 """
679 figure: Figure
680 axes: Axes
681 filename: str | PathLike
682 show: bool
683 block: bool
684 aspect: Literal["auto", "equal"] | float
685 axes_visible: bool
686 bounding_box: ArrayLike
687 tight_layout: bool
688 legend: bool
689 legend_columns: int
690 transparent_background: bool
691 title: str
692 wrap_title: bool
693 x_label: str
694 y_label: str
695 x_ticker: bool
696 y_ticker: bool
699def 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.
706 Other Parameters
707 ----------------
708 kwargs
709 {:func:`colour.plotting.common.KwargsRender`},
710 See the documentation of the previously listed class.
712 Returns
713 -------
714 :class:`tuple`
715 Current figure and axes.
716 """
718 figure = cast("Figure", kwargs.get("figure", plt.gcf()))
719 axes = cast("Axes", kwargs.get("axes", plt.gca()))
721 kwargs = handle_arguments_deprecation(
722 {
723 "ArgumentRenamed": [["standalone", "show"]],
724 },
725 **kwargs,
726 )
728 settings = Structure(
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)
748 if settings.aspect:
749 axes.set_aspect(settings.aspect)
750 if not settings.axes_visible:
751 axes.set_axis_off()
752 if settings.bounding_box:
753 axes.set_xlim(settings.bounding_box[0], settings.bounding_box[1])
754 axes.set_ylim(settings.bounding_box[2], settings.bounding_box[3])
756 if settings.title:
757 axes.set_title(settings.title, wrap=settings.wrap_title)
758 if settings.x_label:
759 axes.set_xlabel(settings.x_label)
760 if settings.y_label:
761 axes.set_ylabel(settings.y_label)
762 if not settings.x_ticker:
763 axes.set_xticks([])
764 if not settings.y_ticker:
765 axes.set_yticks([])
766 if settings.legend:
767 axes.legend(ncol=settings.legend_columns)
769 if settings.tight_layout:
770 figure.tight_layout()
772 if settings.transparent_background:
773 figure.patch.set_alpha(0)
775 if settings.filename is not None:
776 figure.savefig(str(settings.filename))
778 if settings.show:
779 plt.show(block=settings.block)
781 return figure, axes
784def 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.
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.
808 Other Parameters
809 ----------------
810 figure
811 Figure to apply the render elements onto.
812 axes
813 Axes to apply the render elements onto.
815 Returns
816 -------
817 :class:`tuple`
818 Current figure and axes.
819 """
821 rotation = validate_method(
822 rotation,
823 ("horizontal", "vertical"),
824 '"{0}" rotation is invalid, it must be one of {1}!',
825 )
827 figure = kwargs.get("figure", plt.gcf())
828 axes = kwargs.get("axes", plt.gca())
830 offset = as_float_array(optional(offset, (0.0, 0.025)))
832 x_m, y_m = 0, 0
833 for rectangle in rectangles:
834 x_m = max(x_m, rectangle.get_width()) # pyright: ignore
835 y_m = max(y_m, rectangle.get_height()) # pyright: ignore
837 for i, rectangle in enumerate(rectangles):
838 x = rectangle.get_x() # pyright: ignore
839 height = rectangle.get_height() # pyright: ignore
840 width = rectangle.get_width() # pyright: ignore
841 axes.text(
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 )
853 return figure, axes
856def uniform_axes3d(**kwargs: Any) -> Tuple[Figure, Axes3D]:
857 """
858 Set equal aspect ratio to the specified 3D axes.
860 Other Parameters
861 ----------------
862 figure
863 Figure to apply the render elements onto.
864 axes
865 Axes to apply the render elements onto.
867 Returns
868 -------
869 :class:`tuple`
870 Current figure and axes.
871 """
873 figure = kwargs.get("figure", plt.gcf())
874 axes = kwargs.get("axes", plt.gca())
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")
881 extents = np.array([getattr(axes, f"get_{axis}lim")() for axis in "xyz"])
883 centers = np.mean(extents, axis=1)
884 extent = np.max(np.abs(extents[..., 1] - extents[..., 0]))
886 for center, axis in zip(centers, "xyz", strict=True):
887 getattr(axes, f"set_{axis}lim")(center - extent / 2, center + extent / 2)
889 return figure, axes
892def 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.
901 Enable passing custom but compatible objects to plotting definitions that
902 by default expect keys from dataset elements.
904 For example, a typical call to the
905 :func:`colour.plotting.plot_multi_illuminant_sds` definition is as
906 follows:
908 >>> import colour
909 >>> colour.plotting.plot_multi_illuminant_sds(["A"])
910 ... # doctest: +SKIP
912 With the previous example, it is also possible to pass a custom spectral
913 distribution as follows:
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
928 Similarly, a typical call to the
929 :func:`colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931`
930 definition is as follows:
932 >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(["A"])
933 ... # doctest: +SKIP
935 But it is also possible to pass a custom whitepoint as follows:
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
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.
953 Returns
954 -------
955 :class:`dict`
956 Filtered mapping.
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 """
965 if isinstance(filterers, str) or not isinstance(filterers, (list, tuple)):
966 filterers = [filterers]
968 string_filterers: List[str] = [
969 filterer for filterer in filterers if isinstance(filterer, str)
970 ]
972 object_filterers: List[Any] = [
973 filterer for filterer in filterers if is_sibling(filterer, mapping)
974 ]
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 ]
983 if non_siblings:
984 runtime_warning(
985 f'Non-sibling elements are passed-through: "{non_siblings}"'
986 )
988 object_filterers.extend(non_siblings)
990 filtered_mapping = filter_mapping(mapping, string_filterers)
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))
1006 filtered_mapping[name] = filterer
1008 return filtered_mapping
1011def 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.
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.
1034 Returns
1035 -------
1036 :class:`dict`
1037 Filtered *RGB* colourspaces.
1038 """
1040 return filter_passthrough(RGB_COLOURSPACES, filterers, allow_non_siblings)
1043def 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.
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.
1065 Returns
1066 -------
1067 :class:`dict`
1068 Filtered colour matching functions.
1069 """
1071 return filter_passthrough(MSDS_CMFS, filterers, allow_non_siblings)
1074def 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.
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.
1092 Returns
1093 -------
1094 :class:`dict`
1095 Filtered illuminants.
1096 """
1098 illuminants = {}
1100 illuminants.update(
1101 filter_passthrough(SDS_ILLUMINANTS, filterers, allow_non_siblings)
1102 )
1104 illuminants.update(
1105 filter_passthrough(SDS_LIGHT_SOURCES, filterers, allow_non_siblings)
1106 )
1108 return illuminants
1111def 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.
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.
1129 Returns
1130 -------
1131 :class:`dict`
1132 Filtered colour checkers.
1133 """
1135 return filter_passthrough(CCS_COLOURCHECKERS, filterers, allow_non_siblings)
1138def 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.
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.
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 """
1170 if not isinstance(keyword_arguments, dict):
1171 attest(
1172 len(keyword_arguments) == expected_count,
1173 "Multiple keyword arguments defined, but they do not "
1174 "match the expected count!",
1175 )
1177 for i, settings in enumerate(settings_collection):
1178 if isinstance(keyword_arguments, dict):
1179 settings.update(keyword_arguments)
1180 else:
1181 settings.update(keyword_arguments[i])
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)
1193def plot_single_colour_swatch(
1194 colour_swatch: ArrayLike | ColourSwatch, **kwargs: Any
1195) -> Tuple[Figure, Axes]:
1196 """
1197 Plot a single colour swatch.
1199 Parameters
1200 ----------
1201 colour_swatch
1202 Colour swatch to plot, either a regular `ArrayLike` or a
1203 :class:`colour.plotting.ColourSwatch` class instance.
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.
1213 Returns
1214 -------
1215 :class:`tuple`
1216 Current figure and axes.
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...>)
1224 .. image:: ../_static/Plotting_Plot_Single_Colour_Swatch.png
1225 :align: center
1226 :alt: plot_single_colour_swatch
1227 """
1229 return plot_multi_colour_swatches((colour_swatch,), **kwargs)
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)
1241def 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.
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:
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.
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.
1296 Returns
1297 -------
1298 :class:`tuple`
1299 Current figure and axes.
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...>)
1308 .. image:: ../_static/Plotting_Plot_Multi_Colour_Swatches.png
1309 :align: center
1310 :alt: plot_multi_colour_swatches
1311 """
1313 direction = validate_method(
1314 direction,
1315 ("+y", "-y"),
1316 '"{0}" direction is invalid, it must be one of {1}!',
1317 )
1319 if compare_swatches is not None:
1320 compare_swatches = validate_method(
1321 compare_swatches,
1322 ("Diagonal", "Stacked"),
1323 '"{0}" compare swatches method is invalid, it must be one of {1}!',
1324 )
1326 _figure, axes = artist(**kwargs)
1328 # Handling case where `colour_swatches` is a regular *ArrayLike*.
1329 colour_swatches = list(colour_swatches) # pyright: ignore
1330 colour_swatches_converted = []
1331 if not isinstance(first_item(colour_swatches), ColourSwatch):
1332 for _i, colour_swatch in enumerate(
1333 np.reshape(
1334 as_float_array(cast("ArrayLike", colour_swatches))[..., :3], (-1, 3)
1335 )
1336 ):
1337 colour_swatches_converted.append(ColourSwatch(colour_swatch))
1338 else:
1339 colour_swatches_converted = cast("List[ColourSwatch]", colour_swatches)
1341 colour_swatches = colour_swatches_converted
1343 if compare_swatches is not None:
1344 attest(
1345 len(colour_swatches) % 2 == 0,
1346 "Cannot compare an odd number of colour swatches!",
1347 )
1349 colour_swatches_reference = colour_swatches[0::2]
1350 colour_swatches_test = colour_swatches[1::2]
1351 else:
1352 colour_swatches_reference = colour_swatches_test = colour_swatches
1354 columns = optional(columns, len(colour_swatches_reference))
1356 text_settings = {
1357 "offset": 0.05,
1358 "visible": True,
1359 "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1360 }
1361 if text_kwargs is not None:
1362 text_settings.update(text_kwargs)
1363 text_offset = text_settings.pop("offset")
1365 offset_X: float = 0
1366 offset_Y: float = 0
1367 x_min, x_max, y_min, y_max = 0, width, 0, height
1368 y = 1 if direction == "+y" else -1
1369 for i, colour_swatch in enumerate(colour_swatches_reference):
1370 if i % columns == 0 and i != 0:
1371 offset_X = 0
1372 offset_Y += (height + spacing) * y
1374 x_0, x_1 = offset_X, offset_X + width
1375 y_0, y_1 = offset_Y, offset_Y + height * y
1377 axes.fill(
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 )
1384 if compare_swatches == "stacked":
1385 margin_X = width * 0.25
1386 margin_Y = height * 0.25
1387 axes.fill(
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(
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 )
1411 if colour_swatch.name is not None and text_settings["visible"]:
1412 axes.text(
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 )
1421 offset_X += width + spacing
1423 x_max = min(len(colour_swatches), as_int_scalar(columns))
1424 x_max = x_max * width + x_max * spacing - spacing
1425 y_max = offset_Y
1427 axes.patch.set_facecolor(background_colour) # pyright: ignore
1429 if y == 1:
1430 bounding_box = [
1431 x_min - spacing,
1432 x_max + spacing,
1433 y_min - spacing,
1434 y_max + spacing + height,
1435 ]
1436 else:
1437 bounding_box = [
1438 x_min - spacing,
1439 x_max + spacing,
1440 y_max - spacing - height,
1441 y_min + spacing,
1442 ]
1444 settings: Dict[str, Any] = {
1445 "axes": axes,
1446 "bounding_box": bounding_box,
1447 "aspect": "equal",
1448 }
1449 settings.update(kwargs)
1451 return render(**settings)
1454@override_style()
1455def 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.
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.
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.
1490 Returns
1491 -------
1492 :class:`tuple`
1493 Current figure and axes.
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...>)
1502 .. image:: ../_static/Plotting_Plot_Single_Function.png
1503 :align: center
1504 :alt: plot_single_function
1505 """
1507 try:
1508 name = function.__name__
1509 except AttributeError:
1510 name = "Unnamed"
1512 settings: Dict[str, Any] = {
1513 "title": f"{name} - Function",
1514 "legend": False,
1515 }
1516 settings.update(kwargs)
1518 return plot_multi_functions(
1519 {name: function}, samples, log_x, log_y, plot_kwargs, **settings
1520 )
1523@override_style()
1524def 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.
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.
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.
1561 Returns
1562 -------
1563 :class:`tuple`
1564 Current figure and axes.
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...>)
1577 .. image:: ../_static/Plotting_Plot_Multi_Functions.png
1578 :align: center
1579 :alt: plot_multi_functions
1580 """
1582 settings: Dict[str, Any] = dict(kwargs)
1584 _figure, axes = artist(**settings)
1586 plot_settings_collection = [
1587 {
1588 "label": f"{name}",
1589 "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,
1590 }
1591 for name in functions
1592 ]
1594 if plot_kwargs is not None:
1595 update_settings_collection(
1596 plot_settings_collection, plot_kwargs, len(functions)
1597 )
1599 if log_x is not None and log_y is not None:
1600 attest(
1601 log_x >= 2 and log_y >= 2,
1602 "Log base must be equal or greater than 2.",
1603 )
1605 plotting_function = axes.loglog
1607 axes.set_xscale("log", base=log_x)
1608 axes.set_yscale("log", base=log_y)
1609 elif log_x is not None:
1610 attest(log_x >= 2, "Log base must be equal or greater than 2.")
1612 plotting_function = partial(axes.semilogx, base=log_x)
1613 elif log_y is not None:
1614 attest(log_y >= 2, "Log base must be equal or greater than 2.")
1616 plotting_function = partial(axes.semilogy, base=log_y)
1617 else:
1618 plotting_function = axes.plot
1620 samples = optional(samples, np.linspace(0, 1, 1000))
1622 for i, (_name, function) in enumerate(functions.items()):
1623 plotting_function(samples, function(samples), **plot_settings_collection[i])
1625 x_label = f"x - Log Base {log_x} Scale" if log_x is not None else "x - Linear Scale"
1626 y_label = f"y - Log Base {log_y} Scale" if log_y is not None else "y - Linear Scale"
1627 settings = {
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)
1636 return render(**settings)
1639@override_style()
1640def 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.
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:
1661 - ``offset`` : Sets the text offset position.
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.
1670 Returns
1671 -------
1672 :class:`tuple`
1673 Current figure and axes objects from matplotlib.
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...>)
1690 .. image:: ../_static/Plotting_Plot_Image.png
1691 :align: center
1692 :alt: plot_image
1693 """
1695 _figure, axes = artist(**kwargs)
1697 imshow_settings = {
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:
1703 imshow_settings.update(imshow_kwargs)
1705 text_settings = {
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:
1713 text_settings.update(text_kwargs)
1714 text_offset = text_settings.pop("offset")
1716 image = as_float_array(image)
1718 axes.imshow(np.clip(image, 0, 1), **imshow_settings)
1720 if text_settings["text"] is not None:
1721 text = text_settings.pop("text")
1723 axes.text(
1724 text_offset,
1725 text_offset,
1726 text,
1727 transform=axes.transAxes,
1728 ha="left",
1729 va="bottom",
1730 **text_settings,
1731 )
1733 settings: Dict[str, Any] = {
1734 "axes": axes,
1735 "axes_visible": False,
1736 }
1737 settings.update(kwargs)
1739 return render(**settings)
1742def 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.
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.
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 """
1782 x_coords = as_float_array(x_coords)
1783 y_coords = as_float_array(y_coords)
1785 # Validate style
1786 style = validate_method(style, ("solid", "dashed"))
1788 # Draw the ray line
1789 linestyle = "-" if style == "solid" else "--"
1790 axes.plot(
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 )
1800 # Draw arrows on each segment
1801 if show_arrow:
1802 for i in range(len(x_coords) - 1):
1803 x_start, x_end = x_coords[i], x_coords[i + 1]
1804 y_start, y_end = y_coords[i], y_coords[i + 1]
1806 # Calculate midpoint
1807 mid_x = (x_start + x_end) / 2
1808 mid_y = (y_start + y_end) / 2
1810 # Calculate direction
1811 dx = x_end - x_start
1812 dy = y_end - y_start
1814 # Draw arrow at midpoint
1815 axes.annotate(
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 )
1823 # Draw dots at intermediate points (exclude first and last)
1824 if show_dots and len(x_coords) > 2:
1825 axes.plot(
1826 x_coords[1:-1],
1827 y_coords[1:-1],
1828 "ko",
1829 markersize=6,
1830 zorder=CONSTANTS_COLOUR_STYLE.zorder.foreground_scatter,
1831 )