"""Provides built-in thumbnailing of uploaded images at the model level. >>> class Photo(models.Model): ... user = models.ForeignKey(User) ... image = ThumbnailField(thumbnails={'small': Thumbnail(w=150, h=150), 'rounded': RoundedThumbnail(w=150, h=150, radius=5)}) ... def get_image_upload_to(self): ... return 'user-photos/%s/' % self.user.username ... >>> p = Photo(user=user) >>> p.save_image_file('foo.jpg', data) >>> p.save() >>> p.get_image_rounded_url() /assets/user-photos/theusersname/rounded(foo).jpg """ import re import os import os.path import urllib from django.conf import settings from django.db.models import ImageField, signals from django.dispatch import dispatcher from django.utils.functional import curry from PIL import Image, ImageOps, ImageDraw class ThumbnailImageField(ImageField): """Allows model instance to specify upload_to dynamically; also manages a set of thumbnails as generated by a custom set of thumbnail generators. Model class may have a method like: def get_FOO_upload_to(self): return 'path/to/%d' % self.id Based on: http://scottbarnham.com/blog/2007/07/31/uploading-images-to-a-dynamic-path-with-django/ """ def __init__(self, thumbnails={}, *args, **kwargs): dargs = {'upload_to': 'uploaded-images/'} dargs.update(kwargs) super(ThumbnailImageField, self).__init__(*args, **dargs) self.thumbnails = thumbnails def contribute_to_class(self, cls, name): """Hook up events so we can access the instance.""" super(ThumbnailImageField, self).contribute_to_class(cls, name) dispatcher.connect(self._post_init, signals.post_init, sender=cls) dispatcher.connect(self.rebuild_thumbnails, signals.post_save, sender=cls) dispatcher.connect(self.delete_thumbnails, signals.post_delete, sender=cls) for tname in self.thumbnails: setattr(cls, 'get_%s_%s_url' % (name, tname), curry(self.thumbnail_url, tname)) def _post_init(self, instance=None): """Get dynamic upload_to value from the model instance.""" try: method = getattr(instance, 'get_%s_upload_to' % self.attname) except AttributeError: return else: self.upload_to = method() def thumbnail_filename(self, name, instance): thumbnailer = self.thumbnails[name] filename = getattr(instance, self.attname) ext = self.negotiate_output_format(thumbnailer, filename).lower() if ext == 'jpeg': ext = 'jpg' filename = re.sub(r'.*?([^/]+)\.(jpg|png)$', r'\1', filename, re.I) return '%s(%s).%s' % (name, filename, ext) def thumbnail_url(self, name, instance): return settings.MEDIA_URL + urllib.quote(self.upload_to + self.thumbnail_filename(name, instance)) def thumbnail_path(self, name, instance): return os.path.join(settings.MEDIA_ROOT, self.upload_to, self.thumbnail_filename(name, instance)) def rebuild_thumbnails(self, instance=None): """Ensures that all thumbnails for the image exist and are up-to-date, rebuilding them if not.""" im = None #cache im in case we are generating several thumbnails orig = os.path.join(settings.MEDIA_ROOT, getattr(instance, self.attname)) omtime = os.path.getmtime(orig) for name, thumbnailer in self.thumbnails.items(): thumb = self.thumbnail_path(name, instance) try: tmtime = os.path.getmtime(thumb) if tmtime > omtime: continue #thumbnail is up-to-date except OSError: pass # thumbnail (probably) does not exist # generate thumbnail if im is None: im = Image.open(orig) tim = thumbnailer.thumbnail(im.copy()) #thumbnail a copy (Image.thumbnail operates in-place) dirn = os.path.dirname(thumb) if not os.path.exists(dirn): os.makedirs(dirn) if thumb.endswith('.jpg'): tim.save(thumb, 'JPEG') else: tim.save(thumb, 'PNG') def negotiate_output_format(self, thumbnailer, input): if input.lower().endswith('.png'): if thumbnailer.output_alpha() != Thumbnail.FLATTEN_ALPHA: return 'PNG' else: if thumbnailer.output_alpha() == Thumbnail.CREATE_ALPHA: return 'PNG' return 'JPEG' def delete_thumbnails(self, instance=None): """Deletes all thumbnails for a given image.""" for name in self.thumbnails: thumb = self.thumbnail_filename(name, instance) os.unlink(thumb) def db_type(self): """Required by Django for ORM.""" return 'varchar(100)' class Thumbnail(object): """The default thumbnailer, and the base class of other thumbnailers. Each Thumbnail handles the generation, but not the file handling, of the thumbnails for a ThumbnailImageField. """ def __init__(self, w=None, h=None): if w is None: w = 32768 #Infinity! if h is None: h = 32768 #Infinity! self.dims = (w, h) PRESERVE_ALPHA = 0 # outputs an alpha channel if and only if the input provided an alpha channel CREATE_ALPHA = 1 # always outputs an alpha channel FLATTEN_ALPHA = 2 # never outputs an alpha channel def output_alpha(self): """Return a constant representing the behaviour of this thumbnailer instance with respect to the input alpha. The ThumbnailImageField uses this to negotiate whether to generate JPEG or PNG thumbnails. RGB thumbnails are saved as JPEGs, RGBA thumbnails as PNGs. """ return Thumbnail.PRESERVE_ALPHA def thumbnail(self, im): """Called to actually perform the thumbnailing of the object.""" size = im.size if size[0] < self.dims[0] and size[1] < self.dims[1]: return im im.thumbnail(self.dims, Image.ANTIALIAS) return im class ZoomingThumbnail(Thumbnail): """Generates thumbnails at a given size, but zoomed in by a certain factor on the middle of the source image. This is primarily useful when generating thumbnails of textures, where the detail is lost by the normal thumbnailer.""" def __init__(self, scale_factor, w=None, h=None): super(ZoomingThumbnail, self).__init__(w, h) self.scale_factor = scale_factor def thumbnail(self, im): #TODO: adjust zoom to fit, for non-square images zoomed_dims = (self.dims[0] * self.scale_factor, self.dims[1] * self.scale_factor) im.thumbnail(zoomed_dims, Image.ANTIALIAS) return im.crop(( (self.zoomed_dims[0] - self.dims[0])//2, (self.zoomed_dims[1] - self.dims[1])//2, self.dims[0], self.dims[1]) ) class RoundedThumbnail(Thumbnail): """Generates thumbnails with rounded corners.""" def __init__(self, radius=10, w=None, h=None): super(RoundedThumbnail, self).__init__(w, h) self.radius = radius self.generateCorners() def output_alpha(self): return Thumbnail.CREATE_ALPHA def generateCorners(self): import math w = int(math.ceil(self.radius * math.sqrt(0.5))) ioff = self.radius - w buf = '' for j in range(w): row = math.sqrt(self.radius ** 2 - (j + 0.5) ** 2) i = 0 while i + ioff < (row-1): buf += '\xff' i += 1 frac = (row - math.floor(row)) buf += chr(int(frac * 255)) i += 1 while i < w: buf += '\x00' i += 1 q = Image.fromstring('L', (w, w), buf) self.br = Image.new('L', (self.radius, self.radius), 'white') self.br.paste(q, (ioff, 0)) q = ImageOps.mirror(q.rotate(270)) self.br.paste(q, (0, ioff)) draw = ImageDraw.Draw(self.br) draw.rectangle((self.radius-ioff, self.radius-ioff, self.radius, self.radius), fill='black') del(draw) self.tr = ImageOps.flip(self.br) self.tl = ImageOps.mirror(self.tr) self.bl = ImageOps.mirror(self.br) def generateMask(self, dims): mask = Image.new('L', dims, 'white') w, h = dims for corner, top, left in [(self.tl, 0, 0), (self.tr, 0, w - self.radius), (self.bl, h - self.radius, 0), (self.br, h - self.radius, w - self.radius)]: mask.paste(corner, (left, top)) return mask def thumbnail(self, im): im.thumbnail(self.dims, Image.ANTIALIAS) mask = self.generateMask(im.size) buf = Image.new('RGBA', im.size) buf.paste(im, (0, 0), mask) return buf class WatermarkedThumbnail(Thumbnail): def __init__(self, watermark, w=None, h=None): super(WatermarkedThumbnail, self).__init__(w, h) self.watermark = Image.open(os.path.join(settings.MEDIA_ROOT, watermark)) self.watermark.load() def thumbnail(self, im): im = super(WatermarkedThumbnail, self).thumbnail(im) pos = (im.size[0] - self.watermark.size[0], im.size[1] - self.watermark.size[1]) im.paste(self.watermark, pos, self.watermark) return im