diff --git a/pyqtgraph/GraphicsScene/GraphicsScene.py b/pyqtgraph/GraphicsScene/GraphicsScene.py index bab0f776..952a2415 100644 --- a/pyqtgraph/GraphicsScene/GraphicsScene.py +++ b/pyqtgraph/GraphicsScene/GraphicsScene.py @@ -135,7 +135,6 @@ class GraphicsScene(QtGui.QGraphicsScene): self._moveDistance = d def mousePressEvent(self, ev): - #print 'scenePress' QtGui.QGraphicsScene.mousePressEvent(self, ev) if self.mouseGrabberItem() is None: ## nobody claimed press; we are free to generate drag/click events if self.lastHoverEvent is not None: @@ -173,8 +172,8 @@ class GraphicsScene(QtGui.QGraphicsScene): continue if int(btn) not in self.dragButtons: ## see if we've dragged far enough yet cev = [e for e in self.clickEvents if int(e.button()) == int(btn)][0] - dist = Point(ev.screenPos() - cev.screenPos()) - if dist.length() < self._moveDistance and now - cev.time() < self.minDragTime: + dist = Point(ev.scenePos() - cev.scenePos()).length() + if dist == 0 or (dist < self._moveDistance and now - cev.time() < self.minDragTime): continue init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True self.dragButtons.append(int(btn)) @@ -231,8 +230,6 @@ class GraphicsScene(QtGui.QGraphicsScene): prevItems = list(self.hoverItems.keys()) - #print "hover prev items:", prevItems - #print "hover test items:", items for item in items: if hasattr(item, 'hoverEvent'): event.currentItem = item @@ -247,7 +244,7 @@ class GraphicsScene(QtGui.QGraphicsScene): item.hoverEvent(event) except: debug.printExc("Error sending hover event:") - + event.enter = False event.exit = True #print "hover exit items:", prevItems diff --git a/pyqtgraph/GraphicsScene/mouseEvents.py b/pyqtgraph/GraphicsScene/mouseEvents.py index 2e472e04..fb9d3683 100644 --- a/pyqtgraph/GraphicsScene/mouseEvents.py +++ b/pyqtgraph/GraphicsScene/mouseEvents.py @@ -276,8 +276,6 @@ class HoverEvent(object): self._modifiers = moveEvent.modifiers() else: self.exit = True - - def isEnter(self): """Returns True if the mouse has just entered the item's shape""" diff --git a/pyqtgraph/Qt.py b/pyqtgraph/Qt.py index d492824f..2ed9d6f9 100644 --- a/pyqtgraph/Qt.py +++ b/pyqtgraph/Qt.py @@ -9,7 +9,7 @@ This module exists to smooth out some of the differences between PySide and PyQt """ -import sys, re, time +import os, sys, re, time from .python2_3 import asUnicode @@ -17,17 +17,19 @@ PYSIDE = 'PySide' PYQT4 = 'PyQt4' PYQT5 = 'PyQt5' -QT_LIB = None +QT_LIB = os.getenv('PYQTGRAPH_QT_LIB') -## Automatically determine whether to use PyQt or PySide. +## Automatically determine whether to use PyQt or PySide (unless specified by +## environment variable). ## This is done by first checking to see whether one of the libraries ## is already imported. If not, then attempt to import PyQt4, then PySide. -libOrder = [PYQT4, PYSIDE, PYQT5] +if QT_LIB is None: + libOrder = [PYQT4, PYSIDE, PYQT5] -for lib in libOrder: - if lib in sys.modules: - QT_LIB = lib - break + for lib in libOrder: + if lib in sys.modules: + QT_LIB = lib + break if QT_LIB is None: for lib in libOrder: @@ -38,7 +40,7 @@ if QT_LIB is None: except ImportError: pass -if QT_LIB == None: +if QT_LIB is None: raise Exception("PyQtGraph requires one of PyQt4, PyQt5 or PySide; none of these packages could be imported.") if QT_LIB == PYSIDE: @@ -157,6 +159,11 @@ elif QT_LIB == PYQT5: from PyQt5 import QtOpenGL except ImportError: pass + try: + from PyQt5 import QtTest + QtTest.QTest.qWaitForWindowShown = QtTest.QTest.qWaitForWindowExposed + except ImportError: + pass # Re-implement deprecated APIs @@ -208,6 +215,9 @@ elif QT_LIB == PYQT5: VERSION_INFO = 'PyQt5 ' + QtCore.PYQT_VERSION_STR + ' Qt ' + QtCore.QT_VERSION_STR +else: + raise ValueError("Invalid Qt lib '%s'" % QT_LIB) + # Common to PyQt4 and 5 if QT_LIB.startswith('PyQt'): import sip diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index 3aa19daa..51853c61 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -213,20 +213,30 @@ class ROI(GraphicsObject): """Return the angle of the ROI in degrees.""" return self.getState()['angle'] - def setPos(self, pos, update=True, finish=True): + def setPos(self, pos, y=None, update=True, finish=True): """Set the position of the ROI (in the parent's coordinate system). - By default, this will cause both sigRegionChanged and sigRegionChangeFinished to be emitted. - If finish is False, then sigRegionChangeFinished will not be emitted. You can then use - stateChangeFinished() to cause the signal to be emitted after a series of state changes. + Accepts either separate (x, y) arguments or a single :class:`Point` or + ``QPointF`` argument. - If update is False, the state change will be remembered but not processed and no signals + By default, this method causes both ``sigRegionChanged`` and + ``sigRegionChangeFinished`` to be emitted. If *finish* is False, then + ``sigRegionChangeFinished`` will not be emitted. You can then use + stateChangeFinished() to cause the signal to be emitted after a series + of state changes. + + If *update* is False, the state change will be remembered but not processed and no signals will be emitted. You can then use stateChanged() to complete the state change. This allows multiple change functions to be called sequentially while minimizing processing overhead - and repeated signals. Setting update=False also forces finish=False. + and repeated signals. Setting ``update=False`` also forces ``finish=False``. """ - - pos = Point(pos) + if y is None: + pos = Point(pos) + else: + # avoid ambiguity where update is provided as a positional argument + if isinstance(y, bool): + raise TypeError("Positional arguments to setPos() must be numerical.") + pos = Point(pos, y) self.state['pos'] = pos QtGui.QGraphicsItem.setPos(self, pos) if update: @@ -526,7 +536,7 @@ class ROI(GraphicsObject): if isinstance(handle, Handle): index = [i for i, info in enumerate(self.handles) if info['item'] is handle] if len(index) == 0: - raise Exception("Cannot remove handle; it is not attached to this ROI") + raise Exception("Cannot return handle index; not attached to this ROI") return index[0] else: return handle @@ -636,11 +646,20 @@ class ROI(GraphicsObject): if self.mouseHovering == hover: return self.mouseHovering = hover - if hover: - self.currentPen = fn.mkPen(255, 255, 0) + self._updateHoverColor() + + def _updateHoverColor(self): + pen = self._makePen() + if self.currentPen != pen: + self.currentPen = pen + self.update() + + def _makePen(self): + # Generate the pen color for this ROI based on its current state. + if self.mouseHovering: + return fn.mkPen(255, 255, 0) else: - self.currentPen = self.pen - self.update() + return self.pen def contextMenuEnabled(self): return self.removable @@ -919,8 +938,9 @@ class ROI(GraphicsObject): if self.lastState is None: changed = True else: - for k in list(self.state.keys()): - if self.state[k] != self.lastState[k]: + state = self.getState() + for k in list(state.keys()): + if state[k] != self.lastState[k]: changed = True self.prepareGeometryChange() @@ -940,10 +960,11 @@ class ROI(GraphicsObject): self.sigRegionChanged.emit(self) self.freeHandleMoved = False - self.lastState = self.stateCopy() + self.lastState = self.getState() if finish: self.stateChangeFinished() + self.informViewBoundsChanged() def stateChangeFinished(self): self.sigRegionChangeFinished.emit(self) @@ -988,8 +1009,9 @@ class ROI(GraphicsObject): # p.restore() def getArraySlice(self, data, img, axes=(0,1), returnSlice=True): - """Return a tuple of slice objects that can be used to slice the region from data covered by this ROI. - Also returns the transform which maps the ROI into data coordinates. + """Return a tuple of slice objects that can be used to slice the region + from *data* that is covered by the bounding rectangle of this ROI. + Also returns the transform that maps the ROI into data coordinates. If returnSlice is set to False, the function returns a pair of tuples with the values that would have been used to generate the slice objects. ((ax0Start, ax0Stop), (ax1Start, ax1Stop)) @@ -1072,8 +1094,10 @@ class ROI(GraphicsObject): All extra keyword arguments are passed to :func:`affineSlice `. """ + # this is a hidden argument for internal use + fromBR = kwds.pop('fromBoundingRect', False) - shape, vectors, origin = self.getAffineSliceParams(data, img, axes) + shape, vectors, origin = self.getAffineSliceParams(data, img, axes, fromBoundingRect=fromBR) if not returnMappedCoords: return fn.affineSlice(data, shape=shape, vectors=vectors, origin=origin, axes=axes, **kwds) else: @@ -1084,7 +1108,7 @@ class ROI(GraphicsObject): mapped = fn.transformCoordinates(img.transform(), coords) return result, mapped - def getAffineSliceParams(self, data, img, axes=(0,1)): + def getAffineSliceParams(self, data, img, axes=(0,1), fromBoundingRect=False): """ Returns the parameters needed to use :func:`affineSlice ` (shape, vectors, origin) to extract a subset of *data* using this ROI @@ -1095,8 +1119,6 @@ class ROI(GraphicsObject): if self.scene() is not img.scene(): raise Exception("ROI and target item must be members of the same scene.") - shape = self.state['size'] - origin = self.mapToItem(img, QtCore.QPointF(0, 0)) ## vx and vy point in the directions of the slice axes, but must be scaled properly @@ -1106,17 +1128,46 @@ class ROI(GraphicsObject): lvx = np.sqrt(vx.x()**2 + vx.y()**2) lvy = np.sqrt(vy.x()**2 + vy.y()**2) pxLen = img.width() / float(data.shape[axes[0]]) - #img.width is number of pixels or width of item? + #img.width is number of pixels, not width of item. #need pxWidth and pxHeight instead of pxLen ? sx = pxLen / lvx sy = pxLen / lvy vectors = ((vx.x()*sx, vx.y()*sx), (vy.x()*sy, vy.y()*sy)) - shape = self.state['size'] + if fromBoundingRect is True: + shape = self.boundingRect().width(), self.boundingRect().height() + origin = self.mapToItem(img, self.boundingRect().topLeft()) + origin = (origin.x(), origin.y()) + else: + shape = self.state['size'] + origin = (origin.x(), origin.y()) + shape = [abs(shape[0]/sx), abs(shape[1]/sy)] - origin = (origin.x(), origin.y()) return shape, vectors, origin + + def renderShapeMask(self, width, height): + """Return an array of 0.0-1.0 into which the shape of the item has been drawn. + + This can be used to mask array selections. + """ + if width == 0 or height == 0: + return np.empty((width, height), dtype=float) + + # QImage(width, height, format) + im = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32) + im.fill(0x0) + p = QtGui.QPainter(im) + p.setPen(fn.mkPen(None)) + p.setBrush(fn.mkBrush('w')) + shape = self.shape() + bounds = shape.boundingRect() + p.scale(im.width() / bounds.width(), im.height() / bounds.height()) + p.translate(-bounds.topLeft()) + p.drawPath(shape) + p.end() + mask = fn.imageToArray(im)[:,:,0].astype(float) / 255. + return mask def getGlobalTransform(self, relativeTo=None): """Return global transformation (rotation angle+translation) required to move @@ -1576,10 +1627,10 @@ class MultiRectROI(QtGui.QGraphicsObject): pos.append(self.mapFromScene(l.getHandles()[1].scenePos())) return pos - def getArrayRegion(self, arr, img=None, axes=(0,1)): + def getArrayRegion(self, arr, img=None, axes=(0,1), **kwds): rgns = [] for l in self.lines: - rgn = l.getArrayRegion(arr, img, axes=axes) + rgn = l.getArrayRegion(arr, img, axes=axes, **kwds) if rgn is None: continue #return None @@ -1649,6 +1700,7 @@ class MultiLineROI(MultiRectROI): def __init__(self, *args, **kwds): MultiRectROI.__init__(self, *args, **kwds) print("Warning: MultiLineROI has been renamed to MultiRectROI. (and MultiLineROI may be redefined in the future)") + class EllipseROI(ROI): """ @@ -1679,19 +1731,27 @@ class EllipseROI(ROI): p.drawEllipse(r) - def getArrayRegion(self, arr, img=None): + def getArrayRegion(self, arr, img=None, axes=(0, 1), **kwds): """ Return the result of ROI.getArrayRegion() masked by the elliptical shape of the ROI. Regions outside the ellipse are set to 0. """ - arr = ROI.getArrayRegion(self, arr, img) - if arr is None or arr.shape[0] == 0 or arr.shape[1] == 0: - return None - w = arr.shape[0] - h = arr.shape[1] + # Note: we could use the same method as used by PolyLineROI, but this + # implementation produces a nicer mask. + arr = ROI.getArrayRegion(self, arr, img, axes, **kwds) + if arr is None or arr.shape[axes[0]] == 0 or arr.shape[axes[1]] == 0: + return arr + w = arr.shape[axes[0]] + h = arr.shape[axes[1]] ## generate an ellipsoidal mask mask = np.fromfunction(lambda x,y: (((x+0.5)/(w/2.)-1)**2+ ((y+0.5)/(h/2.)-1)**2)**0.5 < 1, (w, h)) - + + # reshape to match array axes + if axes[0] > axes[1]: + mask = mask.T + shape = [(n if i in axes else 1) for i,n in enumerate(arr.shape)] + mask = mask.reshape(shape) + return arr * mask def shape(self): @@ -1772,6 +1832,7 @@ class PolygonROI(ROI): #sc['handles'] = self.handles return sc + class PolyLineROI(ROI): """ Container class for multiple connected LineSegmentROIs. @@ -1801,12 +1862,6 @@ class PolyLineROI(ROI): ROI.__init__(self, pos, size=[1,1], **args) self.setPoints(positions) - #for p in positions: - #self.addFreeHandle(p) - - #start = -1 if self.closed else 0 - #for i in range(start, len(self.handles)-1): - #self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) def setPoints(self, points, closed=None): """ @@ -1824,6 +1879,8 @@ class PolyLineROI(ROI): if closed is not None: self.closed = closed + self.clearPoints() + for p in points: self.addFreeHandle(p) @@ -1831,13 +1888,18 @@ class PolyLineROI(ROI): for i in range(start, len(self.handles)-1): self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) - def clearPoints(self): """ Remove all handles and segments. """ while len(self.handles) > 0: self.removeHandle(self.handles[0]['item']) + + def getState(self): + state = ROI.getState(self) + state['closed'] = self.closed + state['points'] = [Point(h.pos()) for h in self.getHandles()] + return state def saveState(self): state = ROI.saveState(self) @@ -1847,11 +1909,10 @@ class PolyLineROI(ROI): def setState(self, state): ROI.setState(self, state) - self.clearPoints() self.setPoints(state['points'], closed=state['closed']) def addSegment(self, h1, h2, index=None): - seg = LineSegmentROI(handles=(h1, h2), pen=self.pen, parent=self, movable=False) + seg = _PolyLineSegment(handles=(h1, h2), pen=self.pen, parent=self, movable=False) if index is None: self.segments.append(seg) else: @@ -1867,11 +1928,12 @@ class PolyLineROI(ROI): ## Inform all the ROI's segments that the mouse is(not) hovering over it ROI.setMouseHover(self, hover) for s in self.segments: - s.setMouseHover(hover) + s.setParentHover(hover) def addHandle(self, info, index=None): h = ROI.addHandle(self, info, index=index) h.sigRemoveRequested.connect(self.removeHandle) + self.stateChanged(finish=True) return h def segmentClicked(self, segment, ev=None, pos=None): ## pos should be in this item's coordinate system @@ -1899,11 +1961,12 @@ class PolyLineROI(ROI): if len(segments) == 1: self.removeSegment(segments[0]) - else: + elif len(segments) > 1: handles = [h['item'] for h in segments[1].handles] handles.remove(handle) segments[0].replaceHandle(handle, handles[0]) self.removeSegment(segments[1]) + self.stateChanged(finish=True) def removeSegment(self, seg): for handle in seg.handles[:]: @@ -1920,20 +1983,10 @@ class PolyLineROI(ROI): return len(self.handles) > 2 def paint(self, p, *args): - #for s in self.segments: - #s.update() - #p.setPen(self.currentPen) - #p.setPen(fn.mkPen('w')) - #p.drawRect(self.boundingRect()) - #p.drawPath(self.shape()) pass def boundingRect(self): return self.shape().boundingRect() - #r = QtCore.QRectF() - #for h in self.handles: - #r |= self.mapFromItem(h['item'], h['item'].boundingRect()).boundingRect() ## |= gives the union of the two QRectFs - #return r def shape(self): p = QtGui.QPainterPath() @@ -1943,32 +1996,24 @@ class PolyLineROI(ROI): for i in range(len(self.handles)): p.lineTo(self.handles[i]['item'].pos()) p.lineTo(self.handles[0]['item'].pos()) - return p + return p - def getArrayRegion(self, data, img, axes=(0,1), returnMappedCoords=False, **kwds): + def getArrayRegion(self, data, img, axes=(0,1)): """ Return the result of ROI.getArrayRegion(), masked by the shape of the ROI. Values outside the ROI shape are set to 0. """ - sl = self.getArraySlice(data, img, axes=(0,1)) - if sl is None: - return None - sliced = data[sl[0]] - im = QtGui.QImage(sliced.shape[axes[0]], sliced.shape[axes[1]], QtGui.QImage.Format_ARGB32) - im.fill(0x0) - p = QtGui.QPainter(im) - p.setPen(fn.mkPen(None)) - p.setBrush(fn.mkBrush('w')) - p.setTransform(self.itemTransform(img)[0]) - bounds = self.mapRectToItem(img, self.boundingRect()) - p.translate(-bounds.left(), -bounds.top()) - p.drawPath(self.shape()) - p.end() - mask = fn.imageToArray(im)[:,:,0].astype(float) / 255. + br = self.boundingRect() + if br.width() > 1000: + raise Exception() + sliced = ROI.getArrayRegion(self, data, img, axes=axes, fromBoundingRect=True) + mask = self.renderShapeMask(sliced.shape[axes[0]], sliced.shape[axes[1]]) shape = [1] * data.ndim shape[axes[0]] = sliced.shape[axes[0]] shape[axes[1]] = sliced.shape[axes[1]] - return sliced * mask.reshape(shape) + mask = mask.reshape(shape) + + return sliced * mask def setPen(self, *args, **kwds): ROI.setPen(self, *args, **kwds) @@ -2062,6 +2107,32 @@ class LineSegmentROI(ROI): return np.concatenate(rgns, axis=axes[0]) +class _PolyLineSegment(LineSegmentROI): + # Used internally by PolyLineROI + def __init__(self, *args, **kwds): + self._parentHovering = False + LineSegmentROI.__init__(self, *args, **kwds) + + def setParentHover(self, hover): + # set independently of own hover state + if self._parentHovering != hover: + self._parentHovering = hover + self._updateHoverColor() + + def _makePen(self): + if self.mouseHovering or self._parentHovering: + return fn.mkPen(255, 255, 0) + else: + return self.pen + + def hoverEvent(self, ev): + # accept drags even though we discard them to prevent competition with parent ROI + # (unless parent ROI is not movable) + if self.parentItem().translatable: + ev.acceptDrags(QtCore.Qt.LeftButton) + return LineSegmentROI.hoverEvent(self, ev) + + class SpiralROI(ROI): def __init__(self, pos=None, size=None, **args): if size == None: diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 768bbdcf..4cab8662 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -1042,7 +1042,6 @@ class ViewBox(GraphicsWidget): finally: view.blockLink(False) - def screenGeometry(self): """return the screen geometry of the viewbox""" v = self.getViewWidget() @@ -1053,8 +1052,6 @@ class ViewBox(GraphicsWidget): pos = v.mapToGlobal(v.pos()) wr.adjust(pos.x(), pos.y(), pos.x(), pos.y()) return wr - - def itemsChanged(self): ## called when items are added/removed from self.childGroup @@ -1067,18 +1064,23 @@ class ViewBox(GraphicsWidget): self.update() #self.updateAutoRange() + def _invertAxis(self, ax, inv): + key = 'xy'[ax] + 'Inverted' + if self.state[key] == inv: + return + + self.state[key] = inv + self._matrixNeedsUpdate = True # updateViewRange won't detect this for us + self.updateViewRange() + self.update() + self.sigStateChanged.emit(self) + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][ax])) + def invertY(self, b=True): """ By default, the positive y-axis points upward on the screen. Use invertY(True) to reverse the y-axis. """ - if self.state['yInverted'] == b: - return - - self.state['yInverted'] = b - self._matrixNeedsUpdate = True # updateViewRange won't detect this for us - self.updateViewRange() - self.sigStateChanged.emit(self) - self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) + self._invertAxis(1, b) def yInverted(self): return self.state['yInverted'] @@ -1087,14 +1089,7 @@ class ViewBox(GraphicsWidget): """ By default, the positive x-axis points rightward on the screen. Use invertX(True) to reverse the x-axis. """ - if self.state['xInverted'] == b: - return - - self.state['xInverted'] = b - #self.updateMatrix(changed=(False, True)) - self.updateViewRange() - self.sigStateChanged.emit(self) - self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + self._invertAxis(0, b) def xInverted(self): return self.state['xInverted'] diff --git a/pyqtgraph/graphicsItems/tests/test_ROI.py b/pyqtgraph/graphicsItems/tests/test_ROI.py new file mode 100644 index 00000000..973d8f1a --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_ROI.py @@ -0,0 +1,200 @@ +import numpy as np +import pytest +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtTest +from pyqtgraph.tests import assertImageApproved, mouseMove, mouseDrag, mouseClick + + +app = pg.mkQApp() + + +def test_getArrayRegion(): + pr = pg.PolyLineROI([[0, 0], [27, 0], [0, 28]], closed=True) + pr.setPos(1, 1) + rois = [ + (pg.ROI([1, 1], [27, 28], pen='y'), 'baseroi'), + (pg.RectROI([1, 1], [27, 28], pen='y'), 'rectroi'), + (pg.EllipseROI([1, 1], [27, 28], pen='y'), 'ellipseroi'), + (pr, 'polylineroi'), + ] + for roi, name in rois: + # For some ROIs, resize should not be used. + testResize = not isinstance(roi, pg.PolyLineROI) + + check_getArrayRegion(roi, 'roi/'+name, testResize) + + +def check_getArrayRegion(roi, name, testResize=True): + initState = roi.getState() + + #win = pg.GraphicsLayoutWidget() + win = pg.GraphicsView() + win.show() + win.resize(200, 400) + + # Don't use Qt's layouts for testing--these generate unpredictable results. + #vb1 = win.addViewBox() + #win.nextRow() + #vb2 = win.addViewBox() + + # Instead, place the viewboxes manually + vb1 = pg.ViewBox() + win.scene().addItem(vb1) + vb1.setPos(6, 6) + vb1.resize(188, 191) + + vb2 = pg.ViewBox() + win.scene().addItem(vb2) + vb2.setPos(6, 203) + vb2.resize(188, 191) + + img1 = pg.ImageItem(border='w') + img2 = pg.ImageItem(border='w') + vb1.addItem(img1) + vb2.addItem(img2) + + np.random.seed(0) + data = np.random.normal(size=(7, 30, 31, 5)) + data[0, :, :, :] += 10 + data[:, 1, :, :] += 10 + data[:, :, 2, :] += 10 + data[:, :, :, 3] += 10 + + img1.setImage(data[0, ..., 0]) + vb1.setAspectLocked() + vb1.enableAutoRange(True, True) + + roi.setZValue(10) + vb1.addItem(roi) + + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + assert np.all((rgn == data[:, 1:-2, 1:-2, :]) | (rgn == 0)) + img2.setImage(rgn[0, ..., 0]) + vb2.setAspectLocked() + vb2.enableAutoRange(True, True) + + app.processEvents() + + assertImageApproved(win, name+'/roi_getarrayregion', 'Simple ROI region selection.') + + with pytest.raises(TypeError): + roi.setPos(0, False) + + roi.setPos([0.5, 1.5]) + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_halfpx', 'Simple ROI region selection, 0.5 pixel shift.') + + roi.setAngle(45) + roi.setPos([3, 0]) + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_rotate', 'Simple ROI region selection, rotation.') + + if testResize: + roi.setSize([60, 60]) + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_resize', 'Simple ROI region selection, resized.') + + img1.scale(1, -1) + img1.setPos(0, img1.height()) + img1.rotate(20) + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_img_trans', 'Simple ROI region selection, image transformed.') + + vb1.invertY() + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_inverty', 'Simple ROI region selection, view inverted.') + + roi.setState(initState) + img1.resetTransform() + img1.setPos(0, 0) + img1.scale(1, 0.5) + rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) + img2.setImage(rgn[0, ..., 0]) + app.processEvents() + assertImageApproved(win, name+'/roi_getarrayregion_anisotropic', 'Simple ROI region selection, image scaled anisotropically.') + + +def test_PolyLineROI(): + rois = [ + (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=True, pen=0.3), 'closed'), + (pg.PolyLineROI([[0, 0], [10, 0], [0, 15]], closed=False, pen=0.3), 'open') + ] + + #plt = pg.plot() + plt = pg.GraphicsView() + plt.show() + plt.resize(200, 200) + vb = pg.ViewBox() + plt.scene().addItem(vb) + vb.resize(200, 200) + #plt.plotItem = pg.PlotItem() + #plt.scene().addItem(plt.plotItem) + #plt.plotItem.resize(200, 200) + + + plt.scene().minDragTime = 0 # let us simulate mouse drags very quickly. + + # seemingly arbitrary requirements; might need longer wait time for some platforms.. + QtTest.QTest.qWaitForWindowShown(plt) + QtTest.QTest.qWait(100) + + for r, name in rois: + vb.clear() + vb.addItem(r) + vb.autoRange() + app.processEvents() + + assertImageApproved(plt, 'roi/polylineroi/'+name+'_init', 'Init %s polyline.' % name) + initState = r.getState() + assert len(r.getState()['points']) == 3 + + # hover over center + center = r.mapToScene(pg.Point(3, 3)) + mouseMove(plt, center) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_roi', 'Hover mouse over center of ROI.') + + # drag ROI + mouseDrag(plt, center, center + pg.Point(10, -10), QtCore.Qt.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_roi', 'Drag mouse over center of ROI.') + + # hover over handle + pt = r.mapToScene(pg.Point(r.getState()['points'][2])) + mouseMove(plt, pt) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_handle', 'Hover mouse over handle.') + + # drag handle + mouseDrag(plt, pt, pt + pg.Point(5, 20), QtCore.Qt.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_handle', 'Drag mouse over handle.') + + # hover over segment + pt = r.mapToScene((pg.Point(r.getState()['points'][2]) + pg.Point(r.getState()['points'][1])) * 0.5) + mouseMove(plt, pt+pg.Point(0, 2)) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_hover_segment', 'Hover mouse over diagonal segment.') + + # click segment + mouseClick(plt, pt, QtCore.Qt.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_click_segment', 'Click mouse over segment.') + + r.clearPoints() + assertImageApproved(plt, 'roi/polylineroi/'+name+'_clear', 'All points cleared.') + assert len(r.getState()['points']) == 0 + + r.setPoints(initState['points']) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_setpoints', 'Reset points to initial state.') + assert len(r.getState()['points']) == 3 + + r.setState(initState) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_setstate', 'Reset ROI to initial state.') + assert len(r.getState()['points']) == 3 + + \ No newline at end of file diff --git a/pyqtgraph/tests/image_testing.py b/pyqtgraph/tests/image_testing.py index 5d05c2c3..a2b20ee7 100644 --- a/pyqtgraph/tests/image_testing.py +++ b/pyqtgraph/tests/image_testing.py @@ -40,8 +40,9 @@ Procedure for unit-testing with images: # This is the name of a tag in the test-data repository that this version of # pyqtgraph should be tested against. When adding or changing test images, -# create and push a new tag and update this variable. -testDataTag = 'test-data-3' +# create and push a new tag and update this variable. To test locally, begin +# by creating the tag in your ~/.pyqtgraph/test-data repository. +testDataTag = 'test-data-4' import time @@ -58,7 +59,7 @@ if sys.version[0] >= '3': else: import httplib import urllib -from ..Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore, QtTest from .. import functions as fn from .. import GraphicsLayoutWidget from .. import ImageItem, TextItem @@ -105,11 +106,19 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): """ if isinstance(image, QtGui.QWidget): w = image + + # just to be sure the widget size is correct (new window may be resized): + QtGui.QApplication.processEvents() + + graphstate = scenegraphState(w, standardFile) image = np.zeros((w.height(), w.width(), 4), dtype=np.ubyte) qimg = fn.makeQImage(image, alpha=True, copy=False, transpose=False) painter = QtGui.QPainter(qimg) w.render(painter) painter.end() + + # transpose BGRA to RGBA + image = image[..., [2, 1, 0, 3]] if message is None: code = inspect.currentframe().f_back.f_code @@ -144,9 +153,12 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): " different than standard image shape %s." % (ims1, ims2)) sr = np.round(sr).astype(int) - image = downsample(image, sr[0], axis=(0, 1)).astype(image.dtype) + image = fn.downsample(image, sr[0], axis=(0, 1)).astype(image.dtype) assertImageMatch(image, stdImage, **kwargs) + + if bool(os.getenv('PYQTGRAPH_PRINT_TEST_STATE', False)): + print(graphstate) except Exception: if stdFileName in gitStatus(dataPath): print("\n\nWARNING: unit test failed against modified standard " @@ -159,7 +171,7 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): print('Saving new standard image to "%s"' % stdFileName) if not os.path.isdir(stdPath): os.makedirs(stdPath) - img = fn.makeQImage(image, alpha=True, copy=False, transpose=False) + img = fn.makeQImage(image, alpha=True, transpose=False) img.save(stdFileName) else: if stdImage is None: @@ -168,6 +180,7 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): else: if os.getenv('TRAVIS') is not None: saveFailedTest(image, stdImage, standardFile) + print(graphstate) raise @@ -231,7 +244,7 @@ def assertImageMatch(im1, im2, minCorr=None, pxThreshold=50., def saveFailedTest(data, expect, filename): """Upload failed test images to web server to allow CI test debugging. """ - commit, error = runSubprocess(['git', 'rev-parse', 'HEAD']) + commit = runSubprocess(['git', 'rev-parse', 'HEAD']) name = filename.split('/') name.insert(-1, commit.strip()) filename = '/'.join(name) @@ -272,9 +285,9 @@ def makePng(img): """Given an array like (H, W, 4), return a PNG-encoded byte string. """ io = QtCore.QBuffer() - qim = fn.makeQImage(img, alpha=False) - qim.save(io, format='png') - png = io.data().data().encode() + qim = fn.makeQImage(img.transpose(1, 0, 2), alpha=False) + qim.save(io, 'PNG') + png = bytes(io.data().data()) return png @@ -303,7 +316,7 @@ class ImageTester(QtGui.QWidget): QtGui.QWidget.__init__(self) self.resize(1200, 800) - self.showFullScreen() + #self.showFullScreen() self.layout = QtGui.QGridLayout() self.setLayout(self.layout) @@ -321,6 +334,8 @@ class ImageTester(QtGui.QWidget): self.failBtn = QtGui.QPushButton('Fail') self.layout.addWidget(self.passBtn, 2, 0) self.layout.addWidget(self.failBtn, 2, 1) + self.passBtn.clicked.connect(self.passTest) + self.failBtn.clicked.connect(self.failTest) self.views = (self.view.addViewBox(row=0, col=0), self.view.addViewBox(row=0, col=1), @@ -383,6 +398,12 @@ class ImageTester(QtGui.QWidget): else: self.lastKey = str(event.text()).lower() + def passTest(self): + self.lastKey = 'p' + + def failTest(self): + self.lastKey = 'f' + def getTestDataRepo(): """Return the path to a git repository with the required commit checked @@ -531,3 +552,35 @@ def runSubprocess(command, return_code=False, **kwargs): raise sp.CalledProcessError(p.returncode, command) return output + + +def scenegraphState(view, name): + """Return information about the scenegraph for debugging test failures. + """ + state = "====== Scenegraph state for %s ======\n" % name + state += "view size: %dx%d\n" % (view.width(), view.height()) + state += "view transform:\n" + indent(transformStr(view.transform()), " ") + for item in view.scene().items(): + if item.parentItem() is None: + state += itemState(item) + '\n' + return state + + +def itemState(root): + state = str(root) + '\n' + from .. import ViewBox + state += 'bounding rect: ' + str(root.boundingRect()) + '\n' + if isinstance(root, ViewBox): + state += "view range: " + str(root.viewRange()) + '\n' + state += "transform:\n" + indent(transformStr(root.transform()).strip(), " ") + '\n' + for item in root.childItems(): + state += indent(itemState(item).strip(), " ") + '\n' + return state + + +def transformStr(t): + return ("[%0.2f %0.2f %0.2f]\n"*3) % (t.m11(), t.m12(), t.m13(), t.m21(), t.m22(), t.m23(), t.m31(), t.m32(), t.m33()) + + +def indent(s, pfx): + return '\n'.join([pfx+line for line in s.split('\n')])