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.
Tkinter
import in ajsutil.py. – Ajean Mar 5 '15 at 21:12