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
This commit is contained in:
Serafeim Papastefanos 2014-03-11 00:21:00 +02:00
parent 24b0712fc1
commit 11400e3df7
5 changed files with 307 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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