Introduction to PyGEL¶
J. Andreas Bærentzen, January 2018 (revised February 2021)
This tutorial is based on the original Introduction to PyGEL notebook. That page includes interactive 3D views exported from Jupyter.
The PyGEL Python library for geometry processing provides Python 3 bindings for a subset of the features of the C++ GEL library.
PyGEL is a Python package consisting of five modules:
hmeshprovidesManifold, which is a class that represents polygonal meshes using the halfedge representation.hmeshalso provides a slew of functions for manipulating polygonal meshes and theMeshDistanceclass, which makes it simple to compute the distance to a triangle mesh.hmeshalso includes theskeleton_to_feqfunction, which makes it straightforward to produce a quad-only mesh from a graph.graphcontains theGraphclass, which is used for graphs: collections of vertices (in 3D) connected by edges. Unlike aManifold, aGraphdoes not have to represent a surface. Associated functions includeLS_skeletonandMSLS_skeleton, which compute a curve skeleton from aGraphand return the result as a newGraph.MSLS_skeletonis multi-scale and hence much faster.gl_displayprovides theViewerclass, which makes it simple to visualize meshes and graphs.jupyter_displaymakes it easy to use PyGEL in a Jupyter Notebook. This module contains a function that creates a widget for interactively visualizing a mesh or a graph. It is based on Plotly, and it is possible to export the resulting notebooks to HTML while preserving the interactive 3D graphics.spatialcontains theI3DTreeclass, a kD-tree specialized for mapping 3D points to integers — typically indices.scipy.spatialhas a more generic class, so this is perhaps not the most important part of PyGEL.
For installation, see the Installation Guide. For a concise description of the API, see the API Reference.
Loading and viewing a mesh¶
To use PyGEL you need to import the appropriate module. The code below starts by
doing that, then it loads a mesh (a Manifold) called m, creates a Viewer
called viewer, and finally displays m using viewer. The script could be
run from a Jupyter notebook, from an editor, or from an interactive Python
shell. Adjust the mesh path to a file from the GEL data directory.
from pygel3d import hmesh, gl_display as gl
m = hmesh.load("../data/bunnygtest.obj")
viewer = gl.Viewer()
viewer.display(m)
Having executed the code above, you should see a window displaying a 3D model of a bunny, and it is now possible to play with the mesh. You can rotate by pressing the left mouse button and dragging the mouse. You can zoom using the right mouse button, and if you hold shift the right mouse button pans instead of zooming.
One thing that might be puzzling is that the display function does not
return. That is because the viewer is in the same thread of execution as the
rest of your script: it is not running in parallel. To return to the script,
press ESC inside the window. Doing so, you might notice that the image
freezes but the window does not go away. That is as intended. We might want to
return to the window after all — either to visualize a different mesh or simply
to look a bit more at the one we have. To make the window active again, call
event_loop:
You will have noticed that the window with the bunny came alive again. Hit
ESC one more time to exit the viewer. If you really want the window to go
away, you can either wait for the entire script to terminate or explicitly
delete the object:
Exploring the Viewer¶
The display function in Viewer has a number of parameters which were not
involved in the simple example above. In that example, display was called
only with the mesh m. Instead of m we could have called display with a
Graph. The Viewer is happy to display both types of object even at the same
time. However, it is much more full fledged as a mesh viewer than a graph
viewer.
mode: a single character that determines how the mesh is visualized:'w'— wireframe, the default, because we so often want to see the polygons.'i'— isophote rendering. This shows curves on the surface such that there is the same angle between the normal and the light source. In other words, isophotes are curves of even intensity of shading.'g'— glazed (this is supposed to look a bit like ceramics).'s'— scalar field. Draw a scalar field on the surface if you provide a scalar value for each vertex.'l'— line field. The same sort of visualization as scalar field, but you need to provide a 3D vector for each vertex.'n'— normal shading. Fairly boring, but sometimes this is what you want.'x'— renders the mesh transparent in a constant color. This is probably only useful if you want to show a mesh and a graph together, but then it is very useful.
smooth: ifTrue(the default) we use vertex normals. Otherwise, face normals.bg_col: background color. The default is dark grey[0.3, 0.3, 0.3].data: per-vertex data for visualization. This is either a scalar or vector field. It is ignored unless one of those two visualization modes is selected. The default isNone.reset_view: ifFalse, the view is as left in the previous display call. IfTrue, the view is reset to the default. This can be useful if you make changes to a mesh and then want to return to the view you had. The default isFalse.once: ifTruewe immediately exit the event loop and return. However, the window stays, and if the event loop is called from this or any other viewer, the window will still be responsive. Default isFalse.
Jupyter caveat¶
If you are using PyGEL from within a Jupyter Notebook you may find it
intolerable to use the gl_display.Viewer. The Jupyter widgets for visualizing
meshes are far more useful in that setting, and some students have insisted
that the gl_display.Viewer is broken. It is not, but it can feel that way
when it stops the execution of the cells in your notebook.
Visualizing in Jupyter¶
Sometimes we want to show a mesh in a Jupyter Notebook. For that we need a
different visualization tool, based on Plotly. Displaying a mesh with
jupyter_display is as easy as with gl_display, but the features are fewer
and a bit different.
In the following example, jupyter_display is used to visualize a mesh using
wireframe (default) and flat shading. set_export_mode must be called if you
want to export the notebook to HTML with the interactive visualization widgets
preserved. Also, if you do not call it, display must be the last thing you
do in a cell.
We will often need to associate data with the mesh. jupyter_display supports
only scalar fields. In the example below, the vertex x coordinates are used
as a scalar field.
Unfortunately, the Plotly-based visualization is not quite as flexible as the
one based on OpenGL, so these two examples exhaust the features of
jupyter_display.
Working with meshes¶
The point of having a mesh representation is to be able to work with the geometry. We need to be able to visit the faces, edges, and vertices of the mesh and make queries about both geometry and connectivity. As a first example, assume we want to find the smallest and the biggest x coordinate of any vertex in the mesh. The code below loops over all vertices and finds the minimum and the maximum x coordinate.
min_x_coord = 1e32
max_x_coord = -1e32
pos = m.positions()
for v in m.vertices():
min_x_coord = min(min_x_coord, pos[v][0])
max_x_coord = max(max_x_coord, pos[v][0])
print(min_x_coord, max_x_coord)
At this point, you might be asking yourself what pos and v are. v is a
vertex index: an integer we can use to refer to a vertex. For instance, if we
have all our vertex positions in an array then v would be used to index into
that array, referring to a specific vertex position.
In fact, pos is precisely an array that contains all of the vertex positions
— and it is not a copy. Any changes that you make to pos are directly
reflected in the mesh. To illustrate the power of that, let us randomize all
vertex positions.
from random import random
from numpy import array
pos_backup = array(pos)
for v in m.vertices():
pos[v] = [random(), random(), random()]
jd.display(m)
Then restore the original positions:
pos_backup = array(pos) creates a new array with the contents of pos and
assigns it to pos_backup, thereby making a copy of the positions. Afterwards,
we use the slice notation pos[:] = pos_backup to copy back the vertex
positions. If we had just typed pos = pos_backup then pos would simply have
been another reference to the pos_backup array, but the actual vertex
positions would not have been changed. Coming from C++, it can be confusing
that in Python a = b does not copy the contents of b into a but simply
makes a a new name for the data in b.
To give just one example of iteration over the faces of a mesh:
avg_area = 0
F = m.faces()
for f in F:
avg_area += m.area(f)
avg_area /= len(F)
print("Average area : ", avg_area)
Above, we exploited that m.faces() is just an iterable container with all the
face indices. We can do the exact same thing with halfedges.
Circulation¶
Very frequently, we want to visit all the vertices that are neighbors of a
given vertex, or all the halfedges that emanate from that vertex, or all of the
faces which are incident on the vertex. Such queries can be carried out using
the circulate_vertex function. Given a mesh m and a vertex v, we can
find the neighbors of v using:
The 'v' indicates that we want the neighbor vertices, so when the function
returns, N contains an iterable sequence of the neighboring vertices. If we
had given mode='f' or mode='h', we would have obtained the incident faces
or emanating halfedges. mode can be omitted and defaults to 'v'.
In the code example below, we smooth the mesh by repeatedly assigning the average of the neighboring vertices to each vertex. This is a crude way of performing smoothing, but it works for demonstration purposes.
from numpy import zeros
for _ in range(0, 50):
avg_pos = zeros(pos.shape)
for v in m.vertices():
N = m.circulate_vertex(v, 'v')
for vn in N:
avg_pos[v] += pos[vn]
avg_pos[v] /= len(N)
pos[:] = avg_pos
jd.display(m)
Circulation does not only work for vertices; we can also circulate around faces. For instance,
produces an iterable sequence H containing all the halfedges that make up the
boundary of face f. In a similar way, we can get the vertices of f and the
adjacent faces.
Other mesh operations¶
The Manifold class has many member functions that operate on individual
halfedges, faces, or vertices and which can be used to refine or simplify the
mesh. There are also several functions outside of the Manifold class which
can be used to manipulate the entire mesh. See the
HMesh API for a better overview.
Below is one more simple example. We load the original bunny mesh, close its
holes, triangulate it, and simplify it to 5%. We cleanup to remove vertices
that are no longer used, and finally show the mesh. The numbers of vertices
before and after simplification are printed.
bunny = hmesh.load("../data/bunny.obj")
print("vertices before simplification :", bunny.no_allocated_vertices())
hmesh.close_holes(bunny)
hmesh.triangulate(bunny)
hmesh.quadric_simplify(bunny, 0.05)
bunny.cleanup()
print("vertices after simplification :", bunny.no_allocated_vertices())
jd.display(bunny)
Clearly, the only practical thing that has been achieved by the script above is to approximately reproduce the reduced Stanford Bunny mesh that we use in the other examples.
Annotating meshes¶
There is one more feature of gl_display.Viewer which is arguably important.
The viewer allows users to select annotation points by ctrl-clicking on the
mesh. Simply spawning a viewer as shown below is sufficient to try this out.
In the example, the annotation points are then printed. If we change the
position of an annotation point, it will also move inside the viewer.
pos[:] = pos_backup
viewer = gl.Viewer()
viewer.display(m)
ap = viewer.annotation_points()
for p in ap:
print(p)
del viewer # or you will have a stale window lying on the desktop
Finding points in space¶
In geometry processing, we frequently have to deal with collections of irregularly placed points in space. To facilitate queries on this type of data, PyGEL exposes a kD-tree class. A kD-tree allows us to search for the point closest to a given query point much faster (with asymptotic complexity \(O(\log N)\) rather than \(O(N)\)) than if we simply look through the point list.
In the example below, we insert all vertices from our mesh in the I3DTree
data structure and then use closest_point to locate the vertex closest to the
origin. It is less flexible than the SciPy alternative and maybe slightly
simpler to use.
from pygel3d import spatial
tree = spatial.I3DTree()
for v in m.vertices():
tree.insert(pos[v], v)
tree.build()
k, v = tree.closest_point([0, 0, 0], 1.0)
print("key = ", k, " idx = ", v)
Computing distance fields¶
Another frequently used representation for geometry is distance fields. A
distance field is simply a function that maps a point in space to the distance
from that point to the closest point on a given surface. MeshDistance allows
precisely for the computation of such signed distances from arbitrary points in
space to the closest point on a Manifold.
Skeletonization¶
So far we have not touched upon the Graph class. The reason this feature was
included was mainly to provide easy access to our skeletonization algorithm
(Bærentzen and Rotenberg). Since we have
been sticking with the bunny so far, let us turn it into a skeleton as a final
trick. The skeleton of the bunny looks a bit weird in isolation, but you should
be able to guess which edges correspond to which features.
MSLS_skeleton is the multi-scale variant and is much faster on larger graphs.
Next steps¶
- Mesh Operations — loading, repair, smoothing, subdivision
- Graph Processing — building and processing spatial graphs
- Visualization — more viewer and Jupyter display options
- Examples — complete runnable scripts
- HMesh API, Graph API