Extend ColorMap with HSL cycles and subset generation (#1911)

* Extend ColorMap with HSL cycles and subset generation

* relaxed color palette data

* added hex to be installed in colors/maps/
This commit is contained in:
Nils Nemitz 2021-07-24 06:38:17 +09:00 committed by GitHub
parent d396d33799
commit 8f96c78715
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 175 additions and 31 deletions

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import numpy as np import numpy as np
from .Qt import QtGui, QtCore 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 os import path, listdir
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
import warnings import warnings
@ -34,7 +34,7 @@ def listMaps(source=None):
files = listdir( pathname ) files = listdir( pathname )
list_of_maps = [] list_of_maps = []
for filename in files: for filename in files:
if filename[-4:] == '.csv': if filename[-4:] == '.csv' or filename[-4:] == '.hex':
list_of_maps.append(filename[:-4]) list_of_maps.append(filename[:-4])
return list_of_maps return list_of_maps
elif source.lower() == 'matplotlib': elif source.lower() == 'matplotlib':
@ -96,11 +96,11 @@ def _getFromFile(name):
filename = path.join(dirname, 'colors/maps/'+filename) filename = path.join(dirname, 'colors/maps/'+filename)
if not path.isfile( filename ): # try suffixes if file is not found: if not path.isfile( filename ): # try suffixes if file is not found:
if path.isfile( filename+'.csv' ): filename += '.csv' 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: with open(filename,'r') as fh:
idx = 0 idx = 0
color_list = [] color_list = []
if filename[-4:].lower() != '.txt': if filename[-4:].lower() != '.hex':
csv_mode = True csv_mode = True
else: else:
csv_mode = False csv_mode = False
@ -123,18 +123,21 @@ def _getFromFile(name):
elif len(hex_str) == 4: # parse as abbreviated RGBA 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] 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 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 ) color_list.append( color_tuple )
idx += 1 idx += 1
# end of line reading loop # end of line reading loop
# end of open # end of open
cm = ColorMap( cmap = ColorMap( name=name,
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) color=color_list) #, names=color_names)
if cm is not None: if cmap is not None:
cm.name = name cmap.name = name
_mapCache[name] = cm _mapCache[name] = cmap
return cm return cmap
def getFromMatplotlib(name): def getFromMatplotlib(name):
""" """
@ -147,7 +150,7 @@ def getFromMatplotlib(name):
import matplotlib.pyplot as mpl_plt import matplotlib.pyplot as mpl_plt
except ModuleNotFoundError: except ModuleNotFoundError:
return None return None
cm = None cmap = None
col_map = mpl_plt.get_cmap(name) col_map = mpl_plt.get_cmap(name)
if hasattr(col_map, '_segmentdata'): # handle LinearSegmentedColormap if hasattr(col_map, '_segmentdata'): # handle LinearSegmentedColormap
data = col_map._segmentdata data = col_map._segmentdata
@ -165,21 +168,22 @@ def getFromMatplotlib(name):
positions[idx2] = tup[0] positions[idx2] = tup[0]
comp_vals[idx2] = tup[1] # these are sorted in the raw data comp_vals[idx2] = tup[1] # these are sorted in the raw data
col_data[:,idx] = np.interp(col_data[:,3], positions, comp_vals) 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: # some color maps (gnuplot in particular) are defined by RGB component functions:
elif ('red' in data) and isinstance(data['red'], Callable): elif ('red' in data) and isinstance(data['red'], Callable):
col_data = np.zeros((64, 4)) col_data = np.zeros((64, 4))
col_data[:,-1] = np.linspace(0., 1., 64) col_data[:,-1] = np.linspace(0., 1., 64)
for idx, key in enumerate(['red','green','blue']): for idx, key in enumerate(['red','green','blue']):
col_data[:,idx] = np.clip( data[key](col_data[:,-1]), 0, 1) 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 elif hasattr(col_map, 'colors'): # handle ListedColormap
col_data = np.array(col_map.colors) 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 ) cmap = ColorMap( name=name,
if cm is not None: pos = np.linspace(0.0, 1.0, col_data.shape[0]), color=255*col_data[:,:3]+0.5 )
cm.name = name if cmap is not None:
_mapCache[name] = cm cmap.name = name
return cm _mapCache[name] = cmap
return cmap
def getFromColorcet(name): def getFromColorcet(name):
""" Generates a ColorMap object from a colorcet definition. Same as ``colormap.get(name, source='colorcet')``. """ """ 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: for hex_str in color_strings:
if hex_str[0] != '#': continue 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.') raise ValueError(f"Invalid color string '{hex_str}' in colorcet import.")
color_tuple = tuple( bytes.fromhex( hex_str[1:] ) ) color_tuple = tuple( bytes.fromhex( hex_str[1:] ) )
color_list.append( color_tuple ) color_list.append( color_tuple )
if len(color_list) == 0: if len(color_list) == 0:
return None return None
cm = ColorMap( cmap = ColorMap( name=name,
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) color=color_list) #, names=color_names)
if cm is not None: if cmap is not None:
cm.name = name cmap.name = name
_mapCache[name] = cm _mapCache[name] = cmap
return cm 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. 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 h_val, s_val, l_min, l_max = color
else: else:
raise ValueError(f"Invalid color descriptor '{color}'") 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 = [] color_list = []
for l_val in l_vals: for l_val in l_vals:
qcol = QtGui.QColor() qcol = QtGui.QColor()
@ -283,7 +333,7 @@ def modulatedBarData(length=768, width=32):
data = np.zeros( (length, width) ) data = np.zeros( (length, width) )
for idx in range(width): for idx in range(width):
data[:,idx] = gradient + (idx/(width-1)) * modulation data[:,idx] = gradient + (idx/(width-1)) * modulation
np.clip(data, 0.0, 1.0) clip_array(data, 0.0, 1.0)
return data return data
class ColorMap(object): class ColorMap(object):
@ -390,8 +440,9 @@ class ColorMap(object):
if mapping in [self.CLIP, self.REPEAT, self.DIVERGING, self.MIRROR]: if mapping in [self.CLIP, self.REPEAT, self.DIVERGING, self.MIRROR]:
self.mapping_mode = mapping # only allow defined values self.mapping_mode = mapping # only allow defined values
else: else:
raise ValueError("Undefined mapping type '{:s}'".format(str(mapping)) ) raise ValueError(f"Undefined mapping type '{mapping}'")
self.stopsCache = {}
def __str__(self): def __str__(self):
""" provide human-readable identifier """ """ provide human-readable identifier """
if self.name is None: if self.name is None:
@ -428,6 +479,73 @@ class ColorMap(object):
self.pos = 1.0 - np.flip( self.pos ) self.pos = 1.0 - np.flip( self.pos )
self.color = np.flip( self.color, axis=0 ) self.color = np.flip( self.color, axis=0 )
self.stopsCache = {} 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): def map(self, data, mode=BYTE):
""" """

View File

@ -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

View File

@ -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

View File

@ -143,7 +143,7 @@ setup(
package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source
package_data={'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg'], package_data={'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg'],
"pyqtgraph.icons": ["*.svg", "*.png"], "pyqtgraph.icons": ["*.svg", "*.png"],
"pyqtgraph": ["colors/maps/*.csv", "colors/maps/*.txt"], "pyqtgraph": ["colors/maps/*.csv", "colors/maps/*.txt", "colors/maps/*.hex"],
}, },
install_requires = [ install_requires = [
'numpy>=1.17.0', 'numpy>=1.17.0',