# Library of SPAM functions for measuring global descriptors
# Copyright (C) 2020 SPAM Contributors
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
import numpy
import spam.helpers
[docs]
def volume(segmented, phase=1, aspectRatio=(1.0, 1.0, 1.0), specific=False, ROI=None):
"""
Computes the (specific) volume of a given phase for a certain *Region Of
Interest*.
Parameters
-----------
segmented: array of integers
Array where integers represent the phases.
The value 0 is stricly reserved for **not** Region Of Interest.
phase: integer, default=1
The phase for which the fraction volume is computed.
The phase should be greater than 0 if ROI is used.
aspectRatio: tuple of floats, default=(1.0, 1.0, 1.0)
Length between two nodes in every direction e.g. size of a cell.
The length of the tuple is the same as the spatial dimension.
specific: boolean, default=False
Returns the specific volume if True.
ROI: array of boolean, default=None
If not None, boolean array which represents the Region Of Interest.
Segmented will be 0 where ROI is False.
Returns
--------
float
The (specific) volume of the phase.
"""
if phase == 0:
print(
"spam.measurements.globalDescriptors.volume: phase={}. Should be > 0.".format(
phase
)
)
if ROI is not None:
segmented = segmented * ROI
voxelSize = 1.0
for a in aspectRatio:
voxelSize *= a
if specific:
return float((segmented == phase).sum()) / float(segmented.size)
else:
return voxelSize * float((segmented == phase).sum())
[docs]
def eulerCharacteristic(segmented, phase=1, ROI=None, verbose=False):
"""
Compute the Euler Characteristic of a given phase by counting
n-dimensionnal topological features using the formula:
.. code-block:: text
1D: X = #{ Vertices } - #{ Edges }
2D: X = #{ Vertices } - #{ Edges } + #{ Faces }
3D: X = #{ Vertices } - #{ Edges } + #{ Faces } - #{ Polyedra }
This function as been implemented using a optimized algorithm using rolled
views with unsymetrical structuring elements to count, thus avoiding
n nested loops.
Parameters
-----------
segmented: array of integers
Array where integers represent the phases.
The value 0 is stricly reserved for **not** Region Of Interest.
phase: integer, default=1
The phase for which the fraction volume is computed.
The phase should be greater than 0 if ROI is used.
ROI: array of boolean, default=None
If not None, boolean array which represents the Region Of Interest.
Segmented will be 0 where ROI is False.
verbose: boolean, default=False,
Verbose mode.
Returns
--------
int:
The Euler Characteristic
"""
if phase == 0:
print(
"spam.measurements.globalDescriptors.eulerCharacteristic: phase={}. Should be > 0.".format(
phase
)
)
# get dimension
dim = len(segmented.shape)
if ROI is not None:
segmented = segmented * ROI
if dim == 1: # DIMENSION 1
# Count the vertices
counter = (segmented == phase).astype("<u1")
V = counter.sum()
# Count the edges
s_x = numpy.roll(counter, 1)
s_x[0] = 0
counter_x = numpy.logical_and(counter, s_x)
E = counter_x.sum()
# Euler characteristic
X = int(V - E)
if verbose:
print("Compute Euler Characteristic in 1D")
print("* V = #(vertices) = (+) {}".format(V))
print("* E = #(edges) = (-) {}".format(E))
print("* X = V - E = {}".format(X))
elif dim == 2: # DIMENSION 2
# Count the vertices
counter = (segmented == phase).astype("<u1")
V = counter.sum()
# Count the edges
s_x = numpy.roll(counter, 1, axis=0)
s_x[0, :] = 0
counter_x = numpy.logical_and(counter, s_x)
s_y = numpy.roll(counter, 1, axis=1)
s_y[:, 0] = 0
counter_y = numpy.logical_and(counter, s_y)
E = counter_x.sum() + counter_y.sum()
# Count the faces
s_xy = numpy.roll(s_x, 1, axis=1)
s_xy[:, 0] = 0
f_xy = numpy.logical_and(numpy.logical_and(counter_x, s_xy), counter_y)
F = f_xy.sum()
# Euler characteristic
X = int(V - E + F)
if verbose:
print("Compute Euler Characteristic in 2D")
print("* V = #(vertices) = (+) {}".format(V))
print("* E = #(edges) = (-) {}".format(E))
print("* F = #(faces) = (+) {}".format(F))
print("* X = V - E + F = {}".format(X))
elif dim == 3: # DIMENSION 3
# Count the vertices
counter = (segmented == phase).astype("<u1")
V = counter.sum()
# Count the edges
s_x = spam.helpers.singleShift(counter, 1, 0, sub=0)
counter_x = numpy.logical_and(counter, s_x)
s_y = spam.helpers.singleShift(counter, 1, 1, sub=0)
counter_y = numpy.logical_and(counter, s_y)
s_z = spam.helpers.singleShift(counter, 1, 2, sub=0)
counter_z = numpy.logical_and(counter, s_z)
E = counter_x.sum() + counter_y.sum() + counter_z.sum()
# Count the faces
s_xy = spam.helpers.singleShift(s_x, 1, 1, sub=0)
f_xy = numpy.logical_and(numpy.logical_and(counter_x, s_xy), counter_y)
s_xz = spam.helpers.singleShift(s_x, 1, 2, sub=0)
f_xz = numpy.logical_and(numpy.logical_and(counter_x, s_xz), counter_z)
s_yz = spam.helpers.singleShift(s_y, 1, 2, sub=0)
f_yz = numpy.logical_and(numpy.logical_and(counter_y, s_yz), counter_z)
F = f_xy.sum() + f_xz.sum() + f_yz.sum()
# Count the polyhedra
s_xyz = spam.helpers.singleShift(s_xy, 1, 2, sub=0)
f_xyz = numpy.logical_and(numpy.logical_and(f_xy, f_xz), f_yz)
p_xyz = numpy.logical_and(f_xyz, s_xyz)
P = p_xyz.sum()
# Euler characteristic
X = int(V - E + F - P)
if verbose:
print("* V = #(vertices) = (+) {}".format(V))
print("* E = #(edges) = (-) {}".format(E))
print("* F = #(faces) = (+) {}".format(F))
print("* P = #(polyedra) = (-) {}".format(P))
print("* X = V - E + F - P = {}".format(X))
return X
[docs]
def surfaceArea(field, level=0.0, aspectRatio=(1.0, 1.0, 1.0)):
"""
Computes the surface area of a continuous field over a given level set.
This function is based on a marching cubes algorithm of skimage.
Parameters
-----------
field: array of floats
Array where some level sets represent the interface between phases.
level: float, default=0.0
The level set.
See ``skimage.measure.marching_cubes``.
Contour value to search for isosurfaces in volume.
If not given or None, the average of the min and max of vol is used.
aspectRatio: length 3 tuple of floats, default=(1.0, 1.0, 1.0)
Length between two nodes in every direction e.g. size of a cell.
It corresponds to ``spacing`` in ``skimage.measure.marching_cubes``.
Returns
--------
float:
The surface area.
"""
import skimage.measure
# add a margin of 3 voxels
margedField = numpy.zeros([n + 6 for n in field.shape])
margedField[:, :, :] = field.min()
margedField[3:-3, 3:-3, 3:-3] = field
# http://scikit-image.org/docs/dev/api/skimage.measure.html?highlight=mesh_surface_area#skimage.measure.marching_cubes
verts, faces, _, _ = skimage.measure.marching_cubes(
margedField, level=level, spacing=aspectRatio
)
return skimage.measure.mesh_surface_area(verts, faces)
# def totalCurvature(field, level=0.0, aspectRatio=(1.0, 1.0, 1.0)):
# """
# Computes the total curvature of a continuous field over a given level set.
#
# This function is based on a marching cubes algorithm of skimage.
#
# Parameters
# -----------
# field: array of floats
# Array where some level sets represent the interface between phases.
# level: float, default=0.0
# The level set.
# See ``skimage.measure.marching_cubes``.
# Contour value to search for isosurfaces in volume.
# If not given or None, the average of the min and max of vol is used.
# aspectRatio: length 3 tuple of floats, default=(1.0, 1.0, 1.0)
# Length between two nodes in every direction e.g. size of a cell.
# It corresponds to ``spacing`` in ``skimage.measure.marching_cubes``.
#
# Returns
# --------
# float:
# The total curvature.
# """
#
# # specific function
# def faceNormal(tri3d):
# f_normal=numpy.zeros((tri3d.shape[0],3))
# p1=tri3d[:,0,:]-tri3d[:,1,:]
# p2=tri3d[:,0,:]-tri3d[:,2,:]
# n=numpy.cross(p1, p2)
# norm=LA.norm(n,axis=1)
# f_normal[:,0]=n[:,0]/norm[:]
# f_normal[:,1]=n[:,1]/norm[:]
# f_normal[:,2]=n[:,2]/norm[:]
#
# return f_normal
#
# # import
# import skimage.measure
# from numpy import linalg as LA
# import math
#
# # marching cubes
#
# # add a margin of 3 voxels
# margedField = numpy.zeros([n+6 for n in field.shape])
# margedField[:, :, :] = field.min()
# margedField[3:-3, 3:-3, 3:-3] = field
#
# # http://scikit-image.org/docs/dev/api/skimage.measure.html?highlight=mesh_surface_area#skimage.measure.marching_cubes
# verts, faces, _, _ = skimage.measure.marching_cubes(margedField, level=level, spacing=aspectRatio)
#
# # compute curvature
#
# tri3d=verts[faces]
# nb_tri = tri3d.shape[0]
# nb_p = verts.shape[0]
#
# # freeBoundary ### not yet
# f_normal = faceNormal(tri3d)
#
# ### vectors, areas, angles, edges for every triangle
# l_edg=numpy.zeros((nb_tri,3))
# ang_tri=numpy.zeros((nb_tri,3))
# f_center=numpy.zeros((nb_tri,3))
# p=numpy.zeros((nb_tri))
# v0=numpy.zeros((nb_tri,3))
# v1=numpy.zeros((nb_tri,3))
# v2=numpy.zeros((nb_tri,3))
# area_tri=numpy.zeros((nb_tri))
# for i in range(nb_tri):
# # print("Boucle 1: {}/{}".format(i, nb_tri))
# # points
# p0=faces[i,0]
# p1=faces[i,1]
# p2=faces[i,2]
#
# # edges (vectors)
# v0[i,:]=tri3d[i,1,:]-tri3d[i,0,:]
# v1[i,:]=tri3d[i,2,:]-tri3d[i,1,:]
# v2[i,:]=tri3d[i,0,:]-tri3d[i,2,:]
#
# # length of edges
# l_edg[i,0]= LA.norm(v0[i,:])
# l_edg[i,1]= LA.norm(v1[i,:])
# l_edg[i,2]= LA.norm(v2[i,:])
#
# # angle
# ang_tri[i,0]=math.acos(numpy.dot(v0[i,:]/l_edg[i,0], -v2[i,:]/l_edg[i,2]))
# ang_tri[i,1]=math.acos(numpy.dot(-v0[i,:]/l_edg[i,0], v1[i,:]/l_edg[i,1]))
# ang_tri[i,2]=math.pi-ang_tri[i,0]-ang_tri[i,1]
#
# # incenter
# p[i]=numpy.sum(l_edg[i,:])
# f_center[i,0]=(l_edg[i,0]*tri3d[i,2,0]+l_edg[i,1]*tri3d[i,0,0]+l_edg[i,2]*tri3d[i,1,0])/p[i]
# f_center[i,1]=(l_edg[i,0]*tri3d[i,2,1]+l_edg[i,1]*tri3d[i,0,1]+l_edg[i,2]*tri3d[i,1,1])/p[i]
# f_center[i,2]=(l_edg[i,0]*tri3d[i,2,2]+l_edg[i,1]*tri3d[i,0,2]+l_edg[i,2]*tri3d[i,1,2])/p[i]
#
# # surface
# area_tri[i]=((numpy.cross(v0[i,:],v1[i,:])**2).sum()**0.5).sum() /2.0
#
# a_mixed=numpy.zeros((nb_p))
# alf=numpy.zeros((nb_p))
# MC =numpy.zeros((nb_p))
# GC =numpy.zeros((nb_p))
#
# for i in range(nb_p):
# # print("Boucle 2: {}/{}".format(i, nb_p))
# mc_vec=[0,0,0]
# n_vec=[0,0,0]
#
# # if find(bndry_edge) ### not yet
#
# # vertex attachment ? find?
# find=numpy.where(faces==i)
# neib_tri=find[0]
#
# for j in range(len(neib_tri)):
# neib=neib_tri[j]
# # sum of angles around point i ==> GC
# for k in range(3):
# if faces[neib,k]==i:
# alf[i]=alf[i]+ang_tri[neib,k]
# break
# # mean curvature operator
# if k==0:
# mc_vec=mc_vec+(v0[neib,:]/math.tan(ang_tri[neib,2])-v2[neib,:]/math.tan(ang_tri[neib,1]))
# elif k==1:
# mc_vec=mc_vec+(v1[neib,:]/math.tan(ang_tri[neib,0])-v0[neib,:]/math.tan(ang_tri[neib,2]))
# elif k==2:
# mc_vec=mc_vec+(v2[neib,:]/math.tan(ang_tri[neib,1])-v1[neib,:]/math.tan(ang_tri[neib,0]))
#
# # A_mixed calculation
# if (ang_tri[neib,k]>=math.pi/2.0):
# a_mixed[i]=a_mixed[i]+area_tri[neib]/2.0
# else:
# if any(ang >=math.pi/2.0 for ang in ang_tri[neib,:]):
# a_mixed[i]=a_mixed[i]+area_tri[neib]/4.0
# else:
# sum=0
# for m in range(3):
# if m!=k:
# ll=m+1
# if ll==3:
# ll=0
# sum=sum+(l_edg[neib,ll]**2/math.tan(ang_tri[neib,m]))
# a_mixed[i]=a_mixed[i]+sum/8.0
#
#
# # normal vector at each vertex
# # weighted average of normal vectors of neighbour triangles
# wi=1.0/LA.norm( [f_center[neib,0]-verts[i,0],f_center[neib,1]-verts[i,1],f_center[neib,2]-verts[i,2]] )
# n_vec=n_vec+wi*f_normal[neib,:]
#
# GC[i]=(2.0*math.pi-alf[i])/a_mixed[i]
#
# mc_vec=0.25*mc_vec/a_mixed[i]
# n_vec=n_vec/LA.norm(n_vec)
#
# # sign of MC
# if numpy.dot(mc_vec, n_vec)<0:
# MC[i]=-LA.norm(mc_vec)
# else:
# MC[i]=LA.norm(mc_vec)
#
#
# totalCurvature = numpy.multiply(MC, a_mixed).sum()
#
# return totalCurvature
[docs]
def totalCurvature(
field,
level=0.0,
aspectRatio=(1.0, 1.0, 1.0),
stepSize=None,
getMeshValues=False,
fileName=None,
):
"""
Computes the total curvature of a continuous field over a given level set.
This function is based on a marching cubes algorithm of skimage.
Parameters
-----------
field: array of floats
Array where some level sets represent the interface between phases
level: float
The level set
See ``skimage.measure.marching_cubes``.
Contour value to search for isosurfaces in volume.
If None is given, the average of the min and max of vol is used
Default = 0.0
aspectRatio: length 3 tuple of floats
Length between two nodes in every direction `e.g.`, size of a cell.
It corresponds to ``spacing`` in ``skimage.measure.marching_cubes``
Default = (1.0, 1.0, 1.0)
step_size: int
Step size in voxels
Larger steps yield faster but coarser results. The result will always be topologically correct though.
Default = 1
getMeshValues: boolean
Return the mean and gauss curvatures and the areas of the mesh vertices
Default = False
fileName: string
Name of the output vtk file
Only if `getMeshValues` = True
Default = None
Returns
--------
totalCurvature: float
If ``getMeshValues`` = True,
total curvature and a (nx3) array of n vertices
where 1st column is the mean curvature, 2nd column is the gaussian curvature and 3rd column is the area
"""
# import
import skimage.measure
import spam.measurements.measurementsToolkit as mtk
# marching cubes
# add a margin of 3 voxels
margedField = numpy.zeros([n + 6 for n in field.shape])
margedField[:, :, :] = field.min()
margedField[3:-3, 3:-3, 3:-3] = field
# http://scikit-image.org/docs/dev/api/skimage.measure.html?highlight=mesh_surface_area#skimage.measure.marching_cubes
if stepSize is not None:
verts, faces, _, _ = skimage.measure.marching_cubes(
margedField, level=level, spacing=aspectRatio, step_size=stepSize
)
else:
verts, faces, _, _ = skimage.measure.marching_cubes(
margedField, level=level, spacing=aspectRatio
)
# call CPP function return gaussian curvature, mean curvature and areas
meanGaussArea = mtk.computeCurvatures(
faces.astype("<u8").tolist(), verts.astype("<f8").tolist()
)
meanGaussArea = numpy.array(meanGaussArea)
totalCurvature = numpy.multiply(meanGaussArea[:, 0], meanGaussArea[:, 2]).sum()
if fileName is not None:
pointData = {
"meanCurvature": meanGaussArea[:, 0],
"gaussCurvature": meanGaussArea[:, 1],
"area": meanGaussArea[:, 2],
}
import spam.helpers
spam.helpers.writeUnstructuredVTK(
verts, faces, pointData=pointData, elementType="triangle", fileName=fileName
)
if getMeshValues:
return (
totalCurvature,
meanGaussArea[:, 0],
meanGaussArea[:, 1],
meanGaussArea[:, 2],
)
else:
return totalCurvature
[docs]
def perimeter(segmented, phase=1, aspectRatio=(1.0, 1.0), ROI=None):
"""
Computes the perimeter of a given phase.
Parameters
-----------
segmented: array of integers
Array where integers represent the phases.
The value 0 is stricly reserved for **not** Region Of Interest.
phase: integer, default=1
The phase for which the fraction volume is computed.
The phase should be greater than 0 if ROI is used.
aspectRatio: length 2 tuple of floats, default=(1.0, 1.0)
Length between two nodes in every direction e.g. size of a cell.
ROI: array of boolean, default=None
If not None, boolean array which represents the Region Of Interest.
Segmented will be 0 where ROI is False.
Returns
--------
float:
The perimeter.
"""
import spam.filters
if phase == 0:
print(
"spam.measurements.globalDescriptors.perimeter: phase={}. Should be > 0.".format(
phase
)
)
if ROI is not None:
segmented = segmented * ROI
# add border
im_l = numpy.zeros((segmented.shape[0] + 2, segmented.shape[1] + 2), dtype=bool)
im_l[1 : segmented.shape[0] + 1, 1 : segmented.shape[1] + 1] = segmented == phase
# take perimeters
peri = spam.filters.binaryMorphologicalGradient(im_l)
# compute number of each voxel types
late = peri & spam.helpers.multipleShifts(peri, shifts=[+1, 0])
late = late + (peri & spam.helpers.multipleShifts(peri, shifts=[0, +1]))
late = late + (peri & spam.helpers.multipleShifts(peri, shifts=[-1, 0]))
late = late + (peri & spam.helpers.multipleShifts(peri, shifts=[0, -1]))
diag = peri & spam.helpers.multipleShifts(peri, shifts=[+1, +1])
diag = diag + (peri & spam.helpers.multipleShifts(peri, shifts=[+1, -1]))
diag = diag + (peri & spam.helpers.multipleShifts(peri, shifts=[-1, +1]))
diag = diag + (peri & spam.helpers.multipleShifts(peri, shifts=[-1, -1]))
inte = diag & late
jdiag = diag & numpy.logical_not(inte)
jlate = late & numpy.logical_not(inte)
voxelSize = aspectRatio[0]
# compute global coefficient
alpha = (
(1.0 + 0.5**2) ** 0.5 * numpy.sum(inte)
+ numpy.sum(jlate)
+ 2.0**0.5 * numpy.sum(jdiag)
)
return alpha * voxelSize
[docs]
def generic(
segmented,
n,
phase=1,
level=0.0,
aspectRatio=(1.0, 1.0, 1.0),
specific=False,
ROI=None,
verbose=False,
):
"""
Maps all measures in a homogeneous format
Parameters
-----------
segmented: array of integers
Array where integers represent the phases.
The value 0 is stricly reserved for **not** Region Of Interest.
n: int
The number of the measure. The meaning depends of the dimension.
.. code-block:: text
* In 1D:
* n=0: Euler characteristic
* n=1: (specific) length
* In 2D:
* n=0: Euler characteristic
* n=1: perimeter
* n=2: (specific) surface
* In 3D:
* n=0: Euler Characteristic
* n=1: ?
* n=2: surface
* n=3: (specific) volume
phase: integer, default=1
The phase for which the fraction volume is computed.
The phase should be greater than 0 if ROI is used.
level: float, default=0.0
The level set.
See ``skimage.measure.marching_cubes``.
Contour value to search for isosurfaces in volume.
If not given or None, the average of the min and max of vol is used.
specific: boolean, default=False
Returns the specific volume if True.
aspectRatio: length 3 tuple of floats, default=(1.0, 1.0, 1.0)
Length between two nodes in every direction e.g. size of a cell.
ROI: array of boolean, default=None
If not None, boolean array which represents the Region Of Interest.
Segmented will be 0 where ROI is False.
verbose: boolean, default=False,
Verbose mode.
Returns
--------
float:
The measure
"""
# get dimension
dim = len(segmented.shape)
if dim == 1:
if n == 0:
return eulerCharacteristic(segmented, phase=phase, ROI=ROI, verbose=verbose)
elif n == 1:
return volume(
segmented,
phase=phase,
aspectRatio=aspectRatio,
specific=specific,
ROI=ROI,
)
elif dim == 2:
if n == 0:
return eulerCharacteristic(segmented, phase=phase, ROI=ROI, verbose=verbose)
if n == 1:
return perimeter(segmented, phase=phase, ROI=ROI)
if n == 2:
return volume(
segmented,
phase=phase,
aspectRatio=aspectRatio,
specific=specific,
ROI=ROI,
)
elif dim == 3:
if n == 0:
return eulerCharacteristic(segmented, phase=phase, ROI=ROI, verbose=verbose)
if n == 1:
return -1
if n == 2:
return surfaceArea(segmented, level=level, aspectRatio=aspectRatio)
if n == 3:
return volume(
segmented,
phase=phase,
aspectRatio=aspectRatio,
specific=specific,
ROI=ROI,
)
print(
"spam.measurements.globalDescriptors.generic: dim={} (should be between 1 and 3) and n={} (should be between 0 and dim). Return NaN".format(
dim, n
)
)
return numpy.nan