Merge pull request #349 from campagnola/roi-tests

Roi tests
This commit is contained in:
Luke Campagnola 2016-08-27 15:59:53 -07:00 committed by GitHub
commit 95b2acb027
7 changed files with 443 additions and 119 deletions

View File

@ -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

View File

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

View File

@ -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

View File

@ -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 <pyqtgraph.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 <pyqtgraph.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:

View File

@ -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']

View File

@ -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

View File

@ -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')])