Plots as images

Display OpenCV-generated plots as images in the GUI.

Features:

  • viser.GuiApi.add_image() for displaying plot images

  • OpenCV-based plot generation and visualization

  • Real-time plot updates with image streaming

Source: examples/02_gui/10_plots_as_images.py

Plots as images

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)