mirror of
https://github.com/Hopiu/django-imagekit.git
synced 2026-03-28 18:50:31 +00:00
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:
commit
0656292bef
18 changed files with 324 additions and 241 deletions
4
AUTHORS
4
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
|
||||
|
|
|
|||
20
README.rst
20
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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ API Reference
|
|||
.. automodule:: imagekit.processors.resize
|
||||
:members:
|
||||
|
||||
.. automodule:: imagekit.processors.crop
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`admin` Module
|
||||
--------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
imagekit/processors/crop.py
Normal file
71
imagekit/processors/crop.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Django >= 1.3.1
|
||||
BIN
tests/core/assets/Lenna.png
Normal file
BIN
tests/core/assets/Lenna.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 463 KiB |
BIN
tests/core/assets/lenna-800x600-white-border.jpg
Normal file
BIN
tests/core/assets/lenna-800x600-white-border.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
tests/core/assets/lenna-800x600.jpg
Normal file
BIN
tests/core/assets/lenna-800x600.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue