Last week I wrote about how to make your first little plugin for Nuke using Python.
It was a good start but it was missing a few functions to be fully done.
The biggest issue was that once a preset is created, you had no way to edit or delete it, without going to change it manually in the system files. That will be the first thing we will address today.
Secondly, I’d like to automatically name the colors the user picks, and present that as a name suggestion when creating a preset, instead of just “Color”. That might end up being a decent amount of work for not much, but it’s something I’ve been interested in for a while now.
Disclaimer: This is a pretty imposing amount of text and code for a beginner, and I understand if it scares you a bit. It stretched on more than I had originally hoped because I tried to explain as much as I could and give examples that aren’t part of the final code. I hope you will forgive me and manage to get through.
Managing the Color Presets
The most instinctive and “nukest” way I can think about to manage the presets would be to open a Nuke Panel.
Before I even think about coding, I find it helpful to think about what my panel will contain and how it will look like. A good old paper notebook and pen help me lay that down.
The way I’m seeing it right now is that each of the presets names will be displayed as an editable Text field, next to it will be a little color chip and a checkbox for deleting.
At the bottom I may or may not add a “New Preset” button, I’m not too sure how I would handle that code-wise. Finally, I’ll build that as a modalDialog, which will give me Cancel and OK buttons for free.
If the user clicks OK, the changes will be applied, otherwise, nothing will happen.
For coding, I will rely on this page from the documentation: https://learn.foundry.com/nuke/developers/80/pythondevguide/
(I’m working in Nuke 8 at the moment)
The first issue I run into when thinking about my code is that I’m not sure how many knobs I will have in my panel. There could be None, or there could be thousands…
The knobs building needs to be based on the same dictionary we used in part 1. Every 3 knobs (name, color, delete) also need to be linked to each other, so that when we run the code through at the end, we know which color it is that we would like to delete/edit.
It’s actually the first time I have run into this issue, and I’m not too sure how to handle that.
When this happens, the best way to get unstuck is to browse the web for a solution, look for another tool using a similar function, or ask someone who knows.
(You can’t actually know, but at this point, I paused the writing of the article for more than 24h. During these 24 h, I did a lot of things completely unrelated, but I also spent a considerable amount of time thinking about the best way to approach this. I also sent an email to the nuke-python mail list to check the best way to approach this. No answers so far.)
I couldn’t find an example where someone had to do the same thing, so I just got into nuke and started coding a test Panel, based on the examples in the documentation.
I knew I would have to loop through my dictionary to create all the appropriate knobs. Since all the knobs for a specific dictionary entry would need to relate to each other, I decided to group them into a dictionary, then add this dictionary to a list.
Here’s my test code:
import nukescripts dict = {"Test01": 50, "Test02": 120} class Test(nukescripts.PythonPanel): def __init__(self, color_dict): super(Test, self).__init__('Test') # CREATE KNOBS DICTS IN A LISTS self.list = [] for color in color_dict: knob_dict = {"name": nuke.String_Knob(color, '', color), "value": nuke.Int_Knob('value_%s' % color, 'Value')} knob_dict["value"].setValue(color_dict[color]) knob_dict["value"].clearFlag(nuke.STARTLINE) self.list.append(knob_dict) # ADD KNOBS for i in self.list: for k in i: self.addKnob( i[k] ) panel = Test(dict) if panel.showModalDialog(): print(panel.list)
I’m pretty happy with the way this behaves. Note the “self.” in front of my list variable. This makes the variable accessible at the end with “panel.list”. Without it I wouldn’t be able to access the list outside of my panel.
Note: I’m making a big mistake in this test code by naming my variable “dict”. This would “overwrite” the default python dict, which could be bad. I’m leaving it above as an example, and to show everybody makes stupid mistakes sometimes. I will change the name of that variable going forward…
Now, let’s re-arrange the code to match better what we’re actually trying to do.
import nukescripts color_dict = {"Green": 16318464, "Pink" : 4288256256} class ColorPresetPanel(nukescripts.PythonPanel): def __init__(self, colors): super(ColorPresetPanel, self).__init__('Manage Color Presets') # Create a list that will contain all the knobs for easy access. self.knob_list = [] for color in colors: label_knob = nuke.String_Knob("name_%s" % color, '', color) value_knob = nuke.ColorChip_Knob('value_%s' % color, '') delete_checkbox = nuke.Boolean_Knob("delete_%s" % color, 'delete') value_knob.setValue(colors[color]) value_knob.clearFlag(nuke.STARTLINE) delete_checkbox.clearFlag(nuke.STARTLINE) knob_dict = {"name": label_knob, "value": value_knob, "delete": delete_checkbox} self.knob_list.append(knob_dict) # Add the knobs to the panel for knob in [label_knob, value_knob, delete_checkbox]: self.addKnob(knob) panel = ColorPresetPanel(color_dict) if panel.showModalDialog(): print(panel.knob_list)
In the original sketch, I had a button to add a new preset directly from this panel. I don’t think I actually want this in my final plugin, but since you’re now making the plugin yourself, if you do want it, feel free to code it as an exercise!
There are a few tricky things to do that, and I wrote an example solution for it:
class ColorPresetPanel(nukescripts.PythonPanel): def __init__(self, colors): super(ColorPresetPanel, self).__init__('Manage Color Presets') # Create a list that will contain all the knobs for easy access. self.knob_list = [] for color in colors: self.addColorKnobs(color, colors[color]) # Create button knobs self.addButton = nuke.Script_Knob("+") self.addButton.setFlag(nuke.STARTLINE) self.okButton = nuke.Script_Knob("OK") self.cancelButton = nuke.Script_Knob("Cancel") # Also defining a counter, so that when we add new fields, we can number them. self.number = 1 # Add buttons self.addButtons() def addButtons(self): """ Add the +, OK, and Cancel buttons """ self.addKnob(self.addButton) self.addKnob(self.okButton) self.addKnob(self.cancelButton) def removeButtons(self): """ Remove the +, OK, and Cancel buttons """ self.removeKnob(self.addButton) self.removeKnob(self.okButton) self.removeKnob(self.cancelButton) def addColorKnobs(self, color_name, color_value=None): """ Adds knobs for name, color and delete """ label_knob = nuke.String_Knob("name_%s" % color_name, '', color_name) value_knob = nuke.ColorChip_Knob('value_%s' % color_name, '') delete_checkbox = nuke.Boolean_Knob("delete_%s" % color_name, 'delete') if color_value: value_knob.setValue(color_value) value_knob.clearFlag(nuke.STARTLINE) delete_checkbox.clearFlag(nuke.STARTLINE) knob_dict = {"name": label_knob, "value": value_knob, "delete": delete_checkbox} self.knob_list.append(knob_dict) # Add the knobs to the panel for knob in [label_knob, value_knob, delete_checkbox]: self.addKnob(knob) def addColor(self): # Remove 3 buttons: Without that, the new field would be added after them. self.removeButtons() # Add the new knobs self.addColorKnobs("new_color%i" % self.number) self.number += 1 # Put back the 3 buttons we removed self.addButtons() def knobChanged(self, knob): if knob in[self.addButton, ]: self.addColor()
Processing the info from the panel
We now have a nice-looking panel, but it isn’t actually doing anything yet.
Right now, we’re feeding a dictionary into the panel, it gets parsed into knobs. From the modified knobs, we should be able to parse a new updated dictionary.
For the test, I’m going to feed the panel a dictionary containing a wrong value (A magenta that has a value of Cyan) and try to see if I can edit it.
color_dict = {"Green": 16318464, "Pink" : 4288256256} class ColorPresetPanel(nukescripts.PythonPanel): def __init__(self, colors): super(ColorPresetPanel, self).__init__('Manage Color Presets') # Create a list that will contain all the knobs for easy access. self.knob_list = [] for color in colors: label_knob = nuke.String_Knob("name_%s" % color, '', color) value_knob = nuke.ColorChip_Knob('value_%s' % color, '') delete_checkbox = nuke.Boolean_Knob("delete_%s" % color, 'delete') value_knob.setValue(colors[color]) value_knob.clearFlag(nuke.STARTLINE) delete_checkbox.clearFlag(nuke.STARTLINE) knob_dict = {"name": label_knob, "value": value_knob, "delete": delete_checkbox} self.knob_list.append(knob_dict) # Add the knobs to the panel for knob in [label_knob, value_knob, delete_checkbox]: self.addKnob(knob) panel = ColorPresetPanel(color_dict) if panel.showModalDialog(): new_colors = {} for preset in panel.knob_list: if not preset['delete'].value(): new_colors[preset['name'].value()] = preset['value'].value() print(new_colors) #I'm renaming Magenta to Cyan, and deleting Green # Result: {'Blue': 65280, 'Cyan': 16776960, 'Red': 4278190080L}
Cool, that worked. Notice how my ‘Red’ value got a L suffix. That’s just a way python 2 indicates this is a long int. It shouldn’t affect us at all.
Now let’s merge it all with our code from last week to get a final code (for this step).
Don’t forget to add a menu entry to open the panel.
import os import json import nuke import nukescripts # Settings PATH = os.path.expanduser("~/.nuke/nodeColorPresets.json") TARGET_MENU = nuke.toolbar("Nodes") class ColorPresetPanel(nukescripts.PythonPanel): """ Custom Python Panel to manage color presets """ def __init__(self, colors): super(ColorPresetPanel, self).__init__('Manage Color Presets') # Create a list that will contain all the knobs for easy access. self.knob_list = [] for color in colors: label_knob = nuke.String_Knob("name_%s" % color, '', color) value_knob = nuke.ColorChip_Knob('value_%s' % color, '') delete_checkbox = nuke.Boolean_Knob("delete_%s" % color, 'delete') value_knob.setValue(colors[color]) value_knob.clearFlag(nuke.STARTLINE) delete_checkbox.clearFlag(nuke.STARTLINE) knob_dict = {"name": label_knob, "value": value_knob, "delete": delete_checkbox} self.knob_list.append(knob_dict) # Add the knobs to the panel for knob in [label_knob, value_knob, delete_checkbox]: self.addKnob(knob) def readColorPresets(path): """ Read Color presets from Json file """ if not os.path.isfile(path): print("No Color preset found") return {} else: try: with open(path) as f: colors = json.load(f) return colors except: print("Error reading color preset file") return {} def writeColorPresets(path, colors): """ Write Color presets to Json file """ try: with open(path, "w") as f: json.dump(colors, f) return True except: return False #Function to change tile_color: def setTileColor(value=None): """ Changes the tile_color of multiple nodes at once """ if value is None: value = nuke.getColor() for node in nuke.selectedNodes(): node.knob('tile_color').setValue(value) def createTileColorMenu(): """ Create the Menu entry for the Tile Color Presets """ # Loading out color presets colors = readColorPresets(PATH) # And creating the menu. color_menu = TARGET_MENU.addMenu('Color Nodes', icon="color_node.png") # Clear the menu if already populated color_menu.clearMenu() # Populating the Menu for color in sorted(colors): color_menu.addCommand(color, lambda x=colors[color]: setTileColor(x)) color_menu.addCommand("Custom Color", lambda: setTileColor()) color_menu.addCommand("-", "", "") color_menu.addCommand("Add New Preset", lambda: addNewColor()) color_menu.addCommand("Manage Presets", lambda: manageColorPresets()) def addNewColor(): """ Add a new color preset to our presets. """ # Open a color picker panel. color = nuke.getColor() if color: # Load our colors from the file colors = readColorPresets(PATH) valid_name = False while not valid_name: name = nuke.getInput("Give a name to your color", "color") if name: # Verify the name isn't taken. Reopen the panel if it's already taken if not name in colors.keys(): valid_name = True # Get out of the loop #add entry to the dictionary colors[name] = color success = writeColorPresets(PATH, colors) if success: createTileColorMenu() else: print("Error writing file %s" % PATH) else: nuke.message("The name already exists") elif name is None: # User Cancelled return # Function to manage the presets: def manageColorPresets(): colors = readColorPresets(PATH) panel = ColorPresetPanel(colors) if panel.showModalDialog(): new_colors = {} for preset in panel.knob_list: if not preset['delete'].value(): new_colors[preset['name'].value()] = preset['value'].value() success = writeColorPresets(PATH, new_colors) if success: createTileColorMenu() else: print("Error writing file %s" % PATH) createTileColorMenu()
I did some very minor changes to the code, like adding a divider line to the Menu, and sorting the dictionary in the panel so the order of the colors is the same as the order in the menu.
I also moved the clearing of the menu to the function creating the menu rather than being in multiple functions, and added an elif in the addNewColor so it stops asking for a name if the user cancels.
Automatically Naming the Colors
We could argue that naming a color isn’t so painful to do manually, but I’ve been interested for a while in algorithms to match colors. This would be a good exercise to practice using these algorithms.
It might be going a bit past a beginner level, but it shouldn’t be too hard to understand.
How to name the colors
My first intuition to name the colors would have been to get inspired by the way After Effects automatically names colors.
I don’t actually have a copy of After Effects so I’m not able to check exactly how it was behaving, but out of memory, it would name the basic color (Red, Green, Blue, Yellow, Purple, …) and add a Descriptive word if necessary (Dark, Pale, Bright, …).
Converting our color value to HSV, it would actually be relatively easy to generate such a name. It’s quite boring to type all the if/else statements, but at the end, you get a relatively accurate method, that is also extremely easy to understand for a beginner. It can also become more and more accurate by dividing your sections more. (You will see I got lazy on the blue colors and only categorized them as one tint)
def nameColor(hue, saturation, value): color_name = '' if value == 0: color_name = 'Black' return color_name if value == 1 and saturation == 0: color_name = 'White' return color_name if value<= 0.3: color_name += 'Dark ' elif value >= 0.9: color_name += 'Bright ' if saturation == 0: color_name += 'Grey' return color_name elif saturation <= 0.15: color_name += 'Greyish ' elif saturation <= 0.6: color_name += 'Pale ' elif saturation >= 0.9: color_name += 'Vivid ' if hue <= 0.02 or hue >= 0.95: color_name += 'Red' elif hue <= 0.11: color_name += 'Orange' elif hue <= 0.17: color_name += 'Yellow' elif hue <= 0.29: color_name += 'Apple Green' elif hue <= 0.42: color_name += 'Green' elif hue <= 0.46: color_name += 'Cyan Green' elif hue <= 0.52: color_name += 'Cyan' elif hue <= 0.7: color_name += 'Blue' elif hue <= 0.79: color_name += 'Purple' elif hue <= 0.9: color_name += 'Pink' else: color_name += 'Magenta' return color_name print(nameColor(0.78, 0.42, 0.39)) print(nameColor(0.35, 1, 0.95))
This is a totally valid approach, but in my case, I’d like to try something a little bit more complicated.
I would like my color to get matched to the closest known color taken from a list of colors.
Think about what happens when you export a GIF that has a limited amount of allowed colors. Each pixel gets recolored to the closest matching allowed color.
Python would be a bit slow to run such a script on each pixel of an image, but on a single color value why not?
It happens that Wikipedia has a pretty big list of colors with many different names here: List of colors (compact)
I also found a page by a guy named Gauthier Lemoine who extracted the list from Wikipedia and made it into a JSON file for javascript, but the good news is, it’s compatible with Python as well.
He made an online tool to name a color that you can find here. (Edit: you cannot find it anymore because his website is now dead.) His approach described on the page will be the base of what’s following.
With further reading (notably this link) I decided I’d do my matching in YUV colorspace instead of RGB or HSL. It is supposedly better matching human perception (yet far from perfect).
Doing the matching
Python has a colorsys module that can handle color conversions between different color systems like RGB, HSV and YIQ. It doesn’t have YUV, but according to wikipedia YIQ sounds pretty close to YUV, and a bit of googling around makes me believe it should work just as well, if not better than YUV for some tones, so I’ll go with that instead.
From now on, for the rest of this tutorial, I’ll be thinking of colors as a point in a 3D coordinate system. Better yet, we can consider each color to be a 3D vector.
To generate these point clouds, I also used python, with a code inspired by this: http://hagbarth.net/?p=663 (Check out this guy’s blog, he’s got some impressive tools in there).
It’s not needed for coding the tool, but I thought it would help you visualize.
Now remember we had a list of colors from Wikipedia/Gauthier earlier, containing RGB values. I’m going to run that through python and make it into a YIQ list. The RGB values also were based on a 0-255 range, but I need a float value, so I have to keep that in mind.
import colorsys, json # Reading the list with open("C:\Users\Erwan\Desktop\colors.json") as my_file: data = json.load(my_file) yiqData = [] for color in data: rgb = (color['x']/255.0, color['y']/255.0, color['z']/255.0) #add .0 to make sure the math happens in float yiq = colorsys.rgb_to_yiq(rgb[0],rgb[1],rgb[2]) newColor = {'x': yiq[0], 'y':yiq[1], 'z':yiq[2], 'label': color['label']} yiqData.append(newColor) # Writing the new list with open(os.path.expanduser("~/.nuke/colorsYIQ.json"), "w") as my_file: json.dump(yiqData, my_file)
One drawback is that the JSON file is much heavier in YIQ format than RGB 8bit because the format takes more characters to write (almost double).
ex:
{“x”:93,”y”:138,”z”:168,”label”:”Air Force blue”}
{“y”: -0.1435294117647059, “x”: 0.5011764705882353, “z”: -0.0005882352941176949, “label”: “Air Force blue”}
In our case the files are still quite small, so it won’t make much difference, but if we had a much bigger dataset, it would be something to consider.
Get the final YIQ JSON file
Now let’s try to match a single color to one of the labels.
I randomly picked a color 0.75, 0.4, 0.15 (rgb float) that I would probably have named “Pale Tangerine” if I was the one making the names.
Even though all this may sound a bit complex at first, now finding the closest value is pretty straight forward.
We need to measure the distance between this point and each of the known points, and return the closest one.
To measure the distance, it’s basic vector math. We need to subtract our two vectors, then calculate the magnitude of the resulting vector. https://www.mathsisfun.com/algebra/vectors.html
We can either do it manually:
from math import sqrt, pow x1, y1, z1 = 15,12,45 #my first vector x2, y2, z2 = 75,12,4 #my second vector new_x = x1-x2 new_y = y1-y2 new_z = z1-z2 distance = sqrt(pow(new_x,2)+pow(new_y,2)+pow(new_z,2)) print(distance) # Result: 72.6704891961
Or we can use the Nuke Math module, which is not very well documented, but has a good tutorial on nukepedia here: http://www.nukepedia.com/written-tutorials/using-the-nukemath-python-module-to-do-vector-and-matrix-operations/
vectorA = nuke.math.Vector3(15,12,45) vectorB = nuke.math.Vector3(75,12,4) distance = vectorA.distanceBetween(vectorB) print(distance) # Result: 72.6704864502
As you can see, both of these methods have a slightly different result, most likely a rounding error. It’s very little and won’t affect us. I’m going to use the nuke vector3 object for it’s ease of use.
import colorsys import json def nameColor(r, g, b): path_to_color_names = os.path.expanduser("~/.nuke/colorsYIQ.json") yiq = colorsys.rgb_to_yiq(r, g, b) x, y, z = yiq[0], yiq[1], yiq[2] vector_a = nuke.math.Vector3(x, y, z) name = '' smallest_distance = None try: with open(path_to_color_names) as color_names_file: color_names = json.load(color_names_file) except: return "Unknown Color" for color in color_names: vector_b = nuke.math.Vector3(color['x'], color['y'] ,color['z']) distance = vector_a.distanceBetween(vector_b) if smallest_distance is None or distance < smallest_distance: smallest_distance = distance name = color['label'] return name print(nameColor(0.75,0.4,0.15)) # Result: Ruddy brown
That code works, and despite the fact that it has to run once for each value, it’s still almost instantaneous to get the result.
Now, I’ll integrate it with the rest of the plugin:
Final code:
import os import json import colorsys import nuke import nukescripts # Settings PATH = os.path.expanduser("~/.nuke/nodeColorPresets.json") PATH_TO_COLOR_NAMES = os.path.expanduser("~/.nuke/colorsYIQ.json") TARGET_MENU = nuke.toolbar("Nodes") class ColorPresetPanel(nukescripts.PythonPanel): """ Custom Python Panel to manage color presets """ def __init__(self, colors): super(ColorPresetPanel, self).__init__('Manage Color Presets') # Create a list that will contain all the knobs for easy access. self.knob_list = [] for color in colors: label_knob = nuke.String_Knob("name_%s" % color, '', color) value_knob = nuke.ColorChip_Knob('value_%s' % color, '') delete_checkbox = nuke.Boolean_Knob("delete_%s" % color, 'delete') value_knob.setValue(colors[color]) value_knob.clearFlag(nuke.STARTLINE) delete_checkbox.clearFlag(nuke.STARTLINE) knob_dict = {"name": label_knob, "value": value_knob, "delete": delete_checkbox} self.knob_list.append(knob_dict) # Add the knobs to the panel for knob in [label_knob, value_knob, delete_checkbox]: self.addKnob(knob) def readColorPresets(path): """ Read Color presets from Json file """ if not os.path.isfile(path): print("No Color preset found") return {} else: try: with open(path) as f: colors = json.load(f) return colors except: print("Error reading color preset file") return {} def writeColorPresets(path, colors): """ Write Color presets to Json file """ try: with open(path, "w") as f: json.dump(colors, f) return True except: return False #Function to change tile_color: def setTileColor(value=None): """ Changes the tile_color of multiple nodes at once """ if value is None: value = nuke.getColor() for node in nuke.selectedNodes(): node.knob('tile_color').setValue(value) def createTileColorMenu(): """ Create the Menu entry for the Tile Color Presets """ # Loading out color presets colors = readColorPresets(PATH) # And creating the menu. color_menu = TARGET_MENU.addMenu('Color Nodes', icon="color_node.png") # Clear the menu if already populated color_menu.clearMenu() # Populating the Menu for color in sorted(colors): color_menu.addCommand(color, lambda x=colors[color]: setTileColor(x)) color_menu.addCommand("Custom Color", lambda: setTileColor()) color_menu.addCommand("-", "", "") color_menu.addCommand("Add New Preset", lambda: addNewColor()) color_menu.addCommand("Manage Presets", lambda: manageColorPresets()) def addNewColor(): """ Add a new color preset to our presets. """ #Open a color picker panel. color = nuke.getColor() if color: #Load our colors from the file colors = readColorPresets(PATH) valid_name = False r, g, b = hexToRgb(color) default_name = nameColor(r, g, b) while not valid_name: name = nuke.getInput("Give a name to your color", default_name) if name: # Verify the name isn't taken. Reopen the panel if it's already taken if not name in colors: valid_name = True # Get out of the loop # add entry to the dictionary colors[name] = color success = writeColorPresets(PATH, colors) if success: createTileColorMenu() else: print("Error writing file %s" % PATH) else: nuke.message("The name already exists") elif name is None: # User Cancelled return def nameColor(r, g, b): """ Picks a name from the closest known color in a color name list """ yiq = colorsys.rgb_to_yiq(r, g, b) x, y, z = yiq[0], yiq[1], yiq[2] vector_a = nuke.math.Vector3(x, y, z) name = '' smallest_distance = None try: with open(PATH_TO_COLOR_NAMES) as color_names_file: color_names = json.load(color_names_file) except: return "Unknown Color" for color in color_names: vector_b = nuke.math.Vector3(color['x'], color['y'] ,color['z']) distance = vector_a.distanceBetween(vector_b) if smallest_distance is None or distance < smallest_distance: smallest_distance = distance name = color['label'] return name def hexToRgb(nuke_hex): """ Convert HEX color value to RGB """ try: real_hex = '%08x' % nuke_hex r = int(real_hex[0:2], 16)/255.0 g = int(real_hex[2:4], 16)/255.0 b = int(real_hex[4:6], 16)/255.0 except: return None, None, None return r, g, b def manageColorPresets(): """ Edit and Delete color presets """ colors = readColorPresets(PATH) panel = ColorPresetPanel(colors) if panel.showModalDialog(): new_colors = {} for preset in panel.knob_list: if not preset['delete'].value(): new_colors[preset['name'].value()] = preset['value'].value() success = writeColorPresets(PATH, new_colors) if success: createTileColorMenu() else: print("Error writing file %s" % PATH) createTileColorMenu()
I had to do some slight modifications to the original functions, as well as adding a function to convert from the HEX value nuke gives us to RGB values.
Thanks for reading through.
Hey Erwan,
I was looking for documentation online on how to iterate through a dictionary to dynamically create knobs in my panel and stumbled onto this. Thanks for doing so much work and sharing it out to others!