2

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:

line_with_overlapping_points

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.

ToddP
  • 652
  • 13
  • 18
  • How about using `event.artist.get_zorder()`? – Asmus May 07 '19 at 10:05
  • Because I'd like a single pick event whose artist has the maximum zorder value amongst all artists at the specified mouse point. zorder could indeed be used in the 'button_press_event' solution above, but this 'button_press_event' is just a hack, and not a pick event. – ToddP May 07 '19 at 11:10
  • I've rewritten my answer to include an example using a cue for pick events. – Asmus May 07 '19 at 12:23

2 Answers2

3

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".

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • Thanks for this. This works well. I was expecting Matplotlib to have a simpler built-in solution. – ToddP May 08 '19 at 00:02
  • 1
    Not sure where the expectation comes from, but now that it's written down you can copy that class anywhere and use it as is, which is about as simple as it can get, right? – ImportanceOfBeingErnest May 08 '19 at 00:17
  • I expected that because other GUI environments (like Qt and wxPython) resolve selection according to the uppermost control with respect to the user. In most GUIs I know it is not possible to select multiple objects with a single mouse click (without dragging). So I don't understand the purpose of emitting multiple pick events upon a single button click. – ToddP May 08 '19 at 03:55
  • I see. I added some prosa in the answer about why I think noone has implemented such unified event yet. – ImportanceOfBeingErnest May 08 '19 at 10:51
  • This example was helpful when I needed to convert a `motion_notify_event` to select the object it was hovering over. Since `motion_notify_event` doesn't do that, I simply passed it to `PickEvent` like your example. – KCharlie Oct 22 '21 at 21:12
0

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()
Asmus
  • 5,117
  • 1
  • 16
  • 21
  • I can't get this to run in Python 3 (error: "AttributeError: 'NoneType' object has no attribute 'format'"), but I see the idea. However, I don't think this will work because it won't do a point-specific zorder check. That is, `line` should be the topmost artist at all points that are not covered by the `points` artist. In this code the `points` artist is always the topmost. – ToddP May 07 '19 at 11:18
  • Sorry, I had misplaced the parentheses within the `'you clicked on ..'` print statement, I fixed it above. If I understand you correctly, you want to always have the `on_click()` act only on the topmost item in the plot; this means you need to [cue your click events or apply a custom picker](https://stackoverflow.com/a/25450650/565489), where I'd probably choose the former, i.e. cueing and comparing zorder for the collected events. – Asmus May 07 '19 at 11:48
  • This works, but using the timer is an even messier hack than the `button_press_event` solution above. I find @ImportanceOfBeingErnest's solution to be cleaner and more generalizable. Thank you anyway for the suggestions! – ToddP May 08 '19 at 00:07