Image resizing on file uploads. Doing it the easy way.

Python    Django    2010-01-13

Let me start with this proviso: Math is not my strong subject (okay, just stop right there with the Barbie jokes). I'll do just about anything to avoid writing complex equations. And most of the time, I don't have to. Most of the time, I can find alternative solutions, dependent on the type of problem I'm trying to solve of course.

In this case, I'm working on a photo gallery app for a new project (which will be up on github shortly - at this point, it's still too WIP to be put on display). One of the features I'm building in is automated image resizing - an admin user will be able to upload an original image of whatever dimension and have two separate additional images created (a thumbnail and a mid-sized "medium" image).

In my models.py, I'm importing from PIL:

from PIL import Image
import sys, time

My Photo class has three fields for handling the paths to these images - "photo_thumb" and "photo_medium" are for storing the paths of the new images once they've been created. And "photo_original" is the file upload field. I'm using a dynamic file path (each original image and its subsequent resized counterparts are stored in a unique folder named for the timestamp, all under the main gallery folder) in anticipation of duplicate file names.

class Photo(models.Model):
    """
    three size sets:
        thumbnail (photo_thumb)
        medium (photo_medium)
        original (photo_original)
    """

    now = str(int(time.time()))
    filepath = 'gallery/'+now+'/'

    photo_original = models.FileField('original file upload', upload_to=filepath)
    photo_medium = models.CharField(max_length=255, blank=True)
    photo_thumb = models.CharField(max_length=255, blank=True)
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    content_date = models.DateField()
    permissions = models.CharField(max_length=255)
    photo_credits = models.CharField(max_length=255, blank=True)
    approved = models.BooleanField(default=False)
    active = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

I've added a few additional methods to return paths to the thumbnail, medium, and original images (these are not so different from get_absolute_url()).

    def get_thumb(self):
        return "/site_media/%s" % self.photo_thumb

    def get_medium(self):
        return "/site_media/%s" % self.photo_medium

    def get_original(self):
        return "/site_media/%s" % self.photo_original

And all the image resizing magic happens here, where I'm overriding the model's save() method.

    def save(self):
        sizes = {'thumbnail': {'height': 100, 'width': 100}, 'medium': {'height': 300, 'width': 300},}

        super(Photo, self).save()
        photopath = str(self.photo_original.path)  # this returns the full system path to the original file
        im = Image.open(photopath)  # open the image using PIL

	# pull a few variables out of that full path
        extension = photopath.rsplit('.', 1)[1]  # the file extension
        filename = photopath.rsplit('/', 1)[1].rsplit('.', 1)[0]  # the file name only (minus path or extension)
        fullpath = photopath.rsplit('/', 1)[0]  # the path only (minus the filename.extension)

        # use the file extension to determine if the image is valid before proceeding
        if extension not in ['jpg', 'jpeg', 'gif', 'png']: sys.exit()

        # create medium image
        im.thumbnail((sizes['medium']['width'], sizes['medium']['height']), Image.ANTIALIAS)
        medname = filename + "_" + str(sizes['medium']['width']) + "x" + str(sizes['medium']['height']) + ".jpg"
        im.save(fullpath + '/' + medname)
        self.photo_medium = self.filepath + medname

        # create thumbnail
        im.thumbnail((sizes['thumbnail']['width'], sizes['thumbnail']['height']), Image.ANTIALIAS)
        thumbname = filename + "_" + str(sizes['thumbnail']['width']) + "x" + str(sizes['thumbnail']['height']) + ".jpg"
        im.save(fullpath + '/' + thumbname)
        self.photo_thumb = self.filepath + thumbname

        super(Photo, self).save()

As I was researching to figure out how I wanted to do this, I came across a number of snippets and code samples that all used comparisons of images' aspect ratios, comparisons ranging from the simple to the complex. Under some circumstances, it's certainly necessary to do that. But in my case, I just need to produce a set of thumbnails that will not exceed a certain height or width, so that they look relatively uniform on a gallery page.

So for my purposes - and hopefully there are similar cases out there - there's no need to compare image dimensions to determine a resize rate. Instead, I'm using PIL's thumbnail() method. Thumbnail() takes a sizes tuple (w, h) and an optional filter as arguments. When thumbnail() resizes, it produces an image no larger than the given dimensions, and maintains the original aspect. Problem solved!

(For more information on thumbnail(), click here, and for PIL in general, click here.)

Just a note - if I were going to be doing anything with aspect ratios, I'd have done something like this - the size attribute returns a tuple containing the image's width and height in pixels:

        pw = im.size[0]
        ph = im.size[1]

And calculating the aspects of the original image and the incoming dimensions is this simple:

        pr = float(pw) / float(ph)
        mr = float(sizes['medium']['width']) / float(sizes['medium']['height'])

Incidentally, in my save() method I'm creating the medium image first and then cascading down to the thumbnail because, as you might have noticed, I'm leaving the temp image assigned to "im" all the way down. If it mattered to you and you needed to create the images in some order other than largest to smallest, you'd need to use different var names. Just something to be aware of.

Also, that sizes dict at the top may not stay there - I'm trying to come up with a sensible way to make the sizes configurable through the admin. At the moment, I'm considering adding a few more fields to the model and passing them in that way. But if you've got a better idea, let me know.

And here's that models.py, all together (minus a few other classes):

from PIL import Image
import sys, time

from django.db import models

class Photo(models.Model):
    """
    three size sets:
        thumbnail (photo_thumb)
        medium (photo_medium)
        original (photo_original)
    """

    now = str(int(time.time()))
    filepath = 'gallery/'+now+'/'

    photo_original = models.FileField('original file upload', upload_to=filepath)
    photo_medium = models.CharField(max_length=255, blank=True)
    photo_thumb = models.CharField(max_length=255, blank=True)
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    content_date = models.DateField()
    permissions = models.CharField(max_length=255)
    photo_credits = models.CharField(max_length=255, blank=True)
    approved = models.BooleanField(default=False)
    active = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

    def get_thumb(self):
        return "/site_media/%s" % self.photo_thumb

    def get_medium(self):
        return "/site_media/%s" % self.photo_medium

    def get_original(self):
        return "/site_media/%s" % self.photo_original

    def save(self):
        sizes = {'thumbnail': {'height': 100, 'width': 100}, 'medium': {'height': 300, 'width': 300},}

        super(Photo, self).save()
        photopath = str(self.photo_original.path)  # this returns the full system path to the original file
        im = Image.open(photopath)  # open the image using PIL

	# pull a few variables out of that full path
        extension = photopath.rsplit('.', 1)[1]  # the file extension
        filename = photopath.rsplit('/', 1)[1].rsplit('.', 1)[0]  # the file name only (minus path or extension)
        fullpath = photopath.rsplit('/', 1)[0]  # the path only (minus the filename.extension)

        # use the file extension to determine if the image is valid before proceeding
        if extension not in ['jpg', 'jpeg', 'gif', 'png']: sys.exit()

        # create medium image
        im.thumbnail((sizes['medium']['width'], sizes['medium']['height']), Image.ANTIALIAS)
        medname = filename + "_" + str(sizes['medium']['width']) + "x" + str(sizes['medium']['height']) + ".jpg"
        im.save(fullpath + '/' + medname)
        self.photo_medium = self.filepath + medname

        # create thumbnail
        im.thumbnail((sizes['thumbnail']['width'], sizes['thumbnail']['height']), Image.ANTIALIAS)
        thumbname = filename + "_" + str(sizes['thumbnail']['width']) + "x" + str(sizes['thumbnail']['height']) + ".jpg"
        im.save(fullpath + '/' + thumbname)
        self.photo_thumb = self.filepath + thumbname

        super(Photo, self).save()

    class Meta:
        ordering = ['-content_date']