# Copyright 2018-2021 Nick Anthony, Backman Biophotonics Lab, Northwestern University
#
# This file is part of mpl_qt_viz.
#
# mpl_qt_viz is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# mpl_qt_viz is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with mpl_qt_viz. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import copy
import typing
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from matplotlib.widgets import AxesWidget
if typing.TYPE_CHECKING:
from matplotlib.backend_bases import LocationEvent, KeyEvent, MouseEvent
from matplotlib.image import AxesImage
[docs]class AxManager:
"""An object to manage multiple selector tools on a single axes. Only one of these should exist per Axes object.
Args:
ax: The matplotlib Axes object to draw on.
"""
def __init__(self, ax: Axes):
self.artists = []
self.ax = ax
if hasattr(ax, 'pwspyAxisManager'):
raise Exception("Axes already has an AxManager assiged.")
ax.pwspyAxisManager = self
self.canvas = self.ax.figure.canvas
self.canvas.mpl_connect('draw_event', self._update_background)
self.background = None
[docs] def addArtist(self, artist: Artist):
"""Adds an artist to the manager.
Args:
artist: A new matplotlib `Artist` to be managed.
"""
#TODO implement more cases here.
self.artists.append(artist)
if isinstance(artist, Patch):
self.ax.add_patch(artist)
elif isinstance(artist, Line2D):
self.ax.add_line(artist)
else:
self.ax.add_artist(artist)
[docs] def removeArtist(self, artist: Artist):
"""Remove a single `Artist` from the manaager
Args:
artist: A previously added matplotlib `Artist`.
"""
self.artists.remove(artist)
artist.remove()
self.update()
[docs] def update(self):
"""Re-render the axes. Call this after you know that something has changed with the plot."""
#TODO what is the return value here?
if not self.ax.get_visible():
return False
if self.canvas.supports_blit:
if self.background is not None:
self.canvas.restore_region(self.background)
for artist in self.artists:
if artist.get_visible():
try:
self.ax.draw_artist(artist)
except AttributeError:
pass # This can happen if the figure hasn't already had it's initial draw
try:
self.canvas.blit(self.ax.bbox)
except AttributeError: #Sometimes this happens when first opening
self.canvas.draw_idle()
else:
self.canvas.draw_idle()
return False
def _update_background(self, event):
"""force an update of the background"""
# If you add a call to `ignore` here, you'll want to check edge case:
# `release` can call a draw event even when `ignore` is True.
if self.canvas.supports_blit:
self.background = self.canvas.copy_from_bbox(self.ax.bbox)
class InteractiveWidgetBase(AxesWidget):
"""Base class for other selection widgets in this package. Requires to be managed by an AxManager. Inherited classes
can implement a number of action handlers like mouse actions and keyboard presses.
Args:
axMan: A reference to the `AxManager` object used to manage drawing the matplotlib `Axes` that this selector widget is active on.
image: A reference to a matplotlib `AxesImage`. Selectors may use this reference to get information such as data values from the image
for computer vision related tasks.
Attributes:
state (set): A `set` that stores strings indicating the current state (Are we dragging the mouse, is the shift
key pressed, etc.
artists (list): A `list` of matplotlib widgets managed by the selector.
axMan (AxManager): The manager for the Axes. Call its `update` method when something needs to be drawn.
image (AxesImage): A reference to the image being interacted with. Can be used to get the image data.
"""
def __init__(self, axMan: AxManager, image: typing.Optional[AxesImage] = None):
AxesWidget.__init__(self, axMan.ax)
self.axMan = axMan
self.image = image
self._artists = {}
self.connect_event('motion_notify_event', self.onmove)
self.connect_event('button_press_event', self.press)
self.connect_event('button_release_event', self.release)
self.connect_event('key_press_event', self.on_key_press)
self.connect_event('key_release_event', self.on_key_release)
self.connect_event('scroll_event', self.on_scroll)
self.state_modifier_keys = dict(space=' ', clear='escape', shift='shift', control='control')
# will save the data (position at mouseclick)
self.eventpress = None
# will save the data (pos. at mouserelease)
self.eventrelease = None
self._prev_event = None
self.state = set()
def __del__(self):
try:
self.removeArtists()
except TypeError: # Sometimes when the program closes the order that objects are deleted in causes a None typeerror to occur here.
pass
def set_active(self, active: bool):
AxesWidget.set_active(self, active)
if active:
self.axMan._update_background(None)
def ignore(self, event):
"""return *True* if *event* should be ignored. No event callbacks will be called if this returns true."""
if not self.active or not self.axMan.ax.get_visible():
return True
if not self.canvas.widgetlock.available(self): # If canvas was locked
return True
if not hasattr(event, 'button'):
event.button = None
if self.eventpress is None: # If no button was pressed yet ignore the event if it was out of the axes
return event.inaxes != self.ax
if event.button == self.eventpress.button: # If a button was pressed, check if the release-button is the same.
return False
# If a button was pressed, check if the release-button is the same.
return event.inaxes != self.ax or event.button != self.eventpress.button
def __get_data(self, event: LocationEvent):
"""Get the xdata and ydata for event, with limits"""
if event.xdata is None:
return None, None
x0, x1 = self.axMan.ax.get_xbound()
y0, y1 = self.axMan.ax.get_ybound()
xdata = max(x0, event.xdata)
xdata = min(x1, xdata)
ydata = max(y0, event.ydata)
ydata = min(y1, ydata)
return xdata, ydata
def __clean_event(self, event: LocationEvent):
"""Clean up an event
Use prev event if there is no xdata
Limit the xdata and ydata to the axes limits
Set the prev event
"""
if event.xdata is None:
event = self._prev_event
else:
event = copy.copy(event)
event.xdata, event.ydata = self.__get_data(event)
self._prev_event = event
return event
def press(self, event: MouseEvent):
"""Button press handler and validator"""
if not self.ignore(event):
event = self.__clean_event(event)
self.eventpress = event
key = event.key or ''
key = key.replace('ctrl', 'control')
# space state is locked in on a button press
if key == self.state_modifier_keys['space']:
self.state.add('space')
self._press(event)
return True
return False
def release(self, event: MouseEvent):
"""Button release event handler and validator"""
if not self.ignore(event) and self.eventpress:
event = self.__clean_event(event)
self.eventrelease = event
self._release(event)
self.eventpress = None
self.eventrelease = None
self.state.discard('space')
return True
return False
def onmove(self, event: MouseEvent):
"""Cursor move event handler and validator"""
if not self.ignore(event):
event = self.__clean_event(event)
if self.eventpress:
self._ondrag(event)
else:
self._onhover(event)
return True
return False
def on_scroll(self, event: MouseEvent):
"""Mouse scroll event handler and validator"""
if not self.ignore(event):
self._on_scroll(event)
def on_key_press(self, event: KeyEvent):
"""Key press event handler and validator for all selection widgets"""
if self.active:
key = event.key or ''
key = key.replace('ctrl', 'control')
# if key == self.state_modifier_keys['clear']: # This kind of thing can be handled individually by subclasses
# self.set_visible(False)
# return
for (state, modifier) in self.state_modifier_keys.items():
if modifier in key:
self.state.add(state)
self._on_key_press(event)
def on_key_release(self, event: KeyEvent):
"""Key release event handler and validator"""
if self.active:
key = event.key or ''
for (state, modifier) in self.state_modifier_keys.items():
if modifier in key:
self.state.discard(state)
self._on_key_release(event)
def set_visible(self, visible: bool):
""" Set the visibility of our artists """
for artist, shouldBeVisible in self._artists.items():
artist.set_visible(shouldBeVisible and visible)
self.axMan.update()
def setArtistVisible(self, artist: Artist, visible: bool):
"set visibility of a single artist, invisible artists will not be reenabled with `set_visible` True."
self._artists[artist] = visible
artist.set_visible(visible)
def addArtist(self, artist: Artist):
"""Add a matplotlib artist to be managed."""
self.axMan.addArtist(artist)
self._artists[artist] = True # New artists are assumed that they should be visible.
def removeArtists(self):
"""Remove all artist objects associated with this selector"""
while len(self._artists) > 0: # Using a for loop here has problems since we remove items as we go.
self.removeArtist(list(self._artists.keys())[0])
def removeArtist(self, artist: Artist):
self._artists.pop(artist)
self.axMan.removeArtist(artist)
# Overridable events
def _on_key_release(self, event: KeyEvent):
"""Key release event handler"""
pass
def _on_key_press(self, event: KeyEvent):
"""Key press event handler - use for widget-specific key press actions."""
pass
def _on_scroll(self, event: MouseEvent):
"""Mouse scroll event handler"""
pass
def _ondrag(self, event: MouseEvent):
"""Cursor move event handler"""
pass
def _onhover(self, event: MouseEvent):
pass
def _release(self, event: MouseEvent):
"""Button release event handler"""
pass
def _press(self, event: MouseEvent):
"""Button press handler"""
pass