Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions Tests/test_file_webp.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,74 @@ def test_write_method(self, tmp_path: Path) -> None:
hopper().save(buffer_method, format="WEBP", method=6)
assert buffer_no_args.getbuffer() != buffer_method.getbuffer()

def test_save_all(self, tmp_path: Path) -> None:
@pytest.mark.parametrize(
"options",
(
{"alpha_compression": 0},
{"alpha_filtering": 2},
{"autofilter": True},
{"emulate_jpeg_size": True},
{"filter_sharpness": 7},
{"filter_strength": 0},
{"filter_type": 0},
{"low_memory": True},
{"partition_limit": 100},
{"partitions": 3},
{"preprocessing": 1},
{"segments": 1},
{"sns_strength": 0},
{"thread_level": 1},
# Options that do nothing without `pass` == 1
{"target_psnr": 40.0, "pass": 10},
{"target_size": 2000, "pass": 10},
),
)
def test_advanced_options(self, options: dict[str, Any]) -> None:
buffer = io.BytesIO()
hopper().save(buffer, format="WEBP", **options)

with Image.open(buffer) as reloaded:
assert reloaded.size == (128, 128)
reloaded.load()

def test_advanced_options_change_output(self) -> None:
buffer_no_args = io.BytesIO()
hopper().save(buffer_no_args, format="WEBP")

buffer_args = io.BytesIO()
hopper().save(buffer_args, format="WEBP", sns_strength=0, filter_strength=0)

assert buffer_no_args.getbuffer() != buffer_args.getbuffer()

@pytest.mark.parametrize("preset", sorted(WebPImagePlugin._PRESETS))
def test_preset(self, preset: str) -> None:
buffer = io.BytesIO()
hopper().save(buffer, format="WEBP", preset=preset)

with Image.open(buffer) as reloaded:
assert reloaded.size == (128, 128)
reloaded.load()

def test_invalid(self) -> None:
with pytest.raises(ValueError, match="Unknown preset"):
hopper().save(io.BytesIO(), format="WEBP", preset="invalid")

with pytest.raises(ValueError, match="WebP configuration validation failed"):
hopper().save(io.BytesIO(), format="WEBP", segments=99)

with pytest.raises(TypeError):
hopper().save(io.BytesIO(), format="WEBP", sns_strength="invalid")

@pytest.mark.parametrize(
"options",
[{}, {"preset": "photo", "sns_strength": 0}],
ids=["no-options", "advanced-options"],
)
def test_save_all(self, tmp_path: Path, options: dict[str, Any]) -> None:
temp_file = tmp_path / "temp.webp"
im = Image.new("RGB", (1, 1))
im2 = Image.new("RGB", (1, 1), "#f00")
im.save(temp_file, save_all=True, append_images=[im2])
im.save(temp_file, save_all=True, append_images=[im2], **options)

with Image.open(temp_file) as reloaded:
assert_image_equal(im, reloaded)
Expand All @@ -134,7 +197,7 @@ def test_save_all(self, tmp_path: Path) -> None:
def test_unsupported_image_mode(self) -> None:
im = Image.new("1", (1, 1))
with pytest.raises(ValueError):
_webp.WebPEncode(im.getim(), False, 0, 0, "", 4, 0, b"", "")
_webp.WebPEncode(im.getim(), "", b"", "", {})

def test_icc_profile(self, tmp_path: Path) -> None:
self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None})
Expand Down
107 changes: 105 additions & 2 deletions docs/handbook/image-file-formats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,7 @@ using the general tags available through tiffinfo.
WebP
^^^^

Pillow reads and writes WebP files. Requires libwebp v0.5.0 or later.
Pillow reads and writes WebP files. Requires libwebp v1.2.0 or later.

.. _webp-saving:

Expand All @@ -1383,6 +1383,17 @@ Saving

The :py:meth:`~PIL.Image.Image.save` method supports the following options:

**preset**
A predefined set of encoding parameters for a type of source picture.
It is applied before any of the other options here. One of:

* ``"default"`` - the default preset
* ``"picture"`` - digital picture, like portrait, inner shot
* ``"photo"`` - outdoor photograph, with natural lighting
* ``"drawing"`` - hand or line drawing, with high-contrast details
* ``"icon"`` - small-sized colorful images
* ``"text"`` - text-like

**lossless**
If present and true, instructs the WebP writer to use lossless compression.

Expand All @@ -1397,7 +1408,8 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
smallest size and 100 is lossless.

**method**
Quality/speed trade-off (0=fast, 6=slower-better). Defaults to 4.
Quality/speed trade-off (0=fast, 6=slower-better).
Defaults to 4 for single images, 0 for animated WebPs.

**exact**
If true, preserve the transparent RGB values. Otherwise, discard
Expand All @@ -1412,6 +1424,97 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
**xmp**
The XMP data to include in the saved file.

.. _webp-advanced-saving:

Advanced saving options
~~~~~~~~~~~~~~~~~~~~~~~

The following options correspond to `libwebp's encoder
configuration <https://github.com/webmproject/libwebp/blob/main/src/webp/encode.h>`_.
The names, ranges and defaults below follow its ``WebPConfig`` struct,
which is the authoritative description of what these do.
Values outside the range accepted by libwebp raise :py:exc:`ValueError`.

**target_size**
If non-zero, the desired target size in bytes. Takes precedence over ``quality``.
This has no effect unless ``pass`` is greater than 1.

**target_psnr**
If non-zero, the minimal distortion to try to achieve, in dB.
Takes precedence over ``target_size``. Defaults to 0.
This has no effect unless ``pass`` is greater than 1.

**segments**
Maximum number of segments to use, in [1..4]. Defaults to 4.

**sns_strength**
Spatial noise shaping: 0 is off, 100 is maximum. Defaults to 50.

**filter_strength**
Range 0 (off) to 100 (strongest). Defaults to 60.

**filter_sharpness**
Range 0 (off) to 7 (least sharp). Defaults to 0.

**filter_type**
Filtering type: 0 for simple, 1 for strong.
Only used if ``filter_strength`` is greater than 0 or ``autofilter`` is on.
Defaults to 1.

**autofilter**
If true, automatically adjust the filter's strength. Defaults to false.

**alpha_compression**
Algorithm for encoding the alpha plane:
0 for none, 1 for compressed with WebP lossless. Defaults to 1.

**alpha_filtering**
Predictive filtering method for the alpha plane:
0 for none, 1 for fast, 2 for best. Defaults to 1.

**pass**
Number of entropy-analysis passes, in [1..10]. Defaults to 1.
Note that some options only have an effect when this is set to a value greater than 1.

**preprocessing**
Preprocessing filter:
0 for none, 1 for segment-smooth, 2 for pseudo-random dithering.
Defaults to 0.

**partitions**
Log2 of the number of token partitions, in [0..3].
Defaults to 0, for easier progressive decoding.

**partition_limit**
Quality degradation allowed to fit the 512k limit on prediction modes coding:
0 for no degradation, 100 for maximum possible degradation.
Defaults to 0.

**emulate_jpeg_size**
If true, compression parameters will be remapped to better match the
expected output size from JPEG compression.
Generally the output size will be similar, but the degradation will be lower.
Defaults to false.

**thread_level**
If non-zero, try and use multi-threaded encoding. Defaults to 0.

**low_memory**
If true, reduce memory usage, at the cost of higher CPU use.
Defaults to false.

**near_lossless**
Near-lossless encoding, from 0 (maximum loss) to 100 (off).
Only used for lossless encoding. Defaults to 100.

**use_sharp_yuv**
If true, use sharp (and slow) RGB to YUV conversion,
which only affects lossy encoding. Defaults to false.

**qmin**, **qmax**
The minimum and maximum permissible quality factor, clamping ``quality``.
Default to 0 and 100 respectively.

Saving sequences
~~~~~~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions docs/installation/building-from-source.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Many of Pillow's features require external libraries:

* **libwebp** provides the WebP format.

* Pillow requires libwebp **1.2.0** or later.

* **openjpeg** provides JPEG 2000 functionality.

* Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**,
Expand Down
97 changes: 67 additions & 30 deletions src/PIL/WebPImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@
b"VP8L": "RGBA", # lossless
}

# Advanced encoder settings passed through to WebPConfig
_ADVANCED_OPTIONS = (
"alpha_compression",
"alpha_filtering",
"autofilter",
"emulate_jpeg_size",
"filter_sharpness",
"filter_strength",
"filter_type",
"low_memory",
"near_lossless",
"partition_limit",
"partitions",
"pass",
"preprocessing",
"qmax",
"qmin",
"segments",
"sns_strength",
"target_psnr",
"target_size",
"thread_level",
"use_sharp_yuv",
)

# Per https://github.com/webmproject/libwebp/blob/9c4a699e5/src/webp/encode.h#L158-L167
_PRESETS = {
"default": 0,
"picture": 1,
"photo": 2,
"drawing": 3,
"icon": 4,
"text": 5,
}


def _accept(prefix: bytes) -> bool | str:
is_riff_file_format = prefix.startswith(b"RIFF")
Expand Down Expand Up @@ -156,6 +191,31 @@ def _convert_frame(im: Image.Image) -> Image.Image:
return im


def _get_encoder_options(
encoderinfo: dict[str, Any], *, default_method: int
) -> dict[str, Any]:
options: dict[str, Any] = {
"lossless": encoderinfo.get("lossless", False),
"quality": float(encoderinfo.get("quality", 80)),
"alpha_quality": int(encoderinfo.get("alpha_quality", 100)),
"method": encoderinfo.get("method", default_method),
"exact": 1 if encoderinfo.get("exact") else 0,
}
options.update(
{name: encoderinfo[name] for name in _ADVANCED_OPTIONS if name in encoderinfo}
)

preset = encoderinfo.get("preset")
if preset is not None:
try:
options["preset"] = _PRESETS[preset]
except KeyError:
msg = f"Unknown preset {preset!r}, expected one of {sorted(_PRESETS)}"
raise ValueError(msg) from None

return options


def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
encoderinfo = im.encoderinfo.copy()
append_images = list(encoderinfo.get("append_images", []))
Expand Down Expand Up @@ -191,17 +251,15 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
kmax = im.encoderinfo.get("kmax", None)
allow_mixed = im.encoderinfo.get("allow_mixed", False)
verbose = False
lossless = im.encoderinfo.get("lossless", False)
quality = im.encoderinfo.get("quality", 80)
alpha_quality = im.encoderinfo.get("alpha_quality", 100)
method = im.encoderinfo.get("method", 0)
options = _get_encoder_options(im.encoderinfo, default_method=0)
icc_profile = im.encoderinfo.get("icc_profile") or ""
exif = im.encoderinfo.get("exif", "")
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
xmp = im.encoderinfo.get("xmp", "")
if allow_mixed:
lossless = False
options["lossless"] = False
lossless = options["lossless"]

# Sensible keyframe defaults are from gif2webp.c script
if kmin is None:
Expand Down Expand Up @@ -249,14 +307,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
frame = _convert_frame(ims)

# Append the frame to the animation encoder
enc.add(
frame.getim(),
round(timestamp),
lossless,
quality,
alpha_quality,
method,
)
enc.add(frame.getim(), round(timestamp), options)

# Update timestamp and frame index
if isinstance(duration, (list, tuple)):
Expand All @@ -269,7 +320,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
im.seek(cur_idx)

# Force encoder to flush frames
enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0)
enc.add(None, round(timestamp), options)

# Get the final output from the encoder
data = enc.assemble(icc_profile, exif, xmp)
Expand All @@ -281,32 +332,18 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:


def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
lossless = im.encoderinfo.get("lossless", False)
quality = im.encoderinfo.get("quality", 80)
alpha_quality = im.encoderinfo.get("alpha_quality", 100)
options = _get_encoder_options(im.encoderinfo, default_method=4)
icc_profile = im.encoderinfo.get("icc_profile") or ""
exif = im.encoderinfo.get("exif", b"")
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
if exif.startswith(b"Exif\x00\x00"):
exif = exif[6:]
xmp = im.encoderinfo.get("xmp", "")
method = im.encoderinfo.get("method", 4)
exact = 1 if im.encoderinfo.get("exact") else 0

im = _convert_frame(im)

data = _webp.WebPEncode(
im.getim(),
lossless,
float(quality),
float(alpha_quality),
icc_profile,
method,
exact,
exif,
xmp,
)
data = _webp.WebPEncode(im.getim(), icc_profile, exif, xmp, options)
if data is None:
msg = "cannot write file as WebP (encoder returned None)"
raise OSError(msg)
Expand Down
Loading
Loading