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. - -:: - -
- {{ photo.name }} -
- -
- {{ photo.name }} -
- -
- {% for p in photos %} - {{ p.name }} - {% 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(