Overview

The Rendered.ai pattern and code framework for building python applications to generate synthetic data is referred to as Ana. Ana enables users to build Channels that describe the potential range of synthetic data that can be created for a specific user-defined problem. Channels contain Nodes that describe specific operations possible in the Channel and Nodes are arranged into python Packages. Channels reference Volumes that can be used to organize large amounts of user or 3rd party content, such as 3D models, 2D images, and other components required by the channel. Graphs are used to configure individual jobs that will result in synthetic datasets with specific properties.

Graphs

The steps necessary to generate synthetic data with Ana are described by a graph. The graph contains a set of functional capabilities represented as nodes. Nodes are connected by links to form a flow-based program that describes how synthetic data output, including images and other artifacts, will be generated. Graphs are persisted as text files in YAML format. A graph file can be created by hand in a text editor or it can be created visually in the Rendered.ai web interface. Graph files can be uploaded to and downloaded from the web interface.

A graph is made up of nodes, values and links. Nodes are discrete functions that take input, process it, and produce output. Nodes have input ports and output ports. Input ports are either directly assigned a fixed data value or they can get their data value from other nodes. The flow of data between nodes is indicated by connecting an output port of a source node to an input port of a destination node.

The following figure shows an example of a directed graph in visual form:

The nodes in a graph are executed by an application called a channel. The channel provides nodes for a specific synthetic data problem set.

The nodes in a channel are implemented as Python classes and are stored in Python packages. A package contains nodes and support libraries that have related functionality. A channel can use nodes from multiple packages.

The nodes in a package may require static support data. This data is stored as files in a virtual container called a volume. A package can make use of multiple volumes. For more see https://dadoes.atlassian.net/wiki/spaces/DG/pages/1615200272.

The following diagram shows the relationship between channels, packages and volumes.

Example use case

A computer vision engineer (CVE) wants to train a machine learning algorithm that will process low Earth orbit satellite imagery and automatically count the number of cars in a parking lot. They will train the algorithm on synthetic data which consists of thousands of overhead RGB images of parking lots. These images will contain a variety of automobile types, parking lot configurations, and distractor objects such as trees and street lights, all viewed from an overhead angle.

To support this in Ana, a channel called ‘cars_in_parking_lots’ is created. This channel will allow the CVE to create graphs that generate the overhead images they need. The nodes for the channel are drawn from a Python package called ‘satrgb’ that provides basic capabilities such as placing the objects of interest in the scene, setting the sun angle, rendering the image from a simulated RGB camera, etc. Some of these nodes require static data such as 3D models of cars. This support data is provided as blender files stored on a volume called ‘satrgb_data’.

The following diagram shows how this channel is set up.

Once the channel components are linked together, the user creates a graph that describes how images are to be generated. This graph file is then run through an interpreter script which executes the appropriate channel node code to generate the images and other output data.

Graph Files

Graph files can be built by hand in a text editor or they can be auto-generated from the Rendered.ai web interface.

Here is an example of a graph with a single node as shown in the graph editor:

The purpose of this node is to add an instance of a military tank to the synthetic data scene. The class of the node is “Tank” and it has an output port called “object_generator”. Note the node also has a name attribute but this is not displayed in the graphical user interface.

Here is the same graph persisted in YAML text file format.

version: 2
nodes:
  Tank_0:
    nodeClass: Tank
nodeLocations:
  Tank_0:
    x: -0.8408950169881209
    y: 28.892507553100586
YAML

The graph file contains three top level elements

  • “version” - This element defines the graph file language version number. In this example, the version is 2 which is the currently supported version.

  • “nodes” - This element is a dictionary that defines the nodes, links, and values that make up the graph. Each entry in the dictionary defines a node in the graph. The dictionary key is the the node name. The node class, any input values, and any input links are attributes of the node. In this example, there is a single node with the name “Tank_0” and it has a node class of “Tank”. This node has no input links or input values.

  • “nodeLocations” - This element is a dictionary that defines the screen coordinates of nodes as displayed in the graph editor. The dictionary key is the node name. Each entry has two attributes - the x and y coordinates. This element is optional but if you download a graph from the GUI then it is automatically generated using the current screen coordinates. In this example, the node named “Tank_0” has an x coordinate of -0.8408950169881209 and a y coordinate of 28.892507553100586.

Most graphs contain multiple nodes connected by links. Here is an example of a graph with two connected nodes:

The purpose of this graph is to add a tank to the scene and to also add snow to that tank. The snow will cover 50% of the tank.

Nodes can have input ports and output ports. In the graph editor, input ports are shown on the left side of the node and output ports are shown on the right side of the node. Connections between ports are shown as a line from the source node output port to the destination node input port. The line includes a caret symbol indicating the direction that data flows across the link.

In the example above, the Tank node has an output port called “object_generator” which is connected to an input port on the SnowModifier node which is also called “object_generator”. The SnowModifier node has a fixed value of “50” assigned to the input port “coverage”. The “object_generator” output port on the SnowModifier node is not connected to anything.

Here the same graph in YAML format:

version: 2
nodes:
  Tank_0:
    nodeClass: Tank
  SnowModifier_1:
    nodeClass: SnowModifier
    values:
      coverage: "50"
    links:
      object_generator:
        - sourceNode: Tank_0
          outputPort: object_generator
YAML


This is similar to the previous example but here the SnowModifier node also includes elements for input values and input links. These are defined as follows:

  • “values” - This element is a dictionary that specifies the fixed values that are assigned to input ports on the node, one entry per input port. The dictionary key is the input port name and the value is value assigned to that port. Fixed values can be any standard JSON scalar (integer, float, string, etc.), a list, or a dictionary. In the above example, the “coverage” input port has a fixed value of “50”.

  • “links” - This element is a dictionary that specifies the links coming into the node. Links are specified in the destination node definition with one entry per input port. The dictionary key is the input port name. Since an input port can have more than one incoming connection, link definitions are a list. The list entry for a link is a dictionary with two entries - the source node name and output port name that the link is originating from. In the above example, the SnowModifier_1 node has one incoming link connecting to its “object_generator” input port. The sourceNode for this link is node “Tank_0” and the outputPort on that node is “object_generator”.

Channel Structure

The source code, configuration files, and support files for a channel are stored in a directory that has the following structure:

The top directory is the root directory for the channel. The root directory is typically named after the channel.

Under the root are the channel definition file and these sub directories:

  • /.devcontainer

  • /packages

  • /data

  • /docs

  • /mappings

  • /graphs

These are described in more detail in the following sections.

Channel Definition File

The channel definition file is a YAML text file that specifies how the channel is to be configured. The name of the file is the name of the channel.

The channel definition file has several sections. Here is an example of a basic channel definition file:

channel:
  type: blender

add_setup:
  - testpkg1.lib.channel_setup

add_packages:
  - testpkg1
CODE

The “channel” section specifies channel attributes. Two channel attributes are supported - “type” and “base”.

The “type” attribute specifies the execution environment for the channel. Currently, two types of environments are supported - “blender” and “python”. If the channel type is set to “blender”, then the interpreter is executed as a script inside of the Blender application and nodes can call the Blender api. If the channel type is set to “python” then the interpreter is executed as a normal Python script.

The “base” attribute is used with channel inheritance. The base attribute specifies the channel file that will function as the base for this channel. See Additional channel configuration below for details.

The “add_setup” section specifies the function(s) that will be executed before the graph is interpreted. Typically these functions perform setup operations such as deleting the default cube object that is created when Blender is first run, enabling the Cycles rendering engine, enabling GPU rendering, and any other setup tasks required.

The “add_setup” section is specified as a list of Python modules. Before the graph is interpreted, each module is searched for a function named “setup” which is executed. These functions are executed in the order that they are listed.

In this example, the channel type is set to “blender” and there is no inheritance. The “testpkg1.lib.channel_setup” module will be searched for a “setup” function which will be executed before graph interpretation.

The “add_packages” section specifies the list of Python packages that will be searched for nodes to add to the channel. By default when a package is added to the channel, all nodes in that package are added. In the above example, the nodes in Python package “testpkg1” will be added.

Additional sections can be added to a channel definition for more complex channels.

add_nodes

The “add_nodes” section allows the inclusion of specific nodes from a package without including the full package. Here is an example:

add_nodes:
  - package: anatools
    name: SweepArange
    alias: Xyzzy
    category: Tools
    subcategory: Parameter Sweeps
    color: "#0C667A"
CODE

The “add_nodes” section is a list. Each entry specifies a node to be added.

The “package” attribute is required. It specifies the package the node will be drawn from.

The “name” attribute is required. It is the class name of the node to be included.

The “alias” attribute is optional. It specifies a new class name for the node when it is used in the channel.

The “category” attribute is optional. It overrides the GUI menu category for the node that was specified in the node’s schema definition.

The “subcategory” attribute is optional. It overrides the GUI menu subcategory for the node that was specified in the node’s schema definition.

The “color” attribute is optional. It overrides the GUI menu color for the node that was specified in the node’s schema definition.

In the above example, the “SweepArange” node is selected from the “anatools” package and it is given an alias of “Xyzzy”. Graphs in this channel can reference a node of this class by specifying a nodeClass of “Xyzzy”. The GUI menu category for the node is set to “Tools”. The GUI menu subcategory is set to “Parameter Sweeps”. The color of the node will be the hex value "#0C667A".

rename_nodes

The “rename_nodes” section allows a node class to be given a new name. Here is an example:

rename_nodes:
  - old_name: Render
    new_name: Grue
CODE

The “rename_nodes” section is a list. Each entry specifies a node class to be renamed. The “old_name” attribute specifies the old class name and the “new_name” attribute specifies the new class name. In this example, the “Render” node class is renamed to “Grue”. Graphs in this channel can reference a node of this class by specifying a nodeClass of “Grue”.

default_execution_flags

The “default_execution_flags” section is only relevant when a channel is run locally from the command line. They do not affect operation in the cloud service. The purpose of this section is to specify default flag values that will used when those flags are are not included on the “ana” command line. Here is an example:

default_execution_flags:
  "--graph": graphs/my_test_graph.yml
  "--interp_num": 10
CODE

The “default_execution_flags” section is a dictionary. The key for each entry is an execution flag and the value is the default value for that flag. In the above example, the default or “--graph” is set to “graphs/my_test_graph.yml” and the default for “--interp_num” is set to 10.

Additional channel configuration

Sometimes channel configurations can get complex. If you have defined a complex channel and would like to create a new channel that is similar but with only a few minor changes then channel inheritance might be useful.

A channel can inherit attributes from a previously defined channel by specifying the previously defined channel as its base. When this is done, all of the attributes (nodes, default flags, etc) defined in the base channel are also defined in the new channel. The new channel can then add additional attributes or remove attributes that are not needed. Note that the channel definition file for the base channel must also be stored in the channel root directory.

Here is an example of channel inheritance.

channel:
  base: myoriginal.yml

add_packages:
  - mynewpkg

remove_packages:
  - testpkg2

remove_setup:
  - testpkg2.lib.channel_setup

remove_nodes:
  - Render3
CODE

In this example, the base channel is defined in the file “myoriginal.yml”. For purposes of this example, assume the base channel has three packages - “testpkg1”, “testpkg2”, and “testpkg3”.

The “add_packages” section adds a new package called “mynewpkg” to the channel.

The “remove_packages” section removes “testpkg2” from the channel. None of the nodes defined in that package will be in the channel.

The “remove_setup” section removes the “testpkg2.lib.channel_setup” module from the list of setup modules.

The “remove_nodes” section removes the “Render3” node from the channel.

Packages

Packages organize and provide the nodes that can be included in a graph. All Ana packages are standard Python packages. They can be added to the channel from the Python Package Index (PyPI), included as a submodule from a git repository, or as source code stored directly in the channel directory.

Ana packages are added to the channel by including them in the “requirements.txt” file in the channel root directory. Here is an example of a requirements.txt file that adds two packages to the channel.

anatools
-e ./packages/testpkg1
CODE

The “anatools” package is required for all channels as it provides the base capabilities for Ana. In this example, the anatools package will be added from PyPI.

The second package in the example is “testpkg1”. The source code for this package is located in the “packages/testpkg1” subdirectory. This package can either be a git submodule or it can be source code. Note in this example we included the “-e” option which installs the package in editable mode (see PIP options for details). The -e option is useful during package development but is not necessary for a stable production channel where the package code does not change.

Package source code is stored in the “packages” subdirectory under the channel root. Each package is stored in a separate subdirectory that is named after the package. Each package includes a setup.py file so that it can be installed in the channel.

Here is an example of the directory structure for a package called “example”.

The setup.py file needs to include yml files in its package data. Here is the setup file for the example package:

import setuptools

setuptools.setup(
    name='example',
    author='',
    author_email='',
    packages=setuptools.find_packages(),
    package_data={"": ["*.yml"]}
    )
CODE

The example package directory itself contains two subdirectories - nodes and lib - and two files - __init__.py and package.yml.

The “nodes” directory contains the Python source code for all nodes in the package as well as schema files for each node. For every node module, there is a corresponding schema file. Schema files are in YAML format.

The “lib” directory contains Python modules that are used by nodes. This may include base classes, support functions, and other code that is called by a node.

The “__init__.py” is empty. It is required and indicates the directory contains a Python package.

The “package.yml” file is configuration information for the package. The content of this file is package specific, however there are two top level sections that most channels implement - “volumes” and “objects”. Here is a portion of the package.yml file for the example package:

volumes:
  example: 'df8ad806-223b-4d56-a932-838da835ec62'

objects:
  YoYo:
    filename: example:LowPoly.blend
CODE

The “objects” section is a dictionary that defines Blender objects used by the channel. The object type is the key and the value is package-specific configuration information for the object. Most channels implement the “filename” attribute which specifies the location of the blender file containing the object. This can be an absolute path, a relative path (relative to the --data directory) or it can be prepended by a volume name followed by a colon.

The “volumes” section is a dictionary that defines data volumes used by the package. The name of the volume is the key and the value is the volume ID.

Volumes

Volumes are used to store large asset files to keep the Docker images for channels small which means faster startup times for the channel. Volume ID’s are generated when the volume is created via anatools' create_managed_volume(organizationId) SDK call. See the anatools SDK documentation for more details on creating and managing volumes.

Volumes can be mounted during local development using the anamount command. After running the command, the volume will be mounted in the channel directory at “data/volumes/<volume-id>” if the user has access to the volume. For an example of how to mount and develop with a volume locally, review the Add a Generator Node tutorial where we create a new volume then add a Blender file to it.

Nodes 

A node is a discrete functional unit that is called by the interpreter at run time when the corresponding node in the graph file is processed. Nodes are written in Python and stored in Python modules. Node modules are collected together into Ana packages and stored in the appropriate Ana package directory, e.g. <channel-root>/packages/<package-name>/<package-name>/nodes/<node-module>.py

Nodes are Python classes that are derived from an abstract base class called “Node”. The Node base class is implemented in the anatools.lib.node module.

Here is a simple node definition:

from anatools.lib.node import Node

class MyNode(Node):
    def exec(self):
        return {}
PY

In this example, the derived node class is called “MyNode”. Note that the Node base class implements an abstract public member function called “exec” that is called by the interpreter when it is executed. The derived class overrides this functions in order to implement its execution logic. The exec function always returns a dictionary which is the output of the node. In this case the exec function performs no function and returns an empty dictionary.

A node in a graph can receive input and produce output. This is done via input and output ports. Nodes implement ports as dictionaries.

Here is an example of a node that inputs two values, adds them together, and outputs the sum:

from anatools.lib.node import Node

class Add(Node):
    def exec(self):
        value1 = float(self.inputs["Value1"][0])
        value2 = float(self.inputs["Value2"][0])
        sum = value1 + value2
        return {"Sum": sum}
CODE

Input ports are implemented via the “inputs” dictionary which is accessible as an instance variable of the node. The dictionary key is the name of the input port. In this example, we have two input ports named “Value1” and “Value2”.

Since input ports can have multiple incoming links, the incoming values are stored in a list. In this example, we assume both input ports have a single incoming value so we use the zero index for each input port.

Note that input variables should be explicitly converted to the expected data type (in this case float). This is because fixed input values entered into the GUI are stored and returned as text strings. Note that data type conversion errors may need to be accounted for.

Output port data is returned as a dictionary. Each entry in the dictionary corresponds to one of the node output ports. In the above example, the sum of the two input values is returned as output port “Sum”.

Nodes should perform error checking. If there is an unrecoverable error then a message should be generated and execution terminated. Here is an example of error checking in a node:

import sys
import logging

logger = logging.getLogger(__name__)

class OnlyInteger(Node):
    def exec(self):
        try:
            an_int = int(self.inputs["an_int"][0])
        except ValueError as e:
            logging.error("Error converting port 'an_int' to type int", exc_info=e)
            sys.exit(1)
CODE

Ana uses the standard Python logging facility for error messages. In this example, the input data type conversion is surrounded by a try/except. If a ValueError occurs then a message is logged and the application exits.

The default logging level is set to “ERROR”. When running Ana from the command line, this can be changed at runtime via the “--loglevel” switch.

When the interpreter is run interactively, error messages are printed to the console. Messages can also be optionally written to a log file via the “--logfile” command line switch. When the application is run in the cloud, ERROR and higher level messages are displayed in the web interface.

If an error occurs in a node and it is not caught then it will be caught by a last chance handler in the interpreter. In that case, an ERROR level message will be printed and execution will terminate.

Schema files 

For every node there is an associated schema that defines what inputs, outputs, and other attributes are implemented by the node. Schema are stored in schema files in the same directory as the node files. For every node module in the package, there is an associated schema file. Schema files are written in YAML and use the same base name as the corresponding node module, e.g. the schema file for “my_node.py” is “my_node.yml”.

Here is an example schema for the “Add” node defined in the previous section:

schemas:
  Add:
    inputs:
    - name: Value1
      description: The first value to be added
    - name: Value2
      description: The second value to be added
      default: 1
    outputs:
    - name: Sum
      description: The sum of Value1 and Value2 added together
    tooltip: Add two values and return the sum
    category: Functions
    subcategory: Math
    color: "#0C667A"
YAML

The schema file has a single top level element called “schemas” which is a dictionary containing one item for every node defined in the corresponding node module. In this example, the schema file defines a single node called “Add”.

Node input ports are specified as a list of dictionaries, with one list entry per input port. Each input port must specify a name and description. Optionally, a default value for the port can be specified. This value will be used if no value or link is assigned to that port in the graph.

In this example, there are two input ports - “Value1” and “Value2”. The Value2 input port is assigned a default value of 1.

Output ports are specified as a list of dictionaries, with one list entry per output port. Each output port must specify a name and description.

In this example there is one output port - “Sum”.

The “tooltip”, “category”, “subcategory”, and “color” attributes specify information used in the GUI based graph editor.

The “tooltip” attribute specifies a string to be displayed when the user hovers over the (info) symbol on the node.

The “category” and “subcategory” specify where the node will be located in the add-node menu on the left side of the graph editor. In this example, the node will be located under “Functions” → “Math”.

The “color” attribute specifies the color to be used when the node is displayed in the GUI.

Additional attributes can be assigned to input ports to help guide users when they are entering inputs in the GUI. Here is an example:

schemas:
  Location3d:
    inputs:
    - name: Terrain Type 
      description: The type of terrain to generate for this location
      select:
      - desert
      - forest
      - urban
      default: urban
    outputs:
    - name: Terrain
      description: The terrain for this location
    tooltip: Generate a 3d background to be used in the scene
    category: Locations
    subcategory: Procedural
    color: "#0C667A"
CODE

In this example, we define a node called “Location3d” that will procedurally generate 3d terrain. The type of terrain to be generated is specified via the “Terrain Type” input port. The “select” attribute provides a list of values for this port that will be displayed in the GUI as a pull-down menu. The user can scroll through this menu to pick a value. The default value displayed in the pull-down is “urban”.

The Context Module

Nodes often need information about the current execution context. This includes package configuration information, channel configuration information, runtime parameters, etc. This information is stored in the a module called “anatools.lib.context”.

Here is a node that uses context to print the current run number:

import anatools.lib.context as ctx
from anatools.lib.node import Node

class PrintVolumes(Node):
    def exec(self):
        print(f'Current run number {ctx.interp_num')
        return {}
CODE

The following attributes can be retrieved from the context module:

  • ctx.channel - a pointer to the Channel class instance for this channel

  • ctx.seed - the initial seed for random numbers generated by “ctx.random” functions

  • ctx.interp_num - the current interpretation number

  • ctx.preview - a boolean that specifies whether or not the current execution is a preview

  • ctx.output - the value passed in from the “--output” command line switch

  • ctx.data - the value passed in from the “--data” command line switch

  • ctx.random - an instance of the numpy “random” function seeded by ctx.seed

  • ctx.packages - a dictionary of package configurations used by the channel, one entry per package

Base classes and Helper Functions

Ana provides a number of base classes and helper functions to simplify node construction.

Base Class: Node

This is the abstract base class for all nodes. It implements input and output port processing and stores information used in node execution. Here’s a simple Node example:

from anatools.lib.node import Node

class MyNode(Node):
    def exec(self):
        return {}
CODE

For details on how to use this class, see the Node section above.

Base Class: AnaScene

The AnaScene base class simplifies the management of a scene in Blender. The AnaScene class encapsulates the Blender scene data block, allows the user to add objects to the scene, sets up a basic compositor for rendering, and provides methods for generating annotation and metadata files for objects in the scene. Here’s an example of a node that creates an AnaScene

import bpy
from anatools.lib.node import Node
from anatools.lib.scene import AnaScene

class CreateEmptyScene(Node):
    def exec(self):
      ana_scene = AnaScene(blender_scene=bpy.data.scenes["Scene"])
      return {"Scene": ana_scene}
CODE

Base Class: AnaObject

The AnaObject base class simplifies the management of 3D objects in Blender. AnaObject encapsulates the Blender object data block, provides a common mechanism for creating objects, and stores annotation and metadata information for the object.

The following example node creates an AnaObject, loads it from a Blender file, and adds it directly to an AnaScene:

import bpy
from anatools.lib.node import Node
from anatools.lib.ana_object import AnaObject

class AddTruck(Node):
    def exec(self):
        ana_scene = self.inputs["Scene"][0] # get the AnaScene as input
        truck = AnaObject(object_type="truck")
        truck.load(blender_file="path-to-file/truck.blend")
        ana_scene.add_object(my_truck)
        return {"Scene": ana_scene}
CODE

The load method requires the Blender file be configured as follows:

  • The object must be in a collection that has the same name as the object_type, e.g. “truck”

  • The object must have a single root object that has the same name as the object_type, e.g. “truck”. If your blender object is made up of separate components then you can create an “empty” object to be the root and make the separate components children of that empty.

To manipulate the Blender object encapsulated by AnaObject, you access the “root” attribute which is a pointer to the blender data block. For example, by default new AnaObjects are placed at location [0,0,0] in the Blender scene. To move the object to a new location, you modify the location attribute of the root object. Here’s an example of a node that moves an AnaObject to a new location.

from anatools.lib.node import Node

class MoveObject(Node):
    def exec(self):
        # Inputs are an AnaObject and the x,y,z coordinates where it will be moved
        obj = self.inputs["Object"][0]
        x = float(self.inputs["X"][0])
        y = float(self.inputs["Y"][0])
        z = float(self.inputs["Z"][0])
        obj.root.location = [x, y, z]
CODE

Any blender object data block attribute can be modified in this way.

By default, when the AnaObject load method loads the object from a Blender file. To change this behavior you subclass of AnaObject and override the load method. If you override the load method then your new method must do all of the following :

  • Create the Blender object. The object must have a single root object. Set “self.root” to equal the root object's Blender data block.

  • Create a collection to hold the object. Link the root object and all its children to that collection. Set “self.collection” to equal the collection’s data block.

  • Set “self.loaded = True”

Here is an example that creates an AnaObject from a Blender primitive.

import bpy
from anatools.lib.ana_object import AnaObject
from anatools.lib.node import Node

class SuzanneObject(AnaObject):
    def load(self, **kwargs):
        # create the Blender object
        bpy.ops.mesh.primitive_monkey_add(
            size=2, enter_editmode=False,
            align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
        )
        # set the root pointer
        self.root = bpy.context.object
        # create the collection and set its data block pointer
        self.collection = bpy.data.collections.new(self.object_type)
        # link the object to the collection
        self.collection.objects.link(self.root)
        # set the loaded flag
        self.loaded = True
        
class AddSuzanne(Node):
    def exec(self):
        ana_scene = self.inputs["Scene"][0] # get the AnaScene as input
        suz = SuzanneObject(object_type="Suzanne")
        suz.load()
        ana_scene.add_object(suz)
        return {"Scene": ana_scene}
CODE

To manipulate an AnaObject, you access the “root” attribute which points to the blender data block. For example, by default a AnaObject is placed at location [0,0,0] in the Blender scene. To move the object to a new location, you modify the location attribute of the root object. Here’s an example of a node that moves an AnaObject to a new location.

from anatools.lib.node import Node

class MoveObject(Node):
    def exec(self):
        # input the AnaObject
        obj = self.inputs["Object"][0]
        # input the x,y,z coordinates where it will be moved
        x = float(self.inputs["X"][0])
        y = float(self.inputs["Y"][0])
        z = float(self.inputs["Z"][0])
        obj.root.location = [x, y, z]
        return {"Object": obj}
CODE

Any blender object data block attribute can be modified in this way.

Base Classes: ObjectGenerator and ObjectModifier

The ObjectGenerator and ObjectModifier classes provide a scalable, probability-based mechanism for creating and modifying objects in a scene.

Typical use case: A user wants to create images that contain objects randomly selected from a pool of different object types. The user wants the probability of selecting any given object type to be exposed in the graph. The user also has a set of modifications they would like to apply to those objects. Which modifications can be applied to which objects and the probability of a given modification being applied to a given object type must also be exposed in the graph. This can be challenging to represent in a graph if the combination of object types and object modifications is large.

One solution is to build a sample space of object generator and object modifier code fragments. Each entry in the sample space is one of the allowed generator / object modifier combinations along with the probability that it will occur. At run time, one of these generator/modifier combinations is drawn from the sample space, the object is generated, and the modifiers are applied. This process is repeated until the desired number of objects have been added to the scene.

In Ana, the object generator / object modifier sample space is implemented as a tree structure. The tree has a root, intermediate nodes are modifiers and end nodes are generators. Each branch of the tree has a weight assigned to it that determines the probability that branch of the tree will be taken when a root to leaf path is constructed. To select a sample, a path is generated through the tree and the generator and modifier nodes along the selected path are executed in reverse order to create and then modify an object. This process is repeated until the desired number of objects have been created.

Here is an example of a simple generator/modifier tree:

The values on the branches are the relative weights. The bold lines indicate the path constructed for one sample. To create this path we start at the root and select one of the child branches. The branch on the left has a normalized weight of 1/4 (25% probability of being selected) and the branch on the right has a normalized weight of 3/4 (75% probability of being selected). We generate a random number, select the right branch and move to the dirt modifier. From there the left and right child branches each have a weight of 1/2 (50% probability of being selected). We generate a random number, select the left branch, and move to the truck generator. This is an end node so we have a full path which is “dirt modifier → truck generator”. We then execute these code units in reverse order, first generating the truck and then applying the dirt.

This tree can be constructed by executing a graph with Ana nodes that create and link ObjectGenerators and ObjectModifiers into the desired tree structure. Here is an example graph that does this:

This graph is executed from left to right.

  1. The Tank node creates an ObjectGenerator of type “Tank” and passes it to the RustModifier and the DentModifier.

  2. The Bradley node creates an ObjectGenerator of type “Bradley” and passes it to the RustModifier and DentModifier.

  3. The RustModifier node creates an ObjectModifier of type “Rust” and sets the Tank and Bradley generators as children. This subtree is passed to the Weight node.

  4. The DustModifier node creates an ObjectModifier of type “Dust” and sets the Tank and Bradley generators as children. This subtree is passed to the PlaceObjectsRandom node.

  5. The weight node changes the weight of the branch to the subtree that was passed in from the default of 1 to a value of 3. The Weight node then passes that subtree on to the PlaceObjectsRandom node.

  6. The PlaceObjectsRandom node creates a “Branch” ObjectModifier and sets the two subtrees passed to it as children. This completes the generator/modifier tree.

  7. The PlaceObjectsRandom loops 10 times, each time generating a path through the tree and then executing it in reverse order to create and modify an object.

Here is the code for a Node that creates an ObjectGenerator:

from anatools.lib.node import Node
from anatools.lib.generator import get_blendfile_generator

class TankGenerator(Node):
    def exec(self):
        generator = get_blendfile_generator("satrgb", AnaObject, "Tank")
        return {"object_generator": generator}
CODE

This node uses a helper function called “get_blendfile_generator” that creates an ObjectGenerator from the object definition specified in the “package.yml” file. The helper function takes three parameters

  • package - name of the package that defines the object

  • object_class - the Python class that will be used to instantiate the object

  • object_type - the object type as specified in the package.yml file

Object modifiers come in three parts - the node that will generate the ObjectModifier and the object modifier method that does the actual modification.

Here is the code for a Node that creates an ObjectModifier:

from anatools.lib.node import Node
from anatools.lib.generators import ObjectModifier

class ScaleModifier(Node):
    def exec(self):
        # takes one or more object generators as input
        children = self.inputs["object_generator"]
        scale = float(self.inputs["scale"][0])

        # add modifier to the generator tree
        generator = ObjectModifier(
            method="scale",
            children=children,
            scale=scale)
        return {"object_generator": generator}
CODE

In this example, the node takes one or more object generators as inputs as well as the scale factor to apply. It then creates an ObjectModifier and makes the incoming object generators its children. It then passes the new generator tree on to the next node.

The ObjectModifier class has two required parameters plus optional keyword parameters.

  • “method” - this is the name of the modifier method, specified as a text string

  • “children” - this is a list of all children of the ObjectModifier

  • keyword arguments - these arguments will be passed as keword argument parameters to the object modifier method

The object modifier method is a member of the object class that is to be modified. The simplest way to implement this is to include the method in the object class definition. Here is an example of a Jeep object that implements the “scale” modifier method.

from anatools.lib.ana_object import AnaObject

class Jeep(AnaObject):
    def scale(self, scale):
        self.root.scale[0] = scale
        self.root.scale[1] = scale
        self.root.scale[2] = scale
CODE

The problem with this approach is the modifier can only be applied to that specific object class. In most cases we want modifiers to apply to more than one class. The easiest way to do this is to use the mixin design pattern.

A mixin is an abstract base class that defines a method that will be used by other classes. Any class that wants to implement this method specifies the mixin class as one of its parents.

This example shows how to implement the mixin. Note in this example, the mixin class is implemented in a module called mypack.lib.mixins and the classes that inherit from it are in a separate module.

from abc import ABC

class ScaleMixin(ABC):
    def scale(self, scale):
        self.root.scale[0] = scale
        self.root.scale[1] = scale
        self.root.scale[2] = scale
CODE

Here is are several classes that use the mixin:

from anatools.lib.ana_object import AnaObject
from mypack.lib.scale_mixins import ScaleMixin

class Jeep(AnaObject, ScaleMixin):
    pass

class Truck(AnaObject, ScaleMixin):
    pass
CODE