OpenCV-7-通用图像变换

1 摘要

上一章主要介绍了图像卷积操作,此外我们还可以对图像做很多有趣的操作,如使用一个较小的窗口在图像上扫描以完成其他任务等。实际上即使卷积操作能够改变整个图像的效果,但是对于特定的像素,其改变仅由其周围少量像素决定。而本章将要覆盖那些不满足此规律的通用图像操作。

一些非常有用的图像变换(Image Transforms)都比较简单,并且你可能会经常使用到它们,如调整图像大小(Resize)。本章将要介绍的图像操作的输出图像在尺寸上或者某种方式上与原图并不一致,但是感觉上它们仍是同一张图片。而在下一章将会介绍一些处理结果完全是另外一幅图像的变换方法。

2 重采样

2.1 简单重采样

有时我们需要将某个逻辑分辨率的图像(尺寸)转换为另一个分辨率,即我们需要调整图像的大小。这种操作可能并不像想象的那么简单,因为对于拉伸而言涉及到像素的插值运算,对于收缩而言涉及到像素的合并运算。负责处理该认为的函数原型如下。

// src:待处理的图像
// dst:处理好的图像
// dsize:目标分辨率,当设置为cv::Size(0, 0)时表示由缩放系数fx和fy决定目标图像大小
// fx,fy:x和y轴上的缩放系数,当它们都为0时表示使用参数dsize决定目标尺寸大小,
//     需要注意这两种策略必须选择一个
// interpolation:像素插值策略,具体下文介绍
void cv::resize(cv::InputArray src, cv::OutputArray dst,
                cv::Size dsize, double fx = 0, double fy = 0,
                int interpolation = CV::INTER_LINEAR}

参数interpolation的取值和含义如下表。

参数interpolation取值 含义
cv::INTER_NEAREST 最近相邻像素
cv::INTER_LINEAR 双线性插值
cv::INTER_AREA 像素区域重采样
cv::INTER_CUBIC 双三次插值
cv::INTER_LANCZOS4 在8✖️8区域上插值

这个操作的核心问题是插值问题,图像可以看作是离散的网格数据,即在元素图像中的每个像素都存在一个整型坐标,如对于像素P(20, 17)表示第20行第17个像素。当其目标大小与元素目标不同,如更小时该像素可能被映射至一个小数坐标,则需要特殊处理。或者是目标尺寸更大时,目标图像的有些相似无法从原图中直接映射。这些问题都称为前向投影(Forward Projection)问题,通常使用逆向思维解决,即目标像素应从源像素的那个地方取值。而通过目标图像像素坐标计算出的源图参考像素坐标通常不是整数,此时就需要对其附近像素进行插值处理。

最简单的方式就是使用离参考坐标最近的像素,即使用cv::INTER_NEAREST选项。或者也可以分别使用参考坐标周围的2✖️2区域内的像素和它们在对应轴上的距离作为权重进行线性插值计算最终像素值,即使用cv::INTER_LINEAR选项。甚至可以计算参考坐标覆盖像素区域的平均值,即选用cv::INTER_AREA,但需要注意的是这种方式只对缩小图像有效,对于放大图像,该选项和cv::INTER_NEAREST效果相同。为了得到更光滑的插值效果,也可以选用参考坐标周围的4✖️4拟合三次样条插值,最后根据参考坐标的距离计算出最终的像素值,即使用cv::INTER_CUBIC。最后还可以使用Lanczos插值算法,它与三次样条插值类似,但是采样范围扩展至参考坐标周围8✖️8区域。

需要注意函数cv::resize()cv::Mat::resize()的区域,前者上文已经讲解,后者对于超出目标尺寸的原图直接裁剪,没有插值处理。

2.2 图像金字塔

图像金字塔指对原图像不断下采样,直至到想要的分辨率,这个期望可以是只包含一个像素的图像,这种技术被广泛应用于各种视觉程序中。图像金字塔分为高斯(Gaussian)和拉普拉斯(Laplacian)图像金字塔。高斯金字塔指的是下采样图像,而拉普拉斯金字塔指的是从已经下采样的图像中重构原始图像。

2.2.1 高斯金字塔
向下逐层采样

高斯金字塔的每一层Gi+1都是由其下一层Gi构建而出,其中G0为原始图像。构建的方式是首先对Gi层应用高斯卷积核,然后再移除所有的偶数行和列,从而得到Gi+1层。这样得到的每一层图像分辨率都是上一层的1/4,通过不断迭代最后就能得到高斯金字塔。高斯金字塔构建层的采样函数原型如下。

// src:输入图像
// dst:处理结果
// dstsize:输出图像的尺寸,该参数下文介绍
void cv::pyrDown(cv::InputArray src, cv::OutputArray dst,
                 const cv::Size& dstsize = cv::Size());

函数cv::pyrDown的参数dstsize默认值为cv::Size(),此时该参数不影响函数的结果。而此时输出图像的尺寸为((src.cols+1)/2, (src.rows+1)/2),其中行列的加1只是对行列为奇数的输入图像做的特殊调整,对偶数行列的原始图像而言,将不会有+1操作。对于一些特别复杂的场景而言,当需要严格控制输出图像的尺寸时,可以通过设置参数dstsize完成,但是它们需要严格满足如下条件。

构建金字塔

直接使用原图像构建整个金字塔图像序列的函数原型如下。

// src:输入图像
// dst:处理结果,构成金字塔的图像序列,可以理解为成员为cv::OutputArray的STL向量实例,
//     通常为vector<cv::Mat>
// maxlevel:需要构建金字塔最高层数
void cv::buildPyramid(cv::InputArray src, cv::OutputArrayOfArrays dst,
                      int maxlevel);

该函数运行后,得到的结果是一个标准向量容器,其中第一个元素为输入图像,其后的每一幅图像的分辨率都是上一副图像的1/4,最终这些图像可以构建出下图中左侧图像表示的金字塔结构。

在实际应用中,我们可能需要更细粒度的金字塔结构,如上图右侧图像是使用两个金字塔交错得到的细粒度结构。一种实现方式是使用函数cv::resizec()处理上图左侧图像中的每一张图片,但是明显这种方式效率较低。

另外一种方式是只使用函数cv::resizec()处理原始图像,然后在使用函数void cv::buildPyramid处理缩放后的原始图像,最后再将两次构建金字塔的结果交错排列,从而得到更细粒度的金字塔结构,即上图右侧分图。在该图中,原图的缩放系数为根号2,从而保证了金字塔的每一层图像的宽高比例都为根号2。

向上逐层恢复

OpenCV也支持通过类似(不是简单的逆运算)高斯金字塔下采样的方式对图像进行上采样构建长宽都为原图两倍的新图像,其函数原型如下。

// src:输入图像
// dst:处理结果
// dstsize:输出图像的尺寸,下文具体介绍
void cv::pyrUp(cv::InputArray src, cv::OutputArray dst,
               const cv::Size& dstsize = cv::Size());

该函数的内部逻辑是首先为原始图像每列和每行后添加新的行列,并填充为0,然后对每个像素应用高斯滤波器,从而计算出这些新增像素的值。需要注意此时高斯滤波的卷积核权重系数和为4,这是因为通过插入值为0的行列后将1个像素扩展为4个像素,为例恢复平均亮度,需要将权重和设置为4.

和函数cv::pyrDown类似,参数dstsize可以更精细的控制输出图像的分辨率,默认情况下宽高都是原始图像的两倍。同样的参数dstsize的设置必须满足如下条件,即其值非常接近于原始图像的两倍。

2.2.2 拉普拉斯金字塔

前文说过函数cv::pyrUp不是函数cv::pyrDown的简单逆运算,一个最直接的证据就是它也是一个丢失信息的运算。为了恢复原始高分辨率图像,我们需要访问在下采样过程中被丢失掉的数据,这些数据形成了拉普拉斯金字塔(Laplacian Pyramid)。拉普拉斯金字塔的第i层可以通过如下公式表示。

上述公式的UP运算表示的是对高斯金字塔的Gi+1层执行宽高都扩展为2倍的上采样操作,而g5✖️5表示使用大小为5✖️5的高斯滤波器,而这正是函数cv::pyrUp使用的滤波器,因此上述公式可以表示如下。

因此使用高斯金字塔塔的某一层图像向下构建底层原始图像的过程包含高斯金字塔上采样,以及与对应层的拉普拉斯金字塔相加两个步骤。

3 几何变换

接下来将会介绍一些图像的几何变换(Geometric Transformation),这些变换都和立体几何以及投影几何(Projective Geometry)相关。其中包含图像的缩放、平移、旋转和投影等,你可能会在很多场景需要使用到这些变化,如将图像投影到墙上(VR程序)和已经存在的场景进行混合,或者通过这些变换扩大物体识别的训练集合(机器学习)。

对于一个平面图形,几何变换有两种形式,分别是基于2✖️3矩阵的仿射变换(Affine Transforms)和基于3✖️3的投影变换(Perspective Transorms or Homographies)。它们的区别是假定原图形位于某个平面内,则仿射变换得到的结果都是正视这个平面所观察到的效果,而投影变换可能是从某个特别的角度看到的效果。

某个图形仿射变换的结果可以是其中的每个顶点的坐标列向量左乘一个2✖️2矩阵变换A,再加一个平移矩阵B构建出。即对于原始图形某点Xold,其变换后的点Xnew可以表示为Xnew = A✖️Xold + B。也可以合并AB矩阵得到变换矩阵T,并扩展原始坐标Xold至三维,得到Xe,则变换后的点Xnew可以表示为Xnew = T✖️Xold

平面内的一个矩形的仿射变换结果是一个平行四边行,而由于改变了观察点,因此矩形的透视变换的结果可能是一个任意形状的四边形。一些几何变换的示例如下图。

当已知多个图形是属于同一类形状时,通常使用仿射变换来描述它们之间的关系,而不使用透视变换,因为仿射变换需要的参数更少,解决问题也更容易。缺点是仿射变换不能非常准确的描述所有图形之间的联系,而真正的透视变换需要通过单应性(Homography)才能建模。另外一方面对于由于观察点发生变化而导致看到的图形出现轻微的形变(不属于同一类形状)时,如果在误差允许范围内,也可以直接使用仿射变换来描述它们之间的联系。

单应性是一个数学术语,表示将一个点从一个平面映射到另外一个平面。而在计算机视觉的语境下,其含义更狭隘,特指具体相同模型坐标(以模型坐标系为参考的坐标)的两个不同图像平面(通常是世界坐标系中的图像平面和投影坐标系上的投影平面)上的点的映射。这种映射关系可以通过一个3✖️3的正交矩阵表示。

3.1 仿射变换

通常需要应用到仿射变换的情况有两种,第一种是需要对某一幅图像,或者图像内部的兴趣区域执行仿射变换,第二种是需要计算一系列点点仿射变换结果。这两种情况在概念上都是计算仿射变换,但是实际实现上有很大的区别,因此OpenGL提供了两组函数来处理它们,分别是稠密仿射变换和稀疏仿射变换。

3.1.1 稠密仿射变换

稠密变换应用于图像处理,这意味着内部包含像素的合并和插值策略,从而确保得到的图像是光滑的,看上去自然的。其函数原型如下。

// src:输入图像
// dst:计算结果
// M:仿射变换矩阵,尺寸为2✖️3
// dsize:输出图像的大小
// flags:像素插值策略,取值参考函数cv::resize
// borderMode:边框扩展策略,参考上一张滤镜和卷积中的相关定义
// borderValue:当参数borderMode选择使用常量时,使用的常量值
void cv::warpAffine(cv::InputArray src, cv::OutputArray dst,
                    cv::InputArray M, cv::Size dsize, int flags = cv::INTER_LINEAR,
                    int borderMode = cv::BORDER_CONSTANT,
                    const cv::Scalar& borderValue = cv::Scalar());

计算结果dst中的每个像素都是由原始图像中的像素中的某个像素计算而得,其公式如下。需要注意等式左侧和右侧的xy值不相同。

通过该公式计算出的原始图像的参考像素坐标不一定是整数,此时就需要通过参数flags定义的策略进行插值计算。参数flags取值除了函数cv::resize的可取值外,还额外支持cv::WARP_INVERSE_MAP,可以使用逻辑与符号和其他选项叠加使用,表示从dstsrc的反向变换,而不是从srcdst的包装方式。

除了通过数学变换计算仿射矩阵外,还可以通过如下方式计算仿射矩阵。即通过三个顶点可以定义一个平行四边形,通过变换前后的平行四边形可以计算出仿射变换(以坐标原点为参考系)的矩阵,OpenCV实现该功能的函数原型如下。

// 返回值:计算得到的仿射变换矩阵
// src:变换前的三个顶点坐标
// dst:变换后的三个坐标
cv::Mat cv::getAffineTransform(const cv::Point2f* src, const cv::Point2f* dst);

示例AffineTransform使用函数cv::getAffineTransform获取了仿射变换矩阵,然后对输入图像应用这个仿射变换后显示结果,最后连续旋转并展示图像直至用户输入任意键,其核心代码如下。

int main(int argc, const char * argv[]) {
    // 读取原图
    cv::Mat src = cv::imread(argv[1], cv::IMREAD_COLOR);

    // 定义旋转之前图像轮廓顶点坐标
    cv::Point2f srcTri[] = {
        cv::Point2f(0,0),               // 左上
        cv::Point2f(src.cols - 1, 0),   // 右上
        cv::Point2f(0, src.rows - 1)    // 左下
    };
    // 定义旋转之后图像轮廓顶点坐标
    cv::Point2f dstTri[] = {
        cv::Point2f(src.cols * 0.f, src.rows * 0.33f),   // 左上
        cv::Point2f(src.cols * 0.85f, src.rows * 0.25f), // 右上
        cv::Point2f(src.cols * 0.15f, src.rows * 0.7f)   // 左下
    };

    // 计算仿射矩阵
    cv::Mat warp_mat = cv::getAffineTransform(srcTri, dstTri);
    cv::Mat dst, dst2;
    // 应用仿射变换
    cv::warpAffine(src, dst, warp_mat, src.size(),
                   cv::INTER_LINEAR,
                   cv::BORDER_CONSTANT, cv::Scalar());

    // 在仿射变换处理后的图像上目标顶点上绘制圆形
    for (int i = 0; i < 3; ++i) {
        cv::circle(dst, dstTri[i], 5, cv::Scalar(255, 0, 255), -1, cv::LINE_AA);
    }

    // 显示第一次仿射变换的结果
    cv::imshow("Affine Transform Test", dst);
    // 刮起程序直至输入任意键
    cv::waitKey();

    // 定义旋转中心
    cv::Point2f center(src.cols * 0.5f, src.rows * 0.5f);
    // 不断旋转缩放图像
    for (int frame = 0; ; ++frame) {
        // 定义旋转角度和在xy轴上的缩放系数
        double angle = frame * 3 % 360;
        double scale = (cos((angle - 60) * CV_PI/180) + 1.05) * 0.8;
        // 计算旋转缩放矩阵
        cv::Mat rot_mat = cv::getRotationMatrix2D(center, angle, scale);
        
        // 应用仿射变换
        cv::warpAffine(src, dst, rot_mat, src.size(),
                       cv::INTER_LINEAR,
                       cv::BORDER_CONSTANT, cv::Scalar());
        // 显示旋转后的图像
        cv::imshow("Rotated Image", dst);
        // 等待用户输入任意键结束程序
        if (cv::waitKey(30) >= 0) {
            break;
        }
    }
    return 0;
}

上面的代码使用到了另外一种计算仿射矩阵的方式,即函数cv::getRotationMatrix2D,该函数可以计算绕任意点的旋转缩放矩阵,其原型如下。

// 返回值:2✖️3的旋转矩阵
// center:旋转中心
// angle:旋转角度
// scale:旋转后在x轴和y轴上的缩放系数
cv::Mat cv::getRotationMatrix2D(cv::Point2f center, double angle, double scale);

该函数构建出的仿射矩阵如下图,其中α = scale✖️cos(angle),β = scale✖️sin(angle)

该示例程序的运行效果如下图,左侧是原图,中间是应用函数cv::getAffineTransform构建仿射矩阵的效果,右侧是通过函数cv::getRotationMatrix2D构建仿射矩阵的效果。

3.1.2 稀疏仿射变换

稀疏仿射变换用于处理一组坐标,其函数原型如下。源矩阵src中的每个元素都会应用仿射变换最后得到输出矩阵dst

// src:带处理的坐标数组,通道数为Ds的N✖️1矩阵,每个元素表示为列向量
// dst:目标输出矩阵,通道数为Dd的N✖️1矩阵,每个元素表示为列向量
// mtx:仿射变换矩阵,尺寸为Dd✖️Ds,右乘输入向量
void cv::transform(cv::InputArray src, cv::OutputArray dst, cv::InputArray mtx);

平面内的坐标由两个分量表示,即处理平面图像时参数src是一个双通道矩阵,对于不包含平移的仿射变换而言,仿射矩阵通常是2✖️2的,得到的输出结果也是双通道矩阵。但是对于更通用的包含平移的仿射矩阵而言,通常将平面坐标扩充至三维齐次坐标,并将第三个分量设置为1,然后使用2✖️3的仿射向量计算,这种方式的输出向量仍是一个双通道矩阵。

3.1.2 逆仿射变换

OpenCV还提供函数通过一个已知的仿射变换矩阵计算实现其逆变换所需要的矩阵,其函数原型如下。当计算出该矩阵后可以使用前文介绍的稠密或者稀疏仿射变换函数执行对转换后的结果执行逆向操作。

// M:某个仿射变换的矩阵
// iM:该变换的逆变换矩阵
void cv::invertAffineTransform(cv::InputArray M, cv::OutputArray iM);

3.2 透视变换

投影变换可以通过投影矩阵实现,需要注意的是它不是一个线性变换,因为计算的结果需要和最后一个坐标分量相除,对于二维空间而言,即z分量,更多信息可以参考OpenGL系列文章中的空间变换章节。和仿射变换一样,OpenCV提供稠密投影变换和稀疏投影变换分别用于处理图像和离散的点。

3.2.1 稠密投影变换

稠密投影变换的函数原型如下。

// src:输入图像
// dst:计算结果
// M:投影变换矩阵,尺寸为3✖️3
// dsize:输出图像的大小
// flags:像素插值策略,取值参考函数cv::resize
// borderMode:边框扩展策略,参考上一张滤镜和卷积中的相关定义
// borderValue:当参数borderMode选择使用常量时,使用的常量值
void cv::warpPerspective(cv::InputArray src, cv::OutputArray dst,
                         cv::InputArray M, cv::Size dsize,
                         int flags = cv::INTER_LINEAR,
                         int borderMode = cv::BORDER_CONSTANT,
                         const cv::Scalar& borderValue = cv::Scalar());

同样的,投影变换得到的图像中每一个像素都是从原始图像中推导出的,其映射关系满足如下公式。

和仿射变换一样,计算出的参考坐标系,即上式右侧的x和y值不一定为整数,此时需要根据参数flags定义的插值策略计算图像插值。

计算投影变换矩阵

和仿射变换类似,OpenCV提供了两种计算投影变换矩阵的方法,其中一种是根据两组,每组共4个顶点表示的投影变换前后的坐标来计算仿射矩阵,其函数原型如下。

// 返回值:3✖️3的仿射矩阵
// src:变换前的4个顶点坐标
// dst:变换后的4个顶点坐标
cv::Mat cv::getPerspectiveTransform(const cv::Point2f* src,
                                    const cv::Point2f* dst);

示例PerspectiveTransform对一幅图像应用了投影变换,其核心代码如下。

int main(int argc, const char * argv[]) {
    // 读取图像
    cv::Mat src = cv::imread(argv[1], cv::IMREAD_COLOR);
    
    // 定义旋转之前图像轮廓顶点坐标
    cv::Point2f srcQuad[] = {
        cv::Point2f(0, 0),                   // src 左上
        cv::Point2f(src.cols - 1, 0),          // src 右上
        cv::Point2f(src.cols - 1, src.rows - 1), // src 右下
        cv::Point2f(0, src.rows - 1)           // src 左下
    };
    // 定义旋转之后图像轮廓顶点坐标
    cv::Point2f dstQuad[] = {
        cv::Point2f(src.cols * 0.05f, src.rows * 0.33f),
        cv::Point2f(src.cols * 0.9f, src.rows * 0.25f),
        cv::Point2f(src.cols * 0.8f, src.rows * 0.9f),
        cv::Point2f(src.cols * 0.2f, src.rows * 0.7f)
    };
    
    // 计算投影矩阵
    cv::Mat warp_mat = cv::getPerspectiveTransform(srcQuad, dstQuad);
    cv::Mat dst;
    // 应用投影变换
    cv::warpPerspective(src, dst, warp_mat, src.size(), cv::INTER_LINEAR,
                        cv::BORDER_CONSTANT, cv::Scalar());

    // 在仿射变换处理后的图像上目标顶点上绘制圆形
    for (int i = 0; i < 4; i++) {
        cv::circle(dst, dstQuad[i], 5, cv::Scalar(255, 0, 255), -1, cv::LINE_AA);
    }

    // 显示图像
    cv::imshow("Perspective Transform Test", dst);
    // 挂起程序直至用户输入任意键
    cv::waitKey();
    
    return 0;
}

该示例程序的运行结果如下图,其中左侧是原图,右侧是应用投影变换后的效果。

3.2.2 稀疏投影变换

函数cv::transform只能处理线性的几何变换,而投影变换是非线性的,对于二维点Pold(x, y),首先需要扩展其坐标为Pold(x, y, 1),然后将执行线性变换得到的结果除以第3个坐标分量,即变换后的点坐标为Pnew(X/Z, Y/Z),其中XYZ分别是对原坐标Pold列向量左乘投影变换矩阵的结果。通过这种透视除法可以将原始坐标投影到定义的目标平面上。因此OpenCV单独提供了一个函数来完成该任务,其函数原型如下。

// src:待处理的坐标数组,通道数为2或者3的N✖️1矩阵,每个元素表示为列向量
// dst:目标输出矩阵,通道数为2或者3的N✖️1矩阵,每个元素表示为列向量
// mtx:投影变换矩阵,尺寸为3✖️3(二维)或者4✖️4(三维),右乘输入向量
void cv::perspectiveTransform(cv::InputArray src, cv::OutputArray dst, 
                              cv::InputArray mtx);

需要注意这里的投影变换矩阵构建方式和OpenGL中的透视投影变换矩阵的构建方式是有差异的,后者通过透视除法后坐标会被映射至标准设备坐标系中。另外这里我们讲到了该函数可以处理2维投影变换,但是我们需要知道,这里的二维是指嵌在三维空间中的一个平面。考虑相机从不同角度拍摄物体时其本身实际上是位于三维空间内的,则不难理解这个概念。

4 通用变换

仿射变换和投影变换都是通用变换的特例,这两种变换本质上都是将原图中的某个像素映射到不同位置的目标图像中。使用更一般的概念概括,它们都是在执行坐标映射计算。在这小节中将会介绍一些类似的变换,以及一些利用OpenCV实现我们自己的映射变换的方法。

4.1 极坐标映射

在前面的章节中介绍到两个函数cv::cartToPolar()cv::polarToCart(),它们用于将坐标在极坐标系和笛卡尔坐标系中转换。除了单纯的坐标系转换,实际上在更复杂的映射变换中也需要用到这两个函数,如在下文即将讲到的通过函数cv::logPolar()实现的对数映射。

笛卡尔坐标映射至极坐标

笛卡尔坐标映射至极坐标的函数原型如下。该函数在计算弧度时使用了反正切函数atan2(y, x)的近似值,0表示的时x轴正方向。

// x:输入矩阵,单通道,存储笛卡尔坐标的x分量
// y:输入矩阵,单通道,存储笛卡尔坐标的y分量
// magnitude:输出矩阵,单通道,极坐标的幅度分量
// angle:输出矩阵,单通道,极坐标的角度分量
// angleInDegrees:极坐标角度分量单位,false为弧度[0, 2pi),true为角度[0, 360)
void cv::cartToPolar(cv::InputArray x, cv::InputArray y,
                     cv::OutputArray magnitude,
                     cv::OutputArray angle, bool angleInDegrees = false);

该函数的一个使用场景是如果已经通过cv::Sobel()函数计算处理图像的x和y方向上的导数,或者通过函数cv::DFT()cv::filter2D()执行卷积运算得到类似的结果。将x和y方向的导数分布存储在矩阵dx_imgdy_img中,通过这两个矩阵可以的到一系列笛卡尔坐标系中的点。然后定义两个输出矩阵img_magimg_angle分布保存映射变换后的幅度和角度值,执行代码cvCartToPolar(dx_img, dy_img, img_mag, img_angle, True)得到极坐标。通过幅度阈值过滤掉无效数据后就可以建立角度统计的直方图,然后用于更下一步处理,如识别图形。

极坐标映射至笛卡尔坐标

极坐标映射至笛卡尔坐标的函数原型如下。

// magnitude:输入矩阵,单通道,极坐标的幅度分量
// angle:输入矩阵,单通道,极坐标的角度分量
// x:输出矩阵,单通道,笛卡尔坐标的x分量
// y:输出矩阵,单通道,笛卡尔坐标的y分量
// angleInDegrees:极坐标角度分量单位,false为弧度[0, 2pi),true为角度[0, 360)
void cv::polarToCart(cv::InputArray magnitude, cv::InputArray angle,
                     cv::OutputArray x, cv::OutputArray y,
                     bool angleInDegrees = false);

4.2 对数极坐标

对于二维图像而言,对数极坐标转换是将高速坐标P(x, y)转换为以对数表示的极坐标形式P(p, θ)形式,其中p是点P至某一参考点C(xc, yc)的距离的对数,其计算公式如下。

而θ是参考点C到点P的向量和x轴正方向的夹角,其计算方式如下。

通常还对在p前面乘以系数m扩大图像的显示范围。下图演示了将一个正方形周长上的所有点以正方形为中心编码到对数极坐标的效果。

你可能会好奇为什么会有人愿意做这种转换,其实这是源于人类的视觉系统。在人眼中心有一个较小区域,该区域内部感光细胞分布十分密集,并且其向边缘以指数级的速速下降。可以做这样一个实验,尝试一直盯着墙上的某点,然后将你的手伸平,使得手指在眼睛的正前方,然后慢慢将手向边缘移动,同事保持盯着墙上的点,感受视网膜上手指图像的消失,你会发生图像的消失强烈程度随着距离的变换是以指数方式剧烈降低的。这种结构具有某种很好的数学属性,当然这部分内容超出了本文的讨论范围,即这种结构能够保存直线相交的角度。

这种变换在CV中更重要的应用是它能够用于创建二维图像的缩放旋转恒定表示。为了更好的理解这个概念,首先考虑简单的图形,即对于同一个形状的缩放和旋转后得到的多个图形,分别以图形的中心绘制其边缘的对数极坐标曲线,这些曲线经过平移后能够完全重叠。对于图像而言,则是将原图的每个像素转换到对数极坐标系表示,然后通过插值填充空白像素,则同如果是同一副图像的多个缩放和旋转变换结果,经过对数极坐标处理后经过平移,在对数极坐标系中仍然能够重叠,这就是二维图像的缩放旋转恒定表示。

如在下图中,左侧是三个正方形,每个正方形都可以看作是另一个正方形缩放和旋转变换后的结果,右图是这三个正方形边缘的在对数极坐标系中的曲线表示。可以看出长虚线的正方形是实线正方形的缩放结果,在对应的对数极坐标系中的曲线是实线曲线沿着logr轴平移的结果。而短虚线正方形是实线正方形的旋转结果,在相应的对数极坐标系中的曲线是实现曲线沿着θ轴的平移的结果。

在后面的章节中将会介绍到图像识别,现在只需要知道对整个目标图像应用对数极坐标转换是不明智的,因为这个转换对目标图形的中心位置特别敏感。更好的实现方式是首先检测出目标图形的特征点,如顶点等,然后通过关键点裁剪目标图形。再使用这些关键点的中心作为对数极坐标转换的中心,从而得到目标图形的对数极坐标表示曲线,从而和对应图形关联。

对数极坐标转换的函数原型如下。

// src:待处理的输入图像
// dst:处理完成的结果
// center:旋转的中心,即坐标转换的中心参考点
// m:前文提到的距离系数p的缩放系数
// flags:插值和填充模式
//  插值模式和前文其他函数的取值相同
//  填充模式的可选值为cv::WARP_FILL_OUTLIERS表示执行对数极坐标变换
//  cv::WARP_INVERSE_MAP表示执行对数极坐标逆变换
void cv::logPolar(cv::InputArray src, cv::OutputArray dst,
                  cv::Point2f center, double m,
                  int flags = cv::INTER_LINEAR | cv::WARP_FILL_OUTLIERS);

示例LogPolor使用对数极坐标转换处理了一副图像,随后再对其应用逆变换,其核心代码如下。

int main(int argc, const char * argv[]) {
    // 加载图像
    cv::Mat src = cv::imread(argv[1], 1);

    // 读取缩放系数,该系数越大,则得到的对数极坐标表示水平拉伸更大
    double M = atof(argv[2]);
    cv::Mat dst(src.size(), src.type());
    cv::Mat src2(src.size(), src.type());

    // 计算对数极坐标变换
    cv::logPolar(src, dst, cv::Point2f(src.cols * 0.5f, src.rows * 0.5f),
                 M, cv::INTER_LINEAR | cv::WARP_FILL_OUTLIERS);
    // 计算对数极坐标逆变换
    cv::logPolar(dst, src2, cv::Point2f(src.cols*0.5f, src.rows*0.5f),
                 M, cv::INTER_LINEAR | cv::WARP_INVERSE_MAP);
    
    // 显示图像处理的结果
    cv::imshow("log-polar", dst);
    cv::imshow("inverse log-polar", src2);
}

该程序执行的效果如下图,其中左侧是原始图像,中间是对数极坐标变换的结果,右侧是其逆变换的结果。

4.3 自定义映射

OpenCV提供函数cv::remap()实现用户自定义图像的映射方式,其函数原型如下。该方法的一种常见应用场景是优化校准的立体图像,在后续章节介绍中将会学习如何处理相机成像扭曲和对齐,以及如何将它们转换为参数map1map2

// src:输入原始图像
// dst:映射后的图像
// map1和map2的尺寸必须和输入输出图像的尺寸一致,元素数据类型必须是CV::S16C2、
//     CV::F32C1和CV::F32C2中的一个
// 函数内部在计算时会对非整型的坐标映射进行插值运算
// map1:目标图像每个像素映射后的参考坐标x分量
// map2:目标图像每个像素映射后的参考坐标y分量
// interpolation:插值方式,前文函数中的插值方式除了cv::INTER_AREA都可以使用
// borderMode:图像边界的填充方式
// borderValue:图像边界的填充常量
void cv::remap(cv::InputArray src, cv::OutputArray dst, 
               cv::InputArray map1, cv::InputArray map2,
               int interpolation = cv::INTER_LINEAR,
               int borderMode = cv::BORDER_CONSTANT,
               const cv::Scalar& borderValue = cv::Scalar());

5 图像修复

图像通常会受到噪声的污染,产生这些噪声的原因可能是拍摄图片时相机透镜上覆盖了水渍或者灰尘,也可能是老照片上出现的划痕,或者是图片的一部分直接被损坏。图像修复(Inpainting)技术通过混合损坏部分的边缘像素和损坏区域的像素修复图像。下图是一个使用该技术移除图像表面文字的效果图。

5.1 简单修复

简单的图像修复对于损坏区域不是特别大,并且其边缘的像素和纹理仍保留较好的场景才能取得较好的结果,下图演示了对于损坏区域过大的图像修复效果。

简单图像修复函数原型如下。

// src:输入图像,位深度为8,1通道灰度图或者3通道彩色图
// inpaintMask:修复蒙层,尺寸和输入图像相同,位深度为1,通道数为1,非0区域不会应用图像修复技术
// dst:修复完成的图像
// inpaintRadius:每个损坏像素计算最终颜色时的参考像素区域半径
// 较大损坏区域内部的像素在修复时可能直接使用其他更接近边缘的修复后像素的颜色
// 通常情况下该值设置为3,其值过大会出现模糊现象
// flags:图像修复方法,cv::INPAINT_NS使用Navier Stokes方法,
//       cv::INPAINT_TELEA使用Telea方法
void cv::inpaint(cv::InputArray src, cv::InputArray inpaintMask,
                 cv::OutputArray dst, double inpaintRadius, int flags);

5.2 噪声去除

噪声是图像处理中的一个重要问题,在很多应用中,引起噪声的主要原因是较低的光照环境。在较低的光照环境下,相机传感器获得的数字信号必须经过放大,因此噪声信号也被同时放大了。这类信号的特点是出现大量随机的极明亮或者暗淡的孤立像素,在彩色图像中也会出现颜色异常。

OpenCV使用的降噪算法称为快速非局部均值降噪技术(Fast Non-Local Means Denoising, FNLMD),相较于简单的降噪算法仅仅通过计算目标像素周围像素的平均值而言,FNLMD技术的中心思想是在整个图像中寻找相似像素,并计算这些像素的平均值。这里的相似并不是指的像素颜色,而是指的环境相似。对于一副图像局部细节被损坏,但是通常其他区域有类似结构的区域并未被损坏。

相似像素的识别基于一个窗口B(p, s),该窗口的中心为p,尺寸为s。对于损坏像素p定义的窗口B(p, s),以及参考点q定义的窗口B(q, s),它们的距离平方定义为如下等式。

上述等式中Ic(p)表示像素点p的c通道的强度,j是窗口B内的像素索引。即上式计算的是目标窗口和当前窗口对应像素对应通道的强度差平方和,最后再乘一个常数系数。在计算出平方距离后,像素q的权重定义为如下公式。

上述等式中的σ是噪声信号的标准偏差,单位为强度,h是一个通用的过滤参数,它定义了目标窗口和待更新窗口之间随着平方距离增加,窗口无关性的变换快慢。即对于相同的平方距离增量,h值越大,着这两个窗口的相关性越大。在应用方面即h值越大,噪声越容易移除,但是会损失图像细节,反之如果h值越小噪声移除越不彻底,但是图像细节保留得更好。

通常定义一个区域,称为搜索窗口(Search Window),通过计算该窗口内所有像素的加权平均值得到待修补像素的颜色,需要注意对于像素p而言,其权重值计算结果w(p, p)等于1,这个权重值会显得异常大,因此通常使用窗口B(p, s)中最大的权重值作为该像素点点权重。由于最终计算结果有贡献的参考像素的权重仅与定义的窗口和待更新窗口相似度相关,因此这个方法名中包含关键字“非本地”。

5.2.1 处理灰度图像

OpenCV提供了多个FNLMD算法函数,它们用于处理不同的场景,其中基础FNLMD算法函数的原型如下。

// src:输入图像,1、2或者3通道,基本数据类型为cv::U8
// 尽管支持彩色图像,也尽量不要使用该函数处理彩图,应使用函数
//     cv::fastNlMeansDenoisingColored()
// dst:降噪后的图像
// h:权重衰减系数
// templateWindowSize:权重计算窗口的尺寸
// searchWindowSize:搜索窗口的尺寸
void cv::fastNlMeansDenoising(cv::InputArray src, cv::OutputArray dst,
                              float h = 3, int templateWindowSize = 7,
                              int searchWindowSize = 21);

当使用该函数处理灰度图时,建议的权重衰减系数值如下。

噪声标准偏差 权重计算窗口 搜索窗口 权重衰减系数h
0 < σ <= 15 3✖️3 21✖️21 0.40*σ
15 < σ <= 30 5✖️5 21✖️21 0.40*σ
30 < σ <= 45 7✖️7 35✖️35 0.35*σ
45 < σ <= 75 9✖️9 35✖️35 0.35*σ
75 < σ <= 100 11✖️11 35✖️35 0.30*σ
5.2.2 处理彩色图像

处理彩色图像的降噪函数原型如下。由于人眼对亮度和颜色对感知敏感度是不相同对,因此该函数先将图片转换为LAB颜色空间,然后使用亮度权重衰减系数h和颜色权重衰减系数hColor处理原始图像,最后再将得到的结果转换为RGB颜色空间输出。通常亮度权重衰减系数h比颜色权重衰减系数hColor大很多,在大多数场景中设置为hClor设置为10即可。

// src:输入图像,3通道,元素数据类型为cv::U8C3
// dst:降噪后的图像
// h:亮度权重衰减系数
// hColor:颜色权重衰减系数
// templateWindowSize:权重计算窗口的尺寸
// searchWindowSize:搜索窗口的尺寸
void cv::fastNlMeansDenoisingColored(cv::InputArray src, cv::OutputArray dst,
                                     float h = 3, float hColor = 3,
                                     int templateWindowSize = 7,
                                     int searchWindowSize = 21);

推荐的亮度权重衰减系数如下。

噪声标准偏差 权重计算窗口 搜索窗口 亮度权重衰减系数h
0 < σ <= 25 3✖️3 21✖️21 0.50*σ
25 < σ <= 55 5✖️5 35✖️35 0.40*σ
55 < σ <= 100 7✖️7 35✖️35 0.35*σ
5.2.3 处理视频数据

对于视频文件中连续的几张图片而言,图像数据是相似甚至相同的,而噪声通常是不相同的。因此很明显可以利用前后的视频帧来对指定帧应用降噪技术,其函数原型如下。

// srcImgs:需要处理的图片数组
// dst:处理完的图片
// imgToDenoiseIndex:需要降噪的图片索引
// h:权重衰减系数
// templateWindowSize:权重技术窗口尺寸
// searchWindowSize:搜索窗口大小,即以需要处理的图片索引为中心,前后共能搜索多少张图片
void cv::fastNlMeansDenoisingMulti(cv::InputArrayOfArrays srcImgs, 
                                   cv::OutputArray dst,
                                   int imgToDenoiseIndex, int temporalWindowSize,
                                   float h = 3,
                                   int templateWindowSize = 7,
                                   int searchWindowSize = 21);

// hColor:颜色权重衰减系数
void cv::fastNlMeansDenoisingColoredMulti(cv::InputArrayOfArrays srcImgs, 
                                          cv::OutputArray dst,
                                          int imgToDenoiseIndex,
                                          int temporalWindowSize,
                                          float h = 3, float hColor = 3,
                                          int templateWindowSize = 7,
                                          int searchWindowSize = 21);

6 直方图均衡

相机和图像传感器不仅必须适应场景中的自然对比度,还需要管理在可用光照等级下的曝光度。在标准的相机设备中,快门和光圈用于确保传感器接收到的光线不会太多,也不会太少。然而,一张特定图片的对比度范围通常比传感器的可用动态范围宽太多。因此需要在使用更长的曝光时间拍摄暗色区域,以及使用更短的曝光时间拍摄明亮区域,从而避免过饱和白化这两种选择之间进行权衡。很多时候,在一张图像中不能同时处理好这两个问题。

当图片被传感器记录后,尽管我们不能够改变相机拍摄时的曝光细节,但是可以获取其中的数据,然后扩展图像的动态范围以增加它的对比度。最长用的技术就是直方图均衡(Histogram Equalization)。下图中左侧的图片看上去很糟糕,因为颜色变换的范围并不大,这点可以很明显的从右侧的亮度直方图中看出。因为使用的图像数据位深度为8,因此值的取值范围为0到255,但是直方图显示值的取值范围集中在中间的一小部分,直方图均衡化可以扩大值的取值范围,从而使得图像看上去更自然。

该技术的底层数学原理是将一个分布(给定的亮度直方图)映射到另外一个分布(更宽的,理想下亮度的均匀分布)。事实证明使用累积分布函数(Cumulative Distribution Function)能够得到更好的效果。对于理想情况的原始纯高斯分布概率密度函数而言,其累积分布函数表示为下图。

实际上累积分布函数可以应用于任意的分布,只需要技术该分布在对应点点累积概率密度即可。直方图均衡的计算公式如下。其中X为处理前的灰度值,Y为处理后的灰度值,L为灰度级别,通常情况下位深度为8,此时L等于256,pX(x)是像素原始分布的概率密度函数。 该公式用于处理连续的数据,因此使用了积分的方式,而数字图像为离散数据,因此直接累加概率即可。

对上图应用直方图均衡处理后的到的结果如下图,可以明显的感受到图片的对比度提高了很多。

OpenCV提供的直方图均衡处理函数原型如下。该函数对输入输出图像都必须是单通道8位图像,另外如果处理彩色图像应该逐通道处理,并图通常为了获得更好的效果,会将原始图像转换至如LAB颜色空间,然后对亮度通道应用直方图均衡处理。

// src:输入的待处理图像
// dst:处理完成的图像
void cv::equalizeHist(const cv::InputArray src, cv::OutputArray dst);

7 小结

本章首先介绍了一些图像仿射变换的方法,这些变换包括缩放、旋转和投影变换。另外也介绍了笛卡尔坐标系和对数极坐标系的转换函数。这些函数的相同点是它们都是通过对整个图像的全局操作将一个图像转换为另外一个图像。此外还介绍了一个通用的图像映射函数,前面章节的这些变换都可以看作是该函数的一个特例。

随后介绍了一些计算摄影学中的实用方法,如图像修复和降噪,以及直方图均衡。这些算法在处理相机或者视频流时都很有帮助,当你在实现其他的计算机视觉技术前先使用这些技术处理原始图像以提升其质量是一个不错的选择。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268