Adding an EXIF Viewer to the Image Viewer

The other day, we created a simple image viewer. Today we will create a secondary dialog to display the image’s EXIF data, if it has any. We will make it in such a way that this window will update as we change pictures using the cool pubsub module. We will use the version that is included in wxPython for this application, but feel free to use the stand-alone version as well.

Displaying EXIF: The Simple Way

First we’ll create a really simple version of the new frame that we’ll use to display the EXIF data for our images. It will only 11 pieces of data including the file name and the file size. Once we’ve taken a look at how that code works, we’ll add the functionality to display all the data returned from our EXIF parser. Let’s get started!

import os
import wx
from wx.lib.pubsub import Publisher

pil_flag = False
pyexif_flag = False

try:
    import exif
    pyexif_flag = True
except ImportError:
    try:
        from PIL import Image
        from PIL.ExifTags import TAGS
        pil_flag = True
    except ImportError:
        pass

#----------------------------------------------------------------------
def getExifData(photo):
    """
    Extracts the EXIF information from the provided photo
    """
    if pyexif_flag:
        exif_data = exif.parse(photo)
    elif pil_flag:
        exif_data  = {}
        i = Image.open(photo)
        info = i._getexif()
        for tag, value in info.items():
            decoded = TAGS.get(tag, tag)
            exif_data[decoded] = value
    else:
        raise Exception("PyExif and PIL not found!")
    return exif_data

#----------------------------------------------------------------------
def getPhotoSize(photo):
    """
    """
    photo_size = os.path.getsize(photo)
    photo_size = photo_size / 1024.0
    if photo_size > 1000:
        # photo is larger than 1 MB
        photo_size = photo_size / 1024.0
        size = "%0.2f MB" % photo_size
    else:
        size = "%d KB" % photo_size
    return size
    
########################################################################
class Photo:
    """"""

    #----------------------------------------------------------------------
    def __init__(self, photo):
        """Constructor"""
        self.exif_data = getExifData(photo)
        self.filename = os.path.basename(photo)
        self.filesize = getPhotoSize(photo)
    
    
########################################################################
class MainPanel(wx.Panel):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, parent, photo):
        """Constructor"""
        wx.Panel.__init__(self, parent)
        
        # dict of Exif keys and static text labels
        self.photo_data = {"ApertureValue":"Aperture", "DateTime":"Creation Date",
                           "ExifImageHeight":"Height", "ExifImageWidth":"Width",
                           "ExposureTime":"Exposure", "FNumber":"F-Stop",
                           "Flash":"Flash", "FocalLength":"Focal Length", 
                           "ISOSpeedRatings":"ISO", "Model":"Camera Model", 
                           "ShutterSpeedValue":"Shutter Speed"}
        
        # TODO: Display filesize too!
        self.exif_data = photo.exif_data
        self.filename = photo.filename
        self.filesize = photo.filesize
        Publisher().subscribe(self.updatePanel, ("update"))
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        self.layoutWidgets()
        self.SetSizer(self.mainSizer)
        
    #----------------------------------------------------------------------
    def layoutWidgets(self):
        """
        """
        ordered_widgets = ["Model", "ExifImageWidth", "ExifImageHeight",
                           "DateTime", "static_line",
                           "ApertureValue", "ExposureTime", "FNumber",
                           "Flash", "FocalLength", "ISOSpeedRatings",
                           "ShutterSpeedValue"
                           ]
        
        self.buildRow("Filename", self.filename, "Filename")
        self.buildRow("File Size", self.filesize, "FileSize")
        for key in ordered_widgets:
            if key not in self.exif_data and key != "static_line":
                continue
            if (key != "static_line"):
                self.buildRow(self.photo_data[key], self.exif_data[key], key)
            else:
                print "Adding staticLine"
                self.mainSizer.Add(wx.StaticLine(self), 0, wx.ALL|wx.EXPAND, 5)
        
    #----------------------------------------------------------------------
    def buildRow(self, label, value, txtName):
        """"""
        
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        lbl = wx.StaticText(self, label=label, size=(75, -1))
        txt = wx.TextCtrl(self, value=value, size=(150,-1),
                          style=wx.TE_READONLY, name=txtName)
        sizer.Add(lbl, 0, wx.ALL|wx.CENTER, 5)
        sizer.Add(txt, 0, wx.ALL, 5)
        self.mainSizer.Add(sizer)
        
    #----------------------------------------------------------------------
    def updatePanel(self, msg):
        """"""
        photo = msg.data
        self.exif_data = photo.exif_data
        
        children = self.GetChildren()
        for child in children:
            if isinstance(child, wx.TextCtrl):
                self.update(photo, child)
                    
    #----------------------------------------------------------------------
    def update(self, photo, txtWidget):
        """"""
        key = txtWidget.GetName()
        
        if key in self.exif_data:
            value = self.exif_data[key]
        else:
            value = "No Data"
            
        if key == "Filename":
            txtWidget.SetValue(photo.filename)
        elif key == "FileSize":
            txtWidget.SetValue(photo.filesize)
        else:
            txtWidget.SetValue(value)
    
########################################################################
class PhotoInfo(wx.Frame):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, photo_path):
        """Constructor"""
        wx.Frame.__init__(self, None, title="Image Information")
        photo = Photo(photo_path)
        panel = MainPanel(self, photo)
        
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(panel, 1, wx.EXPAND)
        self.SetSizer(sizer)
        sizer.Fit(self)
        
        Publisher().subscribe(self.updateDisplay, ("update display"))
        self.Show()
        
    #----------------------------------------------------------------------
    def updateDisplay(self, msg):
        """
        """
        photo = msg.data
        new_photo = Photo(photo)
        Publisher().sendMessage(("update"), new_photo)
        
#----------------------------------------------------------------------
if __name__ == "__main__":
    import wx.lib.inspection
    app = wx.PySimpleApp()
    photo = "path/to/test/photo.jpg"
    frame = PhotoInfo(photo)
    wx.lib.inspection.InspectionTool().Show()
    app.MainLoop()

That’s a lot of code to go over so we’ll just hit the pertinent points of interest. First off, we need to import some Python EXIF library. I prefer pyexif since it is so easy to get started, but if that fails, then I check for the Python Imaging Library (PIL) and import it instead. If you have some other library you like, feel free to modify the code as necessary. Once we’ve got the library we want loaded, we set a flag which we’ll use in our getExifData function. All this function does is parse the passed in photo to get its EXIF data (if it has any) and then returns that data as a dict. We also have a getPhotoSize function that we use to calculate the photo’s file size. Finally, the last piece before we get to our wxPython code is the Photo class. We use this class to make passing around the photo’s data a little bit easier. Each photo instance will have the following three attributes:

  • exif_data
  • filename
  • filesize

When I first started working on this code, I was trying to pass around a bunch of data about the files and it got pretty annoying and messy. This is much cleaner, although it would probably be even better if we put those functions inside the Photo class itself. I’ll leave that as an exercise for the reader though.

In our MainPanel class, we create a photo_data dictionary that holds EXIF data that is probably the most useful to the user. You can modify the dict as needed if you think there should be more there. Anyway, the keys of the dict correspond to some of the keys returned from our exif dump. The values will become labels. In the layoutWidgets method, we iterate over the dict’s keys and create “rows” of two widgets a piece: a label (wx.StaticText) and a text control (wx.TextCtrl). We use the key for the name parameter of the TextCtrls. This is important when updating the data when the user switches pictures.

The updatePanel and update methods are obviously used to update the panel’s widgets when the user changes photos in the main control. This works via a pubsub receiver that we created in the MainPanel’s __init__. In this case, we actually use a pubsub receiver in the PhotoInfo frame object to call the MainPanel’s receiver. This was mostly to make sure that we got a new photo instance created at the right point, but looking at the code again, I think we could cut out the second receiver in the frame and do everything in the methods in the panel. There’s another fun learning project for you, dear reader.

Displaying EXIF Data: Use a Notebook!

There is a lot of data that we are just ignoring if we stop at this point. However, there’s so much extra data that it wouldn’t fit very well in a dialog of this size. One simple solution would be to put the data on a scrollable panel, but we’re going to go with a Notebook widget instead since a lot of people don’t like scrolling. Of course, you are free to go on an adventure and use the ScrolledPanel widget by itself or in combination with the notebook idea. For now though, let’s take a gander at this complex beast:

import os
import wx
from wx.lib.pubsub import Publisher

pil_flag = False
pyexif_flag = False

try:
    import exif
    pyexif_flag = True
except ImportError:
    try:
        from PIL import Image
        from PIL.ExifTags import TAGS
        pil_flag = True
    except ImportError:
        pass

#----------------------------------------------------------------------
def getExifData(photo):
    """
    Extracts the EXIF information from the provided photo
    """
    if pyexif_flag:
        exif_data = exif.parse(photo)
    elif pil_flag:
        exif_data  = {}
        i = Image.open(photo)
        info = i._getexif()
        for tag, value in info.items():
            decoded = TAGS.get(tag, tag)
            exif_data[decoded] = value
    else:
        raise Exception("PyExif and PIL not found!")
    return exif_data

#----------------------------------------------------------------------
def getPhotoSize(photo):
    """
    Takes a photo path and returns the size of the photo
    """
    photo_size = os.path.getsize(photo)
    photo_size = photo_size / 1024.0
    if photo_size > 1000:
        # photo is larger than 1 MB
        photo_size = photo_size / 1024.0
        size = "%0.2f MB" % photo_size
    else:
        size = "%d KB" % photo_size
    return size
    
########################################################################
class Photo:
    """
    Class to hold information about the passed in photo
    """

    #----------------------------------------------------------------------
    def __init__(self, photo):
        """Constructor"""
        self.exif_data = getExifData(photo)
        self.filename = os.path.basename(photo)
        self.filesize = getPhotoSize(photo)
    
    
########################################################################
class NBPanel(wx.Panel):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, parent, photo, panelOne=False):
        """Constructor"""
        wx.Panel.__init__(self, parent)
        
        self.panelOne = panelOne
        # dict of Exif keys and static text labels
        self.photo_data = {"ApertureValue":"Aperture", "DateTime":"Creation Date",
                           "ExifImageHeight":"Height", "ExifImageWidth":"Width",
                           "ExposureTime":"Exposure", "FNumber":"F-Stop",
                           "Flash":"Flash", "FocalLength":"Focal Length", 
                           "ISOSpeedRatings":"ISO", "Model":"Camera Model", 
                           "ShutterSpeedValue":"Shutter Speed"}
       
        self.exif_data = photo.exif_data
        self.filename = photo.filename
        self.filesize = photo.filesize
        Publisher().subscribe(self.updatePanel, ("update"))
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        self.layoutWidgets()
        self.SetSizer(self.mainSizer)
        
    #----------------------------------------------------------------------
    def layoutWidgets(self):
        """
        Create and layout the various widgets on the panel
        """
        ordered_widgets = ["Model", "ExifImageWidth", "ExifImageHeight",
                           "DateTime", "static_line",
                           "ApertureValue", "ExposureTime", "FNumber",
                           "Flash", "FocalLength", "ISOSpeedRatings",
                           "ShutterSpeedValue"
                           ]
        if self.panelOne:
            self.buildRow("Filename", self.filename, "Filename")
            self.buildRow("File Size", self.filesize, "FileSize")
            for key in ordered_widgets:
                if key not in self.exif_data:
                    continue
                if key != "static_line":
                    self.buildRow(self.photo_data[key], self.exif_data[key], key)
        else:
            keys = self.exif_data.keys()
            keys.sort()
            print "keys for second panel:"
            print keys
            for key in keys:
                if key not in self.exif_data:
                    continue
                if key not in ordered_widgets and "Tag" not in key:
                    self.buildRow(key, self.exif_data[key], key)
        
    #----------------------------------------------------------------------
    def buildRow(self, label, value, txtName):
        """
        Build a two widget row and add it to the main sizer
        """
        
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        if self.panelOne:
            lbl = wx.StaticText(self, label=label, size=(75, -1))
        else: 
            lbl = wx.StaticText(self, label=label, size=(150, -1))
        txt = wx.TextCtrl(self, value=value, size=(150,-1),
                          style=wx.TE_READONLY, name=txtName)
        sizer.Add(lbl, 0, wx.ALL|wx.CENTER, 5)
        sizer.Add(txt, 0, wx.ALL, 5)
        self.mainSizer.Add(sizer)
        
    #----------------------------------------------------------------------
    def updatePanel(self, msg):
        """
        Iterate over the children widgets in the panel and update the 
        text control's values via the "update" method
        """
        photo = msg.data
        self.exif_data = photo.exif_data
        
        children = self.GetChildren()
        for child in children:
            if isinstance(child, wx.TextCtrl):
                self.update(photo, child)
                    
    #----------------------------------------------------------------------
    def update(self, photo, txtWidget):
        """
        Updates the text control's values
        """
        key = txtWidget.GetName()
        
        if key in self.exif_data:
            value = self.exif_data[key]
        else:
            value = "No Data"
            
        if self.panelOne:
            if key == "Filename":
                txtWidget.SetValue(photo.filename)
            elif key == "FileSize":
                txtWidget.SetValue(photo.filesize)
            else:
                txtWidget.SetValue(value)
        else:
            txtWidget.SetValue(value)
        
########################################################################
class InfoNotebook(wx.Notebook):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, parent, photo):
        """Constructor"""
        wx.Notebook.__init__(self, parent, style=wx.BK_BOTTOM)
        
        self.tabOne = NBPanel(self, photo, panelOne=True)
        self.tabTwo = NBPanel(self, photo)
        self.AddPage(self.tabOne, "Main Info")
        self.AddPage(self.tabTwo, "More Info")
        Publisher().subscribe(self.updateDisplay, ("update display"))
        
    #----------------------------------------------------------------------
    def updateDisplay(self, msg):
        """
        Catches the PubSub's "event", creates a new photo instance and
        passes that info to the panel so it can update
        """
        photo = msg.data
        new_photo = Photo(photo)
        Publisher().sendMessage(("update"), new_photo)
    
    
########################################################################
class PhotoInfo(wx.Frame):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, photo_path):
        """Constructor"""
        wx.Frame.__init__(self, None, title="Image Information")
        photo = Photo(photo_path)
        panel = wx.Panel(self)
        notebook = InfoNotebook(panel, photo)
        
        mainSizer = wx.BoxSizer(wx.VERTICAL)
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(notebook, 1, wx.EXPAND)
        panel.SetSizer(sizer)
        
        mainSizer.Add(panel)
        self.SetSizer(mainSizer)
        mainSizer.Fit(self)
        self.Show()
        
#----------------------------------------------------------------------
if __name__ == "__main__":
    app = wx.PySimpleApp()
    photo = r'path/to/photo.jpg'
    frame = PhotoInfo(photo)
    app.MainLoop()    

This code could do with a good refactoring, but it works pretty well. Let’s go over the differences between this version and the last. First off, we change the MainPanel to NBPanel because it will now be a Notebook panel / tab. We add an extra argument to the init as well to allow us to make the first panel different from the second (i.e. panelOne). Then in the layoutWidgets method, we use the panelOne flag to put the ordered_widgets on the first tab and the rest of the EXIF data on the second. We also changed the update method in much the same way. Finally, we added the InfoNotebook class to create the Notebook widget and we put an instance of it on our frame. If you’ve messed with EXIF data much, then you know there are a couple of fields that are rather long. We should put those in multi-line text controls. Here’s one way to do that by changing our buildRow method slightly:

def buildRow(self, label, value, txtName):
    """
    Build a two widget row and add it to the main sizer
    """
    
    sizer = wx.BoxSizer(wx.HORIZONTAL)
    if self.panelOne:
        lbl = wx.StaticText(self, label=label, size=(75, -1))
    else: 
        lbl = wx.StaticText(self, label=label, size=(150, -1))
    if len(value) < 40:
        txt = wx.TextCtrl(self, value=value, size=(150,-1),
                          style=wx.TE_READONLY, name=txtName)
    else:
        txt = wx.TextCtrl(self, value=value, size=(150,60),
                          style=wx.TE_READONLY|wx.TE_MULTILINE,
                          name=txtName)
    sizer.Add(lbl, 0, wx.ALL|wx.CENTER, 5)
    sizer.Add(txt, 0, wx.ALL, 5)
    self.mainSizer.Add(sizer)

In this snippet, we just added an IF statement to check if the length of the value was less than 40 characters. If so, we created a normal text control; otherwise we created a multiline text control. Now, wasn't that easy? Here's what the second tab looks like now:

Wrapping Up

The last part we need to look at is what to change in the main program. Basically, we just need to add an Information button, instantiate our EXIF Viewer and show it in the button's event handler. Something like this would work:

frame = photoInfo.PhotoInfo(self.currentPicturePath)
frame.Show()

To update the EXIF Viewer, we need to send a Pubsub message to it. We could send the message in the previous and next button events, but then we'd have to maintain the code in two places. Instead, we'll put the new code in the loadImage method and send the message like this:

Publisher().sendMessage("update display", self.currentPicturePath)

That's all there is to it. I've included the complete source code below in the Downloads section. Hopefully this has helped you see how easy it is to add a new feature to your GUI project as well as how to display EXIF data using wxPython.

Downloads