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 -*-
|
||||
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
|
||||
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(
|
||||
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,7 +440,8 @@ 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 """
|
||||
@ -429,6 +480,73 @@ class ColorMap(object):
|
||||
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):
|
||||
"""
|
||||
map(data, mode=ColorMap.BYTE)
|
||||
|
13
pyqtgraph/colors/maps/PAL-relaxed.hex
Normal file
13
pyqtgraph/colors/maps/PAL-relaxed.hex
Normal 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
|
13
pyqtgraph/colors/maps/PAL-relaxed_bright.hex
Normal file
13
pyqtgraph/colors/maps/PAL-relaxed_bright.hex
Normal 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
|
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_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',
|
||||
|
Loading…
x
Reference in New Issue
Block a user