From 758c0384119636718b72a89411f85a44e98f9d89 Mon Sep 17 00:00:00 2001 From: Nils Nemitz Date: Tue, 6 Apr 2021 12:50:52 +0900 Subject: [PATCH] Add ColorBarItem for simplified image level adjustment (#1596) * Initial implementation of ColorBarItem * initial commit * fixed missing indent * docstring extension and corrections * Converted example to match others / run as part of tests * load local color maps instead of importing from colorcet * clean up window creation code * horizontal color bar and clean-up * switched to mkQApp initialization * cleaned up some comments --- examples/ColorBarItem.py | 100 ++++++++ examples/colorMaps.py | 12 +- examples/utils.py | 1 + pyqtgraph/__init__.py | 1 + pyqtgraph/graphicsItems/ColorBarItem.py | 258 ++++++++++++++++++++ pyqtgraph/graphicsItems/LinearRegionItem.py | 5 +- 6 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 examples/ColorBarItem.py create mode 100644 pyqtgraph/graphicsItems/ColorBarItem.py diff --git a/examples/ColorBarItem.py b/examples/ColorBarItem.py new file mode 100644 index 00000000..ee3f6485 --- /dev/null +++ b/examples/ColorBarItem.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +This example demonstrates the use of ColorBarItem, which displays a simple interactive color bar. +""" +## Add path to library (just for examples; you do not need this) +import initExample + +import numpy as np +from pyqtgraph.Qt import QtWidgets, mkQApp +import pyqtgraph as pg + +class MainWindow(QtWidgets.QMainWindow): + """ example application main window """ + def __init__(self, *args, **kwargs): + super(MainWindow, self).__init__(*args, **kwargs) + gr_wid = pg.GraphicsLayoutWidget(show=True) + self.setCentralWidget(gr_wid) + self.setWindowTitle('pyqtgraph example: Interactive color bar') + self.resize(800,700) + self.show() + + ## Create image items + data = np.fromfunction(lambda i, j: (1+0.3*np.sin(i)) * (i)**2 + (j)**2, (100, 100)) + noisy_data = data * (1 + 0.2 * np.random.random(data.shape) ) + noisy_transposed = noisy_data.transpose() + + #--- add non-interactive image with integrated color ----------------- + i1 = pg.ImageItem(image=data) + p1 = gr_wid.addPlot(title="non-interactive") + p1.addItem( i1 ) + p1.setMouseEnabled( x=False, y=False) + p1.disableAutoRange() + p1.hideButtons() + p1.setRange(xRange=(0,100), yRange=(0,100), padding=0) + for key in ['left','right','top','bottom']: + p1.showAxis(key) + axis = p1.getAxis(key) + axis.setZValue(1) + if key in ['top', 'right']: + p1.getAxis(key).setStyle( showValues=False ) + + cmap = pg.colormap.get('CET-L9') + bar = pg.ColorBarItem( + interactive=False, values= (0, 30_000), cmap=cmap, + label='vertical fixed color bar' + ) + bar.setImageItem( i1, insert_in=p1 ) + + #--- add interactive image with integrated horizontal color bar -------------- + i2 = pg.ImageItem(image=noisy_data) + p2 = gr_wid.addPlot(1,0, 1,1, title="interactive") + p2.addItem( i2, title='' ) + # inserted color bar also works with labels on the right. + p2.showAxis('right') + p2.getAxis('left').setStyle( showValues=False ) + p2.getAxis('bottom').setLabel('bottom axis label') + p2.getAxis('right').setLabel('right axis label') + + cmap = pg.colormap.get('CET-L4') + bar = pg.ColorBarItem( + values = (0, 30_000), + cmap=cmap, + label='horizontal color bar', + limits = (0, None), + rounding=1000, + orientation = 'horizontal', + pen='#8888FF', hoverPen='#EEEEFF', hoverBrush='#EEEEFF80' + ) + bar.setImageItem( i2, insert_in=p2 ) + + #--- multiple images adjusted by a separate color bar ------------------------ + i3 = pg.ImageItem(image=noisy_data) + p3 = gr_wid.addPlot(0,1, 1,1, title="shared 1") + p3.addItem( i3 ) + + i4 = pg.ImageItem(image=noisy_transposed) + p4 = gr_wid.addPlot(1,1, 1,1, title="shared 2") + p4.addItem( i4 ) + + cmap = pg.colormap.get('CET-L8') + bar = pg.ColorBarItem( + # values = (-15_000, 15_000), + limits = (-30_000, 30_000), # start with full range... + rounding=1000, + width = 10, + cmap=cmap ) + bar.setImageItem( [i3, i4] ) + bar.setLevels( low=-5_000, high=15_000) # ... then adjust to retro sunset. + + # manually adjust reserved space at top and bottom to align with plot + bar.getAxis('bottom').setHeight(21) + bar.getAxis('top').setHeight(31) + gr_wid.addItem(bar, 0,2, 2,1) # large bar spanning both rows + +mkQApp("ColorBarItem Example") +main_window = MainWindow() + +## Start Qt event loop +if __name__ == '__main__': + mkQApp().exec_() diff --git a/examples/colorMaps.py b/examples/colorMaps.py index 174e2f89..18425672 100644 --- a/examples/colorMaps.py +++ b/examples/colorMaps.py @@ -1,14 +1,8 @@ # -*- coding: utf-8 -*- """ -This example demonstrates the use of ImageView, which is a high-level widget for -displaying and analyzing 2D and 3D data. ImageView provides: - - 1. A zoomable region (ViewBox) for displaying the image - 2. A combination histogram and gradient editor (HistogramLUTItem) for - controlling the visual appearance of the image - 3. A timeline for selecting the currently displayed frame (for 3D data only). - 4. Tools for very basic analysis of image data (see ROI and Norm buttons) - +This example demonstrates generating ColorMap objects from external data. +It displays the full list of color maps available as local files or by import +from Matplotlib or ColorCET. """ ## Add path to library (just for examples; you do not need this) import initExample diff --git a/examples/utils.py b/examples/utils.py index a1223982..38c92a03 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -38,6 +38,7 @@ examples = OrderedDict([ ('FillBetweenItem', 'FillBetweenItem.py'), ('ImageItem - video', 'ImageItem.py'), ('ImageItem - draw', 'Draw.py'), + ('ColorBarItem','ColorBarItem.py'), ('Non-uniform Image', 'NonUniformImage.py'), ('Region-of-Interest', 'ROIExamples.py'), ('Bar Graph', 'BarGraphItem.py'), diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index b94c3e7d..fdf1878e 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -223,6 +223,7 @@ from .graphicsItems.GraphicsWidgetAnchor import * from .graphicsItems.PlotCurveItem import * from .graphicsItems.ButtonItem import * from .graphicsItems.GradientEditorItem import * +from .graphicsItems.ColorBarItem import * from .graphicsItems.MultiPlotItem import * from .graphicsItems.ErrorBarItem import * from .graphicsItems.IsocurveItem import * diff --git a/pyqtgraph/graphicsItems/ColorBarItem.py b/pyqtgraph/graphicsItems/ColorBarItem.py new file mode 100644 index 00000000..7c4e9c2d --- /dev/null +++ b/pyqtgraph/graphicsItems/ColorBarItem.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +from ..Qt import QtCore +from .. import functions as fn +from .PlotItem import PlotItem +from .ImageItem import ImageItem +from .LinearRegionItem import LinearRegionItem + +import weakref +import math +import numpy as np + +__all__ = ['ColorBarItem'] + +class ColorBarItem(PlotItem): + """ + **Bases:** :class:`PlotItem ` + + ColorBarItem is a simpler, compact alternative to HistogramLUTItem, without histogram + or the option to adjust the look-up table. + + A labeled axis is displayed directly next to the gradient to help identify values. + Handles included in the color bar allow for interactive adjustment. + + A ColorBarItem can be assigned one or more ImageItems that will be displayed according + to the selected color map and levels. The ColorBarItem can be used as a separate + element in a GraphicsLayout or added to the layout of a PlotItem used to display image + data with coordinate axes. + + ============================= ============================================= + **Signals:** + sigLevelsChanged(self) Emitted when the range sliders are moved + sigLevelsChangeFinished(self) Emitted when the range sliders are released + ============================= ============================================= + """ + sigLevelsChanged = QtCore.Signal(object) + sigLevelsChangeFinished = QtCore.Signal(object) + + def __init__(self, values=(0,1), width=25, cmap=None, label=None, + interactive=True, limits=None, rounding=1, + orientation='vertical', pen='w', hoverPen='r', hoverBrush='#FF000080' ): + """ + Create a new ColorBarItem. + + ============== =========================================================================== + **Arguments:** + values The range of values as tuple (min, max) + width (default=25) The width of the displayed color bar + cmap ColorMap object, look-up table is also applied to assigned ImageItem(s) + label (default=None) Label applied to color bar axis + interactive (default=True) Handles are displayed to interactively adjust level range + limits Limits to adjustment range as (low, high) tuple, None disables limit + rounding (default=1) Adjusted range values are rounded to multiples of this values + orientation (default='vertical') 'horizontal' gives a horizontal color bar + pen color of adjustement handles in interactive mode + hoverPen color of adjustement handles when hovered over + hoverBrush color of movable center region when hovered over + ============== =========================================================================== + """ + super().__init__() + self.img_list = [] # list of controlled ImageItems + self.values = values + self.cmap = cmap + self.rounding = rounding + self.horizontal = bool(orientation == 'horizontal') + + self.lo_prv, self.hi_prv = self.values # remember previous values while adjusting range + if limits is None: + self.lo_lim = None + self.hi_lim = None + else: + self.lo_lim, self.hi_lim = limits + + self.disableAutoRange() + self.hideButtons() + self.setMouseEnabled(x=False, y=False) + self.setMenuEnabled( False) + + if self.horizontal: + self.setRange( xRange=(0,256), yRange=(0,1), padding=0 ) + self.layout.setRowFixedHeight(2, width) + else: + self.setRange( xRange=(0,1), yRange=(0,256), padding=0 ) + self.layout.setColumnFixedWidth(1, width) # width of color bar + + for key in ['left','right','top','bottom']: + self.showAxis(key) + axis = self.getAxis(key) + axis.setZValue(1) + # select main axis: + if self.horizontal and key == 'bottom': + self.axis = axis + elif not self.horizontal and key == 'right': + self.axis = axis + self.axis.setWidth(45) + else: # show other axes to create frame + axis.setStyle( showValues=False, tickLength=0 ) + self.axis.setStyle( showValues=True ) + self.axis.unlinkFromView() + self.axis.setRange( self.values[0], self.values[1] ) + + self.bar = ImageItem() + if self.horizontal: + self.bar.setImage( np.linspace(0, 1, 256).reshape( (-1,1) ) ) + if label is not None: self.getAxis('bottom').setLabel(label) + else: + self.bar.setImage( np.linspace(0, 1, 256).reshape( (1,-1) ) ) + if label is not None: self.getAxis('left').setLabel(label) + self.addItem(self.bar) + if cmap is not None: self.setCmap(cmap) + + if interactive: + if self.horizontal: + align = 'vertical' + else: + align = 'horizontal' + self.region = LinearRegionItem( + [63, 191], align, swapMode='block', + # span=(0.15, 0.85), # limited span looks better, but disables grabbing the region + pen=pen, brush=fn.mkBrush(None), hoverPen=hoverPen, hoverBrush=hoverBrush ) + self.region.setZValue(1000) + self.region.lines[0].addMarker('<|>', size=6) + self.region.lines[1].addMarker('<|>', size=6) + self.region.sigRegionChanged.connect(self._regionChanging) + self.region.sigRegionChangeFinished.connect(self._regionChanged) + self.addItem(self.region) + self.region_changed_enable = True + self.region.setRegion( (63, 191) ) # place handles at 25% and 75% locations + else: + self.region = None + self.region_changed_enable = False + + def setImageItem(self, img, insert_in=None): + """ + assign ImageItem or list of ImageItems to be controlled + + ============== ========================================================================== + **Arguments:** + image ImageItem or list of [ImageItem, ImageItem, ...] that will be set to the + color map of the ColorBarItem. In interactive mode, the levels of all + assigned ImageItems will be controlled simultaneously. + insert_in If a PlotItem is given, the color bar is inserted on the right or bottom + of the plot + ============== ========================================================================== + """ + try: + self.img_list = [ weakref.ref(item) for item in img ] + except TypeError: # failed to iterate, make a single-item list + self.img_list = [ weakref.ref( img ) ] + if insert_in is not None: + if self.horizontal: + insert_in.layout.addItem( self, 5, 1 ) # insert in layout below bottom axis + insert_in.layout.setRowFixedHeight(4, 10) # enforce some space to axis above + else: + insert_in.layout.addItem( self, 2, 5 ) # insert in layout after right-hand axis + insert_in.layout.setColumnFixedWidth(4, 5) # enforce some space to axis on the left + self._update_items( update_cmap = True ) + + def setCmap(self, cmap): + """ + sets a ColorMap object to determine the ColorBarItem's look-up table. The same + look-up table is applied to any assigned ImageItem. + """ + self.cmap = cmap + self._update_items( update_cmap = True ) + + def setLevels(self, values=None, low=None, high=None ): + """ + Sets the displayed range of levels as specified. + + ============== =========================================================================== + **Arguments:** + values Specify levels by tuple (low, high). Either value can be None to leave + to previous value unchanged. Takes precedence over low and high parameters. + low new low level to be applied to color bar and assigned images + high new high level to be applied to color bar and assigned images + ============== =========================================================================== + """ + if values is not None: # values setting takes precendence + low, high = values + lo_new, hi_new = low, high + lo_cur, hi_cur = self.values + # allow None values to preserve original values: + if lo_new is None: lo_new = lo_cur + if hi_new is None: hi_new = hi_cur + if lo_new > hi_new: # prevent reversal + lo_new = hi_new = (lo_new + hi_new) / 2 + # clip to limits if set: + if self.lo_lim is not None and lo_new < self.lo_lim: lo_new = self.lo_lim + if self.hi_lim is not None and hi_new > self.hi_lim: hi_new = self.hi_lim + self.values = self.lo_prv, self.hi_prv = (lo_new, hi_new) + self._update_items() + + def levels(self): + """ returns the currently set levels as the tuple (low, high). """ + return self.values + + def _update_items(self, update_cmap=False): + """ internal: update color maps for bar and assigned ImageItems """ + # update color bar: + self.axis.setRange( self.values[0], self.values[1] ) + if update_cmap and self.cmap is not None: + self.bar.setLookupTable( self.cmap.getLookupTable(nPts=256) ) + # update assigned ImageItems, too: + for img_weakref in self.img_list: + img = img_weakref() + if img is None: continue # dereference weakref + img.setLevels( self.values ) # (min,max) tuple + if update_cmap and self.cmap is not None: + img.setLookupTable( self.cmap.getLookupTable(nPts=256) ) + + def _regionChanged(self): + """ internal: snap adjusters back to default positions on release """ + self.lo_prv, self.hi_prv = self.values + self.region_changed_enable = False # otherwise this affects the region again + self.region.setRegion( (63, 191) ) + self.region_changed_enable = True + self.sigLevelsChangeFinished.emit(self) + + def _regionChanging(self): + """ internal: recalculate levels based on new position of adjusters """ + if not self.region_changed_enable: return + bot, top = self.region.getRegion() + bot = ( (bot - 63) / 64 ) # -1 to +1 over half-bar range + top = ( (top - 191) / 64 ) # -1 to +1 over half-bar range + bot = math.copysign( bot**2, bot ) # quadratic behaviour for sensitivity to small changes + top = math.copysign( top**2, top ) + # These are the new values if adjuster is released now, rate of change depends on original separation + span_prv = self.hi_prv - self.lo_prv # previous span of values + hi_new = self.hi_prv + (span_prv + 2*self.rounding) * top # make sure that we can always + lo_new = self.lo_prv + (span_prv + 2*self.rounding) * bot # reach 2x the minimal step + # Alternative model with speed depending on level magnitude: + # mean_val = abs(self.lo_prv) + abs(self.hi_prv) / 2 + # hi_new = self.hi_prv + (mean_val + 2*self.rounding) * top # make sure that we can always + # lo_new = self.lo_prv + (mean_val + 2*self.rounding) * bot # reach 2x the minimal step + + if self.hi_lim is not None and hi_new > self.hi_lim: # limit maximum value + # print('lim +') + hi_new = self.hi_lim + if lo_new > hi_new - span_prv: # avoid collapsing the span against top or bottom limits + lo_new = hi_new - span_prv + if self.lo_lim is not None and lo_new < self.lo_lim: # limit minimum value + # print('lim -') + lo_new = self.lo_lim + if hi_new < lo_new + span_prv: # avoid collapsing the span against top or bottom limits + hi_new = lo_new + span_prv + if lo_new + self.rounding > hi_new: # do not allow less than one "rounding" unit of span + # print('lim X') + if bot == 0: hi_new = lo_new + self.rounding + elif top == 0: lo_new = hi_new - self.rounding + else: + lo_new = (lo_new + hi_new - self.rounding) / 2 + hi_new = lo_new + self.rounding + lo_new = self.rounding * round( lo_new/self.rounding ) + hi_new = self.rounding * round( hi_new/self.rounding ) + # if hi_new == lo_new: hi_new = lo_new + self.rounding # hack solution if axis span still collapses + self.values = (lo_new, hi_new) + self._update_items() + self.sigLevelsChanged.emit(self) diff --git a/pyqtgraph/graphicsItems/LinearRegionItem.py b/pyqtgraph/graphicsItems/LinearRegionItem.py index 6f888613..106d7490 100644 --- a/pyqtgraph/graphicsItems/LinearRegionItem.py +++ b/pyqtgraph/graphicsItems/LinearRegionItem.py @@ -43,8 +43,9 @@ class LinearRegionItem(GraphicsObject): **Arguments:** values A list of the positions of the lines in the region. These are not limits; limits can be set by specifying bounds. - orientation Options are 'vertical' or 'horizontal', indicating the - The default is 'vertical', indicating that the + orientation Options are 'vertical' or 'horizontal' + The default is 'vertical', indicating that the region is bounded + by vertical lines. brush Defines the brush that fills the region. Can be any arguments that are valid for :func:`mkBrush `. Default is transparent blue.