提取水平和垂直线条
原理
在腐蚀和膨胀操作中,通过卷积核(kernel),或者结构元素(structuring element),将每次被结构元素扫描到的区域中的最小值或最大值赋予其锚点(一般为结构元素的中心),从而实现扩大或缩小图像中的对象的轮廓。
关于膨胀和腐蚀运算,可以参考本合集中的另一篇文章《腐蚀和膨胀》
从腐蚀和膨胀的具体操作中可以看出,结构元素是非常重要的,它决定了最终的计算效果。
结构元素由0和1组成,可以自由排列。它的矩阵尺寸要比被计算的图片的矩阵尺寸小很多。结构元素的中心,被称为原点(origin)或者锚点(anchor point)。这个点是用来定位每次被操作的像素点的。
结构元素可以有很多形状:线型、钻石型、圆盘型等等。要根据具体用途来选择结构元素的形态。通常是,想要在图片中提取或者操作什么形态的对象就用什么形态的结构元素。比如说,如果想要在图片中定位线条,就可以创建一个线型的结构元素。这也是本文的示例中要进行的操作。
实操------去除五线谱的五线
这里使用的图片可以在安装OpenCV的路径中找到:...\opencv\sources\samples\data\note.png
。目标是去掉图中的五线谱,只保留谱号、升降号和音符。
在导入图片之后,要将图片进行灰度化,或者直接以灰度格式读取图片:
cpp
Mat src{ imread("notes.png", IMREAD_GRAYSCALE) };
二进制化
读入灰度图片之后,要先进行二进制化。使用的是adptiveThreshold()
函数,该函数能够应用自适应的阈值将灰度图片转换成二进制的图片。它包含两种阈值类型:THRESH_BINARY
和THRESH_BINARY_INV
。它们各自的算法如下:
THRESH_BINARY
d s t ( x , y ) = { m a x V a l u e if s r c ( x , y ) > T ( x , y ) 0 otherwise dst(x,y)= \begin{cases} maxValue & \quad \text{if } src(x,y)>T(x,y)\\ 0 & \quad \text{otherwise} \end{cases} dst(x,y)={maxValue0if src(x,y)>T(x,y)otherwiseTHRESH_BINARY_INV
d s t ( x , y ) = { 0 if s r c ( x , y ) > T ( x , y ) m a x V a l u e otherwise dst(x,y)= \begin{cases} 0 & \quad \text{if } src(x,y)>T(x,y)\\ maxValue & \quad \text{otherwise} \end{cases} dst(x,y)={0maxValueif src(x,y)>T(x,y)otherwise
- T ( x , y ) T(x,y) T(x,y)是对每个像素进行计算的自适应阈值,由自适应阈值计算方法方法确定
- s r c src src和 d s t dst dst,分别为原图和输出图
所以上述两种阈值类型下的算法都很简单。当图中的像素值大于该像素点的阈值的时候,该像素就会被赋予一个用户自定义的值( m a x V a l u e maxValue maxValue),或者0。
那么每个像素点的阈值是如何确定的呢?
- 首先阈值是由像素点的邻近像素共同决定,这个邻近区域是个方形区域,其边长记为 b l o c k S i z e blockSize blockSize,则该区域一共有 b l o c k S i z e × b l o c k S i z e blockSize\times blockSize blockSize×blockSize个像素点;
- 然后指定一个常数 C C C;
- 接着根据不同的自适应方法计算相应的阈值:
ADAPTIVE_THRESH_MEAN_C
方法:阈值 T ( x , y ) T(x,y) T(x,y)为以像素点 ( x , y ) (x,y) (x,y)为中心的 b l o c k S i z e × b l o c k S i z e blockSize\times blockSize blockSize×blockSize个像素点的平均值减去常数 C C C;ADAPTIVE_THRESH_GAUSSIAN_C
方法:以像素点 ( x , y ) (x,y) (x,y)为中心的 b l o c k S i z e × b l o c k S i z e blockSize\times blockSize blockSize×blockSize个像素点都通过一个符合高斯分布的权重系数就行加权,然后求和,最后再减去常数 C C C,得到阈值阈值 T ( x , y ) T(x,y) T(x,y)。
下面的代码展示了在本例中如何将图片二进制化:
cpp
Mat dst; //用来储存结果的矩阵
//二进制化
adaptiveThreshold(~src, //原图
dst, //目标图
255, //maxValue
ADAPTIVE_THRESH_MEAN_C, //自适应阈值计算方法
THRESH_BINARY, //阈值类型
15, //blockSize
-2); //常量C
经过上面的二进制化之后,图片中的像素值就只有两种0或255。所以图片中就只有黑白两种颜色,如下图:
提取垂直对象
像原理中介绍的那样,要提取垂直的对象就要用垂直线型的结构元素,结构元素的高度指定为原图高度的1/30,示意图如下:
然后使用这个结构元素,进行腐蚀和膨胀运算:
cpp
int v_size{ dst.rows / 30 }; //结构矩阵的高度=图片高度/30
//创建结构元素
Mat verticalStructure{ getStructuringElement(MORPH_RECT, Size(1, v_size)) };
//腐蚀和膨胀运算
erode(dst, dst, verticalStructure, Point(-1, -1));
dilate(dst, dst, verticalStructure, Point(-1, -1));
我们先看腐蚀后的效果:
可以看到垂直方向的线条和色块都被保存下来了,水平方向的五条线都被腐蚀掉了。但是目前图片中的音符看起来有点"干瘪",不太清晰,所以要进行膨胀操作。
膨胀后的效果:
膨胀让线条更加清晰。
这样我们就提取了清晰的垂直方向的对象。
完善边缘和最终输出图片
虽然经过上述对于垂直对象的提取我们已经得到了音符,但是还需要进一步的完善来得到最终的结果。
黑白反转
我们希望最终的图片中背景是白色的,而音符是黑色的。所以要进行黑白反转的操作。
这里使用了bitwise_not()
函数。该函数对图片中的每个像素值进行按位取反的操作:
d s t ( I ) = ¬ s r c ( I ) dst(I)=\neg src(I) dst(I)=¬src(I)
- s r c ( I ) src(I) src(I)为原图片中的像素值
- d s t ( I ) dst(I) dst(I)为按位取反后的像素值
对上一操作的结果vertical
进行按位取反:
cpp
bitwise_not(dst, dst); //按位取反
由于vertical
已经是值为0或255的8位无符号的数据,所以按位取反之后,255变成0, 0变成255。这就实现了黑白的反转。效果如下:
平滑
上面的音符轮廓看上去还不够平滑,所以要进行平滑操作:
cpp
blur(dst, dst, Size(2, 2)); //使用归一化滤波进行平滑
这里使用的是归一化滤波,即将每个像素点替换为以它为中心的2*2的区域的所有像素点的平均值。
关于平滑操作的介绍,可以参照本合集的另一篇文章《图像平滑基础》
平滑之后,效果如下:
这样我们就完成了从五线谱中提取音符的所有操作了,完整代码如下:
完整代码
cpp
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
import <iostream>;
using namespace cv;
void show_wait_destroy(const char* winname, Mat img);
int main() {
Mat src{ imread("notes.png", IMREAD_GRAYSCALE) };
Mat dst; //用来储存结果的矩阵
//**二进制化**
adaptiveThreshold(~src, //原图
dst, //目标图
255, //maxValue
ADAPTIVE_THRESH_MEAN_C, //自适应阈值方法类型
THRESH_BINARY, //阈值类型
15, //blockSize
-2); //常量C
//**提取垂直对象**
int v_size{ dst.rows / 30 }; //结构矩阵的高度=图片高度/30
//创建结构元素
Mat verticalStructure{ getStructuringElement(MORPH_RECT, Size(1, v_size)) };
//腐蚀和膨胀运算
erode(dst, dst, verticalStructure, Point(-1, -1));
dilate(dst, dst, verticalStructure, Point(-1, -1));
//**黑白反转**
bitwise_not(dst, dst); //按位取反
//**平滑**
blur(dst, dst, Size(2, 2)); //使用归一化滤波进行平滑
imshow("原图", src);
imshow("结果图", dst);
waitKey(0);
}
其他图片元素提取实践
除了可以从原图中提取音符,我们还可以进行以下其他操作。
提取水平线条
像之前讲的,如果我们像提取水平线条类型的图片对象就要使用水平线条型的结构元素,就像下图所示(中间为原点):
结构元素的宽度指定为图片宽度的1/30。创建结构元素的代码如下:
cpp
int horizontal_size{ dst.cols / 30 }; //结构矩阵的宽度=图片宽度/30
//创建结构元素
Mat horizontalStructure{ getStructuringElement(MORPH_RECT, //指定结构元素形态为矩形
Size(horizontal_size, 1)) }; //指定结构元素的宽和高
接着,使用这个结构元素进行腐蚀和膨胀运算:
cpp
erode(dst, dst, horizontalStructure, Point(-1, -1)); //腐蚀运算
dilate(dst, dst, horizontalStructure, Point(-1, -1)); //膨胀运算
我们先看腐蚀运算后的效果:
可以看到,用水平线型的结构元素进行腐蚀运算会消除所有垂直的图片对象,只剩下水平线型的对象。
再看膨胀后的效果:
可以看到没什么大的改变,只是将线条的某些部分变得更粗了,线条也更清晰了。
这样我们就提取除了清晰的水平线条了。
提取音符轮廓
还可以描绘出每个音符的轮廓。这个操作需要再音符已经被提取,但还没有进行黑白反转前进行。这个时候我们已经得到了已经被二进制化了的、黑底白色的音符图片。
要描绘音符的轮廓只要对黑白反转后的图片再进行一次二进制化就可以了。不过这次 b l o c k S i z e blockSize blockSize要调得比较小。代码如下:
cpp
Mat edges;
adaptiveThreshold(dst,
edges,
255, //maxValue
ADAPTIVE_THRESH_MEAN_C, //自适应阈值计算方法
THRESH_BINARY, //阈值类型
3, //blockSize
-2); //常量C
与第一次二进制化相比,这次二进制化只是 b l o c k S i z e blockSize blockSize变小了。这里可能看起来有点难以理解,其实仔细想想就能明白。
现在的图片已经变成了白底黑音符的了。对于每个 b l o c k S i z e × b l o c k S i z e blockSize \times blockSize blockSize×blockSize的9个像素点组成的方形区域:
- 如果其中心小于该区域内所有像素的平均值+常数 C C C(2),就变成0,即黑色。这样所有的白色区域都会变成黑色。因为白色区域的9个像素都是255,其平均值也是255,再加上2,就是257。那所有白色的像素点(255)肯定会小于这个值(257),所以就被赋予0值,变成黑色。同样的,全黑的区域也是如此,所以音符还是黑色。
- 如果其中心大于该区域内所有像素的平均值+2,就变成 m a x V a l u e maxValue maxValue,即255。这样所有黑色区域的边缘都会变成255。因为黑色区域的边缘部分是白色(255),但这些白色的像素点又与黑色的像素点相邻。所以在9个像素点组成的方形区域内,至少有一个黑色像素点,那它们的平均数最少也有大概226,即使再加上2,也是228。这样的话,白色的像素点(255)肯定会大于这个值(228),那么就会被赋予 m a x V a l u e = 255 maxValue=255 maxValue=255。所以黑色区域边缘的像素边缘的一圈白色像素点肯定会被保留,于是就变成了轮廓。
- 综上所述,原来白色的区域都变成了黑色,原来黑色的区域还是黑色,只有黑色边缘的一圈白色像素点还是白色。所以图片就会变成下面这个样子:
上面的轮廓看起来不够清晰,所以用一个较小的( 2 × 2 2 \times 2 2×2)结构元素对它进行一次膨胀操作:
cpp
Mat kernel{ Mat::ones(2,2,CV_8UC1) }; //2*2的结构元素
dilate(edges, edges, kernel); //膨胀运算
效果如下:
可以看到线条变粗、变清晰了。
这样就完成了音符轮廓的提取。