Resizable Image Control using PyGTK and Cairo

By jackvalmadre

I’ve been doing a little bit of GUI work in GTK. Something I really missed was a dynamically resizable image control – the gtk.Image object just seems to assume the same dimensions as the picture it’s displaying.

I found a good tutorial about rendering a custom widget using Cairo, with the widget itself inheriting from gtk.DrawingArea. Another useful resource was a C++/gtkmm article about drawing a gtk.gdk.Pixbuf object in a Cairo context. Here’s some screenshots of a demo program in Ubuntu 8.04 and Windows XP.

Scaled, aspect ratio maintained

Scaled, aspect ratio maintained.

Stretched, aspect ratio out the window.

Stretched, aspect ratio out the window.

Aspect preserved, black background.

Aspect preserved, black background.

Running in Windows XP.

Running in Windows XP.

Prevented from exceeding original resolution.

Prevented from exceeding original resolution.

Enlargement allowed, constant aspect enforced.

Enlargement allowed, constant aspect enforced.

resizableimage.py

Here’s the key bits of code for the ResizableImage control:

import pygtk
import gtk
from gtk import DrawingArea

class ResizableImage(DrawingArea):

    def __init__(self, aspect=True, enlarge=False,
            interp=gtk.gdk.INTERP_NEAREST, backcolor=None, max=(1600,1200)):
        """Construct a ResizableImage control.

        Parameters:
        aspect -- Maintain aspect ratio?
        enlarge -- Allow image to be scaled up?
        interp -- Method of interpolation to be used.
        backcolor -- Tuple (R, G, B) with values ranging from 0 to 1,
            or None for transparent.
        max -- Max dimensions for internal image (width, height).

        """
        DrawingArea.__init__(self)
        self.pixbuf = None
        ...
        self.connect('expose_event', self.expose)

The expose event is triggered when the control needs to be repainted. Here we just obtain a Cairo context and call draw().

    def expose(self, widget, event):
        # Load Cairo drawing context.
        self.context = self.window.cairo_create()
        # Set a clip region.
        self.context.rectangle(
            event.area.x, event.area.y,
            event.area.width, event.area.height)
        self.context.clip()
        # Render image.
        self.draw(self.context)
        return False

Then this is where the image actually gets rendered to the control. The size of the image is determined by resizeToFit(). A resized version of the gtk.Pixbuf member is created using scale_simple(). The gtk.Pixbuf image is displayed using set_source_pixbuf().

    def draw(self, context):
        # Get dimensions.
        rect = self.get_allocation()
        x, y = rect.x, rect.y
        # Remove parent offset, if any.
        parent = self.get_parent()
        if parent:
            offset = parent.get_allocation()
            x -= offset.x
            y -= offset.y
        # Fill background color.
        if self.backcolor:
            context.rectangle(x, y, rect.width, rect.height)
            context.set_source_rgb(*self.backcolor)
            context.fill_preserve()
        # Check if there is an image.
        if not self.pixbuf:
            return
        width, height = resizeToFit(
            (self.pixbuf.get_width(), self.pixbuf.get_height()),
            (rect.width, rect.height),
            self.aspect,
            self.enlarge)
        x = x + (rect.width - width) / 2
        y = y + (rect.height - height) / 2
        context.set_source_pixbuf(
            self.pixbuf.scale_simple(width, height, self.interp), x, y)
        context.paint()

    def set_from_pixbuf(self, pixbuf):
        width, height = pixbuf.get_width(), pixbuf.get_height()
        # Limit size of internal pixbuf to increase speed.
        if not self.max or (width < self.max[0] and height < self.max[1]):
            self.pixbuf = pixbuf
        else:
            width, height = resizeToFit((width, height), self.max)
            self.pixbuf = pixbuf.scale_simple(
                width, height,
                gtk.gdk.INTERP_BILINEAR)
        self.invalidate()

The self.max variable limits the size of the internal gtk.Pixbuf to prevent slow render times for larger images. The default maximum is 1600×1200. It can be disabled by setting max=None in the constructor call.

    def set_from_file(self, filename):
        self.set_from_pixbuf(gtk.gdk.pixbuf_new_from_file(filename))

    def invalidate(self):
        self.queue_draw()

    ...

A couple of functions external to the class:

def resizeToFit(image, frame, aspect=True, enlarge=False):
    """Resizes a rectangle to fit within another.

    Parameters:
    image -- A tuple of the original dimensions (width, height).
    frame -- A tuple of the target dimensions (width, height).
    aspect -- Maintain aspect ratio?
    enlarge -- Allow image to be scaled up?

    """
    if aspect:
        return scaleToFit(image, frame, enlarge)
    else:
        return stretchToFit(image, frame, enlarge)

def scaleToFit(image, frame, enlarge=False):
    image_width, image_height = image
    frame_width, frame_height = frame
    image_aspect = float(image_width) / image_height
    frame_aspect = float(frame_width) / frame_height
    # Determine maximum width/height (prevent up-scaling).
    if not enlarge:
        max_width = min(frame_width, image_width)
        max_height = min(frame_height, image_height)
    else:
        max_width = frame_width
        max_height = frame_height
    # Frame is wider than image.
    if frame_aspect > image_aspect:
        height = max_height
        width = int(height * image_aspect)
    # Frame is taller than image.
    else:
        width = max_width
        height = int(width / image_aspect)
    return (width, height)

def stretchToFit(image, frame, enlarge=False):
    image_width, image_height = image
    frame_width, frame_height = frame
    # Stop image from being blown up.
    if not enlarge:
        width = min(frame_width, image_width)
        height = min(frame_height, image_height)
    else:
        width = frame_width
        height = frame_height
    return (width, height)

demogtk.py

Here’s most of the code for the demo program:

import pygtk
import gtk
import gtk.glade

import os
from resizableimage import ResizableImage

class DemoGtk:

    def __init__(self):
        # Initiate GUI.
        self.gladefile = "demo.glade"
        self.wTree = gtk.glade.XML(self.gladefile)
        # Find widgets that we use.
        self.frame = self.wTree.get_widget('pictureframe')
        self.txtFile = self.wTree.get_widget('txtFile')
        self.chkAspect = self.wTree.get_widget('chkAspect')
        self.chkEnlarge = self.wTree.get_widget('chkEnlarge')

        # Connect signals.
        signals = {
            'on_window_destroy': self.destroy,
            'on_txtFile_changed': self.openFile,
            'on_chkAspect_clicked': self.changeAspect,
            'on_chkEnlarge_clicked': self.changeEnlarge
        }
        self.wTree.signal_autoconnect(signals)

        # Do some runtime GUI.
        self.image = ResizableImage(
            self.chkAspect.get_active(),
            self.chkEnlarge.get_active(),
            gtk.gdk.INTERP_BILINEAR)
        self.image.show()
        self.frame.add(self.image)

Options for interpolation type are gtk.gdk.INTERP_NEAREST, gtk.gdk.INTERP_TILES, gtk.gdk.INTERP_BILINEAR, gtk.gdk.INTERP_HYPER. I used “bilinear” here to get decent quality pictures. The “nearest” option is alright if you need more performance.

    def destroy(self, widget, data=None):
        gtk.main_quit()

    def openFile(self, widget, data=None):
        filename = widget.get_text()
        if not os.path.isfile(filename):
            return
        self.image.set_from_file(filename)

    def changeAspect(self, widget, data=None):
        self.image.set_aspect(widget.get_active())

    def changeEnlarge(self, widget, data=None):
        self.image.set_enlarge(widget.get_active())

def main():
    gui = DemoGtk()
    gtk.main()

if __name__ == '__main__':
    main()

Doesn’t look like I’m able to upload the source to WordPress. Just let me know in the comments if you’re after a bit more detail!

Tags: , , , , ,

3 Responses to “Resizable Image Control using PyGTK and Cairo”

  1. Darren Says:

    Thanks for posting this code! I needed something just like this, and (nicely) stumbled across this web site!

  2. Rahim Says:

    Hey man,
    I was browsing through the web that i found ur blog..
    great python application u’ve got…
    i assume u r doin mechatronic engineering at uq..
    i am doing csse1001 at the moment, kinda stunned by what python can actually do..
    -rahim-

  3. Berend Says:

    Haha! I needed this.

    It’s actually hard(-ish) to use get_allocation() correctly.

Leave a Reply