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 +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'' % \ + (self.get_absolute_url(), prop.url) + else: + return u'' % \ + (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