From ba8562b6c3d270db483a7d26dbdbdc63143cad15 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:46:17 -0600 Subject: [PATCH 01/42] Start base class for new interactive selector tools --- lib/matplotlib/interactive_selectors.py | 361 ++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 lib/matplotlib/interactive_selectors.py diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py new file mode 100644 index 000000000000..26a2d708217f --- /dev/null +++ b/lib/matplotlib/interactive_selectors.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- + +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import copy + +import numpy as np + +from .patches import Polygon +from .lines import Line2D + + +class BaseTool(object): + + """Widget that is connected to a single + :class:`~matplotlib.axes.Axes`. + + To guarantee that the tool remains responsive and not garbage-collected, + a reference to the object should be maintained by the user. + + This is necessary because the callback registry + maintains only weak-refs to the functions, which are member + functions of the tool. If there are no references to the tool + object it may be garbage collected which will disconnect the + callbacks. + + Parameters + ---------- + ax: :class:`matplotlib.axes.Axes` + The parent axes for the tool. + on_select: callable, optional + A callback for when a selection is made `on_select(tool)`. + on_move: callable, optional + A callback for when the tool is moved `on_move(tool)`. + on_accept: callable, optional + A callback for when the selection is accepted `on_accept(tool)`. + This is called in response to an 'accept' key event. + interactive: boolean, optional + Whether to allow interaction with the shape using handles. + allow_redraw: boolean, optional + Whether to allow the tool to redraw itself or whether it must be + drawn programmatically and then dragged. + shape_props: dict, optional + The properties of the shape patch. + handle_props: dict, optional + The properties of the handle markers. + pickradius: float, optional + The pick radius of the shape and the handles in pixels. + useblit: boolean, optional + Whether to use blitting while drawing if available. + button: int or list of int, optional + Which mouse button(s) should be used. Typically: + 1 = left mouse button + 2 = center mouse button (scroll wheel) + 3 = right mouse button + keys: dict, optional + A mapping of key shortcuts for the tool. + 'move': Move the existing shape. + 'clear': Clear the current shape. + 'square': Makes the shape square. + 'center': Make the initial point the center of the shape. + 'polygon': Draw a polygon shape for the lasso. + 'square' and 'center' can be combined. + 'accept': Trigger an `on_accept` callback. + + Attributes + ---------- + ax: :class:`matplotlib.axes.Axes` + The parent axes for the tool. + canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass + The parent figure canvas for the tool. + active: bool + If False, the widget does not respond to events. + verts: nd-array of floats (2, N) + The vertices of the tool. + """ + + def __init__(self, ax, on_select=None, on_move=None, on_accept=None, + interactive=True, allow_redraw=True, + shape_props=None, handle_props=None, + pickradius=10, useblit=True, + button=None, state_modifier_keys=None): + self.ax = ax + self.canvas = ax.figure.canvas + self.active = True + + self._callback_on_move = _dummy if on_move is None else on_move + self._callback_on_accept = _dummy if on_accept is None else on_accept + self._callback_on_select = _dummy if on_select is None else on_select + + self._interactive = interactive + self._allow_redraw = allow_redraw + self._useblit = useblit and self.canvas.supports_blit + self._keys = dict(move=' ', clear='escape', + accept='enter', polygon='shift', + square='shift', center='control') + self._keys.update(state_modifier_keys or {}) + + if isinstance(button, int): + self._buttons = [button] + else: + self._buttons = button + + props = dict(facecolor='red', edgecolor='black', visible=False, + alpha=0.2, fill=True, pickradius=pickradius) + props.update(shape_props or {}) + self._patch = Polygon([[0, 0], [1, 1]], True, **props) + self.ax.add_patch(self._patch) + + props = dict(marker='0', markersize=7, mfc='w', ls='none', + alpha=0.5, visible=False, label='_nolegend_', + animated=self.useblit, pickradius=pickradius) + props.update(handle_props or {}) + self._handles = Line2D([], [], **props) + self.ax.add_line(self._handles) + + self._artists = [self._patch, self._handles] + self._state = set() + self._drawing = False + self._dragging = False + self._current = False + self._verts = [] + self._prev_verts = None + self._background = None + self._prevxy = None + self._pos = [np.NaN, np.NaN] + self._size = [np.NaN, np.NaN] + + # Connect the major canvas events to methods.""" + self._cids = [] + self._connect_event('pick_event', self._handle_pick) + self._connect_event('motion_notify_event', self._handle_event) + self._connect_event('button_press_event', self._handle_event) + self._connect_event('button_release_event', self._handle_event) + self._connect_event('draw_event', self._handle_draw) + self._connect_event('key_press_event', self._handle_key_press) + self._connect_event('key_release_event', self._handle_event) + self._connect_event('scroll_event', self._handle_event) + + @property + def verts(self): + return self._verts + + @verts.setter + def verts(self, value): + value = np.asarray(value) + assert value.ndim == 2 + assert value.shape[1] == 2 + self._verts = np.array(value) + self._patch.set_xy(value) + self._set_handles_xy(value) + self._patch.set_animated(False) + self._handles.set_animated(False) + self.canvas.draw_idle() + + def remove(self): + """Clean up the tool.""" + for c in self._cids: + self.canvas.mpl_disconnect(c) + for artist in self._artists: + self.ax.remove(artist) + self.canvas.draw_idle() + + def _handle_pick(self, artist, event): + pass + + def _handle_draw(self, event): + """Update the ax background on a draw event""" + if self._useblit: + self._background = self.canvas.copy_from_bbox(self.ax.bbox) + + def _handle_event(self, event): + """Handle default actions for events and call to event handlers""" + if self._ignore(event): + return + event = self._clean_event(event) + + if event.type == 'button_press_event': + self._on_press(event) + + elif event.type == 'motion_notify_event': + if self._drawing: + self._on_move(event) + self._callback_on_move(self) + + elif event.type == 'button_release_event': + if self._drawing: + self._on_release(event) + if not self.drawing: + self._callback_on_select(self) + + elif event.type == 'key_release_event': + for (state, modifier) in self.state_modifier_keys.items(): + # Keep move state locked until button released. + if state == 'move' and self._drawing: + continue + if modifier in event.key: + self.state.discard(state) + self._on_key_release(event) + + elif event.type == 'scroll_event': + self._on_scroll(event) + + def _handle_key_press(self, event): + """Handle key_press_event defaults and call to subclass handler""" + if event.key == self._keys['clear']: + if self._dragging: + self.verts = self._prev_verts + self._finish_drawing() + elif self._drawing: + for artist in self._artists: + artist.set_visible(False) + self._finish_drawing() + return + + elif event.key == self._keys['accept']: + if not self._drawing: + self._callback_on_accept(self) + if self._allow_redraw: + for artist in self._artists: + artist.set_visible(False) + self.canvas.draw_idle() + + for (state, modifier) in self._keys.items(): + if modifier in event.key: + self._state.add(state) + self._on_key_press(event) + + def _clean_event(self, event): + """Clean up an event + + Use prev xy data if there is no xdata (left the axes) + Limit the xdata and ydata to the axes limits + Set the prev xy data + """ + event = copy.copy(event) + if event.xdata is not None: + x0, x1 = self.ax.get_xbound() + y0, y1 = self.ax.get_ybound() + xdata = max(x0, event.xdata) + event.xdata = min(x1, xdata) + ydata = max(y0, event.ydata) + event.ydata = min(y1, ydata) + self._prevxy = event.xdata, event.ydata + else: + event.xdata, event.ydata = self._prevxy + + event.key = event.key or '' + event.key = event.key.replace('ctrl', 'control') + return event + + def _connect_event(self, event, callback): + """Connect callback with an event. + + This should be used in lieu of `figure.canvas.mpl_connect` since this + function stores callback ids for later clean up. + """ + cid = self.canvas.mpl_connect(event, callback) + self._cids.append(cid) + + def _ignore(self, event): + """Return *True* if *event* should be ignored""" + if not self._active or not self.ax.get_visible(): + return True + + # If canvas was locked + if not self.canvas.widgetlock.available(self): + return True + + # If we are currently drawing + if self._drawing: + return False + + if event.inaxes != self.ax: + return True + + # If it is an invalid button press + if self._buttons is not None: + if getattr(event, 'button', None) not in self._buttons: + return True + + return False + + def _update(self): + """Update the artists while drawing""" + if not self.ax.get_visible(): + return + + if self._useblit: + if self._background is not None: + self.canvas.restore_region(self._background) + for artist in self._artists: + self.ax.draw_artist(artist) + + self.canvas.blit(self.ax.bbox) + else: + self.canvas.draw_idle() + + def _start_drawing(self): + """Start drawing or dragging the shape""" + self._drawing = True + if self._mode == 'interact': + for artist in self._artists: + artist.set_visible(False) + self.canvas.draw() + for artist in self._artists: + artist.set_animated(self._useblit) + artist.set_visible(True) + self._update() + + def _finish_drawing(self): + """Finish drawing or dragging the shape""" + self._drawing = False + self._dragging = False + if self._interactive: + for artist in self._artists: + artist.set_animated(False) + else: + for artist in self._artists: + artist.set_visible(False) + self._state = set() + self._prev_verts = self._verts + self.canvas.draw_idle() + + ############################################################# + # The following are meant to be subclassed + ############################################################# + def _set_handles_xy(self, value): + """By default use the corners and the center.""" + value = np.vstack((value, np.mean(value, axis=0))) + self._handles.set_xy(value) + + def _on_press(self, event): + """Handle a button_press_event""" + pass + + def _on_move(self, event): + """Handle a motion_notify_event""" + pass + + def _on_release(self, event): + """Handle a button_release_event""" + pass + + def _on_key_press(self, event): + """Handle a key_press_event""" + pass + + def _on_key_release(self, event): + """Handle a key_release_event""" + pass + + def _on_scroll(self, event): + """Handle a scroll_event""" + pass + + +def _dummy(tool): + """A dummy callback for a tool.""" + pass From 1e681945987a12475f7c8080f5e5845ceb773fc8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:48:13 -0600 Subject: [PATCH 02/42] Update docstring --- lib/matplotlib/interactive_selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 26a2d708217f..3d077ed5022f 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -13,7 +13,7 @@ class BaseTool(object): - """Widget that is connected to a single + """Interactive selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. To guarantee that the tool remains responsive and not garbage-collected, From 562d85289140b91d4c98f7310d512a0e57f13f94 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:50:17 -0600 Subject: [PATCH 03/42] Cleanup --- lib/matplotlib/interactive_selectors.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 3d077ed5022f..79f18897673a 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -45,8 +45,6 @@ class BaseTool(object): The properties of the shape patch. handle_props: dict, optional The properties of the handle markers. - pickradius: float, optional - The pick radius of the shape and the handles in pixels. useblit: boolean, optional Whether to use blitting while drawing if available. button: int or list of int, optional @@ -79,8 +77,7 @@ class BaseTool(object): def __init__(self, ax, on_select=None, on_move=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, - pickradius=10, useblit=True, - button=None, state_modifier_keys=None): + useblit=True, button=None, keys=None): self.ax = ax self.canvas = ax.figure.canvas self.active = True @@ -95,7 +92,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._keys = dict(move=' ', clear='escape', accept='enter', polygon='shift', square='shift', center='control') - self._keys.update(state_modifier_keys or {}) + self._keys.update(keys or {}) if isinstance(button, int): self._buttons = [button] @@ -103,14 +100,14 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._buttons = button props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, pickradius=pickradius) + alpha=0.2, fill=True, pickradius=10) props.update(shape_props or {}) self._patch = Polygon([[0, 0], [1, 1]], True, **props) self.ax.add_patch(self._patch) props = dict(marker='0', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', - animated=self.useblit, pickradius=pickradius) + animated=self.useblit, pickradius=10) props.update(handle_props or {}) self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) From 2d580b3688dc4a47c76cd5af42e9b6ab239052a1 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:50:43 -0600 Subject: [PATCH 04/42] Comment --- lib/matplotlib/interactive_selectors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 79f18897673a..0cd3d9743f83 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -160,6 +160,7 @@ def remove(self): self.canvas.draw_idle() def _handle_pick(self, artist, event): + # TODO: implement picking logic. pass def _handle_draw(self, event): From d57c49d29fe4d84bd66d74f5323ae3b6e9e29076 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:55:40 -0600 Subject: [PATCH 05/42] Clean up interactivity handling --- lib/matplotlib/interactive_selectors.py | 27 ++++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 0cd3d9743f83..1f2e2c10d64d 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -105,14 +105,18 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._patch = Polygon([[0, 0], [1, 1]], True, **props) self.ax.add_patch(self._patch) - props = dict(marker='0', markersize=7, mfc='w', ls='none', - alpha=0.5, visible=False, label='_nolegend_', - animated=self.useblit, pickradius=10) - props.update(handle_props or {}) - self._handles = Line2D([], [], **props) - self.ax.add_line(self._handles) - - self._artists = [self._patch, self._handles] + if self._interactive: + props = dict(marker='0', markersize=7, mfc='w', ls='none', + alpha=0.5, visible=False, label='_nolegend_', + animated=self.useblit, pickradius=10) + props.update(handle_props or {}) + self._handles = Line2D([], [], **props) + self.ax.add_line(self._handles) + + self._artists = [self._patch, self._handles] + else: + self._artists = [self._patch] + self._state = set() self._drawing = False self._dragging = False @@ -146,9 +150,10 @@ def verts(self, value): assert value.shape[1] == 2 self._verts = np.array(value) self._patch.set_xy(value) - self._set_handles_xy(value) + if self._interactive: + self._set_handles_xy(value) + self._handles.set_animated(False) self._patch.set_animated(False) - self._handles.set_animated(False) self.canvas.draw_idle() def remove(self): @@ -160,6 +165,8 @@ def remove(self): self.canvas.draw_idle() def _handle_pick(self, artist, event): + if not self._interactive: + return # TODO: implement picking logic. pass From 55e0de115916291f776cd08701805873143380ba Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 07:57:16 -0600 Subject: [PATCH 06/42] Minor cleanup --- lib/matplotlib/interactive_selectors.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 1f2e2c10d64d..660e1a823b17 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -108,7 +108,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, if self._interactive: props = dict(marker='0', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', - animated=self.useblit, pickradius=10) + pickradius=10) props.update(handle_props or {}) self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) @@ -125,8 +125,6 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._prev_verts = None self._background = None self._prevxy = None - self._pos = [np.NaN, np.NaN] - self._size = [np.NaN, np.NaN] # Connect the major canvas events to methods.""" self._cids = [] From 940b014e8cd2bcfd8c07c5eb4e49e24c39bef4d3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 08:01:48 -0600 Subject: [PATCH 07/42] Make interactive and allow_redraw attributes --- lib/matplotlib/interactive_selectors.py | 40 +++++++++++++------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 660e1a823b17..36073d0d0665 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -70,6 +70,11 @@ class BaseTool(object): The parent figure canvas for the tool. active: bool If False, the widget does not respond to events. + interactive: boolean + Whether to allow interaction with the shape using handles. + allow_redraw: boolean + Whether to allow the tool to redraw itself or whether it must be + drawn programmatically and then dragged. verts: nd-array of floats (2, N) The vertices of the tool. """ @@ -81,13 +86,13 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.ax = ax self.canvas = ax.figure.canvas self.active = True + self.interactive = interactive + self.allow_redraw = allow_redraw self._callback_on_move = _dummy if on_move is None else on_move self._callback_on_accept = _dummy if on_accept is None else on_accept self._callback_on_select = _dummy if on_select is None else on_select - self._interactive = interactive - self._allow_redraw = allow_redraw self._useblit = useblit and self.canvas.supports_blit self._keys = dict(move=' ', clear='escape', accept='enter', polygon='shift', @@ -105,22 +110,17 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._patch = Polygon([[0, 0], [1, 1]], True, **props) self.ax.add_patch(self._patch) - if self._interactive: - props = dict(marker='0', markersize=7, mfc='w', ls='none', - alpha=0.5, visible=False, label='_nolegend_', - pickradius=10) - props.update(handle_props or {}) - self._handles = Line2D([], [], **props) - self.ax.add_line(self._handles) - - self._artists = [self._patch, self._handles] - else: - self._artists = [self._patch] + props = dict(marker='0', markersize=7, mfc='w', ls='none', + alpha=0.5, visible=False, label='_nolegend_', + pickradius=10) + props.update(handle_props or {}) + self._handles = Line2D([], [], **props) + self.ax.add_line(self._handles) + self._artists = [self._patch, self._handles] self._state = set() self._drawing = False self._dragging = False - self._current = False self._verts = [] self._prev_verts = None self._background = None @@ -148,7 +148,7 @@ def verts(self, value): assert value.shape[1] == 2 self._verts = np.array(value) self._patch.set_xy(value) - if self._interactive: + if self.interactive: self._set_handles_xy(value) self._handles.set_animated(False) self._patch.set_animated(False) @@ -163,7 +163,7 @@ def remove(self): self.canvas.draw_idle() def _handle_pick(self, artist, event): - if not self._interactive: + if not self.interactive: return # TODO: implement picking logic. pass @@ -220,7 +220,7 @@ def _handle_key_press(self, event): elif event.key == self._keys['accept']: if not self._drawing: self._callback_on_accept(self) - if self._allow_redraw: + if self.allow_redraw: for artist in self._artists: artist.set_visible(False) self.canvas.draw_idle() @@ -303,20 +303,22 @@ def _update(self): def _start_drawing(self): """Start drawing or dragging the shape""" self._drawing = True - if self._mode == 'interact': + if self.interactive: for artist in self._artists: artist.set_visible(False) self.canvas.draw() for artist in self._artists: artist.set_animated(self._useblit) artist.set_visible(True) + else: + self._handles.set_visible(False) self._update() def _finish_drawing(self): """Finish drawing or dragging the shape""" self._drawing = False self._dragging = False - if self._interactive: + if self.interactive: for artist in self._artists: artist.set_animated(False) else: From a8b2a945f05f65281167bc2e79c66c95291bcc2f Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 08:03:30 -0600 Subject: [PATCH 08/42] Keep handles in sync even when not interactive --- lib/matplotlib/interactive_selectors.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 36073d0d0665..0e04fec386a9 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -148,9 +148,8 @@ def verts(self, value): assert value.shape[1] == 2 self._verts = np.array(value) self._patch.set_xy(value) - if self.interactive: - self._set_handles_xy(value) - self._handles.set_animated(False) + self._set_handles_xy(value) + self._handles.set_animated(False) self._patch.set_animated(False) self.canvas.draw_idle() From e8e18078029643e0561a6b44763934df38773dc2 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 08:07:33 -0600 Subject: [PATCH 09/42] Update docs --- lib/matplotlib/interactive_selectors.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 0e04fec386a9..2ac16e29cf4c 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -75,7 +75,7 @@ class BaseTool(object): allow_redraw: boolean Whether to allow the tool to redraw itself or whether it must be drawn programmatically and then dragged. - verts: nd-array of floats (2, N) + verts: nd-array of floats (N, 2) The vertices of the tool. """ @@ -330,10 +330,10 @@ def _finish_drawing(self): ############################################################# # The following are meant to be subclassed ############################################################# - def _set_handles_xy(self, value): - """By default use the corners and the center.""" - value = np.vstack((value, np.mean(value, axis=0))) - self._handles.set_xy(value) + def _set_handles_xy(self, vertices): + """By default use the vertices and the center.""" + vertices = np.vstack((vertices, np.mean(vertices, axis=0))) + self._handles.set_xy(vertices) def _on_press(self, event): """Handle a button_press_event""" From 4dec7c3b5a661c73d49fefef29aafb097241cb4f Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 08:13:12 -0600 Subject: [PATCH 10/42] Clean up the clean event --- lib/matplotlib/interactive_selectors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 2ac16e29cf4c..9b2aacdc3fc3 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -232,12 +232,12 @@ def _handle_key_press(self, event): def _clean_event(self, event): """Clean up an event - Use prev xy data if there is no xdata (left the axes) + Use prev xy data if there is no xdata (outside the axes) Limit the xdata and ydata to the axes limits Set the prev xy data """ event = copy.copy(event) - if event.xdata is not None: + if event.xdata is None or event.ydata is None: x0, x1 = self.ax.get_xbound() y0, y1 = self.ax.get_ybound() xdata = max(x0, event.xdata) @@ -289,7 +289,7 @@ def _update(self): if not self.ax.get_visible(): return - if self._useblit: + if self._useblit and self._drawing: if self._background is not None: self.canvas.restore_region(self._background) for artist in self._artists: From 03a2e8496f8d3bae4361fc461cd33b2ab879b292 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 08:52:36 -0600 Subject: [PATCH 11/42] Implement focus/drag/selection logic --- lib/matplotlib/interactive_selectors.py | 90 ++++++++++++++++--------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 9b2aacdc3fc3..49fa5305f812 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -7,8 +7,9 @@ import numpy as np -from .patches import Polygon -from .lines import Line2D +# TODO: convert these to relative when finished +from matplotlib.patches import Polygon +from matplotlib.lines import Line2D class BaseTool(object): @@ -68,7 +69,7 @@ class BaseTool(object): The parent axes for the tool. canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass The parent figure canvas for the tool. - active: bool + active: boolean If False, the widget does not respond to events. interactive: boolean Whether to allow interaction with the shape using handles. @@ -77,6 +78,8 @@ class BaseTool(object): drawn programmatically and then dragged. verts: nd-array of floats (N, 2) The vertices of the tool. + focused: boolean + Whether the tool has focus for keyboard and scroll events. """ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, @@ -88,6 +91,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.active = True self.interactive = interactive self.allow_redraw = allow_redraw + self.focused = True self._callback_on_move = _dummy if on_move is None else on_move self._callback_on_accept = _dummy if on_accept is None else on_accept @@ -105,14 +109,14 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._buttons = button props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, pickradius=10) + alpha=0.2, fill=True, picker=5) props.update(shape_props or {}) self._patch = Polygon([[0, 0], [1, 1]], True, **props) self.ax.add_patch(self._patch) - props = dict(marker='0', markersize=7, mfc='w', ls='none', + props = dict(marker='o', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', - pickradius=10) + picker=10) props.update(handle_props or {}) self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) @@ -121,6 +125,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._state = set() self._drawing = False self._dragging = False + self._drag_idx = None self._verts = [] self._prev_verts = None self._background = None @@ -128,7 +133,6 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, # Connect the major canvas events to methods.""" self._cids = [] - self._connect_event('pick_event', self._handle_pick) self._connect_event('motion_notify_event', self._handle_event) self._connect_event('button_press_event', self._handle_event) self._connect_event('button_release_event', self._handle_event) @@ -149,8 +153,11 @@ def verts(self, value): self._verts = np.array(value) self._patch.set_xy(value) self._set_handles_xy(value) + self._patch.set_visible(True) self._handles.set_animated(False) self._patch.set_animated(False) + if self.interactive: + self._handles.set_visible(True) self.canvas.draw_idle() def remove(self): @@ -161,12 +168,6 @@ def remove(self): self.ax.remove(artist) self.canvas.draw_idle() - def _handle_pick(self, artist, event): - if not self.interactive: - return - # TODO: implement picking logic. - pass - def _handle_draw(self, event): """Update the ax background on a draw event""" if self._useblit: @@ -178,22 +179,26 @@ def _handle_event(self, event): return event = self._clean_event(event) - if event.type == 'button_press_event': - self._on_press(event) + if event.name == 'button_press_event': + self._dragging, idx = self._handles.contains(event) + if self._dragging: + self._drag_idx = idx[0] + if self._dragging or self.allow_redraw: + self._on_press(event) + if not self.allow_redraw: + self.focused = self._patch.contains(event)[0] - elif event.type == 'motion_notify_event': + elif event.name == 'motion_notify_event': if self._drawing: self._on_move(event) self._callback_on_move(self) - elif event.type == 'button_release_event': + elif event.name == 'button_release_event': if self._drawing: self._on_release(event) - if not self.drawing: - self._callback_on_select(self) - elif event.type == 'key_release_event': - for (state, modifier) in self.state_modifier_keys.items(): + elif event.name == 'key_release_event' and self.focused: + for (state, modifier) in self._keys.items(): # Keep move state locked until button released. if state == 'move' and self._drawing: continue @@ -201,19 +206,21 @@ def _handle_event(self, event): self.state.discard(state) self._on_key_release(event) - elif event.type == 'scroll_event': + elif event.name == 'scroll_event' and self.focused: self._on_scroll(event) def _handle_key_press(self, event): """Handle key_press_event defaults and call to subclass handler""" + if not self.focused: + return if event.key == self._keys['clear']: if self._dragging: self.verts = self._prev_verts - self._finish_drawing() + self._finish_drawing(False) elif self._drawing: for artist in self._artists: artist.set_visible(False) - self._finish_drawing() + self._finish_drawing(False) return elif event.key == self._keys['accept']: @@ -237,7 +244,7 @@ def _clean_event(self, event): Set the prev xy data """ event = copy.copy(event) - if event.xdata is None or event.ydata is None: + if event.xdata is not None: x0, x1 = self.ax.get_xbound() y0, y1 = self.ax.get_ybound() xdata = max(x0, event.xdata) @@ -263,7 +270,7 @@ def _connect_event(self, event, callback): def _ignore(self, event): """Return *True* if *event* should be ignored""" - if not self._active or not self.ax.get_visible(): + if not self.active or not self.ax.get_visible(): return True # If canvas was locked @@ -313,7 +320,7 @@ def _start_drawing(self): self._handles.set_visible(False) self._update() - def _finish_drawing(self): + def _finish_drawing(self, selection=False): """Finish drawing or dragging the shape""" self._drawing = False self._dragging = False @@ -325,6 +332,8 @@ def _finish_drawing(self): artist.set_visible(False) self._state = set() self._prev_verts = self._verts + if selection: + self._callback_on_select(self) self.canvas.draw_idle() ############################################################# @@ -333,11 +342,11 @@ def _finish_drawing(self): def _set_handles_xy(self, vertices): """By default use the vertices and the center.""" vertices = np.vstack((vertices, np.mean(vertices, axis=0))) - self._handles.set_xy(vertices) + self._handles.set_data(vertices[:, 0], vertices[:, 1]) def _on_press(self, event): """Handle a button_press_event""" - pass + print('on press', event) def _on_move(self, event): """Handle a motion_notify_event""" @@ -345,21 +354,36 @@ def _on_move(self, event): def _on_release(self, event): """Handle a button_release_event""" - pass + print('on release', event) def _on_key_press(self, event): """Handle a key_press_event""" - pass + print('on key press', event) def _on_key_release(self, event): """Handle a key_release_event""" - pass + print('on key release', event) def _on_scroll(self, event): """Handle a scroll_event""" - pass + print('on scroll', event) def _dummy(tool): """A dummy callback for a tool.""" pass + + +if __name__ == '__main__': + import matplotlib.pyplot as plt + + data = np.random.rand(100, 2) + + subplot_kw = dict(xlim=(0, 1), ylim=(0, 1), autoscale_on=False) + fig, ax = plt.subplots(subplot_kw=subplot_kw) + + pts = ax.scatter(data[:, 0], data[:, 1], s=80) + tool = BaseTool(ax) + tool.verts = [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] + + plt.show() From 3858737f8dc1e5728e405e9105e9fd7d765dc376 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 09:15:36 -0600 Subject: [PATCH 12/42] Implement move logic --- lib/matplotlib/interactive_selectors.py | 41 +++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 49fa5305f812..f23002f8006d 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -156,8 +156,7 @@ def verts(self, value): self._patch.set_visible(True) self._handles.set_animated(False) self._patch.set_animated(False) - if self.interactive: - self._handles.set_visible(True) + self._handles.set_visible(self.interactive) self.canvas.draw_idle() def remove(self): @@ -180,22 +179,39 @@ def _handle_event(self, event): event = self._clean_event(event) if event.name == 'button_press_event': - self._dragging, idx = self._handles.contains(event) - if self._dragging: - self._drag_idx = idx[0] + if self.interactive: + self._dragging, idx = self._handles.contains(event) + if self._dragging: + self._drag_idx = idx['ind'][0] + # If the move handle was selected, enter move state. + if self._drag_idx == self._handles.get_xdata().size - 1: + self._state.add('move') + if self._dragging or self.allow_redraw: - self._on_press(event) + if 'move' in self._state: + self._start_drawing() + else: + self._on_press(event) if not self.allow_redraw: self.focused = self._patch.contains(event)[0] elif event.name == 'motion_notify_event': if self._drawing: - self._on_move(event) + if 'move' in self._state: + center = np.mean(self._verts, axis=0) + self._verts[:, 0] += event.xdata - center[0] + self._verts[:, 1] += event.ydata - center[1] + self.verts = self._verts + else: + self._on_move(event) self._callback_on_move(self) elif event.name == 'button_release_event': if self._drawing: - self._on_release(event) + if 'move' in self._state: + self._finish_drawing() + else: + self._on_release(event) elif event.name == 'key_release_event' and self.focused: for (state, modifier) in self._keys.items(): @@ -232,6 +248,8 @@ def _handle_key_press(self, event): self.canvas.draw_idle() for (state, modifier) in self._keys.items(): + if state == 'move' and not self.interactive: + continue if modifier in event.key: self._state.add(state) self._on_key_press(event) @@ -337,10 +355,13 @@ def _finish_drawing(self, selection=False): self.canvas.draw_idle() ############################################################# - # The following are meant to be subclassed + # The following are meant to be subclassed as needed. ############################################################# def _set_handles_xy(self, vertices): - """By default use the vertices and the center.""" + """By default use the vertices and the center. + + The center "move" handle must be the last handle. + """ vertices = np.vstack((vertices, np.mean(vertices, axis=0))) self._handles.set_data(vertices[:, 0], vertices[:, 1]) From a6e7af77991ede891f530869f748a6abecdf6565 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 10:40:45 -0600 Subject: [PATCH 13/42] Improve handles handling --- lib/matplotlib/interactive_selectors.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index f23002f8006d..f47a59348726 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -152,12 +152,16 @@ def verts(self, value): assert value.shape[1] == 2 self._verts = np.array(value) self._patch.set_xy(value) - self._set_handles_xy(value) self._patch.set_visible(True) - self._handles.set_animated(False) self._patch.set_animated(False) + + handles = self._get_handle_verts() + center = (handles.min(axis=0) + handles.max(axis=0)) / 2 + handles = np.vstack((handles, center)) + self._handles.set_data(handles[:, 0], handles[:, 1]) self._handles.set_visible(self.interactive) - self.canvas.draw_idle() + self._handles.set_animated(False) + self._update() def remove(self): """Clean up the tool.""" @@ -219,7 +223,7 @@ def _handle_event(self, event): if state == 'move' and self._drawing: continue if modifier in event.key: - self.state.discard(state) + self._state.discard(state) self._on_key_release(event) elif event.name == 'scroll_event' and self.focused: @@ -357,13 +361,10 @@ def _finish_drawing(self, selection=False): ############################################################# # The following are meant to be subclassed as needed. ############################################################# - def _set_handles_xy(self, vertices): - """By default use the vertices and the center. - - The center "move" handle must be the last handle. + def _get_handle_verts(self): + """Get the handle vertices for a tool, not including the center. """ - vertices = np.vstack((vertices, np.mean(vertices, axis=0))) - self._handles.set_data(vertices[:, 0], vertices[:, 1]) + return self._verts def _on_press(self, event): """Handle a button_press_event""" From 9bf43c5064a4ce37c91327f0f923ce9c3cc96260 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 11:00:04 -0600 Subject: [PATCH 14/42] Stub out rectangle and ellipse tools --- lib/matplotlib/interactive_selectors.py | 90 ++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index f47a59348726..134302bff7da 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -93,7 +93,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.allow_redraw = allow_redraw self.focused = True - self._callback_on_move = _dummy if on_move is None else on_move + self._callback_on_motion = _dummy if on_move is None else on_move self._callback_on_accept = _dummy if on_accept is None else on_accept self._callback_on_select = _dummy if on_select is None else on_select @@ -207,8 +207,8 @@ def _handle_event(self, event): self._verts[:, 1] += event.ydata - center[1] self.verts = self._verts else: - self._on_move(event) - self._callback_on_move(self) + self._on_motion(event) + self._callback_on_motion(self) elif event.name == 'button_release_event': if self._drawing: @@ -363,32 +363,34 @@ def _finish_drawing(self, selection=False): ############################################################# def _get_handle_verts(self): """Get the handle vertices for a tool, not including the center. + + Return an (N, 2) array of vertices. """ return self._verts def _on_press(self, event): """Handle a button_press_event""" - print('on press', event) + self._start_drawing() - def _on_move(self, event): + def _on_motion(self, event): """Handle a motion_notify_event""" pass def _on_release(self, event): """Handle a button_release_event""" - print('on release', event) + self._finish_drawing() def _on_key_press(self, event): """Handle a key_press_event""" - print('on key press', event) + pass def _on_key_release(self, event): """Handle a key_release_event""" - print('on key release', event) + pass def _on_scroll(self, event): """Handle a scroll_event""" - print('on scroll', event) + pass def _dummy(tool): @@ -396,6 +398,76 @@ def _dummy(tool): pass +class RectangleTool(BaseTool): + + """ A selector tool that takes the shape of a rectangle. + """ + + def __init__(self, ax, center=None, width=None, height=None, + on_select=None, on_move=None, on_accept=None, + interactive=True, allow_redraw=True, + shape_props=None, handle_props=None, + useblit=True, button=None, keys=None): + super(RectangleTool, self).__init__(ax, on_select=on_select, + on_move=on_move, on_accept=on_accept, interactive=interactive, + allow_redraw=allow_redraw, shape_props=shape_props, + handle_props=handle_props, useblit=useblit, button=button, + keys=keys) + self._center = center or [0, 0] + self._width = width or 1 + self._height = height or 1 + if center is not None or width is not None or height is not None: + self._update_geometry() + + @property + def center(self): + return self._center + + @center.setter + def center(self, xy): + self._center = xy + self._update_geometry() + + @property + def width(self): + return self._width + + @width.setter + def width(self, value): + self._width = value + self._update_geometry() + + @property + def height(self): + return self._height + + @height.setter + def height(self, value): + self._height = value + self._update_geometry() + + def _update_geometry(self): + pass + + def _on_motion(self, event): + pass + # TODO + + +class EllipseTool(RectangleTool): + + """ A selector tool that take the shape of an ellipse. + """ + + def _update_geometry(self): + pass + + def _get_handle_verts(self): + """Return the extents of the ellipse. + """ + pass + + if __name__ == '__main__': import matplotlib.pyplot as plt From fcc69a1e637fbaacbfac65566f6cc243d0d83f37 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 2 Jan 2016 16:51:48 -0600 Subject: [PATCH 15/42] Finish rectangle, ellipse, and line tools --- lib/matplotlib/interactive_selectors.py | 281 +++++++++++++++++------- 1 file changed, 201 insertions(+), 80 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 134302bff7da..c84f989dabcc 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -65,10 +65,12 @@ class BaseTool(object): Attributes ---------- - ax: :class:`matplotlib.axes.Axes` + ax: :class:`~matplotlib.axes.Axes` The parent axes for the tool. canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass The parent figure canvas for the tool. + patch: :class:`~matplotlib.patches.Patch` + The patch object contained by the tool. active: boolean If False, the widget does not respond to events. interactive: boolean @@ -88,7 +90,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, useblit=True, button=None, keys=None): self.ax = ax self.canvas = ax.figure.canvas - self.active = True + self._active = True self.interactive = interactive self.allow_redraw = allow_redraw self.focused = True @@ -109,10 +111,10 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._buttons = button props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, picker=5) + alpha=0.2, fill=True, picker=5, linewidth=2) props.update(shape_props or {}) - self._patch = Polygon([[0, 0], [1, 1]], True, **props) - self.ax.add_patch(self._patch) + self.patch = Polygon([[0, 0], [1, 1]], True, **props) + self.ax.add_patch(self.patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', @@ -121,15 +123,16 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) - self._artists = [self._patch, self._handles] + self._artists = [self.patch, self._handles] self._state = set() self._drawing = False self._dragging = False self._drag_idx = None self._verts = [] - self._prev_verts = None + self._prev_data = None self._background = None - self._prevxy = None + self._prev_evt_xy = None + self._start_event = None # Connect the major canvas events to methods.""" self._cids = [] @@ -141,6 +144,18 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._connect_event('key_release_event', self._handle_event) self._connect_event('scroll_event', self._handle_event) + @property + def active(self): + return self._active + + @active.setter + def active(self, value): + self._active = value + if not value: + for artist in self._artists: + artist.set_visible(False) + self.canvas.draw_idle() + @property def verts(self): return self._verts @@ -151,24 +166,48 @@ def verts(self, value): assert value.ndim == 2 assert value.shape[1] == 2 self._verts = np.array(value) - self._patch.set_xy(value) - self._patch.set_visible(True) - self._patch.set_animated(False) + if self._prev_data is None: + self._prev_data = dict(verts=self._verts, + center=self.center, + width=self.width, + height=self.height, + extents=self.extents) + self.patch.set_xy(self._verts) + self.patch.set_visible(True) + self.patch.set_animated(False) handles = self._get_handle_verts() - center = (handles.min(axis=0) + handles.max(axis=0)) / 2 - handles = np.vstack((handles, center)) + handles = np.vstack((handles, self.center)) self._handles.set_data(handles[:, 0], handles[:, 1]) self._handles.set_visible(self.interactive) self._handles.set_animated(False) self._update() + @property + def center(self): + return (self._verts.min(axis=0) + self._verts.max(axis=0)) / 2 + + @property + def width(self): + return np.max(self._verts[:, 0]) - np.min(self._verts[:, 0]) + + @property + def height(self): + return np.max(self._verts[:, 1]) - np.min(self._verts[:, 1]) + + @property + def extents(self): + x, y = self.center + w = self.width / 2 + h = self.height / 2 + return x - w, x + w, y - h, y + h + def remove(self): """Clean up the tool.""" for c in self._cids: self.canvas.mpl_disconnect(c) for artist in self._artists: - self.ax.remove(artist) + artist.remove() self.canvas.draw_idle() def _handle_draw(self, event): @@ -183,7 +222,11 @@ def _handle_event(self, event): event = self._clean_event(event) if event.name == 'button_press_event': - if self.interactive: + + if not self.allow_redraw: + self.focused = self.patch.contains(event)[0] + + if self.interactive and not self._drawing: self._dragging, idx = self._handles.contains(event) if self._dragging: self._drag_idx = idx['ind'][0] @@ -191,13 +234,11 @@ def _handle_event(self, event): if self._drag_idx == self._handles.get_xdata().size - 1: self._state.add('move') - if self._dragging or self.allow_redraw: + if self._drawing or self._dragging or self.allow_redraw: if 'move' in self._state: - self._start_drawing() + self._start_drawing(event) else: self._on_press(event) - if not self.allow_redraw: - self.focused = self._patch.contains(event)[0] elif event.name == 'motion_notify_event': if self._drawing: @@ -213,9 +254,10 @@ def _handle_event(self, event): elif event.name == 'button_release_event': if self._drawing: if 'move' in self._state: - self._finish_drawing() + self._finish_drawing(event) else: self._on_release(event) + self._dragging = False elif event.name == 'key_release_event' and self.focused: for (state, modifier) in self._keys.items(): @@ -236,11 +278,11 @@ def _handle_key_press(self, event): if event.key == self._keys['clear']: if self._dragging: self.verts = self._prev_verts - self._finish_drawing(False) + self._finish_drawing(event, False) elif self._drawing: for artist in self._artists: artist.set_visible(False) - self._finish_drawing(False) + self._finish_drawing(event, False) return elif event.key == self._keys['accept']: @@ -273,9 +315,9 @@ def _clean_event(self, event): event.xdata = min(x1, xdata) ydata = max(y0, event.ydata) event.ydata = min(y1, ydata) - self._prevxy = event.xdata, event.ydata + self._prev_evt_xy = event.xdata, event.ydata else: - event.xdata, event.ydata = self._prevxy + event.xdata, event.ydata = self._prev_evt_xy event.key = event.key or '' event.key = event.key.replace('ctrl', 'control') @@ -328,9 +370,10 @@ def _update(self): else: self.canvas.draw_idle() - def _start_drawing(self): + def _start_drawing(self, event): """Start drawing or dragging the shape""" self._drawing = True + self._start_event = event if self.interactive: for artist in self._artists: artist.set_visible(False) @@ -346,6 +389,7 @@ def _finish_drawing(self, selection=False): """Finish drawing or dragging the shape""" self._drawing = False self._dragging = False + self._start_event = None if self.interactive: for artist in self._artists: artist.set_animated(False) @@ -353,8 +397,12 @@ def _finish_drawing(self, selection=False): for artist in self._artists: artist.set_visible(False) self._state = set() - self._prev_verts = self._verts if selection: + self._prev_data = dict(verts=self._verts, + center=self.center, + width=self.width, + height=self.height, + extents=self.extents) self._callback_on_select(self) self.canvas.draw_idle() @@ -370,7 +418,7 @@ def _get_handle_verts(self): def _on_press(self, event): """Handle a button_press_event""" - self._start_drawing() + self._start_drawing(event) def _on_motion(self, event): """Handle a motion_notify_event""" @@ -378,7 +426,7 @@ def _on_motion(self, event): def _on_release(self, event): """Handle a button_release_event""" - self._finish_drawing() + self._finish_drawing(event) def _on_key_press(self, event): """Handle a key_press_event""" @@ -403,69 +451,140 @@ class RectangleTool(BaseTool): """ A selector tool that takes the shape of a rectangle. """ - def __init__(self, ax, center=None, width=None, height=None, - on_select=None, on_move=None, on_accept=None, - interactive=True, allow_redraw=True, - shape_props=None, handle_props=None, - useblit=True, button=None, keys=None): - super(RectangleTool, self).__init__(ax, on_select=on_select, - on_move=on_move, on_accept=on_accept, interactive=interactive, - allow_redraw=allow_redraw, shape_props=shape_props, - handle_props=handle_props, useblit=useblit, button=button, - keys=keys) - self._center = center or [0, 0] - self._width = width or 1 - self._height = height or 1 - if center is not None or width is not None or height is not None: - self._update_geometry() + _handle_order = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] - @property - def center(self): - return self._center + def set_geometry(self, center, width, height): + radx = width / 2 + rady = height / 2 + self.verts = [[center - radx, center - rady], + [center - radx, center + rady], + [center + radx, center + rady], + [center + radx, center - rady]] - @center.setter - def center(self, xy): - self._center = xy - self._update_geometry() + def _get_handle_verts(self): + xm, ym = self.center + w = self.width / 2 + h = self.height / 2 + xc = xm - w, xm + w, xm + w, xm - w + yc = ym - h, ym - h, ym + h, ym + h + xe = xm - w, xm, xm + w, xm + ye = ym, ym - h, ym, ym + h + x = np.hstack((xc, xe)) + y = np.hstack((yc, ye)) + return np.vstack((x, y)).T - @property - def width(self): - return self._width + def _on_motion(self, event): + # Resize an existing shape. + if self._dragging: + x1, x2, y1, y2 = self._prev_data['extents'] + handle = self._handle_order[self._drag_idx] + if handle in ['NW', 'SW', 'W']: + x1 = event.xdata + elif handle in ['NE', 'SE', 'E']: + x2 = event.xdata + if handle in ['NE', 'N', 'NW']: + y1 = event.ydata + elif handle in ['SE', 'S', 'SW']: + y2 = event.ydata + + # Draw new shape. + else: + center = [self._start_event.xdata, self._start_event.ydata] + center_pix = [self._start_event.x, self._start_event.y] + dx = (event.xdata - center[0]) / 2. + dy = (event.ydata - center[1]) / 2. + + # Draw a square shape. + if 'square' in self._state: + dx_pix = abs(event.x - center_pix[0]) + dy_pix = abs(event.y - center_pix[1]) + if not dx_pix: + return + maxd = max(abs(dx_pix), abs(dy_pix)) + if abs(dx_pix) < maxd: + dx *= maxd / (abs(dx_pix) + 1e-6) + if abs(dy_pix) < maxd: + dy *= maxd / (abs(dy_pix) + 1e-6) + + # Draw from center. + if 'center' in self._state: + dx *= 2 + dy *= 2 + + # Draw from corner. + else: + center[0] += dx + center[1] += dy + + x1, x2, y1, y2 = (center[0] - dx, center[0] + dx, + center[1] - dy, center[1] + dy) + + # Update the shape. + self.set_geometry(((x2 + x1) / 2, (y2 + y1) / 2), abs(x2 - x1), + abs(y2 - y1)) - @width.setter - def width(self, value): - self._width = value - self._update_geometry() - @property - def height(self): - return self._height +class EllipseTool(RectangleTool): - @height.setter - def height(self, value): - self._height = value - self._update_geometry() + """ A selector tool that take the shape of an ellipse. + """ - def _update_geometry(self): - pass + def set_geometry(self, center, width, height): + rad = np.arange(31) * 12 * np.pi / 180 + x = width / 2 * np.cos(rad) + center[0] + y = height / 2 * np.sin(rad) + center[1] + self.verts = np.vstack((x, y)).T - def _on_motion(self, event): - pass - # TODO +class LineTool(BaseTool): -class EllipseTool(RectangleTool): + def __init__(self, ax, on_select=None, on_move=None, on_accept=None, + interactive=True, allow_redraw=True, + shape_props=None, handle_props=None, + useblit=True, button=None, keys=None): + props = dict(edgecolor='red', visible=False, + alpha=0.5, fill=True, picker=5, linewidth=1) + props.update(shape_props or {}) + super(LineTool, self).__init__(ax, on_select=on_select, + on_move=on_move, on_accept=on_accept, interactive=interactive, + allow_redraw=allow_redraw, shape_props=props, + handle_props=handle_props, useblit=useblit, button=button, + keys=keys) - """ A selector tool that take the shape of an ellipse. - """ + @property + def width(self): + return self.patch.get_linewidth() - def _update_geometry(self): - pass + @width.setter + def width(self, value): + self.patch.set_linewidth(value) + self._update() - def _get_handle_verts(self): - """Return the extents of the ellipse. - """ - pass + def _on_press(self, event): + if not self._dragging: + self._verts = [[event.xdata, event.ydata], + [event.xdata, event.ydata]] + self._dragging = True + self._drag_idx = 1 + self._start_drawing(event) + + def _on_motion(self, event): + self._verts[self._drag_idx, :] = event.xdata, event.ydata + self.verts = self._verts + + def _on_scroll(self, event): + if event.button == 'up': + self.patch.set_linewidth(self.width + 1) + elif event.button == 'down' and self.width > 1: + self.patch.set_linewidth(self.width - 1) + self._update() + + def _on_key_press(self, event): + if event.key == '+': + self.patch.set_linewidth(self.width + 1) + elif event.key == '-' and self.width > 1: + self.patch.set_linewidth(self.width - 1) + self._update() if __name__ == '__main__': @@ -477,7 +596,9 @@ def _get_handle_verts(self): fig, ax = plt.subplots(subplot_kw=subplot_kw) pts = ax.scatter(data[:, 0], data[:, 1], s=80) - tool = BaseTool(ax) - tool.verts = [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] + #tool = EllipseTool(ax) + #tool.set_geometry((0.5, 0.5), 0.5, 0.5) + tool = LineTool(ax) + tool.verts = [[0.1, 0.1], [0.5, 0.5]] plt.show() From 91ca5e155620fabf256b8d45827346d77c2e29fe Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 05:39:41 -0600 Subject: [PATCH 16/42] Use pixel space for line calculations and cleanups --- lib/matplotlib/interactive_selectors.py | 320 ++++++++++++++++-------- 1 file changed, 209 insertions(+), 111 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index c84f989dabcc..3f676d21f3dd 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -10,12 +10,10 @@ # TODO: convert these to relative when finished from matplotlib.patches import Polygon from matplotlib.lines import Line2D +from matplotlib import docstring -class BaseTool(object): - - """Interactive selection tool that is connected to a single - :class:`~matplotlib.axes.Axes`. +docstring.interpd.update(BaseTool=""" To guarantee that the tool remains responsive and not garbage-collected, a reference to the object should be maintained by the user. @@ -26,6 +24,28 @@ class BaseTool(object): object it may be garbage collected which will disconnect the callbacks. + Attributes + ---------- + ax: :class:`~matplotlib.axes.Axes` + The parent axes for the tool. + canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass + The parent figure canvas for the tool. + patch: :class:`~matplotlib.patches.Patch` + The patch object contained by the tool. + active: boolean + If False, the widget does not respond to events. + interactive: boolean + Whether to allow interaction with the shape using handles. + allow_redraw: boolean + Whether to allow the tool to redraw itself or whether it must be + drawn programmatically and then dragged. + verts: nd-array of floats (N, 2) + The vertices of the tool in data units. + focused: boolean + Whether the tool has focus for keyboard and scroll events. + """) + +docstring.interpd.update(BaseToolInit=""" Parameters ---------- ax: :class:`matplotlib.axes.Axes` @@ -62,32 +82,26 @@ class BaseTool(object): 'polygon': Draw a polygon shape for the lasso. 'square' and 'center' can be combined. 'accept': Trigger an `on_accept` callback. + """) - Attributes - ---------- - ax: :class:`~matplotlib.axes.Axes` - The parent axes for the tool. - canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass - The parent figure canvas for the tool. - patch: :class:`~matplotlib.patches.Patch` - The patch object contained by the tool. - active: boolean - If False, the widget does not respond to events. - interactive: boolean - Whether to allow interaction with the shape using handles. - allow_redraw: boolean - Whether to allow the tool to redraw itself or whether it must be - drawn programmatically and then dragged. - verts: nd-array of floats (N, 2) - The vertices of the tool. - focused: boolean - Whether the tool has focus for keyboard and scroll events. + +class BaseTool(object): + + """Interactive selection tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + + %(BaseTool)s """ + @docstring.dedent_interpd def __init__(self, ax, on_select=None, on_move=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): + """Initialize the tool. + + %(BaseToolInit)s + """ self.ax = ax self.canvas = ax.figure.canvas self._active = True @@ -146,10 +160,12 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, @property def active(self): + """Get the active state of the tool""" return self._active @active.setter def active(self, value): + """Set the active state of the tool""" self._active = value if not value: for artist in self._artists: @@ -158,52 +174,23 @@ def active(self, value): @property def verts(self): - return self._verts - - @verts.setter - def verts(self, value): - value = np.asarray(value) - assert value.ndim == 2 - assert value.shape[1] == 2 - self._verts = np.array(value) - if self._prev_data is None: - self._prev_data = dict(verts=self._verts, - center=self.center, - width=self.width, - height=self.height, - extents=self.extents) - self.patch.set_xy(self._verts) - self.patch.set_visible(True) - self.patch.set_animated(False) - - handles = self._get_handle_verts() - handles = np.vstack((handles, self.center)) - self._handles.set_data(handles[:, 0], handles[:, 1]) - self._handles.set_visible(self.interactive) - self._handles.set_animated(False) - self._update() + """Get the (N, 2) vertices of the tool""" + return self.patch.get_xy() @property def center(self): + """Get the (x, y) center of the tool""" return (self._verts.min(axis=0) + self._verts.max(axis=0)) / 2 - @property - def width(self): - return np.max(self._verts[:, 0]) - np.min(self._verts[:, 0]) - - @property - def height(self): - return np.max(self._verts[:, 1]) - np.min(self._verts[:, 1]) - @property def extents(self): - x, y = self.center - w = self.width / 2 - h = self.height / 2 - return x - w, x + w, y - h, y + h + """Get the (x0, x1, y0, y1) extents of the tool""" + x0, x1 = np.min(self._verts[:, 0]), np.max(self._verts[:, 0]) + y0, y1 = np.min(self._verts[:, 1]), np.max(self._verts[:, 1]) + return x0, x1, y0, y1 def remove(self): - """Clean up the tool.""" + """Clean up the tool""" for c in self._cids: self.canvas.mpl_disconnect(c) for artist in self._artists: @@ -243,10 +230,10 @@ def _handle_event(self, event): elif event.name == 'motion_notify_event': if self._drawing: if 'move' in self._state: - center = np.mean(self._verts, axis=0) + center = self.center self._verts[:, 0] += event.xdata - center[0] self._verts[:, 1] += event.ydata - center[1] - self.verts = self._verts + self._set_verts(self._verts) else: self._on_motion(event) self._callback_on_motion(self) @@ -261,7 +248,7 @@ def _handle_event(self, event): elif event.name == 'key_release_event' and self.focused: for (state, modifier) in self._keys.items(): - # Keep move state locked until button released. + # Keep move state locked until drawing finished. if state == 'move' and self._drawing: continue if modifier in event.key: @@ -272,12 +259,13 @@ def _handle_event(self, event): self._on_scroll(event) def _handle_key_press(self, event): - """Handle key_press_event defaults and call to subclass handler""" + """Handle key_press_event defaults and call to subclass handler. + """ if not self.focused: return if event.key == self._keys['clear']: if self._dragging: - self.verts = self._prev_verts + self._set_verts(self._prev_verts) self._finish_drawing(event, False) elif self._drawing: for artist in self._artists: @@ -286,12 +274,14 @@ def _handle_key_press(self, event): return elif event.key == self._keys['accept']: - if not self._drawing: - self._callback_on_accept(self) - if self.allow_redraw: - for artist in self._artists: - artist.set_visible(False) - self.canvas.draw_idle() + if self._drawing: + self._finish_drawing(event) + + self._callback_on_accept(self) + if self.allow_redraw: + for artist in self._artists: + artist.set_visible(False) + self.canvas.draw_idle() for (state, modifier) in self._keys.items(): if state == 'move' and not self.interactive: @@ -301,11 +291,11 @@ def _handle_key_press(self, event): self._on_key_press(event) def _clean_event(self, event): - """Clean up an event + """Clean up an event. - Use prev xy data if there is no xdata (outside the axes) - Limit the xdata and ydata to the axes limits - Set the prev xy data + Use previous xy data if there is no xdata (the event was outside the + axes). + Limit the xdata and ydata to the axes limits. """ event = copy.copy(event) if event.xdata is not None: @@ -324,10 +314,7 @@ def _clean_event(self, event): return event def _connect_event(self, event, callback): - """Connect callback with an event. - - This should be used in lieu of `figure.canvas.mpl_connect` since this - function stores callback ids for later clean up. + """Connect callback with an event """ cid = self.canvas.mpl_connect(event, callback) self._cids.append(cid) @@ -355,6 +342,27 @@ def _ignore(self, event): return False + def _set_verts(self, value): + """Commit a change to the tool vertices.""" + value = np.asarray(value) + assert value.ndim == 2 + assert value.shape[1] == 2 + self._verts = np.array(value) + if self._prev_data is None: + self._prev_data = dict(verts=self._verts, + center=self.center, + extents=self.extents) + self.patch.set_xy(self._verts) + self.patch.set_visible(True) + self.patch.set_animated(False) + + handles = self._get_handle_verts() + handles = np.vstack((handles, self.center)) + self._handles.set_data(handles[:, 0], handles[:, 1]) + self._handles.set_visible(self.interactive) + self._handles.set_animated(False) + self._update() + def _update(self): """Update the artists while drawing""" if not self.ax.get_visible(): @@ -375,6 +383,7 @@ def _start_drawing(self, event): self._drawing = True self._start_event = event if self.interactive: + # Force a draw_event without our previous state. for artist in self._artists: artist.set_visible(False) self.canvas.draw() @@ -400,8 +409,6 @@ def _finish_drawing(self, selection=False): if selection: self._prev_data = dict(verts=self._verts, center=self.center, - width=self.width, - height=self.height, extents=self.extents) self._callback_on_select(self) self.canvas.draw_idle() @@ -446,20 +453,38 @@ def _dummy(tool): pass +HANDLE_ORDER = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] + + class RectangleTool(BaseTool): - """ A selector tool that takes the shape of a rectangle. + """Interactive rectangle selection tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + + %(BaseTool)s + width: float + The width of the rectangle in data units. + height: float + The height of the rectangle in data units. """ - _handle_order = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] + @property + def width(self): + """Get the width of the tool in data units""" + return np.max(self._verts[:, 0]) - np.min(self._verts[:, 0]) + + @property + def height(self): + """Get the height of the tool in data units""" + return np.max(self._verts[:, 1]) - np.min(self._verts[:, 1]) def set_geometry(self, center, width, height): radx = width / 2 rady = height / 2 - self.verts = [[center - radx, center - rady], - [center - radx, center + rady], - [center + radx, center + rady], - [center + radx, center - rady]] + self._set_verts([[center - radx, center - rady], + [center - radx, center + rady], + [center + radx, center + rady], + [center + radx, center - rady]]) def _get_handle_verts(self): xm, ym = self.center @@ -477,7 +502,7 @@ def _on_motion(self, event): # Resize an existing shape. if self._dragging: x1, x2, y1, y2 = self._prev_data['extents'] - handle = self._handle_order[self._drag_idx] + handle = HANDLE_ORDER[self._drag_idx] if handle in ['NW', 'SW', 'W']: x1 = event.xdata elif handle in ['NE', 'SE', 'E']: @@ -526,22 +551,47 @@ def _on_motion(self, event): class EllipseTool(RectangleTool): - """ A selector tool that take the shape of an ellipse. + """Interactive ellipse selection tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + + %(BaseTool)s + width: float + The width of the ellipse in data units. + height: float + The height of the ellipse in data units. """ def set_geometry(self, center, width, height): rad = np.arange(31) * 12 * np.pi / 180 x = width / 2 * np.cos(rad) + center[0] y = height / 2 * np.sin(rad) + center[1] - self.verts = np.vstack((x, y)).T + self._set_verts(np.vstack((x, y)).T) class LineTool(BaseTool): + """Interactive line selection tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + + %(BaseTool)s + width: float + The width of the line in pixels. + end_points: (2, 2) float + The [(x0, y0), (x1, y1)] end points of the line in data units. + angle: float + The angle between the left point and the right point in radians in + pixel space. + """ + + @docstring.dedent_interpd def __init__(self, ax, on_select=None, on_move=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): + """Initialize the tool. + + %(BaseToolInit)s + """ props = dict(edgecolor='red', visible=False, alpha=0.5, fill=True, picker=5, linewidth=1) props.update(shape_props or {}) @@ -553,52 +603,100 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, @property def width(self): - return self.patch.get_linewidth() + return self._width - @width.setter - def width(self, value): - self.patch.set_linewidth(value) - self._update() + @property + def end_points(self): + p0x = (self._verts[0, 0] + self._verts[1, 0]) / 2 + p0y = (self._verts[0, 1] + self._verts[1, 1]) / 2 + p1x = (self._verts[3, 0] + self._verts[2, 0]) / 2 + p1y = (self._verts[3, 1] + self._verts[2, 1]) / 2 + return np.array([[p0x, p0y], [p1x, p1y]]) + + @property + def angle(self): + """Find the angle between the left and right points.""" + # Convert to pixels. + pts = self.end_points + pts = self.ax.transData.inverted().transform(pts) + if pts[0, 0] < pts[1, 0]: + return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) + else: + return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) + + def set_geometry(self, end_points, width): + pts = np.asarray(end_points) + self._width = width + + # Get the widths in data units. + x0, y0 = self.ax.transData.inverted().transform((0, 0)) + x1, y1 = self.ax.transData.inverted().transform((width, width)) + wx, wy = x1 - x0, y1 - y0 + + # Find line segments centered on the end points perpendicular to the + # line and the proper width. + # http://math.stackexchange.com/a/9375 + if (pts[1, 0] == pts[0, 0]): + c, s = 0, 1 + else: + m = - 1 / ((pts[1, 1] - pts[0, 1]) / + (pts[1, 0] - pts[0, 0])) + c = 1 / np.sqrt(1 + m ** 2) + s = m / np.sqrt(1 + m ** 2) + + p0 = pts[0, :] + v00 = p0[0] + wx / 2 * c, p0[1] + wy / 2 * s + v01 = p0[0] - wx / 2 * c, p0[1] - wy / 2 * s + + p1 = pts[1, :] + v10 = p1[0] + wx / 2 * c, p1[1] + wy / 2 * s + v11 = p1[0] - wx / 2 * c, p1[1] - wy / 2 * s + + self._set_verts((v00, v01, v11, v10)) + + def _get_handle_verts(self): + return self.end_points def _on_press(self, event): if not self._dragging: - self._verts = [[event.xdata, event.ydata], - [event.xdata, event.ydata]] + self._end_pts = [[event.xdata, event.ydata], + [event.xdata, event.ydata]] self._dragging = True self._drag_idx = 1 self._start_drawing(event) def _on_motion(self, event): - self._verts[self._drag_idx, :] = event.xdata, event.ydata - self.verts = self._verts + end_points = self.end_points + end_points[self._drag_idx, :] = event.xdata, event.ydata + self.set_geometry(end_points, self._width) def _on_scroll(self, event): if event.button == 'up': - self.patch.set_linewidth(self.width + 1) + self.set_geometry(self.width + 1) elif event.button == 'down' and self.width > 1: - self.patch.set_linewidth(self.width - 1) - self._update() + self.set_geometry(self.width - 1) def _on_key_press(self, event): if event.key == '+': - self.patch.set_linewidth(self.width + 1) + self.set_geometry(self.width + 1) elif event.key == '-' and self.width > 1: - self.patch.set_linewidth(self.width - 1) - self._update() + self.set_geometry(self.width - 1) if __name__ == '__main__': import matplotlib.pyplot as plt data = np.random.rand(100, 2) + data[:, 1] *= 2 - subplot_kw = dict(xlim=(0, 1), ylim=(0, 1), autoscale_on=False) - fig, ax = plt.subplots(subplot_kw=subplot_kw) + fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - #tool = EllipseTool(ax) - #tool.set_geometry((0.5, 0.5), 0.5, 0.5) - tool = LineTool(ax) - tool.verts = [[0.1, 0.1], [0.5, 0.5]] + ellipse = EllipseTool(ax) + ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) + ellipse.allow_redraw = False + line = LineTool(ax) + line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) + line.allow_redraw = False plt.show() From 77c1122e75d5447fc49d0c88d357adfa3b8be0a3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 06:37:31 -0600 Subject: [PATCH 17/42] Update documentation --- lib/matplotlib/interactive_selectors.py | 159 ++++++++++++++++-------- 1 file changed, 109 insertions(+), 50 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 3f676d21f3dd..4d2ec425a6a1 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -10,11 +10,13 @@ # TODO: convert these to relative when finished from matplotlib.patches import Polygon from matplotlib.lines import Line2D -from matplotlib import docstring +from matplotlib import docstring, artist as martist -docstring.interpd.update(BaseTool=""" - +# These are not available for the object inspector until after the +# class is built so we define an initial set here for the init +# function and they will be overridden after object definition. +docstring.interpd.update(BaseInteractiveTool="""\ To guarantee that the tool remains responsive and not garbage-collected, a reference to the object should be maintained by the user. @@ -34,6 +36,13 @@ The patch object contained by the tool. active: boolean If False, the widget does not respond to events. + on_select: callable, optional + A callback for when a selection is made `on_select(tool)`. + on_move: callable, optional + A callback for when the tool is moved `on_move(tool)`. + on_accept: callable, optional + A callback for when the selection is accepted `on_accept(tool)`. + This is called in response to an 'accept' key event. interactive: boolean Whether to allow interaction with the shape using handles. allow_redraw: boolean @@ -42,10 +51,9 @@ verts: nd-array of floats (N, 2) The vertices of the tool in data units. focused: boolean - Whether the tool has focus for keyboard and scroll events. - """) + Whether the tool has focus for keyboard and scroll events.""") -docstring.interpd.update(BaseToolInit=""" +docstring.interpd.update(BaseInteractiveToolInit=""" Parameters ---------- ax: :class:`matplotlib.axes.Axes` @@ -90,17 +98,15 @@ class BaseTool(object): """Interactive selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. - %(BaseTool)s + %(BaseInteractiveTool)s """ - @docstring.dedent_interpd def __init__(self, ax, on_select=None, on_move=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): """Initialize the tool. - - %(BaseToolInit)s + %(BaseInteractiveToolInit)s """ self.ax = ax self.canvas = ax.figure.canvas @@ -109,9 +115,9 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.allow_redraw = allow_redraw self.focused = True - self._callback_on_motion = _dummy if on_move is None else on_move - self._callback_on_accept = _dummy if on_accept is None else on_accept - self._callback_on_select = _dummy if on_select is None else on_select + self.on_move = _dummy if on_move is None else on_move + self.on_accept = _dummy if on_accept is None else on_accept + self.on_select = _dummy if on_select is None else on_select self._useblit = useblit and self.canvas.supports_blit self._keys = dict(move=' ', clear='escape', @@ -184,10 +190,10 @@ def center(self): @property def extents(self): - """Get the (x0, x1, y0, y1) extents of the tool""" + """Get the (x0, y0, width, height) extents of the tool""" x0, x1 = np.min(self._verts[:, 0]), np.max(self._verts[:, 0]) y0, y1 = np.min(self._verts[:, 1]), np.max(self._verts[:, 1]) - return x0, x1, y0, y1 + return x0, y0, x1 - x0, y1 - y0 def remove(self): """Clean up the tool""" @@ -236,7 +242,7 @@ def _handle_event(self, event): self._set_verts(self._verts) else: self._on_motion(event) - self._callback_on_motion(self) + self.on_move(self) elif event.name == 'button_release_event': if self._drawing: @@ -259,10 +265,10 @@ def _handle_event(self, event): self._on_scroll(event) def _handle_key_press(self, event): - """Handle key_press_event defaults and call to subclass handler. - """ + """Handle key_press_event defaults and call to subclass handler""" if not self.focused: return + if event.key == self._keys['clear']: if self._dragging: self._set_verts(self._prev_verts) @@ -277,7 +283,7 @@ def _handle_key_press(self, event): if self._drawing: self._finish_drawing(event) - self._callback_on_accept(self) + self.on_accept(self) if self.allow_redraw: for artist in self._artists: artist.set_visible(False) @@ -314,8 +320,7 @@ def _clean_event(self, event): return event def _connect_event(self, event, callback): - """Connect callback with an event - """ + """Connect callback with an event""" cid = self.canvas.mpl_connect(event, callback) self._cids.append(cid) @@ -363,7 +368,7 @@ def _set_verts(self, value): self._handles.set_animated(False) self._update() - def _update(self): + def _update(self, visible=True): """Update the artists while drawing""" if not self.ax.get_visible(): return @@ -372,6 +377,7 @@ def _update(self): if self._background is not None: self.canvas.restore_region(self._background) for artist in self._artists: + artist.set_visible(visible) self.ax.draw_artist(artist) self.canvas.blit(self.ax.bbox) @@ -389,10 +395,11 @@ def _start_drawing(self, event): self.canvas.draw() for artist in self._artists: artist.set_animated(self._useblit) - artist.set_visible(True) else: self._handles.set_visible(False) - self._update() + # Blit without being visible if not draggin to avoid showing the old + # shape. + self._update(self._dragging) def _finish_drawing(self, selection=False): """Finish drawing or dragging the shape""" @@ -410,7 +417,7 @@ def _finish_drawing(self, selection=False): self._prev_data = dict(verts=self._verts, center=self.center, extents=self.extents) - self._callback_on_select(self) + self.on_select(self) self.canvas.draw_idle() ############################################################# @@ -453,15 +460,24 @@ def _dummy(tool): pass +tooldoc = martist.kwdoc(BaseTool) +for k in ('RectangleTool', 'EllipseTool', 'LineTool', 'BaseTool'): + docstring.interpd.update({k: tooldoc}) + +# define BaseTool.__init__ docstring after the class has been added to interpd +docstring.dedent_interpd(BaseTool.__init__) + + HANDLE_ORDER = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] +@docstring.dedent_interpd class RectangleTool(BaseTool): """Interactive rectangle selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. - %(BaseTool)s + %(BaseInteractiveTool)s width: float The width of the rectangle in data units. height: float @@ -471,16 +487,30 @@ class RectangleTool(BaseTool): @property def width(self): """Get the width of the tool in data units""" - return np.max(self._verts[:, 0]) - np.min(self._verts[:, 0]) + return np.ptp(self._verts[:, 0]) @property def height(self): """Get the height of the tool in data units""" - return np.max(self._verts[:, 1]) - np.min(self._verts[:, 1]) - - def set_geometry(self, center, width, height): + return np.ptp(self._verts[:, 1]) + + def set_geometry(self, x0, y0, width, height): + """Set the geometry of the rectangle tool. + + Parameters + ---------- + x0: float + The left coordinate in data units. + y0: float + The bottom coordinate in data units. + width: + The width in data units. + height: + The height in data units. + """ radx = width / 2 rady = height / 2 + center = x0 + width / 2, y0 + height / 2 self._set_verts([[center - radx, center - rady], [center - radx, center + rady], [center + radx, center + rady], @@ -501,16 +531,18 @@ def _get_handle_verts(self): def _on_motion(self, event): # Resize an existing shape. if self._dragging: - x1, x2, y1, y2 = self._prev_data['extents'] + x0, y0, width, height = self._prev_data['extents'] + x1 = x0 + width + y1 = y0 + height handle = HANDLE_ORDER[self._drag_idx] if handle in ['NW', 'SW', 'W']: - x1 = event.xdata + x0 = event.xdata elif handle in ['NE', 'SE', 'E']: - x2 = event.xdata + x1 = event.xdata if handle in ['NE', 'N', 'NW']: - y1 = event.ydata + y0 = event.ydata elif handle in ['SE', 'S', 'SW']: - y2 = event.ydata + y1 = event.ydata # Draw new shape. else: @@ -541,20 +573,20 @@ def _on_motion(self, event): center[0] += dx center[1] += dy - x1, x2, y1, y2 = (center[0] - dx, center[0] + dx, + x0, x1, y0, y1 = (center[0] - dx, center[0] + dx, center[1] - dy, center[1] + dy) # Update the shape. - self.set_geometry(((x2 + x1) / 2, (y2 + y1) / 2), abs(x2 - x1), - abs(y2 - y1)) + self.set_geometry(x0, y0, x1 - x0, y1 - y0) +@docstring.dedent_interpd class EllipseTool(RectangleTool): """Interactive ellipse selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. - %(BaseTool)s + %(BaseInteractiveTool)s width: float The width of the ellipse in data units. height: float @@ -562,18 +594,29 @@ class EllipseTool(RectangleTool): """ def set_geometry(self, center, width, height): - rad = np.arange(31) * 12 * np.pi / 180 + """Set the geometry of the ellipse tool. + + Parameters + ---------- + center: (x, y) float + The center coordinates in data units. + width: + The width in data units. + height: + The height in data units. + """ + rad = np.arange(61) * 6 * np.pi / 180 x = width / 2 * np.cos(rad) + center[0] y = height / 2 * np.sin(rad) + center[1] self._set_verts(np.vstack((x, y)).T) +@docstring.dedent_interpd class LineTool(BaseTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. - - %(BaseTool)s + %(BaseInteractiveTool)s width: float The width of the line in pixels. end_points: (2, 2) float @@ -589,8 +632,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): """Initialize the tool. - - %(BaseToolInit)s + %(BaseInteractiveToolInit)s """ props = dict(edgecolor='red', visible=False, alpha=0.5, fill=True, picker=5, linewidth=1) @@ -603,10 +645,12 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, @property def width(self): + """Get the width of the line in pixels.""" return self._width @property def end_points(self): + """Get the end points of the line in data units.""" p0x = (self._verts[0, 0] + self._verts[1, 0]) / 2 p0y = (self._verts[0, 1] + self._verts[1, 1]) / 2 p1x = (self._verts[3, 0] + self._verts[2, 0]) / 2 @@ -615,7 +659,7 @@ def end_points(self): @property def angle(self): - """Find the angle between the left and right points.""" + """Find the angle between the left and right points in pixel space.""" # Convert to pixels. pts = self.end_points pts = self.ax.transData.inverted().transform(pts) @@ -625,12 +669,22 @@ def angle(self): return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) def set_geometry(self, end_points, width): + """Set the geometry of the line tool. + + Parameters + ---------- + end_points: (2, 2) float + The coordinates of the end points in data space. + width: + The width in pixels. + """ pts = np.asarray(end_points) self._width = width # Get the widths in data units. - x0, y0 = self.ax.transData.inverted().transform((0, 0)) - x1, y1 = self.ax.transData.inverted().transform((width, width)) + xfm = self.ax.transData.inverted() + x0, y0 = xfm.transform((0, 0)) + x1, y1 = xfm.transform((width, width)) wx, wy = x1 - x0, y1 - y0 # Find line segments centered on the end points perpendicular to the @@ -692,9 +746,14 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - ellipse = EllipseTool(ax) - ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) - ellipse.allow_redraw = False + # ellipse = EllipseTool(ax) + # ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) + # ax.invert_yaxis() + + # def test(tool): + # print(tool.center, tool.width, tool.height) + # ellipse.on_accept = test + # ellipse.allow_redraw = False line = LineTool(ax) line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) line.allow_redraw = False From d37f0ebd0371695d48615c2e66919ad63d559f27 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 06:40:48 -0600 Subject: [PATCH 18/42] Only update focus if not drawing --- lib/matplotlib/interactive_selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 4d2ec425a6a1..edabfe3bbaf0 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -216,7 +216,7 @@ def _handle_event(self, event): if event.name == 'button_press_event': - if not self.allow_redraw: + if not self._drawing and not self.allow_redraw: self.focused = self.patch.contains(event)[0] if self.interactive and not self._drawing: From ea7718408c806f8c7f844c3e231e419ba5c18079 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 07:06:00 -0600 Subject: [PATCH 19/42] Update docstrings --- lib/matplotlib/interactive_selectors.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index edabfe3bbaf0..4d2dafe2ce3c 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -26,6 +26,9 @@ object it may be garbage collected which will disconnect the callbacks. + Use `set_geometry()` to update the tool programmatically. The geometry + attributes (verts, center, extents, etc.) are all read-only. + Attributes ---------- ax: :class:`~matplotlib.axes.Axes` @@ -49,7 +52,11 @@ Whether to allow the tool to redraw itself or whether it must be drawn programmatically and then dragged. verts: nd-array of floats (N, 2) - The vertices of the tool in data units. + The vertices of the tool in data units (read-only). + center: (x, y) + The center coordinates of the tool in data units (read-only). + extents: (x0, y0, width, height) float + The total geometry of the tool in data units (read-only). focused: boolean Whether the tool has focus for keyboard and scroll events.""") @@ -479,9 +486,9 @@ class RectangleTool(BaseTool): %(BaseInteractiveTool)s width: float - The width of the rectangle in data units. + The width of the rectangle in data units (read-only). height: float - The height of the rectangle in data units. + The height of the rectangle in data units (read-only). """ @property @@ -588,9 +595,9 @@ class EllipseTool(RectangleTool): %(BaseInteractiveTool)s width: float - The width of the ellipse in data units. + The width of the ellipse in data units (read-only). height: float - The height of the ellipse in data units. + The height of the ellipse in data units (read-only). """ def set_geometry(self, center, width, height): @@ -618,12 +625,13 @@ class LineTool(BaseTool): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s width: float - The width of the line in pixels. + The width of the line in pixels (read-only). end_points: (2, 2) float - The [(x0, y0), (x1, y1)] end points of the line in data units. + The [(x0, y0), (x1, y1)] end points of the line in data units + (read-only). angle: float The angle between the left point and the right point in radians in - pixel space. + pixel space (read-only). """ @docstring.dedent_interpd From 60a69a95fcab186a799f77ef04164ad4735503a8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 07:07:40 -0600 Subject: [PATCH 20/42] Make width optional for the line tool --- lib/matplotlib/interactive_selectors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 4d2dafe2ce3c..194cc9876fa2 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -650,6 +650,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, allow_redraw=allow_redraw, shape_props=props, handle_props=handle_props, useblit=useblit, button=button, keys=keys) + self._width = 1 @property def width(self): @@ -676,17 +677,18 @@ def angle(self): else: return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) - def set_geometry(self, end_points, width): + def set_geometry(self, end_points, width=None): """Set the geometry of the line tool. Parameters ---------- end_points: (2, 2) float The coordinates of the end points in data space. - width: + width: int, optional The width in pixels. """ pts = np.asarray(end_points) + width = width or self._width self._width = width # Get the widths in data units. From 32409953b86c7087ba32ac3033ac8bd15ddef29b Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 07:58:27 -0600 Subject: [PATCH 21/42] Fix initial size and reset behavior --- lib/matplotlib/interactive_selectors.py | 63 +++++++++++++++---------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 194cc9876fa2..9ab328e14c0b 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -160,6 +160,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._background = None self._prev_evt_xy = None self._start_event = None + self._has_selected = False # Connect the major canvas events to methods.""" self._cids = [] @@ -223,7 +224,8 @@ def _handle_event(self, event): if event.name == 'button_press_event': - if not self._drawing and not self.allow_redraw: + if (not self._drawing and not self.allow_redraw and + self._has_selected): self.focused = self.patch.contains(event)[0] if self.interactive and not self._drawing: @@ -234,7 +236,8 @@ def _handle_event(self, event): if self._drag_idx == self._handles.get_xdata().size - 1: self._state.add('move') - if self._drawing or self._dragging or self.allow_redraw: + if (self._drawing or self._dragging or self.allow_redraw or + not self._has_selected): if 'move' in self._state: self._start_drawing(event) else: @@ -273,12 +276,13 @@ def _handle_event(self, event): def _handle_key_press(self, event): """Handle key_press_event defaults and call to subclass handler""" - if not self.focused: + + if not self._drawing and not self.focused: return if event.key == self._keys['clear']: if self._dragging: - self._set_verts(self._prev_verts) + self._set_verts(self._prev_data['verts']) self._finish_drawing(event, False) elif self._drawing: for artist in self._artists: @@ -359,12 +363,12 @@ def _set_verts(self, value): value = np.asarray(value) assert value.ndim == 2 assert value.shape[1] == 2 - self._verts = np.array(value) + self._verts = value if self._prev_data is None: self._prev_data = dict(verts=self._verts, center=self.center, extents=self.extents) - self.patch.set_xy(self._verts) + self.patch.set_xy(value) self.patch.set_visible(True) self.patch.set_animated(False) @@ -408,7 +412,7 @@ def _start_drawing(self, event): # shape. self._update(self._dragging) - def _finish_drawing(self, selection=False): + def _finish_drawing(self, event, selection=False): """Finish drawing or dragging the shape""" self._drawing = False self._dragging = False @@ -425,6 +429,7 @@ def _finish_drawing(self, selection=False): center=self.center, extents=self.extents) self.on_select(self) + self._has_selected = True self.canvas.draw_idle() ############################################################# @@ -447,7 +452,7 @@ def _on_motion(self, event): def _on_release(self, event): """Handle a button_release_event""" - self._finish_drawing(event) + self._finish_drawing(event, True) def _on_key_press(self, event): """Handle a key_press_event""" @@ -518,10 +523,10 @@ def set_geometry(self, x0, y0, width, height): radx = width / 2 rady = height / 2 center = x0 + width / 2, y0 + height / 2 - self._set_verts([[center - radx, center - rady], - [center - radx, center + rady], - [center + radx, center + rady], - [center + radx, center - rady]]) + self._set_verts([[center[0] - radx, center[1] - rady], + [center[0] - radx, center[1] + rady], + [center[0] + radx, center[1] + rady], + [center[0] + radx, center[1] - rady]]) def _get_handle_verts(self): xm, ym = self.center @@ -600,18 +605,21 @@ class EllipseTool(RectangleTool): The height of the ellipse in data units (read-only). """ - def set_geometry(self, center, width, height): + def set_geometry(self, x0, y0, width, height): """Set the geometry of the ellipse tool. Parameters ---------- - center: (x, y) float - The center coordinates in data units. + x0: float + The left coordinate in data units. + y0: float + The bottom coordinate in data units. width: The width in data units. height: The height in data units. """ + center = x0 + width / 2, y0 + height / 2 rad = np.arange(61) * 6 * np.pi / 180 x = width / 2 * np.cos(rad) + center[0] y = height / 2 * np.sin(rad) + center[1] @@ -651,6 +659,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, handle_props=handle_props, useblit=useblit, button=button, keys=keys) self._width = 1 + self._verts = [[]] @property def width(self): @@ -723,8 +732,10 @@ def _get_handle_verts(self): def _on_press(self, event): if not self._dragging: - self._end_pts = [[event.xdata, event.ydata], - [event.xdata, event.ydata]] + self.set_geometry([[event.xdata, event.ydata], + [event.xdata, event.ydata], + [event.xdata, event.ydata], + [event.xdata, event.ydata]]) self._dragging = True self._drag_idx = 1 self._start_drawing(event) @@ -756,16 +767,16 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - # ellipse = EllipseTool(ax) - # ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) + ellipse = EllipseTool(ax) + #ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) # ax.invert_yaxis() - # def test(tool): - # print(tool.center, tool.width, tool.height) - # ellipse.on_accept = test - # ellipse.allow_redraw = False - line = LineTool(ax) - line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) - line.allow_redraw = False + def test(tool): + print(tool.center, tool.width, tool.height) + ellipse.on_accept = test + ellipse.allow_redraw = False + #line = LineTool(ax) + #line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) + #line.allow_redraw = False plt.show() From 5bd0e124eb493d5d015dfeb8338a2719d56f6427 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 07:59:25 -0600 Subject: [PATCH 22/42] Initialize _verts to valid value --- lib/matplotlib/interactive_selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 9ab328e14c0b..60ed1789d5df 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -155,7 +155,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._drawing = False self._dragging = False self._drag_idx = None - self._verts = [] + self._verts = np.array([[0, 0], [0, 0]]) self._prev_data = None self._background = None self._prev_evt_xy = None From b8618b5fb82568a08fa21046c5f181e5358a2290 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 08:06:35 -0600 Subject: [PATCH 23/42] Remove self._verts since it was confusing --- lib/matplotlib/interactive_selectors.py | 53 +++++++++++++------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 60ed1789d5df..1a9370f938de 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -155,7 +155,6 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self._drawing = False self._dragging = False self._drag_idx = None - self._verts = np.array([[0, 0], [0, 0]]) self._prev_data = None self._background = None self._prev_evt_xy = None @@ -194,13 +193,13 @@ def verts(self): @property def center(self): """Get the (x, y) center of the tool""" - return (self._verts.min(axis=0) + self._verts.max(axis=0)) / 2 + return (self.verts.min(axis=0) + self.verts.max(axis=0)) / 2 @property def extents(self): """Get the (x0, y0, width, height) extents of the tool""" - x0, x1 = np.min(self._verts[:, 0]), np.max(self._verts[:, 0]) - y0, y1 = np.min(self._verts[:, 1]), np.max(self._verts[:, 1]) + x0, x1 = np.min(self.verts[:, 0]), np.max(self.verts[:, 0]) + y0, y1 = np.min(self.verts[:, 1]), np.max(self.verts[:, 1]) return x0, y0, x1 - x0, y1 - y0 def remove(self): @@ -247,9 +246,10 @@ def _handle_event(self, event): if self._drawing: if 'move' in self._state: center = self.center - self._verts[:, 0] += event.xdata - center[0] - self._verts[:, 1] += event.ydata - center[1] - self._set_verts(self._verts) + verts = self.verts + verts[:, 0] += event.xdata - center[0] + verts[:, 1] += event.ydata - center[1] + self._set_verts(verts) else: self._on_motion(event) self.on_move(self) @@ -363,15 +363,16 @@ def _set_verts(self, value): value = np.asarray(value) assert value.ndim == 2 assert value.shape[1] == 2 - self._verts = value - if self._prev_data is None: - self._prev_data = dict(verts=self._verts, - center=self.center, - extents=self.extents) + self.patch.set_xy(value) self.patch.set_visible(True) self.patch.set_animated(False) + if self._prev_data is None: + self._prev_data = dict(verts=value, + center=self.center, + extents=self.extents) + handles = self._get_handle_verts() handles = np.vstack((handles, self.center)) self._handles.set_data(handles[:, 0], handles[:, 1]) @@ -425,7 +426,7 @@ def _finish_drawing(self, event, selection=False): artist.set_visible(False) self._state = set() if selection: - self._prev_data = dict(verts=self._verts, + self._prev_data = dict(verts=self.verts, center=self.center, extents=self.extents) self.on_select(self) @@ -440,7 +441,7 @@ def _get_handle_verts(self): Return an (N, 2) array of vertices. """ - return self._verts + return self.verts def _on_press(self, event): """Handle a button_press_event""" @@ -499,12 +500,12 @@ class RectangleTool(BaseTool): @property def width(self): """Get the width of the tool in data units""" - return np.ptp(self._verts[:, 0]) + return np.ptp(self.verts[:, 0]) @property def height(self): """Get the height of the tool in data units""" - return np.ptp(self._verts[:, 1]) + return np.ptp(self.verts[:, 1]) def set_geometry(self, x0, y0, width, height): """Set the geometry of the rectangle tool. @@ -659,7 +660,6 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, handle_props=handle_props, useblit=useblit, button=button, keys=keys) self._width = 1 - self._verts = [[]] @property def width(self): @@ -669,10 +669,11 @@ def width(self): @property def end_points(self): """Get the end points of the line in data units.""" - p0x = (self._verts[0, 0] + self._verts[1, 0]) / 2 - p0y = (self._verts[0, 1] + self._verts[1, 1]) / 2 - p1x = (self._verts[3, 0] + self._verts[2, 0]) / 2 - p1y = (self._verts[3, 1] + self._verts[2, 1]) / 2 + verts = self.verts + p0x = (verts[0, 0] + verts[1, 0]) / 2 + p0y = (verts[0, 1] + verts[1, 1]) / 2 + p1x = (verts[3, 0] + verts[2, 0]) / 2 + p1y = (verts[3, 1] + verts[2, 1]) / 2 return np.array([[p0x, p0y], [p1x, p1y]]) @property @@ -767,16 +768,16 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - ellipse = EllipseTool(ax) + #ellipse = RectangleTool(ax) #ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) # ax.invert_yaxis() def test(tool): print(tool.center, tool.width, tool.height) - ellipse.on_accept = test - ellipse.allow_redraw = False - #line = LineTool(ax) + #ellipse.on_accept = test + #ellipse.allow_redraw = False + line = LineTool(ax) #line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) - #line.allow_redraw = False + line.allow_redraw = False plt.show() From 6622c67c6b23f26bcca48802edd7542e84f24159 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 08:08:48 -0600 Subject: [PATCH 24/42] Fix base class docstring --- lib/matplotlib/interactive_selectors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 1a9370f938de..6264d0eddf49 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -100,6 +100,7 @@ """) +@docstring.dedent_interpd class BaseTool(object): """Interactive selection tool that is connected to a single From 139b03347426be2704a63db2127b2a2891bbe0d8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 08:16:23 -0600 Subject: [PATCH 25/42] Fix the has_selected behavior --- lib/matplotlib/interactive_selectors.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 6264d0eddf49..b71c965e3626 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -381,6 +381,9 @@ def _set_verts(self, value): self._handles.set_animated(False) self._update() + if not self._drawing: + self._has_selected = True + def _update(self, visible=True): """Update the artists while drawing""" if not self.ax.get_visible(): @@ -769,16 +772,17 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - #ellipse = RectangleTool(ax) - #ellipse.set_geometry((0.6, 1.1), 0.5, 0.5) - # ax.invert_yaxis() + ellipse = RectangleTool(ax) + ellipse.set_geometry(0.6, 1.1, 0.5, 0.5) + ax.invert_yaxis() def test(tool): - print(tool.center, tool.width, tool.height) - #ellipse.on_accept = test - #ellipse.allow_redraw = False + print(tool.center, tool.width, tool.height) + + ellipse.on_accept = test + ellipse.allow_redraw = False line = LineTool(ax) - #line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) + line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) line.allow_redraw = False plt.show() From 56ea9cf4e4ce494f931377fe6dbbb6bb1e61e0b3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 08:24:21 -0600 Subject: [PATCH 26/42] Improve handling of key modifiers --- lib/matplotlib/interactive_selectors.py | 35 ++++++++++++------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index b71c965e3626..ac97c498f4ca 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -152,9 +152,10 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.ax.add_line(self._handles) self._artists = [self.patch, self._handles] - self._state = set() + self._modifiers = set() self._drawing = False self._dragging = False + self._moving = False self._drag_idx = None self._prev_data = None self._background = None @@ -234,18 +235,18 @@ def _handle_event(self, event): self._drag_idx = idx['ind'][0] # If the move handle was selected, enter move state. if self._drag_idx == self._handles.get_xdata().size - 1: - self._state.add('move') + self._moving = True if (self._drawing or self._dragging or self.allow_redraw or not self._has_selected): - if 'move' in self._state: + if self._moving: self._start_drawing(event) else: self._on_press(event) elif event.name == 'motion_notify_event': if self._drawing: - if 'move' in self._state: + if self._moving: center = self.center verts = self.verts verts[:, 0] += event.xdata - center[0] @@ -257,19 +258,17 @@ def _handle_event(self, event): elif event.name == 'button_release_event': if self._drawing: - if 'move' in self._state: + if self._moving: self._finish_drawing(event) + self._moving = False else: self._on_release(event) self._dragging = False elif event.name == 'key_release_event' and self.focused: - for (state, modifier) in self._keys.items(): - # Keep move state locked until drawing finished. - if state == 'move' and self._drawing: - continue - if modifier in event.key: - self._state.discard(state) + for (modifier, key) in self._keys.items(): + if key in event.key: + self._modifiers.discard(modifier) self._on_key_release(event) elif event.name == 'scroll_event' and self.focused: @@ -301,11 +300,11 @@ def _handle_key_press(self, event): artist.set_visible(False) self.canvas.draw_idle() - for (state, modifier) in self._keys.items(): - if state == 'move' and not self.interactive: + for (modifer, key) in self._keys.items(): + if modifer == 'move' and not self.interactive: continue - if modifier in event.key: - self._state.add(state) + if key in event.key: + self._modifiers.add(modifer) self._on_key_press(event) def _clean_event(self, event): @@ -428,7 +427,7 @@ def _finish_drawing(self, event, selection=False): else: for artist in self._artists: artist.set_visible(False) - self._state = set() + self._modifiers = set() if selection: self._prev_data = dict(verts=self.verts, center=self.center, @@ -569,7 +568,7 @@ def _on_motion(self, event): dy = (event.ydata - center[1]) / 2. # Draw a square shape. - if 'square' in self._state: + if 'square' in self._modifiers: dx_pix = abs(event.x - center_pix[0]) dy_pix = abs(event.y - center_pix[1]) if not dx_pix: @@ -581,7 +580,7 @@ def _on_motion(self, event): dy *= maxd / (abs(dy_pix) + 1e-6) # Draw from center. - if 'center' in self._state: + if 'center' in self._modifiers: dx *= 2 dy *= 2 From b0f128fcec4f3214199d880a234b4a7a561c326a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 08:27:44 -0600 Subject: [PATCH 27/42] Specify that the patch is a polygon --- lib/matplotlib/interactive_selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index ac97c498f4ca..73628bc02419 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -35,7 +35,7 @@ The parent axes for the tool. canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass The parent figure canvas for the tool. - patch: :class:`~matplotlib.patches.Patch` + patch: :class:`~matplotlib.patches.Polygon` The patch object contained by the tool. active: boolean If False, the widget does not respond to events. From f08b783247f723b79413c4b02c73d411dbed15d9 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 10:01:16 -0600 Subject: [PATCH 28/42] Fix handling of line width --- lib/matplotlib/interactive_selectors.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 73628bc02419..bb1bfe344dc0 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -195,13 +195,15 @@ def verts(self): @property def center(self): """Get the (x, y) center of the tool""" - return (self.verts.min(axis=0) + self.verts.max(axis=0)) / 2 + verts = self.verts + return (verts.min(axis=0) + verts.max(axis=0)) / 2 @property def extents(self): """Get the (x0, y0, width, height) extents of the tool""" - x0, x1 = np.min(self.verts[:, 0]), np.max(self.verts[:, 0]) - y0, y1 = np.min(self.verts[:, 1]), np.max(self.verts[:, 1]) + verts = self.verts + x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) + y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) return x0, y0, x1 - x0, y1 - y0 def remove(self): @@ -708,7 +710,7 @@ def set_geometry(self, end_points, width=None): xfm = self.ax.transData.inverted() x0, y0 = xfm.transform((0, 0)) x1, y1 = xfm.transform((width, width)) - wx, wy = x1 - x0, y1 - y0 + wx, wy = abs(x1 - x0), abs(y1 - y0) # Find line segments centered on the end points perpendicular to the # line and the proper width. @@ -771,8 +773,8 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - ellipse = RectangleTool(ax) - ellipse.set_geometry(0.6, 1.1, 0.5, 0.5) + ellipse = EllipseTool(ax) + ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) ax.invert_yaxis() def test(tool): From 9e0afd68626678b83c7bebad75f076abf3f89067 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 14:57:56 -0600 Subject: [PATCH 29/42] Add the paint tool --- lib/matplotlib/interactive_selectors.py | 286 +++++++++++++++++++----- 1 file changed, 233 insertions(+), 53 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index bb1bfe344dc0..5487c3e524db 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -8,7 +8,8 @@ import numpy as np # TODO: convert these to relative when finished -from matplotlib.patches import Polygon +import matplotlib.colors as mcolors +from matplotlib.patches import Polygon, Rectangle from matplotlib.lines import Line2D from matplotlib import docstring, artist as martist @@ -41,24 +42,30 @@ If False, the widget does not respond to events. on_select: callable, optional A callback for when a selection is made `on_select(tool)`. - on_move: callable, optional - A callback for when the tool is moved `on_move(tool)`. + on_motion: callable, optional + A callback for when the tool is moved `on_motion(tool)`. on_accept: callable, optional A callback for when the selection is accepted `on_accept(tool)`. This is called in response to an 'accept' key event. - interactive: boolean - Whether to allow interaction with the shape using handles. - allow_redraw: boolean - Whether to allow the tool to redraw itself or whether it must be - drawn programmatically and then dragged. verts: nd-array of floats (N, 2) The vertices of the tool in data units (read-only). center: (x, y) The center coordinates of the tool in data units (read-only). extents: (x0, y0, width, height) float The total geometry of the tool in data units (read-only). + """) + + +docstring.interpd.update(BaseInteractiveToolExtra="""\ + interactive: boolean + Whether to allow interaction with the shape using handles. + allow_redraw: boolean + Whether to allow the tool to redraw itself or whether it must be + drawn programmatically and then dragged. focused: boolean - Whether the tool has focus for keyboard and scroll events.""") + Whether the tool has focus for keyboard and scroll events. + """) + docstring.interpd.update(BaseInteractiveToolInit=""" Parameters @@ -67,20 +74,13 @@ The parent axes for the tool. on_select: callable, optional A callback for when a selection is made `on_select(tool)`. - on_move: callable, optional - A callback for when the tool is moved `on_move(tool)`. + on_motion: callable, optional + A callback for when the tool is moved `on_motion(tool)`. on_accept: callable, optional A callback for when the selection is accepted `on_accept(tool)`. This is called in response to an 'accept' key event. - interactive: boolean, optional - Whether to allow interaction with the shape using handles. - allow_redraw: boolean, optional - Whether to allow the tool to redraw itself or whether it must be - drawn programmatically and then dragged. shape_props: dict, optional The properties of the shape patch. - handle_props: dict, optional - The properties of the handle markers. useblit: boolean, optional Whether to use blitting while drawing if available. button: int or list of int, optional @@ -100,6 +100,17 @@ """) +docstring.interpd.update(BaseInteractiveToolInitExtra=""" + interactive: boolean, optional + Whether to allow interaction with the shape using handles. + allow_redraw: boolean, optional + Whether to allow the tool to redraw itself or whether it must be + drawn programmatically and then dragged. + handle_props: dict, optional + The properties of the handle markers. +""") + + @docstring.dedent_interpd class BaseTool(object): @@ -107,14 +118,15 @@ class BaseTool(object): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s + %(BaseInteractiveToolExtra)s """ - def __init__(self, ax, on_select=None, on_move=None, on_accept=None, - interactive=True, allow_redraw=True, - shape_props=None, handle_props=None, - useblit=True, button=None, keys=None): + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, + interactive=True, allow_redraw=True, shape_props=None, + handle_props=None, useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s + %(BaseInteractiveToolInitExtra)s """ self.ax = ax self.canvas = ax.figure.canvas @@ -123,7 +135,7 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, self.allow_redraw = allow_redraw self.focused = True - self.on_move = _dummy if on_move is None else on_move + self.on_motion = _dummy if on_motion is None else on_motion self.on_accept = _dummy if on_accept is None else on_accept self.on_select = _dummy if on_select is None else on_select @@ -136,32 +148,35 @@ def __init__(self, ax, on_select=None, on_move=None, on_accept=None, if isinstance(button, int): self._buttons = [button] else: + button = button or [1, 2, 3] self._buttons = button props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, picker=5, linewidth=2) + alpha=0.2, fill=True, picker=5, linewidth=2, + zorder=1) props.update(shape_props or {}) self.patch = Polygon([[0, 0], [1, 1]], True, **props) self.ax.add_patch(self.patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', - picker=10) + picker=10, zorder=2) props.update(handle_props or {}) self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) self._artists = [self.patch, self._handles] + self._modifiers = set() self._drawing = False self._dragging = False self._moving = False self._drag_idx = None + self._has_selected = False self._prev_data = None self._background = None self._prev_evt_xy = None self._start_event = None - self._has_selected = False # Connect the major canvas events to methods.""" self._cids = [] @@ -224,6 +239,8 @@ def _handle_event(self, event): if self._ignore(event): return event = self._clean_event(event) + if event.xdata is None: + return if event.name == 'button_press_event': @@ -256,7 +273,7 @@ def _handle_event(self, event): self._set_verts(verts) else: self._on_motion(event) - self.on_move(self) + self.on_motion(self) elif event.name == 'button_release_event': if self._drawing: @@ -325,7 +342,7 @@ def _clean_event(self, event): ydata = max(y0, event.ydata) event.ydata = min(y1, ydata) self._prev_evt_xy = event.xdata, event.ydata - else: + elif self._prev_evt_xy is not None: event.xdata, event.ydata = self._prev_evt_xy event.key = event.key or '' @@ -385,7 +402,7 @@ def _set_verts(self, value): if not self._drawing: self._has_selected = True - def _update(self, visible=True): + def _update(self): """Update the artists while drawing""" if not self.ax.get_visible(): return @@ -394,7 +411,6 @@ def _update(self, visible=True): if self._background is not None: self.canvas.restore_region(self._background) for artist in self._artists: - artist.set_visible(visible) self.ax.draw_artist(artist) self.canvas.blit(self.ax.bbox) @@ -414,9 +430,13 @@ def _start_drawing(self, event): artist.set_animated(self._useblit) else: self._handles.set_visible(False) - # Blit without being visible if not draggin to avoid showing the old + # Blit without being visible if not dragging to avoid showing the old # shape. - self._update(self._dragging) + for artist in self._artists: + artist.set_visible(self._dragging) + if artist == self._handles: + artist.set_visible(self._dragging and self.interactive) + self._update() def _finish_drawing(self, event, selection=False): """Finish drawing or dragging the shape""" @@ -473,13 +493,18 @@ def _on_scroll(self, event): pass +class InteractiveTool(BaseTool): + pass + + def _dummy(tool): """A dummy callback for a tool.""" pass tooldoc = martist.kwdoc(BaseTool) -for k in ('RectangleTool', 'EllipseTool', 'LineTool', 'BaseTool'): +for k in ('RectangleTool', 'EllipseTool', 'LineTool', 'BaseTool', + 'PaintTool'): docstring.interpd.update({k: tooldoc}) # define BaseTool.__init__ docstring after the class has been added to interpd @@ -496,6 +521,7 @@ class RectangleTool(BaseTool): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s + %(BaseInteractiveToolExtra)s width: float The width of the rectangle in data units (read-only). height: float @@ -605,6 +631,7 @@ class EllipseTool(RectangleTool): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s + %(BaseInteractiveToolExtra)s width: float The width of the ellipse in data units (read-only). height: float @@ -638,8 +665,9 @@ class LineTool(BaseTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s + %(BaseInteractiveToolExtra)s width: float - The width of the line in pixels (read-only). + The width of the line in pixels. This can be set directly. end_points: (2, 2) float The [(x0, y0), (x1, y1)] end points of the line in data units (read-only). @@ -649,18 +677,19 @@ class LineTool(BaseTool): """ @docstring.dedent_interpd - def __init__(self, ax, on_select=None, on_move=None, on_accept=None, + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s + %(BaseInteractiveToolInitExtra)s """ props = dict(edgecolor='red', visible=False, alpha=0.5, fill=True, picker=5, linewidth=1) props.update(shape_props or {}) super(LineTool, self).__init__(ax, on_select=on_select, - on_move=on_move, on_accept=on_accept, interactive=interactive, + on_motion=on_motion, on_accept=on_accept, interactive=interactive, allow_redraw=allow_redraw, shape_props=props, handle_props=handle_props, useblit=useblit, button=button, keys=keys) @@ -671,6 +700,10 @@ def width(self): """Get the width of the line in pixels.""" return self._width + @width.setter + def width(self, value): + self.set_geometry(self.end_points, value) + @property def end_points(self): """Get the end points of the line in data units.""" @@ -717,6 +750,8 @@ def set_geometry(self, end_points, width=None): # http://math.stackexchange.com/a/9375 if (pts[1, 0] == pts[0, 0]): c, s = 0, 1 + elif (pts[1, 1] == pts[0, 1]): + c, s = 0, 0 else: m = - 1 / ((pts[1, 1] - pts[0, 1]) / (pts[1, 0] - pts[0, 0])) @@ -753,15 +788,159 @@ def _on_motion(self, event): def _on_scroll(self, event): if event.button == 'up': - self.set_geometry(self.width + 1) - elif event.button == 'down' and self.width > 1: - self.set_geometry(self.width - 1) + self.set_geometry(self.end_points, self.width + 1) + elif event.button == 'down': + self.set_geometry(self.end_points, self.width - 1) def _on_key_press(self, event): if event.key == '+': - self.set_geometry(self.width + 1) + self.set_geometry(self.end_points, self.width + 1) elif event.key == '-' and self.width > 1: - self.set_geometry(self.width - 1) + self.set_geometry(self.end_points, self.width - 1) + + +LABELS_CMAP = mcolors.ListedColormap(['white', 'red', 'dodgerblue', 'gold', + 'greenyellow', 'blueviolet']) + + +@docstring.dedent_interpd +class PaintTool(BaseTool): + + """Interactive paint tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + %(BaseInteractiveTool)s + """ + + @docstring.dedent_interpd + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, + shape_props=None, handle_props=None, radius=5, + useblit=True, button=None, keys=None): + """Initialize the tool. + %(BaseInteractiveToolInit)s + """ + super(PaintTool, self).__init__(ax, on_select=on_select, + on_motion=on_motion, on_accept=on_accept, + shape_props=shape_props, useblit=useblit, button=button, + keys=keys) + self.cmap = LABELS_CMAP + self._useblit = useblit and self.canvas.supports_blit + self._previous = None + self._overlay = None + self._overlay_plot = None + self._cursor_shape = [0, 0, 0] + + props = dict(edgecolor='r', facecolor='0.7', alpha=1, + animated=self._useblit, visible=False, zorder=2) + props.update(handle_props or {}) + self._cursor = Rectangle((0, 0), 0, 0, **props) + self.ax.add_patch(self._cursor) + + x0, x1 = self.ax.get_xlim() + y0, y1 = self.ax.get_ylim() + if y0 < y1: + origin = 'lower' + else: + origin = 'upper' + props = dict(cmap=self.cmap, alpha=0.5, origin=origin, + norm=mcolors.NoNorm(), visible=False, zorder=1, + extent=(x0, x1, y0, y1), aspect=self.ax.get_aspect()) + props.update(shape_props or {}) + + extents = self.ax.get_window_extent().extents + self._offsetx = extents[0] + self._offsety = extents[1] + self._shape = (extents[3] - extents[1], extents[2] - extents[0]) + self._overlay = np.zeros(self._shape, dtype='uint8') + self._overlay_plot = self.ax.imshow(self._overlay, **props) + + self._artists = [self._cursor, self._overlay_plot] + + # These must be called last + self.label = 1 + self.radius = radius + self._start_drawing(None) + for artist in self._artists: + artist.set_visible(True) + + @property + def overlay(self): + return self._overlay + + @overlay.setter + def overlay(self, image): + self._overlay = image + if image is None: + self.ax.images.remove(self._overlay_plot) + self._update() + return + self.ax.set_data(image) + self._shape = image.shape + x0, x1 = self.ax.get_xlim() + y0, y1 = self.ax.get_ylim() + self._overlay_plot.set_extent(x0, x1, y0, y1) + # Update the radii and window. + self.radius = self._radius + self._update() + + @property + def label(self): + return self._label + + @label.setter + def label(self, value): + if value >= self.cmap.N: + raise ValueError('Maximum label value = %s' % len(self.cmap - 1)) + self._label = value + self._cursor.set_edgecolor(self.cmap(value)) + + @property + def radius(self): + return self._radius + + @radius.setter + def radius(self, r): + self._radius = r + xfm = self.ax.transData.inverted() + x0, y0 = xfm.transform((0, 0)) + x1, y1 = xfm.transform((r, r)) + self._rx, self._ry = abs(x1 - x0), abs(y1 - y0) + + self._cursor.set_width(self._rx * 2) + self._cursor.set_height(self._ry * 2) + + def _on_press(self, event): + self._update_cursor(event.xdata, event.ydata) + self._update_overlay(event.x, event.y) + self._update() + + def _on_motion(self, event): + self._update_cursor(event.xdata, event.ydata) + if event.button and event.button in self._buttons: + self._update_overlay(event.x, event.y) + self._update() + + def _on_release(self, event): + pass + + def _update_overlay(self, x, y): + col = x - self._offsetx + row = y - self._offsety + + h, w = self._shape + r = self._radius + + xmin = int(max(0, col - r)) + xmax = int(min(w, col + r + 1)) + ymin = int(max(0, row - r)) + ymax = int(min(h, row + r + 1)) + + self._overlay[slice(ymin, ymax), slice(xmin, xmax)] = self.label + self._overlay_plot.set_data(self._overlay) + + def _update_cursor(self, x, y): + x = x - self._rx + y = y - self._ry + self._cursor.set_xy((x, y)) if __name__ == '__main__': @@ -773,17 +952,18 @@ def _on_key_press(self, event): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - ellipse = EllipseTool(ax) - ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) - ax.invert_yaxis() - - def test(tool): - print(tool.center, tool.width, tool.height) - - ellipse.on_accept = test - ellipse.allow_redraw = False - line = LineTool(ax) - line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) - line.allow_redraw = False + # ellipse = EllipseTool(ax) + # ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) + # ax.invert_yaxis() + + # def test(tool): + # print(tool.center, tool.width, tool.height) + + # ellipse.on_accept = test + # ellipse.allow_redraw = False + #line = LineTool(ax) + #line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) + #line.interactive = False + p = PaintTool(ax) plt.show() From 92b56ef096a46e219ea12abdef977b29a18dca9d Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 15:00:27 -0600 Subject: [PATCH 30/42] Make the patch private --- lib/matplotlib/interactive_selectors.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 5487c3e524db..494e573872b2 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -36,8 +36,6 @@ The parent axes for the tool. canvas: :class:`~matplotlib.backend_bases.FigureCanvasBase` subclass The parent figure canvas for the tool. - patch: :class:`~matplotlib.patches.Polygon` - The patch object contained by the tool. active: boolean If False, the widget does not respond to events. on_select: callable, optional @@ -155,8 +153,8 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, alpha=0.2, fill=True, picker=5, linewidth=2, zorder=1) props.update(shape_props or {}) - self.patch = Polygon([[0, 0], [1, 1]], True, **props) - self.ax.add_patch(self.patch) + self._patch = Polygon([[0, 0], [1, 1]], True, **props) + self.ax.add_patch(self._patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', @@ -165,7 +163,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) - self._artists = [self.patch, self._handles] + self._artists = [self._patch, self._handles] self._modifiers = set() self._drawing = False @@ -205,7 +203,7 @@ def active(self, value): @property def verts(self): """Get the (N, 2) vertices of the tool""" - return self.patch.get_xy() + return self._patch.get_xy() @property def center(self): @@ -246,7 +244,7 @@ def _handle_event(self, event): if (not self._drawing and not self.allow_redraw and self._has_selected): - self.focused = self.patch.contains(event)[0] + self.focused = self._patch.contains(event)[0] if self.interactive and not self._drawing: self._dragging, idx = self._handles.contains(event) @@ -383,9 +381,9 @@ def _set_verts(self, value): assert value.ndim == 2 assert value.shape[1] == 2 - self.patch.set_xy(value) - self.patch.set_visible(True) - self.patch.set_animated(False) + self._patch.set_xy(value) + self._patch.set_visible(True) + self._patch.set_animated(False) if self._prev_data is None: self._prev_data = dict(verts=value, From 589eae8b37447a11f93e38ce09e6b18836215b3d Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 3 Jan 2016 15:13:18 -0600 Subject: [PATCH 31/42] Move polygon-specific items out of base tool --- lib/matplotlib/interactive_selectors.py | 72 ++++++++++++------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 494e573872b2..6a085bb91e15 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -45,12 +45,6 @@ on_accept: callable, optional A callback for when the selection is accepted `on_accept(tool)`. This is called in response to an 'accept' key event. - verts: nd-array of floats (N, 2) - The vertices of the tool in data units (read-only). - center: (x, y) - The center coordinates of the tool in data units (read-only). - extents: (x0, y0, width, height) float - The total geometry of the tool in data units (read-only). """) @@ -62,6 +56,12 @@ drawn programmatically and then dragged. focused: boolean Whether the tool has focus for keyboard and scroll events. + verts: nd-array of floats (N, 2) + The vertices of the tool in data units (read-only). + center: (x, y) + The center coordinates of the tool in data units (read-only). + extents: (x0, y0, width, height) float + The total geometry of the tool in data units (read-only). """) @@ -77,8 +77,6 @@ on_accept: callable, optional A callback for when the selection is accepted `on_accept(tool)`. This is called in response to an 'accept' key event. - shape_props: dict, optional - The properties of the shape patch. useblit: boolean, optional Whether to use blitting while drawing if available. button: int or list of int, optional @@ -104,6 +102,8 @@ allow_redraw: boolean, optional Whether to allow the tool to redraw itself or whether it must be drawn programmatically and then dragged. + shape_props: dict, optional + The properties of the shape patch. handle_props: dict, optional The properties of the handle markers. """) @@ -200,25 +200,6 @@ def active(self, value): artist.set_visible(False) self.canvas.draw_idle() - @property - def verts(self): - """Get the (N, 2) vertices of the tool""" - return self._patch.get_xy() - - @property - def center(self): - """Get the (x, y) center of the tool""" - verts = self.verts - return (verts.min(axis=0) + verts.max(axis=0)) / 2 - - @property - def extents(self): - """Get the (x0, y0, width, height) extents of the tool""" - verts = self.verts - x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) - y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) - return x0, y0, x1 - x0, y1 - y0 - def remove(self): """Clean up the tool""" for c in self._cids: @@ -491,8 +472,28 @@ def _on_scroll(self, event): pass -class InteractiveTool(BaseTool): - pass +class PolygonTool(BaseTool): + + """An interactive which draws a polygon shape""" + + @property + def verts(self): + """Get the (N, 2) vertices of the tool""" + return self._patch.get_xy() + + @property + def center(self): + """Get the (x, y) center of the tool""" + verts = self.verts + return (verts.min(axis=0) + verts.max(axis=0)) / 2 + + @property + def extents(self): + """Get the (x0, y0, width, height) extents of the tool""" + verts = self.verts + x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) + y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) + return x0, y0, x1 - x0, y1 - y0 def _dummy(tool): @@ -513,7 +514,7 @@ def _dummy(tool): @docstring.dedent_interpd -class RectangleTool(BaseTool): +class RectangleTool(PolygonTool): """Interactive rectangle selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. @@ -658,7 +659,7 @@ def set_geometry(self, x0, y0, width, height): @docstring.dedent_interpd -class LineTool(BaseTool): +class LineTool(PolygonTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. @@ -811,15 +812,14 @@ class PaintTool(BaseTool): @docstring.dedent_interpd def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, - shape_props=None, handle_props=None, radius=5, + overlay_props=None, cursor_props=None, radius=5, useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s """ super(PaintTool, self).__init__(ax, on_select=on_select, on_motion=on_motion, on_accept=on_accept, - shape_props=shape_props, useblit=useblit, button=button, - keys=keys) + useblit=useblit, button=button, keys=keys) self.cmap = LABELS_CMAP self._useblit = useblit and self.canvas.supports_blit self._previous = None @@ -829,7 +829,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, props = dict(edgecolor='r', facecolor='0.7', alpha=1, animated=self._useblit, visible=False, zorder=2) - props.update(handle_props or {}) + props.update(cursor_props or {}) self._cursor = Rectangle((0, 0), 0, 0, **props) self.ax.add_patch(self._cursor) @@ -842,7 +842,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, props = dict(cmap=self.cmap, alpha=0.5, origin=origin, norm=mcolors.NoNorm(), visible=False, zorder=1, extent=(x0, x1, y0, y1), aspect=self.ax.get_aspect()) - props.update(shape_props or {}) + props.update(overlay_props or {}) extents = self.ax.get_window_extent().extents self._offsetx = extents[0] From 0d999cb9f5e8d8107e11f122af432498f623e9f3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 17:11:22 -0600 Subject: [PATCH 32/42] wip --- lib/matplotlib/interactive_selectors.py | 380 +++++++++++++----------- 1 file changed, 210 insertions(+), 170 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 6a085bb91e15..32094dec7c91 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -56,8 +56,8 @@ drawn programmatically and then dragged. focused: boolean Whether the tool has focus for keyboard and scroll events. - verts: nd-array of floats (N, 2) - The vertices of the tool in data units (read-only). + polygon: `matplotlib.patches.Polygon` + The polygon patch. center: (x, y) The center coordinates of the tool in data units (read-only). extents: (x0, y0, width, height) float @@ -120,8 +120,7 @@ class BaseTool(object): """ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, - interactive=True, allow_redraw=True, shape_props=None, - handle_props=None, useblit=True, button=None, keys=None): + useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s %(BaseInteractiveToolInitExtra)s @@ -129,9 +128,6 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self.ax = ax self.canvas = ax.figure.canvas self._active = True - self.interactive = interactive - self.allow_redraw = allow_redraw - self.focused = True self.on_motion = _dummy if on_motion is None else on_motion self.on_accept = _dummy if on_accept is None else on_accept @@ -149,32 +145,11 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, button = button or [1, 2, 3] self._buttons = button - props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, picker=5, linewidth=2, - zorder=1) - props.update(shape_props or {}) - self._patch = Polygon([[0, 0], [1, 1]], True, **props) - self.ax.add_patch(self._patch) - - props = dict(marker='o', markersize=7, mfc='w', ls='none', - alpha=0.5, visible=False, label='_nolegend_', - picker=10, zorder=2) - props.update(handle_props or {}) - self._handles = Line2D([], [], **props) - self.ax.add_line(self._handles) - - self._artists = [self._patch, self._handles] - + self._artists = [] self._modifiers = set() self._drawing = False - self._dragging = False - self._moving = False - self._drag_idx = None - self._has_selected = False - self._prev_data = None self._background = None self._prev_evt_xy = None - self._start_event = None # Connect the major canvas events to methods.""" self._cids = [] @@ -182,7 +157,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._connect_event('button_press_event', self._handle_event) self._connect_event('button_release_event', self._handle_event) self._connect_event('draw_event', self._handle_draw) - self._connect_event('key_press_event', self._handle_key_press) + self._connect_event('key_press_event', self._handle_event) self._connect_event('key_release_event', self._handle_event) self._connect_event('scroll_event', self._handle_event) @@ -218,92 +193,48 @@ def _handle_event(self, event): if self._ignore(event): return event = self._clean_event(event) - if event.xdata is None: + if event.xdata is None and 'key' not in event.name: return if event.name == 'button_press_event': - - if (not self._drawing and not self.allow_redraw and - self._has_selected): - self.focused = self._patch.contains(event)[0] - - if self.interactive and not self._drawing: - self._dragging, idx = self._handles.contains(event) - if self._dragging: - self._drag_idx = idx['ind'][0] - # If the move handle was selected, enter move state. - if self._drag_idx == self._handles.get_xdata().size - 1: - self._moving = True - - if (self._drawing or self._dragging or self.allow_redraw or - not self._has_selected): - if self._moving: - self._start_drawing(event) - else: - self._on_press(event) + self._handle_button_press(event) elif event.name == 'motion_notify_event': - if self._drawing: - if self._moving: - center = self.center - verts = self.verts - verts[:, 0] += event.xdata - center[0] - verts[:, 1] += event.ydata - center[1] - self._set_verts(verts) - else: - self._on_motion(event) - self.on_motion(self) + self._handle_motion_notify(event) elif event.name == 'button_release_event': - if self._drawing: - if self._moving: - self._finish_drawing(event) - self._moving = False - else: - self._on_release(event) - self._dragging = False - - elif event.name == 'key_release_event' and self.focused: - for (modifier, key) in self._keys.items(): - if key in event.key: - self._modifiers.discard(modifier) - self._on_key_release(event) + self._handle_button_release(event) - elif event.name == 'scroll_event' and self.focused: - self._on_scroll(event) + elif event.name == 'key_press_event': + self._handle_key_press(event) - def _handle_key_press(self, event): - """Handle key_press_event defaults and call to subclass handler""" + elif event.name == 'key_release_event': + self._handle_key_release(event) - if not self._drawing and not self.focused: - return + elif event.name == 'scroll_event': + self._handle_scroll(event) - if event.key == self._keys['clear']: - if self._dragging: - self._set_verts(self._prev_data['verts']) - self._finish_drawing(event, False) - elif self._drawing: - for artist in self._artists: - artist.set_visible(False) - self._finish_drawing(event, False) - return + def _handle_motion_notify(self, event): + self._on_motion(event) + self.on_motion(self) - elif event.key == self._keys['accept']: - if self._drawing: - self._finish_drawing(event) + def _handle_button_release(self, event): + self._on_release(event) - self.on_accept(self) - if self.allow_redraw: - for artist in self._artists: - artist.set_visible(False) - self.canvas.draw_idle() + def _handle_key_release(self, event): + self._on_key_release(event) - for (modifer, key) in self._keys.items(): - if modifer == 'move' and not self.interactive: - continue - if key in event.key: - self._modifiers.add(modifer) + def _handle_scroll(self, event): + self._on_scroll(event) + + def _handle_button_press(self, event): + self._on_press(event) + + def _handle_key_press(self, event): + """Handle key_press_event defaults and call to subclass handler""" self._on_key_press(event) + if event.key == self._keys['accept']: + self.on_accept(self) def _clean_event(self, event): """Clean up an event. @@ -356,6 +287,99 @@ def _ignore(self, event): return False + def _update(self): + """Update the artists while drawing""" + if not self.ax.get_visible(): + return + + if self._useblit and self._drawing: + if self._background is not None: + self.canvas.restore_region(self._background) + for artist in self._artists: + self.ax.draw_artist(artist) + + self.canvas.blit(self.ax.bbox) + else: + self.canvas.draw_idle() + + ############################################################# + # The following are meant to be subclassed as needed. + ############################################################# + def _on_press(self, event): + """Handle a button_press_event""" + pass + + def _on_motion(self, event): + """Handle a motion_notify_event""" + pass + + def _on_release(self, event): + """Handle a button_release_event""" + pass + + def _on_key_press(self, event): + """Handle a key_press_event""" + pass + + def _on_key_release(self, event): + """Handle a key_release_event""" + pass + + def _on_scroll(self, event): + """Handle a scroll_event""" + pass + + +def _dummy(tool): + """A dummy callback for a tool.""" + pass + + +tooldoc = martist.kwdoc(BaseTool) +for k in ('RectangleTool', 'EllipseTool', 'LineTool', 'BaseTool', + 'PaintTool'): + docstring.interpd.update({k: tooldoc}) + +# define BaseTool.__init__ docstring after the class has been added to interpd +docstring.dedent_interpd(BaseTool.__init__) + + +class BasePolygonTool(BaseTool): + + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, + interactive=True, allow_redraw=True, shape_props=None, + handle_props=None, useblit=True, button=None, keys=None): + super(BasePolygonTool, self).__init__(ax, on_select=on_select, + on_accept=on_accept, on_motion=on_motion, useblit=True, + keys=None) + self.interactive = interactive + self.allow_redraw = allow_redraw + self._focused = True + + props = dict(facecolor='red', edgecolor='black', visible=False, + alpha=0.2, fill=True, picker=5, linewidth=2, + zorder=1) + props.update(shape_props or {}) + self.patch = Polygon([[0, 0], [1, 1]], True, **props) + self.ax.add_patch(self._patch) + + props = dict(marker='o', markersize=7, mfc='w', ls='none', + alpha=0.5, visible=False, label='_nolegend_', + picker=10, zorder=2) + props.update(handle_props or {}) + self._handles = Line2D([], [], **props) + self.ax.add_line(self._handles) + + self._artists = [self._patch, self._handles] + + self._drawing = False + self._dragging = False + self._moving = False + self._drag_idx = None + self._has_selected = False + self._prev_data = None + self._start_event = None + def _set_verts(self, value): """Commit a change to the tool vertices.""" value = np.asarray(value) @@ -381,21 +405,6 @@ def _set_verts(self, value): if not self._drawing: self._has_selected = True - def _update(self): - """Update the artists while drawing""" - if not self.ax.get_visible(): - return - - if self._useblit and self._drawing: - if self._background is not None: - self.canvas.restore_region(self._background) - for artist in self._artists: - self.ax.draw_artist(artist) - - self.canvas.blit(self.ax.bbox) - else: - self.canvas.draw_idle() - def _start_drawing(self, event): """Start drawing or dragging the shape""" self._drawing = True @@ -437,6 +446,91 @@ def _finish_drawing(self, event, selection=False): self._has_selected = True self.canvas.draw_idle() + def _handle_button_press(self, event): + if (not self._drawing and not self.allow_redraw and + self._has_selected): + self._focused = self._patch.contains(event)[0] + + if self.interactive and not self._drawing: + self._dragging, idx = self._handles.contains(event) + if self._dragging: + self._drag_idx = idx['ind'][0] + # If the move handle was selected, enter move state. + if self._drag_idx == self._handles.get_xdata().size - 1: + self._moving = True + + if (self._drawing or self._dragging or self.allow_redraw or + not self._has_selected): + if self._moving: + self._start_drawing(event) + else: + self._on_press(event) + + def _handle_motion_notify(self, event): + if self._drawing: + if self._moving: + center = self.center + verts = self.verts + verts[:, 0] += event.xdata - center[0] + verts[:, 1] += event.ydata - center[1] + self._set_verts(verts) + else: + self._on_motion(event) + self.on_motion(self) + + def _handle_button_release(self, event): + if self._drawing: + if self._moving: + self._finish_drawing(event) + self._moving = False + else: + self._on_release(event) + self._dragging = False + + def _handle_key_press(self, event): + """Handle key_press_event defaults and call to subclass handler""" + + if not self._drawing and not self._focused: + return + + if event.key == self._keys['clear']: + if self._dragging: + self._set_verts(self._prev_data['verts']) + self._finish_drawing(event, False) + elif self._drawing: + for artist in self._artists: + artist.set_visible(False) + self._finish_drawing(event, False) + return + + elif event.key == self._keys['accept']: + if self._drawing: + self._finish_drawing(event) + + self.on_accept(self) + if self.allow_redraw: + for artist in self._artists: + artist.set_visible(False) + self.canvas.draw_idle() + + for (modifer, key) in self._keys.items(): + if modifer == 'move' and not self.interactive: + continue + if key in event.key: + self._modifiers.add(modifer) + self._on_key_press(event) + + def _handle_key_release(self, event): + if self._focused: + for (modifier, key) in self._keys.items(): + if key in event.key: + self._modifiers.discard(modifier) + self._on_key_release(event) + + def _handle_scroll(self, event): + if self._focused: + self._on_scroll(event) + ############################################################# # The following are meant to be subclassed as needed. ############################################################# @@ -451,64 +545,10 @@ def _on_press(self, event): """Handle a button_press_event""" self._start_drawing(event) - def _on_motion(self, event): - """Handle a motion_notify_event""" - pass - def _on_release(self, event): """Handle a button_release_event""" self._finish_drawing(event, True) - def _on_key_press(self, event): - """Handle a key_press_event""" - pass - - def _on_key_release(self, event): - """Handle a key_release_event""" - pass - - def _on_scroll(self, event): - """Handle a scroll_event""" - pass - - -class PolygonTool(BaseTool): - - """An interactive which draws a polygon shape""" - - @property - def verts(self): - """Get the (N, 2) vertices of the tool""" - return self._patch.get_xy() - - @property - def center(self): - """Get the (x, y) center of the tool""" - verts = self.verts - return (verts.min(axis=0) + verts.max(axis=0)) / 2 - - @property - def extents(self): - """Get the (x0, y0, width, height) extents of the tool""" - verts = self.verts - x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) - y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) - return x0, y0, x1 - x0, y1 - y0 - - -def _dummy(tool): - """A dummy callback for a tool.""" - pass - - -tooldoc = martist.kwdoc(BaseTool) -for k in ('RectangleTool', 'EllipseTool', 'LineTool', 'BaseTool', - 'PaintTool'): - docstring.interpd.update({k: tooldoc}) - -# define BaseTool.__init__ docstring after the class has been added to interpd -docstring.dedent_interpd(BaseTool.__init__) - HANDLE_ORDER = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] From 6d84c47554f229767cff081fecba2562029f90c8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 17:14:42 -0600 Subject: [PATCH 33/42] wip --- lib/matplotlib/interactive_selectors.py | 41 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 32094dec7c91..116ed16c11f4 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -354,7 +354,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, keys=None) self.interactive = interactive self.allow_redraw = allow_redraw - self._focused = True + self.focused = True props = dict(facecolor='red', edgecolor='black', visible=False, alpha=0.2, fill=True, picker=5, linewidth=2, @@ -439,7 +439,7 @@ def _finish_drawing(self, event, selection=False): artist.set_visible(False) self._modifiers = set() if selection: - self._prev_data = dict(verts=self.verts, + self._prev_data = dict(verts=self.patch.get_xy(), center=self.center, extents=self.extents) self.on_select(self) @@ -449,7 +449,7 @@ def _finish_drawing(self, event, selection=False): def _handle_button_press(self, event): if (not self._drawing and not self.allow_redraw and self._has_selected): - self._focused = self._patch.contains(event)[0] + self.focused = self._patch.contains(event)[0] if self.interactive and not self._drawing: self._dragging, idx = self._handles.contains(event) @@ -470,7 +470,7 @@ def _handle_motion_notify(self, event): if self._drawing: if self._moving: center = self.center - verts = self.verts + verts = self.patch.get_xy() verts[:, 0] += event.xdata - center[0] verts[:, 1] += event.ydata - center[1] self._set_verts(verts) @@ -490,7 +490,7 @@ def _handle_button_release(self, event): def _handle_key_press(self, event): """Handle key_press_event defaults and call to subclass handler""" - if not self._drawing and not self._focused: + if not self._drawing and not self.focused: return if event.key == self._keys['clear']: @@ -521,14 +521,14 @@ def _handle_key_press(self, event): self._on_key_press(event) def _handle_key_release(self, event): - if self._focused: + if self.focused: for (modifier, key) in self._keys.items(): if key in event.key: self._modifiers.discard(modifier) self._on_key_release(event) def _handle_scroll(self, event): - if self._focused: + if self.focused: self._on_scroll(event) ############################################################# @@ -539,7 +539,7 @@ def _get_handle_verts(self): Return an (N, 2) array of vertices. """ - return self.verts + return self.patch.get_xy() def _on_press(self, event): """Handle a button_press_event""" @@ -550,6 +550,25 @@ def _on_release(self, event): self._finish_drawing(event, True) +class PolygonTool(BasePolygonTool): + + """An interactive which draws a polygon shape""" + + @property + def center(self): + """Get the (x, y) center of the tool""" + verts = self.patch.get_xy() + return (verts.min(axis=0) + verts.max(axis=0)) / 2 + + @property + def extents(self): + """Get the (x0, y0, width, height) extents of the tool""" + verts = self.patch.get_xy() + x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) + y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) + return x0, y0, x1 - x0, y1 - y0 + + HANDLE_ORDER = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] @@ -570,12 +589,12 @@ class RectangleTool(PolygonTool): @property def width(self): """Get the width of the tool in data units""" - return np.ptp(self.verts[:, 0]) + return np.ptp(self.patch.get_xy()[:, 0]) @property def height(self): """Get the height of the tool in data units""" - return np.ptp(self.verts[:, 1]) + return np.ptp(self.patch.get_xy()[:, 1]) def set_geometry(self, x0, y0, width, height): """Set the geometry of the rectangle tool. @@ -746,7 +765,7 @@ def width(self, value): @property def end_points(self): """Get the end points of the line in data units.""" - verts = self.verts + verts = self.patch.get_xy() p0x = (verts[0, 0] + verts[1, 0]) / 2 p0y = (verts[0, 1] + verts[1, 1]) / 2 p1x = (verts[3, 0] + verts[2, 0]) / 2 From fe672d6f181079980a861a8c3ea91da8338267d5 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 17:16:18 -0600 Subject: [PATCH 34/42] wip --- lib/matplotlib/interactive_selectors.py | 37 +++++++++++-------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 116ed16c11f4..9abf16ac27c6 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -380,6 +380,20 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._prev_data = None self._start_event = None + @property + def center(self): + """Get the (x, y) center of the tool""" + verts = self.patch.get_xy() + return (verts.min(axis=0) + verts.max(axis=0)) / 2 + + @property + def extents(self): + """Get the (x0, y0, width, height) extents of the tool""" + verts = self.patch.get_xy() + x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) + y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) + return x0, y0, x1 - x0, y1 - y0 + def _set_verts(self, value): """Commit a change to the tool vertices.""" value = np.asarray(value) @@ -550,30 +564,11 @@ def _on_release(self, event): self._finish_drawing(event, True) -class PolygonTool(BasePolygonTool): - - """An interactive which draws a polygon shape""" - - @property - def center(self): - """Get the (x, y) center of the tool""" - verts = self.patch.get_xy() - return (verts.min(axis=0) + verts.max(axis=0)) / 2 - - @property - def extents(self): - """Get the (x0, y0, width, height) extents of the tool""" - verts = self.patch.get_xy() - x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) - y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) - return x0, y0, x1 - x0, y1 - y0 - - HANDLE_ORDER = ['NW', 'NE', 'SE', 'SW', 'W', 'N', 'E', 'S'] @docstring.dedent_interpd -class RectangleTool(PolygonTool): +class RectangleTool(BasePolygonTool): """Interactive rectangle selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. @@ -718,7 +713,7 @@ def set_geometry(self, x0, y0, width, height): @docstring.dedent_interpd -class LineTool(PolygonTool): +class LineTool(BasePolygonTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. From ae1f67ef6043a62d5cb7dc166fbd72a5d3a1a0b2 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 17:19:16 -0600 Subject: [PATCH 35/42] wip --- lib/matplotlib/interactive_selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 9abf16ac27c6..44df7a63a827 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -146,7 +146,6 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._buttons = button self._artists = [] - self._modifiers = set() self._drawing = False self._background = None self._prev_evt_xy = None @@ -372,6 +371,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._artists = [self._patch, self._handles] + self._modifiers = set() self._drawing = False self._dragging = False self._moving = False From 2de4037951c063ce24623f811e134b4eca5b6af3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 17:22:41 -0600 Subject: [PATCH 36/42] wip --- lib/matplotlib/interactive_selectors.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 44df7a63a827..47d3b326b062 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -48,7 +48,7 @@ """) -docstring.interpd.update(BaseInteractiveToolExtra="""\ +docstring.interpd.update(BasePolygonTool="""\ interactive: boolean Whether to allow interaction with the shape using handles. allow_redraw: boolean @@ -96,7 +96,7 @@ """) -docstring.interpd.update(BaseInteractiveToolInitExtra=""" +docstring.interpd.update(BasePolygonToolInit=""" interactive: boolean, optional Whether to allow interaction with the shape using handles. allow_redraw: boolean, optional @@ -116,14 +116,13 @@ class BaseTool(object): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s - %(BaseInteractiveToolExtra)s """ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s - %(BaseInteractiveToolInitExtra)s + %(BasePolygonToolInit)s """ self.ax = ax self.canvas = ax.figure.canvas @@ -343,8 +342,16 @@ def _dummy(tool): docstring.dedent_interpd(BaseTool.__init__) +@docstring.dedent_interpd class BasePolygonTool(BaseTool): + """Interactive polygon selection tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + + %(BaseInteractiveTool)s + %(BasePolygonTool)s + """ + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, interactive=True, allow_redraw=True, shape_props=None, handle_props=None, useblit=True, button=None, keys=None): @@ -574,7 +581,7 @@ class RectangleTool(BasePolygonTool): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s - %(BaseInteractiveToolExtra)s + %(BasePolygonTool)s width: float The width of the rectangle in data units (read-only). height: float @@ -684,7 +691,7 @@ class EllipseTool(RectangleTool): :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s - %(BaseInteractiveToolExtra)s + %(BasePolygonTool)s width: float The width of the ellipse in data units (read-only). height: float @@ -718,7 +725,7 @@ class LineTool(BasePolygonTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s - %(BaseInteractiveToolExtra)s + %(BasePolygonTool)s width: float The width of the line in pixels. This can be set directly. end_points: (2, 2) float @@ -736,7 +743,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, useblit=True, button=None, keys=None): """Initialize the tool. %(BaseInteractiveToolInit)s - %(BaseInteractiveToolInitExtra)s + %(BasePolygonToolInit)s """ props = dict(edgecolor='red', visible=False, alpha=0.5, fill=True, picker=5, linewidth=1) From 5d8992486699d448f22bf9ff4fc14fe0a295e023 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 9 Jan 2016 20:04:08 -0600 Subject: [PATCH 37/42] wip --- lib/matplotlib/interactive_selectors.py | 86 +++++++++++++++++-------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 47d3b326b062..dd0f15b27d5c 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -358,16 +358,13 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, super(BasePolygonTool, self).__init__(ax, on_select=on_select, on_accept=on_accept, on_motion=on_motion, useblit=True, keys=None) - self.interactive = interactive - self.allow_redraw = allow_redraw - self.focused = True props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, picker=5, linewidth=2, + alpha=0.2, fill=True, picker=10, linewidth=2, zorder=1) props.update(shape_props or {}) self.patch = Polygon([[0, 0], [1, 1]], True, **props) - self.ax.add_patch(self._patch) + self.ax.add_patch(self.patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', @@ -376,8 +373,9 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._handles = Line2D([], [], **props) self.ax.add_line(self._handles) - self._artists = [self._patch, self._handles] + self._artists = [self.patch, self._handles] + self._interactive = None self._modifiers = set() self._drawing = False self._dragging = False @@ -387,6 +385,22 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self._prev_data = None self._start_event = None + self.interactive = interactive + self.allow_redraw = allow_redraw + self.focused = True + + @property + def interactive(self): + return self._interactive + + @interactive.setter + def interactive(self, value): + if not value: + self._handles.set_visible(False) + self.patch.set_visible(False) + self.canvas.draw_idle() + self._interactive = value + @property def center(self): """Get the (x, y) center of the tool""" @@ -407,9 +421,9 @@ def _set_verts(self, value): assert value.ndim == 2 assert value.shape[1] == 2 - self._patch.set_xy(value) - self._patch.set_visible(True) - self._patch.set_animated(False) + self.patch.set_xy(value) + self.patch.set_visible(True) + self.patch.set_animated(False) if self._prev_data is None: self._prev_data = dict(verts=value, @@ -417,7 +431,6 @@ def _set_verts(self, value): extents=self.extents) handles = self._get_handle_verts() - handles = np.vstack((handles, self.center)) self._handles.set_data(handles[:, 0], handles[:, 1]) self._handles.set_visible(self.interactive) self._handles.set_animated(False) @@ -470,15 +483,15 @@ def _finish_drawing(self, event, selection=False): def _handle_button_press(self, event): if (not self._drawing and not self.allow_redraw and self._has_selected): - self.focused = self._patch.contains(event)[0] + self.focused = self.patch.contains(event)[0] if self.interactive and not self._drawing: self._dragging, idx = self._handles.contains(event) if self._dragging: self._drag_idx = idx['ind'][0] - # If the move handle was selected, enter move state. - if self._drag_idx == self._handles.get_xdata().size - 1: - self._moving = True + elif self.patch.contains(event)[0]: + self._moving = True + self._dragging = True if (self._drawing or self._dragging or self.allow_redraw or not self._has_selected): @@ -917,7 +930,7 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, # These must be called last self.label = 1 self.radius = radius - self._start_drawing(None) + self._drawing = True for artist in self._artists: artist.set_visible(True) @@ -1002,6 +1015,14 @@ def _update_cursor(self, x, y): self._cursor.set_xy((x, y)) +""" +RectangleTool and EllipseTool have three scale handles and a rotation +Handle +Get rid of the move handle a use contains for move +RegularPolygonTool has a scale handle and a rotation handle +""" + + if __name__ == '__main__': import matplotlib.pyplot as plt @@ -1011,18 +1032,27 @@ def _update_cursor(self, x, y): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - # ellipse = EllipseTool(ax) - # ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) - # ax.invert_yaxis() - - # def test(tool): - # print(tool.center, tool.width, tool.height) - - # ellipse.on_accept = test - # ellipse.allow_redraw = False - #line = LineTool(ax) - #line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 10) - #line.interactive = False - p = PaintTool(ax) + ellipse = EllipseTool(ax) + ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) + ax.invert_yaxis() + def test(tool): + print(tool.center, tool.width, tool.height) + + ellipse.on_accept = test + ellipse.interactive = True + + """ + line = LineTool(ax) + line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 1) + line.interactive = False + """ + + """ + def test(tool): + print(tool.overlay) + + p = PaintTool(ax) + p.on_accept = test + """ plt.show() From af58d64d35d6202cbf3258a2b00895a493c5c30c Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 11 Jan 2016 08:37:30 -0600 Subject: [PATCH 38/42] wip --- lib/matplotlib/interactive_selectors.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index dd0f15b27d5c..ea779c04283c 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -9,7 +9,7 @@ # TODO: convert these to relative when finished import matplotlib.colors as mcolors -from matplotlib.patches import Polygon, Rectangle +from matplotlib.patches import Polygon, Rectangle, Ellipse, RegularPolygon from matplotlib.lines import Line2D from matplotlib import docstring, artist as martist @@ -352,8 +352,11 @@ class BasePolygonTool(BaseTool): %(BasePolygonTool)s """ + _shape = Polygon + def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, - interactive=True, allow_redraw=True, shape_props=None, + interactive=True, allow_redraw=True, patch_args=None, + patch_props=None, handle_props=None, useblit=True, button=None, keys=None): super(BasePolygonTool, self).__init__(ax, on_select=on_select, on_accept=on_accept, on_motion=on_motion, useblit=True, @@ -362,8 +365,10 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, props = dict(facecolor='red', edgecolor='black', visible=False, alpha=0.2, fill=True, picker=10, linewidth=2, zorder=1) - props.update(shape_props or {}) - self.patch = Polygon([[0, 0], [1, 1]], True, **props) + if patch_args is None: + patch_args = [[[0, 0], [1, 1]], True] + props.update(patch_props or {}) + self.patch = self._shape(*patch_args, **props) self.ax.add_patch(self.patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', @@ -601,6 +606,8 @@ class RectangleTool(BasePolygonTool): The height of the rectangle in data units (read-only). """ + _shape = Rectangle + @property def width(self): """Get the width of the tool in data units""" @@ -711,6 +718,8 @@ class EllipseTool(RectangleTool): The height of the ellipse in data units (read-only). """ + _shape = Ellipse + def set_geometry(self, x0, y0, width, height): """Set the geometry of the ellipse tool. @@ -733,7 +742,7 @@ def set_geometry(self, x0, y0, width, height): @docstring.dedent_interpd -class LineTool(BasePolygonTool): +class LineTool(RectangleTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. From 4621bfdfb55db7fcedcaa19e6727567bfc3f4973 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 16 Jan 2016 10:57:38 -0600 Subject: [PATCH 39/42] Refactor the tools to use proper patches --- lib/matplotlib/interactive_selectors.py | 322 +++++++++++------------- lib/matplotlib/patches.py | 13 + 2 files changed, 157 insertions(+), 178 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index ea779c04283c..ee6329785793 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -4,12 +4,11 @@ unicode_literals) import copy - import numpy as np # TODO: convert these to relative when finished import matplotlib.colors as mcolors -from matplotlib.patches import Polygon, Rectangle, Ellipse, RegularPolygon +from matplotlib.patches import Patch, Rectangle, Ellipse, Polygon from matplotlib.lines import Line2D from matplotlib import docstring, artist as martist @@ -117,7 +116,6 @@ class BaseTool(object): %(BaseInteractiveTool)s """ - def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, useblit=True, button=None, keys=None): """Initialize the tool. @@ -343,32 +341,27 @@ def _dummy(tool): @docstring.dedent_interpd -class BasePolygonTool(BaseTool): +class BasePatchTool(BaseTool): - """Interactive polygon selection tool that is connected to a single + """Interactive patch selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s %(BasePolygonTool)s """ - _shape = Polygon - def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, interactive=True, allow_redraw=True, patch_args=None, patch_props=None, handle_props=None, useblit=True, button=None, keys=None): - super(BasePolygonTool, self).__init__(ax, on_select=on_select, + super(BasePatchTool, self).__init__(ax, on_select=on_select, on_accept=on_accept, on_motion=on_motion, useblit=True, keys=None) - props = dict(facecolor='red', edgecolor='black', visible=False, - alpha=0.2, fill=True, picker=10, linewidth=2, - zorder=1) if patch_args is None: patch_args = [[[0, 0], [1, 1]], True] - props.update(patch_props or {}) - self.patch = self._shape(*patch_args, **props) + patch_props = patch_props or {} + self.patch = self._make_patch(**patch_props) self.ax.add_patch(self.patch) props = dict(marker='o', markersize=7, mfc='w', ls='none', @@ -406,39 +399,34 @@ def interactive(self, value): self.canvas.draw_idle() self._interactive = value - @property - def center(self): - """Get the (x, y) center of the tool""" - verts = self.patch.get_xy() - return (verts.min(axis=0) + verts.max(axis=0)) / 2 - @property def extents(self): """Get the (x0, y0, width, height) extents of the tool""" - verts = self.patch.get_xy() - x0, x1 = np.min(verts[:, 0]), np.max(verts[:, 0]) - y0, y1 = np.min(verts[:, 1]), np.max(verts[:, 1]) - return x0, y0, x1 - x0, y1 - y0 + return self.patch.get_extents() - def _set_verts(self, value): - """Commit a change to the tool vertices.""" - value = np.asarray(value) - assert value.ndim == 2 - assert value.shape[1] == 2 + def get_geometry(self): + """Get the tool specific geometry as a dictionary""" + return dict() + + def set_geometry(self, **kwargs): + """Set the tool geometry directly""" + for (key, value) in kwargs.items(): + func = getattr(self.patch, 'set_%s' % key, None) + if func: + func(value) + elif hasattr(self.patch, key): + setattr(self.patch, key, value) - self.patch.set_xy(value) self.patch.set_visible(True) - self.patch.set_animated(False) + self.patch.set_animated(self._drawing) if self._prev_data is None: - self._prev_data = dict(verts=value, - center=self.center, - extents=self.extents) + self._prev_geometry = self.get_geometry() - handles = self._get_handle_verts() + handles = np.asarray(self._get_handle_verts()) self._handles.set_data(handles[:, 0], handles[:, 1]) self._handles.set_visible(self.interactive) - self._handles.set_animated(False) + self._handles.set_animated(self._drawing) self._update() if not self._drawing: @@ -478,9 +466,7 @@ def _finish_drawing(self, event, selection=False): artist.set_visible(False) self._modifiers = set() if selection: - self._prev_data = dict(verts=self.patch.get_xy(), - center=self.center, - extents=self.extents) + self._prev_geometry = self.get_geometry() self.on_select(self) self._has_selected = True self.canvas.draw_idle() @@ -508,11 +494,7 @@ def _handle_button_press(self, event): def _handle_motion_notify(self, event): if self._drawing: if self._moving: - center = self.center - verts = self.patch.get_xy() - verts[:, 0] += event.xdata - center[0] - verts[:, 1] += event.ydata - center[1] - self._set_verts(verts) + self._move(event) else: self._on_motion(event) self.on_motion(self) @@ -534,7 +516,7 @@ def _handle_key_press(self, event): if event.key == self._keys['clear']: if self._dragging: - self._set_verts(self._prev_data['verts']) + self.set_geometry(**self._prev_geometry) self._finish_drawing(event, False) elif self._drawing: for artist in self._artists: @@ -573,12 +555,20 @@ def _handle_scroll(self, event): ############################################################# # The following are meant to be subclassed as needed. ############################################################# + def _make_patch(self, **props): + """Initialize the patch""" + return Patch(**props) + def _get_handle_verts(self): """Get the handle vertices for a tool, not including the center. Return an (N, 2) array of vertices. """ - return self.patch.get_xy() + return None + + def _move(self, event): + """Move the shape""" + pass def _on_press(self, event): """Handle a button_press_event""" @@ -593,57 +583,38 @@ def _on_release(self, event): @docstring.dedent_interpd -class RectangleTool(BasePolygonTool): +class RectangleTool(BasePatchTool): """Interactive rectangle selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s %(BasePolygonTool)s - width: float - The width of the rectangle in data units (read-only). - height: float - The height of the rectangle in data units (read-only). """ - _shape = Rectangle + def get_geometry(self): + return dict(xy=self.patch.get_xy(), + width=self.patch.get_width(), + height=self.patch.get_height(), + angle=self.patch.get_angle()) - @property - def width(self): - """Get the width of the tool in data units""" - return np.ptp(self.patch.get_xy()[:, 0]) - - @property - def height(self): - """Get the height of the tool in data units""" - return np.ptp(self.patch.get_xy()[:, 1]) - - def set_geometry(self, x0, y0, width, height): - """Set the geometry of the rectangle tool. - - Parameters - ---------- - x0: float - The left coordinate in data units. - y0: float - The bottom coordinate in data units. - width: - The width in data units. - height: - The height in data units. - """ - radx = width / 2 - rady = height / 2 - center = x0 + width / 2, y0 + height / 2 - self._set_verts([[center[0] - radx, center[1] - rady], - [center[0] - radx, center[1] + rady], - [center[0] + radx, center[1] + rady], - [center[0] + radx, center[1] - rady]]) + def _make_patch(self, **overrides): + props = dict(facecolor='red', edgecolor='black', visible=False, + alpha=0.2, fill=True, picker=10, linewidth=2, + zorder=1) + props.update(overrides) + return Rectangle((0, 0), 0, 0, **props) def _get_handle_verts(self): - xm, ym = self.center - w = self.width / 2 - h = self.height / 2 + geometry = self.get_geometry() + width, height = geometry['width'], geometry['height'] + if isinstance(self.patch, Ellipse): + xm, ym = geometry['center'] + else: + x0, y0 = geometry['xy'] + xm, ym = x0 + width / 2, y0 + height / 2 + w = width / 2 + h = height / 2 xc = xm - w, xm + w, xm + w, xm - w yc = ym - h, ym - h, ym + h, ym + h xe = xm - w, xm, xm + w, xm @@ -655,9 +626,16 @@ def _get_handle_verts(self): def _on_motion(self, event): # Resize an existing shape. if self._dragging: - x0, y0, width, height = self._prev_data['extents'] - x1 = x0 + width - y1 = y0 + height + geometry = self._prev_geometry + width, height = geometry['width'], geometry['height'] + if isinstance(self.patch, Ellipse): + x0, y0 = geometry['center'] + x0 -= width / 2 + y0 -= height / 2 + else: + x0, y0 = geometry['xy'] + x1, y1 = x0 + width, y0 + height + handle = HANDLE_ORDER[self._drag_idx] if handle in ['NW', 'SW', 'W']: x0 = event.xdata @@ -701,7 +679,17 @@ def _on_motion(self, event): center[1] - dy, center[1] + dy) # Update the shape. - self.set_geometry(x0, y0, x1 - x0, y1 - y0) + width, height = x1 - x0, y1 - y0 + if isinstance(self.patch, Ellipse): + self.set_geometry(center=(x0 + width / 2, y0 + height / 2), + width=width, height=height) + else: + self.set_geometry(xy=(x0, y0), width=width, height=height) + + def _move(self, event): + geo = self.get_geometry() + self.set_geometry(xy=(event.xdata - geo['width'] / 2, + event.ydata - geo['height'] / 2)) @docstring.dedent_interpd @@ -718,64 +706,37 @@ class EllipseTool(RectangleTool): The height of the ellipse in data units (read-only). """ - _shape = Ellipse - - def set_geometry(self, x0, y0, width, height): - """Set the geometry of the ellipse tool. - - Parameters - ---------- - x0: float - The left coordinate in data units. - y0: float - The bottom coordinate in data units. - width: - The width in data units. - height: - The height in data units. - """ - center = x0 + width / 2, y0 + height / 2 - rad = np.arange(61) * 6 * np.pi / 180 - x = width / 2 * np.cos(rad) + center[0] - y = height / 2 * np.sin(rad) + center[1] - self._set_verts(np.vstack((x, y)).T) + def get_geometry(self): + return dict(center=self.patch.center, + width=self.patch.width, + height=self.patch.height, + angle=self.patch.angle) + + def _make_patch(self, **overrides): + props = dict(facecolor='red', edgecolor='black', visible=False, + alpha=0.2, fill=True, picker=10, linewidth=2, + zorder=1) + props.update(overrides) + return Ellipse((0, 0), 0, 0, **props) + + def _move(self, event): + self.set_geometry(center=(event.xdata, event.ydata)) @docstring.dedent_interpd -class LineTool(RectangleTool): +class LineTool(BasePatchTool): """Interactive line selection tool that is connected to a single :class:`~matplotlib.axes.Axes`. %(BaseInteractiveTool)s %(BasePolygonTool)s - width: float - The width of the line in pixels. This can be set directly. - end_points: (2, 2) float - The [(x0, y0), (x1, y1)] end points of the line in data units - (read-only). - angle: float - The angle between the left point and the right point in radians in - pixel space (read-only). """ - @docstring.dedent_interpd - def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, - interactive=True, allow_redraw=True, - shape_props=None, handle_props=None, - useblit=True, button=None, keys=None): - """Initialize the tool. - %(BaseInteractiveToolInit)s - %(BasePolygonToolInit)s - """ - props = dict(edgecolor='red', visible=False, - alpha=0.5, fill=True, picker=5, linewidth=1) - props.update(shape_props or {}) - super(LineTool, self).__init__(ax, on_select=on_select, - on_motion=on_motion, on_accept=on_accept, interactive=interactive, - allow_redraw=allow_redraw, shape_props=props, - handle_props=handle_props, useblit=useblit, button=button, - keys=keys) - self._width = 1 + def get_geometry(self): + return dict(xy=self.patch.get_xy(), + width=self.width, + end_points=self.end_points, + angle=self.angle) @property def width(self): @@ -784,7 +745,9 @@ def width(self): @width.setter def width(self, value): - self.set_geometry(self.end_points, value) + """Set the width of the line in pixels.""" + self._width = value + self.end_points = self.end_points @property def end_points(self): @@ -796,35 +759,13 @@ def end_points(self): p1y = (verts[3, 1] + verts[2, 1]) / 2 return np.array([[p0x, p0y], [p1x, p1y]]) - @property - def angle(self): - """Find the angle between the left and right points in pixel space.""" - # Convert to pixels. - pts = self.end_points - pts = self.ax.transData.inverted().transform(pts) - if pts[0, 0] < pts[1, 0]: - return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) - else: - return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) - - def set_geometry(self, end_points, width=None): - """Set the geometry of the line tool. - - Parameters - ---------- - end_points: (2, 2) float - The coordinates of the end points in data space. - width: int, optional - The width in pixels. - """ + @end_points.setter + def end_points(self, end_points): pts = np.asarray(end_points) - width = width or self._width - self._width = width - # Get the widths in data units. xfm = self.ax.transData.inverted() x0, y0 = xfm.transform((0, 0)) - x1, y1 = xfm.transform((width, width)) + x1, y1 = xfm.transform((self._width, self._width)) wx, wy = abs(x1 - x0), abs(y1 - y0) # Find line segments centered on the end points perpendicular to the @@ -848,17 +789,36 @@ def set_geometry(self, end_points, width=None): v10 = p1[0] + wx / 2 * c, p1[1] + wy / 2 * s v11 = p1[0] - wx / 2 * c, p1[1] - wy / 2 * s - self._set_verts((v00, v01, v11, v10)) + super(LineTool, self).set_geometry(xy=(v00, v01, v11, v10)) + + @property + def angle(self): + """Find the angle between the left and right points in pixel space.""" + # Convert to pixels. + pts = self.end_points + pts = self.ax.transData.inverted().transform(pts) + if pts[0, 0] < pts[1, 0]: + return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) + else: + return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) + + def _make_patch(self, **overrides): + self._width = 1 + props = dict(facecolor='red', edgecolor='red', visible=False, + alpha=0.5, fill=True, picker=10, linewidth=2, + zorder=1) + props.update(overrides) + return Polygon([(0, 0), (0, 0)], **props) def _get_handle_verts(self): return self.end_points def _on_press(self, event): if not self._dragging: - self.set_geometry([[event.xdata, event.ydata], + self.end_points = [[event.xdata, event.ydata], [event.xdata, event.ydata], [event.xdata, event.ydata], - [event.xdata, event.ydata]]) + [event.xdata, event.ydata]] self._dragging = True self._drag_idx = 1 self._start_drawing(event) @@ -866,19 +826,25 @@ def _on_press(self, event): def _on_motion(self, event): end_points = self.end_points end_points[self._drag_idx, :] = event.xdata, event.ydata - self.set_geometry(end_points, self._width) + self.end_points = end_points + + def _move(self, event): + pts = self.end_points + pts[:, 0] += event.xdata - (pts[1, 0] + pts[0, 0]) / 2 + pts[:, 1] += event.ydata - (pts[1, 1] + pts[0, 1]) / 2 + self.end_points = pts def _on_scroll(self, event): if event.button == 'up': - self.set_geometry(self.end_points, self.width + 1) - elif event.button == 'down': - self.set_geometry(self.end_points, self.width - 1) + self.width += 1 + elif event.button == 'down' and self.width > 1: + self.width -= 1 def _on_key_press(self, event): if event.key == '+': - self.set_geometry(self.end_points, self.width + 1) + self.width += 1 elif event.key == '-' and self.width > 1: - self.set_geometry(self.end_points, self.width - 1) + self.width -= 1 LABELS_CMAP = mcolors.ListedColormap(['white', 'red', 'dodgerblue', 'gold', @@ -1041,8 +1007,9 @@ def _update_cursor(self, x, y): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - ellipse = EllipseTool(ax) - ellipse.set_geometry(0.6, 1.1, 0.3, 0.3) + + ellipse = RectangleTool(ax) + ellipse.set_geometry(xy=(0.4, 0.5), width=0.3, height=0.5) ax.invert_yaxis() def test(tool): @@ -1050,13 +1017,12 @@ def test(tool): ellipse.on_accept = test ellipse.interactive = True - """ line = LineTool(ax) - line.set_geometry([[0.1, 0.1], [0.5, 0.5]], 1) - line.interactive = False - """ + line.end_points = [[0.1, 0.1], [0.5, 0.5]] + line.interactive = True + """ """ def test(tool): print(tool.overlay) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 866c636b206b..81f45e96ecce 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -684,6 +684,10 @@ def get_patch_transform(self): self._update_patch_transform() return self._rect_transform + def get_angle(self): + """Return the rotation in degrees (anti-clockwise)""" + return self._angle + def get_x(self): "Return the left coord of the rectangle" return self._x @@ -704,6 +708,15 @@ def get_height(self): "Return the height of the rectangle" return self._height + def set_angle(self, angle): + """ + Set the rotation in degrees (anti-clockwise). + + ACCEPTS: float + """ + self._angle = angle + self.stale = True + def set_x(self, x): """ Set the left coord of the rectangle From 6781b9b9e5a6c94ab4a135a88beb99ab1b16cca5 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 16 Jan 2016 11:11:34 -0600 Subject: [PATCH 40/42] Clean up the line tool --- lib/matplotlib/interactive_selectors.py | 63 +++++++++++-------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index ee6329785793..dbbfbd6da126 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -738,17 +738,6 @@ def get_geometry(self): end_points=self.end_points, angle=self.angle) - @property - def width(self): - """Get the width of the line in pixels.""" - return self._width - - @width.setter - def width(self, value): - """Set the width of the line in pixels.""" - self._width = value - self.end_points = self.end_points - @property def end_points(self): """Get the end points of the line in data units.""" @@ -759,13 +748,28 @@ def end_points(self): p1y = (verts[3, 1] + verts[2, 1]) / 2 return np.array([[p0x, p0y], [p1x, p1y]]) - @end_points.setter - def end_points(self, end_points): - pts = np.asarray(end_points) + @property + def angle(self): + """Find the angle between the left and right points in pixel space.""" + # Convert to pixels. + pts = self.end_points + pts = self.ax.transData.inverted().transform(pts) + if pts[0, 0] < pts[1, 0]: + return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) + else: + return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) + + def set_geometry(self, **kwargs): + self.width = kwargs.pop('width', self.width) + if 'xy' in kwargs: + return super(self, LineTool).set_geometry(**kwargs) + + pts = kwargs.pop('end_points', self.end_points) + pts = np.asarray(pts) # Get the widths in data units. xfm = self.ax.transData.inverted() x0, y0 = xfm.transform((0, 0)) - x1, y1 = xfm.transform((self._width, self._width)) + x1, y1 = xfm.transform((self.width, self.width)) wx, wy = abs(x1 - x0), abs(y1 - y0) # Find line segments centered on the end points perpendicular to the @@ -791,34 +795,21 @@ def end_points(self, end_points): super(LineTool, self).set_geometry(xy=(v00, v01, v11, v10)) - @property - def angle(self): - """Find the angle between the left and right points in pixel space.""" - # Convert to pixels. - pts = self.end_points - pts = self.ax.transData.inverted().transform(pts) - if pts[0, 0] < pts[1, 0]: - return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) - else: - return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) - def _make_patch(self, **overrides): - self._width = 1 + self.width = 1 props = dict(facecolor='red', edgecolor='red', visible=False, alpha=0.5, fill=True, picker=10, linewidth=2, zorder=1) props.update(overrides) - return Polygon([(0, 0), (0, 0)], **props) + return Polygon([(0, 0), (0, 0), (0, 0), (0, 0)], **props) def _get_handle_verts(self): return self.end_points def _on_press(self, event): if not self._dragging: - self.end_points = [[event.xdata, event.ydata], - [event.xdata, event.ydata], - [event.xdata, event.ydata], - [event.xdata, event.ydata]] + self.set_geometry(end_points=[[event.xdata, event.ydata], + [event.xdata, event.ydata]]) self._dragging = True self._drag_idx = 1 self._start_drawing(event) @@ -826,13 +817,13 @@ def _on_press(self, event): def _on_motion(self, event): end_points = self.end_points end_points[self._drag_idx, :] = event.xdata, event.ydata - self.end_points = end_points + self.set_geometry(end_points=end_points) def _move(self, event): pts = self.end_points pts[:, 0] += event.xdata - (pts[1, 0] + pts[0, 0]) / 2 pts[:, 1] += event.ydata - (pts[1, 1] + pts[0, 1]) / 2 - self.end_points = pts + self.set_geometry(end_points=pts) def _on_scroll(self, event): if event.button == 'up': @@ -1008,6 +999,7 @@ def _update_cursor(self, x, y): pts = ax.scatter(data[:, 0], data[:, 1], s=80) + """ ellipse = RectangleTool(ax) ellipse.set_geometry(xy=(0.4, 0.5), width=0.3, height=0.5) ax.invert_yaxis() @@ -1019,10 +1011,9 @@ def test(tool): ellipse.interactive = True """ line = LineTool(ax) - line.end_points = [[0.1, 0.1], [0.5, 0.5]] + line.set_geometry(end_points=[[0.1, 0.1], [0.5, 0.5]]) line.interactive = True - """ """ def test(tool): print(tool.overlay) From fb6b842cf8c6431bd839dfbe14cdaffcf1312205 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 16 Jan 2016 11:23:07 -0600 Subject: [PATCH 41/42] More cleanup --- lib/matplotlib/interactive_selectors.py | 76 +++++++++++++------------ 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index dbbfbd6da126..195451a87e64 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -399,11 +399,6 @@ def interactive(self, value): self.canvas.draw_idle() self._interactive = value - @property - def extents(self): - """Get the (x0, y0, width, height) extents of the tool""" - return self.patch.get_extents() - def get_geometry(self): """Get the tool specific geometry as a dictionary""" return dict() @@ -593,6 +588,16 @@ class RectangleTool(BasePatchTool): """ def get_geometry(self): + """Get the geometry of the ellipse tool. + + Returns + ------- + geometry: dict + xy: The (x0, y0) origin point. + width: The width of the tool. + height: The height of the tool + angle: The angle of the tool (counter-clockwise). + """ return dict(xy=self.patch.get_xy(), width=self.patch.get_width(), height=self.patch.get_height(), @@ -707,6 +712,16 @@ class EllipseTool(RectangleTool): """ def get_geometry(self): + """Get the geometry of the ellipse tool. + + Returns + ------- + geometry: dict + center: The (x0, y0) center point. + width: The width of the tool. + height: The height of the tool + angle: The angle of the tool (counter-clockwise). + """ return dict(center=self.patch.center, width=self.patch.width, height=self.patch.height, @@ -733,39 +748,30 @@ class LineTool(BasePatchTool): """ def get_geometry(self): - return dict(xy=self.patch.get_xy(), - width=self.width, - end_points=self.end_points, - angle=self.angle) - - @property - def end_points(self): - """Get the end points of the line in data units.""" - verts = self.patch.get_xy() - p0x = (verts[0, 0] + verts[1, 0]) / 2 - p0y = (verts[0, 1] + verts[1, 1]) / 2 - p1x = (verts[3, 0] + verts[2, 0]) / 2 - p1y = (verts[3, 1] + verts[2, 1]) / 2 - return np.array([[p0x, p0y], [p1x, p1y]]) + """Get the geometry of the line tool. - @property - def angle(self): - """Find the angle between the left and right points in pixel space.""" - # Convert to pixels. - pts = self.end_points - pts = self.ax.transData.inverted().transform(pts) - if pts[0, 0] < pts[1, 0]: - return np.arctan2(pts[1, 1] - pts[0, 1], pts[1, 0] - pts[0, 0]) - else: - return np.arctan2(pts[0, 1] - pts[1, 1], pts[0, 0] - pts[1, 0]) + Returns + ------- + geometry: dict + end_points: The [(x0, y0), (x1, y1)] points. + width: The width of the tool in pixels. + """ + return dict(end_points=self.end_points, width=self.width) - def set_geometry(self, **kwargs): - self.width = kwargs.pop('width', self.width) - if 'xy' in kwargs: - return super(self, LineTool).set_geometry(**kwargs) + def set_geometry(self, end_points=None, width=None): + """Set the geometry of the line tool. - pts = kwargs.pop('end_points', self.end_points) - pts = np.asarray(pts) + Parameters + ---------- + end_points: [(xo, y0), (x1, y1)] + The end points of the tool + width: int + The width in pixels of the line + """ + self.width = width or self.width + if end_points is None: + end_points = self.end_points + pts = np.asarray(end_points) # Get the widths in data units. xfm = self.ax.transData.inverted() x0, y0 = xfm.transform((0, 0)) From f90d07121010e761a0821205ef6cc4fe3703ea17 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 23 Jan 2016 10:50:31 -0600 Subject: [PATCH 42/42] wip add angle --- lib/matplotlib/interactive_selectors.py | 84 ++++++++++++++++--------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/lib/matplotlib/interactive_selectors.py b/lib/matplotlib/interactive_selectors.py index 195451a87e64..942656df6a85 100644 --- a/lib/matplotlib/interactive_selectors.py +++ b/lib/matplotlib/interactive_selectors.py @@ -10,6 +10,7 @@ import matplotlib.colors as mcolors from matplotlib.patches import Patch, Rectangle, Ellipse, Polygon from matplotlib.lines import Line2D +import matplotlib.transforms as transforms from matplotlib import docstring, artist as martist @@ -364,14 +365,18 @@ def __init__(self, ax, on_select=None, on_motion=None, on_accept=None, self.patch = self._make_patch(**patch_props) self.ax.add_patch(self.patch) - props = dict(marker='o', markersize=7, mfc='w', ls='none', + props = dict(marker='s', markersize=7, mfc='w', ls='none', alpha=0.5, visible=False, label='_nolegend_', picker=10, zorder=2) props.update(handle_props or {}) - self._handles = Line2D([], [], **props) - self.ax.add_line(self._handles) + self._size_handles = Line2D([], [], **props) + props['marker'] = 'o' + self._rot_handle = Line2D([], [], **props) - self._artists = [self.patch, self._handles] + self.ax.add_line(self._size_handles) + self.ax.add_line(self._rot_handle) + + self._artists = [self.patch, self._size_handles, self._rot_handle] self._interactive = None self._modifiers = set() @@ -394,7 +399,8 @@ def interactive(self): @interactive.setter def interactive(self, value): if not value: - self._handles.set_visible(False) + self._size_handles.set_visible(False) + self._rot_handle.set_visible(False) self.patch.set_visible(False) self.canvas.draw_idle() self._interactive = value @@ -418,10 +424,14 @@ def set_geometry(self, **kwargs): if self._prev_data is None: self._prev_geometry = self.get_geometry() - handles = np.asarray(self._get_handle_verts()) - self._handles.set_data(handles[:, 0], handles[:, 1]) - self._handles.set_visible(self.interactive) - self._handles.set_animated(self._drawing) + size_size_handles, rot_handle = np.asarray(self._get_handle_verts()) + self._size_handles.set_data(size_size_handles[:, 0], size_size_handles[:, 1]) + self._size_handles.set_visible(self.interactive) + self._size_handles.set_animated(self._drawing) + if not rot_handle is None: + self._rot_handle.set_visible(self.interactive) + self._rot_handle.set_animated(self._drawing) + self._rot_handle.set_data(*rot_handle) self._update() if not self._drawing: @@ -439,12 +449,13 @@ def _start_drawing(self, event): for artist in self._artists: artist.set_animated(self._useblit) else: - self._handles.set_visible(False) + self._size_handles.set_visible(False) + self._rot_handle.set_visible(False) # Blit without being visible if not dragging to avoid showing the old # shape. for artist in self._artists: artist.set_visible(self._dragging) - if artist == self._handles: + if artist in [self._size_handles, self._rot_handle]: artist.set_visible(self._dragging and self.interactive) self._update() @@ -472,14 +483,16 @@ def _handle_button_press(self, event): self.focused = self.patch.contains(event)[0] if self.interactive and not self._drawing: - self._dragging, idx = self._handles.contains(event) + self._dragging, idx = self._size_handles.contains(event) if self._dragging: self._drag_idx = idx['ind'][0] elif self.patch.contains(event)[0]: self._moving = True self._dragging = True - if (self._drawing or self._dragging or self.allow_redraw or + self._rotating = self._rot_handle.contains(event) + + if (self._drawing or self._dragging or self._rotating or self.allow_redraw or not self._has_selected): if self._moving: self._start_drawing(event) @@ -615,18 +628,25 @@ def _get_handle_verts(self): width, height = geometry['width'], geometry['height'] if isinstance(self.patch, Ellipse): xm, ym = geometry['center'] + x0, y0 = xm - width / 2, ym - height / 2 else: x0, y0 = geometry['xy'] xm, ym = x0 + width / 2, y0 + height / 2 w = width / 2 h = height / 2 - xc = xm - w, xm + w, xm + w, xm - w - yc = ym - h, ym - h, ym + h, ym + h - xe = xm - w, xm, xm + w, xm - ye = ym, ym - h, ym, ym + h + xc = xm - w + yc = ym - h + xe = xm - w, xm + ye = ym, ym - h x = np.hstack((xc, xe)) y = np.hstack((yc, ye)) - return np.vstack((x, y)).T + pts = np.vstack((x, y)).T + rot_trans = transforms.Affine2D() + if isinstance(self.patch, Ellipse): + rot_trans.rotate_deg_around(xm, ym, geometry['angle']) + else: + rot_trans.rotate_deg_around(x0, y0, geometry['angle']) + return rot_trans.transform(pts), rot_trans.transform((xm, ym + h)) def _on_motion(self, event): # Resize an existing shape. @@ -756,7 +776,13 @@ def get_geometry(self): end_points: The [(x0, y0), (x1, y1)] points. width: The width of the tool in pixels. """ - return dict(end_points=self.end_points, width=self.width) + verts = self.patch.get_xy() + p0x = (verts[0, 0] + verts[1, 0]) / 2 + p0y = (verts[0, 1] + verts[1, 1]) / 2 + p1x = (verts[3, 0] + verts[2, 0]) / 2 + p1y = (verts[3, 1] + verts[2, 1]) / 2 + pts = np.array([[p0x, p0y], [p1x, p1y]]) + return dict(end_points=pts, width=self._width) def set_geometry(self, end_points=None, width=None): """Set the geometry of the line tool. @@ -768,9 +794,10 @@ def set_geometry(self, end_points=None, width=None): width: int The width in pixels of the line """ - self.width = width or self.width + geometry = self.get_geometry() + self.width = width or geometry['width'] if end_points is None: - end_points = self.end_points + end_points = geometry['end_points'] pts = np.asarray(end_points) # Get the widths in data units. xfm = self.ax.transData.inverted() @@ -802,7 +829,7 @@ def set_geometry(self, end_points=None, width=None): super(LineTool, self).set_geometry(xy=(v00, v01, v11, v10)) def _make_patch(self, **overrides): - self.width = 1 + self._width = 1 props = dict(facecolor='red', edgecolor='red', visible=False, alpha=0.5, fill=True, picker=10, linewidth=2, zorder=1) @@ -810,7 +837,7 @@ def _make_patch(self, **overrides): return Polygon([(0, 0), (0, 0), (0, 0), (0, 0)], **props) def _get_handle_verts(self): - return self.end_points + return self.get_geometry()['end_points'], None def _on_press(self, event): if not self._dragging: @@ -821,12 +848,12 @@ def _on_press(self, event): self._start_drawing(event) def _on_motion(self, event): - end_points = self.end_points + end_points = self.get_geometry()['end_points'] end_points[self._drag_idx, :] = event.xdata, event.ydata self.set_geometry(end_points=end_points) def _move(self, event): - pts = self.end_points + pts = self.get_geometry()['end_points'] pts[:, 0] += event.xdata - (pts[1, 0] + pts[0, 0]) / 2 pts[:, 1] += event.ydata - (pts[1, 1] + pts[0, 1]) / 2 self.set_geometry(end_points=pts) @@ -1004,10 +1031,9 @@ def _update_cursor(self, x, y): fig, ax = plt.subplots() pts = ax.scatter(data[:, 0], data[:, 1], s=80) - """ - ellipse = RectangleTool(ax) - ellipse.set_geometry(xy=(0.4, 0.5), width=0.3, height=0.5) + ellipse = EllipseTool(ax) + ellipse.set_geometry(center=(0.4, 0.5), width=0.3, height=0.5, angle=45) ax.invert_yaxis() def test(tool): @@ -1019,8 +1045,8 @@ def test(tool): line = LineTool(ax) line.set_geometry(end_points=[[0.1, 0.1], [0.5, 0.5]]) line.interactive = True - """ + def test(tool): print(tool.overlay)