Module amaazetools.trimesh
Expand source code
#tri_mesh.py
#Class for working with triangulated meshes
#test
import graphlearning as gl
import numpy as np
from numpy import matlib
from plyfile import PlyData, PlyElement
import scipy.sparse as sparse
import scipy.spatial as spatial
from skimage import measure
from skimage.color import convert_colorspace
from sklearn.neighbors import NearestNeighbors
from . import svi
from . import edge_detection
import sys
import urllib.request as url
#Non-Class Specific Functions
def marching_cubes(volume,level=None,spacing=(1,1,1)):
""" SK-Image's marching cubes does not return a clean triangulations -
often has replicate points, self-triangles, etc. This fixes that.
Parameters
----------
volume : (l,w,h) float array
A 3-D grid discretization of the function to be surfaced.
level : float
isolevel to extract surface at.
spacing : (3) float
denotes the dimensions of each voxel in the volume.
Returns
-------
p : (n,3) float
points of triangulation
t : (m,3) int
triangles of triangulation
"""
p,t,n,val = measure.marching_cubes(volume,level=level,spacing=spacing)
#sometimes marching cubes produces... artifacts... so here's a very basic intro to mesh cleaning!
#first: eliminate repeated points:
p,index,inv = np.unique(p,axis=0, return_index=True,return_inverse=True)
#next: rid triangulation of non-triangles:
t = inv[t]
badt = (t[:,0]==t[:,1])+(t[:,1]==t[:,2])+(t[:,2]==t[:,0])
t = t[badt ==False,:]
#remove unreferenced points, if any
ind = -1*np.ones(p.shape[0],int)
uni = np.unique(t.flatten()) #these are already sorted for numpy
ind[uni] = np.arange(uni.shape[0])
p = p[ind>-1,:]
t = ind[t]
#finally, check right hand rule... this works for ~convex objects, anyway...
#m = tm.mesh(p,t)
#tc = m.face_centers()
#tn = m.face_normals()
#cent = m.points.mean(0)
#fliporder = np.sum(tn*(tc-cent),1)<0
#t[fliporder,1:3] = t[fliporder,2:0:-1]
return p,t
def withiness(x):
""" Computes withiness (how well 1-D data clusters into two groups).
Parameters
----------
x : (n,1) float array
A 1-D collection of data.
Returns
-------
w : float
The withiness of the data.
m : float
The point at which to split the data into 2 clusters.
"""
x = np.sort(x)
sigma = np.std(x)
n = x.shape[0]
v = np.zeros(n-1,)
for i in range(n-1):
x1 = x[:(i+1)]
x2 = x[(i+1):]
m1 = np.mean(x1);
m2 = np.mean(x2);
v[i] = (np.sum((x1-m1)**2) + np.sum((x2-m2)**2))/(sigma**2*n);
ind = np.argmin(v)
m = x[ind]
w = v[ind]
return w,m
def pca(P):
""" Computes principal component analysis (PCA) on a point cloud P.
Parameters
----------
P : (n,d) float array
A point cloud.
Returns
-------
vals : (d,) float arrayy
The variances among each principal component.
vecs : (d,d) float array
The principal component vectors.
"""
P = P - np.mean(P,axis=0)
vals,vecs = np.linalg.eig(P.T@P)
idx = np.argsort(-vals)
return vals[idx],vecs[:,idx]
def weighted_pca(P,W):
""" Computes weighted principal component analysis (PCA) on a point cloud P.
Parameters
----------
P : (n,d) float array
A point cloud.
W : (n,) float array
An array containing the weights of the points.
Returns
-------
vals : (d,) float array
The variances among each principal component.
vecs : (d,d) float array
The principal component vectors.
sign : boolean
True when the first principal component direction is positively oriented.
"""
P = P - np.sum(P*W[:,None],axis=0)/np.sum(W)
vals,vecs = np.linalg.eig(P.T@(P*W[:,None]))
idx = np.argsort(-vals)
return vals[idx], vecs[:,idx]
#Power method to find principle eigenvector
def power_method(A,tol=1e-12):
""" Computes the smallest (in absolute value) eigenvalue and its corresponding eigenvector using the power method.
Parameters
----------
A : (n,n) float array
A square matrix that one wishes to find the smallest (in absolute value) eigenvalue and corresponding eigenvector of.
tol : float, default is 1e-12
The desired tolerance threshold after which to stop iteration.
Parameters
----------
l : float
The smallest (in absolute value) eigenvalue of A.
x : (n,1) float array
Array containing the eigenvector corresponding to the smallest (in absolute value) eigenvalue of A.
"""
n = A.shape[0]
x = np.random.rand(n,1)
err = 1
i = 1
while err > tol:
x = A@x
x = x/np.linalg.norm(x)
l = np.transpose(x)@A@x
err = np.linalg.norm(A@x - l*x)
i = i+1
return l,x
def pca_smallest_eig_powermethod(X,center=True):
""" Computes the last principal component of a point cloud X using the power method.
Parameters
----------
X : (n,3) float array
A point cloud.
center : boolean, default is True
Data is centered if True.
Returns
-------
A float array of size (3,) containing the last principal component vector.
"""
if center:
m = np.mean(X,axis=0)
cov = np.transpose(X-m)@(X-m)/X.shape[0]
else:
cov = np.transpose(X)@X/X.shape[0]
lmax,v = power_method(cov)
w,v = np.linalg.eig(cov)
l,v = power_method(cov - (lmax+1)*np.eye(3))
return v.flatten()
def pca_smallest_eig(X,center=True):
""" Computes the last principal component of a point cloud X.
Parameters
----------
X : (n,3) float array
A point cloud.
center : boolean, default is True
Data is centered if True.
Returns
-------
A float array of size (3,) containing the last principal component vector.
"""
if center:
m = np.mean(X,axis=0)
cov = np.transpose(X-m)@(X-m)
else:
cov = np.transpose(X)@X
w,v = np.linalg.eig(cov)
i = np.argmin(w)
return v[:,i]
### Mesh Class ###
#Read a ply file
def read_ply(fname):
""" Reads the vertex and triangle data stored in a .ply file.
Parameters
----------
fname: str
Name of the file to read from.
Returns
-------
P : (num_verts,3) float array
The coordinates of the vertices of the mesh.
T : (num_tri,3) int array
The indices of the triangles of the mesh.
"""
plydata = PlyData.read(fname)
#Convert data formats
try:
tri_data = plydata['face'].data['vertex_indices']
except:
tri_data = plydata['face'].data['vertex_index']
T = np.vstack(tri_data)
x = plydata['vertex'].data['x']
y = plydata['vertex'].data['y']
z = plydata['vertex'].data['z']
P = np.vstack((x,y,z))
P = P.transpose()
return P,T.astype(int)
#Load a ply file
def load_ply(path):
""" Loads a file path or url and creates a mesh object.
Parameters
----------
path : str
URL or file path at which to access .ply file.
Returns
-------
A mesh object generated from a .ply file found at the file path location.
"""
try:
url.urlopen(path)
is_url = True
except:
is_url = False
if is_url:
fname = path.rsplit('/', 1)[-1]
url.urlretrieve(path,fname)
else:
fname = path
points,triangles = read_ply(fname)
return mesh(points,triangles)
def synth_mesh(angle, num_pts):
""" Creates a synthetic mesh of two intersecting planes at a desired angle.
Parameters
----------
angle: float
Intersection angle.
num_pts : int
Number of vertices in the mesh.
Returns
-------
A mesh object.
"""
h = 1.5*num_pts**(-1/2)
dx = np.arange(-1,1+h,h)
x,y,z = np.meshgrid(dx,dx,dx)
f = y - x + 0.4
f = np.minimum(f,x + y + 0.4)
f = np.minimum(f,0.4 - y)
f = np.minimum(f,0.4 - z)
f = np.minimum(f,0.4 + z)
#Marching cubes for isosurface
verts,faces,normals,values = measure.marching_cubes(f,0,spacing=(h,h,h),allow_degenerate=False)
#Adjust angle
verts -= [1,1,1]
verts += [0.4,0,0]
verts[:,1] *= np.tan(angle*np.pi/180/2)
#Create mesh and flip normals
m = mesh(verts,faces)
m.flip_normals()
return m
class mesh:
def __init__(self,*args):
self.points = args[0]
self.triangles = args[1]
self.unit_norms = None
self.norms = None
self.centers = None
self.knn_I = None
self.knn_J = None
self.knn_D = None
self.tri_vert_adj_I = None
self.tri_vert_adj_J = None
self.poisson_W_matrix = None
self.poisson_J_matrix = None
self.poisson_node_idx = None
self.poisson_labels = None
#Get number of vertices
def num_verts(self):
""" Computes number of vertices in the mesh.
Returns
-------
The number of vertices in the mesh as an integer.
"""
return self.points.shape[0]
#Get number of triangles
def num_tri(self):
""" Computes number of triangles in the mesh.
Returns
-------
The number of triangles in the mesh as an integer.
"""
return self.triangles.shape[0]
#Converts from (x,y,z) to index of closest point
def get_index(self,point):
""" Computes the index of a given point.
Parameters
----------
point : int or (1,3) float array
A vertex in the mesh, specified by either an integer index or its coordinates.
Returns
-------
The index of the given point as an integer.
"""
if type(point) in [int,np.int32,np.int64]:
point_ind=point
elif type(point) == np.ndarray and len(point)==3:
point_ind = np.argmin(np.linalg.norm(self.points - point,axis=1))
elif type(point) in [tuple,list] and len(point)==3:
point_ind = np.argmin(np.linalg.norm(self.points - np.array(point),axis=1))
else:
sys.exit("'point' must be an integer index, or a length 3 list, tuple, or numpy ndarray (x,y,z)")
return point_ind
def geodesic_patch(self,point,r,k=7,return_mask=False):
""" Computes a geodesic patch around a specified point.
Parameters
----------
point : int or (1,3) float array
A mesh vertex.
r : float
Radius used to build patch.
k : int, default is 7
Number of nearest neighbors to use.
return_mask : boolean, default is False
If True, return the patch as a (num,verts,) boolean array
Returns
-------
An int array containing the patch point indices.
"""
W = gl.weightmatrix.knn(self.points,k,kernel='distance')
G = gl.graph(W)
point_ind = self.get_index(point)
dist = G.dijkstra([point_ind])
mask = dist < r
if return_mask:
return mask.astype(int)
else: #return indices
return np.arange(self.num_verts())[mask]
#vertex-triangle adjacencey matrix
#Returns num_verts x num_tri sparse matrix F with F_ij = 1 if vertex i belongs to triangle j
#If normalize=True, then each row is divided by the number of adjacent triangles,
#so F can be used to interplate from triangles to vertices
def tri_vert_adj(self,normalize=False):
""" Computes a sparse vertex-triangle adjacency matrix.
Parameters
----------
normalize : boolean, default is False
If True, each row is divided by the number of adjacent triangles.
Returns
-------
F : (num_verts,num_tri) boolean array
Adjacency matrix; F[i,j] = 1 if vertex i belongs to triangle j.
"""
num_verts = self.num_verts()
ind = np.arange(self.num_tri())
if np.any(self.tri_vert_adj_I) is None or np.any(self.tri_vert_adj_J) is None:
self.tri_vert_adj_I = np.hstack((self.triangles[:,0],self.triangles[:,1],self.triangles[:,2]))
self.tri_vert_adj_J = np.hstack((ind,ind,ind))
I = self.tri_vert_adj_I
J = self.tri_vert_adj_J
F = sparse.coo_matrix((np.ones(len(I)), (I,J)),shape=(self.num_verts(),self.num_tri())).tocsr()
if normalize:
num_adj_tri = F@np.ones(self.num_tri())
F = sparse.spdiags(1/num_adj_tri,0,self.num_verts(),self.num_verts())@F
return F
#Returns unit normal vectors to vertices (averaging adjacent faces and normalizing)
def vertex_normals(self):
""" Computes normal vectors to vertices.
Returns
-------
A (num_verts,3) float array containing the vertex normal vectors.
"""
if self.unit_norms is None:
self.face_normals()
fn = self.unit_norms
F = self.tri_vert_adj()
vn = F@fn
norms = np.linalg.norm(vn,axis=1)
norms[norms==0] = 1
return vn/norms[:,np.newaxis]
#Returns unit normal vectors
def face_normals(self,normalize=True):
""" Computes normal vectors to triangles (faces).
Parameters
----------
normalize: boolean, default is True
Whether or not to normalize to unit vectors; if False, vector magnitude is twice the area of the corresponding triangle.
Returns
-------
N : (num_tri,3) float array
Array containing the face normal vectors.
"""
P1 = self.points[self.triangles[:,0],:]
P2 = self.points[self.triangles[:,1],:]
P3 = self.points[self.triangles[:,2],:]
N = np.cross(P2-P1,P3-P1)
if normalize:
N = (N.T/(1e-10 + np.linalg.norm(N,axis=1))).T
self.unit_norms = N
return N
else:
self.norms = N
return N
def flip_normals(self):
""" Reverses the orientation of all normal vectors in the mesh
"""
self.triangles = self.triangles[:,::-1]
#Areas of all triangles in mesh
def tri_areas(self):
""" Computes areas of all triangles in the mesh.
Returns
-------
A (num_tri,) float array containing the areas of each triangle (face).
"""
if self.norms is None:
self.face_normals(False)
return np.linalg.norm(self.norms,axis=1)/2
#Surface area of mesh
def surf_area(self):
""" Computes surface area of the mesh.
Returns
-------
The surface area of the entire mesh as a float.
"""
return np.sum(self.tri_areas())
#Centers of each face
def face_centers(self):
""" Computes coordinates of the center of each triangle (face).
Returns
-------
A (num_tri,3) float array containing the coordinates of the face centers.
"""
P1 = self.points[self.triangles[:,0],:]
P2 = self.points[self.triangles[:,1],:]
P3 = self.points[self.triangles[:,2],:]
result = (P1 + P2 + P3)/3
self.centers = result
return result
#Volume enclosed by mesh
def volume(self):
""" Computes the volume of the mesh.
Returns
-------
The volume of the mesh as a float.
"""
if self.centers is None:
self.face_centers()
X = self.centers
X = X - np.mean(X,axis=0)
if self.norms is None:
self.face_normals(False)
return np.sum(X*self.norms)/6
def bbox(self):
""" Computes the bounding box of the mesh.
Returns
-------
A (3,) float array containing the dimensions of the bounding box.
"""
#This code is old, when the weighted version was used
#if self.centers is None:
# self.face_centers()
#X = self.centers.copy()
#X -= np.mean(X,axis=0)
#A = self.tri_areas()
#vals,vecs = weighted_pca(X,A**2)
X = self.points.copy()
X -= np.mean(X,axis=0)
vals,vecs = pca(X)
Y = X@vecs
bb = np.max(Y,axis=0) - np.min(Y,axis=0)
return bb
#Plot triangulated surface
def plotsurf(self,C=None):
""" Plots the mesh as a surface using mayavi.
Parameters
----------
C : (num_verts,3) int array, default is None
An optional per-vertex labeling scheme to use.
Returns
-------
A visualization of the mesh.
"""
from mayavi import mlab
if C is None:
mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles)
else:
mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C)
def cplotsurf(self,C=-1):
""" Plots the mesh as a surface using mayavi.
Parameters
----------
C : (num_verts,3) int array, default is -1
An optional per-vertex labeling scheme to use.
Returns
-------
mesh : amaazetools.trimesh.mesh object
A colored visualization of the mesh.
"""
from mayavi import mlab
if C.any == -1: #if no C given
C = np.ones((len(x),1))
n = len(np.unique(C))
C = C.astype(int)
if n>20:
mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C)
else:
col = (np.arange(1,n+1)) / n
colors = col[C-1]
mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=colors)
return mesh
#Write a ply file
def to_ply(self,fname,c=None):
""" Writes the mesh to a .ply file.
Parameters
----------
fname : str
The name of the .ply file to write the mesh to.
c : numpy array
Color array. If provided, then color is added to ply file.
If array is num_vert x 3, it is interprted as RGB colors
in the range 0,...,255. If the array is one dimensional
of length num_vert, then the values are interpreted as hues.
"""
f = open(fname,"w")
#Write header
f.write('ply\n')
f.write('format binary_little_endian 1.0\n')
f.write('element vertex %u\n'%self.num_verts())
f.write('property double x\n')
f.write('property double y\n')
f.write('property double z\n')
#Write color header if colors are provided
if c is not None:
f.write('property uchar red\n')
f.write('property uchar green\n')
f.write('property uchar blue\n')
f.write('element face %u\n'%self.num_tri())
f.write('property list int int vertex_indices\n')
f.write('end_header\n')
f.close()
f = open(fname,"ab")
#If no colors are provided
if c is None:
#write vertices
f.write(self.points.astype('float64').tobytes())
#If colors are provided
else:
#If scalars provided, then convert from hue to rgb
if c.ndim == 1:
c = c - np.min(c)
c = c/np.max(c)
arr = np.vstack((c,np.ones_like(c),np.ones_like(c))).T
c = 255*convert_colorspace(arr,'HSV','RGB')
#write vertices
for i in range(self.num_verts()):
f.write(self.points[i,:].astype('float64').tobytes())
f.write(c[i,:].astype('uint8').tobytes())
#write faces
T = np.hstack((np.ones((self.num_tri(),1))*3,self.triangles)).astype(int)
f.write(T.astype('int32').tobytes())
#close file
f.close()
def to_gif(self,fname,color = [],duration=7,fps=20,size=750,histeq = True):
""" Writes rotating gif
Parameters
----------
fname : str
gif filename
color : (1,3) or (num_verts,1) or (num_verts,2) float array, default is (.7,.7,.7)
3-tuple 0 to 1 RGB for single color over surface OR array the length of Self.Points for interpolation (1D or 2D - if 2D, uses first column).
duration : float, default is 7
length of gif in seconds
fps: float, default is 20
frames per second
size: float, default is 750
size of gif images
histeq : boolean, default is True
Performs histogram equalization on scalar color array; else should normalize prior to input.
"""
from skimage import exposure
from mayavi import mlab
import moviepy.editor as mpy
from pyface.api import GUI
#Make copy of points
X = self.points.copy()
if np.shape(color)[0] == np.shape(X)[0]: #scalars for plot
opt = 2
if histeq:
color = color - np.amin(color)
color = 1-exposure.equalize_hist(color/np.max(color),nbins=1000)
if np.shape(np.shape(color))[0]>1: #handle input
color = color[:,0]
elif max(np.shape(color)) == 3: #single rgb color
opt = 1
else : #not input - default to single color
color = (0.7,0.7,0.7)
opt = 1
#PCA
Mean = np.mean(X,axis=0)
cov_matrix = (X-Mean).T@(X-Mean)
Vals, P = np.linalg.eig(cov_matrix)
idx = Vals.argsort()
i = idx[2]
idx[2] = idx[1]
idx[1] = i
Vals = Vals[idx]
P = P[:,idx]
P[:,2] = np.cross(P[:,0],P[:,1])
#Rotate fragment
X = X@P
#Plot mesh
f = mlab.figure(bgcolor=(1,1,1),size=(size,size))
if opt == 1:
mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,color=color)
else :
mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,scalars=color)
#Function that makes gif animation
def make_frame(t):
mlab.view(0,180+t/duration*360)
GUI().process_events()
return mlab.screenshot(antialiased=True)
animation = mpy.VideoClip(make_frame, duration=duration)
animation.write_gif(fname, fps=fps)
mlab.close(f)
def svi(self,r,ID=None):
""" Computes spherical volume invariant.
Parameters
----------
r : (k,1) float array
List of radii to use.
ID : (n,1) boolean array, default is None
Spherical volume is only computed at points with True indices.
Returns
-------
S : (n,1) float array
The volumes corresponding to each point.
G : (n,1) float array
The gamma values corresponding to each point.
"""
return svi.svi(self.points,self.triangles,r,ID=ID)
def svipca(self,r):
""" Computes SVIPCA
Parameters
----------
r : (k,1) float array
List of radii to use.
Returns
-------
S : (n,1) float array
The volumes corresponding to each point.
K1 : (n,1) float array
The first principle curvature for each point.
K2 : (n,1) float array
The second principle curvature for each point.
V1 : (n,3) float array
The first principal direction for each point.
V2 : (n,3) float array
The second principal direction for each point.
V3 : (n,3) float array
The third principal direction for each point.
"""
return svi.svipca(self.points,self.triangles,r)
def edge_graph_detect(self,**kwargs):
""" Detects edges using SVIPCA and principal direction metric.
Parameters
----------
M : amaazetools.trimesh.mesh object
k1 : float, optional
A constant on the minimum of the inverse of principal curvatures.
k2 : float, optional
A constant on the mean volume.
VOL : (n,1) float array, optional
Spherical volume corresponding to each point in the mesh.
K1 : (n,1) float array, optional
First principal curvature of each point.
K2 : (n,1) float array, optional
Second principal curvature of each point.
V1 : (n,3) float array, optional
First principal direction for each point.
V2 : (n,3) float array, optional
Second principal direction for each point.
rvol : float, optional
Radius to use for svipca.
rpdir : float, optional
Radius to use for the principal direction metric.
Returns
-------
Edges : (n,1) boolean array
A true value corresponds to that index being an edge point.
"""
return edge_detection.edge_graph_detect(self,**kwargs)
def graph_setup(self,n,r,p,seed=None):
""" Creates the graph to use for poisson learning.
Parameters
----------
n : int
The number of vertices to sample for the graph.
r : float
Radius for graph construction.
p : float
Weight matrix parameter.
seed : int, default is None
Optional seed for random number generator.
Returns
-------
poisson_W_matrix : (n,n) scipy.sparse.lil_matrix
Weight matrix describing similarities of normal vectors.
poisson_J_matrix : (num_verts,n) scipy.sparse.lil_matrix
Matrix with indices of nearest neighbors.
poisson_node_idx : (num_verts,1) int array
The indices of the closest point in the sample.
"""
rng = (
np.random.default_rng(seed=seed)
if seed is not None
else np.random.default_rng()
)
if self.poisson_W_matrix is None or self.poisson_J_matrix is None or self.poisson_node_idx is None:
v = self.vertex_normals()
N = self.num_verts()
#Random subsample
ss_idx = np.matrix(rng.choice(self.points.shape[0],n,replace=False))
y = np.squeeze(self.points[ss_idx,:])
w = np.squeeze(v[ss_idx,:])
xTree = spatial.cKDTree(self.points)
nn_idx = xTree.query_ball_point(y, r)
yTree = spatial.cKDTree(y)
nodes_idx = yTree.query_ball_point(y, r)
bn = np.zeros((n,3))
J = sparse.lil_matrix((N,n))
for i in range(n):
vj = v[nn_idx[i],:]
normal_diff = w[i] - vj
weights = np.exp(-8 * np.sum(np.square(normal_diff),1,keepdims=True))
bn[i] = np.sum(weights*vj,0) / np.sum(weights,0)
#Set ith row of J
normal_diff = bn[i]- vj
weights = np.exp(-8 * np.sum(np.square(normal_diff),1))#,keepdims=True))
J[nn_idx[i],i] = weights
#Normalize rows of J
RSM = sparse.spdiags((1 / np.sum(J,1)).ravel(),0,N,N)
J = RSM @ J
#Compute weight matrix W
W = sparse.lil_matrix((n,n))
for i in range(n):
nj = bn[nodes_idx[i]]
normal_diff = bn[i] - nj
weights = np.exp(-32 * ((np.sqrt(np.sum(np.square(normal_diff),1)))/2)**p)
W[i,nodes_idx[i]] = weights
#Find nearest node to each vertex
nbrs = NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit(y)
instances, node_idx = nbrs.kneighbors(self.points)
self.poisson_W_matrix = W
self.poisson_J_matrix = J
self.poisson_node_idx = node_idx
return self.poisson_W_matrix, self.poisson_J_matrix, self.poisson_node_idx
def poisson_label(self,g,I,n=5000,r=0.5,p=1,s=None,graph_setup=False):
""" Performs poisson learning on the mesh.
Parameters
----------
g : (k,1) int array
Labels to assign to vertices.
I : (k,1) int array
User-selected vertices.
n : int, default is 5000
The number of nodes to sample.
r : float, default is 0.5
The radius for nearest neighbor search.
p : float, default is 1.0
The weight matrix parameter.
s : default is None
Weights for fine-tuning Poisson learning.
graph_setup : boolean, default is False
Force graph construction if True.
Returns
-------
L : (num_verts,1) int array
Poisson labelling of each point in mesh.
"""
if graph_setup or (self.poisson_node_idx is None):
self.graph_setup(n,r,p)
I = self.poisson_node_idx[I]
W = self.poisson_W_matrix
u = poisson_learning(W,g,I)
J = self.poisson_J_matrix
if s is None:
L = np.argmax(J@u,1)
else:
k = np.max(g)+1 #Number of classes, assuming 0,1,2,3,..,k-1 are used
L = np.argmax(J@(u*s[:k]),1)
L = canonical_labels(L)
self.poisson_labels = L
return L
def virtual_goniometer(self,point,r,k=7,SegParam=2,return_edge_points=False,
number_edge_points=None,return_euclidean_radius=False):
""" Runs a virtual goniometer to measure break angles.
Parameters
----------
point : (1,3) float array or int
A mesh vertex, as a coordinate or index.
r : float
Radius used to build patch.
k: int, default is 7
Number of nearest neighbors to use.
SegParam : float, default is 2
Segmentation parameter that encourages splitting patch in half as it increases in size.
return_edge_points : boolean, default is False
If True, return edge points in patch.
number_edge_points : boolean, default is None
Specifies how many edge points to return.
return_euclidean_radius : boolean, default is False
If True, returns Euclidean radius of patch.
Returns
-------
theta : float
The break angle.
n1 : (3,) float array
Contains the normal vector of one break surface.
n2 : (3,) float array
Contains the normal vector of the other surface.
C : (num_verts,) int array
Contains the cluster (1 or 2) of each point in the patch; points not in the patch are assigned a 0.
E : (number_edge_points,1) int array, not returned by default
List of edge point indices. Only returned if return_edge_points=True
euclidean_radius : float, not returned by default
Euclidean radius of virtual goniometer patch. Only returned if return_euclidean_radius=True
"""
patch_ind = self.geodesic_patch(point,r,k=k)
patch = self.points[patch_ind,:]
normals = self.vertex_normals()[patch_ind,:]
theta,n1,n2,C_local = __virtual_goniometer__(patch,normals,SegParam=SegParam)
C = np.zeros(self.num_verts())
C[patch_ind] = C_local
center = self.points[self.get_index(point),:]
euclidean_radius = np.max(np.linalg.norm(patch - center,axis=1))
if return_edge_points:
E = edge_points(patch,C_local,k=k,number=number_edge_points)
E = patch_ind[E]
if return_euclidean_radius:
return theta,n1,n2,C,E,euclidean_radius
else:
return theta,n1,n2,C,E
else:
if return_euclidean_radius:
return theta,n1,n2,C,euclidean_radius
else:
return theta,n1,n2,C
#Virtual goniometer (internal function)
#Input:
# P = nx3 numpy array of vertices of points in patch
# N = nx3 array of vertex normals
# Can also use N as face normals, and P as face centroids
#Output:
# theta = Angle
# n1,n2 = Normal vectors between two patches (theta=angle(n1,n2))
# C = Clusters (C=1 and C=2 are the two detected clusters)
def __virtual_goniometer__(P,N,SegParam=2,UsePCA=True,UsePower=False):
""" Internal function used within class method virtual_goniometer to measure break angles.
Parameters
----------
P : (n,3) float array
Vertices in a patch.
N : (n,3) float array
Vertex normal vectors.
SegParam : float, default is 2
Segmentation parameter that encourages splitting patch in half as it increases in size.
UsePCA: boolean, default is True
Uses PCA instead of averaged surface normals if True.
UsePower : boolean, default is False
Uses the power method when doing PCA if True.
Returns
-------
theta : float
The break angle.
n1 : (3,) float array
Contains the normal vector of one break surface.
n2 : (3,) float array
Contains the normal vector of the other surface.
C : (num_verts,) int array
Contains the cluster (1 or 2) of each point in the patch; points not in the patch are assigned a 0.
"""
n = P.shape[0]
if UsePower:
N1 = pca_smallest_eig_powermethod(N,center=False)
N1 = np.reshape(N1,(3,))
else:
N1 = pca_smallest_eig(N,center=False)
N2 = np.sum(N,axis=0)
v = np.cross(N1,N2)
v = v/np.linalg.norm(v)
m = np.mean(P,axis=0)
dist = np.sqrt(np.sum((P - m)**2,axis=1))
i = np.argmin(dist)
radius = np.max(dist)
D = (P - P[i,:])/radius
#The SegParam=2 is just hand tuned. Larger SegParam encourages the clustering to split the patch in half
#SegParam=0 is the previous version of the virtual goniometer
x = np.sum(v*N,axis=1) + SegParam*np.sum(v*D,axis=1)
#Clustering
w,m = withiness(x)
C = np.zeros(n,)
C[x>m] = 1
C[x<=m] = 2
if UsePCA:
P1 = P[C==1,:]
P2 = P[C==2,:]
if UsePower:
n1 = pca_smallest_eig_powermethod(P1)
n2 = pca_smallest_eig_powermethod(P2)
else:
n1 = pca_smallest_eig(P1)
n2 = pca_smallest_eig(P2)
s1 = np.mean(N[C==1,:],axis=0)
if np.dot(n1,s1) < 0:
n1 = -n1
s2 = np.mean(N[C==2,:],axis=0)
if np.dot(n2,s2) < 0:
n2 = -n2
else: #Use average of surface normals
n1 = np.average(N[C==1,:],axis=0)
n1 = n1/np.linalg.norm(n1)
n2 = np.average(N[C==2,:],axis=0)
n2 = n2/np.linalg.norm(n2)
#Angle between
theta = 180-np.arccos(np.dot(n1,n2))*180/np.pi
return theta,n1,n2,C
def conjgrad(A,b,x,T,tol):
""" Performs conjugate gradient descent.
Parameters
----------
A : matrix multiplying x
b : vector equal to product of A and x
x : initial estimate for x
T : int
Number of time steps allowed.
Tol : float
Desired convergence tolerance of result.
Returns
-------
x : calculated value for x
i : int
Number of iterations required for convergence.
"""
r = b - A@x
p = r
rsold = np.sum(r * r,0)
for i in range(int(T)):
Ap = A@p
alpha = rsold / np.sum(p*Ap,0)
x = x + alpha*p
r = r - alpha*Ap
rsnew = np.sum(r*r,0)
if np.sqrt(np.sum(rsnew)) < tol:
break
p = r + (rsnew / rsold) * p
rsold = rsnew
return x,i
def poisson_learning(W,g,I):
""" Performs poisson learning.
Parameters
----------
W : (n,n) float array
Weight matrix of subsampled graph of mesh.
g : (m,1) int array
Labels to assign to selected vertices.
I : (m,1) int array
Indices of user-selected vertices.
Returns
-------
u : (num_verts,1) int array
Poisson labels for each vertex in the mesh.
"""
k = len(np.unique(g))
n = W.shape[0]
m = len(I)
I = I - 1
g = g.T - 1
F = np.zeros((n,k))
for i in range(m):
F[I[i],g[i]] = 1
c = np.ones((1,n)) @ F / len(g)
F[I] -= c
deg = np.sum(W,1)
D = sparse.spdiags(deg.T,0,n,n)
L = D-W #Unnormalized graph laplacian matrix
#Preconditioning
Dinv2 = sparse.spdiags(np.power(np.sum(W,1),-1/2).T,0,n,n)
Lnorm = Dinv2 @ L @ Dinv2
F = Dinv2 @ F
#Conjugate Gradient Solver
u,i = conjgrad(Lnorm,F,np.zeros((n,k)),1e5, np.sqrt(n)*1e-10)
#Undo preconditioning
u = Dinv2 @ u
return u
def canonical_labels(u):
""" Reorders a label vector into canonical order.
Parameters
----------
u : (num_verts,1) int array
A label vector.
Returns
-------
u : (num_verts,1) int array
A reodered label vector.
"""
n = len(u)
k = len(np.unique(u))
label_set = np.zeros((k,1))
label = 0
for i in range(n):
if u[i] > label:
label += 1
l = u[i]
I = u == label
J = u == l
u[I] = l
u[J] = label
return u
def edge_points(points,u,k=7,return_mask=False,number=None):
""" Computes the edge points of the mesh.
Parameters
----------
points : (n,3) float array
Coordinates of points.
u : (num_verts,1) int array
Array of labels for each point.
k : int, default is 7
Number of nearest neighbors to use.
return_mask : boolean, default is False
If True, return edge_points as a (num_verts,) boolean array.
number : int, default is None
Max number of edge points to return.
Returns
-------
An int array containing the edge point indices.
"""
num_verts = points.shape[0]
W = gl.weightmatrix.knn(points,k,kernel='uniform',symmetrize=False)
d = gl.graph(W).degree_vector()
mask = d*u != W@u
#Select a few points spaced out along edge
if number is not None:
edge_ind = np.arange(num_verts)[mask]
edge_points = points[mask,:]
num_edge_points = len(edge_points)
#PCA
mean = np.mean(edge_points,axis=0)
cov = (edge_points-mean).T@(edge_points-mean)
l,v = sparse.linalg.eigs(cov,k=1,which='LM')
proj = (edge_points-mean)@v.real
#Sort along princpal axis
sort_ind = np.argsort(proj.flatten())
dx = (num_edge_points-1)/(number-1)
spaced_edge_ind = edge_ind[sort_ind[np.arange(0,num_edge_points,dx).astype(int)]]
mask = np.zeros(num_verts,dtype=bool)
mask[spaced_edge_ind]=True
if return_mask:
return mask.astype(int)
else: #return indices
return np.arange(num_verts)[mask]
#def edge_points(self,u,k=7,return_mask=False,number=None):
# """ Computes the edge points of the mesh.
#
# Parameters
# ----------
# u : (num_verts,1) int array
# Array of labels for each point.
# k : int, default is 7
# Number of nearest neighbors to use.
# return_mask : boolean, default is False
# If True, return edge_points as a (num,verts,) boolean array.
# number : int, default is None
# Max number of edge points to return.
#
# Returns
# -------
# An int array containing the edge point indices.
# """
#
# W = gl.weightmatrix.knn(self.points,k,kernel='uniform',symmetrize=False)
# d = gl.graph(W).degree_vector()
# mask = d*u != W@u
#
# #Select a few points spaced out along edge
# if number is not None:
# edge_ind = np.arange(self.num_verts())[mask]
# edge_points = self.points[mask,:]
# num_edge_points = len(edge_points)
#
# #PCA
# mean = np.mean(edge_points,axis=0)
# cov = (edge_points-mean).T@(edge_points-mean)
# l,v = sparse.linalg.eigs(cov,k=1,which='LM')
# proj = (edge_points-mean)@v.real
#
# #Sort along princpal axis
# sort_ind = np.argsort(proj.flatten())
# dx = (num_edge_points-1)/(number-1)
# spaced_edge_ind = edge_ind[sort_ind[np.arange(0,num_edge_points,dx).astype(int)]]
# mask = np.zeros(self.num_verts(),dtype=bool)
# mask[spaced_edge_ind]=True
#
# if return_mask:
# return mask.astype(int)
# else: #return indices
# return np.arange(self.num_verts())[mask]
Functions
def canonical_labels(u)
-
Reorders a label vector into canonical order.
Parameters
u
:(num_verts,1) int array
- A label vector.
Returns
u
:(num_verts,1) int array
- A reodered label vector.
Expand source code
def canonical_labels(u): """ Reorders a label vector into canonical order. Parameters ---------- u : (num_verts,1) int array A label vector. Returns ------- u : (num_verts,1) int array A reodered label vector. """ n = len(u) k = len(np.unique(u)) label_set = np.zeros((k,1)) label = 0 for i in range(n): if u[i] > label: label += 1 l = u[i] I = u == label J = u == l u[I] = l u[J] = label return u
def conjgrad(A, b, x, T, tol)
-
Performs conjugate gradient descent.
Parameters
A
:matrix multiplying x
b
:vector equal to product
ofA and x
x
:initial estimate for x
T
:int
- Number of time steps allowed.
Tol
:float
- Desired convergence tolerance of result.
Returns
x
:calculated value for x
i
:int
- Number of iterations required for convergence.
Expand source code
def conjgrad(A,b,x,T,tol): """ Performs conjugate gradient descent. Parameters ---------- A : matrix multiplying x b : vector equal to product of A and x x : initial estimate for x T : int Number of time steps allowed. Tol : float Desired convergence tolerance of result. Returns ------- x : calculated value for x i : int Number of iterations required for convergence. """ r = b - A@x p = r rsold = np.sum(r * r,0) for i in range(int(T)): Ap = A@p alpha = rsold / np.sum(p*Ap,0) x = x + alpha*p r = r - alpha*Ap rsnew = np.sum(r*r,0) if np.sqrt(np.sum(rsnew)) < tol: break p = r + (rsnew / rsold) * p rsold = rsnew return x,i
def edge_points(points, u, k=7, return_mask=False, number=None)
-
Computes the edge points of the mesh.
Parameters
points
:(n,3) float array
- Coordinates of points.
u
:(num_verts,1) int array
- Array of labels for each point.
k
:int
, defaultis 7
- Number of nearest neighbors to use.
return_mask
:boolean
, defaultis False
- If True, return edge_points as a (num_verts,) boolean array.
number
:int
, defaultis None
- Max number of edge points to return.
Returns
An int array containing the edge point indices.
Expand source code
def edge_points(points,u,k=7,return_mask=False,number=None): """ Computes the edge points of the mesh. Parameters ---------- points : (n,3) float array Coordinates of points. u : (num_verts,1) int array Array of labels for each point. k : int, default is 7 Number of nearest neighbors to use. return_mask : boolean, default is False If True, return edge_points as a (num_verts,) boolean array. number : int, default is None Max number of edge points to return. Returns ------- An int array containing the edge point indices. """ num_verts = points.shape[0] W = gl.weightmatrix.knn(points,k,kernel='uniform',symmetrize=False) d = gl.graph(W).degree_vector() mask = d*u != W@u #Select a few points spaced out along edge if number is not None: edge_ind = np.arange(num_verts)[mask] edge_points = points[mask,:] num_edge_points = len(edge_points) #PCA mean = np.mean(edge_points,axis=0) cov = (edge_points-mean).T@(edge_points-mean) l,v = sparse.linalg.eigs(cov,k=1,which='LM') proj = (edge_points-mean)@v.real #Sort along princpal axis sort_ind = np.argsort(proj.flatten()) dx = (num_edge_points-1)/(number-1) spaced_edge_ind = edge_ind[sort_ind[np.arange(0,num_edge_points,dx).astype(int)]] mask = np.zeros(num_verts,dtype=bool) mask[spaced_edge_ind]=True if return_mask: return mask.astype(int) else: #return indices return np.arange(num_verts)[mask]
def load_ply(path)
-
Loads a file path or url and creates a mesh object.
Parameters
path
:str
- URL or file path at which to access .ply file.
Returns
A mesh object generated from a .ply file found at the file path location.
Expand source code
def load_ply(path): """ Loads a file path or url and creates a mesh object. Parameters ---------- path : str URL or file path at which to access .ply file. Returns ------- A mesh object generated from a .ply file found at the file path location. """ try: url.urlopen(path) is_url = True except: is_url = False if is_url: fname = path.rsplit('/', 1)[-1] url.urlretrieve(path,fname) else: fname = path points,triangles = read_ply(fname) return mesh(points,triangles)
def marching_cubes(volume, level=None, spacing=(1, 1, 1))
-
SK-Image's marching cubes does not return a clean triangulations - often has replicate points, self-triangles, etc. This fixes that.
Parameters
volume
:(l,w,h) float array
- A 3-D grid discretization of the function to be surfaced.
level
:float
- isolevel to extract surface at.
spacing
:(3) float
- denotes the dimensions of each voxel in the volume.
Returns
p
:(n,3) float
- points of triangulation
t
:(m,3) int
- triangles of triangulation
Expand source code
def marching_cubes(volume,level=None,spacing=(1,1,1)): """ SK-Image's marching cubes does not return a clean triangulations - often has replicate points, self-triangles, etc. This fixes that. Parameters ---------- volume : (l,w,h) float array A 3-D grid discretization of the function to be surfaced. level : float isolevel to extract surface at. spacing : (3) float denotes the dimensions of each voxel in the volume. Returns ------- p : (n,3) float points of triangulation t : (m,3) int triangles of triangulation """ p,t,n,val = measure.marching_cubes(volume,level=level,spacing=spacing) #sometimes marching cubes produces... artifacts... so here's a very basic intro to mesh cleaning! #first: eliminate repeated points: p,index,inv = np.unique(p,axis=0, return_index=True,return_inverse=True) #next: rid triangulation of non-triangles: t = inv[t] badt = (t[:,0]==t[:,1])+(t[:,1]==t[:,2])+(t[:,2]==t[:,0]) t = t[badt ==False,:] #remove unreferenced points, if any ind = -1*np.ones(p.shape[0],int) uni = np.unique(t.flatten()) #these are already sorted for numpy ind[uni] = np.arange(uni.shape[0]) p = p[ind>-1,:] t = ind[t] #finally, check right hand rule... this works for ~convex objects, anyway... #m = tm.mesh(p,t) #tc = m.face_centers() #tn = m.face_normals() #cent = m.points.mean(0) #fliporder = np.sum(tn*(tc-cent),1)<0 #t[fliporder,1:3] = t[fliporder,2:0:-1] return p,t
def pca(P)
-
Computes principal component analysis (PCA) on a point cloud P.
Parameters
P
:(n,d) float array
- A point cloud.
Returns
vals
:(d,) float arrayy
- The variances among each principal component.
vecs
:(d,d) float array
- The principal component vectors.
Expand source code
def pca(P): """ Computes principal component analysis (PCA) on a point cloud P. Parameters ---------- P : (n,d) float array A point cloud. Returns ------- vals : (d,) float arrayy The variances among each principal component. vecs : (d,d) float array The principal component vectors. """ P = P - np.mean(P,axis=0) vals,vecs = np.linalg.eig(P.T@P) idx = np.argsort(-vals) return vals[idx],vecs[:,idx]
def pca_smallest_eig(X, center=True)
-
Computes the last principal component of a point cloud X.
Parameters
X
:(n,3) float array
- A point cloud.
center
:boolean
, defaultis True
- Data is centered if True.
Returns
A float array of size (3,) containing the last principal component vector.
Expand source code
def pca_smallest_eig(X,center=True): """ Computes the last principal component of a point cloud X. Parameters ---------- X : (n,3) float array A point cloud. center : boolean, default is True Data is centered if True. Returns ------- A float array of size (3,) containing the last principal component vector. """ if center: m = np.mean(X,axis=0) cov = np.transpose(X-m)@(X-m) else: cov = np.transpose(X)@X w,v = np.linalg.eig(cov) i = np.argmin(w) return v[:,i]
def pca_smallest_eig_powermethod(X, center=True)
-
Computes the last principal component of a point cloud X using the power method.
Parameters
X
:(n,3) float array
- A point cloud.
center
:boolean
, defaultis True
- Data is centered if True.
Returns
A float array of size (3,) containing the last principal component vector.
Expand source code
def pca_smallest_eig_powermethod(X,center=True): """ Computes the last principal component of a point cloud X using the power method. Parameters ---------- X : (n,3) float array A point cloud. center : boolean, default is True Data is centered if True. Returns ------- A float array of size (3,) containing the last principal component vector. """ if center: m = np.mean(X,axis=0) cov = np.transpose(X-m)@(X-m)/X.shape[0] else: cov = np.transpose(X)@X/X.shape[0] lmax,v = power_method(cov) w,v = np.linalg.eig(cov) l,v = power_method(cov - (lmax+1)*np.eye(3)) return v.flatten()
def poisson_learning(W, g, I)
-
Performs poisson learning.
Parameters
W
:(n,n) float array
- Weight matrix of subsampled graph of mesh.
g
:(m,1) int array
- Labels to assign to selected vertices.
I
:(m,1) int array
- Indices of user-selected vertices.
Returns
u
:(num_verts,1) int array
- Poisson labels for each vertex in the mesh.
Expand source code
def poisson_learning(W,g,I): """ Performs poisson learning. Parameters ---------- W : (n,n) float array Weight matrix of subsampled graph of mesh. g : (m,1) int array Labels to assign to selected vertices. I : (m,1) int array Indices of user-selected vertices. Returns ------- u : (num_verts,1) int array Poisson labels for each vertex in the mesh. """ k = len(np.unique(g)) n = W.shape[0] m = len(I) I = I - 1 g = g.T - 1 F = np.zeros((n,k)) for i in range(m): F[I[i],g[i]] = 1 c = np.ones((1,n)) @ F / len(g) F[I] -= c deg = np.sum(W,1) D = sparse.spdiags(deg.T,0,n,n) L = D-W #Unnormalized graph laplacian matrix #Preconditioning Dinv2 = sparse.spdiags(np.power(np.sum(W,1),-1/2).T,0,n,n) Lnorm = Dinv2 @ L @ Dinv2 F = Dinv2 @ F #Conjugate Gradient Solver u,i = conjgrad(Lnorm,F,np.zeros((n,k)),1e5, np.sqrt(n)*1e-10) #Undo preconditioning u = Dinv2 @ u return u
def power_method(A, tol=1e-12)
-
Computes the smallest (in absolute value) eigenvalue and its corresponding eigenvector using the power method.
Parameters
A
:(n,n) float array
- A square matrix that one wishes to find the smallest (in absolute value) eigenvalue and corresponding eigenvector of.
tol
:float
, defaultis 1e-12
- The desired tolerance threshold after which to stop iteration.
Parameters
l
:float
- The smallest (in absolute value) eigenvalue of A.
x
:(n,1) float array
- Array containing the eigenvector corresponding to the smallest (in absolute value) eigenvalue of A.
Expand source code
def power_method(A,tol=1e-12): """ Computes the smallest (in absolute value) eigenvalue and its corresponding eigenvector using the power method. Parameters ---------- A : (n,n) float array A square matrix that one wishes to find the smallest (in absolute value) eigenvalue and corresponding eigenvector of. tol : float, default is 1e-12 The desired tolerance threshold after which to stop iteration. Parameters ---------- l : float The smallest (in absolute value) eigenvalue of A. x : (n,1) float array Array containing the eigenvector corresponding to the smallest (in absolute value) eigenvalue of A. """ n = A.shape[0] x = np.random.rand(n,1) err = 1 i = 1 while err > tol: x = A@x x = x/np.linalg.norm(x) l = np.transpose(x)@A@x err = np.linalg.norm(A@x - l*x) i = i+1 return l,x
def read_ply(fname)
-
Reads the vertex and triangle data stored in a .ply file.
Parameters
fname
:str
- Name of the file to read from.
Returns
P
:(num_verts,3) float array
- The coordinates of the vertices of the mesh.
T
:(num_tri,3) int array
- The indices of the triangles of the mesh.
Expand source code
def read_ply(fname): """ Reads the vertex and triangle data stored in a .ply file. Parameters ---------- fname: str Name of the file to read from. Returns ------- P : (num_verts,3) float array The coordinates of the vertices of the mesh. T : (num_tri,3) int array The indices of the triangles of the mesh. """ plydata = PlyData.read(fname) #Convert data formats try: tri_data = plydata['face'].data['vertex_indices'] except: tri_data = plydata['face'].data['vertex_index'] T = np.vstack(tri_data) x = plydata['vertex'].data['x'] y = plydata['vertex'].data['y'] z = plydata['vertex'].data['z'] P = np.vstack((x,y,z)) P = P.transpose() return P,T.astype(int)
def synth_mesh(angle, num_pts)
-
Creates a synthetic mesh of two intersecting planes at a desired angle.
Parameters
angle
:float
- Intersection angle.
num_pts
:int
- Number of vertices in the mesh.
Returns
A mesh object.
Expand source code
def synth_mesh(angle, num_pts): """ Creates a synthetic mesh of two intersecting planes at a desired angle. Parameters ---------- angle: float Intersection angle. num_pts : int Number of vertices in the mesh. Returns ------- A mesh object. """ h = 1.5*num_pts**(-1/2) dx = np.arange(-1,1+h,h) x,y,z = np.meshgrid(dx,dx,dx) f = y - x + 0.4 f = np.minimum(f,x + y + 0.4) f = np.minimum(f,0.4 - y) f = np.minimum(f,0.4 - z) f = np.minimum(f,0.4 + z) #Marching cubes for isosurface verts,faces,normals,values = measure.marching_cubes(f,0,spacing=(h,h,h),allow_degenerate=False) #Adjust angle verts -= [1,1,1] verts += [0.4,0,0] verts[:,1] *= np.tan(angle*np.pi/180/2) #Create mesh and flip normals m = mesh(verts,faces) m.flip_normals() return m
def weighted_pca(P, W)
-
Computes weighted principal component analysis (PCA) on a point cloud P.
Parameters
P
:(n,d) float array
- A point cloud.
W
:(n,) float array
- An array containing the weights of the points.
Returns
vals
:(d,) float array
- The variances among each principal component.
vecs
:(d,d) float array
- The principal component vectors.
sign
:boolean
- True when the first principal component direction is positively oriented.
Expand source code
def weighted_pca(P,W): """ Computes weighted principal component analysis (PCA) on a point cloud P. Parameters ---------- P : (n,d) float array A point cloud. W : (n,) float array An array containing the weights of the points. Returns ------- vals : (d,) float array The variances among each principal component. vecs : (d,d) float array The principal component vectors. sign : boolean True when the first principal component direction is positively oriented. """ P = P - np.sum(P*W[:,None],axis=0)/np.sum(W) vals,vecs = np.linalg.eig(P.T@(P*W[:,None])) idx = np.argsort(-vals) return vals[idx], vecs[:,idx]
def withiness(x)
-
Computes withiness (how well 1-D data clusters into two groups).
Parameters
x
:(n,1) float array
- A 1-D collection of data.
Returns
w
:float
- The withiness of the data.
m
:float
- The point at which to split the data into 2 clusters.
Expand source code
def withiness(x): """ Computes withiness (how well 1-D data clusters into two groups). Parameters ---------- x : (n,1) float array A 1-D collection of data. Returns ------- w : float The withiness of the data. m : float The point at which to split the data into 2 clusters. """ x = np.sort(x) sigma = np.std(x) n = x.shape[0] v = np.zeros(n-1,) for i in range(n-1): x1 = x[:(i+1)] x2 = x[(i+1):] m1 = np.mean(x1); m2 = np.mean(x2); v[i] = (np.sum((x1-m1)**2) + np.sum((x2-m2)**2))/(sigma**2*n); ind = np.argmin(v) m = x[ind] w = v[ind] return w,m
Classes
class mesh (*args)
-
Expand source code
class mesh: def __init__(self,*args): self.points = args[0] self.triangles = args[1] self.unit_norms = None self.norms = None self.centers = None self.knn_I = None self.knn_J = None self.knn_D = None self.tri_vert_adj_I = None self.tri_vert_adj_J = None self.poisson_W_matrix = None self.poisson_J_matrix = None self.poisson_node_idx = None self.poisson_labels = None #Get number of vertices def num_verts(self): """ Computes number of vertices in the mesh. Returns ------- The number of vertices in the mesh as an integer. """ return self.points.shape[0] #Get number of triangles def num_tri(self): """ Computes number of triangles in the mesh. Returns ------- The number of triangles in the mesh as an integer. """ return self.triangles.shape[0] #Converts from (x,y,z) to index of closest point def get_index(self,point): """ Computes the index of a given point. Parameters ---------- point : int or (1,3) float array A vertex in the mesh, specified by either an integer index or its coordinates. Returns ------- The index of the given point as an integer. """ if type(point) in [int,np.int32,np.int64]: point_ind=point elif type(point) == np.ndarray and len(point)==3: point_ind = np.argmin(np.linalg.norm(self.points - point,axis=1)) elif type(point) in [tuple,list] and len(point)==3: point_ind = np.argmin(np.linalg.norm(self.points - np.array(point),axis=1)) else: sys.exit("'point' must be an integer index, or a length 3 list, tuple, or numpy ndarray (x,y,z)") return point_ind def geodesic_patch(self,point,r,k=7,return_mask=False): """ Computes a geodesic patch around a specified point. Parameters ---------- point : int or (1,3) float array A mesh vertex. r : float Radius used to build patch. k : int, default is 7 Number of nearest neighbors to use. return_mask : boolean, default is False If True, return the patch as a (num,verts,) boolean array Returns ------- An int array containing the patch point indices. """ W = gl.weightmatrix.knn(self.points,k,kernel='distance') G = gl.graph(W) point_ind = self.get_index(point) dist = G.dijkstra([point_ind]) mask = dist < r if return_mask: return mask.astype(int) else: #return indices return np.arange(self.num_verts())[mask] #vertex-triangle adjacencey matrix #Returns num_verts x num_tri sparse matrix F with F_ij = 1 if vertex i belongs to triangle j #If normalize=True, then each row is divided by the number of adjacent triangles, #so F can be used to interplate from triangles to vertices def tri_vert_adj(self,normalize=False): """ Computes a sparse vertex-triangle adjacency matrix. Parameters ---------- normalize : boolean, default is False If True, each row is divided by the number of adjacent triangles. Returns ------- F : (num_verts,num_tri) boolean array Adjacency matrix; F[i,j] = 1 if vertex i belongs to triangle j. """ num_verts = self.num_verts() ind = np.arange(self.num_tri()) if np.any(self.tri_vert_adj_I) is None or np.any(self.tri_vert_adj_J) is None: self.tri_vert_adj_I = np.hstack((self.triangles[:,0],self.triangles[:,1],self.triangles[:,2])) self.tri_vert_adj_J = np.hstack((ind,ind,ind)) I = self.tri_vert_adj_I J = self.tri_vert_adj_J F = sparse.coo_matrix((np.ones(len(I)), (I,J)),shape=(self.num_verts(),self.num_tri())).tocsr() if normalize: num_adj_tri = F@np.ones(self.num_tri()) F = sparse.spdiags(1/num_adj_tri,0,self.num_verts(),self.num_verts())@F return F #Returns unit normal vectors to vertices (averaging adjacent faces and normalizing) def vertex_normals(self): """ Computes normal vectors to vertices. Returns ------- A (num_verts,3) float array containing the vertex normal vectors. """ if self.unit_norms is None: self.face_normals() fn = self.unit_norms F = self.tri_vert_adj() vn = F@fn norms = np.linalg.norm(vn,axis=1) norms[norms==0] = 1 return vn/norms[:,np.newaxis] #Returns unit normal vectors def face_normals(self,normalize=True): """ Computes normal vectors to triangles (faces). Parameters ---------- normalize: boolean, default is True Whether or not to normalize to unit vectors; if False, vector magnitude is twice the area of the corresponding triangle. Returns ------- N : (num_tri,3) float array Array containing the face normal vectors. """ P1 = self.points[self.triangles[:,0],:] P2 = self.points[self.triangles[:,1],:] P3 = self.points[self.triangles[:,2],:] N = np.cross(P2-P1,P3-P1) if normalize: N = (N.T/(1e-10 + np.linalg.norm(N,axis=1))).T self.unit_norms = N return N else: self.norms = N return N def flip_normals(self): """ Reverses the orientation of all normal vectors in the mesh """ self.triangles = self.triangles[:,::-1] #Areas of all triangles in mesh def tri_areas(self): """ Computes areas of all triangles in the mesh. Returns ------- A (num_tri,) float array containing the areas of each triangle (face). """ if self.norms is None: self.face_normals(False) return np.linalg.norm(self.norms,axis=1)/2 #Surface area of mesh def surf_area(self): """ Computes surface area of the mesh. Returns ------- The surface area of the entire mesh as a float. """ return np.sum(self.tri_areas()) #Centers of each face def face_centers(self): """ Computes coordinates of the center of each triangle (face). Returns ------- A (num_tri,3) float array containing the coordinates of the face centers. """ P1 = self.points[self.triangles[:,0],:] P2 = self.points[self.triangles[:,1],:] P3 = self.points[self.triangles[:,2],:] result = (P1 + P2 + P3)/3 self.centers = result return result #Volume enclosed by mesh def volume(self): """ Computes the volume of the mesh. Returns ------- The volume of the mesh as a float. """ if self.centers is None: self.face_centers() X = self.centers X = X - np.mean(X,axis=0) if self.norms is None: self.face_normals(False) return np.sum(X*self.norms)/6 def bbox(self): """ Computes the bounding box of the mesh. Returns ------- A (3,) float array containing the dimensions of the bounding box. """ #This code is old, when the weighted version was used #if self.centers is None: # self.face_centers() #X = self.centers.copy() #X -= np.mean(X,axis=0) #A = self.tri_areas() #vals,vecs = weighted_pca(X,A**2) X = self.points.copy() X -= np.mean(X,axis=0) vals,vecs = pca(X) Y = X@vecs bb = np.max(Y,axis=0) - np.min(Y,axis=0) return bb #Plot triangulated surface def plotsurf(self,C=None): """ Plots the mesh as a surface using mayavi. Parameters ---------- C : (num_verts,3) int array, default is None An optional per-vertex labeling scheme to use. Returns ------- A visualization of the mesh. """ from mayavi import mlab if C is None: mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles) else: mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C) def cplotsurf(self,C=-1): """ Plots the mesh as a surface using mayavi. Parameters ---------- C : (num_verts,3) int array, default is -1 An optional per-vertex labeling scheme to use. Returns ------- mesh : amaazetools.trimesh.mesh object A colored visualization of the mesh. """ from mayavi import mlab if C.any == -1: #if no C given C = np.ones((len(x),1)) n = len(np.unique(C)) C = C.astype(int) if n>20: mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C) else: col = (np.arange(1,n+1)) / n colors = col[C-1] mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=colors) return mesh #Write a ply file def to_ply(self,fname,c=None): """ Writes the mesh to a .ply file. Parameters ---------- fname : str The name of the .ply file to write the mesh to. c : numpy array Color array. If provided, then color is added to ply file. If array is num_vert x 3, it is interprted as RGB colors in the range 0,...,255. If the array is one dimensional of length num_vert, then the values are interpreted as hues. """ f = open(fname,"w") #Write header f.write('ply\n') f.write('format binary_little_endian 1.0\n') f.write('element vertex %u\n'%self.num_verts()) f.write('property double x\n') f.write('property double y\n') f.write('property double z\n') #Write color header if colors are provided if c is not None: f.write('property uchar red\n') f.write('property uchar green\n') f.write('property uchar blue\n') f.write('element face %u\n'%self.num_tri()) f.write('property list int int vertex_indices\n') f.write('end_header\n') f.close() f = open(fname,"ab") #If no colors are provided if c is None: #write vertices f.write(self.points.astype('float64').tobytes()) #If colors are provided else: #If scalars provided, then convert from hue to rgb if c.ndim == 1: c = c - np.min(c) c = c/np.max(c) arr = np.vstack((c,np.ones_like(c),np.ones_like(c))).T c = 255*convert_colorspace(arr,'HSV','RGB') #write vertices for i in range(self.num_verts()): f.write(self.points[i,:].astype('float64').tobytes()) f.write(c[i,:].astype('uint8').tobytes()) #write faces T = np.hstack((np.ones((self.num_tri(),1))*3,self.triangles)).astype(int) f.write(T.astype('int32').tobytes()) #close file f.close() def to_gif(self,fname,color = [],duration=7,fps=20,size=750,histeq = True): """ Writes rotating gif Parameters ---------- fname : str gif filename color : (1,3) or (num_verts,1) or (num_verts,2) float array, default is (.7,.7,.7) 3-tuple 0 to 1 RGB for single color over surface OR array the length of Self.Points for interpolation (1D or 2D - if 2D, uses first column). duration : float, default is 7 length of gif in seconds fps: float, default is 20 frames per second size: float, default is 750 size of gif images histeq : boolean, default is True Performs histogram equalization on scalar color array; else should normalize prior to input. """ from skimage import exposure from mayavi import mlab import moviepy.editor as mpy from pyface.api import GUI #Make copy of points X = self.points.copy() if np.shape(color)[0] == np.shape(X)[0]: #scalars for plot opt = 2 if histeq: color = color - np.amin(color) color = 1-exposure.equalize_hist(color/np.max(color),nbins=1000) if np.shape(np.shape(color))[0]>1: #handle input color = color[:,0] elif max(np.shape(color)) == 3: #single rgb color opt = 1 else : #not input - default to single color color = (0.7,0.7,0.7) opt = 1 #PCA Mean = np.mean(X,axis=0) cov_matrix = (X-Mean).T@(X-Mean) Vals, P = np.linalg.eig(cov_matrix) idx = Vals.argsort() i = idx[2] idx[2] = idx[1] idx[1] = i Vals = Vals[idx] P = P[:,idx] P[:,2] = np.cross(P[:,0],P[:,1]) #Rotate fragment X = X@P #Plot mesh f = mlab.figure(bgcolor=(1,1,1),size=(size,size)) if opt == 1: mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,color=color) else : mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,scalars=color) #Function that makes gif animation def make_frame(t): mlab.view(0,180+t/duration*360) GUI().process_events() return mlab.screenshot(antialiased=True) animation = mpy.VideoClip(make_frame, duration=duration) animation.write_gif(fname, fps=fps) mlab.close(f) def svi(self,r,ID=None): """ Computes spherical volume invariant. Parameters ---------- r : (k,1) float array List of radii to use. ID : (n,1) boolean array, default is None Spherical volume is only computed at points with True indices. Returns ------- S : (n,1) float array The volumes corresponding to each point. G : (n,1) float array The gamma values corresponding to each point. """ return svi.svi(self.points,self.triangles,r,ID=ID) def svipca(self,r): """ Computes SVIPCA Parameters ---------- r : (k,1) float array List of radii to use. Returns ------- S : (n,1) float array The volumes corresponding to each point. K1 : (n,1) float array The first principle curvature for each point. K2 : (n,1) float array The second principle curvature for each point. V1 : (n,3) float array The first principal direction for each point. V2 : (n,3) float array The second principal direction for each point. V3 : (n,3) float array The third principal direction for each point. """ return svi.svipca(self.points,self.triangles,r) def edge_graph_detect(self,**kwargs): """ Detects edges using SVIPCA and principal direction metric. Parameters ---------- M : amaazetools.trimesh.mesh object k1 : float, optional A constant on the minimum of the inverse of principal curvatures. k2 : float, optional A constant on the mean volume. VOL : (n,1) float array, optional Spherical volume corresponding to each point in the mesh. K1 : (n,1) float array, optional First principal curvature of each point. K2 : (n,1) float array, optional Second principal curvature of each point. V1 : (n,3) float array, optional First principal direction for each point. V2 : (n,3) float array, optional Second principal direction for each point. rvol : float, optional Radius to use for svipca. rpdir : float, optional Radius to use for the principal direction metric. Returns ------- Edges : (n,1) boolean array A true value corresponds to that index being an edge point. """ return edge_detection.edge_graph_detect(self,**kwargs) def graph_setup(self,n,r,p,seed=None): """ Creates the graph to use for poisson learning. Parameters ---------- n : int The number of vertices to sample for the graph. r : float Radius for graph construction. p : float Weight matrix parameter. seed : int, default is None Optional seed for random number generator. Returns ------- poisson_W_matrix : (n,n) scipy.sparse.lil_matrix Weight matrix describing similarities of normal vectors. poisson_J_matrix : (num_verts,n) scipy.sparse.lil_matrix Matrix with indices of nearest neighbors. poisson_node_idx : (num_verts,1) int array The indices of the closest point in the sample. """ rng = ( np.random.default_rng(seed=seed) if seed is not None else np.random.default_rng() ) if self.poisson_W_matrix is None or self.poisson_J_matrix is None or self.poisson_node_idx is None: v = self.vertex_normals() N = self.num_verts() #Random subsample ss_idx = np.matrix(rng.choice(self.points.shape[0],n,replace=False)) y = np.squeeze(self.points[ss_idx,:]) w = np.squeeze(v[ss_idx,:]) xTree = spatial.cKDTree(self.points) nn_idx = xTree.query_ball_point(y, r) yTree = spatial.cKDTree(y) nodes_idx = yTree.query_ball_point(y, r) bn = np.zeros((n,3)) J = sparse.lil_matrix((N,n)) for i in range(n): vj = v[nn_idx[i],:] normal_diff = w[i] - vj weights = np.exp(-8 * np.sum(np.square(normal_diff),1,keepdims=True)) bn[i] = np.sum(weights*vj,0) / np.sum(weights,0) #Set ith row of J normal_diff = bn[i]- vj weights = np.exp(-8 * np.sum(np.square(normal_diff),1))#,keepdims=True)) J[nn_idx[i],i] = weights #Normalize rows of J RSM = sparse.spdiags((1 / np.sum(J,1)).ravel(),0,N,N) J = RSM @ J #Compute weight matrix W W = sparse.lil_matrix((n,n)) for i in range(n): nj = bn[nodes_idx[i]] normal_diff = bn[i] - nj weights = np.exp(-32 * ((np.sqrt(np.sum(np.square(normal_diff),1)))/2)**p) W[i,nodes_idx[i]] = weights #Find nearest node to each vertex nbrs = NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit(y) instances, node_idx = nbrs.kneighbors(self.points) self.poisson_W_matrix = W self.poisson_J_matrix = J self.poisson_node_idx = node_idx return self.poisson_W_matrix, self.poisson_J_matrix, self.poisson_node_idx def poisson_label(self,g,I,n=5000,r=0.5,p=1,s=None,graph_setup=False): """ Performs poisson learning on the mesh. Parameters ---------- g : (k,1) int array Labels to assign to vertices. I : (k,1) int array User-selected vertices. n : int, default is 5000 The number of nodes to sample. r : float, default is 0.5 The radius for nearest neighbor search. p : float, default is 1.0 The weight matrix parameter. s : default is None Weights for fine-tuning Poisson learning. graph_setup : boolean, default is False Force graph construction if True. Returns ------- L : (num_verts,1) int array Poisson labelling of each point in mesh. """ if graph_setup or (self.poisson_node_idx is None): self.graph_setup(n,r,p) I = self.poisson_node_idx[I] W = self.poisson_W_matrix u = poisson_learning(W,g,I) J = self.poisson_J_matrix if s is None: L = np.argmax(J@u,1) else: k = np.max(g)+1 #Number of classes, assuming 0,1,2,3,..,k-1 are used L = np.argmax(J@(u*s[:k]),1) L = canonical_labels(L) self.poisson_labels = L return L def virtual_goniometer(self,point,r,k=7,SegParam=2,return_edge_points=False, number_edge_points=None,return_euclidean_radius=False): """ Runs a virtual goniometer to measure break angles. Parameters ---------- point : (1,3) float array or int A mesh vertex, as a coordinate or index. r : float Radius used to build patch. k: int, default is 7 Number of nearest neighbors to use. SegParam : float, default is 2 Segmentation parameter that encourages splitting patch in half as it increases in size. return_edge_points : boolean, default is False If True, return edge points in patch. number_edge_points : boolean, default is None Specifies how many edge points to return. return_euclidean_radius : boolean, default is False If True, returns Euclidean radius of patch. Returns ------- theta : float The break angle. n1 : (3,) float array Contains the normal vector of one break surface. n2 : (3,) float array Contains the normal vector of the other surface. C : (num_verts,) int array Contains the cluster (1 or 2) of each point in the patch; points not in the patch are assigned a 0. E : (number_edge_points,1) int array, not returned by default List of edge point indices. Only returned if return_edge_points=True euclidean_radius : float, not returned by default Euclidean radius of virtual goniometer patch. Only returned if return_euclidean_radius=True """ patch_ind = self.geodesic_patch(point,r,k=k) patch = self.points[patch_ind,:] normals = self.vertex_normals()[patch_ind,:] theta,n1,n2,C_local = __virtual_goniometer__(patch,normals,SegParam=SegParam) C = np.zeros(self.num_verts()) C[patch_ind] = C_local center = self.points[self.get_index(point),:] euclidean_radius = np.max(np.linalg.norm(patch - center,axis=1)) if return_edge_points: E = edge_points(patch,C_local,k=k,number=number_edge_points) E = patch_ind[E] if return_euclidean_radius: return theta,n1,n2,C,E,euclidean_radius else: return theta,n1,n2,C,E else: if return_euclidean_radius: return theta,n1,n2,C,euclidean_radius else: return theta,n1,n2,C
Methods
def bbox(self)
-
Computes the bounding box of the mesh.
Returns
A (3,) float array containing the dimensions of the bounding box.
Expand source code
def bbox(self): """ Computes the bounding box of the mesh. Returns ------- A (3,) float array containing the dimensions of the bounding box. """ #This code is old, when the weighted version was used #if self.centers is None: # self.face_centers() #X = self.centers.copy() #X -= np.mean(X,axis=0) #A = self.tri_areas() #vals,vecs = weighted_pca(X,A**2) X = self.points.copy() X -= np.mean(X,axis=0) vals,vecs = pca(X) Y = X@vecs bb = np.max(Y,axis=0) - np.min(Y,axis=0) return bb
def cplotsurf(self, C=-1)
-
Plots the mesh as a surface using mayavi.
Parameters
C
:(num_verts,3) int array
, defaultis -1
- An optional per-vertex labeling scheme to use.
Returns
mesh
:mesh object
- A colored visualization of the mesh.
Expand source code
def cplotsurf(self,C=-1): """ Plots the mesh as a surface using mayavi. Parameters ---------- C : (num_verts,3) int array, default is -1 An optional per-vertex labeling scheme to use. Returns ------- mesh : amaazetools.trimesh.mesh object A colored visualization of the mesh. """ from mayavi import mlab if C.any == -1: #if no C given C = np.ones((len(x),1)) n = len(np.unique(C)) C = C.astype(int) if n>20: mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C) else: col = (np.arange(1,n+1)) / n colors = col[C-1] mesh = mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=colors) return mesh
def edge_graph_detect(self, **kwargs)
-
Detects edges using SVIPCA and principal direction metric.
Parameters
M
:mesh object
k1
:float
, optional- A constant on the minimum of the inverse of principal curvatures.
k2
:float
, optional- A constant on the mean volume.
VOL
:(n,1) float array
, optional- Spherical volume corresponding to each point in the mesh.
K1
:(n,1) float array
, optional- First principal curvature of each point.
K2
:(n,1) float array
, optional- Second principal curvature of each point.
V1
:(n,3) float array
, optional- First principal direction for each point.
V2
:(n,3) float array
, optional- Second principal direction for each point.
rvol
:float
, optional- Radius to use for svipca.
rpdir
:float
, optional- Radius to use for the principal direction metric.
Returns
Edges
:(n,1) boolean array
- A true value corresponds to that index being an edge point.
Expand source code
def edge_graph_detect(self,**kwargs): """ Detects edges using SVIPCA and principal direction metric. Parameters ---------- M : amaazetools.trimesh.mesh object k1 : float, optional A constant on the minimum of the inverse of principal curvatures. k2 : float, optional A constant on the mean volume. VOL : (n,1) float array, optional Spherical volume corresponding to each point in the mesh. K1 : (n,1) float array, optional First principal curvature of each point. K2 : (n,1) float array, optional Second principal curvature of each point. V1 : (n,3) float array, optional First principal direction for each point. V2 : (n,3) float array, optional Second principal direction for each point. rvol : float, optional Radius to use for svipca. rpdir : float, optional Radius to use for the principal direction metric. Returns ------- Edges : (n,1) boolean array A true value corresponds to that index being an edge point. """ return edge_detection.edge_graph_detect(self,**kwargs)
def face_centers(self)
-
Computes coordinates of the center of each triangle (face).
Returns
A (num_tri,3) float array containing the coordinates of the face centers.
Expand source code
def face_centers(self): """ Computes coordinates of the center of each triangle (face). Returns ------- A (num_tri,3) float array containing the coordinates of the face centers. """ P1 = self.points[self.triangles[:,0],:] P2 = self.points[self.triangles[:,1],:] P3 = self.points[self.triangles[:,2],:] result = (P1 + P2 + P3)/3 self.centers = result return result
def face_normals(self, normalize=True)
-
Computes normal vectors to triangles (faces).
Parameters
normalize
:boolean
, defaultis True
- Whether or not to normalize to unit vectors; if False, vector magnitude is twice the area of the corresponding triangle.
Returns
N
:(num_tri,3) float array
- Array containing the face normal vectors.
Expand source code
def face_normals(self,normalize=True): """ Computes normal vectors to triangles (faces). Parameters ---------- normalize: boolean, default is True Whether or not to normalize to unit vectors; if False, vector magnitude is twice the area of the corresponding triangle. Returns ------- N : (num_tri,3) float array Array containing the face normal vectors. """ P1 = self.points[self.triangles[:,0],:] P2 = self.points[self.triangles[:,1],:] P3 = self.points[self.triangles[:,2],:] N = np.cross(P2-P1,P3-P1) if normalize: N = (N.T/(1e-10 + np.linalg.norm(N,axis=1))).T self.unit_norms = N return N else: self.norms = N return N
def flip_normals(self)
-
Reverses the orientation of all normal vectors in the mesh
Expand source code
def flip_normals(self): """ Reverses the orientation of all normal vectors in the mesh """ self.triangles = self.triangles[:,::-1]
def geodesic_patch(self, point, r, k=7, return_mask=False)
-
Computes a geodesic patch around a specified point.
Parameters
point
:int
or(1,3) float array
- A mesh vertex.
r
:float
- Radius used to build patch.
k
:int
, defaultis 7
- Number of nearest neighbors to use.
return_mask
:boolean
, defaultis False
- If True, return the patch as a (num,verts,) boolean array
Returns
An int array containing the patch point indices.
Expand source code
def geodesic_patch(self,point,r,k=7,return_mask=False): """ Computes a geodesic patch around a specified point. Parameters ---------- point : int or (1,3) float array A mesh vertex. r : float Radius used to build patch. k : int, default is 7 Number of nearest neighbors to use. return_mask : boolean, default is False If True, return the patch as a (num,verts,) boolean array Returns ------- An int array containing the patch point indices. """ W = gl.weightmatrix.knn(self.points,k,kernel='distance') G = gl.graph(W) point_ind = self.get_index(point) dist = G.dijkstra([point_ind]) mask = dist < r if return_mask: return mask.astype(int) else: #return indices return np.arange(self.num_verts())[mask]
def get_index(self, point)
-
Computes the index of a given point.
Parameters
point
:int
or(1,3) float array
- A vertex in the mesh, specified by either an integer index or its coordinates.
Returns
The index of the given point as an integer.
Expand source code
def get_index(self,point): """ Computes the index of a given point. Parameters ---------- point : int or (1,3) float array A vertex in the mesh, specified by either an integer index or its coordinates. Returns ------- The index of the given point as an integer. """ if type(point) in [int,np.int32,np.int64]: point_ind=point elif type(point) == np.ndarray and len(point)==3: point_ind = np.argmin(np.linalg.norm(self.points - point,axis=1)) elif type(point) in [tuple,list] and len(point)==3: point_ind = np.argmin(np.linalg.norm(self.points - np.array(point),axis=1)) else: sys.exit("'point' must be an integer index, or a length 3 list, tuple, or numpy ndarray (x,y,z)") return point_ind
def graph_setup(self, n, r, p, seed=None)
-
Creates the graph to use for poisson learning.
Parameters
n
:int
- The number of vertices to sample for the graph.
r
:float
- Radius for graph construction.
p
:float
- Weight matrix parameter.
seed
:int
, defaultis None
- Optional seed for random number generator.
Returns
poisson_W_matrix
:(n,n) scipy.sparse.lil_matrix
- Weight matrix describing similarities of normal vectors.
poisson_J_matrix
:(num_verts,n) scipy.sparse.lil_matrix
- Matrix with indices of nearest neighbors.
poisson_node_idx
:(num_verts,1) int array
- The indices of the closest point in the sample.
Expand source code
def graph_setup(self,n,r,p,seed=None): """ Creates the graph to use for poisson learning. Parameters ---------- n : int The number of vertices to sample for the graph. r : float Radius for graph construction. p : float Weight matrix parameter. seed : int, default is None Optional seed for random number generator. Returns ------- poisson_W_matrix : (n,n) scipy.sparse.lil_matrix Weight matrix describing similarities of normal vectors. poisson_J_matrix : (num_verts,n) scipy.sparse.lil_matrix Matrix with indices of nearest neighbors. poisson_node_idx : (num_verts,1) int array The indices of the closest point in the sample. """ rng = ( np.random.default_rng(seed=seed) if seed is not None else np.random.default_rng() ) if self.poisson_W_matrix is None or self.poisson_J_matrix is None or self.poisson_node_idx is None: v = self.vertex_normals() N = self.num_verts() #Random subsample ss_idx = np.matrix(rng.choice(self.points.shape[0],n,replace=False)) y = np.squeeze(self.points[ss_idx,:]) w = np.squeeze(v[ss_idx,:]) xTree = spatial.cKDTree(self.points) nn_idx = xTree.query_ball_point(y, r) yTree = spatial.cKDTree(y) nodes_idx = yTree.query_ball_point(y, r) bn = np.zeros((n,3)) J = sparse.lil_matrix((N,n)) for i in range(n): vj = v[nn_idx[i],:] normal_diff = w[i] - vj weights = np.exp(-8 * np.sum(np.square(normal_diff),1,keepdims=True)) bn[i] = np.sum(weights*vj,0) / np.sum(weights,0) #Set ith row of J normal_diff = bn[i]- vj weights = np.exp(-8 * np.sum(np.square(normal_diff),1))#,keepdims=True)) J[nn_idx[i],i] = weights #Normalize rows of J RSM = sparse.spdiags((1 / np.sum(J,1)).ravel(),0,N,N) J = RSM @ J #Compute weight matrix W W = sparse.lil_matrix((n,n)) for i in range(n): nj = bn[nodes_idx[i]] normal_diff = bn[i] - nj weights = np.exp(-32 * ((np.sqrt(np.sum(np.square(normal_diff),1)))/2)**p) W[i,nodes_idx[i]] = weights #Find nearest node to each vertex nbrs = NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit(y) instances, node_idx = nbrs.kneighbors(self.points) self.poisson_W_matrix = W self.poisson_J_matrix = J self.poisson_node_idx = node_idx return self.poisson_W_matrix, self.poisson_J_matrix, self.poisson_node_idx
def num_tri(self)
-
Computes number of triangles in the mesh.
Returns
The number of triangles in the mesh as an integer.
Expand source code
def num_tri(self): """ Computes number of triangles in the mesh. Returns ------- The number of triangles in the mesh as an integer. """ return self.triangles.shape[0]
def num_verts(self)
-
Computes number of vertices in the mesh.
Returns
The number of vertices in the mesh as an integer.
Expand source code
def num_verts(self): """ Computes number of vertices in the mesh. Returns ------- The number of vertices in the mesh as an integer. """ return self.points.shape[0]
def plotsurf(self, C=None)
-
Plots the mesh as a surface using mayavi.
Parameters
C
:(num_verts,3) int array
, defaultis None
- An optional per-vertex labeling scheme to use.
Returns
A visualization of the mesh.
Expand source code
def plotsurf(self,C=None): """ Plots the mesh as a surface using mayavi. Parameters ---------- C : (num_verts,3) int array, default is None An optional per-vertex labeling scheme to use. Returns ------- A visualization of the mesh. """ from mayavi import mlab if C is None: mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles) else: mlab.triangular_mesh(self.points[:,0],self.points[:,1],self.points[:,2],self.triangles,scalars=C)
def poisson_label(self, g, I, n=5000, r=0.5, p=1, s=None, graph_setup=False)
-
Performs poisson learning on the mesh.
Parameters
g
:(k,1) int array
- Labels to assign to vertices.
I
:(k,1) int array
- User-selected vertices.
n
:int
, defaultis 5000
- The number of nodes to sample.
r
:float
, defaultis 0.5
- The radius for nearest neighbor search.
p
:float
, defaultis 1.0
- The weight matrix parameter.
s
:default is None
- Weights for fine-tuning Poisson learning.
graph_setup
:boolean
, defaultis False
- Force graph construction if True.
Returns
L
:(num_verts,1) int array
- Poisson labelling of each point in mesh.
Expand source code
def poisson_label(self,g,I,n=5000,r=0.5,p=1,s=None,graph_setup=False): """ Performs poisson learning on the mesh. Parameters ---------- g : (k,1) int array Labels to assign to vertices. I : (k,1) int array User-selected vertices. n : int, default is 5000 The number of nodes to sample. r : float, default is 0.5 The radius for nearest neighbor search. p : float, default is 1.0 The weight matrix parameter. s : default is None Weights for fine-tuning Poisson learning. graph_setup : boolean, default is False Force graph construction if True. Returns ------- L : (num_verts,1) int array Poisson labelling of each point in mesh. """ if graph_setup or (self.poisson_node_idx is None): self.graph_setup(n,r,p) I = self.poisson_node_idx[I] W = self.poisson_W_matrix u = poisson_learning(W,g,I) J = self.poisson_J_matrix if s is None: L = np.argmax(J@u,1) else: k = np.max(g)+1 #Number of classes, assuming 0,1,2,3,..,k-1 are used L = np.argmax(J@(u*s[:k]),1) L = canonical_labels(L) self.poisson_labels = L return L
def surf_area(self)
-
Computes surface area of the mesh.
Returns
The surface area of the entire mesh as a float.
Expand source code
def surf_area(self): """ Computes surface area of the mesh. Returns ------- The surface area of the entire mesh as a float. """ return np.sum(self.tri_areas())
def svi(self, r, ID=None)
-
Computes spherical volume invariant.
Parameters
r
:(k,1) float array
- List of radii to use.
ID
:(n,1) boolean array
, defaultis None
- Spherical volume is only computed at points with True indices.
Returns
S
:(n,1) float array
- The volumes corresponding to each point.
G
:(n,1) float array
- The gamma values corresponding to each point.
Expand source code
def svi(self,r,ID=None): """ Computes spherical volume invariant. Parameters ---------- r : (k,1) float array List of radii to use. ID : (n,1) boolean array, default is None Spherical volume is only computed at points with True indices. Returns ------- S : (n,1) float array The volumes corresponding to each point. G : (n,1) float array The gamma values corresponding to each point. """ return svi.svi(self.points,self.triangles,r,ID=ID)
def svipca(self, r)
-
Computes SVIPCA
Parameters
r
:(k,1) float array
- List of radii to use.
Returns
S
:(n,1) float array
- The volumes corresponding to each point.
K1
:(n,1) float array
- The first principle curvature for each point.
K2
:(n,1) float array
- The second principle curvature for each point.
V1
:(n,3) float array
- The first principal direction for each point.
V2
:(n,3) float array
- The second principal direction for each point.
V3
:(n,3) float array
- The third principal direction for each point.
Expand source code
def svipca(self,r): """ Computes SVIPCA Parameters ---------- r : (k,1) float array List of radii to use. Returns ------- S : (n,1) float array The volumes corresponding to each point. K1 : (n,1) float array The first principle curvature for each point. K2 : (n,1) float array The second principle curvature for each point. V1 : (n,3) float array The first principal direction for each point. V2 : (n,3) float array The second principal direction for each point. V3 : (n,3) float array The third principal direction for each point. """ return svi.svipca(self.points,self.triangles,r)
def to_gif(self, fname, color=[], duration=7, fps=20, size=750, histeq=True)
-
Writes rotating gif
Parameters
fname
:str
- gif filename
color
:(1,3)
or(num_verts,1)
or(num_verts,2) float array
, defaultis (.7,.7,.7)
- 3-tuple 0 to 1 RGB for single color over surface OR array the length of Self.Points for interpolation (1D or 2D - if 2D, uses first column).
duration
:float
, defaultis 7
- length of gif in seconds
fps
:float
, defaultis 20
- frames per second
size
:float
, defaultis 750
- size of gif images
histeq
:boolean
, defaultis True
- Performs histogram equalization on scalar color array; else should normalize prior to input.
Expand source code
def to_gif(self,fname,color = [],duration=7,fps=20,size=750,histeq = True): """ Writes rotating gif Parameters ---------- fname : str gif filename color : (1,3) or (num_verts,1) or (num_verts,2) float array, default is (.7,.7,.7) 3-tuple 0 to 1 RGB for single color over surface OR array the length of Self.Points for interpolation (1D or 2D - if 2D, uses first column). duration : float, default is 7 length of gif in seconds fps: float, default is 20 frames per second size: float, default is 750 size of gif images histeq : boolean, default is True Performs histogram equalization on scalar color array; else should normalize prior to input. """ from skimage import exposure from mayavi import mlab import moviepy.editor as mpy from pyface.api import GUI #Make copy of points X = self.points.copy() if np.shape(color)[0] == np.shape(X)[0]: #scalars for plot opt = 2 if histeq: color = color - np.amin(color) color = 1-exposure.equalize_hist(color/np.max(color),nbins=1000) if np.shape(np.shape(color))[0]>1: #handle input color = color[:,0] elif max(np.shape(color)) == 3: #single rgb color opt = 1 else : #not input - default to single color color = (0.7,0.7,0.7) opt = 1 #PCA Mean = np.mean(X,axis=0) cov_matrix = (X-Mean).T@(X-Mean) Vals, P = np.linalg.eig(cov_matrix) idx = Vals.argsort() i = idx[2] idx[2] = idx[1] idx[1] = i Vals = Vals[idx] P = P[:,idx] P[:,2] = np.cross(P[:,0],P[:,1]) #Rotate fragment X = X@P #Plot mesh f = mlab.figure(bgcolor=(1,1,1),size=(size,size)) if opt == 1: mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,color=color) else : mlab.triangular_mesh(X[:,0],X[:,1],X[:,2],self.triangles,scalars=color) #Function that makes gif animation def make_frame(t): mlab.view(0,180+t/duration*360) GUI().process_events() return mlab.screenshot(antialiased=True) animation = mpy.VideoClip(make_frame, duration=duration) animation.write_gif(fname, fps=fps) mlab.close(f)
def to_ply(self, fname, c=None)
-
Writes the mesh to a .ply file.
Parameters
fname
:str
- The name of the .ply file to write the mesh to.
c
:numpy array
- Color array. If provided, then color is added to ply file. If array is num_vert x 3, it is interprted as RGB colors in the range 0,…,255. If the array is one dimensional of length num_vert, then the values are interpreted as hues.
Expand source code
def to_ply(self,fname,c=None): """ Writes the mesh to a .ply file. Parameters ---------- fname : str The name of the .ply file to write the mesh to. c : numpy array Color array. If provided, then color is added to ply file. If array is num_vert x 3, it is interprted as RGB colors in the range 0,...,255. If the array is one dimensional of length num_vert, then the values are interpreted as hues. """ f = open(fname,"w") #Write header f.write('ply\n') f.write('format binary_little_endian 1.0\n') f.write('element vertex %u\n'%self.num_verts()) f.write('property double x\n') f.write('property double y\n') f.write('property double z\n') #Write color header if colors are provided if c is not None: f.write('property uchar red\n') f.write('property uchar green\n') f.write('property uchar blue\n') f.write('element face %u\n'%self.num_tri()) f.write('property list int int vertex_indices\n') f.write('end_header\n') f.close() f = open(fname,"ab") #If no colors are provided if c is None: #write vertices f.write(self.points.astype('float64').tobytes()) #If colors are provided else: #If scalars provided, then convert from hue to rgb if c.ndim == 1: c = c - np.min(c) c = c/np.max(c) arr = np.vstack((c,np.ones_like(c),np.ones_like(c))).T c = 255*convert_colorspace(arr,'HSV','RGB') #write vertices for i in range(self.num_verts()): f.write(self.points[i,:].astype('float64').tobytes()) f.write(c[i,:].astype('uint8').tobytes()) #write faces T = np.hstack((np.ones((self.num_tri(),1))*3,self.triangles)).astype(int) f.write(T.astype('int32').tobytes()) #close file f.close()
def tri_areas(self)
-
Computes areas of all triangles in the mesh.
Returns
A (num_tri,) float array containing the areas of each triangle (face).
Expand source code
def tri_areas(self): """ Computes areas of all triangles in the mesh. Returns ------- A (num_tri,) float array containing the areas of each triangle (face). """ if self.norms is None: self.face_normals(False) return np.linalg.norm(self.norms,axis=1)/2
def tri_vert_adj(self, normalize=False)
-
Computes a sparse vertex-triangle adjacency matrix.
Parameters
normalize
:boolean
, defaultis False
- If True, each row is divided by the number of adjacent triangles.
Returns
F
:(num_verts,num_tri) boolean array
- Adjacency matrix; F[i,j] = 1 if vertex i belongs to triangle j.
Expand source code
def tri_vert_adj(self,normalize=False): """ Computes a sparse vertex-triangle adjacency matrix. Parameters ---------- normalize : boolean, default is False If True, each row is divided by the number of adjacent triangles. Returns ------- F : (num_verts,num_tri) boolean array Adjacency matrix; F[i,j] = 1 if vertex i belongs to triangle j. """ num_verts = self.num_verts() ind = np.arange(self.num_tri()) if np.any(self.tri_vert_adj_I) is None or np.any(self.tri_vert_adj_J) is None: self.tri_vert_adj_I = np.hstack((self.triangles[:,0],self.triangles[:,1],self.triangles[:,2])) self.tri_vert_adj_J = np.hstack((ind,ind,ind)) I = self.tri_vert_adj_I J = self.tri_vert_adj_J F = sparse.coo_matrix((np.ones(len(I)), (I,J)),shape=(self.num_verts(),self.num_tri())).tocsr() if normalize: num_adj_tri = F@np.ones(self.num_tri()) F = sparse.spdiags(1/num_adj_tri,0,self.num_verts(),self.num_verts())@F return F
def vertex_normals(self)
-
Computes normal vectors to vertices.
Returns
A (num_verts,3) float array containing the vertex normal vectors.
Expand source code
def vertex_normals(self): """ Computes normal vectors to vertices. Returns ------- A (num_verts,3) float array containing the vertex normal vectors. """ if self.unit_norms is None: self.face_normals() fn = self.unit_norms F = self.tri_vert_adj() vn = F@fn norms = np.linalg.norm(vn,axis=1) norms[norms==0] = 1 return vn/norms[:,np.newaxis]
def virtual_goniometer(self, point, r, k=7, SegParam=2, return_edge_points=False, number_edge_points=None, return_euclidean_radius=False)
-
Runs a virtual goniometer to measure break angles.
Parameters
point
:(1,3) float array
orint
- A mesh vertex, as a coordinate or index.
r
:float
- Radius used to build patch.
k
:int
, defaultis 7
- Number of nearest neighbors to use.
SegParam
:float
, defaultis 2
- Segmentation parameter that encourages splitting patch in half as it increases in size.
return_edge_points
:boolean
, defaultis False
- If True, return edge points in patch.
number_edge_points
:boolean
, defaultis None
- Specifies how many edge points to return.
return_euclidean_radius
:boolean
, defaultis False
- If True, returns Euclidean radius of patch.
Returns
theta
:float
- The break angle.
n1
:(3,) float array
- Contains the normal vector of one break surface.
n2
:(3,) float array
- Contains the normal vector of the other surface.
C
:(num_verts,) int array
- Contains the cluster (1 or 2) of each point in the patch; points not in the patch are assigned a 0.
E
:(number_edge_points,1) int array, not returned by default
- List of edge point indices. Only returned if return_edge_points=True
euclidean_radius
:float, not returned by default
- Euclidean radius of virtual goniometer patch. Only returned if return_euclidean_radius=True
Expand source code
def virtual_goniometer(self,point,r,k=7,SegParam=2,return_edge_points=False, number_edge_points=None,return_euclidean_radius=False): """ Runs a virtual goniometer to measure break angles. Parameters ---------- point : (1,3) float array or int A mesh vertex, as a coordinate or index. r : float Radius used to build patch. k: int, default is 7 Number of nearest neighbors to use. SegParam : float, default is 2 Segmentation parameter that encourages splitting patch in half as it increases in size. return_edge_points : boolean, default is False If True, return edge points in patch. number_edge_points : boolean, default is None Specifies how many edge points to return. return_euclidean_radius : boolean, default is False If True, returns Euclidean radius of patch. Returns ------- theta : float The break angle. n1 : (3,) float array Contains the normal vector of one break surface. n2 : (3,) float array Contains the normal vector of the other surface. C : (num_verts,) int array Contains the cluster (1 or 2) of each point in the patch; points not in the patch are assigned a 0. E : (number_edge_points,1) int array, not returned by default List of edge point indices. Only returned if return_edge_points=True euclidean_radius : float, not returned by default Euclidean radius of virtual goniometer patch. Only returned if return_euclidean_radius=True """ patch_ind = self.geodesic_patch(point,r,k=k) patch = self.points[patch_ind,:] normals = self.vertex_normals()[patch_ind,:] theta,n1,n2,C_local = __virtual_goniometer__(patch,normals,SegParam=SegParam) C = np.zeros(self.num_verts()) C[patch_ind] = C_local center = self.points[self.get_index(point),:] euclidean_radius = np.max(np.linalg.norm(patch - center,axis=1)) if return_edge_points: E = edge_points(patch,C_local,k=k,number=number_edge_points) E = patch_ind[E] if return_euclidean_radius: return theta,n1,n2,C,E,euclidean_radius else: return theta,n1,n2,C,E else: if return_euclidean_radius: return theta,n1,n2,C,euclidean_radius else: return theta,n1,n2,C
def volume(self)
-
Computes the volume of the mesh.
Returns
The volume of the mesh as a float.
Expand source code
def volume(self): """ Computes the volume of the mesh. Returns ------- The volume of the mesh as a float. """ if self.centers is None: self.face_centers() X = self.centers X = X - np.mean(X,axis=0) if self.norms is None: self.face_normals(False) return np.sum(X*self.norms)/6