12
\$\begingroup\$

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.px = scene.addPixmap(QtGui.QPixmap.fromImage(img))
 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.sB.showMessage("Ready")
 self.setWindowTitle('Image Viewer')
 self.setGeometry(10,30,2000,2000)
 self.resize(self.sizeHint())
 self.createActions()
 self.createMenus()
 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."""
 # File menu
 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 createMenus(self):
 """Create the menu buttons."""
 self.fileMenu = QtGui.QMenu("&File", self)
 self.fileMenu.addAction(self.acts['save'])
 self.fileMenu.addAction(self.acts['exit'])
 self.optMenu = QtGui.QMenu("&Options", self)
 self.zoomMenu = QtGui.QMenu("&Zoom", self)
 self.zoomMenu.addAction(self.acts['zoom200'])
 self.zoomMenu.addAction(self.acts['zoom100'])
 self.zoomMenu.addAction(self.acts['zoom050'])
 self.zoomMenu.addAction(self.acts['zoom025'])
 self.optMenu.addMenu(self.zoomMenu)
 self.menuBar().addMenu(self.fileMenu)
 self.menuBar().addMenu(self.optMenu)
 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.

Jamal
35.2k13 gold badges134 silver badges238 bronze badges
asked Dec 23, 2014 at 23:35
\$\endgroup\$
4
  • 1
    \$\begingroup\$ Aw ... sniff, no takers? I didn't even get to get the Tumbleweed badge. Is it just that it's too long? \$\endgroup\$ Commented Jan 9, 2015 at 20:08
  • 2
    \$\begingroup\$ 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? \$\endgroup\$ Commented Mar 5, 2015 at 19:22
  • \$\begingroup\$ @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? \$\endgroup\$ Commented Mar 5, 2015 at 21:07
  • \$\begingroup\$ I should also mention that it needs running under Python 2 because of the Tkinter import in ajsutil.py. \$\endgroup\$ Commented Mar 5, 2015 at 21:12

1 Answer 1

5
+50
\$\begingroup\$

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.

answered Aug 4, 2015 at 23:49
\$\endgroup\$
3
  • 1
    \$\begingroup\$ @EthanBierlein xrange isn't defined in Python 3, which is why this is necessary. \$\endgroup\$ Commented Aug 5, 2015 at 8:44
  • \$\begingroup\$ 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). \$\endgroup\$ Commented Aug 5, 2015 at 15:08
  • \$\begingroup\$ @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. \$\endgroup\$ Commented Aug 9, 2015 at 21:12

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.