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.
1 Answer 1
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 if
s 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 thangreen
. - Some variables are unused, e.g.
smbytefunc
andtobytefunc
. - Instead of
clamp
you can usenumpy.clip
. - Loops with
range(len(...))
are probably better written withenumerate
instead. If you have a list of images you can also just iterate over the contents, instead of using the indexes fromrange(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
andsmid
is more likely to get out of sync with each other. If you want a shortcut, maybe use a property fornimgs
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.
-
1\$\begingroup\$ @EthanBierlein
xrange
isn't defined in Python 3, which is why this is necessary. \$\endgroup\$ferada– ferada2015年08月05日 08:44:56 +00:00Commented 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\$Ajean– Ajean2015年08月05日 15:08:24 +00:00Commented 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\$ferada– ferada2015年08月09日 21:12:49 +00:00Commented Aug 9, 2015 at 21:12
Tkinter
import in ajsutil.py. \$\endgroup\$