diff --git a/CHANGELOG.md b/CHANGELOG.md index a901d42..e3d291c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/pylette/cmd.py b/pylette/cmd.py index 06b68c9..b0e5f5c 100644 --- a/pylette/cmd.py +++ b/pylette/cmd.py @@ -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])})" diff --git a/pylette/src/color.py b/pylette/src/color.py index b50d764..3175b48 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -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 @@ -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, @@ -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]: @@ -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]: @@ -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: diff --git a/pylette/src/colorspaces.py b/pylette/src/colorspaces.py new file mode 100644 index 0000000..743717b --- /dev/null +++ b/pylette/src/colorspaces.py @@ -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])) diff --git a/pylette/src/extractors/oklab.py b/pylette/src/extractors/oklab.py index be24b04..3d47c8d 100644 --- a/pylette/src/extractors/oklab.py +++ b/pylette/src/extractors/oklab.py @@ -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) diff --git a/pylette/src/palette.py b/pylette/src/palette.py index 8125fd3..be894d7 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -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), } diff --git a/pylette/src/types.py b/pylette/src/types.py index 4113c5e..c52103f 100644 --- a/pylette/src/types.py +++ b/pylette/src/types.py @@ -63,6 +63,7 @@ class ColorSpace(str, Enum): RGB = "rgb" HSV = "hsv" HLS = "hls" + OKLAB = "oklab" _EnumT = TypeVar("_EnumT", bound=Enum) diff --git a/tests/integration/test_color_conversion.py b/tests/integration/test_color_conversion.py new file mode 100644 index 0000000..08597a8 --- /dev/null +++ b/tests/integration/test_color_conversion.py @@ -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") diff --git a/tests/integration/test_colorspaces.py b/tests/integration/test_colorspaces.py index b88086d..d09ea08 100644 --- a/tests/integration/test_colorspaces.py +++ b/tests/integration/test_colorspaces.py @@ -233,7 +233,7 @@ 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}" @@ -241,7 +241,7 @@ def test_colorspace_invariants_hls(test_kmean_extracted_palette: Palette): 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}" diff --git a/tests/integration/test_exceptions.py b/tests/integration/test_exceptions.py index 44536dc..76d0386 100644 --- a/tests/integration/test_exceptions.py +++ b/tests/integration/test_exceptions.py @@ -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(