Repository: qTipTip/Pylette

Issue:
Feature - Add Image/bytes or IO support
On my app I already have a image in memory. To use extract_colors I need to save it on a temp file, will be great if I can pass the bytes just.

Something like this:
```python
class ColorPaletteExtractor(Protocol):
    async def __call__(self, asset: bytes) -> List[str]:
        palette = extract_colors(asset, palette_size=10, resize=True)
        ...
```


Discussion:

Patch:
diff --git a/Pylette/src/color_extraction.py b/Pylette/src/color_extraction.py
index a399454..5d0604e 100644
--- a/Pylette/src/color_extraction.py
+++ b/Pylette/src/color_extraction.py
@@ -42,15 +42,17 @@ def median_cut_extraction(
 
 
 def extract_colors(
-    image=None,
+    image_bytes: bytes | None = None,
+    image: str | None = None,
     image_url: str | None = None,
     palette_size: int = 5,
     resize: bool = True,
     mode: Literal["KM"] | Literal["MC"] = "KM",
-    sort_mode: Literal["luminance"] | None = None,
+    sort_mode: Literal["luminance", "frequency"] | None = None,
 ) -> Palette:
     """
     Extracts a set of 'palette_size' colors from the given image.
+    :param image_bytes: bytes representing the image data
     :param image: path to Image file
     :param image_url: url to the image-file
     :param palette_size: number of colors to extract
@@ -60,10 +62,12 @@ def extract_colors(
     :return: a list of the extracted colors
     """
 
-    if image is None and image_url is None:
+    if image_bytes is None and image is None and image_url is None:
         raise ValueError("No image provided")
 
-    if image is None and image_url is not None:
+    if image_bytes is not None:
+        img = Image.open(BytesIO(image_bytes)).convert("RGB")
+    elif image is None and image_url is not None:
         response = requests.get(image_url)
         # Check if the request was successful and content type is an image
         if response.status_code == 200 and "image" in response.headers.get(


Test patch:
diff --git a/tests/integration/test_colorspaces.py b/tests/integration/test_colorspaces.py
index 82a7b41..eee71c2 100644
--- a/tests/integration/test_colorspaces.py
+++ b/tests/integration/test_colorspaces.py
@@ -14,6 +14,14 @@ def test_image_path_as_str():
     yield str(test_image.absolute().resolve())
 
 
+@pytest.fixture
+def test_image_as_bytes():
+    test_image = pathlib.Path(__file__).parent.parent / "data/test_image.png"
+
+    with open(test_image, "rb") as f:
+        yield f.read()
+
+
 @pytest.fixture()
 def test_kmean_extracted_palette(test_image_path_as_str):
     return extract_colors(
@@ -70,6 +78,55 @@ def test_palette_invariants_with_image_path(
     )
 
 
+@pytest.mark.parametrize("palette_size", [1, 5, 10, 100])
+@pytest.mark.parametrize(
+    "extraction_mode",
+    [
+        "KM",
+        pytest.param(
+            "MC",
+            marks=pytest.mark.skip(
+                "Currently a bug in the MC algorithm, causing frequencies not summing to one"
+            ),
+        ),
+    ],
+)
+def test_palette_invariants_with_image_bytes(
+    test_image_as_bytes, palette_size, extraction_mode
+):
+    palette = extract_colors(
+        image_bytes=test_image_as_bytes,
+        palette_size=palette_size,
+        mode=extraction_mode,
+        resize=True,
+    )
+
+    assert (
+        len(palette) == palette_size
+    ), f"Expected {palette_size} colors in palette, got {len(palette)}"
+    assert (
+        palette.number_of_colors == palette_size
+    ), f"Expected {palette_size} colors in palette, got {palette.number_of_colors}"
+    assert (
+        len(palette.colors) == palette_size
+    ), f"Expected {palette_size} colors in palette, got {len(palette.colors)}"
+    assert (
+        palette.colors[0].freq >= palette.colors[-1].freq
+    ), "Expected colors to be sorted by frequency in descending order"
+    assert (
+        palette.colors[0].freq > 0.0
+    ), "Expected the most frequent color to have a frequency greater than 0.0"
+    assert (
+        palette.colors[0].freq <= 1.0
+    ), "Expected the most frequent color to have a frequency less than or equal to 1.0"
+
+    assert_approx_equal(
+        sum(c.freq for c in palette.colors),
+        1.0,
+        err_msg="Expected the sum of all frequencies to be 1.0",
+    )
+
+
 def test_colorspace_invariants_hls(test_kmean_extracted_palette):
     for color in test_kmean_extracted_palette:
         h, l, s = color.get_colors(colorspace="hls")