Skip to content
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`Color.rgb_float`**: New property returning the canonical color as float
sRGB components in `[0, 1]`, plus a `Color.from_srgb_float(...)` constructor
for building colors from continuous (non-quantized) centroids.
- **Honest color attribute names**: `Color.frequency` (relative cluster weight
in `[0, 1]`, summing to 1), `Color.alpha` (raw 0–255 channel), and
`Color.opacity` (the same value as the old `.weight`, in `[0, 1]`). These
replace `.freq`, `.a`, and `.weight` respectively.
- **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"`).

### Changed

Expand All @@ -42,6 +49,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Spatial dimensions are no longer needed since extractors reshape by the array's
actual length. Custom extractors implementing the protocol must update their
signature accordingly.
- **CLI option names aligned with the library**: `--palette-size` (canonical;
`--n` kept as an alias) and `--max-workers` (canonical; `--num-threads` kept
as an alias).

### Deprecated

- **`Color.freq`, `Color.weight`, `Color.a`**: replaced by `Color.frequency`,
`Color.opacity`, and `Color.alpha` respectively. The old names still work for
one release and now emit a `DeprecationWarning`. The CLI's `--n` and
`--num-threads` remain as deprecated aliases of `--palette-size` and
`--max-workers`.

### Removed

Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ pylette image.jpg
pylette *.jpg --export-json --output results/

# Extract 8 colors in HSV colorspace with structured export
pylette photo.png --n 8 --colorspace hsv --export-json --output colors.json
pylette photo.png --palette-size 8 --colorspace hsv --export-json --output colors.json

# Batch process with parallel processing and table display
pylette images/*.png --n 6 --num-threads 4
pylette images/*.png --palette-size 6 --max-workers 4
```

**Example Output:**
Expand Down Expand Up @@ -104,7 +104,7 @@ pylette *.png --export-json --output results/ --no-stdout

```bash
# Use different extraction algorithms
pylette image.jpg --mode MedianCut --n 6
pylette image.jpg --mode MedianCut --palette-size 6

# Handle transparent images
pylette logo.png --alpha-mask-threshold 128
Expand All @@ -128,7 +128,7 @@ for color in palette.colors:
print(f"RGB: {color.rgb}")
print(f"Hex: {color.hex}")
print(f"HSV: {color.hsv}")
print(f"Frequency: {color.freq:.2%}")
print(f"Frequency: {color.frequency:.2%}")

# Export to structured JSON
palette.to_json(filename='palette.json', colorspace='hsv')
Expand Down Expand Up @@ -258,14 +258,14 @@ Arguments:
IMAGE_SOURCES... Images, URLs, or directories to process [required]

Options:
--mode [KMeans|MedianCut] Extraction algorithm [default: KMeans]
--n INTEGER Number of colors to extract [default: 5]
--mode [KMeans|MedianCut|OKLab] Extraction algorithm [default: KMeans]
--palette-size, --n INTEGER Number of colors to extract [default: 5]
--sort-by [frequency|luminance] Sort colors by [default: luminance]
--colorspace [rgb|hsv|hls] Color space [default: rgb]
--export-json Export to JSON format
--output PATH Output file or directory for JSON export
--alpha-mask-threshold [0-255] Alpha threshold for transparency
--num-threads INTEGER Parallel processing threads
--max-workers, --num-threads INTEGER Parallel processing threads
--display-colors Show palette images
--no-stdout Suppress table output
--help Show help message
Expand Down
6 changes: 3 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ Extract palettes from images using simple commands:
pylette *.jpg --export-json --output results/

# Extract 8 colors in HSV colorspace with structured export
pylette photo.png --n 8 --colorspace hsv --export-json --output colors.json
pylette photo.png --palette-size 8 --colorspace hsv --export-json --output colors.json

# Batch process with parallel processing and table display
pylette images/*.png --n 6 --num-threads 4
pylette images/*.png --palette-size 6 --max-workers 4
```

**Example Output:**
Expand Down Expand Up @@ -100,7 +100,7 @@ For programmatic usage and advanced workflows:
print(f"RGB: {color.rgb}")
print(f"Hex: {color.hex}")
print(f"HSV: {color.hsv}")
print(f"Frequency: {color.freq:.2%}")
print(f"Frequency: {color.frequency:.2%}")

# Export to structured JSON
palette.to_json(filename='palette.json', colorspace='hsv')
Expand Down
24 changes: 17 additions & 7 deletions pylette/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def main(
List[str], typer.Argument(help="A list of paths / directories / URLs pointing to images.")
], # These can be paths or URLs
mode: ExtractionMethod = ExtractionMethod.KM,
n: int = 5,
palette_size: int = typer.Option(
5, "--palette-size", "--n", help="Number of colors to extract. (--n is a deprecated alias.)"
),
sort_by: SortBy = SortBy.luminance,
stdout: bool = True,
out_filename: pathlib.Path | None = None,
Expand All @@ -38,8 +40,12 @@ def main(
max=255,
help="Alpha threshold for transparent image masking (0-255). Pixels with alpha below this value are excluded.",
),
num_threads: int | None = typer.Option(
None, min=1, help="Number of threads used for batch extraction of color palettes"
max_workers: int | None = typer.Option(
None,
"--max-workers",
"--num-threads",
min=1,
help="Number of worker threads for batch extraction. (--num-threads is a deprecated alias.)",
),
export_json: bool = typer.Option(False, "--export-json", help="Export palettes to JSON format"),
output: pathlib.Path | None = typer.Option(
Expand All @@ -55,7 +61,7 @@ def main(
raise typer.Exit(1)

# Set up progress bar for CLI
with PyletteProgress(palette_size=n) as progress:
with PyletteProgress(palette_size=palette_size) as progress:
task_id = progress.add_task("Extracting colors...", total=len(image_sources))

def progress_callback(task_number: int, result: BatchResult):
Expand All @@ -71,11 +77,11 @@ def progress_callback(task_number: int, result: BatchResult):

results = batch_extract_colors(
images=image_sources,
palette_size=n,
palette_size=palette_size,
sort_mode=sort_by.value,
mode=mode,
alpha_mask_threshold=alpha_mask_threshold,
max_workers=num_threads,
max_workers=max_workers,
progress_callback=progress_callback,
)

Expand Down Expand Up @@ -181,7 +187,7 @@ def display_palette_summary(successful_results: list[BatchResult], colorspace: C
# Add color rows
for color in palette.colors:
hex_color = color.hex
frequency = f"{color.freq:.1%}"
frequency = f"{color.frequency:.1%}"

# Get colorspace values
color_values = color.get_colors(colorspace)
Expand Down Expand Up @@ -219,3 +225,7 @@ def print_extraction_summary(successful: list[BatchResult], failed: list[BatchRe

def main_typer() -> None:
pylette_app()


if __name__ == "__main__":
main_typer()
65 changes: 51 additions & 14 deletions pylette/src/color.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import colorsys
import warnings

import numpy as np

from pylette.src.types import ColorSpace
from pylette.src.types import ColorSpace, coerce_to_enum

# Weights for calculating luminance
luminance_weights = np.array([0.2126, 0.7152, 0.0722])
Expand Down Expand Up @@ -37,7 +38,7 @@ def __init__(self, rgba: tuple[int, ...], frequency: float):
r, g, b, alpha = (int(round(float(v))) for v in rgba)
self._srgb: tuple[float, float, float] = (r / 255.0, g / 255.0, b / 255.0)
self._alpha: float = alpha / 255.0
self.freq: float = frequency
self.frequency: float = frequency

@classmethod
def from_srgb_float(
Expand Down Expand Up @@ -66,7 +67,7 @@ def from_srgb_float(
r, g, b = srgb
obj._srgb = (_clamp_unit(r), _clamp_unit(g), _clamp_unit(b))
obj._alpha = _clamp_unit(alpha)
obj.freq = frequency
obj.frequency = frequency
return obj

@property
Expand All @@ -91,15 +92,25 @@ def rgb(self) -> tuple[int, int, int]:
return (int(round(r * 255.0)), int(round(g * 255.0)), int(round(b * 255.0)))

@property
def a(self) -> int:
def alpha(self) -> int:
"""
The alpha channel as an 8-bit value.
The alpha channel as a raw 8-bit value (matches :attr:`rgba`).

Returns:
int: Alpha as a plain Python int in [0, 255].
"""
return int(round(self._alpha * 255.0))

@property
def opacity(self) -> float:
"""
The alpha channel as a fraction (opacity) in ``[0, 1]``.

Returns:
float: Opacity in [0, 1].
"""
return self._alpha

@property
def rgba(self) -> tuple[int, int, int, int]:
"""
Expand All @@ -109,17 +120,41 @@ def rgba(self) -> tuple[int, int, int, int]:
tuple[int, int, int, int]: (r, g, b, a) as plain Python ints in [0, 255].
"""
r, g, b = self.rgb
return (r, g, b, self.a)
return (r, g, b, self.alpha)

@property
def a(self) -> int:
"""Deprecated alias for :attr:`alpha`."""
warnings.warn(
"Color.a is deprecated and will be removed; use Color.alpha instead.",
DeprecationWarning,
stacklevel=2,
)
return self.alpha

@property
def weight(self) -> float:
"""
The alpha channel as a fraction in ``[0, 1]``.
"""Deprecated alias for :attr:`opacity`.

Returns:
float: Alpha in [0, 1].
The name is misleading: in a palette context "weight" reads as relative
importance (frequency), but it holds opacity. Use :attr:`opacity`.
"""
return self._alpha
warnings.warn(
"Color.weight is deprecated and will be removed; use Color.opacity instead.",
DeprecationWarning,
stacklevel=2,
)
return self.opacity

@property
def freq(self) -> float:
"""Deprecated alias for :attr:`frequency`."""
warnings.warn(
"Color.freq is deprecated and will be removed; use Color.frequency instead.",
DeprecationWarning,
stacklevel=2,
)
return self.frequency

def display(self, w: int = 50, h: int = 50) -> None:
"""
Expand All @@ -145,18 +180,20 @@ def __lt__(self, other: "Color") -> bool:
Returns:
bool: True if the frequency of this color is less than the frequency of the other color, False otherwise.
"""
return self.freq < other.freq
return self.frequency < other.frequency

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

Parameters:
colorspace (ColorSpace): The color space to use.
colorspace (ColorSpace | str): The color space to use (enum member,
its value, or case-insensitive name).

Returns:
tuple[int, ...] | tuple[float, ...]: The color values in the specified color space.
"""
colorspace = coerce_to_enum(colorspace, ColorSpace)
colors = {ColorSpace.RGB: self.rgb, ColorSpace.HSV: self.hsv, ColorSpace.HLS: self.hls}
return colors[colorspace]

Expand Down
7 changes: 5 additions & 2 deletions pylette/src/color_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
PILImage,
ProcessingStats,
SourceType,
coerce_to_enum,
)


Expand Down Expand Up @@ -88,7 +89,7 @@ def batch_extract_colors(
images: Sequence[ImageInput],
palette_size: int = 5,
resize: bool = True,
mode: ExtractionMethod = ExtractionMethod.KM,
mode: ExtractionMethod | str = ExtractionMethod.KM,
sort_mode: Literal["luminance", "frequency"] | None = None,
alpha_mask_threshold: int | None = None,
max_workers: int | None = None,
Expand Down Expand Up @@ -141,7 +142,7 @@ def extract_colors(
image: ImageInput,
palette_size: int = 5,
resize: bool = True,
mode: ExtractionMethod = ExtractionMethod.KM,
mode: ExtractionMethod | str = ExtractionMethod.KM,
sort_mode: Literal["luminance", "frequency"] | None = None,
alpha_mask_threshold: int | None = None,
) -> Palette:
Expand All @@ -168,6 +169,8 @@ def extract_colors(

start_time = time.time()

mode = coerce_to_enum(mode, ExtractionMethod)

source_type = _get_source_type_from_image_input(image)
# Normalize input to PIL Image and convert to RGBA
img_obj = _normalize_image_input(image)
Expand Down
9 changes: 2 additions & 7 deletions pylette/src/extractors/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Callable, TypeVar

from pylette.src.extractors.protocol import ColorExtractor
from pylette.src.types import ExtractionMethod
from pylette.src.types import ExtractionMethod, coerce_to_enum

_REGISTRY: dict[ExtractionMethod, ColorExtractor] = {}
_E = TypeVar("_E", bound=ColorExtractor)
Expand Down Expand Up @@ -40,12 +40,7 @@ def get_extractor(method: ExtractionMethod | str) -> ColorExtractor:
ValueError: If ``method`` is not a known method or has no registered extractor.
"""

if not isinstance(method, ExtractionMethod):
try:
method = ExtractionMethod(method)
except ValueError:
valid = ", ".join(sorted(m.value for m in ExtractionMethod))
raise ValueError(f"Unknown extraction method {method}. Valid methods: {valid}.") from None
method = coerce_to_enum(method, ExtractionMethod)

try:
return _REGISTRY[method]
Expand Down
Loading