Plots as images¶
Display OpenCV-generated plots as images in the GUI.
Features:
viser.GuiApi.add_image()
for displaying plot imagesOpenCV-based plot generation and visualization
Real-time plot updates with image streaming
Source: examples/02_gui/10_plots_as_images.py

Code¶
1from __future__ import annotations
2
3import colorsys
4import time
5
6import cv2
7import numpy as np
8import tyro
9
10import viser
11import viser.transforms as vtf
12
13
14def get_line_plot(
15 xs: np.ndarray,
16 ys: np.ndarray,
17 height: int,
18 width: int,
19 *,
20 x_bounds: tuple[float, float] | None = None,
21 y_bounds: tuple[float, float] | None = None,
22 title: str | None = None,
23 line_thickness: int = 2,
24 grid_x_lines: int = 8,
25 grid_y_lines: int = 5,
26 font_scale: float = 0.4,
27 background_color: tuple[int, int, int] = (0, 0, 0),
28 plot_area_color: tuple[int, int, int] = (0, 0, 0),
29 grid_color: tuple[int, int, int] = (60, 60, 60),
30 axes_color: tuple[int, int, int] = (100, 100, 100),
31 line_color: tuple[int, int, int] = (255, 255, 255),
32 text_color: tuple[int, int, int] = (200, 200, 200),
33) -> np.ndarray:
34
35 if x_bounds is None:
36 x_bounds = (np.min(xs), np.max(xs.round(decimals=4)))
37 if y_bounds is None:
38 y_bounds = (np.min(ys), np.max(ys))
39
40 # Calculate text sizes for padding.
41 font = cv2.FONT_HERSHEY_DUPLEX
42 sample_y_label = f"{max(abs(y_bounds[0]), abs(y_bounds[1])):.1f}"
43 y_text_size = cv2.getTextSize(sample_y_label, font, font_scale, 1)[0]
44
45 sample_x_label = f"{max(abs(x_bounds[0]), abs(x_bounds[1])):.1f}"
46 x_text_size = cv2.getTextSize(sample_x_label, font, font_scale, 1)[0]
47
48 # Define padding based on font scale.
49 extra_padding = 8
50 left_pad = int(y_text_size[0] * 1.5) + extra_padding # Space for y-axis labels
51 right_pad = int(10 * font_scale) + extra_padding
52
53 # Calculate top padding, accounting for title if present
54 top_pad = int(10 * font_scale) + extra_padding
55 title_font_scale = font_scale * 1.5 # Make title slightly larger
56 if title is not None:
57 title_size = cv2.getTextSize(title, font, title_font_scale, 1)[0]
58 top_pad += title_size[1] + int(10 * font_scale)
59
60 bottom_pad = int(x_text_size[1] * 2.0) + extra_padding # Space for x-axis labels
61
62 # Create larger image to accommodate padding.
63 total_height = height
64 total_width = width
65 plot_width = width - left_pad - right_pad
66 plot_height = height - top_pad - bottom_pad
67 assert plot_width > 0 and plot_height > 0
68
69 # Create image with specified background color
70 img = np.ones((total_height, total_width, 3), dtype=np.uint8)
71 img[:] = background_color
72
73 # Create plot area with specified color
74 plot_area = np.ones((plot_height, plot_width, 3), dtype=np.uint8)
75 plot_area[:] = plot_area_color
76 img[top_pad : top_pad + plot_height, left_pad : left_pad + plot_width] = plot_area
77
78 def scale_to_pixels(values, bounds, pixels):
79 min_val, max_val = bounds
80 normalized = (values - min_val) / (max_val - min_val)
81 return (normalized * (pixels - 1)).astype(np.int32)
82
83 # Vertical grid lines.
84 for i in range(grid_x_lines):
85 x_pos = left_pad + int(plot_width * i / (grid_x_lines - 1))
86 cv2.line(img, (x_pos, top_pad), (x_pos, top_pad + plot_height), grid_color, 1)
87
88 # Horizontal grid lines.
89 for i in range(grid_y_lines):
90 y_pos = top_pad + int(plot_height * i / (grid_y_lines - 1))
91 cv2.line(img, (left_pad, y_pos), (left_pad + plot_width, y_pos), grid_color, 1)
92
93 # Draw axes.
94 cv2.line(
95 img,
96 (left_pad, top_pad + plot_height),
97 (left_pad + plot_width, top_pad + plot_height),
98 axes_color,
99 1,
100 ) # x-axis
101 cv2.line(
102 img, (left_pad, top_pad), (left_pad, top_pad + plot_height), axes_color, 1
103 ) # y-axis
104
105 # Scale and plot the data.
106 x_scaled = scale_to_pixels(xs, x_bounds, plot_width) + left_pad
107 y_scaled = top_pad + plot_height - 1 - scale_to_pixels(ys, y_bounds, plot_height)
108 pts = np.column_stack((x_scaled, y_scaled)).reshape((-1, 1, 2))
109
110 # Draw the main plot line.
111 cv2.polylines(
112 img, [pts], False, line_color, thickness=line_thickness, lineType=cv2.LINE_AA
113 )
114
115 # Draw title if specified
116 if title is not None:
117 title_size = cv2.getTextSize(title, font, title_font_scale, 1)[0]
118 title_x = left_pad + (plot_width - title_size[0]) // 2
119 title_y = int(top_pad / 2) + title_size[1] // 2 - 1
120 cv2.putText(
121 img,
122 title,
123 (title_x, title_y),
124 font,
125 title_font_scale,
126 text_color,
127 1,
128 cv2.LINE_AA,
129 )
130
131 # X-axis labels.
132 for i in range(grid_x_lines):
133 x_val = x_bounds[0] + (x_bounds[1] - x_bounds[0]) * i / (grid_x_lines - 1)
134 x_pos = left_pad + int(plot_width * i / (grid_x_lines - 1))
135 label = f"{x_val:.1f}"
136 if label == "-0.0":
137 label = "0.0"
138 text_size = cv2.getTextSize(label, font, font_scale, 1)[0]
139 cv2.putText(
140 img,
141 label,
142 (x_pos - text_size[0] // 2, top_pad + plot_height + text_size[1] + 10),
143 font,
144 font_scale,
145 text_color,
146 1,
147 cv2.LINE_AA,
148 )
149
150 # Y-axis labels.
151 for i in range(grid_y_lines):
152 y_val = y_bounds[0] + (y_bounds[1] - y_bounds[0]) * (grid_y_lines - 1 - i) / (
153 grid_y_lines - 1
154 )
155 y_pos = top_pad + int(plot_height * i / (grid_y_lines - 1))
156 label = f"{y_val:.1f}"
157 if label == "-0.0":
158 label = "0.0"
159 text_size = cv2.getTextSize(label, font, font_scale, 1)[0]
160 cv2.putText(
161 img,
162 label,
163 (left_pad - text_size[0] - 5, y_pos + 5),
164 font,
165 font_scale,
166 text_color,
167 1,
168 cv2.LINE_AA,
169 )
170
171 return img
172
173
174def create_sine_plot(title: str, counter: int) -> np.ndarray:
175 xs = np.linspace(0, 2 * np.pi, 20)
176 rgb = colorsys.hsv_to_rgb(counter / 4000 % 1, 1, 1)
177 return get_line_plot(
178 xs=xs,
179 ys=np.sin(xs + counter / 20),
180 title=title,
181 line_color=(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)),
182 height=150,
183 width=350,
184 )
185
186
187def main(num_plots: int = 8) -> None:
188 server = viser.ViserServer()
189
190 # Create GUI elements for display runtimes.
191 with server.gui.add_folder("Runtime"):
192 draw_time = server.gui.add_text("Draw / plot (ms)", "0.00", disabled=True)
193 send_gui_time = server.gui.add_text(
194 "Gui update / plot (ms)", "0.00", disabled=True
195 )
196 send_scene_time = server.gui.add_text(
197 "Scene update / plot (ms)", "0.00", disabled=True
198 )
199
200 # Add 2D plots to the GUI.
201 with server.gui.add_folder("Plots"):
202 plots_cb = server.gui.add_checkbox("Update plots", True)
203 gui_image_handles = [
204 server.gui.add_image(
205 create_sine_plot(f"Plot {i}", counter=0),
206 label=f"Image {i}",
207 format="jpeg",
208 )
209 for i in range(num_plots)
210 ]
211
212 # Add 2D plots to the scene. We flip them with a parent coordinate frame.
213 server.scene.add_frame(
214 "/images", wxyz=vtf.SO3.from_y_radians(np.pi).wxyz, show_axes=False
215 )
216 scene_image_handles = [
217 server.scene.add_image(
218 f"/images/plot{i}",
219 image=gui_image_handles[i].image,
220 render_width=3.5,
221 render_height=1.5,
222 format="jpeg",
223 position=(
224 (i % 2 - 0.5) * 3.5,
225 (i // 2 - (num_plots - 1) / 4) * 1.5,
226 0,
227 ),
228 )
229 for i in range(num_plots)
230 ]
231
232 counter = 0
233
234 while True:
235 if plots_cb.value:
236 # Create and time the plot generation.
237 start = time.time()
238 images = [
239 create_sine_plot(f"Plot {i}", counter=counter * (i + 1))
240 for i in range(num_plots)
241 ]
242 draw_time.value = f"{0.98 * float(draw_time.value) + 0.02 * (time.time() - start) / num_plots * 1000:.2f}"
243
244 # Update all plot images.
245 start = time.time()
246 for i, handle in enumerate(gui_image_handles):
247 handle.image = images[i]
248 send_gui_time.value = f"{0.98 * float(send_gui_time.value) + 0.02 * (time.time() - start) / num_plots * 1000:.2f}"
249
250 # Update all scene images.
251 start = time.time()
252 for i, handle in enumerate(scene_image_handles):
253 handle.image = gui_image_handles[i].image
254 send_scene_time.value = f"{0.98 * float(send_scene_time.value) + 0.02 * (time.time() - start) / num_plots * 1000:.2f}"
255
256 # Sleep a bit before continuing.
257 time.sleep(0.02)
258 counter += 1
259
260
261if __name__ == "__main__":
262 tyro.cli(main)