Picking a single artist from a set of overlapping artists in Matplotlib

你说的曾经没有我的故事 提交于 2019-12-11 04:57:16

问题


This question is closely related to the two below, but this question is more general.

Matplotlib pick event order for overlapping artists

Multiple pick events interfering


The problem:

When picking overlapping artists on a single canvas, separate pick events are created for each artist. In the example below, a click on a red point calls on_pick twice, once for lines and once for points. Since the points sit above the line (given their respective zorder values), I would prefer to have just a single pick event generated for the topmost artist (in this case: points).

Example:

import numpy as np
from matplotlib import pyplot

def on_pick(event):
    if event.artist == line:
        print('Line picked')
    elif event.artist == points:
        print('Point picked')


# create axes:
pyplot.close('all')
ax      = pyplot.axes()

# add line:
x       = np.arange(10)
y       = np.random.randn(10)
line    = ax.plot(x, y, 'b-', zorder=0)[0]

# add points overlapping the line:
xpoints = [2, 4, 7]
points  = ax.plot(x[xpoints], y[xpoints], 'ro', zorder=1)[0]

# set pickers:
line.set_picker(5)
points.set_picker(5)
ax.figure.canvas.mpl_connect('pick_event', on_pick)

pyplot.show()

Messy solution:

One solution is to use Matplotlib's button_press_event, then compute distances between the mouse and all artists, like below. However, this solution is quite messy, because adding additional overlapping artists will make this code quite complex, increasing the number of cases and conditions to check.

def on_press(event):
    if event.xdata is not None:
        x,y   = event.xdata, event.ydata  #mouse click coordinates
        lx,ly = line.get_xdata(), line.get_ydata()     #line point coordinates
        px,py = points.get_xdata(), points.get_ydata() #points
        dl    = np.sqrt((x - lx)**2 + (y - ly)**2)     #distances to line points
        dp    = np.sqrt((x - px)**2 + (y - py)**2)     #distances to points
        if dp.min() < 0.05:
            print('Point selected')
        elif dl.min() < 0.05:
            print('Line selected')


pyplot.close('all')
ax      = pyplot.axes()

# add line:
x       = np.arange(10)
y       = np.random.randn(10)
line    = ax.plot(x, y, 'b-', zorder=0)[0]

# add points overlapping the line:
xpoints = [2, 4, 7]
points  = ax.plot(x[xpoints], y[xpoints], 'ro', zorder=1)[0]

# set picker:
ax.figure.canvas.mpl_connect('button_press_event', on_press)

pyplot.show()

Question summary: Is there a better way to select the topmost artist from a set of overlapping artists?

Ideally, I would love be able to do something like this:

pyplot.set_pick_stack( [points, line] )

implying that points will be selected over line for an overlapping pick.


回答1:


It might be easiest to create your own event on button_press_events happening. To pusue the idea of a "set_pick_stack" expressed in the question, this could look as follows. The idea is to store a set of artists and upon a button_press_event check if that event is contained by the artist. Then fire a callback on a custom onpick function.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backend_bases import PickEvent

class PickStack():
    def __init__(self, stack, on_pick):
        self.stack = stack
        self.ax = [artist.axes for artist in self.stack][0]
        self.on_pick = on_pick
        self.cid = self.ax.figure.canvas.mpl_connect('button_press_event',
                                                     self.fire_pick_event)

    def fire_pick_event(self, event):
        if not event.inaxes:
            return
        cont = [a for a in self.stack if a.contains(event)[0]]
        if not cont:
            return
        pick_event = PickEvent("pick_Event", self.ax.figure.canvas, 
                               event, cont[0],
                               guiEvent=event.guiEvent,
                               **cont[0].contains(event)[1])
        self.on_pick(pick_event)

Usage would look like

fig, ax = plt.subplots()

# add line:
x       = np.arange(10)
y       = np.random.randn(10)
line,   = ax.plot(x, y, 'b-', label="Line", picker=5)

# add points overlapping the line:
xpoints = [2, 4, 7]
points,  = ax.plot(x[xpoints], y[xpoints], 'ro', label="Points", picker=5)


def onpick(event):
    txt = f"You picked {event.artist} at xy: " + \
          f"{event.mouseevent.xdata:.2f},{event.mouseevent.xdata:.2f}" + \
          f" index: {event.ind}"
    print(txt)

p = PickStack([points, line], onpick)

plt.show()

The idea here is to supply a list of artists in the order desired for pick events. Of course one could also use zorder to determine the order. This could look like

self.stack = list(stack).sort(key=lambda x: x.get_zorder(), reverse=True)

in the __init__ function.

Because the question arouse in the comments, let's look at why matplotlib does not do this filtering automatically. Well, first I would guess that it's undesired in 50% of the cases at least, where you would like an event for every artist picked. But also, it is much easier for matplotlib to just emit an event for every artist that gets hit by a mouseevent than to filter them. For the former, you just compare coordinates (much like the "messy solution" from the question). Whereas it is hard to get the topmost artist only; of course in the case two artists have differing zorder, it would be possible, but if they have the same zorder, it's just the order they appear in the lists of axes children that determines which is in front. A "pick_upmost_event" would need to check the complete stack of axes children to find out which one to pick. That being said, it's not impossible, but up to now probably noone was convinced it's worth the effort. Surely, people can open an issue or submit an implementation as PR to matplotlib for such "pick_upmost_event".




回答2:


Edited: First of all, it's generally a good idea to keep track of all the artists you're drawing; hence I'd suggest to keep a dictionary artists_dict with all plotted elements as keys, which you can use to store some helpful values (e.g. within another dict).

Apart from this, the code below relies on using a timer which collects the fired events in list_artists, and then processes this list every 100ms via on_pick(list_artists). Within this function, you can check whether one or more than one artists got picked on, then find the one with the highest zorder and do something to it.

import numpy as np
from matplotlib import pyplot

artists_dict={}


def handler(event):
    print('handler fired')
    list_artists.append(event.artist)

def on_pick(list_artists):
    ## if you still want to use the artist dict for something:
    # print([artists_dict[a] for a in list_artists])

    if len(list_artists)==1:
        print('do something to the line here')

        list_artists.pop(0)## cleanup
    elif len(list_artists)>1:### only for more than one plot item
        zorder_list=[ a.get_zorder() for a in list_artists]
        print('highest item has zorder {0}, is at position {1} of list_artists'.format(np.max(zorder_list),np.argmax(zorder_list)))
        print('do something to the scatter plot here')
        print(list(zip(zorder_list,list_artists)))

        list_artists[:]=[]
    else:
        return

# create axes:
pyplot.close('all')
fig,ax=pyplot.subplots()

# add line:
x      = np.arange(10)
y      = np.random.randn(10)
line   = ax.plot(x, y, 'b-', zorder=0)[0]

## insert the "line” into our artists_dict with some metadata
#  instead of inserting zorder:line.get_zorder(), you could also 
#  directly insert zorder:0 of course.
artists_dict[line]={'label':'test','zorder':line.get_zorder()}

# add points overlapping the line:
xpoints = [2, 4, 7]
points  = ax.plot(x[xpoints], y[xpoints], 'ro', zorder=1)[0]

## and we also add the scatter plot 'points'
artists_dict[points]={'label':'scatters','zorder':points.get_zorder()}

# set pickers:
line.set_picker(5)
points.set_picker(5)


## connect to handler function
ax.figure.canvas.mpl_connect('pick_event', handler)
list_artists=[]
## wait a bit
timer=fig.canvas.new_timer(interval=100)
timer.add_callback(on_pick,list_artists)
timer.start()

pyplot.show()


来源:https://stackoverflow.com/questions/56015753/picking-a-single-artist-from-a-set-of-overlapping-artists-in-matplotlib

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!