Laplace spectral analysis#

import vedo
import napari_shape_odyssey as nso
import napari
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn import manifold, cluster
viewer = napari.Viewer(ndisplay=3)
mesh = vedo.Ellipsoid().triangulate().clean()
viewer.add_surface((mesh.points(), np.asarray(mesh.faces())), name="ellipsoid")
<Surface layer 'ellipsoid' at 0x21b8ccb6a60>

Shape spectrum#

The laplacian eigenvalue spectrum of a shape is a signature of its geometry. It is invariant to translation, rotation and scaling. It is also robust to noise and outliers. It is therefore a very useful tool for shape analysis.

eigenvectors, eigenvalues = nso.spectral.shape_fingerprint(viewer.layers[0].data)
feature_table = pd.DataFrame(
    eigenvectors, columns=[f"eigenvector_{i}" for i in range(100)]
)

viewer.layers[0].features = feature_table
viewer.layers[0].features.head()
eigenvector_0 eigenvector_1 eigenvector_2 eigenvector_3 eigenvector_4 eigenvector_5 eigenvector_6 eigenvector_7 eigenvector_8 eigenvector_9 ... eigenvector_90 eigenvector_91 eigenvector_92 eigenvector_93 eigenvector_94 eigenvector_95 eigenvector_96 eigenvector_97 eigenvector_98 eigenvector_99
0 -0.286584 0.466006 2.914393e-06 -2.670153e-02 -0.564916 1.980246e-05 6.031942e-02 0.027270 -0.637259 0.015060 ... -1.837822e-02 -4.096932e-02 2.456711e-03 -0.753308 -3.639717e-01 -9.137496e-02 0.012351 -0.033560 -0.008964 -0.070424
1 -0.286584 0.466010 -6.219595e-03 -2.646580e-02 -0.564921 1.376339e-02 5.980559e-02 0.027405 -0.637245 0.014265 ... -2.121618e-02 -4.241061e-02 2.913675e-04 -0.750394 -3.410896e-01 -1.745569e-01 -0.008940 -0.054190 -0.009444 -0.074741
2 -0.286584 0.467152 2.449732e-16 -2.314927e-15 -0.569310 -2.934374e-16 2.109721e-15 0.028478 -0.647039 0.015295 ... -1.671580e-14 4.440892e-16 -3.640976e-13 -0.871737 3.683165e-14 5.766221e-15 0.015152 -0.039711 -0.011303 -0.114793
3 -0.286584 0.465962 -1.232598e-02 -2.576202e-02 -0.564696 2.724772e-02 5.822571e-02 0.027763 -0.636629 0.013541 ... -2.366231e-02 -4.439061e-02 -1.726597e-03 -0.737174 -3.103782e-01 -2.539173e-01 -0.027743 -0.072156 -0.009965 -0.086762
4 -0.286584 0.465869 -1.821425e-02 -2.460630e-02 -0.564280 4.024184e-02 5.561764e-02 0.028305 -0.635514 0.012927 ... -2.563604e-02 -4.655387e-02 -3.572047e-03 -0.715752 -2.730462e-01 -3.272970e-01 -0.042911 -0.086394 -0.010480 -0.104977

5 rows × 100 columns

Let’s visualize some of the eigenvectors of the Laplacian matrix. The eigenvectors are ordered by their corresponding eigenvalues, so the first eigenvector corresponds to the smallest eigenvalue, and the last eigenvector corresponds to the largest eigenvalue. We will visualize the first 5 eigenvectors on the surface of the object.

n_visualizations = 20
fig, axes = plt.subplots(ncols=n_visualizations, figsize=(13, 7))
for i in range(1, n_visualizations+1):
    mesh_tuple = (mesh.points(), np.asarray(mesh.faces()), eigenvectors[:,i])
    viewer.add_surface(mesh_tuple, colormap='inferno')

    screenshot = viewer.screenshot()
    axes[i-1].imshow(screenshot)
    axes[i-1].axis('off')
../../_images/b05adf36628cb13382818ddbcb1d96208e7318c52d6f31ace20b04fedc083e94.png
fig, ax = plt.subplots(1, 1, figsize=(8, 4))
ax.plot(np.arange(0, 100), eigenvalues, label='Eigenvalu spectrum')
ax.set_xlim(0, 100)
ax.set_ylim(0, 90)
ax.set_xlabel('Eigenvalue index')
ax.set_ylabel('Eigenvalue')
ax.set_title('Eigenvalue spectrum')
Text(0.5, 1.0, 'Eigenvalue spectrum')
../../_images/35550fed3126ab4a4637f9c6f9f3e28277b0d9eb63d325ad8af1797c5ae67abf.png

Simple shapes#

We can also compare the shape spectra of several basic shapes (e.g. ellipsoid of various eccentricities, sphere, cube, etc.) to see how the shape of the object affects the shape spectrum.

ellipsoid1 = vedo.Ellipsoid(pos=(0,0,0), axis1=(1,0,0), axis2=(0,1,0), axis3=(0,0,1), c='r', alpha=0.5).triangulate().clean()
ellipsoid2 = vedo.Ellipsoid(pos=(0,0,0), axis1=(1,0,0), axis2=(0,1,0), axis3=(0,0,2), c='r', alpha=0.5).triangulate().clean()
ellipsoid3 = vedo.Ellipsoid(pos=(0,0,0), axis1=(1,0,0), axis2=(0,2,0), axis3=(0,0,2), c='r', alpha=0.5).triangulate().clean()
cube = vedo.Cube(pos=(0,0,0), side=1, c='b', alpha=0.5).triangulate().clean().subdivide(method=1, n=3)

ellipsoid1.name = 'ellipsoid1'
ellipsoid2.name = 'ellipsoid2'
ellipsoid3.name = 'ellipsoid3'
shapes = [ellipsoid1, ellipsoid2, ellipsoid3, cube]

Let’s have a look at how the different orders of the spectra are expressed on our example meshes. For the sake of speed, we don’t go to very high orders here:

order = 50
for shape in shapes:
    fig, axes = plt.subplots(ncols=n_visualizations, figsize=(13, 7))
    for i, i_order in enumerate(np.arange(1, 50, 10)):
        eigenvectors, eigenvalues = nso.spectral.shape_fingerprint((shape.points(), shape.faces()), order)
        mesh_tuple = (shape.points(), np.asarray(shape.faces()), eigenvectors[:, i_order])
        layer = viewer.add_surface(mesh_tuple, colormap='inferno')
        viewer.camera.angles = (-18, -30, 130)
        viewer.camera.zoom = 710
        screenshot = viewer.screenshot()
        viewer.layers.remove(layer)
        axes[i].imshow(screenshot)
        axes[i].axis('off')
        axes[i].set_title(f'Eigenvalue {i_order}')
../../_images/913472373f132c54c7eeb1b96cb46cc265c2db99c96a24e028d809b14e257e54.png ../../_images/69b882ecff87bac59ce7f32513d8e16af3173add2ab62ff9b7d0852c65876013.png ../../_images/1b69f0d5473d94a304d37564df02f7c8fff0a8e90cfa5ca3d2fb66ab6873237c.png ../../_images/af94473ccfeeb008b7e980385558b0151553b1ba87a14ec1329cc3a24680bf08.png

more complex shapes#

With that out of the way, let’s look at some more complex shapes:

shape1 = vedo.load(vedo.dataurl+"bunny.obj").triangulate().clean().scale(10)
shape2 = vedo.load(vedo.dataurl+"cessna.vtk").triangulate().clean()
shape3 = vedo.load(vedo.dataurl+"teapot.vtk").triangulate().clean()
shape4 = vedo.load(vedo.dataurl+"cow.vtk").triangulate().clean().scale(2)
shapes = [shape1, shape2, shape3, shape4]

Interestingly, we see that the lower order modes can tell us a lot about the basic compontents and the connectedness of the mesh:

for shape in shapes:
    fig, axes = plt.subplots(ncols=n_visualizations, figsize=(13, 7))
    eigenvectors, eigenvalues = nso.spectral.shape_fingerprint((shape.points(), shape.faces()), order)
    
    for i, i_order in enumerate(np.arange(1, 6, 1)):
        mesh_tuple = (shape.points(), np.asarray(shape.faces()), eigenvectors[:, i_order])
        layer = viewer.add_surface(mesh_tuple, colormap='inferno')
        viewer.camera.angles = (64, 60, -67)
        viewer.camera.zoom = 400
        viewer.camera.center = (-0.175, 0.621, -0.119)
        screenshot = viewer.screenshot()
        layer.visible = False
        axes[i].imshow(screenshot)
        axes[i].axis('off')
        axes[i].set_title(f'Eigenvalue {i_order}')
../../_images/34cbeacafb50482be41f579a9219ff604f824b7736f40e03339a6b9bc8c97e75.png ../../_images/531b477748ce37a3f24f3856151d08c72a942d752e456557234d2692d79b6d3e.png ../../_images/d67501b9c27ffe7c32b82506ea07cbbf0d2af21d02b7f38049cb31a811ec9b16.png ../../_images/85a2b08f4c3865e789500a648deccf96f3c9f1323832fdc5599da708aa6e9048.png

If we go to the higher orders we are back to seing expressions of local and global shape:

for shape in shapes:
    fig, axes = plt.subplots(ncols=n_visualizations, figsize=(13, 7))
    eigenvectors, eigenvalues = nso.spectral.shape_fingerprint((shape.points(), shape.faces()), order)
    
    for i, i_order in enumerate(np.arange(1, 50, 10)):
        mesh_tuple = (shape.points(), np.asarray(shape.faces()), eigenvectors[:, i_order])
        layer = viewer.add_surface(mesh_tuple, colormap='inferno')
        viewer.camera.angles = (64, 60, -67)
        viewer.camera.zoom = 400
        viewer.camera.center = (-0.175, 0.621, -0.119)
        screenshot = viewer.screenshot()
        layer.visible = False
        axes[i].imshow(screenshot)
        axes[i].axis('off')
        axes[i].set_title(f'Eigenvalue {i_order}')
../../_images/b2f63ea1d867804e6a56c54e120324567e466c669402dd5eafca71ab372d78cf.png ../../_images/6c6d579aed313b58b454e36df4c55bbf2ec3624c3f6caf90699ce826ecd66c90.png ../../_images/ad91c4f3d0d4efad27ce2fdb703b180a33d2c295bff804a01bc846fbd5617061.png ../../_images/3fa5285bfe325e5476ebef207c3f4ea2cd65c89d61933b6590ef2596806acd59.png

Application: Clustering#

Let’s have a closer look at the cow example and see what the eigenvectors can tell us if we throw them into a clustering algorithm:

cow = vedo.load(vedo.dataurl+"cow.vtk").triangulate().clean().scale(2)
eigenvectors, eigenvalues = nso.spectral.shape_fingerprint((cow.points(), cow.faces()), order)

df = pd.DataFrame(eigenvectors, columns=[f"eigenvector_{i}" for i in range(eigenvectors.shape[1])])
layer = viewer.add_surface((cow.points(), np.asarray(cow.faces())))
layer.features = df
reducer = manifold.TSNE(n_components=2, perplexity=40, n_iter=1000, random_state=0)
embedding = reducer.fit_transform(df)
clusterer = cluster.DBSCAN(eps=3, min_samples=20)
clusterer.fit(embedding)

# Plot the clusters
sns.scatterplot(x=embedding[:,0], y=embedding[:,1], hue=clusterer.labels_, palette='Paired')
<Axes: >
../../_images/e53ce5f092343bc6bb77da89493b20b137e82c38a935784e54e25a941fd15d33.png

It may make a bit more sense in this case to cluster in higher-dimensional space since we can directly visualize on the surface and are not depending on finding a 2D projection of the cluster labels. We find that the clustering already gets a few thing quite right, such as the segmentation of the horns and the ears or assigning a single class for the whole rump of the cow:

clusterer = cluster.DBSCAN(eps=0.5, min_samples=20)
clusterer.fit(df)
viewer.add_surface((cow.points(), np.asarray(cow.faces()), clusterer.labels_), colormap='hsv')
viewer.camera.center = (-0.21, 3.7, -0.15)
viewer.camera.zoom = 240
viewer.camera.angles = (90, -8, -6)

napari.utils.nbscreenshot(viewer, canvas_only=True)