Removing Exif data from images in Django
If you have a website that allows users to upload images it might be a good idea to strip some or all of the Exif data embedded in the image files. The data could, for example, include the latitude and longitude where the user was when they took that selfie they’re using for their avatar. I wondered how to do this on a Django website and this is what I came up with.
A Django model could have an ImageField
, and an image file can be uploaded to this when creating or editing the object. I wanted to automatically strip out any location information from the uploaded file’s Exif data. Ideally, I didn’t want to affect the content of the image itself – re-saving it as a new JPEG image would add another round of compression – but solely edit the Exif data in the file.
Leaving Django aside for the moment, I found several ways to remove Exif data from images using Python:
Use PIL or Pillow to open the image and save a copy; the copy won’t contain any Exif data. This seems a popular choice on Stack Overflow (such as this answer), but it results in a re-saved image – more lossy compression.
Use the exif python module as described in this blog post. If I’ve understood the module’s documentation correctly this also requires a new, lossy, image to be saved.
Pipe out to the well-regarded exiftool command-line application, as described in this blog post, to modify the file. Obviously, this requires exiftool to be available on your system.
Use python3-exiv2, a python binding to exiv2, which is a C++ library. It looks like it’s possible to write modified Exif tags back to the original file.
Use the Piexif module, which allows for writing modified Exif tags back to the original file and has no dependencies.
I ruled out (1) and (2) because I didn’t want to re-save the file, and (3) and (4) because I didn’t want to rely on command-line tools or other libraries.
This left Piexif. On GitHub it hasn’t had any updates for a couple of years and has a bunch of outstanding pull requests and forks containing bug fixes. This doesn’t seem encouraging but it’s been OK for my purposes so far. As of September 2022, it has now been adopted by someone else and is alive and well on GitHub.
One note: In many cases we wouldn’t use the original uploaded file on our website. Often we generate smaller versions for display and these probably don’t contain any of the original’s Exif data. So, if we never display the original image on our site, why do we need to bother editing its Exif data? Because if we’re keeping the original file around on a publicly-readable server someone could still work out how to view it and so read its data.
§ Using Piexif
Outside of Django, the way we’d remove the GPS info from a file’s Exif data using Piexif is like this, after installing it (pip install piexif
):
import piexif
exif_dict = piexif.load("foo.jpg")
if "GPS" in exif_dict and len(exif_dict["GPS"]) > 0:
exif_dict["GPS"] = {}
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, "foo.jpg")
What we’re doing here:
- Use Piexif to get all of the file’s Exif data as a dict.
- If the data has a
"GPS"
key that has something in it, we change it to an empty dict. - Use Piexif to turn the dict into bytes.
- Use Piexif to replace the file’s Exif data with our modified version.
In case you’re wondering, the full list of possible keys in exif_dict
is: "0th"
, "Exif"
, "GPS"
, "Interop"
, "1st"
, and "thumbnail"
.
If you preferred you could remove all of the Exif data, not just the "GPS"
element. You could set the entire exif_dict = {}
, or else set each of these keys’ values – "0th"
, "Exif"
, "GPS"
, "Interop"
, "1st"
– to an empty dict. The "thumbnail"
key’s value should be either a JPEG as bytes, or None
.
§ Using Piexif with a Django model
Now we’ve seen how to remove GPS Exif data from a file using vanilla Python we’re going to use this to edit the Exif data of a file that’s uploaded to an ImageField
on a Django model.
Let’s say we have this simple Django model:
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=255)
cover = models.ImageField(upload_to="books/covers/")
With the Piexif module installed we can modify our Django model to update any Exif data in the Book.cover
property’s image file when the object is saved:
from django.db import models
import piexif
class Book(models.Model):
title = models.CharField(max_length=255)
cover = models.ImageField(upload_to="books/covers/")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Store this so we can tell if it changes in save():
self.__original_cover_name = self.cover.name
def save(self, *args, **kwargs):
# At this point the image file has already been saved to disk.
# Call the parent's save() method first:
super().save(*args, **kwargs):
if self.cover and self.__original_cover_name != self.cover.name:
# The cover is new or changed, so edit the Exif data.
self.sanitize_cover_exif_data()
# Re-set this in case the image has now changed:
self.__original_cover_name = self.cover.name
def sanitize_cover_exif_data(self):
"If the cover image has any GPS info in its Exif data, remove it."
if self.cover:
# Get Exif data from the file as a dict:
exif_dict = piexif.load(self.cover.path)
if "GPS" in exif_dict and len(exif_dict["GPS"]) > 0:
# Clear existing GPS data and put it all back to bytes:
exif_dict["GPS"] = {}
exif_bytes = piexif.dump(exif_dict)
# Replace the file's existing Exif data with our modified version:
piexif.insert(exif_bytes, self.cover.path)
Hopefully the comments explain what’s going on. Here’s an overview:
- We store the original
name
ofBook.cover
so that insave()
we can tell if it’s changed. - If it has, call
sanitize_cover_exif_data()
which loads in the Exif data and, if there’s any GPS info, removes it and replaces the file’s Exif data with our modified version. - Back in the
save()
method we store thename
of the image file again, in case it has changed since the object was initialised.
However, this method of editing a file’s Exif data will only work if your project’s Media files (those uploaded to the site by admins or users, like this image) are stored on the local filesystem. For example, this is usually the case when using Django’s development server (./manage.py runserver
).
If your Media files are stored elsewhere – such as on Amazon S3, and accessed by using django-storages – the above method won’t work. Why? In the example above we supply two of the Piexif methods with self.cover.path
which it uses to open or save the image files. This only works with files on the local filesystem. When the file’s stored on a server elsewhere this generates errors such as “This backend doesn’t support absolute paths.”
So, for that situation we must do things differently…
§ Using Piexif with a Django model with a custom storage backend
For this we need to import several more things and modify the sanitize_cover_exif_data()
method:
import io
import os
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models
import piexif
class Book(models.Model):
# ... Everything else is the same as before.
def sanitize_cover_exif_data(self):
"If the cover image has any GPS info in its Exif data, remove it."
if self.cover:
# Get file's data as bytes, in a way that works with S3 etc and local files:
file = default_storage.open(self.cover.name, mode="rb")
file_data = file.read()
file.close()
# Get Exif data from the file's bytes as a dict:
exif_dict = piexif.load(file_data)
if "GPS" in exif_dict and len(exif_dict["GPS"]) > 0:
# Clear existing GPS data and put it all back to bytes:
exif_dict["GPS"] = {}
exif_bytes = piexif.dump(exif_dict)
# Create a temporary file with existing data...
temp_file = io.BytesIO(file_data)
# ...and replace its Exif data with our updated data:
piexif.insert(exif_bytes, file_data, temp_file)
# Remove existing image from disk (or else, when we save the new one,
# we'll end up with both files, the new one with a different name):
filename = os.path.basename(self.cover.name)
self.cover.delete(save=False)
# Finally, save the temporary file as the new cover, with same name:
self.cover.save(
filename, ContentFile(temp_file.getvalue()), save=False
)
Here’s an overview of the new process:
- As before, we store the path (
name
) of the image file so that, when saving the object we can tell if it’s changed. - Also as before, when saving, if the file has changed, and there is an image, we call the
sanitize_cover_exif_data()
method. - In this method we now get all of the file’s data as bytes using
default_storage.open()
to open the file, which should work no matter where it’s stored. - We use Piexif to get the file’s Exif data as a dict.
- If the data has a
"GPS"
key that has something in it, we change it to an empty dict. - We use Piexif to turn that Exif dict back into bytes.
- Using
io.BytesIO()
we create a new temporary file in memory using the original file’s data. - We then use Piexif to insert the Exif bytes into this temporary file, replacing what was there.
- If we saved that file data to the original filename Django would change the new file’s name so as not to conflict with the original one on disk. e.g. if the original was
foo.jpg
Django might save the new one asfoo_p3yrmLZ.jpg
. We’d still have the original, containing its original Exif data. We don’t want that! So we store the original file’s name and delete the file. - Then we can make a new file using
ContentFile()
and our temporary file’s data, and then save that to the object’scover
property using the original filename. This saves the temporary file to disk in the correct location. - Back in the
save()
method we store thename
of the image file again, in case it has changed since the object was initialised.
This takes advantage of the fact that Piexif’s methods can be passed either a file’s path (which we could use in the previous example) or a file’s data (which we use in this example).
It took a lot of trial and error to work all these steps out and there may well be a better way, but it seems to work in my limited usage so far.
As mentioned earlier, you could remove all of the file’s Exif data, not just the GPS data, using a similar method.
§ Conclusion
This is more complicated than I originally expected, once we take into account storing files on other servers, but it seems to do the trick.
I’m using this in django-spectator, currently in the ThumbnailModelMixin
in core/models.py
. If you plan to use it, I have some tests written for the Publication
model, which uses the mixin, in tests/reading/test_models.py
: test_exif_data_removed_from_added_thumbnail()
and test_exif_data_removed_from_updated_thumbnail()
. My tests are often a bit clunky but I think these do the job.
If you spot any mistakes, or have any suggestions for improvement at all, please do let me know by email.