Skip to content

Commit db73561

Browse files
committed
Merge branch 'main' into s2025
2 parents e3ad757 + 5370709 commit db73561

6 files changed

Lines changed: 334 additions & 2 deletions

File tree

.gitignore

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ cython_debug/
167167
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
168168
# and can be added to the global gitignore or merged into this file. For a more nuclear
169169
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
170+
170171
.idea/
171172

172173
# ZED run files
@@ -180,7 +181,8 @@ setup/zed_sdk.run
180181
cuda/
181182
homework/yolov8n.pt
182183
homework/yolo11n.pt
183-
GEMstack/knowledge/detection/yolov8n.pt
184-
GEMstack/knowledge/detection/yolo11n.pt
185184
yolov8n.pt
186185
yolo11n.pt
186+
187+
# Computation Graph of Launch File Outputs
188+
launch_visualization/graph

launch_visualization/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# GEMstack Launch File Visualizer
2+
3+
## Usage
4+
5+
6+
```python visualize_graph.py <launch_file.yaml> [OPTIONS]```
7+
8+
## Options
9+
10+
| Flag | Description |
11+
|-----------------------|---------------------------------------------------------------------------------------------------------------|
12+
| `-g, --graph <PATH>` | Path to `computation_graph.yaml` (default: `~/GEMstack/knowledge/defaults/computation_graph.yaml`) |
13+
| `-v, --variant <NAME>`| Specific variant to visualize (e.g. `sim`, `fake_sim`). Omit to render **all** variants. |
14+
| `-o, --output <PATH>` | Output file or directory (default: `graph`). If a directory, generates one PNG per variant inside it. |
15+
16+
## Examples
17+
18+
### Render all variants to a folder
19+
20+
```python visualize_graph.py fixed_route.yaml -o out/```
21+
22+
This produces:
23+
24+
-> out/fixed_route_base_vis.png
25+
26+
![Fixed Route Base Visualization](examples/fixed_route_base_vis.png)
27+
28+
-> out/fixed_route_sim_vis.png
29+
30+
![Fixed Route Simulation Visualization](examples/fixed_route_sim_vis.png)
31+
32+
33+
34+
35+
36+
37+
174 KB
Loading
242 KB
Loading
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
graphviz
2+
pyyaml
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
#!/usr/bin/env python3
2+
"""
3+
visualize_graph.py
4+
5+
Static visualization of a GEMstack launch’s active components (with implementations & args)
6+
against the ground-truth computation graph as Graphviz PNGs.
7+
8+
Features:
9+
• Annotates description, mode, vehicle_interface, mission_execution/run
10+
• Green = pure source (no inputs)
11+
• Red = pure sink (no outputs)
12+
• Clusters for drive→perception, drive→planning, visualization
13+
• Pseudo-node “all” for unmatched inputs/outputs
14+
• If no variant specified, outputs one PNG per variant into an output folder
15+
• Filenames prefixed by the launch YAML basename
16+
• Safe lookups to avoid KeyError on unexpected keys
17+
"""
18+
19+
import argparse
20+
import os
21+
import yaml
22+
import json
23+
from graphviz import Digraph
24+
25+
def load_yaml(path):
26+
base = os.path.dirname(os.path.abspath(path))
27+
class Loader(yaml.SafeLoader): pass
28+
def include(loader, node):
29+
return load_yaml(os.path.join(base, loader.construct_scalar(node)))
30+
def relpath(loader, node):
31+
return os.path.join(base, loader.construct_scalar(node))
32+
Loader.add_constructor('!include', include)
33+
Loader.add_constructor('!relative_path', relpath)
34+
with open(path, 'r') as f:
35+
return yaml.load(f, Loader)
36+
37+
def normalize_list(x):
38+
if x is None: return []
39+
if isinstance(x, list): return x
40+
return [x]
41+
42+
def collect_components(gt_graph):
43+
comps = {}
44+
for entry in gt_graph.get('components', []):
45+
for name, io in entry.items():
46+
comps[name] = {
47+
'inputs': normalize_list(io.get('inputs')),
48+
'outputs': normalize_list(io.get('outputs')),
49+
}
50+
return comps
51+
52+
def deep_merge(a, b):
53+
for k, v in b.items():
54+
if k in a and isinstance(a[k], dict) and isinstance(v, dict):
55+
deep_merge(a[k], v)
56+
else:
57+
a[k] = v
58+
59+
def resolve_variant(launch, variant):
60+
if not variant:
61+
return launch
62+
vs = launch.get('variants', {})
63+
if variant not in vs:
64+
raise KeyError(f"Variant '{variant}' not found")
65+
merged = yaml.safe_load(yaml.dump(launch))
66+
deep_merge(merged, vs[variant])
67+
return merged
68+
69+
def apply_run_overrides(spec):
70+
run = spec.get('run', {})
71+
if 'description' in run:
72+
spec['description'] = run['description']
73+
if 'mode' in run:
74+
spec['mode'] = run['mode']
75+
if 'vehicle_interface' in run:
76+
spec['vehicle_interface'] = run['vehicle_interface']
77+
if 'mission_execution' in run:
78+
spec['mission_execution'] = run['mission_execution']
79+
if 'drive' in run:
80+
deep_merge(spec.setdefault('drive', {}), run['drive'])
81+
if 'visualization' in run:
82+
spec['visualization'] = run['visualization']
83+
84+
def gather_active(spec, comps_def):
85+
raw = set()
86+
def recurse(d):
87+
for k, v in d.items():
88+
if isinstance(v, str) or (isinstance(v, dict) and 'type' in v):
89+
raw.add(k)
90+
elif isinstance(v, dict):
91+
recurse(v)
92+
recurse(spec.get('drive', {}))
93+
recurse(spec.get('visualization', {}))
94+
return {c for c in raw if c in comps_def}
95+
96+
def collect_impls_and_args(spec, comps_def):
97+
impls = {}
98+
def recurse(d):
99+
for k, v in d.items():
100+
if isinstance(v, str) and k in comps_def:
101+
impls[k] = {'impl': v, 'args': None}
102+
elif isinstance(v, dict) and 'type' in v and k in comps_def:
103+
impls[k] = {'impl': v['type'], 'args': v.get('args')}
104+
elif isinstance(v, dict):
105+
recurse(v)
106+
recurse(spec.get('drive', {}))
107+
recurse(spec.get('visualization', {}))
108+
return impls
109+
110+
def get_drive_clusters(spec, active):
111+
clusters = {}
112+
for grp, cfg in spec.get('drive', {}).items():
113+
if isinstance(cfg, dict):
114+
comps = [c for c in cfg if c in active]
115+
if comps:
116+
clusters[grp] = comps
117+
return clusters
118+
119+
def _add_node(dot, name, comps_def, impls):
120+
inputs = comps_def.get(name, {}).get('inputs', [])
121+
outputs = comps_def.get(name, {}).get('outputs', [])
122+
style = {}
123+
if not inputs:
124+
style = {'style': 'filled', 'fillcolor': 'lightgreen'}
125+
elif not outputs:
126+
style = {'style': 'filled', 'fillcolor': 'lightcoral'}
127+
impl = impls.get(name, {}).get('impl', '<none>')
128+
args = impls.get(name, {}).get('args')
129+
label = f"{name}\\n[{impl}]"
130+
if args is not None:
131+
label += "\\n" + json.dumps(args)
132+
dot.node(name, label, **style)
133+
134+
def build_static(comps_def, active, impls, spec):
135+
dot = Digraph(comment='Computation Graph')
136+
dot.attr(
137+
rankdir='LR',
138+
margin='1.0,0.5',
139+
nodesep='1.0',
140+
ranksep='1.0'
141+
)
142+
143+
# Top annotation
144+
meta = []
145+
if 'description' in spec:
146+
meta.append(spec['description'])
147+
if 'mode' in spec:
148+
meta.append(f"mode: {spec['mode']}")
149+
vi = spec.get('vehicle_interface')
150+
if isinstance(vi, str):
151+
meta.append(f"vehicle_interface: {vi}")
152+
elif isinstance(vi, dict):
153+
meta.append(
154+
f"vehicle_interface: {vi.get('type')} "
155+
f"{json.dumps(vi.get('args')) if vi.get('args') else ''}"
156+
)
157+
if 'mission_execution' in spec:
158+
meta.append(f"mission_execution: {spec['mission_execution']}")
159+
dot.attr(label='\n'.join(meta) + '\n\n', labelloc='t', fontsize='14')
160+
161+
# Pseudo-node 'all'
162+
unmatched_in = {
163+
inp for c in active
164+
for inp in comps_def.get(c, {}).get('inputs', [])
165+
if inp != 'all' and not any(
166+
inp in comps_def.get(p, {}).get('outputs', []) for p in active
167+
)
168+
}
169+
unmatched_out = {
170+
outp for c in active
171+
for outp in comps_def.get(c, {}).get('outputs', [])
172+
if not any(
173+
outp in comps_def.get(c2, {}).get('inputs', []) for c2 in active
174+
)
175+
}
176+
needs_all = bool(
177+
unmatched_in or unmatched_out or
178+
any('all' in comps_def.get(c, {}).get('inputs', []) for c in active)
179+
)
180+
if needs_all:
181+
dot.node('all', 'all', style='filled', fillcolor='lightblue')
182+
183+
clustered = set()
184+
# drive clusters
185+
for grp, nodes in get_drive_clusters(spec, active).items():
186+
with dot.subgraph(name=f'cluster_{grp}') as c:
187+
c.attr(
188+
label=grp.capitalize(),
189+
style='rounded,filled',
190+
color='lightgrey',
191+
margin='0.5'
192+
)
193+
for comp in nodes:
194+
clustered.add(comp)
195+
_add_node(c, comp, comps_def, impls)
196+
197+
# visualization cluster
198+
if 'visualization' in spec:
199+
with dot.subgraph(name='cluster_visualization') as c:
200+
c.attr(
201+
label='Visualization',
202+
style='rounded,filled',
203+
color='lightgrey',
204+
margin='0.5'
205+
)
206+
for comp in spec.get('visualization', {}):
207+
if comp in active:
208+
clustered.add(comp)
209+
_add_node(c, comp, comps_def, impls)
210+
211+
# remaining nodes
212+
for comp in sorted(active):
213+
if comp in clustered:
214+
continue
215+
_add_node(dot, comp, comps_def, impls)
216+
217+
# edges inputs→component
218+
for comp in active:
219+
for inp in comps_def.get(comp, {}).get('inputs', []):
220+
if inp == 'all' or inp in unmatched_in:
221+
dot.edge(inp, comp, label=inp)
222+
else:
223+
for prod in (
224+
p for p in active
225+
if inp in comps_def.get(p, {}).get('outputs', [])
226+
):
227+
dot.edge(prod, comp, label=inp)
228+
229+
# edges unmatched outputs→all
230+
for comp in active:
231+
for outp in comps_def.get(comp, {}).get('outputs', []):
232+
if outp in unmatched_out:
233+
dot.edge(comp, 'all', label=outp)
234+
235+
return dot
236+
237+
def main():
238+
parser = argparse.ArgumentParser(
239+
description="Static visualization of GEMstack computation graph"
240+
)
241+
parser.add_argument('launch', help="Path to launch YAML")
242+
parser.add_argument(
243+
'-g','--graph',
244+
default=os.path.expanduser(
245+
'../GEMstack/knowledge/defaults/computation_graph.yaml'
246+
)
247+
)
248+
parser.add_argument(
249+
'-v','--variant',
250+
help="Variant to render (omit to render all)"
251+
)
252+
parser.add_argument(
253+
'-o','--output',
254+
default='graph',
255+
help="Output file or folder"
256+
)
257+
args = parser.parse_args()
258+
259+
gt = load_yaml(args.graph)
260+
launch = load_yaml(args.launch)
261+
comps_def = collect_components(gt)
262+
263+
launch_prefix = os.path.splitext(os.path.basename(args.launch))[0]
264+
variants = [v for v in launch.get('variants', {}) if v != 'log_ros']
265+
to_render = [args.variant] if args.variant else ['base'] + variants
266+
267+
multiple = os.path.isdir(args.output) or len(to_render) > 1
268+
if multiple:
269+
os.makedirs(args.output, exist_ok=True)
270+
271+
for vn in to_render:
272+
spec = resolve_variant(launch, None if vn == 'base' else vn)
273+
apply_run_overrides(spec)
274+
active = gather_active(spec, comps_def)
275+
impls = collect_impls_and_args(spec, comps_def)
276+
dot = build_static(comps_def, active, impls, spec)
277+
278+
filename = f"{launch_prefix}_{vn}_vis"
279+
if multiple:
280+
root = os.path.join(args.output, filename)
281+
ext = 'png'
282+
else:
283+
root = filename
284+
ext = args.output.split('.')[-1] if '.' in args.output else 'png'
285+
286+
dot.format = ext
287+
out_path = dot.render(root, cleanup=True, view=not multiple)
288+
print(f"✔ Wrote {out_path}")
289+
290+
if __name__ == '__main__':
291+
main()

0 commit comments

Comments
 (0)