Custom Nodes
This tutorial will show you how to create your own custom nodes to run with
PeekingDuck.
Perhaps you’d like to take a snapshot of a video frame, and post it to your API
endpoint;
or perhaps you have a model trained on a custom dataset, and would like to use
PeekingDuck’s input
, draw
, and output
nodes.
PeekingDuck is designed to be very flexible — you can create your own nodes
and use them with ours.
Let’s start by creating a new PeekingDuck project:
Terminal Session
This creates the following custom_project
folder structure:
custom_project/ ⠀ ├── pipeline_config.yml └── src/ ⠀ └── custom_nodes/ ⠀ └── configs/ ⠀
The sub-folders src
, custom_nodes
, and configs
are empty: they serve
as placeholders for contents to be added.
Recipe 1: Object Detection Score
When the YOLO object detection model detects an object in the image, it assigns a bounding box and a score to it. This score is the “confidence score” which reflects how likely the box contains an object and how accurate is the bounding box. It is a decimal number that ranges from 0.0 to 1.0 (or 100%). This number is internal and not readily viewable.
We will create a custom node to retrieve this score and display it on screen.
This tutorial will use the cat_and_computer.mp4 video from
the earlier object detection tutorial.
Copy it into the custom_project
folder.
Use the following command to create a custom node: peekingduck create-node
It will prompt you to answer several questions.
Press <Enter> to accept the default custom_nodes
folder name, then key
in draw for node type and score for node name.
Finally, press <Enter> to answer Y
when asked to proceed.
The entire interaction is shown here, the answers you type in are shown in green text:
Terminal Session
The custom_project
folder structure should look like this:
custom_project/ ⠀ ├── cat_and_computer.mp4 ├── pipeline_config.yml └── src/ ⠀ └── custom_nodes/ ⠀ ├── configs/ ⠀ │ └── draw/ ⠀ │ └── score.yml └── draw/ ⠀ └── score.py
custom_project
now contains three files that we need to modify to
implement our custom node function.
src/custom_nodes/configs/draw/score.yml:
score.yml
initial content:1# Mandatory configs 2# Receive bounding boxes and their respective labels as input. Replace with 3# other data types as required. List of built-in data types for PeekingDuck can 4# be found at https://peekingduck.readthedocs.io/en/stable/glossary.html. 5input: ["bboxes", "bbox_labels"] 6# Example: 7# Output `obj_attrs` for visualization with `draw.tag` node and `custom_key` for 8# use with other custom nodes. Replace as required. 9output: ["obj_attrs", "custom_key"] 10 11# Optional configs depending on node 12threshold: 0.5 # example
The first file
score.yml
defines the properties of the custom node.
Lines 5 and 9 show the mandatory configsinput
andoutput
.input
specifies the data types the node would consume, to be read from the pipeline.
output
specifies the data types the node would produce, to be put into the pipeline.To display the bounding box confidence score, our node requires three pieces of input data: the bounding box, the score to display, and the image to draw on. These are defined as the data types bboxes, bbox_scores, and img respectively in the API docs.
Our custom node only displays the score on screen and does not produce any outputs for the pipeline, so the output is “none”.
There are also no optional configs, so lines 11 - 12 can be removed.
score.yml
updated content:1# Mandatory configs 2input: ["img", "bboxes", "bbox_scores"] 3output: ["none"] 4 5# No optional configs
Note
Comments in yaml files start with
#
It is possible for a node to haveinput: ["none"]
src/custom_nodes/draw/score.py:
The second file
score.py
contains the boilerplate code for creating a custom node. Update the code to implement the desired behavior for the node.Show/Hide Code for score.py
1""" 2Custom node to show object detection scores 3""" 4 5from typing import Any, Dict, List, Tuple 6import cv2 7from peekingduck.pipeline.nodes.abstract_node import AbstractNode 8 9YELLOW = (0, 255, 255) # in BGR format, per opencv's convention 10 11 12def map_bbox_to_image_coords( 13 bbox: List[float], image_size: Tuple[int, int] 14) -> List[int]: 15 """This is a helper function to map bounding box coords (relative) to 16 image coords (absolute). 17 Bounding box coords ranges from 0 to 1 18 where (0, 0) = image top-left, (1, 1) = image bottom-right. 19 20 Args: 21 bbox (List[float]): List of 4 floats x1, y1, x2, y2 22 image_size (Tuple[int, int]): Width, Height of image 23 24 Returns: 25 List[int]: x1, y1, x2, y2 in integer image coords 26 """ 27 width, height = image_size[0], image_size[1] 28 x1, y1, x2, y2 = bbox 29 x1 *= width 30 x2 *= width 31 y1 *= height 32 y2 *= height 33 return int(x1), int(y1), int(x2), int(y2) 34 35 36class Node(AbstractNode): 37 """This is a template class of how to write a node for PeekingDuck, 38 using AbstractNode as the parent class. 39 This node draws scores on objects detected. 40 41 Args: 42 config (:obj:`Dict[str, Any]` | :obj:`None`): Node configuration. 43 """ 44 45 def __init__(self, config: Dict[str, Any] = None, **kwargs: Any) -> None: 46 """Node initializer 47 48 Since we do not require any special setup, it only calls the __init__ 49 method of its parent class. 50 """ 51 super().__init__(config, node_path=__name__, **kwargs) 52 53 def run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: # type: ignore 54 """This method implements the display score function. 55 As PeekingDuck iterates through the CV pipeline, this 'run' method 56 is called at each iteration. 57 58 Args: 59 inputs (dict): Dictionary with keys "img", "bboxes", "bbox_scores" 60 61 Returns: 62 outputs (dict): Empty dictionary 63 """ 64 65 # extract pipeline inputs and compute image size in (width, height) 66 img = inputs["img"] 67 bboxes = inputs["bboxes"] 68 scores = inputs["bbox_scores"] 69 img_size = (img.shape[1], img.shape[0]) # width, height 70 71 for i, bbox in enumerate(bboxes): 72 # for each bounding box: 73 # - compute (x1, y1) top-left, (x2, y2) bottom-right coordinates 74 # - convert score into a two decimal place numeric string 75 # - draw score string onto image using opencv's putText() 76 # (see opencv's API docs for more info) 77 x1, y1, x2, y2 = map_bbox_to_image_coords(bbox, img_size) 78 score = scores[i] 79 score_str = f"{score:0.2f}" 80 cv2.putText( 81 img=img, 82 text=score_str, 83 org=(x1, y2), 84 fontFace=cv2.FONT_HERSHEY_SIMPLEX, 85 fontScale=1.0, 86 color=YELLOW, 87 thickness=3, 88 ) 89 90 return {} # node has no outputs
The updated node code defines a helper function
map_bbox_to_image_coords
to map the bounding box coordinates to the image coordinates, as explained in this section.The
run
method implements the main logic which processes every bounding box to compute its on-screen coordinates and to draw the bounding box confidence score at its left-bottom position.pipeline_config.yml:
pipeline_config.yml
initial content:1nodes: 2- input.visual: 3 source: https://storage.googleapis.com/peekingduck/videos/wave.mp4 4- model.posenet 5- draw.poses 6- output.screen
This file implements the pipeline. Modify the default pipeline to the one shown below:
pipeline_config.yml
updated content:1nodes: 2- input.visual: 3 source: cat_and_computer.mp4 4- model.yolo: 5 detect: ["cup", "cat", "laptop", "keyboard", "mouse"] 6- draw.bbox: 7 show_labels: True 8- custom_nodes.draw.score 9- output.screen
Line 8 adds our custom node into the pipeline where it will be
run
by PeekingDuck during each pipeline iteration.
Execute peekingduck run to see your custom node in action.
Note
Royalty free video of cat and computer from: https://www.youtube.com/watch?v=-C1TEGZavko
Recipe 2: Keypoints, Count Hand Waves
This tutorial will create a custom node to analyze the skeletal keypoints of the person from the wave.mp4 video in the pose estimation tutorial and to count the number of times the person waves his hand.
The PoseNet pose estimation model outputs seventeen keypoints for the person
corresponding to the different body parts as documented here.
Each keypoint is a pair of (x, y)
coordinates, where x
and y
are
real numbers ranging from 0.0 to 1.0 (using
relative coordinates).
Starting with a newly initialized PeekingDuck folder, call peekingduck
create-node to create a new dabble.wave
custom node as shown below:
Terminal Session
Also, copy wave.mp4 into the above folder. You should end up with the following folder structure:
wave_project/ ⠀ ├── pipeline_config.yml ├── src/ ⠀ │ └── custom_nodes/ ⠀ │ ├── configs/ ⠀ │ │ └── dabble/ ⠀ │ │ └── wave.yml │ └── dabble/ ⠀ │ └── wave.py └── wave.mp4
To implement this tutorial, the three files wave.yml
, wave.py
and
pipeline_config.yml
are to be edited as follows:
src/custom_nodes/configs/dabble/wave.yml:
1# Dabble node has both input and output 2input: ["img", "bboxes", "bbox_scores", "keypoints", "keypoint_scores"] 3output: ["none"] 4 5# No optional configs
We will implement this tutorial using a custom
dabble
node, which will take the inputs img, bboxes, bbox_scores, keypoints, and keypoint_scores from the pipeline. The node has no output.src/custom_nodes/dabble/wave.py:
The
dabble.wave
code structure is similar to thedraw.score
code structure in the other custom node tutorial.Show/Hide Code for wave.py
1""" 2Custom node to show keypoints and count the number of times the person's hand is waved 3""" 4 5from typing import Any, Dict, List, Tuple 6import cv2 7from peekingduck.pipeline.nodes.abstract_node import AbstractNode 8 9# setup global constants 10FONT = cv2.FONT_HERSHEY_SIMPLEX 11WHITE = (255, 255, 255) # opencv loads file in BGR format 12YELLOW = (0, 255, 255) 13THRESHOLD = 0.6 # ignore keypoints below this threshold 14KP_RIGHT_SHOULDER = 6 # PoseNet's skeletal keypoints 15KP_RIGHT_WRIST = 10 16 17 18def map_bbox_to_image_coords( 19 bbox: List[float], image_size: Tuple[int, int] 20) -> List[int]: 21 """First helper function to convert relative bounding box coordinates to 22 absolute image coordinates. 23 Bounding box coords ranges from 0 to 1 24 where (0, 0) = image top-left, (1, 1) = image bottom-right. 25 26 Args: 27 bbox (List[float]): List of 4 floats x1, y1, x2, y2 28 image_size (Tuple[int, int]): Width, Height of image 29 30 Returns: 31 List[int]: x1, y1, x2, y2 in integer image coords 32 """ 33 width, height = image_size[0], image_size[1] 34 x1, y1, x2, y2 = bbox 35 x1 *= width 36 x2 *= width 37 y1 *= height 38 y2 *= height 39 return int(x1), int(y1), int(x2), int(y2) 40 41 42def map_keypoint_to_image_coords( 43 keypoint: List[float], image_size: Tuple[int, int] 44) -> List[int]: 45 """Second helper function to convert relative keypoint coordinates to 46 absolute image coordinates. 47 Keypoint coords ranges from 0 to 1 48 where (0, 0) = image top-left, (1, 1) = image bottom-right. 49 50 Args: 51 bbox (List[float]): List of 2 floats x, y (relative) 52 image_size (Tuple[int, int]): Width, Height of image 53 54 Returns: 55 List[int]: x, y in integer image coords 56 """ 57 width, height = image_size[0], image_size[1] 58 x, y = keypoint 59 x *= width 60 y *= height 61 return int(x), int(y) 62 63 64def draw_text(img, x, y, text_str: str, color_code): 65 """Helper function to call opencv's drawing function, 66 to improve code readability in node's run() method. 67 """ 68 cv2.putText( 69 img=img, 70 text=text_str, 71 org=(x, y), 72 fontFace=cv2.FONT_HERSHEY_SIMPLEX, 73 fontScale=0.4, 74 color=color_code, 75 thickness=2, 76 ) 77 78 79class Node(AbstractNode): 80 """Custom node to display keypoints and count number of hand waves 81 82 Args: 83 config (:obj:`Dict[str, Any]` | :obj:`None`): Node configuration. 84 """ 85 86 def __init__(self, config: Dict[str, Any] = None, **kwargs: Any) -> None: 87 super().__init__(config, node_path=__name__, **kwargs) 88 # setup object working variables 89 self.right_wrist = None 90 self.direction = None 91 self.num_direction_changes = 0 92 self.num_waves = 0 93 94 def run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: # type: ignore 95 """This node draws keypoints and count hand waves. 96 97 Args: 98 inputs (dict): Dictionary with keys 99 "img", "bboxes", "bbox_scores", "keypoints", "keypoint_scores". 100 101 Returns: 102 outputs (dict): Empty dictionary. 103 """ 104 105 # get required inputs from pipeline 106 img = inputs["img"] 107 bboxes = inputs["bboxes"] 108 bbox_scores = inputs["bbox_scores"] 109 keypoints = inputs["keypoints"] 110 keypoint_scores = inputs["keypoint_scores"] 111 112 img_size = (img.shape[1], img.shape[0]) # image width, height 113 114 # get bounding box confidence score and draw it at the 115 # left-bottom (x1, y2) corner of the bounding box (offset by 30 pixels) 116 the_bbox = bboxes[0] # image only has one person 117 the_bbox_score = bbox_scores[0] # only one set of scores 118 119 x1, y1, x2, y2 = map_bbox_to_image_coords(the_bbox, img_size) 120 score_str = f"BBox {the_bbox_score:0.2f}" 121 cv2.putText( 122 img=img, 123 text=score_str, 124 org=(x1, y2 - 30), # offset by 30 pixels 125 fontFace=cv2.FONT_HERSHEY_SIMPLEX, 126 fontScale=1.0, 127 color=WHITE, 128 thickness=3, 129 ) 130 131 # hand wave detection using a simple heuristic of tracking the 132 # right wrist movement 133 the_keypoints = keypoints[0] # image only has one person 134 the_keypoint_scores = keypoint_scores[0] # only one set of scores 135 right_wrist = None 136 right_shoulder = None 137 138 for i, keypoints in enumerate(the_keypoints): 139 keypoint_score = the_keypoint_scores[i] 140 141 if keypoint_score >= THRESHOLD: 142 x, y = map_keypoint_to_image_coords(keypoints.tolist(), img_size) 143 x_y_str = f"({x}, {y})" 144 145 if i == KP_RIGHT_SHOULDER: 146 right_shoulder = keypoints 147 the_color = YELLOW 148 elif i == KP_RIGHT_WRIST: 149 right_wrist = keypoints 150 the_color = YELLOW 151 else: # generic keypoint 152 the_color = WHITE 153 154 draw_text(img, x, y, x_y_str, the_color) 155 156 if right_wrist is not None and right_shoulder is not None: 157 # only count number of hand waves after we have gotten the 158 # skeletal poses for the right wrist and right shoulder 159 if self.right_wrist is None: 160 self.right_wrist = right_wrist # first wrist data point 161 else: 162 # wait for wrist to be above shoulder to count hand wave 163 if right_wrist[1] > right_shoulder[1]: 164 pass 165 else: 166 if right_wrist[0] < self.right_wrist[0]: 167 direction = "left" 168 else: 169 direction = "right" 170 171 if self.direction is None: 172 self.direction = direction # first direction data point 173 else: 174 # check if hand changes direction 175 if direction != self.direction: 176 self.num_direction_changes += 1 177 # every two hand direction changes == one wave 178 if self.num_direction_changes >= 2: 179 self.num_waves += 1 180 self.num_direction_changes = 0 # reset direction count 181 182 self.right_wrist = right_wrist # save last position 183 self.direction = direction 184 185 wave_str = f"#waves = {self.num_waves}" 186 draw_text(img, 20, 30, wave_str, YELLOW) 187 188 return {}
This (long) piece of code implements our custom
dabble
node. It defines three helper functions to convert relative to absolute coordinates and to draw text on-screen. The number of hand waves is displayed at the top-left corner of the screen.A simple heuristic is used to count the number of times the person waves his hand. It tracks the direction in which the right wrist is moving and notes when the wrist changes direction. Upon encountering two direction changes, e.g., left -> right -> left, one wave is counted.
The heuristic also waits until the right wrist has been lifted above the right shoulder before it starts tracking hand direction and counting waves.
pipeline_config.yml:
1nodes: 2- input.visual: 3 source: wave.mp4 4- model.yolo 5- model.posenet 6- dabble.fps 7- custom_nodes.dabble.wave 8- draw.poses 9- draw.legend: 10 show: ["fps"] 11- output.screen
We modify
pipeline_config.yml
to run both the object detection and pose estimation models to obtain the required inputs for our customdabble
node.
Execute peekingduck run to see your custom node in action.
Note
Royalty free video of man waving from: https://www.youtube.com/watch?v=IKj_z2hgYUM
Recipe 3: Debugging
When working with PeekingDuck’s pipeline, you may sometimes wonder what is available in the data pool, or whether a particular data object has been correctly computed. This tutorial will show you how to use a custom node to help with troubleshooting and debugging PeekingDuck’s pipeline.
Continuing from the above tutorial, create a new dabble.debug
custom node:
Terminal Session
The updated folder structure is:
wave_project/ ⠀ ├── pipeline_config.yml ├── src ⠀ │ └── custom_nodes ⠀ │ ├── configs ⠀ │ │ └── dabble ⠀ │ │ ├── debug.yml │ │ └── wave.yml │ └── dabble ⠀ │ ├── debug.py │ └── wave.py └── wave.mp4
Make the following three changes:
Define
debug.yml
to receive “all” inputs from the pipeline, as follows:1# Mandatory configs 2input: ["all"] 3output: ["none"] 4 5# No optional configs
Update
debug.py
as shown below:Show/Hide Code for debug.py
1""" 2A custom node for debugging 3""" 4 5from typing import Any, Dict 6 7from peekingduck.pipeline.nodes.abstract_node import AbstractNode 8 9 10class Node(AbstractNode): 11 """This is a simple example of creating a custom node to help with debugging. 12 13 Args: 14 config (:obj:`Dict[str, Any]` | :obj:`None`): Node configuration. 15 """ 16 17 def __init__(self, config: Dict[str, Any] = None, **kwargs: Any) -> None: 18 super().__init__(config, node_path=__name__, **kwargs) 19 20 def run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: # type: ignore 21 """A simple debugging custom node 22 23 Args: 24 inputs (dict): "all", to view everything in data pool 25 26 Returns: 27 outputs (dict): "none" 28 """ 29 30 self.logger.info("-- debug --") 31 # show what is available in PeekingDuck's data pool 32 self.logger.info(f"input.keys={list(inputs.keys())}") 33 # debug specific data: bboxes 34 bboxes = inputs["bboxes"] 35 bbox_labels = inputs["bbox_labels"] 36 bbox_scores = inputs["bbox_scores"] 37 self.logger.info(f"num bboxes={len(bboxes)}") 38 for i, bbox in enumerate(bboxes): 39 label, score = bbox_labels[i], bbox_scores[i] 40 self.logger.info(f"bbox {i}:") 41 self.logger.info(f" label={label}, score={score:0.2f}") 42 self.logger.info(f" coords={bbox}") 43 44 return {} # no outputs
The custom node code shows how to see what is available in PeekingDuck’s pipeline data pool by printing the input dictionary keys. It also demonstrates how to debug a specific data object, such as bboxes, by printing relevant information for each item within the data.
Update
pipeline_config.yml
:1nodes: 2- input.visual: 3 source: wave.mp4 4- model.yolo 5- model.posenet 6- dabble.fps 7- custom_nodes.dabble.wave 8- custom_nodes.dabble.debug 9- draw.poses 10- draw.legend: 11 show: ["fps"] 12- output.screen
Now, do a peekingduck run and you should see a sample debug output like the one below:
Terminal Session
Other Recipes to Create Custom Nodes
This section describes two faster ways to create custom nodes for users who are familiar with PeekingDuck.
CLI Recipe
You can skip the step-by-step prompts from peekingduck create-node by specifying all the options on the command line, for instance:
Terminal Session
The above is the equivalent of the tutorial Recipe 1: Object Detection Score custom node creation. For more information, see peekingduck create-node --help.
Pipeline Recipe
PeekingDuck can also create custom nodes by parsing your pipeline configuration file. Starting with the basic folder structure from peekingduck init:
wave_project/ ⠀ ├── pipeline_config.yml ├── src ⠀ │ └── custom_nodes ⠀ │ └── configs ⠀ └── wave.mp4and the following modified
pipeline_config.yml
file:1nodes: 2- input.visual: 3 source: wave.mp4 4- model.yolo 5- model.posenet 6- dabble.fps 7- custom_nodes.dabble.wave 8- custom_nodes.dabble.debug 9- draw.poses 10- draw.legend: 11 show: ["fps"] 12- output.screen
You can tell PeekingDuck to parse your pipeline file with peekingduck create-node --config_path pipeline_config.yml:
Terminal Session
[~user/wave_project] > peekingduck create-node --config_path pipeline_config.yml2022-03-14 11:21:21 peekingduck.cli INFO: Creating custom nodes declared in ~user/wave_project/pipeline_config.yml.2022-03-14 11:21:21 peekingduck.declarative_loader INFO: Successfully loaded pipeline file.2022-03-14 11:21:21 peekingduck.cli INFO: Creating files for custom_nodes.dabble.wave:Config file: ~user/wave_project/src/custom_nodes/configs/dabble/wave.ymlScript file: ~user/wave_project/src/custom_nodes/dabble/wave.py2022-03-14 11:21:21 peekingduck.cli INFO: Creating files for custom_nodes.dabble.debug:Config file: ~user/wave_project/src/custom_nodes/configs/dabble/debug.ymlScript file: ~user/wave_project/src/custom_nodes/dabble/debug.py
PeekingDuck will read pipeline_config.yml
and create the two specified custom nodes
custom_nodes.dabble.wave
and custom_nodes.dabble.debug
.
Your folder structure will now look like this:
wave_project/ ⠀ ├── pipeline_config.yml ├── src ⠀ │ └── custom_nodes ⠀ │ ├── configs ⠀ │ │ └── dabble ⠀ │ │ ├── debug.yml │ │ └── wave.yml │ └── dabble ⠀ │ ├── debug.py │ └── wave.py └── wave.mp4
From here, you can proceed to edit the custom node configs and source files.