mirror of https://github.com/wanadev/yoga.git
Merge branch 'webp-support'
This commit is contained in:
commit
8f4d1de706
|
@ -15,3 +15,4 @@ _*.c*
|
|||
*.dylib
|
||||
.vscode
|
||||
.pytest_cache/
|
||||
*.tags
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
-----------------
|
||||
|
||||
|
|
|
@ -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::
|
||||
|
|
15
noxfile.py
15
noxfile.py
|
@ -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(".")
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[tool.black]
|
||||
line-length = 79
|
||||
target-version = ['py27']
|
|
@ -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)
|
2
setup.py
2
setup.py
|
@ -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 |
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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"]
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
|
@ -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)
|
|
@ -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()
|
|
@ -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()
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue