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 +8 -0
- picx/__main__.py +4 -0
- picx/api.py +177 -0
- picx/backends/__init__.py +6 -0
- picx/backends/base.py +11 -0
- picx/backends/pillow.py +177 -0
- picx/backends/pyvips.py +110 -0
- picx/backends/resolver.py +73 -0
- picx/cli.py +187 -0
- picx/doctor.py +112 -0
- picx/errors.py +31 -0
- picx/formats.py +45 -0
- picx/help.py +31 -0
- picx/models.py +37 -0
- picx/presets.py +35 -0
- picx/report.py +62 -0
- picx/tile.py +310 -0
- picx_image_optimizer-0.1.0.dist-info/METADATA +189 -0
- picx_image_optimizer-0.1.0.dist-info/RECORD +22 -0
- picx_image_optimizer-0.1.0.dist-info/WHEEL +4 -0
- picx_image_optimizer-0.1.0.dist-info/entry_points.txt +2 -0
- picx_image_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
)
|
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."""
|
picx/backends/pillow.py
ADDED
|
@@ -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
|
picx/backends/pyvips.py
ADDED
|
@@ -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
|
+
)
|