OpenCV算法学习笔记之阈值分割
in OpenCV with 0 comment

OpenCV算法学习笔记之阈值分割

in OpenCV with 0 comment

阈值分割也被称为二值化处理,简单来说就是将图像灰度值大于(或小于)阈值的像素调整为255,将小于(或大于)阈值的像素灰度值调整为0,这样最终输出图像只有两种灰度值(255和0)。对对比度比较弱的图像进行阈值分割前常常要进行对比度增强。常见的阈值分割技术有全局阈值分割和自适应局部阈值分割。

全局阈值分割

原理

顾名思义,全局阈值分割就是将整个图像灰度值大于阈值(thresh)的像素调为白色,小于或等于阈值的调整为黑色,也可以反过来。用公式表达就是:

$$ O(r,c)=\left\{ \begin{align} 255,I(r,c)>thresh \\ 0, I(r,c) \leq thresh \end{align} \right. $$

其中$O(r,c)$代表输出图像位于第$r$行第$c$列的像素的灰度值,$I(r,c)$代表输入图像位于第$r$行第$c$列的像素的灰度值。

实现

用Python实现就是

import cv2 as cv
import numpy as np

src = cv.imread("inputImage.png")
thresh = int(input())
dst = src.copy()    # 一般不处理原图
dst[dst>thresh] = 255
dst[dst<=thresh] = 0

OpenCV提供函数threshold(InputArray src, OutputArray dst, double thresh, double maxval, int type)实现了全局阈值分割,其中maxval是图像二值化显示时的最大值,一般设置为255;type为类型,有如下取值:

enum ThresholdTypes {
    THRESH_BINARY     = 0, // 大于thresh的设为maxval
    THRESH_BINARY_INV = 1, // 大于thresh的设为0
    THRESH_TRUNC      = 2, // 大于thresh的使用threshold函数,小于等于的不处理
    THRESH_TOZERO     = 3, // 大于thresh的不处理,小于等于的设为0
    THRESH_TOZERO_INV = 4, // 大于thresh的设为0,小于等于的不处理
    THRESH_MASK       = 7,
    THRESH_OTSU       = 8, // 使用OTSU算法计算出阈值
    THRESH_TRIANGLE   = 16 // 使用TRIANGLE算法计算出阈值,与下文的直方图技术法原理类似
};

最后两个的算法之后再解释,我们通常用这两个与THRESH_BINARY结合使用,如type=THRESH_OTSU+THRESH_BINARY代表先用OTSU算法计算出阈值后再根据THRESH_BINARY规则进行分割。

自动求取阈值

阈值分割的核心是如何选取阈值,选取正确的阈值是分割成功的关键。常用的自动求全局阈值的算法有直方图技术法,Otsu算法,熵算法等。

直方图技术法

原理

一般来说,一幅图如果前景和背景对比比较明显的话,那么它的直方图则会有两个比较明显的峰值。两个峰值对应物体内部和外部较多数目的点,两个峰值之间的波谷对应物体边缘附近相对较少数目的点。直方图技术就是首先找到这两个峰值,然后取两个峰值之间的波谷位置对应的灰度值,取其作为阈值。

上述过程可能会有干扰。由于灰度值在直方图中的随机波动,两个波峰(局部最大值)和它们之间的波谷不能很好的确定,比如两个峰值之间可能出现两个最小值,为了解决这种干扰, 一般对直方图进行高斯平滑,逐渐增加高斯滤波的标准差,直到能从平滑后的直方图只存在两个唯一的波峰和它们之间唯一的最小值,但这种方式需要手动调节,可以通过以下方式进行自动选取波峰和波谷:

第一步:找到灰度直方图的第一个峰值,并找到其对应的灰度值。显然,灰度直方图的最大值就是第一个峰值,将其对应的灰度值用fristPeak表示;

第二步:找到直方图的第二个峰值,并找到其对应的灰度值,第二个峰值不一定是第二大值,因为它很可能出现第一个峰值的附近,可以用以下公式计算:

$$ secondPeak = arg_k max\{ (k-firstPeak)^2 * histogram_I(k) \},0 \leq k \leq 255 $$

也可以用绝对值的形式:

$$ secondPeak = arg_k max\{ |k-firstPeak| * histogram_I(k) \},0 \leq k \leq 255 $$

其中$histogram_I(k)$代表灰度值等于$k$的像素的数量;

第三步:找到这两个峰值之间的波谷,如果出现两个或多个波谷,则取左侧的波谷即可,其对应灰度值即为阈值。

Python实现

在利用直方图计算阈值时,会计算一个直方图最大值的位置索引,可以利用numpy的where()函数返回值等于某个数的索引,如

import numpy as np

hist = np.array([1, 3, 9, 2, 8])
max_loc = np.where(hist == np.max(hist))    # max_loc = (array([2], dtype=int64))

如果出现多个最大值,那么会返回存储最大值的所有位置索引的一维数组组成的元组。

下面的函数是利用直方图法自动求取阈值:

def thresh_two_peaks(image):
    """
    :param image: 输入图像
    :return 阈值和阈值分割后结果组成的二元元组
    """
    # 计算灰度直方图
    histogram = calcGrayHist(image)
    # 找到灰度直方图的最大峰值对应的灰度值
    max_loc = np.where(histogram == np.max(histogram))
    first_peak = max_loc[0][0]
    # 寻找灰度直方图第二个峰值对应的灰度值
    measure_dists = np.zeros([256], np.float32)
    for k in range(256):
        measure_dists[k] = pow(k-first_peak, 2) * histogram[k]
    max_loc2 = np.where(measure_dists == np.max(measure_dists))
    second_peak = max_loc2[0][0]
    # 找到两个峰之间的最小值对应的灰度值,作为阈值
    thresh = 0
    # 第一个峰值在第二个峰值的右边
    if first_peak > second_peak:
        temp = histogram[int(second_peak): int(first_peak)]
        min_loc = np.where(temp == np.min(temp))
        thresh = second_peak + min_loc[0][0] + 1
    else:
        temp = histogram[int(first_peak): int(second_peak)]
        min_loc = np.where(temp == np.min(temp))
        thresh = first_peak + min_loc[0][0] + 1
    # 找到阈值后进行阈值处理,得到二值图
    thresh_img = image.copy()
    thresh_img[thresh_img > thresh] = 255
    thresh_img[thresh_img <= thresh] = 0
    
    return (thresh, thresh_img)

上面使用的函数calcGrayHist()是在《OpenCV算法学习笔记之对比度增强》中的函数。

需要注意的是在求两个峰值之前的波谷时,需要判断第二个峰值是在第一个峰值的右侧还是左侧。

C++实现

int threshTwoPeaks(const Mat &image, Mat &threshOut)
{
    // 计算灰度直方图
    Mat histtogram = calcGrayHist(image);
    // 找到灰度直方图中最大峰值对应的灰度值
    Point firstPeakLoc;
    minMaxLoc(histogram, NULL, NULL, NULL, &firstPeakLoc);
    int firstPeak = firstPeakLoc.x;
    // 寻找灰度直方图第二个峰值对应的灰度值
    Mat measureDists = Mat::zeros(Size(256, 1), CV_32FC1);
    for (int k=0; k<256; k++){
        int histK = histogram.at<int>(0, k);
        measureDists.at<float>(0, k) = pow(float(k - firstPeak), 2)*histK;
    }
    Point secondPeakLoc;
    minMaxLoc(measureDists, NULL, NULL, NULL, &secondPeakLoc);
    int secondPeak = secondPeakLoc.x;
    // 找到两个峰值之间最小值对应的灰度值,作为阈值
    Point threshLoc;
    int thresh = 0;
    // 第一个峰值在第二个峰值左侧
    if (firstPeak < secondPeak){
        minMaxLoc(histogram.colRange(firstPeak, secondPeak), NULL, NULL, &threshLoc);
        thresh = firstPeak + threshLoc.x + 1;
    }else{
        minMaxLoc(histogram.colRange(secondPeak, firstPeak), NULL, NULL, &threshLoc);
        thresh = secondPeak + threshLoc.x + 1;
    }
    // 阈值分割
    threshold(image, threshOut, thresh, 255, THRESH_BINARY);
    
    return thresh;
}

Otsu算法

原理

Otsu其实是利用最大方差法。假设输入图像$I$的高为$H$,宽为$W$,$histogram_I$代表其归一化所获得的图像灰度直方图,$histogram_I (k)$代表灰度值等于$k$的像素点的个数在图像中占的比例,$0 \leq k \leq 255$,则步骤为:

第一步:计算灰度直方图的零阶累积矩,也称为累加直方图:

$$ zeroCumuMoment(k) = \sum^k_{i=0}histogram_I(i), k \in [0, 255] $$

第二步:计算灰度直方图的一阶累积矩:

$$ oneCumuMoment(k) = \sum^k_{i=0} (i * histogram_I(i)), k \in [0, 255] $$

第三步:计算图像$I$总体的灰度平均值$mean$,其实就是$k=255$时的一阶累积矩,即:

$$ mean = oneCumuMoment(255) $$

第四步:计算每一个灰度级作为阈值时,前景区域的平均灰度、背景区域的平均灰度与整幅图像的平均灰度的方差,对方差的衡量采用以下度量:

$$ \sigma^2(k) = \frac{(mean * zeroCumuMoment(k) - oneCumuMoment(k))^2}{zeroCumuMoment(k) * (1 - zeroCumuMoment(k))}, k \in [0, 255] $$

第五步:找到上述最大的$\sigma^2(k)$,然后对应的$k$即为Otsu自动选取的阈值,即:

$$ thresh = arg_{k \in [0, 255]} max(\sigma^2(k)) $$

C++实现

需要注意的是在求方差时可能出现分母为0的情况。

/**
 * Otsu算法
 *
 * @param image 输入的单通道8位图
 * @param otsuThreshImage Otsu算法阈值分割后的图像
 * @return Otsu得到的阈值
 */
int otsu(const Mat &image, Mat &otsuThreshImage)
{
    // 计算灰度直方图
    Mat histogram = calcGrayHist(image);
    // 归一化灰度直方图
    Mat normHist;
    histogram.convertTo(normHist, CV_32FC1, 1.0/(image.rows*image.cols), 0.0);
    // 计算累加直方图(零阶累积矩)和一阶累积矩
    Mat zeroCumuMoment = Mat::zeros(Size(256, 1), CV_32FC1);
    Mat oneCumuMoment = Mat::zeros(Size(245, 1), CV_32FC1);
    for (int i = 0; i < 256; i++)
    {
        if (i == 0)
        {
            zeroCumuMoment.at<float>(0, i) = normHist.at<float>(0, i);
            oneCumuMoment.at<float>(0, i) = i * normHist.at<float>(0, i);
        } else {
            zeroCumuMoment.at<float>(0, i) = normHist.at<float>(0, i-1) + normHist.at<float>(0, i);
            oneCumuMoment.at<float>(0, i) = normHist.at<float>(0, i-1) + i * normHist.at<float>(0, i);
        }
    } 
    // 计算类间方差
    Mat variance = Mat::zeros(Size(256, 1), CV_32FC1);
    // 总平均值
    float mean = oneCumuMoment.at<float>(0, 255);
    for (int i = 0; i < 255; i++)
    {
        if (zeroCumuMoment.at<float>(0, i) == 0 || zeroCumuMoment.at<float>(0, i) == 1)
        {
            variance.at<float>(0, i) = 0;
        } else {
            float cofficient = zeroCumuMoment.at<float>(0, i) * (1.0-zeroCumuMoment.at<float>(0, i));
            variance.at<float>(0, i) = pow(mean*zeroCumuMoment.at<float>(0, i)-oneCumuMoment.at<float>(0, i), 2.0) / cofficient;
        }
    }
    // 找到阈值
    Point maxLoc;
    mimMaxLoc(variance, NULL, NULL, NULL, &maxLoc);
    int otsuThresh = maxLoc.x;
    // 阈值处理
    threshold(image, otsuThreshImage, otsuThresh, 255, THRESH_BINARY);
    
    return otsuThresh;
}

对于我们在最开始的全局阈值分割里提到的OpenCV提供的函数threshold中,也可以将参数type设置为THRESH_OTSU以实现OTSU算法求取阈值。

熵算法

原理

信息熵的概念来源于信息论。

假设某个符号$u$有$N$种取值,分别为$u_1, u_2, u_3, \dots ,u_N$,每种符号出现的概率分别为$p_1, p_2, p_3, \dots , p_N$,那么该符号的信息熵为

$$ entropy(u) = - \sum^N_{i=1}p_i log(p_i) $$

将8位图图片看作一种符号,那么图片就有256种灰度取值,设$normHist_I$为图片归一化后的灰度直方图,每一种取值的概率为$normHist(k)$,$0 \leq k \leq 255$,利用熵计算阈值的步骤为:

第一步:计算图片的累加概率直方图,也称为零阶累积矩,记为:

$$ cumuHist(k) = \sum^k_{i=0}normHist_I(i), k \in [0, 255] $$

第二步:计算各个灰度值的熵:

$$ entropy(t) = - \sum^t_{k=0}normHist_I (k)log(normHist_I (k)), 0 \leq k \leq 255 $$

第三步:计算使$f(t)=f_1(t) + f_2(t)$最大值的$t$值,该值记为需要的阈值,其中:

$$ f_1(t) = \frac{entropy(t)}{entropy(255)} \frac{log(cumuHist(t))}{log(max \{ cumuHist(0),cumuHist(1), \dots ,cumuHist(t) \})} \\ f_2(t) =(1 - \frac{entropy(t)}{entropy(255)}) \frac{log(1 - cumuHist(t))}{log(max \{ cumuHist(t+1), \dots ,cumuHist(255) \})} $$

Python实现

在第二步的实现中,由于对数的自变量不能为0,如果判断$normHist_I(k)=0$,那么直接令$entropy(k)=entropy(k - 1)$即可。具体代码如下:

def thresh_entropy(image):
    rows, cols = image.shape
    # 计算灰度直方图
    gray_hist =calcGrayHist(image)
    # 归一化灰度直方图,即概率直方图
    norm_gray_hist = gray_hist / float(rows*cols)
    # 第一步:计算累加直方图,也称为零阶累积矩
    zero_cumu_moment = np.zeros([256], np.float32)
    for k in range(256):
        if k == 0:
            zero_cumu_moment[k] = norm_fray_hist[k]
        else:
            zero_cumu_moment[k] = zero_cumu_moment[k-1] + norm_gray_hist[k]
    # 第二步:计算各个灰度级的熵
    entropy = np.zeros([256], np.float32)
    for k in range(256):
        if k == 0:
            if norm_gray_hist[k] == 0:
                entropy[k] = 0
            else:
                entropy[k] = -norm_gray_hist[k] * math.log10(norm_gray_hist[k])
        else:
            if norm_hist_gray[k] == 0:
                entropy[k] = entropy[k-1]
            else:
                entropy[k] = entropy[k-1] - 
                norm_gray_hist[k]*math.log10(norm_hist_gray[k])
    # 第三步:找阈值
    f_t = np.zeros([256], np.float32)
    ft1, ft2 = 0.0, 0.0
    total_entropy = entropy[255]
    for k in range(255):
        # 找最大值
        max_front = np.max(norm_gray_hist[0:k+1])
        max_back = np.max(norm_gray_hist[k+1:256])
        if max_front == 0 or zero_cumu_moment[k] == 0 or 
           max_front == 1 or zero_cumu_moment[k] ==1 or total_entropy == 0:
            ft1 = 0
        else:
            ft1 = entropy[k] / total_entropy*(math.log10(zero_cumu_moment[k]) / math.log10(max_front))
        if max_back == 0 or 1-zero_cumu_moment[k] == 0 or max_back == 1
           or 1-zero_cumu_moment[k] == 1:
            ft2 = 0
        else:
            if total_entropy == 0:
                ft2 = (math.log10(1-zero_cumu_moment[k]) / math.log10(max_back))
            else:
                ft2 = (1-entropy[k]/total_entropy)*(math.log10(1-zero_cumu_moment[k])/math.log10(max_back))
        f_t[k] = ft1 + ft2
    # 找到最大值的索引,作为阈值
    thresh_loc = np.where(ft == np.max(f_t))
    thresh = thresh_loc[0][0]
    # 阈值处理
    threshold = np.copy(image)
    threshold[threshold > thresh] = 255
    threshold[threshold < thrseh] = 0
    return threshold

自适应阈值

对于不均匀光照的图片,如果设定一个全局的阈值,可能只会得到一张局部分割效果好但是光照不足(充分)的地方效果不好,为此我们可以利用自适应阈值针对图片的不同部分得到不同的阈值,这样整张图片的分割效果就会好很多。

原理

利用不同的平滑算子可以计算出当前像素为中心的邻域的灰度“平均值”,所以使用平滑处理后的输出结果作为每个像素设置阈值的参考值。在自适应阈值处理中,平滑算子的尺寸决定了分割出来的物体尺寸,如果滤波器的尺寸太小,那么估计出的局部阈值将不理想。一般来说,平滑算子的宽度必须大于被识别物体的宽度,平滑算子尺寸越大, 则结果能更好的的作为每个像素阈值的参考,当然也不能无限大。

假设输入图像为$I$,高为$H$,宽为$W$,平滑算子的尺寸记为$h \times w$且都为奇数。步骤如下:

第一步:对图像进行平滑处理,平滑后的结果记为$f_{smooth}(I)$,平滑可以使用均值平滑、高斯平滑、中值平滑;

第二步:自适应阈值矩阵$Thresh = (1 - ratio) * f_{smooth}(I)$,一般令$ration = 0.15$;

第三步:利用局部阈值分割的规则

$$ O(r, c) = \left\{ \begin{align} 255, I(r, c) > Thresh(r, c) \\ 0, I(r, c) \leq Thresh(r, c) \end{align} \right. $$

进行阈值分割。

Python实现

def adaptive_thresh(image, win_size, ratio=0.15):
    """
    自适应阈值分割
    :param image: 输入图像
    :param win_size: 平滑算子尺寸
    :param ratio: 比例系数
    :return: 自适应阈值分割后的结果
    """
    # 第一步:对图像进行均值平滑,这里使用的是OpenCV提供的函数
    image_mean = cv2.boxFilter(image, cv2.CV_32FC1, win_size)
    # 第二步:原图像与平滑结果做差
    out = image - (1.0-ratio)*image_mean
    # 第三步:当差值大于或等于0时,输出值为255;反之为0
    out[out >= 0] = 255
    out[out < 0] = 0
    out = out.astype(np.uint8)
    return out

OpenCV提供自适应阈值分割函数void adaptiveThreshold(InputArray src, OutputArray dst, double maxValue, int adaptiveMethod, int thresholdType, int blockSize, double C),其参数如下表所示:

参数解释
src单通道矩阵,数据类型为CV_8U
dst输出矩阵
maxValue与函数threshold类似,一般取255
adaptiveMethodADAPTIVE_THRESH_MEAN_C: 采用均值平滑;
ADAPTIVE_THRESH_GAUSSIAN_C: 采用高斯平滑
thresholdTypeTHRESH_BINARY, THRESH_BINARY_INV
blockSize平滑算子的尺寸且为奇数
C比例系数

二值图的逻辑运算

“与”和“或”运算

“与”运算可以理解为集合的交集,结果是两幅图白色相交的部分;

“或”运算可以理解为集合的并集,结果是两幅图白色区域的并集;

OpenCV提供函数bitwise_andbitwise_or分别实现了这两种运算;

参考

《OpenCV算法精解——基于Python和C++》(张平)第六章

赞 (0)
Responses