SIFT/SURF/FlannBasedMatcher在某生产线视觉系统中的应用
曾经在UPWORK上看到过某生产线某环节的应用需求:
1. 在挂钩生产线某一个环节中,需要将一个金属徽章正确的贴在一个空白的挂钩上。
2. 传送带将金属徽章移入工作区。 工作区将是由玻璃制成,玻璃下方有一个朝上的摄像头,用于捕获进入工作区的徽章图像, 徽章进入工作区的位置与方向均随机。
3. 视觉系统根据摄像头捕获的图像,在现有的资料库中查找产品编号,并计算出徽章的中心点坐标及其相对旋转角度。
4. 视觉系统将徽章的中心点坐标和旋转角度发送给机器人,机器人将空白的挂钩盖移动到正确的角度和位置,并将徽章和挂钩连接在一起。
5. 视觉系统将产品编号发送给系统,系统打印好当前产品的条形码标签。组装好的挂钩盖离开机器人单元后,操作员将标签贴在成品上。
6. 视觉系统查找单张图像产品编码以及计算徽章中心点坐标和旋转角度的时间应该在1s以内。
7. 该视觉系统可随时扩充现有资料库,便于后续添加更多的徽章设计图像。
我根据上述需求,参考opencv_contrib-3.4.0\modules\xfeatures2d\samples\surf_matcher.cpp和FlannBasedMatcher做了一个Demo,基本满足需求,程序已上传至Github,这里记录并分享该Demo的实现方法,供参考。
1. demo运行环境
win10 + VS2017 + opencv3.4 opencv_contrib
本文将均以SIFT为例来说明,对SURF运行结果有兴趣的同学可以将GIthub源码中相应SIFTDetector替换成SURFDetector即可。我在opencv3.1的SIFT特征检测参数图文详解和sift & surf & bag-of-words 在目标识别中的应用详解中,曾经介绍过SIFT和SURF,这里就不再赘述其用途。
2. 现有产品的视觉资料库的建立与扩充
摄像头从传送带中捕获的图像中,徽章的放置角度和位置都是随机的,而资料库中的产品图像模板则是无旋转无倾斜的(参考本文图1中提及的徽章设计图标)。因SIFT和SURF的特征点描述子拥有优秀的旋转和尺度不变性,所以我们会用opencv_contrib中的sift或surf提取图像模板和待检测图像的特征。使用SIFT提取图像关键点(keypoints)和特征描述子(descriptors)的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct SIFTDetector { Ptr<Feature2D> sift; SIFTDetector() { sift = SIFT::create(); } template<class T> void operator()(const T& in, const T& mask, std::vector<cv::KeyPoint>& pts, T& descriptors, bool useProvided = false) { sift->detectAndCompute(in, mask, pts, descriptors, useProvided); } }; SIFTDetector sift; std::vector<KeyPoint> keypoints1; Mat descriptors1; Mat sourceImg = image.getMat(ACCESS_READ); sift(sourceImg, Mat(), keypoints1, descriptors1); |
第6点需求要求视觉系统查找单张图像产品编码,以及计算徽章中心点坐标和旋转角度的时间应该在1s以内,因此视觉系统资料库会预先建立完成并存储在相关文档中。在视觉系统资料库中,已有产品图像模板,该图像对应的关键点(keypoints)文件与特征描述子(descriptors)文件,均以该图像对应的产品编号命名。例如某徽章图像路径为ref\chrome\chrome001.png,其关键点(keypoints)文件与特征描述子(descriptors)的路径分别为:ref\chrome\features\descriptor\chrome001.txt和ref\chrome\features\keypoints\chrome001.txt。视觉系统资料库的文件夹结构如下图所示:
demo程序启动后,会先从ref\chrome\images获取所有已有产品图像模板的名称,然后从相应文件夹中读取其关键点(keypoints)文件与特征描述子(descriptors)文件存储在相应变量中,若这两个文件中有任意一个不存在,程序将会立刻提取相应关键点(keypoints)文件与特征描述子(descriptors)文件并保存。因此,若有新增产品,只要将相应产品的模板图片以其编码命名,放入指定路径即可。此处提及的功能实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
SIFTDetector sift; vector<Mat> featuressRef; vector<vector<KeyPoint>> keypointsRef; char buffer[60] = { 0 }; std::sprintf(buffer, refChromeImgsPath.c_str()); vector<string> imgRefPaths = newUtils::getFiles(buffer); vector<string> refName; vector<Mat> refImg; //获取参考图库中图片的特征 for (auto f : imgRefPaths) { Mat tmpImg = imread(f); refImg.push_back(tmpImg); UMat image; imread(f, IMREAD_GRAYSCALE).copyTo(image); if (image.empty()) { fprintf(stdout, ">> Invalid image: %s ignore.\n", f.c_str()); continue; } else { string fileName = Utils::getFileName(f); string desFileName = refChromeFeaPath + "/descriptor/" + fileName + ".txt"; string keyFileName = refChromeFeaPath + "/keypoints/" + fileName + ".txt"; //若特征点或描述文件有任何一个不存在,都将重新训练参考图库 if ((_access(desFileName.c_str(), 0) == -1) || (_access(keyFileName.c_str(), 0) == -1)) { std::vector<KeyPoint> keypoints1; UMat _descriptors1; Mat descriptors1 = _descriptors1.getMat(ACCESS_RW); sift(image.getMat(ACCESS_READ), Mat(), keypoints1, descriptors1); WriteFeatures2File(desFileName, descriptors1); //将特征描述子(descriptors)写入文件 WriteKeypoints2File(keyFileName, keypoints1); //将关键点(keypoints)写入文件 featuressRef.push_back(descriptors1); keypointsRef.push_back(keypoints1); refName.push_back(fileName); } else { Mat descriptor; load_features_from_file(desFileName, descriptor); featuressRef.push_back(descriptor); std::vector<KeyPoint> keypoints; load_keypoints_from_file(keyFileName, keypoints); keypointsRef.push_back(keypoints); refName.push_back(fileName); } } } |
3. 寻找待测图像在视觉资料库中匹配的图像模板
我们前面提到过,视觉系统资料库和待测图像的特征点均使用SIFT来提取。当我们需要确认待测图像在视觉系统资料库中对应的图像模板类型时,需要根据SIFT提取的特征点来进行匹配,会用到opencv的特征点匹配类DescriptorMatcher。DescriptorMatcher是匹配特征向量的抽象类,如下图所示,它包含BFmatcher和FlannBasedMatcher。
从官方文档描述来看,二者的区别在于BFMatcher总是尝试所有可能的匹配,从而使得它总能够找到最佳匹配,这也是Brute Force(暴力法)的原始含义。而FlannBasedMatcher中FLANN的含义是Fast Library forApproximate Nearest Neighbors,从字面意思可知它是一种近似法,算法更快但是找到的是最近邻近似匹配,所以当我们需要找到一个相对好的匹配但是不需要最佳匹配的时候往往使用FlannBasedMatcher。当然也可以通过调整FlannBasedMatcher的参数来提高匹配的精度或者提高算法速度,但是相应地算法速度或者算法精度会受到影响。本文介绍的Demo中,我们用FlannBasedMatcher来寻找待测图像所对应的图像模板,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static void findGoodMatcheImage(Mat imageDesc1, vector<Mat>& refImgDescs, int &matchIndex,vector<DMatch> &GoodMatchePoints) { FlannBasedMatcher matcher; vector<vector<DMatch> > matchePoints; matcher.add(refImgDescs);//添加视觉系统资料库的特征点描述子 matcher.train(); matcher.knnMatch(imageDesc1, matchePoints, 2); // 用Lowe's ratio test 过滤匹配点 for (int i = 0; i < matchePoints.size(); i++) { if (matchePoints[i][0].distance < 0.6 * matchePoints[i][1].distance) { GoodMatchePoints.push_back(matchePoints[i][0]); matchIndex = matchePoints[i][0].imgIdx; } } } |
findGoodMatcheImage函数用了knnMatch函数 ,该函数用于从视觉系统资料库的特征点描述子中找到待测图像所匹配的图像模板,本文介绍的Demo中,对knnMatch的调用使用k = 2在两个特征向量集之间执行knn匹配(指示返回每个特征向量的前两个匹配项)。KnnMatch的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void cv::DescriptorMatcher::knnMatch ( InputArray queryDescriptors, //待测图像特征点子描述符。 std::vector< std::vector< DMatch > > & matches, //存放待测图像在视觉资料库中的匹配点,对于同一查询描述符,每个matchs [i]是k个或更少的匹配项。 int k, //每个查询描述符找到的最佳匹配计数,如果查询描述符的总数小于k,则为更少。 InputArrayOfArrays masks = noArray(), bool compactResult = false//遮罩(或多个遮罩)不为空时使用的参数。 如果compactResult为false,则匹配向量的大小与queryDescriptors行的大小相同。 如果compactResult为true,则匹配向量不包含完全被屏蔽的查询描述符的匹配。 ) |
knnMatch函数执行完成后,会将匹配点存放在变量vector<vector<DMatch> > matchePoints中。我们来看一下DMatch的定义:
1 2 3 |
//DMatch: query descriptor index, train descriptor index, train image index, and distance between descriptors. //由说明可知,DMatch所存储的查询结果的四个变量分别为:待测图像特征点描述子序号,资料库特征点描述子序号,资料库特征点描述子所属的图像序号,两个匹配特征点描述子之间的距离。 DMatch (int _queryIdx, int _trainIdx, int _imgIdx, float _distance) |
为了进一步筛选匹配点,来获取优秀的匹配点,opencv给出的FlannBasedMatcher的范例中用了Lowe’s algorithm比率来过滤匹配点并获取更优的匹配点。这即是SIFT的作者Lowe提出的比较最近邻距离与次近邻距离的SIFT匹配方式:取一幅图像中的一个SIFT关键点,并找出其与另一幅图像中欧式距离最近的前两个关键点,在这两个关键点中,如果最近的距离除以次近的距离得到的比率ratio少于某个阈值T,则接受这一对匹配点。因为对于错误匹配,由于特征空间的高维性,相似的距离可能有大量其他的错误匹配,从而它的ratio值比较高。显然降低这个比例阈值T,SIFT匹配点数目会减少,但更加稳定,反之亦然。这里有一篇相关的论文,有兴趣的同学可以研读。很多文献推荐比例阈值T的取值在0.7~0.8之间。为了得到更稳定的点,本文介绍的Demo中取值0.6。
本文介绍的Demo中,视觉系统资料库中的图像只有四张,且这四张图像的差异较为明显,因此在获取匹配图像模板序号时,比较简单matchIndex = matchePoints[i][0].imgIdx。这里默认了每一个优质匹配点所对应的图像序号均相同。但在实际应用中,这样处理是不稳妥的。应该需要统计所有优质匹配点的序号,取比例最高的序号,请要参考此Demo的同学注意更新这一点。
4. 待测图像相对于匹配模板图像的旋转角度
这里我们做一个假设:视觉系统处理的每一张图片中仅有一个徽章,且传送带与徽章的对比色明显。在此条件满足的前提下,我们只要使用findcountous就可以找到徽章在处理图片中的位置进而找到其中心点,详细代码可以参考Github相应项目。
接下来,我们来详述待测图像相对于模板图像旋转角度的计算,这里我们借助于优质匹配点坐标来计算旋转角度,其步骤如下:
4.1. 计算待测图像中优质匹配点之间的直线距离,找到距离最长的两个匹配点,得到直线1。
4.2. 在匹配的视觉资料库图像中,找到与待测图像中距离最长的两个匹配点中相应的两个匹配点,得到直线2。
4.3. 计算直线1与直线2的夹角,此角度即待测图像相对于模板图像的旋转角度。
为什么要选距离最长的两个匹配点呢?我认为取最长的两个点可以提高旋转角度计算的精度。但我们也可以计算出所有匹配点直线之间的夹角,再取平均值;甚至我们也可以通过待测图像的最小外接矩形的旋转角度来计算此旋转角度。但我自己并未对各个方案进行对比,具体哪个方案更实用,我也不确定,有兴趣的同学可以进行尝试,欢迎讨论。
本文此部分功能的实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
static float getRotatedAngle(Mat &refImg, Mat &testImg,std::vector<DMatch>& matches, const std::vector<KeyPoint>& keypointsR, const std::vector<KeyPoint>& keypointsT) { Mat refImage = refImg.clone(); Mat testImage = testImg.clone(); //-- Sort matches and preserve top 10% matches std::sort(matches.begin(), matches.end()); std::vector< DMatch > good_matches; const int ptsPairs = GOOD_PTS_MAX;//std::min(GOOD_PTS_MAX, (int)(matches.size() * GOOD_PORTION)); for (int i = 0; i < ptsPairs; i++) { good_matches.push_back(matches[i]); } //计算标准图中滤除后的匹配点之间的直线距离,取出距离最长的两个点 float maxLen = 0; int index1 = 0; int index2 = 0; for (int m = 0; m < ptsPairs; m++) { Point2f pt1 = keypointsR[good_matches[m].queryIdx].pt; for (int n = ptsPairs - 1; n > m; n--) { Point2f pt2 = keypointsR[good_matches[n].queryIdx].pt; float tmpLen = (pt1.x - pt2.x) * (pt1.x - pt2.x) + (pt1.y - pt2.y) * (pt1.y - pt2.y); if (tmpLen > maxLen) { maxLen = tmpLen; index1 = m; index2 = n; } } } //根据参考图中的距离最长的两个点,计算测试图相对于参考图的旋转角度 Point2f pt1_Ref = keypointsR[good_matches[index1].trainIdx].pt; Point2f pt2_Ref = keypointsR[good_matches[index2].trainIdx].pt; Point2f pt1_Test = keypointsT[good_matches[index1].queryIdx].pt; Point2f pt2_Test = keypointsT[good_matches[index2].queryIdx].pt; line(refImage, pt1_Ref, pt2_Ref,Scalar(0, 255, 0), 2, LINE_AA); line(testImage, pt1_Test, pt2_Test, Scalar(0, 255, 255), 2, LINE_AA); return (calAngle(pt1_Ref, pt2_Ref, pt1_Test, pt2_Test)); } |
5. Demo测试方法
本文介绍的Demo视觉资料库中仅有4张图像模板,我将这个模板进行某个角度的旋转之后保存。测试时,Demo会在控制台中输出其检测到的旋转角度和处理单张图像的时间,并且会显示当前测试图片的最小外接矩形框以及其中心点。在我的电脑中Release模式下,单张图片的处理事件均在100ms以下。若视觉资料库图片增加,单张测试图像的处理时间也将随之增加。
总结
本文介绍了SURF/SIFT/FlannBasedMatcher在某生产线视觉系统中的应用,基本满足整体需求。但若需要投入实际应用,还需要更多的图像模板与测试图像,进行更多测试,同时某些环节也需要进行多个方案的对比,例如第4小节中提到的获取匹配图像的序号,第5小节中计算待测图像相对于模板图像的旋转角度等等。
本文到此结束,感谢阅读,欢迎关注。
getRotatedAngle这个函数中,数组访问可能越界,崩溃
确实有越界的可能性,考虑不够完善,谢谢指出。
以下代码
const int ptsPairs = GOOD_PTS_MAX;//std::min(GOOD_PTS_MAX, (int)(matches.size() * GOOD_PORTION));
应该修改为:const int ptsPairs = std::min(GOOD_PTS_MAX, (int)(matches.size() * GOOD_PORTION));