Coverage for plotting/common.py: 73%

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 Domain1,

67 Generator,

68 Literal,

69 LiteralChromaticAdaptationTransform,

70 LiteralFontScaling,

71 LiteralRGBColourspace,

72 Mapping,

73 PathLike,

74 Range1,

75 Real,

76 Sequence,

77 Tuple,

78 )

79

80 from colour.hints import ArrayLike, List, TypedDict, cast

81 from colour.models import RGB_COLOURSPACES, RGB_Colourspace, XYZ_to_RGB

82 from colour.utilities import (

83 CanonicalMapping,

84 Structure,

85 as_float_array,

86 as_int_scalar,

87 attest,

88 filter_mapping,

89 first_item,

90 is_sibling,

91 optional,

92 runtime_warning,

93 validate_method,

94)

95 from colour.utilities.deprecation import handle_arguments_deprecation

96

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"

103

104 __all__ = [

105 "CONSTANTS_COLOUR_STYLE",

106 "CONSTANTS_ARROW_STYLE",

107 "colour_style",

108 "override_style",

109 "font_scaling",

110 "XYZ_to_plotting_colourspace",

111 "ColourSwatch",

112 "colour_cycle",

113 "KwargsArtist",

114 "artist",

115 "KwargsCamera",

116 "camera",

117 "KwargsRender",

118 "render",

119 "label_rectangles",

120 "uniform_axes3d",

121 "filter_passthrough",

122 "filter_RGB_colourspaces",

123 "filter_cmfs",

124 "filter_illuminants",

125 "filter_colour_checkers",

126 "update_settings_collection",

127 "plot_single_colour_swatch",

128 "plot_multi_colour_swatches",

129 "plot_single_function",

130 "plot_multi_functions",

131 "plot_image",

132 "plot_ray",

133]

134

135 CONSTANTS_COLOUR_STYLE: Structure = Structure(

136 colour=Structure(

137 darkest="#111111",

138 darker="#222222",

139 dark="#333333",

140 dim="#505050",

141 average="#808080",

142 light="#D5D5D5",

143 bright="#EEEEEE",

144 brighter="#F0F0F0",

145 brightest="#F5F5F5",

146 cycle=(

147 "#F44336",

148 "#9C27B0",

149 "#3F51B5",

150 "#03A9F4",

151 "#009688",

152 "#8BC34A",

153 "#FFEB3B",

154 "#FF9800",

155 "#795548",

156 "#607D8B",

157 ),

158 map=LinearSegmentedColormap.from_list(

159 "colour",

160 (

161 "#F44336",

162 "#9C27B0",

163 "#3F51B5",

164 "#03A9F4",

165 "#009688",

166 "#8BC34A",

167 "#FFEB3B",

168 "#FF9800",

169 "#795548",

170 "#607D8B",

171 ),

172 ),

173 cmap="inferno",

174 colourspace=RGB_COLOURSPACES["sRGB"],

175 ),

176 font=Structure(

177 {

178 "size": 10,

179 "scaling": Structure(

180 xx_small=0.579,

181 x_small=0.694,

182 small=0.833,

183 medium=1,

184 large=1 / 0.579,

185 x_large=1 / 0.694,

186 xx_large=1 / 0.833,

187 ),

188 }

189 ),

190 opacity=Structure(high=0.75, medium=0.5, low=0.25),

191 geometry=Structure(x_long=10, long=5, medium=2.5, short=1, x_short=0.5),

192 hatch=Structure(

193 patterns=(

194 "\\\\",

195 "o",

196 "x",

197 ".",

198 "*",

199 "//",

200 )

201 ),

202 zorder=Structure(

203 {

204 "background_polygon": -140,

205 "background_scatter": -130,

206 "background_line": -120,

207 "background_annotation": -110,

208 "background_label": -100,

209 "midground_polygon": -90,

210 "midground_scatter": -80,

211 "midground_line": -70,

212 "midground_annotation": -60,

213 "midground_label": -50,

214 "foreground_polygon": -40,

215 "foreground_scatter": -30,

216 "foreground_line": -20,

217 "foreground_annotation": -10,

218 "foreground_label": 0,

219 }

220 ),

221)

222"""Various defaults settings used across the plotting sub-package."""

223

224# NOTE: Adding our font scaling items so that they can be tweaked without

225# affecting *Matplotplib* ones.

226 for _scaling, _value in CONSTANTS_COLOUR_STYLE.font.scaling.items():

227 matplotlib.font_manager.font_scalings[

228 f"{_scaling.replace('_', '-')}-colour-science"

229 ] = _value

230

231 del _scaling, _value

232

233 CONSTANTS_ARROW_STYLE: Structure = Structure(

234 color=CONSTANTS_COLOUR_STYLE.colour.dark,

235 headwidth=CONSTANTS_COLOUR_STYLE.geometry.short * 4,

236 headlength=CONSTANTS_COLOUR_STYLE.geometry.long,

237 width=CONSTANTS_COLOUR_STYLE.geometry.short * 0.5,

238 shrink=CONSTANTS_COLOUR_STYLE.geometry.short * 0.1,

239 connectionstyle="arc3,rad=-0.2",

240)

241"""Annotation arrow settings used across the plotting sub-package."""

242

243

244 def colour_style(use_style: bool = True) -> dict:

245 """

246 Return the *Colour* plotting style configuration.

247

248 Parameters

249 ----------

250 use_style

251 Whether to apply the style configuration to *Matplotlib*.

252

253 Returns

254 -------

255 :class:`dict`

256 *Colour* plotting style configuration dictionary.

257 """

258

259 constants = CONSTANTS_COLOUR_STYLE

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 }

320

321 if use_style:

322 plt.rcParams.update(style)

323

324 return style

325

326

327 def override_style(**kwargs: Any) -> Callable:

328 """

329 Decorate a function to override *Matplotlib* style.

330

331 Other Parameters

332 ----------------

333 kwargs

334 Keywords arguments for *Matplotlib* style configuration.

335

336 Returns

337 -------

338 Callable

339 Decorated function with overridden *Matplotlib* style.

340

341 Examples

342 --------

343 >>> @override_style(**{"text.color": "red"})

344 ... def f(*args, **kwargs):

345 ... plt.text(0.5, 0.5, "This is a text!")

346 ... plt.show()

347 >>> f() # doctest: +SKIP

348 """

349

350 keywords = dict(kwargs)

351

352 def wrapper(function: Callable) -> Callable:

353 """Wrap specified function wrapper."""

354

355 @functools.wraps(function)

356 def wrapped(*args: Any, **kwargs: Any) -> Any:

357 """Wrap specified function."""

358

359 keywords.update(kwargs)

360

361 style_overrides = {

362 key: value for key, value in keywords.items() if key in plt.rcParams

363 }

364

365 with plt.style.context(style_overrides):

366 return function(*args, **kwargs)

367

368 return wrapped

369

370 return wrapper

371

372

373@contextmanager

374 def font_scaling(scaling: LiteralFontScaling, value: float) -> Generator:

375 """

376 Set a temporary *Matplotlib* font scaling using a context manager.

377

378 Parameters

379 ----------

380 scaling

381 Font scaling to temporarily set.

382 value

383 Value to temporarily set the font scaling with.

384

385 Yields

386 ------

387 Generator.

388

389 Examples

390 --------

391 >>> with font_scaling("medium-colour-science", 2):

392 ... print(matplotlib.font_manager.font_scalings["medium-colour-science"])

393 2

394 >>> print(matplotlib.font_manager.font_scalings["medium-colour-science"])

395 1

396 """

397

398 current_value = matplotlib.font_manager.font_scalings[scaling]

399

400 matplotlib.font_manager.font_scalings[scaling] = value

401

402 yield

403

404 matplotlib.font_manager.font_scalings[scaling] = current_value

405

406

407 def XYZ_to_plotting_colourspace(

408 XYZ: Domain1,

409 illuminant: ArrayLike = RGB_COLOURSPACES["sRGB"].whitepoint,

410 chromatic_adaptation_transform: (

411 LiteralChromaticAdaptationTransform | str | None

412 ) = "CAT02",

413 apply_cctf_encoding: bool = True,

414) -> Range1:

415 """

416 Convert from *CIE XYZ* tristimulus values to the default plotting

417 colourspace.

418

419 Parameters

420 ----------

421 XYZ

422 *CIE XYZ* tristimulus values.

423 illuminant

424 Source illuminant chromaticity coordinates.

425 chromatic_adaptation_transform

426 *Chromatic adaptation* transform.

427 apply_cctf_encoding

428 Apply the default plotting colourspace encoding colour

429 component transfer function / opto-electronic transfer

430 function.

431

432 Returns

433 -------

434 :class:`numpy.ndarray`

435 Default plotting colourspace colour array.

436

437 Notes

438 -----

439 +------------+-----------------------+---------------+

440 | **Domain** | **Scale - Reference** | **Scale - 1** |

441 +============+=======================+===============+

442 | ``XYZ`` | 1 | 1 |

443 +------------+-----------------------+---------------+

444

445 +------------+-----------------------+---------------+

446 | **Range** | **Scale - Reference** | **Scale - 1** |

447 +============+=======================+===============+

448 | ``RGB`` | 1 | 1 |

449 +------------+-----------------------+---------------+

450

451 Examples

452 --------

453 >>> import numpy as np

454 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952])

455 >>> XYZ_to_plotting_colourspace(XYZ) # doctest: +ELLIPSIS

456 array([ 0.7057393..., 0.1924826..., 0.2235416...])

457 """

458

459 return XYZ_to_RGB(

460 XYZ,

461 CONSTANTS_COLOUR_STYLE.colour.colourspace,

462 illuminant,

463 chromatic_adaptation_transform,

464 apply_cctf_encoding,

465 )

466

467

468@dataclass

469 class ColourSwatch:

470 """

471 Define a data structure for a colour swatch.

472

473 Parameters

474 ----------

475 RGB

476 RGB colour values representing the swatch.

477 name

478 Name identifier for the colour swatch.

479 """

480

481 RGB: ArrayLike

482 name: str | None = field(default_factory=lambda: None)

483

484

485 def colour_cycle(**kwargs: Any) -> itertools.cycle:

486 """

487 Create a colour cycle iterator using the specified colour map.

488

489 Other Parameters

490 ----------------

491 colour_cycle_map

492 Matplotlib colourmap name.

493 colour_cycle_count

494 Colours count to pick in the colourmap.

495

496 Returns

497 -------

498 :class:`itertools.cycle`

499 Colour cycle iterator.

500 """

501

502 settings = Structure(

503 colour_cycle_map=CONSTANTS_COLOUR_STYLE.colour.map,

504 colour_cycle_count=len(CONSTANTS_COLOUR_STYLE.colour.cycle),

505 )

506 settings.update(kwargs)

507

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)

513

514 return itertools.cycle(cycle)

515

516

517 class KwargsArtist(TypedDict):

518 """

519 Define keyword argument types for the :func:`colour.plotting.artist`

520 definition.

521

522 Parameters

523 ----------

524 axes

525 Axes that will be passed through without creating a new figure.

526 uniform

527 Whether to create the figure with an equal aspect ratio.

528 """

529

530 axes: Axes

531 uniform: bool

532

533

534 def artist(**kwargs: KwargsArtist | Any) -> Tuple[Figure, Axes]:

535 """

536 Return the current figure and its axes or create a new one.

537

538 Other Parameters

539 ----------------

540 kwargs

541 {:func:`colour.plotting.common.KwargsArtist`},

542 See the documentation of the previously listed class.

543

544 Returns

545 -------

546 :class:`tuple`

547 Current figure and axes.

548 """

549

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

551

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

553

554 axes = kwargs.get("axes")

555 if axes is None:

556 figure = plt.figure(figsize=figure_size)

557

558 return figure, figure.gca()

559

560 axes = cast("Axes", axes)

561 figure = axes.figure

562

563 if isinstance(figure, SubFigure):

564 figure = figure.get_figure()

565

566 return cast("Figure", figure), axes

567

568

569 class KwargsCamera(TypedDict):

570 """

571 Define the keyword argument types for the

572 :func:`colour.plotting.camera` definition.

573

574 Parameters

575 ----------

576 figure

577 Figure to apply the render elements onto.

578 axes

579 Axes to apply the render elements onto.

580 azimuth

581 Camera azimuth.

582 elevation

583 Camera elevation.

584 camera_aspect

585 Matplotlib axes aspect. Default is *equal*.

586 """

587

588 figure: Figure

589 axes: Axes

590 azimuth: float | None

591 elevation: float | None

592 camera_aspect: Literal["equal"] | str

593

594

595 def camera(**kwargs: KwargsCamera | Any) -> Tuple[Figure, Axes3D]:

596 """

597 Configure camera settings for the current 3D visualization.

598

599 Other Parameters

600 ----------------

601 kwargs

602 {:func:`colour.plotting.common.KwargsCamera`},

603 See the documentation of the previously listed class.

604

605 Returns

606 -------

607 :class:`tuple`

608 Current figure and axes.

609 """

610

611 figure = cast("Figure", kwargs.get("figure", plt.gcf()))

612 axes = cast("Axes3D", kwargs.get("axes", plt.gca()))

613

614 settings = Structure(camera_aspect="equal", elevation=None, azimuth=None)

615 settings.update(kwargs)

616

617 if settings.camera_aspect == "equal":

618 uniform_axes3d(axes=axes)

619

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

621

622 return figure, axes

623

624

625 class KwargsRender(TypedDict):

626 """

627 Define the keyword argument types for the

628 :func:`colour.plotting.render` definition.

629

630 Parameters

631 ----------

632 figure

633 Figure to apply the render elements onto.

634 axes

635 Axes to apply the render elements onto.

636 filename

637 Figure will be saved using the specified ``filename`` argument.

638 show

639 Whether to show the figure and call

640 :func:`matplotlib.pyplot.show` definition.

641 block

642 Whether to wait for all figures to be closed before returning.

643 If `True` block and run the GUI main loop until all figure

644 windows are closed. If `False` ensure that all figure windows

645 are displayed and return immediately. In this case, you are

646 responsible for ensuring that the event loop is running to have

647 responsive figures. Defaults to True in non-interactive mode and

648 to False in interactive mode.

649 aspect

650 Matplotlib axes aspect.

651 axes_visible

652 Whether the axes are visible. Default is *True*.

653 bounding_box

654 Array defining current axes limits such as

655 `bounding_box = (x min, x max, y min, y max)`.

656 tight_layout

657 Whether to invoke the :func:`matplotlib.pyplot.tight_layout`

658 definition.

659 legend

660 Whether to display the legend. Default is *False*.

661 legend_columns

662 Number of columns in the legend. Default is *1*.

663 transparent_background

664 Whether to turn off the background patch. Default is *True*.

665 title

666 Figure title.

667 wrap_title

668 Whether to wrap the figure title. Default is *True*.

669 x_label

670 *X* axis label.

671 y_label

672 *Y* axis label.

673 x_ticker

674 Whether to display the *X* axis ticker. Default is *True*.

675 y_ticker

676 Whether to display the *Y* axis ticker. Default is *True*.

677 """

678

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

697

698

699 def render(

700 **kwargs: KwargsRender | Any,

701) -> Tuple[Figure, Axes] | Tuple[Figure, Axes3D]:

702 """

703 Render the current figure while adjusting various settings such as the

704 bounding box, title, or background transparency.

705

706 Other Parameters

707 ----------------

708 kwargs

709 {:func:`colour.plotting.common.KwargsRender`},

710 See the documentation of the previously listed class.

711

712 Returns

713 -------

714 :class:`tuple`

715 Current figure and axes.

716 """

717

718 figure = cast("Figure", kwargs.get("figure", plt.gcf()))

719 axes = cast("Axes", kwargs.get("axes", plt.gca()))

720

721 kwargs = handle_arguments_deprecation(

722 {

723 "ArgumentRenamed": [["standalone", "show"]],

724 },

725 **kwargs,

726 )

727

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)

747

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])

755

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)

768

769 if settings.tight_layout:

770 figure.tight_layout()

771

772 if settings.transparent_background:

773 figure.patch.set_alpha(0)

774

775 if settings.filename is not None:

776 figure.savefig(str(settings.filename))

777

778 if settings.show:

779 plt.show(block=settings.block)

780

781 return figure, axes

782

783

784 def label_rectangles(

785 labels: Sequence[str | Real],

786 rectangles: Sequence[Patch],

787 rotation: Literal["horizontal", "vertical"] | str = "vertical",

788 text_size: float = CONSTANTS_COLOUR_STYLE.font.scaling.medium,

789 offset: ArrayLike | None = None,

790 **kwargs: Any,

791) -> Tuple[Figure, Axes]:

792 """

793 Add labels above specified rectangles.

794

795 Parameters

796 ----------

797 labels

798 Text labels to display above the rectangles.

799 rectangles

800 Rectangle patches used to determine label positions and values.

801 rotation

802 Orientation of the labels.

803 text_size

804 Font size for the labels.

805 offset

806 Label offset as percentages of the largest rectangle dimensions.

807

808 Other Parameters

809 ----------------

810 figure

811 Figure to apply the render elements onto.

812 axes

813 Axes to apply the render elements onto.

814

815 Returns

816 -------

817 :class:`tuple`

818 Current figure and axes.

819 """

820

821 rotation = validate_method(

822 rotation,

823 ("horizontal", "vertical"),

824 '"{0}" rotation is invalid, it must be one of {1}!',

825 )

826

827 figure = kwargs.get("figure", plt.gcf())

828 axes = kwargs.get("axes", plt.gca())

829

830 offset = as_float_array(optional(offset, (0.0, 0.025)))

831

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

836

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 )

852

853 return figure, axes

854

855

856 def uniform_axes3d(**kwargs: Any) -> Tuple[Figure, Axes3D]:

857 """

858 Set equal aspect ratio to the specified 3D axes.

859

860 Other Parameters

861 ----------------

862 figure

863 Figure to apply the render elements onto.

864 axes

865 Axes to apply the render elements onto.

866

867 Returns

868 -------

869 :class:`tuple`

870 Current figure and axes.

871 """

872

873 figure = kwargs.get("figure", plt.gcf())

874 axes = kwargs.get("axes", plt.gca())

875

876 with contextlib.suppress(NotImplementedError): # pragma: no cover

877 # TODO: Reassess according to

878 # https://github.com/matplotlib/matplotlib/issues/1077

879 axes.set_aspect("equal")

880

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

882

883 centers = np.mean(extents, axis=1)

884 extent = np.max(np.abs(extents[..., 1] - extents[..., 0]))

885

886 for center, axis in zip(centers, "xyz", strict=True):

887 getattr(axes, f"set_{axis}lim")(center - extent / 2, center + extent / 2)

888

889 return figure, axes

890

891

892 def filter_passthrough(

893 mapping: Mapping,

894 filterers: Any | str | Sequence[Any | str],

895 allow_non_siblings: bool = True,

896) -> dict:

897 """

898 Filter mapping objects matching specified filterers while passing through

899 class instances whose type is one of the mapping element types.

900

901 Enable passing custom but compatible objects to plotting definitions that

902 by default expect keys from dataset elements.

903

904 For example, a typical call to the

905 :func:`colour.plotting.plot_multi_illuminant_sds` definition is as

906 follows:

907

908 >>> import colour

909 >>> colour.plotting.plot_multi_illuminant_sds(["A"])

910 ... # doctest: +SKIP

911

912 With the previous example, it is also possible to pass a custom spectral

913 distribution as follows:

914

915 >>> data = {

916 ... 500: 0.0651,

917 ... 520: 0.0705,

918 ... 540: 0.0772,

919 ... 560: 0.0870,

920 ... 580: 0.1128,

921 ... 600: 0.1360,

922 ... }

923 >>> colour.plotting.plot_multi_illuminant_sds(

924 ... ["A", colour.SpectralDistribution(data)]

925 ... )

926 ... # doctest: +SKIP

927

928 Similarly, a typical call to the

929 :func:`colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931`

930 definition is as follows:

931

932 >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(["A"])

933 ... # doctest: +SKIP

934

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

936

937 >>> colour.plotting.plot_planckian_locus_in_chromaticity_diagram_CIE1931(

938 ... ["A", {"Custom": np.array([1 / 3 + 0.05, 1 / 3 + 0.05])}]

939 ... )

940 ... # doctest: +SKIP

941

942 Parameters

943 ----------

944 mapping

945 Mapping to filter.

946 filterers

947 Filterer or object class instance (which is passed through directly

948 if its type is one of the mapping element types) or list of

949 filterers.

950 allow_non_siblings

951 Whether to allow non-siblings to be also passed through.

952

953 Returns

954 -------

955 :class:`dict`

956 Filtered mapping.

957

958 Notes

959 -----

960 - If the mapping passed is a :class:`colour.utilities.CanonicalMapping`

961 class instance, then the lower, slugified and canonical keys are

962 also used for matching.

963 """

964

965 if isinstance(filterers, str) or not isinstance(filterers, (list, tuple)):

966 filterers = [filterers]

967

968 string_filterers: List[str] = [

969 filterer for filterer in filterers if isinstance(filterer, str)

970 ]

971

972 object_filterers: List[Any] = [

973 filterer for filterer in filterers if is_sibling(filterer, mapping)

974 ]

975

976 if allow_non_siblings:

977 non_siblings = [

978 filterer

979 for filterer in filterers

980 if filterer not in string_filterers and filterer not in object_filterers

981 ]

982

983 if non_siblings:

984 runtime_warning(

985 f'Non-sibling elements are passed-through: "{non_siblings}"'

986 )

987

988 object_filterers.extend(non_siblings)

989

990 filtered_mapping = filter_mapping(mapping, string_filterers)

991

992 for filterer in object_filterers:

993 # TODO: Consider using "MutableMapping" here.

994 if isinstance(filterer, (dict, CanonicalMapping)):

995 for key, value in filterer.items():

996 filtered_mapping[key] = value

997 else:

998 try:

999 name = filterer.name

1000 except AttributeError:

1001 try:

1002 name = filterer.__name__

1003 except AttributeError:

1004 name = str(id(filterer))

1005

1006 filtered_mapping[name] = filterer

1007

1008 return filtered_mapping

1009

1010

1011 def filter_RGB_colourspaces(

1012 filterers: (

1013 RGB_Colourspace

1014 | LiteralRGBColourspace

1015 | str

1016 | Sequence[RGB_Colourspace | LiteralRGBColourspace | str]

1017 ),

1018 allow_non_siblings: bool = True,

1019) -> Dict[str, RGB_Colourspace]:

1020 """

1021 Filter the *RGB* colourspaces matching the specified filterers.

1022

1023 Parameters

1024 ----------

1025 filterers

1026 Filterer, :class:`colour.RGB_Colourspace` class instance (which is

1027 passed through directly if its type is one of the mapping element

1028 types), or list of filterers. The ``filterers`` elements can also

1029 be of any form supported by the

1030 :func:`colour.plotting.common.filter_passthrough` definition.

1031 allow_non_siblings

1032 Whether to allow non-siblings to be also passed through.

1033

1034 Returns

1035 -------

1036 :class:`dict`

1037 Filtered *RGB* colourspaces.

1038 """

1039

1040 return filter_passthrough(RGB_COLOURSPACES, filterers, allow_non_siblings)

1041

1042

1043 def filter_cmfs(

1044 filterers: (

1045 MultiSpectralDistributions | str | Sequence[MultiSpectralDistributions | str]

1046 ),

1047 allow_non_siblings: bool = True,

1048) -> Dict[str, MultiSpectralDistributions]:

1049 """

1050 Filter the colour matching functions matching the specified filterers.

1051

1052 Parameters

1053 ----------

1054 filterers

1055 Filterer or :class:`colour.LMS_ConeFundamentals`,

1056 :class:`colour.RGB_ColourMatchingFunctions` or

1057 :class:`colour.XYZ_ColourMatchingFunctions` class instance (which is

1058 passed through directly if its type is one of the mapping element

1059 types) or list of filterers. ``filterers`` elements can also be of

1060 any form supported by the

1061 :func:`colour.plotting.common.filter_passthrough` definition.

1062 allow_non_siblings

1063 Whether to allow non-siblings to be also passed through.

1064

1065 Returns

1066 -------

1067 :class:`dict`

1068 Filtered colour matching functions.

1069 """

1070

1071 return filter_passthrough(MSDS_CMFS, filterers, allow_non_siblings)

1072

1073

1074 def filter_illuminants(

1075 filterers: SpectralDistribution | str | Sequence[SpectralDistribution | str],

1076 allow_non_siblings: bool = True,

1077) -> Dict[str, SpectralDistribution]:

1078 """

1079 Filter the illuminants matching the specified filterers.

1080

1081 Parameters

1082 ----------

1083 filterers

1084 Filterer or :class:`colour.SpectralDistribution` class instance

1085 (which is passed through directly if its type is one of the

1086 mapping element types) or list of filterers. ``filterers``

1087 elements can also be of any form supported by the

1088 :func:`colour.plotting.common.filter_passthrough` definition.

1089 allow_non_siblings

1090 Whether to allow non-siblings to be also passed through.

1091

1092 Returns

1093 -------

1094 :class:`dict`

1095 Filtered illuminants.

1096 """

1097

1098 illuminants = {}

1099

1100 illuminants.update(

1101 filter_passthrough(SDS_ILLUMINANTS, filterers, allow_non_siblings)

1102 )

1103

1104 illuminants.update(

1105 filter_passthrough(SDS_LIGHT_SOURCES, filterers, allow_non_siblings)

1106 )

1107

1108 return illuminants

1109

1110

1111 def filter_colour_checkers(

1112 filterers: ColourChecker | str | Sequence[ColourChecker | str],

1113 allow_non_siblings: bool = True,

1114) -> Dict[str, ColourChecker]:

1115 """

1116 Filter the colour checkers matching the specified filterers.

1117

1118 Parameters

1119 ----------

1120 filterers

1121 Filterer or :class:`colour.characterisation.ColourChecker` class

1122 instance (which is passed through directly if its type is one of

1123 the mapping element types) or list of filterers. ``filterers``

1124 elements can also be of any form supported by the

1125 :func:`colour.plotting.common.filter_passthrough` definition.

1126 allow_non_siblings

1127 Whether to allow non-siblings to be also passed through.

1128

1129 Returns

1130 -------

1131 :class:`dict`

1132 Filtered colour checkers.

1133 """

1134

1135 return filter_passthrough(CCS_COLOURCHECKERS, filterers, allow_non_siblings)

1136

1137

1138 def update_settings_collection(

1139 settings_collection: dict | List[dict],

1140 keyword_arguments: dict | List[dict],

1141 expected_count: int,

1142) -> None:

1143 """

1144 Update the specified settings collection *in-place* with the specified

1145 keyword arguments and expected count of settings collection elements.

1146

1147 Parameters

1148 ----------

1149 settings_collection

1150 Settings collection to update.

1151 keyword_arguments

1152 Keyword arguments to update the settings collection.

1153 expected_count

1154 Expected count of settings collection elements.

1155

1156 Examples

1157 --------

1158 >>> settings_collection = [{1: 2}, {3: 4}]

1159 >>> keyword_arguments = {5: 6}

1160 >>> update_settings_collection(settings_collection, keyword_arguments, 2)

1161 >>> print(settings_collection)

1162 [{1: 2, 5: 6}, {3: 4, 5: 6}]

1163 >>> settings_collection = [{1: 2}, {3: 4}]

1164 >>> keyword_arguments = [{5: 6}, {7: 8}]

1165 >>> update_settings_collection(settings_collection, keyword_arguments, 2)

1166 >>> print(settings_collection)

1167 [{1: 2, 5: 6}, {3: 4, 7: 8}]

1168 """

1169

1170 if not isinstance(keyword_arguments, dict):

1171 attest(

1172 len(keyword_arguments) == expected_count,

1173 "Multiple keyword arguments defined, but they do not "

1174 "match the expected count!",

1175 )

1176

1177 for i, settings in enumerate(settings_collection):

1178 if isinstance(keyword_arguments, dict):

1179 settings.update(keyword_arguments)

1180 else:

1181 settings.update(keyword_arguments[i])

1182

1183

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)

1193 def plot_single_colour_swatch(

1194 colour_swatch: ArrayLike | ColourSwatch, **kwargs: Any

1195) -> Tuple[Figure, Axes]:

1196 """

1197 Plot a single colour swatch.

1198

1199 Parameters

1200 ----------

1201 colour_swatch

1202 Colour swatch to plot, either a regular `ArrayLike` or a

1203 :class:`colour.plotting.ColourSwatch` class instance.

1204

1205 Other Parameters

1206 ----------------

1207 kwargs

1208 {:func:`colour.plotting.artist`,

1209 :func:`colour.plotting.plot_multi_colour_swatches`,

1210 :func:`colour.plotting.render`},

1211 See the documentation of the previously listed definitions.

1212

1213 Returns

1214 -------

1215 :class:`tuple`

1216 Current figure and axes.

1217

1218 Examples

1219 --------

1220 >>> RGB = ColourSwatch((0.45620519, 0.03081071, 0.04091952))

1221 >>> plot_single_colour_swatch(RGB) # doctest: +ELLIPSIS

1222 (<Figure size ... with 1 Axes>, <...Axes...>)

1223

1224 .. image:: ../_static/Plotting_Plot_Single_Colour_Swatch.png

1225 :align: center

1226 :alt: plot_single_colour_swatch

1227 """

1228

1229 return plot_multi_colour_swatches((colour_swatch,), **kwargs)

1230

1231

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)

1241 def plot_multi_colour_swatches(

1242 colour_swatches: ArrayLike | Sequence[ArrayLike | ColourSwatch],

1243 width: float = 1,

1244 height: float = 1,

1245 spacing: float = 0,

1246 columns: int | None = None,

1247 direction: Literal["+y", "-y"] | str = "+y",

1248 text_kwargs: dict | None = None,

1249 background_colour: ArrayLike = (1.0, 1.0, 1.0),

1250 compare_swatches: Literal["Diagonal", "Stacked"] | str | None = None,

1251 **kwargs: Any,

1252) -> Tuple[Figure, Axes]:

1253 """

1254 Plot colour swatches with configurable layout and comparison options.

1255

1256 Parameters

1257 ----------

1258 colour_swatches

1259 Colour swatch sequence, either a regular `ArrayLike` or a sequence

1260 of :class:`colour.plotting.ColourSwatch` class instances.

1261 width

1262 Colour swatch width.

1263 height

1264 Colour swatch height.

1265 spacing

1266 Colour swatches spacing.

1267 columns

1268 Colour swatches columns count, defaults to the colour swatch count

1269 or half of it if comparing.

1270 direction

1271 Row stacking direction.

1272 text_kwargs

1273 Keyword arguments for the :func:`matplotlib.pyplot.text`

1274 definition. The following special keywords can also be used:

1275

1276 - ``offset``: Sets the text offset.

1277 - ``visible``: Sets the text visibility.

1278 background_colour

1279 Background colour.

1280 compare_swatches

1281 Whether to compare the swatches, in which case the colour swatch

1282 count must be an even number with alternating reference colour

1283 swatches and test colour swatches. *Stacked* will draw the test

1284 colour swatch in the center of the reference colour swatch,

1285 *Diagonal* will draw the reference colour swatch in the upper left

1286 diagonal area and the test colour swatch in the bottom right

1287 diagonal area.

1288

1289 Other Parameters

1290 ----------------

1291 kwargs

1292 {:func:`colour.plotting.artist`,

1293 :func:`colour.plotting.render`},

1294 See the documentation of the previously listed definitions.

1295

1296 Returns

1297 -------

1298 :class:`tuple`

1299 Current figure and axes.

1300

1301 Examples

1302 --------

1303 >>> RGB_1 = ColourSwatch((0.45293517, 0.31732158, 0.26414773))

1304 >>> RGB_2 = ColourSwatch((0.77875824, 0.57726450, 0.50453169))

1305 >>> plot_multi_colour_swatches([RGB_1, RGB_2]) # doctest: +ELLIPSIS

1306 (<Figure size ... with 1 Axes>, <...Axes...>)

1307

1308 .. image:: ../_static/Plotting_Plot_Multi_Colour_Swatches.png

1309 :align: center

1310 :alt: plot_multi_colour_swatches

1311 """

1312

1313 direction = validate_method(

1314 direction,

1315 ("+y", "-y"),

1316 '"{0}" direction is invalid, it must be one of {1}!',

1317 )

1318

1319 if compare_swatches is not None:

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 )

1325

1326 _figure, axes = artist(**kwargs)

1327

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)

1340

1341 colour_swatches = colour_swatches_converted

1342

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 )

1348

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

1353

1354 columns = optional(columns, len(colour_swatches_reference))

1355

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")

1364

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

1373

1374 x_0, x_1 = offset_X, offset_X + width

1375 y_0, y_1 = offset_Y, offset_Y + height * y

1376

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 )

1383

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 )

1410

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 )

1420

1421 offset_X += width + spacing

1422

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

1426

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

1428

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 ]

1443

1444 settings: Dict[str, Any] = {

1445 "axes": axes,

1446 "bounding_box": bounding_box,

1447 "aspect": "equal",

1448 }

1449 settings.update(kwargs)

1450

1451 return render(**settings)

1452

1453

1454@override_style()

1455 def plot_single_function(

1456 function: Callable,

1457 samples: ArrayLike | None = None,

1458 log_x: int | None = None,

1459 log_y: int | None = None,

1460 plot_kwargs: dict | List[dict] | None = None,

1461 **kwargs: Any,

1462) -> Tuple[Figure, Axes]:

1463 """

1464 Plot the specified function.

1465

1466 Parameters

1467 ----------

1468 function

1469 Function to plot.

1470 samples

1471 Samples to evaluate the functions with.

1472 log_x

1473 Log base to use for the *x* axis scale, if *None*, the *x* axis

1474 scale will be linear.

1475 log_y

1476 Log base to use for the *y* axis scale, if *None*, the *y* axis

1477 scale will be linear.

1478 plot_kwargs

1479 Keyword arguments for the :func:`matplotlib.pyplot.plot`

1480 definition, used to control the style of the plotted function.

1481

1482 Other Parameters

1483 ----------------

1484 kwargs

1485 {:func:`colour.plotting.artist`,

1486 :func:`colour.plotting.plot_multi_functions`,

1487 :func:`colour.plotting.render`},

1488 See the documentation of the previously listed definitions.

1489

1490 Returns

1491 -------

1492 :class:`tuple`

1493 Current figure and axes.

1494

1495 Examples

1496 --------

1497 >>> from colour.models import gamma_function

1498 >>> plot_single_function(partial(gamma_function, exponent=1 / 2.2))

1499 ... # doctest: +ELLIPSIS

1500 (<Figure size ... with 1 Axes>, <...Axes...>)

1501

1502 .. image:: ../_static/Plotting_Plot_Single_Function.png

1503 :align: center

1504 :alt: plot_single_function

1505 """

1506

1507 try:

1508 name = function.__name__

1509 except AttributeError:

1510 name = "Unnamed"

1511

1512 settings: Dict[str, Any] = {

1513 "title": f"{name} - Function",

1514 "legend": False,

1515 }

1516 settings.update(kwargs)

1517

1518 return plot_multi_functions(

1519 {name: function}, samples, log_x, log_y, plot_kwargs, **settings

1520 )

1521

1522

1523@override_style()

1524 def plot_multi_functions(

1525 functions: Dict[str, Callable],

1526 samples: ArrayLike | None = None,

1527 log_x: int | None = None,

1528 log_y: int | None = None,

1529 plot_kwargs: dict | List[dict] | None = None,

1530 **kwargs: Any,

1531) -> Tuple[Figure, Axes]:

1532 """

1533 Plot specified functions.

1534

1535 Parameters

1536 ----------

1537 functions

1538 Functions to plot.

1539 samples

1540 Samples to evaluate the functions with.

1541 log_x

1542 Log base to use for the *x* axis scale, if *None*, the *x* axis

1543 scale will be linear.

1544 log_y

1545 Log base to use for the *y* axis scale, if *None*, the *y* axis

1546 scale will be linear.

1547 plot_kwargs

1548 Keyword arguments for the :func:`matplotlib.pyplot.plot`

1549 definition, used to control the style of the plotted functions.

1550 ``plot_kwargs`` can be either a single dictionary applied to all

1551 the plotted functions with the same settings or a sequence of

1552 dictionaries with different settings for each plotted function.

1553

1554 Other Parameters

1555 ----------------

1556 kwargs

1557 {:func:`colour.plotting.artist`,

1558 :func:`colour.plotting.render`},

1559 See the documentation of the previously listed definitions.

1560

1561 Returns

1562 -------

1563 :class:`tuple`

1564 Current figure and axes.

1565

1566 Examples

1567 --------

1568 >>> functions = {

1569 ... "Gamma 2.2": lambda x: x ** (1 / 2.2),

1570 ... "Gamma 2.4": lambda x: x ** (1 / 2.4),

1571 ... "Gamma 2.6": lambda x: x ** (1 / 2.6),

1572 ... }

1573 >>> plot_multi_functions(functions)

1574 ... # doctest: +ELLIPSIS

1575 (<Figure size ... with 1 Axes>, <...Axes...>)

1576

1577 .. image:: ../_static/Plotting_Plot_Multi_Functions.png

1578 :align: center

1579 :alt: plot_multi_functions

1580 """

1581

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

1583

1584 _figure, axes = artist(**settings)

1585

1586 plot_settings_collection = [

1587 {

1588 "label": f"{name}",

1589 "zorder": CONSTANTS_COLOUR_STYLE.zorder.midground_label,

1590 }

1591 for name in functions

1592 ]

1593

1594 if plot_kwargs is not None:

1595 update_settings_collection(

1596 plot_settings_collection, plot_kwargs, len(functions)

1597 )

1598

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 )

1604

1605 plotting_function = axes.loglog

1606

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.")

1611

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.")

1615

1616 plotting_function = partial(axes.semilogy, base=log_y)

1617 else:

1618 plotting_function = axes.plot

1619

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

1621

1622 for i, (_name, function) in enumerate(functions.items()):

1623 plotting_function(samples, function(samples), **plot_settings_collection[i])

1624

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)

1635

1636 return render(**settings)

1637

1638

1639@override_style()

1640 def plot_image(

1641 image: ArrayLike,

1642 imshow_kwargs: dict | None = None,

1643 text_kwargs: dict | None = None,

1644 **kwargs: Any,

1645) -> Tuple[Figure, Axes]:

1646 """

1647 Plot the specified image using matplotlib.

1648

1649 Parameters

1650 ----------

1651 image

1652 Image array to plot, typically as RGB or grayscale data.

1653 imshow_kwargs

1654 Keyword arguments for the :func:`matplotlib.pyplot.imshow`

1655 definition, controlling image display properties.

1656 text_kwargs

1657 Keyword arguments for the :func:`matplotlib.pyplot.text`

1658 definition, controlling text overlay properties. The following

1659 special keyword arguments can also be used:

1660

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

1662

1663 Other Parameters

1664 ----------------

1665 kwargs

1666 {:func:`colour.plotting.artist`,

1667 :func:`colour.plotting.render`}, See the documentation of the

1668 previously listed definitions for additional plotting controls.

1669

1670 Returns

1671 -------

1672 :class:`tuple`

1673 Current figure and axes objects from matplotlib.

1674

1675 Examples

1676 --------

1677 >>> import os

1678 >>> import colour

1679 >>> from colour import read_image

1680 >>> path = os.path.join(

1681 ... colour.__path__[0],

1682 ... "examples",

1683 ... "plotting",

1684 ... "resources",

1685 ... "Ishihara_Colour_Blindness_Test_Plate_3.png",

1686 ... )

1687 >>> plot_image(read_image(path)) # doctest: +ELLIPSIS

1688 (<Figure size ... with 1 Axes>, <...Axes...>)

1689

1690 .. image:: ../_static/Plotting_Plot_Image.png

1691 :align: center

1692 :alt: plot_image

1693 """

1694

1695 _figure, axes = artist(**kwargs)

1696

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)

1704

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")

1715

1716 image = as_float_array(image)

1717

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

1719

1720 if text_settings["text"] is not None:

1721 text = text_settings.pop("text")

1722

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 )

1732

1733 settings: Dict[str, Any] = {

1734 "axes": axes,

1735 "axes_visible": False,

1736 }

1737 settings.update(kwargs)

1738

1739 return render(**settings)

1740

1741

1742 def plot_ray(

1743 axes: Axes,

1744 x_coords: ArrayLike,

1745 y_coords: ArrayLike,

1746 style: Literal["solid", "dashed"] | str = "solid",

1747 label: str | None = None,

1748 show_arrow: bool = True,

1749 show_dots: bool = False,

1750) -> None:

1751 """

1752 Draw a ray path with optional arrow and interface dots.

1753

1754 Parameters

1755 ----------

1756 axes

1757 Axes to draw the ray on.

1758 x_coords

1759 X coordinates of the ray path.

1760 y_coords

1761 Y coordinates of the ray path.

1762 style

1763 Line style: 'solid' for transmitted rays, 'dashed' for reflected rays.

1764 label

1765 Label for the legend (only on first segment).

1766 show_arrow

1767 Whether to show directional arrow at midpoint.

1768 show_dots

1769 Whether to show dots at intermediate points.

1770

1771 Examples

1772 --------

1773 >>> import matplotlib.pyplot as plt

1774 >>> import numpy as np

1775 >>> _fig, axes = plt.subplots()

1776 >>> x = np.array([0, 1, 2])

1777 >>> y = np.array([0, 1, 0])

1778 >>> plot_ray(axes, x, y, style="solid", label="Ray")

1779 >>> plt.close()

1780 """

1781

1782 x_coords = as_float_array(x_coords)

1783 y_coords = as_float_array(y_coords)

1784

1785 # Validate style

1786 style = validate_method(style, ("solid", "dashed"))

1787

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 )

1799

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]

1805

1806 # Calculate midpoint

1807 mid_x = (x_start + x_end) / 2

1808 mid_y = (y_start + y_end) / 2

1809

1810 # Calculate direction

1811 dx = x_end - x_start

1812 dy = y_end - y_start

1813

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 )

1822

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 )