diff --git a/doc/source/colormap.rst b/doc/source/colormap.rst index 86ffe4a2..d4f93118 100644 --- a/doc/source/colormap.rst +++ b/doc/source/colormap.rst @@ -1,8 +1,81 @@ -ColorMap -======== +Color Maps +========== + +A color map defines a relationship between scalar data values and a range of colors. Color maps are +commonly used to generate false color images, color scatter-plot points, and illustrate the height +of surface plots. + +PyQtGraph's :class:`~pyqtgraph.ColorMap` can conveniently be applied to images and interactively +adjusted by using :class:`~pyqtgraph.ColorBarItem`. +To provide interactively user-defined color mappings, see +:class:`~pyqtgraph.GradientEditorItem` and :class:`~pyqtgraph.GradientWidget`, which wraps it. +:class:`~pyqtgraph.GradientEditorItem` combines the editing with a histogram and controls for +interactively adjusting image levels. + +ColorMap can also be used a convenient source of colors from a consistent palette or to generate +QPen and QBrush objects used to draw lines and fills that are colored according to their values +along the horizontal or vertical axis. + + +Sources for color maps +---------------------- + +Color maps can be user defined by assigning a number of *stops* over the range of 0 to 1. A color +is given for each stop, and the in-between values are generated by interpolation. + +When map colors directly represent values, an improperly designed map can obscure detail over +certain ranges of values, while creating false detail in others. PyQtGraph includes the +perceptually uniform color maps provided by the +`Colorcet project `_. Color maps can also be imported from the +``colorcet`` library or from ``matplotlib``, if either of these is installed. + +To see all available color maps, please run the `ColorMap` demonstration available in the suite of +:ref:`examples`. + +Examples +-------- + +False color display of a 2D data set. Display levels are controlled by +a :class:`ColorBarItem `: + +.. literalinclude:: images/gen_example_false_color_image.py + :lines: 18-28 + :dedent: 8 + +Using QtGui.QPen and QtGui.QBrush to color plots according to the plotted value: + +.. literalinclude:: images/gen_example_gradient_plot.py + :lines: 16-33 + :dedent: 8 + +.. image:: + images/example_false_color_image.png + :width: 49% + :alt: Example of a false color image + +.. image:: + images/example_gradient_plot.png + :width: 49% + :alt: Example of drawing and filling plots with gradients + +The use of color maps is also demonstrated in the `ImageView`, `Color Gradient Plots` and `ColorBarItem` +:ref:`examples`. + + +API Reference +------------- + +.. autofunction:: pyqtgraph.colormap.listMaps + +.. autofunction:: pyqtgraph.colormap.get + +.. autofunction:: pyqtgraph.colormap.getFromMatplotlib + +.. autofunction:: pyqtgraph.colormap.getFromColorcet + + .. autoclass:: pyqtgraph.ColorMap :members: .. automethod:: pyqtgraph.ColorMap.__init__ - diff --git a/doc/source/exporting.rst b/doc/source/exporting.rst index 0bb1c82a..502bd8e9 100644 --- a/doc/source/exporting.rst +++ b/doc/source/exporting.rst @@ -41,23 +41,33 @@ Export Formats Exporting from the API ---------------------- -To export a file programatically, follow this example:: +To export a file programatically, follow this example: - import pyqtgraph as pg - import pyqtgraph.exporters +.. code-block:: python + + import pyqtgraph as pg + import pyqtgraph.exporters - # generate something to export - plt = pg.plot([1,5,2,4,3]) + # generate something to export + plt = pg.plot([1,5,2,4,3]) - # create an exporter instance, as an argument give it - # the item you wish to export - exporter = pg.exporters.ImageExporter(plt.plotItem) + # create an exporter instance, as an argument give it + # the item you wish to export + exporter = pg.exporters.ImageExporter(plt.plotItem) - # set export parameters if needed - exporter.parameters()['width'] = 100 # (note this also affects height parameter) + # set export parameters if needed + exporter.parameters()['width'] = 100 # (note this also affects height parameter) - # save to file - exporter.export('fileName.png') + # save to file + exporter.export('fileName.png') + +To export the overall layout of a GraphicsLayoutWidget `grl`, the exporter initialization is + +.. code-block:: python + + exporter = pg.exporters.ImageExporter( grl.scene() ) + +instead. Exporting 3D Graphics @@ -69,5 +79,3 @@ generate an image from a GLViewWidget by using QGLWidget.grabFrameBuffer or QGLW glview.grabFrameBuffer().save('fileName.png') See the Qt documentation for more information. - - diff --git a/doc/source/graphicsItems/colorbaritem.rst b/doc/source/graphicsItems/colorbaritem.rst new file mode 100644 index 00000000..e4ec490b --- /dev/null +++ b/doc/source/graphicsItems/colorbaritem.rst @@ -0,0 +1,7 @@ +ColorBarItem +============ + +.. autoclass:: pyqtgraph.ColorBarItem + :members: + + .. automethod:: pyqtgraph.ColorBarItem.__init__ diff --git a/doc/source/graphicsItems/index.rst b/doc/source/graphicsItems/index.rst index 54bf12f2..7b497ac3 100644 --- a/doc/source/graphicsItems/index.rst +++ b/doc/source/graphicsItems/index.rst @@ -12,6 +12,7 @@ Contents: plotdataitem plotitem imageitem + colorbaritem pcolormeshitem graphitem viewbox diff --git a/doc/source/images/example_false_color_image.png b/doc/source/images/example_false_color_image.png new file mode 100644 index 00000000..971d58d3 Binary files /dev/null and b/doc/source/images/example_false_color_image.png differ diff --git a/doc/source/images/example_gradient_plot.png b/doc/source/images/example_gradient_plot.png new file mode 100644 index 00000000..f5c87350 Binary files /dev/null and b/doc/source/images/example_gradient_plot.png differ diff --git a/doc/source/images/gen_example_false_color_image.py b/doc/source/images/gen_example_false_color_image.py new file mode 100644 index 00000000..22e6e006 --- /dev/null +++ b/doc/source/images/gen_example_false_color_image.py @@ -0,0 +1,45 @@ +""" +generates 'example_false_color_image.png' +""" +import numpy as np +import pyqtgraph as pg +import pyqtgraph.exporters as exp +from pyqtgraph.Qt import QtCore, QtGui, QtWidgets, mkQApp + +class MainWindow(pg.GraphicsLayoutWidget): + """ example application main window """ + def __init__(self): + super().__init__() + self.resize(420,400) + self.show() + + plot = self.addPlot() # title="non-interactive") + + # prepare demonstration data: + 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) ) + + # Example: False color image with interactive level adjustment + img = pg.ImageItem(image=noisy_data) # create monochrome image from demonstration data + plot.addItem( img ) # add to PlotItem 'plot' + cm = pg.colormap.get('CET-L9') # prepare a linear color map + bar = pg.ColorBarItem( values= (0, 20_000), cmap=cm ) # prepare interactive color bar + # Have ColorBarItem control colors of img and appear in 'plot': + bar.setImageItem( img, insert_in=plot ) + + self.timer = pg.QtCore.QTimer( singleShot=True ) + self.timer.timeout.connect(self.export) + self.timer.start(100) + + def export(self): + print('exporting') + exporter = exp.ImageExporter(self.scene()) + exporter.parameters()['width'] = 420 + exporter.export('example_false_color_image.png') + +mkQApp("False color image example") +main_window = MainWindow() + +## Start Qt event loop +if __name__ == '__main__': + mkQApp().exec_() diff --git a/doc/source/images/gen_example_gradient_plot.py b/doc/source/images/gen_example_gradient_plot.py new file mode 100644 index 00000000..be5c8047 --- /dev/null +++ b/doc/source/images/gen_example_gradient_plot.py @@ -0,0 +1,55 @@ +""" +generates 'example_gradient_plot.png' +""" +import numpy as np +import pyqtgraph as pg +import pyqtgraph.exporters as exp +from pyqtgraph.Qt import QtCore, QtGui, QtWidgets, mkQApp + +class MainWindow(pg.GraphicsLayoutWidget): + """ example application main window """ + def __init__(self): + super().__init__() + self.resize(420,400) + self.show() + + # Prepare demonstration data + raw = np.linspace(0.0, 2.0, 400) + y_data1 = ( (raw+0.1)%1 ) ** 4 + y_data2 = ( (raw+0.1)%1 ) ** 4 - ( (raw+0.6)%1 ) ** 4 + + # Example 1: Gradient pen + cm = pg.colormap.get('CET-L17') # prepare a linear color map + cm.reverse() # reverse it to put light colors at the top + pen = cm.getPen( span=(0.0,1.0), width=5 ) # gradient from blue (y=0) to white (y=1) + # plot a curve drawn with a pen colored according to y value: + curve1 = pg.PlotDataItem( y=y_data1, pen=pen ) + + # Example 2: Gradient brush + cm = pg.colormap.get('CET-D1') # prepare a diverging color map + cm.setMappingMode('diverging') # set mapping mode + brush = cm.getBrush( span=(-1., 1.) ) # gradient from blue at -1 to red at +1 + # plot a curve that is filled to zero with the gradient brush: + curve2 = pg.PlotDataItem( y=y_data2, pen='w', brush=brush, fillLevel=0.0 ) + + for idx, curve in enumerate( (curve1, curve2) ): + plot = self.addPlot(row=idx, col=0) + plot.getAxis('left').setWidth(25) + plot.addItem( curve ) + + self.timer = pg.QtCore.QTimer( singleShot=True ) + self.timer.timeout.connect(self.export) + self.timer.start(100) + + def export(self): + print('exporting') + exporter = exp.ImageExporter(self.scene()) + exporter.parameters()['width'] = 420 + exporter.export('example_gradient_plot.png') + +mkQApp("Gradient plotting example") +main_window = MainWindow() + +## Start Qt event loop +if __name__ == '__main__': + mkQApp().exec_() diff --git a/examples/ColorGradientPlots.py b/examples/ColorGradientPlots.py new file mode 100644 index 00000000..2a46009d --- /dev/null +++ b/examples/ColorGradientPlots.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" +This example demonstrates plotting with color gradients. +It also shows multiple plots with timed rolling updates +""" +# Add path to library (just for examples; you do not need this) +import initExample + +import numpy as np +import time +from pyqtgraph.Qt import QtCore, QtGui, QtWidgets, mkQApp +import pyqtgraph as pg + +class DataSource(object): + """ source of buffered demonstration data """ + def __init__(self, sample_rate=200., signal_period=0.55, negative_period=None, max_length=300): + """ prepare, but don't start yet """ + self.rate = sample_rate + self.period = signal_period + self.neg_period = negative_period + self.start_time = 0. + self.sample_idx = 0 # number of next sample to be taken + + def start(self, timestamp): + """ start acquiring simulated data """ + self.start_time = timestamp + self.sample_idx = 0 + + def get_data(self, timestamp, max_length=6000): + """ return all data acquired since last get_data call """ + next_idx = int( (timestamp - self.start_time) * self.rate ) + if next_idx - self.sample_idx > max_length: + self.sample_idx = next_idx - max_length # catch up if needed + # create some mildly intersting data: + sample_phases = np.arange( self.sample_idx, next_idx, dtype=np.float64 ) + self.sample_idx = next_idx + + sample_phase_pos = sample_phases / (self.period*self.rate) + sample_phase_pos %= 1.0 + if self.neg_period is None: + return sample_phase_pos**4 + sample_phase_neg = sample_phases / (self.neg_period*self.rate) + sample_phase_neg %= 1.0 + return sample_phase_pos**4 - sample_phase_neg**4 + +class MainWindow(pg.GraphicsLayoutWidget): + """ example application main window """ + def __init__(self): + super().__init__() + self.setWindowTitle('pyqtgraph example: gradient plots') + self.resize(800,800) + self.show() + + layout = self # we are using a GraphicsLayoutWidget as main window for convenience + cm = pg.colormap.get('CET-L17') + cm.reverse() + pen0 = cm.getPen( span=(0.0,1.0), width=5 ) + curve0 = pg.PlotDataItem(pen=pen0 ) + comment0 = 'Clipped color map applied to vertical axis' + + cm = pg.colormap.get('CET-D1') + cm.setMappingMode('diverging') + brush = cm.getBrush( span=(-1., 1.), orientation='vertical' ) + curve1 = pg.PlotDataItem(pen='w', brush=brush, fillLevel=0.0 ) + comment1 = 'Diverging vertical color map used as brush' + + cm = pg.colormap.get('CET-L17') + cm.setMappingMode('mirror') + pen2 = cm.getPen( span=(400.0,600.0), width=5, orientation='horizontal' ) + curve2 = pg.PlotDataItem(pen=pen2 ) + comment2 = 'Mirrored color map applied to horizontal axis' + + cm = pg.colormap.get('CET-C2') + cm.setMappingMode('repeat') + pen3 = cm.getPen( span=(100, 200), width=5, orientation='horizontal' ) + curve3 = pg.PlotDataItem(pen=pen3 ) # vertical diverging fill + comment3 = 'Repeated color map applied to horizontal axis' + + curves = (curve0, curve1, curve2, curve3) + comments = (comment0, comment1, comment2, comment3) + + length = int( 3.0 * 200. ) # length of display in samples + self.top_plot = None + for idx, (curve, comment) in enumerate( zip(curves,comments) ): + plot = layout.addPlot(row=idx+1, col=0) + text = pg.TextItem( comment, anchor=(0,1) ) + text.setPos(0.,1.) + if self.top_plot is None: + self.top_plot = plot + else: + plot.setXLink( self.top_plot ) + plot.addItem( curve ) + plot.addItem( text ) + plot.setXRange( 0, length ) + if idx != 1: plot.setYRange( 0. , 1.1 ) + else : plot.setYRange( -1. , 1.2 ) # last plot include positive/negative data + + self.traces = ( + {'crv': curve0, 'buf': np.zeros( length ), 'ptr':0, 'ds': DataSource( signal_period=0.55 ) }, + {'crv': curve1, 'buf': np.zeros( length ), 'ptr':0, 'ds': DataSource( signal_period=0.61, negative_period=0.55 ) }, + {'crv': curve2, 'buf': np.zeros( length ), 'ptr':0, 'ds': DataSource( signal_period=0.65 ) }, + {'crv': curve3, 'buf': np.zeros( length ), 'ptr':0, 'ds': DataSource( signal_period=0.52 ) }, + ) + self.timer = QtCore.QTimer(timerType=QtCore.Qt.PreciseTimer) + self.timer.timeout.connect(self.update) + timestamp = time.perf_counter() + for dic in self.traces: + dic['ds'].start( timestamp ) + self.last_update = time.perf_counter() + self.mean_dt = None + self.timer.start(33) + + def update(self): + """ called by timer at 30 Hz """ + timestamp = time.perf_counter() + # measure actual update rate: + dt = timestamp - self.last_update + if self.mean_dt is None: + self.mean_dt = dt + else: + self.mean_dt = 0.95 * self.mean_dt + 0.05 * dt # average over fluctuating measurements + self.top_plot.setTitle( + 'refresh: {:0.1f}ms -> {:0.1f} fps'.format( 1000*self.mean_dt, 1/self.mean_dt ) + ) + # handle rolling buffer: + self.last_update = timestamp + for dic in self.traces: + new_data = dic['ds'].get_data( timestamp ) + idx_a = dic['ptr'] + idx_b = idx_a + len( new_data ) + len_buffer = dic['buf'].shape[0] + if idx_b < len_buffer: # data does not cross buffer boundary + dic['buf'][idx_a:idx_b] = new_data + else: # part of the new data needs to roll over to beginning of buffer + len_1 = len_buffer - idx_a # this many elements still fit + dic['buf'][idx_a:idx_a+len_1] = new_data[:len_1] # first part of data at end + idx_b = len(new_data) - len_1 + dic['buf'][0:idx_b] = new_data[len_1:] # second part of data at re-start + dic['ptr'] = idx_b + dic['crv'].setData( dic['buf'] ) + +mkQApp("Gradient plotting example") +main_window = MainWindow() + +## Start Qt event loop +if __name__ == '__main__': + mkQApp().exec_() diff --git a/examples/utils.py b/examples/utils.py index 65f764d6..346c6bca 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -13,6 +13,7 @@ examples = OrderedDict([ ('Timestamps on x axis', 'DateAxisItem.py'), ('Image Analysis', 'imageAnalysis.py'), ('Color Maps', 'colorMaps.py'), + ('Color Gradient Plots', 'ColorGradientPlots.py'), ('ViewBox Features', Namespace(filename='ViewBoxFeatures.py', recommended=True)), ('Dock widgets', 'dockarea.py'), ('Console', 'ConsoleWidget.py'), diff --git a/pyqtgraph/colormap.py b/pyqtgraph/colormap.py index 3c6c0ada..d508a8a0 100644 --- a/pyqtgraph/colormap.py +++ b/pyqtgraph/colormap.py @@ -9,15 +9,22 @@ _mapCache = {} def listMaps(source=None): """ - Warning, highly experimental, subject to change. + .. warning:: Experimental, subject to change. - List available color maps - =============== ================================================================= - **Arguments:** - source 'matplotlib' lists maps that can be imported from MatPlotLib - 'colorcet' lists maps that can be imported from ColorCET - otherwise local maps are listed - =============== ================================================================= + List available color maps. + + Parameters + ---------- + source: str, optional + Color map source. If omitted, locally stored maps are listed. Otherwise + + - 'matplotlib' lists maps that can be imported from Matplotlib + - 'colorcet' lists maps that can be imported from ColorCET + + Returns + ------- + list of str + Known color map names. """ if source is None: pathname = path.join(path.dirname(__file__), 'colors','maps') @@ -32,7 +39,7 @@ def listMaps(source=None): import matplotlib.pyplot as mpl_plt list_of_maps = mpl_plt.colormaps() return list_of_maps - except ModuleNotFoundError: + except ModuleNotFoundError: return [] elif source.lower() == 'colorcet': try: @@ -40,23 +47,34 @@ def listMaps(source=None): list_of_maps = list( colorcet.palette.keys() ) list_of_maps.sort() return list_of_maps - except ModuleNotFoundError: + except ModuleNotFoundError: return [] - return [] + return [] def get(name, source=None, skipCache=False): """ - Warning, highly experimental, subject to change. + .. warning:: Experimental, subject to change. - Returns a ColorMap object from a local definition or imported from another library - =============== ================================================================= - **Arguments:** - name Name of color map. Can be a path to a defining file. - source 'matplotlib' imports a map defined by Matplotlib - 'colorcet' imports a maps defined by ColorCET - otherwise local data is used - =============== ================================================================= + Returns a ColorMap object from a local definition or imported from another library. + The generated ColorMap objects are cached for fast repeated access. + + Parameters + ---------- + name: str + Name of color map. In addition to the included maps, this can also + be a path to a file in the local folder. See the files in the + ``pyqtgraph/colors/maps/`` folder for examples of the format. + source: str, optional + If omitted, a locally stored map is returned. Otherwise + + - 'matplotlib' imports a map defined by Matplotlib. + - 'colorcet' imports a map defined by ColorCET. + + skipCache: bool, optional + If `skipCache=True`, the internal cache is skipped and a new + ColorMap object is generated. This can load an unaltered copy + when the previous ColorMap object has been modified. """ if not skipCache and name in _mapCache: return _mapCache[name] @@ -95,7 +113,7 @@ def _getFromFile(name): color_tuple = tuple( [ int(255*float(c)+0.5) for c in comp ] ) else: hex_str = parts[0] - if hex_str[0] == '#': + if hex_str[0] == '#': hex_str = hex_str[1:] # strip leading # if len(hex_str) < 3: continue # not enough information if len(hex_str) == 3: # parse as abbreviated RGB @@ -109,13 +127,16 @@ def _getFromFile(name): # end of line reading loop # end of open cm = ColorMap( - pos=np.linspace(0.0, 1.0, len(color_list)), + pos=np.linspace(0.0, 1.0, len(color_list)), color=color_list) #, names=color_names) _mapCache[name] = cm return cm def getFromMatplotlib(name): - """ import colormap from matplotlib definition """ + """ + Generates a ColorMap object from a Matplotlib definition. + Same as ``colormap.get(name, source='matplotlib')``. + """ # inspired and informed by "mpl_cmaps_in_ImageItem.py", published by Sebastian Hoefer at # https://github.com/honkomonk/pyqtgraph_sandbox/blob/master/mpl_cmaps_in_ImageItem.py try: @@ -147,7 +168,7 @@ def getFromMatplotlib(name): col_data[:,-1] = np.linspace(0., 1., 64) for idx, key in enumerate(['red','green','blue']): col_data[:,idx] = np.clip( data[key](col_data[:,-1]), 0, 1) - cm = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) + cm = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) elif hasattr(col_map, 'colors'): # handle ListedColormap col_data = np.array(col_map.colors) cm = ColorMap(pos=np.linspace(0.0, 1.0, col_data.shape[0]), color=255*col_data[:,:3]+0.5 ) @@ -156,7 +177,7 @@ def getFromMatplotlib(name): return cm def getFromColorcet(name): - """ import colormap from colorcet definition """ + """ Generates a ColorMap object from a colorcet definition. Same as ``colormap.get(name, source='colorcet')``. """ try: import colorcet except ModuleNotFoundError: @@ -165,14 +186,14 @@ def getFromColorcet(name): color_list = [] for hex_str in color_strings: if hex_str[0] != '#': continue - if len(hex_str) != 7: + if len(hex_str) != 7: raise ValueError('Invalid color string '+str(hex_str)+' in colorcet import.') color_tuple = tuple( bytes.fromhex( hex_str[1:] ) ) color_list.append( color_tuple ) - if len(color_list) == 0: + if len(color_list) == 0: return None cm = ColorMap( - pos=np.linspace(0.0, 1.0, len(color_list)), + pos=np.linspace(0.0, 1.0, len(color_list)), color=color_list) #, names=color_names) _mapCache[name] = cm return cm @@ -180,22 +201,20 @@ def getFromColorcet(name): class ColorMap(object): """ - A ColorMap defines a relationship between a scalar value and a range of colors. - ColorMaps are commonly used for false-coloring monochromatic images, coloring - scatter-plot points, and coloring surface plots by height. + ColorMap(pos, color, mapping=ColorMap.CLIP) - Each color map is defined by a set of colors, each corresponding to a - particular scalar value. For example: + ColorMap stores a mapping of specific data values to colors, for example: - | 0.0 -> black - | 0.2 -> red - | 0.6 -> yellow - | 1.0 -> white + | 0.0 → black + | 0.2 → red + | 0.6 → yellow + | 1.0 → white - The colors for intermediate values are determined by interpolating between + The colors for intermediate values are determined by interpolating between the two nearest colors in RGB color space. - To provide user-defined color mappings, see :class:`GradientWidget `. + A ColorMap object provides access to the interpolated colors by indexing with a float value: + ``cm[0.5]`` returns a QColor corresponding to the center of ColorMap `cm`. """ ## mapping modes @@ -221,29 +240,26 @@ class ColorMap(object): def __init__(self, pos, color, mapping=CLIP, mode=None): #, names=None): """ - =============== ======================================================================= - **Arguments:** - pos Array of positions where each color is defined - color Array of colors. - Values are interpreted via - :func:`mkColor() `. - mapping Mapping mode (ColorMap.CLIP, REPEAT, MIRROR or DIVERGING) - controlling mapping of relative index to color. String representations - 'clip', 'repeat', 'mirror' or 'diverging' are also accepted. - CLIP maps colors to [0.0;1.0] and is the default. - REPEAT maps colors to repeating intervals [0.0;1.0];[1.0-2.0],... - MIRROR maps colors to [0.0;-1.0] and [0.0;+1.0] identically - DIVERGING maps colors to [-1.0;+1.0] - =============== ======================================================================= + __init__(pos, color, mapping=ColorMap.CLIP) + + Parameters + ---------- + pos: array_like of float in range 0 to 1 + Assigned positions of specified colors + color: array_like of colors + List of colors, interpreted via :func:`mkColor() `. + mapping: str or int, optional + Controls how values outside the 0 to 1 range are mapped to colors. + See :func:`setMappingMode() ` for details. + + The default of `ColorMap.CLIP` continues to show + the colors assigned to 0 and 1 for all values below or above this range, respectively. """ if mode is not None: warnings.warn( "'mode' argument is deprecated and does nothing.", DeprecationWarning, stacklevel=2 ) - if isinstance(mapping, str): - mapping = self.enumMap[mapping.lower()] - self.pos = np.array(pos) order = np.argsort(self.pos) self.pos = self.pos[order] @@ -252,39 +268,86 @@ class ColorMap(object): axis = -1, arr = color, )[order] + + self.mapping_mode = self.CLIP # default to CLIP mode + if mapping is not None: + self.setMappingMode( mapping ) + self.stopsCache = {} - self.mapping_mode = self.CLIP # default to CLIP mode + def setMappingMode(self, mapping): + """ + Sets the way that values outside of the range 0 to 1 are mapped to colors. + + Parameters + ---------- + mapping: int or str + Sets mapping mode to + + - `ColorMap.CLIP` or 'clip': Values are clipped to the range 0 to 1. ColorMap defaults to this. + - `ColorMap.REPEAT` or 'repeat': Colors repeat cyclically, i.e. range 1 to 2 repeats the colors for 0 to 1. + - `ColorMap.MIRROR` or 'mirror': The range 0 to -1 uses same colors (in reverse order) as 0 to 1. + - `ColorMap.DIVERGING` or 'diverging': Colors are mapped to -1 to 1 such that the central value appears at 0. + """ + if isinstance(mapping, str): + mapping = self.enumMap[mapping.lower()] if mapping in [self.CLIP, self.REPEAT, self.DIVERGING, self.MIRROR]: self.mapping_mode = mapping # only allow defined values - self.stopsCache = {} + else: + raise ValueError("Undefined mapping type '{:s}'".format(str(mapping)) ) def __getitem__(self, key): """ Convenient shorthand access to palette colors """ - if isinstance(key, int): # access by color index + if isinstance(key, int): # access by color index return self.getByIndex(key) # otherwise access by map try: # accept any numerical format that converts to float - float_idx = float(key) + float_idx = float(key) return self.mapToQColor(float_idx) except ValueError: pass return None + def reverse(self): + """ + Reverses the color map, so that the color assigned to a value of 1 now appears at 0 and vice versa. + This is convenient to adjust imported color maps. + """ + self.pos = 1.0 - np.flip( self.pos ) + self.color = np.flip( self.color, axis=0 ) + self.stopsCache = {} + def map(self, data, mode=BYTE): """ - Return an array of colors corresponding to the values in *data*. - Data must be either a scalar position or an array (any shape) of positions. - - The *mode* argument determines the type of data returned: + map(data, mode=ColorMap.BYTE) - =========== =============================================================== - byte (default) Values are returned as 0-255 unsigned bytes. - float Values are returned as 0.0-1.0 floats. - qcolor Values are returned as an array of QColor objects. - =========== =============================================================== + Returns an array of colors corresponding to a single value or an array of values. + Data must be either a scalar position or an array (any shape) of positions. + + Parameters + ---------- + data: float or array_like of float + Scalar value(s) to be mapped to colors + + mode: str or int, optional + Determines return format: + + - `ColorMap.BYTE` or 'byte': Colors are returned as 0-255 unsigned bytes. (default) + - `ColorMap.FLOAT` or 'float': Colors are returned as 0.0-1.0 floats. + - `ColorMap.QCOLOR` or 'qcolor': Colors are returned as QColor objects. + + Returns + ------- + array of color.dtype + for `ColorMap.BYTE` or `ColorMap.FLOAT`: + + RGB values for each `data` value, arranged in the same shape as `data`. + list of QColor objects + for `ColorMap.QCOLOR`: + + Colors for each `data` value as Qcolor objects. """ if isinstance(mode, str): mode = self.enumMap[mode.lower()] - + if mode == self.QCOLOR: pos, color = self.getStops(self.BYTE) else: @@ -316,7 +379,7 @@ class ColorMap(object): return [QtGui.QColor(*x) for x in interp] else: return interp - + def mapToQColor(self, data): """Convenience function; see :func:`map() `.""" return self.map(data, mode=self.QCOLOR) @@ -330,37 +393,115 @@ class ColorMap(object): return self.map(data, mode=self.FLOAT) def getByIndex(self, idx): - """Retrieve palette QColor by index""" + """Retrieve a QColor by the index of the stop it is assigned to.""" return QtGui.QColor( *self.color[idx] ) def getGradient(self, p1=None, p2=None): - """Return a QLinearGradient object spanning from QPoints p1 to p2.""" - if p1 == None: + """ + Returns a QtGui.QLinearGradient corresponding to this ColorMap. + The span and orientiation is given by two points in plot coordinates. + + When no parameters are given for `p1` and `p2`, the gradient is mapped to the + `y` coordinates 0 to 1, unless the color map is defined for a more limited range. + + Parameters + ---------- + p1: QtCore.QPointF, default (0,0) + Starting point (value 0) of the gradient. + p2: QtCore.QPointF, default (dy,0) + End point (value 1) of the gradient. Default parameter `dy` is the span of ``max(pos) - min(pos)`` + over which the color map is defined, typically `dy=1`. + """ + if p1 is None: p1 = QtCore.QPointF(0,0) - if p2 == None: + if p2 is None: p2 = QtCore.QPointF(self.pos.max()-self.pos.min(),0) - g = QtGui.QLinearGradient(p1, p2) - + grad = QtGui.QLinearGradient(p1, p2) + pos, color = self.getStops(mode=self.BYTE) color = [QtGui.QColor(*x) for x in color] - g.setStops(list(zip(pos, color))) - return g + if self.mapping_mode == self.MIRROR: + pos_n = (1. - np.flip(pos)) / 2 + col_n = np.flip( color, axis=0 ) + pos_p = (1. + pos) / 2 + col_p = color + pos = np.concatenate( (pos_n, pos_p) ) + color = np.concatenate( (col_n, col_p) ) + grad.setStops(list(zip(pos, color))) + if self.mapping_mode == self.REPEAT: + grad.setSpread( QtGui.QGradient.RepeatSpread ) + return grad + + def getBrush(self, span=(0.,1.), orientation='vertical'): + """ + Returns a QBrush painting with the color map applied over the selected span of plot values. + When the mapping mode is set to `ColorMap.MIRROR`, the selected span includes the color map twice, + first in reversed order and then normal. + + Parameters + ---------- + span : tuple (min, max), default (0.0, 1.0) + Span of data values covered by the gradient: + + - Color map value 0.0 will appear at `min`, + - Color map value 1.0 will appear at `max`. + + orientation : str, default 'vertical' + Orientiation of the gradient: + + - 'vertical': `span` corresponds to the `y` coordinate. + - 'horizontal': `span` corresponds to the `x` coordinate. + """ + if orientation == 'vertical': + grad = self.getGradient( p1=QtCore.QPointF(0.,span[0]), p2=QtCore.QPointF(0.,span[1]) ) + elif orientation == 'horizontal': + grad = self.getGradient( p1=QtCore.QPointF(span[0],0.), p2=QtCore.QPointF(span[1],0.) ) + else: + raise ValueError("Orientation must be 'vertical' or 'horizontal'") + return QtGui.QBrush(grad) + + def getPen(self, span=(0.,1.), orientation='vertical', width=1.0): + """ + Returns a QPen that draws according to the color map based on vertical or horizontal position. + + Parameters + ---------- + span : tuple (min, max), default (0.0, 1.0) + Span of the data values covered by the gradient: + + - Color map value 0.0 will appear at `min`. + - Color map value 1.0 will appear at `max`. + + orientation : str, default 'vertical' + Orientiation of the gradient: + + - 'vertical' creates a vertical gradient, where `span` corresponds to the `y` coordinate. + - 'horizontal' creates a horizontal gradient, where `span` correspnds to the `x` coordinate. + + width : int or float + Width of the pen in pixels on screen. + """ + brush = self.getBrush( span=span, orientation=orientation ) + pen = QtGui.QPen(brush, width) + pen.setCosmetic(True) + return pen def getColors(self, mode=None): - """Return list of all color stops converted to the specified mode. - If mode is None, then no conversion is done.""" + """Returns a list of all color stops, converted to the specified mode. + If `mode` is None, no conversion is performed. + """ if isinstance(mode, str): mode = self.enumMap[mode.lower()] - + color = self.color if mode in [self.BYTE, self.QCOLOR] and color.dtype.kind == 'f': color = (color * 255).astype(np.ubyte) elif mode == self.FLOAT and color.dtype.kind != 'f': color = color.astype(float) / 255. - + if mode == self.QCOLOR: color = [QtGui.QColor(*x) for x in color] - + return color def getStops(self, mode): @@ -374,20 +515,39 @@ class ColorMap(object): self.stopsCache[mode] = (self.pos, color) return self.stopsCache[mode] - def getLookupTable(self, start=0.0, stop=1.0, nPts=512, alpha=None, mode='byte'): + def getLookupTable(self, start=0.0, stop=1.0, nPts=512, alpha=None, mode=BYTE): """ - Return an RGB(A) lookup table (ndarray). - - =============== ============================================================================= - **Arguments:** - start The starting value in the lookup table (default=0.0) - stop The final value in the lookup table (default=1.0) - nPts The number of points in the returned lookup table. - alpha True, False, or None - Specifies whether or not alpha values are included - in the table. If alpha is None, it will be automatically determined. - mode Determines return type: 'byte' (0-255), 'float' (0.0-1.0), or 'qcolor'. - See :func:`map() `. - =============== ============================================================================= + getLookupTable(start=0.0, stop=1.0, nPts=512, alpha=None, mode=ColorMap.BYTE) + + Returns an equally-spaced lookup table of RGB(A) values created + by interpolating the specified color stops. + + Parameters + ---------- + start: float, default=0.0 + The starting value in the lookup table + stop: float, default=1.0 + The final value in the lookup table + nPts: int, default is 512 + The number of points in the returned lookup table. + alpha: True, False, or None + Specifies whether or not alpha values are included in the table. + If alpha is None, it will be automatically determined. + mode: int or str, default is `ColorMap.BYTE` + Determines return type as described in :func:`map() `, can be + either `ColorMap.BYTE` (0 to 255), `ColorMap.FLOAT` (0.0 to 1.0) or `ColorMap.QColor`. + + Returns + ------- + array of color.dtype + for `ColorMap.BYTE` or `ColorMap.FLOAT`: + + RGB values for each `data` value, arranged in the same shape as `data`. + If alpha values are included the array has shape (`nPts`, 4), otherwise (`nPts`, 3). + list of QColor objects + for `ColorMap.QCOLOR`: + + Colors for each `data` value as QColor objects. """ if isinstance(mode, str): mode = self.enumMap[mode.lower()] @@ -404,13 +564,13 @@ class ColorMap(object): return table def usesAlpha(self): - """Return True if any stops have an alpha < 255""" + """Returns `True` if any stops have assigned colors with alpha < 255.""" max = 1.0 if self.color.dtype.kind == 'f' else 255 return np.any(self.color[:,3] != max) def isMapTrivial(self): """ - Return True if the gradient has exactly two stops in it: black at 0.0 and white at 1.0. + Returns `True` if the gradient has exactly two stops in it: Black at 0.0 and white at 1.0. """ if len(self.pos) != 2: return False