Initial commit
This commit is contained in:
58
env/lib/python3.10/site-packages/willow/__init__.py
vendored
Normal file
58
env/lib/python3.10/site-packages/willow/__init__.py
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
from willow.image import Image # noqa: F401
|
||||
|
||||
|
||||
def setup():
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from willow.image import (
|
||||
AvifImageFile,
|
||||
BMPImageFile,
|
||||
GIFImageFile,
|
||||
HeicImageFile,
|
||||
IcoImageFile,
|
||||
JPEGImageFile,
|
||||
PNGImageFile,
|
||||
RGBAImageBuffer,
|
||||
RGBImageBuffer,
|
||||
SvgImageFile,
|
||||
TIFFImageFile,
|
||||
WebPImageFile,
|
||||
)
|
||||
from willow.optimizers import Cwebp, Gifsicle, Jpegoptim, Optipng, Pngquant
|
||||
from willow.plugins import opencv, pillow, wand
|
||||
from willow.registry import registry
|
||||
from willow.svg import SvgImage
|
||||
|
||||
registry.register_image_class(JPEGImageFile)
|
||||
registry.register_image_class(PNGImageFile)
|
||||
registry.register_image_class(GIFImageFile)
|
||||
registry.register_image_class(BMPImageFile)
|
||||
registry.register_image_class(TIFFImageFile)
|
||||
registry.register_image_class(WebPImageFile)
|
||||
registry.register_image_class(HeicImageFile)
|
||||
registry.register_image_class(RGBImageBuffer)
|
||||
registry.register_image_class(RGBAImageBuffer)
|
||||
registry.register_image_class(SvgImageFile)
|
||||
registry.register_image_class(SvgImage)
|
||||
registry.register_image_class(AvifImageFile)
|
||||
registry.register_image_class(IcoImageFile)
|
||||
|
||||
registry.register_plugin(pillow)
|
||||
registry.register_plugin(wand)
|
||||
registry.register_plugin(opencv)
|
||||
|
||||
registry.register_optimizer(Cwebp)
|
||||
registry.register_optimizer(Gifsicle)
|
||||
registry.register_optimizer(Jpegoptim)
|
||||
registry.register_optimizer(Optipng)
|
||||
registry.register_optimizer(Pngquant)
|
||||
|
||||
# Prevents etree from prefixing XML tag names with anonymous
|
||||
# namespaces, e.g. "<ns0:svg ..."
|
||||
ElementTree.register_namespace("", "http://www.w3.org/2000/svg")
|
||||
|
||||
|
||||
setup()
|
||||
|
||||
|
||||
__version__ = "1.8.0"
|
||||
BIN
env/lib/python3.10/site-packages/willow/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/__pycache__/image.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/__pycache__/image.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/__pycache__/registry.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/__pycache__/registry.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/__pycache__/svg.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/__pycache__/svg.cpython-310.pyc
vendored
Normal file
Binary file not shown.
23550
env/lib/python3.10/site-packages/willow/data/cascades/haarcascade_frontalface_alt2.xml
vendored
Normal file
23550
env/lib/python3.10/site-packages/willow/data/cascades/haarcascade_frontalface_alt2.xml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
358
env/lib/python3.10/site-packages/willow/image.py
vendored
Normal file
358
env/lib/python3.10/site-packages/willow/image.py
vendored
Normal file
@@ -0,0 +1,358 @@
|
||||
import os
|
||||
import re
|
||||
from io import BytesIO
|
||||
from tempfile import NamedTemporaryFile, SpooledTemporaryFile
|
||||
from typing import Optional
|
||||
|
||||
import filetype
|
||||
from defusedxml import ElementTree
|
||||
from filetype.types import image as image_types
|
||||
|
||||
from .registry import registry
|
||||
|
||||
|
||||
class UnrecognisedImageFormatError(IOError):
|
||||
pass
|
||||
|
||||
|
||||
class BadImageOperationError(ValueError):
|
||||
"""
|
||||
Raised when the arguments to an image operation are invalid,
|
||||
e.g. a crop where the left coordinate is greater than the right coordinate
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Image:
|
||||
@classmethod
|
||||
def check(cls):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def operation(func):
|
||||
func._willow_operation = True
|
||||
return func
|
||||
|
||||
@staticmethod
|
||||
def converter_to(to_class, cost=None):
|
||||
def wrapper(func):
|
||||
func._willow_converter_to = (to_class, cost)
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def converter_from(from_class, cost=None):
|
||||
def wrapper(func):
|
||||
if not hasattr(func, "_willow_converter_from"):
|
||||
func._willow_converter_from = []
|
||||
|
||||
if isinstance(from_class, list):
|
||||
func._willow_converter_from.extend([(sc, cost) for sc in from_class])
|
||||
else:
|
||||
func._willow_converter_from.append((from_class, cost))
|
||||
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
def __getattr__(self, attr):
|
||||
try:
|
||||
operation, _, conversion_path, _ = registry.find_operation(type(self), attr)
|
||||
except LookupError:
|
||||
# Operation doesn't exist
|
||||
raise AttributeError(
|
||||
f"{self.__class__.__name__!r} object has no attribute {attr!r}"
|
||||
)
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
image = self
|
||||
|
||||
for converter, _ in conversion_path:
|
||||
image = converter(image)
|
||||
|
||||
return operation(image, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# A couple of helpful methods
|
||||
|
||||
@classmethod
|
||||
def open(cls, f):
|
||||
# Detect image format
|
||||
image_format = filetype.guess_extension(f)
|
||||
|
||||
if image_format is None and cls.maybe_xml(f):
|
||||
image_format = "svg"
|
||||
|
||||
# Find initial class
|
||||
initial_class = INITIAL_IMAGE_CLASSES.get(image_format)
|
||||
if not initial_class:
|
||||
if image_format:
|
||||
raise UnrecognisedImageFormatError(
|
||||
f"Cannot load {image_format} images ({INITIAL_IMAGE_CLASSES!r})"
|
||||
)
|
||||
else:
|
||||
raise UnrecognisedImageFormatError("Unknown image format")
|
||||
|
||||
return initial_class(f)
|
||||
|
||||
@classmethod
|
||||
def maybe_xml(cls, f):
|
||||
# Check if it looks like an XML doc, it will be validated
|
||||
# properly when we parse it in SvgImageFile
|
||||
f.seek(0)
|
||||
pattern = re.compile(rb"^\s*<")
|
||||
for line in f:
|
||||
if pattern.match(line):
|
||||
f.seek(0)
|
||||
return True
|
||||
f.seek(0)
|
||||
return False
|
||||
|
||||
def save(
|
||||
self, image_format, output, apply_optimizers=True
|
||||
) -> Optional["ImageFile"]:
|
||||
# Get operation name
|
||||
if image_format not in [
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"tiff",
|
||||
"webp",
|
||||
"svg",
|
||||
"heic",
|
||||
"avif",
|
||||
"ico",
|
||||
]:
|
||||
raise ValueError("Unknown image format: %s" % image_format)
|
||||
|
||||
operation_name = "save_as_" + image_format
|
||||
return getattr(self, operation_name)(output, apply_optimizers=apply_optimizers)
|
||||
|
||||
def optimize(self, image_file, image_format: str):
|
||||
"""
|
||||
Runs all available optimizers for the given image format on the given image file.
|
||||
|
||||
If the passed image file is a SpooledTemporaryFile or just bytes, we are converting it to a
|
||||
NamedTemporaryFile to guarantee we can access the file so the optimizers to work on it.
|
||||
If we get a string, we assume it's a path to a file, and will attempt to load it from
|
||||
the file system.
|
||||
"""
|
||||
optimizers = registry.get_optimizers_for_format(image_format)
|
||||
if not optimizers:
|
||||
return
|
||||
|
||||
named_file_created = False
|
||||
try:
|
||||
if isinstance(image_file, SpooledTemporaryFile):
|
||||
file = image_file._file
|
||||
with NamedTemporaryFile(delete=False) as named_file:
|
||||
if hasattr(file, "getvalue"): # e.g. BytesIO
|
||||
named_file.write(file.getvalue())
|
||||
else: # e.g. BufferedRandom
|
||||
file.seek(0)
|
||||
named_file.write(file.read())
|
||||
file_path = named_file.name
|
||||
named_file_created = True
|
||||
elif isinstance(image_file, BytesIO):
|
||||
with NamedTemporaryFile(delete=False) as named_file:
|
||||
named_file.write(image_file.getvalue())
|
||||
file_path = named_file.name
|
||||
named_file_created = True
|
||||
elif hasattr(image_file, "name"):
|
||||
file_path = image_file.name
|
||||
elif isinstance(image_file, str):
|
||||
file_path = image_file
|
||||
elif isinstance(image_file, bytes):
|
||||
with NamedTemporaryFile(delete=False) as named_file:
|
||||
named_file.write(image_file)
|
||||
file_path = named_file.name
|
||||
named_file_created = True
|
||||
|
||||
for optimizer in optimizers:
|
||||
optimizer.process(file_path)
|
||||
|
||||
if hasattr(image_file, "seek"):
|
||||
# rewind and replace the image file with the optimized version
|
||||
image_file.seek(0)
|
||||
with open(file_path, "rb") as f:
|
||||
image_file.write(f.read())
|
||||
|
||||
if hasattr(image_file, "truncate"):
|
||||
image_file.truncate() # bring the file size down to the actual image size
|
||||
finally:
|
||||
if named_file_created:
|
||||
os.unlink(file_path)
|
||||
|
||||
|
||||
class ImageBuffer(Image):
|
||||
def __init__(self, size, data):
|
||||
self.size = size
|
||||
self.data = data
|
||||
|
||||
@Image.operation
|
||||
def get_size(self):
|
||||
return self.size
|
||||
|
||||
|
||||
class RGBImageBuffer(ImageBuffer):
|
||||
mode = "RGB"
|
||||
|
||||
@Image.operation
|
||||
def has_alpha(self):
|
||||
return False
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
return False
|
||||
|
||||
|
||||
class RGBAImageBuffer(ImageBuffer):
|
||||
mode = "RGBA"
|
||||
|
||||
@Image.operation
|
||||
def has_alpha(self):
|
||||
return True
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
return False
|
||||
|
||||
|
||||
class ImageFile(Image):
|
||||
@property
|
||||
def format_name(self):
|
||||
"""
|
||||
Willow internal name for the image format
|
||||
ImageFile implementations MUST override this.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
"""
|
||||
Returns the MIME type of the image file
|
||||
ImageFile implementations MUST override this.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(self, f):
|
||||
self.f = f
|
||||
|
||||
|
||||
class JPEGImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "jpeg"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
class PNGImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "png"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/png"
|
||||
|
||||
|
||||
class GIFImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "gif"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/gif"
|
||||
|
||||
|
||||
class BMPImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "bmp"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/bmp"
|
||||
|
||||
|
||||
class TIFFImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "tiff"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/tiff"
|
||||
|
||||
|
||||
class WebPImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "webp"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/webp"
|
||||
|
||||
|
||||
class SvgImageFile(ImageFile):
|
||||
format_name = "svg"
|
||||
mime_type = "image/svg+xml"
|
||||
|
||||
def __init__(self, f, dom=None):
|
||||
if dom is None:
|
||||
f.seek(0)
|
||||
# Will raise xml.etree.ElementTree.ParseError if invalid
|
||||
self.dom = ElementTree.parse(f)
|
||||
f.seek(0)
|
||||
else:
|
||||
self.dom = dom
|
||||
super().__init__(f)
|
||||
|
||||
|
||||
class HeicImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "heic"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/heic"
|
||||
|
||||
|
||||
class AvifImageFile(ImageFile):
|
||||
@property
|
||||
def format_name(self):
|
||||
return "avif"
|
||||
|
||||
@property
|
||||
def mime_type(self):
|
||||
return "image/avif"
|
||||
|
||||
|
||||
class IcoImageFile(ImageFile):
|
||||
format_name = "ico"
|
||||
mime_type = "image/x-icon"
|
||||
|
||||
|
||||
INITIAL_IMAGE_CLASSES = {
|
||||
# A mapping of image formats to their initial class
|
||||
image_types.Jpeg().extension: JPEGImageFile,
|
||||
image_types.Png().extension: PNGImageFile,
|
||||
image_types.Gif().extension: GIFImageFile,
|
||||
image_types.Bmp().extension: BMPImageFile,
|
||||
image_types.Tiff().extension: TIFFImageFile,
|
||||
image_types.Webp().extension: WebPImageFile,
|
||||
"svg": SvgImageFile,
|
||||
image_types.Heic().extension: HeicImageFile,
|
||||
image_types.Avif().extension: AvifImageFile,
|
||||
image_types.Ico().extension: IcoImageFile,
|
||||
}
|
||||
5
env/lib/python3.10/site-packages/willow/optimizers/__init__.py
vendored
Normal file
5
env/lib/python3.10/site-packages/willow/optimizers/__init__.py
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
from .cwebp import Cwebp # noqa: F401
|
||||
from .gifsicle import Gifsicle # noqa: F401
|
||||
from .jpegoptim import Jpegoptim # noqa: F401
|
||||
from .optipng import Optipng # noqa: F401
|
||||
from .pngquant import Pngquant # noqa: F401
|
||||
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/base.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/base.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/cwebp.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/cwebp.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/gifsicle.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/gifsicle.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/jpegoptim.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/jpegoptim.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/optipng.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/optipng.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/pngquant.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/optimizers/__pycache__/pngquant.cpython-310.pyc
vendored
Normal file
Binary file not shown.
53
env/lib/python3.10/site-packages/willow/optimizers/base.py
vendored
Normal file
53
env/lib/python3.10/site-packages/willow/optimizers/base.py
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import ClassVar, List
|
||||
|
||||
logger = logging.getLogger("willow")
|
||||
|
||||
|
||||
class OptimizerBase:
|
||||
library_name: ClassVar[str] = ""
|
||||
image_format: ClassVar[str] = ""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def applies_to(cls, image_format: str) -> bool:
|
||||
return image_format.lower() == cls.image_format.lower()
|
||||
|
||||
@classmethod
|
||||
def get_check_library_arguments(cls) -> List[str]:
|
||||
"""
|
||||
Return a list of arguments to check if the library exists.
|
||||
|
||||
Note: using --help by default as that usually returns a zero exit code
|
||||
"""
|
||||
return ["--help"]
|
||||
|
||||
@classmethod
|
||||
def check_library(cls) -> bool:
|
||||
args = [cls.library_name] + cls.get_check_library_arguments()
|
||||
try:
|
||||
subprocess.check_output(args, stderr=subprocess.STDOUT)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(cls, file_path: str) -> List[str]:
|
||||
"""Return a list of arguments for the given optimizer library."""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def process(cls, file_path: str):
|
||||
args = [cls.library_name] + cls.get_command_arguments(file_path)
|
||||
try:
|
||||
subprocess.check_output(args, stderr=subprocess.STDOUT)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.exception(
|
||||
"Error optimizing %s with the '%s' library with error: %s",
|
||||
file_path,
|
||||
cls.library_name,
|
||||
exc.output,
|
||||
)
|
||||
34
env/lib/python3.10/site-packages/willow/optimizers/cwebp.py
vendored
Normal file
34
env/lib/python3.10/site-packages/willow/optimizers/cwebp.py
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import ClassVar, List
|
||||
|
||||
from .base import OptimizerBase
|
||||
|
||||
__all__ = ["Cwebp"]
|
||||
|
||||
|
||||
class Cwebp(OptimizerBase):
|
||||
"""https://developers.google.com/speed/webp/docs/cwebp"""
|
||||
|
||||
library_name: ClassVar[str] = "cwebp"
|
||||
image_format: ClassVar[str] = "webp"
|
||||
|
||||
@classmethod
|
||||
def get_check_library_arguments(cls) -> List[str]:
|
||||
# running just cwebp gives basic infor and returns a zero exit code
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(
|
||||
cls, file_path: str, progressive: bool = False
|
||||
) -> List[str]:
|
||||
return [
|
||||
"-m",
|
||||
"6", # inspect all encoding possibilities for best file size
|
||||
"-mt", # use multithreading if possible
|
||||
"-pass",
|
||||
"10", # max number of passes
|
||||
"-q",
|
||||
"75", # compression factor. 100 produces the highest quality.
|
||||
file_path,
|
||||
"-o",
|
||||
file_path,
|
||||
]
|
||||
20
env/lib/python3.10/site-packages/willow/optimizers/gifsicle.py
vendored
Normal file
20
env/lib/python3.10/site-packages/willow/optimizers/gifsicle.py
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
from typing import ClassVar, List
|
||||
|
||||
from .base import OptimizerBase
|
||||
|
||||
__all__ = ["Gifsicle"]
|
||||
|
||||
|
||||
class Gifsicle(OptimizerBase):
|
||||
"""http://www.lcdf.org/gifsicle/"""
|
||||
|
||||
library_name: ClassVar[str] = "gifsicle"
|
||||
image_format: ClassVar[str] = "gif"
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(cls, file_path: str) -> List[str]:
|
||||
return [
|
||||
"-b", # required parameter for the package
|
||||
"-O3", # slowest, but produces best results
|
||||
file_path, # the file
|
||||
]
|
||||
21
env/lib/python3.10/site-packages/willow/optimizers/jpegoptim.py
vendored
Normal file
21
env/lib/python3.10/site-packages/willow/optimizers/jpegoptim.py
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import ClassVar, List
|
||||
|
||||
from .base import OptimizerBase
|
||||
|
||||
__all__ = ["Jpegoptim"]
|
||||
|
||||
|
||||
class Jpegoptim(OptimizerBase):
|
||||
"""https://github.com/tjko/jpegoptim"""
|
||||
|
||||
library_name: ClassVar[str] = "jpegoptim"
|
||||
image_format: ClassVar[str] = "jpeg"
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(cls, file_path: str) -> List[str]:
|
||||
return [
|
||||
"--strip-all", # strip out all text information like comments and EXIF data
|
||||
"--max=85", # set maximum quality
|
||||
"--all-progressive", # make the resulting image progressive
|
||||
file_path,
|
||||
]
|
||||
21
env/lib/python3.10/site-packages/willow/optimizers/optipng.py
vendored
Normal file
21
env/lib/python3.10/site-packages/willow/optimizers/optipng.py
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import ClassVar, List
|
||||
|
||||
from .base import OptimizerBase
|
||||
|
||||
__all__ = ["Optipng"]
|
||||
|
||||
|
||||
class Optipng(OptimizerBase):
|
||||
"""https://optipng.sourceforge.net/"""
|
||||
|
||||
library_name: ClassVar[str] = "optipng"
|
||||
image_format: ClassVar[str] = "png"
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(cls, file_path: str) -> List[str]:
|
||||
return [
|
||||
"-quiet",
|
||||
"-o2", # optimization level 2 (out of 7)
|
||||
"-i0", # non-interlaced, progressive scanned image
|
||||
file_path, # the file
|
||||
]
|
||||
25
env/lib/python3.10/site-packages/willow/optimizers/pngquant.py
vendored
Normal file
25
env/lib/python3.10/site-packages/willow/optimizers/pngquant.py
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import ClassVar, List
|
||||
|
||||
from .base import OptimizerBase
|
||||
|
||||
__all__ = ["Pngquant"]
|
||||
|
||||
|
||||
class Pngquant(OptimizerBase):
|
||||
"""https://pngquant.org/"""
|
||||
|
||||
library_name: ClassVar[str] = "pngquant"
|
||||
image_format: ClassVar[str] = "png"
|
||||
|
||||
@classmethod
|
||||
def get_command_arguments(
|
||||
cls, file_path: str, progressive: bool = False
|
||||
) -> List[str]:
|
||||
return [
|
||||
"--force", # allow overwriting existing files
|
||||
"--strip", # remove optional metadata
|
||||
"--skip-if-larger",
|
||||
file_path, # the file as input
|
||||
"--output",
|
||||
file_path, # the file as output
|
||||
]
|
||||
0
env/lib/python3.10/site-packages/willow/plugins/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/willow/plugins/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/opencv.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/opencv.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/pillow.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/pillow.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/wand.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/plugins/__pycache__/wand.cpython-310.pyc
vendored
Normal file
Binary file not shown.
141
env/lib/python3.10/site-packages/willow/plugins/opencv.py
vendored
Normal file
141
env/lib/python3.10/site-packages/willow/plugins/opencv.py
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
|
||||
from willow.image import Image, RGBImageBuffer
|
||||
|
||||
|
||||
def _cv2():
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
from cv import cv2
|
||||
return cv2
|
||||
|
||||
|
||||
def _numpy():
|
||||
import numpy
|
||||
|
||||
return numpy
|
||||
|
||||
|
||||
class BaseOpenCVImage(Image):
|
||||
def __init__(self, image, size):
|
||||
self.image = image
|
||||
self.size = size
|
||||
|
||||
@classmethod
|
||||
def check(cls):
|
||||
_cv2()
|
||||
|
||||
@Image.operation
|
||||
def get_size(self):
|
||||
return self.size
|
||||
|
||||
@Image.operation
|
||||
def get_frame_count(self):
|
||||
# Animation is not supported by OpenCV
|
||||
return 1
|
||||
|
||||
@Image.operation
|
||||
def has_alpha(self):
|
||||
# Alpha is not supported by OpenCV
|
||||
return False
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
# Animation is not supported by OpenCV
|
||||
return False
|
||||
|
||||
|
||||
class OpenCVColorImage(BaseOpenCVImage):
|
||||
@classmethod
|
||||
def check(cls):
|
||||
super().check()
|
||||
_numpy()
|
||||
|
||||
@classmethod
|
||||
@Image.converter_from(RGBImageBuffer)
|
||||
def from_buffer_rgb(cls, image_buffer):
|
||||
"""
|
||||
Converts a Color Image buffer into a numpy array suitable for use with OpenCV
|
||||
"""
|
||||
numpy = _numpy()
|
||||
cv2 = _cv2()
|
||||
|
||||
image = numpy.frombuffer(image_buffer.data, dtype=numpy.uint8)
|
||||
image = image.reshape(image_buffer.size[1], image_buffer.size[0], 3)
|
||||
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||
return cls(image, image_buffer.size)
|
||||
|
||||
|
||||
class OpenCVGrayscaleImage(BaseOpenCVImage):
|
||||
face_haar_flags = 0
|
||||
face_min_neighbors = 3
|
||||
face_haar_scale = 1.1
|
||||
face_min_size = (40, 40)
|
||||
|
||||
@Image.operation
|
||||
def detect_features(self):
|
||||
"""
|
||||
Find interesting features of an image suitable for cropping to.
|
||||
"""
|
||||
numpy = _numpy()
|
||||
cv2 = _cv2()
|
||||
points = cv2.goodFeaturesToTrack(self.image, 20, 0.04, 1.0)
|
||||
if points is None:
|
||||
return []
|
||||
else:
|
||||
points = numpy.reshape(
|
||||
points, (-1, 2)
|
||||
) # Numpy returns it with an extra third dimension
|
||||
return points.tolist()
|
||||
|
||||
@Image.operation
|
||||
def detect_faces(self, cascade_filename="haarcascade_frontalface_alt2.xml"):
|
||||
"""
|
||||
Run OpenCV face detection on the image. Returns a list of coordinates representing a box around each face.
|
||||
"""
|
||||
cv2 = _cv2()
|
||||
cascade_filename = self._find_cascade(cascade_filename)
|
||||
cascade = cv2.CascadeClassifier(cascade_filename)
|
||||
equalised_image = cv2.equalizeHist(self.image)
|
||||
faces = cascade.detectMultiScale(
|
||||
equalised_image,
|
||||
self.face_haar_scale,
|
||||
self.face_min_neighbors,
|
||||
self.face_haar_flags,
|
||||
self.face_min_size,
|
||||
)
|
||||
return [
|
||||
(
|
||||
face[0],
|
||||
face[1],
|
||||
face[0] + face[2],
|
||||
face[1] + face[3],
|
||||
)
|
||||
for face in faces
|
||||
]
|
||||
|
||||
def _find_cascade(self, cascade_filename):
|
||||
"""
|
||||
Find the requested OpenCV cascade file. If a relative path was provided, check local cascades directory.
|
||||
"""
|
||||
if not os.path.isabs(cascade_filename):
|
||||
cascade_filename = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
"data/cascades",
|
||||
cascade_filename,
|
||||
)
|
||||
return cascade_filename
|
||||
|
||||
@classmethod
|
||||
@Image.converter_from(OpenCVColorImage)
|
||||
def from_color(cls, colour_image):
|
||||
"""
|
||||
Convert OpenCVColorImage to an OpenCVGrayscaleImage.
|
||||
"""
|
||||
cv2 = _cv2()
|
||||
image = cv2.cvtColor(colour_image.image, cv2.COLOR_BGR2GRAY)
|
||||
return cls(image, colour_image.size)
|
||||
|
||||
|
||||
willow_image_classes = [OpenCVColorImage, OpenCVGrayscaleImage]
|
||||
513
env/lib/python3.10/site-packages/willow/plugins/pillow.py
vendored
Normal file
513
env/lib/python3.10/site-packages/willow/plugins/pillow.py
vendored
Normal file
@@ -0,0 +1,513 @@
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
from pillow_heif import AvifImagePlugin, HeifImagePlugin # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from willow.image import (
|
||||
AvifImageFile,
|
||||
BadImageOperationError,
|
||||
BMPImageFile,
|
||||
GIFImageFile,
|
||||
HeicImageFile,
|
||||
IcoImageFile,
|
||||
Image,
|
||||
JPEGImageFile,
|
||||
PNGImageFile,
|
||||
RGBAImageBuffer,
|
||||
RGBImageBuffer,
|
||||
TIFFImageFile,
|
||||
WebPImageFile,
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedRotation(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _PIL_Image():
|
||||
import PIL.Image
|
||||
|
||||
return PIL.Image
|
||||
|
||||
|
||||
def _PIL_ImageCms():
|
||||
import PIL.ImageCms
|
||||
|
||||
return PIL.ImageCms
|
||||
|
||||
|
||||
class PillowImage(Image):
|
||||
def __init__(self, image):
|
||||
self.image = image
|
||||
|
||||
@classmethod
|
||||
def check(cls):
|
||||
_PIL_Image()
|
||||
|
||||
@classmethod
|
||||
def is_format_supported(cls, image_format):
|
||||
formats = _PIL_Image().registered_extensions()
|
||||
return image_format in formats.values()
|
||||
|
||||
@Image.operation
|
||||
def get_size(self):
|
||||
return self.image.size
|
||||
|
||||
@Image.operation
|
||||
def get_frame_count(self):
|
||||
# Animation is not supported by PIL
|
||||
return 1
|
||||
|
||||
@Image.operation
|
||||
def has_alpha(self):
|
||||
img = self.image
|
||||
return img.mode in ("RGBA", "LA") or (
|
||||
img.mode == "P" and "transparency" in img.info
|
||||
)
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
# Animation is not supported by PIL
|
||||
return False
|
||||
|
||||
@Image.operation
|
||||
def resize(self, size):
|
||||
# Convert 1 and P images to RGB to improve resize quality
|
||||
# (palleted images don't get antialiased or filtered when minified)
|
||||
if self.image.mode in ["1", "P"]:
|
||||
if self.has_alpha():
|
||||
image = self.image.convert("RGBA")
|
||||
else:
|
||||
image = self.image.convert("RGB")
|
||||
else:
|
||||
image = self.image
|
||||
|
||||
# LANCZOS was previously known as ANTIALIAS
|
||||
return PillowImage(image.resize(size, _PIL_Image().Resampling.LANCZOS))
|
||||
|
||||
@Image.operation
|
||||
def crop(self, rect):
|
||||
left, top, right, bottom = rect
|
||||
width, height = self.image.size
|
||||
if (
|
||||
left >= right
|
||||
or left >= width
|
||||
or right <= 0
|
||||
or top >= bottom
|
||||
or top >= height
|
||||
or bottom <= 0
|
||||
):
|
||||
raise BadImageOperationError(f"Invalid crop dimensions: {rect!r}")
|
||||
|
||||
# clamp to image boundaries
|
||||
clamped_rect = (
|
||||
max(0, left),
|
||||
max(0, top),
|
||||
min(right, width),
|
||||
min(bottom, height),
|
||||
)
|
||||
|
||||
return PillowImage(self.image.crop(clamped_rect))
|
||||
|
||||
@Image.operation
|
||||
def rotate(self, angle):
|
||||
"""
|
||||
Accept a multiple of 90 to pass to the underlying Pillow function
|
||||
to rotate the image.
|
||||
"""
|
||||
|
||||
Image = _PIL_Image()
|
||||
ORIENTATION_TO_TRANSPOSE = {
|
||||
90: Image.Transpose.ROTATE_90,
|
||||
180: Image.Transpose.ROTATE_180,
|
||||
270: Image.Transpose.ROTATE_270,
|
||||
}
|
||||
|
||||
modulo_angle = angle % 360
|
||||
|
||||
# is we're rotating a multiple of 360, it's the same as a no-op
|
||||
if not modulo_angle:
|
||||
return self
|
||||
|
||||
transpose_code = ORIENTATION_TO_TRANSPOSE.get(modulo_angle)
|
||||
|
||||
if not transpose_code:
|
||||
raise UnsupportedRotation(
|
||||
"Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
|
||||
)
|
||||
|
||||
# We call "transpose", as it rotates the image,
|
||||
# updating the height and width, whereas using 'rotate'
|
||||
# only changes the contents of the image.
|
||||
rotated = self.image.transpose(transpose_code)
|
||||
|
||||
return PillowImage(rotated)
|
||||
|
||||
@Image.operation
|
||||
def set_background_color_rgb(self, color):
|
||||
if not self.has_alpha():
|
||||
# Don't change image that doesn't have an alpha channel
|
||||
return self
|
||||
|
||||
# Check type of color
|
||||
if not isinstance(color, (tuple, list)) or not len(color) == 3:
|
||||
raise TypeError("the 'color' argument must be a 3-element tuple or list")
|
||||
|
||||
# Convert non-RGB colour formats to RGB
|
||||
# As we only allow the background color to be passed in as RGB, we
|
||||
# convert the format of the original image to match.
|
||||
image = self.image.convert("RGBA")
|
||||
|
||||
# Generate a new image with background colour and draw existing image on top of it
|
||||
# The new image must temporarily be RGBA in order for alpha_composite to work
|
||||
new_image = _PIL_Image().new(
|
||||
"RGBA", self.image.size, (color[0], color[1], color[2], 255)
|
||||
)
|
||||
|
||||
if hasattr(new_image, "alpha_composite"):
|
||||
new_image.alpha_composite(image)
|
||||
else:
|
||||
# Pillow < 4.2.0 fallback
|
||||
# This method may be slower as the operation generates a new image
|
||||
new_image = _PIL_Image().alpha_composite(new_image, image)
|
||||
|
||||
return PillowImage(new_image.convert("RGB"))
|
||||
|
||||
def get_icc_profile(self):
|
||||
return self.image.info.get("icc_profile")
|
||||
|
||||
def get_exif_data(self):
|
||||
return self.image.info.get("exif")
|
||||
|
||||
@Image.operation
|
||||
def transform_colorspace_to_srgb(self, rendering_intent=0):
|
||||
"""
|
||||
Transforms the color of the image to fit inside sRGB color gamut using the
|
||||
embedded ICC profile. The resulting image will always be in RGB(A) mode
|
||||
and will have a small generic sRGB ICC profile embedded.
|
||||
|
||||
If the image does not have an ICC profile this operation is a no-op.
|
||||
Images without a profile are commonly assumed to be in sRGB color space
|
||||
already.
|
||||
|
||||
:param rendering_intent: Controls how out-of-gamut colors and handled.
|
||||
Defaults to 0 (perceptual) because this is what Pillow defaults to.
|
||||
:return: PillowImage in RGB mode
|
||||
:raises: PIL.ImageCms.PyCMSError
|
||||
|
||||
Further reading:
|
||||
* https://pillow.readthedocs.io/en/stable/reference/ImageCms.html#PIL.ImageCms.profileToProfile
|
||||
* https://www.permajet.com/blog/rendering-intents-explained/
|
||||
"""
|
||||
icc_profile = self.get_icc_profile()
|
||||
|
||||
# Can't transform if there is no profile, no-op
|
||||
if icc_profile is None:
|
||||
return self
|
||||
|
||||
ImageCms = _PIL_ImageCms()
|
||||
# ImageCmsProfile expects profile data to be file-like, give it BytesIO that quacks like a file 🦆
|
||||
icc_profile = ImageCms.ImageCmsProfile(BytesIO(icc_profile))
|
||||
|
||||
# Output mode should always be RGB, unless the image has an alpha channel.
|
||||
output_mode = "RGBA" if self.has_alpha() else "RGB"
|
||||
|
||||
# Attempt to convert from the embedded profile of the image to a generic sRGB one
|
||||
image = ImageCms.profileToProfile(
|
||||
self.image,
|
||||
icc_profile,
|
||||
ImageCms.createProfile("sRGB"),
|
||||
renderingIntent=rendering_intent,
|
||||
outputMode=output_mode,
|
||||
)
|
||||
return PillowImage(image)
|
||||
|
||||
@Image.operation
|
||||
def save_as_jpeg(
|
||||
self,
|
||||
f,
|
||||
quality: int = 85,
|
||||
optimize: bool = False,
|
||||
progressive: bool = False,
|
||||
apply_optimizers: bool = True,
|
||||
):
|
||||
"""
|
||||
Save the image as a JPEG file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param quality: the image quality
|
||||
:param optimize: Whether Pillow should optimize the file. When True, Pillow will
|
||||
attempt to compress the palette by eliminating unused colors.
|
||||
:param progressive: whether to save as progressive JPEG file.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
||||
:return: JPEGImageFile
|
||||
"""
|
||||
if self.image.mode in ["1", "P"]:
|
||||
image = self.image.convert("RGB")
|
||||
else:
|
||||
image = self.image
|
||||
|
||||
kwargs = {"quality": quality}
|
||||
if optimize:
|
||||
kwargs["optimize"] = True
|
||||
if progressive:
|
||||
kwargs["progressive"] = True
|
||||
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
kwargs["icc_profile"] = icc_profile
|
||||
|
||||
exif_data = self.get_exif_data()
|
||||
if exif_data is not None:
|
||||
kwargs["exif"] = exif_data
|
||||
|
||||
image.save(f, "JPEG", **kwargs)
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "jpeg")
|
||||
return JPEGImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_png(self, f, optimize: bool = False, apply_optimizers: bool = True):
|
||||
"""
|
||||
Save the image as a PNG file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param optimize: Whether Pillow should optimize the file. When True, Pillow will
|
||||
attempt to compress the palette by eliminating unused colors.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
||||
:return: PNGImageFile
|
||||
"""
|
||||
|
||||
kwargs = {}
|
||||
image = self.image
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
|
||||
# about how we handle the color conversion to RGB. We don't want to retain
|
||||
# the color profile as-is because it is not meant for RGB images and
|
||||
# will result in inaccurate colors. The transformation to sRGB should result
|
||||
# in a more accurate representation of the original image, though
|
||||
# it will likely not be perfect.
|
||||
if self.image.mode == "CMYK":
|
||||
pillow_image = self.transform_colorspace_to_srgb()
|
||||
image = pillow_image.image
|
||||
kwargs["icc_profile"] = pillow_image.get_icc_profile()
|
||||
else:
|
||||
kwargs["icc_profile"] = icc_profile
|
||||
|
||||
elif image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Pillow only checks presence of optimize kwarg, not its value
|
||||
if optimize:
|
||||
kwargs["optimize"] = True
|
||||
|
||||
exif_data = self.get_exif_data()
|
||||
if exif_data is not None:
|
||||
kwargs["exif"] = exif_data
|
||||
|
||||
image.save(f, "PNG", **kwargs)
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "png")
|
||||
return PNGImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_gif(self, f, apply_optimizers: bool = True):
|
||||
image = self.image
|
||||
|
||||
# All gif files use either the L or P mode but we sometimes convert them
|
||||
# to RGB/RGBA to improve the quality of resizing. We must make sure that
|
||||
# they are converted back before saving.
|
||||
if image.mode not in ["L", "P"]:
|
||||
image = image.convert("P", palette=_PIL_Image().Palette.ADAPTIVE)
|
||||
|
||||
kwargs = {}
|
||||
if "transparency" in image.info:
|
||||
kwargs["transparency"] = image.info["transparency"]
|
||||
|
||||
image.save(f, "GIF", **kwargs)
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "gif")
|
||||
return GIFImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_webp(
|
||||
self,
|
||||
f,
|
||||
quality: int = 80,
|
||||
lossless: bool = False,
|
||||
apply_optimizers: bool = True,
|
||||
):
|
||||
"""
|
||||
Save the image as a WEBP file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param quality: the image quality
|
||||
:param lossless: whether to save as lossless WEBP file.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries.
|
||||
Note that when lossless=True, this will be ignored.
|
||||
:return: WebPImageFile
|
||||
"""
|
||||
|
||||
kwargs = {"quality": quality, "lossless": lossless}
|
||||
|
||||
image = self.image
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
|
||||
# about how we handle the color space. WEBP will encode as RGB so we need to do extra
|
||||
# work to ensure the colors are as accurate as possible. We don't want to retain
|
||||
# the color profile as-is because it is not meant for RGB images and
|
||||
# will result in inaccurate colors. The transformation to sRGB should result
|
||||
# in a more accurate representation of the original image, though
|
||||
# it will likely not be perfect.
|
||||
if image.mode == "CMYK":
|
||||
pillow_image = self.transform_colorspace_to_srgb()
|
||||
image = pillow_image.image
|
||||
kwargs["icc_profile"] = pillow_image.get_icc_profile()
|
||||
else:
|
||||
kwargs["icc_profile"] = icc_profile
|
||||
|
||||
image.save(f, "WEBP", **kwargs)
|
||||
if apply_optimizers and not lossless:
|
||||
self.optimize(f, "webp")
|
||||
return WebPImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_heic(
|
||||
self,
|
||||
f,
|
||||
quality: int = 80,
|
||||
lossless: bool = False,
|
||||
apply_optimizers: bool = True,
|
||||
):
|
||||
"""
|
||||
Save the image as a HEIC file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param quality: the image quality
|
||||
:param lossless: whether to save as lossless HEIC/HEIF file.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries.
|
||||
Note that when lossless=True, this will be ignored.
|
||||
:return: HeicImageFile
|
||||
"""
|
||||
kwargs = {"quality": quality}
|
||||
if lossless:
|
||||
kwargs = {"quality": -1, "chroma": 444}
|
||||
|
||||
image = self.image
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
|
||||
# about how we handle the color space. HEIC will encode as RGB so we need to do extra
|
||||
# work to ensure the colors are as accurate as possible. We don't want to retain
|
||||
# the color profile as-is because it is not meant for RGB images and
|
||||
# will result in inaccurate colors. The transformation to sRGB should result
|
||||
# in a more accurate representation of the original image, though
|
||||
# it will likely not be perfect.
|
||||
if image.mode == "CMYK":
|
||||
pillow_image = self.transform_colorspace_to_srgb()
|
||||
image = pillow_image.image
|
||||
kwargs["icc_profile"] = pillow_image.get_icc_profile()
|
||||
else:
|
||||
kwargs["icc_profile"] = icc_profile
|
||||
|
||||
image.save(f, "HEIF", **kwargs)
|
||||
|
||||
if not lossless and apply_optimizers:
|
||||
self.optimize(f, "heic")
|
||||
|
||||
return HeicImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
|
||||
kwargs = {"quality": quality}
|
||||
if lossless:
|
||||
kwargs = {"quality": -1, "chroma": 444}
|
||||
|
||||
image = self.image
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
|
||||
# about how we handle the color space. AVIF will encode as RGB so we need to do extra
|
||||
# work to ensure the colors are as accurate as possible. We don't want to retain
|
||||
# the color profile as-is because it is not meant for RGB images and
|
||||
# will result in inaccurate colors. The transformation to sRGB should result
|
||||
# in a more accurate representation of the original image, though
|
||||
# it will likely not be perfect.
|
||||
if image.mode == "CMYK":
|
||||
pillow_image = self.transform_colorspace_to_srgb()
|
||||
image = pillow_image.image
|
||||
kwargs["icc_profile"] = pillow_image.get_icc_profile()
|
||||
else:
|
||||
kwargs["icc_profile"] = icc_profile
|
||||
|
||||
image.save(f, "AVIF", **kwargs)
|
||||
|
||||
if not lossless and apply_optimizers:
|
||||
self.optimize(f, "heic")
|
||||
|
||||
return AvifImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_ico(self, f, apply_optimizers=True):
|
||||
self.image.save(f, "ICO")
|
||||
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "ico")
|
||||
|
||||
return IcoImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def auto_orient(self):
|
||||
# JPEG files can be orientated using an EXIF tag.
|
||||
# Make sure this orientation is applied to the data
|
||||
from PIL import ImageOps
|
||||
|
||||
image = ImageOps.exif_transpose(self.image)
|
||||
|
||||
return PillowImage(image)
|
||||
|
||||
@Image.operation
|
||||
def get_pillow_image(self):
|
||||
return self.image
|
||||
|
||||
@classmethod
|
||||
@Image.converter_from(JPEGImageFile)
|
||||
@Image.converter_from(PNGImageFile)
|
||||
@Image.converter_from(GIFImageFile, cost=200)
|
||||
@Image.converter_from(BMPImageFile)
|
||||
@Image.converter_from(TIFFImageFile)
|
||||
@Image.converter_from(WebPImageFile)
|
||||
@Image.converter_from(HeicImageFile)
|
||||
@Image.converter_from(AvifImageFile)
|
||||
@Image.converter_from(IcoImageFile)
|
||||
def open(cls, image_file):
|
||||
image_file.f.seek(0)
|
||||
image = _PIL_Image().open(image_file.f)
|
||||
image.load()
|
||||
|
||||
return cls(image)
|
||||
|
||||
@Image.converter_to(RGBImageBuffer)
|
||||
def to_buffer_rgb(self):
|
||||
image = self.image
|
||||
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
return RGBImageBuffer(image.size, image.tobytes())
|
||||
|
||||
@Image.converter_to(RGBAImageBuffer)
|
||||
def to_buffer_rgba(self):
|
||||
image = self.image
|
||||
|
||||
if image.mode != "RGBA":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
return RGBAImageBuffer(image.size, image.tobytes())
|
||||
|
||||
|
||||
willow_image_classes = [PillowImage]
|
||||
356
env/lib/python3.10/site-packages/willow/plugins/wand.py
vendored
Normal file
356
env/lib/python3.10/site-packages/willow/plugins/wand.py
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
import functools
|
||||
from ctypes import c_char_p, c_void_p
|
||||
|
||||
from willow.image import (
|
||||
AvifImageFile,
|
||||
BadImageOperationError,
|
||||
BMPImageFile,
|
||||
GIFImageFile,
|
||||
HeicImageFile,
|
||||
IcoImageFile,
|
||||
Image,
|
||||
JPEGImageFile,
|
||||
PNGImageFile,
|
||||
RGBAImageBuffer,
|
||||
RGBImageBuffer,
|
||||
TIFFImageFile,
|
||||
WebPImageFile,
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedRotation(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _wand_image():
|
||||
import wand.image
|
||||
|
||||
return wand.image
|
||||
|
||||
|
||||
def _wand_color():
|
||||
import wand.color
|
||||
|
||||
return wand.color
|
||||
|
||||
|
||||
def _wand_api():
|
||||
import wand.api
|
||||
|
||||
return wand.api
|
||||
|
||||
|
||||
def _wand_version():
|
||||
import wand.version
|
||||
|
||||
return wand.version
|
||||
|
||||
|
||||
class WandImage(Image):
|
||||
def __init__(self, image):
|
||||
self.image = image
|
||||
|
||||
@classmethod
|
||||
def check(cls):
|
||||
_wand_image()
|
||||
_wand_color()
|
||||
_wand_api()
|
||||
_wand_version()
|
||||
|
||||
def _clone(self):
|
||||
return WandImage(self.image.clone())
|
||||
|
||||
@classmethod
|
||||
def is_format_supported(cls, image_format):
|
||||
return bool(_wand_version().formats(image_format))
|
||||
|
||||
@Image.operation
|
||||
def get_size(self):
|
||||
return self.image.size
|
||||
|
||||
@Image.operation
|
||||
def get_frame_count(self):
|
||||
return len(self.image.sequence)
|
||||
|
||||
@Image.operation
|
||||
def has_alpha(self):
|
||||
return self.image.alpha_channel
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
return self.image.animation
|
||||
|
||||
@Image.operation
|
||||
def resize(self, size):
|
||||
clone = self._clone()
|
||||
clone.image.resize(size[0], size[1])
|
||||
return clone
|
||||
|
||||
@Image.operation
|
||||
def crop(self, rect):
|
||||
left, top, right, bottom = rect
|
||||
width, height = self.image.size
|
||||
if (
|
||||
left >= right
|
||||
or left >= width
|
||||
or right <= 0
|
||||
or top >= bottom
|
||||
or top >= height
|
||||
or bottom <= 0
|
||||
):
|
||||
raise BadImageOperationError(f"Invalid crop dimensions: {rect!r}")
|
||||
|
||||
clone = self._clone()
|
||||
clone.image.crop(
|
||||
# clamp to image boundaries
|
||||
left=max(0, left),
|
||||
top=max(0, top),
|
||||
right=min(right, width),
|
||||
bottom=min(bottom, height),
|
||||
)
|
||||
return clone
|
||||
|
||||
@Image.operation
|
||||
def rotate(self, angle):
|
||||
not_a_multiple_of_90 = angle % 90
|
||||
|
||||
if not_a_multiple_of_90:
|
||||
raise UnsupportedRotation(
|
||||
"Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
|
||||
)
|
||||
|
||||
clone = self.image.clone()
|
||||
clone.rotate(angle)
|
||||
return WandImage(clone)
|
||||
|
||||
@Image.operation
|
||||
def set_background_color_rgb(self, color):
|
||||
if not self.has_alpha():
|
||||
# Don't change image that doesn't have an alpha channel
|
||||
return self
|
||||
|
||||
# Check type of color
|
||||
if not isinstance(color, (tuple, list)) or not len(color) == 3:
|
||||
raise TypeError("the 'color' argument must be a 3-element tuple or list")
|
||||
|
||||
clone = self._clone()
|
||||
|
||||
# Wand will perform the compositing at the point of setting alpha_channel to 'remove'
|
||||
clone.image.background_color = _wand_color().Color(
|
||||
"rgb({}, {}, {})".format(*color)
|
||||
)
|
||||
clone.image.alpha_channel = "remove"
|
||||
|
||||
if clone.image.alpha_channel:
|
||||
# ImageMagick <=6 fails to set alpha_channel to False, so do it manually
|
||||
clone.image.alpha_channel = False
|
||||
|
||||
return clone
|
||||
|
||||
def get_icc_profile(self):
|
||||
return self.image.profiles.get("icc")
|
||||
|
||||
def get_exif_data(self):
|
||||
return self.image.profiles.get("exif")
|
||||
|
||||
@Image.operation
|
||||
def save_as_jpeg(
|
||||
self,
|
||||
f,
|
||||
quality: int = 85,
|
||||
progressive: bool = False,
|
||||
apply_optimizers: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Save the image as a JPEG file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param quality: the image quality
|
||||
:param progressive: whether to save as progressive JPEG file.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
||||
:return: JPEGImageFile
|
||||
"""
|
||||
with self.image.convert("pjpeg" if progressive else "jpeg") as converted:
|
||||
converted.compression_quality = quality
|
||||
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
converted.profiles["icc"] = icc_profile
|
||||
|
||||
exif_data = self.get_exif_data()
|
||||
if exif_data is not None:
|
||||
converted.profiles["exif"] = exif_data
|
||||
|
||||
converted.save(file=f)
|
||||
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "jpeg")
|
||||
return JPEGImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_png(self, f, apply_optimizers: bool = True, **kwargs):
|
||||
"""
|
||||
Save the image as a PNG file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
||||
:return: PNGImageFile
|
||||
"""
|
||||
with self.image.convert("png") as converted:
|
||||
exif_data = self.get_exif_data()
|
||||
if exif_data is not None:
|
||||
converted.profiles["exif"] = exif_data
|
||||
|
||||
converted.save(file=f)
|
||||
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "png")
|
||||
return PNGImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_gif(self, f, apply_optimizers: bool = True):
|
||||
with self.image.convert("gif") as converted:
|
||||
converted.save(file=f)
|
||||
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "gif")
|
||||
return GIFImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_webp(
|
||||
self,
|
||||
f,
|
||||
quality: int = 80,
|
||||
lossless: bool = False,
|
||||
apply_optimizers: bool = True,
|
||||
):
|
||||
"""
|
||||
Save the image as a WEBP file.
|
||||
|
||||
:param f: the file or file-like object to save to
|
||||
:param quality: the image quality
|
||||
:param lossless: whether to save as lossless WEBP file.
|
||||
:param apply_optimizers: controls whether to run any configured optimizer libraries.
|
||||
Note that when lossless=True, this will be ignored.
|
||||
:return: WebPImageFile
|
||||
"""
|
||||
with self.image.convert("webp") as converted:
|
||||
if lossless:
|
||||
library = _wand_api().library
|
||||
library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
|
||||
library.MagickSetOption(
|
||||
converted.wand,
|
||||
b"webp:lossless",
|
||||
b"true",
|
||||
)
|
||||
else:
|
||||
converted.compression_quality = quality
|
||||
|
||||
icc_profile = self.get_icc_profile()
|
||||
if icc_profile is not None:
|
||||
converted.profiles["icc"] = icc_profile
|
||||
|
||||
converted.save(file=f)
|
||||
|
||||
if not lossless and apply_optimizers:
|
||||
self.optimize(f, "webp")
|
||||
return WebPImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
|
||||
with self.image.convert("avif") as converted:
|
||||
if lossless:
|
||||
converted.compression_quality = 100
|
||||
library = _wand_api().library
|
||||
library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
|
||||
library.MagickSetOption(
|
||||
converted.wand,
|
||||
b"heic:lossless",
|
||||
b"true",
|
||||
)
|
||||
else:
|
||||
converted.compression_quality = quality
|
||||
converted.save(file=f)
|
||||
|
||||
if not lossless and apply_optimizers:
|
||||
self.optimize(f, "avif")
|
||||
|
||||
return AvifImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def save_as_ico(self, f, apply_optimizers=True):
|
||||
with self.image.convert("ico") as converted:
|
||||
converted.save(file=f)
|
||||
|
||||
if apply_optimizers:
|
||||
self.optimize(f, "ico")
|
||||
|
||||
return IcoImageFile(f)
|
||||
|
||||
@Image.operation
|
||||
def auto_orient(self):
|
||||
image = self.image
|
||||
|
||||
if image.orientation not in ["top_left", "undefined"]:
|
||||
image = image.clone()
|
||||
if hasattr(image, "auto_orient"):
|
||||
# Wand 0.4.1 +
|
||||
image.auto_orient()
|
||||
else:
|
||||
orientation_ops = {
|
||||
"top_right": [image.flop],
|
||||
"bottom_right": [functools.partial(image.rotate, degree=180.0)],
|
||||
"bottom_left": [image.flip],
|
||||
"left_top": [
|
||||
image.flip,
|
||||
functools.partial(image.rotate, degree=90.0),
|
||||
],
|
||||
"right_top": [functools.partial(image.rotate, degree=90.0)],
|
||||
"right_bottom": [
|
||||
image.flop,
|
||||
functools.partial(image.rotate, degree=90.0),
|
||||
],
|
||||
"left_bottom": [functools.partial(image.rotate, degree=270.0)],
|
||||
}
|
||||
fns = orientation_ops.get(image.orientation)
|
||||
|
||||
if fns:
|
||||
for fn in fns:
|
||||
fn()
|
||||
|
||||
image.orientation = "top_left"
|
||||
|
||||
return WandImage(image)
|
||||
|
||||
@Image.operation
|
||||
def get_wand_image(self):
|
||||
return self.image
|
||||
|
||||
@classmethod
|
||||
@Image.converter_from(JPEGImageFile, cost=150)
|
||||
@Image.converter_from(PNGImageFile, cost=150)
|
||||
@Image.converter_from(GIFImageFile, cost=150)
|
||||
@Image.converter_from(BMPImageFile, cost=150)
|
||||
@Image.converter_from(TIFFImageFile, cost=150)
|
||||
@Image.converter_from(WebPImageFile, cost=150)
|
||||
@Image.converter_from(HeicImageFile, cost=150)
|
||||
@Image.converter_from(AvifImageFile, cost=150)
|
||||
@Image.converter_from(IcoImageFile, cost=150)
|
||||
def open(cls, image_file):
|
||||
image_file.f.seek(0)
|
||||
image = _wand_image().Image(file=image_file.f)
|
||||
image.wand = _wand_api().library.MagickCoalesceImages(image.wand)
|
||||
|
||||
return cls(image)
|
||||
|
||||
@Image.converter_to(RGBImageBuffer)
|
||||
def to_buffer_rgb(self):
|
||||
return RGBImageBuffer(self.image.size, self.image.make_blob("RGB"))
|
||||
|
||||
@Image.converter_to(RGBAImageBuffer)
|
||||
def to_buffer_rgba(self):
|
||||
return RGBImageBuffer(self.image.size, self.image.make_blob("RGBA"))
|
||||
|
||||
|
||||
willow_image_classes = [WandImage]
|
||||
430
env/lib/python3.10/site-packages/willow/registry.py
vendored
Normal file
430
env/lib/python3.10/site-packages/willow/registry.py
vendored
Normal file
@@ -0,0 +1,430 @@
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .optimizers import OptimizerBase
|
||||
|
||||
|
||||
class UnrecognisedOperationError(LookupError):
|
||||
"""
|
||||
Raised when the operation isn't in any of the known image classes.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UnavailableOperationError(LookupError):
|
||||
"""
|
||||
Raised when all the image classes the operation exists in are not available.
|
||||
(most likely due to a missing image library.)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UnroutableOperationError(LookupError):
|
||||
"""
|
||||
Raised when there is no way to convert the image into an image class that
|
||||
supports the operation.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WillowRegistry:
|
||||
def __init__(self):
|
||||
self._registered_image_classes = set()
|
||||
self._unavailable_image_classes = {}
|
||||
self._registered_operations = defaultdict(dict)
|
||||
self._registered_converters = {}
|
||||
self._registered_converter_costs = {}
|
||||
self._registered_optimizers: List["OptimizerBase"] = []
|
||||
|
||||
def register_operation(self, image_class, operation_name, func):
|
||||
self._registered_operations[image_class][operation_name] = func
|
||||
|
||||
def register_converter(self, from_image_class, to_image_class, func, cost=None):
|
||||
self._registered_converters[from_image_class, to_image_class] = func
|
||||
|
||||
if cost is not None:
|
||||
self._registered_converter_costs[from_image_class, to_image_class] = cost
|
||||
|
||||
def register_image_class(self, image_class):
|
||||
self._registered_image_classes.add(image_class)
|
||||
|
||||
# Check the image class
|
||||
try:
|
||||
image_class.check()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._unavailable_image_classes[image_class] = e
|
||||
|
||||
# Find and register operations/converters
|
||||
for attr in dir(image_class):
|
||||
val = getattr(image_class, attr)
|
||||
if hasattr(val, "_willow_operation"):
|
||||
self.register_operation(image_class, val.__name__, val)
|
||||
elif hasattr(val, "_willow_converter_to"):
|
||||
self.register_converter(
|
||||
image_class,
|
||||
val._willow_converter_to[0],
|
||||
val,
|
||||
cost=val._willow_converter_to[1],
|
||||
)
|
||||
elif hasattr(val, "_willow_converter_from"):
|
||||
for converter_from, cost in val._willow_converter_from:
|
||||
self.register_converter(converter_from, image_class, val, cost=cost)
|
||||
|
||||
def register_plugin(self, plugin):
|
||||
image_classes = getattr(plugin, "willow_image_classes", [])
|
||||
operations = getattr(plugin, "willow_operations", [])
|
||||
converters = getattr(plugin, "willow_converters", [])
|
||||
|
||||
for image_class in image_classes:
|
||||
self.register_image_class(image_class)
|
||||
|
||||
for operation in operations:
|
||||
self.register_operation(operation[0], operation[1], operation[2])
|
||||
|
||||
for converter in converters:
|
||||
self.register_converter(converter[0], converter[1], converter[2])
|
||||
|
||||
def register_optimizer(self, optimizer_class: "OptimizerBase"):
|
||||
"""Registers an optimizer class."""
|
||||
try:
|
||||
# try to check Django settings, if used in that context
|
||||
from django.conf import settings
|
||||
|
||||
enabled_optimizers = getattr(settings, "WILLOW_OPTIMIZERS", False)
|
||||
except ImportError:
|
||||
# fall back to env vars.
|
||||
import os
|
||||
|
||||
enabled_optimizers = os.environ.get("WILLOW_OPTIMIZERS", False)
|
||||
|
||||
if not enabled_optimizers:
|
||||
# WILLOW_OPTIMIZERS is either not set, or is set to a false-y value, so skip registration
|
||||
return
|
||||
|
||||
if isinstance(enabled_optimizers, str):
|
||||
if enabled_optimizers.lower() == "false":
|
||||
return
|
||||
elif enabled_optimizers.lower() == "true":
|
||||
enabled_optimizers = True
|
||||
else:
|
||||
enabled_optimizers = enabled_optimizers.split(",")
|
||||
|
||||
if enabled_optimizers is True:
|
||||
add_optimizer = True
|
||||
else:
|
||||
add_optimizer = optimizer_class.library_name in enabled_optimizers
|
||||
|
||||
if (
|
||||
add_optimizer
|
||||
and optimizer_class.check_library()
|
||||
and optimizer_class not in self._registered_optimizers
|
||||
):
|
||||
self._registered_optimizers.append(optimizer_class)
|
||||
|
||||
def get_operation(self, image_class, operation_name):
|
||||
return self._registered_operations[image_class][operation_name]
|
||||
|
||||
def operation_exists(self, operation_name):
|
||||
for image_class_operations in self._registered_operations.values():
|
||||
if operation_name in image_class_operations:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_converter(self, from_image_class, to_image_class):
|
||||
return self._registered_converters[from_image_class, to_image_class]
|
||||
|
||||
def get_converter_cost(self, from_image_class, to_image_class):
|
||||
return self._registered_converter_costs.get(
|
||||
(from_image_class, to_image_class), 100
|
||||
)
|
||||
|
||||
def get_image_classes(self, with_operation=None, available=None):
|
||||
image_classes = self._registered_image_classes.copy()
|
||||
|
||||
if with_operation:
|
||||
image_classes = set(
|
||||
filter(
|
||||
lambda image_class: image_class in self._registered_operations
|
||||
and with_operation in self._registered_operations[image_class],
|
||||
image_classes,
|
||||
)
|
||||
)
|
||||
|
||||
if not image_classes:
|
||||
raise UnrecognisedOperationError(
|
||||
f"Could not find image class with the '{with_operation}' operation"
|
||||
)
|
||||
|
||||
if available:
|
||||
# Remove unavailable image classes
|
||||
available_image_classes = image_classes - set(
|
||||
self._unavailable_image_classes.keys()
|
||||
)
|
||||
|
||||
# Raise error if all image classes failed the check
|
||||
if not available_image_classes:
|
||||
raise UnavailableOperationError(
|
||||
"\n".join(
|
||||
[
|
||||
"The operation '{}' is available in the following image classes but they all raised errors:".format(
|
||||
with_operation
|
||||
)
|
||||
]
|
||||
+ [
|
||||
"{image_class_name}: {error_message}".format(
|
||||
image_class_name=image_class.__name__,
|
||||
error_message=str(
|
||||
self._unavailable_image_classes.get(
|
||||
image_class, "Unknown error"
|
||||
)
|
||||
),
|
||||
)
|
||||
for image_class in image_classes
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return available_image_classes
|
||||
else:
|
||||
return image_classes
|
||||
|
||||
def get_optimizers_for_format(self, image_format: str) -> List["OptimizerBase"]:
|
||||
optimizers = []
|
||||
for optimizer in self._registered_optimizers:
|
||||
if optimizer.applies_to(image_format):
|
||||
optimizers.append(optimizer)
|
||||
|
||||
return optimizers
|
||||
|
||||
# Routing
|
||||
|
||||
# In some cases, it may not be possible to convert directly between two
|
||||
# image classes, so we need to use one or more intermediate classes in order
|
||||
# to get to where we want to be.
|
||||
|
||||
# For example, the OpenCV plugin doesn't load JPEG images, so the image
|
||||
# needs to be loaded into either Pillow or Wand first and converted to
|
||||
# OpenCV.
|
||||
|
||||
# Using a routing algorithm, we're able to work out the best path to take.
|
||||
|
||||
def get_converters_from(self, from_image_class):
|
||||
"""
|
||||
Yields a tuple for each image class that can be directly converted
|
||||
from the specified image classes. The tuple contains the converter
|
||||
function and the image class.
|
||||
|
||||
For example:
|
||||
|
||||
>>> list(registry.get_converters_from(Pillow))
|
||||
[
|
||||
(convert_pillow_to_wand, Wand),
|
||||
(save_as_jpeg, JpegFile)
|
||||
...
|
||||
]
|
||||
"""
|
||||
for (c_from, c_to), converter in self._registered_converters.items():
|
||||
if c_from is from_image_class:
|
||||
yield converter, c_to
|
||||
|
||||
def find_all_paths(self, start, end, path=[], seen_classes=set()):
|
||||
"""
|
||||
Returns all paths between two image classes.
|
||||
|
||||
Each path is a list of tuples representing the steps to take in order to
|
||||
convert to the new class. Each tuple contains two items: The converter
|
||||
function to call and the class that step converts to.
|
||||
|
||||
The order of the paths returned is undefined.
|
||||
|
||||
For example:
|
||||
|
||||
>>> registry.find_all_paths(JpegFile, OpenCV)
|
||||
[
|
||||
[
|
||||
(load_jpeg_into_pillow, Pillow),
|
||||
(convert_pillow_to_opencv, OpenCV)
|
||||
],
|
||||
[
|
||||
(load_jpeg_into_wand, Wand),
|
||||
(convert_wand_to_opencv, OpenCV)
|
||||
]
|
||||
]
|
||||
"""
|
||||
# Implementation based on https://www.python.org/doc/essays/graphs/
|
||||
if start == end:
|
||||
return [path]
|
||||
|
||||
if start in seen_classes:
|
||||
return []
|
||||
|
||||
if (
|
||||
start not in self._registered_image_classes
|
||||
or start in self._unavailable_image_classes
|
||||
):
|
||||
return []
|
||||
|
||||
paths = []
|
||||
for converter, next_class in self.get_converters_from(start):
|
||||
if next_class not in path:
|
||||
newpaths = self.find_all_paths(
|
||||
next_class,
|
||||
end,
|
||||
path + [(converter, next_class)],
|
||||
seen_classes.union({start}),
|
||||
)
|
||||
|
||||
paths.extend(newpaths)
|
||||
|
||||
return paths
|
||||
|
||||
def get_path_cost(self, start, path):
|
||||
"""
|
||||
Costs up a path and returns the cost as an integer.
|
||||
"""
|
||||
last_class = start
|
||||
total_cost = 0
|
||||
|
||||
for converter, next_class in path:
|
||||
total_cost += self.get_converter_cost(last_class, next_class)
|
||||
last_class = next_class
|
||||
|
||||
return total_cost
|
||||
|
||||
def find_shortest_path(self, start, end):
|
||||
"""
|
||||
Finds the shortest path between two image classes.
|
||||
|
||||
This is similar to the find_all_paths function, except it only returns
|
||||
the path with the lowest cost.
|
||||
"""
|
||||
current_path = None
|
||||
current_cost = None
|
||||
|
||||
for path in self.find_all_paths(start, end):
|
||||
cost = self.get_path_cost(start, path)
|
||||
|
||||
if current_cost is None or cost < current_cost:
|
||||
current_cost = cost
|
||||
current_path = path
|
||||
|
||||
return current_path, current_cost
|
||||
|
||||
def find_closest_image_class(self, start, image_classes):
|
||||
"""
|
||||
Finds which of the specified image classes is the closest, based on the
|
||||
sum of the costs for the conversions needed to convert the image into it.
|
||||
"""
|
||||
current_class = None
|
||||
current_path = None
|
||||
current_cost = None
|
||||
|
||||
for image_class in image_classes:
|
||||
path, cost = self.find_shortest_path(start, image_class)
|
||||
|
||||
if cost is None:
|
||||
# no path found, e.g. from BMP to SVG
|
||||
continue
|
||||
|
||||
if current_cost is None or cost < current_cost:
|
||||
current_class = image_class
|
||||
current_cost = cost
|
||||
current_path = path
|
||||
|
||||
return current_class, current_path, current_cost
|
||||
|
||||
def find_operation(self, from_class, operation_name):
|
||||
"""
|
||||
Finds an operation that can be used by an image in the specified from_class.
|
||||
|
||||
This function returns four values:
|
||||
- The operation function
|
||||
- The class which the operation is implemented on
|
||||
- A path to convert the image into the correct class for the operation
|
||||
- The total cost of all the conversions
|
||||
|
||||
The path (third value) is a list of two-element tuple. Each tuple contains
|
||||
a function to call and a reference to the class that step converts to. See
|
||||
below for an example.
|
||||
|
||||
How it works:
|
||||
|
||||
If the specified operation_name is implemented for from_class, that is returned
|
||||
with an empty conversion path.
|
||||
|
||||
If the specified operation_name is implemented on another class (but not from_class)
|
||||
that operation is returned with the conversion path to that new class.
|
||||
|
||||
If it's implemented on multiple image classes, the closest one is chosen (based
|
||||
on the sum of the costs of each conversion step).
|
||||
|
||||
If the operation_name is not implemented anywhere, there is no route to
|
||||
any image class that implements it or all the image classes that implement
|
||||
it are unavailable, a LookupError will be raised.
|
||||
|
||||
Basic example:
|
||||
|
||||
>>> func, cls, path, cost = registry.find_operation(JPEGImageFile, 'resize')
|
||||
>>> func
|
||||
PillowImage.resize
|
||||
>>> cls
|
||||
PillowImage
|
||||
>>> path
|
||||
[
|
||||
(PillowImage.open, PillowImage)
|
||||
]
|
||||
>>> cost
|
||||
100
|
||||
|
||||
To run the found operation on an image, run each conversion function on that
|
||||
image then run the operation function:
|
||||
|
||||
>>> image = Image.open(...)
|
||||
>>> func, cls, path, cost = registry.find_operation(type(image), operation_name)
|
||||
>>> for converter, new_class in path:
|
||||
... image = converter(image)
|
||||
...
|
||||
>>> func(image, *args, **kwargs)
|
||||
"""
|
||||
try:
|
||||
# Firstly, we check if the operation is implemented on from_class
|
||||
func = self.get_operation(from_class, operation_name)
|
||||
cls = from_class
|
||||
path = []
|
||||
cost = 0
|
||||
except LookupError:
|
||||
# Not implemented on the current class. Find the closest, available,
|
||||
# routable class that has it instead
|
||||
image_classes = self.get_image_classes(
|
||||
with_operation=operation_name, available=True
|
||||
)
|
||||
|
||||
# Choose an image class
|
||||
# image_classes will always have a value here as get_image_classes raises
|
||||
# LookupError if there are no image classes available.
|
||||
cls, path, cost = self.find_closest_image_class(from_class, image_classes)
|
||||
|
||||
if path is None:
|
||||
raise UnroutableOperationError(
|
||||
"The operation '{}' is available in the image class '{}'"
|
||||
" but it can't be converted to from '{}'".format(
|
||||
operation_name,
|
||||
", ".join(
|
||||
image_class.__name__ for image_class in image_classes
|
||||
),
|
||||
from_class.__name__,
|
||||
)
|
||||
)
|
||||
|
||||
# Get the operation function
|
||||
func = self.get_operation(cls, operation_name)
|
||||
|
||||
return func, cls, path, cost
|
||||
|
||||
|
||||
registry = WillowRegistry()
|
||||
348
env/lib/python3.10/site-packages/willow/svg.py
vendored
Normal file
348
env/lib/python3.10/site-packages/willow/svg.py
vendored
Normal file
@@ -0,0 +1,348 @@
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from copy import copy
|
||||
from xml.etree.ElementTree import ElementTree
|
||||
|
||||
from .image import BadImageOperationError, Image, SvgImageFile
|
||||
|
||||
|
||||
class WillowSvgException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSvgAttribute(WillowSvgException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidSvgSizeAttribute(WillowSvgException):
|
||||
pass
|
||||
|
||||
|
||||
class SvgViewBoxParseError(WillowSvgException):
|
||||
pass
|
||||
|
||||
|
||||
ViewBox = namedtuple("ViewBox", "min_x min_y width height")
|
||||
|
||||
|
||||
def view_box_to_attr_str(view_box):
|
||||
return f"{view_box.min_x} {view_box.min_y} {view_box.width} {view_box.height}"
|
||||
|
||||
|
||||
class ViewportToUserSpaceTransform:
|
||||
def __init__(self, scale_x, scale_y, translate_x, translate_y):
|
||||
self.scale_x = scale_x
|
||||
self.scale_y = scale_y
|
||||
self.translate_x = translate_x
|
||||
self.translate_y = translate_y
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"{self.__class__.__name__}(scale_x={self.scale_x}, scale_y="
|
||||
f"{self.scale_y}, translate_x={self.translate_x}, "
|
||||
f"translate_y={self.translate_y})"
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return (
|
||||
self.scale_x == other.scale_x
|
||||
and self.scale_y == other.scale_y
|
||||
and self.translate_x == other.translate_x
|
||||
and self.translate_y == other.translate_y
|
||||
)
|
||||
|
||||
def __call__(self, rect):
|
||||
left, top, right, bottom = rect
|
||||
return (
|
||||
(left + self.translate_x) / self.scale_x,
|
||||
(top + self.translate_y) / self.scale_y,
|
||||
(right + self.translate_x) / self.scale_x,
|
||||
(bottom + self.translate_y) / self.scale_y,
|
||||
)
|
||||
|
||||
|
||||
def get_viewport_to_user_space_transform(
|
||||
svg: "SvgImage",
|
||||
) -> ViewportToUserSpaceTransform:
|
||||
# cairosvg used as a reference
|
||||
view_box = svg.image.view_box
|
||||
|
||||
preserve_aspect_ratio = svg.image.preserve_aspect_ratio.split()
|
||||
try:
|
||||
align, meet_or_slice = preserve_aspect_ratio
|
||||
except ValueError:
|
||||
align = preserve_aspect_ratio[0]
|
||||
meet_or_slice = None
|
||||
|
||||
scale_x = svg.image.width / view_box.width
|
||||
scale_y = svg.image.height / view_box.height
|
||||
|
||||
if align == "none":
|
||||
# if align is "none", the viewBox will be scaled non-uniformly,
|
||||
# so we keep and use both scale_x and scale_y
|
||||
x_position = "min"
|
||||
y_position = "min"
|
||||
else:
|
||||
x_position = align[1:4].lower()
|
||||
y_position = align[5:].lower()
|
||||
choose_coefficient = max if meet_or_slice == "slice" else min
|
||||
# all values of preserveAspectRatio's `align', other than
|
||||
# "none", force uniform scaling, so choose the appropriate
|
||||
# coefficient and use it for scaling both axes
|
||||
scale_x = scale_y = choose_coefficient(scale_x, scale_y)
|
||||
|
||||
# initial offsets to account for non-zero viewBox min-x and min-y
|
||||
translate_x = view_box.min_x * scale_x
|
||||
translate_y = view_box.min_y * scale_y
|
||||
|
||||
# adjust the offsets by the amount the viewBox has been translated
|
||||
# to fit into the viewport (if any)
|
||||
if x_position == "mid":
|
||||
translate_x -= (svg.image.width - view_box.width * scale_x) / 2
|
||||
elif x_position == "max":
|
||||
translate_x -= svg.image.width - view_box.width * scale_x
|
||||
|
||||
if y_position == "mid":
|
||||
translate_y -= (svg.image.height - view_box.height * scale_y) / 2
|
||||
elif y_position == "max":
|
||||
translate_y -= svg.image.height - view_box.height * scale_y
|
||||
|
||||
return ViewportToUserSpaceTransform(scale_x, scale_y, translate_x, translate_y)
|
||||
|
||||
|
||||
class SvgWrapper:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#length
|
||||
UNIT_RE = re.compile(r"(?:em|ex|px|in|cm|mm|pt|pc|%)$")
|
||||
|
||||
# https://www.w3.org/TR/SVG11/types.html#DataTypeNumber
|
||||
# https://www.w3.org/TR/2013/WD-SVG2-20130409/types.html#DataTypeNumber
|
||||
# This will exclude some inputs that Python will accept (e.g. "1.e9", "1."),
|
||||
# but for integration with other tools, we should adhere to the spec
|
||||
NUMBER_PATTERN = r"([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)"
|
||||
|
||||
# https://www.w3.org/Graphics/SVG/1.1/coords.html#ViewBoxAttribute
|
||||
VIEW_BOX_RE = re.compile(
|
||||
rf"^{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}(?:,\s*|\s+)"
|
||||
rf"{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}$"
|
||||
)
|
||||
|
||||
PRESERVE_ASPECT_RATIO_RE = re.compile(
|
||||
r"^none$|^x(Min|Mid|Max)Y(Min|Mid|Max)(\s+(meet|slice))?$",
|
||||
)
|
||||
|
||||
# Borrowed from cairosvg
|
||||
COEFFICIENTS = {
|
||||
"mm": 1 / 25.4,
|
||||
"cm": 1 / 2.54,
|
||||
"in": 1,
|
||||
"pt": 1 / 72.0,
|
||||
"pc": 1 / 6.0,
|
||||
}
|
||||
|
||||
def __init__(self, dom: ElementTree, dpi=96, font_size_px=16):
|
||||
self.dom = dom
|
||||
self.dpi = dpi
|
||||
self.font_size_px = font_size_px
|
||||
self.view_box = self._get_view_box()
|
||||
self.preserve_aspect_ratio = self._get_preserve_aspect_ratio()
|
||||
|
||||
width, width_unit = self._get_width()
|
||||
height, height_unit = self._get_height()
|
||||
# If one attr is missing or relative, we fall back to the other. After
|
||||
# this either both will be valid, or neither will, which will be handled
|
||||
# below. Relative width/height are treated as being undefined - so fall
|
||||
# back first to the other attribute, then the viewBox, then the browser
|
||||
# fallback. This gives us some flexibility for real world use cases, where
|
||||
# SVGs may have a relative height, a relative width, or both
|
||||
if width is None:
|
||||
width = height
|
||||
width_unit = height_unit
|
||||
elif height is None:
|
||||
height = width
|
||||
height_unit = width_unit
|
||||
elif width_unit == "%":
|
||||
width = height
|
||||
width_unit = height_unit
|
||||
elif height_unit == "%":
|
||||
height = width
|
||||
height_unit = width_unit
|
||||
|
||||
# If the root svg element has no width, height, or viewBox attributes,
|
||||
# emulate browser behaviour and set width and height to 300 and 150
|
||||
# respectively, and set the viewBox to match
|
||||
# (https://svgwg.org/specs/integration/#svg-css-sizing). This means we
|
||||
# can always crop and resize without needing to rasterise
|
||||
if width is None and height is None or width_unit == "%" and height_unit == "%":
|
||||
if self.view_box is not None:
|
||||
self.width = self.view_box.width
|
||||
self.height = self.view_box.height
|
||||
else:
|
||||
self.width = 300
|
||||
self.height = 150
|
||||
else:
|
||||
self.width = self._convert_to_px(width, width_unit)
|
||||
self.height = self._convert_to_px(height, height_unit)
|
||||
if self.view_box is None:
|
||||
self.view_box = ViewBox(0, 0, self.width, self.height)
|
||||
|
||||
def __copy__(self):
|
||||
# copy() called on ElementTree.Element makes a shallow copy (child
|
||||
# elements are shared with the original) so is efficient enough - we
|
||||
# only need to copy the root SVG element, as that is the only element
|
||||
# we will mutate
|
||||
dom = ElementTree(copy(self.dom.getroot()))
|
||||
return self.__class__(dom, dpi=self.dpi, font_size_px=self.font_size_px)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, f):
|
||||
return cls(SvgImageFile(f).dom)
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return self.dom.getroot()
|
||||
|
||||
def _get_preserve_aspect_ratio(self):
|
||||
value = self.root.get("preserveAspectRatio", "").strip()
|
||||
if value == "":
|
||||
return "xMidYMid meet"
|
||||
if not self.PRESERVE_ASPECT_RATIO_RE.match(value):
|
||||
raise InvalidSvgAttribute(
|
||||
f"Unable to parse preserveAspectRatio value '{value}'"
|
||||
)
|
||||
return value
|
||||
|
||||
def _get_width(self):
|
||||
attr_value = self.root.get("width")
|
||||
if attr_value:
|
||||
return self._parse_size(attr_value)
|
||||
return None, None
|
||||
|
||||
def _get_height(self):
|
||||
attr_value = self.root.get("height")
|
||||
if attr_value:
|
||||
return self._parse_size(attr_value)
|
||||
return None, None
|
||||
|
||||
def _parse_size(self, raw_value):
|
||||
clean_value = raw_value.strip()
|
||||
match = self.UNIT_RE.search(clean_value)
|
||||
unit = clean_value[match.start() :] if match else None
|
||||
amount_raw = clean_value[: -len(unit)] if unit else clean_value
|
||||
try:
|
||||
amount = float(amount_raw)
|
||||
except ValueError as err:
|
||||
raise InvalidSvgSizeAttribute(
|
||||
f"Unable to parse value from '{raw_value}'"
|
||||
) from err
|
||||
if amount <= 0:
|
||||
raise InvalidSvgSizeAttribute(f"Negative or 0 sizes are invalid ({amount})")
|
||||
return amount, unit
|
||||
|
||||
def _convert_to_px(self, size, unit):
|
||||
if unit in (None, "px"):
|
||||
return size
|
||||
elif unit == "em":
|
||||
return size * self.font_size_px
|
||||
elif unit == "ex":
|
||||
# This is not exactly correct, but it's the best we can do
|
||||
return size * self.font_size_px / 2
|
||||
else:
|
||||
return size * self.dpi * self.COEFFICIENTS[unit]
|
||||
|
||||
def _get_view_box(self):
|
||||
attr_value = self.root.get("viewBox")
|
||||
if attr_value:
|
||||
return self._parse_view_box(attr_value)
|
||||
|
||||
@classmethod
|
||||
def _parse_view_box(cls, raw_value):
|
||||
match = cls.VIEW_BOX_RE.match(raw_value.strip())
|
||||
if match is None:
|
||||
raise SvgViewBoxParseError(f"Unable to parse viewBox value '{raw_value}'")
|
||||
return ViewBox(*map(float, match.groups()))
|
||||
|
||||
def set_root_attr(self, attr, value):
|
||||
self.root.set(attr, str(value))
|
||||
|
||||
def set_width(self, width):
|
||||
self.set_root_attr("width", width)
|
||||
self.width = width
|
||||
|
||||
def set_height(self, height):
|
||||
self.set_root_attr("height", height)
|
||||
self.height = height
|
||||
|
||||
def set_view_box(self, view_box):
|
||||
self.set_root_attr("viewBox", view_box_to_attr_str(view_box))
|
||||
self.view_box = view_box
|
||||
|
||||
def write(self, f):
|
||||
self.dom.write(f, encoding="utf-8")
|
||||
|
||||
|
||||
class SvgImage(Image):
|
||||
def __init__(self, image):
|
||||
self.image: SvgWrapper = image
|
||||
|
||||
@Image.operation
|
||||
def crop(self, rect, get_transformer=get_viewport_to_user_space_transform):
|
||||
left, top, right, bottom = rect
|
||||
if left >= right or top >= bottom:
|
||||
raise BadImageOperationError(f"Invalid crop dimensions: {rect}")
|
||||
|
||||
viewport_width = right - left
|
||||
viewport_height = bottom - top
|
||||
|
||||
transformed_rect = get_transformer(self)(rect)
|
||||
left, top, right, bottom = transformed_rect
|
||||
|
||||
svg_wrapper = copy(self.image)
|
||||
view_box_width = right - left
|
||||
view_box_height = bottom - top
|
||||
svg_wrapper.set_view_box(ViewBox(left, top, view_box_width, view_box_height))
|
||||
svg_wrapper.set_width(viewport_width)
|
||||
svg_wrapper.set_height(viewport_height)
|
||||
return self.__class__(image=svg_wrapper)
|
||||
|
||||
@Image.operation
|
||||
def resize(self, size):
|
||||
new_width, new_height = size
|
||||
if new_width < 1 or new_height < 1:
|
||||
raise BadImageOperationError(f"Invalid resize dimensions: {size}")
|
||||
|
||||
svg_wrapper = copy(self.image)
|
||||
svg_wrapper.set_width(new_width)
|
||||
svg_wrapper.set_height(new_height)
|
||||
return self.__class__(image=svg_wrapper)
|
||||
|
||||
@Image.operation
|
||||
def get_size(self):
|
||||
return (self.image.width, self.image.height)
|
||||
|
||||
@Image.operation
|
||||
def auto_orient(self):
|
||||
return self
|
||||
|
||||
@Image.operation
|
||||
def has_animation(self):
|
||||
return False
|
||||
|
||||
@Image.operation
|
||||
def get_frame_count(self):
|
||||
return 1
|
||||
|
||||
def write(self, f):
|
||||
self.image.write(f)
|
||||
f.seek(0)
|
||||
|
||||
@Image.operation
|
||||
def save_as_svg(self, f):
|
||||
self.write(f)
|
||||
return SvgImageFile(f, dom=self.image.dom)
|
||||
|
||||
@classmethod
|
||||
@Image.converter_from(SvgImageFile)
|
||||
def open(cls, svg_image_file):
|
||||
return cls(image=SvgWrapper(svg_image_file.dom))
|
||||
0
env/lib/python3.10/site-packages/willow/utils/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/willow/utils/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/utils/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/utils/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/willow/utils/__pycache__/deprecation.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/willow/utils/__pycache__/deprecation.cpython-310.pyc
vendored
Normal file
Binary file not shown.
2
env/lib/python3.10/site-packages/willow/utils/deprecation.py
vendored
Normal file
2
env/lib/python3.10/site-packages/willow/utils/deprecation.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
class RemovedInWillow17Warning(DeprecationWarning):
|
||||
pass
|
||||
Reference in New Issue
Block a user