from __future__ import annotations
import pyqtgraph as pg
from pyqtgraph import colormap as cmap
from typing import Generic, Iterator, Sequence, TypeVar, overload, MutableSequence
import numpy as np
from .components import Legend, Region, ScaleBar, TextItem
from .graph_items import (
BarPlot,
Curve,
FillBetween,
InfLine,
LayerItem,
Scatter,
Histogram,
TextGroup,
)
from .mouse_event import MouseClickEvent
from .._shared_utils import convert_color_code, to_rgba
from .._doc import write_docs
from ...widgets.utils import FreeWidget
from ..._app import get_app
BOTTOM = "bottom"
LEFT = "left"
[docs]class LayerList(MutableSequence[LayerItem]):
"""A napari-like layer list for plot item handling."""
def __init__(self, parent: HasDataItems):
self.parent = parent
def __repr__(self) -> str:
if len(self) == 0:
return f"{type(self).__name__}()"
s = ",\n\t".join(repr(layer) for layer in self)
return f"{type(self).__name__}(\n\t{s}\n)"
def __getitem__(self, key: int | str) -> LayerItem:
if isinstance(key, int):
return self.parent._items[key]
elif isinstance(key, str):
for item in self.parent._items:
if item.name == key:
return item
else:
raise ValueError(f"Item '{key}' not found.")
else:
raise TypeError(f"Cannot use type {type(key)} as a key.")
def __setitem__(self, key, value):
raise NotImplementedError("Can't set item")
def __delitem__(self, key: int | str):
return self.parent._remove_item(key)
[docs] def append(self, item: LayerItem):
if not isinstance(item, LayerItem):
raise TypeError(f"Cannot append type {type(item)}.")
self.parent._add_item(item)
[docs] def insert(self, pos: int, item: LayerItem):
if not isinstance(item, LayerItem):
raise TypeError(f"Cannot insert type {type(item)}.")
self.parent._insert_item(pos, item)
def __len__(self):
return len(self.parent._items)
[docs] def clear(self):
for _ in range(len(self)):
self.parent._remove_item(-1)
[docs] def swap(self, pos0: int, pos1: int):
return self.parent._swap_items(pos0, pos1)
[docs] def move(self, source: int, destination: int):
return self.parent._move_item(source, destination)
[docs]class HasDataItems:
_items: list[LayerItem]
@property
def _graphics(self) -> pg.GraphicsWidget:
"""Target widget to add graphics items."""
raise NotImplementedError()
@property
def layers(self) -> LayerList:
return LayerList(self)
@overload
def add_curve(self, x: Sequence[float], **kwargs):
...
@overload
def add_curve(self, x: Sequence[float], y: Sequence[float], **kwargs):
...
[docs] @write_docs
def add_curve(
self,
x=None,
y=None,
face_color=None,
edge_color=None,
color=None,
size: float = 7,
name: str | None = None,
lw: float = 1,
ls: str = "-",
symbol=None,
):
"""
Add a line plot like ``plt.plot(x, y)``.
Parameters
----------
{x}
{y}
{face_color}
{edge_color}
{color}
size: float, default is 7
Symbol size.
{name}
{lw}
{ls}
{symbol}
Returns
-------
Curve
A plot item of a curve.
"""
x, y = _check_xy(x, y)
name = self._find_unique_name(name or "Curve")
face_color, edge_color = _check_colors(face_color, edge_color, color)
item = Curve(
x,
y,
face_color=face_color,
edge_color=edge_color,
size=size,
name=name,
lw=lw,
ls=ls,
symbol=symbol,
)
self._add_item(item)
return item
@overload
def add_scatter(self, x: Sequence[float], **kwargs):
...
@overload
def add_scatter(self, x: Sequence[float], y: Sequence[float], **kwargs):
...
[docs] @write_docs
def add_scatter(
self,
x=None,
y=None,
face_color=None,
edge_color=None,
color=None,
size: float = 7,
name: str | None = None,
lw: float = 1,
ls: str = "-",
symbol="o",
):
"""
Add scatter plot like ``plt.scatter(x, y)``.
Parameters
----------
{x}
{y}
{face_color}
{edge_color}
{color}
size: float, default is 7
Symbol size.
{name}
{lw}
{ls}
{symbol}
Returns
-------
Scatter
A plot item of the scatter plot.
"""
x, y = _check_xy(x, y)
name = self._find_unique_name(name or "Scatter")
face_color, edge_color = _check_colors(face_color, edge_color, color)
item = Scatter(
x,
y,
face_color=face_color,
edge_color=edge_color,
size=size,
name=name,
lw=lw,
ls=ls,
symbol=symbol,
)
self._add_item(item)
return item
[docs] @write_docs
def add_hist(
self,
data: Sequence[float],
bins: int | Sequence | str = 10,
range=None,
density: bool = False,
face_color=None,
edge_color=None,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
):
"""
Add histogram like ``plt.hist(data)``.
Parameters
----------
data : array-like
Data for histogram constrction.
bins : int, sequence of float or str, default is 10
Bin numbers. See ``np.histogram`` for detail.
range : two floats, optional
Bin ranges. See ``np.histogram`` for detail.
density : bool, default is False
If true, plot the density instead of the counts. See ``np.histogram`` for
detail.
{face_color}
{edge_color}
{color}
{name}
{lw}
{ls}
Returns
-------
Histogram
A plot item of the histogram.
"""
name = self._find_unique_name(name or "Histogram")
face_color, edge_color = _check_colors(face_color, edge_color, color)
item = Histogram(
data,
bins=bins,
range=range,
density=density,
face_color=face_color,
edge_color=edge_color,
name=name,
lw=lw,
ls=ls,
)
self._add_item(item)
return item
@overload
def add_bar(self, x: Sequence[float], **kwargs):
...
@overload
def add_bar(self, x: Sequence[float], y: Sequence[float], **kwargs):
...
[docs] @write_docs
def add_bar(
self,
x=None,
y=None,
width: float = 0.6,
face_color=None,
edge_color=None,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
):
"""
Add a bar plot like ``plt.bar(x, y)``.
Parameters
----------
{x}
{y}
width : float, default is 0.6
Width of each bar.
{face_color}
{edge_color}
{color}
{name}
{lw}
{ls}
Returns
-------
BarPlot
A plot item of the bar plot.
"""
x, y = _check_xy(x, y)
name = self._find_unique_name(name or "Bar")
face_color, edge_color = _check_colors(face_color, edge_color, color)
item = BarPlot(
x,
y,
width=width,
face_color=face_color,
edge_color=edge_color,
name=name,
lw=lw,
ls=ls,
)
self._add_item(item)
return item
@overload
def add_fillbetween(self, x: Sequence[float], **kwargs):
...
@overload
def add_fillbetween(self, x: Sequence[float], y: Sequence[float], **kwargs):
...
[docs] @write_docs
def add_fillbetween(
self,
x=None,
y1=None,
y2=None,
face_color=None,
edge_color=None,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
):
x, y1 = _check_xy(x, y1)
name = self._find_unique_name(name or "FillBetween")
face_color, edge_color = _check_colors(face_color, edge_color, color)
item = FillBetween(
x,
y1,
y2,
face_color=face_color,
edge_color=edge_color,
name=name,
lw=lw,
ls=ls,
)
self._add_item(item)
@overload
def add_infline(
self,
slope: float,
intercept: float,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
):
...
@overload
def add_infline(
self,
pos: tuple[float, float],
degree: float,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
):
...
[docs] def add_infline(
self,
*args,
color=None,
name: str | None = None,
lw: float = 1,
ls: str = "-",
**kwargs,
):
if kwargs:
if args:
raise TypeError(
"Cannot mix args and kwargs for infinite line parameters."
)
keys = set(kwargs.keys())
if keys <= {"pos", "angle"}:
args = (kwargs.get("pos", (0, 0)), kwargs.get("angle", 0))
elif keys <= {"slope", "intercept"}:
args = (kwargs.get("slope", (0, 0)), kwargs.get("intercept", 0))
else:
raise ValueError(f"{kwargs} is invalid input.")
nargs = len(args)
if nargs == 1:
arg0 = args[0]
if np.isscalar(arg0):
angle = np.rad2deg(np.arctan(arg0))
pos = (0, 0)
else:
pos = arg0
angle = 90
elif nargs == 2:
arg0, arg1 = args
if np.isscalar(arg0):
angle = np.rad2deg(np.arctan(arg0))
pos = (0, arg1)
else:
pos = arg0
angle = arg1
else:
raise TypeError(
"Arguments of 'add_infline' should be either 'add_infline(slope, intercept)' "
"or 'add_infline(pos, degree)'."
)
item = InfLine(pos, angle, edge_color=color, name=name, lw=lw, ls=ls)
self._add_item(item)
@overload
def add_text(self, x: float, y: float, text: str, **kwargs):
...
@overload
def add_text(
self, x: Sequence[float], y: Sequence[float], text: Sequence[str], **kwargs
):
...
[docs] def add_text(self, x, y, text, color=None, name=None):
if np.isscalar(x) and np.isscalar(y):
x = [x]
y = [y]
text = [text]
item = TextGroup(x, y, text, color, name)
self._add_item(item)
def _add_item(self, item: LayerItem):
item.zorder = len(self._items)
self._graphics.addItem(item.native)
self._items.append(item)
def _insert_item(self, pos: int, item: LayerItem):
self._graphics.addItem(item.native)
self._items.insert(pos, item)
self._reorder()
def _swap_items(self, pos0: int, pos1: int):
item0 = self._items[pos0]
item1 = self._items[pos1]
self._items[pos0] = item1
self._items[pos1] = item0
self._reorder()
def _move_item(self, source: int, destination: int):
if source < destination:
destination -= 1
item = self._items.pop(source)
self._items.insert(destination, item)
self._reorder()
def _remove_item(self, item: LayerItem | int | str):
if isinstance(item, LayerItem):
i = self._items.index(item)
elif isinstance(item, int):
if item < 0:
item += len(self._items)
i = item
elif isinstance(item, str):
for i, each in enumerate(self._items):
if each.name == item:
break
else:
raise ValueError(f"No item named {item}")
if i < 0:
raise ValueError(f"Item {item} not found")
item = self._items.pop(i)
self._graphics.removeItem(item.native)
def _reorder(self):
for i, item in enumerate(self._items):
item.zorder = i
return None
def _find_unique_name(self, prefix: str):
existing_names = [item.name for item in self._items]
name = prefix
i = 0
while name in existing_names:
name = f"{prefix}-{i}"
i += 1
return name
[docs]class HasViewBox(HasDataItems):
def __init__(self, viewbox: pg.ViewBox):
self._viewbox = viewbox
self._items: list[LayerItem] = []
# prepare mouse event
self.mouse_click_callbacks = []
# This ROI is not editable. Mouse click event will use it to determine
# the origin of the coordinate system.
self._coordinate_fiducial = pg.ROI((0, 0))
self._coordinate_fiducial.setVisible(False)
self._viewbox.addItem(self._coordinate_fiducial, ignoreBounds=True)
self._enabled = True
def _mouse_clicked(self, e):
# NOTE: Mouse click event needs a reference item to map coordinates.
# Here plot items always have a linear region item as a default one,
# we can use it as the referene.
ev = MouseClickEvent(e, self._coordinate_fiducial)
x, y = ev.pos()
[xmin, xmax], [ymin, ymax] = self._viewbox.viewRange()
if xmin <= x <= xmax and ymin <= y <= ymax:
for callback in self.mouse_click_callbacks:
callback(ev)
@property
def xlim(self):
"""Range limits of X-axis."""
(xmin, xmax), _ = self._viewbox.viewRange()
return xmin, xmax
@xlim.setter
def xlim(self, value: tuple[float, float]):
self._viewbox.setXRange(*value)
@property
def ylim(self):
"""Range limits of Y-axis."""
_, (ymin, ymax) = self._viewbox.viewRange()
return ymin, ymax
@ylim.setter
def ylim(self, value: tuple[float, float]):
self._viewbox.setYRange(*value)
@property
def enabled(self) -> bool:
"""Mouse interactivity"""
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._viewbox.setMouseEnabled(value, value)
self._enabled = value
interactive = enabled
def _update_scene(self):
raise NotImplementedError()
@property
def border(self):
return to_rgba(self._viewbox.border)
@border.setter
def border(self, value):
value = convert_color_code(value)
self._viewbox.setBorder(value)
[docs]class SimpleViewBox(HasViewBox):
def __init__(self):
super().__init__(pg.ViewBox())
@property
def _graphics(self):
return self._viewbox
[docs]class PlotItem(HasViewBox):
"""
A 1-D plot item that has similar API as napari Viewer.
"""
def __init__(self, viewbox: pg.ViewBox | None = None):
if viewbox is None:
viewbox = pg.ViewBox()
self.pgitem = pg.PlotItem(viewBox=viewbox)
super().__init__(self.pgitem.vb)
# prepare region item
self._region = Region()
self._region.visible = False
self.pgitem.addItem(self._region.native, ignoreBounds=True)
# prepare legend item
self._legend = Legend()
self._legend.native.setParentItem(self._viewbox)
self.pgitem.legend = self._legend.native
# initialize private attributes
self._xlabel = ""
self._ylabel = ""
@property
def _graphics(self):
return self.pgitem
@property
def region(self) -> Region:
"""Linear region item."""
return self._region
@property
def legend(self) -> Legend:
"""Legend item."""
return self._legend
@property
def xlabel(self) -> str:
"""Label of X-axis."""
return self._xlabel
@xlabel.setter
def xlabel(self, label: str):
self.pgitem.setLabel(BOTTOM, label)
self._xlabel = label
@property
def ylabel(self) -> str:
"""Label of Y-axis."""
return self._ylabel
@ylabel.setter
def ylabel(self, label: str):
self.pgitem.setLabel(LEFT, label)
self._ylabel = label
@property
def title(self) -> str:
"""Title text of the graph."""
return self.pgitem.titleLabel.text
@title.setter
def title(self, value: str):
value = str(value)
self.pgitem.setTitle(value)
def _update_scene(self):
# Since plot item does not have graphics scene before being added to
# a graphical layout, mouse event should be connected afterward.
self.pgitem.scene().sigMouseClicked.connect(self._mouse_clicked)
[docs]class ViewBoxExt(pg.ViewBox):
def __init__(
self,
parent=None,
border=None,
lockAspect=False,
enableMouse=True,
invertY=False,
enableMenu=True,
name=None,
invertX=False,
defaultPadding=0.02,
):
pg.ViewBox.__init__(**locals())
from pyqtgraph import icons
self.button = pg.ButtonItem(icons.getGraphPixmap("ctrl"), 14, self)
self.button.hide()
[docs] def hoverEvent(self, ev):
try:
if ev.enter:
self.button.show()
if ev.exit:
self.button.hide()
except RuntimeError:
pass
[docs]class ImageItem(HasViewBox):
def __init__(
self, viewbox: pg.ViewBox | None = None, lock_contrast_limits: bool = False
):
if viewbox is None:
viewbox = ViewBoxExt(lockAspect=True, invertY=True)
self._lock_contrast_limits = lock_contrast_limits
super().__init__(viewbox)
self._image_item = pg.ImageItem()
tr = self._image_item.transform().translate(-0.5, -0.5)
self._image_item.setTransform(tr)
self._viewbox.addItem(self._image_item)
# prepare text overlay
self._text_overlay = TextItem(text="", color="gray")
self._text_overlay.native.setParentItem(self._viewbox)
# prepare scale bar
self._scale_bar = ScaleBar()
self._scale_bar.visible = False
self._scale_bar.native.setParentItem(self._viewbox)
self._scale_bar.native.anchor((1, 1), (1, 1), offset=(-20, -20))
# prepare title and labels
self._title = TextItem(text="", color="white", anchor=(0.5, 1.1))
self._title.pos = [1, 0]
self._viewbox.addItem(self._title.native)
self._title.visible = False
self._xlabel = TextItem(text="", color="white", anchor=(0.5, -0.1))
self._xlabel.pos = [1, 1]
self._viewbox.addItem(self._xlabel.native)
self._xlabel.visible = False
self._ylabel = TextItem(text="", color="white", anchor=(0.5, 1.1), angle=90)
self._ylabel.pos = [0, 1]
self._viewbox.addItem(self._ylabel.native)
self._ylabel.visible = False
if isinstance(viewbox, ViewBoxExt):
# prepare LUT histogram
self._hist = pg.HistogramLUTItem(orientation="horizontal")
self._hist.vb.setBackgroundColor([0, 0, 0, 0.2])
self._hist.setParentItem(self._viewbox)
self._hist.setVisible(False)
@viewbox.button.clicked.connect
def _(e):
visible = not self._hist.isVisible()
self._hist.setVisible(visible)
if visible:
self._hist._updateView()
width = min(160, self._viewbox.width())
self._hist.setFixedWidth(width)
self._cmap = "gray"
def _update_scene(self):
# Since plot item does not have graphics scene before being added to
# a graphical layout, mouse event should be connected afterward.
self._image_item.scene().sigMouseClicked.connect(self._mouse_clicked)
@property
def text_overlay(self) -> TextItem:
"""Text overlay on the image."""
return self._text_overlay
@property
def title(self) -> str:
"""Title text of the graph."""
return self._title.text
@title.setter
def title(self, value: str):
self._title.text = value
@property
def xlabel(self) -> str:
"""Label of X-axis."""
return self._xlabel.text
@xlabel.setter
def xlabel(self, value: str):
self._xlabel.text = value
@property
def ylabel(self) -> str:
"""Label of Y-axis."""
return self._ylabel.text
@xlabel.setter
def ylabel(self, value: str):
self._ylabel.text = value
@property
def scale_bar(self) -> ScaleBar:
"""Scale bar on the image."""
return self._scale_bar
@property
def lock_contrast_limits(self):
"""True if enable auto-contrast."""
return self._lock_contrast_limits
@lock_contrast_limits.setter
def lock_contrast_limits(self, value: bool):
self._lock_contrast_limits = bool(value)
@property
def _graphics(self):
return self._viewbox
@property
def image(self) -> np.ndarray | None:
"""Image data"""
if self._image_item.image is None:
return None
else:
return self._image_item.image.T
@image.setter
def image(self, image: np.ndarray):
no_image = self._image_item.image is None
if no_image:
auto_levels = True
else:
auto_levels = not self._lock_contrast_limits
clims = self.contrast_limits
img = np.asarray(image)
self._image_item.setImage(img.T, autoLevels=auto_levels)
self._hist.setImageItem(self._image_item)
self._hist._updateView()
if no_image:
self._viewbox.autoRange()
if not auto_levels:
self.contrast_limits = clims
sy, sx = img.shape[-2:]
self._title.pos = [sx / 2, self._title.pos[1]]
self._title.visible = True
self._xlabel.pos = [sx / 2, sy]
self._xlabel.visible = True
self._ylabel.pos = [0, sy / 2]
self._ylabel.visible = True
@image.deleter
def image(self):
self._image_item.clear()
self._title.visible = False
self._xlabel.visible = False
self._ylabel.visible = False
@property
def contrast_limits(self) -> list[float, float]:
"""Contrast limits of image"""
return self._image_item.levels
@contrast_limits.setter
def contrast_limits(self, value: tuple[float, float]):
self._hist.setLevels(*value)
@property
def cmap(self):
"""Color map"""
return self._cmap
@cmap.setter
def cmap(self, value):
if isinstance(value, str):
_cmap = cmap.get(value, source="matplotlib")
else:
_cmap = value
self._hist.gradient.setColorMap(_cmap)
self._cmap = value
[docs]class HasBackground(FreeWidget):
@property
def background_color(self):
return self.layoutwidget._background
@background_color.setter
def background_color(self, value):
value = convert_color_code(value)
self.layoutwidget.setBackground(value)
[docs]class QtPlotCanvas(HasBackground, PlotItem):
"""A 1-D data viewer that have similar API as napari Viewer."""
def __init__(self, **kwargs):
app = get_app()
# prepare widget
PlotItem.__init__(self)
self.layoutwidget = pg.GraphicsLayoutWidget()
self.layoutwidget.addItem(self.pgitem)
self._update_scene()
super().__init__(**kwargs)
self.set_widget(self.layoutwidget)
[docs]class QtImageCanvas(HasBackground, ImageItem):
"""A 2-D image viewer that have similar API as napari Viewer."""
def __init__(self, lock_contrast_limits: bool = False, **kwargs):
app = get_app()
# prepare widget
ImageItem.__init__(self, lock_contrast_limits=lock_contrast_limits)
self.layoutwidget = pg.GraphicsLayoutWidget()
self.layoutwidget.addItem(self._viewbox)
self._update_scene()
super().__init__(**kwargs)
self.set_widget(self.layoutwidget)
@property
def background_color(self):
return self.layoutwidget._background
@background_color.setter
def background_color(self, value):
value = convert_color_code(value)
self.layoutwidget.setBackground(value)
[docs]class Qt2YPlotCanvas(HasBackground):
"""A plot canvas with two Y-axis."""
def __init__(self, **kwargs):
app = get_app()
self.layoutwidget = pg.GraphicsLayoutWidget()
item_l = PlotItem()
item_r = SimpleViewBox()
self.layoutwidget.addItem(item_l.pgitem)
item_l.pgitem.scene().addItem(item_r._viewbox)
item_l.pgitem.getAxis("right").linkToView(item_r._viewbox)
item_r._viewbox.setXLink(item_l.pgitem)
item_l._update_scene()
item_l.pgitem.showAxis("right")
self._plot_items: tuple[PlotItem, SimpleViewBox] = (item_l, item_r)
self.updateViews()
item_l.pgitem.vb.sigResized.connect(self.updateViews)
super().__init__(**kwargs)
self.set_widget(self.layoutwidget)
def __getitem__(self, k: int) -> PlotItem:
return self._plot_items[k]
[docs] def updateViews(self):
item_l, item_r = self._plot_items
item_r._viewbox.setGeometry(item_l._viewbox.sceneBoundingRect())
item_r._viewbox.linkedViewChanged(item_l._viewbox, item_r._viewbox.XAxis)
_C = TypeVar("_C", bound=HasViewBox)
class _MultiPlot(HasBackground, Generic[_C]):
_base_item_class: type[_C]
def __init__(
self,
nrows: int = 1,
ncols: int = 1,
sharex: bool = False,
sharey: bool = False,
**kwargs,
):
"""
Multi-axes ``pyqtgraph`` canvas widget. Can contain multiple objects
of {cls}.
Parameters
----------
nrows : int, default is 1
Initial rows of axes.
ncols : int, default is 1
Initail columns of axes.
sharex : bool, default is False
If true, all the x-axes will be linked.
sharey : bool, default is False
If true, all the y-axes will be linked.
"""
app = get_app()
self.layoutwidget = pg.GraphicsLayoutWidget()
self._axes: list[_C] = []
self._sharex = sharex
self._sharey = sharey
super().__init__(**kwargs)
self.set_widget(self.layoutwidget)
if nrows * ncols > 0:
for r in range(nrows):
for c in range(ncols):
self.addaxis(r, c)
def __init_subclass__(cls) -> None:
"""Update doc."""
init = cls.__init__
init.__doc__ = init.__doc__.format(cls=cls._base_item_class.__name__)
def addaxis(
self,
row: int | None = None,
col: int | None = None,
rowspan: int = 1,
colspan: int = 1,
) -> _C:
"""Add a new axis to widget."""
item = self._base_item_class()
self._axes.append(item)
self.layoutwidget.addItem(item._graphics, row, col, rowspan, colspan)
item._update_scene()
if self._sharex and len(self._axes) > 1:
item._viewbox.setXLink(self._axes[0]._viewbox)
if self._sharey and len(self._axes) > 1:
item._viewbox.setYLink(self._axes[0]._viewbox)
return item
def __getitem__(self, k: int) -> _C:
return self._axes[k]
def __delitem__(self, k: int):
item = self._axes[k]
self.layoutwidget.removeItem(item._graphics)
def __iter__(self) -> Iterator[_C]:
return iter(self._axes)
[docs]class QtMultiPlotCanvas(_MultiPlot[PlotItem]):
"""A pyqtgraph-based canvas with multiple plot."""
_base_item_class = PlotItem
[docs]class QtMultiImageCanvas(_MultiPlot[ImageItem]):
"""A pyqtgraph-based canvas with multiple images."""
_base_item_class = ImageItem
def _check_xy(x, y):
if y is None:
if x is None:
x = []
y = []
else:
y = x
x = np.arange(len(y))
return x, y
def _check_colors(face_color, edge_color, color):
if color is None:
return face_color, edge_color
else:
if face_color is None and edge_color is None:
return color, color
else:
raise ValueError(
"Cannot set 'color' and either 'face_color' or "
"'edge_color' at the same time."
)