123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- 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)
|