Commit b25e34f5 authored by Luke Campagnola's avatar Luke Campagnola
Browse files

Features:

- Canvas: added per-item context menus
- Isocurve: 
     option to extend curves to array boundaries
     option to generate QPainterPath instead of vertex array
- Isosurface is a bajillion times faster
- ViewBox
     added clear() method
     added locate(item) method (shows where an item is for debugging)

Bugfixes:
- automated example testing working properly
- Exporter gets incorrect source rect when operating on PlotWidget
- Set correct DPI and size for SVG exporter
- GLMeshItem works properly with whole-mesh color specified as sequence
- bugfix in functions.transformCoordinates for rotated matrices
- reload library checks for modules that are imported multiple times
- GraphicsObject, UIGraphicsItem: added workaround for PyQt / itemChange bug
- ScatterPlotItem: disable cached render during export

Other:
- added documentation for several functions
- minor updates to setup.py
...@@ -93,6 +93,15 @@ class Canvas(QtGui.QWidget): ...@@ -93,6 +93,15 @@ class Canvas(QtGui.QWidget):
self.registeredName = CanvasManager.instance().registerCanvas(self, name) self.registeredName = CanvasManager.instance().registerCanvas(self, name)
self.ui.redirectCombo.setHostName(self.registeredName) self.ui.redirectCombo.setHostName(self.registeredName)
self.menu = QtGui.QMenu()
#self.menu.setTitle("Image")
remAct = QtGui.QAction("Remove item", self.menu)
remAct.triggered.connect(self.removeClicked)
self.menu.addAction(remAct)
self.menu.remAct = remAct
self.ui.itemList.contextMenuEvent = self.itemListContextMenuEvent
def storeSvg(self): def storeSvg(self):
self.ui.view.writeSvg() self.ui.view.writeSvg()
...@@ -513,10 +522,20 @@ class Canvas(QtGui.QWidget): ...@@ -513,10 +522,20 @@ class Canvas(QtGui.QWidget):
listItem.setCheckState(0, QtCore.Qt.Unchecked) listItem.setCheckState(0, QtCore.Qt.Unchecked)
def removeItem(self, item): def removeItem(self, item):
if isinstance(item, QtGui.QTreeWidgetItem):
item = item.canvasItem()
if isinstance(item, CanvasItem): if isinstance(item, CanvasItem):
item.setCanvas(None) item.setCanvas(None)
self.itemList.removeTopLevelItem(item.listItem) listItem = item.listItem
listItem.canvasItem = None
item.listItem = None
self.itemList.removeTopLevelItem(listItem)
self.items.remove(item) self.items.remove(item)
ctrl = item.ctrlWidget()
ctrl.hide()
self.ui.ctrlLayout.removeWidget(ctrl)
else: else:
if hasattr(item, '_canvasItem'): if hasattr(item, '_canvasItem'):
self.removeItem(item._canvasItem) self.removeItem(item._canvasItem)
...@@ -555,7 +574,15 @@ class Canvas(QtGui.QWidget): ...@@ -555,7 +574,15 @@ class Canvas(QtGui.QWidget):
#self.emit(QtCore.SIGNAL('itemTransformChangeFinished'), self, item) #self.emit(QtCore.SIGNAL('itemTransformChangeFinished'), self, item)
self.sigItemTransformChangeFinished.emit(self, item) self.sigItemTransformChangeFinished.emit(self, item)
def itemListContextMenuEvent(self, ev):
self.menuItem = self.itemList.itemAt(ev.pos())
self.menu.popup(ev.globalPos())
def removeClicked(self):
self.removeItem(self.menuItem)
self.menuItem = None
import gc
gc.collect()
class SelectBox(ROI): class SelectBox(ROI):
def __init__(self, scalable=False): def __init__(self, scalable=False):
......
...@@ -210,6 +210,9 @@ class ConsoleWidget(QtGui.QWidget): ...@@ -210,6 +210,9 @@ class ConsoleWidget(QtGui.QWidget):
#self.stdout.write(strn) #self.stdout.write(strn)
def displayException(self): def displayException(self):
"""
Display the current exception and stack.
"""
tb = traceback.format_exc() tb = traceback.format_exc()
lines = [] lines = []
indent = 4 indent = 4
......
...@@ -8,7 +8,7 @@ Simple Data Display Functions ...@@ -8,7 +8,7 @@ Simple Data Display Functions
.. autofunction:: pyqtgraph.image .. autofunction:: pyqtgraph.image
.. autofunction:: pyqtgraph.dbg
Color, Pen, and Brush Functions Color, Pen, and Brush Functions
------------------------------- -------------------------------
...@@ -34,6 +34,8 @@ Qt uses the classes QColor, QPen, and QBrush to determine how to draw lines and ...@@ -34,6 +34,8 @@ Qt uses the classes QColor, QPen, and QBrush to determine how to draw lines and
.. autofunction:: pyqtgraph.colorStr .. autofunction:: pyqtgraph.colorStr
.. autofunction:: pyqtgraph.glColor
Data Slicing Data Slicing
------------ ------------
...@@ -41,6 +43,18 @@ Data Slicing ...@@ -41,6 +43,18 @@ Data Slicing
.. autofunction:: pyqtgraph.affineSlice .. autofunction:: pyqtgraph.affineSlice
Coordinate Transformation
-------------------------
.. autofunction:: pyqtgraph.transformToArray
.. autofunction:: pyqtgraph.transformCoordinates
.. autofunction:: pyqtgraph.solve3DTransform
.. autofunction:: pyqtgraph.solveBilinearTransform
SI Unit Conversion Functions SI Unit Conversion Functions
---------------------------- ----------------------------
...@@ -59,6 +73,12 @@ Image Preparation Functions ...@@ -59,6 +73,12 @@ Image Preparation Functions
.. autofunction:: pyqtgraph.makeQImage .. autofunction:: pyqtgraph.makeQImage
.. autofunction:: pyqtgraph.applyLookupTable
.. autofunction:: pyqtgraph.rescaleData
.. autofunction:: pyqtgraph.imageToArray
Mesh Generation Functions Mesh Generation Functions
------------------------- -------------------------
...@@ -68,4 +88,13 @@ Mesh Generation Functions ...@@ -68,4 +88,13 @@ Mesh Generation Functions
.. autofunction:: pyqtgraph.isosurface .. autofunction:: pyqtgraph.isosurface
Miscellaneous Functions
-----------------------
.. autofunction:: pyqtgraph.pseudoScatter
.. autofunction:: pyqtgraph.systemInfo
...@@ -45,9 +45,9 @@ data = np.abs(np.fromfunction(psi, (50,50,100))) ...@@ -45,9 +45,9 @@ data = np.abs(np.fromfunction(psi, (50,50,100)))
print("Generating isosurface..") print("Generating isosurface..")
verts = pg.isosurface(data, data.max()/4.) verts, faces = pg.isosurface(data, data.max()/4.)
md = gl.MeshData(vertexes=verts) md = gl.MeshData(vertexes=verts, faces=faces)
colors = np.ones((md.faceCount(), 4), dtype=float) colors = np.ones((md.faceCount(), 4), dtype=float)
colors[:,3] = 0.2 colors[:,3] = 0.2
......
...@@ -175,8 +175,11 @@ def testFile(name, f, exe, lib): ...@@ -175,8 +175,11 @@ def testFile(name, f, exe, lib):
try: try:
%s %s
import %s import %s
import sys
print("test complete") print("test complete")
sys.stdout.flush()
import pyqtgraph as pg import pyqtgraph as pg
import time
while True: ## run a little event loop while True: ## run a little event loop
pg.QtGui.QApplication.processEvents() pg.QtGui.QApplication.processEvents()
time.sleep(0.01) time.sleep(0.01)
...@@ -186,7 +189,7 @@ except: ...@@ -186,7 +189,7 @@ except:
""" % ("import %s" % lib if lib != '' else "", os.path.splitext(os.path.split(fn)[1])[0]) """ % ("import %s" % lib if lib != '' else "", os.path.splitext(os.path.split(fn)[1])[0])
#print code #print code
process = subprocess.Popen(['%s -i' % (exe)], shell=True, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE) process = subprocess.Popen(['exec %s -i' % (exe)], shell=True, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
process.stdin.write(code.encode('UTF-8')) process.stdin.write(code.encode('UTF-8'))
#process.stdin.close() #process.stdin.close()
output = '' output = ''
...@@ -202,10 +205,11 @@ except: ...@@ -202,10 +205,11 @@ except:
fail = True fail = True
break break
time.sleep(1) time.sleep(1)
process.terminate() process.kill()
#process.wait()
res = process.communicate() res = process.communicate()
#if 'exception' in res[1].lower() or 'error' in res[1].lower():
if fail: if fail or 'exception' in res[1].decode().lower() or 'error' in res[1].decode().lower():
print('.' * (50-len(name)) + 'FAILED') print('.' * (50-len(name)) + 'FAILED')
print(res[0].decode()) print(res[0].decode())
print(res[1].decode()) print(res[1].decode())
......
...@@ -19,6 +19,7 @@ frames = 200 ...@@ -19,6 +19,7 @@ frames = 200
data = np.random.normal(size=(frames,30,30), loc=0, scale=100) data = np.random.normal(size=(frames,30,30), loc=0, scale=100)
data = np.concatenate([data, data], axis=0) data = np.concatenate([data, data], axis=0)
data = ndi.gaussian_filter(data, (10, 10, 10))[frames/2:frames + frames/2] data = ndi.gaussian_filter(data, (10, 10, 10))[frames/2:frames + frames/2]
data[:, 15:16, 15:17] += 1
win = pg.GraphicsWindow() win = pg.GraphicsWindow()
vb = win.addViewBox() vb = win.addViewBox()
......
...@@ -73,7 +73,8 @@ class Exporter(object): ...@@ -73,7 +73,8 @@ class Exporter(object):
def getSourceRect(self): def getSourceRect(self):
if isinstance(self.item, pg.GraphicsScene): if isinstance(self.item, pg.GraphicsScene):
return self.item.getViewWidget().viewRect() w = self.item.getViewWidget()
return w.viewportTransform().inverted()[0].mapRect(w.rect())
else: else:
return self.item.sceneBoundingRect() return self.item.sceneBoundingRect()
......
...@@ -36,11 +36,14 @@ class SVGExporter(Exporter): ...@@ -36,11 +36,14 @@ class SVGExporter(Exporter):
return return
self.svg = QtSvg.QSvgGenerator() self.svg = QtSvg.QSvgGenerator()
self.svg.setFileName(fileName) self.svg.setFileName(fileName)
self.svg.setSize(QtCore.QSize(100,100)) dpi = QtGui.QDesktopWidget().physicalDpiX()
#self.svg.setResolution(600) ## not really sure why this works, but it seems to be important:
self.svg.setSize(QtCore.QSize(self.params['width']*dpi/90., self.params['height']*dpi/90.))
self.svg.setResolution(dpi)
#self.svg.setViewBox() #self.svg.setViewBox()
targetRect = QtCore.QRect(0, 0, self.params['width'], self.params['height']) targetRect = QtCore.QRect(0, 0, self.params['width'], self.params['height'])
sourceRect = self.getSourceRect() sourceRect = self.getSourceRect()
painter = QtGui.QPainter(self.svg) painter = QtGui.QPainter(self.svg)
try: try:
self.setExportMode(True) self.setExportMode(True)
......
...@@ -491,9 +491,6 @@ def transformToArray(tr): ...@@ -491,9 +491,6 @@ def transformToArray(tr):
## map coordinates through transform ## map coordinates through transform
mapped = np.dot(m, coords) mapped = np.dot(m, coords)
""" """
if isinstance(tr, np.ndarray):
return tr
#return np.array([[tr.m11(), tr.m12(), tr.m13()],[tr.m21(), tr.m22(), tr.m23()],[tr.m31(), tr.m32(), tr.m33()]]) #return np.array([[tr.m11(), tr.m12(), tr.m13()],[tr.m21(), tr.m22(), tr.m23()],[tr.m31(), tr.m32(), tr.m33()]])
## The order of elements given by the method names m11..m33 is misleading-- ## The order of elements given by the method names m11..m33 is misleading--
## It is most common for x,y translation to occupy the positions 1,3 and 2,3 in ## It is most common for x,y translation to occupy the positions 1,3 and 2,3 in
...@@ -506,18 +503,28 @@ def transformToArray(tr): ...@@ -506,18 +503,28 @@ def transformToArray(tr):
else: else:
raise Exception("Transform argument must be either QTransform or QMatrix4x4.") raise Exception("Transform argument must be either QTransform or QMatrix4x4.")
def transformCoordinates(tr, coords): def transformCoordinates(tr, coords, transpose=False):
""" """
Map a set of 2D or 3D coordinates through a QTransform or QMatrix4x4. Map a set of 2D or 3D coordinates through a QTransform or QMatrix4x4.
The shape of coords must be (2,...) or (3,...) The shape of coords must be (2,...) or (3,...)
The mapping will _ignore_ any perspective transformations. The mapping will _ignore_ any perspective transformations.
For coordinate arrays with ndim=2, this is basically equivalent to matrix multiplication.
Most arrays, however, prefer to put the coordinate axis at the end (eg. shape=(...,3)). To
allow this, use transpose=True.
""" """
if transpose:
## move last axis to beginning. This transposition will be reversed before returning the mapped coordinates.
coords = coords.transpose((coords.ndim-1,) + tuple(range(0,coords.ndim-1)))
nd = coords.shape[0] nd = coords.shape[0]
if not isinstance(tr, np.ndarray): if isinstance(tr, np.ndarray):
m = tr
else:
m = transformToArray(tr) m = transformToArray(tr)
m = m[:m.shape[0]-1] # remove perspective m = m[:m.shape[0]-1] # remove perspective
else:
m = tr
## If coords are 3D and tr is 2D, assume no change for Z axis ## If coords are 3D and tr is 2D, assume no change for Z axis
if m.shape == (2,3) and nd == 3: if m.shape == (2,3) and nd == 3:
...@@ -545,9 +552,15 @@ def transformCoordinates(tr, coords): ...@@ -545,9 +552,15 @@ def transformCoordinates(tr, coords):
## map coordinates and return ## map coordinates and return
mapped = (m*coords).sum(axis=1) ## apply scale/rotate mapped = (m*coords).sum(axis=1) ## apply scale/rotate
mapped += translate mapped += translate
if transpose:
## move first axis to end.
mapped = mapped.transpose(tuple(range(1,mapped.ndim)) + (0,))
return mapped return mapped
def solve3DTransform(points1, points2): def solve3DTransform(points1, points2):
""" """
Find a 3D transformation matrix that maps points1 onto points2 Find a 3D transformation matrix that maps points1 onto points2
...@@ -782,7 +795,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): ...@@ -782,7 +795,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False):
if levels.shape != (data.shape[-1], 2): if levels.shape != (data.shape[-1], 2):
raise Exception('levels must have shape (data.shape[-1], 2)') raise Exception('levels must have shape (data.shape[-1], 2)')
else: else:
print(levels) print levels
raise Exception("levels argument must be 1D or 2D.") raise Exception("levels argument must be 1D or 2D.")
#levels = np.array(levels) #levels = np.array(levels)
#if levels.shape == (2,): #if levels.shape == (2,):
...@@ -1066,16 +1079,43 @@ def imageToArray(img, copy=False, transpose=True): ...@@ -1066,16 +1079,43 @@ def imageToArray(img, copy=False, transpose=True):
#return facets #return facets
def isocurve(data, level): def isocurve(data, level, connected=False, extendToEdge=False, path=False):
""" """
Generate isocurve from 2D data using marching squares algorithm. Generate isocurve from 2D data using marching squares algorithm.
*data* 2D numpy array of scalar values ============= =========================================================
*level* The level at which to generate an isosurface Arguments
data 2D numpy array of scalar values
level The level at which to generate an isosurface
connected If False, return a single long list of point pairs
If True, return multiple long lists of connected point
locations. (This is slower but better for drawing
continuous lines)
extendToEdge If True, extend the curves to reach the exact edges of
the data.
path if True, return a QPainterPath rather than a list of
vertex coordinates. This forces connected=True.
============= =========================================================
This function is SLOW; plenty of room for optimization here. This function is SLOW; plenty of room for optimization here.
""" """
if path is True:
connected = True
if extendToEdge:
d2 = np.empty((data.shape[0]+2, data.shape[1]+2), dtype=data.dtype)
d2[1:-1, 1:-1] = data
d2[0, 1:-1] = data[0]
d2[-1, 1:-1] = data[-1]
d2[1:-1, 0] = data[:, 0]
d2[1:-1, -1] = data[:, -1]
d2[0,0] = d2[0,1]
d2[0,-1] = d2[1,-1]
d2[-1,0] = d2[-1,1]
d2[-1,-1] = d2[-1,-2]
data = d2
sideTable = [ sideTable = [
[], [],
[0,1], [0,1],
...@@ -1096,7 +1136,7 @@ def isocurve(data, level): ...@@ -1096,7 +1136,7 @@ def isocurve(data, level):
] ]
edgeKey=[ edgeKey=[
[(0,1),(0,0)], [(0,1), (0,0)],
[(0,0), (1,0)], [(0,0), (1,0)],
[(1,0), (1,1)], [(1,0), (1,1)],
[(1,1), (0,1)] [(1,1), (0,1)]
...@@ -1140,31 +1180,150 @@ def isocurve(data, level): ...@@ -1140,31 +1180,150 @@ def isocurve(data, level):
p1[0]*fi + p2[0]*f + i + 0.5, p1[0]*fi + p2[0]*f + i + 0.5,
p1[1]*fi + p2[1]*f + j + 0.5 p1[1]*fi + p2[1]*f + j + 0.5
) )
if extendToEdge:
## check bounds
p = (
min(data.shape[0]-2, max(0, p[0]-1)),
min(data.shape[1]-2, max(0, p[1]-1)),
)
if connected:
gridKey = i + (1 if edges[m]==2 else 0), j + (1 if edges[m]==3 else 0), edges[m]%2
pts.append((p, gridKey)) ## give the actual position and a key identifying the grid location (for connecting segments)
else:
pts.append(p) pts.append(p)
lines.append(pts) lines.append(pts)
if not connected:
return lines
## turn disjoint list of segments into continuous lines
#lines = [[2,5], [5,4], [3,4], [1,3], [6,7], [7,8], [8,6], [11,12], [12,15], [11,13], [13,14]]
#lines = [[(float(a), a), (float(b), b)] for a,b in lines]
points = {} ## maps each point to its connections
for a,b in lines:
if a[1] not in points:
points[a[1]] = []
points[a[1]].append([a,b])
if b[1] not in points:
points[b[1]] = []
points[b[1]].append([b,a])
## rearrange into chains
for k in points.keys():
try:
chains = points[k]
except KeyError: ## already used this point elsewhere
continue
#print "===========", k
for chain in chains:
#print " chain:", chain
x = None
while True:
if x == chain[-1][1]:
break ## nothing left to do on this chain
x = chain[-1][1]
if x == k:
break ## chain has looped; we're done and can ignore the opposite chain
y = chain[-2][1]
connects = points[x]
for conn in connects[:]:
if conn[1][1] != y:
#print " ext:", conn
chain.extend(conn[1:])
#print " del:", x
del points[x]
if chain[0][1] == chain[-1][1]: # looped chain; no need to continue the other direction
chains.pop()
break
## extract point locations
lines = []
for chain in points.values():
if len(chain) == 2:
chain = chain[1][1:][::-1] + chain[0] # join together ends of chain
else:
chain = chain[0]
lines.append([p[0] for p in chain])
if not path:
return lines ## a list of pairs of points return lines ## a list of pairs of points
path = QtGui.QPainterPath()
for line in lines:
path.moveTo(*line[0])
for p in line[1:]:
path.lineTo(*p)
return path
def traceImage(image, values, smooth=0.5):
"""
Convert an image to a set of QPainterPath curves.
One curve will be generated for each item in *values*; each curve outlines the area
of the image that is closer to its value than to any others.
If image is RGB or RGBA, then the shape of values should be (nvals, 3/4)
The parameter *smooth* is expressed in pixels.
"""
import scipy.ndimage as ndi
if values.ndim == 2:
values = values.T
values = values[np.newaxis, np.newaxis, ...].astype(float)
image = image[..., np.newaxis].astype(float)
diff = np.abs(image-values)
if values.ndim == 4:
diff = diff.sum(axis=2)
labels = np.argmin(diff, axis=2)
paths = []
for i in range(diff.shape[-1]):
d = (labels==i).astype(float)
d = ndi.gaussian_filter(d, (smooth, smooth))
lines = isocurve(d, 0.5, connected=True, extendToEdge=True)
path = QtGui.QPainterPath()
for line in lines:
path.moveTo(*line[0])
for p in line[1:]:
path.lineTo(*p)
paths.append(path)
return paths
IsosurfaceDataCache = None
def isosurface(data, level): def isosurface(data, level):
""" """
Generate isosurface from volumetric data using marching cubes algorithm. Generate isosurface from volumetric data using marching cubes algorithm.
See Paul Bourke, "Polygonising a Scalar Field" See Paul Bourke, "Polygonising a Scalar Field"
(http://local.wasp.uwa.edu.au/~pbourke/geometry/polygonise/) (http://paulbourke.net/geometry/polygonise/)
*data* 3D numpy array of scalar values *data* 3D numpy array of scalar values
*level* The level at which to generate an isosurface *level* The level at which to generate an isosurface
Returns an array of vertex coordinates (N, 3, 3); Returns an array of vertex coordinates (Nv, 3) and an array of
per-face vertex indexes (Nf, 3)
This function is SLOW; plenty of room for optimization here.
""" """
## For improvement, see:
##
## Efficient implementation of Marching Cubes' cases with topological guarantees.
## Thomas Lewiner, Helio Lopes, Antonio Wilson Vieira and Geovan Tavares.
## Journal of Graphics Tools 8(2): pp. 1-15 (december 2003)