简介面向图像处理初学者与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 或 255binary同样但代表区域。看两张输出图你会立刻明白测量面积用binary描述边界用edges两者需要的后续处理完全不一样。2.4 各模块的算法选型速查表模块常用实现OpenCV内置适合场景参数关注点图像平滑GaussianBlur / medianBlur是随机噪声用高斯椒盐噪声用中值核必须是奇数越大越模糊图像增强equalizeHist / filter2D锐化是对比度低用直方图均衡边缘弱用锐化锐化核中心权重 4~5边缘检测Canny是渐变边界、形状定位高阈值低阈值比例 2:1~3:1阈值化threshold / adaptiveThreshold是目标与背景灰度差异大Otsu 只适合双峰直方图细化Zhang-Suen 骨架化否需自己实现中心线、长度测量输入必须是二值图面积测量countNonZero / findContourscontourArea是像素面积统计 / 轮廓过滤是否过滤小噪点、是否做标定这张表是我每次搭图像处理项目都会先列一遍的东西。代码写得再快选型错了后面全是白费。比如细化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.atuchar(i - 1, j); uchar p3 img.atuchar(i - 1, j 1); uchar p4 img.atuchar(i, j 1); uchar p5 img.atuchar(i 1, j 1); uchar p6 img.atuchar(i 1, j); uchar p7 img.atuchar(i 1, j - 1); uchar p8 img.atuchar(i, j - 1); uchar p9 img.atuchar(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.atuchar(i, j) 1; } if (iter 1 (p2 * p4 * p8 0) (p2 * p6 * p8 0)) { marker.atuchar(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 还是 findContoursdouble measureArea(Mat binary) { vectorvectorPoint 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-devWindows 上需要把 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 下调到 30high保持 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); vectorvectorPoint 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结果当面积客户拿着卡尺来问我只能灰溜溜去补标定。后来我把“先标定、后测量、再验重复性”做成标准流程每换一个场景都重新走一遍。图像处理不是黑匣子中间结果和重复性数据才是让方案能交付的底气。希望帮到你。本文还有配套的精品资源点击获取