Merge branch 'develop' into templatetags

Conflicts:
	imagekit/utils.py
This commit is contained in:
Eric Eldredge 2012-09-05 21:46:59 -04:00
commit c50e6cea3b
32 changed files with 1158 additions and 657 deletions

7
.travis.yml Normal file
View file

@ -0,0 +1,7 @@
language: python
python:
- 2.7
install: pip install tox --use-mirrors
script: tox -e py27-django13,py27-django12,py26-django13,py27-django12
notifications:
irc: "irc.freenode.org#imagekit"

View file

@ -22,7 +22,9 @@ Contributors
* `Alexander Bohn`_
* `Timothée Peignier`_
* `Madis Väin`_
* `Jan Sagemüller`_
* `Clay McClure`_
* `Jannis Leidel`_
.. _Justin Driscoll: http://github.com/jdriscoll
.. _HZDG: http://hzdg.com
@ -39,3 +41,6 @@ Contributors
.. _Alexander Bohn: http://github.com/fish2000
.. _Timothée Peignier: http://github.com/cyberdelia
.. _Madis Väin: http://github.com/madisvain
.. _Jan Sagemüller: https://github.com/version2
.. _Clay McClure: https://github.com/claymation
.. _Jannis Leidel: https://github.com/jezdez

View file

@ -1,11 +1,12 @@
ImageKit is a Django app that helps you to add variations of uploaded images
to your models. These variations are called "specs" and can include things
like different sizes (e.g. thumbnails) and black and white versions.
For the full documentation, see `ImageKit on RTD`_.
**For the complete documentation on the latest stable version of ImageKit, see**
`ImageKit on RTD`_. Our `changelog is also available`_.
.. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org
.. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html
Installation
@ -30,10 +31,12 @@ Adding Specs to a Model
-----------------------
Much like ``django.db.models.ImageField``, Specs are defined as properties
of a model class::
of a model class:
.. code-block:: python
from django.db import models
from imagekit.models.fields import ImageSpecField
from imagekit.models import ImageSpecField
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
@ -42,13 +45,28 @@ of a model class::
Accessing the spec through a model instance will create the image and return
an ImageFile-like object (just like with a normal
``django.db.models.ImageField``)::
``django.db.models.ImageField``):
.. code-block:: python
photo = Photo.objects.all()[0]
photo.original_image.url # > '/media/photos/birthday.tiff'
photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg'
Check out ``imagekit.models.fields.ImageSpecField`` for more information.
Check out ``imagekit.models.ImageSpecField`` for more information.
If you only want to save the processed image (without maintaining the original),
you can use a ``ProcessedImageField``:
.. code-block:: python
from django.db import models
from imagekit.models.fields import ProcessedImageField
class Photo(models.Model):
processed_image = ProcessedImageField(format='JPEG', options={'quality': 90})
See the class documentation for details.
Processors
@ -56,19 +74,23 @@ Processors
The real power of ImageKit comes from processors. Processors take an image, do
something to it, and return the result. By providing a list of processors to
your spec, you can expose different versions of the original image::
your spec, you can expose different versions of the original image:
.. code-block:: python
from django.db import models
from imagekit.models.fields import ImageSpecField
from imagekit.processors import resize, Adjust
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFill, Adjust
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
resize.Fill(50, 50)], image_field='original_image',
ResizeToFill(50, 50)], image_field='original_image',
format='JPEG', options={'quality': 90})
The ``thumbnail`` property will now return a cropped image::
The ``thumbnail`` property will now return a cropped image:
.. code-block:: python
photo = Photo.objects.all()[0]
photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg'
@ -76,13 +98,16 @@ The ``thumbnail`` property will now return a cropped image::
photo.original_image.width # > 1000
The original image is not modified; ``thumbnail`` is a new file that is the
result of running the ``imagekit.processors.resize.Fill`` processor on the
original.
result of running the ``imagekit.processors.ResizeToFill`` processor on the
original. (If you only need to save the processed image, and not the original,
pass processors to a ``ProcessedImageField`` instead of an ``ImageSpecField``.)
The ``imagekit.processors`` module contains processors for many common
image manipulations, like resizing, rotating, and color adjustments. However,
if they aren't up to the task, you can create your own. All you have to do is
implement a ``process()`` method::
implement a ``process()`` method:
.. code-block:: python
class Watermark(object):
def process(self, image):
@ -101,7 +126,9 @@ Admin
ImageKit also contains a class named ``imagekit.admin.AdminThumbnail``
for displaying specs (or even regular ImageFields) in the
`Django admin change list`_. AdminThumbnail is used as a property on
Django admin classes::
Django admin classes:
.. code-block:: python
from django.contrib import admin
from imagekit.admin import AdminThumbnail
@ -119,3 +146,60 @@ AdminThumbnail can even use a custom template. For more information, see
``imagekit.admin.AdminThumbnail``.
.. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list
Image Cache Backends
--------------------
Whenever you access properties like ``url``, ``width`` and ``height`` of an
``ImageSpecField``, its cached image is validated; whenever you save a new image
to the ``ImageField`` your spec uses as a source, the spec image is invalidated.
The default way to validate a cache image is to check to see if the file exists
and, if not, generate a new one; the default way to invalidate the cache is to
delete the image. This is a very simple and straightforward way to handle cache
validation, but it has its drawbacks—for example, checking to see if the image
exists means frequently hitting the storage backend.
Because of this, ImageKit allows you to define custom image cache backends. To
be a valid image cache backend, a class must implement three methods:
``validate``, ``invalidate``, and ``clear`` (which is called when the image is
no longer needed in any form, i.e. the model is deleted). Each of these methods
must accept a file object, but the internals are up to you. For example, you
could store the state (valid, invalid) of the cache in a database to avoid
filesystem access. You can then specify your image cache backend on a per-field
basis:
.. code-block:: python
class Photo(models.Model):
...
thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend())
Or in your ``settings.py`` file if you want to use it as the default:
.. code-block:: python
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend'
Community
---------
Please use `the GitHub issue tracker <https://github.com/jdriscoll/django-imagekit/issues>`_
to report bugs with django-imagekit. `A mailing list <https://groups.google.com/forum/#!forum/django-imagekit>`_
also exists to discuss the project and ask questions, as well as the official
`#imagekit <irc://irc.freenode.net/imagekit>`_ channel on Freenode.
Contributing
------------
We love contributions! And you don't have to be an expert with the library—or
even Django—to contribute either: ImageKit's processors are standalone classes
that are completely separate from the more intimidating internals of Django's
ORM. If you've written a processor that you think might be useful to other
people, open a pull request so we can take a look!
ImageKit's image cache backends are also fairly isolated from the ImageKit guts.
If you've fine-tuned one to work perfectly for a popular file storage backend,
let us take a look! Maybe other people could use it.

View file

@ -5,7 +5,7 @@ API Reference
:mod:`models` Module
--------------------
.. automodule:: imagekit.models
.. automodule:: imagekit.models.fields
:members:
@ -13,13 +13,13 @@ API Reference
------------------------
.. automodule:: imagekit.processors
:members:
:members:
.. automodule:: imagekit.processors.resize
:members:
:members:
.. automodule:: imagekit.processors.crop
:members:
:members:
:mod:`admin` Module

View file

@ -1,6 +1,76 @@
Changelog
=========
v2.0.1
------
- Fixed a file descriptor leak in the `utils.quiet()` context manager.
v2.0.0
------
- Added the concept of image cache backends. Image cache backends assume
control of validating and invalidating the cached images from `ImageSpec` in
versions past. The default backend maintins the current behavior: invalidating
an image deletes it, while validating checks whether the file exists and
creates the file if it doesn't. One can create custom image cache backends to
control how their images are cached (e.g., Celery, etc.).
ImageKit ships with three built-in backends:
- ``imagekit.imagecache.PessimisticImageCacheBackend`` - A very safe image
cache backend. Guarantees that files will always be available, but at the
cost of hitting the storage backend.
- ``imagekit.imagecache.NonValidatingImageCacheBackend`` - A backend that is
super optimistic about the existence of spec files. It will hit your file
storage much less frequently than the pessimistic backend, but it is
technically possible for a cache file to be missing after validation.
- ``imagekit.imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache
state backend that uses celery to generate its spec images. Like
``PessimisticCacheStateBackend``, this one checks to see if the file
exists on validation, so the storage is hit fairly frequently, but an
image is guaranteed to exist. However, while validation guarantees the
existence of *an* image, it does not necessarily guarantee that you will
get the correct image, as the spec may be pending regeneration. In other
words, while there are ``generate`` tasks in the queue, it is possible to
get a stale spec image. The tradeoff is that calling ``invalidate()``
won't block to interact with file storage.
- Some of the processors have been renamed and several new ones have been added:
- ``imagekit.processors.ResizeToFill`` - (previously
``imagekit.processors.resize.Crop``) Scales the image to fill the provided
dimensions and then trims away the excess.
- ``imagekit.processors.ResizeToFit`` - (previously
``imagekit.processors.resize.Fit``) Scale to fit the provided dimensions.
- ``imagekit.processors.SmartResize`` - Like ``ResizeToFill``, but crops using
entroy (``SmartCrop``) instead of an anchor argument.
- ``imagekit.processors.BasicCrop`` - Crop using provided box.
- ``imagekit.processors.SmartCrop`` - (previously
``imagekit.processors.resize.SmartCrop``) Crop to provided size, trimming
based on entropy.
- ``imagekit.processors.TrimBorderColor`` - Trim the specified color from the
specified sides.
- ``imagekit.processors.AddBorder`` - Add a border of specific color and
thickness to an image.
- ``imagekit.processors.Resize`` - Scale to the provided dimensions (can distort).
- ``imagekit.processors.ResizeToCover`` - Scale to the smallest size that will
cover the specified dimensions. Used internally by ``Fill`` and
``SmartFill``.
- ``imagekit.processors.ResizeCanvas`` - Takes an image an resizes the canvas,
using a specific background color if the new size is larger than the current
image.
- ``mat_color`` has been added as an arguemnt to ``ResizeToFit``. If set, the
the target image size will be enforced and the specified color will be
used as background color to pad the image.
- We now use `Tox`_ to automate testing.
.. _`Tox`: http://pypi.python.org/pypi/tox
v1.1.0
------
@ -15,6 +85,7 @@ v1.1.0
- The private ``_Resize`` class has been removed.
v1.0.3
------
@ -30,6 +101,7 @@ v1.0.3
- Fixed PIL zeroing out files when write mode is enabled.
v1.0.2
------
@ -42,6 +114,7 @@ v1.0.2
- Fixed a regression from the 0.4.x series in which ImageKit was unable to
convert a PNG file in ``P`` or "palette" mode to JPEG.
v1.0.1
------
@ -51,6 +124,7 @@ v1.0.1
- Fixed the included admin template not being found when ImageKit was and
the packaging of the included admin templates.
v1.0
----

View file

@ -16,8 +16,9 @@ import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath('..'))
sys.path.append(os.path.abspath('_themes'))
os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
# -- General configuration -----------------------------------------------------
@ -49,9 +50,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett &
# built documents.
#
# The short X.Y version.
version = '1.1.0'
version = '2.0.1'
# The full version, including alpha/beta/rc tags.
release = '1.1.0'
release = '2.0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -121,7 +122,7 @@ html_theme_path = ['_themes']
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.

View file

@ -7,7 +7,9 @@ Getting Started
Commands
--------
.. automodule:: imagekit.management.commands.ikflush
.. automodule:: imagekit.management.commands.ikcacheinvalidate
.. automodule:: imagekit.management.commands.ikcachevalidate
Authors
@ -16,6 +18,14 @@ Authors
.. include:: ../AUTHORS
Community
---------
The official Freenode channel for ImageKit is `#imagekit <irc://irc.freenode.net/imagekit>`_.
You should always find some fine people to answer your questions
about ImageKit there.
Digging Deeper
--------------

View file

@ -1,4 +1,34 @@
__title__ = 'django-imagekit'
__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge'
__version__ = (1, 1, 0, 'final', 0)
__version__ = (2, 0, 1, 'final', 0)
__license__ = 'BSD'
def get_version(version=None):
"""Derives a PEP386-compliant version number from VERSION."""
if version is None:
version = __version__
assert len(version) == 5
assert version[3] in ('alpha', 'beta', 'rc', 'final')
# Now build the two parts of the version number:
# main = X.Y[.Z]
# sub = .devN - for pre-alpha releases
# | {a|b|c}N - for alpha, beta and rc releases
parts = 2 if version[2] == 0 else 3
main = '.'.join(str(x) for x in version[:parts])
sub = ''
if version[3] == 'alpha' and version[4] == 0:
# At the toplevel, this would cause an import loop.
from django.utils.version import get_svn_revision
svn_revision = get_svn_revision()[4:]
if svn_revision != 'unknown':
sub = '.dev%s' % svn_revision
elif version[3] != 'final':
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
sub = mapping[version[3]] + str(version[4])
return main + sub

View file

@ -21,11 +21,14 @@ class AdminThumbnail(object):
self.template = template
def __call__(self, obj):
try:
thumbnail = getattr(obj, self.image_field)
except AttributeError:
raise Exception('The property %s is not defined on %s.' % \
(self.image_field, obj.__class__.__name__))
if callable(self.image_field):
thumbnail = self.image_field(obj)
else:
try:
thumbnail = getattr(obj, self.image_field)
except AttributeError:
raise Exception('The property %s is not defined on %s.' % \
(self.image_field, obj.__class__.__name__))
original_image = getattr(thumbnail, 'source_file', None) or thumbnail
template = self.template or 'imagekit/admin/thumbnail.html'

5
imagekit/conf.py Normal file
View file

@ -0,0 +1,5 @@
from appconf import AppConf
class ImageKitConf(AppConf):
DEFAULT_IMAGE_CACHE_BACKEND = 'imagekit.imagecache.PessimisticImageCacheBackend'

View file

@ -1,20 +1,16 @@
import os
from StringIO import StringIO
from django.core.files.base import ContentFile
from .processors import ProcessorPipeline, AutoConvert
from .utils import img_to_fobj, open_image, \
format_to_extension, extension_to_format, UnknownFormatError, \
UnknownExtensionError
from .lib import StringIO
from .processors import ProcessorPipeline
from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format,
UnknownExtensionError)
class SpecFileGenerator(object):
def __init__(self, processors=None, format=None, options={},
def __init__(self, processors=None, format=None, options=None,
autoconvert=True, storage=None):
self.processors = processors
self.format = format
self.options = options
self.options = options or {}
self.autoconvert = autoconvert
self.storage = storage
@ -32,7 +28,7 @@ class SpecFileGenerator(object):
# Determine the format.
format = self.format
if not format:
if filename and not format:
# Try to guess the format from the extension.
extension = os.path.splitext(filename)[1].lower()
if extension:
@ -42,39 +38,10 @@ class SpecFileGenerator(object):
pass
format = format or img.format or original_format or 'JPEG'
# Run the AutoConvert processor
if self.autoconvert:
autoconvert_processor = AutoConvert(format)
img = autoconvert_processor.process(img)
options = dict(autoconvert_processor.save_kwargs.items() + \
options.items())
imgfile = img_to_fobj(img, format, **options)
content = ContentFile(imgfile.read())
content = IKContentFile(filename, imgfile.read(), format=format)
return img, content
def suggest_extension(self, name):
original_extension = os.path.splitext(name)[1]
try:
suggested_extension = format_to_extension(self.format)
except UnknownFormatError:
extension = original_extension
else:
if suggested_extension.lower() == original_extension.lower():
extension = original_extension
else:
try:
original_format = extension_to_format(original_extension)
except UnknownExtensionError:
extension = suggested_extension
else:
# If the formats match, give precedence to the original extension.
if self.format.lower() == original_format.lower():
extension = original_extension
else:
extension = suggested_extension
return extension
def generate_file(self, filename, source_file, save=True):
"""
Generates a new image file by processing the source file and returns

View file

@ -0,0 +1,34 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.importlib import import_module
from imagekit.imagecache.base import InvalidImageCacheBackendError, PessimisticImageCacheBackend, NonValidatingImageCacheBackend
_default_image_cache_backend = None
def get_default_image_cache_backend():
"""
Get the default image cache backend. Uses the same method as
django.core.file.storage.get_storage_class
"""
global _default_image_cache_backend
if not _default_image_cache_backend:
from django.conf import settings
import_path = settings.IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND
try:
dot = import_path.rindex('.')
except ValueError:
raise ImproperlyConfigured("%s isn't an image cache backend module." % \
import_path)
module, classname = import_path[:dot], import_path[dot + 1:]
try:
mod = import_module(module)
except ImportError, e:
raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e))
try:
cls = getattr(mod, classname)
_default_image_cache_backend = cls()
except AttributeError:
raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname))
return _default_image_cache_backend

View file

@ -1,5 +1,8 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.importlib import import_module
class InvalidImageCacheBackendError(ImproperlyConfigured):
pass
class PessimisticImageCacheBackend(object):
@ -55,33 +58,3 @@ class NonValidatingImageCacheBackend(object):
def clear(self, file):
file.delete(save=False)
_default_image_cache_backend = None
def get_default_image_cache_backend():
"""
Get the default image cache backend. Uses the same method as
django.core.file.storage.get_storage_class
"""
global _default_image_cache_backend
if not _default_image_cache_backend:
from .settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path
try:
dot = import_path.rindex('.')
except ValueError:
raise ImproperlyConfigured("%s isn't an image cache backend module." % \
import_path)
module, classname = import_path[:dot], import_path[dot+1:]
try:
mod = import_module(module)
except ImportError, e:
raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e))
try:
cls = getattr(mod, classname)
_default_image_cache_backend = cls()
except AttributeError:
raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname))
return _default_image_cache_backend

View file

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError
def generate(model, pk, attr):
try:
instance = model._default_manager.get(pk=pk)
except model.DoesNotExist:
pass # The model was deleted since the task was scheduled. NEVER MIND!
else:
field_file = getattr(instance, attr)
field_file.delete(save=False)
field_file.generate(save=True)
class CeleryImageCacheBackend(PessimisticImageCacheBackend):
"""
A pessimistic cache state backend that uses celery to generate its spec
images. Like PessimisticCacheStateBackend, this one checks to see if the
file exists on validation, so the storage is hit fairly frequently, but an
image is guaranteed to exist. However, while validation guarantees the
existence of *an* image, it does not necessarily guarantee that you will get
the correct image, as the spec may be pending regeneration. In other words,
while there are `generate` tasks in the queue, it is possible to get a
stale spec image. The tradeoff is that calling `invalidate()` won't block
to interact with file storage.
"""
def __init__(self):
try:
from celery.task import task
except:
raise InvalidImageCacheBackendError("Celery image cache backend requires the 'celery' library")
if not getattr(CeleryImageCacheBackend, '_task', None):
CeleryImageCacheBackend._task = task(generate)
def invalidate(self, file):
self._task.delay(file.instance.__class__, file.instance.pk, file.attname)
def clear(self, file):
file.delete(save=False)

View file

@ -15,3 +15,8 @@ except ImportError:
import ImageStat
except ImportError:
raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
try:
from cStringIO import StringIO
except ImportError:
from StringIO import StringIO

View file

@ -1,3 +1,4 @@
from .. import conf
from .fields import ImageSpecField, ProcessedImageField
import warnings
@ -5,6 +6,6 @@ import warnings
class ImageSpec(ImageSpecField):
def __init__(self, *args, **kwargs):
warnings.warn('ImageSpec has been moved to'
' imagekit.models.fields.ImageSpecField. Please use that'
' instead.', DeprecationWarning)
' imagekit.models.ImageSpecField. Please use that instead.',
DeprecationWarning)
super(ImageSpec, self).__init__(*args, **kwargs)

View file

@ -1,10 +1,16 @@
import os
from django.db import models
from django.db.models.signals import post_init, post_save, post_delete
from ...imagecache import get_default_image_cache_backend
from ...generators import SpecFileGenerator
from .files import ImageSpecFieldFile, ProcessedImageFieldFile
from .utils import ImageSpecFieldDescriptor, ImageKitMeta, BoundImageKitMeta
from ..receivers import configure_receivers
from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta
from ...utils import suggest_extension
configure_receivers()
class ImageSpecField(object):
@ -13,7 +19,7 @@ class ImageSpecField(object):
variants of uploaded images to your models.
"""
def __init__(self, processors=None, format=None, options={},
def __init__(self, processors=None, format=None, options=None,
image_field=None, pre_cache=None, storage=None, cache_to=None,
autoconvert=True, image_cache_backend=None):
"""
@ -45,8 +51,8 @@ class ImageSpecField(object):
based on that format. if not, the extension of the
original file will be passed. You do not have to use
this extension, it's only a recommendation.
:param autoconvert: Specifies whether the AutoConvert processor
should be run before saving.
:param autoconvert: Specifies whether automatic conversion using
``prepare_image()`` should be performed prior to saving.
:param image_cache_backend: An object responsible for managing the state
of cached files. Defaults to an instance of
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND
@ -73,59 +79,27 @@ class ImageSpecField(object):
get_default_image_cache_backend()
def contribute_to_class(self, cls, name):
setattr(cls, name, ImageSpecFieldDescriptor(self, name))
setattr(cls, name, ImageSpecFileDescriptor(self, name))
try:
ik = getattr(cls, '_ik')
except AttributeError:
ik = ImageKitMeta()
# Make sure we don't modify an inherited ImageKitMeta instance
ik = cls.__dict__['ik']
except KeyError:
try:
base = getattr(cls, '_ik')
except AttributeError:
ik = ImageKitMeta()
else:
# Inherit all the spec fields.
ik = ImageKitMeta(base.spec_fields)
setattr(cls, '_ik', ik)
ik.spec_fields.append(name)
# Connect to the signals only once for this class.
uid = '%s.%s' % (cls.__module__, cls.__name__)
post_init.connect(ImageSpecField._post_init_receiver, sender=cls,
dispatch_uid=uid)
post_save.connect(ImageSpecField._post_save_receiver, sender=cls,
dispatch_uid=uid)
post_delete.connect(ImageSpecField._post_delete_receiver, sender=cls,
dispatch_uid=uid)
# Register the field with the image_cache_backend
try:
self.image_cache_backend.register_field(cls, self, name)
except AttributeError:
pass
@staticmethod
def _post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs):
if not raw:
old_hashes = instance._ik._source_hashes.copy()
new_hashes = ImageSpecField._update_source_hashes(instance)
for attname in instance._ik.spec_fields:
if old_hashes[attname] != new_hashes[attname]:
getattr(instance, attname).invalidate()
@staticmethod
def _update_source_hashes(instance):
"""
Stores hashes of the source image files so that they can be compared
later to see whether the source image has changed (and therefore whether
the spec file needs to be regenerated).
"""
instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \
for f in instance._ik.spec_files)
return instance._ik._source_hashes
@staticmethod
def _post_delete_receiver(sender, instance=None, **kwargs):
for spec_file in instance._ik.spec_files:
spec_file.clear()
@staticmethod
def _post_init_receiver(sender, instance, **kwargs):
ImageSpecField._update_source_hashes(instance)
class ProcessedImageField(models.ImageField):
"""
@ -137,14 +111,14 @@ class ProcessedImageField(models.ImageField):
"""
attr_class = ProcessedImageFieldFile
def __init__(self, processors=None, format=None, options={},
def __init__(self, processors=None, format=None, options=None,
verbose_name=None, name=None, width_field=None, height_field=None,
autoconvert=True, **kwargs):
"""
The ProcessedImageField constructor accepts all of the arguments that
the :class:`django.db.models.ImageField` constructor accepts, as well
as the ``processors``, ``format``, and ``options`` arguments of
:class:`imagekit.models.fields.ImageSpecField`.
:class:`imagekit.models.ImageSpecField`.
"""
if 'quality' in kwargs:
@ -160,8 +134,8 @@ class ProcessedImageField(models.ImageField):
filename = os.path.normpath(self.storage.get_valid_name(
os.path.basename(filename)))
name, ext = os.path.splitext(filename)
ext = self.generator.suggest_extension(filename)
return '%s%s' % (name, ext)
ext = suggest_extension(filename, self.generator.format)
return u'%s%s' % (name, ext)
try:

View file

@ -1,15 +1,16 @@
import os
import datetime
from django.db.models.fields.files import ImageFieldFile
from django.db.models.fields.files import ImageField, ImageFieldFile
from django.utils.encoding import force_unicode, smart_str
from ...utils import suggest_extension
class ImageSpecFieldFile(ImageFieldFile):
def __init__(self, instance, field, attname):
ImageFieldFile.__init__(self, instance, field, None)
super(ImageSpecFieldFile, self).__init__(instance, field, None)
self.attname = attname
self.storage = self.field.storage or self.source_file.storage
@property
def source_file(self):
@ -19,7 +20,7 @@ class ImageSpecFieldFile(ImageFieldFile):
else:
image_fields = [getattr(self.instance, f.attname) for f in \
self.instance.__class__._meta.fields if \
isinstance(f, models.ImageField)]
isinstance(f, ImageField)]
if len(image_fields) == 0:
raise Exception('%s does not define any ImageFields, so your' \
' %s ImageSpecField has no image to act on.' % \
@ -36,12 +37,8 @@ class ImageSpecFieldFile(ImageFieldFile):
def _require_file(self):
if not self.source_file:
raise ValueError("The '%s' attribute's image_field has no file associated with it." % self.attname)
def _get_file(self):
self.validate()
return super(ImageFieldFile, self).file
file = property(_get_file, ImageFieldFile._set_file, ImageFieldFile._del_file)
else:
self.validate()
def clear(self):
return self.field.image_cache_backend.clear(self)
@ -61,11 +58,6 @@ class ImageSpecFieldFile(ImageFieldFile):
return self.field.generator.generate_file(self.name, self.source_file,
save)
@property
def url(self):
self.validate()
return super(ImageFieldFile, self).url
def delete(self, save=False):
"""
Pulled almost verbatim from ``ImageFieldFile.delete()`` and
@ -107,7 +99,7 @@ class ImageSpecFieldFile(ImageFieldFile):
filepath, basename = os.path.split(path)
filename = os.path.splitext(basename)[0]
new_name = '%s_%s%s' % (filename, specname, extension)
return os.path.join(os.path.join('cache', filepath), new_name)
return os.path.join('cache', filepath, new_name)
@property
def name(self):
@ -127,9 +119,8 @@ class ImageSpecFieldFile(ImageFieldFile):
raise Exception('No cache_to or default_cache_to value'
' specified')
if callable(cache_to):
suggested_extension = \
self.field.generator.suggest_extension(
self.source_file.name)
suggested_extension = suggest_extension(
self.source_file.name, self.field.generator.format)
new_filename = force_unicode(
datetime.datetime.now().strftime(
smart_str(cache_to(self.instance,
@ -153,6 +144,25 @@ class ImageSpecFieldFile(ImageFieldFile):
# it at least that one time.
pass
@property
def storage(self):
return getattr(self, '_storage', None) or self.field.storage or self.source_file.storage
@storage.setter
def storage(self, storage):
self._storage = storage
def __getstate__(self):
return dict(
attname=self.attname,
instance=self.instance,
)
def __setstate__(self, state):
self.attname = state['attname']
self.instance = state['instance']
self.field = getattr(self.instance.__class__, self.attname)
class ProcessedImageFieldFile(ImageFieldFile):
def save(self, name, content, save=True):

View file

@ -13,7 +13,7 @@ class BoundImageKitMeta(object):
class ImageKitMeta(object):
def __init__(self, spec_fields=None):
self.spec_fields = spec_fields or []
self.spec_fields = list(spec_fields) if spec_fields else []
def __get__(self, instance, owner):
if instance is None:
@ -24,7 +24,7 @@ class ImageKitMeta(object):
return ik
class ImageSpecFieldDescriptor(object):
class ImageSpecFileDescriptor(object):
def __init__(self, field, attname):
self.attname = attname
self.field = field
@ -35,5 +35,8 @@ class ImageSpecFieldDescriptor(object):
else:
img_spec_file = ImageSpecFieldFile(instance, self.field,
self.attname)
setattr(instance, self.attname, img_spec_file)
instance.__dict__[self.attname] = img_spec_file
return img_spec_file
def __set__(self, instance, value):
instance.__dict__[self.attname] = value

View file

@ -0,0 +1,48 @@
from django.db.models.signals import post_init, post_save, post_delete
from ..utils import ik_model_receiver
def update_source_hashes(instance):
"""
Stores hashes of the source image files so that they can be compared
later to see whether the source image has changed (and therefore whether
the spec file needs to be regenerated).
"""
instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \
for f in instance._ik.spec_files)
return instance._ik._source_hashes
@ik_model_receiver
def post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs):
if not raw:
old_hashes = instance._ik._source_hashes.copy()
new_hashes = update_source_hashes(instance)
for attname in instance._ik.spec_fields:
if old_hashes[attname] != new_hashes[attname]:
getattr(instance, attname).invalidate()
@ik_model_receiver
def post_delete_receiver(sender, instance=None, **kwargs):
for spec_file in instance._ik.spec_files:
spec_file.clear()
@ik_model_receiver
def post_init_receiver(sender, instance, **kwargs):
update_source_hashes(instance)
def configure_receivers():
# Connect the signals. We have to listen to every model (not just those
# with IK fields) and filter in our receivers because of a Django issue with
# abstract base models.
# Related:
# https://github.com/jdriscoll/django-imagekit/issues/126
# https://code.djangoproject.com/ticket/9318
uid = 'ik_spec_field_receivers'
post_init.connect(post_init_receiver, dispatch_uid=uid)
post_save.connect(post_save_receiver, dispatch_uid=uid)
post_delete.connect(post_delete_receiver, dispatch_uid=uid)

View file

@ -7,260 +7,7 @@ should be limited to image manipulations--they should be completely decoupled
from both the filesystem and the ORM.
"""
from imagekit.lib import Image, ImageColor, ImageEnhance
from imagekit.processors import resize, crop
RGBA_TRANSPARENCY_FORMATS = ['PNG']
PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF']
class ProcessorPipeline(list):
"""
A :class:`list` of other processors. This class allows any object that
knows how to deal with a single processor to deal with a list of them.
For example::
processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image)
"""
def process(self, img):
for proc in self:
img = proc.process(img)
return img
class Adjust(object):
"""
Performs color, brightness, contrast, and sharpness enhancements on the
image. See :mod:`PIL.ImageEnhance` for more imformation.
"""
def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0):
"""
:param color: A number between 0 and 1 that specifies the saturation
of the image. 0 corresponds to a completely desaturated image
(black and white) and 1 to the original color.
See :class:`PIL.ImageEnhance.Color`
:param brightness: A number representing the brightness; 0 results in
a completely black image whereas 1 corresponds to the brightness
of the original. See :class:`PIL.ImageEnhance.Brightness`
:param contrast: A number representing the contrast; 0 results in a
completely gray image whereas 1 corresponds to the contrast of
the original. See :class:`PIL.ImageEnhance.Contrast`
:param sharpness: A number representing the sharpness; 0 results in a
blurred image; 1 corresponds to the original sharpness; 2
results in a sharpened image. See
:class:`PIL.ImageEnhance.Sharpness`
"""
self.color = color
self.brightness = brightness
self.contrast = contrast
self.sharpness = sharpness
def process(self, img):
original = img = img.convert('RGBA')
for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
factor = getattr(self, name.lower())
if factor != 1.0:
try:
img = getattr(ImageEnhance, name)(img).enhance(factor)
except ValueError:
pass
else:
# PIL's Color and Contrast filters both convert the image
# to L mode, losing transparency info, so we put it back.
# See https://github.com/jdriscoll/django-imagekit/issues/64
if name in ('Color', 'Contrast'):
img = Image.merge('RGBA', img.split()[:3] +
original.split()[3:4])
return img
class Reflection(object):
"""
Creates an image with a reflection.
"""
background_color = '#FFFFFF'
size = 0.0
opacity = 0.6
def process(self, img):
# Convert bgcolor string to RGB value.
background_color = ImageColor.getrgb(self.background_color)
# Handle palleted images.
img = img.convert('RGB')
# Copy orignial image and flip the orientation.
reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM)
# Create a new image filled with the bgcolor the same size.
background = Image.new("RGB", img.size, background_color)
# Calculate our alpha mask.
start = int(255 - (255 * self.opacity)) # The start of our gradient.
steps = int(255 * self.size) # The number of intermedite values.
increment = (255 - start) / float(steps)
mask = Image.new('L', (1, 255))
for y in range(255):
if y < steps:
val = int(y * increment + start)
else:
val = 255
mask.putpixel((0, y), val)
alpha_mask = mask.resize(img.size)
# Merge the reflection onto our background color using the alpha mask.
reflection = Image.composite(background, reflection, alpha_mask)
# Crop the reflection.
reflection_height = int(img.size[1] * self.size)
reflection = reflection.crop((0, 0, img.size[0], reflection_height))
# Create new image sized to hold both the original image and
# the reflection.
composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color)
# Paste the orignal image and the reflection into the composite image.
composite.paste(img, (0, 0))
composite.paste(reflection, (0, img.size[1]))
# Return the image complete with reflection effect.
return composite
class Transpose(object):
"""
Rotates or flips the image.
"""
AUTO = 'auto'
FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT
FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM
ROTATE_90 = Image.ROTATE_90
ROTATE_180 = Image.ROTATE_180
ROTATE_270 = Image.ROTATE_270
methods = [AUTO]
_EXIF_ORIENTATION_STEPS = {
1: [],
2: [FLIP_HORIZONTAL],
3: [ROTATE_180],
4: [FLIP_VERTICAL],
5: [ROTATE_270, FLIP_HORIZONTAL],
6: [ROTATE_270],
7: [ROTATE_90, FLIP_HORIZONTAL],
8: [ROTATE_90],
}
def __init__(self, *args):
"""
Possible arguments:
- Transpose.AUTO
- Transpose.FLIP_HORIZONTAL
- Transpose.FLIP_VERTICAL
- Transpose.ROTATE_90
- Transpose.ROTATE_180
- Transpose.ROTATE_270
The order of the arguments dictates the order in which the
Transposition steps are taken.
If Transpose.AUTO is present, all other arguments are ignored, and
the processor will attempt to rotate the image according to the
EXIF Orientation data.
"""
super(Transpose, self).__init__()
if args:
self.methods = args
def process(self, img):
if self.AUTO in self.methods:
try:
orientation = img._getexif()[0x0112]
ops = self._EXIF_ORIENTATION_STEPS[orientation]
except (KeyError, TypeError, AttributeError):
ops = []
else:
ops = self.methods
for method in ops:
img = img.transpose(method)
return img
class AutoConvert(object):
"""A processor that does some common-sense conversions based on the target
format. This includes things like preserving transparency and quantizing.
This processors is used automatically by ``ImageSpecField`` and
``ProcessedImageField`` immediately before saving the image unless you
specify ``autoconvert=False``.
"""
def __init__(self, format):
self.format = format
def process(self, img):
matte = False
self.save_kwargs = {}
if img.mode == 'RGBA':
if self.format in RGBA_TRANSPARENCY_FORMATS:
pass
elif self.format in PALETTE_TRANSPARENCY_FORMATS:
# If you're going from a format with alpha transparency to one
# with palette transparency, transparency values will be
# snapped: pixels that are more opaque than not will become
# fully opaque; pixels that are more transparent than not will
# become fully transparent. This will not produce a good-looking
# result if your image contains varying levels of opacity; in
# that case, you'll probably want to use a processor to matte
# the image on a solid color. The reason we don't matte by
# default is because not doing so allows processors to treat
# RGBA-format images as a super-type of P-format images: if you
# have an RGBA-format image with only a single transparent
# color, and save it as a GIF, it will retain its transparency.
# In other words, a P-format image converted to an
# RGBA-formatted image by a processor and then saved as a
# P-format image will give the expected results.
alpha = img.split()[-1]
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE,
colors=255)
img.paste(255, mask)
self.save_kwargs['transparency'] = 255
else:
# Simply converting an RGBA-format image to an RGB one creates a
# gross result, so we matte the image on a white background. If
# that's not what you want, that's fine: use a processor to deal
# with the transparency however you want. This is simply a
# sensible default that will always produce something that looks
# good. Or at least, it will look better than just a straight
# conversion.
matte = True
elif img.mode == 'P':
if self.format in PALETTE_TRANSPARENCY_FORMATS:
try:
self.save_kwargs['transparency'] = img.info['transparency']
except KeyError:
pass
elif self.format in RGBA_TRANSPARENCY_FORMATS:
# Currently PIL doesn't support any RGBA-mode formats that
# aren't also P-mode formats, so this will never happen.
img = img.convert('RGBA')
else:
matte = True
else:
img = img.convert('RGB')
# GIFs are always going to be in palette mode, so we can do a little
# optimization. Note that the RGBA sources also use adaptive
# quantization (above). Images that are already in P mode don't need
# any quantization because their colors are already limited.
if self.format == 'GIF':
img = img.convert('P', palette=Image.ADAPTIVE)
if matte:
img = img.convert('RGBA')
bg = Image.new('RGBA', img.size, (255, 255, 255))
bg.paste(img, img)
img = bg.convert('RGB')
if self.format == 'JPEG':
self.save_kwargs['optimize'] = True
return img
from .base import *
from .crop import *
from .resize import *

209
imagekit/processors/base.py Normal file
View file

@ -0,0 +1,209 @@
from imagekit.lib import Image, ImageColor, ImageEnhance
class ProcessorPipeline(list):
"""
A :class:`list` of other processors. This class allows any object that
knows how to deal with a single processor to deal with a list of them.
For example::
processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image)
"""
def process(self, img):
for proc in self:
img = proc.process(img)
return img
class Adjust(object):
"""
Performs color, brightness, contrast, and sharpness enhancements on the
image. See :mod:`PIL.ImageEnhance` for more imformation.
"""
def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0):
"""
:param color: A number between 0 and 1 that specifies the saturation
of the image. 0 corresponds to a completely desaturated image
(black and white) and 1 to the original color.
See :class:`PIL.ImageEnhance.Color`
:param brightness: A number representing the brightness; 0 results in
a completely black image whereas 1 corresponds to the brightness
of the original. See :class:`PIL.ImageEnhance.Brightness`
:param contrast: A number representing the contrast; 0 results in a
completely gray image whereas 1 corresponds to the contrast of
the original. See :class:`PIL.ImageEnhance.Contrast`
:param sharpness: A number representing the sharpness; 0 results in a
blurred image; 1 corresponds to the original sharpness; 2
results in a sharpened image. See
:class:`PIL.ImageEnhance.Sharpness`
"""
self.color = color
self.brightness = brightness
self.contrast = contrast
self.sharpness = sharpness
def process(self, img):
original = img = img.convert('RGBA')
for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
factor = getattr(self, name.lower())
if factor != 1.0:
try:
img = getattr(ImageEnhance, name)(img).enhance(factor)
except ValueError:
pass
else:
# PIL's Color and Contrast filters both convert the image
# to L mode, losing transparency info, so we put it back.
# See https://github.com/jdriscoll/django-imagekit/issues/64
if name in ('Color', 'Contrast'):
img = Image.merge('RGBA', img.split()[:3] +
original.split()[3:4])
return img
class Reflection(object):
"""
Creates an image with a reflection.
"""
def __init__(self, background_color='#FFFFFF', size=0.0, opacity=0.6):
self.background_color = background_color
self.size = size
self.opacity = opacity
def process(self, img):
# Convert bgcolor string to RGB value.
background_color = ImageColor.getrgb(self.background_color)
# Handle palleted images.
img = img.convert('RGBA')
# Copy orignial image and flip the orientation.
reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM)
# Create a new image filled with the bgcolor the same size.
background = Image.new("RGBA", img.size, background_color)
# Calculate our alpha mask.
start = int(255 - (255 * self.opacity)) # The start of our gradient.
steps = int(255 * self.size) # The number of intermedite values.
increment = (255 - start) / float(steps)
mask = Image.new('L', (1, 255))
for y in range(255):
if y < steps:
val = int(y * increment + start)
else:
val = 255
mask.putpixel((0, y), val)
alpha_mask = mask.resize(img.size)
# Merge the reflection onto our background color using the alpha mask.
reflection = Image.composite(background, reflection, alpha_mask)
# Crop the reflection.
reflection_height = int(img.size[1] * self.size)
reflection = reflection.crop((0, 0, img.size[0], reflection_height))
# Create new image sized to hold both the original image and
# the reflection.
composite = Image.new("RGBA", (img.size[0], img.size[1] + reflection_height), background_color)
# Paste the orignal image and the reflection into the composite image.
composite.paste(img, (0, 0))
composite.paste(reflection, (0, img.size[1]))
# Return the image complete with reflection effect.
return composite
class Transpose(object):
"""
Rotates or flips the image.
"""
AUTO = 'auto'
FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT
FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM
ROTATE_90 = Image.ROTATE_90
ROTATE_180 = Image.ROTATE_180
ROTATE_270 = Image.ROTATE_270
methods = [AUTO]
_EXIF_ORIENTATION_STEPS = {
1: [],
2: [FLIP_HORIZONTAL],
3: [ROTATE_180],
4: [FLIP_VERTICAL],
5: [ROTATE_270, FLIP_HORIZONTAL],
6: [ROTATE_270],
7: [ROTATE_90, FLIP_HORIZONTAL],
8: [ROTATE_90],
}
def __init__(self, *args):
"""
Possible arguments:
- Transpose.AUTO
- Transpose.FLIP_HORIZONTAL
- Transpose.FLIP_VERTICAL
- Transpose.ROTATE_90
- Transpose.ROTATE_180
- Transpose.ROTATE_270
The order of the arguments dictates the order in which the
Transposition steps are taken.
If Transpose.AUTO is present, all other arguments are ignored, and
the processor will attempt to rotate the image according to the
EXIF Orientation data.
"""
super(Transpose, self).__init__()
if args:
self.methods = args
def process(self, img):
if self.AUTO in self.methods:
try:
orientation = img._getexif()[0x0112]
ops = self._EXIF_ORIENTATION_STEPS[orientation]
except (KeyError, TypeError, AttributeError):
ops = []
else:
ops = self.methods
for method in ops:
img = img.transpose(method)
return img
class Anchor(object):
"""
Defines all the anchor points needed by the various processor classes.
"""
TOP_LEFT = 'tl'
TOP = 't'
TOP_RIGHT = 'tr'
BOTTOM_LEFT = 'bl'
BOTTOM = 'b'
BOTTOM_RIGHT = 'br'
CENTER = 'c'
LEFT = 'l'
RIGHT = 'r'
_ANCHOR_PTS = {
TOP_LEFT: (0, 0),
TOP: (0.5, 0),
TOP_RIGHT: (1, 0),
LEFT: (0, 0.5),
CENTER: (0.5, 0.5),
RIGHT: (1, 0.5),
BOTTOM_LEFT: (0, 1),
BOTTOM: (0.5, 1),
BOTTOM_RIGHT: (1, 1),
}
@staticmethod
def get_tuple(anchor):
"""Normalizes anchor values (strings or tuples) to tuples.
"""
# If the user passed in one of the string values, convert it to a
# percentage tuple.
if anchor in Anchor._ANCHOR_PTS.keys():
anchor = Anchor._ANCHOR_PTS[anchor]
return anchor

View file

@ -1,5 +1,6 @@
from ..lib import Image, ImageChops, ImageDraw, ImageStat
from .base import Anchor
from .utils import histogram_entropy
from ..lib import Image, ImageChops, ImageDraw, ImageStat
class Side(object):
@ -71,101 +72,31 @@ class TrimBorderColor(object):
return img
class BasicCrop(object):
"""Crops an image to the specified rectangular region.
"""
def __init__(self, x, y, width, height):
"""
:param x: The x position of the clipping box, in pixels.
:param y: The y position of the clipping box, in pixels.
:param width: The width position of the clipping box, in pixels.
:param height: The height position of the clipping box, in pixels.
"""
self.x = x
self.y = y
self.width = width
self.height = height
def process(self, img):
box = (self.x, self.y, self.x + self.width, self.y + self.height)
return img.crop(box)
class Crop(object):
"""
Crops an image , cropping it to the specified width and height
relative to the anchor.
Crops an image, cropping it to the specified width and height. You may
optionally provide either an anchor or x and y coordinates. This processor
functions exactly the same as ``ResizeCanvas`` except that it will never
enlarge the image.
"""
TOP_LEFT = 'tl'
TOP = 't'
TOP_RIGHT = 'tr'
BOTTOM_LEFT = 'bl'
BOTTOM = 'b'
BOTTOM_RIGHT = 'br'
CENTER = 'c'
LEFT = 'l'
RIGHT = 'r'
_ANCHOR_PTS = {
TOP_LEFT: (0, 0),
TOP: (0.5, 0),
TOP_RIGHT: (1, 0),
LEFT: (0, 0.5),
CENTER: (0.5, 0.5),
RIGHT: (1, 0.5),
BOTTOM_LEFT: (0, 1),
BOTTOM: (0.5, 1),
BOTTOM_RIGHT: (1, 1),
}
def __init__(self, width=None, height=None, anchor=None):
"""
:param width: The target width, in pixels.
:param height: The target height, in pixels.
:param anchor: Specifies which part of the image should be retained
when cropping. Valid values are:
- Crop.TOP_LEFT
- Crop.TOP
- Crop.TOP_RIGHT
- Crop.LEFT
- Crop.CENTER
- Crop.RIGHT
- Crop.BOTTOM_LEFT
- Crop.BOTTOM
- Crop.BOTTOM_RIGHT
You may also pass a tuple that indicates the percentages of excess
to be trimmed from each dimension. For example, ``(0, 0)``
corresponds to "top left", ``(0.5, 0.5)`` to "center" and ``(1, 1)``
to "bottom right". This is basically the same as using percentages
in CSS background positions.
"""
def __init__(self, width=None, height=None, anchor=None, x=None, y=None):
self.width = width
self.height = height
self.anchor = anchor
self.x = x
self.y = y
def process(self, img):
from .resize import ResizeCanvas
original_width, original_height = img.size
new_width, new_height = min(original_width, self.width), \
min(original_height, self.height)
trim_x, trim_y = original_width - new_width, \
original_height - new_height
# If the user passed in one of the string values, convert it to a
# percentage tuple.
anchor = self.anchor or Crop.CENTER
if anchor in Crop._ANCHOR_PTS.keys():
anchor = Crop._ANCHOR_PTS[anchor]
x = int(float(trim_x) * float(anchor[0]))
y = int(float(trim_y) * float(anchor[1]))
return BasicCrop(x, y, new_width, new_height).process(img)
return ResizeCanvas(new_width, new_height, anchor=self.anchor,
x=self.x, y=self.y).process(img)
class SmartCrop(object):

View file

@ -1,9 +1,9 @@
from imagekit.lib import Image
from . import crop
import warnings
from .base import Anchor
class BasicResize(object):
class Resize(object):
"""
Resizes an image to the specified width and height.
@ -21,11 +21,11 @@ class BasicResize(object):
return img.resize((self.width, self.height), Image.ANTIALIAS)
class Cover(object):
class ResizeToCover(object):
"""
Resizes the image to the smallest possible size that will entirely cover the
provided dimensions. You probably won't be using this processor directly,
but it's used internally by ``Fill`` and ``SmartFill``.
but it's used internally by ``ResizeToFill`` and ``SmartResize``.
"""
def __init__(self, width, height):
@ -42,59 +42,39 @@ class Cover(object):
float(self.height) / original_height)
new_width, new_height = (int(original_width * ratio),
int(original_height * ratio))
return BasicResize(new_width, new_height).process(img)
return Resize(new_width, new_height).process(img)
class Fill(object):
class ResizeToFill(object):
"""
Resizes an image , cropping it to the exact specified width and height.
Resizes an image, cropping it to the exact specified width and height.
"""
TOP_LEFT = crop.Crop.TOP_LEFT
TOP = crop.Crop.TOP
TOP_RIGHT = crop.Crop.TOP_RIGHT
BOTTOM_LEFT = crop.Crop.BOTTOM_LEFT
BOTTOM = crop.Crop.BOTTOM
BOTTOM_RIGHT = crop.Crop.BOTTOM_RIGHT
CENTER = crop.Crop.CENTER
LEFT = crop.Crop.LEFT
RIGHT = crop.Crop.RIGHT
def __init__(self, width=None, height=None, anchor=None):
"""
:param width: The target width, in pixels.
:param height: The target height, in pixels.
:param anchor: Specifies which part of the image should be retained
when cropping. Valid values are:
- Fill.TOP_LEFT
- Fill.TOP
- Fill.TOP_RIGHT
- Fill.LEFT
- Fill.CENTER
- Fill.RIGHT
- Fill.BOTTOM_LEFT
- Fill.BOTTOM
- Fill.BOTTOM_RIGHT
when cropping.
"""
self.width = width
self.height = height
self.anchor = anchor
def process(self, img):
img = Cover(self.width, self.height).process(img)
return crop.Crop(self.width, self.height,
from .crop import Crop
img = ResizeToCover(self.width, self.height).process(img)
return Crop(self.width, self.height,
anchor=self.anchor).process(img)
class SmartFill(object):
class SmartResize(object):
"""
The ``SmartFill`` processor is identical to ``Fill``, except that it uses
entropy to crop the image instead of a user-specified anchor point.
Internally, it simply runs the ``resize.Cover`` and ``crop.SmartCrop``
The ``SmartResize`` processor is identical to ``ResizeToFill``, except that
it uses entropy to crop the image instead of a user-specified anchor point.
Internally, it simply runs the ``ResizeToCover`` and ``SmartCrop``
processors in series.
"""
def __init__(self, width, height):
"""
@ -105,23 +85,104 @@ class SmartFill(object):
self.width, self.height = width, height
def process(self, img):
img = Cover(self.width, self.height).process(img)
return crop.SmartCrop(self.width, self.height).process(img)
from .crop import SmartCrop
img = ResizeToCover(self.width, self.height).process(img)
return SmartCrop(self.width, self.height).process(img)
class Crop(Fill):
def __init__(self, *args, **kwargs):
warnings.warn('`imagekit.processors.resize.Crop` has been renamed to'
'`imagekit.processors.resize.Fill`.', DeprecationWarning)
super(Crop, self).__init__(*args, **kwargs)
class ResizeCanvas(object):
"""
Resizes the canvas, using the provided background color if the new size is
larger than the current image.
"""
def __init__(self, width, height, color=None, anchor=None, x=None, y=None):
"""
:param width: The target width, in pixels.
:param height: The target height, in pixels.
:param color: The background color to use for padding.
:param anchor: Specifies the position of the original image on the new
canvas. Valid values are:
- Anchor.TOP_LEFT
- Anchor.TOP
- Anchor.TOP_RIGHT
- Anchor.LEFT
- Anchor.CENTER
- Anchor.RIGHT
- Anchor.BOTTOM_LEFT
- Anchor.BOTTOM
- Anchor.BOTTOM_RIGHT
You may also pass a tuple that indicates the position in
percentages. For example, ``(0, 0)`` corresponds to "top left",
``(0.5, 0.5)`` to "center" and ``(1, 1)`` to "bottom right". This is
basically the same as using percentages in CSS background positions.
"""
if x is not None or y is not None:
if anchor:
raise Exception('You may provide either an anchor or x and y'
' coordinate, but not both.')
else:
self.x, self.y = x or 0, y or 0
self.anchor = None
else:
self.anchor = anchor or Anchor.CENTER
self.x = self.y = None
self.width = width
self.height = height
self.color = color or (255, 255, 255, 0)
def process(self, img):
original_width, original_height = img.size
if self.anchor:
anchor = Anchor.get_tuple(self.anchor)
trim_x, trim_y = self.width - original_width, \
self.height - original_height
x = int(float(trim_x) * float(anchor[0]))
y = int(float(trim_y) * float(anchor[1]))
else:
x, y = self.x, self.y
new_img = Image.new('RGBA', (self.width, self.height), self.color)
new_img.paste(img, (x, y))
return new_img
class Fit(object):
class AddBorder(object):
"""
Add a border of specific color and size to an image.
"""
def __init__(self, thickness, color=None):
"""
:param color: Color to use for the border
:param thickness: Thickness of the border. Can be either an int or
a 4-tuple of ints of the form (top, right, bottom, left).
"""
self.color = color
if isinstance(thickness, int):
self.top = self.right = self.bottom = self.left = thickness
else:
self.top, self.right, self.bottom, self.left = thickness
def process(self, img):
new_width = img.size[0] + self.left + self.right
new_height = img.size[1] + self.top + self.bottom
return ResizeCanvas(new_width, new_height, color=self.color,
x=self.left, y=self.top).process(img)
class ResizeToFit(object):
"""
Resizes an image to fit within the specified dimensions.
"""
def __init__(self, width=None, height=None, upscale=None, mat_color=None):
def __init__(self, width=None, height=None, upscale=None, mat_color=None, anchor=Anchor.CENTER):
"""
:param width: The maximum width of the desired image.
:param height: The maximum height of the desired image.
@ -136,6 +197,7 @@ class Fit(object):
self.height = height
self.upscale = upscale
self.mat_color = mat_color
self.anchor = anchor
def process(self, img):
cur_width, cur_height = img.size
@ -151,18 +213,8 @@ class Fit(object):
int(round(cur_height * ratio)))
if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \
self.upscale:
img = BasicResize(new_dimensions[0],
img = Resize(new_dimensions[0],
new_dimensions[1]).process(img)
if self.mat_color:
new_img = Image.new('RGBA', (self.width, self.height), self.mat_color)
new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2))
img = new_img
img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img)
return img
class SmartCrop(crop.SmartCrop):
def __init__(self, *args, **kwargs):
warnings.warn('The SmartCrop processor has been moved to'
' `imagekit.processors.crop.SmartCrop`, where it belongs.',
DeprecationWarning)
super(SmartCrop, self).__init__(*args, **kwargs)

View file

@ -1,5 +0,0 @@
from django.conf import settings
DEFAULT_IMAGE_CACHE_BACKEND = getattr(settings,
'IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND',
'imagekit.imagecache.PessimisticImageCacheBackend')

View file

@ -1,29 +1,49 @@
import tempfile
import os
import mimetypes
import sys
import types
from django.core.files.base import ContentFile
from django.db.models.loading import cache
from django.utils.functional import wraps
from django.utils.encoding import smart_str, smart_unicode
from .imagecache import get_default_image_cache_backend
from .lib import Image, ImageFile
from .lib import Image, ImageFile, StringIO
def img_to_fobj(img, format, **kwargs):
tmp = tempfile.TemporaryFile()
try:
img.save(tmp, format, **kwargs)
except IOError:
# PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough,
# So if we have a problem saving, we temporarily increase it. See
# http://github.com/jdriscoll/django-imagekit/issues/50
old_maxblock = ImageFile.MAXBLOCK
ImageFile.MAXBLOCK = img.size[0] * img.size[1]
try:
img.save(tmp, format, **kwargs)
finally:
ImageFile.MAXBLOCK = old_maxblock
tmp.seek(0)
return tmp
RGBA_TRANSPARENCY_FORMATS = ['PNG']
PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF']
class IKContentFile(ContentFile):
"""
Wraps a ContentFile in a file-like object with a filename and a
content_type. A PIL image format can be optionally be provided as a content
type hint.
"""
def __init__(self, filename, content, format=None):
self.file = ContentFile(content)
self.file.name = filename
mimetype = getattr(self.file, 'content_type', None)
if format and not mimetype:
mimetype = format_to_mimetype(format)
if not mimetype:
ext = os.path.splitext(filename or '')[1]
mimetype = extension_to_mimetype(ext)
self.file.content_type = mimetype
def __str__(self):
return smart_str(self.file.name or '')
def __unicode__(self):
return smart_unicode(self.file.name or u'')
def img_to_fobj(img, format, autoconvert=True, **options):
return save_image(img, StringIO(), format, options, autoconvert)
def get_spec_files(instance):
@ -108,6 +128,19 @@ def _format_to_extension(format):
return None
def extension_to_mimetype(ext):
try:
filename = 'a%s' % (ext or '') # guess_type requires a full filename, not just an extension
mimetype = mimetypes.guess_type(filename)[0]
except IndexError:
mimetype = None
return mimetype
def format_to_mimetype(format):
return extension_to_mimetype(format_to_extension(format))
def extension_to_format(extension):
"""Returns the format that corresponds to the provided extension.
@ -168,6 +201,188 @@ def validate_app_cache(apps, force_revalidation=False):
f.validate()
def suggest_extension(name, format):
original_extension = os.path.splitext(name)[1]
try:
suggested_extension = format_to_extension(format)
except UnknownFormatError:
extension = original_extension
else:
if suggested_extension.lower() == original_extension.lower():
extension = original_extension
else:
try:
original_format = extension_to_format(original_extension)
except UnknownExtensionError:
extension = suggested_extension
else:
# If the formats match, give precedence to the original extension.
if format.lower() == original_format.lower():
extension = original_extension
else:
extension = suggested_extension
return extension
def save_image(img, outfile, format, options=None, autoconvert=True):
"""
Wraps PIL's ``Image.save()`` method. There are two main benefits of using
this function over PIL's:
1. It gracefully handles the infamous "Suspension not allowed here" errors.
2. It prepares the image for saving using ``prepare_image()``, which will do
some common-sense processing given the target format.
"""
options = options or {}
if autoconvert:
img, save_kwargs = prepare_image(img, format)
options = dict(save_kwargs.items() + options.items())
# Attempt to reset the file pointer.
try:
outfile.seek(0)
except AttributeError:
pass
try:
with quiet():
img.save(outfile, format, **options)
except IOError:
# PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough,
# So if we have a problem saving, we temporarily increase it. See
# http://github.com/jdriscoll/django-imagekit/issues/50
old_maxblock = ImageFile.MAXBLOCK
ImageFile.MAXBLOCK = img.size[0] * img.size[1]
try:
img.save(outfile, format, **options)
finally:
ImageFile.MAXBLOCK = old_maxblock
try:
outfile.seek(0)
except AttributeError:
pass
return outfile
class quiet(object):
"""
A context manager for suppressing the stderr activity of PIL's C libraries.
Based on http://stackoverflow.com/a/978264/155370
"""
def __enter__(self):
self.stderr_fd = sys.__stderr__.fileno()
self.null_fd = os.open(os.devnull, os.O_RDWR)
self.old = os.dup(self.stderr_fd)
os.dup2(self.null_fd, self.stderr_fd)
def __exit__(self, *args, **kwargs):
os.dup2(self.old, self.stderr_fd)
os.close(self.null_fd)
os.close(self.old)
def prepare_image(img, format):
"""
Prepares the image for saving to the provided format by doing some
common-sense conversions. This includes things like preserving transparency
and quantizing. This function is used automatically by ``save_image()``
(and classes like ``ImageSpecField`` and ``ProcessedImageField``)
immediately before saving unless you specify ``autoconvert=False``. It is
provided as a utility for those doing their own processing.
:param img: The image to prepare for saving.
:param format: The format that the image will be saved to.
"""
matte = False
save_kwargs = {}
if img.mode == 'RGBA':
if format in RGBA_TRANSPARENCY_FORMATS:
pass
elif format in PALETTE_TRANSPARENCY_FORMATS:
# If you're going from a format with alpha transparency to one
# with palette transparency, transparency values will be
# snapped: pixels that are more opaque than not will become
# fully opaque; pixels that are more transparent than not will
# become fully transparent. This will not produce a good-looking
# result if your image contains varying levels of opacity; in
# that case, you'll probably want to use a processor to matte
# the image on a solid color. The reason we don't matte by
# default is because not doing so allows processors to treat
# RGBA-format images as a super-type of P-format images: if you
# have an RGBA-format image with only a single transparent
# color, and save it as a GIF, it will retain its transparency.
# In other words, a P-format image converted to an
# RGBA-formatted image by a processor and then saved as a
# P-format image will give the expected results.
# Work around a bug in PIL: split() doesn't check to see if
# img is loaded.
img.load()
alpha = img.split()[-1]
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE,
colors=255)
img.paste(255, mask)
save_kwargs['transparency'] = 255
else:
# Simply converting an RGBA-format image to an RGB one creates a
# gross result, so we matte the image on a white background. If
# that's not what you want, that's fine: use a processor to deal
# with the transparency however you want. This is simply a
# sensible default that will always produce something that looks
# good. Or at least, it will look better than just a straight
# conversion.
matte = True
elif img.mode == 'P':
if format in PALETTE_TRANSPARENCY_FORMATS:
try:
save_kwargs['transparency'] = img.info['transparency']
except KeyError:
pass
elif format in RGBA_TRANSPARENCY_FORMATS:
# Currently PIL doesn't support any RGBA-mode formats that
# aren't also P-mode formats, so this will never happen.
img = img.convert('RGBA')
else:
matte = True
else:
img = img.convert('RGB')
# GIFs are always going to be in palette mode, so we can do a little
# optimization. Note that the RGBA sources also use adaptive
# quantization (above). Images that are already in P mode don't need
# any quantization because their colors are already limited.
if format == 'GIF':
img = img.convert('P', palette=Image.ADAPTIVE)
if matte:
img = img.convert('RGBA')
bg = Image.new('RGBA', img.size, (255, 255, 255))
bg.paste(img, img)
img = bg.convert('RGB')
if format == 'JPEG':
save_kwargs['optimize'] = True
return img, save_kwargs
def ik_model_receiver(fn):
@wraps(fn)
def receiver(sender, **kwargs):
if getattr(sender, '_ik', None):
fn(sender, **kwargs)
return receiver
def autodiscover():
"""
Auto-discover INSTALLED_APPS imagespecs.py modules and fail silently when

View file

@ -1 +1,3 @@
Django >= 1.3.1
Django>=1.3.1
django-appconf>=0.5
PIL>=1.1.7

View file

@ -11,9 +11,12 @@ if 'publish' in sys.argv:
read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read()
# Dynamically calculate the version based on imagekit.VERSION.
version = __import__('imagekit').get_version()
setup(
name='django-imagekit',
version=':versiontools:imagekit:',
version=version,
description='Automated image processing for Django models.',
long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')),
author='Justin Driscoll',
@ -25,8 +28,11 @@ setup(
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=[
'django-appconf>=0.5',
],
classifiers=[
'Development Status :: 4 - Beta',
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Framework :: Django',
'Intended Audience :: Developers',
@ -37,7 +43,4 @@ setup(
'Programming Language :: Python :: 2.7',
'Topic :: Utilities'
],
setup_requires=[
'versiontools >= 1.8',
],
)

View file

@ -0,0 +1,34 @@
from django.db import models
from imagekit.models import ImageSpecField
from imagekit.processors import Adjust
from imagekit.processors import ResizeToFill
from imagekit.processors import SmartCrop
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
ResizeToFill(50, 50)], image_field='original_image', format='JPEG',
options={'quality': 90})
smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
sharpness=1.1), SmartCrop(50, 50)], image_field='original_image',
format='JPEG', options={'quality': 90})
class AbstractImageModel(models.Model):
original_image = models.ImageField(upload_to='photos')
abstract_class_spec = ImageSpecField()
class Meta:
abstract = True
class ConcreteImageModel1(AbstractImageModel):
first_spec = ImageSpecField()
class ConcreteImageModel2(AbstractImageModel):
second_spec = ImageSpecField()

View file

@ -1,73 +1,25 @@
from __future__ import with_statement
import os
import tempfile
from django.core.files.base import ContentFile
from django.db import models
from django.test import TestCase
from imagekit import utils
from imagekit.lib import Image
from imagekit.models.fields import ImageSpecField
from imagekit.processors import Adjust
from imagekit.processors.resize import Fill
from imagekit.processors.crop import SmartCrop
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
Fill(50, 50)], image_field='original_image', format='JPEG',
options={'quality': 90})
smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
sharpness=1.1), SmartCrop(50, 50)], image_field='original_image',
format='JPEG', options={'quality': 90})
from .models import (Photo, AbstractImageModel, ConcreteImageModel1,
ConcreteImageModel2)
from .testutils import create_photo, pickleback
class IKTest(TestCase):
def generate_image(self):
tmp = tempfile.TemporaryFile()
Image.new('RGB', (800, 600)).save(tmp, 'JPEG')
tmp.seek(0)
return tmp
def generate_lenna(self):
"""
See also:
http://en.wikipedia.org/wiki/Lenna
http://sipi.usc.edu/database/database.php?volume=misc&image=12
"""
tmp = tempfile.TemporaryFile()
lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg')
with open(lennapath, "r+b") as lennafile:
Image.open(lennafile).save(tmp, 'JPEG')
tmp.seek(0)
return tmp
def create_photo(self, name):
photo = Photo()
img = self.generate_lenna()
file = ContentFile(img.read())
photo.original_image = file
photo.original_image.save(name, file)
photo.save()
img.close()
return photo
def setUp(self):
self.photo = self.create_photo('test.jpg')
self.photo = create_photo('test.jpg')
def test_nodelete(self):
"""Don't delete the spec file when the source image hasn't changed.
"""
filename = self.photo.thumbnail.file.name
thumbnail_timestamp = os.path.getmtime(filename)
self.photo.save()
self.assertTrue(self.photo.thumbnail.storage.exists(filename))
@ -109,3 +61,27 @@ class IKUtilsTest(TestCase):
with self.assertRaises(utils.UnknownFormatError):
utils.format_to_extension('TXT')
class PickleTest(TestCase):
def test_model(self):
ph = pickleback(create_photo('pickletest.jpg'))
# This isn't supposed to error.
ph.thumbnail.source_file
def test_field(self):
thumbnail = pickleback(create_photo('pickletest2.jpg').thumbnail)
# This isn't supposed to error.
thumbnail.source_file
class InheritanceTest(TestCase):
def test_abstract_base(self):
self.assertEqual(set(AbstractImageModel._ik.spec_fields),
set(['abstract_class_spec']))
self.assertEqual(set(ConcreteImageModel1._ik.spec_fields),
set(['abstract_class_spec', 'first_spec']))
self.assertEqual(set(ConcreteImageModel2._ik.spec_fields),
set(['abstract_class_spec', 'second_spec']))

46
tests/core/testutils.py Normal file
View file

@ -0,0 +1,46 @@
import os
import tempfile
from django.core.files.base import ContentFile
from imagekit.lib import Image, StringIO
from .models import Photo
import pickle
def generate_lenna():
"""
See also:
http://en.wikipedia.org/wiki/Lenna
http://sipi.usc.edu/database/database.php?volume=misc&image=12
"""
tmp = tempfile.TemporaryFile()
lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg')
with open(lennapath, "r+b") as lennafile:
Image.open(lennafile).save(tmp, 'JPEG')
tmp.seek(0)
return tmp
def create_instance(model_class, image_name):
instance = model_class()
img = generate_lenna()
file = ContentFile(img.read())
instance.original_image = file
instance.original_image.save(image_name, file)
instance.save()
img.close()
return instance
def create_photo(name):
return create_instance(Photo, name)
def pickleback(obj):
pickled = StringIO()
pickle.dump(obj, pickled)
pickled.seek(0)
return pickle.load(pickled)

22
tox.ini
View file

@ -1,31 +1,45 @@
[tox]
envlist =
py27-django13,
py27-django12,
py26-django13,
py26-django12
py27-django14, py27-django13, py27-django12,
py26-django14, py26-django13, py26-django12
[testenv]
changedir = tests
setenv = PYTHONPATH = {toxinidir}/tests
commands = django-admin.py test core --settings=settings
[testenv:py27-django14]
basepython = python2.7
deps =
Django>=1.4
Pillow
[testenv:py27-django13]
basepython = python2.7
deps =
Django>=1.3,<=1.4
Pillow
[testenv:py27-django12]
basepython = python2.7
deps =
Django>=1.2,<=1.3
Pillow
[testenv:py26-django14]
basepython = python2.6
deps =
Django>=1.4
Pillow
[testenv:py26-django13]
basepython = python2.6
deps =
Django>=1.3,<=1.4
Pillow
[testenv:py26-django12]
basepython = python2.6
deps =
Django>=1.2,<=1.3
Pillow