summaryrefslogtreecommitdiffstats
path: root/imagekit
diff options
context:
space:
mode:
Diffstat (limited to 'imagekit')
-rw-r--r--imagekit/__init__.py13
-rw-r--r--imagekit/defaults.py21
-rw-r--r--imagekit/lib.py17
-rw-r--r--imagekit/management/__init__.py1
-rw-r--r--imagekit/management/commands/__init__.py1
-rw-r--r--imagekit/management/commands/ikflush.py38
-rw-r--r--imagekit/models.py136
-rw-r--r--imagekit/options.py23
-rw-r--r--imagekit/processors.py134
-rw-r--r--imagekit/specs.py119
-rw-r--r--imagekit/tests.py86
-rw-r--r--imagekit/utils.py15
12 files changed, 604 insertions, 0 deletions
diff --git a/imagekit/__init__.py b/imagekit/__init__.py
new file mode 100644
index 0000000..2965bbd
--- /dev/null
+++ b/imagekit/__init__.py
@@ -0,0 +1,13 @@
+"""
+
+Django ImageKit
+
+Author: Justin Driscoll <justin.driscoll@gmail.com>
+Version: 0.2
+
+"""
+VERSION = "0.2"
+
+
+
+ \ No newline at end of file
diff --git a/imagekit/defaults.py b/imagekit/defaults.py
new file mode 100644
index 0000000..e1a05f6
--- /dev/null
+++ b/imagekit/defaults.py
@@ -0,0 +1,21 @@
+""" Default ImageKit configuration """
+
+from imagekit.specs import ImageSpec
+from imagekit import processors
+
+class ResizeThumbnail(processors.Resize):
+ width = 100
+ height = 50
+ crop = True
+
+class EnhanceSmall(processors.Adjustment):
+ contrast = 1.2
+ sharpness = 1.1
+
+class SampleReflection(processors.Reflection):
+ size = 0.5
+ background_color = "#000000"
+
+class DjangoAdminThumbnail(ImageSpec):
+ access_as = 'admin_thumbnail'
+ processors = [ResizeThumbnail, EnhanceSmall, SampleReflection]
diff --git a/imagekit/lib.py b/imagekit/lib.py
new file mode 100644
index 0000000..65646a4
--- /dev/null
+++ b/imagekit/lib.py
@@ -0,0 +1,17 @@
+# Required PIL classes may or may not be available from the root namespace
+# depending on the installation method used.
+try:
+ import Image
+ import ImageFile
+ import ImageFilter
+ import ImageEnhance
+ import ImageColor
+except ImportError:
+ try:
+ from PIL import Image
+ from PIL import ImageFile
+ from PIL import ImageFilter
+ from PIL import ImageEnhance
+ from PIL import ImageColor
+ 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.') \ No newline at end of file
diff --git a/imagekit/management/__init__.py b/imagekit/management/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/imagekit/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/imagekit/management/commands/__init__.py b/imagekit/management/commands/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/imagekit/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/imagekit/management/commands/ikflush.py b/imagekit/management/commands/ikflush.py
new file mode 100644
index 0000000..c03440f
--- /dev/null
+++ b/imagekit/management/commands/ikflush.py
@@ -0,0 +1,38 @@
+from django.db.models.loading import cache
+from django.core.management.base import BaseCommand, CommandError
+from optparse import make_option
+from imagekit.models import ImageModel
+from imagekit.specs import ImageSpec
+
+
+class Command(BaseCommand):
+ help = ('Clears all ImageKit cached files.')
+ args = '[apps]'
+ requires_model_validation = True
+ can_import_settings = True
+
+ def handle(self, *args, **options):
+ return flush_cache(args, options)
+
+def flush_cache(apps, options):
+ """ Clears the image cache
+
+ """
+ apps = [a.strip(',') for a in apps]
+ if apps:
+ print 'Flushing cache for %s...' % ', '.join(apps)
+ else:
+ print 'Flushing caches...'
+
+ for app_label in apps:
+ app = cache.get_app(app_label)
+ models = [m for m in cache.get_models(app) if issubclass(m, ImageModel)]
+
+ for model in models:
+ for obj in model.objects.all():
+ for spec in model._ik.specs:
+ prop = getattr(obj, spec.name(), None)
+ if prop is not None:
+ prop._delete()
+ if spec.pre_cache:
+ prop._create()
diff --git a/imagekit/models.py b/imagekit/models.py
new file mode 100644
index 0000000..140715e
--- /dev/null
+++ b/imagekit/models.py
@@ -0,0 +1,136 @@
+import os
+from datetime import datetime
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.db import models
+from django.db.models.base import ModelBase
+from django.utils.translation import ugettext_lazy as _
+
+from imagekit import specs
+from imagekit.lib import *
+from imagekit.options import Options
+from imagekit.utils import img_to_fobj
+
+# Modify image file buffer size.
+ImageFile.MAXBLOCK = getattr(settings, 'PIL_IMAGEFILE_MAXBLOCK', 256 * 2 ** 10)
+
+# Choice tuples for specifying the crop origin.
+# These are provided for convenience.
+CROP_HORZ_CHOICES = (
+ (0, _('left')),
+ (1, _('center')),
+ (2, _('right')),
+)
+
+CROP_VERT_CHOICES = (
+ (0, _('top')),
+ (1, _('center')),
+ (2, _('bottom')),
+)
+
+
+class ImageModelBase(ModelBase):
+ """ ImageModel metaclass
+
+ This metaclass parses IKOptions and loads the specified specification
+ module.
+
+ """
+ def __init__(cls, name, bases, attrs):
+ parents = [b for b in bases if isinstance(b, ImageModelBase)]
+ if not parents:
+ return
+ user_opts = getattr(cls, 'IKOptions', None)
+ opts = Options(user_opts)
+ try:
+ module = __import__(opts.spec_module, {}, {}, [''])
+ except ImportError:
+ raise ImportError('Unable to load imagekit config module: %s' % \
+ opts.spec_module)
+ for spec in [spec for spec in module.__dict__.values() \
+ if isinstance(spec, type) \
+ and issubclass(spec, specs.ImageSpec) \
+ and spec != specs.ImageSpec]:
+ setattr(cls, spec.name(), specs.Descriptor(spec))
+ opts.specs.append(spec)
+ setattr(cls, '_ik', opts)
+
+
+class ImageModel(models.Model):
+ """ Abstract base class implementing all core ImageKit functionality
+
+ Subclasses of ImageModel are augmented with accessors for each defined
+ image specification and can override the inner IKOptions class to customize
+ storage locations and other options.
+
+ """
+ __metaclass__ = ImageModelBase
+
+ class Meta:
+ abstract = True
+
+ class IKOptions:
+ pass
+
+ def admin_thumbnail_view(self):
+ if not self._imgfield:
+ return None
+ prop = getattr(self, self._ik.admin_thumbnail_spec, None)
+ if prop is None:
+ return 'An "%s" image spec has not been defined.' % \
+ self._ik.admin_thumbnail_spec
+ else:
+ if hasattr(self, 'get_absolute_url'):
+ return u'<a href="%s"><img src="%s"></a>' % \
+ (self.get_absolute_url(), prop.url)
+ else:
+ return u'<a href="%s"><img src="%s"></a>' % \
+ (self._imgfield.url, prop.url)
+ admin_thumbnail_view.short_description = _('Thumbnail')
+ admin_thumbnail_view.allow_tags = True
+
+ @property
+ def _imgfield(self):
+ return getattr(self, self._ik.image_field)
+
+ def _clear_cache(self):
+ for spec in self._ik.specs:
+ prop = getattr(self, spec.name())
+ prop._delete()
+
+ def _pre_cache(self):
+ for spec in self._ik.specs:
+ if spec.pre_cache:
+ prop = getattr(self, spec.name())
+ prop._create()
+
+ def save(self, clear_cache=True, *args, **kwargs):
+ is_new_object = self._get_pk_val is None
+ super(ImageModel, self).save(*args, **kwargs)
+ if is_new_object:
+ clear_cache = False
+ spec = self._ik.preprocessor_spec
+ if spec is not None:
+ newfile = self._imgfield.storage.open(str(self._imgfield))
+ img = Image.open(newfile)
+ img = spec.process(img, None)
+ format = img.format or 'JPEG'
+ if format != 'JPEG':
+ imgfile = img_to_fobj(img, format)
+ else:
+ imgfile = img_to_fobj(img, format,
+ quality=int(spec.quality),
+ optimize=True)
+ content = ContentFile(imgfile.read())
+ newfile.close()
+ name = str(self._imgfield)
+ self._imgfield.storage.delete(name)
+ self._imgfield.storage.save(name, content)
+ if clear_cache and self._imgfield != '':
+ self._clear_cache()
+ self._pre_cache()
+
+ def delete(self):
+ assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname)
+ self._clear_cache()
+ models.Model.delete(self)
diff --git a/imagekit/options.py b/imagekit/options.py
new file mode 100644
index 0000000..022cc9e
--- /dev/null
+++ b/imagekit/options.py
@@ -0,0 +1,23 @@
+# Imagekit options
+from imagekit import processors
+from imagekit.specs import ImageSpec
+
+
+class Options(object):
+ """ Class handling per-model imagekit options
+
+ """
+ image_field = 'image'
+ crop_horz_field = 'crop_horz'
+ crop_vert_field = 'crop_vert'
+ preprocessor_spec = None
+ cache_dir = 'cache'
+ save_count_as = None
+ cache_filename_format = "%(filename)s_%(specname)s.%(extension)s"
+ admin_thumbnail_spec = 'admin_thumbnail'
+ spec_module = 'imagekit.defaults'
+
+ def __init__(self, opts):
+ for key, value in opts.__dict__.iteritems():
+ setattr(self, key, value)
+ self.specs = [] \ No newline at end of file
diff --git a/imagekit/processors.py b/imagekit/processors.py
new file mode 100644
index 0000000..6f6b480
--- /dev/null
+++ b/imagekit/processors.py
@@ -0,0 +1,134 @@
+""" Imagekit Image "ImageProcessors"
+
+A processor defines a set of class variables (optional) and a
+class method named "process" which processes the supplied image using
+the class properties as settings. The process method can be overridden as well allowing user to define their
+own effects/processes entirely.
+
+"""
+from imagekit.lib import *
+
+class ImageProcessor(object):
+ """ Base image processor class """
+ @classmethod
+ def process(cls, image, obj=None):
+ return image
+
+
+class Adjustment(ImageProcessor):
+ color = 1.0
+ brightness = 1.0
+ contrast = 1.0
+ sharpness = 1.0
+
+ @classmethod
+ def process(cls, image, obj=None):
+ for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
+ factor = getattr(cls, name.lower())
+ if factor != 1.0:
+ image = getattr(ImageEnhance, name)(image).enhance(factor)
+ return image
+
+
+class Reflection(ImageProcessor):
+ background_color = '#FFFFFF'
+ size = 0.0
+ opacity = 0.6
+
+ @classmethod
+ def process(cls, image, obj=None):
+ # convert bgcolor string to rgb value
+ background_color = ImageColor.getrgb(cls.background_color)
+ # copy orignial image and flip the orientation
+ reflection = image.copy().transpose(Image.FLIP_TOP_BOTTOM)
+ # create a new image filled with the bgcolor the same size
+ background = Image.new("RGB", image.size, background_color)
+ # calculate our alpha mask
+ start = int(255 - (255 * cls.opacity)) # The start of our gradient
+ steps = int(255 * cls.size) # the number of intermedite values
+ increment = (255 - start) / float(steps)
+ mask = Image.new('L', (1, 255))
+ for y in range(255):
+ if y < steps:
+ val = int(y * increment + start)
+ else:
+ val = 255
+ mask.putpixel((0, y), val)
+ alpha_mask = mask.resize(image.size)
+ # merge the reflection onto our background color using the alpha mask
+ reflection = Image.composite(background, reflection, alpha_mask)
+ # crop the reflection
+ reflection_height = int(image.size[1] * cls.size)
+ reflection = reflection.crop((0, 0, image.size[0], reflection_height))
+ # create new image sized to hold both the original image and the reflection
+ composite = Image.new("RGB", (image.size[0], image.size[1]+reflection_height), background_color)
+ # paste the orignal image and the reflection into the composite image
+ composite.paste(image, (0, 0))
+ composite.paste(reflection, (0, image.size[1]))
+ # return the image complete with reflection effect
+ return composite
+
+
+class Resize(ImageProcessor):
+ width = None
+ height = None
+ crop = False
+ upscale = False
+
+ @classmethod
+ def process(cls, image, obj=None):
+ cur_width, cur_height = image.size
+ if cls.crop:
+ crop_horz = getattr(obj, obj._ik.crop_horz_field, 1)
+ crop_vert = getattr(obj, obj._ik.crop_vert_field, 1)
+ ratio = max(float(cls.width)/cur_width, float(cls.height)/cur_height)
+ resize_x, resize_y = ((cur_width * ratio), (cur_height * ratio))
+ crop_x, crop_y = (abs(cls.width - resize_x), abs(cls.height - resize_y))
+ x_diff, y_diff = (int(crop_x / 2), int(crop_y / 2))
+ box_left, box_right = {
+ 0: (0, cls.width),
+ 1: (int(x_diff), int(x_diff + cls.width)),
+ 2: (int(crop_x), int(resize_x)),
+ }[crop_horz]
+ box_upper, box_lower = {
+ 0: (0, cls.height),
+ 1: (int(y_diff), int(y_diff + cls.height)),
+ 2: (int(crop_y), int(resize_y)),
+ }[crop_vert]
+ box = (box_left, box_upper, box_right, box_lower)
+ image = image.resize((int(resize_x), int(resize_y)), Image.ANTIALIAS).crop(box)
+ else:
+ if not cls.width is None and not cls.height is None:
+ ratio = min(float(cls.width)/cur_width,
+ float(cls.height)/cur_height)
+ else:
+ if cls.width is None:
+ ratio = float(cls.height)/cur_height
+ else:
+ ratio = float(cls.width)/cur_width
+ new_dimensions = (int(round(cur_width*ratio)),
+ int(round(cur_height*ratio)))
+ if new_dimensions[0] > cur_width or \
+ new_dimensions[1] > cur_height:
+ if not cls.upscale:
+ return image
+ image = image.resize(new_dimensions, Image.ANTIALIAS)
+ return image
+
+
+class Transpose(ImageProcessor):
+ """ Rotates or flips the image
+
+ Method should be one of the following strings:
+ - FLIP_LEFT RIGHT
+ - FLIP_TOP_BOTTOM
+ - ROTATE_90
+ - ROTATE_270
+ - ROTATE_180
+
+ """
+ method = 'FLIP_LEFT_RIGHT'
+
+ @classmethod
+ def process(cls, image, obj=None):
+ return image.transpose(getattr(Image, cls.method))
diff --git a/imagekit/specs.py b/imagekit/specs.py
new file mode 100644
index 0000000..a6832ba
--- /dev/null
+++ b/imagekit/specs.py
@@ -0,0 +1,119 @@
+""" ImageKit image specifications
+
+All imagekit specifications must inherit from the ImageSpec class. Models
+inheriting from ImageModel will be modified with a descriptor/accessor for each
+spec found.
+
+"""
+import os
+from StringIO import StringIO
+from imagekit.lib import *
+from imagekit.utils import img_to_fobj
+from django.core.files.base import ContentFile
+
+class ImageSpec(object):
+ pre_cache = False
+ quality = 70
+ increment_count = False
+ processors = []
+
+ @classmethod
+ def name(cls):
+ return getattr(cls, 'access_as', cls.__name__.lower())
+
+ @classmethod
+ def process(cls, image, obj):
+ processed_image = image.copy()
+ for proc in cls.processors:
+ processed_image = proc.process(processed_image, obj)
+ return processed_image
+
+
+class Accessor(object):
+ def __init__(self, obj, spec):
+ self._img = None
+ self._obj = obj
+ self.spec = spec
+
+ def _get_imgfile(self):
+ format = self._img.format or 'JPEG'
+ if format != 'JPEG':
+ imgfile = img_to_fobj(self._img, format)
+ else:
+ imgfile = img_to_fobj(self._img, format,
+ quality=int(self.spec.quality),
+ optimize=True)
+ return imgfile
+
+ def _create(self):
+ if self._exists():
+ return
+ # process the original image file
+ fp = self._obj._imgfield.storage.open(self._obj._imgfield.name)
+ fp.seek(0)
+ fp = StringIO(fp.read())
+ try:
+ self._img = self.spec.process(Image.open(fp), self._obj)
+ # save the new image to the cache
+ content = ContentFile(self._get_imgfile().read())
+ self._obj._imgfield.storage.save(self.name, content)
+ except IOError:
+ pass
+
+ def _delete(self):
+ self._obj._imgfield.storage.delete(self.name)
+
+ def _exists(self):
+ return self._obj._imgfield.storage.exists(self.name)
+
+ def _basename(self):
+ filename, extension = \
+ os.path.splitext(os.path.basename(self._obj._imgfield.name))
+ return self._obj._ik.cache_filename_format % \
+ {'filename': filename,
+ 'specname': self.spec.name(),
+ 'extension': extension.lstrip('.')}
+
+ @property
+ def name(self):
+ return os.path.join(self._obj._ik.cache_dir, self._basename())
+
+ @property
+ def url(self):
+ self._create()
+ if self.spec.increment_count:
+ fieldname = self._obj._ik.save_count_as
+ if fieldname is not None:
+ current_count = getattr(self._obj, fieldname)
+ setattr(self._obj, fieldname, current_count + 1)
+ self._obj.save(clear_cache=False)
+ return self._obj._imgfield.storage.url(self.name)
+
+ @property
+ def file(self):
+ self._create()
+ return self._obj._imgfield.storage.open(self.name)
+
+ @property
+ def image(self):
+ if self._img is None:
+ self._create()
+ if self._img is None:
+ self._img = Image.open(self.file)
+ return self._img
+
+ @property
+ def width(self):
+ return self.image.size[0]
+
+ @property
+ def height(self):
+ return self.image.size[1]
+
+
+class Descriptor(object):
+ def __init__(self, spec):
+ self._spec = spec
+
+ def __get__(self, obj, type=None):
+ return Accessor(obj, self._spec)
diff --git a/imagekit/tests.py b/imagekit/tests.py
new file mode 100644
index 0000000..8c2eb5e
--- /dev/null
+++ b/imagekit/tests.py
@@ -0,0 +1,86 @@
+import os
+import tempfile
+import unittest
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.db import models
+from django.test import TestCase
+
+from imagekit import processors
+from imagekit.models import ImageModel
+from imagekit.specs import ImageSpec
+from imagekit.lib import Image
+
+
+class ResizeToWidth(processors.Resize):
+ width = 100
+
+class ResizeToHeight(processors.Resize):
+ height = 100
+
+class ResizeToFit(processors.Resize):
+ width = 100
+ height = 100
+
+class ResizeCropped(ResizeToFit):
+ crop = ('center', 'center')
+
+class TestResizeToWidth(ImageSpec):
+ access_as = 'to_width'
+ processors = [ResizeToWidth]
+
+class TestResizeToHeight(ImageSpec):
+ access_as = 'to_height'
+ processors = [ResizeToHeight]
+
+class TestResizeCropped(ImageSpec):
+ access_as = 'cropped'
+ processors = [ResizeCropped]
+
+class TestPhoto(ImageModel):
+ """ Minimal ImageModel class for testing """
+ image = models.ImageField(upload_to='images')
+
+ class IKOptions:
+ spec_module = 'imagekit.tests'
+
+
+class IKTest(TestCase):
+ """ Base TestCase class """
+ def setUp(self):
+ # create a test image using tempfile and PIL
+ self.tmp = tempfile.TemporaryFile()
+ Image.new('RGB', (800, 600)).save(self.tmp, 'JPEG')
+ self.tmp.seek(0)
+ self.p = TestPhoto()
+ self.p.image.save(os.path.basename('test.jpg'),
+ ContentFile(self.tmp.read()))
+ self.p.save()
+ # destroy temp file
+ self.tmp.close()
+
+ def test_setup(self):
+ self.assertEqual(self.p.image.width, 800)
+ self.assertEqual(self.p.image.height, 600)
+
+ def test_to_width(self):
+ self.assertEqual(self.p.to_width.width, 100)
+ self.assertEqual(self.p.to_width.height, 75)
+
+ def test_to_height(self):
+ self.assertEqual(self.p.to_height.width, 133)
+ self.assertEqual(self.p.to_height.height, 100)
+
+ def test_crop(self):
+ self.assertEqual(self.p.cropped.width, 100)
+ self.assertEqual(self.p.cropped.height, 100)
+
+ def test_url(self):
+ tup = (settings.MEDIA_URL, self.p._ik.cache_dir, 'test_to_width.jpg')
+ self.assertEqual(self.p.to_width.url, "%s%s/%s" % tup)
+
+ def tearDown(self):
+ # make sure image file is deleted
+ path = self.p.image.path
+ self.p.delete()
+ self.failIf(os.path.isfile(path))
diff --git a/imagekit/utils.py b/imagekit/utils.py
new file mode 100644
index 0000000..352d40f
--- /dev/null
+++ b/imagekit/utils.py
@@ -0,0 +1,15 @@
+""" ImageKit utility functions """
+
+import tempfile
+
+def img_to_fobj(img, format, **kwargs):
+ tmp = tempfile.TemporaryFile()
+ if format != 'JPEG':
+ try:
+ img.save(tmp, format, **kwargs)
+ return
+ except KeyError:
+ pass
+ img.save(tmp, format, **kwargs)
+ tmp.seek(0)
+ return tmp