forked from matplotlib/napari-matplotlib
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbase.py
307 lines (255 loc) · 10.2 KB
/
base.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import os
from pathlib import Path
from typing import List, Optional, Tuple
import napari
from matplotlib.axes import Axes
from matplotlib.backends.backend_qtagg import (
FigureCanvas,
NavigationToolbar2QT,
)
from matplotlib.figure import Figure
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from .util import Interval, from_napari_css_get_size_of
__all__ = ["BaseNapariMPLWidget", "NapariMPLWidget"]
class BaseNapariMPLWidget(QWidget):
"""
Widget containing Matplotlib canvas and toolbar themed to match napari.
This creates a single FigureCanvas, which contains a single
`~matplotlib.figure.Figure`, and an associated toolbar. Both of these
are customised to match the visual style of the main napari window.
It is not responsible for creating any Axes, because different
widgets may want to implement different subplot layouts.
See Also
--------
NapariMPLWidget : A child class that also contains helpful attributes and
methods for working with napari layers.
"""
def __init__(
self,
napari_viewer: napari.Viewer,
parent: Optional[QWidget] = None,
):
super().__init__(parent=parent)
self.viewer = napari_viewer
self.canvas = FigureCanvas()
self.canvas.figure.patch.set_facecolor("none")
self.canvas.figure.set_layout_engine("constrained")
self.toolbar = NapariNavigationToolbar(
self.canvas, parent=self
) # type: ignore[no-untyped-call]
self._replace_toolbar_icons()
# callback to update when napari theme changed
# TODO: this isn't working completely (see issue #140)
# most of our styling respects the theme change but not all
self.viewer.events.theme.connect(self._on_theme_change)
self.setLayout(QVBoxLayout())
self.layout().addWidget(self.toolbar)
self.layout().addWidget(self.canvas)
@property
def figure(self) -> Figure:
"""Matplotlib figure."""
return self.canvas.figure
def add_single_axes(self) -> None:
"""
Add a single Axes to the figure.
The Axes is saved on the ``.axes`` attribute for later access.
"""
self.axes = self.figure.subplots()
self.apply_napari_colorscheme(self.axes)
def apply_napari_colorscheme(self, ax: Axes) -> None:
"""Apply napari-compatible colorscheme to an Axes."""
# get the foreground colours from current theme
theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False)
fg_colour = theme.foreground.as_hex() # fg is a muted contrast to bg
text_colour = theme.text.as_hex() # text is high contrast to bg
# changing color of axes background to transparent
ax.set_facecolor("none")
# changing colors of all axes
for spine in ax.spines:
ax.spines[spine].set_color(fg_colour)
ax.xaxis.label.set_color(text_colour)
ax.yaxis.label.set_color(text_colour)
# changing colors of axes labels
ax.tick_params(axis="x", colors=text_colour)
ax.tick_params(axis="y", colors=text_colour)
def _on_theme_change(self) -> None:
"""Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed.
Note:
At the moment we only handle the default 'light' and 'dark' napari themes.
"""
self._replace_toolbar_icons()
if self.figure.gca():
self.apply_napari_colorscheme(self.figure.gca())
def _theme_has_light_bg(self) -> bool:
"""
Does this theme have a light background?
Returns
-------
bool
True if theme's background colour has hsl lighter than 50%, False if darker.
"""
theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False)
_, _, bg_lightness = theme.background.as_hsl_tuple()
return bg_lightness > 0.5
def _get_path_to_icon(self) -> Path:
"""
Get the icons directory (which is theme-dependent).
Icons modified from
https://github.com/matplotlib/matplotlib/tree/main/lib/matplotlib/mpl-data/images
"""
icon_root = Path(__file__).parent / "icons"
if self._theme_has_light_bg():
return icon_root / "black"
else:
return icon_root / "white"
def _replace_toolbar_icons(self) -> None:
"""
Modifies toolbar icons to match the napari theme, and add some tooltips.
"""
icon_dir = self._get_path_to_icon()
for action in self.toolbar.actions():
text = action.text()
if text == "Pan":
action.setToolTip(
"Pan/Zoom: Left button pans; Right button zooms; "
"Click once to activate; Click again to deactivate"
)
if text == "Zoom":
action.setToolTip(
"Zoom to rectangle; Click once to activate; "
"Click again to deactivate"
)
if len(text) > 0: # i.e. not a separator item
icon_path = os.path.join(icon_dir, text + ".png")
action.setIcon(QIcon(icon_path))
class NapariMPLWidget(BaseNapariMPLWidget):
"""
Widget containing a Matplotlib canvas and toolbar.
In addition to ``BaseNapariMPLWidget``, this class handles callbacks
to automatically update figures when the layer selection or z-step
is changed in the napari viewer. To take advantage of this sub-classes
should implement the ``clear()`` and ``draw()`` methods.
When both the z-step and layer selection is changed, ``clear()`` is called
and if the number a type of selected layers are valid for the widget
``draw()`` is then called. When layer selection is changed ``on_update_layers()``
is also called, which can be useful e.g. for updating a layer list in a
selection widget.
Attributes
----------
viewer : `napari.Viewer`
Main napari viewer.
layers : `list`
List of currently selected napari layers.
See Also
--------
BaseNapariMPLWidget : The parent class of this widget. Contains helpful methods
for creating and working with the Matplotlib figure and any axes.
"""
#: Number of layers taken as input
n_layers_input = Interval(None, None)
#: Type of layer taken as input
input_layer_types: Tuple[napari.layers.Layer, ...] = (napari.layers.Layer,)
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
parent: Optional[QWidget] = None,
):
super().__init__(napari_viewer=napari_viewer, parent=parent)
self._setup_callbacks()
self.layers: List[napari.layers.Layer] = []
helper_text = self.n_layers_input._helper_text
if helper_text is not None:
self.layout().insertWidget(0, QLabel(helper_text))
@property
def n_selected_layers(self) -> int:
"""
Number of currently selected layers.
"""
return len(self.layers)
@property
def current_z(self) -> int:
"""
Current z-step of the napari viewer.
"""
return self.viewer.dims.current_step[0]
def _setup_callbacks(self) -> None:
"""
Sets up callbacks.
Sets up callbacks for when:
- Layer selection is changed
- z-step is changed
"""
# z-step changed in viewer
self.viewer.dims.events.current_step.connect(self._draw)
# Layer selection changed in viewer
self.viewer.layers.selection.events.changed.connect(
self._update_layers
)
def _update_layers(self, event: napari.utils.events.Event) -> None:
"""
Update the ``layers`` attribute with currently selected layers and re-draw.
"""
self.layers = list(self.viewer.layers.selection)
self.layers = sorted(self.layers, key=lambda layer: layer.name)
self.on_update_layers()
self._draw()
def _draw(self) -> None:
"""
Clear current figure, check selected layers are correct, and draw new
figure if so.
"""
self.clear()
if self.n_selected_layers in self.n_layers_input and all(
isinstance(layer, self.input_layer_types) for layer in self.layers
):
self.draw()
self.apply_napari_colorscheme(self.figure.gca())
self.canvas.draw()
def clear(self) -> None:
"""
Clear any previously drawn figures.
This is a no-op, and is intended for derived classes to override.
"""
def draw(self) -> None:
"""
Re-draw any figures.
This is a no-op, and is intended for derived classes to override.
"""
def on_update_layers(self) -> None:
"""
Called when the selected layers are updated.
This is a no-op, and is intended for derived classes to override.
"""
class NapariNavigationToolbar(NavigationToolbar2QT):
"""Custom Toolbar style for Napari."""
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)
self.setIconSize(
from_napari_css_get_size_of(
"QtViewerPushButton", fallback=(28, 28)
)
)
def _update_buttons_checked(self) -> None:
"""Update toggle tool icons when selected/unselected."""
super()._update_buttons_checked()
icon_dir = self.parentWidget()._get_path_to_icon()
# changes pan/zoom icons depending on state (checked or not)
if "pan" in self._actions:
if self._actions["pan"].isChecked():
self._actions["pan"].setIcon(
QIcon(os.path.join(icon_dir, "Pan_checked.png"))
)
else:
self._actions["pan"].setIcon(
QIcon(os.path.join(icon_dir, "Pan.png"))
)
if "zoom" in self._actions:
if self._actions["zoom"].isChecked():
self._actions["zoom"].setIcon(
QIcon(os.path.join(icon_dir, "Zoom_checked.png"))
)
else:
self._actions["zoom"].setIcon(
QIcon(os.path.join(icon_dir, "Zoom.png"))
)