9

稍后编辑:我在这里上传了我的原始数据样本。它实际上是 DICOM 格式的分割图像。该结构的体积约为 16 mL,因此我假设内部椭球体体积应小于该体积。为了从 DICOM 图像中提取点,我使用了以下代码:

import os
import numpy as np
import SimpleITK as sitk


def get_volume_ml(image):
    x_spacing, y_spacing, z_spacing = image.GetSpacing()
    image_nda = sitk.GetArrayFromImage(image)
    imageSegm_nda_NonZero = image_nda.nonzero()
    num_voxels = len(list(zip(imageSegm_nda_NonZero[0],
                              imageSegm_nda_NonZero[1],
                              imageSegm_nda_NonZero[2])))
    if 0 >= num_voxels:
        print('The mask image does not seem to contain an object.')
        return None
    volume_object_ml = (num_voxels * x_spacing * y_spacing * z_spacing) / 1000
    return volume_object_ml


def get_surface_points(folder_path):
    """
    :param folder_path: path to folder where DICOM images are stored
    :return: surface points of the DICOM object
    """
    # DICOM Series
    reader = sitk.ImageSeriesReader()
    dicom_names = reader.GetGDCMSeriesFileNames(os.path.normpath(folder_path))
    reader.SetFileNames(dicom_names)
    reader.MetaDataDictionaryArrayUpdateOn()
    reader.LoadPrivateTagsOn()
    try:
        dcm_img = reader.Execute()
    except Exception:
        print('Non-readable DICOM Data: ', folder_path)
        return None
    volume_obj = get_volume_ml(dcm_img)
    print('The volume of the object in mL:', volume_obj)
    contour = sitk.LabelContour(dcm_img, fullyConnected=False)
    contours = sitk.GetArrayFromImage(contour)
    vertices_locations = contours.nonzero()

    vertices_unravel = list(zip(vertices_locations[0], vertices_locations[1], vertices_locations[2]))
    vertices_list = [list(vertices_unravel[i]) for i in range(0, len(vertices_unravel))]
    surface_points = np.array(vertices_list)

    return surface_points

folder_path = r"C:\Users\etc\TTT [13]\20160415 114441\Series 052 [CT - Abdomen WT 1 0 I31f 3]"
points = get_surface_points(folder_path)

我在 3D 空间中有一组点(n > 1000),它们描述了一个空心的卵形形状。我想要的是适合所有点内的椭圆体(3D)。 我正在寻找点内拟合的最大体积椭球。

我试图 通过修改阈值来调整来自最小封闭椭圆体(又名外边界椭圆体)的代码,我的逻辑开始是所有点都应该小于 < 1 给定椭圆体方程。但没有成功。
err > tol

我还尝试了对mosek的 Loewner-John 改编,但我不知道如何描述超平面与 3D 多面体(Ax <= b 表示)的交集,因此我可以将其用于 3D 案例。所以再没有成功。

内接椭球

外椭球的代码:

import numpy as np
import numpy.linalg as la
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

pi = np.pi
sin = np.sin
cos = np.cos

def plot_ellipsoid(A, centroid, color, ax):
"""

:param A: matrix
:param centroid: center
:param color: color
:param ax: axis
:return:
"""
centroid = np.asarray(centroid)
A = np.asarray(A)
U, D, V = la.svd(A)
rx, ry, rz = 1. / np.sqrt(D)
u, v = np.mgrid[0:2 * np.pi:20j, -np.pi / 2:np.pi / 2:10j]
x = rx * np.cos(u) * np.cos(v)
y = ry * np.sin(u) * np.cos(v)
z = rz * np.sin(v)
E = np.dstack((x, y, z))
E = np.dot(E, V) + centroid
x, y, z = np.rollaxis(E, axis=-1)
ax.plot_wireframe(x, y, z, cstride=1, rstride=1, color=color, alpha=0.2)
ax.set_zlabel('Z-Axis')
ax.set_ylabel('Y-Axis')
ax.set_xlabel('X-Axis')

def mvee(points, tol = 0.001):
    """
    Finds the ellipse equation in "center form"
    (x-c).T * A * (x-c) = 1
    """
    N, d = points.shape
    Q = np.column_stack((points, np.ones(N))).T
    err = tol+1.0
    u = np.ones(N)/N
    while err > tol:
        # assert u.sum() == 1 # invariant
        X = np.dot(np.dot(Q, np.diag(u)), Q.T)
        M = np.diag(np.dot(np.dot(Q.T, la.inv(X)), Q))
        jdx = np.argmax(M)
        step_size = (M[jdx]-d-1.0)/((d+1)*(M[jdx]-1.0))
        new_u = (1-step_size)*u
        new_u[jdx] += step_size
        err = la.norm(new_u-u)
        u = new_u
    c = np.dot(u,points)        
    A = la.inv(np.dot(np.dot(points.T, np.diag(u)), points)
               - np.multiply.outer(c,c))/d
    return A, c

folder_path = r"" # path to a DICOM img folder
points = get_surface_points(folder_path) # or some random pts 

A, centroid = mvee(points)    
U, D, V = la.svd(A)    
rx_outer, ry_outer, rz_outer = 1./np.sqrt(D)
# PLOT
fig = plt.figure()
ax1 = fig.add_subplot(111, projection='3d')
ax1.scatter(points[:, 0], points[:, 1], points[:, 2], c='blue')
plot_ellipsoid(A, centroid, 'green', ax1)

这为我的样本点上的外椭球提供了这个结果: 在此处输入图像描述

主要问题:如何使用 Python 在 3D 点云中拟合椭圆体(3D)?

是否可以修改外部椭球的算法以获得最大内接(内部)椭球?

我正在寻找Python理想的代码。

4

4 回答 4

7
于 2020-05-20T05:34:15.583 回答
6

这个答案是否有效取决于您的数据中有多少噪音。这个想法是首先找到点云的凸包,然后找到适合该包的最大椭圆体。如果您的大多数点都靠近他们描述的椭球表面,那么这个近似值不会“太糟糕”。

为此,请注意凸包可以用一组线性不等式来描述Ax<=b

请注意,边界椭球可以用 来描述E={Bx+d for ||x||_2<=1},其中B是一个半正定矩阵,描述了椭球的拉伸方式和方向,并且d是描述其偏移量的向量。

请注意,椭圆体的体积由 给出det(B^-1)。如果我们试图最大化或最小化这个行列式,我们会失败,因为这会给出一个非凸问题。但是,应用对数变换log(det(B^-1))会使问题再次凸出。我们将要使用的优化程序不允许矩阵求逆,但很容易证明上述等价于-log(det(B))

最后,一些支撑代数操作给了我们优化问题:

minimize -log(det(B))
s.t.     ||B*A_i||_2 + a_i^T * d <= b_i, i = 1, ..., m
         B is PSD

我们可以使用CVXPY在 Python 中解决这个问题,如下所示:

#!/usr/bin/env python3

from mpl_toolkits.mplot3d import axes3d
from scipy.spatial import ConvexHull
import cvxpy as cp
import matplotlib.pyplot as plt
import numpy as np
import sklearn.datasets

#From: https://stackoverflow.com/a/61786434/752843
def random_point_ellipsoid(a,b,c,x0,y0,z0):
    """Generate a random point on an ellipsoid defined by a,b,c"""
    u = np.random.rand()
    v = np.random.rand()
    theta = u * 2.0 * np.pi
    phi = np.arccos(2.0 * v - 1.0)
    sinTheta = np.sin(theta);
    cosTheta = np.cos(theta);
    sinPhi = np.sin(phi);
    cosPhi = np.cos(phi);
    rx = a * sinPhi * cosTheta;
    ry = b * sinPhi * sinTheta;
    rz = c * cosPhi;
    return rx, ry, rz

def random_point_ellipse(W,d):
  # random angle
  alpha = 2 * np.pi * np.random.random()
  # vector on that angle
  pt = np.array([np.cos(alpha),np.sin(alpha)])
  # Ellipsoidize it
  return W@pt+d

def GetRandom(dims, Npts):
  if dims==2:
    W = sklearn.datasets.make_spd_matrix(2)
    d = np.array([2,3])
    points = np.array([random_point_ellipse(W,d) for i in range(Npts)])
  elif dims==3:
    points = np.array([random_point_ellipsoid(3,5,7,2,3,3) for i in range(Npts)])
  else:
    raise Exception("dims must be 2 or 3!")
  noise = np.random.multivariate_normal(mean=[0]*dims, cov=0.2*np.eye(dims), size=Npts)
  return points+noise

def GetHull(points):
  dim  = points.shape[1]
  hull = ConvexHull(points)
  A    = hull.equations[:,0:dim]
  b    = hull.equations[:,dim]
  return A, -b, hull #Negative moves b to the RHS of the inequality

def Plot(points, hull, B, d):
  fig = plt.figure()
  if points.shape[1]==2:
    ax = fig.add_subplot(111)
    ax.scatter(points[:,0], points[:,1])
    for simplex in hull.simplices:
      plt.plot(points[simplex, 0], points[simplex, 1], 'k-')
    display_points = np.array([random_point_ellipse([[1,0],[0,1]],[0,0]) for i in range(100)])
    display_points = display_points@B+d
    ax.scatter(display_points[:,0], display_points[:,1])
  elif points.shape[1]==3:
    ax = fig.add_subplot(111, projection='3d')
    ax.scatter(points[:,0], points[:,1], points[:,2])
    display_points = np.array([random_point_ellipsoid(1,1,1,0,0,0) for i in range(1000)])
    display_points = display_points@B+d
    ax.scatter(display_points[:,0], display_points[:,1], display_points[:,2])
  plt.show()

def FindMaximumVolumeInscribedEllipsoid(points):
  """Find the inscribed ellipsoid of maximum volume. Return its matrix-offset form."""
  dim = points.shape[1]
  A,b,hull = GetHull(points)

  B = cp.Variable((dim,dim), PSD=True) #Ellipsoid
  d = cp.Variable(dim)                 #Center

  constraints = [cp.norm(B@A[i],2)+A[i]@d<=b[i] for i in range(len(A))]
  prob = cp.Problem(cp.Minimize(-cp.log_det(B)), constraints)
  optval = prob.solve()
  if optval==np.inf:
    raise Exception("No solution possible!")
  print(f"Optimal value: {optval}") 

  Plot(points, hull, B.value, d.value)

  return B.value, d.value

FindMaximumVolumeInscribedEllipsoid(GetRandom(dims=2, Npts=100))
FindMaximumVolumeInscribedEllipsoid(GetRandom(dims=3, Npts=100))

快速计算解决方案。

从视觉上看,这给出了(对于 2D):

2D 最大体积内接椭球

请注意,我添加了很多噪音来强调正在发生的事情。

对于 3D:

3D 最大体积内切椭球

尽管上面的代码是针对两个或三个维度编写的,但您可以轻松地将其调整为任意数量的维度,尽管可视化会变得更加困难。

如果凸包不好,而你想要某种“内部凸包”,那就更难了:这个包没有明确定义。但是,您可以使用 alpha 形状来尝试找到这样的船体,然后使用上面的算法来解决它。

还要注意,由于我们使用凸多面体来约束椭圆,而不是点本身,即使这些点完美地描述了一个椭圆体,我们最终也会得到一个低估的体积。我们可以将其可视化,如下所示:

正方形内接圆

如果正方形的顶点是点,那么正方形就是它们的凸包。以船体为界的圆明显小于仅以点为界的圆。

编辑:要获得音量,您需要将像素索引转换为 DICOM 图像的坐标系,如下所示(注意:我不确定我是否已通过正确的值缩放了正确的坐标,但您将鉴于您对数据的了解,能够弄清楚这一点):

from mpl_toolkits.mplot3d import axes3d
from scipy.spatial import ConvexHull
import cvxpy as cp
import matplotlib.pyplot as plt
import numpy as np
import os
import sklearn.datasets
import SimpleITK as sitk
import code



def get_volume_ml(image):
    x_spacing, y_spacing, z_spacing = image.GetSpacing()
    image_nda = sitk.GetArrayFromImage(image)
    imageSegm_nda_NonZero = image_nda.nonzero()
    num_voxels = len(list(zip(imageSegm_nda_NonZero[0],
                              imageSegm_nda_NonZero[1],
                              imageSegm_nda_NonZero[2])))
    if 0 >= num_voxels:
        print('The mask image does not seem to contain an object.')
        return None
    volume_object_ml = (num_voxels * x_spacing * y_spacing * z_spacing) / 1000
    return volume_object_ml

def get_surface_points(dcm_img):
    x_spacing, y_spacing, z_spacing = dcm_img.GetSpacing()
    contour = sitk.LabelContour(dcm_img, fullyConnected=False)
    contours = sitk.GetArrayFromImage(contour)
    vertices_locations = contours.nonzero()

    vertices_unravel = list(zip(vertices_locations[0], vertices_locations[1], vertices_locations[2]))
    vertices_list = [list(vertices_unravel[i]) for i in range(0, len(vertices_unravel))]
    surface_points = np.array(vertices_list)

    surface_points = surface_points.astype(np.float64)

    surface_points[:,0] *= x_spacing/10
    surface_points[:,1] *= y_spacing/10
    surface_points[:,2] *= z_spacing/10

    return surface_points

def get_dcm_image(folder_path):
    reader = sitk.ImageSeriesReader()
    dicom_names = reader.GetGDCMSeriesFileNames(os.path.normpath(folder_path))
    reader.SetFileNames(dicom_names)
    reader.MetaDataDictionaryArrayUpdateOn()
    reader.LoadPrivateTagsOn()
    try:
        dcm_img = reader.Execute()
    except Exception:
        raise Exception('Non-readable DICOM Data: ', folder_path)

    return dcm_img

def GetHull(points):
  dim  = points.shape[1]
  hull = ConvexHull(points)
  A    = hull.equations[:,0:dim]
  b    = hull.equations[:,dim]
  return A, -b, hull #Negative moves b to the RHS of the inequality

def FindMaximumVolumeInscribedEllipsoid(points):
  """Find the inscribed ellipsoid of maximum volume. Return its matrix-offset form."""
  dim = points.shape[1]
  A,b,hull = GetHull(points)

  B = cp.Variable((dim,dim), PSD=True) #Ellipsoid
  d = cp.Variable(dim)                 #Center

  constraints = [cp.norm(B@A[i],2)+A[i]@d<=b[i] for i in range(len(A))]
  prob = cp.Problem(cp.Minimize(-cp.log_det(B)), constraints)
  optval = prob.solve()
  if optval==np.inf:
    raise Exception("No solution possible!")
  print(f"Optimal value: {optval}") 

  return B.value, d.value

#From: https://stackoverflow.com/a/61786434/752843
def random_point_ellipsoid(a,b,c,x0,y0,z0):
    """Generate a random point on an ellipsoid defined by a,b,c"""
    u = np.random.rand()
    v = np.random.rand()
    theta = u * 2.0 * np.pi
    phi = np.arccos(2.0 * v - 1.0)
    sinTheta = np.sin(theta);
    cosTheta = np.cos(theta);
    sinPhi = np.sin(phi);
    cosPhi = np.cos(phi);
    rx = a * sinPhi * cosTheta;
    ry = b * sinPhi * sinTheta;
    rz = c * cosPhi;
    return rx, ry, rz

def Plot(points, B, d):
  hull = ConvexHull(points)

  fig = plt.figure()
  ax = fig.add_subplot(111, projection='3d')
  ax.scatter(points[:,0], points[:,1], points[:,2], marker=".")
  display_points = np.array([random_point_ellipsoid(1,1,1,0,0,0) for i in range(1000)])
  display_points = display_points@B+d
  ax.scatter(display_points[:,0], display_points[:,1], display_points[:,2])
  plt.show()


folder_path = r"data"
dcm_img = get_dcm_image(folder_path)
points = get_surface_points(dcm_img)

B, d = FindMaximumVolumeInscribedEllipsoid(points)

Plot(points, B, d)

ball_vol = 4/3.0*np.pi*(1.0**3)

print("DCM vol: ", get_volume_ml(dcm_img))
print("Ellipsoid Volume: ", np.linalg.det(B) * ball_vol)

这给

DCM vol:  16.2786318359375
Ellipsoid Volume:  11.947614772444393
于 2020-05-20T19:49:20.117 回答
0

我认为,如果你可以假设椭球的质心和你的点是相同的,你可以解决椭球通过离n质心最近或最远点的方程。我不确定我是否有时间加强这个答案,但这种方法应该很容易用标准 Python 工具实现,例如:

https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.center_of_mass.html https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.cKDTree.html

也许用 SymPy 来求解解析方程。

于 2020-05-25T21:48:20.243 回答
0

一种,也许是数学标准,表示椭圆体(表面)的方法是它是集合

{ X | (X-a)'*inv(C)*(X-a) = 1}
the solid ellipsoid is then 
{ X | (X-a)'*inv(C)*(X-a) <= 1}

这里 C 是一个 3x3 对称正定矩阵,a 是椭圆体的“中心”。

我们可以通过使用cholesky分解使这个问题更容易处理,即找到一个下三角矩阵L,使得

C = L*L'

并使用 L 的倒数 M(L 是下三角形,这很容易计算)。我们比实心椭球体是

{ X | (M*(X-a))'*(M*(X-a)) <= 1}
= { | ||M*(X-a))|| <= 1} where ||.|| is the euclidean 

规范

我们有一堆点 X[] 和一个包含它们的椭球 (C,a),即

for all i ||M*(X[i]-a)|| <= 1
i.e. for all i ||Y[i]|| <= 1 where Y[i] = M*(X[i]-a)

现在我们要变换椭圆体(即改变C和a),使所有的点都在变换后的椭圆体之外。我们不妨把 M 和 a 转换一下。

最简单的做法就是将 M 缩放一个常数 s,然后不理会 a。这相当于缩放所有 Y[],在这种情况下,很容易看出要使用的比例因子将是 ||Y[i]|| 的最小值的 1 倍。这样,所有的点都会在变换后的椭圆体之外或之上,有些点会在上面,所以变换后的椭圆尽可能大。

就 D,a 而言,新的椭圆是

D = (1/(s*s))*C

如果这种简单的方法给出了可接受的结果,那就是我会使用的方法。

在不移动中心的情况下,我认为最普遍的做法是改变

M to N*M

约束条件是 N 是上三角形并且在对角线上有正数。我们要求 N

N*Y[i] >= 1 for all i

我们需要一个选择 N 的标准。一个是它应该尽可能少地减少体积,即行列式(对于下三角矩阵来说,它只是对角线元素的乘积)应该尽可能小,受约束。

很可能有一些包可以做这种事情,但是我不知道哪些包(这应该更多地表明我的无知,而不是表明没有这样的包)。

一旦找到 N,变换后的 C 矩阵为

D = L*inv(N)*inv(N')*L'

你也可以改变一个。我留给感兴趣的读者的细节......

于 2020-05-18T17:05:48.797 回答