When and Why 

Sometimes a model may need to be un-skinned and then re-skinned for some reason or another. Maya's handy export skin weights tool does the trick, but with one caveat: you need a good UV map. Maya uses the UV map to plot the weight data in image format using either luminance or alpha values. But what about UV maps that you've designed for some other purpose in mind? Say, you've layered two shoe map pieces upon each other to get a quick paint-job done on the diffuse map, suddenly you get unexpected results and your shoes are now controlled by both bones in your hierarchy. Or, maybe you've got a map that you think has great mesh distribution but Maya's paint method doesn't create accurate results. Therefore, you need an exact vertex by vertex breakdown of your mesh with values that pertain to these values.

This exact issue lead me to finding a decent way of just getting the "facts" from the model, and Maya's commands set provides a super-easy way of doing this exact thing, paired with Python's built in dictionary type objects and JSON's data model, together are perfect for producing easy-to-use files that hold a lot of data in regular string format. The vertex data is stored in the models skinClusters node, and we just need to access it's relevant data to be stored in a json file.

The only downside to this is, that the re-importing takes time and will make Maya temporarily unresponsive while it figures out all the weights. After a few seconds of wait time, you get an exact 1:1 representation of the skin weights. With a fairly low-density model, the size of the JSON file is roughly 1.5mb in size. Understandably this may not be the best solution for the storing of weight data for huge-projects, but so far it has been the most reliable way of knowing you get the facts.



JSON - JavaScript Object Notation

JSON is a handy language parser which can take raw data from many different languages and turn it into formatted strings. This means that objects (Python lists, dictionaries) can be written to a file without the need for a database-like structure.

#example of what JSON formatting does (Python)

>>> myList = ["apple", "pear", "banana"]
>>> type(myList)
# Result: <type 'list'> #
>>> jsonObj = json.dumps(myList)
>>> type(jsonObj)
# Result: <type 'str'> #
>>> listFromJson = json.load(jsonObj)
>>> type(listFromJson)
# Result: <type 'list'> #
  1. myList starts out as a list type object.
  2. json.dumps(myList): returns a string object within a JSON format.
  3. json.load(jsonObj): returns an object of list type.

In Python, the default method of reading and writing to files is a string-based method. Therefore JSON is a great way to encode and decode data into strings to be written to files.

These are two utility functions I've created to help with the parsing of JSON files. One will write the data to a file, and the other reads in data.

Writing to JSON

# utils.py [part 1/4]
import json
import maya.cmds as cmds

def writeJsonFile(dataToWrite, fileName):
    if ".json" not in fileName:
        fileName += ".json"

    print "> write to json file is seeing: {0}".format(fileName)

    with open(fileName, "w") as jsonFile:
        json.dump(dataToWrite, jsonFile, indent=2)

    print "Data was successfully written to {0}".format(fileName)

    return fileName
  1. Import the JSON module, because Python does not autoload this module.
     
  2. writeJsonFile takes two arguments:
    1. dataToWrite: the data which will be passed to the function
    2. fileName: the name of the resulting file
       
  3. In the writeToJson function, we check to see if the extension ".json" is included in the filename. If not, append it to the end.*
     
  4. The print statement helps when debugging the function, we can see the file name being passed.
     
  5. Using with open(fileName, "w") we temporarily open the file in "write" mode, meaning, if any data exists in that file, it will be overwritten.**
     
  6. json.dump() does the work of writing the data to the file object "jsonFile". It does the encoding and writing all in one, plus with handy formatting options like indent, so the file is more easily readable.

 

* This can be any file extension, I prefer .json for file-organization purposes
** JSON likes singular strings in it's file reads, so, over-writing the data in the file is ideal for simple implementation like this.

Reading from JSON

# utils.py - [part 2/4]
def readJsonFile(fileName):
    try:
      with open(fileName, 'r') as jsonFile:
          return json.load(jsonFile)
    except:
        cmds.error("Could not read {0}".format(fileName))
  1.  Function takes in the JSON filename
     
  2. Try opening the file in read mode. We use try because if there's a problem, it'll throw a system-based error, which our "except" exception will catch.
     
  3. json.load(jsonFile) returns whatever data type the data was stored in. It does the act of reading from the file, and parsing the data. 
     
  4. We return that data was found.

Retrieving and Working with the Data

Getting the Vertex Data, Shape Node and SkinCluster Nodes (Utility Function)

As mentioned in the introduction, vertex data is stored in the attributes of the shapeNode of your object. I've created a utility function to get three pieces of information: 

  1. The shape Node name (where the vertex attribute is stored) 
  2. The skinCluster node name (the node that has the skin weight relationship values stored) 
  3. The Vertex List. 

These three pieces are returned in a list format of [shapeName, clusterName, [vertexList]] Note that the vertex list is a list within a list. Why have a function return things in the form of a list? Primarily because these three pieces of information are related to each other, it's much easier to retrieve three values at once than continually asking for them in the future. 

#utils.py [part 3/4]
def geoInfo(vtx=0, geo=0, shape=0, skinC=0): # Returns a list of requested object strings
    returnValues = []
    
    selVTX = [x for x in cmds.ls(sl=1, fl=1) if ".vtx" in x]
    
    if len(selVTX) == 0:
        # geo can be of bool/int type or of string type.
        if type(geo) == int or type(geo) == bool:
            selGEO = cmds.ls(sl=1, objectsOnly=1)[0]
            
        elif type(geo) == str or type(geo) == unicode:
            selGEO = geo
            
        geoShape = cmds.listRelatives(selGEO, shapes=1)[0]
        
        # Deformed shapes occur when reference geometry has a deformer applied to it that is then cached
        # the additional section will take out the namespace of the shapefile (if it exists) and try to
        # apply the deform syntax on it.
        if ":" in geoShape: # the colon : deliminates namespace references
            deformShape = geoShape.partition(":")[2] + "Deformed"
            if len(cmds.ls(deformShape)) != 0:
                geoShape = deformShape
                print "deformed shape found: " + geoShape
    
    else:
        geoShape = selVTX[0].partition(".")[0] + "Shape"
        deformTest = geoShape.partition(":")[2] + "Deformed"
        if len(deformTest) != 0:
            geoShape = deformTest
            print "deformed shape found on selected vertices: " + geoShape
            
            # because the deformed shape is the object listed in the JSON file,
            # the word "Deformed" needs to be injected in the vertex name
            # and the namespace needs to be stripped out, because a deformed Shape is part of the local namespace
            for x in range( len(selVTX) ):
                selVTX[x] = ( selVTX[x].replace(".","ShapeDeformed.") ).partition(":")[2]
            
        selGEO = cmds.listRelatives(geoShape, p=1)[0]
        print geoShape + " | " + selGEO
    
    
    if vtx == 1:
        if len(selVTX) != 0: # if vertices are already selected, then we can take that list whole-sale.
            returnValues.append(selVTX)
        else:
            vtxIndexList = ["{0}.vtx[{1}]".format(geoShape, x) for x in cmds.getAttr ( geoShape + ".vrts", multiIndices=True)]
            returnValues.append(vtxIndexList)
    
    
    if geo == 1 or geo == True or type(geo) == str or type(geo) == unicode: 
        returnValues.append(selGEO)
    
    
    if shape == 1:
        returnValues.append(geoShape)
    
    
    if skinC == 1:
        skinClusterNM = [x for x in cmds.listHistory(geoShape) if cmds.nodeType(x) == "skinCluster" ][0]
        returnValues.append(skinClusterNM)
        
    return returnValues

For the vertex list, it gives a formatted version of the vertices, specifying in the usual Maya formatting geoShapeName.v[###] I have also included the possibility that the user had selected vertices and not just the geometry transform. In this case, the selected vertices are used in the formatted vertex list.

The Deformed Shape Node & Skin Cluster Node

When deformations are applied on referenced geometry, sometimes a deformed shape node will appear and be receiving the input of the skin cluster node (via an intermediate set node). Normally our shape node receives the skin cluster, so we must test for the geometry reference. I simply test to see if there is a name space delimiter (":") in the shape node's name. Then I attempt to populate a selection list from a possible name of the deformed node. Providing that selection list is not empty, I then assign the found deformed node to the geo shape variable. 

The skin cluster node, in all regular circumstances will be attached to the shape node. However, if there is a deformed shape node around, you will need to do a bit of digging to get the skin cluster node. As an aside, the skin cluster node is part to a "skin cluster set", which feeds into the deformed node. So instead of simply getting the connections into the deformed node, we have to get the history of all the nodes feeding into the deformed node, and list comprehend from that the nodes that are strictly of "skinCluster" type. Suffice to say, this isn't a clean-cut procedure, but it gets the job done.

Getting the SkinCluster Data

#utils.py - [part 4/4]
def getVertexWeights(vertexList=[], skinCluster="", thresholdValue=0.001):
    if len(vertexList) != 0 and skinCluster != "":
        verticeDict = {}
        
        for vtx in vertexList:
            influenceVals = cmds.skinPercent(skinCluster, vtx, 
                                             q=1, v=1, ib=thresholdValue)
            
            influenceNames = cmds.skinPercent(skinCluster, vtx, 
                                              transform=None, q=1, 
                                              ib=thresholdValue) 
                        
            verticeDict[vtx] = zip(influenceNames, influenceVals)
        
        return verticeDict
    else:
        cmds.error("No Vertices or SkinCluster passed.")

The final utility function is the vertex weights function. The arguments here are filled by the export process, which uses the geoInfo function above to grab all that information. 

Essentially we want to get a vertex by vertex influence dictionary which will hold vertex data like so:

NameOfObj.vtx[###] = [ (boneName1, influenceVal), (boneName2, influenceVal), ... ]

A dictionary is much like a list, but instead of using just ID numbers to access it's elements, it's using strings as "keys" to access the elements referenced by the "key".

Essentially, with each vertex passed, we're finding the boneName and influence Val (in two separate calls, to skinPercent) and returning a list per call. We can then take both those lists and zip them together, to get a list of pairs of boneName and influenceVal. Because the order of influences of the returned values is the same per each list, it's safe to assume that value stored at ID [##] in the bone name list is the same as the one stored in the value list.

After that, we assign the list of pairs back to a dictionary entry with the vertex name as key. We return that dictionary back to the requested function, in our case, the "export" function covered shortly. 

Aside: Why do this in a separate function, rather than just in the export script? Because this method of storing data may be useful to other scripts you may think up. Re-usability is key! 


Building a UI

The UI Portion of this function is extremely simple. We just want to specify where the JSON file will be stored, and then have an option to Export or Import data from the specified file. File browsing is handled by Maya's built in FileBrowser2* command.

*FileBrowser2 is like FileBrowser but better?

#jsonSkinTool.py [part 1/4]
import maya.cmds as cmds
import utils
reload(utils)

def jsonWeightsUI(*args):
    if cmds.window("JSONWeightUI", q=1, exists=1) == True:
        cmds.deleteUI("JSONWeightUI")
    
    cmds.window("JSONWeightUI", width=500, 
                height=200, mxb=False, mnb=True)
    cmds.columnLayout("JSONWeightMainLayout", 
                      width=500, height=200, 
                      parent="JSONWeightUI")
    cmds.text(label="Import/Export File", 
              width=500, height=50, align="center")
    cmds.textFieldButtonGrp("FileNameDisplay", columnWidth=[[1,0],[2,350]], 
                            width=500, height=25, label="File: ", 
                            buttonLabel="Open", buttonCommand=jsonGetFileName )
    cmds.rowLayout("JSONWeightBtnLayout", numberOfColumns=2, 
                   width=500, height=100, parent="JSONWeightUI")
    cmds.button("ExportJSONButton", width=250, 
                label="Export JSON Weights", 
                command=exportWeightsJSON, 
                parent="JSONWeightBtnLayout")
    cmds.button("ImportJSONButton", 
                width=250, label="Import JSON Weights", 
                command=importWeightsJSON, 
                parent="JSONWeightBtnLayout")
    cmds.showWindow("JSONWeightUI")

Because this script is primarily UI based, I'll touch on the features within the window rather than describe each Maya UI command.

  1. Determine if a window has already been created with the name "JSONWeightUI", if it has, delete it and re-create it. UI Elements need to be uniquely named in Maya's global environment.
     
  2. The textFieldButtonGrp is a type of input that pairs a button and textfield together in one handy function. The button press opens up the file dialog and the file dialog returns the found name.
     
  3. Two buttons whose commands will enact the export/import process.

UI FileBrowser Function

#jsonSkinTools.py [part 2/4]
def jsonGetFileName(*args):
    jsonFilter = "*.json"
    fileNameDir = cmds.fileDialog2(fileFilter=jsonFilter, dialogStyle=2)[0]
    if fileNameDir != "":
        cmds.textFieldButtonGrp("FileNameDisplay", e=1, text=fileNameDir)

This is simply the code that is run when the user clicks on the "Open" button in the textfield in the UI.

You'll notice something different here, and that is instead of returning the value from the fileDialog, we are sending it to the textFieldButtonGrp object's text field. Returning it in this function as a normal return function would not accomplish anything. 


Exporting the Data

So now we know how exactly we're going to retrieve the data we want (the vertex data, the GEO and all of that fun JSON stuff) let's get to exporting the data. This part should be fairly simple and straight-forward as we've got the functions to do all the trivial things. 

Here is the complete export portion of the script:

# jsonSkinTool.py [part 3/4]

def exportWeightsJSON(*args): # returns the filename that was written
    geoData = utils.geoInfo(vtx=1, geo=1, skinC=1)
    selVTX = geoData[0]
    selGEO = geoData[1]
    skinClusterNM = geoData[2]
    thV = 0.001

    vtxListFileName = cmds.textFieldButtonGrp("FileNameDisplay",
                                              q=1, text=1)

    # dictionary to hold all the vertice & relationships
    verticeDict = utils.getVertexWeights(vertexList=selVTX,
                                            skinCluster=skinClusterNM,
                                            thresholdValue=thV)

    if len(verticeDict) >= 1:
        utils.writeJsonFile(dataToWrite=verticeDict,
                                fileName=vtxListFileName)

        print "{0} vertices info was written to JSON file".format(len(verticeDict))
        return vtxListFileName

    else:
        cmds.error("No vertices selected to write to JSON")

Breakdown:

def exportWeightsJSON(*args):
    geoData = utils.geoInfo(vtx=1, geo=1, skinC=1)
    selVTX = geoData[0]
    selGEO = geoData[1]
    skinClusterNM = geoData[2]
    thV = 0.001
  1. As you can see, geoData Holds all the information we need in one place. 
  2. I have assigned it's members to the objects for general readability purposes.
  3. The object thV is a float type, or Threshold Value, which is a value which Maya uses to ignore skin weights that are less than the specified number. I found that with all my skinned objects, 0.001 is fine, and it limits the number of influences to the max specified upon skinning. I usually have 4 influences specified. 
vtxListFileName = cmds.textFieldButtonGrp("FileNameDisplay", q=1, text=1)

This pulls the name directly from the textFieldButtonGrp in the UI.

The Vertices Dictionary

Populate the Dictionary

So now we populate that dictionary with our SkinCluster function mentioned earlier, passing in the values we found with geoInfo.

verticeDict = utils.getVertexWeights(vertexList=selVTX, skinCluster=skinClusterNM, thresholdValue=thV)

This will make verticeDict a dictionary to be passed to a JSON function.

Finally, make sure there's something in verticeDict. We can check this just by checking the length of a dictionary. 

if len(verticeDict) >= 1:
        utils.writeJsonFile(dataToWrite=verticeDict, fileName=vtxListFileName)
        print "{0} vertices info was written to JSON file".format(len(verticeDict))
        return vtxListFileName
    else:
        cmds.error("No vertices selected to write to JSON")

As you can see, I use the writeJsonFile function to take our dictionary and insert it into a file, which has a file name that I've specified earlier. If all goes well, then we'll print out a bit of information of how many vertices were written and the vertex dictionary. This part is purely confirmation that something has been done. I've even gone ahead and made this function return the vtxListFileName string, just in case for future's sake!

This should all go according to plan!

Importing the Data

With the newly created json file, we can read it in and then start applying the weights. 

# jsonSkinTool.py [part 4/4]
def importWeightsJSON(*args):
    importFile = cmds.textFieldButtonGrp("FileNameDisplay", q=1, text=1)
    print "Accessing {0}".format(importFile)
    
    selectGeoData = utils.geoInfo(geo=1, skinC=1)
    geoName = selectGeoData[0]
    skinClusterNM = selectGeoData[1]
    
    vertData = utils.readJsonFile(importFile)   
    
    if len(vertData) > 0:
        
        for key in vertData.keys():
            try:
                cmds.skinPercent(skinClusterNM, key, tv=vertData[key], zri=1)
            except:
                cmds.error("Something went wrong with the skinning")
        print "{0} vertices were set to specificed values.".format(len(vertData.keys())) ##
        
    else:
        cmds.error("JSON File was empty ")

The importFile name we pull from the textFieldButtonGrp from the UI, just as we did with the export function. 

Like the functions before, we get the geoData in the selectGeoData, to find the selected's name and skinClusterNM. This is the reason why we write functions to enact more complicated algorithms, so we can easily read and write comprehensible code in a succinct meaningful way.

From the readJsonFile function, we return a dictionary type object from the json file, that holds all our precious skinCluster vertex data relationships. 

Finally, we apply all that data to the skinPercent, which also takes a ZeroRemainingInfluences argument, or zri short formed. If anything goes wrong, it will stop in it's tracks. 

The Final Import Caveats

I must re-iterate this is not a fast process. Depending upon the vert count and complexity of your weighting system, this will take longer than Maya's own weight import. The reason why I suggest this method is to provide an exact predictable outcome of skin weights, rather than leave it to the fates to see if Maya will export and import weights properly based off of images generated from UV maps.

It is possible to track the progress of the skinImport by using Maya's progressBar function. However, I will save this discussion for another topic.


Putting It Together and Running It

The way I have put together this script is so it can exist in two different python files. 

  1. utils.py file: Has the JSON Read, JSON Write functions, geoInfo and skinWeights functions contained in it
  2. jsonSkinTool.py file: Has the UI, File Dialogue, Import and Export functions contained in it

For the utils.py file, I use these imports to make sure the utilities file can see both maya.cmds and JSON modules:

import json
import maya.cmds as cmds # the "as cmds" allows you to specify a shorthand

In order to use these files, we have to make sure that each piece knows of the other piece. We do this through using "import" much like how we import JSON in the first script section. In the jsonSkinTool.py file we place this in the header:

import utils as utils
import maya.cmds as cmds # this has to be established in each new file
reload(utils)

You'll notice that I haven't included json in this file. That's because all of the json stuff is occuring in the utils file. We don't need to reference it again, unless json was being accessed directly somehow in the jsonSkinTool.py file. As well, there is a reload function called, this is because import is called only once, and if you were to update the utils file, you would end up getting old data from memory. Reload allows Maya's Python implementation to update it's reference to the file, therefore any new code is accepted. 

So now both files are set up, we can include them into Maya. In Maya's Script Editor we run:

import jsonSkinTool as jst
reload(jst)

Just like with the previous two files, we need to make sure Maya is aware of our script. 

Then, to finally run the UI portion of the script we use:

jst.jsonWeightsUI()

Which pops up the window for you to use, and then you're on your way!

Bonus: Check out that sweet JSON File

If all goes to plan, check out the exported weight file and see that it exists, and what is contained within it. If you're so inclined, you can even go ahead and make a copy to mess around with weight values to see what happens. That is one of the great things about this method is that it takes the time and effort out of opening up Maya's component spreadsheet editor.


Conclusion

Overall, I hope this tutorial was able to shed light on some of the great things one can do with Python in Maya. There are a great deal of resources out there that I have learned from, and wish to share my own insight on how I utilize some of the handy tools and functions. The JSON import/export module is incredibly useful for a number of purposes, for example whether it's saving out common file types for project purposes, vertices lists of models, large amounts of data that you wish to share between projects without importing/exporting directly in Maya.


References & Learning

If you wish to learn more about Python and it's methods, I encourage seeking out Python's official documentation, the endless sea of StackOverflow questions and answers, Chad Vernon, Marcus Giordano, and of course, your official Maya documentation