Skip to content

Commit 3ede903

Browse files
authored
(4.2.0) Add direct support for PIL.Image (#84)
* Add PIL Image support * Format * Bump minor version, and add changelog * Fix type
1 parent feff038 commit 3ede903

8 files changed

Lines changed: 73 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
# Released
1212

13+
## 4.2.0 - 08/11/2025
14+
15+
### Added
16+
17+
- Added support for `PIL.Image` type in `extract_colors`.
18+
1319
## 4.1.0 - 4/7/2025
1420

1521
### Added

Pylette/cmd.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,17 @@ def main(
3737
out_filename: pathlib.Path | None = None,
3838
display_colors: bool = False,
3939
colorspace: ColorSpace = ColorSpace.rgb,
40-
alpha_mask_threshold: int | None = typer.Option(None, min=0, max=255, help="Alpha threshold for transparent image masking (0-255). Pixels with alpha below this value are excluded."),
40+
alpha_mask_threshold: int | None = typer.Option(
41+
None,
42+
min=0,
43+
max=255,
44+
help="Alpha threshold for transparent image masking (0-255). Pixels with alpha below this value are excluded.",
45+
),
4146
):
4247
if filename is None and image_url is None:
4348
typer.echo(ctx.get_help())
4449
raise typer.Exit(code=0)
45-
50+
4651
if filename is not None and image_url is not None:
4752
typer.echo("Please provide either a filename or an image-url, but not both.")
4853
raise typer.Exit(code=1)
@@ -54,7 +59,9 @@ def main(
5459
image = image_url
5560

5661
output_file_path = str(out_filename) if out_filename is not None else None
57-
palette = extract_colors(image=image, palette_size=n, sort_mode=sort_by.value, mode=mode.value, alpha_mask_threshold=alpha_mask_threshold)
62+
palette = extract_colors(
63+
image=image, palette_size=n, sort_mode=sort_by.value, mode=mode.value, alpha_mask_threshold=alpha_mask_threshold
64+
)
5865
palette.to_csv(filename=output_file_path, frequency=True, stdout=stdout, colorspace=colorspace.value)
5966
if display_colors:
6067
palette.display()

Pylette/src/color_extraction.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
from Pylette.src.extractors.median_cut import median_cut_extraction
1414
from Pylette.src.palette import Palette
1515

16-
ImageType_T: TypeAlias = Union["os.PathLike[Any]", bytes, NDArray[float], str]
16+
ImageType_T: TypeAlias = Union["os.PathLike[Any]", bytes, NDArray[float], str, Image.Image]
1717

1818

1919
class ImageType(str, Enum):
2020
PATH = "path"
2121
BYTES = "bytes"
2222
ARRAY = "array"
2323
URL = "url"
24+
PIL = "pil"
2425
NONE = "none"
2526

2627

@@ -35,6 +36,8 @@ def _parse_image_type(image: ImageType_T) -> ImageType:
3536
ImageType: The type of the input image.
3637
"""
3738
match image:
39+
case Image.Image():
40+
image_type = ImageType.PIL
3841
case np.ndarray():
3942
image_type = ImageType.ARRAY
4043
case os.PathLike():
@@ -97,6 +100,8 @@ def extract_colors(
97100
img_obj = request_image(image)
98101
case ImageType.ARRAY:
99102
img_obj = Image.fromarray(image)
103+
case ImageType.PIL:
104+
img_obj = image
100105
case ImageType.NONE:
101106
raise ValueError(f"Unable to parse image source. Got image type {type(image)}")
102107

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ from Pylette import extract_colors
9595

9696
# Extract colors from a transparent PNG, ignoring pixels with alpha < 128
9797
palette = extract_colors(
98-
image='transparent_image.png',
99-
palette_size=10,
98+
image='transparent_image.png',
99+
palette_size=10,
100100
alpha_mask_threshold=128
101101
)
102102
```

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ See the [reference documentation](reference.md) for a complete list of available
9494

9595
# Extract colors from a transparent PNG, ignoring pixels with alpha < 128
9696
palette = extract_colors(
97-
image='transparent_image.png',
98-
palette_size=10,
97+
image='transparent_image.png',
98+
palette_size=10,
9999
alpha_mask_threshold=128
100100
)
101101
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pylette"
3-
version = "4.1.0"
3+
version = "4.2.0"
44
description = "A Python library for extracting color palettes from images."
55
authors = [
66
{name = "Ivar Stangeby"}

tests/integration/test_colorspaces.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import pathlib
2+
from typing import Literal
23

34
import cv2
45
import pytest
56
from numpy.testing import assert_approx_equal
7+
from PIL import Image
68

79
from Pylette.src.color_extraction import extract_colors
810

@@ -13,8 +15,14 @@ def test_image_from_opencv():
1315
return cv2.imread(str(test_image.absolute().resolve()))
1416

1517

18+
@pytest.fixture
19+
def test_image_from_PIL():
20+
test_image = pathlib.Path(__file__).parent.parent / "data/test_image.png"
21+
return Image.open(test_image)
22+
23+
1624
@pytest.fixture()
17-
def test_kmean_extracted_palette(test_image_path_as_str):
25+
def test_kmean_extracted_palette(test_image_path_as_str: str):
1826
return extract_colors(image=test_image_path_as_str, palette_size=10, resize=True, mode="KM")
1927

2028

@@ -23,7 +31,9 @@ def test_kmean_extracted_palette(test_image_path_as_str):
2331
"extraction_mode",
2432
["KM", "MC"],
2533
)
26-
def test_palette_invariants_with_image_path(test_image_path_as_str, palette_size, extraction_mode):
34+
def test_palette_invariants_with_image_path(
35+
test_image_path_as_str: str, palette_size: int, extraction_mode: Literal["KM", "MC"]
36+
):
2737
palette = extract_colors(
2838
image=test_image_path_as_str,
2939
palette_size=palette_size,
@@ -117,6 +127,39 @@ def test_palette_invariants_with_image_bytes(test_image_as_bytes, palette_size,
117127
)
118128

119129

130+
@pytest.mark.parametrize("palette_size", [1, 5, 10, 100])
131+
@pytest.mark.parametrize(
132+
"extraction_mode",
133+
["KM", "MC"],
134+
)
135+
def test_palette_invariants_with_PIL_image(test_image_from_PIL, palette_size, extraction_mode):
136+
palette = extract_colors(
137+
image=test_image_from_PIL,
138+
palette_size=palette_size,
139+
resize=True,
140+
mode=extraction_mode,
141+
)
142+
143+
assert len(palette) == palette_size, f"Expected {palette_size} colors in palette, got {len(palette)}"
144+
assert (
145+
palette.number_of_colors == palette_size
146+
), f"Expected {palette_size} colors in palette, got {palette.number_of_colors}"
147+
assert len(palette.colors) == palette_size, f"Expected {palette_size} colors in palette, got {len(palette.colors)}"
148+
assert (
149+
palette.colors[0].freq >= palette.colors[-1].freq
150+
), "Expected colors to be sorted by frequency in descending order"
151+
assert palette.colors[0].freq > 0.0, "Expected the most frequent color to have a frequency greater than 0.0"
152+
assert (
153+
palette.colors[0].freq <= 1.0
154+
), "Expected the most frequent color to have a frequency less than or equal to 1.0"
155+
156+
assert_approx_equal(
157+
sum(c.freq for c in palette.colors),
158+
1.0,
159+
err_msg="Expected the sum of all frequencies to be 1.0",
160+
)
161+
162+
120163
@pytest.mark.parametrize("palette_size", [1, 5, 10, 100])
121164
@pytest.mark.parametrize(
122165
"extraction_mode",

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)