Issues with Automated RC CLI Workflow – Alignment Errors Despite Manual Success

Hello everyone,

I’m encountering persistent alignment errors when automating my RealityCapture workflow using the CLI, even though the same images and XMP data align perfectly when imported manually. I’m using RealityCapture 1.5.1 on Windows, and my manual process requires no extra adjustments in the 1Ds panel—the images and XMP data simply load and align as expected.

What I’m Trying to Achieve

I’ve developed a Python script that:

  1. Organizes images into _geometry and _texture folders.
  2. Renames the image files in these folders (using a naming convention based on the first three digits).
  3. Copies the XMP files into the _geometry folder (without renaming, since they work fine manually).
  4. Imports the images via the RC CLI with settings to force XMP data (disabling “Prefer EXIF over XMP” and ignoring EXIF GPS).
  5. Saves the project after the import step.
  6. Loads the saved project, re-selects all images, and performs alignment and model export.
  7. Introduces a 10‑second delay between each RC command to allow for metadata ingestion and processing.

The Problem

When running the automated process, the alignment step does not produce a valid aligned component. The exported model either contains errors or fails entirely, whereas the manual process yields a proper alignment and model. I’ve experimented with splitting the RC CLI commands into separate calls (with delays between each) and even saving/loading the project between steps, but the issue persists.

My Code

Below is the complete code I’m using. Any insights into what might be causing the discrepancy or suggestions for modifications to the CLI command sequence would be greatly appreciated!

python

Copy

import os
import sys
import shutil
import subprocess
import logging
import time
import zipfile
import re

##############################
# CONFIGURATION
##############################

ROOT_DIR = r"C:\Scans\RawImages"               # Folder containing unsorted photoset folders
RC_EXE = r"C:\Program Files\Epic Games\RealityCapture_1.5\AppProxy.exe"  # RealityCapture executable
OUTPUT_DIR = r"C:\Scans\Output"                # Where final models will be exported
ERROR_DIR = r"C:\Scans\Error"                  # Where failed sessions will be moved
PROCESSED_DIR = r"C:\Scans\Processed"          # Where successfully processed photosets will be moved
LOG_FILE = r"C:\Scans\processing.log"          # Log file for processing times and errors
REGION_FILE = r"C:\Scans\region.rcbox"         # Pre-exported reconstruction region file
XMP_SOURCE_DIR = r"C:\Scans\XMP"               # Folder containing .xmp files

COMMAND_DELAY = 10  # Delay in seconds between each RC command

##############################
# Logging Setup
##############################

def setup_logging(log_file):
    logging.basicConfig(
        filename=log_file,
        level=logging.INFO,
        format="%(asctime)s %(levelname)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )
    console = logging.StreamHandler()
    console.setLevel(logging.INFO)
    formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s")
    console.setFormatter(formatter)
    logging.getLogger("").addHandler(console)

##############################
# Helper Functions for Natural Sorting
##############################

def natural_keys(text):
    """
    Splits text into a list of integers and non-digit parts for natural sorting.
    For example, 'IMG_10.jpg' becomes ['img_', 10, '.jpg'].
    """
    return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]

##############################
# Utility Functions
##############################

def run_chain_command(command):
    """
    Runs a single RealityCapture CLI command.
    """
    logging.info(f"Running command: {' '.join(command)}")
    result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if result.returncode != 0:
        logging.error(f"Error executing command: {result.stderr}")
        raise RuntimeError(f"RealityCapture command failed: {result.stderr}")
    return result.stdout

def run_commands_with_delay(commands, delay=COMMAND_DELAY):
    """
    Runs a list of RC commands sequentially with a delay (in seconds) between each command.
    Each command is a list of arguments.
    """
    for cmd in commands:
        run_chain_command(cmd)
        logging.info(f"Waiting {delay} seconds after command: {' '.join(cmd)}")
        time.sleep(delay)

def prepare_subfolders(scan_folder):
    """
    Moves images into _geometry and _texture folders based on their suffixes.
    Files ending with "-2" go into _geometry and those ending with "-1" go into _texture.
    """
    geometry_folder = os.path.join(scan_folder, "_geometry")
    texture_folder = os.path.join(scan_folder, "_texture")
    os.makedirs(geometry_folder, exist_ok=True)
    os.makedirs(texture_folder, exist_ok=True)
    for file in os.listdir(scan_folder):
        file_path = os.path.join(scan_folder, file)
        if os.path.isfile(file_path):
            name, ext = os.path.splitext(file)
            if ext.lower() in ['.jpg', '.jpeg', '.png']:
                if name.endswith("-2"):
                    shutil.move(file_path, os.path.join(geometry_folder, name[:-2] + ext))
                elif name.endswith("-1"):
                    shutil.move(file_path, os.path.join(texture_folder, name[:-2] + ext))

def rename_photos(scan_folder):
    """
    Renames photos in _geometry and _texture folders according to the following mapping:
      - Extract the first three digits from the original filename.
      - If the number is between 101 and 185 inclusive, new index = original number - 100.
      - If the number is 191, 192, 193, or 194, new index = original number - 105.
    Then each file is renamed to "1 (new_index).ext".
    """
    valid_exts = ['.jpg', '.jpeg', '.png']
    
    def process_folder(folder):
        files = [f for f in os.listdir(folder) if os.path.splitext(f)[1].lower() in valid_exts]
        # Only include files that start with three digits
        files = [f for f in files if re.match(r'^\d{3}', f)]
        files = sorted(files, key=lambda f: int(f[:3]))
        for f in files:
            num = int(f[:3])
            if num >= 191:
                new_index = num - 105
            else:
                new_index = num - 100
            new_name = f"1 ({new_index})" + os.path.splitext(f)[1].lower()
            old_path = os.path.join(folder, f)
            new_path = os.path.join(folder, new_name)
            os.rename(old_path, new_path)
    
    geometry_folder = os.path.join(scan_folder, "_geometry")
    texture_folder = os.path.join(scan_folder, "_texture")
    process_folder(geometry_folder)
    process_folder(texture_folder)

def copy_xmp_data(scan_folder):
    """
    Copies .xmp files from XMP_SOURCE_DIR into the _geometry folder as-is.
    """
    if not os.path.isdir(XMP_SOURCE_DIR):
        logging.warning(f"XMP source directory not found: {XMP_SOURCE_DIR}. Skipping XMP copying.")
        return
    geometry_folder = os.path.join(scan_folder, "_geometry")
    for f in os.listdir(XMP_SOURCE_DIR):
        if f.lower().endswith('.xmp'):
            source_path = os.path.join(XMP_SOURCE_DIR, f)
            dest_path = os.path.join(geometry_folder, f)
            shutil.copy(source_path, dest_path)

def wait_for_rc_termination(timeout=30, interval=2):
    """
    Waits for any running RealityCapture instances to terminate.
    """
    rc_exe_name = os.path.basename(RC_EXE)
    elapsed = 0
    while elapsed < timeout:
        result = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {rc_exe_name}"],
                                stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        if rc_exe_name not in result.stdout:
            return
        time.sleep(interval)
        elapsed += interval
    kill_realitycapture_instances()

def kill_realitycapture_instances():
    """
    Forcefully kills any lingering RealityCapture processes.
    """
    try:
        rc_exe_name = os.path.basename(RC_EXE)
        subprocess.run(["taskkill", "/F", "/IM", rc_exe_name],
                       stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        logging.info("Killed any remaining RealityCapture instances.")
    except Exception as e:
        logging.error(f"Failed to kill RealityCapture instances: {e}")

def wait_for_output_file(filepath, timeout=300, interval=2):
    """
    Waits until the expected output file appears or until timeout expires.
    """
    elapsed = 0
    while elapsed < timeout:
        if os.path.exists(filepath):
            return True
        time.sleep(interval)
        elapsed += interval
    return False

def process_scan(scan_folder):
    """
    Processes a single scan session in two major steps:
      Step 1: Import images (with XMP settings), rename photos, copy XMP files, and save the project.
      Step 2: Load the saved project, re-select images, perform alignment, and export the model.
    Each RC command in the RC sections is executed separately with a 10‑second delay.
    """
    logging.info(f"Processing scan: {scan_folder}")
    try:
        # Organize images and prepare metadata
        prepare_subfolders(scan_folder)
        rename_photos(scan_folder)
        copy_xmp_data(scan_folder)
        
        geometry_src = os.path.join(scan_folder, "_geometry")
        texture_src = os.path.join(scan_folder, "_texture")
        if not os.path.isdir(geometry_src) or not os.path.isdir(texture_src):
            raise RuntimeError("Missing _geometry or _texture folder.")
            
        scan_name = os.path.basename(scan_folder)
        output_model = os.path.join(OUTPUT_DIR, f"{scan_name}.obj")
        # Define a temporary project file path
        project_file = os.path.join(scan_folder, "scan.rcproj")
        if os.path.exists(output_model):
            logging.info(f"Skipping {scan_name}: Already processed.")
            return

        wait_for_rc_termination()
        logging.info(f"Starting RealityCapture processing for {scan_name}...")

        # Step 1: Execute RC commands for project creation and saving.
        commands_import = [
            [RC_EXE, "-headless", "-newScene"],
            [RC_EXE, "-headless", "-set", "appPreferExifOverXmp=false"],
            [RC_EXE, "-headless", "-set", "appIgnoreExifGPS=true"],
            [RC_EXE, "-headless", "-addFolder", geometry_src, "-selectImage", ".*"],
            [RC_EXE, "-headless", "-addFolder", texture_src, "-selectImage", ".*"],
            [RC_EXE, "-headless", "-save", project_file]
            # Note: We omit "-quit" so that the project remains in the saved file.
        ]
        run_commands_with_delay(commands_import)

        # Step 2: Execute RC commands for loading the project and aligning.
        commands_align = [
            [RC_EXE, "-headless", "-load", project_file],
            [RC_EXE, "-headless", "-selectAllImages"],
            [RC_EXE, "-headless", "-align"],
            [RC_EXE, "-headless", "-selectMaximalComponent"],
            [RC_EXE, "-headless", "-setReconstructionRegion", REGION_FILE],
            [RC_EXE, "-headless", "-calculateHighModel"],
            [RC_EXE, "-headless", "-calculateTexture"],
            [RC_EXE, "-headless", "-calculateVertexColors"],
            [RC_EXE, "-headless", "-exportSelectedModel", output_model, "-quit"]
        ]
        run_commands_with_delay(commands_align)

        if not wait_for_output_file(output_model, timeout=300):
            raise RuntimeError(f"Output file {output_model} was not created.")
        logging.info(f"Successfully processed scan: {scan_folder}. Output: {output_model}")

        # Optional: Remove the temporary project file.
        if os.path.exists(project_file):
            os.remove(project_file)

        # Post-processing: Zip model files and move the processed scan.
        base_path = os.path.splitext(output_model)[0]
        mtl_file = base_path + ".mtl"
        texture_file = None
        if os.path.exists(base_path + ".png"):
            texture_file = base_path + ".png"
        elif os.path.exists(base_path + ".jpg"):
            texture_file = base_path + ".jpg"
        # Attempt to change permissions on the .mtl file before zipping.
        if os.path.exists(mtl_file):
            try:
                os.chmod(mtl_file, 0o644)
            except Exception as ex:
                logging.warning(f"Could not change permissions for {mtl_file}: {ex}")
        zip_filename = base_path + ".zip"
        with zipfile.ZipFile(zip_filename, 'w') as zipf:
            zipf.write(output_model, os.path.basename(output_model))
            if os.path.exists(mtl_file):
                zipf.write(mtl_file, os.path.basename(mtl_file))
            if texture_file and os.path.exists(texture_file):
                zipf.write(texture_file, os.path.basename(texture_file))
        os.makedirs(PROCESSED_DIR, exist_ok=True)
        dest = os.path.join(PROCESSED_DIR, os.path.basename(scan_folder))
        shutil.move(scan_folder, dest)
        logging.info(f"Moved processed scan {scan_name} to {dest}")
    except Exception as e:
        logging.error(f"Error processing scan {scan_folder}: {e}")
        os.makedirs(ERROR_DIR, exist_ok=True)
        shutil.move(scan_folder, os.path.join(ERROR_DIR, os.path.basename(scan_folder)))
        logging.info(f"Moved failed scan to error folder: {scan_folder}")

def main():
    setup_logging(LOG_FILE)
    logging.info("=== Starting Batch 3D Model Automation ===")
    if not os.path.isdir(ROOT_DIR):
        logging.error(f"Root directory does not exist: {ROOT_DIR}")
        sys.exit(1)
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    while True:
        scan_folders = [entry.path for entry in os.scandir(ROOT_DIR) if entry.is_dir()]
        if not scan_folders:
            logging.info("No scan sessions found. Waiting for new photosets...")
            time.sleep(60)
            continue
        for scan_folder in scan_folders:
            process_scan(scan_folder)
        logging.info("Completed processing current batch. Waiting for new photosets...")
        time.sleep(60)

if __name__ == "__main__":
    main()

Explanation

  • Separate RC Commands with Delays:
    I split the RC workflow into individual commands for the project creation/import phase and the alignment/export phase. Each command is executed separately with a 10‑second delay between commands via the run_commands_with_delay() function.
  • Project State Preservation:
    The project is saved after importing images (with the proper XMP settings) and then loaded before alignment. This is intended to preserve all scene data between CLI calls.
  • Standard XMP Handling:
    The XMP files are copied without any renaming, as they work perfectly when imported manually alongside the renamed images.

I would appreciate any advice or insights into why the automated process still produces alignment errors, or if there are any nuances with the RC CLI (especially with regard to state preservation or timing) that I might be overlooking. Thanks in advance for your help!

I look forward to your suggestions and insights!

Hello @User-920524d371
I checked this and it seems that the biggest issue there is usage of the setting:
“-set”, “appIgnoreExifGPS=true”
This setting will basically disable the usage of the XMP’s position in the alignment process and therefore the alignemnt is different as you want. Please, keep this setting on.
Also, I noticed that you are trying to use “-set”, “appPreferExifOverXmp=false”. I checked and there is no such CLI command, so this should provide nothing. And the intension of that setting is valid for original image’s XMP, not the XMP created by RealityCapture.