Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pylette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@
from pylette.src.color_extraction import batch_extract_colors, extract_colors
from pylette.src.exceptions import (
InvalidColorspaceError,
InvalidHarmonyError,
InvalidImageError,
NoValidPixelsError,
PyletteError,
UnknownExtractionMethodError,
)
from pylette.src.palette import Palette
from pylette.src.types import HarmonyKind

__all__ = [
"extract_colors",
"batch_extract_colors",
"Palette",
"Color",
"types",
"HarmonyKind",
"PyletteError",
"InvalidImageError",
"NoValidPixelsError",
"UnknownExtractionMethodError",
"InvalidColorspaceError",
"InvalidHarmonyError",
]
62 changes: 60 additions & 2 deletions pylette/src/color.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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

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])
Expand Down Expand Up @@ -210,6 +213,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`.
Expand Down Expand Up @@ -269,6 +287,46 @@ 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))

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:
"""
Expand Down
11 changes: 11 additions & 0 deletions pylette/src/colorspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
4 changes: 4 additions & 0 deletions pylette/src/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
156 changes: 156 additions & 0 deletions pylette/src/operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""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 ``normalize_frequencies`` helper, which only touches colors it
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]:
"""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]:
"""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


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).
"""
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]:
"""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.from_srgb_float(color.rgb_float, color.frequency, alpha=color.opacity))
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.

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


# 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°
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)
Loading