Nav2 Navigation Stack - lyrical  lyrical
ROS 2 Navigation Stack
bt2img.py
1 #!/usr/bin/python3
2 # Copyright (c) 2019 Intel Corporation
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at
7 #
8 # http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15 
16 # This tool converts a behavior tree XML file to a PNG image. Run bt2img.py -h
17 # for instructions
18 
19 import argparse
20 import logging
21 import os
22 import xml.etree.ElementTree as ET
23 
24 import graphviz # pip3 install graphviz
25 
26 control_nodes = [
27  'Fallback',
28  'Parallel',
29  'ReactiveFallback',
30  'ReactiveSequence',
31  'Sequence',
32  'SequenceWithMemory',
33  'BlackboardCheckInt',
34  'BlackboardCheckDouble',
35  'BlackboardCheckString',
36  'ForceFailure',
37  'ForceSuccess',
38  'Inverter',
39  'Repeat',
40  'Subtree',
41  'Timeout',
42  'RecoveryNode',
43  'PipelineSequence',
44  'RoundRobin',
45  'Control',
46 ]
47 action_nodes = [
48  'AlwaysFailure',
49  'AlwaysSuccess',
50  'SetBlackboard',
51  'ComputePathToPose',
52  'FollowPath',
53  'BackUp',
54  'Spin',
55  'Wait',
56  'ClearEntireCostmap',
57  'ReinitializeGlobalLocalization',
58  'Action',
59 ]
60 condition_nodes = [
61  'IsStuck',
62  'GoalReached',
63  'initialPoseReceived',
64  'GoalUpdated',
65  'DistanceTraveled',
66  'TimeExpired',
67  'TransformAvailable',
68  'Condition',
69 ]
70 decorator_nodes = [
71  'Decorator',
72  'RateController',
73  'DistanceController',
74  'SpeedController',
75 ]
76 subtree_nodes = [
77  'SubTree',
78 ]
79 
80 
81 def resolve_ros_package_path(ros_pkg: str, path: str) -> str | None:
82  """
83  Resolve a ROS package path to an actual filesystem path.
84 
85  For example, if you have:
86  <include ros_pkg="nav2_bt_navigator" path="behavior_trees/navigate_to_pose.xml"/>
87 
88  This function returns the actual filesystem path.
89  """
90  try:
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}')
96  return None
97  except PackageNotFoundError as e:
98  logging.error(f'ROS package "{ros_pkg}" not found: {e}')
99  return None
100 
101 
102 def load_includes(
103  xml_element: ET.Element,
104  base_dir: str,
105  processed_files: set[str] | None = None,
106 ) -> ET.Element:
107  """
108  Recursively load and merge included XML files into the tree.
109 
110  For example:
111  <include ros_pkg="nav2_bt_navigator" path="behavior_trees/navigate_to_pose.xml"/>
112 
113  This function:
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
118  """
119  if processed_files is None:
120  processed_files = set()
121 
122  # Get all <include> elements
123  includes = [elem for elem in xml_element if elem.tag == 'include']
124 
125  for include in includes:
126  ros_pkg = include.get('ros_pkg')
127  path = include.get('path')
128 
129  # Resolve the path
130  if ros_pkg and path:
131  include_path = resolve_ros_package_path(ros_pkg, path)
132  else:
133  include_path = os.path.join(base_dir, path) if path else None
134 
135  if include_path:
136  include_path = os.path.abspath(include_path)
137 
138  # Check if we already processed this file (prevent infinite loops)
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)
143  continue
144 
145  processed_files.add(include_path)
146 
147  # Load file if it exists
148  if include_path and os.path.exists(include_path):
149  try:
150  included_tree = ET.parse(include_path)
151  included_root = included_tree.getroot()
152  # Recursively load includes in this file first
153  included_base_dir = os.path.dirname(include_path)
154  load_includes(included_root, included_base_dir, processed_files)
155  # Copy all <BehaviorTree> elements from this file
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}')
160  else:
161  if path:
162  if ros_pkg:
163  file_desc = f'{ros_pkg}/{path}'
164  else:
165  file_desc = path
166  print(f'Warning: Could not resolve included file {file_desc}')
167 
168  # Remove the <include> element
169  if include in xml_element:
170  xml_element.remove(include)
171 
172  return xml_element
173 
174 
175 def main() -> None:
176  args = parse_command_line()
177  xml_tree = ET.parse(args.behavior_tree)
178  root = xml_tree.getroot()
179  # Process includes before parsing the tree structure
180  base_dir = os.path.dirname(os.path.abspath(args.behavior_tree))
181  load_includes(root, base_dir, set())
182 
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)
186  if args.legend:
187  legend = make_legend()
188  legend.format = 'png'
189  legend.render(args.legend)
190  dot.format = 'png'
191  if args.save_dot:
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)
195 
196 
197 def parse_command_line() -> argparse.Namespace:
198  parser = argparse.ArgumentParser(
199  description='Convert a behavior tree XML file to an image'
200  )
201  parser.add_argument(
202  '--behavior_tree',
203  required=True,
204  help='the behavior tree XML file to convert to an image',
205  )
206  parser.add_argument(
207  '--image_out',
208  required=True,
209  help='The name of the output image file. Leave off the .png extension',
210  )
211  parser.add_argument(
212  '--display',
213  action='store_true',
214  help='If specified, opens the image in the default viewer',
215  )
216  parser.add_argument(
217  '--save_dot',
218  type=argparse.FileType('w'),
219  help='Saves the intermediate dot source to the specified file',
220  )
221  parser.add_argument('--legend', help='Generate a legend image as well')
222  return parser.parse_args()
223 
224 
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')
230  return main_tree
231 
232 
233 def find_behavior_tree(xml_tree: ET.ElementTree, tree_name: str) -> ET.Element:
234  trees = xml_tree.findall('BehaviorTree')
235  if len(trees) == 0:
236  raise RuntimeError('No behavior trees were found in the XML file')
237 
238  for tree in trees:
239  if tree_name == tree.get('ID'):
240  return tree
241 
242  raise RuntimeError(f'No behavior tree for name {tree_name} found in the XML file')
243 
244 
245 # Generate a dot description of the root of the behavior tree.
246 def convert2dot(behavior_tree: ET.Element, xml_tree: ET.ElementTree) -> graphviz.Digraph:
247  dot = graphviz.Digraph()
248  root = behavior_tree
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)
252  return dot
253 
254 
255 # Recursive function. We add the children to the dot file, and then recursively
256 # call this function on the children. Nodes are given an ID that is the hash
257 # of the node to ensure each is unique.
258 def convert_subtree(
259  dot: graphviz.Digraph,
260  parent_node: ET.Element,
261  parent_dot_name: str,
262  xml_tree: ET.ElementTree,
263 ) -> None:
264  if parent_node.tag == 'SubTree':
265  add_sub_tree(dot, parent_dot_name, parent_node, xml_tree)
266  else:
267  add_nodes(dot, parent_dot_name, parent_node, xml_tree)
268 
269 
270 def add_sub_tree(
271  dot: graphviz.Digraph,
272  parent_dot_name: str,
273  parent_node: ET.Element,
274  xml_tree: ET.ElementTree,
275 ) -> None:
276  subtree_id = parent_node.get('ID')
277  if subtree_id is None:
278  raise RuntimeError('SubTree node has no ID attribute')
279 
280  # Create a unique dot node for this SubTree element
281  subtree_dot_name = str(hash(parent_node))
282  dot.node(
283  subtree_dot_name,
284  f'SubTree: {subtree_id}',
285  color=node_color('SubTree'),
286  style='filled',
287  shape='box'
288  )
289  dot.edge(parent_dot_name, subtree_dot_name)
290 
291  # Try to expand it if present, otherwise leave it as a leaf
292  try:
293  behavior_tree = find_behavior_tree(xml_tree, subtree_id)
294  except RuntimeError:
295  # Subtree definition not found in the loaded XML; it may be missing
296  # entirely or expected from an <include> that was not loaded or does
297  # not contain the requested BehaviorTree.
298  return
299 
300  # Recurse into the referenced tree
301  convert_subtree(dot, behavior_tree, subtree_dot_name, xml_tree)
302 
303 
304 def add_nodes(
305  dot: graphviz.Digraph,
306  parent_dot_name: str,
307  parent_node: ET.Element,
308  xml_tree: ET.ElementTree,
309 ) -> None:
310  for node in list(parent_node):
311  label = make_label(node)
312  dot.node(
313  str(hash(node)),
314  label,
315  color=node_color(node.tag),
316  style='filled',
317  shape='box',
318  )
319  dot_name = str(hash(node))
320  dot.edge(parent_dot_name, dot_name)
321  convert_subtree(dot, node, dot_name, xml_tree)
322 
323 
324 # The node label contains the:
325 # type, the name if provided, and the parameters.
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')
330  if name:
331  label += f"<tr><td align='text'><b>{name}</b></td></tr>"
332 
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> >'
336  return label
337 
338 
339 def node_color(node_type: str) -> str:
340  if node_type in control_nodes:
341  return 'chartreuse4'
342  if node_type in action_nodes:
343  return 'cornflowerblue'
344  if node_type in condition_nodes:
345  return 'yellow2'
346  if node_type in decorator_nodes:
347  return 'darkorange1'
348  if node_type in subtree_nodes:
349  return 'darkorchid1'
350  # else it's unknown
351  return 'grey'
352 
353 
354 # creates a legend which can be provided with the other images.
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')
359  legend.node(
360  'Action', 'Action Node', shape='box', style='filled', color='cornflowerblue'
361  )
362  legend.node(
363  'Condition', 'Condition Node', shape='box', style='filled', color='yellow2'
364  )
365  legend.node(
366  'Control', 'Control Node', shape='box', style='filled', color='chartreuse4'
367  )
368 
369  return legend
370 
371 
372 if __name__ == '__main__':
373  main()