Coverage for colour/plotting/common.py: 100%

318 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-15 19:01 +1300

1""" 

2Common Plotting 

3=============== 

4 

5Define 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 

28from __future__ import annotations 

29 

30import contextlib 

31import functools 

32import itertools 

33import typing 

34from contextlib import contextmanager 

35from dataclasses import dataclass, field 

36from functools import partial 

37 

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 

46 

47if typing.TYPE_CHECKING: 

48 from matplotlib.axes import Axes 

49 from matplotlib.patches import Patch 

50 from mpl_toolkits.mplot3d.axes3d import Axes3D 

51 

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) 

60 

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 ) 

79 

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 

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 

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

223 

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 

230 

231del _scaling, _value 

232 

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

242 

243 

244def 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 

327def 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 

374def 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 

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. 

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 

469class 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 

485def 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 

517class 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 

534def 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 

569class 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 

595def 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 

625class 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 

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. 

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 

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. 

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 

856def 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 

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. 

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 

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. 

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 

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. 

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 

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. 

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 

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. 

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 

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. 

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) 

1193def 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) 

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. 

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

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. 

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

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. 

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

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. 

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 

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. 

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 )