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:
parent
d396d33799
commit
8f96c78715
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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
|
|
@ -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
|
2
setup.py
2
setup.py
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue