# Multi-layer PyQt4 image viewer

I have written a functional GUI program using PyQt4, and I'm looking for some feedback because it's not as fast as I would like. It takes in some number of large, same-sized 2D arrays and displays a sub-image of each one in its own sub-window - at full resolution or only zoomed by a factor of 2 or 4. The central GUI contains one of the complete images down-sampled so it fits on the screen, on which is a movable selection box that can be moved by mouse or by the arrow keys.

The response when I move the box is slow when I set the sub-window zoom level to 25% or 50%, is there a way to speed it up? I also could not get around creating a sub-class of QtGraphicsView for the central widget, is that the most proper way to do this?

The primary code - it's decently long, but I've removed some other functional settings (such as the color of the box) to bring the line count down:

import sys
import numpy as np
from PyQt4 import QtCore, QtGui
from ajsutil import get_screen_size, bytescale, clamp, upsamp, downsamp

# This is used as the central widget in the main GUI
class SubView(QtGui.QGraphicsView):
"""A sub-class of QGraphicsView that allows specific mouse and keyboard
handling.
"""

# Custom signals - one for keyboard update and one for mouse update
updateEvent = QtCore.pyqtSignal(list)
modEvent = QtCore.pyqtSignal(list)

def __init__(self, img, boxsize):
"""Initialize the class with an image and a box size."""

super(SubView,self).__init__()

wdims = (img.size().width(), img.size().height())
self.bs = boxsize

# Construct a scene with a pixmap and a rectangle
scene = QtGui.QGraphicsScene(0, 0, wdims[0], wdims[1])

self.rpen = QtGui.QPen(QtCore.Qt.green)
self.rect = scene.addRect(0, 0, boxsize,boxsize, pen=self.rpen)

self.setScene(scene)

# Set size policies and settings
self.setSizePolicy(QtGui.QSizePolicy.Fixed,
QtGui.QSizePolicy.Fixed)
self.setMinimumSize(wdims[0], wdims[1])
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setMouseTracking(True)

self.mouseOn = False

def mouseMoveEvent(self, event):
"""If the box is picked up, move the box."""
if self.mouseOn:
self.updateEvent.emit([event.x(), event.y()])

def mousePressEvent(self, event):
"""Pick up or drop the box."""
self.mouseOn = not self.mouseOn

def keyPressEvent(self, event):
"""Move the box with arrow keys."""
if not self.mouseOn:
if event.key() == QtCore.Qt.Key_Up:
mod = [0,-1]
elif event.key() == QtCore.Qt.Key_Down:
mod = [0,1]
elif event.key() == QtCore.Qt.Key_Left:
mod = [-1,0]
elif event.key() == QtCore.Qt.Key_Right:
mod = [1,0]
self.modEvent.emit([x*self.bs/2 for x in mod])

# This is the main GUI!!
class ImageViewerQt(QtGui.QMainWindow):
"""An image viewer for displaying a large image, or set of images, with a
zoom box that can be dragged with the mouse or moved with arrow keys.
Small sub-windows show the full-resolution image(s) downsampled by factors
of up to 4.

Uses a large QGraphicsView subclass as the central widget, with a status
bar under it.
"""
def __init__(self, *args, **kwargs):
# Create the Qt application (if necessary)
self.app = QtGui.QApplication.instance()
if self.app is None:
self.app = QtGui.QApplication(sys.argv)
super(ImageViewerQt,self).__init__()

# Define member attributes
# (I'm using dictionaries to avoid having tons of little attributes,
#  but there may be a better way ....)
self.acts = {}
self.info = {}

boxsize = kwargs.get('boxsize', 512)
self.info['boxsize'] = boxsize
self.info['zoom'] = 1.0

# Construct a list from the input images
nimgs = len(args)
self.imgList = []
if nimgs == 0:
self.imgList.append(np.zeros((1024,1024)))
self.nimgs = 1
else:
for indata in args:
self.imgList.append(indata)
self.nimgs = nimgs
self.names = kwargs.get('names', ["Image %d" % (i+1) for i in
range(nimgs)])

# Set up sizes - try to automatically place the big window and smaller
# window so that they don't overlap
data = self.imgList[0]
dims = np.array(data.shape)
scrdims = get_screen_size()
scales = np.ceil(dims.astype(np.float32) / scrdims)
scale = np.amax(scales).astype(np.float)
wdims = dims / scale
if (scrdims[0]-boxsize) > (scrdims[1]-boxsize):
xoff = wdims[0]+30
yoff = 30
else:
xoff = 5
yoff = wdims[1]+30
nxwin = int((scrdims[0]-xoff) / (boxsize+25))

self.dims = dims
self.wdims = wdims
self.info['scale'] = scale
self.info['xy'] = [boxsize/2, boxsize/2]

# Initialize the vmin/vmax for the small window grayscales and
# set up the float-to-byte conversion function
ominv = data.min()
omaxv = data.max()
self.info['sm_bounds'] = [ominv,omaxv]
self.info['sm_minmax'] = [ominv,omaxv]
self.smbytefunc = lambda x: 255. * (x - ominv) / (omaxv - ominv)

# Initialize the vmin/vmax for the big window grayscale and
# set up the float-to-byte conversion function
ddata = downsamp(data, scale)
minv = ddata.min()
maxv = ddata.max()
self.info['img_bounds'] = [minv,maxv]
self.info['img_minmax'] = [minv,maxv]
self.tobytefunc = lambda x: 255. * (x - minv) / (maxv - minv)

# Define grayscale color scales
self.colortable = [QtGui.qRgb(i, i, i) for i in xrange(256)]
self.smcolortable = [QtGui.qRgb(i, i, i) for i in xrange(256)]

# Construct the QImage used for the big window
self.img = QtGui.QImage(bytescale(ddata).astype(np.uint8),
wdims[0], wdims[1],
QtGui.QImage.Format_Indexed8)
self.img.setColorTable(self.colortable)

# Set up the view
self.view = SubView(self.img, boxsize/scale)
self.view.updateEvent.connect(self.updateView)
self.view.modEvent.connect(self.modView)

self.setCentralWidget(self.view)

self.sB = self.statusBar()
self.setWindowTitle('Image Viewer')
self.setGeometry(10,30,2000,2000)
self.resize(self.sizeHint())

self.createActions()

self.createSubWindows(xoff, yoff, nxwin)

self.show()
self.app.exec_()

def save(self):
"""Save the big image to an image file."""
fname, ffilter = QtGui.QFileDialog.getSaveFileName(self)
self.view.px.pixmap().save(fname)

def quit(self):
"""Exit the GUI."""
for i_img in range(self.nimgs):
self.smid[i_img].close()
self.close()

def createActions(self):
"""Create the actions for the menus."""
self.acts['save'] = QtGui.QAction("&Save Image",self,triggered=self.save)
self.acts['exit'] = QtGui.QAction("E&xit",self,triggered=self.quit)

# Zoom option
self.acts['zoomGroup'] = QtGui.QActionGroup(self)
self.acts['zoom200'] = QtGui.QAction("200%",
self.acts['zoomGroup'], checkable=True)
self.acts['zoom200'].setData(0.5)
self.acts['zoom100'] = QtGui.QAction("100%",
self.acts['zoomGroup'], checkable=True)
self.acts['zoom100'].setData(1.0)
self.acts['zoom050'] = QtGui.QAction(" 50%",
self.acts['zoomGroup'], checkable=True)
self.acts['zoom050'].setData(2.0)
self.acts['zoom025'] = QtGui.QAction(" 25%",
self.acts['zoomGroup'], checkable=True)
self.acts['zoom025'].setData(4.0)
self.acts['zoom100'].setChecked(True)
self.acts['zoomGroup'].triggered.connect(self.setZoom)

def createSubWindows(self, xoff, yoff, nxwin):
"""Create the individual sub-windows containing the full-resolution
images.
"""
# Make lists to hold the ids, labels, and images themselves
self.smid = []
self.smlbl = []
self.smimg = []

bs = self.info['boxsize']
mn = self.info['sm_minmax'][0]
mx = self.info['sm_minmax'][1]

# For each image, construct a QWidget, a QImage, and a QLabel to hold it
for i_img in range(self.nimgs):
self.smid.append(QtGui.QWidget(self))
self.smid[i_img].setWindowTitle(self.names[i_img])
self.smid[i_img].setWindowFlags(QtCore.Qt.Window)

patch = self.imgList[i_img][0:bs,0:bs]
patch = bytescale(patch, vmin=mn, vmax=mx).astype(np.uint8)
img = QtGui.QImage(patch,bs,bs,QtGui.QImage.Format_Indexed8)
img.setColorTable(self.colortable)
self.smimg.append(img)

self.smlbl.append(QtGui.QLabel(self.smid[i_img]))
self.smlbl[i_img].setPixmap(QtGui.QPixmap(img))
self.smlbl[i_img].setMinimumSize(bs,bs)

w, h = (self.smlbl[i_img].sizeHint().width(),
self.smlbl[i_img].sizeHint().height())
xo = xoff + (i_img % nxwin)*(bs+25)
yo = yoff + (i_img / nxwin)*(bs+45)
self.smid[i_img].setGeometry(xo,yo,w,h)

self.smid[i_img].show()

def setZoom(self):
"""Zoom setting slot."""
currAct = self.acts['zoomGroup'].checkedAction()
currzoom = currAct.data()
if np.isscalar(currzoom):
self.info['zoom'] = currzoom
else:
self.info['zoom'] = currzoom.toFloat()[0]

self.updateView()

def modView(self, offxy):
"""Arrow-key control slot."""
wxy = self.info['xy'] / self.info['scale']
newxy = [wxy[i]+offxy[i] for i in range(len(wxy))]
self.updateView(newxy)

def updateView(self, wxy=None):
"""Update the entire GUI based on new box position or setting change."""
if wxy is None:
wxy = self.info['xy'] / self.info['scale']

xy = [x * self.info['scale'] for x in wxy]

bs = self.info['boxsize']
bs2 = bs/2
zm = self.info['zoom']

xc = clamp(xy[0], [bs2*zm, self.dims[0]-bs2*zm])
yc = clamp(xy[1], [bs2*zm, self.dims[1]-bs2*zm])
self.info['xy'] = [xc,yc]

wxc = xc / self.info['scale']
wyc = yc / self.info['scale']
wbs = bs / self.info['scale']
wbs2 = bs2 / self.info['scale']

self.view.rect.setRect(wxc-wbs2*zm,wyc-wbs2*zm,wbs*zm,wbs*zm)

bbox = [yc-bs2*zm, xc-bs2*zm, yc+bs2*zm, xc+bs2*zm]
mnmx = self.info['sm_minmax']
for i_img in range(self.nimgs):
data = self.imgList[i_img]
patch = data[bbox[0]:bbox[2], bbox[1]:bbox[3]]
if zm < 1:
patch = upsamp(patch, 1.0/zm)
else:
patch = downsamp(patch, zm)
patch = bytescale(patch, vmin=mnmx[0], vmax=mnmx[1]).astype(np.uint8)
img = QtGui.QImage(patch,bs,bs,QtGui.QImage.Format_Indexed8)
img.setColorTable(self.colortable)
self.smimg[i_img] = img

self.smlbl[i_img].setPixmap(QtGui.QPixmap(img))

status = "(%d, %d)" % (xc, yc)
self.sB.showMessage(status)

if __name__ == "__main__":
from PIL import Image
testfile = "path to filename"
data = np.array(Image.open(testfile))
red = data[:,:,0]
grn = data[:,:,1]
blu = data[:,:,2]
iv = ImageViewerQt(red, grn, blu, names=['Red','Green','Blue'], boxsize=256)


For completeness, ajsutil.py contains:

from __future__ import print_function
import numpy as np
import collections
try:
import Tkinter as tk
except ImportError:
import tkinter as tk

def get_screen_size():
"""Return a tuple containing the size of the current monitor in pixels."""
root = tk.Tk()

scrwid = root.winfo_screenwidth()
scrhgt = root.winfo_screenheight()

root.destroy()

return scrwid, scrhgt-90

def clamp(data, mnmx):
"""Clamp data to within specified range."""
data = np.maximum(data, mnmx[0])
data = np.minimum(data, mnmx[1])
return data

def upsamp(data, scales):
"""Up-sample by separate scale factors in each dimension."""
# Calculate new dimensions
if not (isinstance(scales, collections.Sequence) or
isinstance(scales, np.ndarray)):
scales = np.tile(scales, data.ndim)

# Set up new dimensions and replicate data
new = np.copy(data)
for idim in range(new.ndim):
new = new.repeat(scales[idim], axis=idim)
return new

def bytescale(data, vmin=None, vmax=None):
"""Scale data to 0-255."""
if vmin == None:
mn = np.amin(data)
else:
mn = vmin
if vmax == None:
mx = np.amax(data)
else:
mx = vmax

out = np.array(255. * (data - mn) / (mx - mn))
return out

def downsamp(data, scales):
"""Downsample by separate scale factors in each dimension."""
# Calculate new dimensions
dims = np.shape(data)
ndim = np.ndim(data)
if not (isinstance(scales, collections.Sequence) or
isinstance(scales, np.ndarray)):
scales = np.tile(scales, ndim)
newdims = [np.floor(dims[i]/scales[i]) for i in range(ndim)]

# If the scale does not divide exactly into dims, chop off the necessary
# last elements before reshaping
slices = []
for i in range(ndim):
slices.append(slice(newdims[i]*scales[i]))
new = np.copy(data[slices])

# Set up new dimensions and reshape the data
sh = [newdims[0], scales[0]]
for idim in range(1, new.ndim):
sh.append(newdims[idim])
sh.append(scales[idim])
new = new.reshape(sh)

# Average over the combining dimensions
for idim in range(np.ndim(data)*2-1, 0, -2):
new = new.mean(idim)
return new


It can be tested with an example file here, by running the main file (there is a __name__ == "__main__" statement at the bottom where you can set the path to the file).

It now runs under both Python 2 and Python 3, but it is still slow when the zoom factor is set.

• Aw ... sniff, no takers? I didn't even get to get the Tumbleweed badge. Is it just that it's too long? – Ajean Jan 9 '15 at 20:08
• I had to change signal to pyqtSignal and change np.zeros(1024,1024) to np.zeros((1024,1024)) to get it to run. You code was incomplete, so it makes is difficult to get it working. I would recommend providing a complete working source (albeit shrunk for brevity). I can't figure out how to reference an image? – panofish Mar 5 '15 at 19:22
• @panofish Thanks for your response! We must have a version or installation discrepancy, because the code as posted works fine for me (I just double-checked by copy-pasting the post). I'll make those changes, though, in case other folks have the issue. I added an example image and execution code for testing. Was there anything else that made it incomplete? – Ajean Mar 5 '15 at 21:07
• I should also mention that it needs running under Python 2 because of the Tkinter import in ajsutil.py. – Ajean Mar 5 '15 at 21:12

# Problems

For Python 2 and 3 compatibility xrange would need to be defined in both versions, e.g.:

try:
xrange
except NameError:
xrange = range


Also, in Python 3 I get a lot of errors because mod isn't set:

Traceback (most recent call last):
File "qt.py", line 64, in keyPressEvent
self.modEvent.emit([x*self.bs/2 for x in mod])
UnboundLocalError: local variable 'mod' referenced before assignment


I'd address that with a method like this:

def keyPressEvent(self, event):
"""Move the box with arrow keys."""
if self.mouseOn:
return

mod = {
QtCore.Qt.Key_Up: [0, -1],
QtCore.Qt.Key_Down: [0, 1],
QtCore.Qt.Key_Left: [-1, 0],
QtCore.Qt.Key_Right: [1, 0]
}.get(event.key())

if not mod:
return

self.modEvent.emit([x * self.bs / 2 for x in mod])


Or so; exit early, use declarative syntax (which looks a bit cleaner than a lot of ifs and only use mod when it actually has a value.

The save method will raise an error if the user didn't select a filename, so check the return value of getSaveFileName before destructuring it:

def save(self):
"""Save the big image to an image file."""
selection = QtGui.QFileDialog.getSaveFileName(self)
if selection:
self.view.px.pixmap().save(selection[0])


Zoom level 25% is showing artifacts when the picture isn't big enough. I guess that's somewhat expected, but I'd still rather catch that instead.

# Style

• Obviously PEP8. Especially whitespace and abbrevations bug me: grn is so not more readable than green.
• Some variables are unused, e.g. smbytefunc and tobytefunc.
• Instead of clamp you can use numpy.clip.
• Loops with range(len(...)) are probably better written with enumerate instead. If you have a list of images you can also just iterate over the contents, instead of using the indexes from range(number_of_images).
• It is less error-prone to keep just one list of images/widgets/... than to keep the number of things as a separate variable; e.g. both nimgs and smid is more likely to get out of sync with each other. If you want a shortcut, maybe use a property for nimgs instead.
• Using both Tk and Qt is not nice; (if you're already using Qt, than) try to minimise the number of (additional) dependencies in your application. I found this question for the screen size and it seems to work fine for this setup.

The separation between the SubView and the main application window is good enough I think. The main view though does quite a lot; it might make sense to move part of the update logic into the separate windows and create a new class for them instead.

# Performance

For the slowness I'd always use a profiler to figure out what's happening there. So with handy dandy cProfile in hand, let's see what takes so much time (python -mcProfile -oqt.profile -stottime qt.py then set zoom to 50% and wiggle the mouse around a lot):

qt.profile% stats
Tue Aug  4 23:41:39 2015    qt.profile

96226 function calls (93379 primitive calls) in 10.468 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
1    4.468    4.468   10.158   10.158 {built-in method exec_}
1731    3.882    0.002    3.882    0.002 {method 'reduce' of 'numpy.ufunc' objects}
1724    0.548    0.000    4.451    0.003 /usr/lib64/python3.3/site-packages/numpy/core/_methods.py:53(_mean)
865    0.425    0.000    0.540    0.001 ./ajsutil.py:39(bytescale)
4353    0.357    0.000    0.370    0.000 {built-in method array}
287    0.193    0.001    5.678    0.020 qt.py:292(updateView)
288    0.064    0.000    0.064    0.000 {built-in method showMessage}
866    0.058    0.000    0.058    0.000 {method 'astype' of 'numpy.ndarray' objects}
26/23    0.053    0.002    0.057    0.002 {built-in method load_dynamic}
862    0.045    0.000    4.810    0.006 ./ajsutil.py:53(downsamp)
1    0.035    0.035    0.035    0.035 {built-in method create}
120    0.030    0.000    0.030    0.000 {built-in method loads}
4    0.027    0.007    0.027    0.007 {built-in method show}
865    0.025    0.000    0.025    0.000 {built-in method setColorTable}
2586    0.022    0.000    0.022    0.000 {method 'reshape' of 'numpy.ndarray' objects}
862    0.018    0.000    0.031    0.000 /usr/lib64/python3.3/site-packages/numpy/lib/shape_base.py:792(tile)
862    0.012    0.000    0.012    0.000 ./ajsutil.py:61(<listcomp>)
1    0.010    0.010   10.277   10.277 qt.py:87(__init__)


And so forth. I've sorted by tottime to see the actual running time in functions.

So what stands out? downsamp and bytescale I'd say.

Let's start with downsamp. I guessed that the mean calls at the end where a problem, because mean comes up in the stats as well; searching for that gives me this Stackoverflow answer, which adapted to the code gives a bit of a boost:

return new.mean(axis=tuple(range(ndim * 2 - 1, 0, -2)))


That said, without optimised routines that are destructive (i.e. don't copy so much image data around) I assume you won't get much of a speedup out of all this. I'd suggest to look into e.g. scikit-image instead of implementing this yourself.

Oh and now that I think of it: I'd rather (up/down)scale the images once when the setting changes and then only index into the already scaled version instead of doing that on every mouse move.

• @EthanBierlein xrange isn't defined in Python 3, which is why this is necessary. – ferada Aug 5 '15 at 8:44
• Thanks for your lovely answer!! I've not yet learned how to use a profiler with Python, this is very informative. Your point about doing the up/down scaling once is well made, that may make things much better! It looks like those really are the culprit - my main reason for not thinking so immediately was because I initially implemented a version of this in Tkinter (using the exact same ajsutil.py), and it performed much faster. Do you have any ideas why that would be? This is me trying to get away from Tkinter (as you so rightly point out, using both = blech). – Ajean Aug 5 '15 at 15:08
• @Ajean sorry, unfortunately I don't really have an idea about Tk, or why using Qt is much slower. It might be some conversions going on that aren't done in Tk, or so; but for that you'd have to ask an expert on that. – ferada Aug 9 '15 at 21:12