22 import xml.etree.ElementTree
as ET
34 'BlackboardCheckDouble',
35 'BlackboardCheckString',
57 'ReinitializeGlobalLocalization',
63 'initialPoseReceived',
81 def resolve_ros_package_path(ros_pkg: str, path: str) -> str |
None:
83 Resolve a ROS package path to an actual filesystem path.
85 For example, if you have:
86 <include ros_pkg="nav2_bt_navigator" path="behavior_trees/navigate_to_pose.xml"/>
88 This function returns the actual filesystem path.
91 from ament_index_python.packages
import get_package_share_directory, PackageNotFoundError
92 pkg_share_dir = get_package_share_directory(ros_pkg)
93 return os.path.join(pkg_share_dir, path)
94 except ImportError
as e:
95 logging.error(f
'Failed to import ament_index_python: {e}')
97 except PackageNotFoundError
as e:
98 logging.error(f
'ROS package "{ros_pkg}" not found: {e}')
103 xml_element: ET.Element,
105 processed_files: set[str] |
None =
None,
108 Recursively load and merge included XML files into the tree.
111 <include ros_pkg="nav2_bt_navigator" path="behavior_trees/navigate_to_pose.xml"/>
114 1. Finds all <include> tags
115 2. Loads those XML files
116 3. Copies <BehaviorTree> elements from them into our XML
117 4. Removes the <include> tag
119 if processed_files
is None:
120 processed_files = set()
123 includes = [elem
for elem
in xml_element
if elem.tag ==
'include']
125 for include
in includes:
126 ros_pkg = include.get(
'ros_pkg')
127 path = include.get(
'path')
131 include_path = resolve_ros_package_path(ros_pkg, path)
133 include_path = os.path.join(base_dir, path)
if path
else None
136 include_path = os.path.abspath(include_path)
139 if include_path
in processed_files:
140 print(f
'Warning: Circular include detected for {include_path}, skipping')
141 if include
in xml_element:
142 xml_element.remove(include)
145 processed_files.add(include_path)
148 if include_path
and os.path.exists(include_path):
150 included_tree = ET.parse(include_path)
151 included_root = included_tree.getroot()
153 included_base_dir = os.path.dirname(include_path)
154 load_includes(included_root, included_base_dir, processed_files)
156 for behavior_tree
in included_root.findall(
'BehaviorTree'):
157 xml_element.append(behavior_tree)
158 except (ET.ParseError, OSError)
as e:
159 print(f
'Warning: Could not load included file {include_path}: {e}')
163 file_desc = f
'{ros_pkg}/{path}'
166 print(f
'Warning: Could not resolve included file {file_desc}')
169 if include
in xml_element:
170 xml_element.remove(include)
176 args = parse_command_line()
177 xml_tree = ET.parse(args.behavior_tree)
178 root = xml_tree.getroot()
180 base_dir = os.path.dirname(os.path.abspath(args.behavior_tree))
181 load_includes(root, base_dir, set())
183 root_tree_name = find_root_tree_name(xml_tree)
184 behavior_tree = find_behavior_tree(xml_tree, root_tree_name)
185 dot = convert2dot(behavior_tree, xml_tree)
187 legend = make_legend()
188 legend.format =
'png'
189 legend.render(args.legend)
192 print(f
'Saving dot to {args.save_dot}')
193 args.save_dot.write(dot.source)
194 dot.render(args.image_out, view=args.display)
197 def parse_command_line() -> argparse.Namespace:
198 parser = argparse.ArgumentParser(
199 description=
'Convert a behavior tree XML file to an image'
204 help=
'the behavior tree XML file to convert to an image',
209 help=
'The name of the output image file. Leave off the .png extension',
214 help=
'If specified, opens the image in the default viewer',
218 type=argparse.FileType(
'w'),
219 help=
'Saves the intermediate dot source to the specified file',
221 parser.add_argument(
'--legend', help=
'Generate a legend image as well')
222 return parser.parse_args()
225 def find_root_tree_name(xml_tree: ET.ElementTree) -> str:
226 root = xml_tree.getroot()
227 main_tree = root.get(
'main_tree_to_execute')
228 if main_tree
is None:
229 raise RuntimeError(
'No main_tree_to_execute attribute found in XML root')
233 def find_behavior_tree(xml_tree: ET.ElementTree, tree_name: str) -> ET.Element:
234 trees = xml_tree.findall(
'BehaviorTree')
236 raise RuntimeError(
'No behavior trees were found in the XML file')
239 if tree_name == tree.get(
'ID'):
242 raise RuntimeError(f
'No behavior tree for name {tree_name} found in the XML file')
246 def convert2dot(behavior_tree: ET.Element, xml_tree: ET.ElementTree) -> graphviz.Digraph:
247 dot = graphviz.Digraph()
249 parent_dot_name = str(hash(root))
250 dot.node(parent_dot_name, root.get(
'ID'), shape=
'box')
251 convert_subtree(dot, root, parent_dot_name, xml_tree)
259 dot: graphviz.Digraph,
260 parent_node: ET.Element,
261 parent_dot_name: str,
262 xml_tree: ET.ElementTree,
264 if parent_node.tag ==
'SubTree':
265 add_sub_tree(dot, parent_dot_name, parent_node, xml_tree)
267 add_nodes(dot, parent_dot_name, parent_node, xml_tree)
271 dot: graphviz.Digraph,
272 parent_dot_name: str,
273 parent_node: ET.Element,
274 xml_tree: ET.ElementTree,
276 subtree_id = parent_node.get(
'ID')
277 if subtree_id
is None:
278 raise RuntimeError(
'SubTree node has no ID attribute')
281 subtree_dot_name = str(hash(parent_node))
284 f
'SubTree: {subtree_id}',
285 color=node_color(
'SubTree'),
289 dot.edge(parent_dot_name, subtree_dot_name)
293 behavior_tree = find_behavior_tree(xml_tree, subtree_id)
301 convert_subtree(dot, behavior_tree, subtree_dot_name, xml_tree)
305 dot: graphviz.Digraph,
306 parent_dot_name: str,
307 parent_node: ET.Element,
308 xml_tree: ET.ElementTree,
310 for node
in list(parent_node):
311 label = make_label(node)
315 color=node_color(node.tag),
319 dot_name = str(hash(node))
320 dot.edge(parent_dot_name, dot_name)
321 convert_subtree(dot, node, dot_name, xml_tree)
326 def make_label(node: ET.Element) -> str:
327 label =
"< <table border='0' cellspacing='0' cellpadding='0'>"
328 label += f
"<tr><td align='text'><i>{node.tag}</i></td></tr>"
329 name = node.get(
'name')
331 label += f
"<tr><td align='text'><b>{name}</b></td></tr>"
333 for param_name, value
in node.items():
334 label += f
"<tr><td align='left'><sub>{param_name}={value}</sub></td></tr>"
335 label +=
'</table> >'
339 def node_color(node_type: str) -> str:
340 if node_type
in control_nodes:
342 if node_type
in action_nodes:
343 return 'cornflowerblue'
344 if node_type
in condition_nodes:
346 if node_type
in decorator_nodes:
348 if node_type
in subtree_nodes:
355 def make_legend() -> graphviz.Digraph:
356 legend = graphviz.Digraph(graph_attr={
'rankdir':
'LR'})
357 legend.attr(label=
'Legend')
358 legend.node(
'Unknown', shape=
'box', style=
'filled', color=
'grey')
360 'Action',
'Action Node', shape=
'box', style=
'filled', color=
'cornflowerblue'
363 'Condition',
'Condition Node', shape=
'box', style=
'filled', color=
'yellow2'
366 'Control',
'Control Node', shape=
'box', style=
'filled', color=
'chartreuse4'
372 if __name__ ==
'__main__':