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 -*-
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):
"""

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_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',