Home / Python / Python for TAs

Python for Technical Artists

Why Python for Tech Art Specifically?

Every technical artist ends up solving the same category of problems: moving files around, transforming data between formats, automating repetitive tasks, and building tools for artists who don't want to touch a terminal. Python excels at all of these.

This tutorial focuses on the Python skills that TAs use every single day - not abstract computer science, but the practical patterns that power real production pipelines.

Prerequisites

This tutorial assumes you know Python basics (variables, functions, loops, file I/O). If you're new to Python, start with Intro to Python first.

Common TA Scripting Patterns

Before we dive into specific topics, here are the patterns you'll see in almost every TA script:

Python
"""Common patterns you'll use in every TA script."""
import os
import logging

# 1. Set up logging instead of print() - makes debugging in production easy
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

# 2. Use constants for magic values
TEXTURE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tga", ".tiff", ".exr"}
MAX_TEXTURE_SIZE_MB = 50
ASSET_ROOT = os.environ.get("ASSET_ROOT", "/projects/my_project/assets")

# 3. Guard your main entry point
def main():
    log.info("Tool started")
    log.info(f"Asset root: {ASSET_ROOT}")
    # Your tool logic here...
    log.info("Tool finished")

if __name__ == "__main__":
    main()
Use Logging, Not Print

In production, print() statements get lost. The logging module gives you timestamps, severity levels, and the ability to write to files. Get in the habit now - your pipeline TDs will thank you.

Working with File Paths and the os Module

File path manipulation is the #1 thing TAs do in Python. The os.path module and pathlib make this cross-platform and reliable.

os.path - The Classic Approach

Python
import os

# Building paths (cross-platform - handles / vs \ automatically)
project_root = "/projects/my_game"
asset_dir = os.path.join(project_root, "assets", "characters", "hero")
print(asset_dir)  # /projects/my_game/assets/characters/hero

# Splitting paths apart
filepath = "/assets/weapons/hero_sword_v003.fbx"

directory = os.path.dirname(filepath)    # /assets/weapons
filename = os.path.basename(filepath)    # hero_sword_v003.fbx
name, ext = os.path.splitext(filename)   # ('hero_sword_v003', '.fbx')

print(f"Directory: {directory}")
print(f"Filename:  {filename}")
print(f"Name:      {name}")
print(f"Extension: {ext}")

# Check if paths exist
if os.path.exists(asset_dir):
    print("Directory exists")
if os.path.isfile(filepath):
    print("File exists")
if os.path.isdir(asset_dir):
    print("Is a directory")

# Get file size
size = os.path.getsize(filepath)
print(f"Size: {size / 1024:.1f} KB")

pathlib - The Modern Approach

Python
from pathlib import Path

# Building paths with the / operator
project = Path("/projects/my_game")
asset_dir = project / "assets" / "characters" / "hero"

# Path properties (no function calls needed)
filepath = Path("/assets/weapons/hero_sword_v003.fbx")
print(filepath.parent)   # /assets/weapons
print(filepath.name)     # hero_sword_v003.fbx
print(filepath.stem)     # hero_sword_v003
print(filepath.suffix)   # .fbx

# Glob for finding files (recursive with **)
textures_dir = Path("/projects/my_game/textures")
all_pngs = list(textures_dir.glob("**/*.png"))
print(f"Found {len(all_pngs)} PNG files")

# Read/write files in one line
config_path = Path("config.txt")
config_path.write_text("render_quality=high\nresolution=4096\n")
content = config_path.read_text()

# Create directories (parents=True makes intermediate dirs)
output = Path("/export/characters/hero/textures")
output.mkdir(parents=True, exist_ok=True)
pathlib vs os.path

For new code, prefer pathlib - it's cleaner and more Pythonic. However, many DCC tool APIs still return strings, so you'll often need to convert: Path(maya_path_string). Know both, use pathlib when you can.

Batch Processing Files

One of the most common TA tasks: "Take these 500 files and do X to all of them." Here's a complete batch renaming tool.

Python
"""Batch rename tool - rename texture files to match a naming convention.

Converts files like:
    HeroSword_Albedo.png    ->  hero_sword_albedo.png
    HeroSword_Normal.PNG    ->  hero_sword_normal.png
    HeroSword Roughness.tga ->  hero_sword_roughness.tga
"""
import os
import re
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)

def sanitize_filename(name):
    """Convert a filename to a clean, pipeline-friendly format.

    Rules:
      - Lowercase everything
      - Replace spaces and camelCase boundaries with underscores
      - Collapse multiple underscores
      - Strip leading/trailing underscores
    """
    # Insert underscore before uppercase letters (CamelCase -> Camel_Case)
    name = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)

    # Replace spaces and hyphens with underscores
    name = re.sub(r"[\s\-]+", "_", name)

    # Lowercase
    name = name.lower()

    # Collapse multiple underscores
    name = re.sub(r"_+", "_", name)

    # Strip leading/trailing underscores
    name = name.strip("_")

    return name

def batch_rename(directory, dry_run=True):
    """Rename all files in a directory to follow the naming convention.

    Args:
        directory: Path to the directory to process.
        dry_run: If True, only preview changes without renaming.

    Returns:
        List of (old_name, new_name) tuples for files that were renamed.
    """
    renamed = []

    for filename in sorted(os.listdir(directory)):
        filepath = os.path.join(directory, filename)
        if not os.path.isfile(filepath):
            continue

        name, ext = os.path.splitext(filename)
        new_name = sanitize_filename(name) + ext.lower()

        if new_name == filename:
            continue  # already clean

        new_path = os.path.join(directory, new_name)

        if os.path.exists(new_path):
            log.warning(f"SKIP - target already exists: {new_name}")
            continue

        if dry_run:
            log.info(f"[DRY RUN] {filename} -> {new_name}")
        else:
            os.rename(filepath, new_path)
            log.info(f"Renamed: {filename} -> {new_name}")

        renamed.append((filename, new_name))

    return renamed

if __name__ == "__main__":
    target_dir = "./textures"

    # Preview first
    print("=== DRY RUN (preview only) ===")
    changes = batch_rename(target_dir, dry_run=True)
    print(f"\n{len(changes)} files would be renamed.")

    # Uncomment to actually rename:
    # print("\n=== RENAMING ===")
    # batch_rename(target_dir, dry_run=False)
Always Dry-Run First

Batch operations are destructive. Always implement a dry_run mode that previews changes before executing. This is a professional habit that will save you from painful mistakes.

Batch File Copying with Progress

Python
"""Copy and organize exported assets into a publish directory."""
import shutil
from pathlib import Path
import logging

log = logging.getLogger(__name__)

def publish_assets(source_dir, publish_dir, extensions=None):
    """Copy assets from source to a structured publish directory.

    Organizes files into subdirectories by extension:
        publish/
            fbx/
            png/
            jpg/
    """
    if extensions is None:
        extensions = {".fbx", ".png", ".jpg", ".tga", ".obj"}

    source = Path(source_dir)
    publish = Path(publish_dir)

    if not source.exists():
        log.error(f"Source directory not found: {source}")
        return

    files = [f for f in source.rglob("*") if f.is_file() and f.suffix.lower() in extensions]
    total = len(files)

    log.info(f"Publishing {total} files from {source} -> {publish}")

    for i, filepath in enumerate(files, start=1):
        ext_dir = publish / filepath.suffix.lower().lstrip(".")
        ext_dir.mkdir(parents=True, exist_ok=True)

        dest = ext_dir / filepath.name
        shutil.copy2(filepath, dest)  # copy2 preserves metadata

        progress = (i / total) * 100
        log.info(f"  [{progress:5.1f}%] {filepath.name} -> {ext_dir.name}/")

    log.info(f"Published {total} files successfully.")

Working with JSON and XML Data

JSON is the go-to format for configs, asset metadata, and tool settings. XML still appears in older pipelines and some DCC export formats.

JSON - Reading and Writing

Python
import json
from pathlib import Path

def load_asset_manifest(path):
    """Load an asset manifest from a JSON file."""
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

def save_asset_manifest(data, path):
    """Save an asset manifest to a JSON file (human-readable)."""
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    print(f"Saved manifest to {path}")

# Create a sample asset manifest
manifest = {
    "project": "MyGame",
    "version": "1.0.0",
    "assets": [
        {
            "name": "hero_sword",
            "type": "weapon",
            "lod_count": 3,
            "poly_counts": [15432, 7200, 2100],
            "textures": [
                "hero_sword_albedo.png",
                "hero_sword_normal.png",
                "hero_sword_roughness.png",
            ],
            "tags": ["weapon", "melee", "hero"],
        },
        {
            "name": "health_potion",
            "type": "consumable",
            "lod_count": 2,
            "poly_counts": [3200, 800],
            "textures": [
                "health_potion_albedo.png",
                "health_potion_emissive.png",
            ],
            "tags": ["consumable", "item", "glowing"],
        },
    ],
}

# Save it
save_asset_manifest(manifest, "asset_manifest.json")

# Load it back
loaded = load_asset_manifest("asset_manifest.json")

# Query the data
for asset in loaded["assets"]:
    total_polys = asset["poly_counts"][0]  # LOD0
    tex_count = len(asset["textures"])
    print(f"{asset['name']:<20s} | {total_polys:>6,} polys | {tex_count} textures")

XML - Parsing Pipeline Configs

Python
"""Parse and modify an XML pipeline config file."""
import xml.etree.ElementTree as ET

def parse_render_config(xml_path):
    """Parse a render settings XML file and return a dict of settings."""
    tree = ET.parse(xml_path)
    root = tree.getroot()

    settings = {}
    for setting in root.findall(".//setting"):
        name = setting.get("name")
        value = setting.text
        dtype = setting.get("type", "string")

        # Convert to the right Python type
        if dtype == "int":
            value = int(value)
        elif dtype == "float":
            value = float(value)
        elif dtype == "bool":
            value = value.lower() in ("true", "1", "yes")

        settings[name] = value

    return settings

def create_sample_config(output_path):
    """Create a sample render config XML file."""
    root = ET.Element("render_config")
    root.set("version", "2.0")

    settings_elem = ET.SubElement(root, "settings")

    configs = [
        ("resolution_x", "1920", "int"),
        ("resolution_y", "1080", "int"),
        ("samples", "256", "int"),
        ("use_denoiser", "true", "bool"),
        ("gamma", "2.2", "float"),
        ("output_format", "exr", "string"),
    ]

    for name, value, dtype in configs:
        setting = ET.SubElement(settings_elem, "setting")
        setting.set("name", name)
        setting.set("type", dtype)
        setting.text = value

    tree = ET.ElementTree(root)
    ET.indent(tree, space="  ")
    tree.write(output_path, encoding="utf-8", xml_declaration=True)
    print(f"Config written to {output_path}")

# Create and parse
create_sample_config("render_config.xml")
settings = parse_render_config("render_config.xml")

for key, value in settings.items():
    print(f"  {key}: {value} ({type(value).__name__})")
JSON vs XML

Prefer JSON for new configs and tools - it's simpler and maps directly to Python dicts and lists. Use XML only when you need to interface with legacy systems or tools that specifically require it (some game engines, older pipeline tools).

Building Simple CLI Tools with argparse

Once your script works, turn it into a proper command-line tool. The argparse module gives you argument parsing, help text, and validation for free.

Python
#!/usr/bin/env python3
"""texture_audit.py - Audit texture files in a project directory.

Usage:
    python texture_audit.py /path/to/textures
    python texture_audit.py /path/to/textures --max-size 25 --format png tga
    python texture_audit.py /path/to/textures --output report.json
"""
import argparse
import json
import os
import sys

def get_texture_info(directory, extensions):
    """Scan directory for texture files and collect metadata."""
    textures = []

    for root, dirs, files in os.walk(directory):
        for filename in files:
            _, ext = os.path.splitext(filename)
            if ext.lower() not in extensions:
                continue

            filepath = os.path.join(root, filename)
            size_mb = os.path.getsize(filepath) / (1024 * 1024)

            textures.append({
                "path": filepath,
                "name": filename,
                "size_mb": round(size_mb, 2),
                "extension": ext.lower(),
            })

    return textures

def run_audit(args):
    """Main audit logic."""
    extensions = {f".{fmt.lstrip('.')}" for fmt in args.format}
    textures = get_texture_info(args.directory, extensions)

    if not textures:
        print("No textures found.")
        return

    # Find oversized files
    oversized = [t for t in textures if t["size_mb"] > args.max_size]

    # Print summary
    total_size = sum(t["size_mb"] for t in textures)
    print(f"\nTexture Audit Report")
    print(f"{'=' * 50}")
    print(f"Directory:   {args.directory}")
    print(f"Files found: {len(textures)}")
    print(f"Total size:  {total_size:.1f} MB")
    print(f"Oversized:   {len(oversized)} (over {args.max_size} MB)")

    if oversized:
        print(f"\n  Oversized textures:")
        for t in sorted(oversized, key=lambda x: x["size_mb"], reverse=True):
            print(f"  {t['size_mb']:8.1f} MB  {t['name']}")

    # Optionally write JSON output
    if args.output:
        report = {
            "directory": args.directory,
            "total_files": len(textures),
            "total_size_mb": round(total_size, 2),
            "oversized_count": len(oversized),
            "max_size_mb": args.max_size,
            "files": textures,
        }
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2)
        print(f"\nFull report saved to {args.output}")

def main():
    parser = argparse.ArgumentParser(
        description="Audit texture files in a project directory.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "directory",
        help="Path to the directory to scan",
    )
    parser.add_argument(
        "--max-size",
        type=float,
        default=50.0,
        help="Maximum texture size in MB before flagging (default: 50)",
    )
    parser.add_argument(
        "--format",
        nargs="+",
        default=["png", "jpg", "jpeg", "tga", "tiff", "exr", "psd"],
        help="Texture formats to scan (default: png jpg jpeg tga tiff exr psd)",
    )
    parser.add_argument(
        "--output",
        help="Path to save a JSON report (optional)",
    )

    args = parser.parse_args()

    if not os.path.isdir(args.directory):
        print(f"Error: Directory not found: {args.directory}", file=sys.stderr)
        sys.exit(1)

    run_audit(args)

if __name__ == "__main__":
    main()
argparse Gives You --help for Free

Running python texture_audit.py --help automatically generates usage documentation from your argument definitions. This makes your tools self-documenting and professional.

Intro to PySide2/Qt for UIs

Eventually, you'll need to build graphical tools for artists. PySide2 (the Python binding for Qt) is the industry standard - it works standalone and inside Maya, Houdini, and other DCC apps.

Python
"""A simple asset renaming tool with a PySide2 GUI.

Install PySide2: pip install PySide2
Or PySide6 for newer Qt: pip install PySide6
"""
import sys

try:
    from PySide2 import QtWidgets, QtCore
except ImportError:
    from PySide6 import QtWidgets, QtCore

class AssetRenamerUI(QtWidgets.QWidget):
    """A simple GUI for batch-renaming assets."""

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Asset Renamer")
        self.setMinimumWidth(500)
        self.setup_ui()

    def setup_ui(self):
        layout = QtWidgets.QVBoxLayout(self)

        # Directory picker
        dir_layout = QtWidgets.QHBoxLayout()
        self.dir_input = QtWidgets.QLineEdit()
        self.dir_input.setPlaceholderText("Select a directory...")
        browse_btn = QtWidgets.QPushButton("Browse...")
        browse_btn.clicked.connect(self.browse_directory)
        dir_layout.addWidget(self.dir_input)
        dir_layout.addWidget(browse_btn)
        layout.addLayout(dir_layout)

        # Options
        options_group = QtWidgets.QGroupBox("Options")
        options_layout = QtWidgets.QVBoxLayout(options_group)

        self.lowercase_cb = QtWidgets.QCheckBox("Convert to lowercase")
        self.lowercase_cb.setChecked(True)

        self.replace_spaces_cb = QtWidgets.QCheckBox("Replace spaces with underscores")
        self.replace_spaces_cb.setChecked(True)

        prefix_layout = QtWidgets.QHBoxLayout()
        prefix_layout.addWidget(QtWidgets.QLabel("Add prefix:"))
        self.prefix_input = QtWidgets.QLineEdit()
        self.prefix_input.setPlaceholderText("e.g., hero_")
        prefix_layout.addWidget(self.prefix_input)

        options_layout.addWidget(self.lowercase_cb)
        options_layout.addWidget(self.replace_spaces_cb)
        options_layout.addLayout(prefix_layout)
        layout.addWidget(options_group)

        # Buttons
        btn_layout = QtWidgets.QHBoxLayout()
        preview_btn = QtWidgets.QPushButton("Preview")
        preview_btn.clicked.connect(self.preview_rename)
        rename_btn = QtWidgets.QPushButton("Rename")
        rename_btn.clicked.connect(self.execute_rename)
        btn_layout.addWidget(preview_btn)
        btn_layout.addWidget(rename_btn)
        layout.addLayout(btn_layout)

        # Results log
        self.log_output = QtWidgets.QTextEdit()
        self.log_output.setReadOnly(True)
        layout.addWidget(self.log_output)

    def browse_directory(self):
        directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Directory")
        if directory:
            self.dir_input.setText(directory)

    def preview_rename(self):
        self.log_output.clear()
        self.log_output.append("=== PREVIEW (no files changed) ===\n")
        self.log_output.append("Preview would show renamed files here...")

    def execute_rename(self):
        self.log_output.clear()
        self.log_output.append("=== RENAMING ===\n")
        self.log_output.append("Rename logic would execute here...")

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    window = AssetRenamerUI()
    window.show()
    sys.exit(app.exec_())
PySide2 Inside DCC Apps

When running inside Maya or Houdini, you don't create your own QApplication - the host app already has one. Instead, parent your widget to the main application window. We'll cover DCC-specific UI integration in the Maya and Houdini tutorials.

Next Steps

You now have the practical Python skills that TAs use daily. Here's where to go next:

  • Data Structures - Master the data structures that power asset registries, metadata systems, and complex pipeline logic.
  • Maya Scripting - Apply your Python skills inside Maya with PyMEL and the Python API.
  • Houdini - Explore procedural workflows with Python and VEX in Houdini.
Build Something Real

Pick one of the scripts from this tutorial and expand it into a complete tool. Add error handling, logging, a config file, and maybe a simple UI. A polished, working tool in your portfolio is worth more than knowing ten concepts superficially.