#!/usr/bin/env python ''' Construct a MaterialX file from the textures in the given folder, using the standard data libraries to build a mapping from texture filenames to shader inputs. By default the standard_surface shading model is assumed, with the --shadingModel option used to select any other shading model in the data libraries. ''' import os import re import argparse from difflib import SequenceMatcher import MaterialX as mx UDIM_TOKEN = '..' UDIM_REGEX = r'\.\d+\.' TEXTURE_EXTENSIONS = [ "exr", "png", "jpg", "jpeg", "tif", "hdr" ] INPUT_ALIASES = { "roughness": "specular_roughness" } class UdimFilePath(mx.FilePath): def __init__(self, pathString): super().__init__(pathString) self._isUdim = False self._udimFiles = [] self._udimRegex = re.compile(UDIM_REGEX) textureDir = self.getParentPath() textureName = self.getBaseName() textureExtension = self.getExtension() if not self._udimRegex.search(textureName): self._udimFiles = [self] return self._isUdim = True fullNamePattern = self._udimRegex.sub(self._udimRegex.pattern.replace('\\', '\\\\'), textureName) udimFiles = filter( lambda f: re.search(fullNamePattern, f.asString()), textureDir.getFilesInDirectory(textureExtension) ) self._udimFiles = [textureDir / f for f in udimFiles] def __str__(self): return self.asPattern() def asPattern(self): if not self._isUdim: return self.asString() textureDir = self.getParentPath() textureName = self.getBaseName() pattern = textureDir / mx.FilePath( self._udimRegex.sub(UDIM_TOKEN, textureName)) return pattern.asString() def isUdim(self): return self._isUdim def getUdimFiles(self): return self._udimFiles def getUdimNumbers(self): def _extractUdimNumber(_file): pattern = self._udimRegex.search(_file.getBaseName()) if pattern: return re.search(r"\d+", pattern.group()).group() return list(map(_extractUdimNumber, self._udimFiles)) def getNameWithoutExtension(self): if self._isUdim: name = self._udimRegex.split(self.getBaseName())[0] else: name = self.getBaseName().rsplit('.', 1)[0] return re.sub(r'[^\w\s]+', '_', name) def listTextures(textureDir, texturePrefix=None): ''' Return a list of texture filenames matching known extensions. ''' texturePrefix = texturePrefix or "" allTextures = [] for ext in TEXTURE_EXTENSIONS: textures = [textureDir / f for f in textureDir.getFilesInDirectory(ext) if f.asString().lower().startswith(texturePrefix.lower())] while textures: textureFile = UdimFilePath(textures[0].asString()) allTextures.append(textureFile) for udimFile in textureFile.getUdimFiles(): textures.remove(udimFile) return allTextures def findBestMatch(textureName, shadingModel): ''' Given a texture name and shading model, return the shader input that is the closest match. ''' parts = textureName.rsplit("_") baseTexName = parts[-1] if baseTexName.lower() == 'color': baseTexName = ''.join(parts[-2:]) if baseTexName in INPUT_ALIASES: baseTexName = INPUT_ALIASES.get(baseTexName.lower()) shaderInputs = shadingModel.getActiveInputs() ratios = [] for shaderInput in shaderInputs: inputName = shaderInput.getName() inputName = re.sub(r'[^a-zA-Z0-9\s]', '', inputName).lower() baseTexName = re.sub(r'[^a-zA-Z0-9\s]', '', baseTexName).lower() sequenceScore = SequenceMatcher(None, inputName, baseTexName).ratio() ratios.append(sequenceScore * 100) highscore = max(ratios) if highscore < 50: return None idx = ratios.index(highscore) return shaderInputs[idx] def buildDocument(textureFiles, mtlxFile, shadingModel, colorspace, useTiledImage): ''' Build a MaterialX document from the given textures and shading model. ''' # Find the default library nodedef, if any, for the requested shading model. stdlib = mx.createDocument() mx.loadLibraries(mx.getDefaultDataLibraryFolders(), mx.getDefaultDataSearchPath(), stdlib) matchingNodeDefs = stdlib.getMatchingNodeDefs(shadingModel) if not matchingNodeDefs: print('Shading model', shadingModel, 'not found in the MaterialX data libraries') return None shadingModelNodeDef = matchingNodeDefs[0] for nodeDef in matchingNodeDefs: if nodeDef.getAttribute('isdefaultversion') == 'true': shadingModelNodeDef = nodeDef # Create content document. doc = mx.createDocument() materialName = mx.createValidName(mtlxFile.getBaseName().rsplit('.', 1)[0]) nodeGraph = doc.addNodeGraph('NG_' + materialName) shaderNode = doc.addNode(shadingModel, 'SR_' + materialName, 'surfaceshader') doc.addMaterialNode('M_' + materialName, shaderNode) # Iterate over texture files. imageNodeCategory = 'tiledimage' if useTiledImage else 'image' udimNumbers = set() for textureFile in textureFiles: textureName = textureFile.getNameWithoutExtension() shaderInput = findBestMatch(textureName, shadingModelNodeDef) if not shaderInput: print('Skipping', textureFile.getBaseName(), 'which does not match any', shadingModel, 'input') continue inputName = shaderInput.getName() inputType = shaderInput.getType() # Skip inputs that have already been created, e.g. in multi-UDIM materials. if shaderNode.getInput(inputName) or nodeGraph.getChild(textureName): continue mtlInput = shaderNode.addInput(inputName) textureName = nodeGraph.createValidChildName(textureName) imageNode = nodeGraph.addNode(imageNodeCategory, textureName, inputType) # Set color space. if shaderInput.isColorType(): imageNode.setColorSpace(colorspace) # Set file path. filePathString = os.path.relpath(textureFile.asPattern(), mtlxFile.getParentPath().asString()) imageNode.setInputValue('file', filePathString, 'filename') # Apply special cases for normal maps. inputNode = imageNode connNode = imageNode inBetweenNodes = [] if inputName.endswith('normal') and shadingModel == 'standard_surface': inBetweenNodes = ["normalmap"] for inNodeName in inBetweenNodes: connNode = nodeGraph.addNode(inNodeName, textureName + '_' + inNodeName, inputType) connNode.setConnectedNode('in', inputNode) inputNode = connNode # Create output. outputNode = nodeGraph.addOutput(textureName + '_output', inputType) outputNode.setConnectedNode(connNode) mtlInput.setConnectedOutput(outputNode) mtlInput.setType(inputType) if textureFile.isUdim(): udimNumbers.update(set(textureFile.getUdimNumbers())) # Create udim set if udimNumbers: geomInfoName = doc.createValidChildName('GI_' + materialName) geomInfo = doc.addGeomInfo(geomInfoName) geomInfo.setGeomPropValue('udimset', list(udimNumbers), "stringarray") # Return the new document return doc def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--outputFilename', dest='outputFilename', type=str, help='Filename of the output MaterialX document.') parser.add_argument('--shadingModel', dest='shadingModel', type=str, default="standard_surface", help='The shading model used in analyzing input textures.') parser.add_argument('--colorSpace', dest='colorSpace', type=str, help='The colorspace in which input textures should be interpreted, defaulting to srgb_texture.') parser.add_argument('--texturePrefix', dest='texturePrefix', type=str, help='Filter input textures by the given prefix.') parser.add_argument('--tiledImage', dest='tiledImage', action="store_true", help='Request tiledimage nodes instead of image nodes.') parser.add_argument(dest='inputDirectory', nargs='?', help='Input folder that will be scanned for textures, defaulting to the current working directory.') options = parser.parse_args() texturePath = mx.FilePath.getCurrentPath() if options.inputDirectory: texturePath = mx.FilePath(options.inputDirectory) if not texturePath.isDirectory(): print('Input folder not found:', texturePath) return mtlxFile = texturePath / mx.FilePath('material.mtlx') if options.outputFilename: mtlxFile = mx.FilePath(options.outputFilename) textureFiles = listTextures(texturePath, texturePrefix=options.texturePrefix) if not textureFiles: print('No matching textures found in input folder.') return # Get shading model and color space. shadingModel = 'standard_surface' colorspace = 'srgb_texture' if options.shadingModel: shadingModel = options.shadingModel if options.colorSpace: colorspace = options.colorSpace print('Analyzing textures in the', texturePath.asString(), 'folder for the', shadingModel, 'shading model.') # Create the MaterialX document. doc = buildDocument(textureFiles, mtlxFile, shadingModel, colorspace, options.tiledImage) if not doc: return if options.outputFilename: # Write the document to disk. if not mtlxFile.getParentPath().exists(): mtlxFile.getParentPath().createDirectory() mx.writeToXmlFile(doc, mtlxFile.asString()) print('Wrote MaterialX document to disk:', mtlxFile.asString()) else: # Print the document to the standard output. print('Generated MaterialX document:') print(mx.writeToXmlString(doc)) if __name__ == '__main__': main()