GraphicsItem.py 18.6 KB
Newer Older
1
2
3
from pyqtgraph.Qt import QtGui, QtCore  
from pyqtgraph.GraphicsScene import GraphicsScene
from pyqtgraph.Point import Point
Luke Campagnola's avatar
Luke Campagnola committed
4
import pyqtgraph.functions as fn
5
import weakref
Luke Campagnola's avatar
Luke Campagnola committed
6
import operator
7

Luke Campagnola's avatar
Luke Campagnola committed
8
class GraphicsItem(object):
9
    """
Luke Campagnola's avatar
Luke Campagnola committed
10
11
12
13
    **Bases:** :class:`object`

    Abstract class providing useful methods to GraphicsObject and GraphicsWidget.
    (This is required because we cannot have multiple inheritance with QObject subclasses.)
14
15
16
17

    A note about Qt's GraphicsView framework:

    The GraphicsView system places a lot of emphasis on the notion that the graphics within the scene should be device independent--you should be able to take the same graphics and display them on screens of different resolutions, printers, export to SVG, etc. This is nice in principle, but causes me a lot of headache in practice. It means that I have to circumvent all the device-independent expectations any time I want to operate in pixel coordinates rather than arbitrary scene coordinates. A lot of the code in GraphicsItem is devoted to this task--keeping track of view widgets and device transforms, computing the size and shape of a pixel in local item coordinates, etc. Note that in item coordinates, a pixel does not have to be square or even rectangular, so just asking how to increase a bounding rect by 2px can be a rather complex task.
18
    """
Luke Campagnola's avatar
Luke Campagnola committed
19
    def __init__(self, register=True):
20
21
22
23
24
25
26
27
        if not hasattr(self, '_qtBaseClass'):
            for b in self.__class__.__bases__:
                if issubclass(b, QtGui.QGraphicsItem):
                    self.__class__._qtBaseClass = b
                    break
        if not hasattr(self, '_qtBaseClass'):
            raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self))
        
28
29
        self._viewWidget = None
        self._viewBox = None
30
        self._connectedView = None
31
        self._exportOpts = False   ## If False, not currently exporting. Otherwise, contains dict of export options.
Luke Campagnola's avatar
Luke Campagnola committed
32
33
        if register:
            GraphicsScene.registerObject(self)  ## workaround for pyqt bug in graphicsscene.items()
34
                    
35
36
37

                    
                    
38
39
40
41
42
43
44
45
46
47
48
49
50
51
    def getViewWidget(self):
        """
        Return the view widget for this item. If the scene has multiple views, only the first view is returned.
        The return value is cached; clear the cached value with forgetViewWidget()
        """
        if self._viewWidget is None:
            scene = self.scene()
            if scene is None:
                return None
            views = scene.views()
            if len(views) < 1:
                return None
            self._viewWidget = weakref.ref(self.scene().views()[0])
        return self._viewWidget()
52
    
53
54
    def forgetViewWidget(self):
        self._viewWidget = None
55
    
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
    def getViewBox(self):
        """
        Return the first ViewBox or GraphicsView which bounds this item's visible space.
        If this item is not contained within a ViewBox, then the GraphicsView is returned.
        If the item is contained inside nested ViewBoxes, then the inner-most ViewBox is returned.
        The result is cached; clear the cache with forgetViewBox()
        """
        if self._viewBox is None:
            p = self
            while True:
                p = p.parentItem()
                if p is None:
                    vb = self.getViewWidget()
                    if vb is None:
                        return None
                    else:
                        self._viewBox = weakref.ref(vb)
                        break
                if hasattr(p, 'implements') and p.implements('ViewBox'):
                    self._viewBox = weakref.ref(p)
                    break
        return self._viewBox()  ## If we made it this far, _viewBox is definitely not None

    def forgetViewBox(self):
        self._viewBox = None
        
        
    def deviceTransform(self, viewportTransform=None):
        """
        Return the transform that converts local item coordinates to device coordinates (usually pixels).
        Extends deviceTransform to automatically determine the viewportTransform.
        """
88
89
90
        if self._exportOpts is not False and 'painter' in self._exportOpts: ## currently exporting; device transform may be different.
            return self._exportOpts['painter'].deviceTransform()
            
91
92
93
94
95
        if viewportTransform is None:
            view = self.getViewWidget()
            if view is None:
                return None
            viewportTransform = view.viewportTransform()
Luke Campagnola's avatar
Luke Campagnola committed
96
        dt = self._qtBaseClass.deviceTransform(self, viewportTransform)
97
98
99
100
101
102
103
104
        
        #xmag = abs(dt.m11())+abs(dt.m12())
        #ymag = abs(dt.m21())+abs(dt.m22())
        #if xmag * ymag == 0: 
        if dt.determinant() == 0:  ## occurs when deviceTransform is invalid because widget has not been displayed
            return None
        else:
            return dt
105
106
107
108
109
110
111
112
113
        
    def viewTransform(self):
        """Return the transform that maps from local coordinates to the item's ViewBox coordinates
        If there is no ViewBox, return the scene transform.
        Returns None if the item does not have a view."""
        view = self.getViewBox()
        if view is None:
            return None
        if hasattr(view, 'implements') and view.implements('ViewBox'):
114
115
116
117
            tr = self.itemTransform(view.innerSceneItem())
            if isinstance(tr, tuple):
                tr = tr[0]   ## difference between pyside and pyqt
            return tr
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
        else:
            return self.sceneTransform()
            #return self.deviceTransform(view.viewportTransform())



    def getBoundingParents(self):
        """Return a list of parents to this item that have child clipping enabled."""
        p = self
        parents = []
        while True:
            p = p.parentItem()
            if p is None:
                break
            if p.flags() & self.ItemClipsChildrenToShape:
                parents.append(p)
        return parents
    
    def viewRect(self):
        """Return the bounds (in item coordinates) of this item's ViewBox or GraphicsWidget"""
        view = self.getViewBox()
        if view is None:
            return None
141
142
143
144
145
        bounds = self.mapRectFromView(view.viewRect())
        if bounds is None:
            return None

        bounds = bounds.normalized()
146
147
148
149
150
151
152
153
154
        
        ## nah.
        #for p in self.getBoundingParents():
            #bounds &= self.mapRectFromScene(p.sceneBoundingRect())
            
        return bounds
        
        
        
155
156
157
158

    def pixelVectors(self, direction=None):
        """Return vectors in local coordinates representing the width and height of a view pixel.
        If direction is specified, then return vectors parallel and orthogonal to it.
159
        
Luke Campagnola's avatar
Luke Campagnola committed
160
161
162
        Return (None, None) if pixel size is not yet defined (usually because the item has not yet been displayed)
        or if pixel size is below floating-point precision limit.
        """
163

164
165
        dt = self.deviceTransform()
        if dt is None:
166
167
168
            return None, None
        
        if direction is None:
Luke Campagnola's avatar
Luke Campagnola committed
169
170
171
            direction = Point(1, 0)  
        if direction.manhattanLength() == 0:
            raise Exception("Cannot compute pixel length for 0-length vector.")
172
            
Luke Campagnola's avatar
Luke Campagnola committed
173
174
175
        ## attempt to re-scale direction vector to fit within the precision of the coordinate system
        if direction.x() == 0:
            r = abs(dt.m32())/(abs(dt.m12()) + abs(dt.m22()))
176
            #r = 1.0/(abs(dt.m12()) + abs(dt.m22()))
Luke Campagnola's avatar
Luke Campagnola committed
177
178
        elif direction.y() == 0:
            r = abs(dt.m31())/(abs(dt.m11()) + abs(dt.m21()))
179
            #r = 1.0/(abs(dt.m11()) + abs(dt.m21()))
Luke Campagnola's avatar
Luke Campagnola committed
180
181
        else:
            r = ((abs(dt.m32())/(abs(dt.m12()) + abs(dt.m22()))) * (abs(dt.m31())/(abs(dt.m11()) + abs(dt.m21()))))**0.5
182
        directionr = direction * r
Luke Campagnola's avatar
Luke Campagnola committed
183
        
184
        viewDir = Point(dt.map(directionr) - dt.map(Point(0,0)))
Luke Campagnola's avatar
Luke Campagnola committed
185
186
187
        if viewDir.manhattanLength() == 0:
            return None, None   ##  pixel size cannot be represented on this scale
            
188
189
190
191
192
193
        orthoDir = Point(viewDir[1], -viewDir[0])  ## orthogonal to line in pixel-space
        
        try:  
            normView = viewDir.norm()  ## direction of one pixel orthogonal to line
            normOrtho = orthoDir.norm()
        except:
194
            raise Exception("Invalid direction %s" %directionr)
195
196
            
        
Luke Campagnola's avatar
Luke Campagnola committed
197
        dti = fn.invertQTransform(dt)
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
        return Point(dti.map(normView)-dti.map(Point(0,0))), Point(dti.map(normOrtho)-dti.map(Point(0,0)))  
    
        #vt = self.deviceTransform()
        #if vt is None:
            #return None
        #vt = vt.inverted()[0]
        #orig = vt.map(QtCore.QPointF(0, 0))
        #return vt.map(QtCore.QPointF(1, 0))-orig, vt.map(QtCore.QPointF(0, 1))-orig
        
    def pixelLength(self, direction, ortho=False):
        """Return the length of one pixel in the direction indicated (in local coordinates)
        If ortho=True, then return the length of one pixel orthogonal to the direction indicated.
        
        Return None if pixel size is not yet defined (usually because the item has not yet been displayed).
        """
        normV, orthoV = self.pixelVectors(direction)
        if normV == None or orthoV == None:
            return None
        if ortho:
            return orthoV.length()
        return normV.length()
        
220
221
222
        

    def pixelSize(self):
Luke Campagnola's avatar
Luke Campagnola committed
223
        ## deprecated
224
        v = self.pixelVectors()
225
226
        if v == (None, None):
            return None, None
227
228
229
        return (v[0].x()**2+v[0].y()**2)**0.5, (v[1].x()**2+v[1].y()**2)**0.5

    def pixelWidth(self):
Luke Campagnola's avatar
Luke Campagnola committed
230
        ## deprecated
231
232
233
        vt = self.deviceTransform()
        if vt is None:
            return 0
Luke Campagnola's avatar
Luke Campagnola committed
234
        vt = fn.invertQTransform(vt)
235
236
237
        return Point(vt.map(QtCore.QPointF(1, 0))-vt.map(QtCore.QPointF(0, 0))).length()
        
    def pixelHeight(self):
Luke Campagnola's avatar
Luke Campagnola committed
238
        ## deprecated
239
240
241
        vt = self.deviceTransform()
        if vt is None:
            return 0
Luke Campagnola's avatar
Luke Campagnola committed
242
        vt = fn.invertQTransform(vt)
243
244
245
246
247
248
        return Point(vt.map(QtCore.QPointF(0, 1))-vt.map(QtCore.QPointF(0, 0))).length()
        
        
    def mapToDevice(self, obj):
        """
        Return *obj* mapped from local coordinates to device coordinates (pixels).
249
        If there is no device mapping available, return None.
250
251
252
253
254
255
256
257
258
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        return vt.map(obj)
        
    def mapFromDevice(self, obj):
        """
        Return *obj* mapped from device coordinates (pixels) to local coordinates.
259
        If there is no device mapping available, return None.
260
261
262
263
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
Luke Campagnola's avatar
Luke Campagnola committed
264
        vt = fn.invertQTransform(vt)
265
266
        return vt.map(obj)

267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
    def mapRectToDevice(self, rect):
        """
        Return *rect* mapped from local coordinates to device coordinates (pixels).
        If there is no device mapping available, return None.
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        return vt.mapRect(rect)

    def mapRectFromDevice(self, rect):
        """
        Return *rect* mapped from device coordinates (pixels) to local coordinates.
        If there is no device mapping available, return None.
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
Luke Campagnola's avatar
Luke Campagnola committed
285
        vt = fn.invertQTransform(vt)
286
287
        return vt.mapRect(rect)
    
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
    def mapToView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        return vt.map(obj)
        
    def mapRectToView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        return vt.mapRect(obj)
        
    def mapFromView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
Luke Campagnola's avatar
Luke Campagnola committed
304
        vt = fn.invertQTransform(vt)
305
306
307
308
309
310
        return vt.map(obj)

    def mapRectFromView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
Luke Campagnola's avatar
Luke Campagnola committed
311
        vt = fn.invertQTransform(vt)
312
313
314
        return vt.mapRect(obj)

    def pos(self):
Luke Campagnola's avatar
Luke Campagnola committed
315
        return Point(self._qtBaseClass.pos(self))
316
317
    
    def viewPos(self):
Luke Campagnola's avatar
Luke Campagnola committed
318
        return self.mapToView(self.mapFromParent(self.pos()))
319
320
321
    
    def parentItem(self):
        ## PyQt bug -- some items are returned incorrectly.
Luke Campagnola's avatar
Luke Campagnola committed
322
        return GraphicsScene.translateGraphicsItem(self._qtBaseClass.parentItem(self))
323
        
Luke Campagnola's avatar
Luke Campagnola committed
324
325
326
327
328
329
330
    def setParentItem(self, parent):
        ## Workaround for Qt bug: https://bugreports.qt-project.org/browse/QTBUG-18616
        if parent is not None:
            pscene = parent.scene()
            if pscene is not None and self.scene() is not pscene:
                pscene.addItem(self)
        return self._qtBaseClass.setParentItem(self, parent)
331
332
333
    
    def childItems(self):
        ## PyQt bug -- some child items are returned incorrectly.
Luke Campagnola's avatar
Luke Campagnola committed
334
        return list(map(GraphicsScene.translateGraphicsItem, self._qtBaseClass.childItems(self)))
335
336
337
338
339
340
341
342
343


    def sceneTransform(self):
        ## Qt bug: do no allow access to sceneTransform() until 
        ## the item has a scene.
        
        if self.scene() is None:
            return self.transform()
        else:
Luke Campagnola's avatar
Luke Campagnola committed
344
            return self._qtBaseClass.sceneTransform(self)
345
346
347
348
349
350
351
352
353


    def transformAngle(self, relativeItem=None):
        """Return the rotation produced by this item's transform (this assumes there is no shear in the transform)
        If relativeItem is given, then the angle is determined relative to that item.
        """
        if relativeItem is None:
            relativeItem = self.parentItem()
            
354
355
356
357

        tr = self.itemTransform(relativeItem)
        if isinstance(tr, tuple):  ## difference between pyside and pyqt
            tr = tr[0]  
358
359
360
361
        vec = tr.map(Point(1,0)) - tr.map(Point(0,0))
        return Point(vec).angle(Point(1,0))
        
        
362
    #def itemChange(self, change, value):
Luke Campagnola's avatar
Luke Campagnola committed
363
        #ret = self._qtBaseClass.itemChange(self, change, value)
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
        #if change == self.ItemParentHasChanged or change == self.ItemSceneHasChanged:
            #print "Item scene changed:", self
            #self.setChildScene(self)  ## This is bizarre.
        #return ret

    #def setChildScene(self, ch):
        #scene = self.scene()
        #for ch2 in ch.childItems():
            #if ch2.scene() is not scene:
                #print "item", ch2, "has different scene:", ch2.scene(), scene
                #scene.addItem(ch2)
                #QtGui.QApplication.processEvents()
                #print "   --> ", ch2.scene()
            #self.setChildScene(ch2)

    def _updateView(self):
        ## called to see whether this item has a new view to connect to
        ## NOTE: This is called from GraphicsObject.itemChange or GraphicsWidget.itemChange.

        ## It is possible this item has moved to a different ViewBox or widget;
        ## clear out previously determined references to these.
        self.forgetViewBox()
        self.forgetViewWidget()
387
        
388
389
        ## check for this item's current viewbox or view widget
        view = self.getViewBox()
390
391
392
        #if view is None:
            ##print "  no view"
            #return
393

394
395
396
397
398
        oldView = None
        if self._connectedView is not None:
            oldView = self._connectedView()
            
        if view is oldView:
399
400
401
402
            #print "  already have view", view
            return

        ## disconnect from previous view
403
404
        if oldView is not None:
            #print "disconnect:", self, oldView
Luke Campagnola's avatar
Luke Campagnola committed
405
406
407
408
409
410
411
412
413
414
            try:
                oldView.sigRangeChanged.disconnect(self.viewRangeChanged)
            except TypeError:
                pass
            
            try:
                oldView.sigTransformChanged.disconnect(self.viewTransformChanged)
            except TypeError:
                pass
            
415
            self._connectedView = None
416
417

        ## connect to new view
418
419
420
        if view is not None:
            #print "connect:", self, view
            view.sigRangeChanged.connect(self.viewRangeChanged)
421
            view.sigTransformChanged.connect(self.viewTransformChanged)
422
423
            self._connectedView = weakref.ref(view)
            self.viewRangeChanged()
424
            self.viewTransformChanged()
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
        
        ## inform children that their view might have changed
        self._replaceView(oldView)
        
        
    def _replaceView(self, oldView, item=None):
        if item is None:
            item = self
        for child in item.childItems():
            if isinstance(child, GraphicsItem):
                if child.getViewBox() is oldView:
                    child._updateView()
                        #self._replaceView(oldView, child)
            else:
                self._replaceView(oldView, child)
        
        
442
443
444
445
446
447

    def viewRangeChanged(self):
        """
        Called whenever the view coordinates of the ViewBox containing this item have changed.
        """
        pass
448
449
450
451
452
    
    def viewTransformChanged(self):
        """
        Called whenever the transformation matrix of the view has changed.
        """
453
454
455
456
457
458
459
460
461
462
463
464
465
466
        pass
    
    #def prepareGeometryChange(self):
        #self._qtBaseClass.prepareGeometryChange(self)
        #self.informViewBoundsChanged()
        
    def informViewBoundsChanged(self):
        """
        Inform this item's container ViewBox that the bounds of this item have changed.
        This is used by ViewBox to react if auto-range is enabled.
        """
        view = self.getViewBox()
        if view is not None and hasattr(view, 'implements') and view.implements('ViewBox'):
            view.itemBoundsChanged(self)  ## inform view so it can update its range if it wants
Luke Campagnola's avatar
Luke Campagnola committed
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
    
    def childrenShape(self):
        """Return the union of the shapes of all descendants of this item in local coordinates."""
        childs = self.allChildItems()
        shapes = [self.mapFromItem(c, c.shape()) for c in self.allChildItems()]
        return reduce(operator.add, shapes)
    
    def allChildItems(self, root=None):
        """Return list of the entire item tree descending from this item."""
        if root is None:
            root = self
        tree = []
        for ch in root.childItems():
            tree.append(ch)
            tree.extend(self.allChildItems(ch))
        return tree
    
    
485
    def setExportMode(self, export, opts=None):
486
487
488
489
490
        """
        This method is called by exporters to inform items that they are being drawn for export
        with a specific set of options. Items access these via self._exportOptions.
        When exporting is complete, _exportOptions is set to False.
        """
491
492
        if opts is None:
            opts = {}
493
494
495
496
497
498
        if export:
            self._exportOpts = opts
            #if 'antialias' not in opts:
                #self._exportOpts['antialias'] = True
        else:
            self._exportOpts = False
Luke Campagnola's avatar
Luke Campagnola committed
499