wxPython Image Viewer Shell Extension

Updated Reverend Jim 2 Tallied Votes 401 Views Share

Introduction

I recently bought a new laptop. Since I haven't used VB.Net in years (nothing but Python now) I did not install Visual Studio. That meant rewriting all my VB apps in Python/wxPython. One of my most often used apps was a shell extension I wrote to add a folder right-click option to launch a small image viewer. This short tutorial will explain how the pieces work. I will not get into how to write an app in wxPython. You will find other tutorials on that here on Daniweb.

I'll post the entire project as three files

  1. ZoomPic.pyw (main code)
  2. ZoomPic.reg (Windows registry mod - need to be edited)
  3. perCeivedType.py (Utility method for file screening)

I'll check this thread periodically for questions. I likely glossed over some things that may need further explanation. There's a reason I don't get paid to do this.

Drawing the display

The images are displayed in one of two modes, windowed, or full-screen. Normally, if you wanted to display an image you would use a wx.StaticBitmap control. The process would be

  • Read the data from the file into a wx.Image object
  • Scale the image to fit the size of the display control
  • Convert the image to a bitmap and copy to the wx.StaticBitmap

There are a few problems with this approach.

  • The image will be distorted unless the container has the same aspect ratio as the image
  • You will usually get an annoying flicker when changing files

We'll handle the first problem by writing a rescale method to ensure that the image will fit in the container with no distortion. We'll handle the second by using a method called double buffering to do the redrawing of the display.

Here is the rescale method.

cw,ch   the height and width of the container
iw,ih   the height and width of the image
aspect  the aspect ratio h/w of the original image
nw,nh   the new width and height adjusting for the container

After we calculate the new width and height based on the width of the container we then check to see if the new height is bigger than the container height. If so we recalculate the new width and height based on the container height.

Note that this only creates a scaled image. It does not display it.

def ScaleToFit(self, image):
    """Scale an image to fit parent control"""

    # Get image size, container size, and calculate aspect ratio
    cw, ch = self.Size
    iw, ih = image.GetSize()
    aspect = ih / iw

    # Determine new size with aspect and adjust if any cropping
    nw = cw
    nh = int(nw * aspect)

    if nh > ch:
        nh = ch
        nw = int(nh / aspect)

    # Return the newly scaled image
    return image.Scale(int(nw*self.zoom), int(nh*self.zoom))

Now comes the part I still find confusing - direct draw using double buffering. Normally you let wxPython handle refreshing the display but in our case we are going to force a redraw on several events. We'll get to those events in a minute. For now, let's see how to do a direct draw.

We are going to create something called a device context. Consider it like a printer driver, but for the screen. You can only create a device context within the event handler for an EVT_PAINT event which we will trigger manually by calling the Refresh() method. If it sounds confusing, it is. But it will make more sense in a minute. We are going to use double buffering.

I'm going to use an analogy to explain what double buffering is. I'm either going to insult your intelligence, or expose my ignorance (based on my limited understanding). You've all seen the flip-style animation where you draw a separate picture on the sheets of a book. When you flip the pages you see an animated scene. Imagine that every time you flipped to a new page you had to wait for the artist to draw the image. That would introduce an annoying delay. Now imagine that while you are looking at one image the artist is busy rendering the next (he's really fast) so that when you flip to the next page it is already rendered.

That is a simple-minded way of looking at double buffering. The new image is not displayed until it is ready. Here's how we do it. First we create the device context. Then we clear it and draw the bitmap into the device at the given starting x and y coordinates. Then, as a bonus, we add some text to the display.

dc = wx.AutoBufferedPaintDC(self)
dc.Clear()
dc.DrawBitmap(bitmap, sx, sy, True)
dc.SetTextForeground(wx.WHITE)
dc.SetTextBackground(wx.BLACK)
dc.DrawText(self.files[self.index], 5, 5)

Note that for this to work we must have set

self.SetBackgroundStyle(wx.BG_STYLE_CUSTOM)

in the MyFrame initialization. Why this is necessary was explained to me by "you just have to". I eventually found this in the wxPython official docs.

There are a lot of methods for the device context object for drawing all sorts of shapes, icons, etc. For the first iteration of our drawing method we are going to assume an image has already been loaded from the file and that we are not concerned about zoom/pan. Here is the first iteration of the on_paint method

def on_paint(self, evt):
    """Draw the current image"""

    # Scale the image to fit and convert to bitmap           
    image  = self.ScaleToFit(self.image)
    bitmap = image.ConvertToBitmap()

    # Determine upper left corner to centre image on screen
    iw, ih = bitmap.GetSize()
    sw, sh = self.Size
    sx, sy = int((sw - iw) / 2), int((sh - ih) / 2)

    # Draw the image
    dc = wx.AutoBufferedPaintDC(self)
    dc.Clear()
    dc.DrawBitmap(bitmap, sx, sy, True)
    dc.SetTextForeground(wx.WHITE)
    dc.SetTextBackground(wx.BLACK)
    dc.DrawText(self.files[self.index], 5, 5)
    evt.Skip()

Now we want to add a few controls to the interface. I like to use hotkeys. For one, navigating with things like arrow keys is easier (for me) than constantly moving the mouse and clicking, plus, putting buttons or menus on the display takes away from the picture. The hotkeys I've defined are

arrow left/up      prev image
arrow right/down   next image
f                  toggle full-screen/windowed (later)
esc                quit  

Mouse controls are

wheel              prev/next image
left-click         zoom
left-click/drag    zoom/pan

Hotkeys (or accelerators) can be linked to menu items and buttons. Because we want the buttons to be invisible we will make them zero-sized. Then we bind them to our custom event handlers. Each event handler ends with a call to Skip() to allow wxPython to do whatever default actions it does. Our button setup looks like

# Display previous picture
self.btnPrev = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnPrev.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_prev, self.btnPrev)

# Display next picture
self.btnNext = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnNext.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_next, self.btnNext)

# Toggle fullscreen
self.btnFull = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnFull.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_full, self.btnFull)

# Exit app
self.btnExit = wx.Button(self, wx.ID_ANY, size=(0,0))
self.btnExit.Visible = False
self.Bind(wx.EVT_BUTTON, self.evt_exit, self.btnExit)

Linking the controls to hotkeys is done by

#Define hotkeys
hotkeys = [wx.AcceleratorEntry() for i in range(6)]
hotkeys[0].Set(wx.ACCEL_NORMAL, wx.WXK_DOWN, self.btnNext.Id)
hotkeys[1].Set(wx.ACCEL_NORMAL, wx.WXK_UP, self.btnPrev.Id)
hotkeys[2].Set(wx.ACCEL_NORMAL, wx.WXK_RIGHT, self.btnNext.Id)
hotkeys[3].Set(wx.ACCEL_NORMAL, wx.WXK_LEFT, self.btnPrev.Id)
hotkeys[4].Set(wx.ACCEL_NORMAL, wx.WXK_ESCAPE, self.btnExit.Id)
hotkeys[5].Set(wx.ACCEL_NORMAL, ord('f'), self.btnFull.Id)
accel = wx.AcceleratorTable(hotkeys)
self.SetAcceleratorTable(accel)

And the mouse setup looks like

# Connect Mouse events to event handlers
self.Bind(wx.EVT_LEFT_DOWN, self.on_left_down)
self.Bind(wx.EVT_LEFT_UP, self.on_left_up)
self.Bind(wx.EVT_MOTION, self.on_motion)
self.Bind(wx.EVT_MOUSEWHEEL, self.on_scroll)

The EVT_MOTION event handler will be used when zooming/panning.

Finally, we bind our handlers for painting and resizing

# Connect events to event handlers
self.Bind(wx.EVT_SIZE, self.on_size)
self.Bind(wx.EVT_PAINT, self.on_paint)

How it works

When the application starts, it scans the current working directory for image files. As a simple test, we can look for all files with an extension matching a list of known image files. That's what I use in this example in the isImage method. I'll include here a more general version which checks the Windows Registry to see what files Windows recognizes as images.

The file names are saved in a list and the first file is read into self.image. Since this is done before we bind the event handlers, when on_paint is called the first time the Window is displayed it will already have access to the first image. After that, any time we want to display a new image, all we have to do is read it into self.image then call Refresh to trigger on_paint.

By maintaining a flag (self.left_down) that keeps track of when the left mouse button is pressed, we can determine whether to display the image full size (relative to the window) or zoomed in.

One thing in particular I had trouble with was switching between windowed and full-screen. If I started the application in windowed mode I was able to toggle between modes with no problem. However, If I started in full-screen, toggling to windowed mode caused the app to hang. User Zig_Zag at the wxPython forum showed me how to fix it, but I still don't see why the fix works. Originally I had

self.ShowFullScreen(True)

in the initialization of MyFrame. When moved to OnInit for MyApp as

self.frame.ShowFullScreen(True)

the problem went away.

Adding as a shell extension

You can run ZoomPic from the command line by giving it a folder name as a parameter. More useful is having it available in the context menu for a folder. On my computer, the registry location for a folder context menu is under HKEY_CLASSES_ROOT at

Directory
    Shell
        ZoomPic
            command
                (Default) REG_SZ "C:\Users\rjdeg\AppData\Local\Programs\Python\Python310\pythonw.exe" "D:\apps\ZoomPic\ZoomPic.pyw" "%1"

There are likely other entries under Directory at the same level as Shell so we just add ZoomPic as a new one. This will not cause anything else to stop working. Note that the value of the REG_SZ value will change from system to system. You'll have to put in the full path for your pythonw.exe and zoompic.pyw. To make it easier, just take the attached file, ZoomPic.reg, modify it for your system, then run it (or double click on it) to add it to your registry.

More general file types

"""                                                                                    Name:                                                                                                                                                             
Name:

    perceivedType.py                                                            

Description:                                                                    

    This is a set of methods that use the Windows registry to return a string   
    describing how Windows interprets the given file. The current methods will  
    return a string description as provided by Windows, or "unknown" if Windows 
    does not have an associated file type. The auxiliary functions return True  
    or False for tests for specific file types.                                 

 Auxiliary Functions:                                                           

      isVideo(file) - returns True if PerceivedType = "video"               
      isAudio(file) - returns True if PerceivedType = "audio"               
      isImage(file) - returns True if PerceivedType = "image"               
      isText (file) - returns True if PerceivedType = "text"                

Parameters:                                                                     

    file:str    a file name                                                     
    degug:bool  print debug info if True (default=False)                        

Audit:                                                                          

    2021-07-17  rj  original code                                               

"""

import os
import winreg


def perceivedType(file: str, debug: bool = False) -> str:
    """Returns the windows registry perceived type string for the given file"""

    if debug:
        print(f'\nchecking {file=}')

    try:
        key = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, os.path.splitext(file)[-1])
        inf = winreg.QueryInfoKey(key)

        for i in range(0, inf[1]):
            res = winreg.EnumValue(key, i)
            if debug:
                print(f'    {res=}')
            if res[0] == 'PerceivedType':
                return res[1].lower()
    except:
        pass

    return "unknown"

def isVideo(file: str) -> str: return perceivedType(file) == 'video'
def isAudio(file: str) -> str: return perceivedType(file) == 'audio'
def isImage(file: str) -> str: return perceivedType(file) == 'image'
def isText(file: str) -> str: return perceivedType(file) == 'text'


if __name__ == '__main__':
    for file in ('file.avi', 'file.mov', 'file.txt', 'file.jpg', 'file.mp3', 'file.pdf', 'file.xyz'):
        print('Perceived type of "%s" is %s' % (file, perceivedType(file, debug=True)))

Recommended Reading

Two excellent books on developing applications in wxPython are

wxPython 2.8 Application Development Cookbook by Cody Precord.

Creating GUI Applications With wxPython by Michael Driscoll

The second book is more recent and uses wxPython features that may not have been available in earlier books.

Another excellent resource is wxPython Forum. The experts there were very helpful when I got stuck.

Reverend Jim 4,780 Hi, I'm Jim, one of DaniWeb's moderators. Moderator Featured Poster

Uploaded new code & registry hack.

New code is a little cleaner and adds a 3x zoom if SHIFT-LEFT-CLICK. New registry patch adds ZoomPic to Drive letters as well as folders.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.