From d2a23500399477b86c8286007162e8df72aa2841 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 5 Nov 2016 09:26:01 -0500 Subject: [PATCH 1/9] Add painter widget --- lib/matplotlib/widgets.py | 151 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index a12aa82e83e5..b48f2a152293 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -19,6 +19,7 @@ import numpy as np from matplotlib import rcParams +from .colors import ListedColormap, NoNorm from .mlab import dist from .patches import Circle, Rectangle, Ellipse from .lines import Line2D @@ -368,10 +369,10 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt='%1.2f', ax.set_xticks([]) ax.set_navigate(False) - self.connect_event('button_press_event', self._update) - self.connect_event('button_release_event', self._update) + self.connect_event('button_press_event', self.update) + self.connect_event('button_release_event', self.update) if dragging: - self.connect_event('motion_notify_event', self._update) + self.connect_event('motion_notify_event', self.update) self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes, verticalalignment='center', horizontalalignment='right') @@ -1311,7 +1312,7 @@ def onmove(self, event): self.linev.set_visible(self.visible and self.vertOn) self.lineh.set_visible(self.visible and self.horizOn) - self._update() + self.update() def _update(self): @@ -1431,7 +1432,7 @@ def onmove(self, event): for line in self.hlines: line.set_ydata((event.ydata, event.ydata)) line.set_visible(self.visible) - self._update() + self.update() def _update(self): if self.useblit: @@ -1468,6 +1469,8 @@ def __init__(self, ax, onselect, useblit=False, button=None, if isinstance(button, int): self.validButtons = [button] + elif button is None: + self.validButtons = [1, 2, 3] else: self.validButtons = button @@ -2595,3 +2598,141 @@ def onmove(self, event): self.canvas.blit(self.ax.bbox) else: self.canvas.draw_idle() + + +LABELS_CMAP = ListedColormap(['white', 'red', 'dodgerblue', 'gold', + 'greenyellow', 'blueviolet']) + + +class Painter(_SelectorWidget): + + """Interactive paint tool that is connected to a single + :class:`~matplotlib.axes.Axes`. + """ + def __init__(self, ax, on_select=None, on_motion=None, + overlay_props=None, cursor_props=None, radius=5, + useblit=True, button=None, state_modifier_keys=None): + """Initialize the tool. + %(BaseInteractiveToolInit)s + """ + super(Painter, self).__init__(ax, on_select, + useblit=useblit, button=button, + state_modifier_keys=state_modifier_keys) + self.cmap = LABELS_CMAP + 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(cursor_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=NoNorm(), visible=False, zorder=1, + extent=(x0, x1, y0, y1), aspect=self.ax.get_aspect()) + props.update(overlay_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._drawing = True + 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 _press(self, event): + self._update_cursor(event.xdata, event.ydata) + self._update_overlay(event.x, event.y) + self.update() + + def _onmove(self, event): + self._update_cursor(event.xdata, event.ydata) + if event.button and event.button in self.validButtons: + self._update_overlay(event.x, event.y) + self.update() + + def _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)) From 1933b4cd966c28e141b716dec1d304cacc10d95c Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 5 Nov 2016 09:29:29 -0500 Subject: [PATCH 2/9] Undo accidental changes --- lib/matplotlib/widgets.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index b48f2a152293..6877505c0982 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -369,10 +369,10 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt='%1.2f', ax.set_xticks([]) ax.set_navigate(False) - self.connect_event('button_press_event', self.update) - self.connect_event('button_release_event', self.update) + self.connect_event('button_press_event', self._update) + self.connect_event('button_release_event', self._update) if dragging: - self.connect_event('motion_notify_event', self.update) + self.connect_event('motion_notify_event', self._update) self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes, verticalalignment='center', horizontalalignment='right') @@ -1312,7 +1312,7 @@ def onmove(self, event): self.linev.set_visible(self.visible and self.vertOn) self.lineh.set_visible(self.visible and self.horizOn) - self.update() + self._update() def _update(self): @@ -1432,7 +1432,7 @@ def onmove(self, event): for line in self.hlines: line.set_ydata((event.ydata, event.ydata)) line.set_visible(self.visible) - self.update() + self._update() def _update(self): if self.useblit: From bf3219cb219dd07ec3dd92ef74d470b0a7aa2d6f Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 5 Nov 2016 10:20:26 -0500 Subject: [PATCH 3/9] Update docs and allow cmap to be passed in --- lib/matplotlib/widgets.py | 61 ++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6877505c0982..54b6f79c95ca 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2605,20 +2605,57 @@ def onmove(self, event): class Painter(_SelectorWidget): + """ + Paint regions on an axes. - """Interactive paint tool that is connected to a single - :class:`~matplotlib.axes.Axes`. + For the widget to remain responsive you must keep a reference to it. + + Example usage:: + + import numpy as np + import matplotlib.pyplot as plt + from matplotlib.widgets import Painter + + data = np.random.rand(100, 2) + data[:, 1] *= 2 + + fig, ax = plt.subplots() + pts = ax.scatter(data[:, 0], data[:, 1], s=80) + + def test(x, y): + print(x, y) + + p = Painter(ax, test) + plt.show() """ - def __init__(self, ax, on_select=None, on_motion=None, - overlay_props=None, cursor_props=None, radius=5, - useblit=True, button=None, state_modifier_keys=None): - """Initialize the tool. - %(BaseInteractiveToolInit)s + + def __init__(self, ax, on_select=None, overlay_props=None, + cursor_props=None, radius=5, cmap=LABELS_CMAP, + useblit=True, button=None): + """ + Parameters: + + *ax* : :class:`~matplotlib.axes.Axes` + The parent axes for the widget + *on_select* : function + A callback for when a region is painted. Called with the + (x, y) coordinates of the region. + *overlay_props* : dict + The properties to apply to the overlay. + *cursor_props* : ditc + - The properties to apply to the cursor. + *radius* : int + - The radius of the cursor in pixels. + *cmap* : :class:~matplotlib.colorls.ListedColormap` + - The colormap to use for the cursors. + *useblit* : bool + - Whether to use blitting. + *button* : list + The button numbers supported for the tool (defaults to [1, 2, 3]) """ super(Painter, self).__init__(ax, on_select, - useblit=useblit, button=button, - state_modifier_keys=state_modifier_keys) - self.cmap = LABELS_CMAP + useblit=useblit, button=button) + self.cmap = cmap self._previous = None self._overlay = None self._overlay_plot = None @@ -2659,6 +2696,7 @@ def __init__(self, ax, on_select=None, on_motion=None, @property def overlay(self): + """The paint overlay image""" return self._overlay @overlay.setter @@ -2679,6 +2717,7 @@ def overlay(self, image): @property def label(self): + """The label index""" return self._label @label.setter @@ -2690,6 +2729,7 @@ def label(self, value): @property def radius(self): + """The label radius in pixels""" return self._radius @radius.setter @@ -2713,6 +2753,7 @@ def _onmove(self, event): if event.button and event.button in self.validButtons: self._update_overlay(event.x, event.y) self.update() + self.onselect(event.xdata, event.ydata) def _release(self, event): pass From 908f0b80743ce91a23d2d0044fbd8ede7d82dbdb Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 5 Nov 2016 10:26:52 -0500 Subject: [PATCH 4/9] Add example and enforce integer image extents --- examples/widgets/painter.py | 30 ++++++++++++++++++++++++++++++ lib/matplotlib/widgets.py | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 examples/widgets/painter.py diff --git a/examples/widgets/painter.py b/examples/widgets/painter.py new file mode 100644 index 000000000000..a789af555551 --- /dev/null +++ b/examples/widgets/painter.py @@ -0,0 +1,30 @@ +from __future__ import print_function +""" +Drag the mouse to paint selected areas of the plot. The callback prints +the (x, y) coordinates of the center of the painted region. + +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.widgets import Painter + +# Create a figure and axes for the plot. +fig, ax = plt.subplots() + + +# Generate data and create a scatter plot. +data = np.random.rand(100, 2) +data[:, 1] *= 2 +pts = ax.scatter(data[:, 0], data[:, 1], s=80) + + +# Define the "on_select" callback. +def test(x, y): + print("(%3.2f, %3.2f)" % (x, y)) + + +print("\n click and drag \n (x, y)") + +# Create the painter tool and show the plot. +p = Painter(ax, test) +plt.show() diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 54b6f79c95ca..1402f70926f7 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2681,7 +2681,8 @@ def __init__(self, ax, on_select=None, overlay_props=None, 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._shape = (int(extents[3] - extents[1]), + int(extents[2] - extents[0])) self._overlay = np.zeros(self._shape, dtype='uint8') self._overlay_plot = self.ax.imshow(self._overlay, **props) From a49725f9f1e38eaa16c16b88560fd560963fa68e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 5 Nov 2016 10:37:22 -0500 Subject: [PATCH 5/9] Fix overlay setter and add tests --- lib/matplotlib/tests/test_widgets.py | 32 ++++++++++++++++++++++++++++ lib/matplotlib/widgets.py | 4 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 2555037828f1..448875c5d07f 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -271,3 +271,35 @@ def clicked_function(): pass cid = check.on_clicked(clicked_function) check.disconnect(cid) + + +@cleanup +def test_paint_tool(): + ax = get_ax() + + def onselect(x, y): + pass + + tool = widgets.Painter(ax, onselect) + + tool.radius = 10 + assert tool.radius == 10 + tool.label = 2 + assert tool.label == 2 + + do_event(tool, 'press', xdata=100, ydata=100) + do_event(tool, 'onmove', xdata=110, ydata=110) + do_event(tool, 'release') + + assert tool.overlay[tool.overlay == 2].size == 878 + + tool.label = 5 + do_event(tool, 'press', xdata=20, ydata=20) + do_event(tool, 'onmove', xdata=40, ydata=40) + do_event(tool, 'release') + + assert tool.overlay[tool.overlay == 5].size == 882 + assert tool.overlay[tool.overlay == 2].size == 878 + + tool.overlay = tool.overlay * 0 + assert tool.overlay.sum() == 0 diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 1402f70926f7..04d2984d4639 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2707,11 +2707,11 @@ def overlay(self, image): self.ax.images.remove(self._overlay_plot) self.update() return - self.ax.set_data(image) + self._overlay_plot.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) + self._overlay_plot.set_extent((x0, x1, y0, y1)) # Update the radii and window. self.radius = self._radius self.update() From cdcd45ea40fd74b4c0e8e4e7c6964d275ec89a80 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sun, 6 Nov 2016 08:39:41 -0600 Subject: [PATCH 6/9] Add handling of state_modifier_keys --- lib/matplotlib/widgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 04d2984d4639..b58dc18cd98b 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2631,7 +2631,7 @@ def test(x, y): def __init__(self, ax, on_select=None, overlay_props=None, cursor_props=None, radius=5, cmap=LABELS_CMAP, - useblit=True, button=None): + useblit=True, button=None, state_modifier_keys=None): """ Parameters: @@ -2652,9 +2652,11 @@ def __init__(self, ax, on_select=None, overlay_props=None, - Whether to use blitting. *button* : list The button numbers supported for the tool (defaults to [1, 2, 3]) + *state_modifier_keys* : dict + A mapping of key names to state modifiers. """ super(Painter, self).__init__(ax, on_select, - useblit=useblit, button=button) + useblit=useblit, button=button, state_modifier_keys=None) self.cmap = cmap self._previous = None self._overlay = None From 2f6eb99d0cade736c5bbf9144a3a7f87fa09facb Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 14 Nov 2016 14:58:45 -0600 Subject: [PATCH 7/9] Clear the labels when resizing but update size --- lib/matplotlib/widgets.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index b58dc18cd98b..71de21a36ff8 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2680,15 +2680,10 @@ def __init__(self, ax, on_select=None, overlay_props=None, extent=(x0, x1, y0, y1), aspect=self.ax.get_aspect()) props.update(overlay_props or {}) - extents = self.ax.get_window_extent().extents - self._offsetx = extents[0] - self._offsety = extents[1] - self._shape = (int(extents[3] - extents[1]), - int(extents[2] - extents[0])) - self._overlay = np.zeros(self._shape, dtype='uint8') + self._resize(None) self._overlay_plot = self.ax.imshow(self._overlay, **props) - self.artists = [self._cursor, self._overlay_plot] + self.connect_event('resize_event', self._resize) # These must be called last self.label = 1 @@ -2758,6 +2753,16 @@ def _onmove(self, event): self.update() self.onselect(event.xdata, event.ydata) + def _resize(self, event): + extents = self.ax.get_window_extent().extents + self._offsetx = extents[0] + self._offsety = extents[1] + self._shape = (int(extents[3] - extents[1]), + int(extents[2] - extents[0])) + self._overlay = np.zeros(self._shape, dtype='uint8') + if self._overlay_plot: + self._overlay_plot.set_data(self._overlay) + def _release(self, event): pass From 10ddacb91dd56c5470db9ba0a1fad125f7b51132 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 15 Nov 2016 15:41:44 -0600 Subject: [PATCH 8/9] Address review comments --- examples/widgets/painter.py | 11 +++++-- lib/matplotlib/tests/test_widgets.py | 1 + lib/matplotlib/widgets.py | 49 ++++++++++++++++------------ 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/examples/widgets/painter.py b/examples/widgets/painter.py index a789af555551..6cb283823174 100644 --- a/examples/widgets/painter.py +++ b/examples/widgets/painter.py @@ -1,9 +1,14 @@ -from __future__ import print_function """ +=============================== +Painter Tool Demo +=============================== + Drag the mouse to paint selected areas of the plot. The callback prints the (x, y) coordinates of the center of the painted region. """ +from __future__ import print_function + import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import Painter @@ -19,12 +24,12 @@ # Define the "on_select" callback. -def test(x, y): +def callback(x, y): print("(%3.2f, %3.2f)" % (x, y)) print("\n click and drag \n (x, y)") # Create the painter tool and show the plot. -p = Painter(ax, test) +p = Painter(ax, callback) plt.show() diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 448875c5d07f..81f77ef59f11 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -291,6 +291,7 @@ def onselect(x, y): do_event(tool, 'onmove', xdata=110, ydata=110) do_event(tool, 'release') + # Ensure that the correct number of pixels are labeled. assert tool.overlay[tool.overlay == 2].size == 878 tool.label = 5 diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 71de21a36ff8..0b4f04989994 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2610,6 +2610,10 @@ class Painter(_SelectorWidget): For the widget to remain responsive you must keep a reference to it. + This is not meant to be used with polar coordinate plots. + + Resizing the plot will clear the existing selection. + Example usage:: import numpy as np @@ -2633,30 +2637,33 @@ def __init__(self, ax, on_select=None, overlay_props=None, cursor_props=None, radius=5, cmap=LABELS_CMAP, useblit=True, button=None, state_modifier_keys=None): """ - Parameters: - - *ax* : :class:`~matplotlib.axes.Axes` - The parent axes for the widget - *on_select* : function - A callback for when a region is painted. Called with the - (x, y) coordinates of the region. - *overlay_props* : dict - The properties to apply to the overlay. - *cursor_props* : ditc - - The properties to apply to the cursor. - *radius* : int - - The radius of the cursor in pixels. - *cmap* : :class:~matplotlib.colorls.ListedColormap` - - The colormap to use for the cursors. - *useblit* : bool - - Whether to use blitting. - *button* : list + Parameters + ========== + + ax: :class:`~matplotlib.axes.Axes` + The parent axes for the widget. + on_select: function + A callback for when a region is painted. Called with the + (x, y) coordinates of the region. + overlay_props: dict + The properties to apply to the overlay. + cursor_props: dcit + The properties to apply to the cursor. + radius: int + The radius of the cursor in pixels. + cmap: :class:~matplotlib.colorls.ListedColormap` + The colormap to use for the cursors. + useblit: bool + Whether to use blitting. + button: list The button numbers supported for the tool (defaults to [1, 2, 3]) - *state_modifier_keys* : dict + state_modifier_keys: dict A mapping of key names to state modifiers. """ - super(Painter, self).__init__(ax, on_select, - useblit=useblit, button=button, state_modifier_keys=None) + super(Painter, self).__init__( + ax, on_select, useblit=useblit, button=button, + state_modifier_keys=None + ) self.cmap = cmap self._previous = None self._overlay = None From 3e3d9bdce3bc2be74c49fa32ebb48052833dba93 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Sat, 19 Nov 2016 06:01:05 -0600 Subject: [PATCH 9/9] Add a note about setting the color --- examples/widgets/painter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/widgets/painter.py b/examples/widgets/painter.py index 6cb283823174..930d2a38758d 100644 --- a/examples/widgets/painter.py +++ b/examples/widgets/painter.py @@ -32,4 +32,5 @@ def callback(x, y): # Create the painter tool and show the plot. p = Painter(ax, callback) +p.label = 1 # set the color plt.show()