Custom data visualization with PyOpenGL and PyQt — Part 3 — Displaying data
In part 2 we learned how to draw a basic shape in Qt and PyOpenGL. In part3 we will learn how to update the shape drawn in OpenGL by passing parameters to the shader program, draw multiple instances of the OpenGL widget on screen and finally how to refresh the OpenGL widgets according to data read from a file.
By the end of this tutorial part, you’ll be able to implement the following exciting interactive window:
Our Qt application will include two buttons, one for loading the data and the second one for starting data visualization replay. Additionally, it will include two instances of an OpenGL widget which displays a percentage bar, with all red indicating 0% and all green indicating 100%.
Upgrading our triangle into a square
In part 1 we drew a triangle using the following OpenGL command glDrawArrays(GL_TRIANGLES, 0, 3), and when we defined our vertices we define three coordinates for our single triangle. If we want to draw a rectangle using the same method, we must define two triangles and pass 6 coordinates to the GPU. In this case we would have to define redundant information, as two coordinates of the rectangle would be defined twice. Instead, we can use indexed drawing, by passing coordinates and coordinates indexes and drawing the shape accordingly.
We’ll introduce the following updates to our initializeGL method. First, we’ll define the four coordinates which define the rectangle:
self.bar_height = 1.0
self.bar_width = 0.6
# define vertices to be drawn
vertices = [[self.bar_width / 2, self.bar_height / 2, 0.0],
[self.bar_width / 2, -self.bar_height / 2, 0.0],
[-self.bar_width / 2, -self.bar_height / 2, 0.0],
[-self.bar_width / 2, self.bar_height / 2, 0.0]]
Next we’ll define the indices which define the two triangles within the rectangle:
indices = [[0, 1, 3],
[1, 2, 3]]
Next we’ll need to define a second type of buffer object — an element buffer object (EBO). Through the EBO we’ll send the indices to the GPU:
EBO = glGenBuffers(1)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, 6 * 4, np.array(indices, dtype=np.uint32), GL_STATIC_DRAW)
Finally, we’ll use the method glDrawElements instead of glDrawArrays to draw the rectangle in method paintGL:
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, None)
Passing parameters to the shader program
First we’ll update out vertex shader to send the y coordinate of each pixel to the fragment shader. We added the output variable y_pos and assign it the value of aPos.y.
# vertex shader code
vertexShaderCode = """#version 420 core \n
layout (location = 0) in vec3 aPos;\n
out float y_pos;\n
void main()\n
{\n
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n
y_pos = aPos.y;\n
}"""
Next, we’ll update the fragment shader by adding an input variable from the vertex shader and an external input variable. The external input is defined by the keyword uniform. A uniform is a type of global variable in a shader program and can be used to pass values to the shader program. A uniform holds its value until updated externally. Additionally, we’ll add the input variable y_pos which defined as an output variable in the vertex shader.
# fragment shader code
fragmentShaderCode = """#version 420 core\n
out vec4 FragColor;\n
uniform float in_val; \n
in float y_pos; \n
void main()\n
{\n
if (y_pos < in_val)\n
FragColor = vec4(0.0f, 0.8f, 0.0f, 1.0f);\n
else\n
FragColor = vec4(0.8f, 0.0f, 0.0f, 1.0f);\n
}"""
To pass a new value to the shader program we need to find the uniforms location in the shader program by calling the method glGetUniformLocation which receives the shader program and the name of the uniform. Next we’ll pass the parameter using the glUniform1f method. We’re using glUniform1f because in our case the uniform is a 1-dimensional float variable.
vertexColorLocation = glGetUniformLocation(self.shaderProgram, "in_val")
glUniform1f(vertexColorLocation, self.p)
At this point you’re probably wondering what does self.p stand for. In our OpenGL widget we’ll add a data attribute to hold the last value passed to the widget. To update the value we’ll add the method setPercentVal:
def setPercentVal(self, val: int):
if val > 100:
val = 100
elif val < 0:
val = 0
self.p = self.bar_height * val / 100.0 - (self.bar_height / 2)
Updating the Qt MainWindow class
Seeing as our new window just became a bit more complex, we’ll need to implement a new custom widget which will become our MainWindows new central widget.
First lets implement a new class — customWidget. This widget inherits from Qts QWidget class.
custom widgte to be set as the main window central widget
class customWidget(QWidget):
def __init__(self):
super().__init__()
self.file_open = False
main_layout = QVBoxLayout()
button_layout = QHBoxLayout()
gl_layout = QHBoxLayout()
# create two buttons
btn1 = QPushButton("Open File")
btn1.pressed.connect(self.openFile)
btn2 = QPushButton("Play")
btn2.pressed.connect(self.play)
button_layout.addStretch()
button_layout.addWidget(btn1)
button_layout.addWidget(btn2)
button_layout.addStretch()
# create two OpenGL widgets
self.gl1 = glWidget(self)
self.gl1.format().setVersion(4, 2)
self.gl1.format().setProfile(QGLFormat.CoreProfile)
self.gl2 = glWidget(self)
self.gl2.format().setVersion(4, 2)
self.gl2.format().setProfile(QGLFormat.CoreProfile)
gl_layout.addWidget(self.gl1)
gl_layout.addWidget(self.gl2)
# add a timer to refresh the openGL widgets
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.updateGLWidgets)
self.timer.setInterval(100) # arbitrarly set to 100[ms]
main_layout.addLayout(button_layout)
main_layout.addLayout(gl_layout)
self.setLayout(main_layout)
In the constructor we’ll add the following components:
- Two buttons — btn1 for reading a file and btn2 for updating the OpenGL widgets.
- Two of our custom OpenGL widgets.
- A QTimer — For updating the OpenGL widgets periodically.
- Three layout objects — 1 QVBoxLayout and 2 QHBoxLayout. Which are used to create a custom layout withing the QWidget. QVBoxLayout is a vertical layout which stacks Qt Widgets one on top of the other, and QHBoxLayout is a horizontal layout in which Qt Widgets are arranged horizontally. In our customWidget we’ll set the vertical layout as the main layout, add the two buttons in the first horizontal layout and the two OpenGL widgets in the second horizontal layout, and then add both horizontal layouts to the vertical layout.
Next we’ll implement a class method for opening a file, to be called when btn1 is pressed.
def openFile(self):
fname = QFileDialog.getOpenFileName(self, 'Open file', 'c:\\', "data files (*.csv)")
self.file = open(fname[0], 'r')
self.file_open = True
To connect the button to method openFile:
btn1.pressed.connect(self.openFile)
To start reading the data from the file we’ll implement the method:
def play(self):
if self.file_open:
self.timer.start()
Once the timer starts the QTimer will call method updateWidgets on each QTimer tick, until EOF is reached.
ef updateGLWidgets(self):
line = self.file.readline()
if line != "":
vals = line.split(",")
self.gl1.setPercentVal(int(vals[0]))
self.gl2.setPercentVal(int(vals[1]))
self.gl1.update()
self.gl2.update()
else:
self.timer.stop()
You can find the full version of the code in the following git repository: link
Summary
In part 3 we updated our openGL widget to update according to data passed to it as a parameter. This very basic application will be the base for future more complex widgets in the following tutorials. For now even with this very basic application we can for example read data in real-time and present to the user data information such as engine rpm, battery voltage and so on.
One thing which is missing in our basic widget is text, so before skipping to more advanced widgets, in part 4 we’ll discuss displaying text in OpenGL.