
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:
- Get a valid filepath to the current folder
- Get a valid filepath to a target folder
- Select files in the current folder
- 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 Filenames | Target |
---|---|
texture_metal_diffuse.png | T_metal_C.png |
grass_file_01.ma | M_grass.ma |
hello_world.txt | NOTE_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):

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:
- Connect the
QLineEdit
widget’seditingFinished()
signal to a new slot namedget_filepath_from_lineedit()
- 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: