Houdini Python

From bernie's
Jump to navigation Jump to search

Getting the topmost input of a node

Let's say you want to get the first node in a tree (like a file sop) that's connected to a given SOP. This goes up inputs (the first one) until it won't find one anymore. Simple but useful.


import hou
myNode = hou.node('/obj/geo1/null1')
while myNode.input(0):
    myNode = myNode.input(0)

Delete children of a node that have their display flags turned off

import hou
obj = hou.selectedNodes()[0]
for curNode in obj.children():
    if(curNode.type().name() == 'geo' and curNode.isDisplayFlagSet() == False):

Move all keyframes to the beginning of the timeline

Story is I have some tracked camera+plate that are TC'ed at range at frame 24878933 for instance which is a pain in the ass, and no shotgun/grid tool to put that to an artist-friendly frame start like 101 or 1001

This will take a node selection (or a node as argument), figure out the range of all the keyframes on the node and subnodes (optionally), and offset the range of these keyframes to match the start of your current timeline.


import hou

def offsetParmKeyframes( parmTup, offset ):
    '''given a parm, move its keyframes in time by the offset. Deletes an recreates the keyframes'''

    keyframes = parmTup.keyframes()
    if keyframes:
        for kf in keyframes:
            kf.setFrame( kf.frame() + offset )
            parmTup.setKeyframe( kf )

def keyframesToPlaybar(node=None,recursive=False):
    '''given a node or a selection, go through keyframes and move all keys to the timeline'''
    #gather all parms and subitem parms
    nodelist = []
    if node is None:
        nodelist = hou.selectedNodes()
        if not nodelist:
            hou.ui.displayMessage('Select a node')
        if recursive:
            newnodelist = ()
            for node in nodelist:
                newnodelist = newnodelist + node.allSubChildren()
            nodelist = nodelist + newnodelist

    #keep only those that have >1 keyframes, find out the min and max framerange of all the keyframes found        
    parms = []
    frameRangeMinMax = [999999999,-999999999]
    for node in nodelist:    
        for parm in node.parms():
            #only keep parms that have more than one keyframe, otherwise it's an expression or single keyframe >= doesn't need to be moved
            if len(parm.keyframes())>1:
                frameRangeMinMax[0] = min([parm.keyframes()[0].frame(),frameRangeMinMax[0]])
                frameRangeMinMax[1] = max([parm.keyframes()[-1].frame(),frameRangeMinMax[1]])
    offset = hou.playbar.playbackRange()[0] - frameRangeMinMax[0]
    for parm in parms:
        offsetParmKeyframes( parm, offset )

    return offset


After Effects camera to Houdini camera

Pretty crappy/hacky code but works enough for me to post here. This assumes it's a baked, every-frame-is-keyframed camera, should work with linear keyframes too.


  • Put this code in a shelf
  • in AE select the position and rotation properties (that have keyframes. I need to generalize the code to add more properties)
  • Ctrl-c
  • In Houdini press your newly created shelf button. Should be good!
import hou, re

def clipboardToCamera():
    ''' takes a clipboard containing AE keyframe data and creates and returns camera '''
    #todo: generalize to other items, add aperture/zoom 

    #baked constants
    matchFps = False        # match scene fps from comp size
    matchRez = True         # match camera rez from comp size
    translateResize = -0.01 # scene scale change, this one worked for me
    AEclipboard = hou.ui.getTextFromClipboard()
    #check if we have AE data
    if 'Keyframe Data' not in AEclipboard:
        hou.ui.displayMessage('No keyframe data from AE found')
    #match FPS
    if matchFps:
            AEfps = float(AEclipboard.split('Units Per Second')[1].splitlines()[0])
            hou.ui.displayMessage('Changing FPS to '+string(AEfps))
            print('Units Per Second not found in keyframe data')

    #check for transform keyword, if so, create camera and add keys
    if 'Transform' in AEclipboard:
        camera = hou.node('/obj').createNode('cam')
        #I found I needed to change rotation order to match AE<>Houdini camera rotation
        #match Resolution
        if matchRez:
                w = float(AEclipboard.split('Source Width')[1].splitlines()[0])
                h = float(AEclipboard.split('Source Height')[1].splitlines()[0])
                print('Source Width and/or Source Height not found in keyframe data')            
        #aeNames = ['Camera Options Aperture','Transform Position','Transform Orientation']
        aeNames = ['Transform Position','Transform Orientation']
        houdiniParms = ['t','r']
        itemN = -1
        for index, item in enumerate( aeNames ):
            aeName = item.replace(' ', '\\t*\\s*')
            # print('split @ '+aeName)
            # the followin is nasty but should give us tuples [[frame#1,x,y,z][frame#2,x,y,z]]
            clipboardSplit = re.split(aeName, AEclipboard)
            clipboarSplitKeyframes = clipboardSplit[1].split('\n\n')[0].splitlines()
            kf = [x.split('\t')[1:-1] for x in clipboarSplitKeyframes[2:]]
            # make a keyframe for each line
            for line in kf:
                itemN = itemN + 1
                #if itemN < 5:
                #    print(line)
                for index2, subparm in enumerate(['x','y','z']):
                    curParm = camera.path()+'/'+houdiniParms[index]+subparm
                    if houdiniParms[index] == 't':
                        scale = translateResize
                        scale = 1
                    frameOffset = 0
                    #if itemN < 5:
                    #    print(curParm+' '+str(int(line[0])+frameOffset)+' '+str(float(line[index2+1])*scale))

def setKeyFrame(parm,time,value):
    hou_keyframe = hou.Keyframe()
    hou_keyframe.setExpression("constant()", hou.exprLanguage.Hscript)

Python node cook timer


Poor man's Performance Monitor. Probably better versions out there but it worked for my HDA. Uses Python3's timeit and hou.session 'global' var to to a time delta by calling two python scripts sandwiching your heavy Sops.

First script node

import timeit;hou.session.t0=timeit.default_timer()

(python sleep 3 seconds in my example: import time;time.sleep(3) )

And the timer stop. Should be cleaned up as there's probably node cooking/caching shenanigans going on.

import timeit
tdelta = t1-hou.session.t0
hou.pwd().setComment(str(f"{tdelta:.03f} secs."))

Get start and end file number from sequence

import re, hou, glob
f = hou.node('/obj/geo1/file2').parm('file')

def outputSequenceStartEnd(fileparm):
    '''given a file parm return the beginning and end of sequence'''
    fileparm = f.rawValue()
    matchObj = re.match( r'^(.*)\$F(\d*)(.*)', parmval, re.M|re.I)        #expects $F* in the filename
    before = matchObj.groups()[0]
    extension = matchObj.groups()[2]
    fileGlob =  before + '*' + extension
    files = glob.glob(fileGlob)
    files = sorted(files)
    startframe = files[0].split(before[-6:])[-1].split(extension)[0]    #dirty hack to get the file number because reusing the regex clashes with the glob return
    endframe = files[-1].split(before[-6:])[-1].split(extension)[0]
    return [startframe,endframe]

#returns ['200000', '204805']

Octane menu to choose current IPR rop

Because I'm tired of having to navigate if there is more than one Octane ROP in my file. As a bonus saves last used IPR as default


import hou

def chooseIPRdialog(buttons):
        c = hou.session.choice
        hou.session.choice = 0

    dialog = hou.ui.displayMessage('IPR choice', buttons=buttons,default_choice=hou.session.choice)
    hou.session.choice = dialog
    if dialog == len(buttons)-1:
        dialog = False
    return dialog
#grab iprs
iprs = []
for node in hou.node('/').allSubChildren():
    if node.type().name() == 'Octane_ROP':

#launch dialog, return ipr, launch ipr if not cancelled
names = [x.name() for x in iprs]
if len(names) == 1:
    chosenIPR = chooseIPRdialog(names)
    if chosenIPR is not False:

Log for loop progress to console


Kinda dirty but you can just use a print argument in a python sop it should log out and be visible in your renderfarm logs. Here I showed progress every 500 items.

#i have two spare parameters fetching current iteration and total number of iterations
#using detail(1,'iteration',0) expression or -1 if you use a spare
iterFloat = float(`chs("iter")`)
maxFloat = float(`chs("numiter")`)

    print(str(round(iterFloat/maxFloat*100,1))+ " %  "+str(int(iterFloat))+"/"+str(int(maxFloat)))

Auto-add frame offset parameter

I've written padzero(...) too many times in production so I decided to waste a few hours writing a way to add it to the right-click menu available for parms


For this to work I need two things:

An XML file called PARMmenu.xml (doc), in the python path or more generally the base folder of your preferences. I loosely based myself on code from qLib and other ressources. But the important thing to note is that there are two 'important' sections:

  • A 'context' tag that is a python code that parses the parameter you right click and returns True or False -- which will tell Houdini if you need to show the menu (it is generated on the fly). In my case it looks for '$F#' in the parameter value
  • A 'scriptCode' tag that is the actual code that is run

To make things cleaner I call code from my personal python library (see below) instead of putting the full code here.

/!\ Pain in the Butt Houdini19 update: since it's Python3+ I can't use 'reload(module)' anymore. Code below work for Python2.7

<?xml version="1.0" encoding="UTF-8"?>
		<subMenu id="bernie_rclick">
			<scriptItem id="bernie_set_frameoffset_parm">
				<label>Add a frame offset parm to $F#</label>
						import bernie_tools
						return bernie_tools.validate_sequence_parm(kwargs)
				<scriptCode><![CDATA[import bernie_tools;reload(bernie_tools);bernie_tools.add_sequence_offset_spareparm(kwargs)]]></scriptCode>

This is the actual code, that I place in a pythonpath (here it is in $HOUDINI_USER_PREF/python2.7libs/bernie_tools.py)

import hou
import traceback
import re

def validate_sequence_parm(kwargs):
	'''Checks if the first parm contains a value with $F'''
	returnvalue = False
		returnvalue = '$F' in kwargs['parms'][0].rawValue() 
		print("ERROR: %s" % traceback.format_exc())
	return returnvalue

def add_sequence_offset_spareparm(kwargs):
	'''Adds an int spare parm below a parm that has a file sequence expression of type '$F#' using hscript padzero'''
	parm = kwargs['parms'][0]
	parmval = parm.rawValue()
	#check if there is a $F(plus digit)
	matchObj = re.match( r'^(.*)\$F(\d*)(.*)', parmval, re.M|re.I)

	if matchObj:
		n = parm.node()
		parmGrp = n.parmTemplateGroup()
		existing_parm = parmGrp.find(parm.name())
		#create an offset parm with a similar name. No error checking!
		plabel = existing_parm.label() + " Offset"
		pname = parm.name()+"_offset"
		offsetParmTemplate = hou.IntParmTemplate( pname, plabel, 1, default_expression=(["$F + 0"]))
		parmGrp.insertAfter(existing_parm, offsetParmTemplate)
		#display the expression as it's most like we want to mmb through the offset

		#figure out if there is any padding in the original parm using the regex result, otherwise padding at 1 (no padding)
		padding = 1
		if matchObj.groups()[1]:
			padding = matchObj.groups()[1]
		expression = matchObj.groups()[0]+"`padzero("+str(padding)+",ch('" + pname + "'))`"+matchObj.groups()[2]

Set path parameter true for all alembic nodes

Don't know why it's not on by default, thanks maya ? Will probably add it to a more general right click menu.

import hou
sel = hou.selectedNodes()
for item in sel:
    for node in item.allSubChildren():
        if node.type().name() == 'alembic':

Houdini set random layercolorid for Octane

Piece of shit integration. Sorry Juanjo

from random import randint, uniform
from colorsys import hsv_to_rgb
s = 1.0
v = 1.0
for o in hou.selectedItems():
    h = uniform(0.0, 1.0)
    s = uniform(0.5, 1.0)
    v = uniform(0.5, 1.0)
    r, g, b = hsv_to_rgb(h, s, v)

Selecting other nodes of similar type

  • Will select all nodes that correspond to the type of the currently selected nodes, in the same hierachy.
  • Todo: allow to select items that are visible only in the region of the viewpane we are in. is it useful ?
import hou
selection = hou.selectedNodes()
nodetypes = []
for node in selection:
    nodetype = node.type()
    if nodetype not in nodetypes:

selectednodes = []
for node in hou.selectedNodes()[0].parent().allSubChildren():
    if node.type() in nodetypes:
for node in selectednodes:
hou.ui.setStatusMessage(str(len(selectednodes)) + " nodes selected")

get complementary color

MMhh . This seems overly complicated for something so simple in other languages. Will modify it if I find a better solution

r = parm('octane_gradient3_1cr').eval()
g = parm('octane_gradient3_1cg').eval()
b = parm('octane_gradient3_1cb').eval()
#create color object
col = hou.Color((r,g,b))
#create hsv object from rgb color
hsv = hou.Color.hsv(col)
#get complementary color hue (accross the color wheel)
h = (hsv[0]+180)%360
#create new color
c = hou.Color()
#set color from hsv values
#return red
return c.rgb()[0]

screenshot to background image

work in progress, interactively take a screenshot and puts it in the network view

  • saves image in a screenshot subfolder next to .hip file
  • is attached to a null that you can move/delete/template
  • uses Minicap as my capture software on Windows
  • is probably buggy
  • For future reference for myself, this is where houdini stores (some of?) its UI python functions C:\Program Files\Side Effects Software\Houdini ####\houdini\python2.7libs, maybe it's overloadable to edit the interface ? Also good nographutils hidden doc from Juraj Tomori :https://jtomori.github.io/houdini_additional_python_docs/nodegraphutils.html
  • Similar code has been embedded to Prism pipeline: https://prism-pipeline.com/



Code moved to https://berniebernie.fr/wiki/Houdini_UI_Customization#Screenshot_to_background_image

load all objs in list by frame

Generate obj list:

import os 
dir_path = os.path.dirname(os.path.realpath(__file__))
listfilename = 'objs_list.txt'

objlist = []
for root, dirs, files in os.walk(dir_path):
    for file in files:
        if file.lower().endswith('.obj'):
        	objlist.append(os.path.join(root, file))
print('Writing '+str(len(objlist))+'to '+dir_path+'/'+listfilename)

with open(dir_path+'/'+listfilename, 'w') as file_handler:
    for item in objlist:

In read file expression (with an added 'file' parm pointing to the above filelist.txt, called filelist)  :

import os, linecache
fl = hou.parm('filelist').eval()
path = linecache.getline(fl, int(hou.frame())).rstrip()
return path.replace(os.sep,'/')

ROP output driver to animated gif

To place in post render script. Linux for now, imagemagick is finicky. Matt Estela has a great page as usual https://www.tokeru.com/cgwiki/index.php?title=GeneralUtilties#Gifs rdSjyCR.gif

import distutils.spawn, subprocess
convert = distutils.spawn.find_executable('convert')

if convert:
    convertCmd = 'convert -loop 0 -delay 4 {}{} -layers OptimizePlus -colorspace sRGB +map {}'
    splitpath = hou.pwd().parm('picture').eval().split('.')
    gifPath = '.'.join(splitpath[:-2])+'.gif'
    wildcardPath = '.'.join(splitpath[:-2])+'.*.'+splitpath[-1]
    convertCmd = convertCmd.format(wildcardPath,'[600x600]',gifPath)
    convertCmd = convertCmd + '&& xdg-open '+gifPath
    print('imgmagick convert to gif not found')

Selected node as python code

(linux/windows may vary)

import hou
import os
import subprocess

sel = hou.selectedNodes()
strCode = sel[0].asCode(1,1)
fp = hou.getenv('tmp')+"/houdini_node_output.txt"
with open(fp, "w") as text_file:

#os.system('xdg-open \"%s\"' % fp) 
#Hey look at me, I'm a big idiot and I wrote the script from scratch because I had forgotten I had already coded it. How well, here's another version
import hou, tempfile, os

new_file, filename = tempfile.mkstemp(suffix='.txt')
text = ''
for node in hou.selectedNodes():
    text += node.asCode()
os.write(new_file, str.encode(text))



Paste clipboard nodes to object merges


# this snippet will paste nodes in clipboard to object merges
# use it with a shortcut (I overrode 'alt-v' in network pane context)

import hou

network = hou.ui.curDesktop().paneTabUnderCursor()
networkpath = network.pwd().path()
pos = network.cursorPosition()

clipboard = hou.ui.getTextFromClipboard()

n = 0

if clipboard:
    list = clipboard.split()
    for item in list:
        if hou.node(item) != None:
            merge = hou.node(networkpath).createNode('object_merge','merge_'+item.split('/')[-1])
            if n == 0:
            n = n + 1

Toggle off/on separate AOVs Arnold ROPs

Just saving it in case I need it

for child in hou.node('/out').children():
    #print child.type().name()
    if 'ppArnold' in child.type().name():
        #if child.name() == 'osacks_electric_brain_january_visual_cortex':
        for p in child.parms():
            if 'ar_aov_separate' in str(p) and 'file' not in str(p):
                print(child.name()+"."+str(p)+"   "+str(p.eval()))

Set expresions linking light visible parameter to display flag

idk why the candidate light didn't work on my machine so i used this

for child in hou.node('/obj').children():
    if 'light' in child.type().name():
        child.parm('light_enable').setExpression('hou.pwd().isDisplayFlagSet()', language=hou.exprLanguage.Python)

Skip if frame is in list


Works if there's a parm called skip on the same (switch) node

#true only on frames specified. Dirty but works
if str(int(hou.frame())) in hou.pwd().parm('skip').eval().split(' '):
    return 1
    return 0

and on a button if you want to easily add current frame to the list, make sure it's set to python

hou.pwd().parm('skip').set(hou.pwd().parm('skip').eval()+' '+str(int(hou.frame())))

Import neuron swc data


# reads SWC and creates geo from it
# http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html
#       0           ¦             1             ¦    2   3   4  ¦    5   ¦            6
# sample number     ¦    structure identifier   ¦    x   y   z  ¦ radius ¦  parent sample (connectivity)

import os
from itertools import *

node = hou.pwd()
geo = node.geometry()

# procedures

def isBadLine(line):
    return line[0]=='#'
#def createPoint(geo,data):

curDir = hou.node('/obj/swc_to_poly/directory_to_read').parm('readin').eval()
print("processing: "+curDir)

#read swc files in dir
files = []
for name in os.listdir(curDir):
    if name.endswith('.swc'):
        files.append(os.path.join(curDir, name))

files = files[-1:]
for file in files:
    print("  --- processing file: "+file)
    with open(file) as f:
        for line in dropwhile(isBadLine, f):                    # only read lines that don't have hashtag
            data = line.lstrip().rstrip().split(" ")
            data = map(float,data)
            point = geo.createPoint()                           # create one point per line and fill attributes
            point.setAttribValue('id', int(data[0]))
            point.setAttribValue('structid', int(data[1]))
            point.setAttribValue('radius', data[5])
            point.setAttribValue('parent_id', int(data[6]))

    #print file

Create nodes according to primitive groups


node = hou.pwd()

#custom undo stack 
with hou.undos.group("Poly Reduce Groups"):
    #uses parent, otherwise recursion happens with python sop node
    node = node.inputs()[0]
    geo = node.geometry()
    groups = geo.primGroups()
    count = 0
    mainReduce = ''
    merge = node.parent().createNode('merge')
    for o in groups:
        #keep a single prim group
        blast = node.parent().createNode('blast')
        #use polyreduce with keep points on with an expression
        reduce = node.parent().createNode('polyreduce')
        #create the 'step by' channel on this node
        parmTemplateGrp = reduce.parmTemplateGroup()
        stepByParm = hou.FloatParmTemplate('step', 'StepBy', 1,default_value=([10]))
        if count == 0:
            mainReduce = reduce
            params = ['percentage','optimizationbias','borderweight','attribweight','topologicalweight','step']
            for element in params:
        count = count + 1
    mainReduce.setSelected(True, clear_all_selected=True)

Load a folder of objs and merge them

import hou
import glob
obj = hou.node("/obj")
s = obj.createNode('geo', 'loader', run_init_scripts=False) 
m = s.createNode('merge')

path = '/users/me/Downloads/body/*.obj'
i = -1
for file in glob.glob(path):
    i += 1
    f = s.createNode('file')

Best fitting bbox from eigenvectors

thanks to petz @ https://forums.odforce.net/topic/14879-bounding-box/

# This code is called when instances of this SOP cook.
geo = hou.pwd().geometry()
prims = geo.prims()
points = geo.points()

# parms
output = 0
maxIter = 10
threshold = 0.001

centroid = sum([point.position() for point in points], hou.Vector3()) * (1.0 / len(points))

# build covariance matrix
val11 = 0;  val12 = 0;  val13 = 0
val21 = 0;  val22 = 0;  val23 = 0
val31 = 0;  val32 = 0;  val33 = 0

for point in points:
    pos = point.position()
    val11 += (pos[0] - centroid[0]) * (pos[0] - centroid[0])
    val12 += (pos[0] - centroid[0]) * (pos[1] - centroid[1])
    val13 += (pos[0] - centroid[0]) * (pos[2] - centroid[2])
    val21 += (pos[1] - centroid[1]) * (pos[0] - centroid[0])
    val22 += (pos[1] - centroid[1]) * (pos[1] - centroid[1])
    val23 += (pos[1] - centroid[1]) * (pos[2] - centroid[2])
    val31 += (pos[2] - centroid[2]) * (pos[0] - centroid[0])
    val32 += (pos[2] - centroid[2]) * (pos[1] - centroid[1])
    val33 += (pos[2] - centroid[2]) * (pos[2] - centroid[2])

mat = hou.Matrix3(((val11, val12, val13), (val21, val22, val23), (val31, val32, val33)))
mat = hou.Matrix4(mat.inverted())

# search for eigenvector with lowest eigenvalue
vec1 = hou.Vector3(1.0, 1.0, 1.0)
vecTemp = vec1 * mat
vec2 = vecTemp * (1.0 / vecTemp.length())
i = 0
while not vec1.isAlmostEqual(vec2) and i < 100:
    vec1 = vec2
    vecTemp = vec1 * mat
    vec2 = vecTemp * (1.0 / vecTemp.length())
    i += 1
minAxis = vec2.normalized()

# build matrix to transform geometry to initial position and orientation
up = hou.Vector3(0.0, 1.0, 0.0)
matUp = hou.hmath.buildTranslate(-centroid)
matUp *= minAxis.matrixToRotateTo(up)

# initialize attributes
initRot = 9
angleSum = 0
ratio = 1
i = 0

# adaptively rotate geometry until best orientation for smallest bounding box is found
while i < maxIter and ratio > threshold:
    angle = 0
    angleHold = 0
    angleRotBest = 0
    bboxSize = geo.boundingBox().sizevec()
    vol = bboxSize[0] * bboxSize[1] * bboxSize[2]
    volMin = vol
    volMinHold = vol

    for n in range(10):
        angle += initRot
        matRot = hou.hmath.buildRotateAboutAxis(up, initRot)
        bboxSize = geo.boundingBox().sizevec()
        vol = bboxSize[0] * bboxSize[1] * bboxSize[2]
        if vol < volMin:
            volMin = vol
            angleRotBest = angle
    # check ratio of vol and vol of previous bounding box
    ratio = abs(volMinHold - volMin)
    volMinHold = volMin
    if ratio == 0.0 and i == 0:
        ratio = 1.0
    angleRot = angle - angleRotBest + initRot
    angleSum += angleRotBest - initRot
    matRot = hou.hmath.buildRotateAboutAxis(up, -angleRot)
    initRot *= 0.2
    i += 1

# bounding box 
bbox = geo.boundingBox()
size = bbox.sizevec()
vol = size[0] * size[1] * size[2]

# build matrix to transform geometry back to oiginal position and orientation
mat = hou.hmath.buildRotateAboutAxis(up, -angleSum) * matUp.inverted()
matAttrib = geo.findGlobalAttrib("mat")
if not matAttrib:
    matAttrib = geo.addAttrib(hou.attribType.Global, "mat", (   0.0, 0.0, 0.0, 0.0,
                                                                0.0, 0.0, 0.0, 0.0,
                                                                0.0, 0.0, 0.0, 0.0,
                                                                0.0, 0.0, 0.0, 0.0))
geo.setGlobalAttribValue(matAttrib, mat.asTuple())

#create objects from voronoi
from math import cos, sin, pi

obj = hou.selectedNodes()[0]
node = obj.displayNode()
prims = node.geometry().prims()

lastprim = prims[ len(prims)-1 ]
lastprimName = lastprim.attribValue("name")
lastprimName = lastprimName.partition("piece")
lastprimName = int( lastprimName[ len(lastprimName)-1 ] )

print lastprimName

subnet = hou.node("obj").createNode("subnet")

for i in range(0,lastprimName+1):
    geo = subnet.createNode("geo")
    geo.setPosition([cos( (1.0*i/lastprimName+1 ) * 2.0 * pi )*4.0, sin( (1.0*i/lastprimName+1 ) * 2.0 * pi )*4.0 ]) # lol
    objectMerge = geo.createNode("object_merge")
    delete = objectMerge.createOutputNode("delete")

Get functions in an OTL

#in button callback

#in script window
def hello:

Preroll output to stdout (good for HQUEUE), put in a python node that gets fetched each step (source?). Also add f1 f2 parms to the python node with $RFSTART and $RFEND

import sys

start = hou.node(".").parm("f1").eval()
end = hou.node(".").parm("f2").eval()
current = hou.frame()

progress = str(int(hou.hmath.fit(current, start, end, 0.0, 100.0))).zfill(3)

print("\rFrame {0} done".format(current))
print("\rALF_PROGRESS {0}%".format(progress))