Ransac: Random Sample Consensus, 随机抽样一致性。RANSAC算法在1981年由Fischler和Bolles首次提出。

Ransac是一种通过使用观测到的数据点来估计数学模型参数的迭代方法。其中数据点包括内点(inlier),外点(outlier)。outlier对模型的估计没有价值,因此该方法也可以叫做outlier检测方法。其中inliners指样本中正确的的样本点,常称为内点,内群点,正确样本,outliners指样本中的噪声点(如测量时误差很大的点,数据值不准确的点等),常称为外点,离群点,噪声点。

所以通俗点将,就是我们有一组观测数据,但是数据点中有很多噪声点,如果我们直接用模型来拟合数据,模型肯定会受噪声点影响,而且噪声点比例越大,拟合出的模型也会越不准确。Ransac就是用来解决样本中的外点问题(噪声),最多可处理50%的外点情况。

1. Ransac的假设和思想

再深入点讨论,有一组观测数据,要建立一个模型来拟合数据,我们第一步要做的,肯定是用一个标准对数据进行筛选,去除噪声点,让数据尽量干净和准确。但是如果我们没有一个合适的筛选标准,该怎么办呢?我们可以假设:观测数据中除了外点(噪声),肯定存在内点(准确点),而且内点的个数够我们用来拟合一个模型(比如拟合一条直线,至少要两个数据点)。有了这个假设,我们可以从观测数据中随机挑选出n个数据点,用这n个点来拟合模型,然后对这个模型进行评价。如果通过迭代,反复重复这个过程,只要迭代次数过大,我们随机挑选出来的n个样本点是有可能全部是内点的,再配合上模型评价,就能找到最优的拟合模型。这就是ransac的主要假设和思想,官方叙述如下:

RANSAC的基本假设是 “内群”数据可以通过几组模型参数来叙述其数据分布,而“离群”数据则 是不适合模型化的数据。 数据会受噪声影响, 噪声指的是离群:例如从极端的噪声, 错误解释有关数据的测量, 或不正确的假设。RANSAC假定,给定一组(通常很小的)内群,存在一个 程序,这个程序可以估算最佳解释或最适用于这一数据模型的参数。

宏观点来看,Ransac是一种思想,一种利用迭代来增加概率的方法,即:

RANSAC是一种思想,一个求解已知模型的参数的框架。它不限定某一特定的问题,可以是计算机视觉的问题,同样也可以是统计数学,甚至可以是经济学领域的模型参数估计问题。
RANSAC是一种迭代的方法,用来在一组包含离群的被观测数据中估算出数学模型的参数。 RANSAC 是一个非确定性算法,在某种意义上说,它会产生一个在一定概率下合理的结果,其允许使用更多次的迭代来使其概率增加

2. Ransac基本流程和参数设置

2.1 Ransac步骤

RANSAC算法的输入:

1.一组观测数据(往往含有较大的噪声或无效点
2.一个用于解释观测数据的参数化模型
3.一些可信的参数

迭代过程:

在数据中随机选择n个点设定为内群
计算适合内群的模型,如线性直线模型y=ax+b
把其它刚才没选到的点带入刚才建立的模型中,计算是否为内群点
记下内群数量
重复以上步骤, 迭代k次
比较哪次计算中内群数量最多,内群最多的那次所建的模型就是我们所要求的解

注意:不同问题对应的数学模型不同,因此在计算模型参数时方法必定不同,RANSAC的作用不在于 计算模型参数。(这导致ransac的缺点在于要求数学模型已知)

2.2 Ransac参数设置

在上面的步骤中,有两个参数比较重要,需要提前确定,即:

一开始的时候我们要随机选择多少点(n)
以及要重复迭代多少次(k)

假设每个点是真正内群的概率为 w:

[w = 内群的数目/(内群数目+外群数目)
]

通常我们不知道 w 是多少, (w^n)是所选择的n个点都是内群的机率, (1-w^n)是所选择的n个点至少有一个不是内群的机率, ((1 − w^n)^k)是表示重复 k 次都没有全部的n个点都是内群的机率, 假设算法跑 k 次以后成功的机率是p,那么

[1 − p = (1 − w^n)^k\
p = 1 − (1 − w^n)^k
]

我们可以通过P反算得到抽取次数K:(K=log(1-P)/log(1-w^n)。)

所以如果希望成功机率高:

当n不变时,k越大,则p越大;
当w不变时,n越大,所需的k就越大。
通常w未知,所以n选小一点比较好。

实际设置经验

可以先设置好P和n,然后K设置为一个很大的数,然后在迭代的过程中,每次迭代完都可以计算一次w,然后对K进行动态修改。 opencv的findHomography函数中ransac就是采用的这一策略:

H = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5)  # 采用Ransac方法计算单应性矩阵

参考:https://blog.csdn.net/laobai1015/article/details/51683076/

3. Ransac应用

在计算机视觉方面,Ransac最主要的应用主要包括直线拟合,平面拟合,单应性矩阵拟合等。这里简单介绍下直线拟合和透视矩阵拟合。

3.1 Ransac直线拟合

假设我们知道两个变量X与Y之间呈线性关系,Y=aX+b,我们想确定参数a与b的具体值。通过实验, 可以得到一组X与Y的测试值。虽然理论上两个未知数的方程只需要两组值即可确认,但由于系统误差的原因,任意取两点算出的a与b的值都不尽相同。通常情况下,我们可以采用最小二乘法拟合出直线方程(最小二乘法:通过计算最小均方差关于参数a、b的偏导数为零时的值)。但是最小二乘法只适合于误差较小的情况,如果测量数据中外点较多,误差很大,就需要采用Ransac算法。

在下图中分别是Ransac和最小二乘法拟合的直线,可以看出两者的差别。直接采用最小二乘法拟合直线,直线会受离群点影响,偏离中间的内群数据点,而ransac只是挑选部分数据点进行直线拟合,所以不会受离群点影响。
ransac算法思想-风君雪科技博客

下面是一段示例代码,比较了ransac和最小二乘法拟合直线的区别:

#include<iostream>
#include<opencv2/opencv.hpp>
#include<vector>
#include<set>
using namespace std;

bool ransacFitLine(vector<cv::Point2d>& points, cv::Vec4f& bestParam, vector<cv::Point2d>& inlinerPoints, int initInlinerNums, int iter, double thresh, int EndInlinerNums){
/*
    @param points:  所有的样本点.
    @param bestParam:  拟合最好的直线方程
    @param inlinerPoints:  拟合最好时的内点集合
    @param initInlinerNums: 用来拟合直线的初始化内点个数
    @param iter: 最大迭代次数
    @param thresh: 阈值,用来判断某个点是否在拟合的直线上
    @param EndInlinerNums: 阈值,内点个数超过阈值时停止迭代
 */

    set<int> randomIndex;
    vector<cv::Point2d> randomPoints;
    vector<cv::Point2d> tempInliners;
    int n = points.size();
    double minErr=DBL_MAX;
    bool flag = false;

    while(iter>=0){

        randomIndex.clear();
        randomPoints.clear();
        tempInliners.clear();

        // 随机选取initInlinerNums个点,拟合直线,其他点作为测试点
        for(int i=0; i<initInlinerNums; i++){
            int index = rand()%n;
            randomIndex.insert(index);
            randomPoints.push_back(points[index]);
        }

        // 采用随机点拟合直线
        cv::Vec4f lineParam;
        cv::fitLine(randomPoints, lineParam, cv::DIST_L2, 0, 0.01, 0.01);
        double linek = lineParam[1]/lineParam[0];
        
        // 计算测试点是否也在拟合直线上, 在直线上的点当作inliner
        for(int i=0; i<n; i++){
            if(randomIndex.find(i)==randomIndex.end()){
                double err = linek*(points[i].x - lineParam[2]) + lineParam[3] - points[i].y;
                err = err*err; // 差值的平方作为误差
                if (err < thresh){
                    tempInliners.push_back(points[i]);
                }
            }
        }
        
        // 当前inliner内点个数超过阈值时
        if(tempInliners.size()>EndInlinerNums){
            //采用所有的内点,拟合直线并计算平均误差
            tempInliners.insert(tempInliners.end(), randomPoints.begin(), randomPoints.end());
            cv::fitLine(randomPoints, lineParam, cv::DIST_L2, 0, 0.01, 0.01);
            double linek = lineParam[1]/lineParam[0];
            double sumErr = 0;
            for(int i=0; i<tempInliners.size(); i++){
                double err = linek*(points[i].x - lineParam[2]) + lineParam[3] - points[i].y;
                sumErr += err*err; // 差值的平方作为误差
            }
            sumErr = sumErr/tempInliners.size();  // 误差平方和的平均值
            if(minErr>sumErr){  // 记录最小的平均误差,
                minErr = sumErr;
                bestParam = lineParam;
                inlinerPoints.assign(tempInliners.begin(), tempInliners.end()); 
                flag = true;
            }
        }
        iter--;
    }
    if(!flag) cout << "did't meet fit acceptance criteria" << endl;

    return flag;
}

int main(int argc, char* argv[]){
    cv::RNG rng(200);//seed为200

    vector<cv::Point2d> points;

    // 生成500个随机样本点
    int nSamples = 500;   // 生成样本数据个数
    double k = 1 + rng.gaussian(1);  // 0-2之间的随机斜率
    cout << k << endl;
    for(int i=0; i<nSamples; i++){
        double x = rng.uniform(40, 600);
        double y = k*x + rng.uniform(-30, 30);  // 加入随机噪声
        x += rng.uniform(-30, 30);             // 加入随机噪声
        points.push_back(cv::Point2d(x, y));
    }

    // 加入outlier
    for(int j=0; j< 100; j++){
        double x = rng.uniform(200, 600);
        double y = rng.uniform(10, 300);
        points.push_back(cv::Point2d(x, y));
    }


    cv::Mat img = cv::Mat(720, 720, CV_8UC3, cv::Scalar(255,255,255));

    //绘制point
    for(int i=0; i<points.size(); i++){
        cv::circle(img, points[i], 3, cv::Scalar(0, 0, 0), 2);
    }  
    
     // 最小二乘法
    if(1){
        cv::Vec4f lineParam;
        fitLine(points, lineParam, cv::DIST_L2, 0.0, 0.01, 0.01);
        //获取点斜式的点和斜率, y=k(x-x0) + y0
        cv::Point point0;
        point0.x = lineParam[2];
        point0.y = lineParam[3];
        double linek = lineParam[1] / lineParam[0];
        

        //绘制最小二乘法拟合的直线
        cv::Point2d point1, point2;
        point1.x=10;
        point1.y = linek*(point1.x-point0.x) + point0.y;
        point2.x = 600;
        point2.y = linek*(point2.x-point0.x) + point0.y;
        cv::line(img, point1, point2, cv::Scalar(0, 255, 0), 4);
    }    

    // ransac拟合直线
    if(1){   // 为了避免命名冲突,所以放在if局部范围内
        cv::Vec4f lineParam;
        vector<cv::Point2d> inlinerPoints;
        bool ret = ransacFitLine(points, lineParam, inlinerPoints, 50, 1000, 7e3, 300);
        if(ret){
            cout << ret << endl;
            cout << lineParam << endl;
            cout << inlinerPoints.size() << endl;
            
            //获取点斜式的点和斜率, y=k(x-x0) + y0
            cv::Point point0;
            point0.x = lineParam[2];
            point0.y = lineParam[3];
            double linek = lineParam[1] / lineParam[0];
            

            //绘制拟合的直线
            cv::Point2d point1, point2;
            point1.x=10;
            point1.y = linek*(point1.x-point0.x) + point0.y;
            point2.x = 600;
            point2.y = linek*(point2.x-point0.x) + point0.y;
            cv::line(img, point1, point2, cv::Scalar(0, 0, 255), 4);
        }else{
            cout << "Fitting failed, try to change parameters for ransac!!!" << endl;
        }
        
    }

    cv::imshow("img", img);
    cv::waitKey(0);
    cv::destroyAllWindows();
    return 0;
}

代码结果如下:

ransac算法思想-风君雪科技博客

3.2 Ransac单应性矩阵拟合

关于单应矩阵理解:https://zhuanlan.zhihu.com/p/49435367

在进行图像匹配,全景拼接等时,常会用到单应性矩阵。例如在做全景拼接时,对于同一个场景,相机在不同角度拍摄了两张照片,一般先寻找两幅图片的匹配特征点,然后通过匹配特征点的对应关系计算出一个矩阵,这个矩阵就是单应性矩阵,利用这个矩阵就能将两张图片组合在一起。所以单应矩阵描述的是两组坐标点的关系,即:

[egin{bmatrix}
x_0 \
y_0 \
1
end{bmatrix} =Hegin{bmatrix}
X’_0 \
Y’_0 \
1
end{bmatrix} \
]

上面H即为单应性矩阵,((x_0, y_0))为第一幅图片上的坐标点,((X’_0, Y’_0))为第二幅图片上的坐标点,两个点是一组匹配点。H一般为3×3的矩阵,形式如下:

[H = egin{bmatrix}
a_{11} & a_{12} & a_{13} \
a_{21} & a_{22} & a_{23} \
a_{31} & a_{32} & a_{33} \
end{bmatrix}
]

这个矩阵有9个未知数(自由度为8),为了求解矩阵我们一般令(a_{33}=1), 则一组匹配点可以列如下两个方程:

ransac算法思想-风君雪科技博客

若有四组匹配点,就能列出8个方程,便能计算出单应矩阵H, 如下图所示:

ransac算法思想-风君雪科技博客

opencv的getPerspectiveTransform函数就是通过四组匹配点来计算单应性矩阵(投影矩阵/透视矩阵),如下:

 H = cv2.getPerspectiveTransform(src, dst)   # 返回3*3的矩阵
 参数:    
      src:原图像中的四组坐标,如 np.float32([[56,65],[368,52],[28,387],[389,390]])
      dst: 转换后的对应四组坐标,如np.float32([[0,0],[300,0],[0,300],[300,300]])

如果我们只有四组匹配点,而且能够保证匹配点都是正确的,的确可以采用上述方法求解单应矩阵,但是如果我们有100组匹配点,而且有些匹配点可能是错误的,该怎么求解一个拟合最好的矩阵呢?

仔细观察上面八个方程的形式,发现是一个矩阵求解的问题:

[AH=B
]

其中,A是8×8的矩阵,H是8×1的矩阵,B也是8×1的矩阵。若有100组匹配点,则A是200×8的矩阵,B是200×1的矩阵。

联想到上面最小二乘法拟合直线的问题:(y=wx),那么在这里x就是矩阵A的每一行(8×1的向量),(w)就是矩阵H,(y)

就是矩阵B的每一行(1×1), 如下图所示:
ransac算法思想-风君雪科技博客

所以100组匹配点就是对应200条数据,采用最小二乘法进行拟合(不算是直线了,算平面拟合了)。

有了求解方法,另一个问题就是:100组匹配点中可能存在错误的匹配,很明显错误的匹配就是所谓的outlier了。很自然的能想到:采用ransac代替上面的最小二乘法即可。

上述就是采用Ransac拟合单应性矩阵的思想,在opencv中findHomography函数实现了上述过程,如下所示:

#findHomography(srcPoints,dstPoints,method=0,ransacReprojThreshold=3,mask=noArray(),maxIters=2000,confidence=0.995)	

H = cv2.findHomography(src_pts, dst_pts, method=cv2.RANSAC, ransacReprojThreshold=5)  # 生成变换矩阵
参数:
	src_pts:原图像中的坐标(大于等于四组)
	dst_pts: 转换后的对应坐标(大于等于四组)
	method:求解单应矩阵的方法,取值如下:
			0:最小二乘法
			RANSAC:RANSAC拟合方法
			LMEDS: Least-Median robust method
			RHO:PROSAC-based robust method
   ransacReprojThreshold:ransac中判断是否是内点的阈值

下面看一个全景拼接的示例,全景拼接的常规流程 如下:

1、针对某个场景拍摄多张/序列图像

2、通过匹配特征(sift匹配)计算下一张图像与上一张图像之间的匹配特征点

3、对匹配特征点进行筛选,剔除掉部分匹配点。采用ransanc拟合匹配点之间的单应性矩阵

3、利用单应性矩阵进行图像映射,将下一张图像叠加到上一张图像的坐标系中

4、变换后的融合/合成
ransac算法思想-风君雪科技博客

下面是一个代码示例:

# coding: utf-8
import numpy as np
import cv2

left_img = cv2.imdecode(np.fromfile(r"D:笔记cppimage_processing图像拼接left.jpg", dtype=np.uint8), 1)
left_img = cv2.resize(left_img, (600, 400))
right_img = cv2.imdecode(np.fromfile(r"D:笔记cppimage_processing图像拼接ight.jpg", dtype=np.uint8), 1)
right_img = cv2.resize(right_img, (600, 400))
left_gray = cv2.cvtColor(left_img, cv2.COLOR_BGR2GRAY)
right_gray = cv2.cvtColor(right_img, cv2.COLOR_BGR2GRAY)

hessian = 300
surf = cv2.xfeatures2d.SIFT_create(hessian) # 将Hessian Threshold设置为400,阈值越大能检测的特征就越少
kp1, des1 = surf.detectAndCompute(left_gray, None)  # 查找关键点和描述符
kp2, des2 = surf.detectAndCompute(right_gray, None)

# kp1s = np.float32([kp.pt for kp in kp1])
# kp2s = np.float32([kp.pt for kp in kp2])

# 显示特征点
img_with_drawKeyPoint_left = cv2.drawKeypoints(left_gray, kp1, np.array([]), flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow("img_with_drawKeyPoint_left", img_with_drawKeyPoint_left)

img_with_drawKeyPoint_right = cv2.drawKeypoints(right_gray, kp2, np.array([]), flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow("img_with_drawKeyPoint_right", img_with_drawKeyPoint_right)



'''
BFMatcher简称暴力匹配,意思就是尝试所有可能匹配,实现最佳匹配。

FlannBasedMatcher简称最近邻近似匹配。
是一种近似匹配方法,并不追求完美!,因此速度更快。
可以调整FlannBasedMatcher参数改变匹配精度或改变算法速度。
参考:https://blog.csdn.net/claroja/article/details/83411108
'''
FLANN_INDEX_KDTREE = 0  # 建立FLANN匹配器的参数

indexParams = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)  # 配置索引,密度树的数量为5
searchParams = dict(checks=50)  # 指定递归次数
# FlannBasedMatcher:是目前最快的特征匹配算法(最近邻搜索)
flann = cv2.FlannBasedMatcher(indexParams, searchParams)  # 建立匹配器

# 参考https://docs.opencv.org/master/db/d39/classcv_1_1DescriptorMatcher.html#a378f35c9b1a5dfa4022839a45cdf0e89
'''
int queryIdx –>是测试图像的特征点描述符(descriptor)的下标,同时也是描述符对应特征点(keypoint)的下标。

int trainIdx –> 是样本图像的特征点描述符的下标,同样也是相应的特征点的下标。

int imgIdx –>当样本是多张图像的话有用。

float distance –>代表这一对匹配的特征点描述符(本质是向量)的欧氏距离,数值越小也就说明两个特征点越相像。
'''

matches = flann.knnMatch(des1, des2, k=2)  # 得出匹配的关键点


good = []
# 提取优秀的特征点
for m, n in matches:
    if m.distance < 0.7 * n.distance:  # 如果第一个邻近距离比第二个邻近距离的0.7倍小,则保留
        good.append(m)

src_pts = np.array([kp1[m.queryIdx].pt for m in good])  # 查询图像的特征描述子索引
dst_pts = np.array([kp2[m.trainIdx].pt for m in good])  # 训练(模板)图像的特征描述子索引


# findHomography参考https://docs.opencv.org/master/d9/d0c/group__calib3d.html#ga4abc2ece9fab9398f2e560d53c8c9780
# 单应矩阵:https://www.cnblogs.com/wangguchangqing/p/8287585.html
H = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5)  # 生成变换矩阵

h1, w1 = left_gray.shape[:2]
h2, w2 = right_gray.shape[:2]
shift = np.array([[1.0, 0, w1], [0, 1.0, 0], [0, 0, 1.0]])
# 点积
M = np.dot(shift, H[0])  # 获取左边图像到右边图像的投影映射关系, 向右移动w1?

dst = cv2.warpPerspective(left_img, M, (w1+w2, max(h1, h2)))  # 透视变换,新图像可容纳完整的两幅图
cv2.imshow('left_img', dst)  # 显示,第一幅图已在标准位置
dst[0:h2, w1:w1+w2] = right_img  # 将第二幅图放在右侧
# cv2.imwrite('tiled.jpg',dst_corners)
cv2.imshow('total_img', dst)
cv2.imshow('leftgray', left_img)
cv2.imshow('rightgray', right_img)
cv2.waitKey(0)
cv2.destroyAllWindows()

效果如下:

ransac算法思想-风君雪科技博客

参考:

https://blog.csdn.net/hzwwpgmwy/article/details/83578694

https://zhuanlan.zhihu.com/p/82793501

4 Ransac优缺点

优点:

它能鲁棒的估计模型参数。例如,它能从包含大量局外点的数据集中估计出高精度的参数。

缺点:

它计算参数的迭代次数没有上限;如果设置迭代次数的上限,得到的结果可能不是最优的结果,甚至可能得到错误的结果。
RANSAC只有一定的概率得到可信的模型,概率与迭代次数成正比。
它要求设置跟问题相关的阀值。
RANSAC只能从特定的数据集中估计出一个模型,如果存在两个(或多个)模型,RANSAC不能找到别的模型。
要求数学模型已知

参考:https://zhuanlan.zhihu.com/p/36301702/
https://blog.csdn.net/leonardohaig/article/details/104570965