monobiome/notebooks/gamut_3d_plotly.py

1021 lines
31 KiB
Python

# adapted from the Coloraide Python package
# file: https://github.com/facelessuser/coloraide/blob/main/tools/gamut_3d_plotly.py
"""Plot color space using Plotly."""
import sys
import argparse
from scipy.spatial import Delaunay
import plotly.graph_objects as go
import math
import plotly.io as io
import os
import json
sys.path.insert(0, os.getcwd())
try:
from coloraide_extras.everything import ColorAll as Color
except ImportError:
from coloraide.everything import ColorAll as Color
from coloraide.spaces import HSLish, HSVish, HWBish, Labish, LChish, RGBish # noqa: E402
from coloraide import algebra as alg # noqa: E402
FORCE_OWN_GAMUT = {'ryb', 'ryb-biased'}
def get_face_color(cmap, simplex, filters):
"""Get best color."""
return Color.average([cmap[simplex[0]], cmap[simplex[1]], cmap[simplex[2]]], space='srgb').to_string(hex=True)
def create_custom_hsl(gamut):
"""Create a custom color object that has access to a special `hsl-gamut` space to map surface in."""
cs = Color.CS_MAP[gamut]
hsl = Color.CS_MAP['hsl']
scale = not isinstance(cs, RGBish)
class HSL(type(hsl)):
NAME = f'-hsl-{gamut}'
BASE = gamut
GAMUT_CHECK = gamut
CLIP_SPACE = None
WHITE = cs.WHITE
DYAMIC_RANGE = cs.DYNAMIC_RANGE
INDEXES = cs.indexes()
# Scale channels as needed
OFFSET_1 = cs.channels[INDEXES[0]].low if scale else 0.0
OFFSET_2 = cs.channels[INDEXES[1]].low if scale else 0.0
OFFSET_3 = cs.channels[INDEXES[2]].low if scale else 0.0
SCALE_1 = cs.channels[INDEXES[0]].high if scale else 1.0
SCALE_2 = cs.channels[INDEXES[1]].high if scale else 1.0
SCALE_3 = cs.channels[INDEXES[2]].high if scale else 1.0
def to_base(self, coords):
"""Convert from RGB to HSL."""
# Convert from HSL back to its original space
coords = hsl.to_base(coords)
# Scale and offset the values back to the origin space's configuration
coords[0] = coords[0] * (self.SCALE_1 - self.OFFSET_1) + self.OFFSET_1
coords[1] = coords[1] * (self.SCALE_2 - self.OFFSET_2) + self.OFFSET_2
coords[2] = coords[2] * (self.SCALE_3 - self.OFFSET_3) + self.OFFSET_3
ordered = [0.0, 0.0, 0.0]
# Consistently order a given color spaces points based on its type
for e, c in enumerate(coords):
ordered[self.INDEXES[e]] = c
return ordered
def from_base(self, coords):
"""Convert from HSL to RGB."""
# Undo order a given color spaces points based on its type
coords = [coords[i] for i in self.INDEXES]
# Scale and offset the values such that channels are between 0 - 1
coords[0] = (coords[0] - self.OFFSET_1) / (self.SCALE_1 - self.OFFSET_1)
coords[1] = (coords[1] - self.OFFSET_2) / (self.SCALE_2 - self.OFFSET_2)
coords[2] = (coords[2] - self.OFFSET_3) / (self.SCALE_3 - self.OFFSET_3)
# Convert to HSL
return hsl.from_base(coords)
class ColorCyl(Color):
"""Custom color."""
ColorCyl.register(HSL())
return ColorCyl
def create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters):
"""Create the 3D renders."""
i, j, k = tri.simplices.T
if opacity:
mesh = go.Mesh3d(
x=x,
y=y,
z=z,
i=i,
j=j,
k=k,
vertexcolor=cmap if not faces else None,
facecolor=[
get_face_color(cmap, t, filters) if not fcolor else fcolor for t in tri.simplices
] if faces else None,
flatshading = True,
lighting = {"vertexnormalsepsilon": 0, "facenormalsepsilon": 0}
)
mesh.update(hoverinfo='skip')
mesh.update(opacity=opacity)
fig.add_traces([mesh])
if edges:
# Draw the triangles, but ensure they are separate by adding `None` to the end.
xe = []
ye = []
ze = []
tri_colors = []
for p0, p1, p2 in tri.simplices:
xe.extend([x[p0], x[p1], x[p2], x[p0], None])
ye.extend([y[p0], y[p1], y[p2], y[p0], None])
ze.extend([z[p0], z[p1], z[p2], z[p0], None])
if ecolor is None:
tri_colors.extend([cmap[p0], cmap[p1], cmap[p2], cmap[p0], '#000000'])
# Use a single color for edges.
if ecolor is not None:
tri_colors = ecolor
lines = go.Scatter3d(
x=xe,
y=ye,
z=ze,
mode='lines',
marker={'size':0},
showlegend=False,
name='',
line={'color': tri_colors}
)
lines.update(hoverinfo='skip')
fig.add_traces([lines])
def cyl_disc(
fig,
ColorCyl,
space,
gamut,
location,
resolution,
opacity,
edges,
faces,
ecolor,
fcolor,
gmap,
flags,
filters
):
"""
Plot cylindrical disc on either top or bottom of an RGB cylinder.
Expectation is either a HSL, HSV, or HSB style cylinder.
"""
cs = ColorCyl.CS_MAP[gamut]
factor = cs.channels[1].high
# Using a lightness of 0 can sometimes cause the bottom not to show with certain resolutions, so use a very
# small value instead.
zpos = 1e-16 if location == 'bottom' else 1.0 * factor
x = []
y = []
z = []
u = []
v = []
cmap = []
# Interpolate a circle on the outer edge
s1 = ColorCyl.steps(
[ColorCyl(gamut, [hue, 1 * factor, 1 * zpos]) for hue in alg.linspace(0, 360, 2, endpoint=True)],
steps=max(7, (resolution // 6) * 6 + 1),
space=gamut,
hue='specified'
)
s2 = ColorCyl(gamut, [alg.NaN, 1e-16, alg.NaN])
# Interpolate concentric circles to the center of the disc
step = int(resolution / 2)
for r in range(step):
for t1 in s1:
s2['hue'] = t1['hue']
c = t1.mix(s2, r / (step - 1), space=gamut, hue='specified')
hue = c._space.hue_index()
radius = c._space.radial_index()
u.append(c[radius])
v.append(c[hue])
c.convert(space, norm=False, in_place=True)
store_coords(c, x, y, z, flags)
# Ensure colors fit in output color gamut.
s = c.convert('srgb')
if not s.in_gamut():
s.fit(**gmap)
else:
s.clip()
if filters:
s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()
cmap.append(s.to_string(hex=True))
# Calculate triangles
tri = Delaunay([*zip(u, v)])
create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)
def store_coords(c, x, y, z, flags):
"""Store coordinates."""
# LCh spaces
if flags['is_lchish']:
light, chroma, hue = c._space.names()
a, b = alg.polar_to_rect(c[chroma], c[hue])
x.append(a)
y.append(b)
z.append(c[light])
# HSL, HSV, or HWB spaces
elif flags['is_hslish'] or flags['is_hsvish'] or flags['is_hwbish']:
hue, sat, light = c._space.names()
a, b = alg.polar_to_rect(c[sat], c[hue])
x.append(a)
y.append(b)
z.append(c[light])
# Any other generic cylindrical space that doesn't fit in the categories above.
elif flags['is_cyl']:
hue = c._space.hue_index()
radius = c._space.radial_index()
a, b = alg.polar_to_rect(c[radius], c[hue])
x.append(a)
y.append(b)
z.append(c[3 - hue - radius])
# Lab spaces
elif flags['is_labish']:
light, a, b = c._space.names()
x.append(c[a])
y.append(c[b])
z.append(c[light])
# Non-cylindrical spaces could be done here, but normally are not.
else:
x.append(c[0])
y.append(c[1])
z.append(c[2])
def render_space_cyl(fig, space, gamut, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters):
"""Renders the color space using an HSL cylinder that is then mapped to the given space."""
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
# Determine the gamut mapping space to use.
# Some spaces cannot be generalized (HWB and HPLuv for instance).
if flags['is_hwbish']:
ColorCyl = Color
gspace = space
elif Color.CS_MAP[gamut].is_polar():
ColorCyl = Color
gspace = gamut
else:
_gamut = space if space in FORCE_OWN_GAMUT else gamut
ColorCyl = create_custom_hsl(_gamut)
gspace = f'-hsl-{_gamut}'
cs = ColorCyl.CS_MAP[gspace]
# Adjust scaling factor if the mapping space requires it
factor = cs.channels[1].high
# Render the two halves of the cylinder
u = []
v = []
x = []
y = []
z = []
cmap = []
# Interpolate the cylinder from 0 to 360 degrees.
# Include, at the very least, 6 evenly spaced hues, and at higher resolutions
# will include a multiple that will include the same 6 key points.
# In HSL, this will cover all the corners of the RGB space.
s1 = ColorCyl.steps(
[ColorCyl(gspace, [hue, 1 * factor, 1 * factor]) for hue in alg.linspace(0, 360, 2, endpoint=True)],
steps=max(7, (resolution // 6) * 6 + 1),
space=gspace,
hue='specified'
)
# A generic color at the bottom of the space which we can rotate for
# interpolation by changing the hue.
s2 = ColorCyl(gspace, [alg.NaN, 1 * factor, 1e-16])
# Create a 3D mesh by interpolating ring at each lightness down the cylinder side.
# Include at least 3 points of lightness: lightest, darkest, and mid, which in
# HSL is the most colorful colors.
for color in s1:
s2['hue'] = color['hue']
for c in ColorCyl.steps([color, s2], steps=max(3, (resolution // 2) * 2 + 1), space=gspace, hue='specified'):
u.append(c[2])
v.append(c['hue'])
c.convert(space, norm=False, in_place=True)
store_coords(c, x, y, z, flags)
# Adjust gamut to fit the display space
s = c.convert('srgb')
if not s.in_gamut():
s.fit(**gmap)
else:
s.clip()
if filters:
s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()
cmap.append(s.to_string(hex=True))
# Calculate the triangles
tri = Delaunay([*zip(u, v)])
create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)
# Generate tops for spaces that do not normally get tops automatically.
if flags['is_hwbish'] or (flags['is_cyl'] and not flags['is_lchish']) or isinstance(cs, HSVish):
cyl_disc(
fig, ColorCyl, space, gspace, 'top', resolution, opacity, edges, faces, ecolor, fcolor, gmap, flags, filters
)
cyl_disc(
fig, ColorCyl, space, gspace, 'bottom', resolution, opacity, edges, faces, ecolor, fcolor, gmap, flags, filters
)
return fig
def plot_gamut_in_space(
space,
gamuts,
title="",
dark=False,
gmap=None,
size=(800, 800),
camera=None,
aspect=None,
projection='perspective',
filters=()
):
"""Plot the given space in sRGB."""
if gmap is None:
gmap = {}
io.templates.default = 'plotly_dark' if dark else 'plotly'
if camera is None:
camera = {'a': 45, 'e': 45, 'r': math.sqrt(1.25 ** 2 + 1.25 ** 2 + 1.25 ** 2)}
a = math.radians((90 - camera['a']) % 360)
e = math.radians(90 - camera['e'])
r = camera['r']
y = r * math.sin(e) * math.cos(a)
x = r * math.sin(e) * math.sin(a)
z = r * math.cos(e)
if aspect is None:
aspect = {'x': 1, 'y': 1, 'z': 1}
# Get names for
target = Color.CS_MAP[space]
if len(target.CHANNELS) > 3:
print('Color spaces with dimensions greater than 3 are not supported')
return None
names = target.CHANNELS
is_cyl = target.is_polar()
is_labish = isinstance(target, Labish)
is_lchish = isinstance(target, LChish)
is_hslish_hsvish = isinstance(target, (HSLish, HSVish))
# Setup axes
if is_labish:
c1, c2, c3 = target.indexes()
axm = [c2, c3, c1]
elif is_lchish:
c1, c2, c3 = target.indexes()
axm = [c3, c2, c1]
elif is_hslish_hsvish:
axm = [0, 1, 2]
else:
axm = [0, 1, 2]
showbackground = True
backgroundcolor = "rgb(230, 230, 230)" if not dark else '#282830'
gridcolor = "rgb(255, 255, 255)" if not dark else '#111'
zerolinecolor = "rgb(255, 255, 255)" if not dark else '#111'
axis = {
"showbackground": showbackground,
"backgroundcolor": backgroundcolor,
"gridcolor": gridcolor,
"zerolinecolor": zerolinecolor,
}
xaxis = str(names[axm[0]]) if not is_cyl else f"{names[axm[0]]} (0˚ - 360˚)"
yaxis = str(names[axm[1]])
zaxis = str(names[axm[2]])
# Setup plot layout
layout = go.Layout(
# General figure characteristics
title=title,
width=size[0],
height=size[1],
# Specify scene layout
scene=go.layout.Scene(
xaxis=go.layout.scene.XAxis(title=xaxis, showticklabels=not is_cyl, **axis),
yaxis=go.layout.scene.YAxis(title=yaxis, **axis),
zaxis=go.layout.scene.ZAxis(title=zaxis, **axis),
aspectratio=aspect
),
# Control camera position
scene_camera={
"projection": go.layout.scene.camera.Projection(type=projection),
"center": {"x": 0, "y": 0, "z": 0},
"up": {"x": 0, "y": 0, "z": 1},
"eye": {"x": x, "y": y, "z": z}
}
)
# Create figure to store the plot
fig = go.Figure(layout=layout)
for gamut, config in gamuts.items():
opacity = config.get('opacity', 1)
resolution = config.get('resolution', 200)
edges = config.get('edges', False)
ecolor = None
if isinstance(edges, str):
c = Color(edges).convert('srgb').fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
ecolor = c.to_string(hex=True, alpha=False)
edges = True
faces = config.get('faces', False)
fcolor = ''
if isinstance(faces, str):
c = Color(faces).convert('srgb').fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
fcolor = c.to_string(hex=True, alpha=False)
faces = True
render_space_cyl(fig, space, gamut, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters)
return fig
def plot_colors(fig, space, gamut, gmap_colors, colors, gmap, filters=()):
"""Plot gamut mapping."""
if not gmap_colors and not colors:
return
gamut_mapping = gmap_colors.split(';') if gmap_colors.strip() else []
non_mapped = colors.split(';') if colors.strip() else []
if gamut_mapping or non_mapped:
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
l = len(gamut_mapping)
for i, c in enumerate(gamut_mapping + non_mapped):
c1 = Color(c)
c2 = Color(c).fit(gamut, **gmap)
c1.convert(space, in_place=True)
c2.convert(space, in_place=True)
x = []
y = []
z = []
for c in ([c1, c2] if i < l else [c1]):
store_coords(c, x, y, z, flags)
c2.convert('srgb', in_place=True).fit(**gmap)
if filters:
c2.filter(filters[0], **filters[1], in_place=True, out_space=c2.space()).clip()
fig.add_trace(
go.Scatter3d(
x=x, y=y, z=z,
line={'color': 'black', 'width': 2},
marker={
'color': c2.to_string(hex=True, alpha=False),
'size': [16, 0],
'opacity': 1,
'line': {'width': 2}
},
showlegend=False
)
)
def plot_interpolation(
fig,
space,
interp_colors,
interp_method,
gmap,
simulate_alpha,
interp_gmap,
filters=()
):
"""Plot interpolations."""
if not interp_colors:
return
colors = Color.steps(
interp_colors.split(';'),
**interp_method
)
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
x = []
y = []
z = []
cmap = []
for c in colors:
c.convert(space, in_place=True)
if interp_gmap:
c.fit('srgb', **gmap)
store_coords(c, x, y, z, flags)
c.convert('srgb', in_place=True)
c.fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
if simulate_alpha:
cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
else:
cmap.append(c.to_string(hex=True, alpha=False))
trace = go.Scatter3d(
x=x, y=y, z=z,
mode = 'markers',
marker={'color': cmap, 'opacity': 1},
showlegend=False
)
fig.add_trace(trace)
def plot_harmony(
fig,
space,
harmony,
gmap,
simulate_alpha,
harmony_gmap,
filters=()
):
"""Plot color harmony."""
if not harmony:
return
hcolor, options = harmony
if 'space' not in options:
options['space'] = space
options['out_space'] = space
colors = Color(hcolor).harmony(**options)
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
cmap = []
x = []
y = []
z = []
for s in colors:
c = s.normalize(nans=False)
if harmony_gmap:
c.fit('srgb', **gmap)
store_coords(c, x, y, z, flags)
c.convert('srgb', in_place=True).fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
if simulate_alpha:
cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
else:
cmap.append(c.to_string(hex=True, alpha=False))
if options['name'] in ('wheel', 'rectangle', 'square', 'triad'):
x.append(x[0])
y.append(y[0])
z.append(z[0])
cmap.append(cmap[0])
size = ([8] * (len(x) - 1)) + [0]
else:
size = [8] * len(x)
trace = go.Scatter3d(
x=x, y=y, z=z,
marker={'size': size, 'color': cmap, 'opacity': 1},
line={'color': 'black', 'width': 3},
showlegend=False
)
fig.add_trace(trace)
def plot_average(
fig,
space,
avg_colors,
avg_options,
gmap,
simulate_alpha,
avg_gmap,
filters=()
):
"""Plot interpolations."""
if not avg_colors:
return
parts = avg_colors.split(':')
colors = parts[0].split(';')
if len(parts) == 2:
weights = [float(i.strip()) for i in parts[1].split(',')]
else:
weights = None
color = Color.average(
colors,
weights,
space=avg_options['space']
)
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
for s in colors:
x = []
y = []
z = []
cmap = []
c = Color(s).convert(space, in_place=True).normalize(nans=False)
if avg_gmap:
c.fit('srgb', **gmap)
store_coords(c, x, y, z, flags)
c.convert('srgb', in_place=True).fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
if simulate_alpha:
cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
else:
cmap.append(c.to_string(comma=True, alpha=False))
c = Color(color).convert(space, in_place=True).normalize(nans=False)
if avg_gmap:
c.fit('srgb', **gmap)
store_coords(c, x, y, z, flags)
c.convert('srgb', in_place=True).fit(**gmap)
if filters:
c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
if simulate_alpha:
cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
else:
cmap.append(c.to_string(hex=True, alpha=False))
trace = go.Scatter3d(
x=x, y=y, z=z,
marker={'size': [8, 16], 'color': cmap, 'opacity': 1},
line={'color': cmap[0], 'width': 3},
showlegend=False
)
fig.add_trace(trace)
def plot_trajectory(
fig,
space,
trajectory_colors, # list[Color] or anything Color() can parse
gmap,
filters=(),
connect_line=True,
marker_size=8,
line_width=3,
opacity=1.0,
simulate_alpha=False
):
# Figure out how to place coords in this `space`
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
x = []
y = []
z = []
cmap = []
for c in trajectory_colors:
c = Color(c) # accept either Color objects or strings
c.convert(space, in_place=True).normalize(nans=False)
# project to 3D coords in the same way gamut surfaces are plotted
store_coords(c, x, y, z, flags)
# choose marker fill color: display in sRGB, gamut-mapped
c_disp = c.convert('srgb')
if not c_disp.in_gamut():
c_disp.fit(**gmap)
else:
c_disp.clip()
if filters:
c_disp.filter(filters[0], **filters[1], in_place=True, out_space=c_disp.space()).clip()
if simulate_alpha:
cmap.append(Color.layer([c_disp, 'white'], space='srgb').to_string(hex=True, alpha=False))
else:
cmap.append(c_disp.to_string(hex=True, alpha=False))
mode = 'lines+markers' if connect_line else 'markers'
fig.add_trace(
go.Scatter3d(
x=x,
y=y,
z=z,
opacity=opacity,
mode=mode,
marker={
'size': marker_size,
'color': cmap,
'opacity': 1,
'line': {'width': 1, 'color': 'black'}
},
line={'width': line_width, 'color': 'black'} if connect_line else {'width': 0},
showlegend=False
)
)
def main():
"""Main."""
parser = argparse.ArgumentParser(prog='3d_diagrams', description='Plot 3D gamut in a different color spaces.')
parser.add_argument('--space', '-s', help='Desired space.')
# Gamut and gamut mapping
parser.add_argument(
'--gamut',
'-g',
action='append',
default=[],
help=(
"Gamut space to render space in. Can be followed by a JSON config in the form 'space:{}' to set `edges`,"
'`faces`, `opacity`, or `resolution`. `edges` and `faces` can be a boolean to disable or enable them or '
'color to configure them all as a specific color.'
)
)
parser.add_argument(
'--gmap',
default='raytrace',
help=(
"Gamut mapping algorithm. To set additional options, follow the algorithm with with a JSON string and "
"containing the parameters in the form of 'algorithm:{}'."
)
)
parser.add_argument('--gmap-colors', default='', help='Color(s) to gamut map, separated by semicolons.')
parser.add_argument(
'--colors',
default='',
help='Plot arbitrary color points. Colors are separated with semicolons.'
)
# Interpolation visualization
parser.add_argument(
'--avg-colors',
default='',
help="Colors that should be averaged together separated by semicolons."
)
parser.add_argument(
'--average-options',
default='{}',
help=(
"Averaging configuration (JSON string)."
)
)
parser.add_argument('--interp-colors', default='', help='Interpolation colors separated by semicolons.')
parser.add_argument(
'--interp-method',
default='linear',
help=(
"Interpolation configuration. Interpolation method followed by an optional JSON containing options: "
"'method: {}'"
)
)
parser.add_argument(
'--harmony',
default='',
help=(
"Harmony configuration: 'color:harmony'. Harmony can be followed by an optional JSON containing options: "
"'color:harmony: {}'"
)
)
parser.add_argument(
'--mix-alpha',
action='store_true',
help="Simulate interpolation/averaging/harmony opacity by overlaying on white."
)
parser.add_argument(
'--mix-gmap',
action='store_true',
help="Force plotted interpolation/averaging/harmony results to be gamut mapped."
)
parser.add_argument(
'--filter',
default='',
help=(
"Apply filter. Filter options can be provided as a JSON string after filter name: 'filter:{}'."
)
)
# Graphical and plotting options
parser.add_argument('--title', '-t', default='', help="Provide a title for the diagram.")
parser.add_argument('--dark', action="store_true", help="Use dark theme.")
parser.add_argument('--output', '-o', default='', help='Output file.')
parser.add_argument('--height', '-H', type=int, default=800, help="Height")
parser.add_argument('--width', '-W', type=int, default=800, help="Width")
# Camera and perspective
parser.add_argument('--pos', '-p', default=None, help="Position of camara 'x:y:z'")
parser.add_argument('--azimuth', '-A', type=float, default=45, help="Camera X position")
parser.add_argument('--elevation', '-E', type=float, default=45, help="Camera Y position")
parser.add_argument('--distance', '-D', type=float, default=2.5, help="Camera Z position")
parser.add_argument(
'--aspect-ratio', '-R',
default='1:1:1',
help="Aspect ratio. Set to 0:0:0 to leave aspect ratio untouched."
)
parser.add_argument(
'--projection', '-P',
default='perspective',
help="Projection mode, perspective or orthographic"
)
args = parser.parse_args()
gamuts = {}
first = None
for gamut in args.gamut:
parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(gamut.split(':', 1))]
gamuts[parts[0]] = {} if len(parts) == 1 else parts[1]
first = parts[0]
if first is None:
first = 'srgb'
gamuts['srgb'] = {}
aspect = {k: float(v) for k, v in zip(['x', 'y', 'z'], args.aspect_ratio.split(':'))}
parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.gmap.split(':', 1))]
gmap = {'method': parts[0]}
if len(parts) == 2:
gmap.update(parts[1])
filters = []
if args.filter:
parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.filter.split(':', 1))]
filters.append(parts[0])
if len(parts) == 2:
filters.append(parts[1])
else:
filters.append({})
# Plot the color space(s)
fig = plot_gamut_in_space(
args.space,
gamuts,
title=args.title,
dark=args.dark,
gmap=gmap,
size=(args.width, args.height),
camera={'a': args.azimuth, 'e': args.elevation, 'r': args.distance},
aspect=aspect,
projection=args.projection,
filters=filters
)
parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.interp_method.split(':', 1))]
interp = {'method': parts[0], 'hue': 'shorter', 'steps': 100}
if len(parts) == 2:
interp.update(parts[1])
# Plot interpolation
plot_interpolation(
fig,
args.space,
args.interp_colors,
interp,
gmap,
args.mix_alpha,
args.mix_gmap,
filters
)
avg_options = {"space": "srgb-linear"}
avg_options.update(json.loads(args.average_options))
plot_average(
fig,
args.space,
args.avg_colors,
avg_options,
gmap,
args.mix_alpha,
args.mix_gmap,
filters
)
parts = [p.strip() if e < 2 else json.loads(p) for e, p in enumerate(args.harmony.split(':', 2)) if p]
harmony_config = []
if parts:
hcolor = parts[0]
harmony = {'name': parts[1]}
if len(parts) == 3:
harmony.update(parts[2])
harmony_config = [hcolor, harmony]
plot_harmony(
fig,
args.space,
harmony_config,
gmap,
args.mix_alpha,
args.mix_gmap,
filters
)
# Plot gamut mapping examples
plot_colors(fig, args.space, first, args.gmap_colors, args.colors, gmap, filters)
# Show or save the data as an image, etc.
if fig:
if args.output:
filetype = os.path.splitext(args.output)[1].lstrip('.').lower()
if filetype == 'html':
with open(args.output, 'w') as f:
f.write(io.to_html(fig))
elif filetype == 'json':
io.write_json(fig, args.output)
else:
with open(args.output, 'wb') as f:
f.write(fig.to_image(format=filetype, width=args.width, height=args.height))
else:
fig.show('browser')
return 0
return 1
if __name__ == "__main__":
sys.exit(main())