Added Color.categorical_set that generates a creates a list of visually distinct colors

This commit is contained in:
gumyr 2025-11-19 10:01:58 -05:00
parent f3b080e351
commit 4507d78fff
2 changed files with 296 additions and 89 deletions

View file

@ -34,6 +34,7 @@ from __future__ import annotations
# other pylint warning to temp remove:
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import colorsys
import copy as copy_module
import itertools
import json
@ -1345,6 +1346,74 @@ class Color:
"""Color repr"""
return f"Color{str(tuple(self))}"
@classmethod
def categorical_set(
cls,
color_count: int,
starting_hue: ColorLike | float = 0.0,
alpha: float | Iterable[float] = 1.0,
) -> list[Color]:
"""Generate a palette of evenly spaced colors.
Creates a list of visually distinct colors suitable for representing
discrete categories (such as different parts, assemblies, or data
series). Colors are evenly spaced around the hue circle and share
consistent lightness and saturation levels, resulting in balanced
perceptual contrast across all hues.
Produces palettes similar in appearance to the **Tableau 10** and **D3
Category10** color setsboth widely recognized standards in data
visualization for their clarity and accessibility. These values have
been empirically chosen to maintain consistent perceived brightness
across hues while avoiding overly vivid or dark colors.
Args:
color_count (int): Number of colors to generate.
starting_hue (ColorLike | float): Either a Color-like object or
a hue value in the range [0.0, 1.0] that defines the starting color.
alpha (float | Iterable[float]): Alpha value(s) for the colors. Can be a
single float or an iterable of length `color_count`.
Returns:
list[Color]: List of generated colors.
Raises:
ValueError: If starting_hue is out of range or alpha length mismatch.
"""
# --- Determine starting hue ---
if isinstance(starting_hue, float):
if not (0.0 <= starting_hue <= 1.0):
raise ValueError("Starting hue must be within range 0.01.0")
elif isinstance(starting_hue, int):
if starting_hue < 0:
raise ValueError("Starting color integer must be non-negative")
rgb = tuple(Color(starting_hue))[:3]
starting_hue = colorsys.rgb_to_hls(*rgb)[0]
else:
raise TypeError(
"Starting hue must be a float in [0,1] or an integer color literal"
)
# --- Normalize alpha values ---
if isinstance(alpha, (float, int)):
alphas = [float(alpha)] * color_count
else:
alphas = list(alpha)
if len(alphas) != color_count:
raise ValueError("Number of alpha values must match color_count")
# --- Generate color list ---
hues = np.linspace(
starting_hue, starting_hue + 1.0, color_count, endpoint=False
)
colors = [
cls(*colorsys.hls_to_rgb(h % 1.0, 0.55, 0.9), a)
for h, a in zip(hues, alphas)
]
return colors
@staticmethod
def _rgb_from_int(triplet: int) -> tuple[float, float, float]:
red, remainder = divmod(triplet, 256**2)

View file

@ -26,8 +26,9 @@ license:
"""
import colorsys
import copy
import math
import numpy as np
import pytest
@ -36,44 +37,84 @@ from build123d.geometry import Color
# Overloads
@pytest.mark.parametrize("color, expected", [
@pytest.mark.parametrize(
"color, expected",
[
pytest.param(Color("blue"), (0, 0, 1, 1), id="name"),
pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"),
pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"),
])
],
)
def test_overload_name(color, expected):
np.testing.assert_allclose(tuple(color), expected, 1e-5)
@pytest.mark.parametrize("color, expected", [
@pytest.mark.parametrize(
"color, expected",
[
pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"),
pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"),
pytest.param(Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha"),
pytest.param(Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), (0.1, 0.2, 0.3, 0.5), id="kw rgba"),
])
pytest.param(
Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha"
),
pytest.param(
Color(red=0.1, green=0.2, blue=0.3, alpha=0.5),
(0.1, 0.2, 0.3, 0.5),
id="kw rgba",
),
],
)
def test_overload_rgba(color, expected):
np.testing.assert_allclose(tuple(color), expected, 1e-5)
@pytest.mark.parametrize("color, expected", [
pytest.param(Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code"),
pytest.param(Color(0x006692, 0x80), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), id="color_code + alpha"),
pytest.param(Color(0x006692, alpha=0x80), (0, 102 / 255, 146 / 255, 128 / 255), id="color_code + kw alpha"),
pytest.param(Color(color_code=0x996692, alpha=0xCC), (153 / 255, 102 / 255, 146 / 255, 204 / 255), id="kw color_code + alpha"),
])
@pytest.mark.parametrize(
"color, expected",
[
pytest.param(
Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code"
),
pytest.param(
Color(0x006692, 0x80),
(0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF),
id="color_code + alpha",
),
pytest.param(
Color(0x006692, alpha=0x80),
(0, 102 / 255, 146 / 255, 128 / 255),
id="color_code + kw alpha",
),
pytest.param(
Color(color_code=0x996692, alpha=0xCC),
(153 / 255, 102 / 255, 146 / 255, 204 / 255),
id="kw color_code + alpha",
),
],
)
def test_overload_hex(color, expected):
np.testing.assert_allclose(tuple(color), expected, 1e-5)
@pytest.mark.parametrize("color, expected", [
@pytest.mark.parametrize(
"color, expected",
[
pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"),
pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"),
pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"),
pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga"),
pytest.param(
Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga"
),
pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"),
])
],
)
def test_overload_tuple(color, expected):
np.testing.assert_allclose(tuple(color), expected, 1e-5)
# ColorLikes
@pytest.mark.parametrize("color_like", [
@pytest.mark.parametrize(
"color_like",
[
pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"),
pytest.param("red", id="name str"),
pytest.param("red ", id="name str whitespace"),
@ -86,7 +127,9 @@ def test_overload_tuple(color, expected):
pytest.param("#ff0000ff", id="hex str rgba 24bit"),
pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"),
pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"),
pytest.param(("#ff0000ff", .6), id="tuple hex str rgba 24bit + alpha (not used)"),
pytest.param(
("#ff0000ff", 0.6), id="tuple hex str rgba 24bit + alpha (not used)"
),
pytest.param("#f00", id="hex str rgb 12bit"),
pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"),
pytest.param(("#f00",), id="tuple hex str rgb 12bit"),
@ -94,47 +137,59 @@ def test_overload_tuple(color, expected):
pytest.param("#f00f", id="hex str rgba 12bit"),
pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"),
pytest.param(("#f00f",), id="tuple hex str rgba 12bit"),
pytest.param(("#f00f", .6), id="tuple hex str rgba 12bit + alpha (not used)"),
pytest.param(0xff0000, id="hex int"),
pytest.param((0xff0000), id="tuple hex int"),
pytest.param((0xff0000, 0xff), id="tuple hex int + alpha"),
pytest.param(("#f00f", 0.6), id="tuple hex str rgba 12bit + alpha (not used)"),
pytest.param(0xFF0000, id="hex int"),
pytest.param((0xFF0000), id="tuple hex int"),
pytest.param((0xFF0000, 0xFF), id="tuple hex int + alpha"),
pytest.param((1, 0, 0), id="tuple rgb int"),
pytest.param((1, 0, 0, 1), id="tuple rgba int"),
pytest.param((1., 0., 0.), id="tuple rgb float"),
pytest.param((1., 0., 0., 1.), id="tuple rgba float"),
])
pytest.param((1.0, 0.0, 0.0), id="tuple rgb float"),
pytest.param((1.0, 0.0, 0.0, 1.0), id="tuple rgba float"),
],
)
def test_color_likes(color_like):
expected = (1, 0, 0, 1)
np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5)
np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5)
@pytest.mark.parametrize("color_like, expected", [
@pytest.mark.parametrize(
"color_like, expected",
[
pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"),
pytest.param(1., (1, 1, 1, 1), id="r float"),
pytest.param((1.,), (1, 1, 1, 1), id="tuple r float"),
pytest.param((1., 0.), (1, 0, 1, 1), id="tuple rg float"),
])
pytest.param(1.0, (1, 1, 1, 1), id="r float"),
pytest.param((1.0,), (1, 1, 1, 1), id="tuple r float"),
pytest.param((1.0, 0.0), (1, 0, 1, 1), id="tuple rg float"),
],
)
def test_color_likes_incomplete(color_like, expected):
np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5)
np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5)
@pytest.mark.parametrize("color_like", [
@pytest.mark.parametrize(
"color_like",
[
pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"),
pytest.param(("red", 0.6), id="tuple name str + alpha"),
pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"),
pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"),
pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"),
pytest.param(("#f009"), id="tuple hex str rgba 12bit"),
pytest.param((0xff0000, 153), id="tuple hex int + alpha int"),
pytest.param((1., 0., 0., 0.6), id="tuple rbga float"),
])
pytest.param((0xFF0000, 153), id="tuple hex int + alpha int"),
pytest.param((1.0, 0.0, 0.0, 0.6), id="tuple rbga float"),
],
)
def test_color_likes_alpha(color_like):
expected = (1, 0, 0, 0.6)
np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5)
np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5)
# Exceptions
@pytest.mark.parametrize("name", [
@pytest.mark.parametrize(
"name",
[
pytest.param("build123d", id="invalid color name"),
pytest.param("#ffg", id="invalid rgb 12bit"),
pytest.param("#fffg", id="invalid rgba 12bit"),
@ -144,21 +199,34 @@ def test_color_likes_alpha(color_like):
pytest.param("#fffff", id="short rgb 24bit"),
pytest.param("#fffffff", id="short rgba 24bit"),
pytest.param("#fffffffff", id="long rgba 24bit"),
])
],
)
def test_exceptions_color_name(name):
with pytest.raises(Exception):
Color(name)
@pytest.mark.parametrize("color_type", [
pytest.param((dict({"name": "red", "alpha": 1},)), id="dict arg"),
@pytest.mark.parametrize(
"color_type",
[
pytest.param(
(
dict(
{"name": "red", "alpha": 1},
)
),
id="dict arg",
),
pytest.param(("red", "blue"), id="str + str"),
pytest.param((1., "blue"), id="float + str order"),
pytest.param((1.0, "blue"), id="float + str order"),
pytest.param((1, "blue"), id="int + str order"),
])
],
)
def test_exceptions_color_type(color_type):
with pytest.raises(Exception):
Color(*color_type)
# Methods
def test_rgba_wrapped():
c = Color(1.0, 1.0, 0.0, 0.5)
@ -167,17 +235,87 @@ def test_rgba_wrapped():
assert c.wrapped.GetRGB().Blue() == 0.0
assert c.wrapped.Alpha() == 0.5
def test_copy():
c = Color(0.1, 0.2, 0.3, alpha=0.4)
c_copy = copy.copy(c)
np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), rtol=1e-5)
def test_str_repr_is():
c = Color(1, 0, 0)
assert str(c) == "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'"
assert repr(c) == "Color(1.0, 0.0, 0.0, 1.0)"
def test_str_repr_near():
c = Color(1, 0.5, 0)
assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'"
assert repr(c) == "Color(1.0, 0.5, 0.0, 1.0)"
class TestColorCategoricalSet:
def test_returns_expected_number_of_colors(self):
colors = Color.categorical_set(5)
assert len(colors) == 5
assert all(isinstance(c, Color) for c in colors)
def test_colors_are_evenly_spaced_in_hue(self):
count = 8
colors = Color.categorical_set(count)
hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors]
diffs = [(hues[(i + 1) % count] - hues[i]) % 1.0 for i in range(count)]
avg_diff = sum(diffs) / len(diffs)
assert all(math.isclose(d, avg_diff, rel_tol=1e-2) for d in diffs)
def test_starting_hue_as_float(self):
(r, g, b, _) = tuple(Color.categorical_set(1, starting_hue=0.25)[0])
h = colorsys.rgb_to_hls(r, g, b)[0]
assert math.isclose(h, 0.25, rel_tol=0.05)
def test_starting_hue_as_int_hex(self):
# Blue (0x0000FF) should be valid and return a Color
c = Color.categorical_set(1, starting_hue=0x0000FF)[0]
assert isinstance(c, Color)
def test_starting_hue_invalid_type(self):
with pytest.raises(TypeError):
Color.categorical_set(3, starting_hue="invalid")
def test_starting_hue_out_of_range(self):
with pytest.raises(ValueError):
Color.categorical_set(3, starting_hue=1.5)
with pytest.raises(ValueError):
Color.categorical_set(3, starting_hue=-0.1)
def test_starting_hue_negative_int(self):
with pytest.raises(ValueError):
Color.categorical_set(3, starting_hue=-1)
def test_constant_alpha_applied(self):
colors = Color.categorical_set(3, alpha=0.7)
for c in colors:
(_, _, _, a) = tuple(c)
assert math.isclose(a, 0.7, rel_tol=1e-6)
def test_iterable_alpha_applied(self):
alphas = (0.1, 0.5, 0.9)
colors = Color.categorical_set(3, alpha=alphas)
for a, c in zip(alphas, colors):
(_, _, _, returned_alpha) = tuple(c)
assert math.isclose(a, returned_alpha, rel_tol=1e-6)
def test_iterable_alpha_length_mismatch(self):
with pytest.raises(ValueError):
Color.categorical_set(4, alpha=[0.5, 0.7])
def test_hues_wrap_around(self):
colors = Color.categorical_set(10, starting_hue=0.95)
hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors]
assert all(0.0 <= h <= 1.0 for h in hues)
def test_alpha_defaults_to_one(self):
colors = Color.categorical_set(4)
for c in colors:
(_, _, _, a) = tuple(c)
assert math.isclose(a, 1.0, rel_tol=1e-6)