February 2023: Sketching Tool with PyQt6

Qt is always a little bit relevant, and my knowledge about it is always a little bit out of date. The last time I touched Qt was when it was Qt5, but it's Qt6.4 now. At the same time I've wanted to change up my art practice for the next few weeks in a way that will require a lot of references. Sometimes that's really all it takes.

An evening of coding later, it's done, and I'm about ready to start with my practice. It's been a short one (good too, considering it's still exam season and it's kicking my butt), but there's still some interesting stuff to mention. I wanted to use a local repository of images and randomly display one of them for a set amount of time. The script then basically consists of two parts:

1. Randomly find the path of an image, wait a bit and refresh

2. The whole Qt thing

The first part I didn't think would be very difficult, but I ended up being wrong about that. The second part I had little idea about, because I forget everything about front-end development fairly quickly. Luckily, this is a very rudimentary GUI, especially since it doesn't actually seem to have any parts interfacing between user and script. Still, cannibalizing my old scripts for this purpose didn't turn out as fruitful as I originally though, since as always, new versions come with new internal module structure. Thus begins the age old song of "where did it come from, where did it go?" as my compiler told me Qt type objects didn't have some flag or another.

As always, the interface goes into a class, derived from QWidgets.

class Canvas(QtWidgets.QWidget):
    def __init__(self, path, interval):
        super(Canvas, self).__init__()
        self.interval = interval
        self.path = path
    
        self.imgs = os.listdir(self.path)
        self.initUI()

    def initUI(self):
        self.resize(2000, 1200)
        self.center()
        self.setWindowTitle("SKETCH!")

        self.lb = QtWidgets.QLabel(self)
        p = self.path + random.choice(self.imgs)
        pixmap = QtGui.QPixmap(p)
        self.lb.resize(self.width(), self.height())
        self.lb.setPixmap(pixmap.scaled(self.lb.size(),
            QtCore.Qt.AspectRatioMode.KeepAspectRatio))
        self.show()

    def center(self):
        qr = self.frameGeometry()
        cp = QtGui.QGuiApplication.primaryScreen().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

Yeah, I know I hard-coded the dimensions to something nonsensical, but let's ignore that for now. Generally, a window is created and then a label with a pixel-map (pixmap) is inserted into it. I wanted to explicitly keep the image's aspect ratio. By default it stretches the image into the resize parameters, which will make a substantial part of my references look really wonky. Stuff like this is already everywhere on the internet, so so far so good. Now, when I need to time something in python, I tend to use the time.sleep() function. This won't work in this case. Qt's show() method wants the script to keep running, or it just doesn't display anything. Threading with timers always seems tricky to me. I tried a few versions of it, but having threads running in the Qt-class just seems icky to me and I closing and reopening the application won't work, because of how it's implemented in the main() function.

def main():
    parser = argparse.ArgumentParser("local_refs")
    parser.add_argument("dir", help="Source Directory of Images", 
            type=str)
    parser.add_argument("interval", 
            help="Time the Image will be displayed in Seconds", 
            type = int)
    args = parser.parse_args()
    
    

    # The Qt Portion
    app = QtWidgets.QApplication(sys.argv)
    w = Canvas(args.dir, args.interval)
    app.exec()

Those three innocent lines underneath the "Qt Portion" is difficult to hack around. Intuitively I would honestly just kill the application and reopen it to refresh the picture, but the w variable comes up bad. If I use sleep, then the interface won't be loaded in. To close the application I would need to call app.quit(), so I need to keep that in scope. I would then have to use two separate threads and pass the variables back and forth. That feels really, really bad, but it'd work. Luckily there's a better way: PyQt comes with a testing submodule "QtTest". It has a wait method internally for the Qt application and so you can wait and refresh the frame from within the class.

    def initUI(self):
        self.resize(2000, 1200)
        self.center()
        self.setWindowTitle("SKETCH!")

        self.lb = QtWidgets.QLabel(self)

        while (1):
            self.refresh()
            QtTest.QTest.qWait(self.interval * 1000)

    def refresh(self):
        p = self.path + random.choice(self.imgs)
        pixmap = QtGui.QPixmap(p)
        self.lb.resize(self.width(), self.height())
        self.lb.setPixmap(pixmap.scaled(self.lb.size(),
            QtCore.Qt.AspectRatioMode.KeepAspectRatio))
        self.show()

It works basically like a waiting thread for python, but it takes care of the rendering in the meantime. It makes me immensely happy. What's left is a way to dynamically resize the frame, and there's a nice event handler for this too.

 def resizeEvent(self, event):
        self.lb.resize(self.width(), self.height())
        self.lb.setPixmap(self.lb.pixmap().scaled(self.lb.size()))
        QtWidgets.QWidget.resizeEvent(self, event)

And that's all the features I wanted from my tool. Well, except for an elegant way to exit out of the application, but I don't really care about anything that Ctrl+C can't fix for these kinds of things.

Here the entire script again, which I may kick up on github one of these days. If I can bring myself to, I guess.

import os, sys
import time
import argparse
import random

from PyQt6 import QtCore, QtGui, QtWidgets, QtTest

class Canvas(QtWidgets.QWidget):
    def __init__(self, path, interval):
        super(Canvas, self).__init__()
        self.interval = interval
        self.path = path
    
        self.imgs = os.listdir(self.path)
        self.initUI()

    def initUI(self):
        self.resize(2000, 1200)
        self.center()
        self.setWindowTitle("SKETCH!")

        self.lb = QtWidgets.QLabel(self)

        while (1):
            self.refresh()
            QtTest.QTest.qWait(self.interval * 1000)

    def refresh(self):
        p = self.path + random.choice(self.imgs)
        pixmap = QtGui.QPixmap(p)
        self.lb.resize(self.width(), self.height())
        self.lb.setPixmap(pixmap.scaled(self.lb.size(),
            QtCore.Qt.AspectRatioMode.KeepAspectRatio))
        self.show()

    def resizeEvent(self, event):
        self.lb.resize(self.width(), self.height())
        self.lb.setPixmap(self.lb.pixmap().scaled(self.lb.size()))
        QtWidgets.QWidget.resizeEvent(self, event)

    def center(self):
        qr = self.frameGeometry()
        cp = QtGui.QGuiApplication.primaryScreen().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

def main():
    parser = argparse.ArgumentParser("local_refs")
    parser.add_argument("dir", help="Source Directory of Images", 
            type=str)
    parser.add_argument("interval", 
            help="Time the Image will be displayed in Seconds", 
            type = int)
    args = parser.parse_args()
    
    app = QtWidgets.QApplication(sys.argv)
    w = Canvas(args.dir, args.interval)
    app.exec()

if __name__ == '__main__':
    main()
Previous
Previous

March 2023: Dvorak

Next
Next

January 2023: Spring Cleaning