Files
NomadNet/tools/colorgen.py
T
2026-05-21 00:17:39 +02:00

321 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Color Table Generator with Perceptual Normalization
Generates HSL color tables with optional normalization to uniform perceptual
intensity (CIELAB L*) followed by global lightness scaling.
"""
import argparse
import colorsys
import math
import sys
def hsl_to_rgb(h, s, l):
"""Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0)."""
h_norm = (h % 360) / 360.0
s_norm = max(0, min(100, s)) / 100.0
l_norm = max(0, min(100, l)) / 100.0
r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm)
return (r, g, b)
def rgb_to_hex(r, g, b):
"""Convert RGB 0.0-1.0 to lowercase hex string."""
r = max(0, min(1, r))
g = max(0, min(1, g))
b = max(0, min(1, b))
return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
# CIELAB conversion functions
def srgb_to_linear(c):
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def linear_to_srgb(c):
if c <= 0.0031308:
return c * 12.92
return 1.055 * (c ** (1/2.4)) - 0.055
def rgb_to_lab(r, g, b):
"""Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant."""
# sRGB to Linear
r_lin = srgb_to_linear(r)
g_lin = srgb_to_linear(g)
b_lin = srgb_to_linear(b)
# Linear to XYZ (D65)
X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin
Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin
Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin
# XYZ to LAB
Xn, Yn, Zn = 95.047, 100.0, 108.883
delta = 6/29
def f(t):
if t > delta**3:
return t ** (1/3)
return t / (3 * delta**2) + 4/29
L = 116 * f(Y / Yn) - 16
a = 500 * (f(X / Xn) - f(Y / Yn))
b_val = 200 * (f(Y / Yn) - f(Z / Zn))
return (L, a, b_val)
def lab_to_rgb(L, a, b_val):
"""Convert CIELAB to sRGB (0-1)."""
Xn, Yn, Zn = 95.047, 100.0, 108.883
delta = 6/29
def inv_f(y):
if y > delta:
return y ** 3
return 3 * delta**2 * (y - 4/29)
L_adj = (L + 16) / 116
X = Xn * inv_f(L_adj + a / 500)
Y = Yn * inv_f(L_adj)
Z = Zn * inv_f(L_adj - b_val / 200)
# XYZ to Linear RGB
r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z
g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z
b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z
r = linear_to_srgb(r_lin)
g = linear_to_srgb(g_lin)
b = linear_to_srgb(b_lin)
return (r, g, b)
def generate_colors(
hues=None,
hue_start=0.0,
hue_step=30.0,
sat_start=70.0,
sat_step=0.0,
sat_steps=1,
light_start=50.0,
light_step=0.0,
light_steps=1,
perceptual_multiplier=1.0,
use_hsl_space=False,
normalize=False,
normalize_target=None,
discards=[]
):
"""
Generate color table with optional perceptual normalization.
Pipeline:
1. Generate HSL variations
2. If normalize: Convert to LAB, set all L* to target (mean or specified)
3. Apply global perceptual multiplier to L*
4. Convert to RGB and output hex
"""
# Determine hue list
if hues is not None:
hue_list = [float(h) % 360 for h in hues]
else:
hue_list = []
current = hue_start % 360
if hue_step <= 0:
raise ValueError("hue_step must be positive when using start/step mode")
while current < 360:
hue_list.append(current)
current += hue_step
# Determine if we need LAB processing
needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space
colors = []
lab_colors = [] # Store as (L, a, b) tuples if processing needed
for h in hue_list:
for i in range(sat_steps):
s = sat_start + (i * sat_step)
s = max(0, min(100, s))
for j in range(light_steps):
# l = light_start + (j * light_step)
l = light_start + (i * light_step)
l = max(0, min(100, l))
r, g, b = hsl_to_rgb(h, s, l)
if not needs_lab:
colors.append(rgb_to_hex(r, g, b))
else:
L, a, b_lab = rgb_to_lab(r, g, b)
lab_colors.append((L, a, b_lab))
filtered_colors = []
filtered_lab_colors = []
i = 0
for c in colors:
i += 1
if not str(i) in discards:
filtered_colors.append(c)
i = 0
for c in lab_colors:
i += 1
if not str(i) in discards:
filtered_lab_colors.append(c)
colors = filtered_colors
lab_colors = filtered_lab_colors
if not needs_lab:
return colors
# Step 2: Perceptual Normalization (equalize intensity)
if normalize:
if normalize_target is not None:
target_L = normalize_target
else:
# Calculate mean perceptual lightness
target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors)
print(f"Target L*: {target_L}")
# Clamp target to valid LAB range
target_L = max(0, min(100, target_L))
# Normalize all colors to target L, preserving hue (a, b)
lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors]
# Step 3: Apply final global perceptual multiplier
final_colors = []
for L, a, b in lab_colors:
L_final = L * perceptual_multiplier
L_final = max(0, min(100, L_final))
r, g, b = lab_to_rgb(L_final, a, b)
final_colors.append(rgb_to_hex(r, g, b))
return final_colors
def main():
parser = argparse.ArgumentParser(
description="Generate perceptually normalized color tables",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex
Examples:
# 32 colors normalized to same perceptual intensity, then darkened 20%
python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8
# Force all colors to exactly L*=55, then scale by 1.1
python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1
# Generate with lightness variation, normalize to mean intensity, output brightened
python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2
"""
)
# Hue configuration
hue_group = parser.add_mutually_exclusive_group()
hue_group.add_argument("--hues", type=str, metavar="LIST",
help='Comma-separated hue values, e.g., "0,60,120"')
hue_group.add_argument("--hue-start", type=float, default=0.0,
help="Initial hue offset in degrees")
parser.add_argument("--hue-step", type=float, default=30.0,
help="Separation between hues")
# Saturation/Lightness configuration
parser.add_argument("--sat-start", type=float, default=75.0,
help="Initial saturation (0-100)")
parser.add_argument("--sat-step", type=float, default=0.0,
help="Saturation step")
parser.add_argument("--sat-steps", type=int, default=1,
help="Number of saturation variations")
parser.add_argument("--light-start", type=float, default=50.0,
help="Initial lightness (0-100)")
parser.add_argument("--light-step", type=float, default=0.0,
help="Lightness step")
parser.add_argument("--light-steps", type=int, default=1,
help="Number of lightness variations")
# Perceptual processing
parser.add_argument("--normalize", action="store_true",
help="Normalize all colors to same perceptual intensity (L*) before applying multiplier")
parser.add_argument("--normalize-target", type=float, default=None, metavar="L",
help="Target L* value (0-100) for normalization. Default: mean of generated colors")
parser.add_argument("--perceptual-multiplier", type=float, default=1.0,
help="Final global lightness multiplier (1.0=no change)")
parser.add_argument("--hsl-space", action="store_true",
help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)")
# Output
parser.add_argument("--python-list", action="store_true",
help="Output as Python list")
parser.add_argument("--separator", type=str, default="\n",
help="Separator between colors")
parser.add_argument("--stats", action="store_true",
help="Print generation stats to stderr")
hue_group.add_argument("--discard", type=str, metavar="LIST",
help='Discard output indexes, e.g., "20,2,33"')
args = parser.parse_args()
if args.hsl_space and args.normalize:
print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr)
args.hsl_space = False
try:
if args.discard: discards = args.discard.split(",")
else: discards = []
colors = generate_colors(
hues=args.hues.split(",") if args.hues else None,
hue_start=args.hue_start,
hue_step=args.hue_step,
sat_start=args.sat_start,
sat_step=args.sat_step,
sat_steps=args.sat_steps,
light_start=args.light_start,
light_step=args.light_step,
light_steps=args.light_steps,
perceptual_multiplier=args.perceptual_multiplier,
use_hsl_space=args.hsl_space,
normalize=args.normalize,
normalize_target=args.normalize_target,
discards=discards,
)
i = 0
# Enable to test on light background
# print(f"#!bg=fff\n")
if args.python_list: print("colors = [", end="")
for c in colors:
i+=1
if args.python_list:
print(f"\"{c}\", ", end="")
else: print(f"`FT{c}colored {i}`f")
if args.python_list: print("]")
if args.stats:
total = len(colors)
mode = "normalized + " if args.normalize else ""
print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})",
file=sys.stderr)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()