File: RemoteGraphicsView.py

package info (click to toggle)
python-pyqtgraph 0.13.7-6
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 8,072 kB
  • sloc: python: 54,043; makefile: 127; ansic: 40; sh: 2
file content (323 lines) | stat: -rw-r--r-- 12,195 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
from ..Qt import QT_LIB, QtCore, QtGui, QtWidgets

import atexit
import enum
import mmap
import os
import sys
import tempfile

from .. import Qt
from .. import CONFIG_OPTIONS
from .. import multiprocess as mp
from .GraphicsView import GraphicsView

__all__ = ['RemoteGraphicsView']


def serialize_mouse_enum(*args):
    # PySide6 (opt-in in 6.3.1) and PyQt6
    # - implemented as python enums
    # - can pickle enums and flags
    # - PyQt6 cannot cast to int
    # PyQt5 5.12, PyQt5 5.15, PySide2 5.15, PySide6 can pickle enums but not flags
    # PySide2 5.12 cannot pickle enums nor flags
    # MouseButtons and KeyboardModifiers are flags
    return [x if isinstance(x, enum.Enum) else int(x) for x in args]


class MouseEvent(QtGui.QMouseEvent):
    @staticmethod
    def get_state(obj, picklable=False):
        typ = obj.type()
        if isinstance(typ, int):
            # PyQt6 returns an int here instead of QEvent.Type,
            # but its QtGui.QMouseEvent constructor takes only QEvent.Type.
            # Note however that its QtCore.QEvent constructor accepts both
            # QEvent.Type and int.
            typ = QtCore.QEvent.Type(typ)
        lpos = obj.position() if hasattr(obj, 'position') else obj.localPos()
        gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.screenPos()
        btn, btns, mods = obj.button(), obj.buttons(), obj.modifiers()
        if picklable:
            typ, btn, btns, mods = serialize_mouse_enum(typ, btn, btns, mods)
        return typ, lpos, gpos, btn, btns, mods

    def __init__(self, rhs):
        super().__init__(*self.get_state(rhs))

    def __getstate__(self):
        return self.get_state(self, picklable=True)

    def __setstate__(self, state):
        typ, lpos, gpos, btn, btns, mods = state
        typ = QtCore.QEvent.Type(typ)
        btn = QtCore.Qt.MouseButton(btn)
        if not isinstance(btns, enum.Enum):
            btns = QtCore.Qt.MouseButtons(btns)
        if not isinstance(mods, enum.Enum):
            mods = QtCore.Qt.KeyboardModifiers(mods)
        super().__init__(typ, lpos, gpos, btn, btns, mods)


class WheelEvent(QtGui.QWheelEvent):
    @staticmethod
    def get_state(obj, picklable=False):
        # {PyQt6, PySide6}      have position()
        # {PyQt5, PySide2} 5.15 have position()
        # {PyQt5, PySide2} 5.15 have posF() (contrary to C++ docs)
        # {PyQt5, PySide2} 5.12 have posF()
        lpos = obj.position() if hasattr(obj, 'position') else obj.posF()
        gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.globalPosF()
        pixdel, angdel, btns = obj.pixelDelta(), obj.angleDelta(), obj.buttons()
        mods, phase, inverted = obj.modifiers(), obj.phase(), obj.inverted()
        if picklable:
            btns, mods, phase = serialize_mouse_enum(btns, mods, phase)
        return lpos, gpos, pixdel, angdel, btns, mods, phase, inverted

    def __init__(self, rhs):
        items = list(self.get_state(rhs))
        items[1] = items[0]     # gpos = lpos
        super().__init__(*items)

    def __getstate__(self):
        return self.get_state(self, picklable=True)

    def __setstate__(self, state):
        pos, gpos, pixdel, angdel, btns, mods, phase, inverted = state
        if not isinstance(btns, enum.Enum):
            btns = QtCore.Qt.MouseButtons(btns)
        if not isinstance(mods, enum.Enum):
            mods = QtCore.Qt.KeyboardModifiers(mods)
        phase = QtCore.Qt.ScrollPhase(phase)
        super().__init__(pos, gpos, pixdel, angdel, btns, mods, phase, inverted)


class EnterEvent(QtGui.QEnterEvent):
    @staticmethod
    def get_state(obj):
        lpos = obj.position() if hasattr(obj, 'position') else obj.localPos()
        wpos = obj.scenePosition() if hasattr(obj, 'scenePosition') else obj.windowPos()
        gpos = obj.globalPosition() if hasattr(obj, 'globalPosition') else obj.screenPos()
        return lpos, wpos, gpos

    def __init__(self, rhs):
        super().__init__(*self.get_state(rhs))

    def __getstate__(self):
        return self.get_state(self)

    def __setstate__(self, state):
        super().__init__(*state)


class LeaveEvent(QtCore.QEvent):
    @staticmethod
    def get_state(obj, picklable=False):
        typ = obj.type()
        if picklable:
            typ, = serialize_mouse_enum(typ)
        return typ,

    def __init__(self, rhs):
        super().__init__(*self.get_state(rhs))

    def __getstate__(self):
        return self.get_state(self, picklable=True)

    def __setstate__(self, state):
        typ, = state
        typ = QtCore.QEvent.Type(typ)
        super().__init__(typ)


class RemoteGraphicsView(QtWidgets.QWidget):
    """
    Replacement for GraphicsView that does all scene management and rendering on a remote process,
    while displaying on the local widget.
    
    GraphicsItems must be created by proxy to the remote process.
    
    """
    def __init__(self, parent=None, *args, **kwds):
        """
        The keyword arguments 'useOpenGL' and 'backgound', if specified, are passed to the remote
        GraphicsView.__init__(). All other keyword arguments are passed to multiprocess.QtProcess.__init__().
        """
        self._img = None
        self._imgReq = None
        self._sizeHint = (640,480)  ## no clue why this is needed, but it seems to be the default sizeHint for GraphicsView.
                                    ## without it, the widget will not compete for space against another GraphicsView.
        QtWidgets.QWidget.__init__(self)

        # separate local keyword arguments from remote.
        remoteKwds = {}
        for kwd in ['useOpenGL', 'background']:
            if kwd in kwds:
                remoteKwds[kwd] = kwds.pop(kwd)

        self._proc = mp.QtProcess(**kwds)
        self.pg = self._proc._import('pyqtgraph')
        self.pg.setConfigOptions(**CONFIG_OPTIONS)
        rpgRemote = self._proc._import('pyqtgraph.widgets.RemoteGraphicsView')
        self._view = rpgRemote.Renderer(*args, **remoteKwds)
        self._view._setProxyOptions(deferGetattr=True)
        
        self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
        self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
        self.setMouseTracking(True)
        self.shm = None
        shmFileName = self._view.shmFileName()
        if sys.platform == 'win32':
            opener = lambda path, flags: os.open(path, flags | os.O_TEMPORARY)
        else:
            opener = None
        self.shmFile = open(shmFileName, 'rb', opener=opener)
        
        self._view.sceneRendered.connect(mp.proxy(self.remoteSceneChanged)) #, callSync='off'))
                                                                            ## Note: we need synchronous signals
                                                                            ## even though there is no return value--
                                                                            ## this informs the renderer that it is 
                                                                            ## safe to begin rendering again. 
        
        for method in ['scene', 'setCentralItem']:
            setattr(self, method, getattr(self._view, method))
        
    def resizeEvent(self, ev):
        ret = super().resizeEvent(ev)
        self._view.resize(self.size(), _callSync='off')
        return ret
        
    def sizeHint(self):
        return QtCore.QSize(*self._sizeHint)
        
    def remoteSceneChanged(self, data):
        w, h, size = data
        if self.shm is None or self.shm.size != size:
            if self.shm is not None:
                self.shm.close()
            self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_READ)
        self._img = QtGui.QImage(self.shm, w, h, QtGui.QImage.Format.Format_RGB32).copy()
        self.update()
        
    def paintEvent(self, ev):
        if self._img is None:
            return
        p = QtGui.QPainter(self)
        p.drawImage(self.rect(), self._img, self._img.rect())
        p.end()

    def mousePressEvent(self, ev):
        self._view.mousePressEvent(MouseEvent(ev), _callSync='off')
        ev.accept()
        return super().mousePressEvent(ev)

    def mouseReleaseEvent(self, ev):
        self._view.mouseReleaseEvent(MouseEvent(ev), _callSync='off')
        ev.accept()
        return super().mouseReleaseEvent(ev)

    def mouseMoveEvent(self, ev):
        self._view.mouseMoveEvent(MouseEvent(ev), _callSync='off')
        ev.accept()
        return super().mouseMoveEvent(ev)
        
    def wheelEvent(self, ev):
        self._view.wheelEvent(WheelEvent(ev), _callSync='off')
        ev.accept()
        return super().wheelEvent(ev)

    def enterEvent(self, ev):
        self._view.enterEvent(EnterEvent(ev), _callSync='off')
        return super().enterEvent(ev)
        
    def leaveEvent(self, ev):
        self._view.leaveEvent(LeaveEvent(ev), _callSync='off')
        return super().leaveEvent(ev)
        
    def remoteProcess(self):
        """Return the remote process handle. (see multiprocess.remoteproxy.RemoteEventHandler)"""
        return self._proc

    def close(self):
        """Close the remote process. After this call, the widget will no longer be updated."""
        self._view.sceneRendered.disconnect()
        self._proc.close()


class Renderer(GraphicsView):
    ## Created by the remote process to handle render requests
    
    sceneRendered = QtCore.Signal(object)
    
    def __init__(self, *args, **kwds):
        ## Create shared memory for rendered image
        self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_')
        size = mmap.PAGESIZE
        self.shmFile.write(b'\x00' * size)
        self.shmFile.flush()
        self.shm = mmap.mmap(self.shmFile.fileno(), size, access=mmap.ACCESS_WRITE)
        atexit.register(self.close)
        
        GraphicsView.__init__(self, *args, **kwds)
        self.scene().changed.connect(self.update)
        self.img = None
        self.renderTimer = QtCore.QTimer()
        self.renderTimer.timeout.connect(self.renderView)
        self.renderTimer.start(16)
        
    def close(self):
        self.shm.close()
        self.shmFile.close()

    def shmFileName(self):
        return self.shmFile.name
        
    def update(self):
        self.img = None
        return super().update()
        
    def resize(self, size):
        oldSize = self.size()
        super().resize(size)
        self.resizeEvent(QtGui.QResizeEvent(size, oldSize))
        self.update()
        
    def renderView(self):
        if self.img is None:
            ## make sure shm is large enough and get its address
            if self.width() == 0 or self.height() == 0:
                return
            dpr = self.devicePixelRatioF()
            iwidth = int(self.width() * dpr)
            iheight = int(self.height() * dpr)
            size = iwidth * iheight * 4
            if size > self.shm.size():
                try:
                    self.shm.resize(size)
                except SystemError:
                    # actually, the platforms on which resize() _does_ work
                    # can also take this codepath
                    self.shm.close()
                    fd = self.shmFile.fileno()
                    os.ftruncate(fd, size)
                    self.shm = mmap.mmap(fd, size, access=mmap.ACCESS_WRITE)
            
            ## render the scene directly to shared memory

            # see functions.py::ndarray_to_qimage() for rationale
            if QT_LIB.startswith('PyQt'):
                # PyQt5, PyQt6 >= 6.0.1
                img_ptr = int(Qt.sip.voidptr(self.shm))
            else:
                # PySide2, PySide6
                img_ptr = self.shm

            self.img = QtGui.QImage(img_ptr, iwidth, iheight, QtGui.QImage.Format.Format_RGB32)
            self.img.setDevicePixelRatio(dpr)

            self.img.fill(0xffffffff)
            p = QtGui.QPainter(self.img)
            self.render(p, self.viewRect(), self.rect())
            p.end()
            self.sceneRendered.emit((iwidth, iheight, self.shm.size()))