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()))
|