Here’s a demo run of the code:
Background
From the VisPy website:
VisPy is a Python library for interactive scientific visualization that is designed to be fast, scalable, and easy to use.
While looking for a near real time data visualization alternative to the venerable matplotlib, I came across this jaw dropping demo:
Absolutely insane, achieving that kind of performance in python is amazing to say the least. This demo in particular seems like it would be more likely to come from a pygame application at the least, but looks more like it would be a Unity project.
The VisPy project is massive, but luckily, there is a set of really good examples included in the repo. Reminds me of the Arduino standard library in this way. After through all of running these, I didn’t find exactly what I was looking for.
For how simple the finished product looks, the learning curve on the way there was surprisingly steep. Hopefully this post saves you some time.
Code + Explanation
I’d like to be able to draw a grid n
* n
rectangles, and control their side lengths and the amount of space between them. Being able to update the color of the rectangle is also a requirement.
The goal is to be able to visualize audio signals on a low resolution grid of LEDs.
This software is to be the basis for iterating on ideas and working out bugs without having to redesign hardware.
This example uses a single vispy.visuals.collections.PolygonCollection
object to draw the rectangles rather than using a bunch of RectangleVisual objects. Moreover, I found that once there were hundreds of these RectangleVisual
objects, I could not achieve the FPS range I was after. Searching online, I found an explanation for this here:
Any solution that requires more than 10 visuals will perform terribly as it requires a separate GL program for each visual. Each program is drawn sequentially so it takes the GPU a long time to get through each one. It would be best to have one visual that draws all of these rectangles with the appropriate color (I’d never heard of a wafermap before)
So reader, if you’re working on something similar, I hope this snippet is a good starting point:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
""" vispy_grid_of_rectangles.py Create a grid of rectangles of arbitrary size, and update their color using vispy. You'll need to install the following packages: * mypy==0.720 * numpy==1.18.2 * vispy==0.6.4 * PyQt5==5.14 4/20/2020 - Devon Bray - www.esologic.com/quickly-drawing-grids-of-rectangles-and-updating-their-colors-with-vispy """ import random from typing import Optional, Union import numpy as np from vispy import app, gloo from vispy.util.event import Event from vispy.visuals.collections import PathCollection, PolygonCollection # Both the `PolygonCollection` and `PathCollection` used in this example support 3D polygons, # But we're only concerned with the 2D case here, so this global constant is set to avoid confusion. Z_POSITION_DEFAULT = 0.0 def rectangle_polygon_vertices_2d( horizontal_side_length: float, vertical_side_length: float, ) -> np.ndarray: """ Create a list of vertices that represent a rectangle centered around (0, 0, 0). The `_side_length` parameters should be floats greater than zero and less than one. These are 2D rectangles, not boxes, so changing the `z_position` will move the rectangle along the z axis. :param horizontal_side_length: how long the horizontal sizes should be :param vertical_side_length: how long the vertical sides should be :return: the vertices (x, y, z) as a numpy array. """ return ( np.array( [ [1.0, 1.0, Z_POSITION_DEFAULT], # top right [-1.0, 1.0, Z_POSITION_DEFAULT], # top left [-1.0, -1.0, Z_POSITION_DEFAULT], # bottom left [1.0, -1.0, Z_POSITION_DEFAULT], # bottom right ] ) * (horizontal_side_length, vertical_side_length, 1) / 2 ) def offset_rectangle(position: int, side_length_float: float, buffer_length_float: float) -> float: """ Given the position of a rectangle plus it's side/buffer lengths, create the float offset from the outer edge. :param position: The column/row number of the current rectangle :param side_length_float: the side length of the rectangle as a float :param buffer_length_float: the buffer (distance between rectangles) as a float :return: the offset distance for the current rectangle as a float """ # Since the rectangle is currently centered around (0, 0), exactly HALF of it is already above # the origin. middle_offset = side_length_float / 2 # These are the side lengths of the previous rectangles length_of_rectangles = position * side_length_float # These are the lengths of the previous buffers between rectangles border_lengths = (position + 1) * buffer_length_float return middle_offset + length_of_rectangles + border_lengths class GridOfRectangles(app.Canvas): """ Draw a grid of rectangles using `PolygonCollection` and `PathCollection` objects. Every 1 second, change each rectangles to a random color. """ def __init__( self: "GridOfRectangles", rows: int, columns: int, horizontal_side_length_pixels: int, vertical_side_length_pixels: int, buffer_length_pixels: int, **kwargs: Optional[Union[bool, str]] ) -> None: """ Constructor, note that `self.polygons` and `self.edges_of_polygons` are created before the parent class is initialized. :param rows: the desired number of rows in the grid :param columns: the desired number of columns in the grid :param horizontal_side_length_pixels: the side horizontal length (width) of each of the rectangles in pixels :param vertical_side_length_pixels: the vertical side length (height) of each rectangle in pixels :param buffer_length_pixels: the number of pixels between each rectangle. :param kwargs: keyword arguments to be passed to the parent canvas constructor. :return: None """ self.polygons = PolygonCollection("agg", color="shared") self.edges_of_polygons = PathCollection("agg", color="shared") app.Canvas.__init__( self, size=( (columns * horizontal_side_length_pixels) + ((columns + 1) * buffer_length_pixels), (rows * vertical_side_length_pixels) + ((rows + 1) * buffer_length_pixels), ), **kwargs ) self._timer = app.Timer(interval=1, connect=self.on_timer, start=True) horizontal_side = (horizontal_side_length_pixels / self.physical_size[0]) * 2 horizontal_buffer = (buffer_length_pixels / self.physical_size[0]) * 2 vertical_side_length = (vertical_side_length_pixels / self.physical_size[1]) * 2 vertical_buffer_length = (buffer_length_pixels / self.physical_size[1]) * 2 self.num_rectangles = columns * rows for num_column in range(columns): for num_row in range(rows): xyz_offset = ( -1 + (offset_rectangle(num_column, horizontal_side, horizontal_buffer)), # X 1 - ( offset_rectangle(num_row, vertical_side_length, vertical_buffer_length) ), # Y Z_POSITION_DEFAULT, # Z ) polygon_points = ( rectangle_polygon_vertices_2d(horizontal_side, vertical_side_length) + xyz_offset ) # The default color for each of this will be black. # The color of the polygons will be changed with `on_timer`, but the edges will # stay as is. self.polygons.append(polygon_points, color=(0, 0, 0, 1)) self.edges_of_polygons.append(polygon_points, closed=True, color=(0, 0, 0, 1)) self.edges_of_polygons["linewidth"] = 1 self.edges_of_polygons["viewport"] = 0, 0, self.physical_size[0], self.physical_size[1] def on_draw(self: "GridOfRectangles", event: Event) -> None: """ Called each time a frame is to be written, as often as possible :param event: The vispy event context :return: None """ gloo.clear("white") self.polygons.draw() self.edges_of_polygons.draw() self.update() def on_timer(self: "GridOfRectangles", event: Event) -> None: """ This function sets the color of each of the polygons to a random color. Called by the timer, every 1 second. :param event: The vispy event context :return: None """ self.polygons["color"] = np.array( list( (random.uniform(0, 1), random.uniform(0, 1), random.uniform(0, 1), 1) for _ in range(self.num_rectangles) ) ) self.update() if __name__ == "__main__": canvas = GridOfRectangles( rows=500, columns=500, horizontal_side_length_pixels=3, vertical_side_length_pixels=3, buffer_length_pixels=1, show=True, keys="interactive", ) gloo.set_viewport(0, 0, canvas.size[0], canvas.size[1]) gloo.set_state("translucent", depth_test=False) canvas.measure_fps() app.run() |
With a grid of 100 x 100, I’m able update the visual at ~30 FPS, which is way more that I would ever need for my application. If you wanted to extend this to more objects, it seems like the bet approach is to use markers.
Thanks for reading.
2 Comments