Skip to content

Commit 29b5c0e

Browse files
authored
feat: Export to JSON (#94)
* Improve export to include JSON support * Add JSON exports and associated tests * Remove unused export mode enum * Fix JSON format
1 parent bb387f9 commit 29b5c0e

7 files changed

Lines changed: 723 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [Unreleased]
10+
11+
### Added
12+
- JSON export functionality for palettes with `to_json()` method
13+
- General `export()` method supporting multiple formats (JSON, CSV) with JSON as default
14+
- CLI `--export-json` flag for JSON output instead of CSV to stdout
15+
- CLI `--output` parameter with auto-detection for individual vs combined file export
16+
- Smart file naming with prefix + index (`palette_001.json`) for directory exports
17+
- Support for combined JSON files containing multiple palettes
18+
- `hex` property on `Color` class, representing RGB-values in hexadecimal
19+
- Pylette JSON format with colorspace-specific field names (e.g., `rgb`, `hsv`, `hls`)
20+
921
# Released
1022

1123
## 4.4.0 - 20/08/2025

Pylette/cmd.py

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import pathlib
23
from enum import Enum
34
from typing import Annotated, List
@@ -6,20 +7,14 @@
67

78
from Pylette.src.cli_utils import PyletteProgress
89
from Pylette.src.color_extraction import batch_extract_colors
9-
from Pylette.src.types import BatchResult, ExtractionMethod
10+
from Pylette.src.types import BatchResult, ColorSpace, ExtractionMethod
1011

1112

1213
class SortBy(str, Enum):
1314
frequency = "frequency"
1415
luminance = "luminance"
1516

1617

17-
class ColorSpace(str, Enum):
18-
rgb = "rgb"
19-
hsv = "hsv"
20-
hls = "hls"
21-
22-
2318
pylette_app = typer.Typer()
2419

2520

@@ -34,7 +29,7 @@ def main(
3429
stdout: bool = True,
3530
out_filename: pathlib.Path | None = None,
3631
display_colors: bool = False,
37-
colorspace: ColorSpace = ColorSpace.rgb,
32+
colorspace: ColorSpace = ColorSpace.RGB,
3833
alpha_mask_threshold: int | None = typer.Option(
3934
None,
4035
min=0,
@@ -44,7 +39,19 @@ def main(
4439
num_threads: int | None = typer.Option(
4540
None, min=1, help="Number of threads used for batch extraction of color palettes"
4641
),
42+
export_json: bool = typer.Option(False, "--export-json", help="Export palettes to JSON format"),
43+
output: pathlib.Path | None = typer.Option(
44+
None,
45+
"--output",
46+
help="Output file or directory for JSON export."
47+
"If directory: creates individual files. If file: creates combined file.",
48+
),
4749
):
50+
# Validate export_json requirements
51+
if export_json and output is None:
52+
typer.echo("Error: --output is required when using --export-json", err=True)
53+
raise typer.Exit(1)
54+
4855
output_file_path = str(out_filename) if out_filename is not None else None
4956

5057
# Set up progress bar for CLI
@@ -74,12 +81,19 @@ def progress_callback(task_number: int, result: BatchResult):
7481

7582
successful = [r for r in results if r.success]
7683
failed = [r for r in results if not r.success]
77-
for success in successful:
78-
if success.palette is not None:
79-
success.palette.to_csv(
80-
filename=output_file_path, frequency=True, stdout=stdout, colorspace=colorspace.value
81-
)
82-
if display_colors:
84+
85+
if export_json and output:
86+
handle_json_export(successful, output, colorspace)
87+
else:
88+
# Original CSV behavior
89+
for success in successful:
90+
if success.palette is not None:
91+
success.palette.to_csv(filename=output_file_path, frequency=True, stdout=stdout, colorspace=colorspace)
92+
93+
# Display colors if requested
94+
if display_colors:
95+
for success in successful:
96+
if success.palette is not None:
8397
success.palette.display()
8498

8599
if failed:
@@ -93,6 +107,50 @@ def progress_callback(task_number: int, result: BatchResult):
93107
raise typer.Exit(2)
94108

95109

110+
def handle_json_export(
111+
successful_results: list[BatchResult], output_path: pathlib.Path, colorspace: ColorSpace
112+
) -> None:
113+
"""Handle JSON export for successful palette extractions."""
114+
115+
if output_path.is_dir() or (not output_path.exists() and not output_path.suffix):
116+
# Directory mode: individual files
117+
output_dir = output_path
118+
output_dir.mkdir(parents=True, exist_ok=True)
119+
120+
for i, result in enumerate(successful_results, 1):
121+
if result.palette is not None:
122+
filename = output_dir / f"palette_{i:03d}.json"
123+
result.palette.to_json(filename=str(filename), colorspace=colorspace, stdout=False)
124+
125+
typer.echo(f"✓ Exported {len(successful_results)} palettes to {output_dir}")
126+
127+
else:
128+
# File mode: combined file
129+
output_path.parent.mkdir(parents=True, exist_ok=True)
130+
131+
# Create combined JSON structure
132+
combined_data: dict[str, object] = {
133+
"palettes": [],
134+
"total_count": len(successful_results),
135+
"colorspace": colorspace,
136+
}
137+
palettes_list = []
138+
139+
for result in successful_results:
140+
if result.palette is not None:
141+
palette_data = result.palette.to_json(filename=None, colorspace=colorspace, stdout=False)
142+
if palette_data is not None:
143+
palettes_list.append(palette_data)
144+
145+
combined_data["palettes"] = palettes_list
146+
147+
# Write combined file
148+
with open(output_path, "w") as f:
149+
json.dump(combined_data, f, indent=2)
150+
151+
typer.echo(f"✓ Exported {len(successful_results)} palettes to {output_path}")
152+
153+
96154
def print_extraction_summary(successful: list[BatchResult], failed: list[BatchResult]):
97155
total = len(successful) + len(failed)
98156

Pylette/src/color.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import colorsys
2-
from typing import Literal, cast
2+
from typing import cast
33

44
import numpy as np
55

6+
from Pylette.src.types import ColorSpace
7+
68
# Weights for calculating luminance
79
luminance_weights = np.array([0.2126, 0.7152, 0.0722])
810

@@ -50,17 +52,17 @@ def __lt__(self, other: "Color") -> bool:
5052
"""
5153
return self.freq < other.freq
5254

53-
def get_colors(self, colorspace: Literal["rgb", "hsv", "hls"] = "rgb") -> tuple[int, ...] | tuple[float, ...]:
55+
def get_colors(self, colorspace: ColorSpace = ColorSpace.RGB) -> tuple[int, ...] | tuple[float, ...]:
5456
"""
5557
Returns the color values in the specified color space.
5658
5759
Parameters:
58-
colorspace (Literal["rgb", "hsv", "hls"]): The color space to use.
60+
colorspace (ColorSpace): The color space to use.
5961
6062
Returns:
6163
tuple[int, ...] | tuple[float, ...]: The color values in the specified color space.
6264
"""
63-
colors = {"rgb": self.rgb, "hsv": self.hsv, "hls": self.hls}
65+
colors = {ColorSpace.RGB: self.rgb, ColorSpace.HSV: self.hsv, ColorSpace.HLS: self.hls}
6466
return colors[colorspace]
6567

6668
@property
@@ -83,6 +85,16 @@ def hls(self) -> tuple[float, float, float]:
8385
"""
8486
return colorsys.rgb_to_hls(r=self.rgb[0] / 255, g=self.rgb[1] / 255, b=self.rgb[2] / 255)
8587

88+
@property
89+
def hex(self) -> str:
90+
"""
91+
Returns the color as a hexadecimal string.
92+
93+
Returns:
94+
str: The color in hexadecimal format (e.g., "#FF5733").
95+
"""
96+
return f"#{self.rgb[0]:02X}{self.rgb[1]:02X}{self.rgb[2]:02X}"
97+
8698
@property
8799
def luminance(self) -> float:
88100
"""

Pylette/src/palette.py

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

34
import numpy as np
45
from PIL import Image
56

67
from Pylette.src.color import Color
7-
from Pylette.src.types import ExtractionParams, ImageInfo, PaletteMetaData, ProcessingStats, SourceType
8+
from Pylette.src.types import ColorSpace, ExtractionParams, ImageInfo, PaletteMetaData, ProcessingStats, SourceType
89

910

1011
class Palette:
@@ -92,7 +93,7 @@ def to_csv(
9293
self,
9394
filename: str | None = None,
9495
frequency: bool = True,
95-
colorspace: Literal["rgb", "hsv", "hls"] = "rgb",
96+
colorspace: ColorSpace = ColorSpace.RGB,
9697
stdout: bool = True,
9798
):
9899
"""
@@ -143,6 +144,125 @@ def random_color(self, N: int, mode: str = "frequency") -> list[Color]:
143144
else:
144145
raise ValueError(f"Invalid mode: {mode}. Must be 'frequency' or 'uniform'.")
145146

147+
def to_json(
148+
self,
149+
filename: str | None = None,
150+
colorspace: ColorSpace = ColorSpace.RGB,
151+
include_metadata: bool = True,
152+
stdout: bool = True,
153+
) -> dict[str, object] | None:
154+
"""
155+
Exports the palette to JSON format.
156+
157+
Parameters:
158+
filename (str | None): File to save to. If None, returns the dictionary.
159+
colorspace (Literal["rgb", "hsv", "hls"]): Color space to use.
160+
include_metadata (bool): Whether to include palette metadata.
161+
stdout (bool): Whether to print to stdout.
162+
163+
Returns:
164+
dict | None: The palette data as a dictionary if filename is None.
165+
"""
166+
167+
# Build the palette data
168+
palette_data: dict[str, object] = {
169+
"colors": [],
170+
"palette_size": self.number_of_colors,
171+
"colorspace": colorspace,
172+
}
173+
174+
colors_list = []
175+
# Add color data
176+
for color in self.colors:
177+
color_values = color.get_colors(colorspace)
178+
color_data: dict[str, object] = {
179+
"frequency": float(color.freq),
180+
}
181+
182+
# Add colorspace-specific field
183+
colorspace_field = colorspace.value.lower() # "rgb", "hsv", "hls"
184+
if colorspace == ColorSpace.RGB:
185+
# RGB values should be integers
186+
color_data[colorspace_field] = [int(v) if isinstance(v, np.integer) else v for v in color_values]
187+
else:
188+
# HSV/HLS values should be floats
189+
color_data[colorspace_field] = [
190+
float(v) if isinstance(v, (np.integer, np.floating)) else v for v in color_values
191+
]
192+
193+
# Add hex (always present, derived from RGB)
194+
color_data["hex"] = color.hex
195+
196+
# Add RGB reference if colorspace is not RGB
197+
if colorspace != ColorSpace.RGB:
198+
color_data["rgb"] = [int(v) if isinstance(v, np.integer) else v for v in color.rgb]
199+
200+
colors_list.append(color_data)
201+
202+
palette_data["colors"] = colors_list
203+
204+
# Add metadata if requested and available
205+
if include_metadata and self.metadata:
206+
metadata_dict: dict[str, object] = {}
207+
208+
if "image_source" in self.metadata:
209+
metadata_dict["image_source"] = self.metadata["image_source"]
210+
if "source_type" in self.metadata:
211+
metadata_dict["source_type"] = self.metadata["source_type"]
212+
if "extraction_params" in self.metadata:
213+
metadata_dict["extraction_params"] = self.metadata["extraction_params"]
214+
if "image_info" in self.metadata:
215+
metadata_dict["image_info"] = self.metadata["image_info"]
216+
if "processing_stats" in self.metadata:
217+
metadata_dict["processing_stats"] = self.metadata["processing_stats"]
218+
219+
palette_data["metadata"] = metadata_dict
220+
221+
# Print to stdout if requested
222+
if stdout:
223+
print(json.dumps(palette_data, indent=2))
224+
225+
# Save to file if filename provided
226+
if filename is not None:
227+
with open(filename, "w") as f:
228+
json.dump(palette_data, f, indent=2)
229+
return None
230+
231+
# Return data if no filename provided
232+
return palette_data
233+
234+
def export(
235+
self,
236+
filename: str,
237+
format: Literal["json", "csv"] = "json",
238+
colorspace: ColorSpace = ColorSpace.RGB,
239+
include_frequency: bool = True,
240+
include_metadata: bool = True,
241+
stdout: bool = False,
242+
) -> None:
243+
"""
244+
General export method that supports multiple formats with JSON as default.
245+
246+
Parameters:
247+
filename (str): File to save to (extension will be added automatically).
248+
format (Literal["json", "csv"]): Export format (default: json).
249+
colorspace (Literal["rgb", "hsv", "hls"]): Color space to use.
250+
include_frequency (bool): Whether to include frequency data.
251+
include_metadata (bool): Whether to include metadata (JSON only).
252+
stdout (bool): Whether to print to stdout.
253+
"""
254+
255+
if format == "json":
256+
json_filename = f"{filename}.json"
257+
self.to_json(
258+
filename=json_filename, colorspace=colorspace, include_metadata=include_metadata, stdout=stdout
259+
)
260+
elif format == "csv":
261+
csv_filename = f"{filename}.csv"
262+
self.to_csv(filename=csv_filename, frequency=include_frequency, colorspace=colorspace, stdout=stdout)
263+
else:
264+
raise ValueError(f"Unsupported format: {format}. Supported formats: 'json', 'csv'")
265+
146266
def __str__(self):
147267
return "".join(["({}, {}, {}, {}) \n".format(c.rgb[0], c.rgb[1], c.rgb[2], c.freq) for c in self.colors])
148268

Pylette/src/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ class ExtractionMethod(str, Enum):
5858
KM = "KMeans"
5959

6060

61+
class ColorSpace(str, Enum):
62+
RGB = "rgb"
63+
HSV = "hsv"
64+
HLS = "hls"
65+
66+
6167
# PaletteMetaData Types
6268
class SourceType(str, Enum):
6369
FILE_PATH = "file_path"

0 commit comments

Comments
 (0)