OpenCV-6-滤波和卷积

1 前言

到目前为止,我们已经了解了OpenCV的基本结构体,这些结构体可以用于表示一副图像。我们学会了通过HighGUI模块中的接口创建一个应用程序并展示处理后的图像。有了这些基础,接下来我们将学习更多更复杂的图像操作方法。这些操作更关注图像本身即像素之间的联系,从而得到不错的视觉效果,以完成复杂的计算机视觉任务。

在深入本章的内容之前,我们先了解两个重要的概念。第一个是滤波器,它也被称为核。第二个是边界区域,常常在OpenCV绕一个像素点应用滤波器,或者其他其他函数时,处理的区域越过图像边缘时会遇到。

2 基本概念

2.1 滤波器、核和卷积

本章主要讨论的概念是图像滤波(Image Filtering)。滤波指的是一种特别的算法,这种算法通过逐个计算图像I中的像素I(x, y)得到新的图像,而具体的计算方式是使用I(x, y)即其附近的像素数据计算出新的图像。而附近这些像素的区域称为滤波器(Filter)或者核(Kernel)。在本章节中所遇到的大多数卷积核都是线性核(Linear Kernels),新的图像I’(x, y)可以认为是原始像素部分的值加权和,其计算公式如下。此外,中值滤波器就不是一个线性核,它是使用核的中位数来替换目标像素。

核内非0的像素组成了核的支集(Support of the Kernel)。尽管卷积(Convolution)在计算机视觉社区通常是用于概括线性和非线性滤波器在实体图像上的应用,但通常这种使用线性核的图像滤波也直接称为卷积。

使用二维数组k(i,j)来将表示卷积核表示为图像会更直观和方便,可视化的结果如下。其中A图书5✖️5核,B图是标准化的5✖️5核,C图是Sobel核,D图是标准化的高斯核,其中加粗的位置表示锚点。

上图中加粗的位置表示锚点(Anchor Point),它表示这个卷积核如何与图像对齐。比如D图中的41倍加粗,这意味着在使用上面的公式计算新图I‘(x, y)像素数据时,该像素的权重时41/275,其附近像素的权重分别和卷积核视图中对应位置的值相同。

2.2 边界处理

和一些别的图像处理库(如MatLab)不同,OpenCV应用图像滤镜后,输出图像和输入图像具有相同的大小。为了实现这个效果,在处理图像边界像素时,如图像顶点像素和卷积核锚点对齐后,核已经超出了图像边界,OpenCV会创建虚拟像素(Virtual Pixels)确保核内的每个元素都有对应的像素。由于缺乏公认的正确处理方式,通常我们使用自定义的方式来应对具体的场景。

2.2.1 边框扩展

对于大多数的函数,只要你指定了规则,它们会自动在图像边框创建虚拟像素。需要注意不是真正的创建新的像素,而是通过映射坐标完成。当然我们也可以手动扩充图像边框来观察每种扩展规则的效果,创建图像边框的函数原型如下。

// src:源图像
// dst:加上边框后的图像,比原图大
// top:向上扩充的像素行数
// bottom:向下扩充的像素行数
// left:向左扩充的像数行数
// right:向右扩充的像素行数
// borderType:边框扩充的方式,取值如下表
// value:使用常量扩充时的边框像素填充值
void cv::copyMakeBorder(cv::InputArray src, cv::OutputArray dst,
                        int top, int bottom, int left, int right,
                        int borderType, const cv::Scalar& value = cv::Scalar());

参数borderType的取值及其含义如下表。

参数borderType的取值 含义
cv::BORDER_CONSTANT 使用参数value提供的常量填充图片
cv::BORDER_WRAP 重复图片以填充边框,即将原图平移至此边界处
cv::BORDER_REPLICATE 拷贝边缘像数填充边框
cv::BORDER_REFLECT 镜像重复图片以填充边框,即以该边界作为镜像轴翻转图片
cv::BORDER_REFLECT_101 镜像重复图片以填充边框,边界像数除外
cv::BORDER_DEFAULT cv::BORDER_REFLECT_101的别名

所有的边框填充效果如下图。其中第一幅图展示的是未处理的原图,以做对比。

2.2.2 维度扩展

在某些特定的场景下,你可能需要计算原图边界外虚拟像素的参考像素(它们值相等)的坐标,从而更加自定义的扩展原始图像,并且你甚至需要自定义在不同维度上的扩展规则。函数cv::borderInterpolate可以计算虚拟像素映射的参考像素坐标在某个维度上的分量。该方法通常被OpenCV内部函数调用,当然你也可以在自己的函数中调用。

处理这个问题的函数原型如下,返回值的维度含义取决于其使用场景,如在函数img.at中将其用于x的坐标,则表示函数cv::borderInterpolate计算的是虚拟像素映射的参考像素在x轴上的坐标分量,并且在调用该函数时对应的参考应该传入原图的x轴上的值,如len应该传x轴的长度。

// 返回值:参考像素目标维度上的坐标、
// p:目标维度上的坐标,原图的左上角像素坐标为P(0),可以为负
// len:原始图像在目标维度的长度
// borderType:虚拟像素扩展方式,和函数void cv::copyMakeBorder相同
int cv::borderInterpolate(int p, int len, int borderType);

我们可以在不同的维度上使用不同的扩展类型,使用示例如下。

float val = img.at<float>(cv::borderInterpolate(100, img.rows, BORDER_REFLECT_101),
                          cv::borderInterpolate(-5, img.cols, BORDER_WRAP));

3 阈值操作

在使用OpenCV接口处理图像的过程中经常会遇到一种情况,在历经多部操作后做最终决策时需要对低于或者高于某些值的数据特殊处理,这些特殊值称为阈值(Threshold)。OpenCV提供函数一组函数来完成这些任务。

3.1 固定阈值

其中函数cv::threshold使用来恒定的阈值处理原图像,其原型如下。

// 返回值:
// src:待处理的原始图像
// dst:处理好的图像
// thresh:阈值
// maxValue:向上操作的最大值
// thresholdType:阈值操作类型
double cv::threshold(cv::InputArray src, cv::OutputArray dst,
                     double thresh, double maxValue, int thresholdType);

参数thresholdType的可取值及其含义如下表。其中SRCI表示原始数据第I个元素,DSTI表示计算结果的第I个元素,THRESH表示实参thresh的值,MAXVALUE表示形参maxValue的值。

参数thresholdType的取值 含义
cv::THRESH_BINARY DSTI = (SRCI > THRESH) ? MAXVALUE : 0
cv::THRESH_BINARY_INV DSTI = (SRCI > THRESH) ? 0 : MAXVALUE
cv::THRESH_TRUNC DSTI = (SRCI > THRESH) ? THRESH : SRCI
cv::THRESH_TOZERO DSTI = (SRCI > THRESH) ? SRCI : 0
cv::THRESH_TOZERO_INV DSTI = (SRCI > THRESH) ? 0 : SRCI

上面各种选项对数据的处理可以表示为下图。其中左上第一幅图表示的是原始的数据,其中横着的三根虚线由上至下分别表示调用函数时设置的maxValuethresh以及0。后面的5幅图分别对应了应用5中不同的处理策略后的数据。

示例Threshold将一幅图片的三个通道数据加到一起,然后再将其截取到100,其核心代码如下。

void sum_rgb(const cv::Mat& src, cv::Mat& dst) {
    // 将图片数据分割到三个独立的矩阵中
    std::vector<cv::Mat> planes;
    cv::split(src, planes);
    cv::Mat b = planes[0], g = planes[1], r = planes[2];
    cv::Mat source;

    // 计算三个通道的平均值
    cv::addWeighted(r, 1./3., g, 1./3., 0.0, source);
    cv::addWeighted(source, 1., b, 1./3., 0.0, source);

    // 将像素值截取到100
    cv::threshold(source, dst, 100, 100, cv::THRESH_TRUNC);
}

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

    // 使用文件名创窗口,并在其中显示图片
    cv::imshow(argv[1], dst);
    return 0;
}

我们可能并不想直接将所有像素的数据加到元素基础数据类型为8位的矩阵中,因为这可能会导致数据溢出。因此在上面的代码中我们使用了函数cv::addWeighted计算三个通道的平均值,然后将计算结果向下截断至100再的到最终的图像。通过使用浮点型的临时数据,可以优化上面代码得到示例程序ThresholdOp,其核心代码如下。其中函数cv::accumulate通过将原始数据加入到另一个数据格式的矩阵中,从而巧妙的转换数据类型。

void sum_rgb(const cv::Mat& src, cv::Mat& dst) {
    // 将图片数据分割到三个独立的矩阵中
    std::vector<cv::Mat> planes;
    cv::split(src, planes);
    cv::Mat b = planes[0], g = planes[1], r = planes[2];

    // 计算三个通道的和
    cv::Mat s = cv::Mat::zeros(b.size(), CV_32F);
    cv::accumulate(b, s);
    cv::accumulate(g, s);
    cv::accumulate(r, s);

    // 将像素值截取到100
    cv::threshold(s, s, 100, 100, cv::THRESH_TRUNC);
    s.convertTo(dst, b.type());
}

函数cv::threshold可以自动尝试计算最优的阈值,只需将参数thresh设置为cv::THRESH_OTSU即可。其内部会寻找所有可能的阈值,并计算由阈值分开的两组像素的方差,最优值的计算公式如下。

其中w1(t)和w2(t)是由两组数据像素个数所决定的权重,这种方式不管是计算最大还是最小的整体方差都是一样耗时的,因为它们都需要遍历所有可能的取值。因此这不是一个高效的过程。

3.2 自适应阈值

有一种优化后的阈值技术,在处理的过程中其值是在不断变化的。OpenCV提供了函数cv::adaptiveThreshold实现了这种技术,其原型如下。

// src:原始输入图像
// dst:处理后的图像
// maxValue:在阈值操作时的最大值
// adaptiveMethod:自适应策略,均值或者高斯
// thresholdType:阈值处理类型,和函数cv::threshold的对应参数具有相同含义
// blockSize:块大小
// C:常量值
void cv::adaptiveThreshold(cv::InputArray src, cv::OutputArray dst,
                           double maxValue, int adaptiveMethod, 
                           int thresholdType, int blockSize, double C);

根据自适应方法的背景,函数cv::adaptiveThreshold支持两种不同的自适应阈值策略。对于两种策略,我们计算某个像素点P(x, y)的阈值T(x, y)时都是计算以该像素点为中心的一个大小为b✖️b核内像素加权平均值,再减去一个常量C,其中b是参数blockSize设置的值,常量C就是参数C设置的值。

如果参数adaptiveMethod的策略选择的是cv::ADAPTIVE_THRESH_MEAN_C,加权平均值的方法是求平均值。如果选择的策略是cv::ADAPTIVE_THRESH_GUASSIAN_C,每个像数的权重由高斯分布函数以及该像素到核中心的距离决定。

当被处理的图片有较强的明暗比时,使用自适应阈值处理是很有用的。该函数值能处理单通道的8位整型或者浮点型数据,并且要求源图像和目标图像不同。示例ADThreshold分别使用了函数cv::thresholdcv::adaptiveThreshold处理同一个强明暗对比的图像,其核心代码如下。

int main(int argc, const char * argv[]) {
    // 解析命令行参数
    double fixed_threshold = (double)atof(argv[1]);
    int threshold_type = atoi(argv[2]) ? cv::THRESH_BINARY : cv::THRESH_BINARY_INV;
    int adaptive_method = atoi(argv[3]) ? cv::ADAPTIVE_THRESH_MEAN_C : 
                                          cv::ADAPTIVE_THRESH_GAUSSIAN_C;
    int block_size = atoi(argv[4]);
    double offset = (double)atof(argv[5]);
    // 读取灰度图
    cv::Mat Igray = cv::imread(argv[6], cv::IMREAD_GRAYSCALE);
    if (Igray.empty()) {
        std::cout << "Can not load " << argv[6] << std::endl;
        return -1;
    }

    // 声明处理后的图像对象
    cv::Mat It, Iat;
    // 阈值处理
    cv::threshold(Igray, It, fixed_threshold, 255, threshold_type);
    cv::adaptiveThreshold(Igray, Iat, 255, adaptive_method,
                          threshold_type, block_size, offset);
        
    // 显示结果
    cv::imshow("Raw", Igray);
    cv::imshow("Threshold", It);
    cv::imshow("Adaptive Threshold", Iat);
    // 挂起程序
    cv::waitKey(0);

    return 0;
}

使用如下参数指令

// 需要注意此处的程序名和文件名取决于具体的环境
./ADThreshold 35 1 1 71 15 */IMG_0199.jpg

启动程序后,其运行结果如下,上方的图片是处理之前的原始图片,左下角图片是使用函数cv::threshold和一个全局的阈值处理后的结果,右下角的图片是使用函数cv::adaptiveThreshold处理后的结果。使用自适应阈值处理后,可以得到完整的棋盘图片,这是使用全局阈值技术不能实现的。

4 图像平滑

图像平滑也称为图像模糊(Blurring),它是图像处理中的一个常用的技术。应用图像平滑的原因可能有很多,但是通常是用于降低噪声和伪影(Camera Artifacts)。同样的使用Pyramid方法降低图像分辨率时,图像平滑也非常重要,这个技术在本系列后面的文章中会详细讲到。一种图像平滑方式高斯模糊(Gaussian Blur)的效果如下图,这里每个像素都是按高斯分布对周围像素的加权和,该方式在下文会详细介绍。

OpenCV支持5种不同的平滑操作函数,在每个函数中参数srcdst都分别表示原始图像以及处理完成的结果,参数borderType都指示了图像边缘的处理策略。

4.1 盒式滤镜

基础模糊滤镜是盒式滤镜的特例,盒式滤镜会在下文介绍。基础模糊滤镜在处理每个像素时只是简单的对以该像素和锚点关联后(应用锚点坐标anchor),对大小为kSize的核内所有像素求平均值,其函数原型如下。

// src:原始图像
// dst:处理结果,得到的矩阵基本数据类型和原始图像保持一致
// ksize:卷积核大小
// anchor:锚点坐标,即锚点在卷积核内的坐标,锚点需要和待处理像素对齐,默认值为(-1, -1)
// 表示锚点位于卷积核中心
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::blur(cv::InputArray src, cv::OutputArray dst,
              cv::Size ksize, cv::Point anchor = cv::Point(-1,-1),
              int borderType = cv::BORDER_DEFAULT);

基础模糊滤镜,也称为标准盒式滤镜是盒式滤镜(Box filter)的特殊形式,其卷积核内各个元素的权重取值如下图。

盒式滤镜值的是卷积核内所有元素的权重值都相同,在大多数情况下所有元素的权重都为1或者1/A,其中A表示卷积核的元素个数。后者称为标准盒式滤镜(Normalized Box Filter),也是调用函数cv::blur得到的结果,其处理效果如下图。下面四幅图种,左侧的两幅为处理前的图像,右侧的两幅为处理后的图像。

盒式滤镜的函数原型如下。

// src:原始图像
// dst:处理结果
// ddepth:输出结果的基本数据类型,如CV_8U、CV_32F等,设置为-1时表示使用原始图像矩阵的
//         基本数据类型
// ksize:卷积核大小
// anchor:锚点坐标,即锚点在卷积核内的坐标,锚点需要和待处理像素对齐,默认值为(-1, -1),
//         表示锚点位于卷积核中心
// normalize:是否需要标准话处理,即计算时核内每个像素的权重都需要除以核内元素个数,
//            也就是使用普通模糊滤镜
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::boxFilter(cv::InputArray src, cv::OutputArray dst,
                   int ddepth, cv::Size ksize, cv::Point anchor = cv::Point(-1,-1),
                   bool normalize = true, int borderType = cv::BORDER_DEFAULT);

4.2 中值滤波器

中值滤波器(Media Filter)使用核内的中值像素替换目标像素,这是一种非线性的卷积核。前文介绍的一般模糊滤镜取核内像素平均值,这种方式对样本内的孤立的极值非常敏感,如出现极大或者极小值(如数字摄影里面的噪声),这会使得计算结果偏离大多数样本的实际情况。而中值滤镜取核内像素的中值就能够很好的处理这种情况,这种方式不易受到极值影响。其函数原型如下。

// src:原始图像
// dst:处理结果
// ksize:卷积核大小
void cv::medianBlur(cv::InputArray src, cv::OutputArray dst, cv::Size ksize);

使用中值滤波器处理后的效果如下图,其中左侧的两幅为处理前的图像,右侧的两幅为处理后的图像。

4.3 高斯滤波器

高斯滤波器可能是最有用的一个滤镜,它在处理每个像素时会根据高斯分布以及卷积核内的各个元素到中心的距离来确定它们的权重,这些权重都是标准化后的数值。其函数原型如下。

// src:原始图像
// dst:处理结果
// ksize:卷积核大小
// sigmaX:x轴方向上的分布方差
// sigmaY:y轴方向上的分布方差
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::GaussianBlur(cv::InputArray src, cv::OutputArray dst,
                      cv::Size ksize, double sigmaX, double sigmaY = 0.0,
                      int borderType = cv::BORDER_DEFAULT);

在理解参数sigmaXsigmaY之前,首先需要知道高斯模糊的原理,我们已经讲过高斯模糊是指计算卷积核加权和时,权重的取值使用了期望值为0的高斯分布算法。而高斯分布也被称为正态分布,二元正态分布的概率密度函数如下。

其中σx和σy分别表示在x和y轴上的方差,p表示x轴和y轴的相关系数,μx和μy分别表示x和y轴上的期望。而使用高斯模糊计算权重时默认p = μx = μy = 0,因此上述公式可以简写为如下形式。

此时以卷积核中心坐标为P(0, 0),可以计算出核内所有元素的概率密度,需要注意的是从数学角度上看,概率密度函数上的一点都表示变量为该值所定义的一个极小区间内的分布概率。而在高斯模糊算法种,我们假定概率密度函数就是该值对应的概率,随后通过标准化操作确保这种假设是可用的。上述公式中σx和σy分别对应所设的参数sigmaXsigmaY。简单的将就是这两个数值越大,则对应轴的方差越大,则模糊效果越好,反之则越低。

另外如果参数sigmaX指定了值,而参数sigmaY指定为默认值0,则该函数内部在计算时,会使用sigmaX的值替换sigmaY的值。如果这两个值都设置为0,则默认会使用如下公式计算。

使用该函数设置核大小为(5, 3),sigmaX为1,sigmaY为0.5时的核权重系数如下图。

OpenCV在实现时针对常见的核进行了优化,如使用标准方差(如sigmaX = 0.0)的3✖️3,5✖️5和7✖️7大小的卷积核。高斯模糊支持单和3通道,8位整型和32位浮点型的图片。高斯模糊的效果如下图,其中左侧的两幅为处理前的图像,右侧的两幅为处理后的图像。

4.4 双边滤波器

OpenCV支持的第5种也是最后一种模糊滤波器是双边滤波器(Bilateral Filtering),它是更大一个图像分析操作类别,即保持边缘平滑(Edge-Preserving-Smoothing)的一种实现方式。高斯滤波基于一个事实,即在真实图像中通常情况下相邻的像素是逐渐变化的,而随机噪声则在相邻像素间剧烈波动,因此高斯滤波在保留图像原始信号的前提下能够削弱噪声。但是这种方式会模糊边缘,破环边缘信息,因为边缘两侧的像素通常变化较大。在以明显更高的时间处理成本为代价下,双边滤波可以在不平滑边缘的情况下平滑一幅图像。

和高斯滤波器一样,双边滤波也需要计算卷积核内的每个元素的权重,但是双边滤波计算的权重包含两个部分。第一部分和高斯模糊计算的权重方式相同,第二部分也是一种基于高斯分布计算的权重,但是它是基于当前像素距离中心像素的色彩强度差而不是空间距离计算的。需要注意对于彩色图像,色彩强度差是各个分量加权和,权值参考了CIE Lab颜色空间内的欧式距离。另外从技术上讲双边滤波不一定要使用高斯分布计算权重,只是OpenCV内部选择使用这种方式实现。

简单的看待双边滤波器的方式是你可以将其认为是一种特殊的高斯模糊,不过对于颜色越相近的像素其权重越高,从而使得边缘更明显,对比度更高。双边滤波就像是将原始图像变成一幅水彩化,特别是多次迭代使用双边滤波 器后更明显。另外双边滤波器在图像分割领域非常有用。OpenCV提供的双边滤波函数原型如下。

// src:原始图像
// dst:处理结果
// d:卷积核的直径
// sigmaColor:计算色彩强度权重分量时使用的方差,值越大,不相似的像素权重越高,
//。           处理后的图像边缘越不清晰
// sigmaSpace:技术空间距离权重分量时使用的方差
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::bilateralFilter(cv::InputArray src, cv::OutputArray dst,
                         int d, double sigmaColor, double sigmaSpace,
                         int borderType = cv::BORDER_DEFAULT);

双边滤波器处理图像的效果如下,其中左侧的两幅为处理前的图像,右侧的两幅为处理后的图像。

卷积核的直径d对算法的效率有很大的影响,通常在处理视频时该参数不超过5,在处理非实时场景时可以向上调整为9。另外也可以将该值设为-1,这样函数内部会根据设置的参数sigmaSpace值计算出一个合适的直径。实际应用中,参数sigmaSpace设置为10是会得到轻微但是明显的效果,当其设置为150将会得到显著的效果,处理后的图像某种程度上看上去已经是一幅卡通画了。

5 导数和梯度

一种最基本的重要卷积类型就是计算(或者近似计算)导数。计算导数的方法有很多,但是在具体的某个场景中可用的却不多。计算导数可以用于抽取图片内部的边缘,如果单独观察图像上的某行像素的数据,不难发现图像边缘的地方都是像素颜色发生剧烈变化的地方。如果我们将像素的颜色看成是f(x)的函数,则图像内部的边缘就是器导函数f’(x)取得较大值的地方。如下图中如果我们提取左侧图像的边缘得到右侧图像,不难看出边缘都是像素变换剧烈的地方。

5.1 索贝尔导数

最常用的一种是索贝尔导数(Sobel Derivative),关于索贝尔导数更多信息也可以参考此文章,OpenCV提供的索贝尔导数函数原型如下。

// src:原始图像
// dst:处理结果
// ddepth:输出结果的基本数据类型,如CV_8U、CV_32F等,设置为-1时表示使用原始图像矩阵的基本
//         数据类型
// xorder:x方向上的求导顺序,取值为0、1、2,0表示该方向不求导,但是不能和yorder同时为0
// yorder:y方向上的求导顺序,取值为0、1、2,0表示该方向不求导
// ksize:卷积核的大小,需要是奇数
// scale:最终结果的缩放值
// delta:最终结果的偏移量
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::Sobel(cv::InputArray src, cv::OutputArray dst,
               int ddepth, int xorder, int yorder, cv::Size ksize = 3,
               double scale = 1, double delta = 0,
               int borderType = cv::BORDER_DEFAULT);

参数ddepth一个使用场景是如果处理的图像基本数据类型是8位,则输出矩阵dst的基本数据类型至少要是CV_16S,从而保证数据不会溢出。参数ksize最大取值为31,如果设置的值小于3,则默认使用3。该算法卷积核的权重不在这里讨论,函数的计算过程可以描述为如下的公式。

当参数xorder设置为1,yorder设置为0时表示求x方向上的导数,即在y方向上的“单位”增量,得到的结果如下图。

当参数xorder设置为0,yorder设置为1时表示求y方向上的导数,即在x方向上的“单位”增量,得到的结果如下图。

Sobel算子有一个好处就是可以定义任意大小的核,并且这些核可以被快速和迭代式的构造出。由于该算法是取离散的样本数据,因此核越大越能削弱噪声计算过程的影响,则计算得到的导数越接近真实值。另一方面加入导数在空间上剧烈变化,则核越大越容易导致计算结果发生偏差。

为了更准确的理解Sobel算子,我们必须意识到它不是真正的导数,因为它是定义在离散空间上的。这也就是说,在x方向上的二阶索贝尔算子并不是二阶导数,而是其近似值。这也解释了为什么更大的核能够使用更多样本数据来拟合出更真实的值。

5.2 Scharr滤波器

实际上在离散空间求近似导数的方法有很多,Sobel算子其实对于较小的卷积核计算结果不是很准确。当你单纯的计算x或者y轴方向的导数提取图像边缘时,这种误差不容易显现出来,因为误差产生的方向和轴的方向是对齐的。而在计算某个偏离轴方向的导数(Directional Derivatives)时,如使用两个独立Sobel算子的y和x轴计算结果的比值y/x和反正切函数来计算图像梯度的方向(颜色变化的方向)时这种误差就会显现出来。计算角度可以使用矩阵x和y以及前几章讲过的函数cv::cartToPolar()完成。

一个需要分析图像梯度方向的例子是在分析图像形状时,你可能需要围绕目标对象计算一个图像梯度角度的直方图,此时梯度角度的误差将会降低形状分类器的性能。对于一个3✖️3的索贝尔滤镜,梯度角偏离x轴或者y轴越远,这种误差越大。OpenCV以一种特殊的方式(该方式的原理暂不明朗)将ksize设置为cv::SCHARR,从而得到不同的滤波系数即卷积计算时的权重,来解决在这种小而快的3✖️3索贝尔滤镜所带来的误差。这种特殊的Sobel滤波器被称为Scharr滤波器,它的效率和Sobel滤波器相同,但精确度更高,因此对于3✖️3的核,尽量选用这种滤镜。

Scharr滤波器的滤波系数如下图,A图为应用在水平方向上的滤波系数,B图为应用在垂直方向上的滤波系数。

5.3 拉普拉斯变换

Marr(Marr, D. Vision. San Francisco: Freeman, 1982)第一次在 OpenCV中应用了拉普拉斯函数(Laplacian Function),该函数实现了对拉普拉斯算子(注意区分拉普拉斯算子和后面章节会讲到的拉普拉斯金字塔)的离散近似。其应用到的核心公式如下。

观察上述公式不难看出拉普拉斯函数就是对计算原始图像的二次偏微分方程的和,并且可以由Sobel算子实现,的确OpenCV在实现拉普拉斯函数时直接使用了Sobel算子。其函数原型定义如下。

// src:原始图像
// dst:处理结果
// ddepth:输出结果的基本数据类型,如CV_8U、CV_32F等
// ksize:卷积核的大小,需要是奇数
// scale:最终结果的缩放值
// delta:最终结果的偏移量
// borderType:边缘处理策略,即虚拟像素引用策略,默认BORDER_DEFAULT
void cv::Laplacian(cv::InputArray src, cv::OutputArray dst, int ddepth,
                   cv::Size ksize = 3, double scale = 1, double delta = 0,
                   int borderType = cv::BORDER_DEFAULT);

当参数ksize比1大时,拉普拉斯函数的计算方式直接将对应的Sobel算子计算结果相加,当ksize等于1时,拉普拉斯函数直接使用如下的单个卷积核计算。

拉普拉斯算子有很多应用,其中最常见的一种是用于检测斑点。由于拉普拉斯算子计算的是x轴或y轴的二阶导数之和,因此对于一个点或者比卷积核小的斑点,如果其周围颜色相对更高,则得到的运算结果为较大正值,如果周围颜色相对更低,则计算得到的结果为较小负值。

拉普拉斯算子同样也可以用于边缘检测,也就正是OpenCV提供的函数cv::Laplacian的效果。一阶导数表示的是图像像素变化的快慢,因此在图像边缘这些其绝对值会变得较大。而二阶导数描述的是一阶导数的变化率,因此在边缘处一阶导数取得极值则二阶导数值为0。但是二阶导数对原图中的细微变化很敏感,因此可能一些不太明显的边缘也会被记录下来,但是可以通过指定其一阶导数的最低值,即图像变化率的阈值来过滤掉这部分边缘。此外,拉普拉斯算子由于计算的是二阶导数,因此其边缘看不出强弱之分,并且这种算法易受到图像噪声的影响。

下图是使用拉普拉斯滤波器的效果,其中上侧左图是处理之前的原始图像,右侧是处理后的图像。圆圈标出的部分x轴方向的像素分析表示如下测三幅函数图。其中第一幅是被放大图像的x轴方向像数值,第二幅是其一阶导数,第三幅是其二阶导数。二阶导数中的0值中对应的一阶导数最大的那个就是原图中的明显边缘。

6 图像形态学

OpenCV提供了一组接口快速便利的处理图像形态学变换(Morphological Transformations)任务,图像形态学处理可以简单的理解和基于图像形状的图形处理技术,在详细了解这个技术之前,首先熟悉下一些最流行的图像形态学变化操作,它们展示如下图。从上至下,从左至右依次为原始图像、膨胀效果、腐蚀效果、开效果、闭效果、梯度效果、顶帽效果和底帽效果,这里我们先不必理解每种效果的实现逻辑,只需要对这些特殊的形态学变化有直观的认识即可,下文会详细的介绍各个效果。

图像形态学是一个单独的领域,在计算机视觉发展的早期阶段,也衍生了一系列的图像形态学方法。其中大部分都是基于某个特定目的开发的,其中一些在后来的发展中也被应用到了更多的场景中。本质上图像形态学操作都基于两个积分的操作,即膨胀和腐蚀,更复杂的操作都可以使用它们来表示。

6.1 膨胀和腐蚀

基本的形态学变化是膨胀(Dilation)和腐蚀(Erosion),它们被广泛应用于消除噪声、元素隔离和连接场景中。基于它们发展的更复杂的形态学操作也能被用于寻找强度峰值和孔洞,以及定义另外一种形式的图像梯度。

膨胀的技术方式是使用核内像素值最大的元素替代目标像素,这属于非线性的操作。通常情况下膨胀操作使用的是一个正方形或者圆形的实心核。膨胀操作的效果如下图,其中A图是原始图像,B图是一次膨胀操作的结果,C图是两次膨胀操作的结果,可以明显看到经历过膨胀操作后,图像看上去像是围绕边缘长了一圈。另外需要注意的是这里图像用的是暗色,背景是白色,实际上应该用相反的颜色区分图像和背景,因为膨胀操作使用更亮的像素替代更暗的像素。

腐蚀操作是膨胀操作的反操作,它使用了核内最小强度值像素替换目标像素,其效果如下图,其中A图是原始图像,B图是经历一次腐蚀操作的效果,C图是应用两次腐蚀操作的效果。同样需要注意这里图像用的是暗色,背景是白色,实际上应该用相反的颜色区分图像和背景。

图像形态学操作通常是应用于阈值处理后的布尔图像,需要注意的是OpenCV里最小的矩阵基本数据是8位,相关函数使用0表示false,非0表示true。不过由于膨胀和腐蚀操作是取最大和最小值,因此它也可以应用于灰度图像。

通俗的讲,膨胀操作扩展了图像的明亮部分,而腐蚀操作则减少了这部分。另外膨胀操作可以填充凹处,而腐蚀操作可以消除凸起。当然具体的效果还是和使用的核有关,但是只要核是凸起并且是实心的,这种表述就是正确的。

OpenCV提供的膨胀核腐蚀函数原型如下。

// src:原始图像
// dst:处理后的图像
// element:卷积核,如果传入未初始化的矩阵,则直接使用锚点在核中心的3✖️3卷积核
// anchor:锚点的坐标
// iterations:一次函数调用内部实际的迭代次数
// borderType:边缘处理策略,即虚拟像素引用策略,默认cv::BORDER_CONSTANT
// borderValue:边缘像素填充的常量
void cv::erode(cv::InputArray src, cv::OutputArray dst,
               cv::InputArray element, cv::Point anchor = cv::Point(-1,-1),
               int iterations = 1, int borderType = cv::BORDER_CONSTANT,
               const cv::Scalar& borderValue = 
                   cv::morphologyDefaultBorderValue());

void cv::dilate(cv::InputArray src, cv::OutputArray dst,
                cv::InputArray element, cv::Point anchor = cv::Point(-1,-1),
                int iterations = 1, int borderType = cv::BORDER_CONSTANT,
                const cv::Scalar& borderValue = 
                    cv::morphologyDefaultBorderValue());

在很多场景下,由于受到噪声、阴影或者其他类似效果影响,图像中的大块区域会被分割成为多个碎片,细微的膨胀操作就能够将这些碎片融合在一起,因此它通常用于寻找联通碎片(Connected Components)。下图则展示了膨胀操作的效果,其中左侧两幅图像是处理之前的原图,而右侧两幅图像是处理后的结果。

由于腐蚀操作处理图像时能够保证主要的图像区域的整体视觉效果不发生变化,但是会消除小范围的噪声,因此腐蚀操作通常用于消除图像中的亮斑。下图则展示了腐蚀操作的效果,其中左侧两幅图像是处理之前的原图,而右侧两幅图像是处理后的结果。

6.2 通用形态学函数

当处理的图像是二值图像时,即只存在true(>0)和false(=0)两种状态时,基本的膨胀和腐蚀操作就已经足够了。但是当你处理的是彩色或者灰度图像时,通常还需要借助一些额外的操作。其中部分操作可以通过函数cv::morphologyEx实现,其原型如下。

// src:原始图像
// dst:处理后的图像
// op:额外的操作,其取值会在下文详细讲到
// element:卷积核,如果传入未初始化的矩阵,则直接使用锚点在核中心的3✖️3卷积核
// anchor:锚点的坐标
// iterations:一次函数调用内部实际的迭代次数
// borderType:边缘处理策略,即虚拟像素引用策略,默认cv::BORDER_DEFAULT
// borderValue:边缘像素填充的常量
void cv::morphologyEx(cv::InputArray src, cv::OutputArray dst, int op,
                      cv::InputArray element, cv::Point anchor = cv::Point(-1,-1),
                      int iterations = 1, int borderType = cv::BORDER_DEFAULT,
                      const cv::Scalar& borderValue =
                          cv::morphologyDefaultBorderValue());

参数op的取值和含义如下表,在下文还会详细介绍每种操作的含义。

参数op的取值 含义 是否需要临时图像
cv::MOP_OPEN 形态学开
cv::MOP_CLOSE 形态学闭
cv::MOP_GRADIENT 形态学梯度 总是需要
cv::MOP_TOPHAT 顶帽 就地需要(当src = dst时)
cv::MOP_BLACKHAT 底帽 就地需要(当src = dst时)
6.2.1 开闭操作

开(Opening)操作和闭(Closing)操作都是膨胀和腐蚀操作的简单结合。其中开操作是先应用腐蚀操作,再应用膨胀操作。它通常用于计算布尔型图像中的独立区域数量,例如对众多细胞组成的显微图片进行阈值操作后,在计算细胞个数时需要先将相邻的细胞分离开,从而方便计数。需要注意iterations迭代次数的含义,如果是迭代n次,意味则执行完所有的n次腐蚀操作后,再连续执行n次连续的膨胀操作。

下图是开操作的效果,可以看见经历一次开操作后原本有联系的两个区域被分离开。如果进行两次迭代,则相对较小的明亮区域甚至会消失。

而对于闭操作是先应用膨胀操作,再应用腐蚀操作。它通常用于更复杂的碎片联通算法从而填充不想要的或者噪声引起的片段,简单的说就是在不改变图像主体形态的胖廋情况下填充阴暗部分缝隙。需要注意iterations迭代次数的含义,如果是迭代n次,意味则执行完所有的n次膨胀操作后,再连续执行n次连续的腐蚀操作。下图是闭操作应用在图像上的效果。

在联通碎片的场景中,通常的做法是使用腐蚀或者开操作移除由噪声带来的碎片,然后再使用闭操作连接相邻的大型碎片。尽管开操作和闭操作的最终效果和腐蚀和膨胀效果类似,但是由于开闭操作内部都是由一组逆向操作组成,因此它们对于原大型碎片的轮廓保留更准确。

对于非布尔型图像而言,开操作是消除相对于领域像素更高的异常值,其示意直方图如下。

开操作实际应用于图像的效果如下。

闭操作最明显的作用就是消除相对于领域像素强度更低的异常值,其示意直方图如下。

闭操作实际应用于图像的效果如下。

6.2.2 形态学梯度

可以快速的通过如下的公式理解形态学梯度(Morphological Gradient)的定义。

gradient(src) = dilate(src) - erode(src)

简单的理解图像形态学效果的处理结果就是提取图像的轮廓,对布尔型值图像的应用效果如下图。

对于灰度图像应用形态学梯度操作,可以观察出图像明暗的变化快慢,即提取的到的边缘越明显则明暗变化越快,反之越慢,这也是将该操作称之为梯度操作的原因。形态学梯度操作通常用于提取明亮区域的轮廓,从而将其视为一个完整对象,或者多个完整对象的一部分。相对于前文讲到的边缘检测操作而言,这种操作更容易得到完整的轮廓。下图以是图像形态学操作的示意直方图。

图像形态学梯度的实际应用效果如下图。

6.2.3 顶帽和底帽操作

顶帽(Top Hat)操作和底帽(Black Hat)操作分别用于提取图像中的明亮碎片和阴暗孔洞,当你想要分离和目标物体具有亮度差异的附着碎片时,你可能需要使用到这两种方法。在处理生物组织和细胞的显微图片时你可能会遭遇这样的场景。这两种操作可以用如下公式来表示。

TopHat(src) = src - open(src)
BlackHat(src) = close(src) - src

可以看出顶帽操作是原图像和其开操作的差,而在前面的内容中已经描述过图像的开操作作用是消除明亮的碎片,因此可以推断出顶帽操作是提取这些明亮碎片。事实上也确实如下,下图展示了对于布尔型图像应用顶帽操作的效果。

下图展示了对于灰度图像应用顶帽操作的效果。

而底帽操作计算的是原图的闭操作和原图的差,回忆上文的内容,闭操作是填充图像中的暗示孔洞,因此底帽操作的结果就是以明亮的颜色显示原图中的阴暗裂缝或者凹槽。下图展示了对于布尔型图像应用底帽操作的效果。

下图展示了对于灰度图像应用底帽操作的效果。

6.3 自定义卷积核

到目前为止介绍的图像形态学操作使用的卷积核都是默认3✖️3的核,此外OpenCV也支持自定义的卷积核。在形态学语境下,卷积核也被称为构造元素。OpenCV提供的构建自定义核的函数为cv::getStructuringElement,可以使用该函数构造的核作为调用函数cv::dilate()cv::erode()cv::morphologyEx()的参数。但是这通常需要做更多的工作,通常需要使用的是一个非方形的不规则形状的卷积核。函数cv::getStructuringElement的原型如下。

// shape:核的形状,下文会详细介绍
// ksize:核的大小,必须是奇数
// anchor:锚点坐标,默认值为cv::Point(-1,-1),表示位于核的中心
cv::Mat cv::getStructuringElement(int shape, cv::Size ksize,
                                  cv::Point anchor = cv::Point(-1,-1));

参数shape的取值和含义如下。其中cv::MORPH_CROSS是用于和旧版本接口交互的。在老版C语言风格接口中表示卷积核有一个单独的数据结构。使用新版的OpenCV已经不需要使用这种方式,当你需要使用比通过函数cv::getStructuringElement创建的基于基础形状的卷积核更复杂的形式时,你可以在使用形态学操作相关函数时使用任意的cv::Mat实例。

参数shape的取值 形状 描述
cv::MORPH_RECT 矩形 Ei, j = 1, ∀ i, j
cv::MORPH_ELLIPSE 椭圆形 以ksize.width和ksize.height为椭圆的轴
cv::MORPH_CROSS 交叉形状 Ei, j = 1, 当 i == anchor.y 或 j == anchor.x

7 通用线性滤波器

7.1 可分解线性核

到目前为止介绍的卷积还是都是使用的内部定义的卷积核,我们只能通过一些参数对具体的和进行有限的修改。实际上对于线性滤波器,OpenCV可以处理完全由我们自定义的核。也就是说我们可以直接使用一个矩阵对象来描述这个核。

另外核的一些特征可能会严重影响到算法的效率。比如可分解的核的计算效率会明显优于不可分解的核。这两种核表示如下图,其中A是一个可以分解的核,它的每列元素都是B向量和C向量对应值的乘积。而D则是一个不可分解的核。

对于可分解的核,如A核,可以先使用B核对图像y轴进行卷积操作,然后再使用C核对图像x轴进行卷积操作。这种巧妙的操作在保证了同样的处理效果前提下极大的节省了运算实际,特别是对于直径越大的核,这种性能提升越明显。计算的复杂度可以由An平方降低2An,A为图像的像素数,n为卷积核的直径。

7.2 通用滤波函数

计算图像卷积包含大量的计算工作,OpenCV提供了专门的一组函数用于处理这个任务,并且其内部包含一定的优化。其中函数cv::filter2D的原型如下。

// src:原始图像
// dst:处理后的图像
// ddepth:输出结果的基本数据类型,如CV_8U、CV_32F等
// kernel:自定义的卷积核,如果定义了参数anchor,则核的宽高可以为偶数,否则必须是奇数
// anchor:锚点的坐标
// delta:计算结果的偏移量
// borderType:边缘处理策略,即虚拟像素引用策略,默认cv::BORDER_DEFAULT
cv::filter2D(cv::InputArray src, cv::OutputArray dst, int ddepth,
             cv::InputArray kernel, cv::Point anchor = cv::Point(-1,-1),
             double delta = 0, int borderType = cv::BORDER_DEFAULT);

如果自定义的核是可分离的,可以使用函数cv::sepFilter2D及x轴核y轴分离核来完成图像卷积工作,从而得到巨大的性能提升,函数原型如下。

// src:原始图像
// dst:处理后的图像
// ddepth:输出结果的基本数据类型,如CV_8U、CV_32F等
// rowKernel:x轴卷积核,1✖️N矩阵
// columnKernel:y轴卷积核,M✖️1矩阵
// anchor:锚点的坐标
// delta:计算结果的偏移量
// borderType:边缘处理策略,即虚拟像素引用策略,默认cv::BORDER_DEFAULT
cv::sepFilter2D(cv::InputArray src, cv::OutputArray dst, int ddepth,
                cv::InputArray rowKernel, cv::InputArray columnKernel,
                cv::Point anchor = cv::Point(-1,-1),
                double delta = 0, int borderType = cv::BORDER_DEFAULT);

7.3 常用卷积核

Sobel滤波器核Scharr滤波器的核可以通过如下函数构建。导数核是可分解核,因此此处返回的是x轴核y轴的分解核。

// kx:x轴的卷积核
// ky:y轴的卷积核
// dx:x轴的求导阶数
// dy:y轴的求导阶数
// ksize:卷积核的大小,取值为1、3、5、7或者为cv::SCHARR
// 当取值为cv::SCHARR时返回的是SCHARR滤波器的核,否则返回的是Sobel滤波器的核
// normalize:是否需要标准化核元素,对于浮点型图像设置为true,对于整型图像设置为false,
//            以免丢失精度
// ktype:输出矩阵的基本数据类型,取值为CV_32F核CV_64F
void cv::getDerivKernels(cv::OutputArray kx, cv::OutputArray ky,
                         int dx, int dy, int ksize,
                         bool normalize = true, int ktype = CV_32F);

对于参数normalize而言,如果一定要某个时刻对处理整型数据图像的卷积核进行标准化操作,则可以使用如下的标准化系数。

生成高斯滤波器的函数原型如下。和导数核一样,高斯滤波的核也是可分离的,因此该函数的饭绘制是ksize✖️1大小的系数矩阵。

// 返回值:构建好的高斯滤波器
// ksize:卷积核大小,必须是奇数
// sigma:高斯分布的标准差
// ktype:输出矩阵的基本数据类型
cv::Mat cv::getGaussianKernel(int ksize, double sigma, int ktype = CV_32F);

核的权重系数计算公式如下。

系数a会对权重系数进行标准化处理。外如果参数sigma设置为-1,则该函数将会根据参数ksize计算计算出一个sigma值,其计算公式如下。

8 小节

这个章节首先介绍了一般的图像卷积操作,以及如果在卷积操作中处理图像边界。另外也介绍到了图像卷积核,以及线性核非线性滤波器的区别。最后介绍了一些常见的图像滤波器,以及它们应用在图像上的效果。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容