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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **String acceptance for `mode` and `colorspace`**: both now accept an enum
member, its value, or its case-insensitive name (e.g. `mode="KM"`,
`mode="KMeans"`).
- **Unified color-space conversion**: `Color.to(space)` is now the single entry
point for converting a color to `rgb`, `hsv`, `hls`, or the new `oklab` space
(`Color.oklab` / `ColorSpace.OKLAB`).
- **Typed exception hierarchy**: `PyletteError` base with `InvalidImageError`,
`NoValidPixelsError`, `UnknownExtractionMethodError`, and
`InvalidColorspaceError`. Using a `except PyletteError` clause now catches any
Expand Down Expand Up @@ -66,6 +69,8 @@ rather than message. Each subclass also derives from `ValueError`, so existing
one release and now emit a `DeprecationWarning`. The CLI's `--n` and
`--num-threads` remain as deprecated aliases of `--palette-size` and
`--max-workers`.
- **`Color.get_colors(...)`**: replaced by `Color.to(...)`. It still works for
one release and now emits a `DeprecationWarning`.

### Removed

Expand Down
2 changes: 1 addition & 1 deletion pylette/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def display_palette_summary(successful_results: list[BatchResult], colorspace: C
frequency = f"{color.frequency:.1%}"

# Get colorspace values
color_values = color.get_colors(colorspace)
color_values = color.to(colorspace)

if colorspace == ColorSpace.RGB:
rgb_str = f"({int(color_values[0])}, {int(color_values[1])}, {int(color_values[2])})"
Expand Down
52 changes: 46 additions & 6 deletions pylette/src/color.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import colorsys
import warnings
from typing import cast

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

Expand Down Expand Up @@ -183,9 +185,34 @@ def __lt__(self, other: "Color") -> bool:
"""
return self.frequency < other.frequency

def to(self, space: ColorSpace | str = ColorSpace.RGB) -> tuple[int, ...] | tuple[float, ...]:
"""
Returns the color in the requested color space.

This is the single conversion entry point; ``get_colors``, ``hsv``,
``hls``, and the OKLab view all route through it, and all space math
lives in :mod:`pylette.src.colorspaces`.

Parameters:
space (ColorSpace | str): The target space (enum member, its value,
or case-insensitive name): ``rgb``, ``hsv``, ``hls``, or ``oklab``.

Returns:
tuple[int, ...] | tuple[float, ...]: The color values in that space
(RGB as 8-bit ints; the others as floats).
"""
space = coerce_to_enum(space, ColorSpace, error_cls=InvalidColorspaceError)
if space is ColorSpace.RGB:
return self.rgb
if space is ColorSpace.HSV:
return colorsys.rgb_to_hsv(*self._srgb)
if space is ColorSpace.HLS:
return colorsys.rgb_to_hls(*self._srgb)
return srgb_to_oklab(self._srgb)

def get_colors(self, colorspace: ColorSpace | str = ColorSpace.RGB) -> tuple[int, ...] | tuple[float, ...]:
"""
Returns the color values in the specified color space.
Deprecated alias for :meth:`to`.

Parameters:
colorspace (ColorSpace | str): The color space to use (enum member,
Expand All @@ -194,9 +221,12 @@ def get_colors(self, colorspace: ColorSpace | str = ColorSpace.RGB) -> tuple[int
Returns:
tuple[int, ...] | tuple[float, ...]: The color values in the specified color space.
"""
colorspace = coerce_to_enum(colorspace, ColorSpace, error_cls=InvalidColorspaceError)
colors = {ColorSpace.RGB: self.rgb, ColorSpace.HSV: self.hsv, ColorSpace.HLS: self.hls}
return colors[colorspace]
warnings.warn(
"Color.get_colors() is deprecated and will be removed; use Color.to() instead.",
DeprecationWarning,
stacklevel=2,
)
return self.to(colorspace)

@property
def hsv(self) -> tuple[float, float, float]:
Expand All @@ -206,7 +236,7 @@ def hsv(self) -> tuple[float, float, float]:
Returns:
tuple[float, float, float]: The color values in HSV color space.
"""
return colorsys.rgb_to_hsv(*self._srgb)
return cast("tuple[float, float, float]", self.to(ColorSpace.HSV))

@property
def hls(self) -> tuple[float, float, float]:
Expand All @@ -216,7 +246,17 @@ def hls(self) -> tuple[float, float, float]:
Returns:
tuple[float, float, float]: The color values in HLS color space.
"""
return colorsys.rgb_to_hls(*self._srgb)
return cast("tuple[float, float, float]", self.to(ColorSpace.HLS))

@property
def oklab(self) -> tuple[float, float, float]:
"""
Converts the color to the perceptual OKLab color space.

Returns:
tuple[float, float, float]: The ``(L, a, b)`` OKLab components.
"""
return cast("tuple[float, float, float]", self.to(ColorSpace.OKLAB))

@property
def hex(self) -> str:
Expand Down
70 changes: 70 additions & 0 deletions pylette/src/colorspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Color-space transforms

OKLab (Björn Ottosson, 2020 -- https://bottosson.github.io/posts/oklab/) is a
perceptual color space built so that Euclidean distance approximates perceived
color difference.
"""

import numpy as np

from pylette.src.types import FloatArray


# sRGB <-> linear sRGB (IEC 61966-2-1 transfer function)
def srgb_to_linear(srgb: FloatArray) -> FloatArray:
"""Map gamma-encoded sRGB in ``[0, 1]`` to linear-light sRGB in ``[0, 1]``."""
return np.where(srgb <= 0.04045, srgb / 12.92, ((srgb + 0.055) / 1.055) ** 2.4)


def linear_to_srgb(linear: FloatArray) -> FloatArray:
"""Map linear-light sRGB in ``[0, 1]`` back to gamma-encoded sRGB in ``[0, 1]``."""
linear = np.clip(linear, 0.0, 1.0)
return np.where(linear <= 0.0031308, 12.92 * linear, 1.055 * np.power(linear, 1.0 / 2.4) - 0.055)


# linear sRGB <-> OKLab (Ottosson 2020)
#
# Forward matrices are the ones from the webpage. The inverse matrices are derived
# from them so the round-trip is numerically self-consistent (single source of
# truth) rather than two independently transcribed constant sets.

# linear sRGB -> LMS
_LRGB_TO_LMS = np.array(
[
[0.4122214708, 0.5363325363, 0.0514459929],
[0.2119034982, 0.6806995451, 0.1073969566],
[0.0883024619, 0.2817188376, 0.6299787005],
]
)
# nonlinear l'm's' -> OKLab
_LMS_TO_OKLAB = np.array(
[
[0.2104542553, 0.7936177850, -0.0040720468],
[1.9779984951, -2.4285922050, 0.4505937099],
[0.0259040371, 0.7827717662, -0.8086757660],
]
)
_OKLAB_TO_LMS = np.linalg.inv(_LMS_TO_OKLAB)
_LMS_TO_LRGB = np.linalg.inv(_LRGB_TO_LMS)


def linear_srgb_to_oklab(rgb: FloatArray) -> FloatArray:
"""Convert an ``(N, 3)`` array of linear sRGB to OKLab."""
lms = rgb @ _LRGB_TO_LMS.T
lms_nonlinear = np.cbrt(lms)
return lms_nonlinear @ _LMS_TO_OKLAB.T


def oklab_to_linear_srgb(lab: FloatArray) -> FloatArray:
"""Convert an ``(N, 3)`` array of OKLab back to linear sRGB."""
lms_nonlinear = lab @ _OKLAB_TO_LMS.T
lms = lms_nonlinear**3
return lms @ _LMS_TO_LRGB.T


def srgb_to_oklab(srgb: tuple[float, float, float]) -> tuple[float, float, float]:
"""Convert a single gamma-encoded sRGB triple in ``[0, 1]`` to OKLab ``(L, a, b)``."""
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]))
55 changes: 2 additions & 53 deletions pylette/src/extractors/oklab.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,61 +27,10 @@
from typing_extensions import override

from pylette.src.color import Color
from pylette.src.colorspaces import linear_srgb_to_oklab, linear_to_srgb, oklab_to_linear_srgb, srgb_to_linear
from pylette.src.extractors.protocol import NP_T, ColorExtractorBase
from pylette.src.extractors.registry import register
from pylette.src.types import ExtractionMethod, FloatArray


# sRGB <-> linear sRGB (IEC 61966-2-1 transfer function)
def srgb_to_linear(srgb: FloatArray) -> FloatArray:
"""Map gamma-encoded sRGB in ``[0, 1]`` to linear-light sRGB in ``[0, 1]``."""
return np.where(srgb <= 0.04045, srgb / 12.92, ((srgb + 0.055) / 1.055) ** 2.4)


def linear_to_srgb(linear: FloatArray) -> FloatArray:
"""Map linear-light sRGB in ``[0, 1]`` back to gamma-encoded sRGB in ``[0, 1]``."""
linear = np.clip(linear, 0.0, 1.0)
return np.where(linear <= 0.0031308, 12.92 * linear, 1.055 * np.power(linear, 1.0 / 2.4) - 0.055)


# linear sRGB <-> OKLab (Ottosson 2020)
#
# Forward matrices are the ones from the webpage. the inverse matrices are derived
# from them so the round-trip is numerically self-consistent (single source of
# truth) rather than two independently transcribed constant sets.

# linear sRGB -> LMS
_LRGB_TO_LMS = np.array(
[
[0.4122214708, 0.5363325363, 0.0514459929],
[0.2119034982, 0.6806995451, 0.1073969566],
[0.0883024619, 0.2817188376, 0.6299787005],
]
)
# nonlinear l'm's' -> OKLab
_LMS_TO_OKLAB = np.array(
[
[0.2104542553, 0.7936177850, -0.0040720468],
[1.9779984951, -2.4285922050, 0.4505937099],
[0.0259040371, 0.7827717662, -0.8086757660],
]
)
_OKLAB_TO_LMS = np.linalg.inv(_LMS_TO_OKLAB)
_LMS_TO_LRGB = np.linalg.inv(_LRGB_TO_LMS)


def linear_srgb_to_oklab(rgb: FloatArray) -> FloatArray:
"""Convert an ``(N, 3)`` array of linear sRGB to OKLab."""
lms = rgb @ _LRGB_TO_LMS.T
lms_nonlinear = np.cbrt(lms)
return lms_nonlinear @ _LMS_TO_OKLAB.T


def oklab_to_linear_srgb(lab: FloatArray) -> FloatArray:
"""Convert an ``(N, 3)`` array of OKLab back to linear sRGB."""
lms_nonlinear = lab @ _OKLAB_TO_LMS.T
lms = lms_nonlinear**3
return lms @ _LMS_TO_LRGB.T
from pylette.src.types import ExtractionMethod


@register(ExtractionMethod.OKLAB)
Expand Down
2 changes: 1 addition & 1 deletion pylette/src/palette.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def to_json(
colors_list = []
# Add color data
for color in self.colors:
color_values = color.get_colors(colorspace)
color_values = color.to(colorspace)
color_data: dict[str, object] = {
"frequency": float(color.frequency),
}
Expand Down
1 change: 1 addition & 0 deletions pylette/src/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class ColorSpace(str, Enum):
RGB = "rgb"
HSV = "hsv"
HLS = "hls"
OKLAB = "oklab"


_EnumT = TypeVar("_EnumT", bound=Enum)
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/test_color_conversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import colorsys

import numpy as np
import pytest

from pylette import Color, InvalidColorspaceError
from pylette.src.colorspaces import linear_srgb_to_oklab, srgb_to_linear, srgb_to_oklab
from pylette.src.types import ColorSpace


@pytest.fixture
def sample() -> Color:
return Color(rgba=(142, 152, 174, 255), frequency=0.5)


def test_to_rgb_returns_int_tuple(sample: Color) -> None:
assert sample.to(ColorSpace.RGB) == sample.rgb
assert sample.to("rgb") == sample.rgb


def test_to_hsv_and_hls_match_colorsys(sample: Color) -> None:
srgb = sample.rgb_float
assert sample.to(ColorSpace.HSV) == colorsys.rgb_to_hsv(*srgb)
assert sample.to(ColorSpace.HLS) == colorsys.rgb_to_hls(*srgb)


def test_properties_delegate_to_to(sample: Color) -> None:
assert sample.hsv == sample.to(ColorSpace.HSV)
assert sample.hls == sample.to(ColorSpace.HLS)
assert sample.oklab == sample.to(ColorSpace.OKLAB)


def test_get_colors_is_deprecated_and_delegates(sample: Color) -> None:
for space in ColorSpace:
with pytest.warns(DeprecationWarning):
value = sample.get_colors(space)
assert value == sample.to(space)


def test_oklab_view_matches_shared_module(sample: Color) -> None:
assert sample.to(ColorSpace.OKLAB) == srgb_to_oklab(sample.rgb_float)
assert sample.to("oklab") == srgb_to_oklab(sample.rgb_float)


def test_oklab_single_color_matches_batched_transform() -> None:
"""The single-color helper must agree with the batched (N, 3) transform used
by the extractor — i.e. one source of truth for the matrices."""
srgb = (0.2, 0.6, 0.9)
single = srgb_to_oklab(srgb)
batched = linear_srgb_to_oklab(srgb_to_linear(np.asarray([srgb], dtype=np.float64)))[0]
assert single == pytest.approx(tuple(batched))


def test_oklab_white_is_lightness_one() -> None:
white = Color(rgba=(255, 255, 255, 255), frequency=1.0)
L, a, b = white.oklab
assert L == pytest.approx(1.0, abs=1e-6)
assert a == pytest.approx(0.0, abs=1e-6)
assert b == pytest.approx(0.0, abs=1e-6)


def test_unknown_space_raises_invalid_colorspace_error(sample: Color) -> None:
with pytest.raises(InvalidColorspaceError):
sample.to("not-a-space")
4 changes: 2 additions & 2 deletions tests/integration/test_colorspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,15 @@ def test_palette_invariants_with_image_url(

def test_colorspace_invariants_hls(test_kmean_extracted_palette: Palette):
for color in test_kmean_extracted_palette:
H, L, S = color.get_colors(colorspace="hls")
H, L, S = color.to("hls")
assert 0 <= H <= 360, f"Expected 0 <= h <= 360, got {H}"
assert 0 <= L <= 1, f"Expected 0 <= l <= 1, got {L}"
assert 0 <= S <= 1, f"Expected 0 <= s <= 1, got {S}"


def test_colorspace_invariants_hsv(test_kmean_extracted_palette: Palette):
for color in test_kmean_extracted_palette:
H, L, V = color.get_colors(colorspace="hls")
H, L, V = color.to("hls")
assert 0 <= H <= 360, f"Expected 0 <= h <= 360, got {H}"
assert 0 <= L <= 1, f"Expected 0 <= l <= 1, got {L}"
assert 0 <= V <= 1, f"Expected 0 <= s <= 1, got {V}"
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ def test_invalid_colorspace_in_to_json_raises_invalid_colorspace_error(opaque_im
palette.to_json(colorspace="not-a-space")


def test_invalid_colorspace_in_get_colors_raises_invalid_colorspace_error() -> None:
def test_invalid_colorspace_in_color_to_raises_invalid_colorspace_error() -> None:
color = Color(rgba=(10, 20, 30, 255), frequency=1.0)
with pytest.raises(InvalidColorspaceError):
color.get_colors(colorspace="not-a-space")
color.to("not-a-space")


@pytest.mark.parametrize(
Expand Down