From f3be4890b09293b37bded8d6b05cdf6f463fbaa1 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 19:27:10 +0200 Subject: [PATCH 01/14] add oklab_to_srgb inverse round-trip --- pylette/src/colorspaces.py | 11 +++++++++++ tests/integration/test_colorspaces.py | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/pylette/src/colorspaces.py b/pylette/src/colorspaces.py index 743717b..b5e594e 100644 --- a/pylette/src/colorspaces.py +++ b/pylette/src/colorspaces.py @@ -68,3 +68,14 @@ def srgb_to_oklab(srgb: tuple[float, float, float]) -> tuple[float, float, float arr = np.asarray([srgb], dtype=np.float64) lab = linear_srgb_to_oklab(srgb_to_linear(arr))[0] return (float(lab[0]), float(lab[1]), float(lab[2])) + + +def oklab_to_srgb(lab: tuple[float, float, float]) -> tuple[float, float, float]: + """Convert a single OKLab ``(L, a, b)`` triple to gamma-encoded sRGB in ``[0, 1]``. + + Inverse of :func:`srgb_to_oklab`. ``linear_to_srgb`` clips into ``[0, 1]``, + so out-of-gamut OKLab inputs (e.g. an averaged centroid) are returned clamped. + """ + arr = np.asarray([lab], dtype=np.float64) + srgb = linear_to_srgb(oklab_to_linear_srgb(arr))[0] + return (float(srgb[0]), float(srgb[1]), float(srgb[2])) diff --git a/tests/integration/test_colorspaces.py b/tests/integration/test_colorspaces.py index 66159b9..047b434 100644 --- a/tests/integration/test_colorspaces.py +++ b/tests/integration/test_colorspaces.py @@ -281,3 +281,11 @@ def test_color_extraction_deterministic_kmeans( assert g == g2, f"Expected g1 == g2, got {g} != {g2}" assert b == b2, f"Expected b1 == b2, got {b} != {b2}" assert freq == freq2, f"Expected freq1 == freq2, got {freq} != {freq2}" + + +@pytest.mark.parametrize("srgb", [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (0.2, 0.5, 0.9), (0.55, 0.1, 0.33)]) +def test_oklab_to_srgb_roundtrips(srgb: tuple[float, float, float]) -> None: + from pylette.src.colorspaces import oklab_to_srgb, srgb_to_oklab + + restored = oklab_to_srgb(srgb_to_oklab(srgb)) + assert restored == pytest.approx(srgb, abs=1e-6) From 82039602ed6038657e47187d98311a5b8d0e71e4 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 19:37:34 +0200 Subject: [PATCH 02/14] add delta_e OKLab perceptual difference --- pylette/src/color.py | 15 +++++++++++++++ tests/integration/test_operations.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/integration/test_operations.py diff --git a/pylette/src/color.py b/pylette/src/color.py index 3175b48..0db1c31 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -210,6 +210,21 @@ def to(self, space: ColorSpace | str = ColorSpace.RGB) -> tuple[int, ...] | tupl return colorsys.rgb_to_hls(*self._srgb) return srgb_to_oklab(self._srgb) + def delta_e(self, other: "Color") -> float: + """Perceptual color difference to ``other`` as Euclidean distance in OKLab. + + OKLab is built so that straight-line distance approximates perceived + difference, so no extra weighting is applied. Symmetric, non-negative, + and zero for colors with identical OKLab coordinates. + + Parameters: + other (Color): The color to compare against. + + Returns: + float: The OKLab ΔE between the two colors. + """ + return float(np.linalg.norm(np.array(self.oklab) - np.array(other.oklab))) + def get_colors(self, colorspace: ColorSpace | str = ColorSpace.RGB) -> tuple[int, ...] | tuple[float, ...]: """ Deprecated alias for :meth:`to`. diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py new file mode 100644 index 0000000..db18ba7 --- /dev/null +++ b/tests/integration/test_operations.py @@ -0,0 +1,15 @@ +import pytest + +from pylette import Color + + +def test_delta_e_is_zero_for_identical_colors() -> None: + c = Color(rgba=(120, 60, 200, 255), frequency=1.0) + assert c.delta_e(c) == pytest.approx(0.0, abs=1e-9) + + +def test_delta_e_is_symmetric_and_positive() -> None: + a = Color(rgba=(10, 20, 30, 255), frequency=0.5) + b = Color(rgba=(200, 180, 50, 255), frequency=0.5) + assert a.delta_e(b) == pytest.approx(b.delta_e(a)) + assert a.delta_e(b) > 0.0 From 57823202c490fa9a56c563f7d351f8c8d194e3ff Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 19:42:01 +0200 Subject: [PATCH 03/14] add InvalidHarmonyError --- pylette/__init__.py | 2 ++ pylette/src/exceptions.py | 4 ++++ tests/integration/test_exceptions.py | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/pylette/__init__.py b/pylette/__init__.py index 24cd04f..c7a85c5 100644 --- a/pylette/__init__.py +++ b/pylette/__init__.py @@ -3,6 +3,7 @@ from pylette.src.color_extraction import batch_extract_colors, extract_colors from pylette.src.exceptions import ( InvalidColorspaceError, + InvalidHarmonyError, InvalidImageError, NoValidPixelsError, PyletteError, @@ -21,4 +22,5 @@ "NoValidPixelsError", "UnknownExtractionMethodError", "InvalidColorspaceError", + "InvalidHarmonyError", ] diff --git a/pylette/src/exceptions.py b/pylette/src/exceptions.py index f58b73d..6409af7 100644 --- a/pylette/src/exceptions.py +++ b/pylette/src/exceptions.py @@ -25,3 +25,7 @@ class UnknownExtractionMethodError(PyletteError, ValueError): class InvalidColorspaceError(PyletteError, ValueError): """The requested color space is not recognized.""" + + +class InvalidHarmonyError(PyletteError, ValueError): + """The requested color-harmony kind is not recognized.""" diff --git a/tests/integration/test_exceptions.py b/tests/integration/test_exceptions.py index 80e78c7..40c5e05 100644 --- a/tests/integration/test_exceptions.py +++ b/tests/integration/test_exceptions.py @@ -112,3 +112,10 @@ def test_batch_classifies_failures_by_exception_type(opaque_image: Image.Image, failed = by_success[False] assert isinstance(failed.error, InvalidImageError) assert isinstance(failed.error, PyletteError) + + +def test_invalid_harmony_error_is_pylette_error() -> None: + from pylette import InvalidHarmonyError, PyletteError + + assert issubclass(InvalidHarmonyError, PyletteError) + assert issubclass(InvalidHarmonyError, ValueError) From 0bf32d671e2456139dfcfe5bf474bbc973b857ed Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 19:48:09 +0200 Subject: [PATCH 04/14] add HarmonyKind enum --- pylette/__init__.py | 2 ++ pylette/src/types.py | 6 ++++++ tests/integration/test_operations.py | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/pylette/__init__.py b/pylette/__init__.py index c7a85c5..2d04054 100644 --- a/pylette/__init__.py +++ b/pylette/__init__.py @@ -10,6 +10,7 @@ UnknownExtractionMethodError, ) from pylette.src.palette import Palette +from pylette.src.types import HarmonyKind __all__ = [ "extract_colors", @@ -17,6 +18,7 @@ "Palette", "Color", "types", + "HarmonyKind", "PyletteError", "InvalidImageError", "NoValidPixelsError", diff --git a/pylette/src/types.py b/pylette/src/types.py index ab1208f..45b297f 100644 --- a/pylette/src/types.py +++ b/pylette/src/types.py @@ -66,6 +66,12 @@ class ColorSpace(str, Enum): OKLAB = "oklab" +class HarmonyKind(str, Enum): + COMPLEMENTARY = "complementary" + TRIADIC = "triadic" + ANALOGOUS = "analogous" + + _EnumT = TypeVar("_EnumT", bound=Enum) diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index db18ba7..ed01b87 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -13,3 +13,12 @@ def test_delta_e_is_symmetric_and_positive() -> None: b = Color(rgba=(200, 180, 50, 255), frequency=0.5) assert a.delta_e(b) == pytest.approx(b.delta_e(a)) assert a.delta_e(b) > 0.0 + + +def test_harmony_kind_coerces_from_string() -> None: + from pylette import HarmonyKind + from pylette.src.types import coerce_to_enum + + assert coerce_to_enum("triadic", HarmonyKind) is HarmonyKind.TRIADIC + assert coerce_to_enum("COMPLEMENTARY", HarmonyKind) is HarmonyKind.COMPLEMENTARY + assert coerce_to_enum(HarmonyKind.ANALOGOUS, HarmonyKind) is HarmonyKind.ANALOGOUS From a8dc0234b0b0c8564bedd9536c57516472ec095f Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 19:55:43 +0200 Subject: [PATCH 05/14] add weighted_oklab_mean and frequency normalization --- pylette/src/operations.py | 39 ++++++++++++++++++++++ tests/integration/test_operations.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 pylette/src/operations.py diff --git a/pylette/src/operations.py b/pylette/src/operations.py new file mode 100644 index 0000000..f3953e7 --- /dev/null +++ b/pylette/src/operations.py @@ -0,0 +1,39 @@ +"""Pure, immutable palette operations over lists of :class:`~pylette.src.color.Color`. + +This module holds the numeric core for the palette operations exposed as thin +methods on :class:`~pylette.src.color.Color` and +:class:`~pylette.src.palette.Palette`. Functions here take and return plain +``list[Color]`` (or a single ``Color``); they never mutate their inputs except +for the private ``_normalize_frequencies`` helper, which only touches colors it +was handed to finalize a freshly built result. +""" + +import numpy as np + +from pylette.src.color import Color +from pylette.src.colorspaces import oklab_to_srgb + + +def weighted_oklab_mean(colors: list[Color]) -> tuple[tuple[float, float, float], float]: + """Frequency-weighted mean of ``colors`` in OKLab, returned as ``(srgb, opacity)``. + + The mean is computed in OKLab (perceptually even) then projected back to + gamma-encoded sRGB. Opacity is averaged with the same weights. If the total + frequency is zero, uniform weights are used so the call never divides by zero. + """ + labs = np.array([c.oklab for c in colors], dtype=np.float64) + opacities = np.array([c.opacity for c in colors], dtype=np.float64) + weights = np.array([c.frequency for c in colors], dtype=np.float64) + if weights.sum() == 0: + weights = np.ones_like(weights) + mean_lab = np.average(labs, axis=0, weights=weights) + mean_opacity = float(np.average(opacities, weights=weights)) + return oklab_to_srgb((float(mean_lab[0]), float(mean_lab[1]), float(mean_lab[2]))), mean_opacity + + +def _normalize_frequencies(colors: list[Color]) -> list[Color]: # pyright: ignore[reportUnusedFunction] + """Assign equal frequencies summing to 1.0 across ``colors`` in place.""" + n = len(colors) + for color in colors: + color.frequency = 1.0 / n + return colors diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index ed01b87..454f41b 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -1,6 +1,7 @@ import pytest from pylette import Color +from pylette.src import operations def test_delta_e_is_zero_for_identical_colors() -> None: @@ -22,3 +23,52 @@ def test_harmony_kind_coerces_from_string() -> None: assert coerce_to_enum("triadic", HarmonyKind) is HarmonyKind.TRIADIC assert coerce_to_enum("COMPLEMENTARY", HarmonyKind) is HarmonyKind.COMPLEMENTARY assert coerce_to_enum(HarmonyKind.ANALOGOUS, HarmonyKind) is HarmonyKind.ANALOGOUS + + +def test_weighted_oklab_mean_of_identical_colors_is_that_color() -> None: + colors = [Color(rgba=(40, 160, 90, 255), frequency=0.3) for _ in range(3)] + srgb, opacity = operations.weighted_oklab_mean(colors) + assert srgb == pytest.approx(colors[0].rgb_float, abs=1e-6) + assert opacity == pytest.approx(1.0) + + +def test_weighted_oklab_mean_uses_frequency_weights() -> None: + dark = Color(rgba=(0, 0, 0, 255), frequency=0.9) # opacity 1.0 + light = Color(rgba=(255, 255, 255, 0), frequency=0.1) # opacity 0.0 + (r, g, b), opacity = operations.weighted_oklab_mean([dark, light]) + (ur, ug, ub), _ = operations.weighted_oklab_mean( + [ + Color(rgba=(0, 0, 0, 255), frequency=0.5), + Color(rgba=(255, 255, 255, 255), frequency=0.5), + ] + ) + # 90% weight on black pulls the mean darker than the 50/50 midpoint + assert r < ur and g < ug and b < ub + # opacity is frequency-weighted: 0.9*1.0 + 0.1*0.0 + assert opacity == pytest.approx(0.9) + + +def test_weighted_oklab_mean_zero_total_frequency_uses_uniform_weights() -> None: + zero = [ + Color(rgba=(0, 0, 0, 255), frequency=0.0), + Color(rgba=(255, 255, 255, 255), frequency=0.0), + ] + uniform = [ + Color(rgba=(0, 0, 0, 255), frequency=0.5), + Color(rgba=(255, 255, 255, 255), frequency=0.5), + ] + result_zero, _ = operations.weighted_oklab_mean(zero) + result_uniform, _ = operations.weighted_oklab_mean(uniform) + # zero total frequency falls back to uniform weights -> same result as 50/50 + assert result_zero == pytest.approx(result_uniform, abs=1e-9) + + +def test_normalize_frequencies_sums_to_one() -> None: + colors = [Color(rgba=(i, i, i, 255), frequency=0.0) for i in (10, 20, 30, 40)] + normalized = operations._normalize_frequencies(colors) + assert sum(c.frequency for c in normalized) == pytest.approx(1.0) + assert all(c.frequency == pytest.approx(0.25) for c in normalized) + + +def test_normalize_frequencies_empty_list() -> None: + assert operations._normalize_frequencies([]) == [] From 4d092cf36d75df6a5bd1c40b9c606abb4fa64dd8 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 20:21:05 +0200 Subject: [PATCH 06/14] add dedup for exact-duplicate colors --- pylette/src/operations.py | 24 ++++++++++++++++++++++++ pylette/src/palette.py | 13 +++++++++++++ tests/integration/test_operations.py | 25 ++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/pylette/src/operations.py b/pylette/src/operations.py index f3953e7..07de50b 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -37,3 +37,27 @@ def _normalize_frequencies(colors: list[Color]) -> list[Color]: # pyright: igno for color in colors: color.frequency = 1.0 / n return colors + + +def dedup(colors: list[Color]) -> list[Color]: + """Collapse exactly-equal colors (same 8-bit RGB), summing their frequencies. + + Cheap and lossless: the representative keeps the float precision of the first + occurrence and its opacity; only frequencies are combined. First-seen order + is preserved. + """ + groups: dict[tuple[int, int, int], list[Color]] = {} + order: list[tuple[int, int, int]] = [] + for color in colors: + key = color.rgb + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append(color) + result: list[Color] = [] + for key in order: + group = groups[key] + rep = group[0] + frequency = sum(c.frequency for c in group) + result.append(Color.from_srgb_float(rep.rgb_float, frequency, alpha=rep.opacity)) + return result diff --git a/pylette/src/palette.py b/pylette/src/palette.py index be894d7..8a5b206 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -3,6 +3,7 @@ import numpy as np from PIL import Image +from pylette.src import operations from pylette.src.color import Color from pylette.src.exceptions import InvalidColorspaceError from pylette.src.types import ( @@ -126,6 +127,18 @@ def random_color(self, N: int, mode: str = "frequency") -> list[Color]: else: raise ValueError(f"Invalid mode: {mode}. Must be 'frequency' or 'uniform'.") + def dedup(self) -> "Palette": + """Return a new palette with exactly-equal colors merged (frequencies summed). + + Only colorspace-identical colors (same 8-bit RGB) are collapsed; for + perceptual near-duplicates use :meth:`merge_similar`. The original palette + is left unchanged. + + Returns: + Palette: A new palette with exact duplicates removed. + """ + return Palette(operations.dedup(self.colors)) + def to_json( self, filename: str | None = None, diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index 454f41b..1939047 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -1,6 +1,6 @@ import pytest -from pylette import Color +from pylette import Color, Palette from pylette.src import operations @@ -72,3 +72,26 @@ def test_normalize_frequencies_sums_to_one() -> None: def test_normalize_frequencies_empty_list() -> None: assert operations._normalize_frequencies([]) == [] + + +def test_dedup_collapses_exact_duplicates_and_sums_frequency() -> None: + colors = [ + Color(rgba=(10, 20, 30, 255), frequency=0.5), + Color(rgba=(10, 20, 30, 255), frequency=0.2), + Color(rgba=(200, 100, 50, 255), frequency=0.3), + ] + result = operations.dedup(colors) + assert len(result) == 2 + assert result[0].rgb == (10, 20, 30) + assert result[0].frequency == pytest.approx(0.7) + assert result[1].rgb == (200, 100, 50) + + +def test_palette_dedup_is_immutable_and_returns_new_palette() -> None: + colors = [Color(rgba=(10, 20, 30, 255), frequency=0.5), Color(rgba=(10, 20, 30, 255), frequency=0.5)] + palette = Palette(colors) + deduped = palette.dedup() + assert isinstance(deduped, Palette) + assert len(deduped) == 1 + assert len(palette) == 2 # original untouched + assert sum(deduped.frequencies) == pytest.approx(1.0) From 7931ff7e010f55d3558a6ce30e4b2c63aef24ff2 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 20:25:45 +0200 Subject: [PATCH 07/14] add perceptual sort by OKLab lightness --- pylette/src/operations.py | 9 +++++++++ pylette/src/palette.py | 14 ++++++++++++++ tests/integration/test_operations.py | 21 +++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/pylette/src/operations.py b/pylette/src/operations.py index 07de50b..f00d448 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -39,6 +39,15 @@ def _normalize_frequencies(colors: list[Color]) -> list[Color]: # pyright: igno return colors +def sort_perceptual(colors: list[Color], descending: bool = False) -> list[Color]: + """Return a new list of ``colors`` sorted by OKLab lightness (L). + + Python's ``sorted`` is stable, so equal-lightness colors keep their relative + order; sorting an already-sorted list is a no-op (idempotent). + """ + return sorted(colors, key=lambda c: c.oklab[0], reverse=descending) + + def dedup(colors: list[Color]) -> list[Color]: """Collapse exactly-equal colors (same 8-bit RGB), summing their frequencies. diff --git a/pylette/src/palette.py b/pylette/src/palette.py index 8a5b206..b3659d7 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -139,6 +139,20 @@ def dedup(self) -> "Palette": """ return Palette(operations.dedup(self.colors)) + def sort_perceptual(self, descending: bool = False) -> "Palette": + """Return a new palette sorted by perceptual lightness (OKLab L). + + Ascending by default (darkest first). The sort is stable and idempotent. + The original palette is left unchanged. + + Parameters: + descending (bool): If True, sort lightest first. + + Returns: + Palette: A new, perceptually sorted palette. + """ + return Palette(operations.sort_perceptual(self.colors, descending=descending)) + def to_json( self, filename: str | None = None, diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index 1939047..015de98 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -95,3 +95,24 @@ def test_palette_dedup_is_immutable_and_returns_new_palette() -> None: assert len(deduped) == 1 assert len(palette) == 2 # original untouched assert sum(deduped.frequencies) == pytest.approx(1.0) + + +def test_sort_perceptual_orders_by_lightness_and_is_idempotent() -> None: + colors = [ + Color(rgba=(255, 255, 255, 255), frequency=0.25), + Color(rgba=(0, 0, 0, 255), frequency=0.25), + Color(rgba=(128, 128, 128, 255), frequency=0.5), + ] + sorted_once = operations.sort_perceptual(colors) + lightness = [c.oklab[0] for c in sorted_once] + assert lightness == sorted(lightness) # ascending by L + sorted_twice = operations.sort_perceptual(sorted_once) + assert [c.rgb for c in sorted_twice] == [c.rgb for c in sorted_once] # idempotent + assert [c.rgb for c in colors][0] == (255, 255, 255) # input untouched + + +def test_palette_sort_perceptual_descending() -> None: + palette = Palette([Color(rgba=(0, 0, 0, 255), frequency=0.5), Color(rgba=(255, 255, 255, 255), frequency=0.5)]) + result = palette.sort_perceptual(descending=True) + assert result[0].rgb == (255, 255, 255) + assert len(palette) == 2 # original untouched From d029c234802f73f69cf1b7c9ea637a832dd238aa Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 20:29:34 +0200 Subject: [PATCH 08/14] add merge_similar perceptual clustering --- pylette/src/operations.py | 29 +++++++++++++++++++ pylette/src/palette.py | 16 +++++++++++ tests/integration/test_operations.py | 42 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/pylette/src/operations.py b/pylette/src/operations.py index f00d448..df9e1b6 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -48,6 +48,35 @@ def sort_perceptual(colors: list[Color], descending: bool = False) -> list[Color return sorted(colors, key=lambda c: c.oklab[0], reverse=descending) +def merge_similar(colors: list[Color], delta_e: float) -> list[Color]: + """Merge colors within ``delta_e`` of each other (OKLab ΔE) into single swatches. + + Greedy single pass over ``colors`` (extractor order is deterministic, so the + result is too): each color joins the first existing cluster whose running + representative is within ``delta_e``, otherwise it starts a new cluster. Each + cluster's representative is the frequency-weighted OKLab mean of its members, + with the summed frequency, so the total frequency is preserved. + + Raises: + ValueError: If ``delta_e`` is negative. + """ + if delta_e < 0: + raise ValueError(f"delta_e must be non-negative, got {delta_e}.") + clusters: list[list[Color]] = [] + reps: list[Color] = [] + for color in colors: + for i, rep in enumerate(reps): + if color.delta_e(rep) <= delta_e: + clusters[i].append(color) + srgb, opacity = weighted_oklab_mean(clusters[i]) + reps[i] = Color.from_srgb_float(srgb, sum(c.frequency for c in clusters[i]), alpha=opacity) + break + else: + clusters.append([color]) + reps.append(color) + return reps + + def dedup(colors: list[Color]) -> list[Color]: """Collapse exactly-equal colors (same 8-bit RGB), summing their frequencies. diff --git a/pylette/src/palette.py b/pylette/src/palette.py index b3659d7..22e057a 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -139,6 +139,22 @@ def dedup(self) -> "Palette": """ return Palette(operations.dedup(self.colors)) + def merge_similar(self, delta_e: float) -> "Palette": + """Return a new palette with perceptually similar colors merged. + + Colors within ``delta_e`` of each other (OKLab ΔE, see + :meth:`Color.delta_e`) collapse to their frequency-weighted OKLab mean, with + frequencies summed (so the total stays ≈ 1.0). The original palette is left + unchanged. For exact duplicates only, use :meth:`dedup`. + + Parameters: + delta_e (float): Non-negative ΔE threshold; larger merges more. + + Returns: + Palette: A new palette with near-duplicates merged. + """ + return Palette(operations.merge_similar(self.colors, delta_e)) + def sort_perceptual(self, descending: bool = False) -> "Palette": """Return a new palette sorted by perceptual lightness (OKLab L). diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index 015de98..f635cf9 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -116,3 +116,45 @@ def test_palette_sort_perceptual_descending() -> None: result = palette.sort_perceptual(descending=True) assert result[0].rgb == (255, 255, 255) assert len(palette) == 2 # original untouched + + +def test_merge_similar_collapses_near_duplicates() -> None: + colors = [ + Color(rgba=(100, 100, 100, 255), frequency=0.4), + Color(rgba=(101, 101, 101, 255), frequency=0.3), # ~identical to first + Color(rgba=(10, 200, 50, 255), frequency=0.3), # clearly different + ] + result = operations.merge_similar(colors, delta_e=0.05) + assert len(result) == 2 + assert sum(c.frequency for c in result) == pytest.approx(1.0) + # the merged grey carries the summed frequency of its two members + assert max(c.frequency for c in result) == pytest.approx(0.7) + + +def test_merge_similar_idempotent_at_fixed_threshold() -> None: + colors = [ + Color(rgba=(100, 100, 100, 255), frequency=0.4), + Color(rgba=(101, 101, 101, 255), frequency=0.3), + Color(rgba=(10, 200, 50, 255), frequency=0.3), + ] + once = operations.merge_similar(colors, delta_e=0.05) + twice = operations.merge_similar(once, delta_e=0.05) + assert [c.rgb for c in twice] == [c.rgb for c in once] + + +def test_merge_similar_rejects_negative_threshold() -> None: + with pytest.raises(ValueError): + operations.merge_similar([Color(rgba=(0, 0, 0, 255), frequency=1.0)], delta_e=-1.0) + + +def test_palette_merge_similar_is_immutable() -> None: + palette = Palette( + [ + Color(rgba=(100, 100, 100, 255), frequency=0.5), + Color(rgba=(101, 101, 101, 255), frequency=0.5), + ] + ) + merged = palette.merge_similar(delta_e=0.05) + assert len(merged) == 1 + assert len(palette) == 2 + assert sum(merged.frequencies) == pytest.approx(1.0) From 53a7b7b265b77a0dfffed1ceb8fc06030958cfd6 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 20:33:58 +0200 Subject: [PATCH 09/14] add OKLab gradient interpolation --- pylette/src/color.py | 23 +++++++++++- pylette/src/operations.py | 24 ++++++++++++- pylette/src/palette.py | 34 ++++++++++++++++++ tests/integration/test_operations.py | 54 ++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/pylette/src/color.py b/pylette/src/color.py index 0db1c31..b5398ba 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -1,6 +1,9 @@ import colorsys import warnings -from typing import cast +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from pylette.src.palette import Palette import numpy as np @@ -284,6 +287,24 @@ def hex(self) -> str: r, g, b = self.rgb return f"#{r:02X}{g:02X}{b:02X}" + def gradient_to(self, other: "Color", steps: int) -> "Palette": + """Return a palette of ``steps`` colors interpolated from this color to ``other``. + + Interpolation runs in OKLab for a perceptually even ramp and includes both + endpoints. The generated colors get equal frequencies summing to 1.0. + + Parameters: + other (Color): The end color of the ramp. + steps (int): Total number of colors, including both endpoints (>= 2). + + Returns: + Palette: A new palette holding the gradient. + """ + from pylette.src import operations + from pylette.src.palette import Palette + + return Palette(operations.interpolate(self, other, steps)) + @property def luminance(self) -> float: """ diff --git a/pylette/src/operations.py b/pylette/src/operations.py index df9e1b6..987a6fc 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -31,7 +31,7 @@ def weighted_oklab_mean(colors: list[Color]) -> tuple[tuple[float, float, float] return oklab_to_srgb((float(mean_lab[0]), float(mean_lab[1]), float(mean_lab[2]))), mean_opacity -def _normalize_frequencies(colors: list[Color]) -> list[Color]: # pyright: ignore[reportUnusedFunction] +def _normalize_frequencies(colors: list[Color]) -> list[Color]: """Assign equal frequencies summing to 1.0 across ``colors`` in place.""" n = len(colors) for color in colors: @@ -77,6 +77,28 @@ def merge_similar(colors: list[Color], delta_e: float) -> list[Color]: return reps +def interpolate(a: Color, b: Color, steps: int) -> list[Color]: + """Return ``steps`` colors interpolated from ``a`` to ``b`` in OKLab (inclusive). + + Interpolating in OKLab gives a perceptually even ramp. Opacity is + interpolated linearly. The result has equal frequencies summing to 1.0. + + Raises: + ValueError: If ``steps`` is less than 2. + """ + if steps < 2: + raise ValueError(f"steps must be at least 2, got {steps}.") + la = a.oklab + lb = b.oklab + result: list[Color] = [] + for i in range(steps): + t = i / (steps - 1) + lab = (la[0] + t * (lb[0] - la[0]), la[1] + t * (lb[1] - la[1]), la[2] + t * (lb[2] - la[2])) + opacity = a.opacity + t * (b.opacity - a.opacity) + result.append(Color.from_srgb_float(oklab_to_srgb(lab), frequency=0.0, alpha=opacity)) + return _normalize_frequencies(result) + + def dedup(colors: list[Color]) -> list[Color]: """Collapse exactly-equal colors (same 8-bit RGB), summing their frequencies. diff --git a/pylette/src/palette.py b/pylette/src/palette.py index 22e057a..d9eb581 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -155,6 +155,40 @@ def merge_similar(self, delta_e: float) -> "Palette": """ return Palette(operations.merge_similar(self.colors, delta_e)) + def gradient(self, steps_between: int) -> "Palette": + """Return a new palette with interpolated colors bridging consecutive swatches. + + Between each consecutive pair of colors, ``steps_between`` OKLab-interpolated + colors are inserted, producing a smooth ramp across the whole palette. The + result has equal frequencies summing to 1.0. A palette with fewer than two + colors has nothing to bridge and is returned unchanged. The original palette + is left unchanged. + + Parameters: + steps_between (int): Colors to insert between each pair (>= 1). + + Returns: + Palette: A new palette holding the gradient. + + Raises: + ValueError: If ``steps_between`` is less than 1. + """ + if steps_between < 1: + raise ValueError(f"steps_between must be at least 1, got {steps_between}.") + colors = self.colors + if len(colors) < 2: + return Palette([Color.from_srgb_float(c.rgb_float, c.frequency, alpha=c.opacity) for c in colors]) + out: list[Color] = [] + for idx in range(len(colors) - 1): + segment = operations.interpolate(colors[idx], colors[idx + 1], steps_between + 2) + if idx > 0: + segment = segment[1:] # drop the seam color shared with the previous segment + out.extend(segment) + n = len(out) + for c in out: + c.frequency = 1.0 / n + return Palette(out) + def sort_perceptual(self, descending: bool = False) -> "Palette": """Return a new palette sorted by perceptual lightness (OKLab L). diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index f635cf9..ae02765 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -158,3 +158,57 @@ def test_palette_merge_similar_is_immutable() -> None: assert len(merged) == 1 assert len(palette) == 2 assert sum(merged.frequencies) == pytest.approx(1.0) + + +def test_interpolate_includes_endpoints_and_normalizes() -> None: + a = Color(rgba=(0, 0, 0, 255), frequency=1.0) + b = Color(rgba=(255, 255, 255, 255), frequency=1.0) + ramp = operations.interpolate(a, b, steps=5) + assert len(ramp) == 5 + assert ramp[0].rgb == (0, 0, 0) + assert ramp[-1].rgb == (255, 255, 255) + assert sum(c.frequency for c in ramp) == pytest.approx(1.0) + lightness = [c.oklab[0] for c in ramp] + assert lightness == sorted(lightness) # monotonic ramp + + +def test_interpolate_rejects_too_few_steps() -> None: + a = Color(rgba=(0, 0, 0, 255), frequency=1.0) + b = Color(rgba=(255, 255, 255, 255), frequency=1.0) + with pytest.raises(ValueError): + operations.interpolate(a, b, steps=1) + + +def test_color_gradient_to_returns_palette() -> None: + a = Color(rgba=(0, 0, 0, 255), frequency=1.0) + b = Color(rgba=(255, 0, 0, 255), frequency=1.0) + grad = a.gradient_to(b, steps=3) + assert isinstance(grad, Palette) + assert len(grad) == 3 + + +def test_palette_gradient_bridges_consecutive_swatches() -> None: + palette = Palette( + [ + Color(rgba=(0, 0, 0, 255), frequency=0.5), + Color(rgba=(255, 255, 255, 255), frequency=0.5), + ] + ) + grad = palette.gradient(steps_between=1) + # endpoints + 1 inserted between them = 3 + assert len(grad) == 3 + assert sum(grad.frequencies) == pytest.approx(1.0) + assert len(palette) == 2 # original untouched + + +def test_palette_gradient_single_color_unchanged() -> None: + palette = Palette([Color(rgba=(10, 20, 30, 255), frequency=1.0)]) + grad = palette.gradient(steps_between=3) + assert len(grad) == 1 + assert grad[0].rgb == (10, 20, 30) + + +def test_palette_gradient_rejects_zero_steps() -> None: + palette = Palette([Color(rgba=(0, 0, 0, 255), frequency=0.5), Color(rgba=(1, 1, 1, 255), frequency=0.5)]) + with pytest.raises(ValueError): + palette.gradient(steps_between=0) From 75fa4a0a14a66150ed563e3189a4155dd74c4ca9 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 20:46:28 +0200 Subject: [PATCH 10/14] add color harmonies --- pylette/src/color.py | 24 ++++++++++++++- pylette/src/operations.py | 32 ++++++++++++++++++++ pylette/src/palette.py | 22 ++++++++++++++ tests/integration/test_operations.py | 45 +++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/pylette/src/color.py b/pylette/src/color.py index b5398ba..f8cd6e1 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -9,7 +9,7 @@ from pylette.src.colorspaces import srgb_to_oklab from pylette.src.exceptions import InvalidColorspaceError -from pylette.src.types import ColorSpace, coerce_to_enum +from pylette.src.types import ColorSpace, HarmonyKind, coerce_to_enum # Weights for calculating luminance luminance_weights = np.array([0.2126, 0.7152, 0.0722]) @@ -305,6 +305,28 @@ def gradient_to(self, other: "Color", steps: int) -> "Palette": return Palette(operations.interpolate(self, other, steps)) + def harmony(self, kind: "HarmonyKind | str") -> "Palette": + """Return a color-harmony scheme generated from this color. + + ``kind`` is ``"complementary"``, ``"triadic"``, or ``"analogous"`` (a + :class:`HarmonyKind` member, its value, or case-insensitive name). The + returned palette holds the seed plus its hue-rotated partners with equal + frequencies. + + Parameters: + kind (HarmonyKind | str): The harmony to generate. + + Returns: + Palette: A new palette holding the harmony scheme. + + Raises: + InvalidHarmonyError: If ``kind`` is not recognized. + """ + from pylette.src import operations + from pylette.src.palette import Palette + + return Palette(operations.harmony(self, kind)) + @property def luminance(self) -> float: """ diff --git a/pylette/src/operations.py b/pylette/src/operations.py index 987a6fc..c161f26 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -8,10 +8,14 @@ was handed to finalize a freshly built result. """ +import colorsys + import numpy as np from pylette.src.color import Color from pylette.src.colorspaces import oklab_to_srgb +from pylette.src.exceptions import InvalidHarmonyError +from pylette.src.types import HarmonyKind, coerce_to_enum def weighted_oklab_mean(colors: list[Color]) -> tuple[tuple[float, float, float], float]: @@ -121,3 +125,31 @@ def dedup(colors: list[Color]) -> list[Color]: frequency = sum(c.frequency for c in group) result.append(Color.from_srgb_float(rep.rgb_float, frequency, alpha=rep.opacity)) return result + + +# Hue offsets (fraction of the 360° wheel) per harmony, seed listed first. +_HARMONY_OFFSETS: dict[HarmonyKind, tuple[float, ...]] = { + HarmonyKind.COMPLEMENTARY: (0.0, 0.5), # +180° + HarmonyKind.TRIADIC: (0.0, 1.0 / 3.0, 2.0 / 3.0), # ±120° + HarmonyKind.ANALOGOUS: (-1.0 / 12.0, 0.0, 1.0 / 12.0), # ±30°, seed in the middle +} + + +def harmony(seed: Color, kind: HarmonyKind | str) -> list[Color]: + """Generate a color-harmony scheme from ``seed`` by rotating hue in HSV. + + Complementary (+180°), triadic (±120°), or analogous (±30°). The returned + colors share the seed's saturation, value, and opacity and get equal + frequencies summing to 1.0. + + Raises: + InvalidHarmonyError: If ``kind`` is not a valid :class:`HarmonyKind`. + """ + kind = coerce_to_enum(kind, HarmonyKind, error_cls=InvalidHarmonyError) + h, s, v = seed.hsv + result: list[Color] = [] + for offset in _HARMONY_OFFSETS[kind]: + new_h = (h + offset) % 1.0 + r, g, b = colorsys.hsv_to_rgb(new_h, s, v) + result.append(Color.from_srgb_float((r, g, b), frequency=0.0, alpha=seed.opacity)) + return _normalize_frequencies(result) diff --git a/pylette/src/palette.py b/pylette/src/palette.py index d9eb581..9ac9159 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -9,6 +9,7 @@ from pylette.src.types import ( ColorSpace, ExtractionParams, + HarmonyKind, ImageInfo, PaletteMetaData, ProcessingStats, @@ -203,6 +204,27 @@ def sort_perceptual(self, descending: bool = False) -> "Palette": """ return Palette(operations.sort_perceptual(self.colors, descending=descending)) + def harmony(self, kind: "HarmonyKind | str") -> "Palette": + """Return a color-harmony scheme seeded from this palette's dominant color. + + Convenience wrapper over :meth:`Color.harmony`: the seed is the + highest-frequency color. An empty palette yields an empty palette. + + Parameters: + kind (HarmonyKind | str): ``"complementary"``, ``"triadic"``, or + ``"analogous"``. + + Returns: + Palette: A new palette holding the harmony scheme. + + Raises: + InvalidHarmonyError: If ``kind`` is not recognized. + """ + if not self.colors: + return Palette([]) + seed = max(self.colors, key=lambda c: c.frequency) + return Palette(operations.harmony(seed, kind)) + def to_json( self, filename: str | None = None, diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index ae02765..b726f92 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -1,6 +1,6 @@ import pytest -from pylette import Color, Palette +from pylette import Color, HarmonyKind, InvalidHarmonyError, Palette from pylette.src import operations @@ -212,3 +212,46 @@ def test_palette_gradient_rejects_zero_steps() -> None: palette = Palette([Color(rgba=(0, 0, 0, 255), frequency=0.5), Color(rgba=(1, 1, 1, 255), frequency=0.5)]) with pytest.raises(ValueError): palette.gradient(steps_between=0) + + +def test_harmony_complementary_has_two_colors() -> None: + seed = Color(rgba=(200, 50, 50, 255), frequency=1.0) + result = operations.harmony(seed, HarmonyKind.COMPLEMENTARY) + assert len(result) == 2 + assert sum(c.frequency for c in result) == pytest.approx(1.0) + + +def test_harmony_triadic_and_analogous_counts() -> None: + seed = Color(rgba=(200, 50, 50, 255), frequency=1.0) + assert len(operations.harmony(seed, "triadic")) == 3 + assert len(operations.harmony(seed, "analogous")) == 3 + + +def test_harmony_rejects_unknown_kind() -> None: + seed = Color(rgba=(200, 50, 50, 255), frequency=1.0) + with pytest.raises(InvalidHarmonyError): + operations.harmony(seed, "tetradic") + + +def test_color_harmony_returns_palette() -> None: + seed = Color(rgba=(200, 50, 50, 255), frequency=1.0) + result = seed.harmony("triadic") + assert isinstance(result, Palette) + assert len(result) == 3 + + +def test_palette_harmony_seeds_from_dominant_color() -> None: + palette = Palette( + [ + Color(rgba=(10, 20, 30, 255), frequency=0.2), + Color(rgba=(200, 50, 50, 255), frequency=0.8), # dominant + ] + ) + result = palette.harmony("complementary") + # first color of a complementary scheme is the seed = the dominant color + assert result[0].rgb == (200, 50, 50) + + +def test_palette_harmony_empty_palette() -> None: + result = Palette([]).harmony("triadic") + assert len(result) == 0 From 33f20aa20c4fe9c59b81565ca2e030bc81fd1709 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 21:17:40 +0200 Subject: [PATCH 11/14] add Hypothesis tests --- tests/integration/test_operations.py | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index b726f92..26d8179 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -1,4 +1,6 @@ import pytest +from hypothesis import given, settings +from hypothesis import strategies as st from pylette import Color, HarmonyKind, InvalidHarmonyError, Palette from pylette.src import operations @@ -255,3 +257,71 @@ def test_palette_harmony_seeds_from_dominant_color() -> None: def test_palette_harmony_empty_palette() -> None: result = Palette([]).harmony("triadic") assert len(result) == 0 + + +def _normalized_palette(colors: list[Color]) -> Palette: + total = sum(c.frequency for c in colors) + return Palette([Color.from_srgb_float(c.rgb_float, c.frequency / total, alpha=c.opacity) for c in colors]) + + +def _palette_strategy() -> st.SearchStrategy[Palette]: + color = st.builds( + Color, + rgba=st.tuples(st.integers(0, 255), st.integers(0, 255), st.integers(0, 255), st.just(255)), + frequency=st.floats(0.01, 1.0), + ) + return st.lists(color, min_size=1, max_size=8).map(_normalized_palette) + + +def _assert_palette_invariants(palette: Palette) -> None: + if len(palette) == 0: + return + assert sum(palette.frequencies) == pytest.approx(1.0) + for color in palette.colors: + assert all(isinstance(ch, int) and 0 <= ch <= 255 for ch in color.rgb) + + +@settings(max_examples=50, deadline=None) +@given(palette=_palette_strategy(), delta_e=st.floats(0.0, 0.3)) +def test_merge_similar_preserves_invariants(palette: Palette, delta_e: float) -> None: + original_len = len(palette) + result = palette.merge_similar(delta_e) + _assert_palette_invariants(result) + assert len(result) <= original_len + assert len(palette) == original_len # immutability + + +@settings(max_examples=50, deadline=None) +@given(palette=_palette_strategy()) +def test_dedup_and_sort_preserve_invariants(palette: Palette) -> None: + before = [(c.rgb, c.frequency) for c in palette.colors] + deduped = palette.dedup() + _assert_palette_invariants(deduped) + assert len(deduped) <= len(palette) + ordered = palette.sort_perceptual() + _assert_palette_invariants(ordered) + assert len(ordered) == len(palette) + # idempotent + stable + assert [c.rgb for c in ordered.sort_perceptual().colors] == [c.rgb for c in ordered.colors] + assert [(c.rgb, c.frequency) for c in palette.colors] == before # original unchanged + + +@settings(max_examples=50, deadline=None) +@given(palette=_palette_strategy(), steps_between=st.integers(1, 4)) +def test_gradient_preserves_invariants(palette: Palette, steps_between: int) -> None: + before = [(c.rgb, c.frequency) for c in palette.colors] + result = palette.gradient(steps_between) + _assert_palette_invariants(result) + assert len(result) >= len(palette) # gradient is expanding (or unchanged for <2 colors) + assert [(c.rgb, c.frequency) for c in palette.colors] == before # original unchanged + + +@settings(max_examples=50, deadline=None) +@given(palette=_palette_strategy(), kind=st.sampled_from(list(HarmonyKind))) +def test_harmony_preserves_invariants(palette: Palette, kind: HarmonyKind) -> None: + before = [(c.rgb, c.frequency) for c in palette.colors] + result = palette.harmony(kind) + _assert_palette_invariants(result) + expected = 2 if kind is HarmonyKind.COMPLEMENTARY else 3 + assert len(result) == expected + assert [(c.rgb, c.frequency) for c in palette.colors] == before # original unchanged From 8d0355e63d92a2b6c535f90f220ac2a97315ca1c Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 21:21:23 +0200 Subject: [PATCH 12/14] document palette operations --- docs/index.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/index.md b/docs/index.md index 5c0791a..798fbf1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -212,6 +212,29 @@ Control how your palettes are saved: +## Operating on palettes + +Palette operations are immutable — each returns a new `Palette`, so they chain: + +```python +from pylette import extract_colors + +palette = ( + extract_colors("image.jpg", palette_size=12) + .merge_similar(delta_e=0.05) # collapse perceptual near-duplicates (OKLab ΔE) + .sort_perceptual() # order by perceptual lightness +) + +# Perceptual difference between two colors: +distance = palette[0].delta_e(palette[1]) + +# Generate a harmony scheme from a single color: +scheme = palette[0].harmony("triadic") # complementary | triadic | analogous + +# Build a smooth ramp: +ramp = palette[0].gradient_to(palette[-1], steps=8) +``` + ## Example Palettes Check out these palettes extracted using Pylette! The top row corresponds to extraction using K-Means, and the bottom From 1235fafadc4fbabdc492f9b711ccce116969fcc1 Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 21:37:17 +0200 Subject: [PATCH 13/14] copy colors on return for true immutability and make normalize_frequencies public --- pylette/src/color.py | 2 +- pylette/src/operations.py | 15 ++++++++------- pylette/src/palette.py | 11 ++++------- tests/integration/test_operations.py | 6 ++++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pylette/src/color.py b/pylette/src/color.py index f8cd6e1..15c8d05 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -305,7 +305,7 @@ def gradient_to(self, other: "Color", steps: int) -> "Palette": return Palette(operations.interpolate(self, other, steps)) - def harmony(self, kind: "HarmonyKind | str") -> "Palette": + def harmony(self, kind: HarmonyKind | str) -> "Palette": """Return a color-harmony scheme generated from this color. ``kind`` is ``"complementary"``, ``"triadic"``, or ``"analogous"`` (a diff --git a/pylette/src/operations.py b/pylette/src/operations.py index c161f26..38e642c 100644 --- a/pylette/src/operations.py +++ b/pylette/src/operations.py @@ -4,7 +4,7 @@ methods on :class:`~pylette.src.color.Color` and :class:`~pylette.src.palette.Palette`. Functions here take and return plain ``list[Color]`` (or a single ``Color``); they never mutate their inputs except -for the private ``_normalize_frequencies`` helper, which only touches colors it +for the ``normalize_frequencies`` helper, which only touches colors it was handed to finalize a freshly built result. """ @@ -35,7 +35,7 @@ def weighted_oklab_mean(colors: list[Color]) -> tuple[tuple[float, float, float] return oklab_to_srgb((float(mean_lab[0]), float(mean_lab[1]), float(mean_lab[2]))), mean_opacity -def _normalize_frequencies(colors: list[Color]) -> list[Color]: +def normalize_frequencies(colors: list[Color]) -> list[Color]: """Assign equal frequencies summing to 1.0 across ``colors`` in place.""" n = len(colors) for color in colors: @@ -49,7 +49,8 @@ def sort_perceptual(colors: list[Color], descending: bool = False) -> list[Color Python's ``sorted`` is stable, so equal-lightness colors keep their relative order; sorting an already-sorted list is a no-op (idempotent). """ - return sorted(colors, key=lambda c: c.oklab[0], reverse=descending) + ordered = sorted(colors, key=lambda c: c.oklab[0], reverse=descending) + return [Color.from_srgb_float(c.rgb_float, c.frequency, alpha=c.opacity) for c in ordered] def merge_similar(colors: list[Color], delta_e: float) -> list[Color]: @@ -77,7 +78,7 @@ def merge_similar(colors: list[Color], delta_e: float) -> list[Color]: break else: clusters.append([color]) - reps.append(color) + reps.append(Color.from_srgb_float(color.rgb_float, color.frequency, alpha=color.opacity)) return reps @@ -100,7 +101,7 @@ def interpolate(a: Color, b: Color, steps: int) -> list[Color]: lab = (la[0] + t * (lb[0] - la[0]), la[1] + t * (lb[1] - la[1]), la[2] + t * (lb[2] - la[2])) opacity = a.opacity + t * (b.opacity - a.opacity) result.append(Color.from_srgb_float(oklab_to_srgb(lab), frequency=0.0, alpha=opacity)) - return _normalize_frequencies(result) + return normalize_frequencies(result) def dedup(colors: list[Color]) -> list[Color]: @@ -127,7 +128,7 @@ def dedup(colors: list[Color]) -> list[Color]: return result -# Hue offsets (fraction of the 360° wheel) per harmony, seed listed first. +# Hue offsets (fraction of the 360-degree wheel) per harmony; seed is at offset 0.0 (middle entry for analogous). _HARMONY_OFFSETS: dict[HarmonyKind, tuple[float, ...]] = { HarmonyKind.COMPLEMENTARY: (0.0, 0.5), # +180° HarmonyKind.TRIADIC: (0.0, 1.0 / 3.0, 2.0 / 3.0), # ±120° @@ -152,4 +153,4 @@ def harmony(seed: Color, kind: HarmonyKind | str) -> list[Color]: new_h = (h + offset) % 1.0 r, g, b = colorsys.hsv_to_rgb(new_h, s, v) result.append(Color.from_srgb_float((r, g, b), frequency=0.0, alpha=seed.opacity)) - return _normalize_frequencies(result) + return normalize_frequencies(result) diff --git a/pylette/src/palette.py b/pylette/src/palette.py index 9ac9159..c1b0570 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -185,10 +185,7 @@ def gradient(self, steps_between: int) -> "Palette": if idx > 0: segment = segment[1:] # drop the seam color shared with the previous segment out.extend(segment) - n = len(out) - for c in out: - c.frequency = 1.0 / n - return Palette(out) + return Palette(operations.normalize_frequencies(out)) def sort_perceptual(self, descending: bool = False) -> "Palette": """Return a new palette sorted by perceptual lightness (OKLab L). @@ -204,11 +201,11 @@ def sort_perceptual(self, descending: bool = False) -> "Palette": """ return Palette(operations.sort_perceptual(self.colors, descending=descending)) - def harmony(self, kind: "HarmonyKind | str") -> "Palette": + def harmony(self, kind: HarmonyKind | str) -> "Palette": """Return a color-harmony scheme seeded from this palette's dominant color. - Convenience wrapper over :meth:`Color.harmony`: the seed is the - highest-frequency color. An empty palette yields an empty palette. + Seeds from the dominant (highest-frequency) color and delegates to the + harmony operation. An empty palette yields an empty palette. Parameters: kind (HarmonyKind | str): ``"complementary"``, ``"triadic"``, or diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index 26d8179..0eb297b 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -67,13 +67,13 @@ def test_weighted_oklab_mean_zero_total_frequency_uses_uniform_weights() -> None def test_normalize_frequencies_sums_to_one() -> None: colors = [Color(rgba=(i, i, i, 255), frequency=0.0) for i in (10, 20, 30, 40)] - normalized = operations._normalize_frequencies(colors) + normalized = operations.normalize_frequencies(colors) assert sum(c.frequency for c in normalized) == pytest.approx(1.0) assert all(c.frequency == pytest.approx(0.25) for c in normalized) def test_normalize_frequencies_empty_list() -> None: - assert operations._normalize_frequencies([]) == [] + assert operations.normalize_frequencies([]) == [] def test_dedup_collapses_exact_duplicates_and_sums_frequency() -> None: @@ -284,11 +284,13 @@ def _assert_palette_invariants(palette: Palette) -> None: @settings(max_examples=50, deadline=None) @given(palette=_palette_strategy(), delta_e=st.floats(0.0, 0.3)) def test_merge_similar_preserves_invariants(palette: Palette, delta_e: float) -> None: + before = [(c.rgb, c.frequency) for c in palette.colors] original_len = len(palette) result = palette.merge_similar(delta_e) _assert_palette_invariants(result) assert len(result) <= original_len assert len(palette) == original_len # immutability + assert [(c.rgb, c.frequency) for c in palette.colors] == before # original unchanged @settings(max_examples=50, deadline=None) From 8f1b8ddde950147b97d0ff1f33c01b6a27f0b68b Mon Sep 17 00:00:00 2001 From: Ivar Stangeby Date: Sun, 28 Jun 2026 21:52:46 +0200 Subject: [PATCH 14/14] update changelog --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec03321..22c2486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# Unreleased + +## 6.1.0 + +### Added + +- **Palette operations**: a composable set of operations for working with an + extracted palette. Every operation is immutable — it returns a new `Palette` + (or `Color`s) and leaves the original untouched — so calls can be chained. + - `Palette.merge_similar(delta_e)`: merge perceptually near-duplicate colors + (within an OKLab ΔE threshold) into their frequency-weighted OKLab mean, + summing frequencies. + - `Palette.dedup()`: collapse exactly-equal colors (same 8-bit RGB), summing + their frequencies. + - `Palette.sort_perceptual(descending=False)`: order colors by perceptual + lightness (OKLab L); stable and idempotent. + - `Palette.gradient(steps_between)` and `Color.gradient_to(other, steps)`: + build a smooth ramp by interpolating between colors in OKLab. + - `Palette.harmony(kind)` and `Color.harmony(kind)`: generate a color-harmony + scheme (`complementary`, `triadic`, or `analogous`) by hue rotation; + `Palette.harmony` seeds from the dominant color. +- **`Color.delta_e(other)`**: perceptual color difference as the Euclidean + distance between two colors in the OKLab color space. +- **`HarmonyKind`** enum (`COMPLEMENTARY`, `TRIADIC`, `ANALOGOUS`) and the + **`InvalidHarmonyError`** exception (part of the `PyletteError` hierarchy), + both exported from `pylette`. + + # Released ## 6.0.0