Adjusted input for merge_textures

Updated merge_textures to also accept roughness, ao and metallic maps directly.
Metallic is optional. If no metallic map is supplied, blue channel in the NMO will be black.
This commit is contained in:
Killergnom
2025-09-29 18:57:57 +02:00
parent a60ae1c1ea
commit bd885f1b99
8 changed files with 199 additions and 130 deletions

View File

@@ -1,96 +1,121 @@
import os
import sys
from PIL import Image # type: ignore
from PIL import Image # type: ignore
import time
import concurrent.futures
from typing import Dict, List, Optional, Tuple
# Define suffix lists for BaseColor, Normal, RMA/ORM
# Define suffix lists for BaseColor, Normal, packed maps, and single-channel maps
BASECOLOR_SUFFIXES = ['_alb.', '_albedo.', '_bc.', '_basecolor.', '_b.']
NORMAL_SUFFIXES = ['_nrm.', '_normal.', '_n.']
RMA_SUFFIXES = ['_rma.']
ORM_SUFFIXES = ['_orm.']
ROUGHNESS_SUFFIXES = ['_roughness.', '_rough.', '_rgh.']
METALLIC_SUFFIXES = ['_metallic.', '_metalness.', '_metal.', '_met.']
AO_SUFFIXES = ['_ao.', '_ambientocclusion.', '_occlusion.']
EMISSIVE_SUFFIXES = ['_emissive.']
OPACITY_SUFFIXES = ['_opacity.']
MASK_SUFFIXES = ['_mask.','_m.']
MASK_SUFFIXES = ['_mask.', '_m.']
def detect_texture_type(filename):
""" Detects the type of texture based on its suffix """
if any(suffix in filename.lower() for suffix in BASECOLOR_SUFFIXES):
"""Detect the type of texture based on naming suffixes."""
lowered = filename.lower()
if any(suffix in lowered for suffix in BASECOLOR_SUFFIXES):
return 'BaseColor'
elif any(suffix in filename.lower() for suffix in NORMAL_SUFFIXES):
if any(suffix in lowered for suffix in NORMAL_SUFFIXES):
return 'Normal'
elif any(suffix in filename.lower() for suffix in RMA_SUFFIXES):
if any(suffix in lowered for suffix in RMA_SUFFIXES):
return 'RMA'
elif any(suffix in filename.lower() for suffix in ORM_SUFFIXES):
if any(suffix in lowered for suffix in ORM_SUFFIXES):
return 'ORM'
elif any(suffix in filename.lower() for suffix in EMISSIVE_SUFFIXES):
if any(suffix in lowered for suffix in ROUGHNESS_SUFFIXES):
return 'Roughness'
if any(suffix in lowered for suffix in METALLIC_SUFFIXES):
return 'Metallic'
if any(suffix in lowered for suffix in AO_SUFFIXES):
return 'AO'
if any(suffix in lowered for suffix in EMISSIVE_SUFFIXES):
return 'Emissive'
elif any(suffix in filename.lower() for suffix in OPACITY_SUFFIXES):
if any(suffix in lowered for suffix in OPACITY_SUFFIXES):
return 'Opacity'
elif any(suffix in filename.lower() for suffix in MASK_SUFFIXES):
if any(suffix in lowered for suffix in MASK_SUFFIXES):
return 'Mask'
return None
def get_material_name(filename):
""" Strips the 'T_' or 'TX_' prefix but keeps the suffix for texture type detection.
Returns the full material name without the suffix for output file naming. """
"""Strip the T_/TX_ prefix while keeping the suffix for detection and return the material name."""
base_name = os.path.basename(filename)
# Remove the 'T_' or 'TX_' prefix
if base_name.startswith('T_'):
base_name = base_name[2:]
elif base_name.startswith('TX_'):
base_name = base_name[3:]
# Return the base_name without the suffix for output naming
return base_name.rsplit('_', 1)[0] # Split only at the last underscore
return base_name.rsplit('_', 1)[0]
def convert_single_material(material_data: Tuple[str, Dict[str, str]], output_folder: str) -> Tuple[bool, str]:
"""Convert a single material to BCR/NMO format"""
"""Convert a single material to BCR/NMO format."""
material, files = material_data
basecolor_file = files.get('BaseColor')
normal_file = files.get('Normal')
rma_file = files.get('RMA')
orm_file = files.get('ORM')
roughness_file = files.get('Roughness')
metallic_file = files.get('Metallic')
ao_file = files.get('AO')
emissive_file = files.get('Emissive')
opacity_file = files.get('Opacity')
mask_file = files.get('Mask')
try:
if convert_to_bcr_nmo(material, basecolor_file, normal_file, rma_file, orm_file, emissive_file, opacity_file, mask_file, output_folder):
success, warnings = convert_to_bcr_nmo(
material,
basecolor_file,
normal_file,
rma_file,
orm_file,
roughness_file,
metallic_file,
ao_file,
emissive_file,
opacity_file,
mask_file,
output_folder,
)
if success:
if warnings:
return True, f"{material}: Successfully converted. Warning(s): {' '.join(warnings)}"
return True, f"{material}: Successfully converted."
else:
return False, f"Skipping {material}: input file sizes do not match."
return False, f"Skipping {material}: input file sizes do not match."
except Exception as e:
return False, f"Error processing {material}: {str(e)}"
def process_textures(input_files):
""" Main function to process all textures in a folder and convert to BCR/NMO """
textures = {}
# Group files by material name
"""Main function to process all textures in a folder and convert to BCR/NMO."""
textures: Dict[str, Dict[str, str]] = {}
for filepath in input_files:
filename = os.path.basename(filepath)
material_name = get_material_name(filename)
texture_type = detect_texture_type(filename)
if texture_type is None:
continue
if material_name not in textures:
textures[material_name] = {}
textures[material_name][texture_type] = filepath
# Create a merged folder in the same directory as the input
base_path = os.path.dirname(input_files[0])
output_folder = os.path.join(base_path, 'merged')
os.makedirs(output_folder, exist_ok=True)
material_count = len(textures)
print(f"Detected {material_count} Materials to process.")
# Check for required textures and filter out incomplete materials
valid_materials = {}
valid_materials: Dict[str, Dict[str, str]] = {}
failed_converts = 0
for material, files in textures.items():
missing_files = []
if not files.get('BaseColor'):
@@ -98,24 +123,24 @@ def process_textures(input_files):
if not files.get('Normal'):
missing_files.append('Normal')
if not (files.get('RMA') or files.get('ORM')):
missing_files.append('RMA or ORM')
if not files.get('Roughness'):
missing_files.append('Roughness')
if not files.get('AO'):
missing_files.append('AO')
if missing_files:
print(f"Skipping {material}: missing {', '.join(missing_files)}")
failed_converts += 1
else:
valid_materials[material] = files
# Process materials in parallel
success_count = 0
with concurrent.futures.ThreadPoolExecutor() as executor:
# Submit all materials for processing
future_to_material = {
executor.submit(convert_single_material, (material, files), output_folder): material
executor.submit(convert_single_material, (material, files), output_folder): material
for material, files in valid_materials.items()
}
# Process results as they complete
for future in concurrent.futures.as_completed(future_to_material):
material = future_to_material[future]
try:
@@ -128,62 +153,106 @@ def process_textures(input_files):
except Exception as e:
failed_converts += 1
print(f"Error processing {material}: {str(e)}")
print(f"+++{success_count} of {material_count} materials successfully converted+++")
time.sleep(3)
def convert_to_bcr_nmo(material, basecolor_file, normal_file, rma_file, orm_file, emissive_file, opacity_file, mask_file, output_folder):
""" Converts given textures to BCR and NMO formats """
def _ensure_single_channel(image_path: str):
"""Load an arbitrary image and return a single-channel version for merging."""
channel_image = Image.open(image_path)
if channel_image.mode != 'L':
channel_image = channel_image.convert('L')
return channel_image
def convert_to_bcr_nmo(
material: str,
basecolor_file: Optional[str],
normal_file: Optional[str],
rma_file: Optional[str],
orm_file: Optional[str],
roughness_file: Optional[str],
metallic_file: Optional[str],
ao_file: Optional[str],
emissive_file: Optional[str],
opacity_file: Optional[str],
mask_file: Optional[str],
output_folder: str,
) -> Tuple[bool, List[str]]:
"""Convert the provided textures to BCR and NMO targets."""
if basecolor_file is None or normal_file is None:
raise ValueError('BaseColor and Normal textures are required.')
warnings: List[str] = []
basecolor_img = Image.open(basecolor_file).convert('RGBA')
normal_img = Image.open(normal_file).convert('RGBA')
base_r, base_g, base_b, _ = basecolor_img.split()
normal_r, normal_g, _, _ = normal_img.split()
roughness_channel = metallic_channel = ao_channel = None
if rma_file:
rma_img = Image.open(rma_file).convert('RGBA')
if not (basecolor_img.size == normal_img.size == rma_img.size):
return False
# BCR conversion
bcr_img = Image.merge('RGBA', (basecolor_img.split()[0], basecolor_img.split()[1], basecolor_img.split()[2], rma_img.split()[0])) # Use Roughness (Alpha from RMA/ORM)
bcr_img.save(os.path.join(output_folder, f"{material}_BCR.tga"))
# NMO conversion
nmo_img = Image.merge('RGBA', (normal_img.split()[0], normal_img.split()[1], rma_img.split()[1], rma_img.split()[2])) # Use Metallic, AO from RMA/ORM
nmo_img.save(os.path.join(output_folder, f"{material}_NMO.tga"))
packed_img = Image.open(rma_file).convert('RGBA')
if not (basecolor_img.size == normal_img.size == packed_img.size):
return False, warnings
roughness_channel, metallic_channel, ao_channel, _ = packed_img.split()
elif orm_file:
rma_img = Image.open(orm_file).convert('RGBA')
if not (basecolor_img.size == normal_img.size == rma_img.size):
return False
# BCR conversion
bcr_img = Image.merge('RGBA', (basecolor_img.split()[0], basecolor_img.split()[1], basecolor_img.split()[2], rma_img.split()[1])) # Use Roughness (Alpha from RMA/ORM)
bcr_img.save(os.path.join(output_folder, f"{material}_BCR.tga"))
# NMO conversion
nmo_img = Image.merge('RGBA', (normal_img.split()[0], normal_img.split()[1], rma_img.split()[2], rma_img.split()[0])) # Use Metallic, AO from RMA/ORM
nmo_img.save(os.path.join(output_folder, f"{material}_NMO.tga"))
# Optionally handle emissive and opacity maps
packed_img = Image.open(orm_file).convert('RGBA')
if not (basecolor_img.size == normal_img.size == packed_img.size):
return False, warnings
ao_channel, roughness_channel, metallic_channel, _ = packed_img.split()
else:
if roughness_file is None or ao_file is None:
raise ValueError('Roughness and AO textures are required when RMA/ORM is absent.')
roughness_channel = _ensure_single_channel(roughness_file)
ao_channel = _ensure_single_channel(ao_file)
if metallic_file is not None:
metallic_channel = _ensure_single_channel(metallic_file)
else:
metallic_channel = Image.new('L', basecolor_img.size, 0)
warnings.append(f"{material}: Missing Metallic texture. Using a black channel.")
if not (
basecolor_img.size
== normal_img.size
== roughness_channel.size
== ao_channel.size
):
return False, warnings
if metallic_channel.size != basecolor_img.size:
return False, warnings
if roughness_channel is None or metallic_channel is None or ao_channel is None:
raise RuntimeError('Failed to resolve packed or single-channel textures.')
bcr_img = Image.merge('RGBA', (base_r, base_g, base_b, roughness_channel))
bcr_img.save(os.path.join(output_folder, f"{material}_BCR.tga"))
nmo_img = Image.merge('RGBA', (normal_r, normal_g, metallic_channel, ao_channel))
nmo_img.save(os.path.join(output_folder, f"{material}_NMO.tga"))
if emissive_file:
emissive_img = Image.open(emissive_file)
# Preserve original color mode instead of forcing RGB
if emissive_img.mode != 'RGBA':
emissive_img = emissive_img.convert('RGBA')
emissive_img.save(os.path.join(output_folder, f"{material}_EM.tga"))
if opacity_file:
opacity_img = Image.open(opacity_file)
# Preserve original color mode instead of forcing grayscale
if opacity_img.mode != 'RGBA':
opacity_img = opacity_img.convert('RGBA')
opacity_img.save(os.path.join(output_folder, f"{material}_OP.tga"))
if mask_file:
mask_img = Image.open(mask_file)
# Preserve original color mode instead of forcing grayscale
if mask_img.mode != 'RGBA':
mask_img = mask_img.convert('RGBA')
mask_img.save(os.path.join(output_folder, f"{material}_MASK.tga"))
return True
return True, warnings
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: drag and drop texture files onto the script")
else:
# Get the file paths from sys.argv (ignoring the first argument which is the script name)
input_files = sys.argv[1:]
process_textures(input_files)