diff --git a/pyqtgraph/colormap.py b/pyqtgraph/colormap.py index e8ba439d..50057ac3 100644 --- a/pyqtgraph/colormap.py +++ b/pyqtgraph/colormap.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import numpy as np from .Qt import QtGui, QtCore -from .functions import mkColor, eq, colorDistance +from .functions import mkColor, eq, colorDistance, clip_scalar, clip_array from os import path, listdir from collections.abc import Callable, Sequence import warnings @@ -34,7 +34,7 @@ def listMaps(source=None): files = listdir( pathname ) list_of_maps = [] for filename in files: - if filename[-4:] == '.csv': + if filename[-4:] == '.csv' or filename[-4:] == '.hex': list_of_maps.append(filename[:-4]) return list_of_maps elif source.lower() == 'matplotlib': @@ -96,11 +96,11 @@ def _getFromFile(name): filename = path.join(dirname, 'colors/maps/'+filename) if not path.isfile( filename ): # try suffixes if file is not found: if path.isfile( filename+'.csv' ): filename += '.csv' - elif path.isfile( filename+'.txt' ): filename += '.txt' + elif path.isfile( filename+'.hex' ): filename += '.hex' with open(filename,'r') as fh: idx = 0 color_list = [] - if filename[-4:].lower() != '.txt': + if filename[-4:].lower() != '.hex': csv_mode = True else: csv_mode = False @@ -123,18 +123,21 @@ def _getFromFile(name): elif len(hex_str) == 4: # parse as abbreviated RGBA hex_str = 2*hex_str[0] + 2*hex_str[1] + 2*hex_str[2] + 2*hex_str[3] if len(hex_str) < 6: continue # not enough information - color_tuple = tuple( bytes.fromhex( hex_str ) ) + try: + color_tuple = tuple( bytes.fromhex( hex_str ) ) + except ValueError as e: + raise ValueError(f"failed to convert hexadecimal value '{hex_str}'.") from e color_list.append( color_tuple ) idx += 1 # end of line reading loop # end of open - cm = ColorMap( + cmap = ColorMap( name=name, pos=np.linspace(0.0, 1.0, len(color_list)), color=color_list) #, names=color_names) - if cm is not None: - cm.name = name - _mapCache[name] = cm - return cm + if cmap is not None: + cmap.name = name + _mapCache[name] = cmap + return cmap def getFromMatplotlib(name): """ @@ -147,7 +150,7 @@ def getFromMatplotlib(name): import matplotlib.pyplot as mpl_plt except ModuleNotFoundError: return None - cm = None + cmap = None col_map = mpl_plt.get_cmap(name) if hasattr(col_map, '_segmentdata'): # handle LinearSegmentedColormap data = col_map._segmentdata @@ -165,21 +168,22 @@ def getFromMatplotlib(name): positions[idx2] = tup[0] comp_vals[idx2] = tup[1] # these are sorted in the raw data col_data[:,idx] = np.interp(col_data[:,3], positions, comp_vals) - cm = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) + cmap = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) # some color maps (gnuplot in particular) are defined by RGB component functions: elif ('red' in data) and isinstance(data['red'], Callable): col_data = np.zeros((64, 4)) 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) + cmap = 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 ) - if cm is not None: - cm.name = name - _mapCache[name] = cm - return cm + cmap = ColorMap( name=name, + pos = np.linspace(0.0, 1.0, col_data.shape[0]), color=255*col_data[:,:3]+0.5 ) + if cmap is not None: + cmap.name = name + _mapCache[name] = cmap + return cmap def getFromColorcet(name): """ Generates a ColorMap object from a colorcet definition. Same as ``colormap.get(name, source='colorcet')``. """ @@ -192,20 +196,66 @@ def getFromColorcet(name): for hex_str in color_strings: if hex_str[0] != '#': continue if len(hex_str) != 7: - raise ValueError('Invalid color string '+str(hex_str)+' in colorcet import.') + raise ValueError(f"Invalid color string '{hex_str}' in colorcet import.") color_tuple = tuple( bytes.fromhex( hex_str[1:] ) ) color_list.append( color_tuple ) if len(color_list) == 0: return None - cm = ColorMap( - pos=np.linspace(0.0, 1.0, len(color_list)), + cmap = ColorMap( name=name, + pos=np.linspace(0.0, 1.0, len(color_list)), color=color_list) #, names=color_names) - if cm is not None: - cm.name = name - _mapCache[name] = cm - return cm + if cmap is not None: + cmap.name = name + _mapCache[name] = cmap + return cmap -def makeMonochrome(color='green'): +def makeHslCycle( hue=0.0, saturation=1.0, lightness=0.5, steps=36 ): + """ + Returns a ColorMap object that traces a circular or spiraling path around the HSL color space. + + Parameters + ---------- + hue : float or tuple of floats + Starting point or (start, end) for hue. Values can lie outside the [0 to 1] range + to realize multiple cycles. For a single value, one full hue cycle is generated. + The default starting hue is 0.0 (red). + saturation : float or tuple of floats, optional + Saturation value for the colors in the cycle, in the range of [0 to 1]. + If a (start, end) tuple is given, saturation gradually changes between these values. + The default saturation is 1.0. + lightness : float or tuple of floats, optional + Lightness value for the colors in the cycle, in the range of [0 to 1]. + If a (start, end) tuple is given, lightness gradually changes between these values. + The default lightness is 1.0. + steps: int, optional + Number of steps in the cycle. Between these steps, the color map will interpolate in RGB space. + The default number of steps is 36, generating a color map with 37 stops. + """ + if isinstance( hue, (tuple, list) ): + hueA, hueB = hue + else: + hueA = hue + hueB = hueA + 1.0 + if isinstance( saturation, (tuple, list) ): + satA, satB = saturation + else: + satA = satB = saturation + if isinstance( lightness, (tuple, list) ): + lgtA, lgtB = lightness + else: + lgtA = lgtB = lightness + hue_vals = np.linspace(hueA, hueB, num=steps+1) + sat_vals = np.linspace(satA, satB, num=steps+1) + lgt_vals = np.linspace(lgtA, lgtB, num=steps+1) + color_list = [] + for hue, sat, lgt in zip( hue_vals, sat_vals, lgt_vals): + qcol = QtGui.QColor() + qcol.setHslF( hue%1.0, sat, lgt ) + color_list.append( qcol ) + name = f'Hue {hueA:0.2f}-{hueB:0.2f}' + return ColorMap( None, color_list, name=name ) + +def makeMonochrome(color='neutral'): """ Returns a ColorMap object with a dark to bright ramp and adjustable tint. @@ -258,7 +308,7 @@ def makeMonochrome(color='green'): h_val, s_val, l_min, l_max = color else: raise ValueError(f"Invalid color descriptor '{color}'") - l_vals = np.linspace(l_min, l_max, num=10) + l_vals = np.linspace(l_min, l_max, num=16) color_list = [] for l_val in l_vals: qcol = QtGui.QColor() @@ -283,7 +333,7 @@ def modulatedBarData(length=768, width=32): data = np.zeros( (length, width) ) for idx in range(width): data[:,idx] = gradient + (idx/(width-1)) * modulation - np.clip(data, 0.0, 1.0) + clip_array(data, 0.0, 1.0) return data class ColorMap(object): @@ -390,8 +440,9 @@ class ColorMap(object): if mapping in [self.CLIP, self.REPEAT, self.DIVERGING, self.MIRROR]: self.mapping_mode = mapping # only allow defined values else: - raise ValueError("Undefined mapping type '{:s}'".format(str(mapping)) ) - + raise ValueError(f"Undefined mapping type '{mapping}'") + self.stopsCache = {} + def __str__(self): """ provide human-readable identifier """ if self.name is None: @@ -428,6 +479,73 @@ class ColorMap(object): self.pos = 1.0 - np.flip( self.pos ) self.color = np.flip( self.color, axis=0 ) self.stopsCache = {} + + def getSubset(self, start, span): + """ + Returns a new ColorMap object that extracts the subset specified by 'start' and 'length' + to the full 0.0 to 1.0 range. A negative length results in a color map that is reversed + relative to the original. + + Parameters + ---------- + start : float (0.0 to 1.0) + Starting value that defines the 0.0 value of the new color map. + span : float (-1.0 to 1.0) + span of the extracted region. The orignal color map will be trated as cyclical + if the extracted interval exceeds the 0.0 to 1.0 range. + """ + pos, col = self.getStops( mode=ColorMap.FLOAT ) + start = clip_scalar(start, 0.0, 1.0) + span = clip_scalar(span, -1.0, 1.0) + + if span == 0.0: + raise ValueError("'length' needs to be non-zero") + stop = (start + span) + if stop > 1.0 or stop < 0.0: stop = stop % 1.0 + # find indices *inside* range, start and end will be added by sampling later + if span > 0: + ref_pos = start # lowest position value at start + idxA = np.searchsorted( pos, start, side='right' ) + idxB = np.searchsorted( pos, stop , side='left' ) # + 1 # right-side element of interval + wraps = bool( stop < start ) # wraps around? + else: + ref_pos = stop # lowest position value at stop + idxA = np.searchsorted( pos, stop , side='right') + idxB = np.searchsorted( pos, start, side='left' ) # + 1 # right-side element of interval + wraps = bool( stop > start ) # wraps around? + + if wraps: # wraps around: + length1 = (len(pos)-idxA) # before wrap + length2 = idxB # after wrap + new_length = length1 + length2 + 2 # combined; plus edge elements + new_pos = np.zeros( new_length ) + new_col = np.zeros( (new_length, 4) ) + new_pos[ 1:length1+1] = (0 + pos[idxA:] - ref_pos) / span # starting point lie in 0 to 1 range + new_pos[length1+1:-1] = (1 + pos[:idxB] - ref_pos) / span # end point wrapped to -1 to 0 range + new_pos[length1] -= np.copysign(1e-6, span) # breaks degeneracy of shifted 0.0 and 1.0 values + new_col[ 1:length1+1] = col[idxA:] + new_col[length1+1:-1] = col[:idxB] + else: # does not wrap around: + new_length = (idxB - idxA) + 2 # two additional edge values will be added + new_pos = np.zeros( new_length ) + new_col = np.zeros( (new_length, 4) ) + new_pos[1:-1] = (pos[idxA:idxB] - ref_pos) / span + new_col[1:-1] = col[idxA:idxB] + + if span < 0: # for reversed subsets, positions now progress 0 to -1 and need to be flipped + new_pos += 1.0 + new_pos = np.flip( new_pos) + new_col = np.flip( new_col, axis=0 ) + + new_pos[ 0] = 0.0 + new_col[ 0] = self.mapToFloat(start) + new_pos[-1] = 1.0 + new_col[-1] = self.mapToFloat(stop) + + cmap = ColorMap( pos=new_pos, color=255.*new_col ) + cmap.name = f"{self.name}[{start:.2f}({span:+.2f})]" + return cmap + def map(self, data, mode=BYTE): """ diff --git a/pyqtgraph/colors/maps/PAL-relaxed.hex b/pyqtgraph/colors/maps/PAL-relaxed.hex new file mode 100644 index 00000000..8b99ea78 --- /dev/null +++ b/pyqtgraph/colors/maps/PAL-relaxed.hex @@ -0,0 +1,13 @@ +; PyQtGraph's "relaxed" plot color palette +; This is the darker variant for plotting on a light background ("light mode") +; +#f97f10 ; orange +#e5bb00 ; yellow +#94ab00 ; grass +#12a12a ; green +#007c8c ; sea +#0e56c2 ; blue +#813be3 ; indigo +#c01188 ; purple +#e23512 ; red +#f97f10 ; orange diff --git a/pyqtgraph/colors/maps/PAL-relaxed_bright.hex b/pyqtgraph/colors/maps/PAL-relaxed_bright.hex new file mode 100644 index 00000000..3fbef6e2 --- /dev/null +++ b/pyqtgraph/colors/maps/PAL-relaxed_bright.hex @@ -0,0 +1,13 @@ +; PyQtGraph's "relaxed" plot color palette +; This is the brighter variant for plotting on a dark background ("dark mode") +; +#ff9d47 ; orange +#f7e100 ; yellow +#b3cf00 ; grass +#1ec23a ; green +#00a0b5 ; sea +#1f78ff ; blue +#a54dff ; indigo +#e22ca8 ; purple +#ff532b ; red +#ff9d47 ; orange diff --git a/setup.py b/setup.py index 6068ceba..d006d658 100644 --- a/setup.py +++ b/setup.py @@ -143,7 +143,7 @@ setup( package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source package_data={'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg'], "pyqtgraph.icons": ["*.svg", "*.png"], - "pyqtgraph": ["colors/maps/*.csv", "colors/maps/*.txt"], + "pyqtgraph": ["colors/maps/*.csv", "colors/maps/*.txt", "colors/maps/*.hex"], }, install_requires = [ 'numpy>=1.17.0',