clean-up of PlotDataItem downsample methods (#1725)

* clean-up of PlotDataItem downsample methods

* high end of range at low end, not zero

Co-authored-by: Ogi Moore <ognyan.moore@gmail.com>
This commit is contained in:
Nils Nemitz 2021-04-29 14:07:24 +09:00 committed by GitHub
parent 4ee1fe4388
commit a7bc2b9a63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 223 additions and 183 deletions

View File

@ -553,6 +553,8 @@ class GraphicsItem(object):
""" """
Called whenever the view coordinates of the ViewBox containing this item have changed. Called whenever the view coordinates of the ViewBox containing this item have changed.
""" """
# when this is called, _cachedView is not invalidated.
# this means that for functions overriding viewRangeChanged, viewRect() may be stale.
pass pass
def viewTransformChanged(self): def viewTransformChanged(self):

View File

@ -155,9 +155,6 @@ class PlotDataItem(GraphicsObject):
self.yData = None self.yData = None
self.xDisp = None self.xDisp = None
self.yDisp = None self.yDisp = None
#self.dataMask = None
#self.curves = []
#self.scatters = []
self.curve = PlotCurveItem() self.curve = PlotCurveItem()
self.scatter = ScatterPlotItem() self.scatter = ScatterPlotItem()
self.curve.setParentItem(self) self.curve.setParentItem(self)
@ -167,8 +164,15 @@ class PlotDataItem(GraphicsObject):
self.scatter.sigClicked.connect(self.scatterClicked) self.scatter.sigClicked.connect(self.scatterClicked)
self.scatter.sigHovered.connect(self.scatterHovered) self.scatter.sigHovered.connect(self.scatterHovered)
self._viewRangeWasChanged = False # self._xViewRangeWasChanged = False
self._styleWasChanged = True # force initial update # self._yViewRangeWasChanged = False
# self._styleWasChanged = True # force initial update
# update-required notifications are handled through properties to allow future management through
# the QDynamicPropertyChangeEvent sent on any change.
self.setProperty('xViewRangeWasChanged', False)
self.setProperty('yViewRangeWasChanged', False)
self.setProperty('styleWasChanged', True) # force initial update
self._dataRect = None self._dataRect = None
self._drlLastClip = (0.0, 0.0) # holds last clipping points of dynamic range limiter self._drlLastClip = (0.0, 0.0) # holds last clipping points of dynamic range limiter
@ -532,11 +536,11 @@ class PlotDataItem(GraphicsObject):
if 'name' in kargs: if 'name' in kargs:
self.opts['name'] = kargs['name'] self.opts['name'] = kargs['name']
self._styleWasChanged = True self.setProperty('styleWasChanged', True)
if 'connect' in kargs: if 'connect' in kargs:
self.opts['connect'] = kargs['connect'] self.opts['connect'] = kargs['connect']
self._styleWasChanged = True self.setProperty('styleWasChanged', True)
## if symbol pen/brush are given with no previously set symbol, then assume symbol is 'o' ## if symbol pen/brush are given with no previously set symbol, then assume symbol is 'o'
if 'symbol' not in kargs and ('symbolPen' in kargs or 'symbolBrush' in kargs or 'symbolSize' in kargs): if 'symbol' not in kargs and ('symbolPen' in kargs or 'symbolBrush' in kargs or 'symbolSize' in kargs):
@ -549,7 +553,7 @@ class PlotDataItem(GraphicsObject):
for k in list(self.opts.keys()): for k in list(self.opts.keys()):
if k in kargs: if k in kargs:
self.opts[k] = kargs[k] self.opts[k] = kargs[k]
self._styleWasChanged = True self.setProperty('styleWasChanged', True)
#curveArgs = {} #curveArgs = {}
#for k in ['pen', 'shadowPen', 'fillLevel', 'brush']: #for k in ['pen', 'shadowPen', 'fillLevel', 'brush']:
#if k in kargs: #if k in kargs:
@ -582,14 +586,11 @@ class PlotDataItem(GraphicsObject):
self.yDisp = None self.yDisp = None
profiler('set data') profiler('set data')
self.updateItems( styleUpdate = self._styleWasChanged ) self.updateItems( styleUpdate = self.property('styleWasChanged') )
self._styleWasChanged = False # items have been updated self.setProperty('styleWasChanged', False) # items have been updated
profiler('update items') profiler('update items')
self.informViewBoundsChanged() self.informViewBoundsChanged()
#view = self.getViewBox()
#if view is not None:
#view.itemBoundsChanged(self) ## inform view so it can update its range if it wants
self.sigPlotChanged.emit(self) self.sigPlotChanged.emit(self)
profiler('emit') profiler('emit')
@ -635,159 +636,161 @@ class PlotDataItem(GraphicsObject):
if self.xData is None: if self.xData is None:
return (None, None) return (None, None)
if self.xDisp is None or self._viewRangeWasChanged: if( self.xDisp is not None and
x = self.xData not (self.property('xViewRangeWasChanged') and self.opts['clipToView']) and
y = self.yData not (self.property('xViewRangeWasChanged') and self.opts['autoDownsample']) and
not (self.property('yViewRangeWasChanged') and self.opts['dynamicRangeLimit'] is not None)
):
return self.xDisp, self.yDisp
x = self.xData
y = self.yData
if y.dtype == bool:
y = y.astype(np.uint8)
if x.dtype == bool:
x = x.astype(np.uint8)
view = self.getViewBox()
if view is None:
view_range = None
else:
view_range = self.getViewBox().viewRect() # this is always up-to-date
if view_range is None:
view_range = self.viewRect()
if y.dtype == bool: if self.opts['fftMode']:
y = y.astype(np.uint8) x,y = self._fourierTransform(x, y)
if x.dtype == bool: # Ignore the first bin for fft data if we have a logx scale
x = x.astype(np.uint8) if self.opts['logMode'][0]:
x=x[1:]
y=y[1:]
if self.opts['fftMode']: if self.opts['derivativeMode']: # plot dV/dt
x,y = self._fourierTransform(x, y) y = np.diff(self.yData)/np.diff(self.xData)
# Ignore the first bin for fft data if we have a logx scale x = x[:-1]
if self.opts['logMode'][0]: if self.opts['phasemapMode']: # plot dV/dt vs V
x=x[1:] x = self.yData[:-1]
y=y[1:] y = np.diff(self.yData)/np.diff(self.xData)
with np.errstate(divide='ignore'):
if self.opts['logMode'][0]:
x = np.log10(x)
if self.opts['logMode'][1]:
if np.issubdtype(y.dtype, np.floating):
eps = np.finfo(y.dtype).eps
else:
eps = 1
y = np.copysign(np.log10(np.abs(y)+eps), y)
if self.opts['derivativeMode']: # plot dV/dt ds = self.opts['downsample']
y = np.diff(self.yData)/np.diff(self.xData) if not isinstance(ds, int):
x = x[:-1] ds = 1
if self.opts['phasemapMode']: # plot dV/dt vs V
x = self.yData[:-1]
y = np.diff(self.yData)/np.diff(self.xData)
with np.errstate(divide='ignore'):
if self.opts['logMode'][0]:
x = np.log10(x)
if self.opts['logMode'][1]:
if np.issubdtype(y.dtype, np.floating):
eps = np.finfo(y.dtype).eps
else:
eps = 1
y = np.copysign(np.log10(np.abs(y)+eps), y)
ds = self.opts['downsample'] if self.opts['autoDownsample']:
if not isinstance(ds, int): # this option presumes that x-values have uniform spacing
ds = 1 if view_range is not None and len(x) > 1:
dx = float(x[-1]-x[0]) / (len(x)-1)
if dx != 0.0:
x0 = (view_range.left()-x[0]) / dx
x1 = (view_range.right()-x[0]) / dx
width = self.getViewBox().width()
if width != 0.0:
ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor']))))
## downsampling is expensive; delay until after clipping.
if self.opts['autoDownsample']: if self.opts['clipToView']:
# this option presumes that x-values have uniform spacing if view is None or view.autoRangeEnabled()[0]:
range = self.viewRect() pass # no ViewBox to clip to, or view will autoscale to data range.
if range is not None and len(x) > 1: else:
dx = float(x[-1]-x[0]) / (len(x)-1) # clip-to-view always presumes that x-values are in increasing order
if dx != 0.0: if view_range is not None and len(x) > 1:
x0 = (range.left()-x[0]) / dx # print('search:', view_range.left(),'-',view_range.right() )
x1 = (range.right()-x[0]) / dx # find first in-view value (left edge) and first out-of-view value (right edge)
width = self.getViewBox().width() # since we want the curve to go to the edge of the screen, we need to preserve
if width != 0.0: # one down-sampled point on the left and one of the right, so we extend the interval
ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) x0 = np.searchsorted(x, view_range.left()) - ds
## downsampling is expensive; delay until after clipping. x0 = fn.clip_scalar(x0, 0, len(x)) # workaround
# x0 = np.clip(x0, 0, len(x))
if self.opts['clipToView']: x1 = np.searchsorted(x, view_range.right()) + ds
view = self.getViewBox() x1 = fn.clip_scalar(x1, x0, len(x))
if view is None or not view.autoRangeEnabled()[0]: # x1 = np.clip(x1, 0, len(x))
# this option presumes that x-values are in increasing order x = x[x0:x1]
range = self.viewRect() y = y[x0:x1]
if range is not None and len(x) > 1:
# clip to visible region extended by downsampling value, assuming
# uniform spacing of x-values, has O(1) performance
dx = float(x[-1]-x[0]) / (len(x)-1)
# workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+
# x0 = np.clip(int((range.left()-x[0])/dx) - 1*ds, 0, len(x)-1)
# x1 = np.clip(int((range.right()-x[0])/dx) + 2*ds, 0, len(x)-1)
x0 = fn.clip_scalar(int((range.left()-x[0])/dx) - 1*ds, 0, len(x)-1)
x1 = fn.clip_scalar(int((range.right()-x[0])/dx) + 2*ds, 0, len(x)-1)
# if data has been clipped too strongly (in case of non-uniform if ds > 1:
# spacing of x-values), refine the clipping region as required if self.opts['downsampleMethod'] == 'subsample':
# worst case performance: O(log(n)) x = x[::ds]
# best case performance: O(1) y = y[::ds]
if x[x0] > range.left(): elif self.opts['downsampleMethod'] == 'mean':
x0 = np.searchsorted(x, range.left()) - 1*ds n = len(x) // ds
x0 = fn.clip_scalar(x0, 0, len(x)) # workaround # x = x[:n*ds:ds]
# x0 = np.clip(x0, 0, len(x)) stx = ds//2 # start of x-values; try to select a somewhat centered point
if x[x1] < range.right(): x = x[stx:stx+n*ds:ds]
x1 = np.searchsorted(x, range.right()) + 2*ds y = y[:n*ds].reshape(n,ds).mean(axis=1)
x1 = fn.clip_scalar(x1, 0, len(x)) elif self.opts['downsampleMethod'] == 'peak':
# x1 = np.clip(x1, 0, len(x)) n = len(x) // ds
x = x[x0:x1] x1 = np.empty((n,2))
y = y[x0:x1] stx = ds//2 # start of x-values; try to select a somewhat centered point
x1[:] = x[stx:stx+n*ds:ds,np.newaxis]
x = x1.reshape(n*2)
y1 = np.empty((n,2))
y2 = y[:n*ds].reshape((n, ds))
y1[:,0] = y2.max(axis=1)
y1[:,1] = y2.min(axis=1)
y = y1.reshape(n*2)
if ds > 1: if self.opts['dynamicRangeLimit'] is not None:
if self.opts['downsampleMethod'] == 'subsample': if view_range is not None:
x = x[::ds] data_range = self.dataRect()
y = y[::ds] if data_range is not None:
elif self.opts['downsampleMethod'] == 'mean': view_height = view_range.height()
n = len(x) // ds limit = self.opts['dynamicRangeLimit']
x = x[:n*ds:ds] hyst = self.opts['dynamicRangeHyst']
y = y[:n*ds].reshape(n,ds).mean(axis=1) # never clip data if it fits into +/- (extended) limit * view height
elif self.opts['downsampleMethod'] == 'peak': if ( # note that "bottom" is the larger number, and "top" is the smaller one.
n = len(x) // ds not data_range.bottom() < view_range.top() # never clip if all data is too small to see
x1 = np.empty((n,2)) and not data_range.top() > view_range.bottom() # never clip if all data is too large to see
x1[:] = x[:n*ds:ds,np.newaxis] and data_range.height() > 2 * hyst * limit * view_height
x = x1.reshape(n*2) ):
y1 = np.empty((n,2)) cache_is_good = False
y2 = y[:n*ds].reshape((n, ds)) # check if cached display data can be reused:
y1[:,0] = y2.max(axis=1) if self.yDisp is not None: # top is minimum value, bottom is maximum value
y1[:,1] = y2.min(axis=1) # how many multiples of the current view height does the clipped plot extend to the top and bottom?
y = y1.reshape(n*2) top_exc =-(self._drlLastClip[0]-view_range.bottom()) / view_height
bot_exc = (self._drlLastClip[1]-view_range.top() ) / view_height
if self.opts['dynamicRangeLimit'] is not None: # print(top_exc, bot_exc, hyst)
view_range = self.viewRect() if ( top_exc >= limit / hyst and top_exc <= limit * hyst
if view_range is not None: and bot_exc >= limit / hyst and bot_exc <= limit * hyst ):
data_range = self.dataRect() # restore cached values
if data_range is not None: x = self.xDisp
view_height = view_range.height() y = self.yDisp
limit = self.opts['dynamicRangeLimit'] cache_is_good = True
hyst = self.opts['dynamicRangeHyst'] if not cache_is_good:
# never clip data if it fits into +/- (extended) limit * view height min_val = view_range.bottom() - limit * view_height
if ( # note that "bottom" is the larger number, and "top" is the smaller one. max_val = view_range.top() + limit * view_height
not data_range.bottom() < view_range.top() # never clip if all data is too small to see if( self.yDisp is not None # Do we have an existing cache?
and not data_range.top() > view_range.bottom() # never clip if all data is too large to see and min_val >= self._drlLastClip[0] # Are we reducing it further?
and data_range.height() > 2 * hyst * limit * view_height and max_val <= self._drlLastClip[1] ):
): # if we need to clip further, we can work in-place on the output buffer
cache_is_good = False # print('in-place:', end='')
# check if cached display data can be reused: # workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ :
if self.yDisp is not None: # top is minimum value, bottom is maximum value # np.clip(self.yDisp, out=self.yDisp, a_min=min_val, a_max=max_val)
# how many multiples of the current view height does the clipped plot extend to the top and bottom? fn.clip_array(self.yDisp, min_val, max_val, out=self.yDisp)
top_exc =-(self._drlLastClip[0]-view_range.bottom()) / view_height self._drlLastClip = (min_val, max_val)
bot_exc = (self._drlLastClip[1]-view_range.top() ) / view_height # print('{:.1e}<->{:.1e}'.format( min_val, max_val ))
# print(top_exc, bot_exc, hyst) x = self.xDisp
if ( top_exc >= limit / hyst and top_exc <= limit * hyst y = self.yDisp
and bot_exc >= limit / hyst and bot_exc <= limit * hyst ): else:
# restore cached values # if none of the shortcuts worked, we need to recopy from the full data
x = self.xDisp # print('alloc:', end='')
y = self.yDisp # workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ :
cache_is_good = True # y = np.clip(y, a_min=min_val, a_max=max_val)
if not cache_is_good: y = fn.clip_array(y, min_val, max_val)
min_val = view_range.bottom() - limit * view_height self._drlLastClip = (min_val, max_val)
max_val = view_range.top() + limit * view_height # print('{:.1e}<->{:.1e}'.format( min_val, max_val ))
if( self.yDisp is not None # Do we have an existing cache? self.xDisp = x
and min_val >= self._drlLastClip[0] # Are we reducing it further? self.yDisp = y
and max_val <= self._drlLastClip[1] ): self.setProperty('xViewRangeWasChanged', False)
# if we need to clip further, we can work in-place on the output buffer self.setProperty('yViewRangeWasChanged', False)
# print('in-place:', end='')
# workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ :
# np.clip(self.yDisp, out=self.yDisp, a_min=min_val, a_max=max_val)
fn.clip_array(self.yDisp, min_val, max_val, out=self.yDisp)
self._drlLastClip = (min_val, max_val)
# print('{:.1e}<->{:.1e}'.format( min_val, max_val ))
x = self.xDisp
y = self.yDisp
else:
# if none of the shortcuts worked, we need to recopy from the full data
# print('alloc:', end='')
# workaround for slowdown from numpy deprecation issues in 1.17 to 1.20+ :
# y = np.clip(y, a_min=min_val, a_max=max_val)
y = fn.clip_array(y, min_val, max_val)
self._drlLastClip = (min_val, max_val)
# print('{:.1e}<->{:.1e}'.format( min_val, max_val ))
self.xDisp = x
self.yDisp = y
self._viewRangeWasChanged = False
return self.xDisp, self.yDisp return self.xDisp, self.yDisp
def dataRect(self): def dataRect(self):
@ -862,11 +865,6 @@ class PlotDataItem(GraphicsObject):
def clear(self): def clear(self):
#for i in self.curves+self.scatters:
#if i.scene() is not None:
#i.scene().removeItem(i)
#self.curves = []
#self.scatters = []
self.xData = None self.xData = None
self.yData = None self.yData = None
self.xDisp = None self.xDisp = None
@ -888,20 +886,37 @@ class PlotDataItem(GraphicsObject):
def scatterHovered(self, plt, points, ev): def scatterHovered(self, plt, points, ev):
self.sigPointsHovered.emit(self, points, ev) self.sigPointsHovered.emit(self, points, ev)
def viewRangeChanged(self): # def viewTransformChanged(self):
# view range has changed; re-plot if needed # """ view transform (and thus range) has changed, replot if needed """
self._viewRangeWasChanged = True # viewTransformChanged is only called when the cached viewRect of GraphicsItem
if( self.opts['clipToView'] # has already been invalidated. However, responding here will make PlotDataItem
or self.opts['autoDownsample'] # update curve and scatter later than intended.
): # super().viewTransformChanged() # this invalidates the viewRect() cache!
self.xDisp = self.yDisp = None
self.updateItems(styleUpdate=False) def viewRangeChanged(self, vb=None, ranges=None, changed=None):
elif self.opts['dynamicRangeLimit'] is not None: """ view range has changed; re-plot if needed """
# update, but do not discard cached display data update_needed = False
if changed is None or changed[0]:
# if ranges is not None:
# print('hor:', ranges[0])
self.setProperty('xViewRangeWasChanged', True)
if( self.opts['clipToView']
or self.opts['autoDownsample']
):
self.xDisp = self.yDisp = None
update_needed = True
if changed is None or changed[1]:
# if ranges is not None:
# print('ver:', ranges[1])
self.setProperty('yViewRangeWasChanged', True)
if self.opts['dynamicRangeLimit'] is not None:
# update, but do not discard cached display data
update_needed = True
if update_needed:
self.updateItems(styleUpdate=False) self.updateItems(styleUpdate=False)
def _fourierTransform(self, x, y): def _fourierTransform(self, x, y):
## Perform fourier transform. If x values are not sampled uniformly, ## Perform Fourier transform. If x values are not sampled uniformly,
## then use np.interp to resample before taking fft. ## then use np.interp to resample before taking fft.
dx = np.diff(x) dx = np.diff(x)
uniform = not np.any(np.abs(dx-dx[0]) > (abs(dx[0]) / 1000.)) uniform = not np.any(np.abs(dx-dx[0]) > (abs(dx[0]) / 1000.))

View File

@ -88,7 +88,7 @@ class ViewBox(GraphicsWidget):
sigYRangeChanged = QtCore.Signal(object, object) sigYRangeChanged = QtCore.Signal(object, object)
sigXRangeChanged = QtCore.Signal(object, object) sigXRangeChanged = QtCore.Signal(object, object)
sigRangeChangedManually = QtCore.Signal(object) sigRangeChangedManually = QtCore.Signal(object)
sigRangeChanged = QtCore.Signal(object, object) sigRangeChanged = QtCore.Signal(object, object, object)
sigStateChanged = QtCore.Signal(object) sigStateChanged = QtCore.Signal(object)
sigTransformChanged = QtCore.Signal(object) sigTransformChanged = QtCore.Signal(object)
sigResized = QtCore.Signal(object) sigResized = QtCore.Signal(object)
@ -1553,11 +1553,12 @@ class ViewBox(GraphicsWidget):
link.linkedViewChanged(self, ax) link.linkedViewChanged(self, ax)
# emit range change signals # emit range change signals
# print('announcing view range changes:',self.state['viewRange'] )
if changed[0]: if changed[0]:
self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0]))
if changed[1]: if changed[1]:
self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1]))
self.sigRangeChanged.emit(self, self.state['viewRange']) self.sigRangeChanged.emit(self, self.state['viewRange'], changed)
def updateMatrix(self, changed=None): def updateMatrix(self, changed=None):
if not self._matrixNeedsUpdate: if not self._matrixNeedsUpdate:

View File

@ -125,14 +125,36 @@ def test_clipping():
w.addItem(c) w.addItem(c)
c.setClipToView(True) c.setClipToView(True)
w.setXRange(200, 600) for x_min in range(-200, 2**10 - 100, 100):
for x_min in range(100, 2**10 - 100, 100): x_max = x_min + 100
w.setXRange(x_min, x_min + 100) w.setXRange(x_min, x_max, padding=0)
xDisp, _ = c.getData() xDisp, _ = c.getData()
vr = c.viewRect() # vr = c.viewRect()
if len(xDisp) > 3: # check that all points except the first and last are on screen
assert( xDisp[ 1] >= x_min and xDisp[ 1] <= x_max )
assert( xDisp[-2] >= x_min and xDisp[-2] <= x_max )
assert xDisp[0] <= vr.left() c.setDownsampling(ds=1) # disable downsampling
assert xDisp[-1] >= vr.right() for x_min in range(-200, 2**10 - 100, 100):
x_max = x_min + 100
w.setXRange(x_min, x_max, padding=0)
xDisp, _ = c.getData()
# vr = c.viewRect() # this tends to be out of data, so we check against the range that we set
if len(xDisp) > 3: # check that all points except the first and last are on screen
assert( xDisp[ 0] == x[ 0] or xDisp[ 0] < x_min ) # first point should be unchanged, or off-screen
assert( xDisp[ 1] >= x_min and xDisp[ 1] <= x_max )
assert( xDisp[-2] >= x_min and xDisp[-2] <= x_max )
assert( xDisp[-1] == x[-1] or xDisp[-1] > x_max ) # last point should be unchanged, or off-screen
c.setData(x=np.zeros_like(y), y=y) # test zero width data set:
# test center and expected number of remaining data points
for center, num in ((-100.,1), (100.,1), (0.,len(y)) ):
# when all elements are off-screen, only one will be kept
# when all elelemts are on-screen, all should be kept
# and the code should not crash for zero separation
w.setXRange( center-50, center+50, padding=0 )
xDisp, yDisp = c.getData()
assert len(xDisp) == num
assert len(yDisp) == num
w.close() w.close()