问题
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_event
s 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