Skip to content

Instantly share code, notes, and snippets.

@jedypod
Last active December 4, 2022 14:50
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jedypod/d13595f856976869fe4cacd265a2b15e to your computer and use it in GitHub Desktop.
Save jedypod/d13595f856976869fe4cacd265a2b15e to your computer and use it in GitHub Desktop.
BalanceGrade - A Nuke tool to neutralize and balance the color of multiple input plates. Can be used with the CalibrateMacbeth or mmColorTarget toolsets to calibrate gamut as well.

BalanceGrade

BalanceGrade Screenshot

BalanceGrade is a Nuke tool to neutralize and balance the color of multiple input plates. It presents an interface to assist with common operations like

  • Applying a 3x3 ColorMatrix to calibrate the hue of an image.
  • Matching a source color to a target color
  • Calibrating the blackpoint of an image to be neutral
  • Adjusting the exposure of an image in stops
  • Balancing the color of an image with a multiply

These transforms are combined into a 3 operations: a 3x3 matrix, a multiply, and an offset. The grade can then be saved out to a single spimtx file, or an spimtx matrix + a cc file. Or you can export only a cc file if you are not using the 3x3 matrix to calibrate the colors of your image.

BalanceGradeSimple

I've also included a version of the tool without the ColorMatrix if you prefer a simpler linear slope-offset color transform.

Methodology

Calculating a calibration 3x3 color matrix

To calculate a calibration matrix you can use a tool like mmColorTarget or CalibrateMacbeth to generate a 3x3 matrix based on a Macbeth ColorChecker chart in an image. The matrix will calibrate the image based on the Macbeth chart to more closely match the white balance and color gamut of the target colorspace. Normally you would create your 3x3 matrix such that the exposure doesn't change too much.

Balancing

When analyzing a sequence of shots for balancing the colors, there are a few important guidelines to follow.

  • Don't push the colors too far from the original photography.
  • Don't compensate for differences in lighting. Adjust overall exposure and color differences only.

Procedure

  • Identify a good target shot to pivot all the other shots towards.
  • Arrange your shots by similar angle and field of view.
  • Begin matching similar source colors to target colors.
  • Work iteratively. Do a rough first pass on all the shots before refining.
  • Evaluate in context. Don't focus exclusively on a single shot in isolation. Evaluate groups of similar shots and the entire sequence while you keep everything balanced together.

Usage

Blackpoint: If you need to adjust the blackpoint of the image, there are a couple tools to help you. If you only want to neutralize the color of the blacks you can view the image you are adjusting and gain the viewer way way up to find the darkest blacks in the image. (Make sure you use gain and not gamma). Ctrl+shift+drag in the viewer to select the region you want to sample. Now click Set Blackpoint. The region is analyzed and the blackpoint knob is set with the values. If preserve luminance is enabled, the overall brightness of the region won't change but the rgb will be equalized to remove color cast. Otherwise the sampled region will be set to 0, 0, 0.

Source / Target matching: Look at the image you are adjusting. Identify a good source color that matches a color you are trying to match to in your target. This could be a similar material between the two shots for example. Select the colorpicker on the src color knob. Ctrl+Shift drag in the nuke viewer to set the value. Now view the target image, and do the same for the dst color. You can do this in the reverse order as well if it is more intuitive. Don't forget to un-select the colorpicker or you might mess up your sample later!

Overall Grading: If you need to do aditional color matching by eye you can use the multiply knob. Make sure to use the floating color for more precision!

Exporting

You can export to two formats.

  1. cc file Exporting a cc file will combine your adjustements into a SOP adjustment with only slope (gain) and offset (add). Note that any colormatrix you have applied will not be included.
  2. spimtx the spimtx format is similar to the spi1d and spi3d lut formats, but contains a 3x3 matrix plus an offset value for each color channel (If you look at the exported file you might notice that the offset values are huge numbers. This is because they are expressed in 16 bit integer numbers, so 0 = 0 and 1 = 65535). If this format is used it will contain your matrix calibration and your combined slope and offset values. If you have calibration only checked, only the colormatrix calibration will be included.

These files can now be used in your OCIO color pipeline!

Happy balancing...

set cut_paste_input [stack 0]
push $cut_paste_input
Group {
name BalanceGrade
tile_color 0x6c9de1ff
addUserKnob {20 BalanceGrade}
addUserKnob {26 ColorMatrix_label l "@b;ColorMatrix" T " "}
addUserKnob {41 matrix T ColorMatrix.matrix}
addUserKnob {22 reset_mtx l Reset T "nuke.thisNode()\['matrix'].setValue(\[1,0,0,0,1,0,0,0,1])" +STARTLINE}
addUserKnob {26 ""}
addUserKnob {26 match_label l "@b;Match" T ""}
addUserKnob {18 matchsrc l "src color"}
matchsrc 0.18
addUserKnob {6 matchsrc_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 matchdst l "dst color"}
matchdst 0.18
addUserKnob {6 matchdst_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 blackpoint t "this value will be subtracted from the offset"}
blackpoint {0 0 0}
addUserKnob {6 blackpoint_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {22 set_blackpoint l "Set Blackpoint" t "Set blackpoint based on selected region in the viewer. If preserve luminance is checked, the rgb will be balanced but the overall luminance will not be changed." T "node = nuke.thisNode()\nblackpoint_preserve_luminance = node\['blackpoint_preserve_luminance'].getValue()\nnuke.root().begin()\n\n# Get viewer and connected node to calculate format resolution\nviewer = nuke.activeViewer().node()\nif viewer.input(0):\n viewed_node = viewer.input(0)\n if not nuke.selectedNodes():\n viewed_node.setSelected(1)\nelse:\n viewed_node = nuke.activeViewer().node()\n\n# Get selected sample area\nbboxinfo = nuke.activeViewer().node()\['colour_sample_bbox'].value()\naspect = float(viewed_node.width() * viewed_node.pixelAspect()) / float(viewed_node.height())\ncornerA = \[(bboxinfo\[0]*0.5+0.5) * viewed_node.width(), (((bboxinfo\[1] * 0.5) + (0.5/aspect)) * aspect) * viewed_node.height()]\ncornerB = \[(bboxinfo\[2]*0.5+0.5) * viewed_node.width(), (((bboxinfo\[3] * 0.5) + (0.5/aspect)) * aspect) * viewed_node.height()]\narea = \[cornerB\[0] - cornerA\[0], cornerB\[1] - cornerA\[1]]\ncenter = \[cornerA\[0] + (area\[0]/2), cornerA\[1] + (area\[1] / 2)]\n\n# Reset blackpoint and add knob\nnode\['blackpoint'].setValue(\[0, 0, 0])\nadd_value = node\['add'].getValue()\nnode\['add'].setValue(\[0, 0, 0])\n\n# Sample input colors within box\ncolor_sample = \[node.sample('rgba.red', center\[0], center\[1], area\[0], area\[1]), node.sample('rgba.green', center\[0], center\[1], area\[0], area\[1]), node.sample('rgba.blue', center\[0], center\[1], area\[0], area\[1])]\n\nif blackpoint_preserve_luminance:\n # calculate average luminance with rec709 weighting\n average_luminance = color_sample\[0]*0.2126 + color_sample\[1]*0.7152 + color_sample\[2]*0.0722\n color_sample = \[v - average_luminance for v in color_sample]\n\n# Set blackpoint to sampled value\nnode\['blackpoint'].setValue(color_sample)\nnode\['add'].setValue(add_value)\n" +STARTLINE}
addUserKnob {6 blackpoint_preserve_luminance l "preserve luminance" t "Try not to shift luminance of the blackpoint when sampling, only shift the color to be neutral." -STARTLINE}
blackpoint_preserve_luminance true
addUserKnob {22 reset_match l Reset T "n = nuke.thisNode()\nn\['blackpoint'].setValue(\[0, 0, 0])\nn\['add'].setValue(\[0, 0, 0])\nn\['matchsrc'].setValue(0.18)\nn\['matchdst'].setValue(0.18)" +STARTLINE}
addUserKnob {26 grade_label l "@b; Grade" T " "}
addUserKnob {7 exposure t "Adjust exposure in stops" R -4 4}
addUserKnob {6 exposure_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 multiply R 0 4}
multiply {1 1 1}
addUserKnob {6 multiply_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 add R -0.25 0.25}
add {0 0 0}
addUserKnob {6 add_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {22 reset_grade l Reset T "n = nuke.thisNode()\nn\['exposure'].setValue(0)\nn\['multiply'].setValue(\[1, 1, 1])\nn\['add'].setValue(\[0, 0, 0])" +STARTLINE}
addUserKnob {26 ""}
addUserKnob {6 invert t "Invert the color transform." +STARTLINE}
addUserKnob {41 export_cc l "Export CC" T OCIOCDLTransform.export_cc}
addUserKnob {22 export_spimtx l "Export spimtx" t "Export spimtx format describing the balancegrade.\n\nSupports saturation, primaries multiply, offset." T "from __future__ import print_function\nfrom __future__ import with_statement\nimport nuke\n\ndef mtx_mult(a, b):\n # multiply two 3x3 matrices and return the result\n a = \[a\[0:3], a\[3:6], a\[6:9]]\n b = \[b\[0:3], b\[3:6], b\[6:9]]\n c = \[\[sum(a * b for a, b in zip(a_row, b_col)) for b_col in zip(*b)] for a_row in a]\n return c\[0] + c\[1] + c\[2]\n\ndef export_spimtx(output_path=None):\n # export an spimtx file given the color transformations specified on the balancegrade node.\n node = nuke.thisNode()\n nuke.root().begin()\n spimtx_calibration_only = node\['spimtx_calibration_only'].getValue()\n\n if not output_path:\n output_path = nuke.getFilename('output_path')\n if not output_path:\n print('Error: no output path specified. Exiting...')\n return\n\n with node:\n cdltransform = nuke.toNode('OCIOCDLTransform')\n \n mtx = node\['matrix'].getValue()\n\n if spimtx_calibration_only:\n dst_mtx = mtx\n offset = \[0, 0, 0]\n slope = \[1, 1, 1]\n else:\n offset = cdltransform\['offset'].getValue()\n slope = cdltransform\['slope'].getValue()\n mult_mtx = \[slope\[0], 0, 0, 0, slope\[1], 0, 0, 0, slope\[2]]\n dst_mtx = mtx_mult(mult_mtx, mtx)\n\n output_spimtx_string = '\{0\} \{1\} \{2\} \{3\} \{4\} \{5\} \{6\} \{7\} \{8\} \{9\} \{10\} \{11\}'.format(\n dst_mtx\[0],\n dst_mtx\[1],\n dst_mtx\[2],\n int(round(offset\[0] * 65535)),\n dst_mtx\[3],\n dst_mtx\[4],\n dst_mtx\[5],\n int(round(offset\[1] * 65535)),\n dst_mtx\[6],\n dst_mtx\[7],\n dst_mtx\[8],\n int(round(offset\[2] * 65535))\n )\n\n # Create spimtx file\n spimtx_file = open(output_path, 'w+')\n spimtx_file.write(output_spimtx_string)\n spimtx_file.close()\n\n\nif __name__=='__main__':\n export_spimtx()" +STARTLINE}
addUserKnob {6 spimtx_calibration_only l "calibration only" t "only export the colormatrix calibration to the spimtx file. \n\notherwise export the entire balancegrade to the spimtx file." -STARTLINE}
}
Input {
inputs 0
name Input
xpos -370
ypos -562
}
Dot {
name Dot1
label " "
note_font "Helvetica Bold"
note_font_size 24
note_font_color 0xa5a5a501
xpos -336
ypos -486
}
set Nb2af9f00 [stack 0]
OCIOCDLTransform {
slope {{parent.OCIOCDLTransform.slope} {parent.OCIOCDLTransform.slope} {parent.OCIOCDLTransform.slope}}
offset {{parent.OCIOCDLTransform.offset} {parent.OCIOCDLTransform.offset} {parent.OCIOCDLTransform.offset}}
direction inverse
name OCIOCDLTransform_inverse
xpos -260
ypos -442
}
ColorMatrix {
matrix {
{{parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix}}
{{parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix}}
{{parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix} {parent.ColorMatrix.matrix}}
}
invert true
name ColorMatrix_invert
xpos -260
ypos -370
}
push $Nb2af9f00
ColorMatrix {
matrix {
{1 0 0}
{0 1 0}
{0 0 1}
}
name ColorMatrix
xpos -370
ypos -442
}
OCIOCDLTransform {
slope {{"matchdst/matchsrc*pow(2, exposure)*multiply"} {"matchdst/matchsrc*pow(2, exposure)*multiply"} {"matchdst/matchsrc*pow(2, exposure)*multiply"}}
offset {{"parent.add - parent.blackpoint"} {"parent.add - parent.blackpoint"} {"parent.add - parent.blackpoint"}}
name OCIOCDLTransform
xpos -370
ypos -370
}
Switch {
inputs 2
which {{parent.invert}}
name Switch_inverse
xpos -370
ypos -274
}
Output {
name Output
xpos -370
ypos -202
}
end_group
set cut_paste_input [stack 0]
push $cut_paste_input
Group {
name BalanceGrade
tile_color 0x6c9de1ff
addUserKnob {20 BalanceGrade_tab l BalanceGrade}
addUserKnob {26 match_label l "@b;match" T ""}
addUserKnob {18 matchsrc l "src color"}
matchsrc 0.18
addUserKnob {6 matchsrc_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 matchdst l "dst color"}
matchdst 0.18
addUserKnob {6 matchdst_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 blackpoint t "this value will be subtracted from the offset"}
blackpoint {0 0 0}
addUserKnob {6 blackpoint_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {22 set_blackpoint l "Set Blackpoint" t "Set blackpoint based on selected region in the viewer. If preserve luminance is checked, the rgb will be balanced but the overall luminance will not be changed." T "node = nuke.thisNode()\nblackpoint_preserve_luminance = node\['blackpoint_preserve_luminance']\nnuke.root().begin()\n\n# Get viewer and connected node to calculate format resolution\nviewer = nuke.activeViewer().node()\nif viewer.input(0):\n viewed_node = viewer.input(0)\n if not nuke.selectedNodes():\n viewed_node.setSelected(1)\nelse:\n viewed_node = nuke.activeViewer().node()\n\n# Get selected sample area\nbboxinfo = nuke.activeViewer().node()\['colour_sample_bbox'].value()\naspect = float(viewed_node.width() * viewed_node.pixelAspect()) / float(viewed_node.height())\ncornerA = \[(bboxinfo\[0]*0.5+0.5) * viewed_node.width(), (((bboxinfo\[1] * 0.5) + (0.5/aspect)) * aspect) * viewed_node.height()]\ncornerB = \[(bboxinfo\[2]*0.5+0.5) * viewed_node.width(), (((bboxinfo\[3] * 0.5) + (0.5/aspect)) * aspect) * viewed_node.height()]\narea = \[cornerB\[0] - cornerA\[0], cornerB\[1] - cornerA\[1]]\ncenter = \[cornerA\[0] + (area\[0]/2), cornerA\[1] + (area\[1] / 2)]\n\n# Reset blackpoint and add knob\nnode\['blackpoint'].setValue(\[0, 0, 0])\nadd_value = node\['add'].getValue()\nnode\['add'].setValue(\[0, 0, 0])\n\n# Sample input colors within box\ncolor_sample = \[node.sample('rgba.red', center\[0], center\[1], area\[0], area\[1]), node.sample('rgba.green', center\[0], center\[1], area\[0], area\[1]), node.sample('rgba.blue', center\[0], center\[1], area\[0], area\[1])]\n\nif blackpoint_preserve_luminance:\n # calculate average luminance with rec709 weighting\n average_luminance = color_sample\[0]*0.2126 + color_sample\[1]*0.7152 + color_sample\[2]*0.0722\n color_sample = \[v - average_luminance for v in color_sample]\n\n# Set blackpoint to sampled value\nnode\['blackpoint'].setValue(color_sample)\nnode\['add'].setValue(add_value)\n" +STARTLINE}
addUserKnob {6 blackpoint_preserve_luminance l "preserve luminance" t "Try not to shift luminance of the blackpoint when sampling, only shift the color to be neutral." -STARTLINE}
blackpoint_preserve_luminance true
addUserKnob {22 reset_match l Reset T "n = nuke.thisNode()\nn\['blackpoint'].setValue(\[0, 0, 0])\nn\['add'].setValue(\[0, 0, 0])\nn\['matchsrc'].setValue(0.18)\nn\['matchdst'].setValue(0.18)" +STARTLINE}
addUserKnob {26 grade_label l "@b; grade" T " "}
addUserKnob {7 exposure t "Adjust exposure in stops" R -4 4}
addUserKnob {6 exposure_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 multiply R 0 4}
multiply {1 1 1}
addUserKnob {6 multiply_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {18 add R -0.25 0.25}
add {0 0 0}
addUserKnob {6 add_panelDropped l "panel dropped state" -STARTLINE +HIDDEN}
addUserKnob {22 reset_grade l Reset T "n = nuke.thisNode()\nn\['exposure'].setValue(0)\nn\['multiply'].setValue(\[1, 1, 1])\nn\['add'].setValue(\[0, 0, 0])" +STARTLINE}
addUserKnob {20 calculated_slope_offset_grp l " calculated slope and offset" n 1}
calculated_slope_offset_grp 0
addUserKnob {41 slope T OCIOCDLTransform.slope}
addUserKnob {41 offset T OCIOCDLTransform.offset}
addUserKnob {20 endGroup n -1}
addUserKnob {26 ""}
addUserKnob {6 invert t "Invert the color transform." +STARTLINE}
}
Input {
inputs 0
name Input
xpos -370
ypos -490
}
Dot {
name Dot1
xpos -336
ypos -426
}
set N380ad830 [stack 0]
OCIOCDLTransform {
slope {{parent.OCIOCDLTransform.slope} {parent.OCIOCDLTransform.slope} {parent.OCIOCDLTransform.slope}}
offset {{parent.OCIOCDLTransform.offset} {parent.OCIOCDLTransform.offset} {parent.OCIOCDLTransform.offset}}
direction inverse
working_space scene_linear
name OCIOCDLTransform_inverse
xpos -260
ypos -370
}
push $N380ad830
OCIOCDLTransform {
slope {{"matchdst/matchsrc*pow(2, exposure)*multiply"} {"matchdst/matchsrc*pow(2, exposure)*multiply"} {"matchdst/matchsrc*pow(2, exposure)*multiply"}}
offset {{"parent.add - parent.blackpoint"} {"parent.add - parent.blackpoint"} {"parent.add - parent.blackpoint"}}
working_space scene_linear
name OCIOCDLTransform
xpos -480
ypos -370
}
Switch {
inputs 2
which {{parent.invert}}
name Switch_inverse
xpos -370
ypos -310
}
Output {
name Output
xpos -370
ypos -250
}
end_group
@Build-1
Copy link

Build-1 commented Apr 16, 2022

Hello, I'm trying to install/add Nuke Color Tools but no luck. Can you let me know what's the right way to add this toolset? Thank you.

@sharktacos
Copy link

copy the raw code above and paste into Nuke.

@Build-1
Copy link

Build-1 commented Jun 26, 2022

copy the raw code above and paste into Nuke.

Thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment