picx-image-optimizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
picx/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Public Python API for picx."""
2
+
3
+ from picx.api import optimize_dir, optimize_image
4
+ from picx.help import examples, help
5
+ from picx.models import OptimizeResult
6
+ from picx.tile import tile_image
7
+
8
+ __all__ = ["OptimizeResult", "examples", "help", "optimize_dir", "optimize_image", "tile_image"]
picx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from picx.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
picx/api.py ADDED
@@ -0,0 +1,177 @@
1
+ from dataclasses import replace
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from pathlib import Path
4
+ from typing import List, Optional, Union
5
+
6
+ from picx.backends.resolver import optimize_with_backend
7
+ from picx.formats import INPUT_FORMATS, normalize_format, output_extension, source_format
8
+ from picx.models import BackendName, OptimizeOptions, OptimizeResult
9
+ from picx.presets import apply_preset
10
+
11
+
12
+ def optimize_image(
13
+ source: Union[str, Path],
14
+ output: Optional[Union[str, Path]] = None,
15
+ format: Optional[str] = None,
16
+ quality: int = 82,
17
+ max_width: Optional[int] = None,
18
+ max_height: Optional[int] = None,
19
+ strip_meta: bool = True,
20
+ target_size: Optional[int] = None,
21
+ preset: Optional[str] = None,
22
+ backend: BackendName = "auto",
23
+ allow_large: bool = False,
24
+ max_pixels: Optional[int] = None,
25
+ ) -> OptimizeResult:
26
+ source_path = Path(source)
27
+ output_path = _resolve_output_path(source_path, output, format)
28
+
29
+ if not source_path.exists():
30
+ return _skipped(source_path, output_path, f"Input does not exist: {source_path}")
31
+ if not source_path.is_file():
32
+ return _skipped(source_path, output_path, f"Input is not a file: {source_path}")
33
+
34
+ try:
35
+ input_format = source_format(source_path)
36
+ requested_format = normalize_format(format)
37
+ options = OptimizeOptions(
38
+ output_format=requested_format,
39
+ quality=_validate_quality(quality),
40
+ max_width=_validate_dimension(max_width, "max_width"),
41
+ max_height=_validate_dimension(max_height, "max_height"),
42
+ strip_meta=strip_meta,
43
+ target_size=_validate_target_size(target_size),
44
+ backend=_validate_backend(backend),
45
+ allow_large=allow_large,
46
+ max_pixels=_validate_target_size(max_pixels),
47
+ )
48
+ preset_options = apply_preset(options, preset)
49
+ resolved_options = (
50
+ preset_options
51
+ if preset_options.output_format is not None
52
+ else replace(preset_options, output_format=input_format)
53
+ )
54
+ resolved_output = _resolve_output_path(source_path, output, resolved_options.output_format)
55
+ return optimize_with_backend(source_path, resolved_output, resolved_options)
56
+ except ValueError as exc:
57
+ return _skipped(source_path, output_path, str(exc))
58
+
59
+
60
+ def optimize_dir(
61
+ source_dir: Union[str, Path],
62
+ out: Union[str, Path],
63
+ format: Optional[str] = None,
64
+ quality: int = 82,
65
+ max_width: Optional[int] = None,
66
+ max_height: Optional[int] = None,
67
+ strip_meta: bool = True,
68
+ target_size: Optional[int] = None,
69
+ recursive: bool = True,
70
+ preset: Optional[str] = None,
71
+ backend: BackendName = "auto",
72
+ allow_large: bool = False,
73
+ max_pixels: Optional[int] = None,
74
+ jobs: int = 1,
75
+ ) -> List[OptimizeResult]:
76
+ root = Path(source_dir)
77
+ output_root = Path(out)
78
+ if not root.exists() or not root.is_dir():
79
+ return [_skipped(root, output_root, f"Input directory does not exist: {root}")]
80
+
81
+ images = _iter_images(root, recursive)
82
+ _validate_jobs(jobs)
83
+
84
+ def optimize_one(image: Path) -> OptimizeResult:
85
+ return optimize_image(
86
+ image,
87
+ output=_dir_output_dir(root, output_root, image),
88
+ format=format,
89
+ quality=quality,
90
+ max_width=max_width,
91
+ max_height=max_height,
92
+ strip_meta=strip_meta,
93
+ target_size=target_size,
94
+ preset=preset,
95
+ backend=backend,
96
+ allow_large=allow_large,
97
+ max_pixels=max_pixels,
98
+ )
99
+
100
+ if jobs == 1:
101
+ return [optimize_one(image) for image in images]
102
+ with ThreadPoolExecutor(max_workers=jobs) as executor:
103
+ return list(executor.map(optimize_one, images))
104
+
105
+
106
+ def _iter_images(root: Path, recursive: bool) -> List[Path]:
107
+ pattern = "**/*" if recursive else "*"
108
+ return sorted(
109
+ path for path in root.glob(pattern) if path.is_file() and path.suffix.lower().lstrip(".") in INPUT_FORMATS
110
+ )
111
+
112
+
113
+ def _dir_output_dir(root: Path, output_root: Path, image: Path) -> Path:
114
+ relative_parent = image.relative_to(root).parent
115
+ return output_root / relative_parent
116
+
117
+
118
+ def _resolve_output_path(
119
+ source: Path,
120
+ output: Optional[Union[str, Path]],
121
+ format_name: Optional[str],
122
+ ) -> Path:
123
+ output_format = normalize_format(format_name) if format_name else None
124
+ extension = output_extension(output_format) if output_format else source.suffix
125
+
126
+ if output is None:
127
+ return source.with_name(f"{source.stem}.optimized{extension}")
128
+
129
+ output_path = Path(output)
130
+ if output_path.exists() and output_path.is_dir():
131
+ return output_path / source.with_suffix(extension).name
132
+ if output_path.suffix:
133
+ return output_path
134
+ return output_path / source.with_suffix(extension).name
135
+
136
+
137
+ def _validate_quality(value: int) -> int:
138
+ if value < 1 or value > 100:
139
+ raise ValueError("quality must be between 1 and 100")
140
+ return value
141
+
142
+
143
+ def _validate_dimension(value: Optional[int], name: str) -> Optional[int]:
144
+ if value is not None and value <= 0:
145
+ raise ValueError(f"{name} must be greater than 0")
146
+ return value
147
+
148
+
149
+ def _validate_target_size(value: Optional[int]) -> Optional[int]:
150
+ if value is not None and value <= 0:
151
+ raise ValueError("size values must be greater than 0")
152
+ return value
153
+
154
+
155
+ def _validate_backend(value: BackendName) -> BackendName:
156
+ if value not in {"auto", "pillow", "pyvips"}:
157
+ raise ValueError("backend must be one of: auto, pillow, pyvips")
158
+ return value
159
+
160
+
161
+ def _validate_jobs(value: int) -> int:
162
+ if value < 1:
163
+ raise ValueError("jobs must be greater than 0")
164
+ return value
165
+
166
+
167
+ def _skipped(source: Path, output: Path, error: str) -> OptimizeResult:
168
+ original_size = source.stat().st_size if source.exists() and source.is_file() else 0
169
+ return OptimizeResult(
170
+ source_path=source,
171
+ output_path=output,
172
+ original_size=original_size,
173
+ output_size=0,
174
+ savings_ratio=0.0,
175
+ skipped=True,
176
+ error=error,
177
+ )
@@ -0,0 +1,6 @@
1
+ """Image processing backends."""
2
+
3
+ from picx.backends.base import ImageBackend
4
+ from picx.backends.pillow import PillowBackend
5
+
6
+ __all__ = ["ImageBackend", "PillowBackend"]
picx/backends/base.py ADDED
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+ from typing import Protocol
3
+
4
+ from picx.models import OptimizeOptions, OptimizeResult
5
+
6
+
7
+ class ImageBackend(Protocol):
8
+ """Backend contract reserved for future engines such as pyvips."""
9
+
10
+ def optimize(self, source: Path, output: Path, options: OptimizeOptions) -> OptimizeResult:
11
+ """Optimize a single image."""
@@ -0,0 +1,177 @@
1
+ from io import BytesIO
2
+ from pathlib import Path
3
+ from typing import Dict, Iterator, Optional
4
+
5
+ from PIL import Image, ImageOps, UnidentifiedImageError
6
+ from contextlib import contextmanager
7
+
8
+ from picx.errors import large_image_guidance, webp_dimension_guidance
9
+ from picx.formats import PIL_FORMATS
10
+ from picx.models import OptimizeOptions, OptimizeResult
11
+
12
+ MIN_QUALITY = 1
13
+ DEFAULT_MAX_PIXELS = Image.MAX_IMAGE_PIXELS
14
+
15
+
16
+ class PillowBackend:
17
+ """Default image optimization backend using Pillow."""
18
+
19
+ def optimize(self, source: Path, output: Path, options: OptimizeOptions) -> OptimizeResult:
20
+ original_size = source.stat().st_size
21
+ try:
22
+ with _pixel_limit(options):
23
+ with Image.open(source) as image:
24
+ large_result = self._large_image_result(source, output, image, options)
25
+ if large_result is not None:
26
+ return large_result
27
+ prepared = self._prepare_image(image, options)
28
+ output.parent.mkdir(parents=True, exist_ok=True)
29
+ output_format = options.output_format or source.suffix.lower().lstrip(".")
30
+ data = self._encode(prepared, output_format, options)
31
+ output.write_bytes(data)
32
+ output_size = output.stat().st_size
33
+ return OptimizeResult(
34
+ source_path=source,
35
+ output_path=output,
36
+ original_size=original_size,
37
+ output_size=output_size,
38
+ savings_ratio=self._savings_ratio(original_size, output_size),
39
+ backend="pillow",
40
+ format=output_format,
41
+ width=prepared.width,
42
+ height=prepared.height,
43
+ )
44
+ except Image.DecompressionBombError as exc:
45
+ return OptimizeResult(
46
+ source_path=source,
47
+ output_path=output,
48
+ original_size=original_size,
49
+ output_size=0,
50
+ savings_ratio=0.0,
51
+ skipped=True,
52
+ error=f"{exc} {large_image_guidance(0, options.max_pixels or DEFAULT_MAX_PIXELS)}",
53
+ backend="pillow",
54
+ )
55
+ except (OSError, ValueError, UnidentifiedImageError) as exc:
56
+ return OptimizeResult(
57
+ source_path=source,
58
+ output_path=output,
59
+ original_size=original_size,
60
+ output_size=0,
61
+ savings_ratio=0.0,
62
+ skipped=True,
63
+ error=str(exc),
64
+ backend="pillow",
65
+ )
66
+
67
+ def _large_image_result(
68
+ self,
69
+ source: Path,
70
+ output: Path,
71
+ image: Image.Image,
72
+ options: OptimizeOptions,
73
+ ) -> Optional[OptimizeResult]:
74
+ if options.allow_large:
75
+ return None
76
+ max_pixels = options.max_pixels if options.max_pixels is not None else DEFAULT_MAX_PIXELS
77
+ pixels = image.width * image.height
78
+ if max_pixels is not None and pixels > max_pixels:
79
+ return OptimizeResult(
80
+ source_path=source,
81
+ output_path=output,
82
+ original_size=source.stat().st_size,
83
+ output_size=0,
84
+ savings_ratio=0.0,
85
+ skipped=True,
86
+ error=large_image_guidance(pixels, max_pixels),
87
+ backend="pillow",
88
+ )
89
+ return None
90
+
91
+ def _prepare_image(self, image: Image.Image, options: OptimizeOptions) -> Image.Image:
92
+ transposed = ImageOps.exif_transpose(image)
93
+ resized = self._resize(transposed, options)
94
+ return self._strip_metadata(resized) if options.strip_meta else resized.copy()
95
+
96
+ def _resize(self, image: Image.Image, options: OptimizeOptions) -> Image.Image:
97
+ if options.max_width is None and options.max_height is None:
98
+ return image.copy()
99
+ max_width = options.max_width or image.width
100
+ max_height = options.max_height or image.height
101
+ return ImageOps.contain(image, (max_width, max_height))
102
+
103
+ def _strip_metadata(self, image: Image.Image) -> Image.Image:
104
+ copied = image.copy()
105
+ copied.info.clear()
106
+ return copied
107
+
108
+ def _encode(self, image: Image.Image, output_format: str, options: OptimizeOptions) -> bytes:
109
+ if options.target_size is None:
110
+ return self._save(image, output_format, options.quality)
111
+ return self._encode_target_size(image, output_format, options)
112
+
113
+ def _encode_target_size(
114
+ self,
115
+ image: Image.Image,
116
+ output_format: str,
117
+ options: OptimizeOptions,
118
+ ) -> bytes:
119
+ low = MIN_QUALITY
120
+ high = max(MIN_QUALITY, min(100, options.quality))
121
+ best: Optional[bytes] = None
122
+ smallest = self._save(image, output_format, MIN_QUALITY)
123
+
124
+ while low <= high:
125
+ quality = (low + high) // 2
126
+ data = self._save(image, output_format, quality)
127
+ if len(data) <= options.target_size:
128
+ best = data
129
+ low = quality + 1
130
+ else:
131
+ high = quality - 1
132
+
133
+ return best if best is not None else smallest
134
+
135
+ def _save(self, image: Image.Image, output_format: str, quality: int) -> bytes:
136
+ if output_format == "webp" and (image.width > 16383 or image.height > 16383):
137
+ raise ValueError(webp_dimension_guidance(image.width, image.height))
138
+ buffer = BytesIO()
139
+ pil_format = PIL_FORMATS[output_format]
140
+ prepared = self._convert_for_format(image, output_format)
141
+ save_options = self._save_options(output_format, quality)
142
+ prepared.save(buffer, format=pil_format, **save_options)
143
+ return buffer.getvalue()
144
+
145
+ def _convert_for_format(self, image: Image.Image, output_format: str) -> Image.Image:
146
+ if output_format in {"jpg", "jpeg"} and image.mode not in {"RGB", "L"}:
147
+ return image.convert("RGB")
148
+ return image
149
+
150
+ def _save_options(self, output_format: str, quality: int) -> Dict[str, object]:
151
+ normalized_quality = max(MIN_QUALITY, min(100, quality))
152
+ if output_format in {"jpg", "jpeg"}:
153
+ return {"quality": normalized_quality, "optimize": True, "progressive": True}
154
+ if output_format == "png":
155
+ return {"optimize": True}
156
+ if output_format == "webp":
157
+ return {"quality": normalized_quality, "method": 6}
158
+ if output_format == "avif":
159
+ return {"quality": normalized_quality}
160
+ if output_format in {"tif", "tiff"}:
161
+ return {"compression": "tiff_lzw"}
162
+ return {}
163
+
164
+ def _savings_ratio(self, original_size: int, output_size: int) -> float:
165
+ if original_size <= 0:
166
+ return 0.0
167
+ return max(0.0, (original_size - output_size) / original_size)
168
+
169
+
170
+ @contextmanager
171
+ def _pixel_limit(options: OptimizeOptions) -> Iterator[None]:
172
+ previous = Image.MAX_IMAGE_PIXELS
173
+ Image.MAX_IMAGE_PIXELS = None
174
+ try:
175
+ yield
176
+ finally:
177
+ Image.MAX_IMAGE_PIXELS = previous
@@ -0,0 +1,110 @@
1
+ from pathlib import Path
2
+ from typing import Dict, Optional
3
+
4
+ from picx.formats import output_extension, source_format
5
+ from picx.errors import webp_dimension_guidance
6
+ from picx.models import OptimizeOptions, OptimizeResult
7
+
8
+ MIN_QUALITY = 1
9
+
10
+
11
+ class PyvipsBackend:
12
+ """Optional backend for large images using pyvips/libvips."""
13
+
14
+ def __init__(self) -> None:
15
+ import pyvips # type: ignore[import-not-found]
16
+
17
+ self._pyvips = pyvips
18
+
19
+ def optimize(self, source: Path, output: Path, options: OptimizeOptions) -> OptimizeResult:
20
+ original_size = source.stat().st_size
21
+ try:
22
+ image = self._load(source)
23
+ resized = self._resize(image, options)
24
+ output_format = options.output_format or source_format(source)
25
+ output.parent.mkdir(parents=True, exist_ok=True)
26
+ data = self._encode(resized, output_format, options)
27
+ output.write_bytes(data)
28
+ output_size = output.stat().st_size
29
+ return OptimizeResult(
30
+ source_path=source,
31
+ output_path=output,
32
+ original_size=original_size,
33
+ output_size=output_size,
34
+ savings_ratio=self._savings_ratio(original_size, output_size),
35
+ backend="pyvips",
36
+ format=output_format,
37
+ width=resized.width,
38
+ height=resized.height,
39
+ )
40
+ except Exception as exc:
41
+ return OptimizeResult(
42
+ source_path=source,
43
+ output_path=output,
44
+ original_size=original_size,
45
+ output_size=0,
46
+ savings_ratio=0.0,
47
+ skipped=True,
48
+ error=str(exc),
49
+ backend="pyvips",
50
+ )
51
+
52
+ def _load(self, source: Path):
53
+ return self._pyvips.Image.new_from_file(str(source), access="sequential")
54
+
55
+ def _resize(self, image, options: OptimizeOptions):
56
+ if options.max_width is None and options.max_height is None:
57
+ return image
58
+ max_width = options.max_width or image.width
59
+ max_height = options.max_height or image.height
60
+ scale = min(max_width / image.width, max_height / image.height, 1.0)
61
+ if scale >= 1.0:
62
+ return image
63
+ return image.resize(scale)
64
+
65
+ def _encode(self, image, output_format: str, options: OptimizeOptions) -> bytes:
66
+ if options.target_size is None:
67
+ return self._write(image, output_format, options.quality, options.strip_meta)
68
+ return self._encode_target_size(image, output_format, options)
69
+
70
+ def _encode_target_size(self, image, output_format: str, options: OptimizeOptions) -> bytes:
71
+ low = MIN_QUALITY
72
+ high = max(MIN_QUALITY, min(100, options.quality))
73
+ best: Optional[bytes] = None
74
+ smallest = self._write(image, output_format, MIN_QUALITY, options.strip_meta)
75
+
76
+ while low <= high:
77
+ quality = (low + high) // 2
78
+ data = self._write(image, output_format, quality, options.strip_meta)
79
+ if len(data) <= options.target_size:
80
+ best = data
81
+ low = quality + 1
82
+ else:
83
+ high = quality - 1
84
+
85
+ return best if best is not None else smallest
86
+
87
+ def _write(self, image, output_format: str, quality: int, strip_meta: bool) -> bytes:
88
+ if output_format == "webp" and (image.width > 16383 or image.height > 16383):
89
+ raise ValueError(webp_dimension_guidance(image.width, image.height))
90
+ extension = output_extension(output_format)
91
+ options = self._write_options(output_format, quality, strip_meta)
92
+ if output_format in {"jpg", "jpeg"} and image.hasalpha():
93
+ image = image.flatten(background=[255, 255, 255])
94
+ return image.write_to_buffer(extension, **options)
95
+
96
+ def _write_options(self, output_format: str, quality: int, strip_meta: bool) -> Dict[str, object]:
97
+ normalized_quality = max(MIN_QUALITY, min(100, quality))
98
+ strip_options: Dict[str, object] = {"strip": True} if strip_meta else {}
99
+ if output_format in {"jpg", "jpeg", "webp", "avif"}:
100
+ return {"Q": normalized_quality, **strip_options}
101
+ if output_format == "png":
102
+ return {"compression": 9, **strip_options}
103
+ if output_format in {"tif", "tiff"}:
104
+ return {"compression": "lzw", **strip_options}
105
+ return strip_options
106
+
107
+ def _savings_ratio(self, original_size: int, output_size: int) -> float:
108
+ if original_size <= 0:
109
+ return 0.0
110
+ return max(0.0, (original_size - output_size) / original_size)
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from picx.backends.base import ImageBackend
5
+ from picx.backends.pillow import PillowBackend
6
+ from picx.errors import pyvips_missing_guidance
7
+ from picx.models import OptimizeOptions, OptimizeResult
8
+
9
+
10
+ def optimize_with_backend(source: Path, output: Path, options: OptimizeOptions) -> OptimizeResult:
11
+ if options.backend == "pillow":
12
+ return PillowBackend().optimize(source, output, options)
13
+ if options.backend == "pyvips":
14
+ return _pyvips_or_error().optimize(source, output, options)
15
+
16
+ pillow_result = PillowBackend().optimize(source, output, options)
17
+ if not _should_retry_with_pyvips(pillow_result):
18
+ return pillow_result
19
+
20
+ pyvips_backend = _optional_pyvips()
21
+ if pyvips_backend is None:
22
+ error = f"{pillow_result.error} {pyvips_missing_guidance()}"
23
+ return OptimizeResult(
24
+ source_path=source,
25
+ output_path=output,
26
+ original_size=pillow_result.original_size,
27
+ output_size=0,
28
+ savings_ratio=0.0,
29
+ skipped=True,
30
+ error=error,
31
+ backend="auto",
32
+ )
33
+ return pyvips_backend.optimize(source, output, options)
34
+
35
+
36
+ def _should_retry_with_pyvips(result: OptimizeResult) -> bool:
37
+ return bool(result.error and "exceeding the configured limit" in result.error)
38
+
39
+
40
+ def _optional_pyvips() -> Optional[ImageBackend]:
41
+ try:
42
+ from picx.backends.pyvips import PyvipsBackend
43
+
44
+ return PyvipsBackend()
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def _pyvips_or_error() -> ImageBackend:
50
+ try:
51
+ from picx.backends.pyvips import PyvipsBackend
52
+
53
+ return PyvipsBackend()
54
+ except Exception as exc:
55
+ return MissingPyvipsBackend(str(exc))
56
+
57
+
58
+ class MissingPyvipsBackend:
59
+ def __init__(self, detail: str) -> None:
60
+ self._detail = detail
61
+
62
+ def optimize(self, source: Path, output: Path, options: OptimizeOptions) -> OptimizeResult:
63
+ original_size = source.stat().st_size if source.exists() and source.is_file() else 0
64
+ return OptimizeResult(
65
+ source_path=source,
66
+ output_path=output,
67
+ original_size=original_size,
68
+ output_size=0,
69
+ savings_ratio=0.0,
70
+ skipped=True,
71
+ error=pyvips_missing_guidance(self._detail),
72
+ backend="pyvips",
73
+ )