Merge branch 'webp-support'

This commit is contained in:
Fabien LOISON 2021-04-16 10:19:56 +02:00
commit 8f4d1de706
No known key found for this signature in database
GPG Key ID: FF90CA148348048E
27 changed files with 775 additions and 99 deletions

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
ignore = E203, E241, W503, E501

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ _*.c*
*.dylib
.vscode
.pytest_cache/
*.tags

View File

@ -8,11 +8,11 @@ YOGA - Yummy Optimizer for Gorgeous Assets
**YOGA** is a command-line tool and a library that can:
* convert and optimize images from various format to JPEG and PNG,
* convert and optimize images from various format to JPEG, PNG and WEBP,
* convert and optimize 3D models from various formats to `glTF and GLB`_.
**Images** are opened using Pillow_ and optimized using Guetzli_ (for JPEGs) and
Zopflipng_ (for PNGs).
**Images** are opened using Pillow_ and optimized using Guetzli_ (for JPEGs),
Zopflipng_ (for PNGs) and libwebp_ (for WEBP).
**3D Models** are converted and optimized using assimp_. If models contain or
reference images, they are processed by YOGA's image optimizer.
@ -33,6 +33,7 @@ EXAMPLE: Converting and optimizing a 3D model from CLI::
.. _Pillow: https://github.com/python-pillow/Pillow
.. _Guetzli: https://github.com/google/guetzli
.. _Zopflipng: https://github.com/google/zopfli
.. _libwebp: https://chromium.googlesource.com/webm/libwebp/
.. _assimp: https://github.com/assimp/assimp

View File

@ -16,11 +16,12 @@ YOGA Image Command Line Interface
-h, --help show this help message and exit
-v, --verbose enable verbose mode
-q, --quiet enable quiet mode (takes precedence over verbose)
--output-format {orig,auto,jpeg,png}
--output-format {orig,auto,jpeg,png,webp,webpl}
format of the output image
--resize {orig,<SIZE>,<WIDTH>x<HEIGHT>}
resize the image
--jpeg-quality 0-100 JPEG quality if the output format is set to 'jpeg'
--webp-quality 0-100 WEBP quality if the output format is set to 'webp'
--opacity-threshold 0-255
threshold below which a pixel is considered transparent
@ -34,9 +35,9 @@ The simplest way to optimize an image is by using the following command::
.. NOTE::
When the output format is not specified, YOGA outputs an image using the same format as the input one (``PNG````PNG``, ``JPEG````JPEG``).
When the output format is not specified, YOGA outputs an image using the same format as the input one (``PNG````PNG``, ``JPEG````JPEG``, ``WEBP````WEBP``, ``WEBP (lossless)````WEBP (lossless)``).
Only PNGs and JPEGs are supported as input when the output format is not explicitly specified.
Only PNGs, JPEGs and WEBPs are supported as input when the output format is not explicitly specified.
Output Format
@ -52,10 +53,12 @@ The following formats are supported:
* ``auto``: The output format is automatically selected. YOGA will generate a PNG if the input image is using transparency, else it will generate a JPEG.
* ``png``: Outputs a PNG image.
* ``jpeg``: Outputs a JPEG image.
* ``webp``: Outputs a lossy WEBP image.
* ``webpl``: Outputs a lossless WEBP image.
.. NOTE::
When using the ``"orig"`` output format, YOGA will only accept PNGs and JPEGs images as input.
When using the ``"orig"`` output format, YOGA will only accept PNG, JPEG and WEBP images as input.
Resize Output Image
@ -94,6 +97,25 @@ The default JPEG quality is ``84%``.
This option has effect only when the output image is a JPEG.
Output WEBP Quality
-------------------
YOGA allows you to tune the desired quality of the WEBP it outputs with the ``--webp-quality`` option. This option takes an integer between ``0`` and ``100`` as parameter:
* ``0``: ugly images but smaller files,
* ``100``: best quality images but larger files.
The default WEBP quality is ``90%``.
::
yoga image --output-format=webp --webp-quality=90 input.png output.webp
.. NOTE::
This option has effect only when the output image is a lossy WEBP.
Opacity Threshold
-----------------

View File

@ -8,11 +8,11 @@ Welcome to YOGA's documentation!
**YOGA** is a command-line tool and a library that can:
* convert and optimize images from various format to JPEG and PNG,
* convert and optimize images from various format to JPEG, PNG and WEBP,
* convert and optimize 3D models from various formats to `glTF and GLB`_.
**Images** are opened using Pillow_ and optimized using Guetzli_ (for JPEGs) and
Zopflipng_ (for PNGs).
**Images** are opened using Pillow_ and optimized using Guetzli_ (for JPEGs),
Zopflipng_ (for PNGs) and libwebp_ (for WEBP).
**3D Models** are converted and optimized using assimp_. If models contain or
reference images, they are processed by YOGA's image optimizer.
@ -21,6 +21,7 @@ reference images, they are processed by YOGA's image optimizer.
.. _Pillow: https://github.com/python-pillow/Pillow
.. _Guetzli: https://github.com/google/guetzli
.. _Zopflipng: https://github.com/google/zopfli
.. _libwebp: https://chromium.googlesource.com/webm/libwebp/
.. _assimp: https://github.com/assimp/assimp
.. toctree::

View File

@ -9,21 +9,28 @@ PYTHON_FILES = [
]
@nox.session
@nox.session(reuse_venv=True)
def lint(session):
session.install("flake8", "black")
session.run("flake8", *PYTHON_FILES)
session.run("black", "--line-length=79", "--check", *PYTHON_FILES)
session.run(
"black",
"--line-length=79",
"--check",
"--diff",
"--color",
*PYTHON_FILES,
)
@nox.session(python=["2.7", "3.7", "3.8", "3.9"])
@nox.session(python=["2.7", "3.7", "3.8", "3.9"], reuse_venv=True)
def test(session):
session.install("pytest")
session.install(".")
session.run("pytest", "-v", "test")
@nox.session
@nox.session(reuse_venv=True)
def gendoc(session):
session.install("sphinx", "sphinx-rtd-theme")
session.install(".")

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[tool.black]
line-length = 79
target-version = ['py27']

42
scripts/lsriff.py Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python
import sys
from yoga.image.encoders.webp import get_riff_structure, get_vp8x_info
def print_riff_info(input_path):
image = open(input_path, "rb").read()
riff = get_riff_structure(image)
print("+-- %s" % input_path)
print(
" +-- RIFF [size: %i, formtype: %s]"
% (riff["size"], riff["formtype"])
)
for chunk in riff["chunks"]:
print(
" +-- %s [offset: %i, size: %i]"
% (chunk["type"], chunk["data_offset"], chunk["size"])
)
if chunk["type"] == "VP8X":
vp8x_info = get_vp8x_info(
image[
chunk["data_offset"] : chunk["data_offset"] + chunk["size"]
]
)
for key, value in vp8x_info.items():
print(" +-- %s: %i" % (key, value))
if __name__ == "__main__":
if len(sys.argv) < 2:
print("USAGE:")
print(
" ./scripts/lsriff.py <image.webp> [image2.webp [image3.webp [...]]]"
)
sys.exit(1)
for input_path in sys.argv[1:]:
print_riff_info(input_path)

View File

@ -77,7 +77,7 @@ setup(
url="https://github.com/wanadev/yoga",
license="BSD-3-Clause",
long_description=long_description,
keywords="image jpeg png optimizer guetzli zopfli 3d model mesh assimp gltf glb converter", # noqa
keywords="image webp jpeg png optimizer guetzli zopfli zopflipng libwebp 3d model mesh assimp gltf glb converter",
author="Wanadev",
author_email="contact@wanadev.fr",
maintainer="Fabien LOISON, Alexis BREUST",

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
test/images/animated.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -5,10 +5,10 @@ import pytest
from PIL import Image
import yoga.image
_MAGIC_PNG = b"\x89PNG\r\n"
_MAGIC_JPEG = b"\xFF\xD8\xFF\xE0"
from yoga.image.encoders.jpeg import is_jpeg
from yoga.image.encoders.png import is_png
from yoga.image.encoders.webp import is_lossy_webp
from yoga.image.encoders.webp_lossless import is_lossless_webp
class Test_optimize(object):
@ -24,13 +24,13 @@ class Test_optimize(object):
output = io.BytesIO()
yoga.image.optimize(input_, output)
output.seek(0)
assert output.read().startswith(_MAGIC_PNG)
assert is_png(output.read())
def test_output_path(self, tmpdir):
output_path = os.path.join(str(tmpdir), "output1.png")
yoga.image.optimize("test/images/alpha.png", output_path)
output = open(output_path, "rb")
assert output.read().startswith(_MAGIC_PNG)
assert is_png(output.read())
def test_output_file(self, tmpdir):
output_path = os.path.join(str(tmpdir), "output2.png")
@ -38,48 +38,54 @@ class Test_optimize(object):
yoga.image.optimize("test/images/alpha.png", output)
output.close()
output = open(output_path, "rb")
assert output.read().startswith(_MAGIC_PNG)
assert is_png(output.read())
def test_output_bytesio(self):
output = io.BytesIO()
yoga.image.optimize("test/images/alpha.png", output)
output.seek(0)
assert output.read().startswith(_MAGIC_PNG)
assert is_png(output.read())
@pytest.mark.parametrize(
"image_path,magic",
"image_path,format_checker",
[
("test/images/image1.jpg", _MAGIC_JPEG),
("test/images/unused-alpha.png", _MAGIC_PNG),
("test/images/image1.jpg", is_jpeg),
("test/images/unused-alpha.png", is_png),
("test/images/alpha.lossy.webp", is_lossy_webp),
("test/images/alpha.lossless.webp", is_lossless_webp),
],
)
def test_option_output_format_default(self, image_path, magic):
def test_option_output_format_default(self, image_path, format_checker):
output = io.BytesIO()
yoga.image.optimize(image_path, output)
output.seek(0)
assert output.read().startswith(magic)
assert format_checker(output.read())
@pytest.mark.parametrize(
"image_path,format_,magic",
"image_path,format_,format_checker",
[
# fmt: off
("test/images/image1.jpg", "orig", _MAGIC_JPEG),
("test/images/unused-alpha.png", "orig", _MAGIC_PNG),
("test/images/alpha.png", "auto", _MAGIC_PNG),
("test/images/unused-alpha.png", "auto", _MAGIC_JPEG),
("test/images/image1.jpg", "auto", _MAGIC_JPEG),
("test/images/image1.jpg", "jpeg", _MAGIC_JPEG),
("test/images/unused-alpha.png", "jpeg", _MAGIC_JPEG),
("test/images/image1.jpg", "png", _MAGIC_PNG),
("test/images/unused-alpha.png", "png", _MAGIC_PNG),
("test/images/image1.jpg", "orig", is_jpeg),
("test/images/unused-alpha.png", "orig", is_png),
("test/images/alpha.png", "auto", is_png),
("test/images/unused-alpha.png", "auto", is_jpeg),
("test/images/image1.jpg", "auto", is_jpeg),
("test/images/image1.jpg", "jpeg", is_jpeg),
("test/images/unused-alpha.png", "jpeg", is_jpeg),
("test/images/image1.jpg", "png", is_png),
("test/images/unused-alpha.png", "png", is_png),
("test/images/alpha.lossy.webp", "webp", is_lossy_webp),
("test/images/alpha.lossy.webp", "orig", is_lossy_webp),
("test/images/alpha.lossless.webp", "webpl", is_lossless_webp),
("test/images/alpha.lossless.webp", "orig", is_lossless_webp),
# fmt: on
],
)
def test_option_output_format(self, image_path, format_, magic):
def test_option_output_format(self, image_path, format_, format_checker):
output = io.BytesIO()
yoga.image.optimize(image_path, output, {"output_format": format_})
output.seek(0)
assert output.read().startswith(magic)
assert format_checker(output.read())
def test_option_output_format_orig_with_unsuported_output_format(self):
output = io.BytesIO()
@ -134,6 +140,21 @@ class Test_optimize(object):
assert len(output2.read()) < len(output1.read())
def test_webp_quality(self):
output1 = io.BytesIO()
yoga.image.optimize(
"test/images/alpha.lossy.webp", output1, {"webp_quality": 1.00}
)
output1.seek(0)
output2 = io.BytesIO()
yoga.image.optimize(
"test/images/alpha.lossy.webp", output2, {"webp_quality": 0.50}
)
output2.seek(0)
assert len(output2.read()) < len(output1.read())
@pytest.mark.skip(reason="Requires output_format=auto")
def test_opacity_threshold(self):
raise NotImplementedError() # TODO

View File

@ -0,0 +1,189 @@
from PIL import Image
import pytest
from yoga.image.encoders import webp
class Test_little_endian_unint32_bytes_to_python_int(object):
def test_uint32_value(self):
assert (
webp.little_endian_unint32_bytes_to_python_int(b"\x78\x56\x34\x12")
== 305419896
)
class Test_get_riff_structure(object):
@pytest.fixture
def webp_image(self):
return open("test/images/alpha.lossless.metadata.webp", "rb").read()
def test_riff_structure(object, webp_image):
riff = webp.get_riff_structure(webp_image)
assert riff["formtype"] == "WEBP"
assert riff["size"] == 11868
assert len(riff["chunks"]) == 5
assert riff["chunks"][0]["type"] == "VP8X"
assert riff["chunks"][1]["type"] == "ICCP"
assert riff["chunks"][2]["type"] == "VP8L"
assert riff["chunks"][3]["type"] == "EXIF"
assert riff["chunks"][4]["type"] == "XMP "
class Test_get_vp8x_info(object):
def test_flag_icc(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_icc"] is False
vp8x_info = webp.get_vp8x_info(
b"\x20\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_icc"] is True
def test_flag_alpha(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_alpha"] is False
vp8x_info = webp.get_vp8x_info(
b"\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_alpha"] is True
def test_flag_exif(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_exif"] is False
vp8x_info = webp.get_vp8x_info(
b"\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_exif"] is True
def test_flag_xmp(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_xmp"] is False
vp8x_info = webp.get_vp8x_info(
b"\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_xmp"] is True
def test_flag_animation(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_anim"] is False
vp8x_info = webp.get_vp8x_info(
b"\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
assert vp8x_info["has_anim"] is True
def test_canvas_width(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\xAA\xBB\xCC\x00\x00\x00"
)
assert vp8x_info["canvas_width"] == 0xCCBBAA + 1
def test_canvas_height(self):
vp8x_info = webp.get_vp8x_info(
b"\x00\x00\x00\x00\x00\x00\x00\xAA\xBB\xCC"
)
assert vp8x_info["canvas_height"] == 0xCCBBAA + 1
class Test_is_lossy_webp(object):
def test_with_lossy_webp(object):
image_bytes = open("test/images/alpha.lossy.webp", "rb").read()
assert webp.is_lossy_webp(image_bytes) is True
def test_with_lossless_webp(object):
image_bytes = open("test/images/alpha.lossless.webp", "rb").read()
assert webp.is_lossy_webp(image_bytes) is False
def test_with_png(object):
image_bytes = open("test/images/alpha.png", "rb").read()
assert webp.is_lossy_webp(image_bytes) is False
class Test_encode_lossy_webp(object):
@pytest.mark.parametrize(
"image_path",
[
"test/images/image1.jpg",
"test/images/indexed.png",
"test/images/grayscale.png",
],
)
def test_no_alpha(self, image_path):
input_image = Image.open(image_path)
output_image_bytes = webp.optimize_lossy_webp(input_image, 0.90)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8 "]
def test_unused_alpha(self):
input_image = Image.open("test/images/unused-alpha.png")
output_image_bytes = webp.optimize_lossy_webp(input_image, 0.90)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8 "]
@pytest.mark.parametrize(
"image_path",
[
"test/images/alpha.png",
"test/images/threshold.png",
],
)
def test_alpha(self, image_path):
input_image = Image.open(image_path)
output_image_bytes = webp.optimize_lossy_webp(input_image, 0.90)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8X", "VP8 ", "ALPH"]
# Checks that the ALPH (alpha channel) chunk is present in the file
alph_chunk_found = False
for chunk in riff["chunks"]:
if chunk["type"] == "ALPH":
alph_chunk_found = True
assert alph_chunk_found
@pytest.mark.parametrize(
"image_path",
[
"test/images/alpha.png",
"test/images/threshold.png",
],
)
def test_alpha_vp8x_flags(self, image_path):
input_image = Image.open(image_path)
output_image_bytes = webp.optimize_lossy_webp(input_image, 0.90)
riff = webp.get_riff_structure(output_image_bytes)
vp8x_chunk = [c for c in riff["chunks"] if c["type"] == "VP8X"][0]
vp8x_data = output_image_bytes[
vp8x_chunk["data_offset"] : vp8x_chunk["data_offset"]
+ vp8x_chunk["size"]
]
vp8x_info = webp.get_vp8x_info(vp8x_data)
assert vp8x_info["has_alpha"] is True
assert vp8x_info["has_icc"] is False
assert vp8x_info["has_exif"] is False
assert vp8x_info["has_xmp"] is False
assert vp8x_info["has_anim"] is False
def test_qualiy(self):
input_image = Image.open("test/images/alpha.png")
output_image_bytes_100 = webp.optimize_lossy_webp(input_image, 1.00)
output_image_bytes_50 = webp.optimize_lossy_webp(input_image, 0.50)
assert len(output_image_bytes_50) < len(output_image_bytes_100)

View File

@ -0,0 +1,63 @@
from PIL import Image
import pytest
from yoga.image.encoders import webp
from yoga.image.encoders import webp_lossless
class Test_is_lossless_webp(object):
def test_with_lossy_webp(object):
image_bytes = open("test/images/alpha.lossy.webp", "rb").read()
assert webp_lossless.is_lossless_webp(image_bytes) is False
def test_with_lossless_webp(object):
image_bytes = open("test/images/alpha.lossless.webp", "rb").read()
assert webp_lossless.is_lossless_webp(image_bytes) is True
def test_with_png(object):
image_bytes = open("test/images/alpha.png", "rb").read()
assert webp_lossless.is_lossless_webp(image_bytes) is False
class Test_encode_lossless_webp(object):
@pytest.mark.parametrize(
"image_path",
[
"test/images/image1.jpg",
"test/images/indexed.png",
"test/images/grayscale.png",
],
)
def test_no_alpha(self, image_path):
input_image = Image.open(image_path)
output_image_bytes = webp_lossless.optimize_lossless_webp(input_image)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8L"]
def test_unused_alpha(self):
input_image = Image.open("test/images/unused-alpha.png")
output_image_bytes = webp_lossless.optimize_lossless_webp(input_image)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8L"]
@pytest.mark.parametrize(
"image_path",
[
"test/images/alpha.png",
"test/images/threshold.png",
],
)
def test_alpha(self, image_path):
input_image = Image.open(image_path)
output_image_bytes = webp_lossless.optimize_lossless_webp(input_image)
riff = webp.get_riff_structure(output_image_bytes)
# Checks there is only wanted chunks in the file
for chunk in riff["chunks"]:
assert chunk["type"] in ["VP8L"]

View File

@ -37,12 +37,15 @@ class Test_image_have_alpha(object):
assert not helpers.image_have_alpha(image, threshold)
class Test_gess_image_format(object):
class Test_guess_image_format(object):
@pytest.mark.parametrize(
"image_path, expected_format",
[
("test/images/image1.jpg", "jpeg"),
("test/images/alpha.png", "png"),
("test/images/alpha.lossy.webp", "webp"),
("test/images/alpha.lossless.webp", "webpl"),
("test/images/alpha.lossless.metadata.webp", "webpl"),
],
)
def test_supported_image_format(self, image_path, expected_format):
@ -53,3 +56,8 @@ class Test_gess_image_format(object):
image_bytes = open("test/images/alpha.svg", "rb").read()
with pytest.raises(ValueError):
helpers.guess_image_format(image_bytes)
def test_unsuported_animated_webp(self):
image_bytes = open("test/images/animated.webp", "rb").read()
with pytest.raises(ValueError):
helpers.guess_image_format(image_bytes)

View File

@ -24,6 +24,8 @@ class Test_normalize_options(object):
("output_format", "AuTo", "auto"),
("output_format", "jpg", "jpeg"),
("output_format", "JPG", "jpeg"),
("output_format", "WEBP", "webp"),
("output_format", "WEBPL", "webpl"),
# resize
("resize", "orig", "orig"),
("resize", "OrIg", "orig"),
@ -39,6 +41,18 @@ class Test_normalize_options(object):
("jpeg_quality", u"0.42", 0.42),
("jpeg_quality", u".42", 0.42),
("jpeg_quality", u"42", 0.42),
# webp_quality
("webp_quality", 0.42, 0.42),
("webp_quality", 42, 0.42),
("webp_quality", "0.42", 0.42),
("webp_quality", ".42", 0.42),
("webp_quality", "42", 0.42),
("webp_quality", b"0.42", 0.42),
("webp_quality", b".42", 0.42),
("webp_quality", b"42", 0.42),
("webp_quality", u"0.42", 0.42),
("webp_quality", u".42", 0.42),
("webp_quality", u"42", 0.42),
# opacity_threshold
("opacity_threshold", 128, 128),
("opacity_threshold", 0.5, 128),

View File

@ -12,9 +12,10 @@ Converting and optimizing an image::
You can also tune the output by passing options::
yoga.image.optimize("./input.png", "./output.png", options={
"output_format": "orig", # "orig"|"auto"|"jpeg"|"png"
"output_format": "orig", # "orig"|"auto"|"jpeg"|"png"|"webp"|"webpl
"resize": "orig", # "orig"|[width,height]
"jpeg_quality": 0.84, # 0.00-1.0
"webp_quality": 0.90, # 0.00-1.0
"opacity_threshold": 254, # 0-255
})
@ -43,11 +44,13 @@ The following formats are supported:
PNG if the input image is using transparency, else it will generate a JPEG.
* ``png``: Outputs a PNG image.
* ``jpeg``: Outputs a JPEG image.
* ``webp`` Outputs a lossy WEBP image.
* ``webpl`` Outputs a lossless WEBP image.
.. NOTE::
When using the ``"orig"`` output format, YOGA will only accept PNGs and
JPEGs images as input.
When using the ``"orig"`` output format, YOGA will only accept PNG, JPEG and
WEBP images as input.
resize
@ -100,6 +103,28 @@ The value is a number between ``0.00`` and ``1.00`` (``0.84`` by default):
This option has effect only when the output image is a JPEG.
webp_quality
~~~~~~~~~~~~
The quality of the output WEBPs.
The value is a number between ``0.00`` and ``1.00`` (``0.90`` by default):
* ``0.00``: ugly images but smaller files,
* ``1.00``: best quality images but larger files.
::
yoga.image.optimize("./input.png", "./output.webp", options={
"output_format": "webp",
"webp_quality": 0.90,
})
.. NOTE::
This option has effect only when the output image is a lossy WEBP.
opacity_threshold
~~~~~~~~~~~~~~~~~
@ -123,14 +148,14 @@ API
---
"""
import io
from PIL import Image
import pyguetzli
import zopfli
from .encoders.jpeg import optimize_jpeg
from .encoders.png import optimize_png
from .encoders.webp import optimize_lossy_webp
from .encoders.webp_lossless import optimize_lossless_webp
from .options import normalize_options
from .helpers import image_have_alpha
from . import helpers
def optimize(input_file, output_file, options={}, verbose=False, quiet=False):
@ -144,58 +169,53 @@ def optimize(input_file, output_file, options={}, verbose=False, quiet=False):
"""
options = normalize_options(options)
image = Image.open(input_file)
# Image as file-like object
if type(input_file) in (str, type(u"")):
image_file = open(input_file, "rb")
elif hasattr(input_file, "read") and hasattr(input_file, "seek"):
image_file = input_file
else:
raise ValueError("Unsupported parameter type for 'input_file'")
if options["output_format"] == "orig" and image.format not in (
"JPEG",
"PNG",
):
raise ValueError(
"The input image must be a JPEG or a PNG when setting 'output_format' to 'orig'" # noqa: E501
)
# Open the image with Pillow
image = Image.open(image_file)
# resize
# Resize image if requested
if options["resize"] != "orig":
image.thumbnail(options["resize"], Image.LANCZOS)
# output format
output_format = None
# Output format
if options["output_format"] == "orig":
output_format = image.format.lower()
elif options["output_format"] in ("jpeg", "png"):
output_format = options["output_format"]
else: # auto
if image_have_alpha(image, options["opacity_threshold"]):
image_file.seek(0) # PIL.Image already read the file
output_format = helpers.guess_image_format(image_file.read())
elif options["output_format"] == "auto":
if helpers.image_have_alpha(image, options["opacity_threshold"]):
output_format = "png"
else:
# XXX Maybe we should try to encode in both format
# and choose the smaller output?
# XXX Maybe we should try to encode in both format and choose the
# smaller output?
output_format = "jpeg"
# convert / optimize
output_image_bytes = None
if output_format == "jpeg":
output_image_bytes = pyguetzli.process_pil_image(
image, int(options["jpeg_quality"] * 100)
)
else:
image_io = io.BytesIO()
image.save(image_io, format="PNG", optimize=False)
image_io.seek(0)
image_bytes = image_io.read()
output_format = options["output_format"]
# Optimize using zopflipng
zopflipng = zopfli.ZopfliPNG()
zopflipng.lossy_8bit = True
zopflipng.lossy_transparent = True
zopflipng.filter_strategies = "01234mepb"
zopflipng.iterations = 20
zopflipng.iterations_large = 7
output_image_bytes = zopflipng.optimize(image_bytes)
# Convert / Optimize
if output_format == "jpeg":
output_image_bytes = optimize_jpeg(image, options["jpeg_quality"])
elif output_format == "png":
output_image_bytes = optimize_png(image)
elif output_format == "webp":
output_image_bytes = optimize_lossy_webp(
image, options["webp_quality"]
)
elif output_format == "webpl":
output_image_bytes = optimize_lossless_webp(image)
# write to output_file
# Write to output_file
if not hasattr(output_file, "write"):
output_file = open(output_file, "wb")
output_file.write(output_image_bytes)
# Close input file if we opened it
if type(input_file) in (str, type(u"")):
image_file.close()

View File

@ -40,8 +40,8 @@ def add_image_cli_options(parser, prefix=""):
parser.add_argument(
"--%soutput-format" % prefix,
help="format of the output image",
metavar="{orig,auto,jpeg,png}",
choices=["orig", "auto", "jpeg", "jpg", "png"],
metavar="{orig,auto,jpeg,png,webp,webpl}",
choices=["orig", "auto", "jpeg", "jpg", "png", "webp", "webpl"],
default=DEFAULT_OPTIONS["output_format"],
)
parser.add_argument(
@ -58,6 +58,13 @@ def add_image_cli_options(parser, prefix=""):
type=partial(_type_range, 0, 100),
default=DEFAULT_OPTIONS["jpeg_quality"],
)
parser.add_argument(
"--%swebp-quality" % prefix,
help="WEBP quality if the output format is set to 'webp'",
metavar="0-100",
type=partial(_type_range, 0, 100),
default=DEFAULT_OPTIONS["webp_quality"],
)
parser.add_argument(
"--%sopacity-threshold" % prefix,
help="threshold below which a pixel is considered transparent",

View File

View File

@ -0,0 +1,34 @@
import pyguetzli
def is_jpeg(file_bytes):
"""Whether or not the given bytes represent a JPEG file.
:params bytes file_bytes: The bytes of the file to check.
:rtype: bool
:return: ``True`` if the bytes represent a JPEG file, ``False`` else.
"""
JPEG_MAGICS = [
b"\xFF\xD8\xFF\xDB",
b"\xFF\xD8\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00\x01", # JFIF format
b"\xFF\xD8\xFF\xEE",
b"\xFF\xD8\xFF\xE1", # xx xx 45 78 69 66 00 00 / Exif format
]
for magic in JPEG_MAGICS:
if file_bytes.startswith(magic):
return True
return False
def optimize_jpeg(image, quality):
"""Encode image to JPEG using Guetzli.
:param PIL.Image image: The image to encode.
:param float quality: The output JPEG quality (from ``0.00``to ``1.00``).
:returns: The encoded image's bytes.
"""
if not 0.00 <= quality <= 1.00:
raise ValueError("JPEG quality value must be between 0.00 and 1.00")
return pyguetzli.process_pil_image(image, int(quality * 100))

View File

@ -0,0 +1,38 @@
import io
import zopfli
def is_png(file_bytes):
"""Whether or not the given bytes represent a PNG file.
:params bytes file_bytes: The bytes of the file to check.
:rtype: bool
:return: ``True`` if the bytes represent a PNG file, ``False`` else.
"""
return file_bytes.startswith(b"\x89PNG\r\n")
def optimize_png(image):
"""Encode image to PNG using ZopfliPNG.
:param PIL.Image image: The image to encode.
:returns: The encoded image's bytes.
"""
# Export the image as a PNG file-like bytes
image_io = io.BytesIO()
image.save(image_io, format="PNG", optimize=False)
image_io.seek(0)
image_bytes = image_io.read()
# Optimize using ZopfliPNG
zopflipng = zopfli.ZopfliPNG()
zopflipng.lossy_8bit = True
zopflipng.lossy_transparent = True
zopflipng.filter_strategies = "01234mepb"
zopflipng.iterations = 20
zopflipng.iterations_large = 7
return zopflipng.optimize(image_bytes)

127
yoga/image/encoders/webp.py Normal file
View File

@ -0,0 +1,127 @@
import io
import sys
import struct
def little_endian_unint32_bytes_to_python_int(bytes_):
return struct.unpack("<L", bytes_)[0]
def get_riff_structure(data):
if data[0:4] != b"RIFF":
raise ValueError("Unvalid RIFF: Not a RIFF file")
result = {
"formtype": data[8:12].decode(),
"size": little_endian_unint32_bytes_to_python_int(data[4:8]),
"chunks": [],
}
if result["size"] + 8 != len(data):
raise ValueError("Unvalid RIFF: Truncated data")
offset = 12 # RIFF header length
while offset < len(data):
chunk = {
"type": data[offset : offset + 4].decode(),
"data_offset": offset + 8,
"size": little_endian_unint32_bytes_to_python_int(
data[offset + 4 : offset + 8]
),
}
result["chunks"].append(chunk)
offset += 8 + chunk["size"] + chunk["size"] % 2
return result
def get_vp8x_info(data):
# fmt: off
# RRILEXAR
VP8X_FLAG_ICC = 0b00100000 # noqa: E221
VP8X_FLAG_ALPHA = 0b00010000 # noqa: E221
VP8X_FLAG_EXIF = 0b00001000 # noqa: E221
VP8X_FLAG_XMP = 0b00000100 # noqa: E221
VP8X_FLAG_ANIM = 0b00000010 # noqa: E221
# fmt: on
if len(data) != 10:
ValueError("Invaild VP8X data")
# TODO Remove this once Python 2.7 support is dropped
if sys.version_info.major == 2:
_py27_str_to_int_fix = ord
else:
_py27_str_to_int_fix = lambda b: b # noqa: E731
return {
"has_icc": bool(_py27_str_to_int_fix(data[0]) & VP8X_FLAG_ICC),
"has_alpha": bool(_py27_str_to_int_fix(data[0]) & VP8X_FLAG_ALPHA),
"has_exif": bool(_py27_str_to_int_fix(data[0]) & VP8X_FLAG_EXIF),
"has_xmp": bool(_py27_str_to_int_fix(data[0]) & VP8X_FLAG_XMP),
"has_anim": bool(_py27_str_to_int_fix(data[0]) & VP8X_FLAG_ANIM),
"canvas_width": little_endian_unint32_bytes_to_python_int(
data[4:7] + b"\x00"
)
+ 1,
"canvas_height": little_endian_unint32_bytes_to_python_int(
data[7:10] + b"\x00"
)
+ 1,
}
def is_riff(file_bytes):
"""Whether or not the given bytes represent a RIFF file.
:params bytes file_bytes: The bytes of the file to check.
:rtype: bool
:return: ``True`` if the bytes represent a RIFF file, ``False`` else.
"""
return file_bytes.startswith(b"RIFF")
def is_lossy_webp(file_bytes):
"""Whether or not the given bytes represent a lossy WEBP file.
:params bytes file_bytes: The bytes of the file to check.
:rtype: bool
:return: ``True`` if the bytes represent a lossy WEBP file, ``False`` else.
"""
if not is_riff(file_bytes):
return False
riff = get_riff_structure(file_bytes)
if riff["formtype"] == "WEBP":
chunks = [chunk["type"] for chunk in riff["chunks"]]
if "VP8 " in chunks:
return True
return False
def optimize_lossy_webp(image, quality):
"""Encode image to lossy WEBP using Pillow.
:param PIL.Image image: The image to encode.
:param float quality: The output WEBP quality (from ``0.00``to ``1.00``).
:returns: The encoded image's bytes.
"""
if not 0.00 <= quality <= 1.00:
raise ValueError("WEBP quality value must be between 0.00 and 1.00")
image_io = io.BytesIO()
image.save(
image_io,
format="WEBP",
lossless=False,
quality=int(quality * 100),
method=6,
)
image_io.seek(0)
return image_io.read()

View File

@ -0,0 +1,45 @@
import io
from .webp import is_riff
from .webp import get_riff_structure
def is_lossless_webp(file_bytes):
"""Whether or not the given bytes represent a lossless WEBP file.
:params bytes file_bytes: The bytes of the file to check.
:rtype: bool
:return: ``True`` if the bytes represent a lossless WEBP file, ``False``
else.
"""
if not is_riff(file_bytes):
return False
riff = get_riff_structure(file_bytes)
if riff["formtype"] == "WEBP":
chunks = [chunk["type"] for chunk in riff["chunks"]]
if "VP8L" in chunks:
return True
return False
def optimize_lossless_webp(image):
"""Encode image to lossless WEBP using Pillow.
:param PIL.Image image: The image to encode.
:returns: The encoded image's bytes.
"""
image_io = io.BytesIO()
image.save(
image_io,
format="WEBP",
lossless=True,
quality=100,
method=6,
)
image_io.seek(0)
return image_io.read()

View File

@ -1,3 +1,9 @@
from .encoders.jpeg import is_jpeg
from .encoders.png import is_png
from .encoders.webp import is_lossy_webp
from .encoders.webp_lossless import is_lossless_webp
def image_have_alpha(image, threshold=0xFE):
if threshold <= 0:
return False
@ -11,10 +17,16 @@ def image_have_alpha(image, threshold=0xFE):
return False
def guess_image_format(image_initial_bytes):
if image_initial_bytes.startswith(b"\xFF\xD8\xFF\xE0"):
return "jpeg"
elif image_initial_bytes.startswith(b"\x89PNG\r\n"):
return "png"
else:
raise ValueError("Unsupported image format")
def guess_image_format(image_bytes):
FORMATS = {
"jpeg": is_jpeg,
"png": is_png,
"webp": is_lossy_webp,
"webpl": is_lossless_webp,
}
for format_, checker in FORMATS.items():
if checker(image_bytes):
return format_
raise ValueError("Unsupported image format")

View File

@ -3,9 +3,10 @@ import re
# fmt: off
DEFAULT_OPTIONS = {
"output_format": "orig", # orig|auto|jpeg|png
"output_format": "orig", # orig|auto|jpeg|png|webp|webpl
"resize": "orig", # orig|[w,h]
"jpeg_quality": 0.84, # 0.00-1.00
"webp_quality": 0.90, # 0.00-1.00
"opacity_threshold": 254 # 0-255
}
# fmt: on
@ -30,7 +31,7 @@ def normalize_options(options=None):
if value == "jpg":
value = "jpeg"
if value not in ("orig", "auto", "jpeg", "png"):
if value not in ("orig", "auto", "jpeg", "png", "webp", "webpl"):
raise ValueError("Invalid value for 'output_format': '%s'" % value)
result["output_format"] = value
@ -86,6 +87,23 @@ def normalize_options(options=None):
result["jpeg_quality"] = value
# 0-100 -> 0.00-1.00
# 110 -> 1.00
# "100" -> 1.00
if "webp_quality" in options:
value = options["webp_quality"]
if type(value) in (str, type(u""), bytes):
value = float(value)
if value > 1:
value = value / 100.0
if value > 1:
value = 1
result["webp_quality"] = value
# 0.00-1.00[ -> 0-255
# 300 -> 255
# "100" -> 100