Source code for genepy3d.obj.surfaces

"""Methods for working with Surface objects.
"""

import numpy as np
from scipy import interpolate
from scipy.ndimage import morphology
from skimage import measure

import alphashape

from genepy3d.util import plot as pl

from pyevtk.hl import unstructuredGridToVTK
from pyevtk.vtk import VtkTriangle
import vtk

import trimesh

[docs] class Surface(trimesh.Trimesh): """Triangulated surface in 3D; inherits from Trimesh and thus is triangulated upon creation if the mesh is not a triangulation. The Trimesh library is here: https://trimsh.org/trimesh.html. Attributes: vertices (ndarray (float)): vertices coordinates. faces (ndarray (float)): index of the vertices of each of the simplices. """
[docs] @classmethod def from_points_qhull(cls,points): """Create surface object from point cloud via the convex hull algorithms. Args: points (nx3 array of float): coordinates of point cloud. Returns: a Surface object. Examples: .. code-block:: python import numpy as np from genepy3d.obj import surfaces import matplotlib.pyplot as plt # coors = np.array(...): array whose columns are x, y and z coordinates surf = surfaces.Surface.from_points_qhull(coors) # surface from convex hull fig = plt.figure() ax = fig.add_subplot(111,projection='3d') surf.plot(ax); """ # Notes # We used ConvexHull from scipy, but the result seems to contain crossed triangulation. # We employed convex_hull property from TriMesh for better estimation. See self.get_qhull() for more detail. from scipy.spatial import ConvexHull hull = ConvexHull(points) # Becasue hull.simplices are in the context of the full point list, not just Qhull vertexes simplices=np.reshape(np.stack([np.where(hull.vertices==i)[0] for i in hull.simplices.flat]),hull.simplices.shape) return cls(hull.points[hull.vertices,:], simplices).get_qhull()
[docs] @classmethod def from_points_alpha_shape(cls,points,alpha=None): """Create surface object from point cloud via the alpha-shape algorithms. The result is the set triangles part of tetrahedrals with a circumsphere of radius less that alpha that face outward. Args: points (nx3 array of float): coordinates of point cloud. alpha (float): value of alpha. Returns: a Surface instace. Notes: There is no guarantee on the topology of the resulting mesh. Examples: .. code-block:: python import numpy as np from genepy3d.obj import surfaces from genepy3d.util import geo import matplotlib.pyplot as plt # coors = np.array(...): array whose columns are x, y and z coordinates # Diagonal distance of the bounding box of the point cloud # Alpha value can be set relatively to this distance # Larger value of alpha produces surface closer to the convex hull min_coors = np.min(coors,axis=0) max_coors = np.max(coors,axis=0) diadst = geo.l2(min_coors,max_coors) # diagonal distance alpha = diadst/3. # alpha value # Surface from the alpha shape surf = surfaces.Surface.from_points_alpha_shape(coors,alpha) fig = plt.figure() ax = fig.add_subplot(111,projection='3d') surf.plot(ax) """ if alpha == None: mesh = alphashape.alphashape(points) else: mesh = alphashape.alphashape(points,alpha) return cls(mesh.vertices,mesh.faces)
# @classmethod # def from_points_alpha_shape(cls,points,alpha): # """Create surface object from point cloud via the alpha-shape algorithms. # The result is the set triangles part of tetrahedrals with a circumsphere of radius less that alpha that face outward. # Python implementation from https://stackoverflow.com/questions/26303878/alpha-shapes-in-3d # Args: # points (nx3 array of float): coordinates of point cloud. # alpha (float): value of alpha. # Returns: # a Surface instace. # Notes: # There is no guarantee on the topology of the resulting mesh. # Examples: # .. code-block:: python # import numpy as np # from genepy3d.obj import surfaces # from genepy3d.util import geo # import matplotlib.pyplot as plt # # coors = np.array(...): array whose columns are x, y and z coordinates # # Diagonal distance of the bounding box of the point cloud # # Alpha value can be set relatively to this distance # # Larger value of alpha produces surface closer to the convex hull # min_coors = np.min(coors,axis=0) # max_coors = np.max(coors,axis=0) # diadst = geo.l2(min_coors,max_coors) # diagonal distance # alpha = diadst/3. # alpha value # # Surface from the alpha shape # surf = surfaces.Surface.from_points_alpha_shape(coors,alpha) # fig = plt.figure() # ax = fig.add_subplot(111,projection='3d') # surf.plot(ax) # """ # from scipy.spatial import Delaunay # import numpy as np # from collections import defaultdict # # def alpha_shape_3D(pos, alpha): # tetra = Delaunay(points) # # Find radius of the circumsphere. # # By definition, radius of the sphere fitting inside the tetrahedral needs # # to be smaller than alpha value # # http://mathworld.wolfram.com/Circumsphere.html # tetrapos = np.take(points,tetra.simplices,axis=0) # normsq = np.sum(tetrapos**2,axis=2)[:,:,None] # ones = np.ones((tetrapos.shape[0],tetrapos.shape[1],1)) # a = np.linalg.det(np.concatenate((tetrapos,ones),axis=2)) # Dx = np.linalg.det(np.concatenate((normsq,tetrapos[:,:,[1,2]],ones),axis=2)) # Dy = -np.linalg.det(np.concatenate((normsq,tetrapos[:,:,[0,2]],ones),axis=2)) # Dz = np.linalg.det(np.concatenate((normsq,tetrapos[:,:,[0,1]],ones),axis=2)) # c = np.linalg.det(np.concatenate((normsq,tetrapos),axis=2)) # # Remove zero determinant # ids = np.argwhere(a!=0).flatten() # Dx = Dx[ids] # Dy = Dy[ids] # Dz = Dz[ids] # a = a[ids] # c = c[ids] # tetra_vertices = tetra.simplices[ids] # # Radius of circumshpere # r = np.sqrt(Dx**2+Dy**2+Dz**2-4*a*c)/(2*np.abs(a)) # try: # # Find tetrahedrals # tetras = tetra_vertices[r<alpha,:] # # triangles # TriComb = np.array([(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]) # Triangles = tetras[:,TriComb].reshape(-1,3) # Triangles = np.sort(Triangles,axis=1) # # Remove triangles that occurs twice, because they are within shapes # TrianglesDict = defaultdict(int) # for tri in Triangles:TrianglesDict[tuple(tri)] += 1 # Triangles=np.array([tri for tri in TrianglesDict if TrianglesDict[tri] ==1]) # #edges # EdgeComb=np.array([(0, 1), (0, 2), (1, 2)]) # Edges=Triangles[:,EdgeComb].reshape(-1,2) # Edges=np.sort(Edges,axis=1) # Edges=np.unique(Edges,axis=0) # Vertices = np.unique(Edges) # simplices=np.reshape(np.stack([np.where(Vertices==i)[0] for i in Triangles.flat]),Triangles.shape) # return Surface(points[Vertices,:], simplices) # except: # return None # can not compute the surface from the given alpha value # @classmethod # def from_OFF(cls,afile): #TODO # """Create surface object from OFF file. Does no check on the loaded mesh. # TODO # Args: # afile (string): path to load # Returns: # a Surface object # """ # return
[docs] @classmethod def from_stl(cls,afile,xy2zratio=None,center=False): """Create surface object from STL file. This function is *DEPRECATED*. use ``Surface.from_file()`` instead. Args: afile (string): filepath to load the surface. xy2zratio (float): in case of anisotromic measurement, ratio to apply to z coordinates w.r.t. x and y. center (bool): center the mesh. Returns: a Surface object. """ # from stl import mesh # your_mesh = mesh.Mesh.from_file(afile) # points=np.reshape(your_mesh.data['vectors'],[3*your_mesh.data['vectors'].shape[0],3]) # points=np.unique(points,axis=0) # inds=[] # for i in range(your_mesh.data['vectors'].shape[0]): # for j in range(your_mesh.data['vectors'].shape[1]): # inds.append(np.where([(points[k,:]==your_mesh.data['vectors'][i][j]).all() for k in range(len(points))])[0]) # inds=np.reshape(np.stack(inds),your_mesh.data['vectors'].shape[0:2]) # #points_centered=preprocessing.scale(points,with_std=False) # Load file using load() in trimesh try: your_mesh = trimesh.load(afile) except: raise Exception("Failed to load stl file.") points = your_mesh.vertices inds = your_mesh.faces if center: points[:,0]=points[:,0]-np.mean(points[:,0]) points[:,1]=points[:,1]-np.mean(points[:,1]) points[:,2]=points[:,2]-np.mean(points[:,2]) if xy2zratio: points[:,2]=points[:,2]*xy2zratio return cls(points,inds)
[docs] @classmethod def from_ply(cls,afile,xy2zratio=None,center=False): """Create surface object from PLY file. This function is DEPRECATED. use ``Surface.from_file()`` instead. Args: afile (string): filepath to load the surface. xy2zratio (float): in case of anisotromic measurement, ratio to apply to z coordinates w.r.t. x and y. center (bool): center the mesh. Returns: a Surface object. """ # Since from_STL() use trimesh.load() func, it is also convenient to load ply file return cls.from_stl(afile,xy2zratio,center)
[docs] @classmethod def from_file(cls,afile,xy2zratio=None,center=False): """Wrapper of trimesh.load() to load a surface from file. To see which file formats are supported. Use ``surfaces.available_formats()``. Args: afile (string): filepath to load the surface. xy2zratio (float): in case of anisotromic measurement, ratio to apply to z coordinates w.r.t. x and y. center (bool): center the mesh. Returns: a Surface object. Examples: .. code-block:: python from genepy3d.obj import surfaces import matplotlib.pyplot as plt filepath = "path/to/file" surf = surfaces.Surface.from_file(filepath) fig = plt.figure() ax = fig.add_subplot(111,projection="3d") surf.plot(ax) """ # Load file using load() in trimesh try: your_mesh = trimesh.load(afile) except: raise Exception("Failed to load file.") points = your_mesh.vertices inds = your_mesh.faces if center: points[:,0]=points[:,0]-np.mean(points[:,0]) points[:,1]=points[:,1]-np.mean(points[:,1]) points[:,2]=points[:,2]-np.mean(points[:,2]) if xy2zratio: points[:,2]=points[:,2]*xy2zratio return cls(points,inds)
[docs] @classmethod def from_volume(cls,vol,lbl,spacing=(1.,1.,1.),step_size=1,level=0.5,opening=False,fillholes=False): """Create isosurface of a 3D voxels volume from image stack using marching cubes. We assume that the image stack contains multiple volumic objects specified by integer labels. This function extracts the volume of a given label, then estimates the outlined surface using marching cubes from scikit-image. Args: vol (3D ndarray): 3D volume. lbl (int): voxel label of the volume to be extracted from the image stack. spacing (tuple (float)): voxel spacing using in marching cubes. step_size (int): parameter defining the mesh resolution (higher step_size yields coarser mesh). level (float): between 0 and 1, determine proportion between background (0.) and foreground (1.) in volume. opening (bool): preprocess volumne by morphology opening. fillholes (bool): preprocess volumne by morphology fillholes. Returns: a Surface object. Examples: .. code-block:: python import numpy as np from genepy3d.obj import surfaces import matplotlib.pyplot as plt stack = np.zeros((100,100,100)) # an image stack of size 100x100x100 stack[20:40,20:40,20:40] = 1 # a volume of label = 1 surf = surfaces.Surface.from_volume(stack,1) # outline surface of volume fig = plt.figure() ax = fig.add_subplot(111,projection='3d') surf.plot(ax); """ # extend volume shape to avoid missing faces at border ext = 2*step_size + 1 extvol = np.zeros((vol.shape[0]+2*ext,vol.shape[1]+2*ext,vol.shape[2]+2*ext)) extvol[ext:ext+vol.shape[0],ext:ext+vol.shape[1],ext:ext+vol.shape[2]] = vol if isinstance(lbl,int): extvol[extvol!=lbl]=0. else: for lev in lbl: extvol[extvol!=lev] = 0. extvol[extvol!=0] = 1. if opening == True: extvol = morphology.binary_opening(extvol,iterations=3).astype(float) if fillholes == True: extvol = morphology.binary_fill_holes(extvol).astype(float) # get isosurface by lewiner algorithm verts, faces, _, _ = measure.marching_cubes(extvol, level=level, spacing=spacing, step_size=step_size) # get vertice coordinates back to the origin verts = verts - ext return cls(verts[:,[2,1,0]],faces)
[docs] def get_qhull(self): """Return the Convex Hull of the surface. Returns: a Surface object. """ # from scipy.spatial import ConvexHull # hull = ConvexHull(self.vertices) # #becasue hull.simplices are in the context of the full point list, not just Qhull vertexes # simplices=np.reshape(np.stack([np.where(hull.vertices==i)[0] for i in hull.simplices.flat]),hull.simplices.shape) qhull = self.convex_hull return Surface(qhull.vertices, qhull.faces)
[docs] def get_bbox(self): """Return the bounding box of the surface. Returns: a Surface object. """ return Surface(self.bounding_box.vertices,self.bounding_box.faces)
[docs] def get_obbox(self): """Return the oriented bounding box of the surface. Returns: a Surface object. """ return Surface(self.bounding_box_oriented.vertices,self.bounding_box_oriented.faces)
[docs] def get_obbox_axes(self): """Return the three basic axis-vectors covered the oriented bounding box. Returns: Tuple contains: - coordinates of the three axes. - lengths of the three axes. """ # Get oriented bounding box obbox = self.get_obbox() # Compute the angles between adjacent faces theta = np.round(obbox.face_adjacency_angles,3) # Find inplane edges (should be excluded from the measurement) inplane_edges = obbox.face_adjacency_edges[theta==0] # Exclude inplane edges edges_axis = [] edges_len = [] for i in range(len(obbox.edges_unique)): query = len(np.argwhere(np.array([len(np.setdiff1d(item,obbox.edges_unique[i])) for item in inplane_edges])==0).flatten())==0 if query is True: # not inplane edge # print("Included",obbox.edges_unique[i],np.round(obbox.edges_unique_length[i],3)) edges_len.append(np.round(obbox.edges_unique_length[i],3)) edges_axis.append(obbox.edges_unique[i]) # else: # print("Excluded",obbox.edges_unique[i]) # Get only on triple of edges with a common point uix = [0] for i in range(1,len(edges_axis)): candidate_ix = np.intersect1d(edges_axis[i], edges_axis[0]).flatten() if len(candidate_ix)>0: if candidate_ix == edges_axis[0][0]: uix.append(i) edges_len = np.array(edges_len)[uix] edges_axis = np.array(edges_axis)[uix] # Sort by lengths six = np.argsort(edges_len) edges_len = edges_len[six] edges_axis = edges_axis[six] return obbox.vertices[edges_axis], edges_len
[docs] def compute_sphericity(self): """Compute the sphericity index of the surface. The sphericity formula is from this paper: https://www.sciencedirect.com/science/article/pii/S1877750318304757#bib0205 Sphericity = ((minor**2) / (median*major))**(1/3), where minor, median and major are lengths of the elliposoid fitting the surface. These lengths can be found from the orient bounding box of the surface. Returns: a float. """ # Get edges lengths of the oriented bounding boxes _, edges_len = self.get_obbox_axes() if len(edges_len)!=3: raise Exception("Failed to determine minor, medium, major lengths of bounding box.") else: sphericity_index = (edges_len[0]**2 / (edges_len[1]*edges_len[2]))**(1./3) return sphericity_index
def _find_projected_face(self,edges_axis): """Support function for get_paraboloid_centroid(). Args: edges_axis (array): coordinates of 3 basic lines of the oriented bounding box. """ best_dist = np.inf best_i, best_j = None, None best_crv, best_center = None, None # iterate each face of the oriented bounding box # find the intersection with surface # selection the center of the face the touch the head of the paraboiloid. for i in [0,1,2]: for j in [0,1]: # compute intersection between surface the face of the oriented bounding box. k = np.bitwise_xor(j,1) normal = edges_axis[i][k] - edges_axis[i][j] origin = edges_axis[i][j] crv = self.section(normal,origin) # get center of the face of the oriented bounding box. if i == 0: l,m=1,2 elif i == 1: l,m=0,2 else: l,m=0,1 c0 = edges_axis[i][j] c1 = edges_axis[l][1] + (edges_axis[m][1] - edges_axis[m][0]) if j == 1: c1 += (edges_axis[i][1] - edges_axis[i][0]) center = np.mean(np.array([c0,c1]),axis=0) # compute distance of intersected positions to the center dist_lst = [] for iv in range(crv.vertices.shape[0]): dist = np.sum((crv.vertices[iv] - center)**2)**0.5 dist_lst.append(dist) # get the face with the minimal distance mean_dist = np.mean(dist_lst) if mean_dist < best_dist: best_dist = mean_dist best_i = i best_j = j best_crv = crv best_center = center return best_i, best_j, best_dist, best_crv, best_center
[docs] def get_paraboloid_centroid(self): """Find the centroid lying on the paraboloid. Assuming the surface has a paraboloid form. This function estimate the centroid lying on the surface. It's not similar to the centroid computed by averaging faces positions. """ # Three axes of the oriented bounding box edges_axis, _ = self.get_obbox_axes() best_i, best_j, best_dist, best_crv, best_center = self._find_projected_face(edges_axis) return best_center
[docs] def split(self): """Override split() by casting Trimesh to Surface. If the surface consists of distinct subsurfaces, then the function split() returns these subsurfaces. Returns: a list of distinct subsurfaces. """ surface_splits = [] trimesh_splits = super(Surface,self).split() # call split() from Trimesh # cast Trimesh obj to Surface obj if len(trimesh_splits)!=0: for m in trimesh_splits: surface_splits.append(Surface(m.vertices,m.faces)) return surface_splits
[docs] def to_points(self): """Convert to Points object. Returns: a Points object, consisting of the vertices of the mesh. """ from genepy3d.obj.points import Points return Points(self.vertices)
[docs] def export_to_VTK(self,filepath,kind='PolyData'): """Export surface in VTK fileformat, either as PolyData or unstructuredGrid. Args: filepath (string): path to export the file to, *with no extention* kind (string): one of 'PolyData' or 'UnstructuredGrid' (default:'PolyData'). Returns: a VTK fileformat. """ if kind == 'PolyData': Points = vtk.vtkPoints() Triangles = vtk.vtkCellArray() for p in self.vertices: Points.InsertNextPoint(p[0],p[1],p[2]) for s in self.faces: Triangle = vtk.vtkTriangle() Triangle.GetPointIds().SetId(0, s[0]) Triangle.GetPointIds().SetId(1, s[1]) Triangle.GetPointIds().SetId(2, s[2]) Triangles.InsertNextCell(Triangle) polydata = vtk.vtkPolyData() polydata.SetPoints(Points) polydata.SetPolys(Triangles) polydata.Modified() writer = vtk.vtkPolyDataWriter() writer.SetFileName(filepath) writer.SetInputData(polydata) writer.Write() if kind== 'UnstructuredGrid': offset=[] conn=[] for s in self.faces: conn.extend(s) offset.append(3) offset=np.array(offset) conn=np.array(conn) celltype=np.ones(len(self.faces))*VtkTriangle.tid unstructuredGridToVTK(filepath, np.ascontiguousarray(self.vertices[:,0]), np.ascontiguousarray(self.vertices[:,1]), np.ascontiguousarray(self.vertices[:,2]), connectivity = conn, offsets = offset, cell_types = celltype, cellData = None, pointData = None)
[docs] def plot(self,ax,**kwds): """Plot outline in 3D or 2D-projection (xy, xz and yz) using matplotlib. Args: ax: axis to be plotted. projection (str): support '3d'|'xy'|'xz'|'yz'. scales: (tuples [float]): scales in x y and z. args_3d (dic): matplotlib arguments to plot 3d boundary (set of 3d polygons). args_2d (dic): matplotlib arguments to plot 2d boundary. equal_aspect (bool): make equal aspect for both axes. """ if 'projection' in kwds.keys(): projection = kwds['projection'] else: projection = '3d' if 'scales' in kwds.keys(): scales=kwds['scales'] else: scales=(1.,1.,1.) if 'args_2d' in kwds.keys(): args_2d = kwds['args_2d'] else: args_2d = {'edgecolor':'yellow','edgewidth':1,'facecolor':None,'alpha':0.5,'divby':'row','ndiv':1} if 'args_3d' in kwds.keys(): args_3d = kwds['args_3d'] else: args_3d = {'edgecolor':'none','color':'yellow','alpha':0.5,'linewidths':0.1} if 'equal_aspect' in kwds.keys(): equal_aspect = kwds['equal_aspect'] else: equal_aspect = True vertices_scale = np.zeros(self.vertices.shape) vertices_scale[:,0] = 1.*self.vertices[:,0]/scales[0] vertices_scale[:,1] = 1.*self.vertices[:,1]/scales[1] vertices_scale[:,2] = 1.*self.vertices[:,2]/scales[2] if projection=='3d': self._plot3d(ax,vertices_scale,args_3d) if equal_aspect == True: param = pl.fix_equal_axis(vertices_scale) ax.set_xlim(param['xmin'],param['xmax']) ax.set_ylim(param['ymin'],param['ymax']) ax.set_zlim(param['zmin'],param['zmax']) ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_zlabel('Z') else: if projection=='xy': coors2d = vertices_scale[:,[0,1]] elif projection=='xz': coors2d = vertices_scale[:,[0,2]] else: coors2d = vertices_scale[:,[1,2]] self._plot2d(ax,coors2d,**args_2d) if equal_aspect==True: ax.axis('equal')
def _plot3d(self,ax,coors,args_3d={'edgecolor':'none','color':'yellow','alpha':0.5,'linewidths':0.1}): """Support 3d plot outline. Args: ax: axis to be plotted. coors (2d numpy array [float]): 3d coordinates of outline. edgecolor (str): edge color. facecolor (str): face color. alpha (float): transparent. """ ax.plot_trisurf(coors[:, 0], coors[:,1], self.faces, coors[:, 2],**args_3d) #for simplex in self.faces: # tri = mplot3d.art3d.Poly3DCollection([coors[simplex,:].tolist()],**args_3d) # # tri.set_color(facecolor) # # tri.set_edgecolor(edgecolor) # ax.add_collection3d(tri) def _plot2d(self,ax,coors,edgecolor='yellow',edgewidth=1,facecolor=None,alpha=0.5,divby='row',ndiv=1): """Support 2d plot of outline. Allow plotting outline in different divisions along a specific axis. Args: ax: axis to be plotted. coors (2d numpy array [float]): 2d coordinates of outline. edgecolor (str): edge color. edgewidth (float): edge width. facecolor (str|list|matplotlib_colormap): face color. alpha (float): transparent. divby (str): axis of division, support 'row'|'col'. ndiv (int): number of division along specific axis. """ # boundary = np.array(self.vertices.tolist()+[self.vertices[0]]) from scipy.spatial import ConvexHull hull = ConvexHull(coors) xbound = list(coors[hull.vertices,0])+[coors[hull.vertices[0],0]] ybound = list(coors[hull.vertices,1])+[coors[hull.vertices[0],1]] if ndiv==1: if facecolor is None: ax.plot(xbound,ybound,c=edgecolor,lw=edgewidth,alpha=alpha) else: ax.fill(xbound,ybound,ec=edgecolor,lw=edgewidth,fc=facecolor,alpha=alpha) else: coef, t = interpolate.splprep([xbound, ybound], k=1) tnew = np.linspace(0,1,500) xnew, ynew = interpolate.splev(tnew,coef) # generate xnew, ynew which fill inside of outline if divby=='row': slices = np.linspace(ynew.min(),ynew.max(),ndiv+1) vals = ynew else: slices = np.linspace(xnew.min(),xnew.max(),ndiv+1) vals = xnew for i in range(len(slices)-1): idx = np.argwhere((vals>=slices[i])&(vals<=slices[i+1])).flatten() if facecolor is not None: if type(facecolor) is list: _fc = facecolor[i] elif type(facecolor) is str: _fc = facecolor else: # color map _fc = facecolor(i*1.0/(len(slices)-1)) ax.fill(xnew[idx],ynew[idx],fc=_fc,ec=edgecolor,lw=edgewidth,alpha=alpha) else: ax.plot(xnew[idx],ynew[idx],c=edgecolor,lw=edgewidth,alpha=alpha)
[docs] def available_formats(): """Return file formats that support by Trimesh for loading the surface. Returns: list of support file formats. """ return trimesh.available_formats()