diff --git a/AUTHORS b/AUTHORS
index 0ae6234..02e459b 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -6,6 +6,7 @@ Maintainers
~~~~~~~~~~~
* `Bryan Veloso`_
+* `Matthew Tretter`_
* `Chris Drackett`_
* `Greg Newman`_
@@ -14,11 +15,11 @@ Contributors
* `Josh Ourisman`_
* `Jonathan Slenders`_
-* `Matthew Tretter`_
* `Eric Eldredge`_
* `Chris McKenzie`_
* `Markus Kaiserswerth`_
* `Ryan Bagwell`_
+* `Alexander Bohn`_
.. _Justin Driscoll: http://github.com/jdriscoll
@@ -33,3 +34,4 @@ Contributors
.. _Chris McKenzie: http://github.com/kenzic
.. _Ryan Bagwell: http://github.com/ryanbagwell
.. _Markus Kaiserswerth: http://github.com/mkai
+.. _Alexander Bohn: http://github.com/fish2000
diff --git a/README.rst b/README.rst
index b52319c..22b891f 100644
--- a/README.rst
+++ b/README.rst
@@ -6,9 +6,19 @@ like different sizes (e.g. thumbnails) and black and white versions.
Installation
------------
-1. ``pip install django-imagekit``
+1. Install `PIL`_ or `Pillow`_. If you're using `ImageField`s in Django, you
+ should have already done this.
+2. ``pip install django-imagekit``
(or clone the source and put the imagekit module on your path)
-2. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py
+3. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py
+
+.. note:: If you've never seen Pillow before, it considers itself a
+ more-frequently updated "friendly" fork of PIL that's compatible with
+ setuptools. As such, it shares the same namespace as PIL does and is a
+ drop-in replacement.
+
+.. _`PIL`: http://pypi.python.org/pypi/PIL
+.. _`Pillow`: http://pypi.python.org/pypi/Pillow
Adding Specs to a Model
@@ -23,7 +33,7 @@ of a model class::
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
formatted_image = ImageSpec(image_field='original_image', format='JPEG',
- quality=90)
+ options={'quality': 90})
Accessing the spec through a model instance will create the image and return
an ImageFile-like object (just like with a normal
@@ -51,7 +61,7 @@ your spec, you can expose different versions of the original image::
original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1),
resize.Crop(50, 50)], image_field='original_image',
- format='JPEG', quality=90)
+ format='JPEG', options={'quality': 90})
The ``thumbnail`` property will now return a cropped image::
@@ -77,7 +87,7 @@ implement a ``process()`` method::
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
watermarked_image = ImageSpec([Watermark()], image_field='original_image',
- format='JPEG', quality=90)
+ format='JPEG', options={'quality': 90})
Admin
diff --git a/docs/apireference.rst b/docs/apireference.rst
index c997026..cbaca49 100644
--- a/docs/apireference.rst
+++ b/docs/apireference.rst
@@ -18,6 +18,9 @@ API Reference
.. automodule:: imagekit.processors.resize
:members:
+.. automodule:: imagekit.processors.crop
+:members:
+
:mod:`admin` Module
--------------------
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 2d81f58..742ad29 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,16 +1,30 @@
Changelog
=========
+v1.1.0
+------
+
+- A ``SmartCrop`` resize processor was added. This allows an image to be
+ cropped based on the amount of entropy in the target image's histogram.
+
+- The ``quality`` argument was removed in favor of an ``options`` dictionary.
+ This is a more general solution which grants access to PIL's format-specific
+ options (including "quality", "progressive", and "optimize" for JPEGs).
+
+- The ``TrimColor`` processor was renamed to ``TrimBorderColor``.
+
+- The private ``_Resize`` class has been removed.
+
v1.0.3
------
- ``ImageSpec._create()`` was renamed ``ImageSpec.generate()`` and is now
available in the public API.
-- Added an ``AutoConvert`` processor to encapsulate the trasnparency
+- Added an ``AutoConvert`` processor to encapsulate the transparency
handling logic.
-- Refactored trasnaprency handling to be smarter, handleing a lot more of
+- Refactored transparency handling to be smarter, handling a lot more of
the situations in which one would convert to or from formats that support
transparency.
diff --git a/docs/conf.py b/docs/conf.py
index d2f8520..b7f4f73 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -48,9 +48,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett &
# built documents.
#
# The short X.Y version.
-version = '1.0.3'
+version = '1.1.0'
# The full version, including alpha/beta/rc tags.
-release = '1.0.3'
+release = '1.1.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
deleted file mode 100644
index c3a6092..0000000
--- a/docs/tutorial.rst
+++ /dev/null
@@ -1,141 +0,0 @@
-.. _ref-tutorial:
-
-ImageKit in 7 Steps
-===================
-
-Step 1
-******
-
-::
-
- $ pip install django-imagekit
-
-(or clone the source and put the imagekit module on your path)
-
-Step 2
-******
-
-Add ImageKit to your models.
-
-::
-
- # myapp/models.py
-
- from django.db import models
- from imagekit.models import ImageModel
-
- class Photo(ImageModel):
- name = models.CharField(max_length=100)
- original_image = models.ImageField(upload_to='photos')
- num_views = models.PositiveIntegerField(editable=False, default=0)
-
- class IKOptions:
- # This inner class is where we define the ImageKit options for the model
- spec_module = 'myapp.specs'
- cache_dir = 'photos'
- image_field = 'original_image'
- save_count_as = 'num_views'
-
-Step 3
-******
-
-Create your specifications.
-
-::
-
- # myapp/specs.py
-
- from imagekit.specs import ImageSpec
- from imagekit import processors
-
- # first we define our thumbnail resize processor
- class ResizeThumb(processors.Resize):
- width = 100
- height = 75
- crop = True
-
- # now we define a display size resize processor
- class ResizeDisplay(processors.Resize):
- width = 600
-
- # now let's create an adjustment processor to enhance the image at small sizes
- class EnchanceThumb(processors.Adjustment):
- contrast = 1.2
- sharpness = 1.1
-
- # now we can define our thumbnail spec
- class Thumbnail(ImageSpec):
- access_as = 'thumbnail_image'
- pre_cache = True
- processors = [ResizeThumb, EnchanceThumb]
-
- # and our display spec
- class Display(ImageSpec):
- increment_count = True
- processors = [ResizeDisplay]
-
-Step 4
-******
-
-Flush the cache and pre-generate thumbnails (ImageKit has to be added to ``INSTALLED_APPS`` for management command to work).
-
-::
-
- $ python manage.py ikflush myapp
-
-Step 5
-******
-
-Use your new model in templates.
-
-::
-
-
-

-
-
-
-

-
-
-
- {% for p in photos %}
-

- {% endfor %}
-
-
-Step 6
-******
-
-Play with the API.
-
-::
-
- >>> from myapp.models import Photo
- >>> p = Photo.objects.all()[0]
-
- >>> p.display.url
- u'/static/photos/myphoto_display.jpg'
- >>> p.display.width
- 600
- >>> p.display.height
- 420
- >>> p.display.image
-
- >>> p.display.file
-
- >>> p.display.spec
-
-
-Step 7
-******
-
-Enjoy a nice beverage.
-
-::
-
- from refrigerator import beer
-
- beer.enjoy()
-
-
diff --git a/imagekit/__init__.py b/imagekit/__init__.py
index 9c52efa..1b2a4a3 100644
--- a/imagekit/__init__.py
+++ b/imagekit/__init__.py
@@ -1,4 +1,4 @@
__title__ = 'django-imagekit'
__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge'
-__version__ = (1, 0, 3, 'final', 0)
+__version__ = (1, 1, 0, 'final', 0)
__license__ = 'BSD'
diff --git a/imagekit/lib.py b/imagekit/lib.py
index 092cb5c..efacb79 100644
--- a/imagekit/lib.py
+++ b/imagekit/lib.py
@@ -1,13 +1,17 @@
# Required PIL classes may or may not be available from the root namespace
# depending on the installation method used.
try:
- from PIL import Image, ImageColor, ImageEnhance, ImageFile, ImageFilter
+ from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, \
+ ImageFilter, ImageDraw, ImageStat
except ImportError:
try:
import Image
import ImageColor
+ import ImageChops
import ImageEnhance
import ImageFile
import ImageFilter
+ import ImageDraw
+ 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.')
diff --git a/imagekit/management/commands/ikflush.py b/imagekit/management/commands/ikflush.py
index de140f1..11d29f5 100644
--- a/imagekit/management/commands/ikflush.py
+++ b/imagekit/management/commands/ikflush.py
@@ -27,8 +27,7 @@ def flush_cache(apps, options):
print 'Flushing cache for "%s.%s"' % (app_label, model.__name__)
for obj in model.objects.order_by('-pk'):
for spec_file in get_spec_files(obj):
- if spec_file is not None:
- spec_file.delete(save=False)
+ spec_file.delete(save=False)
if spec_file.field.pre_cache:
spec_file.generate(False)
else:
diff --git a/imagekit/models.py b/imagekit/models.py
index 5297ab2..0f16bb2 100755
--- a/imagekit/models.py
+++ b/imagekit/models.py
@@ -15,15 +15,15 @@ from imagekit.processors import ProcessorPipeline, AutoConvert
class _ImageSpecMixin(object):
- def __init__(self, processors=None, quality=70, format=None,
+ def __init__(self, processors=None, format=None, options={},
autoconvert=True):
self.processors = processors
- self.quality = quality
self.format = format
+ self.options = options
self.autoconvert = autoconvert
def process(self, image, file):
- processors = ProcessorPipeline(self.processors)
+ processors = ProcessorPipeline(self.processors or [])
return processors.process(image.copy())
@@ -35,16 +35,19 @@ class ImageSpec(_ImageSpecMixin):
"""
_upload_to_attr = 'cache_to'
- def __init__(self, processors=None, quality=70, format=None,
+ def __init__(self, processors=None, format=None, options={},
image_field=None, pre_cache=False, storage=None, cache_to=None,
autoconvert=True):
"""
:param processors: A list of processors to run on the original image.
- :param quality: The quality of the output image. This option is only
- used for the JPEG format.
:param format: The format of the output file. If not provided,
ImageSpec will try to guess the appropriate format based on the
extension of the filename and the format of the input image.
+ :param options: A dictionary that will be passed to PIL's
+ ``Image.save()`` method as keyword arguments. Valid options vary
+ between formats, but some examples include ``quality``,
+ ``optimize``, and ``progressive`` for JPEGs. See the PIL
+ documentation for others.
:param image_field: The name of the model property that contains the
original image.
:param pre_cache: A boolean that specifies whether the image should
@@ -71,8 +74,8 @@ class ImageSpec(_ImageSpecMixin):
"""
- _ImageSpecMixin.__init__(self, processors, quality=quality,
- format=format, autoconvert=autoconvert)
+ _ImageSpecMixin.__init__(self, processors, format=format,
+ options=options, autoconvert=autoconvert)
self.image_field = image_field
self.pre_cache = pre_cache
self.storage = storage
@@ -89,12 +92,10 @@ class ImageSpec(_ImageSpecMixin):
# Connect to the signals only once for this class.
uid = '%s.%s' % (cls.__module__, cls.__name__)
- post_save.connect(_post_save_handler,
- sender=cls,
- dispatch_uid='%s_save' % uid)
- post_delete.connect(_post_delete_handler,
- sender=cls,
- dispatch_uid='%s.delete' % uid)
+ post_save.connect(_post_save_handler, sender=cls,
+ dispatch_uid='%s_save' % uid)
+ post_delete.connect(_post_delete_handler, sender=cls,
+ dispatch_uid='%s.delete' % uid)
def _get_suggested_extension(name, format):
@@ -125,6 +126,7 @@ class _ImageSpecFileMixin(object):
img = open_image(content)
original_format = img.format
img = self.field.process(img, self)
+ options = dict(self.field.options or {})
# Determine the format.
format = self.field.format
@@ -139,29 +141,45 @@ class _ImageSpecFileMixin(object):
pass
format = format or img.format or original_format or 'JPEG'
- if format == 'JPEG':
- img_to_fobj_kwargs = dict(quality=int(self.field.quality),
- optimize=True)
- else:
- img_to_fobj_kwargs = {}
-
# Run the AutoConvert processor
if getattr(self.field, 'autoconvert', True):
autoconvert_processor = AutoConvert(format)
img = autoconvert_processor.process(img)
- img_to_fobj_kwargs.update(autoconvert_processor.save_kwargs)
+ options = dict(autoconvert_processor.save_kwargs.items() + \
+ options.items())
- imgfile = img_to_fobj(img, format, **img_to_fobj_kwargs)
+ imgfile = img_to_fobj(img, format, **options)
content = ContentFile(imgfile.read())
return img, content
class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile):
- def __init__(self, instance, field, attname, source_file):
+ def __init__(self, instance, field, attname):
ImageFieldFile.__init__(self, instance, field, None)
- self.storage = field.storage or source_file.storage
self.attname = attname
- self.source_file = source_file
+ self.storage = self.field.storage or self.source_file.storage
+
+ @property
+ def source_file(self):
+ field_name = getattr(self.field, 'image_field', None)
+ if field_name:
+ field_file = getattr(self.instance, field_name)
+ else:
+ image_fields = [getattr(self.instance, f.attname) for f in \
+ self.instance.__class__._meta.fields if \
+ isinstance(f, models.ImageField)]
+ if len(image_fields) == 0:
+ raise Exception('{0} does not define any ImageFields, so your '
+ '{1} ImageSpec has no image to act on.'.format(
+ self.instance.__class__.__name__, self.attname))
+ elif len(image_fields) > 1:
+ raise Exception('{0} defines multiple ImageFields, but you have '
+ 'not specified an image_field for your {1} '
+ 'ImageSpec.'.format(self.instance.__class__.__name__,
+ self.attname))
+ else:
+ field_file = image_fields[0]
+ return field_file
def _require_file(self):
if not self.source_file:
@@ -220,10 +238,11 @@ class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile):
self.close()
del self.file
- try:
- self.storage.delete(self.name)
- except (NotImplementedError, IOError):
- pass
+ if self.name and self.storage.exists(self.name):
+ try:
+ self.storage.delete(self.name)
+ except NotImplementedError:
+ pass
# Delete the filesize cache.
if hasattr(self, '_size'):
@@ -291,33 +310,11 @@ class _ImageSpecDescriptor(object):
self.attname = attname
self.field = field
- def _get_image_field_file(self, instance):
- field_name = getattr(self.field, 'image_field', None)
- if field_name:
- field = getattr(instance, field_name)
- else:
- image_fields = [getattr(instance, f.attname) for f in \
- instance.__class__._meta.fields if \
- isinstance(f, models.ImageField)]
- if len(image_fields) == 0:
- raise Exception('{0} does not define any ImageFields, so your '
- '{1} ImageSpec has no image to act on.'.format(
- instance.__class__.__name__, self.attname))
- elif len(image_fields) > 1:
- raise Exception('{0} defines multiple ImageFields, but you have '
- 'not specified an image_field for your {1} '
- 'ImageSpec.'.format(instance.__class__.__name__,
- self.attname))
- else:
- field = image_fields[0]
- return field
-
def __get__(self, instance, owner):
if instance is None:
return self.field
else:
- img_spec_file = ImageSpecFile(instance, self.field,
- self.attname, self._get_image_field_file(instance))
+ img_spec_file = ImageSpecFile(instance, self.field, self.attname)
setattr(instance, self.attname, img_spec_file)
return img_spec_file
@@ -358,18 +355,22 @@ class ProcessedImageField(models.ImageField, _ImageSpecMixin):
_upload_to_attr = 'upload_to'
attr_class = ProcessedImageFieldFile
- def __init__(self, processors=None, quality=70, format=None,
+ def __init__(self, processors=None, format=None, options={},
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 ``quality`` arguments of
+ as the ``processors``, ``format``, and ``options`` arguments of
:class:`imagekit.models.ImageSpec`.
"""
- _ImageSpecMixin.__init__(self, processors, quality=quality,
- format=format, autoconvert=autoconvert)
+ if 'quality' in kwargs:
+ raise Exception('The "quality" keyword argument has been'
+ """ deprecated. Use `options={'quality': %s}` instead.""" \
+ % kwargs['quality'])
+ _ImageSpecMixin.__init__(self, processors, format=format,
+ options=options, autoconvert=autoconvert)
models.ImageField.__init__(self, verbose_name, name, width_field,
height_field, **kwargs)
diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py
index 51b6282..2dbb857 100644
--- a/imagekit/processors/__init__.py
+++ b/imagekit/processors/__init__.py
@@ -8,7 +8,7 @@ from both the filesystem and the ORM.
"""
from imagekit.lib import Image, ImageColor, ImageEnhance
-from imagekit.processors import resize
+from imagekit.processors import resize, crop
RGBA_TRANSPARENCY_FORMATS = ['PNG']
@@ -167,7 +167,7 @@ class Transpose(object):
try:
orientation = img._getexif()[0x0112]
ops = self._EXIF_ORIENTATION_STEPS[orientation]
- except (TypeError, AttributeError):
+ except (KeyError, TypeError, AttributeError):
ops = []
else:
ops = self.methods
@@ -187,10 +187,10 @@ class AutoConvert(object):
def __init__(self, format):
self.format = format
- self.save_kwargs = {}
def process(self, img):
matte = False
+ self.save_kwargs = {}
if img.mode == 'RGBA':
if self.format in RGBA_TRANSPARENCY_FORMATS:
pass
@@ -227,7 +227,10 @@ class AutoConvert(object):
matte = True
elif img.mode == 'P':
if self.format in PALETTE_TRANSPARENCY_FORMATS:
- self.save_kwargs['transparency'] = img.info['transparency']
+ 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.
@@ -250,4 +253,7 @@ class AutoConvert(object):
bg.paste(img, img)
img = bg.convert('RGB')
+ if self.format == 'JPEG':
+ self.save_kwargs['optimize'] = True
+
return img
diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py
new file mode 100644
index 0000000..aca43d3
--- /dev/null
+++ b/imagekit/processors/crop.py
@@ -0,0 +1,71 @@
+from ..lib import Image, ImageChops, ImageDraw, ImageStat
+
+
+class Side(object):
+ TOP = 't'
+ RIGHT = 'r'
+ BOTTOM = 'b'
+ LEFT = 'l'
+ ALL = (TOP, RIGHT, BOTTOM, LEFT)
+
+
+def crop(img, bbox, sides=Side.ALL):
+ bbox = (
+ bbox[0] if Side.LEFT in sides else 0,
+ bbox[1] if Side.TOP in sides else 0,
+ bbox[2] if Side.RIGHT in sides else img.size[0],
+ bbox[3] if Side.BOTTOM in sides else img.size[1],
+ )
+ return img.crop(bbox)
+
+
+def detect_border_color(img):
+ mask = Image.new('1', img.size, 1)
+ w, h = img.size[0] - 2, img.size[1] - 2
+ if w > 0 and h > 0:
+ draw = ImageDraw.Draw(mask)
+ draw.rectangle([1, 1, w, h], 0)
+ return ImageStat.Stat(img.convert('RGBA').histogram(mask)).median
+
+
+class TrimBorderColor(object):
+ """Trims a color from the sides of an image.
+
+ """
+ def __init__(self, color=None, tolerance=0.3, sides=Side.ALL):
+ """
+ :param color: The color to trim from the image, in a 4-tuple RGBA value,
+ where each component is an integer between 0 and 255, inclusive. If
+ no color is provided, the processor will attempt to detect the
+ border color automatically.
+ :param tolerance: A number between 0 and 1 where 0. Zero is the least
+ tolerant and one is the most.
+ :param sides: A list of sides that should be trimmed. Possible values
+ are provided by the :class:`Side` enum class.
+
+ """
+ self.color = color
+ self.sides = sides
+ self.tolerance = tolerance
+
+ def process(self, img):
+ source = img.convert('RGBA')
+ border_color = self.color or tuple(detect_border_color(source))
+ bg = Image.new('RGBA', img.size, border_color)
+ diff = ImageChops.difference(source, bg)
+ if self.tolerance not in (0, 1):
+ # If tolerance is zero, we've already done the job. A tolerance of
+ # one would mean to trim EVERY color, and since that would result
+ # in a zero-sized image, we just ignore it.
+ if not 0 <= self.tolerance <= 1:
+ raise ValueError('%s is an invalid tolerance. Acceptable values'
+ ' are between 0 and 1 (inclusive).' % self.tolerance)
+ tmp = ImageChops.constant(diff, int(self.tolerance * 255)) \
+ .convert('RGBA')
+ diff = ImageChops.subtract(diff, tmp)
+
+ bbox = diff.getbbox()
+ if bbox:
+ img = crop(img, bbox, self.sides)
+
+ return img
diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py
index e2788d5..4e82bdf 100644
--- a/imagekit/processors/resize.py
+++ b/imagekit/processors/resize.py
@@ -1,21 +1,9 @@
+
+import math
from imagekit.lib import Image
-class _Resize(object):
- width = None
- height = None
-
- def __init__(self, width=None, height=None):
- if width is not None:
- self.width = width
- if height is not None:
- self.height = height
-
- def process(self, img):
- raise NotImplementedError('process must be overridden by subclasses.')
-
-
-class Crop(_Resize):
+class Crop(object):
"""
Resizes an image , cropping it to the specified width and height.
@@ -60,7 +48,8 @@ class Crop(_Resize):
- Crop.BOTTOM_RIGHT
"""
- super(Crop, self).__init__(width, height)
+ self.width = width
+ self.height = height
self.anchor = anchor
def process(self, img):
@@ -86,7 +75,7 @@ class Crop(_Resize):
return img
-class Fit(_Resize):
+class Fit(object):
"""
Resizes an image to fit within the specified dimensions.
@@ -100,7 +89,8 @@ class Fit(_Resize):
dimensions.
"""
- super(Fit, self).__init__(width, height)
+ self.width = width
+ self.height = height
self.upscale = upscale
def process(self, img):
@@ -121,3 +111,87 @@ class Fit(_Resize):
return img
img = img.resize(new_dimensions, Image.ANTIALIAS)
return img
+
+
+def histogram_entropy(im):
+ """
+ Calculate the entropy of an images' histogram. Used for "smart cropping" in easy-thumbnails;
+ see: https://raw.github.com/SmileyChris/easy-thumbnails/master/easy_thumbnails/utils.py
+
+ """
+ if not isinstance(im, Image.Image):
+ return 0 # Fall back to a constant entropy.
+
+ histogram = im.histogram()
+ hist_ceil = float(sum(histogram))
+ histonorm = [histocol / hist_ceil for histocol in histogram]
+
+ return -sum([p * math.log(p, 2) for p in histonorm if p != 0])
+
+
+class SmartCrop(object):
+ """
+ Crop an image 'smartly' -- based on smart crop implementation from easy-thumbnails:
+
+ https://github.com/SmileyChris/easy-thumbnails/blob/master/easy_thumbnails/processors.py#L193
+
+ Smart cropping whittles away the parts of the image with the least entropy.
+
+ """
+
+ def __init__(self, width=None, height=None):
+ self.width = width
+ self.height = height
+
+ def compare_entropy(self, start_slice, end_slice, slice, difference):
+ """
+ Calculate the entropy of two slices (from the start and end of an axis),
+ returning a tuple containing the amount that should be added to the start
+ and removed from the end of the axis.
+
+ """
+ start_entropy = histogram_entropy(start_slice)
+ end_entropy = histogram_entropy(end_slice)
+
+ if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01:
+ # Less than 1% difference, remove from both sides.
+ if difference >= slice * 2:
+ return slice, slice
+ half_slice = slice // 2
+ return half_slice, slice - half_slice
+
+ if start_entropy > end_entropy:
+ return 0, slice
+ else:
+ return slice, 0
+
+ def process(self, img):
+ source_x, source_y = img.size
+ diff_x = int(source_x - min(source_x, self.width))
+ diff_y = int(source_y - min(source_y, self.height))
+ left = top = 0
+ right, bottom = source_x, source_y
+
+ while diff_x:
+ slice = min(diff_x, max(diff_x // 5, 10))
+ start = img.crop((left, 0, left + slice, source_y))
+ end = img.crop((right - slice, 0, right, source_y))
+ add, remove = self.compare_entropy(start, end, slice, diff_x)
+ left += add
+ right -= remove
+ diff_x = diff_x - add - remove
+
+ while diff_y:
+ slice = min(diff_y, max(diff_y // 5, 10))
+ start = img.crop((0, top, source_x, top + slice))
+ end = img.crop((0, bottom - slice, source_x, bottom))
+ add, remove = self.compare_entropy(start, end, slice, diff_y)
+ top += add
+ bottom -= remove
+ diff_y = diff_y - add - remove
+
+ box = (left, top, right, bottom)
+ img = img.crop(box)
+
+ return img
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..6a3296e
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+Django >= 1.3.1
diff --git a/tests/core/assets/Lenna.png b/tests/core/assets/Lenna.png
new file mode 100644
index 0000000..59ef68a
Binary files /dev/null and b/tests/core/assets/Lenna.png differ
diff --git a/tests/core/assets/lenna-800x600-white-border.jpg b/tests/core/assets/lenna-800x600-white-border.jpg
new file mode 100644
index 0000000..d0b1183
Binary files /dev/null and b/tests/core/assets/lenna-800x600-white-border.jpg differ
diff --git a/tests/core/assets/lenna-800x600.jpg b/tests/core/assets/lenna-800x600.jpg
new file mode 100644
index 0000000..7c2ccd8
Binary files /dev/null and b/tests/core/assets/lenna-800x600.jpg differ
diff --git a/tests/core/tests.py b/tests/core/tests.py
index 36d7ae7..55bc70d 100644
--- a/tests/core/tests.py
+++ b/tests/core/tests.py
@@ -1,3 +1,5 @@
+from __future__ import with_statement
+
import os
import tempfile
@@ -9,13 +11,19 @@ from imagekit import utils
from imagekit.lib import Image
from imagekit.models import ImageSpec
from imagekit.processors import Adjust
-from imagekit.processors.resize import Crop
+from imagekit.processors.resize import Crop, SmartCrop
class Photo(models.Model):
original_image = models.ImageField(upload_to='photos')
+
thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), Crop(50, 50)],
- image_field='original_image', format='JPEG', quality=90)
+ image_field='original_image', format='JPEG',
+ options={'quality': 90})
+
+ smartcropped_thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), SmartCrop(50, 50)],
+ image_field='original_image', format='JPEG',
+ options={'quality': 90})
class IKTest(TestCase):
@@ -25,14 +33,42 @@ class IKTest(TestCase):
tmp.seek(0)
return tmp
- def setUp(self):
- self.photo = Photo()
- img = self.generate_image()
+ 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())
- self.photo.original_image = file
- self.photo.original_image.save('test.jpeg', file)
- self.photo.save()
+ 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')
+
+ 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))
def test_save_image(self):
photo = Photo.objects.get(id=self.photo.id)
@@ -47,8 +83,11 @@ class IKTest(TestCase):
self.assertTrue(os.path.isfile(photo.thumbnail.file.name))
def test_thumbnail_size(self):
+ """ Explicit and smart-cropped thumbnail size """
self.assertEqual(self.photo.thumbnail.width, 50)
self.assertEqual(self.photo.thumbnail.height, 50)
+ self.assertEqual(self.photo.smartcropped_thumbnail.width, 50)
+ self.assertEqual(self.photo.smartcropped_thumbnail.height, 50)
def test_thumbnail_source_file(self):
self.assertEqual(