From ba7129a719202c762f26019a2d1db5065d329ece Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Sat, 17 Jul 2021 21:02:06 -0700 Subject: [PATCH] Add option to limit LinearRegionitem bounds to a secondary item (#1834) * Added clipItem option to LinearRegionItem * Added a clipItem option to LinearRegionItem Handle case when no self.viewBox() is yet available * Implement LinearRegionItem clipItem * Undo unnecessary change * Update clipItem doc * Fixup docstring formatting * Cleanup * Support clearing clipItem via setBounds. Fix initialization bug * Add tests for LinearRegionItem clipItem * Better clipItem demo in crosshair example * Another test to verify claim in docstring Co-authored-by: Arjun Chennu Co-authored-by: Ogi Moore Co-authored-by: Arjun Chennu --- examples/crosshair.py | 5 +- examples/imageAnalysis.py | 2 +- pyqtgraph/graphicsItems/LinearRegionItem.py | 106 ++++++++++++----- tests/graphicsItems/test_LinearRegionItem.py | 116 +++++++++++++++++++ 4 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 tests/graphicsItems/test_LinearRegionItem.py diff --git a/examples/crosshair.py b/examples/crosshair.py index b1d8cc46..2479fbe2 100644 --- a/examples/crosshair.py +++ b/examples/crosshair.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Demonstrates some customized mouse interaction by drawing a crosshair that follows the mouse. @@ -38,7 +39,9 @@ data2 = 15000 + 15000 * pg.gaussianFilter(np.random.random(size=10000), 10) + 30 p1.plot(data1, pen="r") p1.plot(data2, pen="g") -p2.plot(data1, pen="w") +p2d = p2.plot(data1, pen="w") +# bound the LinearRegionItem to the plotted data +region.setClipItem(p2d) def update(): region.setZValue(10) diff --git a/examples/imageAnalysis.py b/examples/imageAnalysis.py index f4e76219..9a3441d0 100644 --- a/examples/imageAnalysis.py +++ b/examples/imageAnalysis.py @@ -106,7 +106,7 @@ def imageHoverEvent(event): val = data[i, j] ppos = img.mapToParent(pos) x, y = ppos.x(), ppos.y() - p1.setTitle("pos: (%0.1f, %0.1f) pixel: (%d, %d) value: %g" % (x, y, i, j, val)) + p1.setTitle("pos: (%0.1f, %0.1f) pixel: (%d, %d) value: %.3g" % (x, y, i, j, val)) # Monkey-patch the image to use our custom hover function. # This is generally discouraged (you should subclass ImageItem instead), diff --git a/pyqtgraph/graphicsItems/LinearRegionItem.py b/pyqtgraph/graphicsItems/LinearRegionItem.py index f589b5e9..b5ae6f49 100644 --- a/pyqtgraph/graphicsItems/LinearRegionItem.py +++ b/pyqtgraph/graphicsItems/LinearRegionItem.py @@ -36,7 +36,7 @@ class LinearRegionItem(GraphicsObject): def __init__(self, values=(0, 1), orientation='vertical', brush=None, pen=None, hoverBrush=None, hoverPen=None, movable=True, bounds=None, - span=(0, 1), swapMode='sort'): + span=(0, 1), swapMode='sort', clipItem=None): """Create a new LinearRegionItem. ============== ===================================================================== @@ -60,25 +60,35 @@ class LinearRegionItem(GraphicsObject): ``span=(0.5, 1)`` to draw only on the top half of the view. swapMode Sets the behavior of the region when the lines are moved such that - their order reverses. "block" means the user cannot drag - one line past the other. "push" causes both lines to be - moved if one would cross the other. "sort" means that - lines may trade places, but the output of getRegion - always gives the line positions in ascending order. None - means that no attempt is made to handle swapped line - positions. The default is "sort". + their order reverses: + + * "block" means the user cannot drag one line past the other + * "push" causes both lines to be moved if one would cross the other + * "sort" means that lines may trade places, but the output of + getRegion always gives the line positions in ascending order. + * None means that no attempt is made to handle swapped line + positions. + + The default is "sort". + clipItem An item whose bounds will be used to limit the region bounds. + This is useful when a LinearRegionItem is added on top of an + :class:`~pyqtgraph.ImageItem` or + :class:`~pyqtgraph.PlotDataItem` and the visual region should + not extend beyond its range. This overrides ``bounds``. ============== ===================================================================== """ GraphicsObject.__init__(self) self.orientation = orientation - self.bounds = QtCore.QRectF() self.blockLineSignal = False self.moving = False self.mouseHovering = False self.span = span self.swapMode = swapMode - self._bounds = None + self.clipItem = clipItem + + self._boundingRectCache = None + self._clipItemBoundsCache = None # note LinearRegionItem.Horizontal and LinearRegionItem.Vertical # are kept for backward compatibility. @@ -88,7 +98,7 @@ class LinearRegionItem(GraphicsObject): span=span, pen=pen, hoverPen=hoverPen, - ) + ) if orientation in ('horizontal', LinearRegionItem.Horizontal): self.lines = [ @@ -125,9 +135,10 @@ class LinearRegionItem(GraphicsObject): self.setHoverBrush(hoverBrush) self.setMovable(movable) - + def getRegion(self): """Return the values at the edges of the region.""" + r = (self.lines[0].value(), self.lines[1].value()) if self.swapMode == 'sort': return (min(r), max(r)) @@ -168,19 +179,28 @@ class LinearRegionItem(GraphicsObject): self.hoverBrush = fn.mkBrush(*br, **kargs) def setBounds(self, bounds): - """Optional [min, max] bounding values for the region. To have no bounds on the - region use [None, None]. - Does not affect the current position of the region unless it is outside the new bounds. - See :func:`setRegion ` to set the position - of the region.""" - for l in self.lines: - l.setBounds(bounds) - - def setMovable(self, m): + """Set ``(min, max)`` bounding values for the region. + + The current position is only affected it is outside the new bounds. See + :func:`~pyqtgraph.LinearRegionItem.setRegion` to set the position of the region. + + Use ``(None, None)`` to disable bounds. + """ + if self.clipItem is not None: + self.setClipItem(None) + self._setBounds(bounds) + + def _setBounds(self, bounds): + # internal impl so user-facing setBounds can clear clipItem and clipItem can + # set bounds without clearing itself + for line in self.lines: + line.setBounds(bounds) + + def setMovable(self, m=True): """Set lines to be movable by the user, or not. If lines are movable, they will also accept HoverEvents.""" - for l in self.lines: - l.setMovable(m) + for line in self.lines: + line.setMovable(m) self.movable = m self.setAcceptHoverEvents(m) @@ -188,13 +208,45 @@ class LinearRegionItem(GraphicsObject): if self.span == (mn, mx): return self.span = (mn, mx) - self.lines[0].setSpan(mn, mx) - self.lines[1].setSpan(mn, mx) + for line in self.lines: + line.setSpan(mn, mx) self.update() + def setClipItem(self, item=None): + """Set an item to which the region is bounded. + + If ``None``, bounds are disabled. + """ + self.clipItem = item + self._clipItemBoundsCache = None + if item is None: + self._setBounds((None, None)) + if item is not None: + self._updateClipItemBounds() + + def _updateClipItemBounds(self): + # set region bounds corresponding to clipItem + item_vb = self.clipItem.getViewBox() + if item_vb is None: + return + + item_bounds = item_vb.childrenBounds(items=(self.clipItem,)) + if item_bounds == self._clipItemBoundsCache or None in item_bounds: + return + + self._clipItemBoundsCache = item_bounds + + if self.orientation in ('horizontal', LinearRegionItem.Horizontal): + self._setBounds(item_bounds[1]) + else: + self._setBounds(item_bounds[0]) + def boundingRect(self): br = QtCore.QRectF(self.viewRect()) # bounds of containing ViewBox mapped to local coords. + if self.clipItem is not None: + self._updateClipItemBounds() + rng = self.getRegion() if self.orientation in ('vertical', LinearRegionItem.Vertical): br.setLeft(rng[0]) @@ -211,8 +263,8 @@ class LinearRegionItem(GraphicsObject): br = br.normalized() - if self._bounds != br: - self._bounds = br + if self._boundingRectCache != br: + self._boundingRectCache = br self.prepareGeometryChange() return br diff --git a/tests/graphicsItems/test_LinearRegionItem.py b/tests/graphicsItems/test_LinearRegionItem.py new file mode 100644 index 00000000..49a4dc7a --- /dev/null +++ b/tests/graphicsItems/test_LinearRegionItem.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +import pytest + +import pyqtgraph as pg +import numpy as np +import math + +app = pg.mkQApp() + + +def check_region(lr, bounds, exact=False, rtol=0.005): + """Optionally tolerant LinearRegionItem region check""" + reg = lr.getRegion() + if exact: + assert reg[0] == bounds[0] + assert reg[1] == bounds[1] + else: + assert math.isclose(reg[0], bounds[0], rel_tol=rtol) + assert math.isclose(reg[1], bounds[1], rel_tol=rtol) + + +@pytest.mark.parametrize("orientation", ["vertical", "horizontal"]) +def test_clip_to_plot_data_item(orientation): + """Vertical and horizontal LRIs clipping both bounds to a PlotDataItem""" + # initial bounds for the LRI + init_vals = (-1.5, 1.5) + + # data for a PlotDataItem to clip to, both inside the inial bounds + x = np.linspace(-1, 1, 10) + y = np.linspace(1, 1.2, 10) + + p = pg.PlotWidget() + pdi = p.plot(x=x, y=y) + + lr = pg.LinearRegionItem(init_vals, clipItem=pdi, orientation=orientation) + p.addItem(lr) + + app.processEvents() + + if orientation == "vertical": + check_region(lr, x[[0, -1]]) + else: + check_region(lr, y[[0, -1]]) + + +def test_disable_clip_item(): + """LRI clipItem (ImageItem) disabled by explicit call to setBounds""" + init_vals = (5, 40) + + p = pg.PlotWidget() + img = pg.ImageItem(image=np.eye(20, 20)) + p.addItem(img) + + lr = pg.LinearRegionItem(init_vals, clipItem=img) + p.addItem(lr) + + app.processEvents() + + # initial bound that was out of range snaps to the imageitem bound + check_region(lr, (init_vals[0], img.height()), exact=True) + + # disable clipItem, move the right InfiniteLine beyond the new bound + lr.setBounds(init_vals) + lr.lines[1].setPos(init_vals[1] + 10) + + app.processEvents() + + check_region(lr, init_vals, exact=True) + + +def test_clip_to_item_in_other_vb(): + """LRI clip to item in a different ViewBox""" + init_vals = (10, 50) + img_shape = (20, 20) + + win = pg.GraphicsLayoutWidget() + + # "remote" PlotDataItem to bound to + p1 = win.addPlot() + img = pg.ImageItem(image=np.eye(*img_shape)) + p1.addItem(img) + + # plot the LRI lives in + p2 = win.addPlot() + x2 = np.linspace(-200, 200, 100) + p2.plot(x=x2, y=x2) + + lr = pg.LinearRegionItem(init_vals) + p2.addItem(lr) + app.processEvents() + + # no clip item set yet, should be initial bounds + check_region(lr, init_vals, exact=True) + + lr.setClipItem(img) + app.processEvents() + + # snap to image width + check_region(lr, (init_vals[0], img_shape[1]), exact=True) + + +def test_clip_item_override_init_bounds(): + """clipItem overrides bounds provided in the constructor""" + init_vals = (-10, 10) + init_bounds = (-5, 5) + img_shape = (5, 5) + + p = pg.PlotWidget() + img = pg.ImageItem(image=np.eye(*img_shape)) + p.addItem(img) + + lr = pg.LinearRegionItem(init_vals, clipItem=img, bounds=init_bounds) + p.addItem(lr) + + app.processEvents() + check_region(lr, (0, img_shape[1]), exact=True)