特征检测与匹配笔记(OpenCV3编程入门--毛星云--基于特征的局部匹配算法)

立体匹配算法分类

1、OpenCV实现的立体匹配算法:可以分为2类:全局匹配算法与局部匹配算法。全局匹配算法精度高,缺点是计算速度慢,实时性差;局部匹配算法分为基于区域的匹配,基于特征的匹配;局部匹配计算速度快,可以满足实时性要求,但是精度较全局匹配差;基于特征的匹配只能得到稀疏的视差,需要靠插值完成视差图的重建;
2、全局匹配算法:OpenCV实现了SGBM(半全局块匹配算法),GC算法(OpenCV3.0以后版本没有实现);
3、基于区域的局部匹配算法:OpenCV实现了BM(块匹配算法);
4、基于特征的局部匹配算法:OpenCV实现了10种特征检测匹配方法:

1. FAST  --FastFeatureDetector;
2. STAR --StarFeatureDetector;
3. SIFT --尺度不变特征变换;
4. SURF --加速鲁棒特征;
5. ORB
6. MSER
7. GFTT
8. HARRIS
9. Dense
10.SimpleBlob 

SURF特征点检测原理

1、SURF : SpeededUp Robust Features , 直译“加速版的具有鲁棒性的特征算法” , SURF是尺度不变特征变换算法(SIFT)的加速版,计算速度更快,在多幅图片下具有更好的稳定性;SURF最大的特征在于采用了Harr特征以及积分图像的概念,大大加快了程序的运行时间;
2、SURF算法原理:
①构建Hessian矩阵,构建高斯金字塔尺度空间;
②利用非极大值抑制初步确定特征点;
③精确定位极值点(采用三维线性插值法得到亚像素级的特征点)
④选取特征点的主方向:
Sift选取特征点主方向是采用在特征点领域内统计其梯度直方图,取直方图bin值最大的以及超过bin值80%的哪些方向作为特征点的主方向;
Surf则是不统计其梯度直方图,而是统计特征点领域内的harr小波特征,取最大值那个扇形的方向作为特征点的主方向;
⑤构造surf特征点描述子(Descriptor):
Sift算法是在特征点周围取1616的领域,将该领域化为44个小区域,每个小区域统计8个方向梯度,最后得到448 = 128维的向量,作为该点的Sift描述子
Surf算法中,在特征点周围取20s(s是所检测到该特征点所在的尺度),然后将该框分为16个子区域,每个区域计算harr小波特征的4个值,所以每个特征点就是16*4=64维的向量,相比Sift,特征点描述子少了一半,特征匹配速度大大加快;

3、OpenCV3.0以后版本中,SURF、SurfFeatureDetector、SurfDescriptorExtractor代表相同含义(typedef定义的别名);(类继承关系)

在这里插入图片描述
在这里插入图片描述
4、SurfFeatureDetector类常用的方法:detect()(寻找特征点)、compute()(计算特征点的描述子);

绘制关键点与KeyPoint类

1、drawKeyPoints()函数:第5个参数flag : 绘制关键点的特征标识符(注意不同参数效果)
在这里插入图片描述
(Google翻译:)
在这里插入图片描述

2、KeyPoint关键点:(使用描述符描述其特征信息)
关键点是角点概念的扩展,对来自图像的小的局部像斑的信息进行编码,使关键点具有高度可辨别性,关键点的描述性信息被概括成描述符的形式,其描述符的维度通常比形成关键点的像素像斑低得多。(摘自Learning OpenCV3)
在这里插入图片描述
在这里插入图片描述

_size : 特征点领域直径
_angle : 特征点方向(0-360),(-1表示不使用)
_response : 关键点强度;
_octave : 关键点所在的图像金字塔的组;
_class_id : 用于聚类的id;

3、Surf、Sift是OpenCV的nofree模块,需要安装opencv_contrib附加库;(CMake编译时需要选择编译平台为x64!)
在这里插入图片描述

Surf特征点检测demo1

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat src = imread("00.jpg", IMREAD_COLOR);
	imshow("input", src);

	int minHessian = 1000;  
	Ptr<SURF> detector = SURF::create(minHessian);
	vector<KeyPoint> keypoint_1;
	detector->detect(src, keypoint_1);			//检测SURF特征关键点

	//绘制KeyPoints
	Mat img_keypoint_1;
	drawKeypoints(src, keypoint_1, img_keypoint_1, Scalar::all(-1), DrawMatchesFlags::DEFAULT);
	//显示KeyPoints
	imshow("detect", img_keypoint_1);

	waitKey(0);
	return 0;
}

在这里插入图片描述
在这里插入图片描述

Surf特征描述子计算与特征匹配(暴力匹配BFMatch)

1、Surf算法为每个检测到的特征定义了位置与尺度,尺度值可用于定义围绕特征点的窗口大小,不论物体的尺度在窗口是什么样的,都将包含相同的视觉信息,这些信息用于表示特征点以使得它们与众不同;
2、OpenCV中,使用Surf进行特征点描述与匹配主要是drawMatches()方法与BruteForceMatcher类使用;
3、drawMatcher() : 绘制两幅图像中的匹配点
在这里插入图片描述
在这里插入图片描述
matchMask : 确定哪些匹配是会绘制出来的掩膜,如果掩膜为空,表示所有匹配都进行绘制;

4、BFMatcher类常用方法:
train() :
训练一个描述符匹配器(例如,flann索引)。在所有要匹配的方法中,方法train()每次在匹配之前都会运行。一些描述符匹配器(例如,BruteForceMatcher)具有此方法的空实现。其他的描述符匹配器真的训练他们的内部结构(例如,FlannBasedMatcher训练flann::Index)。
match() : 查询列表,并与训练好的字典中的描述符进行比较,查询列表上每个关键点与列表中的“最佳匹配”匹配;
knnMatch() : k-最邻近匹配;
radiusMatch() : 半径匹配,返回与查询描述符特定距离内的所有匹配;
在这里插入图片描述

5、特征点匹配步骤:
①Surf算子计算特征点;
②Surf算子计算特征点描述子;
③BruteForceMatcher中的函数match()强行匹配两幅图像的特征向量;

Surf特征点暴力匹配demo2

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat src1 = imread("1.jpg");
	Mat src2 = imread("2.jpg");

	Ptr<SURF> surfdect = SURF::create(1500);
	//计算Surf特征点
	vector<KeyPoint> keypoint_src1;
	vector<KeyPoint> keypoint_src2;
	surfdect->detect(src1, keypoint_src1);
	surfdect->detect(src2, keypoint_src2);
	//计算描述符(特征向量)
	Mat descriptor_src1;
	Mat descriptor_src2;
	surfdect->compute(src1, keypoint_src1, descriptor_src1);
	surfdect->compute(src2, keypoint_src2, descriptor_src2);
	//匹配两幅图像中的描述子
	vector<DMatch> dMatch;
	Ptr<BFMatcher> bfMatch = BFMatcher::create(NORM_L2, false);
	bfMatch->match(descriptor_src1,descriptor_src2,dMatch);
	//绘制匹配点
	Mat imgMatches;
	drawMatches(src1, keypoint_src1, src2, keypoint_src2, dMatch, imgMatches);

	imshow("BF Match", imgMatches);

	waitKey(0);
	return 0;
}

暴力匹配结果:(许多误匹配点)
在这里插入图片描述

快速近似最近邻逼近搜索函数库(FLANN)

1、FLANN : Fast Library for Approximate Nearest Neighbors;
2、FlannBasedMatcher类继承DescriptorMatcher类;使用match()方法匹配;
在这里插入图片描述
3、match()方法:
在这里插入图片描述
4、DMatch类的四个主要属性:用来存储描述符匹配结果
在这里插入图片描述

FLANN匹配demo3

在这里插入图片描述

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat src1 = imread("1.jpg");
	Mat src2 = imread("2.jpg");
	//计算Surf特征点
	Ptr<SURF> surfdect = SURF::create(1000);
	vector<KeyPoint> keypoint_src1;
	vector<KeyPoint> keypoint_src2;
	surfdect->detect(src1, keypoint_src1);
	surfdect->detect(src2, keypoint_src2);
	//计算描述符(特征向量)
	Mat descriptor_src1;
	Mat descriptor_src2;
	surfdect->compute(src1, keypoint_src1, descriptor_src1);
	surfdect->compute(src2, keypoint_src2, descriptor_src2);
	//使用FLANN算法匹配描述符向量
	vector<DMatch> dMatch;
	Ptr<FlannBasedMatcher> flannMatcher = FlannBasedMatcher::create();
	flannMatcher->match(descriptor_src1, descriptor_src2, dMatch);
	double max_dist = 0, min_dist = 100;
	//快速计算关键点之间的最大和最小距离
	for (int i = 0; i < descriptor_src1.rows; i++)
	{
		double dist = dMatch[i].distance;
		if (dist < min_dist) min_dist = dist;
		if (dist > max_dist) max_dist = dist;
	}
	//输出距离信息
	printf("Max dist : %f \n", max_dist);
	printf("Min dist : %f \n", min_dist);

	//储存符合条件的匹配结果(dist < 2 * min_dist)
	vector<DMatch> good_match;
	for (int i = 0; i < descriptor_src1.rows; i++)
	{
		if (dMatch[i].distance < 2 * min_dist)
		{
			good_match.push_back(dMatch[i]);
		}
	}

	//绘制符合条件的匹配点
	Mat imgMatch;
	drawMatches(src1, keypoint_src1, src2, keypoint_src2, good_match, imgMatch,
		Scalar::all(-1), Scalar::all(-1), vector<char>(), DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);

	//输出相关点匹配信息
	for (int i = 0; i < good_match.size(); i++)
	{
		printf("> 符合条件的匹配点 [%d] 特征点1:%d --- 特征点2: %d  \n",
			i, good_match[i].queryIdx, good_match[i].trainIdx);
	}
	//显示匹配结果
	imshow("FLANN Match", imgMatch);
	
	waitKey(0);
	return 0;
}

在这里插入图片描述
在这里插入图片描述

FLANN+SURFdemo4

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat trainImg = imread("3.jpg");
	Mat trainImg_gray;
	imshow("input", trainImg);
	cvtColor(trainImg, trainImg_gray, CV_BGR2GRAY);

	//检测Surf关键点,提取训练图像描述符
	vector<KeyPoint> train_keypoints;
	Mat trainDescriptor;
	Ptr<SURF> surfDetector = SURF::create(1000);
	surfDetector->detect(trainImg, train_keypoints);    //寻找特征点
	surfDetector->compute(trainImg, train_keypoints, trainDescriptor);  //计算描述符

	//创建基于FLANN的描述符匹配对象
	FlannBasedMatcher flannMatch;
	vector<Mat> train_desc_collection(1, trainDescriptor);
	flannMatch.add(train_desc_collection);
	flannMatch.train();

	//创建视频对象,定义帧率
	VideoCapture capture(0);
	unsigned int frameCount = 0;

	while (char(waitKey(1)) != 'q')
	{
		int64 time0 = getTickCount();
		Mat testImage, testImage_gray;
		//capture >> testImage;
		capture.read(testImage);
		if (testImage.empty())
			continue;
		cvtColor(testImage, testImage_gray, CV_BGR2GRAY);

		//检测关键点,计算特征向量(描述符)
		vector<KeyPoint> test_keypoint;
		Mat test_descriptor;
		surfDetector->detect(testImage_gray, test_keypoint);
		surfDetector->compute(testImage_gray, test_keypoint, test_descriptor);

		//匹配训练和测试描述符
		vector<vector<DMatch>> matches;
		flannMatch.knnMatch(test_descriptor, matches, 2);

		//根据劳式算法得到优秀匹配点
		vector<DMatch> goodMatches;
		for (unsigned int i = 0; i < matches.size(); i++)
		{
			if (matches[i][0].distance < 0.6 * matches[i][1].distance)
			{
				goodMatches.push_back(matches[i][0]);
			}
		}

		//绘制匹配点并显示窗口
		Mat dstImage;
		drawMatches(testImage, test_keypoint, trainImg, train_keypoints, goodMatches, dstImage);
		imshow("匹配窗口", dstImage);

		//输出帧率信息
		cout << "当前帧率为: " << getTickFrequency() / (getTickCount() - time0) << endl;

	}

	return 0;

}

在这里插入图片描述
在这里插入图片描述

帧率偏低,算法效率有待加强:
在这里插入图片描述

BF+SIFT特征匹配demo5

1、理论上SURF是SIFT速度的3倍;
在这里插入图片描述

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat trainImg = imread("3.jpg");
	Mat trainImg_gray;
	imshow("input", trainImg);
	cvtColor(trainImg, trainImg_gray, CV_BGR2GRAY);

	//检测Sift关键点,提取训练图像描述符
	vector<KeyPoint> train_keypoints;
	Mat trainDescriptor;
	Ptr<SIFT> siftDetector = SIFT::create();			//使用智能指针创建SIFI对象
	siftDetector->detect(trainImg, train_keypoints);    //寻找特征点
	siftDetector->compute(trainImg, train_keypoints, trainDescriptor);  //计算描述符

	//创建基于Brute Force的描述符匹配对象
	BFMatcher bfMatcher;
	vector<Mat> train_desc_collection(1, trainDescriptor);
	bfMatcher.add(train_desc_collection);
	bfMatcher.train();

	//创建视频对象,定义帧率
	VideoCapture capture(0);
	unsigned int frameCount = 0;

	while (char(waitKey(1)) != 'q')
	{
		int64 time0 = getTickCount();
		Mat testImage, testImage_gray;
		capture >> testImage;
		//capture.read(testImage);
		if (testImage.empty())
			continue;
		cvtColor(testImage, testImage_gray, CV_BGR2GRAY);

		//检测关键点,计算特征向量(描述符)
		vector<KeyPoint> test_keypoint;
		Mat test_descriptor;
		siftDetector->detect(testImage_gray, test_keypoint);
		siftDetector->compute(testImage_gray, test_keypoint, test_descriptor);

		//匹配训练和测试描述符
		vector<vector<DMatch>> matches;
		bfMatcher.knnMatch(test_descriptor, matches, 2);

		//根据劳式算法得到优秀匹配点
		vector<DMatch> goodMatches;
		for (unsigned int i = 0; i < matches.size(); i++)
		{
			if (matches[i][0].distance < 0.6 * matches[i][1].distance)
			{
				goodMatches.push_back(matches[i][0]);
			}
		}

		//绘制匹配点并显示窗口
		Mat dstImage;
		drawMatches(testImage, test_keypoint, trainImg, train_keypoints, goodMatches, dstImage);
		imshow("匹配窗口", dstImage);

		//输出帧率信息
		cout << "当前帧率为: " << getTickFrequency() / (getTickCount() - time0) << endl;

	}
	return 0;
}

在这里插入图片描述

笔记本运算速度较慢,SIFT有明显卡顿;
在这里插入图片描述

寻找已知物体demo6

1、在FLANN特征匹配的基础上,还可以进一步用Homography映射找出已知物体:具体的为使用findHomography()计算单应性矩阵,使用perspectiveTransform()计算映射点群;

2、findHomography() : 计算源图像与目标图像之间的透视变换H;
srcPoints : CV_32FC2 的矩阵类型或vector< Point2f >
dstPoints : CV_32FC2 的矩阵类型或vector< Point2f >
在这里插入图片描述

3、perspectiveTransform() : 进行向量透视矩阵变换;
m: 33 或44浮点型矩阵;
在这里插入图片描述

4、demo6

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat src1 = imread("1.jpg");
	Mat src2 = imread("2.jpg");
	if (!src1.data || !src2.data)
	{
		cout << "load the image failed..." << endl;
		return -1;
	}
	//检测Surf特征点
	Ptr<SURF> surfDetector = SURF::create(1000);
	vector<KeyPoint> keypoint_src1;
	vector<KeyPoint> keypoint_src2;
	surfDetector->detect(src1, keypoint_src1);
	surfDetector->detect(src1, keypoint_src2);
	//计算描述符
	Mat descriptor_src1, descriptor_src2;
	surfDetector->compute(src1, keypoint_src1, descriptor_src1);
	surfDetector->compute(src2, keypoint_src2, descriptor_src2);

	//FLANN 特征点匹配
	FlannBasedMatcher flannMatcher;
	vector<DMatch> dMatch;
	flannMatcher.match(descriptor_src1, descriptor_src2, dMatch);
	double max_dist = 0, min_dist = 100;  //最小距离和最大距离

	for (int i = 0; i < dMatch.size(); i++)
	{
		double dist = dMatch[i].distance;
		if (dist < min_dist) min_dist = dist;
		if (dist > max_dist) max_dist = dist;
	}
	printf(">Max dist : %f\n", max_dist);
	printf(">Min dist : %f\n", min_dist);

	//存下匹配距离小于 3 * min_dist 的点对
	vector<DMatch> good_Match;
	for (int i = 0; i < dMatch.size(); i++)
	{
		if (dMatch[i].distance < 3 * min_dist)
		{
			good_Match.push_back(dMatch[i]);
		}
	}

	//绘制出匹配到的关键点
	Mat img_matches;
	drawMatches(src1, keypoint_src1, src2, keypoint_src2, good_Match, img_matches, 
			Scalar::all(-1), Scalar::all(-1), vector<char>(), DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);

	//定义2个局部变量
	vector<Point2f> obj;
	vector<Point2f> scene;
	//从匹配成功的匹配对中获取关键点
	for (int i = 0; i < good_Match.size(); i++)
	{
		obj.push_back(keypoint_src1[good_Match[i].queryIdx].pt);
		scene.push_back(keypoint_src2[good_Match[i].trainIdx].pt);
	}
	//计算透视变换
	Mat H = findHomography(obj, scene, CV_RANSAC);
	//从待测图片中获取角点
	vector<Point2f> obj_corners(4);
	obj_corners[0] = Point(0, 0);
	obj_corners[1] = Point(src1.cols, 0);
	obj_corners[2] = Point(src1.cols, src1.rows);
	obj_corners[3] = Point(0, src1.rows);
	vector<Point2f> scene_corners(4);
	//进行透视变换
	perspectiveTransform(obj_corners, scene_corners,H);

	//绘制出角点之间的直线
	line(img_matches, scene_corners[0] + Point2f(static_cast<float>(src1.cols), 0),
		scene_corners[1] + Point2f(static_cast<float>(src1.cols), 0),
		Scalar(255, 0, 123), 4);
	line(img_matches, scene_corners[1] + Point2f(static_cast<float>(src1.cols), 0),
		scene_corners[2] + Point2f(static_cast<float>(src1.cols), 0),
		Scalar(255, 0, 123), 4);
	line(img_matches, scene_corners[2] + Point2f(static_cast<float>(src1.cols), 0),
		scene_corners[3] + Point2f(static_cast<float>(src1.cols), 0),
		Scalar(255, 0, 123), 4);
	line(img_matches, scene_corners[3] + Point2f(static_cast<float>(src1.cols), 0),
		scene_corners[0] + Point2f(static_cast<float>(src1.cols), 0),
		Scalar(255, 0, 123), 4);

	//显示最终结果
	imshow("Good Matches & Object detection", img_matches);
	waitKey(0);
	return 0;
}

在这里插入图片描述
在这里插入图片描述

ORB特征提取简介

1、ORB : ORiented Brief简称,是brief算法改进版,2011年在《ORB: an efficient alternative to SIFT or SURF》论文中被提出,据说,ORB算法综合性能在各种测评里相较于其他特征提取算法是最好的。
2、Brief : Binary Robust Independent Elementary Features的缩写,主要思路: 在特征点附近随机选取若干点对,将这些点对的灰度值大小组成一个二进制串,并将这个二进制串作为该特征点的描述子。
3、BRIEF 优点是速度,缺点:不具备旋转不变性、对噪声敏感、不具备尺度不变性;
4、统计数据,ORB速度是SIFI的100倍,SURF的10倍
5、 ORB继承自Feature2D

typedef ORB OrbFeatureDetector;
typdef ORB OrbDescriptorExtractor;
class CV_EXPORTS_W ORB : public Feature2D
{
}

ORB + FLANN-LSH(位置敏感哈希索引)特征点匹配demo7

#include <opencv2/opencv.hpp>
#include <opencv2/xfeatures2d.hpp>   //添加Surf
#include <iostream>

using namespace std;
using namespace cv;
using namespace cv::xfeatures2d;     //添加命名空间

int main(int argc, char** argv)
{
	Mat src = imread("3.jpg");
	if (!src.data)
	{
		cout << "could not load the image..." << endl;
		return -1;
	}
	Mat src_gray;
	cvtColor(src, src_gray, CV_BGR2GRAY);

	Ptr<ORB> orbDetector = ORB::create();
	vector<KeyPoint> keyPoint_src;
	Mat descriptor_src;
	orbDetector->detect(src_gray, keyPoint_src);						//寻找特征点
	orbDetector->compute(src_gray, keyPoint_src, descriptor_src);	//计算特征向量

	//基于Flann的描述符对象匹配
	flann::Index flannIndex(descriptor_src, flann::LshIndexParams(12, 20, 2), cvflann::FLANN_DIST_HAMMING);

	//初始化视频采集对象
	VideoCapture cap(0);
	cap.set(CV_CAP_PROP_FRAME_WIDTH, 360);			//设置采集视频的宽度高度
	cap.set(CV_CAP_PROP_FRAME_HEIGHT, 900);

	unsigned int frameCount = 0;

	while (1)
	{
		double time0 = static_cast<double>(getTickCount());  //记录起始时间
		Mat captureImage, captureImage_gray;
		cap >> captureImage;
		if (captureImage.empty())
			continue;
		cvtColor(captureImage, captureImage_gray, CV_BGR2GRAY);

		//寻找ORB特征点并计算特征点描述符
		vector<KeyPoint> keypoint_cap;
		Mat descriptor_cap;
		orbDetector->detect(captureImage_gray, keypoint_cap);       //计算特征点
		orbDetector->compute(captureImage_gray, keypoint_cap, descriptor_cap);  //计算描述符

		//匹配和测试描述符,获取2个最邻近的描述符
		Mat matchIndex(descriptor_cap.rows, 2, CV_32SC1);
		Mat matchDistance(descriptor_cap.rows, 2, CV_32FC1);
		//调用K邻近算法
		flannIndex.knnSearch(descriptor_cap, matchIndex, matchDistance, 2, flann::SearchParams());

		//根据 Lowe's algorithm 选出最佳匹配
		vector<DMatch> goodMatches;
		for (int i = 0; i < matchDistance.rows; i++)
		{
			if (matchDistance.at<float>(i, 0) < 0.6 * matchDistance.at<float>(i, 1))
			{
				DMatch dmatches(i, matchIndex.at<int>(i, 0), matchDistance.at<float>(i, 0));
				goodMatches.push_back(dmatches);
			}
		}

		//绘制显示匹配窗口
		Mat resultImage;
		drawMatches(src, keyPoint_src, captureImage, keypoint_cap, goodMatches, resultImage);

		imshow("匹配窗口", resultImage);

		//显示帧率
		cout << "> 帧率: " << getTickFrequency() / (getTickCount() - time0) << endl;

		if (char(waitKey(1)) == 27) break;

	}
	return 0;
}

在这里插入图片描述

发布了61 篇原创文章 · 获赞 5 · 访问量 4471

猜你喜欢

转载自blog.csdn.net/hellohake/article/details/104930117