Projects an Axis translate through an animated Camera into 2D XY over the camera range, with optional Transform export.
Reconcile3D Fast takes a background (or root format), a Camera, and an Axis, then projects the Axis translate into screen space. Hit Calculate to write reconciled 2D points into the XY output knob across the camera’s animation range.
Use it when you need 2D tracks or offsets from known 3D points without running a full Reconcile3D pass. Camera and Axis can sit behind dots; the script resolves the top node. Non-animated cameras are rejected; if the range cannot be read from translate or rotate curves, the root frame range is used.
Clear resets the XY output. Transform builds a Transform node and copies the animated XY keys onto translate so you can drive 2D work from the reconciled path.
set cut_paste_input [stack 0]
version 13.0 v1
push 0
push 0
push $cut_paste_input
Group {
inputs 3
name reconcile3DFast
tile_color 0x421010ff
selected true
xpos 12819
ypos -4378
addUserKnob {20 PlanarProjection l reconcile3DFast}
addUserKnob {22 Calculate t "Calculate the reconciled 2D points for each 3D point." T "import nuke\nfrom nukescripts import snap3d as sn\nimport math\n\n\n## Based on Planar Projection by Vit Sedlacek and Jed Smith\n\n\ndef cameraProjectionMatrix(cameraNode, frame, imageformat):\n ## modified code from nukescripts/Snap3D\n\n # Matrix to transform points into camera-relative coords.\n wm = nuke.math.Matrix4()\n for i in range(16):\n wm\[i] = cameraNode\[\"matrix\"].getValueAt(frame, i)\n\n wm.transpose()\n camTransform = wm.inverse()\n\n # Matrix to take the camera projection knobs into account\n roll = float(cameraNode\[\"winroll\"].getValueAt(frame, 0))\n scale_x = float(cameraNode\[\"win_scale\"].getValueAt(frame, 0))\n scale_y = float(cameraNode\[\"win_scale\"].getValueAt(frame, 1))\n translate_x = float(cameraNode\[\"win_translate\"].getValueAt(frame, 0))\n translate_y = float(cameraNode\[\"win_translate\"].getValueAt(frame, 1))\n m = nuke.math.Matrix4()\n m.makeIdentity()\n m.rotateZ(math.radians(roll))\n m.scale(1.0 / scale_x, 1.0 / scale_y, 1.0)\n m.translate(-translate_x, -translate_y, 0.0)\n\n # Projection matrix based on the focal length, aperture and clipping planes of the camera\n focal_length = float(cameraNode\[\"focal\"].getValueAt(frame))\n h_aperture = float(cameraNode\[\"haperture\"].getValueAt(frame))\n near = float(cameraNode\[\"near\"].getValueAt(frame))\n far = float(cameraNode\[\"far\"].getValueAt(frame))\n projection_mode = int(cameraNode\[\"projection_mode\"].getValueAt(frame))\n p = nuke.math.Matrix4()\n p.projection(focal_length / h_aperture, near, far, projection_mode == 0)\n\n # Matrix to translate the projected points into normalised pixel coords\n imageAspect = float(imageformat.height()) / float(imageformat.width())\n\n t = nuke.math.Matrix4()\n t.makeIdentity()\n t.translate(1.0, 1.0 - (1.0 - imageAspect / float(imageformat.pixelAspect())), 0.0)\n\n # Matrix to scale normalised pixel coords into actual pixel coords.\n x_scale = float(imageformat.width()) / 2.0\n y_scale = x_scale * imageformat.pixelAspect()\n s = nuke.math.Matrix4()\n s.makeIdentity()\n s.scale(x_scale, y_scale, 1.0)\n\n # The projection matrix transforms points into camera coords, modifies based\n # on the camera knob values, projects points into clip coords, translates the\n # clip coords so that they lie in the range 0,0 - 2,2 instead of -1,-1 - 1,1,\n # then scales the clip coords to proper pixel coords.\n return s * t * p * m * camTransform\n\n\ndef projectPoints(frame, camera=None, point=None, imageformat=None):\n # Modify projectpoint function in nukescripts.snap3d to add frame argument\n if not imageformat:\n imageformat = nuke.root()\[\"format\"].value()\n camMatrix = cameraProjectionMatrix(camera, frame, imageformat)\n if camMatrix == None:\n raise RuntimeError(\"snap3d.cameraProjectionMatrix() returned None for camera.\")\n\n if not (isinstance(point, list) or isinstance(point, tuple)):\n raise ValueError(\"Argument point must be a list or tuple.\")\n\n for point in point:\n # Would be nice to not do this for every item but since lists/tuples can\n # containg anything...\n if isinstance(point, nuke.math.Vector3):\n pt = point\n elif isinstance(point, list) or isinstance(point, tuple):\n pt = nuke.math.Vector3(point\[0], point\[1], point\[2])\n else:\n raise ValueError(\n \"All items in point must be nuke.math.Vector3 or list/tuple of 3 floats.\"\n )\n\n tPos = camMatrix * nuke.math.Vector4(pt.x, pt.y, pt.z, 1.0)\n # print tPos\n try:\n yield nuke.math.Vector2(tPos.x / tPos.w, tPos.y / tPos.w)\n except ZeroDivisionError:\n print(f\"Zero Division Error on frame \{frame\} with point data \{point\}\")\n yield nuke.math.Vector2()\n\n\ndef calculate(node):\n # Get the input Camera and verify it is right. (Assume camera is topnode of input to handle dots)\n cam_input = node.input(1)\n # Sanity check\n if not (cam_input and isinstance(cam_input, nuke.Node)):\n nuke.message(\"A Camera node must be connected.\")\n return\n if \"Camera\" in cam_input.Class():\n cam = cam_input\n else:\n cam = nuke.toNode(nuke.tcl(\"full_name \[topnode %s]\" % cam_input.name()))\n\n # AXIS INPUT\n axis_input = node.input(2)\n if not (axis_input and isinstance(axis_input, nuke.Node)):\n nuke.message(\"A axis node must be connected.\")\n return\n if \"Axis\" in axis_input.Class():\n axis = axis_input\n else:\n axis = nuke.toNode(nuke.tcl(\"full_name \[topnode %s]\" % axis_input.name()))\n\n # BG INPUT\n bg = node.input(0)\n if not bg:\n nuke.message(\n \"BG not connected, so the root format will be used to reconcile the 3D point into screen space.\"\n )\n\n # Get framerange to operate on from camera animation curves\n first = None\n last = None\n try:\n if cam\[\"translate\"].isAnimated():\n for curve in cam\[\"translate\"].animations():\n if first == None:\n first = int(curve.keys()\[0].x)\n else:\n first = min(first, int(curve.keys()\[0].x))\n for curve in cam\[\"translate\"].animations():\n if last == None:\n last = int(curve.keys()\[-1].x)\n else:\n last = max(last, int(curve.keys()\[-1].x))\n elif cam\[\"rotate\"].isAnimated():\n for curve in cam\[\"rotate\"].animations():\n if first == None:\n first = int(curve.keys()\[0].x)\n else:\n\n first = min(first, int(curve.keys()\[0].x))\n for curve in cam\[\"rotate\"].animations():\n if last == None:\n last = int(curve.keys()\[-1].x)\n else:\n last = max(last, int(curve.keys()\[-1].x))\n else:\n nuke.message(\"Input Camera is not animated.\")\n return\n except:\n nuke.message(\n \"Something went wrong getting the camera animation. Using Root framerange...\"\n )\n first = nuke.root().firstFrame()\n last = nuke.root().lastFrame()\n\n framerange = nuke.FrameRange(\"\{0\}-\{1\}\".format(first, last))\n\n # Only run if the ip knob is not default\n ipknob = axis\[\"translate\"]\n\n opknob = node\[\"op1\"]\n opknob.clearAnimated()\n opknob.setAnimated()\n\n # Building the data into a list of AnimationKey objects,\n # and then applying that list to the knob using addKey is significantly faster than other methods.\n # This makes the Calculate button instantaneous instead of taking forever.\n point_animcurve = \[\[], \[]]\n for frame in framerange:\n\n # print \"values are \", ipknob.getValueAt(frame)\n # Sample input point knob on every frame if it's animated or expression-linked\n if ipknob.isAnimated() or ipknob.hasExpression():\n point = next(projectPoints(\n frame, cam, \[ipknob.getValueAt(frame)], node.format()\n ))\n else:\n point = next(projectPoints(frame, cam, \[ipknob.value()], node.format()))\n for index in range(2):\n point_animcurve\[index].append(nuke.AnimationKey(frame, point\[index]))\n\n for index, curve in enumerate(opknob.animations()):\n curve.addKey(point_animcurve\[index])\n\n\nif __name__ == \"__main__\":\n calculate(nuke.thisNode())\n" +STARTLINE}
addUserKnob {22 clear_out l Clear t "Clear output knobs." -STARTLINE T "n = nuke.thisNode()\n\nkop = n\['op1']\nkop.clearAnimated()\nkop.setValue(kop.defaultValue())"}
addUserKnob {12 op1 l "XY output"}
addUserKnob {22 Transform T "# Create tracker node\nfrom __future__ import with_statement\n\ngrid_x = int(nuke.toNode('preferences').knob('GridWidth').value())\ngrid_y = int(nuke.toNode('preferences').knob('GridHeight').value())\npproj = nuke.thisNode()\n\nwith nuke.root():\n transform_node = nuke.nodes.Transform()\n transform_node.setXYpos(pproj.xpos() - grid_x * 0, pproj.ypos() + grid_y * 2)\n \[n.setSelected(False) for n in nuke.allNodes()]\n transform_node.setSelected(True)\n\n if pproj\['op1'].isAnimated():\n tknob = transform_node\['translate']\n tknob.clearAnimated()\n tknob.setAnimated()\n for x, c in enumerate(tknob.animations()):\n opknob = pproj\['op1']\n if opknob.isAnimated():\n c.addKey(opknob.animation(x).keys())\n else:\n tknob.clearAnimated()" +STARTLINE}
addUserKnob {26 ""}
addUserKnob {26 lbl l "" +STARTLINE T "Reconcile3DFast v1.1"}
addUserKnob {22 btn l DerekVFX.ca T "nuke.tcl('start', 'https://derekvfx.ca')" +STARTLINE}
}
Input {
inputs 0
name cam
label "\[value number]"
xpos 400
ypos -301
number 1
}
Input {
inputs 0
name axis
label "\[value number]"
xpos 197
ypos -307
number 2
}
Input {
inputs 0
name img
label "\[value number]"
xpos 620
ypos -302
}
Output {
name Output1
selected true
xpos 620
ypos 158
}
end_group