基于OpenCV的图像透视变换详解(从理论到实现再到实践) |
您所在的位置:网站首页 › ps里怎么改变图片透视 › 基于OpenCV的图像透视变换详解(从理论到实现再到实践) |
一、仿射变换与透视变换
一直无法理解两种仿射变换与透视变换的区别,因此详细学习了两种变换的具体细节,重新书写了公式,并给出自己的一些看法。 1.仿射变换可以认为,仿射变换是透视变换的一种特例。 仿射变换是一种二维坐标到二维坐标之间的线性变换,也就是只涉及一个平面内二维图形的线性变换。 图形的平移、旋转、错切、放缩都可以用仿射变换的变换矩阵表示。 它保持了二维图形的两种性质: ① “平直性”:直线经过变换之后依然是直线。一条直线经过平移、旋转、错切、放缩都还是一条直线。 ②“平行性”:变换后平行线依然是平行线,且直线上点的位置顺序不变。 直观的感受就是,我们在电脑上对一张图片进行拖动、翻转、拉伸等等操作,看这一张图片的视角是不会变的。 任意的仿射变换都能表示为一个坐标向量乘以一个矩阵的形式,下面是几种仿射变换的矩阵形式。 放缩: 旋转: 错切: 上面几种变换都可以直接只用2x2矩阵变换,但是平移无法做到,因为在2x2矩阵中无论怎么相乘都无法变换出一个常数量。因此需要将原本的2维坐标向量变成齐次坐标,也就是用3维向量来表示2维向量。 平移: 变成齐次坐标后,为了实现原本2x2矩阵的放缩、旋转、错切的变换,只需要令 上面的变换都是线性变换,因此仿射变换可以用以下通式来表示,也就是网上常见到的形式: 此时仿射变换的变换矩阵 因此坐标变换的方程组如下: 可以看到有6个未知的系数,需要3对映射点(前提是相互独立)才能求解。这不难理解,6个变量自然需要至少列6个等式才可计算,而1对映射点可以提供2个等式。 同时3个点唯一确定一个平面,另外的3个映射点由于是线性变换也必然在同一个平面内,所以可以说仿射变换是平面内的图形变换。 2.透视变换透视变换是将图片投影到一个新的视平面,也称作投影映射。 它是二维 相对于仿射变换,它不仅仅是线性变换。它提供了更大的灵活性,可以将一个四边形区域映射到另一个四边形区域。 透视变换也是通过矩阵乘法实现的,使用的是一个3x3的矩阵,矩阵的前两行与仿射矩阵相同,这意味着仿射变换的所有变换透视变换也可以实现。而第三行则用于实现透视变换。 透视变换也使用了齐次坐标来表示二维向量:
此时透视变换的变换矩阵 透视变换得到的 事实上这里就可以明白为什么仿射变换是透视变换的一种特例。因为如果仿射变换后的坐标向量也用齐次坐标 此时仿射变换与透视变换的形式得到统一,仿射变换过程视为如下: 所以仿射变换只是透视变换矩阵第三行为 再回到透视变换,整个透视变换的过程如下: 因此坐标变换的方程组如下: 共有9个未知参数,可以进一步简化为8个未知参数,也就是网上常见的形式。 这里我的思路是:证明变换矩阵 将变换矩阵设为 得到方程组如下: 可以发现最后 因此对于 对上述变量重新命名即可得到变换矩阵T: 此时只有8个未知参数,变换方程组也变成如下: 求解8个未知数需要8个等式,1组映射点提供2个等式,所以需要找到4组映射点,这也是为什么我们需要提供变换前后的4个点来表示透视变换。 至此我们也可以理解为什么透视变换是二维到三维,三维又到二维的过程。变换之前的点为 透视变换的效果相当于观察者的视角发生改变时所观察到画面产生的变化。 为什么通过一个矩阵可以实现这种视角变化?具体的数学原理在网上没找到,大部分文章也只是止步于变换矩阵 经过推证,我认为透视变换的过程应该是如下我画的图: 首先观察点位于原点 经过透视变换矩阵 然后该图形各个点与视点(即原点)连线,在 这也解决了我一直以来的困惑,即透视变换引发我们图片视角的变化并不是我们观察者视角的变化,而是观察者视角不变而对物体空间位置发生了转换,导致了我们看到的图片视角变化。这可以理解为:不是我们人走动看到了物体角度发生变化,而是人始终保持不动,其他人对物体进行了挪动导致我们看到这个物体角度发生了变化。 二、透视变换实现在应用中,需要用到OpenCV的两个函数getPerspectiveTransform()和warpPerspective()两个函数。 1.getPerspectiveTransform() Mat getPerspectiveTransform(InputArray src, InputArray dst, int solveMethod = DECOMP_LU) ①功能描述 从4对映射点计算透视变换的变换矩阵 参数 src:源图像四边形的4个顶点坐标。 参数 dst:目标图像对应四边形的4个顶点坐标。 参数 solveMethod:传递给cv::solve(#DecompTypes)的计算方法,默认是DECOMP_LU,一般不用输入此参数。 返回值:Mat型变换矩阵,可直接用于warpPerspective()函数 2.warpPerspective() void warpPerspective( InputArray src, OutputArray dst, InputArray M, Size dsize, int flags=INTER_LINEAR, int borderMode = BORDER_CONSTANT, const Scalar& borderValue = Scalar()); ①功能描述 将变换矩阵 参数src:输入图像。 参数dst:输出图像,需要初始化一个空矩阵用来保存结果,不用设定矩阵尺寸。 参数M:3x3的转换矩阵。 参数dsize:输出图像的大小。 参数flags:设置插值方法。默认为INTER_LINEAR表示双线性插值,INTER_NEAREST表示最近邻插值,WARP_INVERSE_MAP表示M作为反转转换 (dst->src) 。 参数borderMode:像素外推方法,默认为BORDER_CONSTANT,指定常数填充。翻阅官方文档发现还有一个选项是BORDER_REPLICATE。 参数borderValue:常数填充时边界的颜色设置,默认是(0,0,0),表示黑色。这就是为什么透视变换后图片周围是黑色的原因。这里需要注意的是类型为Scalar (B, G, R)。 3.函数的使用代码如下: #include #include using namespace std; using namespace cv; void main() { Mat img = imread("test.png"); Point2f AffinePoints0[4] = { Point2f(0, 0), Point2f(img.cols, 0), Point2f(0, img.rows), Point2f(img.cols, img.rows) };//变化前的4个节点 Point2f AffinePoints1[4] = { Point2f(100, 0), Point2f(img.cols - 100, 0),Point2f(0, img.rows), Point2f(img.cols, img.rows) };//变化后的4个节点 Mat Trans = getPerspectiveTransform(AffinePoints0, AffinePoints1);//由4组映射点得到变换矩阵 Mat dst_perspective;//存储目标透视图像 warpPerspective(img, dst_perspective, Trans, Size(img.cols, img.rows));//执行透视变换 imshow("原图像", img); imshow("透视变换后", dst_perspective); waitKey(); }执行结果如下: 可以看到,原本的效果是从正面平视电脑屏幕,现在透视变换后是仰视电脑屏幕(或者说电脑屏幕向后倾斜)。 4.映射点标记为了更清晰地知道变换前后的映射点,我们可以在图上标注出来,这里使用到OpenCV的circle函数。 ①函数原型void circle() circle ( InputOutputArray img, Point center, int radius, const Scalar &color, int thickness=1, int lineType=LINE_8, int shift=0) ②函数功能在图像上画一个具有给定中心和半径的空心或实心圆。 ③函数参数参数img:画圆绘制的图像。 参数center:画圆的圆心坐标,类型为Scalar(x, y)。 参数radius:圆的半径。 参数color:圆的颜色,规则为(B,G,R),类型为Scalar(B,G,R)。 参数thickness:如果正数表示组成圆的线条的粗细程度。如果是负数表示圆是否被填充,如FILLED表示要绘制实心的圆。 参数line_type:线条的类型,默认是LINE_8。 参数shift:圆心坐标点和半径值的小数点位数,默认为0位。 ④代码过程 for (int i = 0; i < 4; i++)//显示4组映射点的位置 { //画一个圆心在映射点(转换前),半径为10,线条粗细为3,红色的圆 circle(img, AffinePoints0[i], 10, Scalar(59, 23, 232), 3); //画一个圆心在映射点(转换后),半径为10,线条粗细为3,蓝色的圆 circle(dst_perspective, AffinePoints1[i], 10, Scalar(139, 0, 0), 3); }执行效果: 可以看到转换前后的4组点都被画了出来,因为我映射点设置在边角,所以只能看得到圆的一部分。当设置在图片中间时可以看到整个圆形。 5.库函数的实现一般直接调用上述函数即可实现透视变换,这里为了更好的理解变换过程,我对上面两个函数中的透视变换函数进行了实现(求变换矩阵的函数就是纯数学问题,这里不再重复),可能与官方的库函数实现有所不同。 warpPerspective的实现思路是: 已知变换矩阵和源图像,需要使用变换矩阵将源图像透视变换为目标图像。这个过程并不是我们上面理解的正向过程,即将变换矩阵T乘上原图像的所有坐标 已知: 同除以 同左乘 即: 设求得的变换矩阵T的逆 展开后可以得到: 该式表明,当我们求得变换矩阵T的逆 代码实现过程如下: //自己实现的wrapPerspective函数 void _wrapPerspective(const Mat& src, const Mat& T, Mat& dst)//src为源图像,T为变换矩阵,dst为目标图像 { dst.create(src.size(), src.type());//创建一个和原图像一样大小的Mat Mat T_inverse;//变换矩阵的逆 invert(T, T_inverse);//求矩阵T的逆,结果存到T_inverse //取出矩阵中的值 double c11 = T_inverse.ptr(0)[0]; double c12 = T_inverse.ptr(0)[1]; double c13 = T_inverse.ptr(0)[2]; double c21 = T_inverse.ptr(1)[0]; double c22 = T_inverse.ptr(1)[1]; double c23 = T_inverse.ptr(1)[2]; double c31 = T_inverse.ptr(2)[0]; double c32 = T_inverse.ptr(2)[1]; double c33 = T_inverse.ptr(2)[2]; //遍历目标图像的每个位置,求取原图像对应位置的像素值 for (int y = 0; y < dst.rows; y++) { for (int x = 0; x < dst.cols; x++) { double xp = c11 * x + c12 * y + c13; double yp = c21 * x + c22 * y + c23; double z = c31 * x + c32 * y + c33;//z' z = z ? 1.0 / z : 0;//z'不为0时求导数,否则设为0 xp *= z; yp *= z; //将双精度坐标限制在整型能表示的最大最小值之间 double fx = max((double)INT_MIN, min((double)INT_MAX, xp)); double fy = max((double)INT_MIN, min((double)INT_MAX, yp)); //转化为int,这里简单地使用了最近邻插值 int X = saturate_cast(fx); int Y = saturate_cast(fy); //是否在原图像大小范围内 if (X >= 0 && X < src.cols && Y >= 0 && Y < src.cols) { dst.at(y, x)[0] = src.at(Y, X)[0]; dst.at(y, x)[1] = src.at(Y, X)[1]; dst.at(y, x)[2] = src.at(Y, X)[2]; } else//以黑色填充 { dst.at(y, x)[0] = 0; dst.at(y, x)[1] = 0; dst.at(y, x)[2] = 0; } } } }运行效果: 和官方的函数实现的结果比较可发现基本一致,说明实现思路正确。 三、透视变换的应用 1、透视变换的交互程序 ①实验要求:设计一个交互程序,可以编辑四边形顶点,并且顶点位置改变时图像形变的结果可以实时更新。 ②实验思路:根据上面提到的知识,实现已知4对映射点的的透视变换是很容易的。因此问题的难点在于如何设计交互程序,这里需要使用到OpenCV的鼠标点击事件。 void setMousecallback(const string& winname, MouseCallback onMouse, void* userdata=0)参数描述: winname:窗口的名字。 onMouse:鼠标响应函数或者说回调函数。指定窗口里每次鼠标事件发生的时候,被调用的函数指针。 这个函数的原型为void on_Mouse(int event, int x, int y, int flags, void* param)。 userdata:传给回调函数的参数,默认为0。这个参数我个人还没用到过。 void MouseCallback(int event,int x,int y,int flags,void *useradata);参数描述: event:鼠标事件。 x:鼠标事件的x坐标。 y: 鼠标事件的y坐标。 flags: 代表鼠标的拖拽事件和键盘鼠标联合的事件。 userdata : 可选的参数,目前没用到过。 鼠标事件event主要有下面几种: EVENT_MOUSEMOVE :鼠标移动 EVENT_LBUTTONDOWN : 鼠标左键按下 EVENT_RBUTTONDOWN : 鼠标右键按下 EVENT_MBUTTONDOWN : 鼠标中键按下 EVENT_LBUTTONUP : 鼠标左键放开 EVENT_RBUTTONUP : 鼠标右键放开 EVENT_MBUTTONUP : 中键放开 EVENT_LBUTTONDBLCLK : 左键双击 EVENT_RBUTTONDBLCLK : 右键双击 EVENT_MBUTTONDBLCLK : 中键双击 Flags主要有一下几种: EVENT_FLAG_LBUTTON :左键拖拽 EVENT_FLAG_RBUTTON : 右键拖拽 EVENT_FLAG_MBUTTON : 中键拖拽 EVENT_FLAG_CTRLKEY : Ctrl按下不放 EVENT_FLAG_SHIFTKEY : shift按下不放 EVENT_FLAG_ALTKEY : alt按下不放 总体思路: 为了更好的观感,我们需要一张比原图像稍大一点的画布来放目标图像。初始4个映射点在原图像的4个角上。在这张画布上,当鼠标移动到4个映射点的圆形范围时,如果此时按下左键不放开就可以实现映射点的拖拽。当鼠标左键放开时,此时鼠标的位置就是映射点移动后的位置。然后重新计算变换矩阵,对原图像实现透视变换并显示。 当然实时更新也可以理解为当左键拖拽时映射点位置一发生变化就计算变换矩阵并实现透视变换,但实验中发现这样延迟很高(debug模式下),并且实际使用中我们有时候已经明确要将映射点移动到某个目标位置了,但在这移动过程中都会进行不必要的透视变换。 ③实现过程重点在于鼠标事件的编写,其他的过程都与前面透视变换方法相似。 当左键按下时,需要判断是否点击在了映射点的区域,如果点在了有效区域,那么还需要记录点击的是哪一个映射点。 当左键按下并拖拽时,如果需要在拖拽过程中实时更新透视变换,那么实时记录映射点位置并进行透视变换。我这里实现方式上选择了左键松开后再进行透视变换,因此左键拖拽时不需要记录映射点位置。但为了更好的交互效果,我还是记录位置并绘制了一些直线和圆形进行交互提示。 当左键松开后,执行透视变换。 具体代码如下: void mouseHander(int event, int x, int y, int flags, void* p) { if (event == EVENT_LBUTTONDOWN)//左键按下 { for (int i = 0; i < 4; i++) { //判断是否选择了某个映射点 if (abs(x - dstPoint[i].x) = 0)//左键松开 { //执行透视变换 Mat Trans = getPerspectiveTransform(srcPoint, dstPoint);//由4组映射点得到变换矩阵 warpPerspective(srcImg, dstImg, Trans, Size(dstImg.cols, dstImg.rows));//执行透视变换 for (int i = 0; i < 4; i++)//显示4组映射点的位置 { //画一个圆心在映射点(转换后),半径为10,线条粗细为3,黄色的圆 circle(dstImg, dstPoint[i], radius, Scalar(0, 215, 255), 3); } imshow("透视变换后", dstImg); pickPoint = -1;//重置选取状态 } } ④运行结果接着对不同图片进行测试: 可以看到对路的海报透视变换后,我们看的视角从平视变成了俯视路面。 今天随手拍了一张图书馆的照片,是从图书馆左侧的位置拍摄的,对其透视变换: 可以看到调整完后,视角更接近于正面。 ⑤源代码 #include #include using namespace std; using namespace cv; Mat srcImg, dstImg;//原图像、目标图像 Point2f srcPoint[4], dstPoint[4];//原图像和目标图像的4个映射点 Point2f beforePlace;//记录移动映射点之前的位置 int radius;//映射点的判定半径 int pickPoint;//记录点击了哪个点 void mouseHander(int event, int x, int y, int flags, void* p) { if (event == EVENT_LBUTTONDOWN)//左键按下 { for (int i = 0; i < 4; i++) { //判断是否选择了某个映射点 if (abs(x - dstPoint[i].x) = 0)//左键松开 { //执行透视变换 Mat Trans = getPerspectiveTransform(srcPoint, dstPoint);//由4组映射点得到变换矩阵 warpPerspective(srcImg, dstImg, Trans, Size(dstImg.cols, dstImg.rows));//执行透视变换 for (int i = 0; i < 4; i++)//显示4组映射点的位置 { //画一个圆心在映射点(转换后),半径为10,线条粗细为3,黄色的圆 circle(dstImg, dstPoint[i], radius, Scalar(0, 215, 255), 3); } imshow("透视变换后", dstImg); pickPoint = -1;//重置选取状态 } } void main() { srcImg = imread("library.jpg"); radius = 10;//设置四个点的圆的半径 pickPoint = -1; //映射前的4个点 srcPoint[0] = Point2f(0, 0); srcPoint[1] = Point2f(srcImg.cols, 0); srcPoint[2] = Point2f(srcImg.cols, srcImg.rows); srcPoint[3] = Point2f(0, srcImg.rows); //创建一张略大于原图像的画布 dstImg = Mat::zeros(Size(2 * radius + 100 + srcImg.cols, 2 * radius + 100 + srcImg.rows), srcImg.type()); //初始映射后的4个点 dstPoint[0] = Point2f(radius + 50, radius + 50); dstPoint[1] = Point2f(radius + 50 + srcImg.cols, radius + 50); dstPoint[2] = Point2f(radius + 50 + srcImg.cols, radius + 50 + srcImg.rows); dstPoint[3] = Point2f(radius + 50, radius + 50 + srcImg.rows); Mat Trans = getPerspectiveTransform(srcPoint, dstPoint);//由4组映射点得到变换矩阵 Mat dst_perspective;//存储目标透视图像 warpPerspective(srcImg, dstImg, Trans, Size(dstImg.cols, dstImg.rows));//执行透视变换 for (int i = 0; i < 4; i++)//显示初始4组映射点的位置 { //画一个圆心在映射点(转换后),半径为10,线条粗细为3,黄色的圆 circle(dstImg, dstPoint[i], radius, Scalar(95, 180, 243), 3); } imshow("原图像", srcImg); imshow("透视变换后", dstImg); //鼠标事件 setMouseCallback("透视变换后", mouseHander); waitKey(); } 2.虚拟广告牌的实现根据上面的透视变换的过程,我们可以想到一种应用:将图片A作为广告牌透视变换到图片B的特定位置。 实现过程也很容易,我们将图片A的初始映射点设置在四个角上,然后在背景图B上选择透视变换后的4个映射点,再将图片A透视变换到指定位置(将图片B的原位置覆盖)。这里需要注意的是映射点的选取需要规定好固定的顺序,否则无法与原本的映射点一一对应,我默认选取点的顺序是从左上角开始顺时针选取。 #include #include using namespace std; using namespace cv; Mat srcImg, dstImg;//原图像、目标图像 Mat resultImg;//结果图像 vector srcPoints, dstPoints;//原图像和目标图像的映射点 int pickNums;//目前已选取的节点 void mouseHander(int event, int x, int y, int flags, void* p) { if (event == EVENT_LBUTTONDOWN)//左键按下 { Mat tmp = dstImg.clone(); if (pickNums == 4)//选取的点超过4个后,下一次点击会实现透视变换 { //执行透视变换 Mat Trans = getPerspectiveTransform(srcPoints, dstPoints);//由4组映射点得到变换矩阵 warpPerspective(srcImg, tmp, Trans, Size(tmp.cols, tmp.rows));//执行透视变换 resultImg = dstImg.clone(); for (int y = 0; y < dstImg.rows; y++) { for (int x = 0; x < dstImg.cols; x++) { if ((int)tmp.at(y, x)[0] == 0 && (int)tmp.at(y, x)[1] == 0 && (int)tmp.at(y, x)[2] == 0)//像素点全0 continue; else//非全0 { resultImg.at(y, x)[0] = tmp.at(y, x)[0]; resultImg.at(y, x)[1] = tmp.at(y, x)[1]; resultImg.at(y, x)[2] = tmp.at(y, x)[2]; } } } imshow("虚拟广告牌", resultImg); dstPoints.clear(); pickNums = 0; } else//选取的节点还没4个 { dstPoints.push_back(Point2f(x, y)); pickNums++; for (int i = 0; i < dstPoints.size(); i++) { circle(tmp, dstPoints[i], 5, Scalar(0, 215, 255), 3); } imshow("虚拟广告牌", tmp); } } } int main() { srcImg = imread("test.png");//透视变换的图像,也就是广告图 //设置原图像的4个映射点 srcPoints.push_back(Point2f(0, 0)); srcPoints.push_back(Point2f(srcImg.cols, 0)); srcPoints.push_back(Point2f(srcImg.cols, srcImg.rows)); srcPoints.push_back(Point2f(0, srcImg.rows)); dstImg = imread("library.jpg");//背景图 imshow("虚拟广告牌", dstImg); //鼠标事件 setMouseCallback("虚拟广告牌", mouseHander); waitKey(0); return 0; }思路是比较简单的,就是每次累计选取4个点,选完后在将广告牌图片透视变换映射到相应位置。 因为透视变换后的图片使用黑色全0填充边缘,所以对于(0,0,0)使用原本背景图,非(0,0,0)则使用透视变换后的图片。这样有一个问题就是如果广告牌中有像素为(0,0,0)那么不会盖过背景图,但是测试下来发现一般图片出现的(0,0,0)的像素较少,或者我们也可以将原本图片中像素值为(0,0,0)统一加上一个很小的偏移量比如变为(0,0,1),对整体影响不大。 实现效果: 我们还可以用来把同学的电脑屏幕变成你想要的照片: 为了实现动态的广告牌,我们需要做的修改就是将广告牌图片更换为视频文件,也就是从视频文件中读取出每一帧进行透视变换。 以下是改动后的代码: #include #include using namespace std; using namespace cv; Mat dstImg, frame;//原图像、目标图像,视频帧 Mat resultImg;//结果图像 vector srcPoints, dstPoints;//原图像和目标图像的映射点 int pickNums;//目前已选取的节点 void mouseHander(int event, int x, int y, int flags, void* p) { if (event == EVENT_LBUTTONDOWN)//左键按下 { Mat tmp = dstImg.clone(); if (pickNums == 4)//选取的点超过4个后,下一次点击会实现透视变换 { //打开视频文件 VideoCapture capture; capture.open("sdu_cut.mp4"); if (!capture.isOpened()) { cout |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |