离散傅里叶变换 - 快速计算方法及C实现 - 第一篇

DFT – Fast algorithms and C implementations - Part1


引言

算法中经常用到傅里叶变换,很长一段时间我都是使用FFTW("the fastest fft library in the west", 一个基于fortran语言的fft算法库,该库也为Matlab以及intel MKL两个计算软件提供傅里叶算法,非常牛叉。但是最近我多次发现因为在项目中使用FFTW导致程序莫名其妙的运行错误,花了我很长时间才将错误定位出来,在于fftw_plan的创建过程存在一些隐藏的玄妙。在调试代码以及研究算法的过程中,我一般只需要单线程,或者单实例,但是在工程部署中基本上都需要多线程和多实例同时运行,如果一个程序中包含多个并行的检测器/跟踪器,这些子程序又都需要独立调用fftw,问题就发生了。网上有说法是fftw_plan的创建不能在多线程中同时进行,不过我的问题似乎有点难以理解,比如同样的程序在windows下一般都没问题,在Ubuntu下有可能会出问题,在TX2上总是会出问题,搞得我很崩溃。

自己写FFT实现的想法由来已久,原因有三:(1)对fftw内部的报错有些难以理解,大大增加我的算法的工程部署难度;(2)想要算法完全可控:fftw内部好像会自动调用多线程,或者需要在编译的时候需要增加多线程开关选项,但是如果我想在一个程序中某些fft过程中调用多线程、某些fft过程不调用多线程,就难以实现,因为fftw没有为各个fft函数增加线程开关;(3)程序源代码完全封闭:经过我之前的努力,我已基本将opencv的常见功能用自己编写的C代码替代了,效率相比oepncv有一定的提升,目前唯一的遗憾是fft这一块还要依赖fftw库,虽然fftw也是跨平台的,但是windows下使用只能通过官网下载dll和lib,Ubuntu下也是需要安装一个deb包,几乎无法通过下载源码编译,而且源码是fortran的,不编译成库没法在C中调用。

自己写fft难度很大。如果只是实现fft的功能,那实在简单,按照公式直接两个for循环就搞定了,但是fft的计算其实有太多的技巧在里面,这些技巧并不是信手拈来的,而是通过复杂的数学推导得出来的复杂的计算公式。最近查了一些资料,Winograd的经典论文"On Computing the Discrete Fourier Transform"(1978),Springer出版的图书"Fast Fourier Transform: Algorithms And Applications"(2010),国内出版的图书"快速傅里叶变换及其C程序"(2004)。粗略扫了扫,都没看懂(cry...),另外还找了些网络教材,比如傅里叶变换的矩阵分析、FFT快速傅里叶变换(蝶形算法)详解、FFT快速傅里叶变换的工作原理。不得不感慨一句:真TM难!去年还是前年,商汤来我们学校做了一个专场报告,现场我找他们的技术总监问了一下他们有没有傅里叶算法,回复说他们之前也尝试写fft,用了几个月,发现还是不如fftw快,然后就放弃了,最后是用了买的MKL库。我以前重写过开源C库kissfft,写得很痛苦,因为当时并不懂它的计算原理,只能完全按照它的步骤来,虽然我重写的版本比原版快了不少(内存优化、使用SSE),但是不得不承认,跟fftw/matlab(据说也是调用的fftw)/MKL(应该是目前CPU上最快的fft算法了,可以看作是针对英特尔CPU优化过的fftw)相比,差了不止一个量级。然后我把这个我自己写的fft算法用在了LayeredKCF程序里面,这是我第一个无任何依赖库的的完整C++程序,做目标跟踪的。注:GPU方法不在讨论范围。

最近因为一度在工程部署中被fftw耽误了很多时间,后来临时通过使用opencv替换了相关功能(效率低且没有我想要的接口),促使我决定再写一个fft算法,即使慢一点,我也要想程序完全可控。


基础知识

1. DFT的定义

N长序列\lbrace x_j \rbrace _{j=0}^{N-1}的DFT为\lbrace X_k \rbrace_{k=0}^{N-1},其中:

X_k = \sum_{j=0}^{N-1} {x_j W_N^{jk}}

W_N=e^{-\frac{2\pi}{N}i}

W_N^{jk}\triangleq \left( W_N \right )^{jk}=e^{-\frac{2\pi jk}{N}i}

上面的i是虚数单位:i*i=-1

2. 欧拉公式

欧拉公式:e^{ix}=\cos(x)+i\sin(x)。因此:

W_N^k=\cos\left(-\frac{2\pi k}{N}\right)+i\cdot\sin\left(-\frac{2\pi k}{N}\right)

比如:W_N^0=1, W_2^1=-1

3. 离散函数f_N(k)=W_N^k的基本性质:

(1)f_N(k)N为周期:

W_N^{k\pm N}=W_N^k

(2)f_N(k)具有共轭对称性:

W_N^{-k}=\left(W_N^k\right)^*

其中(\bullet)^*表示对一个复数取共轭(实部不变,虚部取反)。

从而:W_N^{N-k}=\left(W_N^k\right)^*

(3) 将W_N^k的上下标同时乘以一个数,结果保持不变:

W_{sN}^{sk}=W_N^k

比如:W_8^4=W_4^2=W_2^1

4. DFT的基本性质

(1)标量的DFT

如果序列长度为1(称为标量;长度大于1的序列可以称为一个向量),则它的DFT等于其本身。

(2)inverse DFT

逆傅里叶变换(inverse DFT)与正向傅里叶(forward DFT)的区别仅仅是傅里叶系数由W_N^k变成了W_N^{-k}

x_j = \sum_{k=0}^{N-1}{X_k W_N^{-k}}

对上式两边求共轭,由于“乘积的共轭=共轭的乘积”,即\left((a+bi)\cdot(c+di)\right)^*=(a+bi)^*\cdot (c+di)^*,因此:

x_j^*=\sum_{k=0}^{N-1}X_k^*W_N^k

因此,为了求一个复序列的inverse DFT,可以先对序列的每个元素分别求共轭,然后执行forward DFT,再对结果的每一个元素分别求共轭即可。求共轭不需要进行任何加减运算,只需要一次逻辑操作即可,因此极为便捷,在后面代码部分将会详细介绍。

(3)2d DFT 

由于DFT是线性运算,因此二维DFT可以视为两个一维DFT的复合函数,参见《快速傅里叶变换及其C程序》P253:

离散傅里叶变换 - 快速计算方法及C实现 - 第一篇_第1张图片

简言之:二维DFT = 先对每行分别进行DFT + 再对每列分别进行DFT = 先对每列分别DFT + 再对每行分别DFT

(4)radix-2 DFT

基-2 离散傅里叶变换是快速傅里叶变换(FFT)的重要内容,也是蝶形算法的核心。下面我们简单推导一下radix-2 DFT算法:

前提假设:N可以被2整除

首先对DFT公式按奇偶项进行拆分:

\begin{align} X_k &=\sum_{j<N}{x_jW_N^{jk}} \nonumber \\ &=\sum_{j<N/2}{x_{2j}W_N^{2jk}}+\sum_{j<N/2}{x_{2j+1}W_N^{(2j+1)k}} \nonumber \end{align}

由于W_N^{2jk}=W_{N/2}^{jk},故而:

X_k=\sum_{j<N/2}{x_{2j}W_{N/2}^{jk}}+W_N^k\sum_{j<N/2}{x_{2j+1}W_{N/2}^{jk}}

\lbrace A_k \rbrace _{k=0}^{k=N/2-1},\lbrace B_k \rbrace _{k=0}^{N/2-1}分别为N/2长序列\lbrace x_{2j} \rbrace _{j=0}^{N/2-1}和序列\lbrace x_{2j+1} \rbrace _{j=0}^{N/2-1}的DFT,则:

对任意k

因此X的前一半可以通过A_k,B_k的线性组合来得到。对于X的后一半:

\begin{align} X_{k+N/2} & = \sum_{j<N/2}{x_{2j}W_N^{2j(k+N/2)}}+\sum_{j<N/2}{x_{2j+1}W_N^{(2j+1)(k+N/2)}} \nonumber \\ & = \sum_{j<N/2}{x_{2j}W_{N/2}^{jk}}-W_N^k\sum_{j<N/2}{x_{2j+1}W_{N/2}^{jk}} \nonumber \end{align}

其中用到了W_2^1=-1

则对任意k

可以看到,一个N长DFT可以分解成两个N/2长DFT的线性组合,其中两个子序列分别是原始序列的偶数项以及奇数项组成的序列。

(5)1d real dft

一维实数序列的离散傅里叶变换可以有不同的方式进行实现,最基本的就是构造一个同样长度的复数序列,实部与实数序列相同,虚部全部为0. 当实序列的长度N为偶数时,则存在更加有效的计算方法,参见《快速傅里叶变换及其C程序》P75:

离散傅里叶变换 - 快速计算方法及C实现 - 第一篇_第2张图片

意思就是说,一个偶数长的实序列的DFT可以这么做:构造一个N/2长的复数序列,其中第k项由原来的实序列的第k个偶数作为实部、以原序列第k个奇数作为虚部。然后对这个复数序列求DFT,再根据DFT结果来恢复两个单独的DFT序列——分别对应原实数序列的偶数子序列和奇数子序列。其中用到的一个原理就是:若\lbrace x_j \rbrace为实序列,则

X_{N-k}=\sum_j{x_jW_N^{j(N-k)}}=\sum_j{x_j W_N^{-jk}}=\left( \sum_j{x_j W_N^{jk}}\right)^*=X_k^*

最后根据radix-2算法,既然我们已经知道了奇、偶子列的DFT,就可以恢复原序列的DFT了。

综上所述:

逆DFT、二维DFT、实数DFT,都可以通过一维复数DFT来高效率地实现,因此我们要解决的重点问题就是高效率地实现一维复数DFT。

你可能感兴趣的:(编程,DFT,FFT,原理)