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
This commit is contained in:
Nils Nemitz 2021-04-06 12:50:52 +09:00 committed by GitHub
parent 929f3bda0d
commit 758c038411
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 366 additions and 11 deletions

100
examples/ColorBarItem.py Normal file
View File

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

View File

@ -1,14 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
This example demonstrates the use of ImageView, which is a high-level widget for This example demonstrates generating ColorMap objects from external data.
displaying and analyzing 2D and 3D data. ImageView provides: It displays the full list of color maps available as local files or by import
from Matplotlib or ColorCET.
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)
""" """
## Add path to library (just for examples; you do not need this) ## Add path to library (just for examples; you do not need this)
import initExample import initExample

View File

@ -38,6 +38,7 @@ examples = OrderedDict([
('FillBetweenItem', 'FillBetweenItem.py'), ('FillBetweenItem', 'FillBetweenItem.py'),
('ImageItem - video', 'ImageItem.py'), ('ImageItem - video', 'ImageItem.py'),
('ImageItem - draw', 'Draw.py'), ('ImageItem - draw', 'Draw.py'),
('ColorBarItem','ColorBarItem.py'),
('Non-uniform Image', 'NonUniformImage.py'), ('Non-uniform Image', 'NonUniformImage.py'),
('Region-of-Interest', 'ROIExamples.py'), ('Region-of-Interest', 'ROIExamples.py'),
('Bar Graph', 'BarGraphItem.py'), ('Bar Graph', 'BarGraphItem.py'),

View File

@ -223,6 +223,7 @@ from .graphicsItems.GraphicsWidgetAnchor import *
from .graphicsItems.PlotCurveItem import * from .graphicsItems.PlotCurveItem import *
from .graphicsItems.ButtonItem import * from .graphicsItems.ButtonItem import *
from .graphicsItems.GradientEditorItem import * from .graphicsItems.GradientEditorItem import *
from .graphicsItems.ColorBarItem import *
from .graphicsItems.MultiPlotItem import * from .graphicsItems.MultiPlotItem import *
from .graphicsItems.ErrorBarItem import * from .graphicsItems.ErrorBarItem import *
from .graphicsItems.IsocurveItem import * from .graphicsItems.IsocurveItem import *

View File

@ -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 <pyqtgraph.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)

View File

@ -43,8 +43,9 @@ class LinearRegionItem(GraphicsObject):
**Arguments:** **Arguments:**
values A list of the positions of the lines in the region. These are not values A list of the positions of the lines in the region. These are not
limits; limits can be set by specifying bounds. limits; limits can be set by specifying bounds.
orientation Options are 'vertical' or 'horizontal', indicating the orientation Options are 'vertical' or 'horizontal'
The default is 'vertical', indicating that the 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 brush Defines the brush that fills the region. Can be any arguments that
are valid for :func:`mkBrush <pyqtgraph.mkBrush>`. Default is are valid for :func:`mkBrush <pyqtgraph.mkBrush>`. Default is
transparent blue. transparent blue.