OpenCV 形态学操作与乐谱线条提取
目标
在本教程中,您将学习如何应用两个非常常见的形态算子(即膨胀和侵蚀),并创建自定义核,以便在水平轴和垂直轴上提取直线。为此,您将使用以下 OpenCV 函数:
cv::dilate(膨胀)cv::erode(侵蚀)cv::getStructuringElement(获取结构元素)cv::morphologyEx(形态学变换)
在一个示例中,您的目标是从乐谱图像中提取音符之间的线条,从而分离出音符本身。
理论
形态学操作
形态学是一组图像处理操作,它们基于预定义的结构元素(也称为内核)处理图像。输出图像中每个像素的值基于输入图像中相应像素与其相邻像素的比较。通过选择内核的大小和形状,您可以构建对输入图像的特定形状敏感的形态操作。
两个最基本的形态学操作是扩张和侵蚀。膨胀将像素添加到图像中物体的边界上,而侵蚀则恰恰相反。添加或删除的像素量分别取决于用于处理图像的结构元素的大小和形状。通常,这两个操作遵循的规则如下:
膨胀 (Dilation):输出像素的值是结构元素大小和形状内所有像素的最大值。例如,在二进制图像中,如果输入图像中属于内核范围的任何像素设置为值 1,则输出图像的相应像素也将设置为 1。后者适用于任何类型的图像(例如灰度、BGR 等)。膨胀操作常用于填充物体内部的小孔洞或连接邻近的物体。
![图:二值图像上的膨胀效果演示]
侵蚀 (Erosion):反之亦然。输出像素的值是结构元素大小和形状范围内所有像素的最小值。请看下面的示例图:侵蚀操作常用于去除小噪点或断开两个相连的物体。
![图:二值图像上的侵蚀效果演示]
结构元素
如上所述,一般来说,在任何形态学操作中,用于探测输入图像的结构元素是最重要的部分。
结构元素是一个仅由 0 和 1 组成的矩阵,可以具有任意形状和大小。通常比正在处理的图像小得多,而值为 1 的像素定义邻域。结构元素的中心像素(称为原点)标识感兴趣的像素 - 正在处理的像素。
例如,下面演示了 7x7 大小的菱形结构元素。
![图:菱形结构元素及其起源]
结构元素可以具有许多常见的形状,例如线条、菱形、圆盘、周期线以及圆形和大小。通常,您选择与要在输入图像中处理/提取的对象具有相同大小和形状的结构元素。例如,要查找图像中的线条,请创建一个线性结构元素,稍后将看到。
对于乐谱提取任务,我们需要区分水平线和垂直线。因此,我们将创建长宽比极端的矩形结构元素:一个宽度大高度小的元素用于检测水平线,一个宽度小高度大的元素用于检测垂直线。
代码示例 C++
本教程代码如下所示。代码基于 OpenCV 4.x 版本编写,确保兼容性和稳定性。
#include <opencv2/opencv.hpp>
#include <iostream>
// 辅助函数:显示图像并等待按键关闭窗口
void show_wait_destroy(const char* winname, const cv::Mat& img) {
cv::imshow(winname, img);
int key = cv::waitKey(0);
if (key == 'q' || key == 'Q') {
return;
}
cv::destroyWindow(winname);
}
int main(int argc, char** argv) {
// 1. 解析命令行参数加载图像
std::string input_path = "music_sheet.png"; // 默认路径,实际使用时可替换为 argv[1]
if (argc > 1) {
input_path = argv[1];
}
cv::Mat src = cv::imread(input_path);
if (src.empty()) {
std::cout << "无法打开或找到图像!" << std::endl;
std::cout << "Usage: " << argv[0] << " <image_path>" << std::endl;
return -1;
}
// 显示源图像
show_wait_destroy("Source", src);
// 2. 将源图像转换为灰色(如果尚未转换为灰色)
cv::Mat gray;
if (src.channels() == 3) {
cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
} else {
gray = src.clone();
}
show_wait_destroy("Gray", gray);
// 3. 灰度到二进制图像
// 在灰色 bitwise_not 应用 adaptiveThreshold,注意 ~ 符号
// 这里使用自适应阈值来增强对比度,适合光照不均的乐谱
cv::Mat bw;
// 参数说明:src, dst, maxValue, adaptiveMethod, thresholdType, blockSize, C
cv::adaptiveThreshold(~gray, bw, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C,
cv::THRESH_BINARY_INV, 15, -2);
show_wait_destroy("Binary", bw);
// 4. 创建将用于提取水平线和垂直线的图像副本
cv::Mat horizontal = bw.clone();
cv::Mat vertical = bw.clone();
// 5. 提取水平线
// 在水平轴上指定大小,通常为列数的 1/30 左右
int horizontal_size = horizontal.cols / 30;
// 创建用于通过形态操作提取水平线的结构元素
// MORPH_RECT 表示矩形,(-1, -1) 表示自动计算
cv::Mat horizontalStructure = cv::getStructuringElement(
cv::MORPH_RECT, cv::Size(horizontal_size, 1));
// 应用形态学操作:先腐蚀后膨胀(开运算)以去除噪声,或先膨胀后腐蚀(闭运算)
// 此处为了提取线条,通常使用腐蚀去除非水平部分,再膨胀恢复
cv::erode(horizontal, horizontal, horizontalStructure);
cv::dilate(horizontal, horizontal, horizontalStructure);
show_wait_destroy("Horizontal Lines", horizontal);
// 6. 提取垂直线
// 指定垂直轴上的大小,通常为行数的 1/30 左右
int vertical_size = vertical.rows / 30;
// 创建结构元素,用于通过形态操作提取垂直线
cv::Mat verticalStructure = cv::getStructuringElement(
cv::MORPH_RECT, cv::Size(1, vertical_size));
// 应用形态学操作
cv::erode(vertical, vertical, verticalStructure);
cv::dilate(vertical, vertical, verticalStructure);
show_wait_destroy("Vertical Lines", vertical);
// 7. 细化边缘/结果优化
// 正如你所看到的,我们快到了。但是,在这一点上,您会注意到音符的边缘有点粗糙。
// 因此,我们需要优化边缘以获得更平滑的结果。
// 逻辑步骤:
// 1. 提取边
// 2. 扩张(边缘)
// 3. src.copyTo(平滑)
// 4. 模糊平滑 IMG
// 5. smooth.copyTo(src,edges)
// 第 1 步:提取边
cv::Mat edges;
// 再次使用阈值处理垂直图像以获取清晰的边缘掩码
cv::adaptiveThreshold(vertical, edges, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C,
cv::THRESH_BINARY, 3, -2);
show_wait_destroy("Edges", edges);
// 步骤 2:膨胀边缘
cv::Mat kernel = cv::Mat::ones(2, 2, CV_8U);
cv::dilate(edges, edges, kernel);
show_wait_destroy("Dilated Edges", edges);
// 步骤 3:复制原始垂直图像到平滑变量
cv::Mat smooth;
vertical.copyTo(smooth);
// 步骤 4:模糊平滑图像
cv::GaussianBlur(smooth, smooth, cv::Size(2, 2), 0);
// 步骤 5:将平滑后的图像与边缘结合
// 使用位运算保留边缘信息
smooth &= edges;
// 显示最终结果
show_wait_destroy("Smoothed Final", smooth);
return 0;
}
结果说明
从获取图像开始,以下是各阶段的详细处理流程及预期结果分析。
加载图像
首先使用 CommandLineParser 或直接读取文件路径加载图像。确保图像路径正确且格式支持(如 PNG, JPG)。
cv::Mat src = cv::imread(argv[1]);
if (src.empty()) {
// 错误处理
}
![图:原始乐谱图像示例]
灰度转换
将源图像转换为灰色(如果尚未转换为灰色)。这减少了数据维度,便于后续的二值化处理。
cv::Mat gray;
if (src.channels() == 3) {
cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
} else {
gray = src.clone();
}
![图:灰度图像示例]
灰度到二进制图像
在灰色 bitwise_not 应用 adaptiveThreshold,注意 ~ 符号。自适应阈值能更好地处理光照不均匀的情况,这对于扫描的乐谱尤为重要。
cv::Mat bw;
cv::adaptiveThreshold(~gray, bw, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C,
cv::THRESH_BINARY_INV, 15, -2);
![图:二值化图像示例]
输出图像
现在我们准备应用形态学运算来提取水平线和垂直线,从而将音符与乐谱分开,但首先让我们初始化我们将为此目的使用的输出图像。
cv::Mat horizontal = bw.clone();
cv::Mat vertical = bw.clone();
结构元素设计
正如我们在理论中指定的那样,为了提取我们想要的对象,我们需要创建相应的结构元素。由于我们要提取水平线,因此用于此目的的相应结构元素将具有以下形状:长条形矩形。
在源代码中,这由以下代码片段表示:
int horizontal_size = horizontal.cols / 30;
cv::Mat horizontalStructure = cv::getStructuringElement(
cv::MORPH_RECT, cv::Size(horizontal_size, 1));
应用形态学操作后,只有水平方向的线条会被保留,其他方向的噪声(如音符)会被腐蚀掉。
![图:提取的水平线示例]
这同样适用于具有相应结构元素的垂直线:
int vertical_size = vertical.rows / 30;
cv::Mat verticalStructure = cv::getStructuringElement(
cv::MORPH_RECT, cv::Size(1, vertical_size));
![图:提取的垂直线示例]
细化边缘/结果优化
正如你所看到的,我们快到了。但是,在这一点上,您会注意到音符的边缘有点粗糙。因此,我们需要优化边缘以获得更平滑的结果。
- 提取边:通过阈值处理获得清晰的边缘掩码。
- 扩张(边缘):使用小核膨胀边缘,使其更连续。
- 平滑处理:利用高斯模糊减少锯齿。
- 融合:将平滑后的图像与边缘掩码结合。
最终结果显示了经过优化的乐谱线条,去除了大部分干扰噪声。
![图:平滑 - 最终结果示例]
常见问题与注意事项
- 结构元素尺寸调整:
horizontal_size和vertical_size的比例(如/30)需要根据具体图像分辨率进行调整。如果线条提取不完整,可适当增大比例;如果误提取了音符,应减小比例。 - 阈值方法选择:
ADAPTIVE_THRESH_GAUSSIAN_C适用于光照不均的场景,若图像光照均匀,也可尝试THRESH_BINARY配合固定阈值。 - 性能优化:对于高分辨率图像,形态学操作可能较慢。建议先缩放图像进行处理,或使用并行计算库。
- 内存管理:确保及时释放中间变量(如
bw,gray),避免内存泄漏,特别是在循环处理多张图片时。
总结
本文详细介绍了 OpenCV 中形态学操作的基本原理及其在乐谱线条提取中的应用。通过合理设计结构元素并结合腐蚀、膨胀操作,可以有效分离图像中的几何特征。掌握这些基础技术有助于进一步开发复杂的文档分析和 OCR 系统。

