-
Notifications
You must be signed in to change notification settings - Fork 34
/
color-b64sort
executable file
·150 lines (132 loc) · 7 KB
/
color-b64sort
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
#!/usr/bin/env python
import collections as cs, pathlib as pl, itertools as it
import os, sys, base64, random
from colormath.color_objects import sRGBColor, LabColor
from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000
def color_delta(c1, c2):
'Returns delta between any type of color specs.'
c1, c2 = (( convert_color( c if not isinstance(c, str)
else sRGBColor.new_from_rgb_hex(c), LabColor )
if not isinstance(c, LabColor) else c ) for c in [c1, c2])
return delta_e_cie2000(c1, c2)
def main(args=None):
c_bg_diff_def = 30
import argparse, textwrap, re
dd = lambda text: re.sub( r' \t+', ' ',
textwrap.dedent(text).strip('\n') + '\n' ).replace('\t', ' ')
parser = argparse.ArgumentParser(usage='%(prog)s [options] [colors-file]',
formatter_class=argparse.RawTextHelpFormatter, description=dd('''
Tool to filter, order and compress color palettes.
Takes a plaintext any-space-separated list of colors from fancy tools like
https://medialab.github.io/iwanthue/ and intended background color,
removes colors that are too close to latter, then orders them by
"random, but different from last ones" criteria, also discarding close ones.
Encodes resulting colors (3-byte RGBs) into base64 for a compact representation.'''))
parser.add_argument('colors_file', nargs='?', help=dd('''
File with many hex-encoded colors separated by any kind of spaces.
Default is to read the list from stdin stream, same as if "-" is specified.'''))
group = parser.add_argument_group('Input/output tweaks')
group.add_argument('-B', '--b64-input', action='store_true',
help=dd('''
Read input colors from base64 default-output format,
instead of plaintext list of hex-encoded colors.'''))
group.add_argument('-H', '--hex-output', action='store_true',
help='Output colors in hex format, one per line, instead of default base64.')
group.add_argument('-C', '--convert', action='store_true',
help='Do not reorder or filter colors in any way, just output same list.')
group = parser.add_argument_group('Color order/filtering opts')
group.add_argument('-b', '--bg-color',
metavar='hex(:threshold)', default=f'e7ebf3:{c_bg_diff_def}', help=dd('''
Background color to make sure all other colors will be distinct from.
Can have an optional colon-separated Delta E CIE 2000 threshold value,
otherwise threshold from default value will be used. Default: %(default)s'''))
group.add_argument('-c', '--avoid-color',
action='append', metavar='hex(:threshold)', help=dd(f'''
Additional color(s) to avoid similarity with, with same idea
and hex[:threshold] specification as in -b/--bg-color option above.
Can be used multiple times to avoid similarity with all specified colors.
Same default threshold value if omitted as in -b/--bg-color ({c_bg_diff_def}).'''))
group.add_argument('-s', '--random-seed', metavar='string', help=dd('''
Seed for PRNG used when picking colors "at random", for repeatable results.
Any string should work. Default is to use --bg-color value (with threshold).'''))
group.add_argument('-t', '--threshold',
metavar='delta_e_cie_2000', type=float, default=10, help=dd('''
Min threshold for delta between colors in a set, applied when random-picking them.
Next picked color is discarded if too similar to any among already-picked ones.'''))
group.add_argument('-k', '--sort-delta-keys',
metavar='w1:n1,...', default='0.3:5,0.2:10,0.1:20', help=dd('''
Comma/space-separated list of weight:count tuples used for final color ordering.
With empty value here, next color is selected by: -min(deltas with all-picked).
Adding 0.3:5, will use -0.3*min(deltas of last-5) -0.7*min(deltas) instead,
i.e. give last 5 diffs 30%% weight when deciding ordering, so that colors aren't
mixed in a way where e.g. 5 similar ones end up close together.
This orders colors picked in PRNG-order with --threshold check.
Using "-" value here will disable this ordering.'''))
opts = parser.parse_args(sys.argv[1:] if args is None else args)
c_list = ( sys.stdin.read()
if (opts.colors_file or '-').strip() == '-' else pl.Path(opts.colors_file).read_text() )
if not opts.b64_input: c_list = list(map(sRGBColor.new_from_rgb_hex, c_list.split()))
else:
c_list = base64.urlsafe_b64decode(c_list)
c_list = list(sRGBColor.new_from_rgb_hex(
f'{int.from_bytes(c_list[n:n+3], "big"):06x}' ) for n in range(0, len(c_list), 3))
c_list_lab = list(convert_color(c, LabColor) for c in c_list)
print(f'Colors [input]: {len(c_list)}')
if opts.convert: c_list_pick = c_list
else:
c_parse = lambda c: (func(v) for func, v in zip(
[sRGBColor.new_from_rgb_hex, float], [*c.split(':'), c_bg_diff_def][:2] ))
c_bg, c_bg_diff_min = c_parse(opts.bg_color)
bg_spec = f'{c_bg.get_rgb_hex().lstrip("#")}:{c_bg_diff_min:.1f}'
if c_bg_diff_min > 0:
c_list = list(c for c in c_list if color_delta(c, c_bg) > c_bg_diff_min)
print(f'Colors [diff-from={bg_spec}]: {len(c_list)}')
if opts.avoid_color:
for c in opts.avoid_color:
c, c_diff_min = c_parse(c)
if c_diff_min <= 0: continue
c_list = list(c for c in c_list if color_delta(c, c_bg) > c_diff_min)
print(f'Colors [with-avoid-colors]: {len(c_list)}')
prng_seed = opts.random_seed or f'{bg_spec}'
print(f'PRNG seed string: {prng_seed!r}')
random.seed(int.from_bytes(prng_seed.encode(), 'big'))
ns_order = list(range(len(c_list)))
ns_diffs = cs.defaultdict(dict)
for n1, n2 in it.combinations(ns_order, 2):
ns_diffs[n1][n2] = ns_diffs[n2][n1] = \
delta_e_cie2000(c_list_lab[n1], c_list_lab[n2])
c_pick_diff = opts.threshold
random.shuffle(ns_order)
ns_pick = list()
for n in ns_order:
if any((ns_diffs[n][np] < c_pick_diff) for np in ns_pick): continue
ns_pick.append(n)
if opts.sort_delta_keys != '-':
wcs = opts.sort_delta_keys.replace(',', ' ').split()
if wcs: wcs = list((float(w), int(c)) for w, c in (v.split(':', 1) for v in wcs))
if (w := 1 - sum(v[0] for v in wcs)) < 0:
parser.error(f'Sum of --sort-delta-keys weights must be <=1: {opts.sort_delta_keys!r}')
wcs.append((w, 2**32-1))
ns_pick, ns_stack = list(), ns_pick
while ns_stack:
if ns_pick and len(ns_stack) > 1:
ns_stack.sort( key=lambda n:
sum(w * min(ns_diffs[n][nc] for nc in ns_pick[:c]) for w, c in wcs) )
ns_pick.append(ns_stack.pop()) # has max(weighted-delta-mins) value
c_list_pick = list(c_list[n] for n in ns_pick)
print(f'Colors [selected]: {len(c_list_pick)}')
if not opts.hex_output:
print(f'\nBase64 RGB color list [{len(c_list_pick)} color(s)]:\n')
splits, b64 = list(), base64.urlsafe_b64encode(b''.join(
int(c.get_rgb_hex().lstrip('#'), 16).to_bytes(3, 'big')
for c in c_list_pick )).decode()
for bs in range(65, 80):
lines = list(b64[n:n+bs] for n in range(0, len(b64), bs))
splits.append((bs - len(lines[-1]), lines))
for line in sorted(splits)[0][1]: print(f' {line}')
else:
print(f'\nHex-RGB color list [{len(c_list_pick)} color(s)]:\n')
for c in c_list_pick: print(f' {c.get_rgb_hex()}')
print()
if __name__ == '__main__': sys.exit(main())