Skip to content

Commit

Permalink
Create material from textures (#1746)
Browse files Browse the repository at this point in the history
This changelist adds a Python script to construct a MaterialX document from a folder of textures, as proposed in #1556 for ASWF Dev Days 2024.
  • Loading branch information
Michaelredaa authored Apr 30, 2024
1 parent 897b754 commit ccf44f5
Show file tree
Hide file tree
Showing 2 changed files with 270 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ jobs:
run: |
python MaterialXTest/main.py
python MaterialXTest/genshader.py
python Scripts/creatematerial.py ../resources/Materials/Examples/StandardSurface/chess_set --texturePrefix chessboard --shadingModel standard_surface
python Scripts/creatematerial.py ../resources/Materials/Examples/GltfPbr/boombox --shadingModel gltf_pbr
python Scripts/mxformat.py ../resources/Materials/TestSuite/stdlib/upgrade --yes --upgrade
python Scripts/mxvalidate.py ../resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx --stdlib --verbose
python Scripts/mxdoc.py --docType md ../libraries/pbrlib/pbrlib_defs.mtlx
Expand Down
268 changes: 268 additions & 0 deletions python/Scripts/creatematerial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
#!/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>.'
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()

0 comments on commit ccf44f5

Please sign in to comment.