PyQt mousePressEvent - get object that was clicked on?

9.6k views Asked by At

I'm using PyQt and PyQtGraph to build a relatively simple plotting UI. As part of this I have a graphicsview (pyqtgraph's graphicslayoutwidget) that has PlotItems dynamically added to it by the user.

What I'm trying to achieve is allowing the user to select a PlotItem by double clicking on it.

It's simple enough to get if the user has double clicked somewhere within the widget window, but I can't seem to figure out how to return what was clicked on.

Most of my search results have come up with trying to reimplement mousePressEvent for certain pushbuttons. I've read a bit about event filters, but I'm not sure if that's the necessary solution here.

I'm not sure what other information might be useful for helping answer this question, so if it's unclear what I'm asking let me know so I can clarify.

Edit:

Duplicate of this:

pyqtgraph: When I click on a PlotItem how do I know which item has been clicked

3

There are 3 answers

3
Luke On BEST ANSWER

One strategy is to connect to GraphicsScene.sigMouseClicked and then ask the scene which items are under the mouse cursor.

This should get you part way there:

import pyqtgraph as pg

w = pg.GraphicsWindow()
for i in range(4):
    w.addPlot(0, i)

def onClick(event):
    items = w.scene().items(event.scenePos())
    print "Plots:", [x for x in items if isinstance(x, pg.PlotItem)]

w.scene().sigMouseClicked.connect(onClick)
0
Buzz On

After a lot of troubles working on this problem I figured out that the method items of a QGraphicsView cannot be used carelessly as reported in many answers around stackoverflow.

Direct Solution

I came up with a custom solution to grab the nearest data point to click point. Here is the function

class DSP:
    @staticmethod
    def map_to_nearest(x: np.ndarray, x0: Union[np.ndarray, float]) -> np.ndarray:
        """This methods takes an array of time values and map it to nearest value in nidaq time array
        It returns the indices"""

        if type(x0) == float:
            x0 = np.array([x0])

        # A bit of magic
        x_tiled = np.tile(x, (x0.size, 1))
        x0_tiled = np.tile(x0, (x.size, 1)).T

        diff = np.abs(x_tiled - x0_tiled)
        idx = np.argmin(diff, axis=1)
        return idx

class MyPlotWidget(pg.PlotWidget):
        def nearest_data_index_to_mouse_click(self, click_scene_pos: QPointF):
        """
        :param click_scene_pos: The position of the mouse click in Scene coordinate
        :return:
            - int - Nearest point data index (or None)
            - view_rect (A rectangle in data coordinates of pixel_dist (equivalent scene coordinates) side
        """
        
        # QPoint to numpy array
        qpoint2np = lambda x: np.array([x.x(), x.y()])
        
        # Filter out all not data-driven items in the list. Must be customized and improved!
        get_data_items_only = lambda items: [item for item in items if
                                            any([isinstance(item, x) for x in [pg.PlotDataItem, pg.PlotCurveItem, pg.ScatterPlotItem]])]
        
        # Half side of the rectangular ROI around the click point
        pixel_dist = 5
        
        # Numpy click point
        p_click = qpoint2np(self.plot_item.mapToView(click_scene_pos))
        
        # Rectangle ROI in scene (pixel) coordinates
        scene_rect = QRectF(click_scene_pos.x() - pixel_dist, click_scene_pos.y() - pixel_dist, 2*pixel_dist, 2*pixel_dist)
                    
        # Rectangle ROI in data coordinates - NB: transforming from scene_rect to view_rect allows for the different x-y scaling!
        view_rect: QRectF = self.getPlotItem().mapRectToView(scene_rect)
        
        # Get all items canonically intercepted thourgh the methods already discussed by other answers
        items = get_data_items_only(self.scene().items(scene_rect))
        
        if len(items) == 0:
            return None, None, view_rect
            
        # Make your own decisional criterion
        item = items[0]


        # p0: bottom-left     p1: upper-right  view_rect items (DO NOT USE bottomLeft() and topRight()! The scene coordinates are different!
        # Y axis is upside-down
        p0 = np.array([view_rect.x(), view_rect.y() - view_rect.height()])
        p1 = np.array([view_rect.x() + view_rect.width(), view_rect.y()])

        # Limit the analysis to the same x-interval as the ROI
        _x_limits = np.array([p0[0], p1[0]])
        _item_data_x, _item_data_y = item.getData()
        
        xi = DSP.map_to_nearest(_item_data_x, _x_limits)
        # If the point is out of the interval
        if xi.size == 0:
            return None, None, view_rect
        xi = np.arange(xi[0], xi[1]+1).astype(np.int_)
        
        x, y = _item_data_x[xi], _item_data_y[xi]
        
        # (2,1) limited item data array
        _item_data = np.array([x,y])
        subitem = pg.PlotCurveItem(x=x, y=y)
        
        # Now intersects is used again, but this time the path is limited to a few points near the click! Some error might remains, but it may works well in most cases
        if subitem.getPath().intersects(view_rect):
            # Find nearest point
            delta = _item_data - p_click.reshape(2,1)
            min_dist_arg = np.argmin(np.linalg.norm(delta, axis=0))
            return item, xi[min_dist_arg], view_rect
        
        # View_rect is returned just to allow me to plot the ROI for debug reason
        return None, None, view_rect

Implementing a custom data-tip TextItem, this is the results:

enter image description here

NOTE:

  1. The dot can obviously be not at the center of the ROI, depending on the density of points of the line
  2. This is a solution of mine that I'm sure can be improved. It's just the first I got.
  3. Not sure if it works with log-scales. It should, since the scene_rect is automatically transformed, but I don't know so far how pyqtgraph handles the underlying data of the PlotCurveItem
  4. I experienced that, even though one uses PlotDataItem, it will be turned in a couple of (ScatterPlotItem and CurvePlotItem) to handle both the lines and the symbols (markers) of the plot

Explanation

First of all, though not documented, it seems likely that QGraphicsView::items() implements or uses the method QPainterPath::intersects(). Checking the documentation of this method (with QRectF as an argument):

There is an intersection if any of the lines making up the rectangle crosses a part of the path or if any part of the rectangle overlaps with any area enclosed by the path.

By running some test scripts, it seems that QPainterPath is considering always a closed path, possibly by connecting the last point with the first one. Indeed, the script below:

from PySide2.QtCore import QPoint, QRectF
from PySide2.QtGui import QPainterPath, QPicture, QPainter
from PySide2.QtWidgets import QApplication, QMainWindow

import pyqtgraph as pg
import numpy as np

app = QApplication([])


# Path1 made of two lines
x = [0, 5, 0]
y = [0, 5, 6]

path1 = pg.PlotCurveItem(x, y, name='Path1')
rect = QRectF(1,4,1,1)

# RectItem (here omitted) is taken from https://stackoverflow.com/questions/60012070/drawing-a-rectangle-in-pyqtgraph
rect_item = RectItem(rect)
pw = pg.PlotWidget()

pw.addItem(path1)
pw.addItem(rect_item)

text = f'path1.getPath().intersects(rect): {path1.getPath().intersects(rect)}'

pw.addItem(pg.TextItem(text))

# Need to replicate the item
rect_item = RectItem(rect)
path2 =pg.PlotCurveItem(x=[0,5,4], y=[0,5,6])
pw2 = pg.PlotWidget()
pw2.addItem(path2)
pw2.addItem(rect_item)

text = f'path2.getPath().intersects(rect): {path2.getPath().intersects(rect)}'
pw2.addItem(pg.TextItem(text))

pw.show()
pw2.show()

app.exec_()

gets this plots and results as output:

enter image description here

0
Dorcioman On

A very straightforward alternative is to use a lambda function:

q_label = QLabel("MyLabel")
q_label.mousePressEvent = lambda e: on_name_clicked(q_label)


def on_name_clicked(self, q_label):
    print(q_label.text())