434 lines
13 KiB
Python
434 lines
13 KiB
Python
|
|
import inspect
|
||
|
|
|
||
|
|
from wagtail.images.exceptions import InvalidFilterSpecError
|
||
|
|
from wagtail.images.rect import Rect, Vector
|
||
|
|
from wagtail.images.utils import parse_color_string
|
||
|
|
|
||
|
|
|
||
|
|
class Operation:
|
||
|
|
def __init__(self, method, *args):
|
||
|
|
self.method = method
|
||
|
|
self.args = args
|
||
|
|
|
||
|
|
# Check arguments
|
||
|
|
try:
|
||
|
|
inspect.getcallargs(self.construct, *args)
|
||
|
|
except TypeError as e:
|
||
|
|
raise InvalidFilterSpecError(e)
|
||
|
|
|
||
|
|
# Call construct
|
||
|
|
try:
|
||
|
|
self.construct(*args)
|
||
|
|
except ValueError as e:
|
||
|
|
raise InvalidFilterSpecError(e)
|
||
|
|
|
||
|
|
def construct(self, *args):
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
|
||
|
|
# Transforms
|
||
|
|
|
||
|
|
|
||
|
|
class ImageTransform:
|
||
|
|
"""
|
||
|
|
Tracks transformations that are performed on an image.
|
||
|
|
|
||
|
|
This allows multiple transforms to be processed in a single operation and also
|
||
|
|
accumulates the operations into a single scale/offset which can be used for
|
||
|
|
features such as transforming the focal point of the image.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, size, image_is_svg=False):
|
||
|
|
self._check_size(size, allow_floating_point=image_is_svg)
|
||
|
|
self.image_is_svg = image_is_svg
|
||
|
|
self.size = size
|
||
|
|
self.scale = (1.0, 1.0)
|
||
|
|
self.offset = (0.0, 0.0)
|
||
|
|
|
||
|
|
def clone(self):
|
||
|
|
clone = ImageTransform(self.size, self.image_is_svg)
|
||
|
|
clone.scale = self.scale
|
||
|
|
clone.offset = self.offset
|
||
|
|
return clone
|
||
|
|
|
||
|
|
def resize(self, size):
|
||
|
|
"""
|
||
|
|
Change the image size, stretching the transform to make it fit the new size.
|
||
|
|
"""
|
||
|
|
self._check_size(size, allow_floating_point=self.image_is_svg)
|
||
|
|
clone = self.clone()
|
||
|
|
clone.scale = (
|
||
|
|
clone.scale[0] * size[0] / self.size[0],
|
||
|
|
clone.scale[1] * size[1] / self.size[1],
|
||
|
|
)
|
||
|
|
clone.size = size
|
||
|
|
return clone
|
||
|
|
|
||
|
|
def crop(self, rect):
|
||
|
|
"""
|
||
|
|
Crop the image to the specified rect.
|
||
|
|
"""
|
||
|
|
self._check_size(tuple(rect.size), allow_floating_point=self.image_is_svg)
|
||
|
|
|
||
|
|
# Transform the image so the top left of the rect is at (0, 0), then set the size
|
||
|
|
clone = self.clone()
|
||
|
|
clone.offset = (
|
||
|
|
clone.offset[0] - rect.left / self.scale[0],
|
||
|
|
clone.offset[1] - rect.top / self.scale[1],
|
||
|
|
)
|
||
|
|
clone.size = tuple(rect.size)
|
||
|
|
return clone
|
||
|
|
|
||
|
|
def transform_vector(self, vector):
|
||
|
|
"""
|
||
|
|
Transforms the given vector into the coordinate space of the final image.
|
||
|
|
|
||
|
|
Use this to find out where a point on the source image would end up in the
|
||
|
|
final image after cropping/resizing has been performed.
|
||
|
|
|
||
|
|
Returns a new vector.
|
||
|
|
"""
|
||
|
|
return Vector(
|
||
|
|
(vector.x + self.offset[0]) * self.scale[0],
|
||
|
|
(vector.y + self.offset[1]) * self.scale[1],
|
||
|
|
)
|
||
|
|
|
||
|
|
def untransform_vector(self, vector):
|
||
|
|
"""
|
||
|
|
Transforms the given vector back to the coordinate space of the source image.
|
||
|
|
|
||
|
|
This performs the inverse of `transform_vector`. Use this to find where a point
|
||
|
|
in the final cropped/resized image originated from in the source image.
|
||
|
|
|
||
|
|
Returns a new vector.
|
||
|
|
"""
|
||
|
|
return Vector(
|
||
|
|
vector.x / self.scale[0] - self.offset[0],
|
||
|
|
vector.y / self.scale[1] - self.offset[1],
|
||
|
|
)
|
||
|
|
|
||
|
|
def get_rect(self):
|
||
|
|
"""
|
||
|
|
Returns a Rect representing the region of the original image to be cropped.
|
||
|
|
"""
|
||
|
|
return Rect(
|
||
|
|
-self.offset[0],
|
||
|
|
-self.offset[1],
|
||
|
|
-self.offset[0] + self.size[0] / self.scale[0],
|
||
|
|
-self.offset[1] + self.size[1] / self.scale[1],
|
||
|
|
)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _check_size(size, allow_floating_point=False):
|
||
|
|
if not isinstance(size, tuple) or len(size) != 2:
|
||
|
|
raise TypeError("Image size must be a 2-tuple")
|
||
|
|
|
||
|
|
if not allow_floating_point and (
|
||
|
|
int(size[0]) != size[0] or int(size[1]) != size[1]
|
||
|
|
):
|
||
|
|
raise TypeError("Image size must be a 2-tuple of integers")
|
||
|
|
|
||
|
|
if size[0] < 1 or size[1] < 1:
|
||
|
|
raise ValueError("Image width and height must both be 1 or greater")
|
||
|
|
|
||
|
|
|
||
|
|
class TransformOperation(Operation):
|
||
|
|
def run(self, image, transform):
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
|
||
|
|
class FillOperation(TransformOperation):
|
||
|
|
vary_fields = (
|
||
|
|
"focal_point_width",
|
||
|
|
"focal_point_height",
|
||
|
|
"focal_point_x",
|
||
|
|
"focal_point_y",
|
||
|
|
)
|
||
|
|
|
||
|
|
def construct(self, size, *extra):
|
||
|
|
# Get width and height
|
||
|
|
width_str, height_str = size.split("x")
|
||
|
|
self.width = int(width_str)
|
||
|
|
self.height = int(height_str)
|
||
|
|
|
||
|
|
# Crop closeness
|
||
|
|
self.crop_closeness = 0
|
||
|
|
|
||
|
|
for extra_part in extra:
|
||
|
|
if extra_part.startswith("c"):
|
||
|
|
self.crop_closeness = int(extra_part[1:])
|
||
|
|
else:
|
||
|
|
raise ValueError("Unrecognised filter spec part: %s" % extra_part)
|
||
|
|
|
||
|
|
# Divide it by 100 (as it's a percentage)
|
||
|
|
self.crop_closeness /= 100
|
||
|
|
|
||
|
|
# Clamp it
|
||
|
|
if self.crop_closeness > 1:
|
||
|
|
self.crop_closeness = 1
|
||
|
|
|
||
|
|
def run(self, transform, image):
|
||
|
|
image_width, image_height = transform.size
|
||
|
|
focal_point = image.get_focal_point()
|
||
|
|
|
||
|
|
# Get crop aspect ratio
|
||
|
|
crop_aspect_ratio = self.width / self.height
|
||
|
|
|
||
|
|
# Get crop max
|
||
|
|
crop_max_scale = min(image_width, image_height * crop_aspect_ratio)
|
||
|
|
crop_max_width = crop_max_scale
|
||
|
|
crop_max_height = crop_max_scale / crop_aspect_ratio
|
||
|
|
|
||
|
|
# Initialise crop width and height to max
|
||
|
|
crop_width = crop_max_width
|
||
|
|
crop_height = crop_max_height
|
||
|
|
|
||
|
|
# Use crop closeness to zoom in
|
||
|
|
if focal_point is not None:
|
||
|
|
# Get crop min
|
||
|
|
crop_min_scale = max(
|
||
|
|
focal_point.width, focal_point.height * crop_aspect_ratio
|
||
|
|
)
|
||
|
|
crop_min_width = crop_min_scale
|
||
|
|
crop_min_height = crop_min_scale / crop_aspect_ratio
|
||
|
|
|
||
|
|
# Sometimes, the focal point may be bigger than the image...
|
||
|
|
if not crop_min_scale >= crop_max_scale:
|
||
|
|
# Calculate max crop closeness to prevent upscaling
|
||
|
|
max_crop_closeness = max(
|
||
|
|
1
|
||
|
|
- (self.width - crop_min_width) / (crop_max_width - crop_min_width),
|
||
|
|
1
|
||
|
|
- (self.height - crop_min_height)
|
||
|
|
/ (crop_max_height - crop_min_height),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Apply max crop closeness
|
||
|
|
crop_closeness = min(self.crop_closeness, max_crop_closeness)
|
||
|
|
|
||
|
|
if 1 >= crop_closeness >= 0:
|
||
|
|
# Get crop width and height
|
||
|
|
crop_width = (
|
||
|
|
crop_max_width
|
||
|
|
+ (crop_min_width - crop_max_width) * crop_closeness
|
||
|
|
)
|
||
|
|
crop_height = (
|
||
|
|
crop_max_height
|
||
|
|
+ (crop_min_height - crop_max_height) * crop_closeness
|
||
|
|
)
|
||
|
|
|
||
|
|
# Find focal point UV
|
||
|
|
if focal_point is not None:
|
||
|
|
fp_x, fp_y = focal_point.centroid
|
||
|
|
else:
|
||
|
|
# Fall back to positioning in the centre
|
||
|
|
fp_x = image_width / 2
|
||
|
|
fp_y = image_height / 2
|
||
|
|
|
||
|
|
fp_u = fp_x / image_width
|
||
|
|
fp_v = fp_y / image_height
|
||
|
|
|
||
|
|
# Position crop box based on focal point UV
|
||
|
|
crop_x = fp_x - (fp_u - 0.5) * crop_width
|
||
|
|
crop_y = fp_y - (fp_v - 0.5) * crop_height
|
||
|
|
|
||
|
|
# Convert crop box into rect
|
||
|
|
rect = Rect.from_point(crop_x, crop_y, crop_width, crop_height)
|
||
|
|
|
||
|
|
# Make sure the entire focal point is in the crop box
|
||
|
|
if focal_point is not None:
|
||
|
|
rect = rect.move_to_cover(focal_point)
|
||
|
|
|
||
|
|
# Don't allow the crop box to go over the image boundary
|
||
|
|
rect = rect.move_to_clamp(Rect(0, 0, image_width, image_height))
|
||
|
|
|
||
|
|
# Crop!
|
||
|
|
transform = transform.crop(rect.round())
|
||
|
|
|
||
|
|
# Get scale for resizing
|
||
|
|
# The scale should be the same for both the horizontal and
|
||
|
|
# vertical axes
|
||
|
|
aftercrop_width, aftercrop_height = transform.size
|
||
|
|
scale = self.width / aftercrop_width
|
||
|
|
|
||
|
|
# Only resize if the image is too big
|
||
|
|
if scale < 1.0:
|
||
|
|
# Resize!
|
||
|
|
transform = transform.resize((self.width, self.height))
|
||
|
|
|
||
|
|
return transform
|
||
|
|
|
||
|
|
|
||
|
|
class MinMaxOperation(TransformOperation):
|
||
|
|
def construct(self, size):
|
||
|
|
# Get width and height
|
||
|
|
width_str, height_str = size.split("x")
|
||
|
|
self.width = int(width_str)
|
||
|
|
self.height = int(height_str)
|
||
|
|
|
||
|
|
def run(self, transform, image):
|
||
|
|
image_width, image_height = transform.size
|
||
|
|
|
||
|
|
horz_scale = self.width / image_width
|
||
|
|
vert_scale = self.height / image_height
|
||
|
|
|
||
|
|
if self.method == "min":
|
||
|
|
if image_width <= self.width or image_height <= self.height:
|
||
|
|
return transform
|
||
|
|
|
||
|
|
if horz_scale > vert_scale:
|
||
|
|
width = self.width
|
||
|
|
height = int(image_height * horz_scale)
|
||
|
|
else:
|
||
|
|
width = int(image_width * vert_scale)
|
||
|
|
height = self.height
|
||
|
|
|
||
|
|
elif self.method == "max":
|
||
|
|
if image_width <= self.width and image_height <= self.height:
|
||
|
|
return transform
|
||
|
|
|
||
|
|
if horz_scale < vert_scale:
|
||
|
|
width = self.width
|
||
|
|
height = int(image_height * horz_scale)
|
||
|
|
else:
|
||
|
|
width = int(image_width * vert_scale)
|
||
|
|
height = self.height
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Unknown method
|
||
|
|
return transform
|
||
|
|
|
||
|
|
# prevent zero width or height, it causes a ValueError on transform.resize
|
||
|
|
width = width if width > 0 else 1
|
||
|
|
height = height if height > 0 else 1
|
||
|
|
|
||
|
|
return transform.resize((width, height))
|
||
|
|
|
||
|
|
|
||
|
|
class WidthHeightOperation(TransformOperation):
|
||
|
|
def construct(self, size):
|
||
|
|
self.size = int(size)
|
||
|
|
|
||
|
|
def run(self, transform, image):
|
||
|
|
image_width, image_height = transform.size
|
||
|
|
|
||
|
|
if self.method == "width":
|
||
|
|
if image_width <= self.size:
|
||
|
|
return transform
|
||
|
|
|
||
|
|
scale = self.size / image_width
|
||
|
|
|
||
|
|
width = self.size
|
||
|
|
height = int(image_height * scale)
|
||
|
|
|
||
|
|
elif self.method == "height":
|
||
|
|
if image_height <= self.size:
|
||
|
|
return transform
|
||
|
|
|
||
|
|
scale = self.size / image_height
|
||
|
|
|
||
|
|
width = int(image_width * scale)
|
||
|
|
height = self.size
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Unknown method
|
||
|
|
return transform
|
||
|
|
|
||
|
|
# prevent zero width or height, it causes a ValueError on transform.resize
|
||
|
|
width = width if width > 0 else 1
|
||
|
|
height = height if height > 0 else 1
|
||
|
|
|
||
|
|
return transform.resize((width, height))
|
||
|
|
|
||
|
|
|
||
|
|
class ScaleOperation(TransformOperation):
|
||
|
|
def construct(self, percent):
|
||
|
|
self.percent = float(percent)
|
||
|
|
|
||
|
|
def run(self, transform, image):
|
||
|
|
image_width, image_height = transform.size
|
||
|
|
|
||
|
|
scale = self.percent / 100
|
||
|
|
width = int(image_width * scale)
|
||
|
|
height = int(image_height * scale)
|
||
|
|
|
||
|
|
# prevent zero width or height, it causes a ValueError on transform.resize
|
||
|
|
width = width if width > 0 else 1
|
||
|
|
height = height if height > 0 else 1
|
||
|
|
|
||
|
|
return transform.resize((width, height))
|
||
|
|
|
||
|
|
|
||
|
|
# Filters
|
||
|
|
|
||
|
|
|
||
|
|
class FilterOperation(Operation):
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
|
||
|
|
class DoNothingOperation(FilterOperation):
|
||
|
|
def construct(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
return willow
|
||
|
|
|
||
|
|
|
||
|
|
class JPEGQualityOperation(FilterOperation):
|
||
|
|
def construct(self, quality):
|
||
|
|
self.quality = int(quality)
|
||
|
|
|
||
|
|
if self.quality > 100:
|
||
|
|
raise ValueError("JPEG quality must not be higher than 100")
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
env["jpeg-quality"] = self.quality
|
||
|
|
|
||
|
|
|
||
|
|
class AvifQualityOperation(FilterOperation):
|
||
|
|
def construct(self, quality):
|
||
|
|
self.quality = int(quality)
|
||
|
|
|
||
|
|
if self.quality > 100:
|
||
|
|
raise ValueError("AVIF quality must not be higher than 100")
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
env["avif-quality"] = self.quality
|
||
|
|
|
||
|
|
|
||
|
|
class WebPQualityOperation(FilterOperation):
|
||
|
|
def construct(self, quality):
|
||
|
|
self.quality = int(quality)
|
||
|
|
|
||
|
|
if self.quality > 100:
|
||
|
|
raise ValueError("WebP quality must not be higher than 100")
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
env["webp-quality"] = self.quality
|
||
|
|
|
||
|
|
|
||
|
|
class FormatOperation(FilterOperation):
|
||
|
|
supported_formats = ["jpeg", "png", "gif", "webp", "avif", "ico", "heic"]
|
||
|
|
|
||
|
|
def construct(self, format, *options):
|
||
|
|
self.format = format
|
||
|
|
self.options = options
|
||
|
|
|
||
|
|
if self.format not in self.supported_formats:
|
||
|
|
raise ValueError(
|
||
|
|
f"Format must be one of: {', '.join(self.supported_formats)}. Got: {self.format}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
env["output-format"] = self.format
|
||
|
|
env["output-format-options"] = self.options
|
||
|
|
|
||
|
|
|
||
|
|
class BackgroundColorOperation(FilterOperation):
|
||
|
|
def construct(self, color_string):
|
||
|
|
self.color = parse_color_string(color_string)
|
||
|
|
|
||
|
|
def run(self, willow, image, env):
|
||
|
|
return willow.set_background_color_rgb(self.color)
|