Credits to Poly Haven for the stone and metal textures used in this image.

Tech Art Crash Course (Part I): Creating a Batch Renamer with Qt Designer and PyQt


To skip to the demo video, click here.

Background

From February 2024 to April 2024, I had the chance to take a technical art course through ELVTR - I had heard that the tech art role required some proficiency in Python programming, in addition to art sensibilities in the case of shaders and VFX.

Before taking the course, I was acquainted with Python, but not within the context of games. And, I had not yet tried PyQt/Qt for Python.

Batch Renamer Utility

A few weeks into the coursework (from late February to early March), I was given a task - I was to implement a batch renamer utility, firstly as a command line script, and later on as a PyQt6 application built on top of the previous script.

The assignment was meant to introduce a common pain point with game asset management - finding a way to quickly rename hundreds (or thousands) of texture exports, 3D renders and configuration files located in a repository somewhere, while retaining some flexibility in configuring said operation.

This utility would then be called upon to target files with typos, or files which needed to be moved to some other directory, or files in need of an update to a new naming convention … and so on.

The Dilemma

At the time of taking the course, the utility was expected to do at least the following per rename operation:

  1. Get a valid filepath to the current folder
  2. Get a valid filepath to a target folder
  3. Select files in the current folder
  4. Rename the selected files in place, or copy to the target folder

Optionally, the utility could do one or more of the following steps per operation:

  • Filter selected files by file extension
  • Find and replace one or more substrings in the selected files
  • Add a prefix and/or suffix to the selected files
  • Enable overwriting the selected files, if the files already exist in the target folder

Given the following test files, the output files were to be free of noise such as the version number of the file, or extraneous words such as “file”, “final”, et cetera. At the same time, each filename was to be surrounded with short, descriptive text indicating the type of game asset that the file represented:

Initial FilenamesTarget
texture_metal_diffuse.pngT_metal_C.png
grass_file_01.maM_grass.ma
hello_world.txtNOTE_hello_world_TEMP.txt

For example, the filename for a diffuse map could have a “T” prefix instead of “texture”, and a “C” suffix instead of “diffuse” or “color”.

Tools and Setup

Qt Designer was introduced early on as a free tool for quickly prototyping graphical user interfaces with widgets from the Qt GUI framework (though the possibility remained open to build a GUI from scratch).

A batch file was also provided to help with translating the XML based .ui file produced from Qt Designer to an equivalent .py file - the batch file made use of the pyuic.py module included with PyQt6, and mapped Qt widget names directly to the data attributes of a Python class named Ui_MainWindow when run:

python -m PyQt6.uic.pyuic -x batch_renamer.ui -o batch_renamer_ui.py

The resulting output file would be included as a dependency in another Python file, which included some starter code. The example code demonstrated how to connect a button click event to an event handler. Or in Qt terms, connecting a clicked() signal to a slot:

# Our Qt Designer GUI as a Python class
from batch_renamer_ui import Ui_MainWindow

class BatchRenamerWindow(QMainWindow, Ui_MainWindow):

    def __init__(self):
            # UI Setup
            super().__init__()
            super(Ui_MainWindow).__init__()
            self.setupUi(self)
            # Connect button to a function
            # Make sure that there is a Qt Widget named "browseBtn"
            self.browseBtn.clicked.connect(self.get_filepath)

    def get_filepath(self):
        self.filepath = QFileDialog().getExistingDirectory()

This Python file, which contained the class definition for the BatchRenamerWindow, would become the primary working file over those next couple of weeks.

Implementation

Since I was unfamiliar with Python bindings for Qt beforehand, note that the following details will primarily cover discoveries made while working with PyQt.

UI Design

After prototyping in Qt Designer and converting my .ui file to .py, I eventually landed on the GUI shown below, which I styled with a Qt Style Sheet (credits to DevSec Studio):

Batch Renamer Utility GUI

Though I had a GUI (nice to look at, but with zero functionality), I still needed to handle user interaction with Qt’s edit fields, buttons, and other clickable widgets.

Requirements

After familiarizing myself with the project files, I extended the starter code and leaned on the os module for file navigation, os.path for pathname manipulation, and re for regular expression matching in tandem with built-in string methods such as str.replace().

To fulfill the first requirement, I reused the browseBtn in the starter code, which I mapped to a QPushButton to react to the clicked() signal. I added one line to the get_filepath() function in order to set the display text of the corresponding QLineEdit widget:

self.lineEditFilepath.setText(self.filepath)

Since the second requirement was similar to that of the first, I set the second filepath to the target folder with some reused code and variable names:

self.lineEditNewFolder.setText(self.new_folder)

To fulfill the third requirement - and to validate incoming filepaths - I looked at the QLineEdit widget which held the value for the current folder. I wanted to handle the resulting value after clicking on the browseBtn, and the resulting value after editing the contents of the QLineEdit widget directly. I decided to:

  1. Rename get_filepath() to get_filepath_from_filedialog()
  2. Connect the QLineEdit widget’s editingFinished() signal to a new slot named get_filepath_from_lineedit()
  3. React whenever self.filepath was set to some value, with a new function named check_filepath()

Steps 2 and 3 are shown below:

self.lineEditFilepath.editingFinished.connect(self.get_filepath_from_lineedit())
def get_filepath_from_lineedit(self):
    self.filepath = self.lineEditFilepath.text()
    self.check_filepath()

The check_filepath() function was responsible for checking if a recently set filepath pointed to a valid directory - if so, then the pending list of selected files (displayed via QListWidget) would update with the help of os.walk():

def check_filepath(self):
    if os.path.isdir(self.filepath):
        self.update_list()
    else:
        # Log error to console

def update_list(self):
    """
    Clear listwidget
    read files in filepath with os.walk
    Add files as new items
    """
    self.listWidget.clear()
    for root, dirs, files in os.walk(self.filepath):
        self.listWidget.addItems(files)

*Code reused for the second widget which held the value for the target folder.

To fulfill the fourth and final requirement, I included two QRadioButtons which were set to exclusive mode by default, meaning that if one radio button were to be enabled, then the other would be disabled and vice versa. Depending on which radio button was toggled on, an internal flag named copy_files would be set to true or false:

self.radioButtonRename.toggled.connect(self.set_copy_files)
self.radioButtonCopy.toggled.connect(self.set_copy_files)
def set_copy_files(self):
    """
    Toggle between rename/move files mode and copy files mode
    """
    if self.radioButtonRename.isChecked():
        self.copy_files = False
    elif self.radioButtonCopy.isChecked():
        self.copy_files = True

Optional Features

As for the optional steps per operation, I relied on os.path.splitext() to get the extension per filename, in order to filter on the .png, .ma, and .txt file extensions.

For the string replacement operation, I retrieved a space-delimited string from the corresponding QLineEdit widget, and called str.split() to get an array of strings to search for. I retrieved one replacement string from the other, corresponding QLineEdit widget, and looped over the individual array elements in order to str.replace() with the new string:

def find_and_replace(files)
    """
    If the filename contains the string to find, 
    then replace with the new string
    """
    new_files = []
    for file in files:
        fname, ext = os.path.splitext(file)
        for s in self.strings_to_find:
            # replace with the new string
            fname = fname.replace(s, self.string_to_replace)
        new_file = fname + ext
        new_files.append(new_file)

    return new_files

For adding a prefix and/or suffix to each filename, an os.path.splitext() followed by a few string concatenations were sufficient.

For the overwrite flag, I found that the toggled() and isChecked() methods could be called upon a QCheckBox in addition to the QRadioButton, as both Qt widgets inherit from the same QAbstractButton class. So, I repurposed the existing code around the rename/copy flag, for reuse with the overwrite flag:

self.checkBoxOverwrite.toggled.connect(self.set_overwrite)
def set_overwrite(self):
    """
    Set the overwrite flag
    """
    self.overwrite = self.checkBoxOverwrite.isChecked()

QoL Improvements

I had time to add some additional behavior to the batch renamer utility to make it easier to use. I gave the tool the ability to:

  • Enable the “Run” button only if a valid starting filepath is set
  • Clear (most) QLineEdit contents after each batch rename operation
  • Support comma-delimited strings to search for and replace
  • Collapse consecutive whitepace in the strings to search for, into a single space

The consecutive whitespace issue was addressed with re.sub() and a regular expression for one or more whitespace characters, or \s+, as the pattern to match.

I also had time to grant the ability to log color-coded errors and warnings, as well as successful rename operations, in the dialog itself in addition to logging to standard out.

However, one issue did arise as I worked on the logger behavior. I noticed that clicking, and then removing focus from a QLineEdit widget (by clicking outside of the edit field) would trigger an extra warning. I decided to address this by detecting if the QLineEdit contents had changed between focus events, with help from the textChanged() signal.

I reworked the logger so that an “invalid filepath” warning would print under the condition that the current folder had changed. I connected the corresponding QLineEdit widget’s textChanged() signal to a new slot which I named update_filepath(). Upon activation, the update_filepath() function would update an internal boolean flag to keep track of changes:

self.lineEditFilepath.textChanged.connect(self.update_filepath)
def update_filepath(self):
    """
    Check if lineEdit text for the filepath has changed
    """
    if not self.filepath_changed:
        self.filepath_changed = True

Demo

Below is a screen recording of the batch renamer utility: