基于OpenCV的图像透视变换详解(从理论到实现再到实践)

一、仿射变换与透视变换

         一直无法理解两种仿射变换与透视变换的区别,因此详细学习了两种变换的具体细节,重新书写了公式,并给出自己的一些看法。

1.仿射变换

        可以认为,仿射变换透视变换的一种特例

        仿射变换是一种二维坐标二维坐标之间的线性变换,也就是只涉及一个平面内二维图形的线性变换。

        图形的平移旋转错切放缩都可以用仿射变换的变换矩阵表示。

        它保持了二维图形的两种性质:

       ① “平直性”:直线经过变换之后依然是直线。一条直线经过平移旋转错切放缩都还是一条直线。

        ②“平行性”:变换后平行线依然是平行线,且直线上点的位置顺序不变。

        直观的感受就是,我们在电脑上对一张图片进行拖动、翻转、拉伸等等操作,看这一张图片的视角是不会变的。

        任意的仿射变换都能表示为一个坐标向量乘以一个矩阵的形式,下面是几种仿射变换的矩阵形式。

        放缩

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ \end{array} \right ] = \left[ \begin{array}{c} T_{x}x\\ T_{y}y\\ \end{array} \right ] = \left[ \begin{array}{ccc} T_{x}& 0 \\ 0& T_{y} \\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ \end{array} \right ] \end{equation}

        旋转

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ \end{array} \right ] = \left[ \begin{array}{c} xcos\theta-ysin\theta\\ xsin\theta+ycos\theta\\ \end{array} \right ] = \left[ \begin{array}{cc} cos\theta& -sin\theta \\ sin\theta& cos\theta \\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ \end{array} \right ] \end{equation}

        错切

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ \end{array} \right ] = \left[ \begin{array}{c} x+ytan\phi\\ y+xtan\varphi\\ \end{array} \right ] = \left[ \begin{array}{cc} 1& tan\phi \\ tan\varphi& 1 \\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ \end{array} \right ] \end{equation}

        上面几种变换都可以直接只用2x2矩阵变换,但是平移无法做到,因为在2x2矩阵中无论怎么相乘都无法变换出一个常数量。因此需要将原本的2维坐标向量变成齐次坐标,也就是用3维向量来表示2维向量。

        \left[ \begin{array}{c} x\\ y\\ \end{array} \right ] => \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ]

        平移:

        \begin{equation} \left[ \begin{array}{c} x'\\ y'\\ \end{array} \right ] = \left[ \begin{array}{c} x+T_{x}\\ y+T_{y}\\ \end{array} \right ] = \left[ \begin{array}{ccc} 1& 0 &T_{x}\\ 0& 1 &T_{y}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        变成齐次坐标后,为了实现原本2x2矩阵的放缩、旋转、错切的变换,只需要令T_{x}=T_{y}=0即可。

        上面的变换都是线性变换,因此仿射变换可以用以下通式来表示,也就是网上常见到的形式:

        \begin{equation} \left[ \begin{array}{c} x'\\ y'\\ \end{array} \right ] = \left[ \begin{array}{c} a_{11}x+a_{12}y+a_{13}\\ a_{21}x+a_{22}y+a_{23}\\ \end{array} \right ] = \left[ \begin{array}{ccc} a_{11}& a_{12}&a_{13}\\ a_{21}& a_{22} &a_{23}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        此时仿射变换的变换矩阵T= \left[ \begin{array}{ccc} a_{11}& a_{12}&a_{13}\\ a_{21}& a_{22} &a_{23}\\ \end{array} \right ]2x3矩阵。

        因此坐标变换的方程组如下:

        \begin{equation} \left\{ \begin{matrix}{} x'=a_{11}x+a_{12}y+a_{13} \\ y'=a_{21}x+a_{22}y+a_{23} \\ \end{matrix} \right. \end{equation}

        可以看到有6个未知的系数,需要3对映射点(前提是相互独立)才能求解。这不难理解,6个变量自然需要至少列6个等式才可计算,而1对映射点可以提供2个等式

        同时3个点唯一确定一个平面,另外的3个映射点由于是线性变换也必然在同一个平面内,所以可以说仿射变换是平面内的图形变换。

2.透视变换

        透视变换是将图片投影到一个新的视平面,也称作投影映射。

        它是二维(x,y)三维(X,Y,Z),再到另一个二维空间(x',y')的映射。

        相对于仿射变换,它不仅仅是线性变换。它提供了更大的灵活性,可以将一个四边形区域映射到另一个四边形区域。

        透视变换也是通过矩阵乘法实现的,使用的是一个3x3的矩阵,矩阵的前两行与仿射矩阵相同,这意味着仿射变换的所有变换透视变换也可以实现。而第三行则用于实现透视变换。

        透视变换也使用了齐次坐标来表示二维向量:

        \begin{equation} \left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] = \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        

        此时透视变换的变换矩阵T= \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ]3x3矩阵

        透视变换得到的\left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ]不是最后的坐标,需要进一步转化:

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] = z' \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ] \end{equation}

        \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ]才是最后转化后的坐标,即:

\begin{equation} \left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ] = \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ] \end{equation}

        事实上这里就可以明白为什么仿射变换是透视变换的一种特例。因为如果仿射变换后的坐标向量也用齐次坐标\left[ \begin{array}{c} x'\\ y'\\ 1\\ \end{array} \right ]表示,2x3矩阵转为3x3矩阵可以看作是:

T_{affine}= \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ 0& 0 &1\\ \end{array} \right ]

        此时仿射变换与透视变换的形式得到统一,仿射变换过程视为如下:

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ 1\\ \end{array} \right ] = \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ 0& 0 &1\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        所以仿射变换只是透视变换矩阵第三行\left[ \begin{matrix} 0&0&1\\ \end{matrix} \right ]的一种特殊情况。此时已经扩展到3维向量,其实我们也可以理解为仿射变换是在空间坐标系Z=1的平面上进行的平面内的图像变换,即z方向的值无论怎么变都是1,始终都限制在Z=1平面内。

        再回到透视变换,整个透视变换的过程如下:

\begin{equation} \begin{aligned} \left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ] = \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ] = \frac{1}{z'} \left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] = \frac{1}{z'} \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \\= \frac{1}{a_{31}x+a_{32}y+a_{33}} \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{aligned} \end{equation}

        因此坐标变换的方程组如下:

\begin{equation} \left\{ \begin{matix}{} x''=\frac{x'}{z'}=\frac{a_{11}x+a_{12}y+a_{13}}{a_{31}x+a_{32}y+a_{33}} \\ y''=\frac{y'}{z'}=\frac{a_{21}x+a_{22}y+a_{23}}{a_{31}x+a_{32}y+a_{33}} \\ \end{matix} \right. \end{equation}

        共有9个未知参数,可以进一步简化为8个未知参数,也就是网上常见的形式。

        这里我的思路是:证明变换矩阵T= \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ]与变换矩阵T'=kT= \left[ \begin{array}{ccc} ka_{11}& ka_{12} &ka_{13}\\ ka_{21}& ka_{22} &ka_{23}\\ ka_{31}& ka_{32} &ka_{33}\\ \end{array} \right ](k是常数),所表示的变换是等价的。

        将变换矩阵设为T',代入运算:

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] =T' \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] =kT \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] = \left[ \begin{array}{ccc} ka_{11}& ka_{12} &ka_{13}\\ ka_{21}& ka_{22} &ka_{23}\\ ka_{31}& ka_{32} &ka_{33}\\ \end{array} \right ] \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        得到方程组如下:

\begin{equation} \left\{ \begin{matix}{} x''=\frac{k(a_{11}x+a_{12}y+a_{13})}{k(a_{31}x+a_{32}y+a_{33})}=\frac{a_{11}x+a_{12}y+a_{13}}{a_{31}x+a_{32}y+a_{33}} \\ y''=\frac{k(a_{21}x+a_{22}y+a_{23})}{k(a_{31}x+a_{32}y+a_{33})}=\frac{a_{21}x+a_{22}y+a_{23}}{a_{31}x+a_{32}y+a_{33}} \\ \end{matix} \right. \end{equation}

        可以发现最后T'计算出来的x'',y''与使用变换矩阵T计算出来的结果是一样的。

        因此对于T= \left[ \begin{array}{ccc} a_{11}& a_{12} &a_{13}\\ a_{21}& a_{22} &a_{23}\\ a_{31}& a_{32} &a_{33}\\ \end{array} \right ],我们总可以等价为\frac{T}{a_{33}}= \left[ \begin{array}{ccc} \frac{a_{11}}{a_{33}}& \frac{a_{12}}{a_{33}} &\frac{a_{13}}{a_{33}}\\ \frac{a_{21}}{a_{33}}& \frac{a_{22}}{a_{33}} &\frac{a_{23}}{a_{33}}\\ \frac{a_{31}}{a_{33}}& \frac{a_{32}}{a_{33}} &1\\ \end{array} \right ]所带来的转换。

        对上述变量重新命名即可得到变换矩阵T:

T= \left[ \begin{array}{ccc} b_{11}& b_{12} &b_{13}\\ b_{21}& b_{22} &b_{23}\\ b_{31}& b_{32} &1\\ \end{array} \right ]

        此时只有8个未知参数,变换方程组也变成如下:

\begin{equation} \left\{ \begin{matix}{} x''=\frac{x'}{z'}=\frac{b_{11}x+b_{12}y+b_{13}}{b_{31}x+b_{32}y+1} \\ y''=\frac{y'}{z'}=\frac{b_{21}x+b_{22}y+b_{23}}{b_{31}x+b_{32}y+1} \\ \end{array} \right. \end{equation}

        求解8个未知数需要8个等式,1组映射点提供2个等式,所以需要找到4组映射点,这也是为什么我们需要提供变换前后4个点来表示透视变换。

        至此我们也可以理解为什么透视变换是二维到三维,三维又到二维的过程。变换之前的点为\left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ],这是三维空间上的点,但我们认知上它在二维平面Z=1上的投影是(x,y)。通过矩阵变换成三维空间中的点\left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ],再通过除以三维中Z轴的值z'得到三维空间的点\left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ],最后投影到二维平面Z=1得到点(x',y')。整个过程就是把二维转到三维,再转映射回之前的二维空间。

        透视变换效果相当于观察者的视角发生改变时所观察到画面产生的变化。

        为什么通过一个矩阵可以实现这种视角变化?具体的数学原理在网上没找到,大部分文章也只是止步于变换矩阵T,对于具体原因没有深入说明,这困扰了我很长时间。

        经过推证,我认为透视变换的过程应该是如下我画的图:

        首先观察点位于原点(0,0,0),然后往z轴的正方向看去,投影面上(x,y,1)就是我们在显示屏看到物体的位置。

        经过透视变换矩阵T变化,原本的(x,y,1)变成(x',y',z')(可以证明,变换后的点仍然处于空间中的同一个平面)。此时的坐标已经不仅仅在Z=1平面上,而是在整个三维空间中,也就是矩阵T会将原本在Z=1平面内的图形转化为空间某一个平面的图形。这就是转换矩阵使得看图片的视角变化的原因。

        然后该图形各个点与视点(即原点)连线,在Z=1投影面上投影形成图形(x'',y'')。数值上的表现是三个坐标值都除以z',这么做的原因其实是几何上的等比例的缩放:

\frac{0-1}{0-z'}=\frac{0-x''}{0-x'}=\frac{0-y''}{0-y'}

        这也解决了我一直以来的困惑,即透视变换引发我们图片视角的变化并不是我们观察者视角的变化,而是观察者视角不变而对物体空间位置发生了转换,导致了我们看到的图片视角变化。这可以理解为:不是我们人走动看到了物体角度发生变化,而是人始终保持不动,其他人对物体进行了挪动导致我们看到这个物体角度发生了变化。

二、透视变换实现

        在应用中,需要用到OpenCV的两个函数getPerspectiveTransform()warpPerspective()两个函数。

1.getPerspectiveTransform()

Mat getPerspectiveTransform(InputArray src, InputArray dst, int solveMethod = DECOMP_LU)

①功能描述

        从4对映射点计算透视变换的变换矩阵T,返回的矩阵数据类型是Mat。这里需要注意的是,1对映射点指的是\left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ]\left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ],但是变换的矩阵T作用在\left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ]\left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ]。即:

\left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] = T \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ]

②参数描述

        参数 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());

①功能描述

        将变换矩阵T应用于原图像,使其透视变换为目标图像。

②参数描述

        参数src:输入图像。

        参数dst:输出图像,需要初始化一个空矩阵用来保存结果,不用设定矩阵尺寸。

        参数M3x3的转换矩阵。

        参数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 <iostream>
#include<opencv2/opencv.hpp>
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乘上原图像的所有坐标\left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ]得到目标图像的坐标。因为这样会导致目标图像上的个别坐标\left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ]没有映射。我们使用反向过程,将\left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ]映射回原图像寻找目标像素值。反向映射的推导过程如下:

        已知:

\begin{equation} \left[ \begin{array}{c} x'\\ y'\\ z'\\ \end{array} \right ] = T \left[ \begin{array}{c} x\\ y\\ 1\\ \end{array} \right ] \end{equation}

        同除以z'

\begin{equation} \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ] = T \left[ \begin{array}{c} \frac{x}{z'}\\ \frac{y}{z'}\\ \frac{1}{z'}\\ \end{array} \right ] \end{equation}

        同左乘T^{-1}

\begin{equation} T^{-1} \left[ \begin{array}{c} \frac{x'}{z'}\\ \frac{y'}{z'}\\ 1\\ \end{array} \right ] = \left[ \begin{array}{c} \frac{x}{z'}\\ \frac{y}{z'}\\ \frac{1}{z'}\\ \end{array} \right ] \end{equation}

        即:

\begin{equation} T^{-1} \left[ \begin{array}{c} x''\\ y''\\ 1\\ \end{array} \right ] = \left[ \begin{array}{c} \frac{x}{z'}\\ \frac{y}{z'}\\ \frac{1}{z'}\\ \end{array} \right ] \end{equation}

        设求得的变换矩阵T的逆T^{-1}为:

T^{-1}= \left[ \begin{array}{ccc} c_{11}& c_{12} &c_{13}\\ c_{21}& c_{22} &c_{23}\\ c_{31}& c_{32} &c_{33}\\ \end{array} \right ]

        展开后可以得到:

\begin{equation} \left\{ \begin{matrix}{} x=(c_{11}x''+c_{12}y''+c_{13})z' \\ y=(c_{21}x''+c_{22}y''+c_{23})z'\\ z'=\frac{1}{c_{31}x''+c_{32}y''+c_{33}}\\ \end{array} \right. \end{equation}

        该式表明,当我们求得变换矩阵T的逆T^{-1}之后,对于目标图像上的任意坐标(x'',y'')都可以用上面公式求得在原图像上对应坐标的位置(x,y),进而得到这个位置的像素值px(x,y)。当然,位置不一定是整数,可能需要插值。

        代码实现过程如下:

//自己实现的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<double>(0)[0];
    double c12 = T_inverse.ptr<double>(0)[1];
    double c13 = T_inverse.ptr<double>(0)[2];
    double c21 = T_inverse.ptr<double>(1)[0];
    double c22 = T_inverse.ptr<double>(1)[1];
    double c23 = T_inverse.ptr<double>(1)[2];
    double c31 = T_inverse.ptr<double>(2)[0];
    double c32 = T_inverse.ptr<double>(2)[1];
    double c33 = T_inverse.ptr<double>(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<int>(fx);
            int Y = saturate_cast<int>(fy);
            //是否在原图像大小范围内
            if (X >= 0 && X < src.cols && Y >= 0 && Y < src.cols)
            {
                dst.at<Vec3b>(y, x)[0] = src.at<Vec3b>(Y, X)[0];
                dst.at<Vec3b>(y, x)[1] = src.at<Vec3b>(Y, X)[1];
                dst.at<Vec3b>(y, x)[2] = src.at<Vec3b>(Y, X)[2];
            }
            else//以黑色填充
            {
                dst.at<Vec3b>(y, x)[0] = 0;
                dst.at<Vec3b>(y, x)[1] = 0;
                dst.at<Vec3b>(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) <= radius && abs(y - dstPoint[i].y) <= radius)
            {
                pickPoint = i;
                beforePlace = dstPoint[pickPoint];//记录原本的位置
                break;
            }
        }
    }
    else if (event == EVENT_MOUSEMOVE && pickPoint >= 0 )//左键按下后选取了某个点且拖拽
    {
        //更改映射后坐标
        dstPoint[pickPoint].x = x, dstPoint[pickPoint].y = y;
        //在临时图像上实时显示鼠标拖动时形成的图像
        //注意不能直接在dstImg上画,否则会画很多次
        Mat tmp = dstImg.clone();
        //原本的圆
        circle(tmp, beforePlace, radius, Scalar(228, 164, 140), -1);
        //绘制直线
        line(tmp, dstPoint[0], dstPoint[1], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[1], dstPoint[2], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[2], dstPoint[3], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[3], dstPoint[0], Scalar(246, 230, 171), 5, 8);
        //重新绘制4个圆
        for (int i = 0; i < 4; i++)
        {
            if (i != pickPoint)
                circle(tmp, dstPoint[i], radius, Scalar(228, 164, 140), -1);
            else
                circle(tmp, dstPoint[i], radius, Scalar(96, 96, 240), -1);
        }
        imshow("透视变换后", tmp);
    }
    else if (event == EVENT_LBUTTONUP && pickPoint >= 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 <iostream>
#include<opencv2/opencv.hpp>
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) <= radius && abs(y - dstPoint[i].y) <= radius)
            {
                pickPoint = i;
                beforePlace = dstPoint[pickPoint];//记录原本的位置
                break;
            }
        }
    }
    else if (event == EVENT_MOUSEMOVE && pickPoint >= 0 )//左键按下后选取了某个点且拖拽
    {
        //更改映射后坐标
        dstPoint[pickPoint].x = x, dstPoint[pickPoint].y = y;
        //在临时图像上实时显示鼠标拖动时形成的图像
        //注意不能直接在dstImg上画,否则会画很多次
        Mat tmp = dstImg.clone();
        //原本的圆
        circle(tmp, beforePlace, radius, Scalar(228, 164, 140), -1);
        //绘制直线
        line(tmp, dstPoint[0], dstPoint[1], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[1], dstPoint[2], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[2], dstPoint[3], Scalar(246, 230, 171), 5, 8);
        line(tmp, dstPoint[3], dstPoint[0], Scalar(246, 230, 171), 5, 8);
        //重新绘制4个圆
        for (int i = 0; i < 4; i++)
        {
            if (i != pickPoint)
                circle(tmp, dstPoint[i], radius, Scalar(228, 164, 140), -1);
            else
                circle(tmp, dstPoint[i], radius, Scalar(96, 96, 240), -1);
        }
        imshow("透视变换后", tmp);
    }
    else if (event == EVENT_LBUTTONUP && pickPoint >= 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 <iostream>
#include<opencv2/opencv.hpp>
using namespace std;
using namespace cv;

Mat srcImg, dstImg;//原图像、目标图像
Mat resultImg;//结果图像
vector<Point2f> 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<Vec3b>(y, x)[0] == 0 && (int)tmp.at<Vec3b>(y, x)[1] == 0 && (int)tmp.at<Vec3b>(y, x)[2] == 0)//像素点全0
                        continue;
                    else//非全0
                    {
                        resultImg.at<Vec3b>(y, x)[0] = tmp.at<Vec3b>(y, x)[0];
                        resultImg.at<Vec3b>(y, x)[1] = tmp.at<Vec3b>(y, x)[1];
                        resultImg.at<Vec3b>(y, x)[2] = tmp.at<Vec3b>(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 <iostream>
#include<opencv2/opencv.hpp>
using namespace std;
using namespace cv;

Mat dstImg, frame;//原图像、目标图像,视频帧
Mat resultImg;//结果图像
vector<Point2f> 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 << "无法打开视频文件!" << endl;
			}
			int num = 0;
			while (capture.read(frame))
			{
				num++;
				if (num == 1)//第一帧
				{
					//设置原图像的4个映射点
					srcPoints.push_back(Point2f(0, 0));
					srcPoints.push_back(Point2f(frame.cols, 0));
					srcPoints.push_back(Point2f(frame.cols, frame.rows));
					srcPoints.push_back(Point2f(0, frame.rows));
				}
				//执行透视变换
				Mat Trans = getPerspectiveTransform(srcPoints, dstPoints);//由4组映射点得到变换矩阵
				warpPerspective(frame, 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<Vec3b>(y, x)[0] == 0 && (int)tmp.at<Vec3b>(y, x)[1] == 0 && (int)tmp.at<Vec3b>(y, x)[2] == 0)//像素点全0
							continue;
						else//非全0
						{
							resultImg.at<Vec3b>(y, x)[0] = tmp.at<Vec3b>(y, x)[0];
							resultImg.at<Vec3b>(y, x)[1] = tmp.at<Vec3b>(y, x)[1];
							resultImg.at<Vec3b>(y, x)[2] = tmp.at<Vec3b>(y, x)[2];
						}
					}
				}
				imshow("虚拟广告牌", resultImg);

				//中途退出
				char c = waitKey(1);
				if (c == 27)
				{
					break;
				}

			}
			dstPoints.clear();
			srcPoints.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()
{
	dstImg = imread("b.jpg");//背景图
	imshow("虚拟广告牌", dstImg);
	//鼠标事件
	setMouseCallback("虚拟广告牌", mouseHander);
	waitKey(0);
	return 0;
}

        思路和静态广告牌基本一致,主要就是从读取普通图片变成了读取一帧一帧的视频文件。为了获取视频文件的初始映射点,需要先读取第一帧来确定四个角的映射点位置。

        上述代码在选取4个点之后需要等到该视频文件播放完才能继续开始新的一轮选取,否则会报错。

        以下是部分实验结果,因为视频文件过大无法上传,仅截取部分内容转为gif展示:

猜你喜欢

转载自blog.csdn.net/m0_51653200/article/details/127361624