mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-12 01:03:11 +00:00
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:
parent
24b0712fc1
commit
11400e3df7
5 changed files with 307 additions and 19 deletions
73
wagtail/wagtailimages/backends/__init__.py
Normal file
73
wagtail/wagtailimages/backends/__init__.py
Normal 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)
|
||||
132
wagtail/wagtailimages/backends/base.py
Normal file
132
wagtail/wagtailimages/backends/base.py
Normal 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)
|
||||
41
wagtail/wagtailimages/backends/pillow_backend.py
Normal file
41
wagtail/wagtailimages/backends/pillow_backend.py
Normal 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)
|
||||
)
|
||||
39
wagtail/wagtailimages/backends/wand_backend.py
Normal file
39
wagtail/wagtailimages/backends/wand_backend.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue