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.
"""
# when this is called, _cachedView is not invalidated.
# this means that for functions overriding viewRangeChanged, viewRect() may be stale.
pass
def viewTransformChanged(self):

View File

@ -155,9 +155,6 @@ class PlotDataItem(GraphicsObject):
self.yData = None
self.xDisp = None
self.yDisp = None
#self.dataMask = None
#self.curves = []
#self.scatters = []
self.curve = PlotCurveItem()
self.scatter = ScatterPlotItem()
self.curve.setParentItem(self)
@ -167,8 +164,15 @@ class PlotDataItem(GraphicsObject):
self.scatter.sigClicked.connect(self.scatterClicked)
self.scatter.sigHovered.connect(self.scatterHovered)
self._viewRangeWasChanged = False
self._styleWasChanged = True # force initial update
# self._xViewRangeWasChanged = False
# 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._drlLastClip = (0.0, 0.0) # holds last clipping points of dynamic range limiter
@ -532,11 +536,11 @@ class PlotDataItem(GraphicsObject):
if 'name' in kargs:
self.opts['name'] = kargs['name']
self._styleWasChanged = True
self.setProperty('styleWasChanged', True)
if 'connect' in kargs:
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' 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()):
if k in kargs:
self.opts[k] = kargs[k]
self._styleWasChanged = True
self.setProperty('styleWasChanged', True)
#curveArgs = {}
#for k in ['pen', 'shadowPen', 'fillLevel', 'brush']:
#if k in kargs:
@ -582,14 +586,11 @@ class PlotDataItem(GraphicsObject):
self.yDisp = None
profiler('set data')
self.updateItems( styleUpdate = self._styleWasChanged )
self._styleWasChanged = False # items have been updated
self.updateItems( styleUpdate = self.property('styleWasChanged') )
self.setProperty('styleWasChanged', False) # items have been updated
profiler('update items')
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)
profiler('emit')
@ -635,159 +636,161 @@ class PlotDataItem(GraphicsObject):
if self.xData is None:
return (None, None)
if self.xDisp is None or self._viewRangeWasChanged:
x = self.xData
y = self.yData
if( self.xDisp is not None and
not (self.property('xViewRangeWasChanged') and self.opts['clipToView']) and
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:
y = y.astype(np.uint8)
if x.dtype == bool:
x = x.astype(np.uint8)
if self.opts['fftMode']:
x,y = self._fourierTransform(x, y)
# Ignore the first bin for fft data if we have a logx scale
if self.opts['logMode'][0]:
x=x[1:]
y=y[1:]
if self.opts['fftMode']:
x,y = self._fourierTransform(x, y)
# Ignore the first bin for fft data if we have a logx scale
if self.opts['logMode'][0]:
x=x[1:]
y=y[1:]
if self.opts['derivativeMode']: # plot dV/dt
y = np.diff(self.yData)/np.diff(self.xData)
x = x[:-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)
if self.opts['derivativeMode']: # plot dV/dt
y = np.diff(self.yData)/np.diff(self.xData)
x = x[:-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 not isinstance(ds, int):
ds = 1
ds = self.opts['downsample']
if not isinstance(ds, int):
ds = 1
if self.opts['autoDownsample']:
# this option presumes that x-values have uniform spacing
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']:
# this option presumes that x-values have uniform spacing
range = self.viewRect()
if range is not None and len(x) > 1:
dx = float(x[-1]-x[0]) / (len(x)-1)
if dx != 0.0:
x0 = (range.left()-x[0]) / dx
x1 = (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['clipToView']:
if view is None or view.autoRangeEnabled()[0]:
pass # no ViewBox to clip to, or view will autoscale to data range.
else:
# clip-to-view always presumes that x-values are in increasing order
if view_range is not None and len(x) > 1:
# print('search:', view_range.left(),'-',view_range.right() )
# find first in-view value (left edge) and first out-of-view value (right edge)
# since we want the curve to go to the edge of the screen, we need to preserve
# one down-sampled point on the left and one of the right, so we extend the interval
x0 = np.searchsorted(x, view_range.left()) - ds
x0 = fn.clip_scalar(x0, 0, len(x)) # workaround
# x0 = np.clip(x0, 0, len(x))
if self.opts['clipToView']:
view = self.getViewBox()
if view is None or not view.autoRangeEnabled()[0]:
# this option presumes that x-values are in increasing order
range = self.viewRect()
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)
x1 = np.searchsorted(x, view_range.right()) + ds
x1 = fn.clip_scalar(x1, x0, len(x))
# x1 = np.clip(x1, 0, len(x))
x = x[x0:x1]
y = y[x0:x1]
# if data has been clipped too strongly (in case of non-uniform
# spacing of x-values), refine the clipping region as required
# worst case performance: O(log(n))
# best case performance: O(1)
if x[x0] > range.left():
x0 = np.searchsorted(x, range.left()) - 1*ds
x0 = fn.clip_scalar(x0, 0, len(x)) # workaround
# x0 = np.clip(x0, 0, len(x))
if x[x1] < range.right():
x1 = np.searchsorted(x, range.right()) + 2*ds
x1 = fn.clip_scalar(x1, 0, len(x))
# x1 = np.clip(x1, 0, len(x))
x = x[x0:x1]
y = y[x0:x1]
if ds > 1:
if self.opts['downsampleMethod'] == 'subsample':
x = x[::ds]
y = y[::ds]
elif self.opts['downsampleMethod'] == 'mean':
n = len(x) // ds
# x = x[:n*ds:ds]
stx = ds//2 # start of x-values; try to select a somewhat centered point
x = x[stx:stx+n*ds:ds]
y = y[:n*ds].reshape(n,ds).mean(axis=1)
elif self.opts['downsampleMethod'] == 'peak':
n = len(x) // ds
x1 = np.empty((n,2))
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['downsampleMethod'] == 'subsample':
x = x[::ds]
y = y[::ds]
elif self.opts['downsampleMethod'] == 'mean':
n = len(x) // ds
x = x[:n*ds:ds]
y = y[:n*ds].reshape(n,ds).mean(axis=1)
elif self.opts['downsampleMethod'] == 'peak':
n = len(x) // ds
x1 = np.empty((n,2))
x1[:] = x[: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 self.opts['dynamicRangeLimit'] is not None:
view_range = self.viewRect()
if view_range is not None:
data_range = self.dataRect()
if data_range is not None:
view_height = view_range.height()
limit = self.opts['dynamicRangeLimit']
hyst = self.opts['dynamicRangeHyst']
# never clip data if it fits into +/- (extended) limit * view height
if ( # note that "bottom" is the larger number, and "top" is the smaller one.
not data_range.bottom() < view_range.top() # never clip if all data is too small to see
and not data_range.top() > view_range.bottom() # never clip if all data is too large to see
and data_range.height() > 2 * hyst * limit * view_height
):
cache_is_good = False
# check if cached display data can be reused:
if self.yDisp is not None: # top is minimum value, bottom is maximum value
# how many multiples of the current view height does the clipped plot extend to the top and bottom?
top_exc =-(self._drlLastClip[0]-view_range.bottom()) / view_height
bot_exc = (self._drlLastClip[1]-view_range.top() ) / view_height
# print(top_exc, bot_exc, hyst)
if ( top_exc >= limit / hyst and top_exc <= limit * hyst
and bot_exc >= limit / hyst and bot_exc <= limit * hyst ):
# restore cached values
x = self.xDisp
y = self.yDisp
cache_is_good = True
if not cache_is_good:
min_val = view_range.bottom() - limit * view_height
max_val = view_range.top() + limit * view_height
if( self.yDisp is not None # Do we have an existing cache?
and min_val >= self._drlLastClip[0] # Are we reducing it further?
and max_val <= self._drlLastClip[1] ):
# if we need to clip further, we can work in-place on the output buffer
# 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
if self.opts['dynamicRangeLimit'] is not None:
if view_range is not None:
data_range = self.dataRect()
if data_range is not None:
view_height = view_range.height()
limit = self.opts['dynamicRangeLimit']
hyst = self.opts['dynamicRangeHyst']
# never clip data if it fits into +/- (extended) limit * view height
if ( # note that "bottom" is the larger number, and "top" is the smaller one.
not data_range.bottom() < view_range.top() # never clip if all data is too small to see
and not data_range.top() > view_range.bottom() # never clip if all data is too large to see
and data_range.height() > 2 * hyst * limit * view_height
):
cache_is_good = False
# check if cached display data can be reused:
if self.yDisp is not None: # top is minimum value, bottom is maximum value
# how many multiples of the current view height does the clipped plot extend to the top and bottom?
top_exc =-(self._drlLastClip[0]-view_range.bottom()) / view_height
bot_exc = (self._drlLastClip[1]-view_range.top() ) / view_height
# print(top_exc, bot_exc, hyst)
if ( top_exc >= limit / hyst and top_exc <= limit * hyst
and bot_exc >= limit / hyst and bot_exc <= limit * hyst ):
# restore cached values
x = self.xDisp
y = self.yDisp
cache_is_good = True
if not cache_is_good:
min_val = view_range.bottom() - limit * view_height
max_val = view_range.top() + limit * view_height
if( self.yDisp is not None # Do we have an existing cache?
and min_val >= self._drlLastClip[0] # Are we reducing it further?
and max_val <= self._drlLastClip[1] ):
# if we need to clip further, we can work in-place on the output buffer
# 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.setProperty('xViewRangeWasChanged', False)
self.setProperty('yViewRangeWasChanged', False)
return self.xDisp, self.yDisp
def dataRect(self):
@ -862,11 +865,6 @@ class PlotDataItem(GraphicsObject):
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.yData = None
self.xDisp = None
@ -888,20 +886,37 @@ class PlotDataItem(GraphicsObject):
def scatterHovered(self, plt, points, ev):
self.sigPointsHovered.emit(self, points, ev)
def viewRangeChanged(self):
# view range has changed; re-plot if needed
self._viewRangeWasChanged = True
if( self.opts['clipToView']
or self.opts['autoDownsample']
):
self.xDisp = self.yDisp = None
self.updateItems(styleUpdate=False)
elif self.opts['dynamicRangeLimit'] is not None:
# update, but do not discard cached display data
# def viewTransformChanged(self):
# """ view transform (and thus range) has changed, replot if needed """
# viewTransformChanged is only called when the cached viewRect of GraphicsItem
# has already been invalidated. However, responding here will make PlotDataItem
# update curve and scatter later than intended.
# super().viewTransformChanged() # this invalidates the viewRect() cache!
def viewRangeChanged(self, vb=None, ranges=None, changed=None):
""" view range has changed; re-plot if needed """
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)
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.
dx = np.diff(x)
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)
sigXRangeChanged = QtCore.Signal(object, object)
sigRangeChangedManually = QtCore.Signal(object)
sigRangeChanged = QtCore.Signal(object, object)
sigRangeChanged = QtCore.Signal(object, object, object)
sigStateChanged = QtCore.Signal(object)
sigTransformChanged = QtCore.Signal(object)
sigResized = QtCore.Signal(object)
@ -1553,11 +1553,12 @@ class ViewBox(GraphicsWidget):
link.linkedViewChanged(self, ax)
# emit range change signals
# print('announcing view range changes:',self.state['viewRange'] )
if changed[0]:
self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0]))
if changed[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):
if not self._matrixNeedsUpdate:

View File

@ -125,14 +125,36 @@ def test_clipping():
w.addItem(c)
c.setClipToView(True)
w.setXRange(200, 600)
for x_min in range(100, 2**10 - 100, 100):
w.setXRange(x_min, x_min + 100)
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()
# 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()
assert xDisp[-1] >= vr.right()
c.setDownsampling(ds=1) # disable downsampling
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()