Last active
May 1, 2023 21:59
-
-
Save yig/3d2a68232554df3b76509438ebba2106 to your computer and use it in GitHub Desktop.
Generate palette swatches, one random and one following a template. Can also generate uniform color wheels in Lab/Luv space via `python3 simplepalettes.py --wheel -L 60 -c 33 triad foo`.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
''' | |
Author: Yotam Gingold <yotam (strudel) yotamgingold.com> | |
License: Public Domain [CC0](http://creativecommons.org/publicdomain/zero/1.0/) | |
On GitHub as a gist: https://gist.github.com/yig/3d2a68232554df3b76509438ebba2106 | |
''' | |
from __future__ import print_function, division | |
from numpy import * | |
import skimage.io | |
from skimage import color | |
import os | |
## For reproducibility | |
random.seed(1) | |
LUMINANCE = 60. | |
CHROMA = 33. | |
VERIFY = True | |
def generate_one_random_lch(): | |
''' | |
Lch has Luminance in the range [0,100], | |
chroma in the range [0,100], | |
and hue in radians [0,2*pi]. | |
''' | |
return ( LUMINANCE, CHROMA, 2*pi*random.random() ) | |
def lch2rgb( lch ): | |
''' | |
Given a color in Lch color space as a tuple of three numbers, | |
returns a color in sRGB color space as a tuple of three numbers. | |
''' | |
assert len( lch ) == 3 | |
assert float( lch[0] ) == lch[0] | |
assert float( lch[1] ) == lch[1] | |
assert float( lch[2] ) == lch[2] | |
if VERIFY and not lch_color_is_in_sRGB_gamut( lch ): | |
print( "Color out of sRGB gamut!" ) | |
return color.lab2rgb( color.lch2lab( asfarray(lch)[None,None,:] ) )[0,0] | |
def lch_color_is_in_sRGB_gamut( lch ): | |
''' | |
Given a color in Lch color space as a tuple of three numbers, | |
returns True if the color can be converted to sRGB and False otherwise. | |
''' | |
c_lab = color.lch2lab( asfarray(lch)[None,None,:] ) | |
c_lab2 = color.rgb2lab( color.lab2rgb( c_lab ) ) | |
return abs( c_lab - c_lab2 ).max() < 1e-5 | |
# def hue_too_close( hue1, hue2 ): | |
# return lch_too_close( ( LUMINANCE, CHROMA, hue1 ), ( LUMINANCE, CHROMA, hue2 ) ) | |
def lch_too_close( col1, col2 ): | |
## 2.3 is JND in Lab. | |
return linalg.norm( lch2lab(col1) - lch2lab(col2) ) <= 2.3*10 | |
def lch2lab( lch ): return color.lch2lab( asfarray(lch)[None,None,:] )[0,0] | |
def sort_lch_colors_relative_to_first( seq ): | |
seq = asfarray( seq ) | |
hue0 = seq[0][2] | |
for i in range(1,len(seq)): | |
while seq[i][2] < hue0: seq[i][2] += 2*pi | |
seq = seq.tolist() | |
seq_sorted = seq[:1] + sorted( seq[1:], key = lambda x: x[2] ) | |
## mod the numbers back | |
seq_sorted = [ ( c[0], c[1], fmod( c[2], 2*pi ) ) for c in seq_sorted ] | |
return seq_sorted | |
def generate_complementary(): | |
''' | |
Returns two palettes. The first is a complementary palette consisting of | |
two RGB colors opposite each other on the Lch color wheel. | |
The second palette will share one of the colors, but the other will have random hue. | |
All colors will have identical luminance and chroma. | |
''' | |
color1 = generate_one_random_lch() | |
color2 = ( color1[0], color1[1], fmod( color1[2] + pi, 2*pi ) ) | |
random1 = color1 | |
random2 = color2 | |
## Rejection sample until the palettes are different | |
while lch_too_close( random2, color2 ) or lch_too_close( random2, random1 ): | |
print( "rejection sampling" ) | |
random2 = generate_one_random_lch() | |
return ( [ lch2rgb(c) for c in (color1,color2) ], [ lch2rgb(c) for c in (random1,random2) ] ) | |
def generate_monochromatic(): | |
''' | |
Returns two palettes. The first is a monochromatic palette consisting of | |
two RGB colors, one with full chroma (saturation) and another with the same | |
lightness and hue but half the chroma. | |
The second palette will share one of the colors, but the other will have random hue. | |
''' | |
color1 = generate_one_random_lch() | |
# color2 = ( color1[0], 0.5*color1[1], color1[2] ) | |
## Don't go all the way to black. | |
color2 = ( (0.5 + 0.25*random.random())*color1[0], (0.5 + 0.25*random.random())*color1[1], color1[2] ) | |
random1 = color1 | |
## Use color1's chromaticity. | |
# random2 = ( color1[0], color1[1], generate_one_random_lch()[2] ) | |
## Rejection sample until the palettes are different | |
random2 = color2 | |
while( | |
lch_too_close( random2, color2 ) | |
or | |
lch_too_close( random1, random2 ) | |
or | |
## Don't accidentally choose a monochromatic or complementary scheme. | |
lch_too_close( random1, (random1[0], random1[1], random2[2]) ) | |
or | |
lch_too_close( random1, (random1[0], random1[1], fmod( random2[2] + pi, 2*pi )) ) | |
): | |
print( "rejection sampling" ) | |
## Don't go all the way to black. | |
random2 = ( color2[0], color2[1], generate_one_random_lch()[2] ) | |
return ( [ lch2rgb(c) for c in (color1,color2) ], [ lch2rgb(c) for c in (random1,random2) ] ) | |
def generate_triad(): | |
''' | |
Returns two palettes. The first is a complementary palette consisting of | |
three RGB colors equally spaced in hue around the Lch color wheel. | |
The second palette will share one of the colors, but the others will have random hue. | |
All colors will have identical luminance and chroma. | |
''' | |
color1 = generate_one_random_lch() | |
color2 = ( color1[0], color1[1], fmod( color1[2] + 2*pi/3., 2*pi ) ) | |
color3 = ( color1[0], color1[1], fmod( color1[2] + 4*pi/3., 2*pi ) ) | |
## Keep the first random color the same as the first triad color. | |
random1 = color1 | |
## Rejection sample until we get three distinct colors, no two of which are similar | |
## to the template palette or to each other. | |
random2 = color1 | |
random3 = color1 | |
while( | |
( lch_too_close( random2, color1 ) or lch_too_close( random2, color2 ) or lch_too_close( random2, color3 ) ) | |
and | |
( lch_too_close( random3, color1 ) or lch_too_close( random3, color2 ) or lch_too_close( random3, color3 ) ) | |
) or ( | |
lch_too_close( random1, random2 ) or lch_too_close( random1, random3 ) or lch_too_close( random2, random3 ) | |
): | |
print( "rejection sampling" ) | |
random2 = generate_one_random_lch() | |
random3 = generate_one_random_lch() | |
return ( [ lch2rgb(c) for c in (color1,color2,color3) ], [ lch2rgb(c) for c in sort_lch_colors_relative_to_first([random1,random2,random3]) ] ) | |
def generate_square(): | |
''' | |
Returns two palettes. The first is a complementary palette consisting of | |
three RGB colors equally spaced in hue around the Lch color wheel. | |
The second palette will share one of the colors, but the others will have random hue. | |
All colors will have identical luminance and chroma. | |
''' | |
color1 = generate_one_random_lch() | |
color2 = ( color1[0], color1[1], fmod( color1[2] + 1*pi/4, 2*pi ) ) | |
color3 = ( color1[0], color1[1], fmod( color1[2] + 2*pi/4, 2*pi ) ) | |
color4 = ( color1[0], color1[1], fmod( color1[2] + 3*pi/4, 2*pi ) ) | |
random1 = color1 | |
## Rejection sample until we get three distinct colors, no three of which are similar | |
## to the template palette or to each other. | |
random2 = color1 | |
random3 = color1 | |
random4 = color1 | |
while( | |
( lch_too_close( random2, color1 ) or lch_too_close( random2, color2 ) or lch_too_close( random2, color3 ) or lch_too_close( random2, color4 ) ) | |
and | |
( lch_too_close( random3, color1 ) or lch_too_close( random3, color2 ) or lch_too_close( random3, color3 ) or lch_too_close( random3, color4 ) ) | |
and | |
( lch_too_close( random4, color1 ) or lch_too_close( random4, color2 ) or lch_too_close( random4, color3 ) or lch_too_close( random4, color4 ) ) | |
) or ( | |
lch_too_close( random1, random2 ) | |
or | |
lch_too_close( random1, random3 ) | |
or | |
lch_too_close( random1, random4 ) | |
or | |
lch_too_close( random2, random3 ) | |
or | |
lch_too_close( random2, random4 ) | |
or | |
lch_too_close( random3, random4 ) | |
): | |
print( "rejection sampling" ) | |
random2 = generate_one_random_lch() | |
random3 = generate_one_random_lch() | |
random4 = generate_one_random_lch() | |
return ( [ lch2rgb(c) for c in (color1,color2,color3,color4) ], [ lch2rgb(c) for c in sort_lch_colors_relative_to_first([random1,random2,random3,random4]) ] ) | |
def palette2swatch( palette ): | |
''' | |
Given a sequence of sRGB colors, returns an image with side-by-side | |
50x50 solid color squares of each color in the palette. | |
''' | |
EDGE_SIZE = 50 | |
return hstack([ asfarray(c)[None,None,:]*ones((EDGE_SIZE,EDGE_SIZE,3)) for c in palette ]) | |
def save_palette2wedges( palette, filename, clobber = False ): | |
''' | |
Given a sequence of sRGB colors, saves an image with the palette | |
colors in a wedge arrangement. | |
''' | |
if os.path.exists( filename ) and not clobber: | |
raise RuntimeError( "File exists, will not clobber: %s" % filename ) | |
import cairocffi as cairo | |
### We want to arrange the colors in a circle. | |
### Option 1: | |
### So that colors don't overlap, let's make them circular swatches around the circle. | |
### What radius for the circle? It depends on how many colors. To keep the | |
### size constant, assume the maximum is 4. Then the closest two colors are at, | |
### without loss of generality, (r,0) and (0,r). We want the center-to-center | |
### distance to be some constant C. | |
### Then the distance sqrt(2 r^2) = sqrt(2)*r = 2*C/2 = C. | |
### So r = C/sqrt(2). | |
# assert 1 <= len( palette ) <= 4 | |
### Option 2: | |
### As wedges around a circle. | |
from math import pi, cos, sin | |
EDGE_SIZE = 300 | |
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, EDGE_SIZE, EDGE_SIZE) | |
ctx = cairo.Context(surface) | |
## Set up a transformation so that 0,0 is the center of the image, | |
## and +-1 are the corners. | |
## Subtract 10 pixels from each coordinate for padding. | |
ctx.scale( EDGE_SIZE, EDGE_SIZE ) | |
ctx.scale( 0.5, 0.5 ) | |
ctx.translate( 1.0, 1.0 ) | |
arc_radians = 2.*pi/len(palette) | |
for i, color in enumerate( palette ): | |
ctx.set_source_rgb( *color ) | |
ctx.move_to( 0,0 ) | |
ctx.line_to( cos( (i+0)*arc_radians ), sin( (i+0)*arc_radians ) ) | |
ctx.arc( 0, 0, 1.0, (i+0)*arc_radians, (i+1)*arc_radians ) | |
ctx.line_to( cos( (i+1)*arc_radians ), sin( (i+1)*arc_radians ) ) | |
ctx.fill() | |
surface.write_to_png( filename ) | |
print( "Saved:", filename ) | |
def save_palette2circles( palette, filename, clobber = False ): | |
''' | |
Given a sequence of sRGB colors, saves an image with the palette | |
colors in a circular arrangement. | |
''' | |
if os.path.exists( filename ) and not clobber: | |
raise RuntimeError( "File exists, will not clobber: %s" % filename ) | |
import cairocffi as cairo | |
from math import pi, cos, sin, sqrt | |
### We want to arrange the colors in a circle. | |
### Option 1: | |
### So that colors don't overlap, let's make them circular swatches around the circle. | |
### What radius for the circle? It depends on how many colors. To keep the | |
### size constant, assume the maximum is 4. Then the closest two colors are at, | |
### without loss of generality, (r,0) and (0,r). We want the center-to-center | |
### distance to be some constant C (which can be the diameter of the small circles | |
### so that they are "kissing" at those positions). | |
### Then the distance sqrt(2*r^2) = sqrt(2)*r = C. | |
### So r = C/sqrt(2). | |
### Then the image itself has width E = C + 2*r. | |
### This means that r = (E - C)/2 = C/sqrt(2). | |
### <=> E - C = C*sqrt(2) <=> C + C*sqrt(2) = E <=> C = E/(1 + sqrt(2)) | |
### In our canonical coordinate system, E = 2. | |
assert 1 <= len( palette ) <= 4 | |
EDGE_SIZE = 300 | |
small_circle_radius = 1./(1 + sqrt(2)) | |
large_circle_radius = 2*small_circle_radius/sqrt(2) | |
### Option 2: | |
### Find a radius for the large circle that may not take up the entire | |
### image but keeps some circles kissing (possibly with padding). | |
### TODO | |
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, EDGE_SIZE, EDGE_SIZE) | |
ctx = cairo.Context(surface) | |
## Set up a transformation so that 0,0 is the center of the image, | |
## and +-1 are the corners. | |
## Subtract 10 pixels from each coordinate for padding. | |
ctx.scale( EDGE_SIZE, EDGE_SIZE ) | |
ctx.scale( 0.5, 0.5 ) | |
ctx.translate( 1.0, 1.0 ) | |
radians_apart = 2*pi/len(palette) | |
for i, color in enumerate( palette ): | |
ctx.set_source_rgb( *color ) | |
cx, cy = large_circle_radius*cos(i*radians_apart), large_circle_radius*sin(i*radians_apart) | |
ctx.move_to( cx, cy ) | |
ctx.arc( cx, cy, small_circle_radius, 0, 2*pi ) | |
ctx.fill() | |
surface.write_to_png( filename ) | |
print( "Saved:", filename ) | |
def generate_debug_wheel(): | |
''' | |
Returns an sRGB image of all hues around the Lch color wheel. | |
''' | |
out_of_gamut_count = 0 | |
img = zeros((50,360,3)) | |
c = asfarray(generate_one_random_lch()) | |
for col, h in enumerate( linspace( 0, 2*pi, 360 ) ): | |
c[2] = h | |
img[:,col,:] = asfarray(lch2rgb(c))[None,:] | |
## verify | |
if not lch_color_is_in_sRGB_gamut( c ): | |
print( "Color out of sRGB gamut!" ) | |
out_of_gamut_count += 1 | |
print( "Wheel: %s colors out of gamut (%.2f%%)" % ( out_of_gamut_count, (100.*out_of_gamut_count) / 360 ) ) | |
return img | |
def save_image_to_file( img, filename, visualization = 'squares', clobber = False ): | |
if os.path.exists( filename ) and not clobber: | |
raise RuntimeError( "File exists, will not clobber: %s" % filename ) | |
skimage.io.imsave( filename, img ) | |
print( "Saved:", filename ) | |
def save_palette_pair( prefix, template, clobber = False, template2filename = None ): | |
if template == 'complementary': | |
t, r = generate_complementary() | |
elif template == 'monochromatic': | |
t, r = generate_monochromatic() | |
elif template == 'triad': | |
t, r = generate_triad() | |
elif template == 'square': | |
t, r = generate_square() | |
else: | |
raise RuntimeError( "Unknown template: %s" % template ) | |
if template2filename is None: | |
tname = prefix + '-' + template + '.png' | |
rname = prefix + '-' + 'random' + '.png' | |
else: | |
tname = template2filename( prefix, template ) | |
rname = template2filename( prefix, 'random' ) | |
if visualization == 'squares': | |
timg = palette2swatch( t ) | |
rimg = palette2swatch( r ) | |
save_image_to_file( timg, tname, clobber = clobber ) | |
save_image_to_file( rimg, rname, clobber = clobber ) | |
elif visualization == 'wedges': | |
save_palette2wedges( t, tname, clobber = clobber ) | |
save_palette2wedges( r, rname, clobber = clobber ) | |
elif visualization == 'circles': | |
save_palette2circles( t, tname, clobber = clobber ) | |
save_palette2circles( r, rname, clobber = clobber ) | |
else: | |
raise RuntimeError( "Unknown visualization: %s" % visualization ) | |
return tname, rname | |
def main(): | |
import argparse | |
parser = argparse.ArgumentParser( description = "Save a pair of palettes, one from a template and one random." ) | |
parser.add_argument( "template", type = str, choices = ['complementary', 'monochromatic', 'triad', 'square'], help="The template. Must be one of: complementary, monochromatic, triad, square." ) | |
parser.add_argument( "prefix", type = str, help="The output path prefix." ) | |
parser.add_argument( "--clobber", action = 'store_true', help="If set, this will overwrite existing files." ) | |
parser.add_argument( "-L", type = float, help="Override the default luminance." ) | |
parser.add_argument( "-c", type = float, help="Override the default chroma." ) | |
parser.add_argument( "--wheel", action = 'store_true', help="Generate a debug wheel saved to 'wheel.png' and quit." ) | |
parser.add_argument( "--seed", type = int, help="The random seed. Default 1." ) | |
parser.add_argument( "-V", "--visualization", type = str, default = 'squares', help="The visualization style. Default 'squares'." ) | |
args = parser.parse_args() | |
if args.seed is not None: | |
random.seed( args.seed ) | |
global LUMINANCE, CHROMA | |
if args.L is not None: | |
LUMINANCE = args.L | |
if args.c is not None: | |
CHROMA = args.c | |
print( "Luminance:", LUMINANCE ) | |
print( "Chroma:", CHROMA ) | |
if args.wheel: | |
W = generate_debug_wheel() | |
save_image_to_file( W, 'wheel.png', clobber = args.clobber ) | |
return | |
save_palette_pair( args.prefix, args.template, visualization = args.visualization, clobber = args.clobber ) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment