检查两个三次贝塞尔曲线是否相交。

41
为了一个个人项目,我需要找出两条三次贝塞尔曲线是否相交。我不需要知道它们在哪里相交,我只需要知道它们是否相交。然而,我需要快速完成这项工作。
我在网上搜寻到很多资源,其中有一个问题的答案看起来很有希望,在这里可以找到。
因此,经过我了解了Sylvester矩阵行列式消元式以及为什么它有用,我认为我已经理解了解决方案的原理。但是,事实并非如此,它并不如此有效。

实验验证

我使用我的绘图计算器绘制了两个相交的贝塞尔样条曲线(我们称之为B 0和B 1)。它们的坐标如下(P 0,P 1,P 2,P 3):
(1, 1) (2, 4) (3, 4) (4, 3)
(3, 5) (3, 6) (0, 1) (3, 1)

结果如下,其中B0是“水平”曲线,B1是另一条曲线:

Two cubic Bézier curves that intersect

按照前面提到的问题的最佳答案的指示,我将B0减去了B1。这给了我两个方程式(X轴和Y轴),根据我的计算器,它们是:

x = 9t^3 - 9t^2 - 3t + 2
y = 9t^3 - 9t^2 - 6t + 4

西尔维斯特矩阵

然后我根据这个构建了以下的西尔维斯特矩阵:

9  -9  -3   2   0   0
0   9  -9  -3   2   0
0   0   9  -9  -3   2
9  -9  -6   4   0   0
0   9  -9  -6   4   0
0   0   9  -9  -6   4

之后,我编写了一个用于计算矩阵行列式的C++函数,使用的是Laplace展开法

template<int size>
float determinant(float* matrix)
{
    float total = 0;
    float sign = 1;
    float temporaryMatrix[(size - 1) * (size - 1)];
    for (int i = 0; i < size; i++)
    {
        if (matrix[i] != 0)
        {
            for (int j = 1; j < size; j++)
            {
                float* targetOffset = temporaryMatrix + (j - 1) * (size - 1);
                float* sourceOffset = matrix + j * size;
                int firstCopySize = i * sizeof *matrix;
                int secondCopySize = (size - i - 1) * sizeof *matrix;
                memcpy(targetOffset, sourceOffset, firstCopySize);
                memcpy(targetOffset + i, sourceOffset + i + 1, secondCopySize);
            }
            float subdeterminant = determinant<size - 1>(temporaryMatrix);
            total += matrix[i] * subdeterminant * sign;
        }
        sign *= -1;
    }
    return total;
}

template<>
float determinant<1>(float* matrix)
{
    return matrix[0];
}

对于相对较小的矩阵(2x2、3x3和4x4),似乎工作得相当不错,所以我期望它也可以适用于6x6的矩阵。但是我并没有进行广泛的测试,有可能它存在问题。


问题

如果我正确理解了另一个问题的答案,则由于曲线相交,行列式应该为0。然而,给我的程序提供上面我制作的Sylvester矩阵时,结果是-2916。

这是我出错还是他们出错?确定两个三次Bézier曲线是否相交的正确方法是什么?


如果不必要,请勿计算行列式。如果您想检查奇异性,请计算最低和最高奇异值。如果出于某种原因需要行列式,请勿使用拉普拉斯展开!它具有指数时间复杂度。您可以在O(n^3)上完成它! - sellibitze
2
将您的 Sylvester 矩阵插入到 http://www.bluebit.gr/matrix-calculator/ 的矩阵计算器中,行列式的值为 -2916。您可能需要修复行列式函数。 - Kyle Lutz
@Kyle Lutz,我在发帖后大约5分钟就发现了这一点,并修复了我的行列式函数。 - zneak
@Paul Baker 我没有,是我的计算器做的。它将我们知道的三次贝塞尔方程重构为 (-P0 + 3*P1 - 3*P2 + P4) * t^3 + 3*(P0 - 2*P1 + P2) * t^2 - 3*(P0 - P1) * t + P0;而我展示的两个方程只是两条曲线的 X 和 Y 函数相减。WolframAlpha 证实它们是相同的。 - zneak
2
这是一个完美的算法,但它只能找到两个参数曲线在完全相同的参数值处相交(这就是@PaulBaker答案所指的)。真正的问题(“曲线是否相交?”)是一个双变量多项式,你需要找到其根,这是一个我不知道是否存在解析解的问题。我认为问题应该被编辑以包括这个备注。 - Ad N
显示剩余2条评论
8个回答

22

贝塞尔曲线的交点是通过(非常酷)Asymptote矢量图形语言实现的:在这里查找intersect()

尽管他们没有解释他们实际使用的算法,除了说它来自于《Metafont Book》的第137页,但似乎它的关键是Bezier曲线的两个重要属性(虽然我现在找不到该网站上的其他页面来解释这些属性):

  • Bezier曲线始终包含在由其4个控制点定义的边界框内
  • Bezier曲线始终可以在任意t值处分成2个子Bezier曲线

有了这两个属性和一个相交多边形的算法,您可以递归到任意精度:

bezInt(B1, B2):

  1. 矩形框(bbox) B1 和 B2 相交吗?
    • 否:返回 false。
    • 是:继续。
  2. 矩形框(bbox) B1 和 B2 的面积之和是否小于阈值(threshold)?
    • 是:返回 true。
    • 否:继续。
  3. 在 t = 0.5 处将 B1 分割成 B1a 和 B1b
  4. 在 t = 0.5 处将 B2 分割成 B2a 和 B2b
  5. 返回 bezInt(B1a, B2a) || bezInt(B1a, B2b) || bezInt(B1b, B2a) || bezInt(B1b, B2b)。

如果曲线不相交,这将非常快 - 这是通常情况吗?

[编辑] 看起来将 Bezier 曲线分成两个的算法称为 de Casteljau's 算法


我一直在思考使用这个解决方案。它叫做贝塞尔裁剪。 - zneak
2
@zneak:实际上,根据本帖中多次提到的论文(http://cagd.cs.byu.edu/~557/text/ch7.pdf),这是*Bézier细分*方法。 - Ad N

9
如果您正在为生产代码进行此操作,我建议使用Bezier剪切算法。该算法在此免费在线CAGD文本的第7.7节(pdf)中有很好的解释,适用于任何阶数的Bezier曲线,并且速度快且稳健。
虽然从数学角度来看,使用标准根查找器或矩阵可能更加简单明了,但是Bezier剪切相对容易实现和调试,并且实际上具有更少的浮点误差。这是因为每当它创建新数字时,它都会进行加权平均(凸组合),因此不会基于嘈杂的输入进行外推。

是的,Bézier剪裁是目前被接受的答案。但很棒的是,你有论据表明它是更好的解决方案。 - zneak
我看到了,使用粗线来剪切而不是边界框会使收敛速度更快,并且工作量也不会太大。 - tfinniga

3
这是我的问题还是他们的问题?
你是基于这个答案中附带的第4条评论来解释行列式吗?如果是,我相信错误就在那里。这里再次重现评论:
如果行列式为零,则X和Y中恰好有一个根在*相同的t值处,因此两条曲线相交。(t可能不在0..1的区间内)。
我没有看到这部分有任何问题,但作者继续说:
如果行列式不等于零,您可以确定两条曲线在任何地方都不会相交。
我认为这是不正确的。两条曲线很可能相交,即使矩阵具有非零行列式,在t值不同的位置也会存在交点。我相信这就是你的情况。

2
这是一个困难的问题。我建议将每个贝塞尔曲线分成5-10个离散的线段,然后进行线段交点计算。 enter image description here
foreach SampledLineSegment line1 in Bezier1
    foreach SampledLineSegment line2 in Bezier2
        if( line1 intersects line2 )
            then Bezier1 intersects Bezier2

这可能是一个合理的近似,但我更喜欢贝塞尔裁剪方法,因为它提供了可测量的准确性,并且速度仍然相当快。 - zneak
3
同意。起初我误读了“miserable”的准确性,所以我有些担心。 - bobobobo
如果两条曲线有一个单点与它们相切,那么你可能会错过一个交点。由于直线段会稍微靠近平均值,所以曲线可能会相互接触,而直线段近似则不会。 - Tatarize
还有一种可能性是,你可能会遇到两条基本平行的贝塞尔曲线交叉错误的情况。但是好的一面是,这很容易计算,你可以将这两个相交的线段分成另外五个线段或其他数量,以更好地了解它们的交点。或者只是粗略地近似最小误差,你会更多或少地接受它。 - Tatarize
而且,考虑到边界框快速计算方法可以用于相交线的快速纯比较失败,您可能更能够在一瞬间基本解决这个近似问题。因为将曲线排序为单调段并找到实际需要检查的一两条线段,可以基本上被诱导为基本恒定时间。 - Tatarize

2

我不知道它有多快,但如果你有两个曲线C1(t)和C2(k),当C1(t) == C2(k)时它们相交。因此,你有两个方程(每个x和每个y)对应两个变量(t,k)。您可以使用足够精度的数值方法解决该系统。当您找到t、k参数后,应检查是否存在[0,1]上的参数。如果是,则它们在[0,1]上相交。


我对数学不够好,不知道如何解决参数方程。能否给出我的问题中的两条曲线的示例? - zneak
我不知道具体该怎么做(使用哪种方法)。我只知道有这样的方法。也许你可以找到C++数值方法库。 - Andrew

1

我并不是这方面的专家,但我关注一个很好的博客,里面讲了很多关于曲线的内容。他提供了两篇很好的文章,讨论了你的问题(第二个链接有一个交互式演示和一些源代码)。其他人可能对这个问题有更好的见解,但我希望这可以帮到你!

http://cagd.cs.byu.edu/~557/text/ch7.pdf (存档副本)


谢谢提供链接。不过第二个链接是关于二次曲线的,我认为这种曲线要容易解决一个数量级,因为你只需要减去它们的方程并使用二次公式即可。不过我会阅读你提供的第一个链接。 - zneak
啊抱歉关于第二个链接,希望第一个有所帮助。 - GWW

0
我认为最简单且可能最快的答案是将它们细分成非常小的线,并找到曲线相交的点,如果它们确实相交。
public static void towardsCubic(double[] xy, double x0, double y0, double x1, double y1, double x2, double y2, double x3, double y3, double t) {
    double x, y;
    x = (1 - t) * (1 - t) * (1 - t) * x0 + 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t * x3;
    y = (1 - t) * (1 - t) * (1 - t) * y0 + 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t * y3;
    xy[0] = x;
    xy[1] = y;
}

public static void towardsQuad(double[] xy, double x0, double y0, double x1, double y1, double x2, double y2, double t) {
    double x, y;
    x = (1 - t) * (1 - t) * x0 + 2 * (1 - t) * t * x1 + t * t * x2;
    y = (1 - t) * (1 - t) * y0 + 2 * (1 - t) * t * y1 + t * t * y2;
    xy[0] = x;
    xy[1] = y;
}

显然,暴力解决方案是不好的。请参考bo{4}的答案,还有很多线性几何和碰撞检测技术可以帮助你。


选择您想要的曲线切片数量。例如100个应该很好。

我们将所有线段按照它们具有的最大y值进行排序。然后,我们在列表中添加第二个标志,表示该线段的最小y值。

我们保留一个活动边缘列表。

我们遍历按y排序的线段列表,当我们遇到一个前导线段时,我们将其添加到活动列表中。当我们遇到带有小y标记的值时,我们将该线段从活动列表中删除。

然后,我们可以通过类似于扫描线的方式简单地迭代整个线段集,通过简单地迭代列表来单调地增加y。我们遍历已排序的列表中的值,这通常会删除一个线段并添加一个新线段(或对于拆分和合并节点,添加两个线段或删除两个线段)。从而保持相关线段的活动列表。

我们在将新的活动线段添加到活动线段列表中时运行快速失败的相交检查,仅针对该线段和当前活动线段。

因此,在我们遍历曲线的采样段时,我们始终知道哪些线段是相关的。我们知道这些段在y坐标上共享重叠部分。我们可以快速失败任何不重叠于其x坐标的新段。在它们在x坐标上重叠的罕见情况下,我们再检查这些段是否相交。

这可能会将线段交点检查的数量基本减少到交点的数量。

foreach(segment in sortedSegmentList) {
    if (segment.isLeading()) {
        checkAgainstActives(segment);
        actives.add(segment);
    }
    else actives.remove(segment)
}

checkAgainstActive() 函数会检查该线段和活动列表中的任何线段是否重叠其 x 坐标,如果是,则对它们运行线段相交检查,并采取适当的操作。


还要注意的是,这个算法适用于任意数量、任意类型、任意顺序和任意混合的曲线。如果我们遍历整个分段列表,它将找到每一个交点。它可以找到贝塞尔曲线与圆相交的每一个点,或者找到十几条二次贝塞尔曲线彼此(或本身)的所有交点,而且全部在同一瞬间完成。

经常引用的第7章文档指出,在子细分算法中:

“一旦一对曲线被细分到足够程度,以至于它们可以被各自接近一条线段,误差在一个公差范围内”

我们实际上可以直接跳过中间步骤。我们可以快速地比较线段和曲线之间可容忍的误差。最终,这就是典型答案所做的。


其次,需要注意的是,这里碰撞检测速度提升的主要原因是基于它们的最高y值进行排序以添加到活动列表中,并基于最低y值从活动列表中移除的线段有序列表,同样可以直接用于Bezier曲线的凸多边形。我们的线段只是一个二阶多边形,但我们可以轻松地做任意数量的点,并按照任何曲线顺序检查所有控制点的边界框。因此,我们将拥有一个活动凸多边形点列表,而不是活动线段列表。我们可以简单地使用De Casteljau算法来分割曲线,从而对其进行采样,作为细分曲线而不是线段。因此,我们将拥有4个或7个等等点,运行相同的例程,非常适合快速失败。

在最大y处添加相关点组,在最小y处删除它,并仅比较活动列表。因此,我们可以快速且更好地实现Bezier细分算法。只需找到边界框重叠,然后细分那些重叠的曲线并删除未重叠的曲线即可。同样,我们可以处理任意数量的曲线,甚至是从上一次迭代中细分出来的曲线。就像我们的段近似方法可以快速解决数百个不同曲线(甚至是不同阶数)之间的每个交点位置一样。只需检查所有曲线以查看边界框是否重叠,如果是,则细分这些曲线,直到我们的曲线足够小或者我们用完了它们。


0

是的,我知道,这个帖子已经被接受并关闭了很长时间,但是...

首先,我想感谢你,zneak,给了我灵感。你的努力让我找到了正确的方法。

其次,我对已接受的解决方案并不完全满意。这种方法在我最喜欢的免费软件IPE中使用,它的bugzilla上有很多关于交点问题低精度和可靠性的投诉,我也是其中之一。

解决问题的缺失技巧是由zneak提出的方法:只需将其中一条曲线缩短一个因子k>0,那么Sylvester矩阵的行列式就会等于零。显然,如果缩短的曲线相交,则原始曲线也会相交。现在,任务变成了寻找适当的k因子值。这导致了解决9次单变量多项式的问题。该多项式的实数正根负责潜在的交点。(这不应该让人惊讶,两个三次贝塞尔曲线最多可以相交9次。)最终选择是为了仅找到那些提供0<=t<=1的k因子。

现在是Maxima代码,其中起始点是由zneak提供的8个点:

p0x:1; p0y:1;
p1x:2; p1y:4;
p2x:3; p2y:4;
p3x:4; p3y:3;

q0x:3; q0y:5;
q1x:3; q1y:6;
q2x:0; q2y:1;
q3x:3; q3y:1;

c0x:p0x;
c0y:p0y;
c1x:3*(p1x-p0x);
c1y:3*(p1y-p0y);
c2x:3*(p2x+p0x)-6*p1x;
c2y:3*(p2y+p0y)-6*p1y;
c3x:3*(p1x-p2x)+p3x-p0x;
c3y:3*(p1y-p2y)+p3y-p0y;

d0x:q0x;
d0y:q0y;
d1x:3*(q1x-q0x);
d1y:3*(q1y-q0y);
d2x:3*(q2x+q0x)-6*q1x;
d2y:3*(q2y+q0y)-6*q1y;
d3x:3*(q1x-q2x)+q3x-q0x;
d3y:3*(q1y-q2y)+q3y-q0y;

x:c0x-d0x + (c1x-d1x*k)*t+ (c2x-d2x*k^2)*t^2+ (c3x-d3x*k^3)*t^3;
y:c0y-d0y + (c1y-d1y*k)*t+ (c2y-d2y*k^2)*t^2+ (c3y-d3y*k^3)*t^3;

z:resultant(x,y,t);

此时,Maxima 回答:

(%o35)−2*(1004*k^95049*k^8+5940*k^71689*k^6+10584*k^58235*k^42307*k^3+1026*k^2+108*k+76)

让Maxima解决这个方程:

rr: float( realroots(z,1e-20))  

答案是:

(%o36) [k=−0.40256438624399,k=0.43261490325108,k=0.84718739982868,k=2.643321910825066,k=2.71772491293651]

现在选择正确的k值的代码如下:
for item in rr do ( 
  evk:ev(k,item),
  if evk>0 then (  
    /*print("k=",evk),*/ 
    xx:ev(x,item),  rx:float( realroots(xx,1e-20)),/*print("x(t)=",xx,"   roots: ",rx),*/
    yy:ev(y,item),  ry:float( realroots(yy,1e-20)),/*print("y(t)=",yy,"   roots: ",ry),*/
    for it1 in rx do (  t1:ev(t,it1),
    for it2 in ry do (  t2:ev(t,it2),
       dt:abs(t1-t2),
       if dt<1e-10 then (
         /*print("Common root=",t1,"  delta t=",dt),*/
         if (t1>0) and (t1<=1) then ( t2:t1*evk,
         if (t2>0) and (t2<=1) then (                           
                 x1:c0x + c1x*t1+ c2x*t1^2+ c3x*t1^3,
                 y1:c0y + c1y*t1+ c2y*t1^2+ c3y*t1^3,
                 print("Intersection point:     x=",x1, "      y=",y1)
)))))/*,disp ("-----")*/
));

Maxima的答案:

"Intersection point:     x="1.693201254437358"      y="2.62375005067273
(%o37) done

不仅有甜蜜,还有另外一些需要注意的事项。如果以下情况发生,所提出的方法将无法使用:

  • P0=Q0,或者更普遍地说,如果P0位于第二条曲线(或其延伸)上。可以尝试交换曲线。
  • 极为罕见的情况是,两条曲线都属于同一K族(例如,它们的无限延伸是相同的)。
  • 要注意(sqr(c3x)+sqr(c3y))=0或(sqr(d3x)+sqr(3y))=0这种情况,这里的二次方程假装是立方Bezier曲线。

有人会问,为什么只进行一次缩短。由于反转-反向定律的发现,这已经足够了,但这是另一个故事。


曲线属于同一K族的罕见情况在2D绘图软件中并不意外。用户可能会创建一个贝塞尔曲线,然后将其分成几个部分。 - Malcolm McLean

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