简介:面向图像处理初学者与C++开发者的实验资源包,内容源自中国科技大学图像测量课程,完整覆盖五个经典实验:图像平滑与增强、边缘检测、阈值化与细化、面积测量,以及区域边界提取与周长计算。压缩包共46个文件,含7个C++源码、23个BMP测试图像、6个Markdown说明文档及6个TXT记录文件,整体约3.03MB;每个实验独立成模块,配有src、output和readme,便于按步骤对照学习。实现上采用空域邻域平均、中值/最大最小值滤波、图像锐化、Prewitt一阶微分算子、基于直方图阈值分割及S.Watanabe二值化、Deutsch细化等经典算法,并包含面积测量和边界周长计算的完整流程。目前已有365人学习下载,适合用来巩固数字图像处理基础算法,或作为课程实验与毕业设计的参考实现。
1. 拿一张零件图直接数黑色像素,面积为什么总是不准?
拿到一张零件照片,想自动量出内孔面积。直接数黑色像素,往往因为反光、毛刺和光照不均匀,测出来十有八九不对。我一般不会一上来就做二值化,而是先把图像平滑掉噪声,增强边缘对比度,再通过边缘检测或阈值化把目标从背景里剥离,必要时细化到单像素骨架,最后做面积测量。这套处理链在 C++ 里用 OpenCV 实现,就是标题里的六个操作:图像平滑、增强、边缘检测、阈值化、细化、面积测量。下面的代码把这六个操作串成一条完整管线,适合刚搭完 OpenCV 环境、想要一套能直接落地的代码模板的人。后面我会逐个函数拆开,把参数设定和常见坑点一起标出来。
2. 为什么要按“平滑→增强→边缘检测→阈值化→细化→测量”这个顺序?
2.1 一条从像素矩阵到面积数值的处理流水线
图像处理不是随意拼函数。每一步的输入和输出类型不同,串错顺序最容易翻车。以面积测量为目标,常见做法是:原始灰度图先平滑,降低噪声对后续梯度计算的干扰;再增强,把前景和背景的对比度拉开;边缘检测或阈值化负责提取目标;细化只对二值图有效,把区域变中心线;面积测量最后基于二值图统计像素。用伪代码表示就是:
// 伪代码:先列链路,后面逐个模块替换成可编译代码 Mat src = imread("sample.png", IMREAD_GRAYSCALE); Mat smooth = smoothImage(src); // 噪声变小,方便后边提取特征 Mat sharp = enhanceImage(smooth); // 对比度变大,弱边缘更容易看见 Mat edges = edgeDetect(sharp); // 得到边缘图 Mat binary = thresholdImage(sharp); // 得到二值区域 Mat skel = thinningImage(binary); // 得到单像素宽度骨架 double area = measureArea(binary); // 统计非零像素 / 轮廓像素面积这里有两个容易误用的点:第一,边缘检测和阈值化不是二选一,而是分别回答“边界在哪”和“区域在哪”两个问题;第二,细化只对二值图有意义,如果直接对灰度图做细化,会产生大量无意义的骨架线。所以我在代码里始终让thinning接收上一个threshold的输出。这个顺序就是整个代码包的设计主线。
2.2 平滑和增强的先后顺序:先降噪,再拉对比度
很多初学者会先做直方图均衡化,再平滑,理由是增强后看得更清楚。但先增强会把噪声一并放大,之后再做高斯平滑,虽然噪声被压下去,原本需要的细小边缘也可能被磨掉。反过来先平滑后增强,噪声已经压到较低水平,增强操作放大的主要是真实梯度,边缘细节保留度更高。这是我从实际项目里试出来的经验:除非你的图本身很干净,否则不要打乱这个顺序。
平滑的选型看噪声类型。高斯滤波适合随机高斯噪声,影响所有像素;中值滤波适合椒盐噪声,也就是图像上离散的黑点或白点。如果你不确定噪声类型,先用高斯3x3核起步,观察边缘断裂情况再决定是否加大。增强部分我习惯用直方图均衡化加一次拉普拉斯锐化:均衡化解决整体对比度不足,锐化解决局部边缘模糊。
2.3 边缘检测和阈值化到底谁说了算?
Canny 边缘检测适合目标与背景之间有渐变的场景。它的输出是细线,但不保证轮廓封闭,直接拿边缘图做面积测量会少算一大块。阈值化输出的是完整区域,适合面积测量,但受光照影响大,单一阈值很难应付不均匀光。实际工程里我会这样分工:如果只看面积,以阈值化为主;如果还要做轮廓分析、定位或形状匹配,再把 Canny 结果叠加进去。细化属于可选项,它只有一个用途:拿单像素中心线去做长度、分叉、拓扑分析。你只需要面积,完全可以跳过细化那一步。
用一个最简单的小实验可以直观看出区别。对同一张增强后的图分别跑 Canny 和 Otsu 阈值化:
Mat enhanced = enhance(smoothImage(src, false)); Mat edges, binary; Canny(enhanced, edges, 50, 150); threshold(enhanced, binary, 0, 255, THRESH_BINARY | THRESH_OTSU); imwrite("edges.png", edges); // 线状、可能开口 imwrite("binary.png", binary); // 区域状、封闭edges是单通道二值图,像素值为 0 或 255;binary同样,但代表区域。看两张输出图,你会立刻明白:测量面积用binary,描述边界用edges,两者需要的后续处理完全不一样。
2.4 各模块的算法选型速查表
| 模块 | 常用实现 | OpenCV内置 | 适合场景 | 参数关注点 |
|---|---|---|---|---|
| 图像平滑 | GaussianBlur / medianBlur | 是 | 随机噪声用高斯,椒盐噪声用中值 | 核必须是奇数,越大越模糊 |
| 图像增强 | equalizeHist / filter2D锐化 | 是 | 对比度低用直方图均衡,边缘弱用锐化 | 锐化核中心权重 4~5 |
| 边缘检测 | Canny | 是 | 渐变边界、形状定位 | 高阈值低阈值比例 2:1~3:1 |
| 阈值化 | threshold / adaptiveThreshold | 是 | 目标与背景灰度差异大 | Otsu 只适合双峰直方图 |
| 细化 | Zhang-Suen 骨架化 | 否,需自己实现 | 中心线、长度测量 | 输入必须是二值图 |
| 面积测量 | countNonZero / findContours+contourArea | 是 | 像素面积统计 / 轮廓过滤 | 是否过滤小噪点、是否做标定 |
这张表是我每次搭图像处理项目都会先列一遍的东西。代码写得再快,选型错了后面全是白费。比如细化,OpenCV 主库没有直接提供,网上能找到的代码质量参差不齐,我习惯自己实现一个 Zhang-Suen 版,后面会给出核心函数。
3. 把六个模块写成 C++ 代码:完整实现与参数调整
3.1 读取图像与灰度化:先把输入弄干净
#include <opencv2/opencv.hpp> #include <iostream> using namespace cv; using namespace std; Mat readGray(const string &path) { Mat img = imread(path, IMREAD_GRAYSCALE); if (img.empty()) { cerr << "无法读取图片: " << path << endl; } return img; }这里直接以灰度模式读取,省去转灰度这一步,也避免彩色图转灰度时的通道顺序问题。如果你的输入是彩色图,但后续流程只关心亮度,IMREAD_GRAYSCALE是最快的做法。需要注意,imread在 Windows 下对中文路径支持很差,后面我会专门讲这个坑。代码里的path参数建议用英文路径,文件放在data/目录下。
3.2 图像平滑:高斯核与中值核的选择
Mat smoothImage(const Mat &src, bool useMedian = false) { Mat dst; if (useMedian) { medianBlur(src, dst, 5); // 5x5 中值滤波,去椒盐噪声 } else { GaussianBlur(src, dst, Size(3, 3), 0); // 3x3 高斯,默认 sigma } return dst; }关键参数是核大小。GaussianBlur的第三个参数Size(3,3)表示 3x3 窗口,核越大图像越模糊,细节丢失越严重。我从经验出发:一般零件图用 3x3 起步,如果噪声明显可以调到 5x5,不要一上来就 7x7。sigmaX填 0 时,OpenCV 会根据核宽自动计算标准差,多数场景不用手写。medianBlur的核也必须是大于 1 的奇数,5 表示 5x5 窗口,适合有椒盐噪声的图像,但会把小孔或细线磨掉。如果你的目标本身很细,优先选高斯而不是中值。
3.3 图像增强:直方图均衡化 + 拉普拉斯锐化
Mat enhanceImage(const Mat &src) { Mat equalized; equalizeHist(src, equalized); Mat kernel = (Mat_<float>(3, 3) << 0, -1, 0, -1, 5, -1, 0, -1, 0); Mat sharpened; filter2D(equalized, sharpened, equalized.depth(), kernel); return sharpened; }equalizeHist会把灰度直方图拉伸到接近均匀分布,原本昏暗的细节会变明显,但它也会放大噪声,所以必须放在平滑之后。拉普拉斯锐化核的中心权重是 5,周围是 -1,等价于把原图加上拉普拉斯算子的负值,边缘处灰度变化被放大。如果锐化后噪点太刺眼,把中心权重从 5 降到 4,减弱锐化强度。这个核不是固定的,你也可以用 OpenCV 的Laplacian再叠加原图,但用filter2D自定义核更容易控制强度。
3.4 边缘检测:Canny 高低阈值怎么设
Mat edgeDetect(const Mat &src, double low = 50, double high = 150) { Mat edges; Canny(src, edges, low, high); return edges; }Canny 的两个阈值是这套代码里最像“玄学”的地方。low控制弱边界,high控制强边界。低于low的梯度直接丢弃,高于high的认为是确定边界,介于两者之间且与强边界连通的才会保留。常见比例是 1:2 到 1:3,我用 50 和 150 作为默认值。如果你发现边缘断断续续,可以把low降低,比如 30,让更多弱边缘参与;如果发现满天噪点,就提高low。边缘图是 8 位单通道二值图,但要注意它并不封闭,直接用于面积测量会漏算,所以它只做形状分析用。
3.5 图像阈值化:Otsu 自动阈值和固定阈值二选一
Mat thresholdImage(const Mat &src, int fixedVal = 127, bool useOtsu = true) { Mat dst; if (useOtsu) { threshold(src, dst, 0, 255, THRESH_BINARY | THRESH_OTSU); } else { threshold(src, dst, fixedVal, 255, THRESH_BINARY); } return dst; }THRESH_OTSU会忽略第四个参数,由算法自动计算全局最优阈值,适合直方图呈现双峰分布的情况。但它的前提是前景和背景面积不能差太远。如果零件很小、背景很大,Otsu 算出来的阈值会偏向背景,导致目标区域被切开。此时改用固定阈值,先把fixedVal设成 127,再看二值图调整。还有一种场景,光照从图像一侧到另一侧逐渐变暗,全局阈值必然局部失效,这时要用adaptiveThreshold,它按窗口算局部阈值,对光照渐变更抗干扰,但会产生更多细碎背景区域,需要搭配面积过滤使用。
3.6 图像细化:Zhang-Suen 骨架化实现要点
OpenCV 主库没有细化函数,我一般直接实现 Zhang-Suen 算法。它的思想是迭代删除满足条件的前景像素,直到剩下单像素宽度的骨架。核心函数如下:
// 输入必须是二值图:前景像素 255,背景像素 0 void thinningIteration(Mat &img, int iter) { Mat marker = Mat::zeros(img.size(), CV_8UC1); for (int i = 1; i < img.rows - 1; i++) { for (int j = 1; j < img.cols - 1; j++) { uchar p2 = img.at<uchar>(i - 1, j); uchar p3 = img.at<uchar>(i - 1, j + 1); uchar p4 = img.at<uchar>(i, j + 1); uchar p5 = img.at<uchar>(i + 1, j + 1); uchar p6 = img.at<uchar>(i + 1, j); uchar p7 = img.at<uchar>(i + 1, j - 1); uchar p8 = img.at<uchar>(i, j - 1); uchar p9 = img.at<uchar>(i - 1, j - 1); int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; // 周围前景像素数 if (B >= 2 && B <= 6) { int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) + (p4 == 0 && p5 == 1) + (p5 == 0 && p6 == 1) + (p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) + (p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1); if (A == 1) { if (iter == 0 && (p2 * p4 * p6 == 0) && (p4 * p6 * p8 == 0)) { marker.at<uchar>(i, j) = 1; } if (iter == 1 && (p2 * p4 * p8 == 0) && (p2 * p6 * p8 == 0)) { marker.at<uchar>(i, j) = 1; } } } } } img &= ~marker; } void thinning(Mat &img) { Mat prev; do { img.copyTo(prev); thinningIteration(img, 0); thinningIteration(img, 1); } while (countNonZero(img) < countNonZero(prev) && countNonZero(img) > 0); }这段逻辑拆开看并不难。thinningIteration每次遍历所有像素,检查周围 8 邻域里前景像素数量B和 0→1 的变化次数A,只有满足拓扑保持条件的点才被标记删除。iter = 0和iter = 1是两个不同方向的子迭代,交替执行是为了保持骨架连通性。do...while里有变化就继续,直到没有像素可删。参数上唯一要注意的是输入必须是纯二值图,灰度图直接传进来会得到一团乱麻。如果你的检测对象只是圆形垫片这类简单区域,骨架细化对面积测量没有帮助,实际项目里我通常会跳过它。
3.7 面积测量:countNonZero 还是 findContours?
double measureArea(Mat &binary) { vector<vector<Point>> contours; findContours(binary.clone(), contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); double total = 0; for (size_t i = 0; i < contours.size(); i++) { double area = contourArea(contours[i]); if (area > 50) { // 过滤孤立小噪点 total += area; } } return total; }countNonZero(binary)可以直接统计非零像素个数,速度最快,但它把二值图里所有白色像素都算进去,包括噪声和意外区域。findContours能找出轮廓,再通过contourArea得到每个轮廓的面积,可以在循环里做过滤。这里必须binary.clone(),因为findContours会修改输入图像,不克隆的话你的二值图后续没法再用。RETR_EXTERNAL只提取最外层轮廓,适合测量外轮廓面积;如果你想算内孔面积,情况会复杂一些,需要根据孔洞边界是否在目标内部做选择。单位是像素平方,和物理面积之间差一个标定系数,这个我在最后单独讲。
3.8 主流程:把六个模块串成一条可运行的管线
int main() { Mat src = readGray("data/part.png"); if (src.empty()) return -1; Mat smooth = smoothImage(src, false); Mat enhanced = enhanceImage(smooth); Mat edges = edgeDetect(enhanced, 50, 150); Mat binary = thresholdImage(enhanced, 127, true); Mat skeleton = binary.clone(); thinning(skeleton); // 可选,面积测量不需要它 imwrite("output/smooth.png", smooth); imwrite("output/edges.png", edges); imwrite("output/binary.png", binary); imwrite("output/skeleton.png", skeleton); double area = measureArea(binary); cout << "Area(pixels) = " << area << endl; return 0; }注意edges和binary都来自enhanced,而不是让edges参与阈值化。原因是边缘图不封闭,拿它做面积测量会漏算。中间过程全部用imwrite保存到output目录,这就是调试这套代码的“后悔药”:面积不对的时候,先看是哪一步输出出了问题,不要直接去调最后面积。这个习惯能帮你省下大量对着数字猜参数的时间。
4. 下载包的工程结构、编译与运行环境
4.1 代码包该有的文件组织方式
拿到一个图像处理 C++ 代码包,第一件事不是看代码,而是看目录结构。常见的可维护结构是下面这样:
project/ ├─ CMakeLists.txt ├─ README.md ├─ data/ │ ├─ sample.png # 测试图 │ └─ calibration.png # 标定用的已知尺寸物体 ├─ include/ │ └─ image_process.h └─ src/ ├─ image_process.cpp └─ main.cppimage_process.h用来声明上一章那些函数,image_process.cpp放实现,main.cpp只做流程编排。这样做的理由是:平滑、增强、阈值化这些模块,换个项目还要再用,封装成独立函数比全写在main里好维护得多。头文件声明大概像这样:
// include/image_process.h #pragma once #include <opencv2/opencv.hpp> using namespace cv; using namespace std; Mat readGray(const string &path); Mat smoothImage(const Mat &src, bool useMedian = false); Mat enhanceImage(const Mat &src); Mat edgeDetect(const Mat &src, double low = 50, double high = 150); Mat thresholdImage(const Mat &src, int fixedVal = 127, bool useOtsu = true); void thinning(Mat &img); double measureArea(Mat &binary);这种组织方式对新手很友好:想看哪个函数,直接去对应文件搜,不用在几百行代码里翻。
4.2 CMakeLists.txt 最小模板与编译命令
cmake_minimum_required(VERSION 3.10) project(image_process) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) include_directories(${OpenCV_INCLUDE_DIRS}) add_executable(image_process src/main.cpp src/image_process.cpp ) target_link_libraries(image_process ${OpenCV_LIBS})这是最简可用配置。find_package(OpenCV REQUIRED)会去系统路径找 OpenCV 安装,找不到会直接报错。Linux 上需要提前安装libopencv-dev,Windows 上需要把 OpenCV 解压后的路径设置到环境变量OpenCV_DIR指向 build 目录。编译命令如下:
mkdir build cd build cmake .. cmake --build .如果你用 Visual Studio Code 配置 C/C++ 环境,可以在tasks.json里把cmake --build .作为构建任务,快捷键一键编译。这套流程对 C++ 初学者稍微有点门槛,但比把几十个源文件用命令行手动g++编译要可靠得多。特别是 OpenCV 的 include 和 lib 路径很乱,CMake 能自动处理大半。
4.3 Windows 运行环境:Visual C++ Redistributable 为什么必须装
用 MSVC 编译的 C++ 程序在目标机器上运行时,经常提示缺少VCRUNTIME140.dll。这不是你代码的问题,而是目标机器没有安装对应的 Visual C++ Redistributable 运行库。常见做法是把运行库安装包一并放进“相关文件”目录,或者直接告诉使用者在通用软件站搜索microsoft visual c++ redistributable (x64) 下载,安装对应版本即可。如果不想每台机器都装,可以把需要的 DLL 和 exe 放在同一目录,比如opencv_world4*.dll和VCRUNTIME140.dll,并把当前目录加入 PATH。需要提醒的是,Debug 和 Release 的依赖不一样,发布代码包时最好用 Release 构建,避免对方机器缺一堆调试库。
5. 五个高频坑与排查顺序:从现象到解决
5.1 边缘断裂:Canny 输出总是断的
现象:edges.png里轮廓线有明显缺口,尤其是弧线和暗部边缘处,后续无法形成封闭轮廓。
原因:最常见的是没有先做平滑就让 Canny 跑,噪声梯度干扰了边缘判断;其次是高低阈值设太高,弱边缘被直接丢弃。
解决:先确认输入是enhanced而不是原图。然后把low从 50 下调到 30,high保持 90,比例为 1:3。如果缺口还在,可以加一步膨胀把断点接上:dilate(edges, edges, getStructuringElement(MORPH_RECT, Size(3, 3)));,让缺口在视觉上闭合,但注意膨胀会让边缘变粗,后面做细化时影响不大,做面积测量时不推荐用这种粗化后的边缘。
5.2 面积总是偏大或偏小
现象:计算出来的面积和卡尺量出来的结果对不上,偏大或偏小比较稳定。
原因:阈值化后的二值图里,目标边缘有毛刺或孔洞,countNonZero把背景上的白色噪点也算进去了;或者在RETR_EXTERNAL模式下漏掉了内部孔洞面积。
解决:在measureArea里加最小面积过滤,比如area > 50,把孤立噪点排除。如果目标表面有孔洞导致面积偏小,在thresholdImage之后做形态学闭运算:morphologyEx(binary, binary, MORPH_CLOSE, getStructuringElement(MORPH_ELLIPSE, Size(5, 5)));,把孔洞填上。如果目标是暗色零件,记得用THRESH_BINARY_INV,别把背景白色区域当目标。
5.3 细化骨架出现大量毛刺
现象:skeleton.png主干旁边伸出很多短分叉,看起来像刺,无法用于长度或拓扑分析。
原因:Zhang-Suen 对噪声和边缘毛刺非常敏感,输入二值图边缘不平滑,或存在孤立小点,都会在迭代中保留成分支。
解决:细化前先对binary做一次形态学开运算,去掉小突起:morphologyEx(binary, binary, MORPH_OPEN, getStructuringElement(MORPH_RECT, Size(3, 3)));。如果毛刺还在,说明开运算核太小,可以换成5x5。还有一个容易忽略的点:thinning的输入必须是二值图,Canny 输出虽然也是二值图,但边缘线通常没有填满区域,直接细化会得到一团散线。必须先阈值化成封闭区域,再细化。
5.4 光照一变,Otsu 就翻车
现象:把设备搬到窗户旁或者换了一盏灯,面积测量结果明显漂移,之前调好的阈值就不对劲了。
原因:Otsu 是全局阈值,它假设整个图像的灰度直方图有两个明显的峰。光照不均时直方图上只有一个大峰,Otsu 会把阈值割在峰的一侧,导致目标区域被错误分割。
解决:这种情况优先用自适应阈值。adaptiveThreshold(src, binary, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 31, 10);,其中 31 是局部窗口大小,10 是常数偏差。窗口越大,算法越接近全局阈值;窗口太小,会把纹理误判为目标。实际调试时我会把窗口设成目标直径的 1/10 到 1/5,偏差 5 到 15 之间慢慢试。自适应阈值会产生更多背景碎点,所以一定要配合measureArea里的最小面积过滤。
5.5 中文字符串路径读图失败:imread返回空
现象:代码在英文路径下正常运行,一旦把图片放到中文目录里,imread就一直返回empty()。
原因:OpenCV 在 Windows 上对中文路径支持不好,底层用的字符集不是 UTF-8,中文路径在转换时丢失。
解决:最简单的做法是把所有图片和相关文件都放在纯英文路径下,比如D:/image_project/data/part.png,文件名也不要带中文。如果一定要支持中文路径,可以先用std::filesystem获取短路径或自行拷贝到临时文件,再进行imread。我自己的习惯是项目建立第一天就固定英文路径,这个坑能绕开就绕开。
6. 进阶:用标定和重复测量把像素面积换算成物理面积
上一章的面积单位是像素平方,客户要的是平方毫米。要完成换算,需要知道一个像素对应多少物理尺寸。常见做法是拍一个已知物理尺寸的物体,比如直径 25 毫米的硬币,经过同一套预处理链得到二值轮廓,然后用像素宽度推算比例。核心代码可以这样写:
double computeScale(Mat &gray, double realWidthMm) { Mat smooth = smoothImage(gray, false); Mat enhanced = enhanceImage(smooth); Mat binary = thresholdImage(enhanced, 127, true); vector<vector<Point>> contours; findContours(binary.clone(), contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); double scale = 0.0; double maxArea = 0.0; for (auto &c : contours) { double area = contourArea(c); if (area > maxArea) { maxArea = area; Rect r = boundingRect(c); double pixelWidth = min(r.width, r.height); // 用短边近似直径 scale = realWidthMm / pixelWidth; } } return scale; }得到scale后,目标面积换算公式是realArea = contourArea(binary) * scale * scale。注意标定系数只对当前镜头和当前工作距离有效。镜头倾斜、物距变化、变焦,都会让比例改变。我习惯把标定值写进一个YAML配置文件,每次换工装或调镜头后重新拍一张标定图更新配置。只做一次标定就长期使用,迟早会在某个项目里翻车。
验证这套流程是否可靠,不能只看一张图。连续拍 10 张同一零件,跑同一套代码,计算面积平均值和标准差。如果标准差除以平均值超过 1%,说明图像处理链或光照还不够稳定,需要回头调阈值或加光源。这个做法也能帮你区分是算法问题还是硬件问题:标准差偏大说明重复性差,均值偏大说明标定系数偏低。
我以前做视觉测量时,直接拿countNonZero结果当面积,客户拿着卡尺来问,我只能灰溜溜去补标定。后来我把“先标定、后测量、再验重复性”做成标准流程,每换一个场景都重新走一遍。图像处理不是黑匣子,中间结果和重复性数据才是让方案能交付的底气。希望帮到你。
本文还有配套的精品资源,点击获取