From 11400e3df762da761e557516e9fd103d04588ec1 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 00:21:00 +0200 Subject: [PATCH] Add image processing backends This is a first version of Wagtail wit support for image processing backends. A first refactoring of image processing code has been done, moving the image processing code to the wagtailimages/backends package and changing models.py (more specifically, the process_image method of Filter model) to use the code there. The backends package contains the __init__.py module which defines the get_image_backend method that will get the correct image backend based on the passe parameer (or default if not parameer is passed) and the WAGTAILIMAGES_BACKENDS option. The code is copie with small modifications from similar code in wagtailsearch/backends package. Concerning the backends, a BaseImageBackend is defined in the base module. This interface defines a number of methods that the backends inheriting from it should implement. The only methods that need to be implemented is open_image, save_image, resize and crop_to_centre. The other methods are more or less the same (if the image object provides a size attribute). The two backends, wand_backend and pillow_backend provide implementations for Pillow and Wand. Both seem to be working but Wand needs some more testing. To be able to wand, we have to install Wand + ImageMagick. Follow the instructions here: http://docs.wand-py.org/en/0.3.5/guide/install.html. Things to do next: a. Check again the API that BaseImageBackend exposes -- it's not very intuitive and some things should be more DRY b. Find out a method of choosing the backend from the outside world - we may have an Image class that we want to render with Wand and another class that we want to render with Pillow. c. Assert that crop_to_centre method works fine also. d. Check resize filters of Wand (Imagemagick) e. Make tests pass with both Pillow and Wand backends --- wagtail/wagtailimages/backends/__init__.py | 73 ++++++++++ wagtail/wagtailimages/backends/base.py | 132 ++++++++++++++++++ .../wagtailimages/backends/pillow_backend.py | 41 ++++++ .../wagtailimages/backends/wand_backend.py | 39 ++++++ wagtail/wagtailimages/models.py | 41 +++--- 5 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 wagtail/wagtailimages/backends/__init__.py create mode 100644 wagtail/wagtailimages/backends/base.py create mode 100644 wagtail/wagtailimages/backends/pillow_backend.py create mode 100644 wagtail/wagtailimages/backends/wand_backend.py diff --git a/wagtail/wagtailimages/backends/__init__.py b/wagtail/wagtailimages/backends/__init__.py new file mode 100644 index 000000000..ea9a03c43 --- /dev/null +++ b/wagtail/wagtailimages/backends/__init__.py @@ -0,0 +1,73 @@ +# Backend loading +# Based on the Django cache framework and wagtailsearch +# https://github.com/django/django/blob/5d263dee304fdaf95e18d2f0619d6925984a7f02/django/core/cache/__init__.py + +from importlib import import_module +from django.utils import six +import sys +from django.conf import settings + +from base import InvalidImageBackendError + +# Pinched from django 1.7 source code. +# TODO: Replace this with "from django.utils.module_loading import import_string" +# when django 1.7 is released +# TODO: This is not DRY - should be imported from a utils module +def import_string(dotted_path): + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImportError if the import failed. + """ + try: + module_path, class_name = dotted_path.rsplit('.', 1) + except ValueError: + msg = "%s doesn't look like a module path" % dotted_path + six.reraise(ImportError, ImportError(msg), sys.exc_info()[2]) + + module = import_module(module_path) + + try: + return getattr(module, class_name) + except AttributeError: + msg = 'Module "%s" does not define a "%s" attribute/class' % ( + dotted_path, class_name) + six.reraise(ImportError, ImportError(msg), sys.exc_info()[2]) + + +def get_image_backend(backend='default', **kwargs): + # Get configuration + default_conf = { + 'default': { + 'BACKEND': 'wagtail.wagtailimages.backends.wand_backend.WandBackend', + }, + } + WAGTAILIMAGES_BACKENDS = getattr( + settings, 'WAGTAILIMAGES_BACKENDS', default_conf) + + # Try to find the backend + try: + # Try to get the WAGTAILIMAGES_BACKENDS entry for the given backend name first + conf = WAGTAILIMAGES_BACKENDS[backend] + except KeyError: + try: + # Trying to import the given backend, in case it's a dotted path + import_string(backend) + except ImportError as e: + raise InvalidImageBackendError("Could not find backend '%s': %s" % ( + backend, e)) + params = kwargs + else: + # Backend is a conf entry + params = conf.copy() + params.update(kwargs) + backend = params.pop('BACKEND') + + # Try to import the backend + try: + backend_cls = import_string(backend) + except ImportError as e: + raise InvalidImageBackendError("Could not find backend '%s': %s" % ( + backend, e)) + + # Create backend + return backend_cls(params) diff --git a/wagtail/wagtailimages/backends/base.py b/wagtail/wagtailimages/backends/base.py new file mode 100644 index 000000000..7ae700ba6 --- /dev/null +++ b/wagtail/wagtailimages/backends/base.py @@ -0,0 +1,132 @@ +from django.db import models +from django.core.exceptions import ImproperlyConfigured + +class InvalidImageBackendError(ImproperlyConfigured): + pass + + +class BaseImageBackend(object): + def __init__(self, params): + pass + + def open_image(self, input_file): + """ + Open an image and return the backend specific image object to pass + to other methods. The object return has to have a size attribute + which is a tuple with the width and height of the image and a format + attribute with the format of the image. + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + + def save_image(self, image, output): + """ + Save the image to the output + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + def resize(self, image, size): + """ + resize image to the requested size, using highest quality settings + (antialiasing enabled, converting to true colour if required) + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + + def crop_to_centre(self, image, size): + raise NotImplementedError('subclasses of BaseImageBackend must provide an crop_to_centre() method') + + + def resize_to_max(self, image, size): + """ + Resize image down to fit within the given dimensions, preserving aspect ratio. + Will leave image unchanged if it's already within those dimensions. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width and original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a smaller image + if horz_scale < vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return self.resize(image, final_size) + + + + def resize_to_min(self, image, size): + """ + Resize image down to cover the given dimensions, preserving aspect ratio. + Will leave image unchanged if width or height is already within those limits. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width or original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a larger image + if horz_scale > vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return self.resize(image, final_size) + + + def resize_to_width(self, image, target_width): + """ + Resize image down to the given width, preserving aspect ratio. + Will leave image unchanged if it's already within that width. + """ + (original_width, original_height) = image.size + + if original_width <= target_width: + return image + + scale = float(target_width) / original_width + + final_size = (target_width, int(original_height * scale)) + + return self.resize(image, final_size) + + + def resize_to_height(self, image, target_height): + """ + Resize image down to the given height, preserving aspect ratio. + Will leave image unchanged if it's already within that height. + """ + (original_width, original_height) = image.size + + if original_height <= target_height: + return image + + scale = float(target_height) / original_height + + final_size = (int(original_width * scale), target_height) + + return self.resize(image, final_size) + + + def resize_to_fill(self, image, size): + """ + Resize down and crop image to fill the given dimensions. Most suitable for thumbnails. + (The final image will match the requested size, unless one or the other dimension is + already smaller than the target size) + """ + resized_image = resize_to_min(image, size) + return self.crop_to_centre(resized_image, size) diff --git a/wagtail/wagtailimages/backends/pillow_backend.py b/wagtail/wagtailimages/backends/pillow_backend.py new file mode 100644 index 000000000..bebc76144 --- /dev/null +++ b/wagtail/wagtailimages/backends/pillow_backend.py @@ -0,0 +1,41 @@ +from django.db import models + +from wagtail.wagtailsearch.backends.base import BaseSearch +from wagtail.wagtailsearch.indexed import Indexed +from base import BaseImageBackend +import PIL.Image + +class PillowBackend(BaseImageBackend): + def __init__(self, params): + super(PillowBackend, self).__init__(params) + + def open_image(self, input_file): + image = PIL.Image.open(input_file) + return image + + def save_image(self, image, output, format): + image.save(output, format) + + + def resize(self, image, size): + if image.mode in ['1', 'P']: + image = image.convert('RGB') + return image.resize(size, PIL.Image.ANTIALIAS) + + + def crop_to_centre(self, image, size): + (original_width, original_height) = image.size + (target_width, target_height) = size + + # final dimensions should not exceed original dimensions + final_width = min(original_width, target_width) + final_height = min(original_height, target_height) + + if final_width == original_width and final_height == original_height: + return image + + left = (original_width - final_width) / 2 + top = (original_height - final_height) / 2 + return image.crop( + (left, top, left + final_width, top + final_height) + ) \ No newline at end of file diff --git a/wagtail/wagtailimages/backends/wand_backend.py b/wagtail/wagtailimages/backends/wand_backend.py new file mode 100644 index 000000000..339453907 --- /dev/null +++ b/wagtail/wagtailimages/backends/wand_backend.py @@ -0,0 +1,39 @@ +from django.db import models +from django.conf import settings +from base import BaseImageBackend + +from wand.image import Image + +class WandBackend(BaseImageBackend): + def __init__(self, params): + super(WandBackend, self).__init__(params) + + def open_image(self, input_file): + image = Image(file=input_file) + return image + + def save_image(self, image, output, format): + image.format = format + image.save(file=output) + + def resize(self, image, size): + image.resize(size[0], size[1]) + return image + + + def crop_to_centre(self, image, size): + (original_width, original_height) = image.size + (target_width, target_height) = size + + # final dimensions should not exceed original dimensions + final_width = min(original_width, target_width) + final_height = min(original_height, target_height) + + if final_width == original_width and final_height == original_height: + return image + + left = (original_width - final_width) / 2 + top = (original_height - final_height) / 2 + return image.crop( + (left, top, left + final_width, top + final_height) + ) \ No newline at end of file diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 61082c2cb..f15556d95 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -1,7 +1,6 @@ import StringIO import os.path -import PIL.Image from taggit.managers import TaggableManager from django.core.files import File @@ -17,8 +16,7 @@ from django.utils.translation import ugettext_lazy as _ from unidecode import unidecode from wagtail.wagtailadmin.taggable import TagSearchable -from wagtail.wagtailimages import image_ops - +from wagtail.wagtailimages.backends import get_image_backend class AbstractImage(models.Model, TagSearchable): title = models.CharField(max_length=255, verbose_name=_('Title') ) @@ -146,11 +144,11 @@ class Filter(models.Model): spec = models.CharField(max_length=255, db_index=True) OPERATION_NAMES = { - 'max': image_ops.resize_to_max, - 'min': image_ops.resize_to_min, - 'width': image_ops.resize_to_width, - 'height': image_ops.resize_to_height, - 'fill': image_ops.resize_to_fill, + 'max': 'resize_to_max', + 'min': 'resize_to_min', + 'width': 'resize_to_width', + 'height': 'resize_to_height', + 'fill': 'resize_to_fill', } def __init__(self, *args, **kwargs): @@ -159,12 +157,12 @@ class Filter(models.Model): def _parse_spec_string(self): # parse the spec string, which is formatted as (method)-(arg), - # and save the results to self.method and self.method_arg + # and save the results to self.method_name and self.method_arg try: - (method_name, method_arg_string) = self.spec.split('-') - self.method = Filter.OPERATION_NAMES[method_name] + (method_name_simple, method_arg_string) = self.spec.split('-') + self.method_name = Filter.OPERATION_NAMES[method_name_simple] - if method_name in ('max', 'min', 'fill'): + if method_name_simple in ('max', 'min', 'fill'): # method_arg_string is in the form 640x480 (width, height) = [int(i) for i in method_arg_string.split('x')] self.method_arg = (width, height) @@ -181,18 +179,23 @@ class Filter(models.Model): generate an output image with this filter applied, returning it as another django.core.files.File object """ + + # TODO: Pass default this as a parameter + backend = get_image_backend('default') + if not self.method: self._parse_spec_string() - - input_file.open() - image = PIL.Image.open(input_file) + + image = backend.open_image(input_file) file_format = image.format - # perform the resize operation - image = self.method(image, self.method_arg) + method = getattr(backend, self.method_name) + + image = method(image, self.method_arg) output = StringIO.StringIO() - image.save(output, file_format) + backend.save_image(image, output, file_format) + # generate new filename derived from old one, inserting the filter spec string before the extension input_filename_parts = os.path.basename(input_file.name).split('.') @@ -202,7 +205,7 @@ class Filter(models.Model): output_filename = '.'.join(output_filename_parts) output_file = File(output, name=output_filename) - input_file.close() + return output_file