implemented and improved routines. Can now extract features, fill holes and produce vtk output

This commit is contained in:
Michael Krayer 2021-08-10 21:00:18 +02:00
parent fd23122470
commit 6623c67c86
1 changed files with 152 additions and 135 deletions

287
field.py
View File

@ -648,109 +648,82 @@ def gaussian_filter_umean_channel(array,spacing,sigma,truncate=4.0):
class Features3d: class Features3d:
def __init__(self,input,threshold,origin,spacing,periodicity,invert=False,has_ghost=False): def __init__(self,input,threshold,origin,spacing,periodicity,
invert=False,has_ghost=False,keep_input=False,
contour_method='flying_edges',cellvol_normal_component=2,
report=False):
assert len(origin)==3, "'origin' must be of length 3" assert len(origin)==3, "'origin' must be of length 3"
assert len(spacing)==3, "'spacing' must be of length 3" assert len(spacing)==3, "'spacing' must be of length 3"
assert len(periodicity)==3, "'periodicity' must be of length 3" assert len(periodicity)==3, "'periodicity' must be of length 3"
assert isinstance(input,np.ndarray), "'input' must be numpy.ndarray." assert isinstance(input,np.ndarray), "'input' must be numpy.ndarray."
# Assign basic properties to class variables # Assign basic properties to class variables
self.origin = tuple(float(x) for x in origin) self.origin = np.array(origin,dtype=np.float)
self.spacing = tuple(float(x) for x in spacing) self.spacing = np.array(spacing,dtype=np.float)
self.dimensions = input.shape
self.periodicity = tuple(bool(x) for x in periodicity) self.periodicity = tuple(bool(x) for x in periodicity)
self._invert = invert
self._sldata = tuple(slice(0,-1) if x else None for x in periodicity)
# If regions are supposed to be inverted, i.e. the interior consists of values # If regions are supposed to be inverted, i.e. the interior consists of values
# smaller than the threshold instead of larger, change the sign of the array. # smaller than the threshold instead of larger, change the sign of the array.
sign_invert = -1 if invert else +1 sign_invert = -1 if invert else +1
self._threshold = sign_invert*threshold self._threshold = sign_invert*threshold
# '_data' is the array which is to be triangulated. Here one trailing ghost cell is # '_input' is the array which is to be triangulated. Here one trailing ghost cell is
# required to get closed surfaces. The data will always be copied since it probably # required to get closed surfaces. The data will always be copied since it probably
# needs to be copied for vtk anyway (needs FORTRAN contiguous array) and this way # needs to be copied for vtk anyway (needs FORTRAN contiguous array) and this way
# there will never be side effects on the input data. # there will never be side effects on the input data.
if has_ghost: if has_ghost:
self._data = sign_invert*input self._input = sign_invert*input
else: else:
pw = tuple((0,1) if x else (0,0) for x in periodicity) pw = tuple((0,1) if x else (0,0) for x in periodicity)
self._data = np.pad(sign_invert*input,pw,mode='wrap') self._input = np.pad(sign_invert*input,pw,mode='wrap')
# Triangulate
self.triangulate(contour_method=contour_method,
cellvol_normal_component=cellvol_normal_component,
report=report)
# Drop input if requested: this may free memory in case it was copied/temporary.
if not keep_input: self._input = None
return
@classmethod @classmethod
def from_field(cls,fld,threshold,periodicity,invert=False,has_ghost=False): def from_field(cls,fld,threshold,periodicity,invert=False,has_ghost=False,
return cls(fld.data,threshold,fld.origin,fld.spacing,periodicity,invert=invert,has_ghost=has_ghost) keep_input=False,contour_method='flying_edges',
cellvol_normal_component=2,report=False):
return cls(fld.data,threshold,fld.origin,fld.spacing,periodicity,
invert=invert,has_ghost=has_ghost,keep_input=keep_input,
contour_method=contour_method,
cellvol_normal_component=cellvol_normal_component,
report=report)
@property def triangulate(self,contour_method='flying_edges',cellvol_normal_component=2,report=False):
def threshold(self): return self._threshold
@property
def points(self): return self._points
@property
def faces(self): return self._faces
@property
def nfeatures(self): return self._nfeatures
def fill_holes(self):
# Check if volume negative -> cell normal direction
# self.binary.fill_holes()
# li = ndimage.binary_erosion(self.binary._data)
# self._data[li] = 2*self._threshold # arbitrary, only needs to be above threshold
# if self._faces is not None:
# self.triangulate(contour_method=self.__TRI_CONTMETH,
# cellvol_normal_component=self.__TRI_NORMCOMP)
return
def reduce_noise(self,threshold=1e-5,is_relative=True):
'''Removes all objects with smaller (binary-based) volume than a threshold.'''
# if is_relative:
# threshold = threshold*self.binary.volume_domain()
# vol = self.binary.volumes()
# li = vol<threshold
# mask = self.binary.discard_features(li,return_mask=True)
# self._data[mask] = np.nan#self._threshold
# if self._faces is not None:
# self.triangulate(contour_method=self.__TRI_CONTMETH,
# cellvol_normal_component=self.__TRI_NORMCOMP)
return
def triangulate(self,contour_method='flying_edges',cellvol_normal_component=2):
import pyvista as pv import pyvista as pv
import vtk import vtk
from scipy import ndimage, spatial from scipy import ndimage, spatial
from time import time # Check if '_input' is available: might have been dropped after initialization
# Save arguments in case we need to recreate triangulation later on assert self._input is not None, "'_input' not available. Initialize object with keep_input=True flag."
self.__TRI_CONTMETH = contour_method # Wrap data for VTK using pyvista
self.__TRI_NORMCOMP = cellvol_normal_component
# Wrap data with pyvista/VTK
datavtk = pv.UniformGrid() datavtk = pv.UniformGrid()
datavtk.dimensions = self._data.shape datavtk.dimensions = self._input.shape
datavtk.origin = self.origin datavtk.origin = self.origin
datavtk.spacing = self.spacing datavtk.spacing = self.spacing
datavtk.point_arrays['data'] = self._data.ravel('F') datavtk.point_arrays['data'] = self._input.ravel('F')
# Compute full contour: ensure we only have triangles to efficiently extract points # Compute full contour: ensure we only have triangles to efficiently extract points
# later on. # later on.
t = time() if report: print('[Features3d.triangulate] computing isocontour using {}...'.format(contour_method))
contour_ = datavtk.contour([self._threshold],method=contour_method,compute_scalars=False) contour_ = datavtk.contour([self._threshold],method=contour_method,compute_scalars=False)
assert contour_.is_all_triangles(), "Contouring produced non-triangle cells." assert contour_.is_all_triangles(), "Contouring produced non-triangle cells."
print('CONTOUR:',time()-t) # Compute the connectivity of the triangulated surface: first we run an ordinary
# Compute the connectivity of the triangulated surface: first we run a normal
# connectivity filter neglecting periodic wrapping. # connectivity filter neglecting periodic wrapping.
t = time() if report: print('[Features3d.triangulate] computing connectivity...')
alg = vtk.vtkPolyDataConnectivityFilter() # ~twice as fast as default vtkConnectivityFilter alg = vtk.vtkPolyDataConnectivityFilter() # ~twice as fast as default vtkConnectivityFilter
alg.SetInputData(contour_) alg.SetInputData(contour_)
alg.SetExtractionModeToAllRegions() alg.SetExtractionModeToAllRegions()
alg.SetColorRegions(True) alg.SetColorRegions(True)
alg.Update() alg.Update()
contour_ = pv.filters._get_output(alg) contour_ = pv.filters._get_output(alg)
print('connectivity computed in',time()-t,'seconds')
# Now determine the boundary points, i.e. points on the boundary which have a corresponding # Now determine the boundary points, i.e. points on the boundary which have a corresponding
# vertex on the other side of the domain # vertex on the other side of the domain
t = time() if report: print('[Features3d.triangulate] correcting connectivity for periodic boundaries...')
points = contour_.points points = contour_.points
bd_points_xyz = np.zeros((0,3),dtype=points.dtype) bd_points_xyz = np.zeros((0,3),dtype=points.dtype)
bd_points_idx = np.zeros((0,),dtype=np.int64) bd_points_idx = np.zeros((0,),dtype=np.int)
for axis in range(3): for axis in range(3):
if not self.periodicity[axis]: continue if not self.periodicity[axis]: continue
# Compute position of boundary, period of domain and set a threshold for "on boundary" condition # Compute position of boundary, period of domain and set a threshold for "on boundary" condition
@ -766,18 +739,14 @@ class Features3d:
li = np.abs(points[:,axis]-bound_pos)<bound_dist li = np.abs(points[:,axis]-bound_pos)<bound_dist
bd_points_idx = np.append(bd_points_idx,np.nonzero(li)[0],axis=0) bd_points_idx = np.append(bd_points_idx,np.nonzero(li)[0],axis=0)
bd_points_xyz = np.append(bd_points_xyz,points[li,:]-period,axis=0) bd_points_xyz = np.append(bd_points_xyz,points[li,:]-period,axis=0)
print('Points selected in',time()-t,'seconds')
# Construct a KD Tree for efficient neighborhood search # Construct a KD Tree for efficient neighborhood search
t = time()
kd = spatial.KDTree(bd_points_xyz,leafsize=10,compact_nodes=True,copy_data=False,balanced_tree=True) kd = spatial.KDTree(bd_points_xyz,leafsize=10,compact_nodes=True,copy_data=False,balanced_tree=True)
print('kd tree built in',time()-t,'seconds')
# Construct a map to get new labels for regions. We search for pairs of points with # Construct a map to get new labels for regions. We search for pairs of points with
# a very small distance inbetween. Then we check if their labels differ and choose # a very small distance inbetween. Then we check if their labels differ and choose
# the lower label as the new joint one. # the lower label as the new joint one.
t = time()
point_labels = contour_.point_arrays['RegionId'] point_labels = contour_.point_arrays['RegionId']
nfeatures = np.max(point_labels) nfeatures = np.max(point_labels)+1
map_ = np.array(range(nfeatures+1),dtype=point_labels.dtype) map_ = np.array(range(nfeatures),dtype=point_labels.dtype)
overlap_dist = 1e-4*np.sqrt(np.square(datavtk.spacing).sum()) overlap_dist = 1e-4*np.sqrt(np.square(datavtk.spacing).sum())
for (ii,jj) in kd.query_pairs(r=overlap_dist): for (ii,jj) in kd.query_pairs(r=overlap_dist):
label_ii = point_labels[bd_points_idx[ii]] label_ii = point_labels[bd_points_idx[ii]]
@ -791,29 +760,17 @@ class Features3d:
# #
map_ = np.unique(map_,return_inverse=True)[1] map_ = np.unique(map_,return_inverse=True)[1]
point_labels = map_[point_labels] point_labels = map_[point_labels]
nfeatures = np.max(map_) nfeatures = np.max(map_)+1 # starts with zero
print('mapped overlaps in',time()-t,'seconds')
# pl = pv.Plotter()
# pl.add_mesh(contour_,scalars=point_labels,opacity=1.0,specular=1.0,interpolate_before_map=True)
# pl.show()
# Labels are now stored as point data. To efficiently convert it to cell data, the first # Labels are now stored as point data. To efficiently convert it to cell data, the first
# point of each cell determines the value for this cell. # point of each cell determines the value for this cell.
t = time() if report: print('[Features3d.triangulate] identifying cell based labels...')
ncells = contour_.n_faces ncells = contour_.n_faces
faces = contour_.faces.reshape(ncells,4)[:,:] faces = contour_.faces.reshape(ncells,4)[:,:]
# cell_labels = np.zeros((ncells,))
# for ii,face in enumerate(faces):
# cell_labels[ii] = point_labels[face[1]]
cell_labels = point_labels[faces[:,1]] cell_labels = point_labels[faces[:,1]]
print('cell interpolation in',time()-t,'seconds') # Compute the volume and area per cell. For the volume computation, an arbitrary component
# While we have the full contour in memory, compute the area and volume per cell. For # of the normal has to be chosen which defaults to the z-component and is set by
# the volume computation, an arbitrary component of the normal has to be chosen which # 'cellvol_normal_component'.
# defaults to the z-component and is set by 'cellvol_normal_component'. if report: print('[Features3d.triangulate] calculating area and volume per cell...')
t = time()
# faces = contour_.faces.reshape(contour_.n_faces,4)[:,:]
# points = contour_.points
X = points[faces[:,1],:] X = points[faces[:,1],:]
Y = points[faces[:,2],:] Y = points[faces[:,2],:]
Z = points[faces[:,3],:] Z = points[faces[:,3],:]
@ -821,20 +778,77 @@ class Features3d:
cc = (X+Y+Z)/3 cc = (X+Y+Z)/3
area = 0.5*np.sqrt(np.square(cn).sum(axis=1)) area = 0.5*np.sqrt(np.square(cn).sum(axis=1))
vol = 0.5*cn[:,cellvol_normal_component]*cc[:,cellvol_normal_component] vol = 0.5*cn[:,cellvol_normal_component]*cc[:,cellvol_normal_component]
print('CELLPROPERTIES:',time()-t)
# Now the label is known per cell. We only need to find all cells with the same label # Now the label is known per cell. We only need to find all cells with the same label
# and group them. Internally we will store the points in one array and the cells in # and group them. Therefore we sort the array holding the faces by feature label
# nlabel tuples of ndarrays. # and save the offset in another array to easily extract them whenever necessary.
t = time() if report: print('[Features3d.triangulate] finalizing internal data...')
offset = np.cumsum(np.bincount(cell_labels))[1:-1] offset = np.zeros((nfeatures+1,),dtype=np.int64)
offset[1:] = np.cumsum(np.bincount(cell_labels))
print(nfeatures,np.bincount(cell_labels),offset)
ind = np.argsort(cell_labels) ind = np.argsort(cell_labels)
self._offset = offset self._offset = offset
self._faces = faces[ind,1:] self._faces = faces[ind,:]
self._points = points self._points = points
self._cell_areas = area[ind] self._cell_areas = area[ind]
self._cell_volumes = vol[ind] self._cell_volumes = vol[ind]
self._nfeatures = nfeatures self._nfeatures = nfeatures
print('FINALIZING:',time()-t) return
@property
def threshold(self): return self._threshold
@property
def points(self): return self._points
@property
def faces(self): return self._faces
@property
def nfeatures(self): return self._nfeatures
def fill_holes(self,report=False):
'''Remove triangulation which is fully enclosed by another. These regions
will have a negative volume due to the direction of their normal vector.'''
self.discard_features(np.flatnonzero(self.volumes()<0),report=report)
return
def reduce_noise(self,threshold=1e-5,is_relative=True,report=False):
'''Discards all objects with smaller volume than a threshold.'''
if is_relative:
vol_domain = np.prod(self.spacing*self.dimensions)
threshold = threshold*vol_domain
self.discard_features(np.flatnonzero(self.volumes()<threshold),report=report)
return
def discard_features(self,labels,clean_points=False,report=False):
'''Removes features from triangulated data.'''
from collections.abc import Iterable
from time import time
if not isinstance(labels,Iterable): labels = [labels]
# Get index ranges which are to be deleted and also create an array
# which determines the size of the block to be deleted
idx = []
gapsize = np.zeros((self.nfeatures,),dtype=np.int)
for label in labels:
rng_ = np.arange(self._offset[label],self._offset[label+1])
idx.append(rng_)
gapsize[label] = len(rng_)
idx = np.concatenate(idx,axis=0)
# Save former number of faces for report
nfaces = self._faces.shape[0]
# Delete indexed elements from arrays
self._faces = np.delete(self._faces,idx,axis=0)
self._cell_areas = np.delete(self._cell_areas,idx,axis=0)
self._cell_volumes = np.delete(self._cell_volumes,idx,axis=0)
# Correct offset
self._offset[1:] = self._offset[1:]-np.cumsum(gapsize)
self._offset = self._offset[:-len(labels)]
if report:
print('[Features3d.discard_features]',end=' ')
print('discarded {:} features with {:} faces.'.format(
len(labels),nfaces-self._faces.shape[0]))
if clean_points:
self.clean_points(report=report)
return return
@property @property
@ -844,10 +858,10 @@ class Features3d:
def points(self): return self._points def points(self): return self._points
@property @property
def cell_areas(self): return np.split(self._cell_areas,self._offset) def cell_areas(self): return np.split(self._cell_areas,self._offset[1:-1])
@property @property
def cell_volumes(self): return np.split(self._cell_volumes,self._offset) def cell_volumes(self): return np.split(self._cell_volumes,self._offset[1:-1])
def area(self,feature): def area(self,feature):
'''Returns the surface area of feature. If feature is None, total surface '''Returns the surface area of feature. If feature is None, total surface
@ -858,36 +872,22 @@ class Features3d:
return np.sum(self.cell_areas[feature-1]) return np.sum(self.cell_areas[feature-1])
def areas(self): def areas(self):
'''Returns a tuple with surface areas of all features.''' '''Returns an array with surface areas of all features.'''
return tuple(np.sum(x) for x in self.cell_areas) return np.add.reduceat(self._cell_areas,self._offset[:-1])
def volume(self,feature,method='triangulation'): def volume(self,feature):
'''Returns volume enclosed by feature. If feature isNone, total volume of '''Returns volume enclosed by feature. If feature isNone, total volume of
all features is returned.''' all features is returned.'''
if method=='triangulation': if feature is None:
if self._faces is None: return np.sum(self._cell_volumes)
self.triangulate(contour_method=self.__TRI_CONTMETH, else:
cellvol_normal_component=self.__TRI_NORMCOMP) return np.sum(self.cell_volumes[feature-1])
if feature is None:
return np.sum(self._cell_volumes)
else:
return np.sum(self.cell_volumes[feature-1])
elif method=='binary':
return self.binary.volume(feature)
else:
raise ValueError("Invalid method. Available methods: 'triangulation','binary'")
def volumes(self,method='triangulation'): def volumes(self):
if method=='triangulation': '''Returns an array with volumes of all features.'''
self.triangulate(contour_method=self.__TRI_CONTMETH, return np.add.reduceat(self._cell_volumes,self._offset[:-1])
cellvol_normal_component=self.__TRI_NORMCOMP)
return tuple(np.sum(x) for x in self.cell_volumes)
elif method=='binary':
return self.binary.volumes()
else:
raise ValueError("Invalid method. Available methods: 'triangulation','binary'")
def which_feature(self,coords,method='binary'): def find_feature(self,coords):
coords = np.array(coords) coords = np.array(coords)
assert coords.ndim==2 and coords.shape[1]==3, "'coords' need to be provided as 3xN array." assert coords.ndim==2 and coords.shape[1]==3, "'coords' need to be provided as 3xN array."
# if method=='binary': # if method=='binary':
@ -895,21 +895,38 @@ class Features3d:
# elif method=='triangulation': # elif method=='triangulation':
return return
def to_vtk(self,label,reduce_points=False): def clean_points(self,report=False):
if self._faces is None: nfaces_ = self._faces.shape[0]
raise RuntimeError('Features have not been triangulated yet. Call triangulate() method first.') ind,inv = np.unique(self._faces[:,1:],return_inverse=True)
if report:
print('[Features3d.clean_points]',end=' ')
print('removed {:} orphan points.'.format(self._points.shape[0]-len(ind)))
self._faces[:,1:4] = inv.reshape(nfaces_,3)
self._points = self._points[ind,:]
def to_vtk(self,labels,reduce_points=False):
import pyvista as pv import pyvista as pv
idx = label-1 from collections.abc import Iterable
if reduce_points: if labels is None:
nfaces_ = self._faces[idx].shape[0] return pv.PolyData(self._points,self._faces)
ind,inv = np.unique(self._faces[idx][:,1:4],return_inverse=True) if not isinstance(labels,Iterable):
faces_ = np.zeros((nfaces_,4),dtype=self._faces[idx].dtype) labels = [labels]
faces_[:,0] = 3 points = self._points
faces_[:,1:4] = inv.reshape(nfaces_,3) faces = []
points_ = self._points[ind,:] for label in labels:
return pv.PolyData(points_,faces_) sl_ = slice(self._offset[label],self._offset[label+1])
else: faces.append(self._faces[sl_,:])
return pv.PolyData(self._points,self._faces[idx]) faces = np.concatenate(faces,axis=0)
# if reduce_points:
# nfaces_ = self._faces[idx].shape[0]
# ind,inv = np.unique(self._faces[:,1:4],return_inverse=True)
# faces_ = np.zeros((nfaces_,4),dtype=self._faces.dtype)
# faces_[:,0] = 3
# faces_[:,1:4] = inv.reshape(nfaces_,3)
# points_ = self._points[ind,:]
# return pv.PolyData(points_,faces_)
# else:
return pv.PolyData(points,faces)
class BinaryFieldNd: class BinaryFieldNd:
def __init__(self,input,periodicity,connect_diagonals=False,deep=False,has_ghost=False): def __init__(self,input,periodicity,connect_diagonals=False,deep=False,has_ghost=False):