Django Images & Thumbnails
4 min read

Django Images & Thumbnails

Django Images & Thumbnails

I recently added an avatar image to my User model in my latest project, and I had a few extra requirements around it:

  • The stored image should be square
  • The stored image should have a maximum size of (400, 400)
  • An extra, smaller, thumbnail image should be stored - size (60, 60)

First, I defined the sizes I wanted for my main image and the thumb, and added 2 fields to my User model, one for the main avatar image, and one for the thumbnail version.

AVATAR_DIMENSION = 400
THUMBNAIL_SIZE = (60, 60)

class User(AbstractBaseUser, PermissionsMixin):
    ...
    avatar = models.ImageField(upload_to="profiles/", blank=True, null=True)
    avatar_thumb = models.ImageField(upload_to="profiles/thumbs/", blank=True, null=True)

I also initiate a var to hold the current value:

   __orig_avatar = None

And add an __init__ method to set this when we instantiate a User object:

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__orig_avatar = self.avatar

This means that when we initialise a User object using something like:

user = User.objects.get(pk=1)

We can access not only the avatar and avatar_thumb values, but also the __orig_avatar value. This will be identical to avatar immediately after creating the User instance. We will use this in the save() method to see if the value of avatar has changed.

On the user_form.html template, I am using a table to display the fields, and added the avatar field in to a new table row. My project also uses crispy forms as you can see, though this isn't a requirement to get the image upload working.

<tr>
   <td>
      {{ form.avatar|as_crispy_field }}
      {% if form.avatar.value %}
         <img src="{{ MEDIA_PREFIX }}{{ form.avatar.value }}" class="rounded-circle" 
               alt="{{ form.avatar.value }}">
      {% endif %}
   </td>
</tr>

Since the field is an ImageField this displays the necessary upload button to allow the user to select the file they want. A "clear" checkbox is also provided should the user want to remove the image completely.

When the form is submitted and the model is saved, the User.save() method from the model will get called.

First, we call the parent save method to update the fields we have set. If the user has changed the avatar by uploading a new image, then this will make sure the new value is set.

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

It will not, however, change the value of __orig_avatar, meaning we can check against this to see if it has changed.

If we've cleared a previously existing avatar, then clear the avatar_thumb field as well:

        if self.__orig_avatar and not self.avatar and self.avatar_thumb:
            self.avatar_thumb = None
            self.__orig_avatar = None
            self.save()

Otherwise, if the avatar is set and has changed, we open the new image (which has just been saved):

        elif self.avatar and self.avatar != self.__orig_avatar:
            img = Image.open(self.avatar.path)

Then we can see if the height or width exceeds the maximum we've set, and resize accordingly if necessary:

            if img.height > AVATAR_DIMENSION or img.width > AVATAR_DIMENSION:
                output_size = (AVATAR_DIMENSION, AVATAR_DIMENSION)
                img.thumbnail(output_size)

If the avatar isn't square, we want to crop it, but take the centre rather than close to an edge.

                # If not square, crop and take the centre!
                crop_box = ()
                if img._size[0] > img._size[1]:
                    inset = (img._size[0] - img._size[1]) // 2
                    crop_box = (inset, 0, inset + img._size[1], img._size[1])
                elif img._size[1] > img._size[0]:
                    inset = (img._size[1] - img._size[0]) // 2
                    crop_box = (0, inset, img._size[0], inset + img._size[0])

                if crop_box:
                    img = img.crop(box=crop_box)

If this last bit seems confusing, inset is the half the difference in size between height and width. So if an image was 200x100, we want to end up with a 100x100 image, but the crop_box we create would be (0, 50, 100, 150) to get the middle of the image, rather than one of the edges (crop_box is a tuple with the values (top left x, top left y, bottom left x, bottom left y) )

Save the avatar, and update teh value of __orig_avatar so that the save method does not recursively call this part of the update.

            img.save(self.avatar.path)
            self.__orig_avatar = self.avatar

Now we want to create the thumbnail:

            import os

            thumb = img.copy()
            thumb.thumbnail(THUMBNAIL_SIZE)

Work out exactly where it should be saved:

            thumb_url = "profiles/thumbs/"
            thumb_path = f"{os.path.dirname(self.avatar.path)}/thumbs/"
            thumb_file = os.path.basename(self.avatar.path)
            thumb_filepath = f"{thumb_path}{thumb_file}"

Create the path if it doesn't exist, and save the thumb file:

            if not os.path.exists(thumb_path):
                os.makedirs(thumb_path)
            thumb.save(thumb_filepath)

And, finally, assign the thumbnail to the avatar_thumb field and save the User instance again, now everything is set correctly (this is why it was important to update __orig_avatar, as otherwise this method would keep coming down here, creating more thumbs, and calling itself again - not great!

            self.avatar_thumb = f"{thumb_url}{thumb_file}"
            self.save()

Et voilĂ , c'est tout! Easier than you might think!

There are of course other ways to do this. A post_save signal might be an alternative. It should also be possible to do the file manipulation from the form save if you want.

Other things you could think about:

  • Removing old image files saved to disk - at the moment if you replace or remove an image, the old one will be left behind. This will of course take up disk space. Removal could be done as part of the save() method, or it could be a scheduled task that runs every so often and cleans up files that aren't attached to a record.
  • Changing the directory structure for profile images to include the user ID so the same image can be uploaded with the same name to multiple users (as it stands, pillow will handle this by appending extra characters if we try and do this).

Enjoying these posts? Subscribe for more