Merge branch 'release/1.1.0'

* release/1.1.0: (28 commits)
  Adding (crude) changelog information for 1.1.0.
  Bumping the version number.
  Removing Pillow from requirements.txt and adding a note about PIL/Pillow to the README. References #72.
  Being a bit friendlier to Python 2.5 users... or is it 2.4. Fixes #82.
  Change handling of spec deletion
  No need to check for None
  Catch KeyError on image.info dictionary
  Using difference instead of subtract
  Indentation tweak
  Makes evaluation of `source_file` lazy
  Test illustrating #75, #74
  Separate create_photo method
  avoid Transpose to crash when exif data doesn't exists
  Allows `None` value for `processors` argument
  Remove _Resize class
  Adds crop module to docs
  Explicitly import crop module
  Renames processor to `TrimBorderColor`
  Gives precedence to user options
  Replaces `quality` argument with `options` dict
  ...
This commit is contained in:
Bryan Veloso 2011-12-22 22:18:31 -08:00
commit c0ac951be9
18 changed files with 324 additions and 241 deletions

View file

@ -6,6 +6,7 @@ Maintainers
~~~~~~~~~~~ ~~~~~~~~~~~
* `Bryan Veloso`_ * `Bryan Veloso`_
* `Matthew Tretter`_
* `Chris Drackett`_ * `Chris Drackett`_
* `Greg Newman`_ * `Greg Newman`_
@ -14,11 +15,11 @@ Contributors
* `Josh Ourisman`_ * `Josh Ourisman`_
* `Jonathan Slenders`_ * `Jonathan Slenders`_
* `Matthew Tretter`_
* `Eric Eldredge`_ * `Eric Eldredge`_
* `Chris McKenzie`_ * `Chris McKenzie`_
* `Markus Kaiserswerth`_ * `Markus Kaiserswerth`_
* `Ryan Bagwell`_ * `Ryan Bagwell`_
* `Alexander Bohn`_
.. _Justin Driscoll: http://github.com/jdriscoll .. _Justin Driscoll: http://github.com/jdriscoll
@ -33,3 +34,4 @@ Contributors
.. _Chris McKenzie: http://github.com/kenzic .. _Chris McKenzie: http://github.com/kenzic
.. _Ryan Bagwell: http://github.com/ryanbagwell .. _Ryan Bagwell: http://github.com/ryanbagwell
.. _Markus Kaiserswerth: http://github.com/mkai .. _Markus Kaiserswerth: http://github.com/mkai
.. _Alexander Bohn: http://github.com/fish2000

View file

@ -6,9 +6,19 @@ like different sizes (e.g. thumbnails) and black and white versions.
Installation 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) (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 Adding Specs to a Model
@ -23,7 +33,7 @@ of a model class::
class Photo(models.Model): class Photo(models.Model):
original_image = models.ImageField(upload_to='photos') original_image = models.ImageField(upload_to='photos')
formatted_image = ImageSpec(image_field='original_image', format='JPEG', 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 Accessing the spec through a model instance will create the image and return
an ImageFile-like object (just like with a normal 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') original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1),
resize.Crop(50, 50)], image_field='original_image', 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:: The ``thumbnail`` property will now return a cropped image::
@ -77,7 +87,7 @@ implement a ``process()`` method::
class Photo(models.Model): class Photo(models.Model):
original_image = models.ImageField(upload_to='photos') original_image = models.ImageField(upload_to='photos')
watermarked_image = ImageSpec([Watermark()], image_field='original_image', watermarked_image = ImageSpec([Watermark()], image_field='original_image',
format='JPEG', quality=90) format='JPEG', options={'quality': 90})
Admin Admin

View file

@ -18,6 +18,9 @@ API Reference
.. automodule:: imagekit.processors.resize .. automodule:: imagekit.processors.resize
:members: :members:
.. automodule:: imagekit.processors.crop
:members:
:mod:`admin` Module :mod:`admin` Module
-------------------- --------------------

View file

@ -1,16 +1,30 @@
Changelog 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 v1.0.3
------ ------
- ``ImageSpec._create()`` was renamed ``ImageSpec.generate()`` and is now - ``ImageSpec._create()`` was renamed ``ImageSpec.generate()`` and is now
available in the public API. available in the public API.
- Added an ``AutoConvert`` processor to encapsulate the trasnparency - Added an ``AutoConvert`` processor to encapsulate the transparency
handling logic. 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 the situations in which one would convert to or from formats that support
transparency. transparency.

View file

@ -48,9 +48,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett &
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '1.0.3' version = '1.1.0'
# The full version, including alpha/beta/rc tags. # 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 # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View file

@ -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.
::
<div class="original">
<img src="{{ photo.original_image.url }}" alt="{{ photo.name }}">
</div>
<div class="display">
<img src="{{ photo.display.url }}" alt="{{ photo.name }}">
</div>
<div class="thumbs">
{% for p in photos %}
<img src="{{ p.thumbnail_image.url }}" alt="{{ p.name }}">
{% endfor %}
</div>
Step 6
******
Play with the API.
::
>>> from myapp.models import Photo
>>> p = Photo.objects.all()[0]
<Photo: MyPhoto>
>>> p.display.url
u'/static/photos/myphoto_display.jpg'
>>> p.display.width
600
>>> p.display.height
420
>>> p.display.image
<JpegImagePlugin.JpegImageFile instance at 0xf18990>
>>> p.display.file
<File: /path/to/media/photos/myphoto_display.jpg>
>>> p.display.spec
<class 'myapp.specs.Display'>
Step 7
******
Enjoy a nice beverage.
::
from refrigerator import beer
beer.enjoy()

View file

@ -1,4 +1,4 @@
__title__ = 'django-imagekit' __title__ = 'django-imagekit'
__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' __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' __license__ = 'BSD'

View file

@ -1,13 +1,17 @@
# Required PIL classes may or may not be available from the root namespace # Required PIL classes may or may not be available from the root namespace
# depending on the installation method used. # depending on the installation method used.
try: try:
from PIL import Image, ImageColor, ImageEnhance, ImageFile, ImageFilter from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, \
ImageFilter, ImageDraw, ImageStat
except ImportError: except ImportError:
try: try:
import Image import Image
import ImageColor import ImageColor
import ImageChops
import ImageEnhance import ImageEnhance
import ImageFile import ImageFile
import ImageFilter import ImageFilter
import ImageDraw
import ImageStat
except ImportError: 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.') raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')

View file

@ -27,8 +27,7 @@ def flush_cache(apps, options):
print 'Flushing cache for "%s.%s"' % (app_label, model.__name__) print 'Flushing cache for "%s.%s"' % (app_label, model.__name__)
for obj in model.objects.order_by('-pk'): for obj in model.objects.order_by('-pk'):
for spec_file in get_spec_files(obj): 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: if spec_file.field.pre_cache:
spec_file.generate(False) spec_file.generate(False)
else: else:

View file

@ -15,15 +15,15 @@ from imagekit.processors import ProcessorPipeline, AutoConvert
class _ImageSpecMixin(object): class _ImageSpecMixin(object):
def __init__(self, processors=None, quality=70, format=None, def __init__(self, processors=None, format=None, options={},
autoconvert=True): autoconvert=True):
self.processors = processors self.processors = processors
self.quality = quality
self.format = format self.format = format
self.options = options
self.autoconvert = autoconvert self.autoconvert = autoconvert
def process(self, image, file): def process(self, image, file):
processors = ProcessorPipeline(self.processors) processors = ProcessorPipeline(self.processors or [])
return processors.process(image.copy()) return processors.process(image.copy())
@ -35,16 +35,19 @@ class ImageSpec(_ImageSpecMixin):
""" """
_upload_to_attr = 'cache_to' _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, image_field=None, pre_cache=False, storage=None, cache_to=None,
autoconvert=True): autoconvert=True):
""" """
:param processors: A list of processors to run on the original image. :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, :param format: The format of the output file. If not provided,
ImageSpec will try to guess the appropriate format based on the ImageSpec will try to guess the appropriate format based on the
extension of the filename and the format of the input image. 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 :param image_field: The name of the model property that contains the
original image. original image.
:param pre_cache: A boolean that specifies whether the image should :param pre_cache: A boolean that specifies whether the image should
@ -71,8 +74,8 @@ class ImageSpec(_ImageSpecMixin):
""" """
_ImageSpecMixin.__init__(self, processors, quality=quality, _ImageSpecMixin.__init__(self, processors, format=format,
format=format, autoconvert=autoconvert) options=options, autoconvert=autoconvert)
self.image_field = image_field self.image_field = image_field
self.pre_cache = pre_cache self.pre_cache = pre_cache
self.storage = storage self.storage = storage
@ -89,12 +92,10 @@ class ImageSpec(_ImageSpecMixin):
# Connect to the signals only once for this class. # Connect to the signals only once for this class.
uid = '%s.%s' % (cls.__module__, cls.__name__) uid = '%s.%s' % (cls.__module__, cls.__name__)
post_save.connect(_post_save_handler, post_save.connect(_post_save_handler, sender=cls,
sender=cls, dispatch_uid='%s_save' % uid)
dispatch_uid='%s_save' % uid) post_delete.connect(_post_delete_handler, sender=cls,
post_delete.connect(_post_delete_handler, dispatch_uid='%s.delete' % uid)
sender=cls,
dispatch_uid='%s.delete' % uid)
def _get_suggested_extension(name, format): def _get_suggested_extension(name, format):
@ -125,6 +126,7 @@ class _ImageSpecFileMixin(object):
img = open_image(content) img = open_image(content)
original_format = img.format original_format = img.format
img = self.field.process(img, self) img = self.field.process(img, self)
options = dict(self.field.options or {})
# Determine the format. # Determine the format.
format = self.field.format format = self.field.format
@ -139,29 +141,45 @@ class _ImageSpecFileMixin(object):
pass pass
format = format or img.format or original_format or 'JPEG' 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 # Run the AutoConvert processor
if getattr(self.field, 'autoconvert', True): if getattr(self.field, 'autoconvert', True):
autoconvert_processor = AutoConvert(format) autoconvert_processor = AutoConvert(format)
img = autoconvert_processor.process(img) 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()) content = ContentFile(imgfile.read())
return img, content return img, content
class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile): class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile):
def __init__(self, instance, field, attname, source_file): def __init__(self, instance, field, attname):
ImageFieldFile.__init__(self, instance, field, None) ImageFieldFile.__init__(self, instance, field, None)
self.storage = field.storage or source_file.storage
self.attname = attname 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): def _require_file(self):
if not self.source_file: if not self.source_file:
@ -220,10 +238,11 @@ class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile):
self.close() self.close()
del self.file del self.file
try: if self.name and self.storage.exists(self.name):
self.storage.delete(self.name) try:
except (NotImplementedError, IOError): self.storage.delete(self.name)
pass except NotImplementedError:
pass
# Delete the filesize cache. # Delete the filesize cache.
if hasattr(self, '_size'): if hasattr(self, '_size'):
@ -291,33 +310,11 @@ class _ImageSpecDescriptor(object):
self.attname = attname self.attname = attname
self.field = field 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): def __get__(self, instance, owner):
if instance is None: if instance is None:
return self.field return self.field
else: else:
img_spec_file = ImageSpecFile(instance, self.field, img_spec_file = ImageSpecFile(instance, self.field, self.attname)
self.attname, self._get_image_field_file(instance))
setattr(instance, self.attname, img_spec_file) setattr(instance, self.attname, img_spec_file)
return img_spec_file return img_spec_file
@ -358,18 +355,22 @@ class ProcessedImageField(models.ImageField, _ImageSpecMixin):
_upload_to_attr = 'upload_to' _upload_to_attr = 'upload_to'
attr_class = ProcessedImageFieldFile 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, verbose_name=None, name=None, width_field=None, height_field=None,
autoconvert=True, **kwargs): autoconvert=True, **kwargs):
""" """
The ProcessedImageField constructor accepts all of the arguments that The ProcessedImageField constructor accepts all of the arguments that
the :class:`django.db.models.ImageField` constructor accepts, as well 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`. :class:`imagekit.models.ImageSpec`.
""" """
_ImageSpecMixin.__init__(self, processors, quality=quality, if 'quality' in kwargs:
format=format, autoconvert=autoconvert) 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, models.ImageField.__init__(self, verbose_name, name, width_field,
height_field, **kwargs) height_field, **kwargs)

View file

@ -8,7 +8,7 @@ from both the filesystem and the ORM.
""" """
from imagekit.lib import Image, ImageColor, ImageEnhance from imagekit.lib import Image, ImageColor, ImageEnhance
from imagekit.processors import resize from imagekit.processors import resize, crop
RGBA_TRANSPARENCY_FORMATS = ['PNG'] RGBA_TRANSPARENCY_FORMATS = ['PNG']
@ -167,7 +167,7 @@ class Transpose(object):
try: try:
orientation = img._getexif()[0x0112] orientation = img._getexif()[0x0112]
ops = self._EXIF_ORIENTATION_STEPS[orientation] ops = self._EXIF_ORIENTATION_STEPS[orientation]
except (TypeError, AttributeError): except (KeyError, TypeError, AttributeError):
ops = [] ops = []
else: else:
ops = self.methods ops = self.methods
@ -187,10 +187,10 @@ class AutoConvert(object):
def __init__(self, format): def __init__(self, format):
self.format = format self.format = format
self.save_kwargs = {}
def process(self, img): def process(self, img):
matte = False matte = False
self.save_kwargs = {}
if img.mode == 'RGBA': if img.mode == 'RGBA':
if self.format in RGBA_TRANSPARENCY_FORMATS: if self.format in RGBA_TRANSPARENCY_FORMATS:
pass pass
@ -227,7 +227,10 @@ class AutoConvert(object):
matte = True matte = True
elif img.mode == 'P': elif img.mode == 'P':
if self.format in PALETTE_TRANSPARENCY_FORMATS: 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: elif self.format in RGBA_TRANSPARENCY_FORMATS:
# Currently PIL doesn't support any RGBA-mode formats that # Currently PIL doesn't support any RGBA-mode formats that
# aren't also P-mode formats, so this will never happen. # aren't also P-mode formats, so this will never happen.
@ -250,4 +253,7 @@ class AutoConvert(object):
bg.paste(img, img) bg.paste(img, img)
img = bg.convert('RGB') img = bg.convert('RGB')
if self.format == 'JPEG':
self.save_kwargs['optimize'] = True
return img return img

View file

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

View file

@ -1,21 +1,9 @@
import math
from imagekit.lib import Image from imagekit.lib import Image
class _Resize(object): class Crop(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):
""" """
Resizes an image , cropping it to the specified width and height. Resizes an image , cropping it to the specified width and height.
@ -60,7 +48,8 @@ class Crop(_Resize):
- Crop.BOTTOM_RIGHT - Crop.BOTTOM_RIGHT
""" """
super(Crop, self).__init__(width, height) self.width = width
self.height = height
self.anchor = anchor self.anchor = anchor
def process(self, img): def process(self, img):
@ -86,7 +75,7 @@ class Crop(_Resize):
return img return img
class Fit(_Resize): class Fit(object):
""" """
Resizes an image to fit within the specified dimensions. Resizes an image to fit within the specified dimensions.
@ -100,7 +89,8 @@ class Fit(_Resize):
dimensions. dimensions.
""" """
super(Fit, self).__init__(width, height) self.width = width
self.height = height
self.upscale = upscale self.upscale = upscale
def process(self, img): def process(self, img):
@ -121,3 +111,87 @@ class Fit(_Resize):
return img return img
img = img.resize(new_dimensions, Image.ANTIALIAS) img = img.resize(new_dimensions, Image.ANTIALIAS)
return img 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

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
Django >= 1.3.1

BIN
tests/core/assets/Lenna.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -1,3 +1,5 @@
from __future__ import with_statement
import os import os
import tempfile import tempfile
@ -9,13 +11,19 @@ from imagekit import utils
from imagekit.lib import Image from imagekit.lib import Image
from imagekit.models import ImageSpec from imagekit.models import ImageSpec
from imagekit.processors import Adjust from imagekit.processors import Adjust
from imagekit.processors.resize import Crop from imagekit.processors.resize import Crop, SmartCrop
class Photo(models.Model): class Photo(models.Model):
original_image = models.ImageField(upload_to='photos') original_image = models.ImageField(upload_to='photos')
thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), Crop(50, 50)], 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): class IKTest(TestCase):
@ -25,14 +33,42 @@ class IKTest(TestCase):
tmp.seek(0) tmp.seek(0)
return tmp return tmp
def setUp(self): def generate_lenna(self):
self.photo = Photo() """
img = self.generate_image() 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()) file = ContentFile(img.read())
self.photo.original_image = file photo.original_image = file
self.photo.original_image.save('test.jpeg', file) photo.original_image.save(name, file)
self.photo.save() photo.save()
img.close() 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): def test_save_image(self):
photo = Photo.objects.get(id=self.photo.id) photo = Photo.objects.get(id=self.photo.id)
@ -47,8 +83,11 @@ class IKTest(TestCase):
self.assertTrue(os.path.isfile(photo.thumbnail.file.name)) self.assertTrue(os.path.isfile(photo.thumbnail.file.name))
def test_thumbnail_size(self): def test_thumbnail_size(self):
""" Explicit and smart-cropped thumbnail size """
self.assertEqual(self.photo.thumbnail.width, 50) self.assertEqual(self.photo.thumbnail.width, 50)
self.assertEqual(self.photo.thumbnail.height, 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): def test_thumbnail_source_file(self):
self.assertEqual( self.assertEqual(