基于FFT的快速模板匹配:从数学证明到高效实现

模板匹配是计算机视觉中最基础的任务之一,广泛应用于目标检测、图像配准、运动跟踪等领域。OpenCV提供了六种模板匹配方法,但当图像和模板尺寸较大时,直接计算会非常耗时。本文将详细介绍如何利用卷积定理,将模板匹配转化为FFT卷积形式,实现百倍以上的加速。

卷积定理及其证明

卷积定理是信号处理领域最重要的定理之一,它建立了时域(空间域)与频域之间的桥梁。在证明卷积定理之前,我们先回顾傅里叶变换的定义。

傅里叶变换的定义

对于连续函数 f(t),其傅里叶变换定义为:

F(\omega) = \mathcal{F}\{f\}(\omega) = \int_{-\infty}^{\infty} f(t) \, e^{-i\omega t} \, dt

逆傅里叶变换定义为:

f(t) = \mathcal{F}^{-1}\{F\}(t) = \frac{1}{2\pi} \int_{-\infty}^{\infty} F(\omega) \, e^{i\omega t} \, d\omega

对于离散信号,傅里叶变换和逆变换分别为:

F[k] = \sum_{n=0}^{N-1} f[n] \, e^{-i\frac{2\pi}{N}kn}
f[n] = \frac{1}{N} \sum_{k=0}^{N-1} F[k] \, e^{i\frac{2\pi}{N}kn}

卷积的定义

对于两个函数 fg,它们的卷积定义为:

(f * g)(t) = \int_{-\infty}^{\infty} f(\tau) \cdot g(t - \tau) \, d\tau

卷积运算的物理意义是:将函数 g 翻转并平移 t 后,与函数 f 相乘再积分。在信号处理中,卷积描述了一个信号通过线性时不变系统后的输出。

卷积定理的证明

定理:设 fg 是两个函数,F = \mathcal{F}\{f\}G = \mathcal{F}\{g\} 分别是它们的傅里叶变换,则:

\mathcal{F}\{f * g\} = F \cdot G

或者等价地:

f * g = \mathcal{F}^{-1}\{F \cdot G\}

证明

我们从卷积的傅里叶变换出发进行推导。设 h(t) = (f * g)(t),则 h(t) 的傅里叶变换为:

\begin{aligned} H(\omega) &= \mathcal{F}\{f * g\}(\omega) \\ &= \int_{-\infty}^{\infty} (f * g)(t) \, e^{-i\omega t} \, dt \\ &= \int_{-\infty}^{\infty} \left[ \int_{-\infty}^{\infty} f(\tau) \, g(t - \tau) \, d\tau \right] e^{-i\omega t} \, dt \end{aligned}

交换积分顺序(假设函数满足适当的条件使得积分可以交换):

H(\omega) = \int_{-\infty}^{\infty} f(\tau) \left[ \int_{-\infty}^{\infty} g(t - \tau) \, e^{-i\omega t} \, dt \right] d\tau

对于内层积分,我们做变量替换。设 u = t - \tau,则 t = u + \taudt = du。当 t \to -\inftyu \to -\infty,当 t \to \inftyu \to \infty。代入得:

\begin{aligned} \int_{-\infty}^{\infty} g(t - \tau) \, e^{-i\omega t} \, dt &= \int_{-\infty}^{\infty} g(u) \, e^{-i\omega (u + \tau)} \, du \\ &= e^{-i\omega \tau} \int_{-\infty}^{\infty} g(u) \, e^{-i\omega u} \, du \\ &= e^{-i\omega \tau} \cdot G(\omega) \end{aligned}

这里我们用到了傅里叶变换的定义 G(\omega) = \int_{-\infty}^{\infty} g(u) \, e^{-i\omega u} \, du

将结果代回 H(\omega) 的表达式:

\begin{aligned} H(\omega) &= \int_{-\infty}^{\infty} f(\tau) \cdot e^{-i\omega \tau} \cdot G(\omega) \, d\tau \\ &= G(\omega) \int_{-\infty}^{\infty} f(\tau) \, e^{-i\omega \tau} \, d\tau \\ &= G(\omega) \cdot F(\omega) \\ &= F(\omega) \cdot G(\omega) \end{aligned}

这就证明了:

\mathcal{F}\{f * g\} = F \cdot G

对两边取逆傅里叶变换,得到:

f * g = \mathcal{F}^{-1}\{F \cdot G\}

证毕。

互相关与卷积的关系

在图像处理中,我们更常用的是互相关运算。互相关与卷积的区别在于不需要翻转核函数。对于二维图像 I 和模板 T,互相关定义为:

(I \star T)(x, y) = \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n) \cdot T(m, n)

互相关与卷积的关系可以通过以下推导得到。设 \hat{T}T 的翻转版本:

\hat{T}(m, n) = T(h-1-m, w-1-n)

则互相关可以表示为:

\begin{aligned} (I \star T)(x, y) &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n) \cdot T(m, n) \\ &= \sum_{m'=0}^{h-1} \sum_{n'=0}^{w-1} I(x+h-1-m', y+w-1-n') \cdot T(h-1-m', w-1-n') \\ &= \sum_{m'=0}^{h-1} \sum_{n'=0}^{w-1} I(x+h-1-m', y+w-1-n') \cdot \hat{T}(m', n') \end{aligned}

其中我们做了变量替换 m' = h-1-mn' = w-1-n。这正是卷积的形式,因此:

I \star T = I * \hat{T}

互相关的FFT实现

根据卷积定理,卷积可以在频域中计算:

I * \hat{T} = \mathcal{F}^{-1}\{\mathcal{F}\{I\} \cdot \mathcal{F}\{\hat{T}\}\}

现在需要证明时域翻转对应频域共轭。设 g(t) 的傅里叶变换为 G(\omega),则翻转后的函数 \hat{g}(t) = g(-t) 的傅里叶变换为:

\begin{aligned} \mathcal{F}\{\hat{g}\}(\omega) &= \int_{-\infty}^{\infty} g(-t) \, e^{-i\omega t} \, dt \\ &= \int_{-\infty}^{\infty} g(u) \, e^{-i\omega (-u)} \, du \quad (\text{令 } u = -t) \\ &= \int_{-\infty}^{\infty} g(u) \, e^{i\omega u} \, du \\ &= \overline{\int_{-\infty}^{\infty} g(u) \, e^{-i\omega u} \, du} \\ &= \overline{G(\omega)} \end{aligned}

因此,时域翻转对应频域共轭:

\mathcal{F}\{\hat{T}\} = \overline{\mathcal{F}\{T\}}

最终,互相关的FFT计算公式为:

I \star T = \mathcal{F}^{-1}\{\mathcal{F}\{I\} \cdot \overline{\mathcal{F}\{T\}}\}

实际计算时,需要先将图像和模板零填充到相同尺寸,以避免循环卷积的边界效应。设 \tilde{I}\tilde{T} 分别是图像和模板零填充后的结果,填充尺寸为 (H + h - 1) \times (W + w - 1),则:

I \star T = \mathcal{F}^{-1}\{\mathcal{F}\{\tilde{I}\} \cdot \overline{\mathcal{F}\{\tilde{T}\}}\}

这个定理的威力在于:直接计算卷积的时间复杂度为 O(N^2),而FFT的时间复杂度仅为 O(N \log N)

模板匹配的六种方法

OpenCV提供了六种模板匹配方法,每种方法适用于不同的场景。设图像为 I,模板为 T,模板尺寸为 h \times w,像素总数 N = h \cdot w。在位置 (x, y) 处,图像块记为 I_{x,y},其均值为 \bar{I}_{x,y},模板均值为 \bar{T}

TM_CCORR(互相关) 直接计算图像块与模板的内积:

R_{CCORR}(x, y) = \sum_{m,n} I(x+m, y+n) \cdot T(m, n)

TM_CCORR_NORMED(归一化互相关) 通过归一化消除光照强度的影响:

R_{CCORR\_NORMED}(x, y) = \frac{\sum_{m,n} I(x+m, y+n) \cdot T(m, n)}{\sqrt{\sum_{m,n} I(x+m, y+n)^2 \cdot \sum_{m,n} T(m, n)^2}}

TM_SQDIFF(平方差) 计算图像块与模板的欧氏距离平方,值越小表示越匹配:

R_{SQDIFF}(x, y) = \sum_{m,n} [I(x+m, y+n) - T(m, n)]^2

TM_SQDIFF_NORMED(归一化平方差) 是平方差的归一化版本:

R_{SQDIFF\_NORMED}(x, y) = \frac{\sum_{m,n} [I(x+m, y+n) - T(m, n)]^2}{\sqrt{\sum_{m,n} I(x+m, y+n)^2 \cdot \sum_{m,n} T(m, n)^2}}

TM_CCOEFF(相关系数) 去除直流分量后计算相关,对光照变化更鲁棒:

R_{CCOEFF}(x, y) = \sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}] \cdot [T(m, n) - \bar{T}]

TM_CCOEFF_NORMED(归一化相关系数) 是最常用的方法,结果在 [-1, 1] 范围内,1表示完全匹配:

R_{CCOEFF\_NORMED}(x, y) = \frac{\sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}] \cdot [T(m, n) - \bar{T}]}{\sqrt{\sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}]^2 \cdot \sum_{m,n} [T(m, n) - \bar{T}]^2}}

从时域到频域:完整的公式推导

直接计算模板匹配需要对图像中每个位置都遍历整个模板,时间复杂度为 O(H \cdot W \cdot h \cdot w)。利用FFT,我们可以将所有位置的计算转化为一次频域运算,复杂度降为 O(H \cdot W \cdot \log(H \cdot W))。下面详细推导每种方法的FFT实现。

TM_CCORR:互相关的FFT实现

TM_CCORR直接计算图像与模板的互相关。根据互相关的定义:

R_{CCORR}(x, y) = \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n) \cdot T(m, n)

在前面的卷积定理证明中,我们已经推导出互相关的FFT计算公式:

I \star T = \mathcal{F}^{-1}\{\mathcal{F}\{I\} \cdot \overline{\mathcal{F}\{T\}}\}

现在需要说明实际计算时的具体步骤。首先,为了避免循环卷积的边界效应,需要将图像和模板零填充到相同尺寸。填充尺寸必须满足:

H_f \geq H + h - 1, \quad W_f \geq W + w - 1

通常取 H_f = H + h - 1W_f = W + w - 1。设填充后的图像和模板分别为 \tilde{I}\tilde{T},则:

C_{full} = \mathcal{F}^{-1}\{\mathcal{F}\{\tilde{I}\} \cdot \overline{\mathcal{F}\{\tilde{T}\}}\}

结果 C_{full} 的尺寸为 (H + h - 1) \times (W + w - 1),这是"full"模式的互相关,包含了模板部分超出图像边界的情况。对于模板匹配,我们需要的是"valid"模式,即模板完全在图像内部的区域。valid模式的结果尺寸为 (H - h + 1) \times (W - w + 1),对应于 C_{full} 的左上角区域:

R_{CCORR} = C_{full}[0:H-h+1, 0:W-w+1]

TM_CCORR_NORMED:归一化互相关的FFT实现

归一化互相关的公式为:

R_{CCORR\_NORMED}(x, y) = \frac{\sum_{m,n} I(x+m, y+n) \cdot T(m, n)}{\sqrt{\sum_{m,n} I(x+m, y+n)^2 \cdot \sum_{m,n} T(m, n)^2}}

分子是互相关 C(x, y),已经可以用FFT计算。分母包含两部分:图像块的能量 \sum_{m,n} I(x+m, y+n)^2 和模板的能量 \sum_{m,n} T(m, n)^2

模板的能量是常数,可以预先计算:

S_T = \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} T(m, n)^2

图像块的能量则随位置变化。定义图像平方的窗口和:

S_2(x, y) = \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n)^2

这个式子的含义是:对于每个位置 (x, y),计算以该位置为起点的 h \times w 图像块中所有像素的平方和。

关键观察是:S_2(x, y) 本质上也是一种互相关,只是把模板换成了全1矩阵。设 \mathbf{1} 是一个 h \times w 的全1矩阵,则:

\begin{aligned} (I^2 \star \mathbf{1})(x, y) &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n)^2 \cdot \mathbf{1}(m, n) \\ &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n)^2 \cdot 1 \\ &= S_2(x, y) \end{aligned}

因此,S_2 可以用FFT计算:

S_2 = \mathcal{F}^{-1}\{\mathcal{F}\{\tilde{I}^2\} \cdot \overline{\mathcal{F}\{\tilde{\mathbf{1}}\}}\}[0:H-h+1, 0:W-w+1]

其中 \tilde{I}^2 是图像平方后零填充的结果,\tilde{\mathbf{1}} 是全1矩阵零填充的结果。

最终,归一化互相关的FFT实现为:

R_{CCORR\_NORMED}(x, y) = \frac{C(x, y)}{\sqrt{S_2(x, y) \cdot S_T}}

TM_SQDIFF:平方差的FFT实现

平方差的公式为:

R_{SQDIFF}(x, y) = \sum_{m,n} [I(x+m, y+n) - T(m, n)]^2

直接看这个公式,似乎与互相关没有关系。但如果我们展开平方项,就会发现其中的联系:

\begin{aligned} R_{SQDIFF}(x, y) &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} [I(x+m, y+n) - T(m, n)]^2 \\ &= \sum_{m,n} [I(x+m, y+n)^2 - 2I(x+m, y+n) \cdot T(m, n) + T(m, n)^2] \\ &= \sum_{m,n} I(x+m, y+n)^2 - 2\sum_{m,n} I(x+m, y+n) \cdot T(m, n) + \sum_{m,n} T(m, n)^2 \end{aligned}

现在我们逐项分析:

第一项 \sum_{m,n} I(x+m, y+n)^2 正是前面定义的 S_2(x, y),即图像平方的窗口和。

第二项 \sum_{m,n} I(x+m, y+n) \cdot T(m, n) 正是互相关 C(x, y)

第三项 \sum_{m,n} T(m, n)^2 是模板能量 S_T,是常数。

因此,平方差可以表示为:

R_{SQDIFF}(x, y) = S_2(x, y) - 2C(x, y) + S_T

或者写成更简洁的形式:

R_{SQDIFF} = S_2 + S_T - 2C

三项都可以用FFT高效计算:S_2 是图像平方与全1矩阵的互相关,C 是图像与模板的互相关,S_T 是预先计算的常数。

TM_SQDIFF_NORMED:归一化平方差的FFT实现

归一化平方差的公式为:

R_{SQDIFF\_NORMED}(x, y) = \frac{\sum_{m,n} [I(x+m, y+n) - T(m, n)]^2}{\sqrt{\sum_{m,n} I(x+m, y+n)^2 \cdot \sum_{m,n} T(m, n)^2}}

分子已经推导为 S_2(x, y) + S_T - 2C(x, y)。分母与TM_CCORR_NORMED的分母完全相同,即 \sqrt{S_2(x, y) \cdot S_T}

因此:

R_{SQDIFF\_NORMED}(x, y) = \frac{S_2(x, y) + S_T - 2C(x, y)}{\sqrt{S_2(x, y) \cdot S_T}}

TM_CCOEFF:相关系数的FFT实现

相关系数方法的核心思想是去除均值后计算相关,这样可以消除光照强度的影响。公式为:

R_{CCOEFF}(x, y) = \sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}] \cdot [T(m, n) - \bar{T}]

其中 \bar{I}_{x,y} 是图像块在位置 (x, y) 处的均值,\bar{T} 是模板的均值:

\bar{I}_{x,y} = \frac{1}{N} \sum_{m,n} I(x+m, y+n), \quad \bar{T} = \frac{1}{N} \sum_{m,n} T(m, n)

为了将这个公式转化为可以用FFT计算的形式,我们需要展开它。首先展开乘积:

\begin{aligned} R_{CCOEFF}(x, y) &= \sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}] \cdot [T(m, n) - \bar{T}] \\ &= \sum_{m,n} [I(x+m, y+n) \cdot T(m, n) - I(x+m, y+n) \cdot \bar{T} - \bar{I}_{x,y} \cdot T(m, n) + \bar{I}_{x,y} \cdot \bar{T}] \end{aligned}

将求和分配到每一项:

R_{CCOEFF}(x, y) = \sum_{m,n} I(x+m, y+n) \cdot T(m, n) - \bar{T}\sum_{m,n} I(x+m, y+n) - \bar{I}_{x,y}\sum_{m,n} T(m, n) + \bar{I}_{x,y} \cdot \bar{T} \cdot N

现在逐项分析:

第一项 \sum_{m,n} I(x+m, y+n) \cdot T(m, n) 是互相关 C(x, y)

第二项 \sum_{m,n} I(x+m, y+n) 是图像块的像素和,记为 S_1(x, y)

第三项 \sum_{m,n} T(m, n) 是模板的像素和,记为 S_{T1},是常数。

第四项中,N 是模板像素数,\bar{I}_{x,y} \cdot \bar{T} \cdot N 可以进一步简化。

注意到 \bar{T} = S_{T1}/N\bar{I}_{x,y} = S_1(x, y)/N,代入第三项和第四项:

\begin{aligned} \text{第三项} &= -\bar{I}_{x,y} \cdot S_{T1} = -\frac{S_1(x, y)}{N} \cdot S_{T1} \\ \text{第四项} &= \bar{I}_{x,y} \cdot \bar{T} \cdot N = \frac{S_1(x, y)}{N} \cdot \frac{S_{T1}}{N} \cdot N = \frac{S_1(x, y) \cdot S_{T1}}{N} \end{aligned}

可以看到,第三项和第四项正好相互抵消!因此:

R_{CCOEFF}(x, y) = C(x, y) - \bar{T} \cdot S_1(x, y)

\bar{T} = S_{T1}/N 代入:

R_{CCOEFF}(x, y) = C(x, y) - \frac{S_{T1} \cdot S_1(x, y)}{N}

现在需要证明 S_1(x, y) 也可以用FFT计算。S_1(x, y) 的定义是:

S_1(x, y) = \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n)

这正是图像与全1矩阵的互相关:

\begin{aligned} (I \star \mathbf{1})(x, y) &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n) \cdot \mathbf{1}(m, n) \\ &= \sum_{m=0}^{h-1} \sum_{n=0}^{w-1} I(x+m, y+n) \cdot 1 \\ &= S_1(x, y) \end{aligned}

因此:

S_1 = \mathcal{F}^{-1}\{\mathcal{F}\{\tilde{I}\} \cdot \overline{\mathcal{F}\{\tilde{\mathbf{1}}\}}\}[0:H-h+1, 0:W-w+1]

TM_CCOEFF_NORMED:归一化相关系数的FFT实现

归一化相关系数是最复杂也是最常用的方法。公式为:

R_{CCOEFF\_NORMED}(x, y) = \frac{\sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}] \cdot [T(m, n) - \bar{T}]}{\sqrt{\sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}]^2 \cdot \sum_{m,n} [T(m, n) - \bar{T}]^2}}

分子已经推导完毕,是 R_{CCOEFF}(x, y) = C(x, y) - S_{T1} \cdot S_1(x, y) / N

现在需要推导分母。分母包含图像块和模板的方差。首先推导图像块的方差:

\begin{aligned} \sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}]^2 &= \sum_{m,n} [I(x+m, y+n)^2 - 2I(x+m, y+n) \cdot \bar{I}_{x,y} + \bar{I}_{x,y}^2] \\ &= \sum_{m,n} I(x+m, y+n)^2 - 2\bar{I}_{x,y}\sum_{m,n} I(x+m, y+n) + N \cdot \bar{I}_{x,y}^2 \end{aligned}

\bar{I}_{x,y} = S_1(x, y)/N 代入:

\begin{aligned} \sum_{m,n} [I(x+m, y+n) - \bar{I}_{x,y}]^2 &= S_2(x, y) - 2\frac{S_1(x, y)}{N} \cdot S_1(x, y) + N \cdot \frac{S_1(x, y)^2}{N^2} \\ &= S_2(x, y) - \frac{2S_1(x, y)^2}{N} + \frac{S_1(x, y)^2}{N} \\ &= S_2(x, y) - \frac{S_1(x, y)^2}{N} \end{aligned}

这正是图像块的方差,记为 \text{Var}_I(x, y)

同理,模板的方差为:

\begin{aligned} \sum_{m,n} [T(m, n) - \bar{T}]^2 &= \sum_{m,n} T(m, n)^2 - \frac{(\sum_{m,n} T(m, n))^2}{N} \\ &= S_T - \frac{S_{T1}^2}{N} \end{aligned}

记为 \text{Var}_T,这是一个常数,可以预先计算。

最终,归一化相关系数的FFT实现为:

R_{CCOEFF\_NORMED}(x, y) = \frac{C(x, y) - S_1(x, y) \cdot S_{T1} / N}{\sqrt{\left(S_2(x, y) - \frac{S_1(x, y)^2}{N}\right) \cdot \left(S_T - \frac{S_{T1}^2}{N}\right)}}

统一的计算框架

通过上述推导,我们发现所有六种方法都可以用三个FFT计算出的量来表示。这三个量分别是:

C = I \star T,即图像与模板的互相关,用于计算分子中的相关项。

S_1 = I \star \mathbf{1},即图像与全1矩阵的互相关,用于计算图像块的像素和。

S_2 = I^2 \star \mathbf{1},即图像平方与全1矩阵的互相关,用于计算图像块的能量。

加上三个模板常数:S_T = \sum T^2(模板能量),S_{T1} = \sum T(模板像素和),N = h \cdot w(模板像素数),就可以计算出所有六种匹配结果。

这种统一的框架不仅简化了实现,还保证了计算效率。具体来说,只需要对图像 \tilde{I} 做一次FFT,对图像平方 \tilde{I}^2 做一次FFT,对模板 \tilde{T} 做一次FFT,对全1矩阵 \tilde{\mathbf{1}} 做一次FFT,然后进行三次频域乘积和三次逆FFT,就能得到 CS_1S_2 三个核心量。后续的所有计算都是简单的逐点运算,复杂度仅为 O(H \cdot W)

效果对比

运行代码后,我们可以得到FFT实现与OpenCV的对比结果。下图展示了六种方法的匹配热力图:

matching_results.png

从图中可以看到,我们的FFT实现与OpenCV的结果几乎完全一致,差值图显示误差在可忽略的范围内。右列的差值图颜色接近黑色,说明两种方法的数值差异极小。

最佳匹配位置的可视化结果如下:

best_match_locations.png

可以看到我们使用的FFT加速的结果和OpenCV的模板匹配实现基本一致。

数值精度验证

从运行输出的数值对比可以看出,FFT实现与OpenCV的高度一致:

=== 数值对比(与 cv2.matchTemplate) ===
TM_CCORR: max_err=2.240000e+02, mean_err=3.329635e+01, rel_max=8.109e-07, rel_mean=1.205e-07
TM_CCORR_NORMED: max_err=1.788139e-06, mean_err=1.768073e-07, rel_max=1.788e-06, rel_mean=1.768e-07
TM_SQDIFF: max_err=4.800000e+02, mean_err=6.757257e+01, rel_max=3.034e-06, rel_mean=4.272e-07
TM_SQDIFF_NORMED: max_err=1.142450e+00, mean_err=3.005487e-02, rel_max=1.142e+00, rel_mean=3.005e-02
TM_CCOEFF: max_err=2.491250e+02, mean_err=3.909091e+01, rel_max=2.171e-05, rel_mean=3.407e-06
TM_CCOEFF_NORMED: max_err=2.512969e-04, mean_err=4.180090e-06, rel_max=2.513e-04, rel_mean=4.180e-06

未归一化的误差看起来很大是因为量纲,rel_mean是考虑到量纲后的误差

性能分析

FFT方法的时间复杂度为 O(H \cdot W \cdot \log(H \cdot W)),与模板尺寸无关。对于大图像配合大模板的场景,加速效果尤为显著。

方法复杂度
直接计算O(H \cdot W \cdot h \cdot w)
FFT方法O(H \cdot W \cdot \log(H \cdot W))

图片较大时,加速比可达百倍以上。这使得FFT模板匹配特别适用于遥感图像分析、医学影像处理、实时视频跟踪等需要处理大尺寸图像的场景。

总结

卷积定理为模板匹配提供了一个优雅的加速方案。通过将时域中的滑动窗口计算转化为频域中的逐点乘积,我们实现了从 O(N^2 \cdot M^2)O(N^2 \log N) 的复杂度降低。更重要的是,所有六种OpenCV模板匹配方法都可以统一到同一个FFT计算框架中,只需要三次FFT变换就能得到全部结果。

这种加速方法在保持数值精度的同时,大幅提升了计算效率,使得模板匹配能够应用于更大规模的图像处理任务。完整的实现代码已包含在本文中,读者可以直接运行验证效果。

代码

import numpy as np
import cv2
import matplotlib.pyplot as plt
import argparse
from pathlib import Path

def fft_xcorr2d_full(a, b):
    """
    full 互相关 a ⋆ b,尺寸 (Ha+Hb-1, Wa+Wb-1)
    实现:ifft2( FFT(a_pad) * conj(FFT(b_pad)) )
    """
    Ha, Wa = a.shape
    Hb, Wb = b.shape
    Hf, Wf = Ha + Hb - 1, Wa + Wb - 1
    Fa = np.fft.rfft2(a, s=(Hf, Wf))
    Fb = np.fft.rfft2(b, s=(Hf, Wf))
    Fc = Fa * np.conj(Fb)
    c_full = np.fft.irfft2(Fc, s=(Hf, Wf))
    return c_full

def valid_from_full_xcorr(full, H, W, h, w):
    """
    对于“互相关”的 full 结果,valid(窗口左上角对齐)应取:
    [0:H-h+1, 0:W-w+1]
    """
    return full[0:H - h + 1, 0:W - w + 1]

def compute_fft_stats(I, T):
    """
    用同一个坐标系(互相关 + 顶左对齐 valid)计算:
    - C = I ⋆ T
    - S1 = I 与 Ones 的相关(窗口和)
    - S2 = I^2 与 Ones 的相关(窗口平方和)
    返回 C, S1, S2 以及模板常量 ST, ST2, var_T, N
    """
    H, W = I.shape
    h, w = T.shape
    N = float(h * w)

    # 分子:互相关
    C_full = fft_xcorr2d_full(I, T)
    C = valid_from_full_xcorr(C_full, H, W, h, w)

    # 同样用互相关计算窗口统计量,确保对齐一致
    Ones = np.ones_like(T, dtype=I.dtype)
    S1_full = fft_xcorr2d_full(I, Ones)
    S1 = valid_from_full_xcorr(S1_full, H, W, h, w)

    S2_full = fft_xcorr2d_full(I * I, Ones)
    S2 = valid_from_full_xcorr(S2_full, H, W, h, w)

    ST = float(T.sum())
    ST2 = float((T * T).sum())
    var_T = ST2 - (ST * ST) / N

    return C, S1, S2, ST, ST2, var_T, N

def match_template_fft(image, template, use_float32=True):
    """
    六种方法(与 OpenCV 定义一致),全部基于“频域互相关 + 顶左对齐 valid”
    """
    dtype = np.float32 if use_float32 else np.float64
    I = image.astype(dtype, copy=False)
    T = template.astype(dtype, copy=False)

    C, S1, S2, ST, ST2, var_T, N = compute_fft_stats(I, T)

    # 1) CCORR
    R_CCORR = C

    # 2) CCORR_NORMED
    denom_ccorr = np.sqrt(S2 * ST2).astype(dtype)
    R_CCORR_NORMED = np.divide(
        R_CCORR, denom_ccorr, out=np.zeros_like(R_CCORR), where=denom_ccorr > 0
    )

    # 3) SQDIFF
    R_SQDIFF = S2 + ST2 - 2.0 * C

    # 4) SQDIFF_NORMED
    denom_sqdiff = np.sqrt(S2 * ST2).astype(dtype)
    R_SQDIFF_NORMED = np.divide(
        R_SQDIFF, denom_sqdiff, out=np.zeros_like(R_SQDIFF), where=denom_sqdiff > 0
    )

    # 5) CCOEFF
    R_CCOEFF = C - (S1 * ST) / dtype(N)

    # 6) CCOEFF_NORMED
    var_I = S2 - (S1 * S1) / dtype(N)
    var_I = np.maximum(var_I, 0.0).astype(dtype)
    var_T_safe = max(var_T, 0.0)
    denom_ccoef = np.sqrt(var_I * dtype(var_T_safe)).astype(dtype)
    R_CCOEFF_NORMED = np.divide(
        R_CCOEFF, denom_ccoef, out=np.zeros_like(R_CCOEFF), where=denom_ccoef > 0
    )

    return {
        "TM_CCORR": R_CCORR,
        "TM_CCORR_NORMED": R_CCORR_NORMED,
        "TM_SQDIFF": R_SQDIFF,
        "TM_SQDIFF_NORMED": R_SQDIFF_NORMED,
        "TM_CCOEFF": R_CCOEFF,
        "TM_CCOEFF_NORMED": R_CCOEFF_NORMED
    }

def compare_with_opencv(image, template):
    methods = {
        "TM_CCORR": cv2.TM_CCORR,
        "TM_CCORR_NORMED": cv2.TM_CCORR_NORMED,
        "TM_SQDIFF": cv2.TM_SQDIFF,
        "TM_SQDIFF_NORMED": cv2.TM_SQDIFF_NORMED,
        "TM_CCOEFF": cv2.TM_CCOEFF,
        "TM_CCOEFF_NORMED": cv2.TM_CCOEFF_NORMED,
    }

    ours = match_template_fft(image, template, use_float32=True)

    report = {}
    diffs = {}
    opencv_maps = {}
    for name, m in methods.items():
        opencv_res = cv2.matchTemplate(image.astype(np.float32),
                                       template.astype(np.float32),
                                       m)
        ours_map = ours[name].astype(opencv_res.dtype, copy=False)
        diff = ours_map - opencv_res
        max_err = float(np.max(np.abs(diff)))
        mean_err = float(np.mean(np.abs(diff)))
        scale = float(max(1.0, np.max(np.abs(opencv_res))))
        rel_max = max_err / scale
        rel_mean = mean_err / scale
        report[name] = {
            "opencv_shape": opencv_res.shape,
            "our_shape": ours_map.shape,
            "max_abs_error": max_err,
            "mean_abs_error": mean_err,
            "rel_max_error": rel_max,
            "rel_mean_error": rel_mean
        }
        diffs[name] = diff
        opencv_maps[name] = opencv_res
    return report, ours, opencv_maps, diffs

def draw_results(image, template, ours, opencv_maps, diffs, src_top_left, save_path=None):
    """
    可视化:
    - 每种方法一行:ours / opencv / |diff|
    - 另绘原图+最佳匹配框(用 CCOEFF_NORMED 和 SQDIFF_NORMED)
    """
    methods_order = [
        "TM_CCORR",
        "TM_CCORR_NORMED",
        "TM_SQDIFF",
        "TM_SQDIFF_NORMED",
        "TM_CCOEFF",
        "TM_CCOEFF_NORMED",
    ]

    nrows, ncols = len(methods_order), 3
    fig, axes = plt.subplots(nrows, ncols, figsize=(12, 2.8*nrows), constrained_layout=True)
    if nrows == 1:
        axes = np.array([axes])

    for i, name in enumerate(methods_order):
        ax1, ax2, ax3 = axes[i]

        m1 = ours[name]
        m2 = opencv_maps[name]
        d = np.abs(diffs[name])

        im1 = ax1.imshow(m1, cmap="viridis")
        ax1.set_title(f"{name} (ours)")
        fig.colorbar(im1, ax=ax1, fraction=0.046, pad=0.04)

        im2 = ax2.imshow(m2, cmap="viridis")
        ax2.set_title(f"{name} (opencv)")
        fig.colorbar(im2, ax=ax2, fraction=0.046, pad=0.04)

        im3 = ax3.imshow(d, cmap="magma")
        ax3.set_title(f"|diff| (ours - opencv)")
        fig.colorbar(im3, ax=ax3, fraction=0.046, pad=0.04)

        for ax in (ax1, ax2, ax3):
            ax.set_xticks([])
            ax.set_yticks([])

    if save_path:
        fig.savefig(save_path[0], dpi=150, bbox_inches='tight')
        print(f"Saved: {save_path[0]}")
    else:
        plt.show()
    plt.close(fig)

    # 在原图上画最佳匹配(以 CCOEFF_NORMED 最大值和 SQDIFF_NORMED 最小值为例)
    H, W = image.shape
    h, w = template.shape

    def draw_rect_on_image(ax, top_left, color='lime', label=''):
        r0, c0 = top_left
        ax.add_patch(plt.Rectangle((c0, r0), w, h, fill=False, edgecolor=color, linewidth=2))
        if label:
            ax.text(c0, r0-5, label, color=color, fontsize=10, backgroundcolor='k')

    best_maps = [
        ("TM_CCOEFF_NORMED", True, "max"),  # 最大越好
        ("TM_SQDIFF_NORMED", False, "min"), # 最小越好
    ]

    fig2, ax2 = plt.subplots(1, 2, figsize=(12, 5), constrained_layout=True)
    for k, (name, is_max, tag) in enumerate(best_maps):
        if is_max:
            best_pos_ours = np.unravel_index(int(np.argmax(ours[name])), ours[name].shape)
            best_pos_cv = np.unravel_index(int(np.argmax(opencv_maps[name])), opencv_maps[name].shape)
        else:
            best_pos_ours = np.unravel_index(int(np.argmin(ours[name])), ours[name].shape)
            best_pos_cv = np.unravel_index(int(np.argmin(opencv_maps[name])), opencv_maps[name].shape)

        ax = ax2[k]
        ax.imshow(image, cmap='gray')
        draw_rect_on_image(ax, (int(best_pos_ours[0]), int(best_pos_ours[1])), color='lime', label=f"ours {tag}")
        draw_rect_on_image(ax, (int(best_pos_cv[0]), int(best_pos_cv[1])), color='cyan', label=f"opencv {tag}")
        draw_rect_on_image(ax, (int(src_top_left[0]), int(src_top_left[1])), color='yellow', label="template src")
        ax.set_title(f"Best by {name}")
        ax.set_xticks([]); ax.set_yticks([])

    if save_path and len(save_path) > 1:
        fig2.savefig(save_path[1], dpi=150, bbox_inches='tight')
        print(f"Saved: {save_path[1]}")
    else:
        plt.show()
    plt.close(fig2)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--image", type=str, default="test.png", help="输入灰度图路径,默认 test.png")
    parser.add_argument("--x", type=int, default=None, help="模板左上角 x(行)")
    parser.add_argument("--y", type=int, default=None, help="模板左上角 y(列)")
    parser.add_argument("--h", type=int, default=None, help="模板高度")
    parser.add_argument("--w", type=int, default=None, help="模板宽度")
    args = parser.parse_args()

    img_path = Path(args.image)
    if not img_path.exists():
        raise FileNotFoundError(f"找不到图像文件: {img_path}")

    # 读灰度
    image = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
    if image is None:
        raise RuntimeError("读取图像失败,请确认路径与文件格式。")
    image = image.astype(np.float32)

    H, W = image.shape

    # 默认模板:图像中部区域
    h = args.h if args.h is not None else max(16, H // 6)
    w = args.w if args.w is not None else max(16, W // 6)
    x0 = args.x if args.x is not None else max(0, (H // 3))
    y0 = args.y if args.y is not None else max(0, (W // 3))

    # 防越界
    x0 = min(max(0, x0), max(0, H - h))
    y0 = min(max(0, y0), max(0, W - w))

    template = image[x0:x0+h, y0:y0+w].copy()

    # 计算与对比
    report, ours, opencv_maps, diffs = compare_with_opencv(image, template)

    print("=== 数值对比(与 cv2.matchTemplate) ===")
    for k, v in report.items():
        print(f"{k}: max_err={v['max_abs_error']:.6e}, mean_err={v['mean_abs_error']:.6e}, "
              f"rel_max={v['rel_max_error']:.3e}, rel_mean={v['rel_mean_error']:.3e}")

    # 归一化方法应在合理范围(CCOEFF_NORMED、CCORR_NORMED ∈ [-1,1];SQDIFF_NORMED ∈ [0,2])
    for name in ["TM_CCORR_NORMED", "TM_SQDIFF_NORMED", "TM_CCOEFF_NORMED"]:
        m = ours[name]
        print(f"{name} range: min={float(m.min()):.6f}, max={float(m.max()):.6f}")

    # 可视化
    draw_results(image, template, ours, opencv_maps, diffs, src_top_left=(x0, y0),
                 save_path=["matching_results.png", "best_match_locations.png"])

if __name__ == "__main__":
    main()