Creating a Simple Photo Viewer with wxPython

The other day, I was chatting with some wxPython newbies on the wxPython IRC channel and one of them wanted to know how to display images in wx. There are lots of different ways to do this, but I had a pre-made solution that I'd cobbled together a couple of years ago for work. Since this is a fairly popular topic, I thought it would be wise to let you, dear reader, in on the secret.

Image Viewer Take One

One of the simplest ways to display an image in wxPython is to use the wx.StaticBitmap to do your dirty work. In this example, we want a placeholder for the image, so we'll use wx.EmptyImage for that. Finally, we need a way to scale the image down if it's too large for either our resolution or our application. For that, we'll use a tip I received from the illustrious Andrea Gavana (creator of the AGW library). That's enough for the introductions, let's look at the code:

import os
import wx

class PhotoCtrl(wx.App):
    def __init__(self, redirect=False, filename=None):
        wx.App.__init__(self, redirect, filename)
        self.frame = wx.Frame(None, title='Photo Control')
                          
        self.panel = wx.Panel(self.frame)

        self.PhotoMaxSize = 240
        
        self.createWidgets()
        self.frame.Show()
        
    def createWidgets(self):
        instructions = 'Browse for an image'
        img = wx.EmptyImage(240,240)
        self.imageCtrl = wx.StaticBitmap(self.panel, wx.ID_ANY, 
                                         wx.BitmapFromImage(img))
        
        instructLbl = wx.StaticText(self.panel, label=instructions)
        self.photoTxt = wx.TextCtrl(self.panel, size=(200,-1))
        browseBtn = wx.Button(self.panel, label='Browse')
        browseBtn.Bind(wx.EVT_BUTTON, self.onBrowse)
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer = wx.BoxSizer(wx.HORIZONTAL)
        
        self.mainSizer.Add(wx.StaticLine(self.panel, wx.ID_ANY),
                           0, wx.ALL|wx.EXPAND, 5)
        self.mainSizer.Add(instructLbl, 0, wx.ALL, 5)
        self.mainSizer.Add(self.imageCtrl, 0, wx.ALL, 5)
        self.sizer.Add(self.photoTxt, 0, wx.ALL, 5)
        self.sizer.Add(browseBtn, 0, wx.ALL, 5)        
        self.mainSizer.Add(self.sizer, 0, wx.ALL, 5)
        
        self.panel.SetSizer(self.mainSizer)
        self.mainSizer.Fit(self.frame)

        self.panel.Layout()
        
    def onBrowse(self, event):
        """ 
        Browse for file
        """
        wildcard = "JPEG files (*.jpg)|*.jpg"
        dialog = wx.FileDialog(None, "Choose a file",
                               wildcard=wildcard,
                               style=wx.OPEN)
        if dialog.ShowModal() == wx.ID_OK:
            self.photoTxt.SetValue(dialog.GetPath())
        dialog.Destroy() 
        self.onView()

    def onView(self):
        filepath = self.photoTxt.GetValue()
        img = wx.Image(filepath, wx.BITMAP_TYPE_ANY)
        # scale the image, preserving the aspect ratio
        W = img.GetWidth()
        H = img.GetHeight()
        if W > H:
            NewW = self.PhotoMaxSize
            NewH = self.PhotoMaxSize * H / W
        else:
            NewH = self.PhotoMaxSize
            NewW = self.PhotoMaxSize * W / H
        img = img.Scale(NewW,NewH)

        self.imageCtrl.SetBitmap(wx.BitmapFromImage(img))
        self.panel.Refresh()
        
if __name__ == '__main__':
    app = PhotoCtrl()
    app.MainLoop()

You may be thinking that this example is kind of complex. Well, it's actually not that bad since the code is only 76 lines long! Let's go over the various methods to see what's happening.

def __init__(self, redirect=False, filename=None):
        wx.App.__init__(self, redirect, filename)
        self.frame = wx.Frame(None, title='Photo Control')
                          
        self.panel = wx.Panel(self.frame)

        self.PhotoMaxSize = 500
        
        self.createWidgets()
        self.frame.Show()

The __init__ initializes out wx.App object, instantiates a frame and adds a panel as the frame's only child. We set the photo's max size to 500 pixels so that it's easy to see, but won't be too big for our monitor. Then we call our createWidgets method and finally display the frame by calling Show(). Since we already went over the EmptyImage widget earlier, I think it's safe to skip the createWidgets method and move on with one caveat. Notice the last three lines from createWidgets:

self.panel.SetSizer(self.mainSizer)
self.mainSizer.Fit(self.frame)
self.panel.Layout()

This will set the panel's sizer and then fit the frame to the widgets contained in the sizer. This will keep out application looking tidy as we won't have a bunch of extra pixels worth of panel sticking out in odd places. Try it without these lines and see what happens if you can't picture it!

Our onBrowse method is next:

def onBrowse(self, event):
    """ 
    Browse for file
    """
    wildcard = "JPEG files (*.jpg)|*.jpg"
    dialog = wx.FileDialog(None, "Choose a file",
                           wildcard=wildcard,
                           style=wx.OPEN)
    if dialog.ShowModal() == wx.ID_OK:
        self.photoTxt.SetValue(dialog.GetPath())
    dialog.Destroy() 
    self.onView()

First off, we create a wildcard that specifies only JPEG files and then we pass it to our wx.FileDialog constructor. This will limit the dialog in such a way that it only shows JPEG files. If the user presses the OK (or Open) button, then we set the photoTxt control's value to the path of the file chosen. We should probably add some error checking to make sure the file is a valid JPEG file, but we'll leave that as an exercise for the reader. After the value is set, the dialog is destroyed and onView is called, which will show the picture. Let's see how that works:

def onView(self):
    filepath = self.photoTxt.GetValue()
    img = wx.Image(filepath, wx.BITMAP_TYPE_ANY)
    # scale the image, preserving the aspect ratio
    W = img.GetWidth()
    H = img.GetHeight()
    if W > H:
        NewW = self.PhotoMaxSize
        NewH = self.PhotoMaxSize * H / W
    else:
        NewH = self.PhotoMaxSize
        NewW = self.PhotoMaxSize * W / H
    img = img.Scale(NewW,NewH)

    self.imageCtrl.SetBitmap(wx.BitmapFromImage(img))
    self.panel.Refresh()
    self.mainSizer.Fit(self.frame)

First off, we grab the image's path from the photo text control and create an instance of wx.Image using that information. Next, we grab the file's width and height and use a simple calculation to scale the picture down to the size we specified at the beginning. Then we call our StaticBitmap's SetBitmap() method to show the image. Finally, we refresh the panel so that everything repaints and we call our sizer's Fit method so that the frame resizes to fit the new photo in an appropriate fashion. Now we have a fully functional image viewer!

Making a Better Photo Viewer

If you're one of my astute readers, you'll notice that every time you want to view a new picture in your picture folder, you'll need to browse for it. That's not a good user experience, is it? Let's try to expand our control so we can do the following:

  • Add a Previous and Next button to go backwards and forwards over the pictures
  • Create a button that can "play" the photos (i.e. change photos every so often)
  • Make the Previous and Next buttons "smart" enough to start over when they reach the end of the list of files

For our first trick, we'll put the Frame and Panel into separate classes and just use PySimpleApp instead of App. This makes our code a little more orderly, although it also has some drawbacks (such as communicating between the frame and panel). The panel will hold most of our widgets, so let's look at the frame class first since it's much simpler:

import glob
import wx

from wx.lib.pubsub import Publisher

########################################################################
class ViewerFrame(wx.Frame):
    """"""

    #----------------------------------------------------------------------
    def __init__(self):
        """Constructor"""
        wx.Frame.__init__(self, None, title="Image Viewer")
        panel = ViewerPanel(self)
        self.folderPath = ""
        Publisher().subscribe(self.resizeFrame, ("resize"))
        
        self.initToolbar()
        self.sizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer.Add(panel, 1, wx.EXPAND)
        self.SetSizer(self.sizer)
        
        self.Show()
        self.sizer.Fit(self)
        self.Center()
                
    #----------------------------------------------------------------------
    def initToolbar(self):
        """
        Initialize the toolbar
        """
        self.toolbar = self.CreateToolBar()
        self.toolbar.SetToolBitmapSize((16,16))
        
        open_ico = wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_TOOLBAR, (16,16))
        openTool = self.toolbar.AddSimpleTool(wx.ID_ANY, open_ico, "Open", "Open an Image Directory")
        self.Bind(wx.EVT_MENU, self.onOpenDirectory, openTool)
        
        self.toolbar.Realize()
        
    #----------------------------------------------------------------------
    def onOpenDirectory(self, event):
        """
        Opens a DirDialog to allow the user to open a folder with pictures
        """
        dlg = wx.DirDialog(self, "Choose a directory",
                           style=wx.DD_DEFAULT_STYLE)
        
        if dlg.ShowModal() == wx.ID_OK:
            self.folderPath = dlg.GetPath()
            print self.folderPath
            picPaths = glob.glob(self.folderPath + "\\*.jpg")
            print picPaths
        Publisher().sendMessage("update images", picPaths)
        
    #----------------------------------------------------------------------
    def resizeFrame(self, msg):
        """"""
        self.sizer.Fit(self)

If you've read much wxPython code, then the above should be pretty familiar to you. We construct the frame as usual, then we create an instance of our ViewerPanel and pass it self so that the panel will have a reference to the frame. The next important bit is the creation of our Pubsub listener singleton. This will call the frame's resizeFrame method, which is only called when we display a new picture. The next important piece is the call to initToolbar, which creates a toolbar on our frame. The other method of the frame is onOpenDirectory, which is very similar to the browse function from our first application. In this case, we want to select an entire folder and only pull the paths of the JPEG files from it. Thus, we use Python's glob file to do that. Once that's done, it sends a pubsub message to the panel along with the list of picture paths.

Now we can take a look at the most important piece of code: the ViewerPanel.

import wx

from wx.lib.pubsub import Publisher

########################################################################
class ViewerPanel(wx.Panel):
    """"""

    #----------------------------------------------------------------------
    def __init__(self, parent):
        """Constructor"""
        wx.Panel.__init__(self, parent)
        
        width, height = wx.DisplaySize()
        self.picPaths = []
        self.currentPicture = 0
        self.totalPictures = 0
        self.photoMaxSize = height - 200
        Publisher().subscribe(self.updateImages, ("update images"))

        self.slideTimer = wx.Timer(None)
        self.slideTimer.Bind(wx.EVT_TIMER, self.update)
        
        self.layout()
        
    #----------------------------------------------------------------------
    def layout(self):
        """
        Layout the widgets on the panel
        """
        
        self.mainSizer = wx.BoxSizer(wx.VERTICAL)
        btnSizer = wx.BoxSizer(wx.HORIZONTAL)
        
        img = wx.EmptyImage(self.photoMaxSize,self.photoMaxSize)
        self.imageCtrl = wx.StaticBitmap(self, wx.ID_ANY, 
                                         wx.BitmapFromImage(img))
        self.mainSizer.Add(self.imageCtrl, 0, wx.ALL|wx.CENTER, 5)
        self.imageLabel = wx.StaticText(self, label="")
        self.mainSizer.Add(self.imageLabel, 0, wx.ALL|wx.CENTER, 5)
        
        btnData = [("Previous", btnSizer, self.onPrevious),
                   ("Slide Show", btnSizer, self.onSlideShow),
                   ("Next", btnSizer, self.onNext)]
        for data in btnData:
            label, sizer, handler = data
            self.btnBuilder(label, sizer, handler)
            
        self.mainSizer.Add(btnSizer, 0, wx.CENTER)
        self.SetSizer(self.mainSizer)
            
    #----------------------------------------------------------------------
    def btnBuilder(self, label, sizer, handler):
        """
        Builds a button, binds it to an event handler and adds it to a sizer
        """
        btn = wx.Button(self, label=label)
        btn.Bind(wx.EVT_BUTTON, handler)
        sizer.Add(btn, 0, wx.ALL|wx.CENTER, 5)
        
    #----------------------------------------------------------------------
    def loadImage(self, image):
        """"""
        image_name = os.path.basename(image)
        img = wx.Image(image, wx.BITMAP_TYPE_ANY)
        # scale the image, preserving the aspect ratio
        W = img.GetWidth()
        H = img.GetHeight()
        if W > H:
            NewW = self.photoMaxSize
            NewH = self.photoMaxSize * H / W
        else:
            NewH = self.photoMaxSize
            NewW = self.photoMaxSize * W / H
        img = img.Scale(NewW,NewH)

        self.imageCtrl.SetBitmap(wx.BitmapFromImage(img))
        self.imageLabel.SetLabel(image_name)
        self.Refresh()
        Publisher().sendMessage("resize", "")
        
    #----------------------------------------------------------------------
    def nextPicture(self):
        """
        Loads the next picture in the directory
        """
        if self.currentPicture == self.totalPictures-1:
            self.currentPicture = 0
        else:
            self.currentPicture += 1
        self.loadImage(self.picPaths[self.currentPicture])
        
    #----------------------------------------------------------------------
    def previousPicture(self):
        """
        Displays the previous picture in the directory
        """
        if self.currentPicture == 0:
            self.currentPicture = self.totalPictures - 1
        else:
            self.currentPicture -= 1
        self.loadImage(self.picPaths[self.currentPicture])
        
    #----------------------------------------------------------------------
    def update(self, event):
        """
        Called when the slideTimer's timer event fires. Loads the next
        picture from the folder by calling th nextPicture method
        """
        self.nextPicture()
        
    #----------------------------------------------------------------------
    def updateImages(self, msg):
        """
        Updates the picPaths list to contain the current folder's images
        """
        self.picPaths = msg.data
        self.totalPictures = len(self.picPaths)
        self.loadImage(self.picPaths[0])
        
    #----------------------------------------------------------------------
    def onNext(self, event):
        """
        Calls the nextPicture method
        """
        self.nextPicture()
    
    #----------------------------------------------------------------------
    def onPrevious(self, event):
        """
        Calls the previousPicture method
        """
        self.previousPicture()
    
    #----------------------------------------------------------------------
    def onSlideShow(self, event):
        """
        Starts and stops the slideshow
        """
        btn = event.GetEventObject()
        label = btn.GetLabel()
        if label == "Slide Show":
            self.slideTimer.Start(3000)
            btn.SetLabel("Stop")
        else:
            self.slideTimer.Stop()
            btn.SetLabel("Slide Show")        

Instead of going over every method in this class, we'll just take a tour of the highlights. Don't worry! The methods that we skip are super easy to grasp and if you just don't get it, feel free to comment on this article or ask the question on the wxPython mailing list. Our first order of business is to look at the init. For the most part, this is a pretty normal initialization, but we also have a couple of goofy lines:

width, height = wx.DisplaySize()
self.photoMaxSize = height - 200

What's that all about? Well, the idea here is to get the user's monitor resolution and then size the application to be just the right height. We want it to fit above the taskbar and below the top of the screen. That is all we're doing here. Note that this was tested on Windows 7, so you may need to adjust accordingly for your OS of choice. The loadImage is almost exactly the same as the one we saw in our first example. The only difference is that we use pubsub to tell the frame to resize. Technically, you could also do something like: self.frame.sizer.Fit(self.frame). This is known as bad code. Use the pubsub method instead.

The nextPicture and previousPicture methods are quite similar and should probably be combined, but for now we will leave them as is. In both of these methods, we use currentPicture property and add or subtract as needed to go to the next or previous picture. We also check to see if we've hit the upper or lower bound (i.e. zero or totalPictures) and reset currentPicture appropriately. This allows us to cycle through all the photos forever. Now that we've covered the cycling, we need to figure out how to do a slide show. It's actually pretty easy.

First, we create a wx.Timer object and bind it to the update method. When the "Slide Show" button is pressed, the timer is started or stopped. If it is started, then the timer will fire every 3 seconds (1000 = 1 second) and that will call the update method, which calls the nextPicture method. As you can see, Python makes everything very simple to do. The rest of the methods are simple utility methods that are called by one of the others.

Wrapping Up

I hope you found this interesting. I will be working on expanding this example into something even cooler and I'll be sure to share whatever I come up with here. Just the other day, someone on the wxPython IRC channel thought it would be cool if there was a series of articles on simple working wxPython applications. Consider this to be the first (or second if you count wxPyMail). Hopefully I can come up with some other interesting ones.

Note: This code was tested on Windows 7 Home (32-bit), wxPython 2.8.10.1, Python 2.6

Downloads

Copyright © 2024 Mouse Vs Python | Powered by Pythonlibrary