Color map linearization (#1797)
* extended color map functions * assertion string, documentation pass, comment purge * fix some documentation links * simplify assert statement * removed comments and redundancy, renamed modulated bar * include modulatedBarData in documentation * test running matplotlib with dummy backend * skip color maps example on OSX/PySide6.1.1 * removed skipping of tests * reverted some accidental whitespace, removed unneeded numpy import * removed unneded alpha parameter
This commit is contained in:
parent
f002d70adc
commit
7d41e8a878
@ -73,6 +73,9 @@ API Reference
|
||||
|
||||
.. autofunction:: pyqtgraph.colormap.getFromColorcet
|
||||
|
||||
.. autofunction:: pyqtgraph.colormap.modulatedBarData
|
||||
|
||||
|
||||
|
||||
|
||||
.. autoclass:: pyqtgraph.ColorMap
|
||||
|
@ -30,12 +30,18 @@ Qt uses the classes QColor, QPen, and QBrush to determine how to draw lines and
|
||||
|
||||
.. autofunction:: pyqtgraph.intColor
|
||||
|
||||
.. autofunction:: pyqtgraph.CIELabColor
|
||||
|
||||
.. autofunction:: pyqtgraph.colorCIELab
|
||||
|
||||
.. autofunction:: pyqtgraph.colorTuple
|
||||
|
||||
.. autofunction:: pyqtgraph.colorStr
|
||||
|
||||
.. autofunction:: pyqtgraph.glColor
|
||||
|
||||
.. autofunction:: pyqtgraph.colorDistance
|
||||
|
||||
|
||||
Data Slicing
|
||||
------------
|
||||
|
@ -7,13 +7,11 @@ from Matplotlib or ColorCET.
|
||||
## Add path to library (just for examples; you do not need this)
|
||||
import initExample
|
||||
|
||||
import numpy as np
|
||||
from pyqtgraph.Qt import QtCore, QtGui
|
||||
import pyqtgraph as pg
|
||||
|
||||
app = pg.mkQApp()
|
||||
|
||||
## Create window with ImageView widget
|
||||
win = QtGui.QMainWindow()
|
||||
win.resize(1000,800)
|
||||
|
||||
@ -25,70 +23,51 @@ scr = QtGui.QScrollArea()
|
||||
scr.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
|
||||
scr.setWidget(lw)
|
||||
win.setCentralWidget(scr)
|
||||
win.show()
|
||||
win.setWindowTitle('pyqtgraph example: Color maps')
|
||||
win.show()
|
||||
|
||||
## Create color map test image
|
||||
width = 3*256
|
||||
height = 32
|
||||
img = np.zeros( (width, height) )
|
||||
gradient = np.linspace(0.05, 0.95, width)
|
||||
modulation = np.zeros(width)
|
||||
for idx in range(width):
|
||||
modulation[idx] = -0.05 * np.sin( 200 * np.pi * idx/width )
|
||||
for idx in range(height):
|
||||
img[:,idx] = gradient + (idx/(height-1)) * modulation
|
||||
bar_width = 32
|
||||
bar_data = pg.colormap.modulatedBarData(width=bar_width)
|
||||
|
||||
num_bars = 0
|
||||
|
||||
lw.addLabel('=== local color maps ===')
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
def add_heading(lw, name):
|
||||
global num_bars
|
||||
lw.addLabel('=== '+name+' ===')
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
|
||||
def add_bar(lw, name, cm):
|
||||
global num_bars
|
||||
lw.addLabel(name)
|
||||
imi = pg.ImageItem( bar_data )
|
||||
imi.setLookupTable( cm.getLookupTable(alpha=True) )
|
||||
vb = lw.addViewBox(lockAspect=True, enableMouse=False)
|
||||
vb.addItem( imi )
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
|
||||
add_heading(lw, 'local color maps')
|
||||
list_of_maps = pg.colormap.listMaps()
|
||||
for map_name in list_of_maps:
|
||||
num_bars += 1
|
||||
lw.addLabel(map_name)
|
||||
cmap = pg.colormap.get(map_name)
|
||||
imi = pg.ImageItem()
|
||||
imi.setImage(img)
|
||||
imi.setLookupTable( cmap.getLookupTable(alpha=True) )
|
||||
vb = lw.addViewBox(lockAspect=True, enableMouse=False)
|
||||
vb.addItem(imi)
|
||||
lw.nextRow()
|
||||
cm = pg.colormap.get(map_name)
|
||||
add_bar(lw, map_name, cm)
|
||||
|
||||
lw.addLabel('=== Matplotlib import ===')
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
add_heading(lw, 'Matplotlib import')
|
||||
list_of_maps = pg.colormap.listMaps('matplotlib')
|
||||
for map_name in list_of_maps:
|
||||
num_bars += 1
|
||||
lw.addLabel(map_name)
|
||||
cmap = pg.colormap.get(map_name, source='matplotlib', skipCache=True)
|
||||
if cmap is not None:
|
||||
imi = pg.ImageItem()
|
||||
imi.setImage(img)
|
||||
imi.setLookupTable( cmap.getLookupTable(alpha=True) )
|
||||
vb = lw.addViewBox(lockAspect=True, enableMouse=False)
|
||||
vb.addItem(imi)
|
||||
lw.nextRow()
|
||||
cm = pg.colormap.get(map_name, source='matplotlib', skipCache=True)
|
||||
if cm is not None:
|
||||
add_bar(lw, map_name, cm)
|
||||
|
||||
lw.addLabel('=== ColorCET import ===')
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
add_heading(lw, 'ColorCET import')
|
||||
list_of_maps = pg.colormap.listMaps('colorcet')
|
||||
for map_name in list_of_maps:
|
||||
num_bars += 1
|
||||
lw.addLabel(map_name)
|
||||
cmap = pg.colormap.get(map_name, source='colorcet', skipCache=True)
|
||||
if cmap is not None:
|
||||
imi = pg.ImageItem()
|
||||
imi.setImage(img)
|
||||
imi.setLookupTable( cmap.getLookupTable(alpha=True) )
|
||||
vb = lw.addViewBox(lockAspect=True, enableMouse=False)
|
||||
vb.addItem(imi)
|
||||
lw.nextRow()
|
||||
|
||||
lw.setFixedHeight(num_bars * (height+5) )
|
||||
cm = pg.colormap.get(map_name, source='colorcet', skipCache=True)
|
||||
if cm is not None:
|
||||
add_bar(lw, map_name, cm)
|
||||
|
||||
lw.setFixedHeight(num_bars * (bar_width+5) )
|
||||
|
||||
if __name__ == '__main__':
|
||||
pg.exec()
|
||||
|
120
examples/colorMapsLinearized.py
Normal file
120
examples/colorMapsLinearized.py
Normal file
@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
This example demonstrates linearized ColorMap objects using colormap.makeMonochrome()
|
||||
or using the `ColorMap`'s `linearize()` method.
|
||||
"""
|
||||
# Add path to library (just for examples; you do not need this)
|
||||
import initExample
|
||||
|
||||
import numpy as np
|
||||
from pyqtgraph.Qt import QtCore, QtGui
|
||||
import pyqtgraph as pg
|
||||
|
||||
name_list = (
|
||||
'warm','neutral','cool',
|
||||
'green','amber','blue','red','pink','lavender',
|
||||
(0.5, 0.2, 0.1, 0.8)
|
||||
)
|
||||
ramp_list = [
|
||||
pg.colormap.makeMonochrome(name)
|
||||
for name in name_list
|
||||
]
|
||||
|
||||
cm_list = []
|
||||
# Create a gray ramp for demonstrating the idea:
|
||||
cm = pg.ColorMap( None, [
|
||||
QtGui.QColor( 0, 0, 0),
|
||||
QtGui.QColor( 10, 10, 10),
|
||||
QtGui.QColor(127, 127, 127),
|
||||
QtGui.QColor(240, 240, 240),
|
||||
QtGui.QColor(255, 255, 255)
|
||||
])
|
||||
cm_list.append(('Distorted gray ramp',cm))
|
||||
|
||||
# Create a rainbow scale in HSL color space:
|
||||
length = 41
|
||||
col_list = []
|
||||
for idx in range(length):
|
||||
frac = idx/(length-1)
|
||||
qcol = QtGui.QColor()
|
||||
qcol.setHslF( (2*frac-0.15)%1.0, 0.8, 0.5-0.5*np.cos(np.pi*frac) )
|
||||
col_list.append(qcol)
|
||||
cm = pg.ColorMap( None, col_list )
|
||||
cm_list.append( ('Distorted HSL spiral', cm) )
|
||||
|
||||
# Create some random examples:
|
||||
for example_idx in range(3):
|
||||
previous = None
|
||||
col_list = []
|
||||
for idx in range(8):
|
||||
values = np.random.random((3))
|
||||
if previous is not None:
|
||||
intermediate = (values + previous) / 2
|
||||
qcol = QtGui.QColor()
|
||||
qcol.setRgbF( *intermediate )
|
||||
col_list.append( qcol)
|
||||
qcol1 = QtGui.QColor()
|
||||
qcol1.setRgbF( *values )
|
||||
col_list.append( qcol1)
|
||||
previous = values
|
||||
cm = pg.ColorMap( None, col_list )
|
||||
cm_list.append( (f'random {example_idx+1}', cm) )
|
||||
|
||||
app = pg.mkQApp()
|
||||
win = QtGui.QMainWindow()
|
||||
win.resize(1000,800)
|
||||
|
||||
lw = pg.GraphicsLayoutWidget()
|
||||
lw.setFixedWidth(1000)
|
||||
lw.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)
|
||||
|
||||
scr = QtGui.QScrollArea()
|
||||
scr.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
|
||||
scr.setWidget(lw)
|
||||
win.setCentralWidget(scr)
|
||||
win.setWindowTitle('pyqtgraph example: Linearized color maps')
|
||||
win.show()
|
||||
|
||||
bar_width = 32
|
||||
bar_data = pg.colormap.modulatedBarData(width=bar_width)
|
||||
|
||||
num_bars = 0
|
||||
|
||||
def add_heading(lw, name):
|
||||
global num_bars
|
||||
lw.addLabel('=== '+name+' ===')
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
|
||||
def add_bar(lw, name, cm):
|
||||
global num_bars
|
||||
lw.addLabel(name)
|
||||
imi = pg.ImageItem( bar_data )
|
||||
imi.setLookupTable( cm.getLookupTable(alpha=True) )
|
||||
vb = lw.addViewBox(lockAspect=True, enableMouse=False)
|
||||
vb.addItem( imi )
|
||||
num_bars += 1
|
||||
lw.nextRow()
|
||||
|
||||
add_heading(lw, 'ramp generator')
|
||||
for cm in ramp_list:
|
||||
add_bar(lw, cm.name, cm)
|
||||
|
||||
add_heading(lw, 'linearizer demonstration')
|
||||
for (name, cm) in cm_list:
|
||||
add_bar(lw, name, cm)
|
||||
cm.linearize()
|
||||
add_bar(lw, '> linearized', cm)
|
||||
|
||||
add_heading(lw, 'consistency with included maps')
|
||||
for name in ('CET-C3', 'CET-L17', 'CET-L2'):
|
||||
# lw.addLabel(str(name))
|
||||
cm = pg.colormap.get(name)
|
||||
add_bar(lw, name, cm)
|
||||
cm.linearize()
|
||||
add_bar(lw, '> linearized', cm)
|
||||
|
||||
lw.setFixedHeight(num_bars * (bar_width+5) )
|
||||
|
||||
if __name__ == '__main__':
|
||||
pg.exec()
|
@ -13,8 +13,6 @@ examples = OrderedDict([
|
||||
('Timestamps on x axis', 'DateAxisItem.py'),
|
||||
('Image Analysis', 'imageAnalysis.py'),
|
||||
('Matrix Display', 'MatrixDisplayExample.py'),
|
||||
('Color Maps', 'colorMaps.py'),
|
||||
('Color Gradient Plots', 'ColorGradientPlots.py'),
|
||||
('ViewBox Features', Namespace(filename='ViewBoxFeatures.py', recommended=True)),
|
||||
('Dock widgets', 'dockarea.py'),
|
||||
('Console', 'ConsoleWidget.py'),
|
||||
@ -31,6 +29,11 @@ examples = OrderedDict([
|
||||
('Verlet chain', 'verlet_chain_demo.py'),
|
||||
('Koch Fractal', 'fractal.py'),
|
||||
])),
|
||||
('Colors', OrderedDict([
|
||||
('Color Maps', 'colorMaps.py'),
|
||||
('Color Map Linearization', 'colorMapsLinearized.py'),
|
||||
('Color Gradient Plots', 'ColorGradientPlots.py')
|
||||
])),
|
||||
('GraphicsItems', OrderedDict([
|
||||
('Scatter Plot', 'ScatterPlot.py'),
|
||||
#('PlotItem', 'PlotItem.py'),
|
||||
|
@ -1,6 +1,6 @@
|
||||
import numpy as np
|
||||
from .Qt import QtGui, QtCore
|
||||
from .functions import mkColor, eq
|
||||
from .functions import mkColor, eq, colorDistance
|
||||
from os import path, listdir
|
||||
from collections.abc import Callable, Sequence
|
||||
import warnings
|
||||
@ -129,7 +129,9 @@ def _getFromFile(name):
|
||||
cm = ColorMap(
|
||||
pos=np.linspace(0.0, 1.0, len(color_list)),
|
||||
color=color_list) #, names=color_names)
|
||||
_mapCache[name] = cm
|
||||
if cm is not None:
|
||||
cm.name = name
|
||||
_mapCache[name] = cm
|
||||
return cm
|
||||
|
||||
def getFromMatplotlib(name):
|
||||
@ -173,6 +175,7 @@ def getFromMatplotlib(name):
|
||||
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
|
||||
|
||||
@ -195,9 +198,91 @@ def getFromColorcet(name):
|
||||
cm = ColorMap(
|
||||
pos=np.linspace(0.0, 1.0, len(color_list)),
|
||||
color=color_list) #, names=color_names)
|
||||
_mapCache[name] = cm
|
||||
if cm is not None:
|
||||
cm.name = name
|
||||
_mapCache[name] = cm
|
||||
return cm
|
||||
|
||||
def makeMonochrome(color='green'):
|
||||
"""
|
||||
Returns a ColorMap object with a dark to bright ramp and adjustable tint.
|
||||
|
||||
In addition to neutral, warm or cold grays, imitations of monochrome computer monitors are also
|
||||
available. The following predefined color ramps are available:
|
||||
`neutral`, `warm`, `cool`, `green`, `amber`, `blue`, `red`, `pink`, `lavender`.
|
||||
|
||||
The ramp can also be specified by a tuple of float values in the range of 0 to 1.
|
||||
In this case `(h, s, l0, l1)` describe hue, saturation, minimum lightness and maximum lightness
|
||||
within the HSL color space. The values `l0` and `l1` can be omitted. They default to
|
||||
`l0=0.0` and `l1=1.0` in this case.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
color: str or tuple of floats
|
||||
Color description. Can be one of the predefined identifiers, or a tuple
|
||||
`(h, s, l0, l1)`, `(h, s)` or (`h`).
|
||||
'green', 'amber', 'blue', 'red', 'lavender', 'pink'
|
||||
or a tuple of relative ``(R,G,B)`` contributions in range 0.0 to 1.0
|
||||
"""
|
||||
name=f'Monochrome {color}'
|
||||
defaults = {
|
||||
'neutral': (0.00, 0.00, 0.00, 1.00),
|
||||
'warm' : (0.10, 0.08, 0.00, 0.95),
|
||||
'cool' : (0.60, 0.08, 0.00, 0.95),
|
||||
'green' : (0.35, 0.55, 0.02, 0.90),
|
||||
'amber' : (0.09, 0.80, 0.02, 0.80),
|
||||
'blue' : (0.58, 0.85, 0.02, 0.95),
|
||||
'red' : (0.01, 0.60, 0.02, 0.90),
|
||||
'pink' : (0.93, 0.65, 0.02, 0.95),
|
||||
'lavender': (0.75, 0.50, 0.02, 0.90)
|
||||
}
|
||||
if isinstance(color, str):
|
||||
if color in defaults:
|
||||
h_val, s_val, l_min, l_max = defaults[color]
|
||||
else:
|
||||
valid = ','.join(defaults.keys())
|
||||
raise ValueError(f"Undefined color descriptor '{color}', known values are:\n{valid}")
|
||||
else:
|
||||
s_val = 0.70 # set up default values
|
||||
l_min = 0.00
|
||||
l_max = 1.00
|
||||
if not hasattr(color,'__len__'):
|
||||
h_val = float(color)
|
||||
elif len(color) == 1:
|
||||
h_val = color[0]
|
||||
elif len(color) == 2:
|
||||
h_val, s_val = color
|
||||
elif len(color) == 4:
|
||||
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)
|
||||
color_list = []
|
||||
for l_val in l_vals:
|
||||
qcol = QtGui.QColor()
|
||||
qcol.setHslF( h_val, s_val, l_val )
|
||||
color_list.append( qcol )
|
||||
return ColorMap( None, color_list, name=name, linearize=True )
|
||||
|
||||
def modulatedBarData(length=768, width=32):
|
||||
"""
|
||||
Returns an NumPy array that represents a modulated color bar ranging from 0 to 1.
|
||||
This is used to judge the perceived variation of the color gradient.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
length: int
|
||||
Length of the data set. Values will vary from 0 to 1 over this axis.
|
||||
width: int
|
||||
Width of the data set. The modulation will vary from 0% to 4% over this axis.
|
||||
"""
|
||||
gradient = np.linspace(0.00, 1.00, length)
|
||||
modulation = -0.04 * np.sin( (np.pi/4) * np.arange(length) )
|
||||
data = np.zeros( (length, width) )
|
||||
for idx in range(width):
|
||||
data[:,idx] = gradient + (idx/(width-1)) * modulation
|
||||
np.clip(data, 0.0, 1.0)
|
||||
return data
|
||||
|
||||
class ColorMap(object):
|
||||
"""
|
||||
@ -216,7 +301,6 @@ class ColorMap(object):
|
||||
A ColorMap object provides access to the interpolated colors by indexing with a float value:
|
||||
``cm[0.5]`` returns a QColor corresponding to the center of ColorMap `cm`.
|
||||
"""
|
||||
|
||||
## mapping modes
|
||||
CLIP = 1
|
||||
REPEAT = 2
|
||||
@ -238,14 +322,14 @@ class ColorMap(object):
|
||||
'qcolor': QCOLOR,
|
||||
}
|
||||
|
||||
def __init__(self, pos, color, mapping=CLIP, mode=None): #, names=None):
|
||||
def __init__(self, pos, color, mapping=CLIP, mode=None, linearize=False, name=''):
|
||||
"""
|
||||
__init__(pos, color, mapping=ColorMap.CLIP)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pos: array_like of float in range 0 to 1
|
||||
Assigned positions of specified colors
|
||||
pos: array_like of float in range 0 to 1, or None
|
||||
Assigned positions of specified colors. `None` sets equal spacing.
|
||||
color: array_like of colors
|
||||
List of colors, interpreted via :func:`mkColor() <pyqtgraph.mkColor>`.
|
||||
mapping: str or int, optional
|
||||
@ -255,24 +339,35 @@ class ColorMap(object):
|
||||
The default of `ColorMap.CLIP` continues to show
|
||||
the colors assigned to 0 and 1 for all values below or above this range, respectively.
|
||||
"""
|
||||
self.name = name # storing a name helps identify ColorMaps sampled by Palette
|
||||
if mode is not None:
|
||||
warnings.warn(
|
||||
"'mode' argument is deprecated and does nothing.",
|
||||
DeprecationWarning, stacklevel=2
|
||||
)
|
||||
self.pos = np.array(pos)
|
||||
order = np.argsort(self.pos)
|
||||
self.pos = self.pos[order]
|
||||
self.color = np.apply_along_axis(
|
||||
func1d = lambda x: np.uint8( mkColor(x).getRgb() ), # cast RGB integer values to uint8
|
||||
axis = -1,
|
||||
arr = color,
|
||||
)[order]
|
||||
if pos is None:
|
||||
order = range(len(color))
|
||||
self.pos = np.linspace(0.0, 1.0, num=len(color))
|
||||
else:
|
||||
self.pos = np.array(pos)
|
||||
order = np.argsort(self.pos)
|
||||
self.pos = self.pos[order]
|
||||
|
||||
self.color = np.zeros( (len(color), 4) ) # stores float rgba values
|
||||
for cnt, idx in enumerate(order):
|
||||
self.color[cnt] = mkColor(color[idx]).getRgbF()
|
||||
# alternative code may be more efficient, but fails to handle lists of QColor.
|
||||
# self.color = np.apply_along_axis(
|
||||
# func1d = lambda x: np.uint8( mkColor(x).getRgb() ), # cast RGB integer values to uint8
|
||||
# axis = -1,
|
||||
# arr = color,
|
||||
# )[order]
|
||||
|
||||
self.mapping_mode = self.CLIP # default to CLIP mode
|
||||
if mapping is not None:
|
||||
self.setMappingMode( mapping )
|
||||
self.stopsCache = {}
|
||||
if linearize: self.linearize()
|
||||
|
||||
def setMappingMode(self, mapping):
|
||||
"""
|
||||
@ -294,6 +389,12 @@ class ColorMap(object):
|
||||
self.mapping_mode = mapping # only allow defined values
|
||||
else:
|
||||
raise ValueError("Undefined mapping type '{:s}'".format(str(mapping)) )
|
||||
|
||||
def __str__(self):
|
||||
""" provide human-readable identifier """
|
||||
if self.name is None:
|
||||
return 'unnamed ColorMap({:d})'.format(len(self.pos))
|
||||
return "ColorMap({:d}):'{:s}'".format(len(self.pos),self.name)
|
||||
|
||||
def __getitem__(self, key):
|
||||
""" Convenient shorthand access to palette colors """
|
||||
@ -306,6 +407,17 @@ class ColorMap(object):
|
||||
except ValueError: pass
|
||||
return None
|
||||
|
||||
def linearize(self):
|
||||
"""
|
||||
Adjusts the positions assigned to color stops to approximately equalize the perceived color difference
|
||||
for a fixed step.
|
||||
"""
|
||||
colors = self.getColors(mode=self.QCOLOR)
|
||||
distances = colorDistance(colors)
|
||||
positions = np.insert( np.cumsum(distances), 0, 0.0 )
|
||||
self.pos = positions / positions[-1] # normalize last value to 1.0
|
||||
self.stopsCache = {}
|
||||
|
||||
def reverse(self):
|
||||
"""
|
||||
Reverses the color map, so that the color assigned to a value of 1 now appears at 0 and vice versa.
|
||||
|
@ -214,7 +214,7 @@ def mkColor(*args):
|
||||
types. Accepted arguments are:
|
||||
|
||||
================ ================================================
|
||||
'c' one of: r, g, b, c, m, y, k, w
|
||||
'c' one of: r, g, b, c, m, y, k, w
|
||||
R, G, B, [A] integers 0-255
|
||||
(R, G, B, [A]) tuple of integers 0-255
|
||||
float greyscale, 0.0-1.0
|
||||
@ -372,13 +372,170 @@ def hsvColor(hue, sat=1.0, val=1.0, alpha=1.0):
|
||||
c = QtGui.QColor()
|
||||
c.setHsvF(hue, sat, val, alpha)
|
||||
return c
|
||||
|
||||
|
||||
# Matrices and math taken from "CIELab Color Space" by Gernot Hoffmann
|
||||
# http://docs-hoffmann.de/cielab03022003.pdf
|
||||
MATRIX_XYZ_FROM_RGB = np.array( (
|
||||
( 0.4124, 0.3576, 0.1805),
|
||||
( 0.2126, 0.7152, 0.0722),
|
||||
( 0.0193, 0.1192, 0.9505) ) )
|
||||
|
||||
MATRIX_RGB_FROM_XYZ = np.array( (
|
||||
( 3.2410,-1.5374,-0.4985),
|
||||
(-0.9692, 1.8760, 0.0416),
|
||||
( 0.0556,-0.2040, 1.0570) ) )
|
||||
|
||||
VECTOR_XYZn = np.array( ( 0.9505, 1.0000, 1.0891) ) # white reference at illuminant D65
|
||||
|
||||
def CIELabColor(L, a, b, alpha=1.0):
|
||||
"""
|
||||
Generates as QColor from CIE L*a*b* values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
L: float
|
||||
Lightness value ranging from 0 to 100
|
||||
a, b: float
|
||||
(green/red) and (blue/yellow) coordinates, typically -127 to +127.
|
||||
alpha: float, optional
|
||||
Opacity, ranging from 0 to 1
|
||||
|
||||
Notes
|
||||
-----
|
||||
The CIE L*a*b* color space parametrizes color in terms of a luminance `L`
|
||||
and the `a` and `b` coordinates that locate the hue in terms of
|
||||
a "green to red" and a "blue to yellow" axis.
|
||||
|
||||
These coordinates seek to parametrize human color preception in such a way
|
||||
that the Euclidean distance between the coordinates of two colors represents
|
||||
the visual difference between these colors. In particular, the difference
|
||||
|
||||
ΔE = sqrt( (L1-L2)² + (a1-a2)² + (b1-b2)² ) = 2.3
|
||||
|
||||
is considered the smallest "just noticeable difference" between colors.
|
||||
|
||||
This simple equation represents the CIE76 standard. Later standards CIE94
|
||||
and CIE2000 refine the difference calculation ΔE, while maintaining the
|
||||
L*a*b* coordinates.
|
||||
|
||||
Alternative (and arguably more accurate) methods exist to quantify color
|
||||
difference, but the CIELab color space remains a convenient approximation.
|
||||
|
||||
Under a known illumination, assumed to be white standard illuminant D65
|
||||
here, a CIELab color induces a response in the human eye
|
||||
that is described by the tristimulus value XYZ. Once this is known, an
|
||||
sRGB color can be calculated to induce the same response.
|
||||
|
||||
More information and underlying mathematics can be found in e.g.
|
||||
"CIELab Color Space" by Gernot Hoffmann, available at
|
||||
http://docs-hoffmann.de/cielab03022003.pdf .
|
||||
|
||||
Also see :func:`colorDistance() <pyqtgraph.colorDistance>`.
|
||||
"""
|
||||
# convert to tristimulus XYZ values
|
||||
vec_XYZ = np.full(3, ( L +16)/116 ) # Y1 = (L+16)/116
|
||||
vec_XYZ[0] += a / 500 # X1 = (L+16)/116 + a/500
|
||||
vec_XYZ[2] -= b / 200 # Z1 = (L+16)/116 - b/200
|
||||
for idx, val in enumerate(vec_XYZ):
|
||||
if val > 0.20689:
|
||||
vec_XYZ[idx] = vec_XYZ[idx]**3
|
||||
else:
|
||||
vec_XYZ[idx] = (vec_XYZ[idx] - 16/116) / 7.787
|
||||
vec_XYZ = VECTOR_XYZn * vec_XYZ # apply white reference
|
||||
# print(f'XYZ: {vec_XYZ}')
|
||||
|
||||
# convert XYZ to linear RGB
|
||||
vec_RGB = MATRIX_RGB_FROM_XYZ @ vec_XYZ
|
||||
# gamma-encode linear RGB
|
||||
arr_sRGB = np.zeros(3)
|
||||
for idx, val in enumerate( vec_RGB[:3] ):
|
||||
if val > 0.0031308: # (t) RGB value for linear/exponential transition
|
||||
arr_sRGB[idx] = 1.055 * val**(1/2.4) - 0.055
|
||||
else:
|
||||
arr_sRGB[idx] = 12.92 * val # (s)
|
||||
arr_sRGB = clip_array( arr_sRGB, 0.0, 1.0 ) # avoid QColor errors
|
||||
qcol = QtGui.QColor()
|
||||
qcol.setRgbF( *arr_sRGB )
|
||||
if alpha < 1.0: qcol.setAlpha(alpha)
|
||||
return qcol
|
||||
|
||||
def colorCIELab(qcol):
|
||||
"""
|
||||
Describes a QColor by an array of CIE L*a*b* values.
|
||||
Also see :func:`CIELabColor() <pyqtgraph.CIELabColor>` .
|
||||
|
||||
Parameters
|
||||
----------
|
||||
qcol: QColor
|
||||
QColor to be converted
|
||||
|
||||
Returns
|
||||
-------
|
||||
NumPy array
|
||||
Color coordinates `[L, a, b]`.
|
||||
"""
|
||||
srgb = qcol.getRgbF()[:3] # get sRGB values from QColor
|
||||
# convert gamma-encoded sRGB to linear:
|
||||
vec_RGB = np.zeros(3)
|
||||
for idx, val in enumerate( srgb ):
|
||||
if val > (12.92 * 0.0031308): # coefficients (s) * (t)
|
||||
vec_RGB[idx] = ((val+0.055)/1.055)**2.4
|
||||
else:
|
||||
vec_RGB[idx] = val / 12.92 # (s) coefficient
|
||||
# converted linear RGB to tristimulus XYZ:
|
||||
vec_XYZ = MATRIX_XYZ_FROM_RGB @ vec_RGB
|
||||
# normalize with white reference and convert to L*a*b* values
|
||||
vec_XYZ1 = vec_XYZ / VECTOR_XYZn
|
||||
for idx, val in enumerate(vec_XYZ1):
|
||||
if val > 0.008856:
|
||||
vec_XYZ1[idx] = vec_XYZ1[idx]**(1/3)
|
||||
else:
|
||||
vec_XYZ1[idx] = 7.787*vec_XYZ1[idx] + 16/116
|
||||
vec_Lab = np.array([
|
||||
116 * vec_XYZ1[1] - 16, # Y1
|
||||
500 * (vec_XYZ1[0] - vec_XYZ1[1]), # X1 - Y1
|
||||
200 * (vec_XYZ1[1] - vec_XYZ1[2])] ) # Y1 - Z1
|
||||
return vec_Lab
|
||||
|
||||
def colorDistance(colors, metric='CIE76'):
|
||||
"""
|
||||
Returns the perceptual distances between a sequence of QColors.
|
||||
See :func:`CIELabColor() <pyqtgraph.CIELabColor>` for more information.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
colors: list of QColor
|
||||
Two or more colors to calculate the distances between.
|
||||
metric: string, optional
|
||||
Metric used to determined the difference. Only 'CIE76' is supported at this time,
|
||||
where a distance of 2.3 is considered a "just noticeable difference".
|
||||
The default may change as more metrics become available.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List
|
||||
The `N-1` sequential distances between `N` colors.
|
||||
"""
|
||||
metric = metric.upper()
|
||||
if len(colors) < 1: return np.array([], dtype=np.float)
|
||||
if metric == 'CIE76':
|
||||
dist = []
|
||||
lab1 = None
|
||||
for col in colors:
|
||||
lab2 = colorCIELab(col)
|
||||
if lab1 is None: #initialize on first element
|
||||
lab1 = lab2
|
||||
continue
|
||||
dE = math.sqrt( np.sum( (lab1-lab2)**2 ) )
|
||||
dist.append(dE)
|
||||
lab1 = lab2
|
||||
return np.array(dist)
|
||||
raise ValueError(f'Metric {metric} is not available.')
|
||||
|
||||
def colorTuple(c):
|
||||
"""Return a tuple (R,G,B,A) from a QColor"""
|
||||
return (c.red(), c.green(), c.blue(), c.alpha())
|
||||
|
||||
|
||||
def colorStr(c):
|
||||
"""Generate a hex string code from a QColor"""
|
||||
return ('%02x'*4) % colorTuple(c)
|
||||
|
@ -248,6 +248,18 @@ def test_siParse(s, suffix, expected):
|
||||
with pytest.raises(expected):
|
||||
pg.siParse(s, suffix=suffix)
|
||||
|
||||
def test_CIELab_reconversion():
|
||||
color_list = [ pg.Qt.QtGui.QColor('#100235') ] # known problematic values
|
||||
for _ in range(20):
|
||||
qcol = pg.Qt.QtGui.QColor()
|
||||
qcol.setRgbF( *np.random.random((3)) )
|
||||
color_list.append(qcol)
|
||||
|
||||
for qcol1 in color_list:
|
||||
vec_Lab = pg.functions.colorCIELab( qcol1 )
|
||||
qcol2 = pg.functions.CIELabColor(*vec_Lab)
|
||||
for val1, val2 in zip( qcol1.getRgb(), qcol2.getRgb() ):
|
||||
assert abs(val1-val2)<=1, f'Excess CIELab reconversion error ({qcol1.name() } > {vec_Lab } > {qcol2.name()})'
|
||||
|
||||
MoveToElement = pg.QtGui.QPainterPath.ElementType.MoveToElement
|
||||
LineToElement = pg.QtGui.QPainterPath.ElementType.LineToElement
|
||||
@ -321,4 +333,3 @@ def test_arrayToQPath(xs, ys, connect, expected):
|
||||
continue
|
||||
element = path.elementAt(i)
|
||||
assert eq(expected[i], (element.type, element.x, element.y))
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user