Credits to Poly Haven for tree, stone, and iron textures.

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 was unfamiliar with PyQt/Qt for Python.

Batch Renamer Utility

A few weeks into the coursework, 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 completed utility would help with consolidating hundreds (or thousands) of files from various owners/teams under some common naming convention, and provide the option to rename files in place or copy them to some destination folder.

The Dilemma

The utility was 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 for each filename
  • Add a prefix and/or suffix to each filename
  • Enable overwriting the selected files (if the files already exist in the target folder)

Given the following test files, the output filenames were to be free of noise such as the version number of the file, or words such as “file”, “final”, et cetera. At the same time, each filename would have a short prefix and/or suffix to indicate the game asset type:

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 as a free tool for 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 provided to convert the XML based .ui file from Qt Designer to an equivalent .py file, and map 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 batch_renamer_ui.py file would be included as a dependency in another file containing some starter code - below is an example of a button click event connected to an event handler. Or in Qt terms, a clicked() signal connected 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()

Implementation

As I was unfamiliar with PyQt beforehand, note that the following details will mostly cover discoveries made while working with PyQt.

UI Design

After some initial prototyping in Qt Designer, I eventually landed on the GUI shown below, which I styled with a Qt Style Sheet (credits to DevSec Studio):

Batch Renamer Utility GUI

Now that I had a GUI, I needed to add in some functionality - I 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, as well as 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 to open the file dialog, and the resulting value after editing the contents of the QLineEdit widget directly. I decided to:

  1. Connect the QLineEdit widget’s editingFinished() signal to a new slot named get_filepath_from_lineedit()
  2. React whenever self.filepath was set to some value, either through:
    • file dialog confirmation
    • the QLineEdit widget (direct edit)
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)

To fulfill the fourth and final requirement, I included two QRadioButtons set to exclusive mode (only one radio button can be enabled at a time). Depending on which radio button was enabled, a flag named copy_files (checked when the utility is run) would be set to true/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

For the optional steps per operation, I relied on os.path.splitext() to extract the extension from currently selected files and str.split() on a space-delimited string from the corresponding QLineEdit widget to filter on .png, .ma, and .txt file extensions.

For the string replacement operation, I called str.split() on a space-delimited string from the corresponding QLineEdit widget to get an array of strings to search for, and called str.replace() on each array element:

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 string concatenations were sufficient.

For the overwrite flag, I found that the QCheckBox inherited the toggled() and isChecked() methods from the QAbstractButton class. I reused the existing code for the rename/copy flag to handle the overwrite flag:

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

QoL Features

I wanted to make the batch renamer utility easier to use, and 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
  • Log color-coded errors (yellow for errors and warnings, green for successful rename operations)
  • Support comma-delimited strings to search for and replace
  • Collapse consecutive whitespace in QLineEdit widgets into a single space (re.sub() for pattern matching and \s+ as the pattern)

Bugfixes

I noticed that clicking, and then removing focus from a QLineEdit widget would trigger an extra warning. I addressed this by checking if the QLineEdit contents had changed between focus events, with help from the textChanged() signal.

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: