Hello everyone,
I’m using a Python script to automate my 3D scanning workflow with RealityCapture 1.5.1 CLI. In my workflow, I split the input images into two sets:
- Projector images (filenames ending with “-2”) used solely for geometry reconstruction.
- Texture images (filenames ending with “-1”) intended for final texturing.
My script does the following:
- It prepares the scan folder by moving the images into two subfolders (_geometry and _texture).
- In Phase 1, it builds a 3D model using the geometry images, aligns them, automatically sets the reconstruction region, calculates a normal model, unwraps the model, and saves the project.
- After Phase 1, the script deletes the _geometry folder to force RealityCapture to use only the texture images during texturing.
- In Phase 2, the script loads the saved project, optionally overrides the reconstruction region using a pre‑exported .rcbox file, then adds the texture images from the _texture folder, unwraps and calculates texture (using a texture configuration file that sets the texturing image layer to 4096), and finally exports the model.
Even though the process completes successfully (as shown in the log), the final 3D model uses the projector images (the geometry photoset) for texturing instead of the intended texture photos.
I have also attached my texture_config.xml
file and a screenshot from within RealityCapture showing that the correct texturing image layer (4096) is selected.
Below is the complete code I’m using:
import os
import sys
import shutil
import subprocess
import logging
import time
import cv2 # OpenCV for image analysis
##############################
CONFIGURATION
##############################
ROOT_DIR = r"C:\Scans\RawImages" # Root directory containing scan session 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
TEMP_DIR = r"C:\Scans\Temp" # Temporary folder (for intermediate files)
LOG_FILE = r"C:\Scans\processing.log" # Log file for processing times and errors
Pre-exported reconstruction region file (optional)
REGION_FILE = r"C:\Scans\region.rcbox"
Texture configuration file (exported from RC) to force the correct texturing layer.
TEXTURE_CONFIG_FILE = r"C:\Scans\texture_config.xml"
##############################
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)
##############################
Utility Functions
##############################
def run_chain_command(command, cwd=None):
“”“Runs a RealityCapture CLI command chain.”“”
logging.info(f"Running chain command: {’ ‘.join(command)}")
start_time = time.time()
result = subprocess.run(
command,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
elapsed = time.time() - start_time
if result.returncode != 0:
logging.error(f"Command error: {’ ‘.join(command)}\nError: {result.stderr}")
raise RuntimeError(f"Command failed: {’ '.join(command)}“)
logging.info(f"Chain command finished in {elapsed:.2f} seconds.”)
return result.stdout
def prepare_subfolders(scan_folder):
“”"
Ensures that _geometry and _texture subfolders exist in the scan folder.
Moves images ending with “-2” into _geometry and those ending with “-1” into _texture.
The suffix is removed from the filename.
“”"
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"):
new_name = name[:-2] + ext
dest_path = os.path.join(geometry_folder, new_name)
shutil.move(file_path, dest_path)
logging.info(f"Moved {file} to _geometry as {new_name}")
elif name.endswith("-1"):
new_name = name[:-2] + ext
dest_path = os.path.join(texture_folder, new_name)
shutil.move(file_path, dest_path)
logging.info(f"Moved {file} to _texture as {new_name}")
def detect_target_region(image_path):
“”"
Uses OpenCV to analyze an image and detect the largest contour.
Returns normalized coordinates (nxmin, nymin, nxmax, nymax) in [0,1].
If detection fails, returns (0, 0, 1, 1).
“”"
img = cv2.imread(image_path)
if img is None:
logging.warning(f"Failed to read image for analysis: {image_path}“)
return (0, 0, 1, 1)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5,5), 0)
_, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
logging.warning(“No contours found in image analysis.”)
return (0, 0, 1, 1)
largest = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest)
height, width = img.shape[:2]
nxmin = x / width
nymin = y / height
nxmax = (x + w) / width
nymax = (y + h) / height
logging.info(f"Detected target region in {image_path}: ({nxmin:.2f}, {nymin:.2f}, {nxmax:.2f}, {nymax:.2f})”)
return (nxmin, nymin, nxmax, nymax)
##############################
Main Scan Processing Function
##############################
def process_scan(scan_folder, rc_exe_path, output_folder, error_folder):
“”"
Processes one scan session using a two-phase workflow.
Phase 1:
- Prepare subfolders (_geometry and _texture) and move images accordingly.
- Immediately load texture settings via -set commands (ImageLayerForColoring and ImageLayerForTexturing set to 4096)
so that these settings are applied during alignment.
- Create a new scene using the geometry images, align, auto-set the reconstruction region,
calculate the normal model, unwrap, and save the project.
Then, delete the _geometry folder to force RC to use only the texture photoset for texturing.
Phase 2:
- Load the saved project.
- Optionally override the reconstruction region.
- Add texture images.
- Unwrap, calculate texture (using the texture configuration file if available), and export the final model.
"""
logging.info(f"Starting scan: {scan_folder}")
try:
# Phase 0: Prepare subfolders.
prepare_subfolders(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("Scan folder must contain both _geometry and _texture subfolders.")
if not os.listdir(geometry_src):
raise RuntimeError("No geometry images found in _geometry.")
if not os.path.isdir(texture_src) or not os.listdir(texture_src):
raise RuntimeError("No texture images found in _texture.")
scan_name = os.path.basename(os.path.normpath(scan_folder))
project_file = os.path.join(TEMP_DIR, f"{scan_name}_project.rcproj")
output_model = os.path.join(output_folder, f"{scan_name}.obj")
# Phase 1: Build project using geometry images.
# Load texture settings early via -set commands.
logging.info("Phase 1: Loading texture settings and building geometry model.")
cmd_phase1 = [
rc_exe_path,
"-headless",
"-newScene",
"-set", "ImageLayerForColoring=4096",
"-set", "ImageLayerForTexturing=4096",
"-addFolder", geometry_src,
"-set", "appIncSubdirs=true",
"-align",
"-selectMaximalComponent",
"-setReconstructionRegionAuto",
"-calculateNormalModel",
"-unwrap",
"-save", project_file,
"-quit"
]
run_chain_command(cmd_phase1)
if not os.path.isfile(project_file):
raise RuntimeError("Project file was not saved in Phase 1.")
# Optional: Log a sample geometry image analysis.
sample_images = [f for f in os.listdir(geometry_src) if os.path.isfile(os.path.join(geometry_src, f))]
if sample_images:
sample_image_path = os.path.join(geometry_src, sample_images[0])
norm_bbox = detect_target_region(sample_image_path)
logging.info(f"Sample geometry region: {norm_bbox}")
else:
logging.warning("No sample image found in _geometry.")
# Delete the _geometry folder to force use of texture images.
logging.info("Deleting _geometry folder to force use of texture photos for texturing...")
shutil.rmtree(geometry_src)
if os.path.isdir(geometry_src):
raise RuntimeError("Failed to delete _geometry folder.")
logging.info("Geometry photoset deleted successfully.")
# Phase 2: Load project and apply texturing.
logging.info("Phase 2: Loading project and applying textures...")
if not os.path.isfile(project_file):
raise RuntimeError("Project file missing in Phase 2.")
if os.path.isfile(REGION_FILE):
logging.info(f"Overriding reconstruction region with: {REGION_FILE}")
cmd_region = [
rc_exe_path,
"-headless",
"-load", project_file,
"-setReconstructionRegion", REGION_FILE,
"-quit"
]
run_chain_command(cmd_region)
else:
logging.info("No region file found; skipping region override.")
# Add texture images.
cmd_add_tex = [
rc_exe_path,
"-headless",
"-load", project_file,
"-addFolder", os.path.abspath(texture_src),
"-set", "appIncSubdirs=true",
"-quit"
]
run_chain_command(cmd_add_tex)
# Final unwrapping, texturing, and export.
if os.path.isfile(TEXTURE_CONFIG_FILE):
logging.info(f"Applying texture configuration from: {TEXTURE_CONFIG_FILE}")
cmd_texture = [
rc_exe_path,
"-headless",
"-load", project_file,
"-unwrap",
"-calculateTexture", TEXTURE_CONFIG_FILE,
"-exportSelectedModel", output_model,
"-quit"
]
else:
logging.info("No texture configuration file found; using default texturing.")
cmd_texture = [
rc_exe_path,
"-headless",
"-load", project_file,
"-unwrap",
"-calculateTexture",
"-exportSelectedModel", output_model,
"-quit"
]
run_chain_command(cmd_texture)
logging.info(f"Scan processed successfully: {scan_folder}. Output: {output_model}")
except Exception as e:
logging.error(f"Processing failed for scan {scan_folder}: {e}")
os.makedirs(error_folder, exist_ok=True)
dest_folder = os.path.join(error_folder, os.path.basename(os.path.normpath(scan_folder)))
try:
shutil.move(scan_folder, dest_folder)
logging.info(f"Moved failed scan to error folder: {dest_folder}")
except Exception as move_error:
logging.error(f"Error moving folder {scan_folder} to error folder: {move_error}")
##############################
Main Script Entry Point
##############################
def main():
setup_logging(LOG_FILE)
logging.info(“=== Starting Dynamic Region 3D Scanning 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)
os.makedirs(ERROR_DIR, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
for entry in os.scandir(ROOT_DIR):
if entry.is_dir():
scan_folder = entry.path
process_scan(scan_folder, RC_EXE, OUTPUT_DIR, ERROR_DIR)
logging.info("=== Dynamic Region 3D Scanning Automation Completed ===")
if name == “main”:
main()
Although the process completes without errors, the final exported 3D model uses the projector images (from the geometry photoset) for its texture instead of the intended texture photos from the _texture folder. I’ve verified that my texture configuration (which sets both ImageLayerForColoring and ImageLayerForTexturing to 4096) is loaded by the script, and I’ve attached the texture_config.xml
file along with a screenshot from RC showing the correct setting in the GUI.
Has anyone encountered this issue or have suggestions on how to force RealityCapture via CLI to use only the texture photoset for texturing? Any help or pointers would be greatly appreciated!
Thank you in advance for your assistance.