在一个多面体/点集中内切最大体积椭球

12

后续编辑: 我在这里上传了我的原始数据样本。它实际上是DICOM格式的分割图像。该结构的体积为大约16毫升,因此我认为内部椭球体积应该比这小。为了从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)。我正在寻找最大体积的椭球体,以适应这些点。
我尝试通过修改阈值err> tol来调整最小外接椭球(又名外部边界椭球)的代码,我的逻辑是基于椭球方程式,所有点都应该小于1。但没有成功。
我还尝试了mosek上的Loewner-John适应方法,但我没有弄清楚如何描述超平面与3D多面体(Ax <= b表示)的交点,以便我可以将其用于3D情况。所以再次失败。

inscribed ellipsoid

外椭球体的代码:

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)

以下是针对我的样本点的外部椭球的结果: enter image description here

主要问题:如何使用Python将一个椭球(3D)套在一堆3D点上?

是否可以修改用于外部椭球的算法以获取最大内切(内部)椭球?

我正在寻找适用于Python的代码。


那么问题是什么?我不确定这里有没有问题。 - bla
2
你可以先从包含椭球体开始,然后再缩小它吗?不清楚这是否会给出最优解,但可能会给出一个答案。严格来说,我认为问题并不精确:你需要说明“内部”是什么意思。 - dmuir
@dmuir 我认为问题无法精确定义。我正在寻找的是可以适合这些点(而不接触它们)的最大体积。然而,我认为没有计算有效(或快速)的方法来证明任何内切体积都是最大的。所以,是的,你现在建议的方法是有意义的。我应该如何缩小外椭球,并如何检查它是否在点内? - RMS
为了澄清你的问题“椭球体是否被包含在点云中”,你是指椭球体包含在点云凸包内吗?还是指椭球体需要满足两个条件:1.椭球体被包含在点云的凸包内。2.点云中没有任何一个点被包含在椭球体内? - Hongkai Dai
1
这不是你要找的答案,但我看到了一篇文章,在LocalSolver中解决了类似的问题(有Python API)https://www.localsolver.com/news.html?id=96。也许它会有所帮助。 - k88
显示剩余8条评论
4个回答

9

问题陈述

给定一组点 v₁, v₂, ..., vₙ,找到一个满足以下两个约束条件的大椭球体:

  1. 该椭球体在凸包 ℋ = ConvexHull(v₁, v₂, ..., vₙ) 中。
  2. 其中没有任何一个点 v₁, v₂, ..., vₙ 在该椭球体内部。

我提出了一种迭代过程来寻找符合这两个约束条件的大椭球体。在每次迭代中,我们需要解决一个半定规划问题。尽管这个迭代过程保证收敛,但它不能保证收敛到全局最大的椭球体。

方法

寻找单个椭球体

我们迭代过程的核心是在每次迭代中找到一个满足三个条件的椭球体:

  • 该椭球体被包含在 ConvexHull(v₁, v₂, ..., vₙ) = {x | Ax<=b} 中。
  • 对于一组点 u₁, ... uₘ(其中 {v₁, v₂, ..., vₙ} ⊂ {u₁, ... uₘ},即点云中给定的点属于这组点 u₁, ... uₘ),该椭球体不包含任何点 u₁, ... uₘ。我们将这组点 u₁, ... uₘ 称为“外部点”。
  • 对于一组点 w₁,..., wₖ(其中 {w₁,..., wₖ} ∩ {v₁, v₂, ..., vₙ} = ∅,即在 v₁, v₂, ..., vₙ 中没有任何一个点属于 {w₁,..., wₖ}),该椭球体包含所有点 w₁,..., wₖ。我们将这组点 w₁,..., wₖ 称为“内部点”。

直观的想法是,“内部点” w₁,..., wₖ 表示椭球体的体积。我们将添加新点到“内部点”中以增加椭球体的体积。

为了通过凸优化找到这样一个椭球体,我们将其参数化为

{x | xᵀPx + 2qᵀx  ≤ r}

我们将搜索 P、q、r

“外部点”u₁,...uₘ都在椭球体之外的条件可以表示为:

uᵢᵀPuᵢ + 2qᵀuᵢ >= r ∀ i=1, ..., m

这是对P、q、r的线性约束条件。

"内部点" w₁,..., wₖ 都在椭球内部的条件可以表述为:

wᵢᵀPwᵢ + 2qᵀwᵢ <= r ∀ i=1, ..., k

这也是对P、q、r的线性约束。

我们还会强制执行以下约束条件:

P is positive definite

当矩阵P是正定的,且存在点集wᵢ满足条件 wᵢᵀPwᵢ + 2qᵀwᵢ ≤ r 时,集合 {x | xᵀPx + 2qᵀx ≤ r} 就成为一个椭球体。

同时,我们还有一个约束条件,即椭球体在凸壳 ℋ(其中有l个半空间)内部(即 {x | aᵢᵀx≤ bᵢ, i=1,...,l} 的 H 表示)。利用 s-lemma,我们可以知道判断半空间 {x|aᵢᵀx≤ bᵢ} 是否包含该椭球体的必要和充分条件为:

∃ λᵢ >= 0,
s.t [P            q -λᵢaᵢ/2]  is positive semidefinite.
    [(q-λᵢaᵢ/2)ᵀ     λᵢbᵢ-r]

因此,我们可以解决以下半定规划问题,以找到包含所有“内部点”的椭球体,不包含任何“外部点”,并且在凸包 ℋ 内。
find P, q, r, λ
s.t uᵢᵀPuᵢ + 2qᵀuᵢ >= r ∀ i=1, ..., m
    wᵢᵀPwᵢ + 2qᵀwᵢ <= r ∀ i=1, ..., k
    P is positive definite.
    λ >= 0,
    [P            q -λᵢaᵢ/2]  is positive semidefinite.
    [(q-λᵢaᵢ/2)ᵀ     λᵢbᵢ-r]

我们将其称为P,q,r = find_ellipsoid(outside_points,inside_points,A,b)

该椭球体积与(r + qᵀP⁻¹q)/ power(det(P),1 / 3)成比例。

迭代过程。

我们将“外部点”初始化为点云中的所有点v₁,v₂,...,vₙ,“内部点”则为凸包ℋ中的单个点w₁。在每次迭代中,我们使用上一子段中的find_ellipsoid函数查找包含所有“内部点”但不包含任何“外部点”的ℋ内椭球体。根据find_ellipsoid中SDP的结果,我们执行以下操作:

  • 如果SDP可行,则比较新发现的椭球体和到目前为止发现的最大椭球体。如果此新椭球体更大,则将其接受为到目前为止发现的最大椭球体。
  • 如果SDP不可行,则删除“内部点”中的最后一个点,并将此点添加到“外部点”。

在两种情况下,我们会在凸包ℋ中取一个新的样本点,将该样本点添加到“内部点”,然后再次解决SDP。

完整算法如下:

  1. 将“外部点”初始化为v₁,v₂,...,vₙ,将“内部点”初始化为凸包ℋ中的一个随机点。
  2. 当iter < max_iterations时:
  3. 解决SDP P,q,r = find_ellipsoid(outside_points,inside_points,A,b)
  4. 如果SDP可行且volume(Ellipsoid(P,q,r))> largest_volume,则设置P_best = P,q_best = q,r_best = r
  5. 如果SDP不可行,则pt = inside_points.pop_last(),outside_points.push_back(pt)。
  6. 在ℋ中随机抽取一个新点,将该点附加到“内部点”,iter += 1。进入步骤3。

代码

from scipy.spatial import ConvexHull, Delaunay
import scipy
import cvxpy as cp
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import dirichlet
from mpl_toolkits.mplot3d import Axes3D  # noqa


def get_hull(pts):
    dim = pts.shape[1]
    hull = ConvexHull(pts)
    A = hull.equations[:, 0:dim]
    b = hull.equations[:, dim]
    return A, -b, hull


def compute_ellipsoid_volume(P, q, r):
    """
    The volume of the ellipsoid xᵀPx + 2qᵀx ≤ r is proportional to
    power(r + qᵀP⁻¹q, dim/2)/sqrt(det(P))
    We return this number.
    """
    dim = P.shape[0]
    return np.power(r + q @ np.linalg.solve(P, q)), dim/2) / \
        np.sqrt(np.linalg.det(P))


def uniform_sample_from_convex_hull(deln, dim, n):
    """
    Uniformly sample n points in the convex hull Ax<=b
    This is copied from
    https://dev59.com/l7fna4cB1Zd3GeqPysMd
    @param deln Delaunay of the convex hull.
    """
    vols = np.abs(np.linalg.det(deln[:, :dim, :] - deln[:, dim:, :]))\
        / np.math.factorial(dim)
    sample = np.random.choice(len(vols), size=n, p=vols / vols.sum())

    return np.einsum('ijk, ij -> ik', deln[sample],
                     dirichlet.rvs([1]*(dim + 1), size=n))


def centered_sample_from_convex_hull(pts):
    """
    Sample a random point z that is in the convex hull of the points
    v₁, ..., vₙ. z = (w₁v₁ + ... + wₙvₙ) / (w₁ + ... + wₙ) where wᵢ are all
    uniformly sampled from [0, 1]. Notice that by central limit theorem, the
    distribution of this sample is centered around the convex hull center, and
    also with small variance when the number of points are large.
    """
    num_pts = pts.shape[0]
    pts_weights = np.random.uniform(0, 1, num_pts)
    z = (pts_weights @ pts) / np.sum(pts_weights)
    return z


def find_ellipsoid(outside_pts, inside_pts, A, b):
    """
    For a given sets of points v₁, ..., vₙ, find the ellipsoid satisfying
    three constraints:
    1. The ellipsoid is within the convex hull of these points.
    2. The ellipsoid doesn't contain any of the points.
    3. The ellipsoid contains all the points in @p inside_pts
    This ellipsoid is parameterized as {x | xᵀPx + 2qᵀx ≤ r }.
    We find this ellipsoid by solving a semidefinite programming problem.
    @param outside_pts outside_pts[i, :] is the i'th point vᵢ. The point vᵢ
    must be outside of the ellipsoid.
    @param inside_pts inside_pts[i, :] is the i'th point that must be inside
    the ellipsoid.
    @param A, b The convex hull of v₁, ..., vₙ is Ax<=b
    @return (P, q, r, λ) P, q, r are the parameterization of this ellipsoid. λ
    is the slack variable used in constraining the ellipsoid inside the convex
    hull Ax <= b. If the problem is infeasible, then returns
    None, None, None, None
    """
    assert(isinstance(outside_pts, np.ndarray))
    (num_outside_pts, dim) = outside_pts.shape
    assert(isinstance(inside_pts, np.ndarray))
    assert(inside_pts.shape[1] == dim)
    num_inside_pts = inside_pts.shape[0]

    constraints = []
    P = cp.Variable((dim, dim), symmetric=True)
    q = cp.Variable(dim)
    r = cp.Variable()

    # Impose the constraint that v₁, ..., vₙ are all outside of the ellipsoid.
    for i in range(num_outside_pts):
        constraints.append(
            outside_pts[i, :] @ (P @ outside_pts[i, :]) +
            2 * q @ outside_pts[i, :] >= r)
    # P is strictly positive definite.
    epsilon = 1e-6
    constraints.append(P - epsilon * np.eye(dim) >> 0)

    # Add the constraint that the ellipsoid contains @p inside_pts.
    for i in range(num_inside_pts):
        constraints.append(
            inside_pts[i, :] @ (P @ inside_pts[i, :]) +
            2 * q @ inside_pts[i, :] <= r)

    # Now add the constraint that the ellipsoid is in the convex hull Ax<=b.
    # Using s-lemma, we know that the constraint is
    # ∃ λᵢ > 0,
    # s.t [P            q -λᵢaᵢ/2]  is positive semidefinite.
    #     [(q-λᵢaᵢ/2)ᵀ     λᵢbᵢ-r]
    num_faces = A.shape[0]
    lambda_var = cp.Variable(num_faces)
    constraints.append(lambda_var >= 0)
    Q = [None] * num_faces
    for i in range(num_faces):
        Q[i] = cp.Variable((dim+1, dim+1), PSD=True)
        constraints.append(Q[i][:dim, :dim] == P)
        constraints.append(Q[i][:dim, dim] == q - lambda_var[i] * A[i, :]/2)
        constraints.append(Q[i][-1, -1] == lambda_var[i] * b[i] - r)

    prob = cp.Problem(cp.Minimize(0), constraints)
    try:
        prob.solve(verbose=False)
    except cp.error.SolverError:
        return None, None, None, None

    if prob.status == 'optimal':
        P_val = P.value
        q_val = q.value
        r_val = r.value
        lambda_val = lambda_var.value
        return P_val, q_val, r_val, lambda_val
    else:
        return None, None, None, None


def draw_ellipsoid(P, q, r, outside_pts, inside_pts):
    """
    Draw an ellipsoid defined as {x | xᵀPx + 2qᵀx ≤ r }
    This ellipsoid is equivalent to
    |Lx + L⁻¹q| ≤ √(r + qᵀP⁻¹q)
    where L is the symmetric matrix satisfying L * L = P
    """
    fig = plt.figure()
    dim = P.shape[0]
    L = scipy.linalg.sqrtm(P)
    radius = np.sqrt(r + q@(np.linalg.solve(P, q)))
    if dim == 2:
        # first compute the points on the unit sphere
        theta = np.linspace(0, 2 * np.pi, 200)
        sphere_pts = np.vstack((np.cos(theta), np.sin(theta)))
        ellipsoid_pts = np.linalg.solve(
            L, radius * sphere_pts - (np.linalg.solve(L, q)).reshape((2, -1)))
        ax = fig.add_subplot(111)
        ax.plot(ellipsoid_pts[0, :], ellipsoid_pts[1, :], c='blue')

        ax.scatter(outside_pts[:, 0], outside_pts[:, 1], c='red')
        ax.scatter(inside_pts[:, 0], inside_pts[:, 1], s=20, c='green')
        ax.axis('equal')
        plt.show()
    if dim == 3:
        u = np.linspace(0, np.pi, 30)
        v = np.linspace(0, 2*np.pi, 30)

        sphere_pts_x = np.outer(np.sin(u), np.sin(v))
        sphere_pts_y = np.outer(np.sin(u), np.cos(v))
        sphere_pts_z = np.outer(np.cos(u), np.ones_like(v))
        sphere_pts = np.vstack((
            sphere_pts_x.reshape((1, -1)), sphere_pts_y.reshape((1, -1)),
            sphere_pts_z.reshape((1, -1))))
        ellipsoid_pts = np.linalg.solve(
            L, radius * sphere_pts - (np.linalg.solve(L, q)).reshape((3, -1)))
        ax = plt.axes(projection='3d')
        ellipsoid_pts_x = ellipsoid_pts[0, :].reshape(sphere_pts_x.shape)
        ellipsoid_pts_y = ellipsoid_pts[1, :].reshape(sphere_pts_y.shape)
        ellipsoid_pts_z = ellipsoid_pts[2, :].reshape(sphere_pts_z.shape)
        ax.plot_wireframe(ellipsoid_pts_x, ellipsoid_pts_y, ellipsoid_pts_z)
        ax.scatter(outside_pts[:, 0], outside_pts[:, 1], outside_pts[:, 2],
                   c='red')
        ax.scatter(inside_pts[:, 0], inside_pts[:, 1], inside_pts[:, 2], s=20,
                   c='green')
        ax.axis('equal')
        plt.show()


def find_large_ellipsoid(pts, max_iterations):
    """
    We find a large ellipsoid within the convex hull of @p pts but not
    containing any point in @p pts.
    The algorithm proceeds iteratively
    1. Start with outside_pts = pts, inside_pts = z where z is a random point
       in the convex hull of @p outside_pts.
    2. while num_iter < max_iterations
    3.   Solve an SDP to find an ellipsoid that is within the convex hull of
         @p pts, not containing any outside_pts, but contains all inside_pts.
    4.   If the SDP in the previous step is infeasible, then remove z from
         inside_pts, and append it to the outside_pts.
    5.   Randomly sample a point in the convex hull of @p pts, if this point is
         outside of the current ellipsoid, then append it to inside_pts.
    6.   num_iter += 1
    When the iterations limit is reached, we report the ellipsoid with the
    maximal volume.
    @param pts pts[i, :] is the i'th points that has to be outside of the
    ellipsoid.
    @param max_iterations The iterations limit.
    @return (P, q, r) The largest ellipsoid is parameterized as
    {x | xᵀPx + 2qᵀx ≤ r }
    """
    dim = pts.shape[1]
    A, b, hull = get_hull(pts)
    hull_vertices = pts[hull.vertices]
    deln = hull_vertices[Delaunay(hull_vertices).simplices]

    outside_pts = pts
    z = centered_sample_from_convex_hull(pts)
    inside_pts = z.reshape((1, -1))

    num_iter = 0
    max_ellipsoid_volume = -np.inf
    while num_iter < max_iterations:
        (P, q, r, lambda_val) = find_ellipsoid(outside_pts, inside_pts, A, b)
        if P is not None:
            volume = compute_ellipsoid_volume(P, q, r)
            if volume > max_ellipsoid_volume:
                max_ellipsoid_volume = volume
                P_best = P
                q_best = q
                r_best = r
            else:
                # Adding the last inside_pts doesn't increase the ellipsoid
                # volume, so remove it.
                inside_pts = inside_pts[:-1, :]
        else:
            outside_pts = np.vstack((outside_pts, inside_pts[-1, :]))
            inside_pts = inside_pts[:-1, :]

        # Now take a new sample that is outside of the ellipsoid.
        sample_pts = uniform_sample_from_convex_hull(deln, dim, 20)
        is_in_ellipsoid = np.sum(sample_pts.T*(P_best @ sample_pts.T), axis=0)\
            + 2 * sample_pts @ q_best <= r_best
        if np.all(is_in_ellipsoid):
            # All the sampled points are in the ellipsoid, the ellipsoid is
            # already large enough.
            return P_best, q_best, r_best
        else:
            inside_pts = np.vstack((
                inside_pts, sample_pts[np.where(~is_in_ellipsoid)[0][0], :]))
            num_iter += 1

    return P_best, q_best, r_best

if __name__ == "__main__":
    pts = np.array([[0., 0.], [0., 1.], [1., 1.], [1., 0.], [0.2, 0.4]])
    max_iterations = 10
    P, q, r = find_large_ellipsoid(pts, max_iterations)

我也将代码放在github仓库中。

结果

这是一个简单的二维例子,假设我们想要找到一个大的椭球体,它不包含下图中的五个红点。 这是第一次迭代后的结果iteration1_result。 红色的点是“外部点”(初始的外部点是v₁,v₂,...,vₙ),绿色的点是初始的“内部点”。

在第二次迭代中,椭球体变成了

iteration2_result。 通过添加一个“内部点”(绿色圆点),椭球体变得更大。

这个gif展示了10次迭代的动画。all_iterations_result


谢谢,这是一个非常深入的数学解释。然而,我遇到的问题是如何将数学转化为实际的代码,因为似乎我需要一些非常特定和晦涩的库,这些库并不是标准的Python库。 - RMS
1
是的,确切地说。对于云中的所有点,内部椭球体不应包含任何点。只是我的点有这种非常奇怪的形状。请参见更新后原始问题中示例数据点的附件。 - RMS
1
@Roxane,我刚刚更新了我的方法。这种新方法以迭代方式解决问题。每次迭代我们需要解决一个半定规划问题(我建议使用Mosek而不是SCS来解决此问题)。此外,我将代码放在这里和github存储库中。 - Hongkai Dai
1
我得到了这个错误: Traceback (most recent call last): File "......................py", line 251, in <module> P, q, r = find_large_ellipsoid(pts, max_iterations) File "....................py", line 235, in find_large_ellipsoid is_in_ellipsoid = np.sum(sample_pts.T*(P_best @ sample_pts.T), axis=0)
UnboundLocalError: 引用了未赋值的本地变量 'P_best'
- seghier
1
@seghier。从代码中可以看出,如果max_iterations <= 0或者从volume = compute_ellipsoid_volume(P, q, r)返回的volume是NAN,则P_best未设置。您能否检查一下volume的返回值?另外,我们还有一个github仓库https://github.com/hongkai-dai/large_inscribed_ellipsoid/,通过在该仓库上提交问题来讨论错误会更容易些。 - Hongkai Dai
显示剩余2条评论

8
这个答案是否有效取决于数据中有多少噪音。首先找到点云的凸包,然后找到适合该凸包内部的最大椭球体。如果大多数点都靠近描述它们的椭球面,则此近似不会“太糟糕”。
为此,请注意凸包可以由一组线性不等式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://dev59.com/irTma4cB1Zd3GeqP3Dvu#61786434
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 maximum volume inscribed ellipsoid

请注意,我添加了很多噪声来强调正在发生的事情。
对于3D:

3D maximum volume inscribed ellipsoid

虽然上面的代码是为二维或三维编写的,但您可以轻松地为任意数量的维度进行适应,尽管可视化将变得更加困难。
如果凸包不好,您想要某种“内部凸包”,那就更难了:这个凸包没有明确定义。但是,您可以使用 alpha shapes来尝试找到这样的凸包,然后使用上面的算法来解决它。
还要注意,由于我们使用凸多面体来限制椭球体,而不是点本身,即使点完美地描述了一个椭球体,我们最终也会得到一个被低估的体积。我们可以将其可视化,如下所示:

Circle inscribed in a square

如果正方形的顶点是这些点,则该正方形是它们的凸包。由凸包界定的圆明显比仅由这些点所界定的圆更小。
编辑:要获得体积,您需要将像素索引转换为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://dev59.com/irTma4cB1Zd3GeqP3Dvu#61786434
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

1
我相信体积是det(B^-1)B的特征值与轴长度成比例(参见https://math.stackexchange.com/a/1558345/14493)。 - Richard
1
我描述的椭球体积总是小于这些点所组成的凸包体积。如果你的点都在一个表面上,那么我描述的椭球体积总是低估这个表面的体积。然而,如果你的点不形成一个明确定义的表面(因为有噪音),那么这些点就没有明确定义的体积。 - Richard
1
@Roxanne:精确的体积是 sqrt(det(B)) * Vol(Ball(0, 1))。也就是说,行列式的平方根乘以三维单位球的体积(链接)。你能否绘制你的结果,看看生成的椭球是否符合你的数据? - Richard
1
@Roxanne:请看我更新的答案。你需要将像素索引转换为适合你问题的坐标。 - Richard
1
@seghier:这些包都应该很容易地从 pip 或 conda 安装,而 blas+lapack 应该在大多数发行版的存储库中有预编译的包。 - Richard
显示剩余11条评论

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来解决这个分析方程。


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。

最简单的方法就是通过一个常数s来缩放M,而保持a不变。这相当于缩放所有的Y[],在这种情况下很容易看出要使用的缩放因子应该是1除以||Y[i]||的最小值。 这样,所有的点都将在变换后的椭球外部或上面,其中一些点将位于其上,因此变换后的椭圆尽可能大。

用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'

你也可以改变a。具体细节留给有兴趣的读者...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接