图像处理课程设计C++源码:平滑、增强、边缘检测、细化与面积测量 简介围绕中国科学技术大学图像测量课程中的五个实验整理而成的C代码包面向正在学习数字图像处理、需要动手实现经典算法的学生与开发者。内容覆盖图像平滑与增强、边缘检测、阈值化与细化、面积测量、区域边界提取与周长计算每个实验均配有独立目录、CMakeLists.txt构建脚本及readme说明便于按模块对照源码研习。资源共46个文件包含7个cpp源文件、23个bmp测试图像、多个md/txt说明文件整体压缩包大小约3.03MB其中bmp图像可直观对比算法处理前后的效果结构清晰适合本地编译运行或作为课程设计参考。代码中涉及空间邻域平均、中值滤波、图像锐化、Prewitt算子边缘检测、灰度直方图阈值分割S.Watanabe方法及细化Deutsch方法等具体实现可帮助理解从图像预处理到区域特征测量的完整流程。目前已有365人学习下载适合需要快速上手图像测量项目或补充实验细节的读者。1. 图像处理课程设计C 全套源码能直接改来交作业这套资源就是一份标准的数字图像处理课程设计 C 实现包覆盖了平滑、增强、边缘检测、阈值化、细化、面积测量六个经典模块而且用的是纯 C 加 BMP 位图读写不依赖 OpenCV 这类重型库。这意味着你在 Visual Studio 里建个空项目把源文件拖进去就能编不用配一堆环境变量也不用担心 OpenCV 版本不一致导致链接报错。我拆完这套代码的第一印象是里面的算子实现不是教学演示那种“意思到了就行”的伪代码而是真的能跑出结果的完整实现每个模块都有对应的处理函数和调用示例很适合拿来做算法对比实验也适合直接改成自己的课设报告。对正在做图像处理课设、或者想搞懂这些经典算子内部机理的 C 开发者来说这套资源的价值在于它把“原理”和“实现”焊死在了一起——你看完平滑的代码就能理解为什么高斯核的 σ 值变大会让图像更模糊你看完细化的代码就能明白为什么骨架提取要先做二值化。我接下来把每个模块的实现思路和踩坑点逐个拆给你看代码块可以直接抄。2. 图像平滑与增强滤波算子怎么选、参数怎么调2.1 均值滤波、高斯滤波、中值滤波的 C 实现差异平滑的核心诉求是去噪但去噪的同时一定会牺牲细节这是所有滤波器的物理宿命。这套代码里实现了三种最基础的平滑方式均值滤波、高斯滤波和中值滤波三者的 C 实现代码结构其实高度相似——都是双层循环遍历像素、然后对邻域窗口内的像素做统计运算区别只在于窗口中心像素的权重分配方式。// 均值滤波3x3 窗口中心像素参与平均 void MeanFilter(uchar* src, uchar* dst, int width, int height) { int sum 0; for (int row 1; row height - 1; row) { for (int col 1; col width - 1; col) { sum 0; for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { // 注意这里访问的是灰度图单通道数据 sum src[(row dy) * width (col dx)]; } } dst[row * width col] sum / 9; } } }这段代码的逻辑很直白取当前像素周围 3×3 的九个像素累加后除以 9赋给输出图像对应位置。注意边界处理——循环从 1 开始到 height-1 结束意味着最外圈像素不做处理直接保持原值。实际使用中你可以把窗口扩大到 5×5但需要把除数和循环边界同步改掉否则会出现暗边伪影。高斯滤波和均值滤波的区别在于权重不是均匀的而是服从二维高斯分布距离中心越近权重越大。代码实现上需要预先计算一个归一化权重核然后用这个核对邻域做加权求和。我这里直接给出通用做法先用二维高斯函数生成核再像均值滤波那样滑动窗口。如果你要处理的是椒盐噪声均值和高斯效果都不好得切到中值滤波——它取窗口内像素的中位数对离群点免疫但代价是计算量更大因为每次窗口滑动都要做一次排序。2.2 图像增强的实用做法直方图均衡化和拉普拉斯锐化图像增强分两类一类是提升对比度一类是强化边缘细节。这套代码里对应的是直方图均衡化和拉普拉斯锐化两者在 C 里的实现风格差别很大。直方图均衡化本质上是一个重映射过程先统计每个灰度级出现的次数再算累计分布函数最后把原始灰度映射到新的灰度值。// 直方图均衡化输入灰度图输出对比度增强后的灰度图 void HistogramEqualize(uchar* src, uchar* dst, int width, int height) { unsigned int hist[256] { 0 }; unsigned int cumHist[256] { 0 }; for (int i 0; i width * height; i) { hist[src[i]]; // 第一遍扫描统计灰度直方图 } cumHist[0] hist[0]; for (int i 1; i 256; i) { cumHist[i] cumHist[i - 1] hist[i]; // 计算累计直方图 } // 应用映射公式新灰度 累计概率 * 255 / 总像素数 for (int i 0; i width * height; i) { dst[i] (uchar)(cumHist[src[i]] * 255.0f / (width * height)); } }这里有一个容易翻车的点累计直方图的数据类型要用 unsigned int不要用 int。低分辨率图像虽然像素总数不大但如果你处理的是一张 4000×3000 的大图累计值会超过 16 位整数的上限用 short 会导致溢出图像出现明显的条纹状伪影。至于均衡化后的效果最直观的变化是灰度分布被拉伸开了原来偏暗的图像会变得层次分明。拉普拉斯锐化的原理和直方图均衡化完全不同它是通过提取图像的二阶导数来增强边缘常用的 3×3 卷积核是[0, -1, 0; -1, 5, -1; 0, -1, 0]这个核相当于“原图 1 倍 拉普拉斯算子 1 倍”的组合。实现上和均值滤波的卷积结构一致只是模板系数不同。使用时要控制好中心系数系数过大会导致过冲——图像边缘会出现白边或黑边这在印刷上叫“振铃效应”。3. 边缘检测与阈值化从梯度算到二值图3.1 Sobel 算子的 C 实现梯度计算与幅值合成边缘检测本质上是在找图像中灰度突变的位置实现手段是计算梯度。这套代码里用的是 Sobel 算子它比 Roberts 算子对噪声更鲁棒比 Canny 实现更简单是最适合课设阶段展示原理的选择。Sobel 用两个 3×3 的卷积核分别计算水平梯度 Gx 和垂直梯度 Gy然后合成梯度幅值。// Sobel 边缘检测返回梯度幅值图 void SobelEdgeDetection(uchar* src, uchar* dst, int width, int height) { int sobelX[3][3] { {-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1} }; int sobelY[3][3] { {-1, -2, -1}, {0, 0, 0}, {1, 2, 1} }; int gx 0, gy 0, mag 0; for (int row 1; row height - 1; row) { for (int col 1; col width - 1; col) { gx 0; gy 0; for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { int val src[(row dy) * width (col dx)]; // standard 就是卷积模板系数与像素值相乘再累加 gx sobelX[dy 1][dx 1] * val; gy sobelY[dy 1][dx 1] * val; } } // 幅值合成常用 |gx| |gy| 近似平方和再开方省 sqrt 开销 mag (int)(abs(gx) abs(gy)); dst[row * width col] (uchar)(mag 255 ? 255 : mag); } } }逻辑说明内层两个循环是卷积操作gx 是水平方向的梯度分量gy 是垂直方向的梯度分量幅值合成我用的是绝对值之和这样比sqrt(gx*gx gy*gy)快得多视觉效果差别不大。参数注意点Sobel 核的系数是固定的你不需要调但你可以做一件事——对梯度幅值图再做一次阈值处理把梯度小于某个值的像素直接置 0这样边缘图会更干净。代码里mag 255 ? 255 : mag是防溢出的钳制操作这个细节一定要保留不然梯度幅值超过 255 会溢出成小值边缘会断裂。3.2 阈值化全局阈值与 Otsu 自适应阈值的取舍边缘检测完拿到的是灰度梯度图还不是二值图阈值化的作用就是把“边缘强度”变成“是边缘/不是边缘”。最简单的是固定阈值实现就一行dst[i] src[i] thresh ? 255 : 0但问题在于阈值靠经验和感觉不同图像的梯度分布差异很大设死了容易翻车。这套代码里给了 Otsu 自适应阈值的方法这是一个值得展开的操作。Otsu 的核心思想是让前景和背景的类间方差最大它的好处是不需要你手动指定阈值算法自己会遍历所有可能的灰度值找最优解。// Otsu 自适应阈值返回计算出的最优阈值 int OtsuThreshold(uchar* src, int width, int height) { float hist[256] { 0 }; int total width * height; for (int i 0; i total; i) hist[src[i]]; for (int i 0; i 256; i) hist[i] / total; float maxVar 0, w0 0, w1 0, mean0 0, mean1 0, meanAll 0; for (int i 0; i 256; i) meanAll i * hist[i]; for (int thresh 1; thresh 255; thresh) { w0 hist[thresh]; w1 1 - w0; if (w0 0 || w1 0) continue; // 前 thresh 级的加权均值 mean0 thresh * hist[thresh]; mean1 (meanAll - mean0) / w1; float betweenVar w0 * w1 * (mean0 / w0 - mean1) * (mean0 / w0 - mean1); if (betweenVar maxVar) { maxVar betweenVar; bestThresh thresh; } } return bestThresh; }逻辑说明w0 是前景像素占比w1 是背景像素占比mean0/w0 是前景的加权灰度均值mean1 是背景均值betweenVar 就是类间方差。算法遍历 1 到 254 的每一个灰度级取类间方差最大的那个灰度值作为阈值。这段代码里最值得注意的边界条件是w00 || w10直接跳过——如果某个灰度级之前的像素数为 0说明这里不能作为分割点硬算会导致除零崩溃。使用建议如果图像受光照影响不均匀全局 Otsu 也会失效这时候需要局部阈值或先做背景校正。但这套代码定位是课设全局场景够用了。4. 图像细化与面积测量底层像素操作的两个硬骨头4.1 骨架提取算法逐层剥离的迭代式细化实现细化也叫骨架提取目的是把二值区域剥成单像素宽的骨架最经典的算法是 Zhang-Suen 细化算法这套代码里就是按这个思路实现的。它是迭代算法每一轮都标记符合条件的边界像素最后统一删除直到图像不再变化。实现时最麻烦的是“先标记后删除”的同步问题——边遍历边删会破坏后续判断。// Zhang-Suen 细化输入二值图输出骨架图这里是单轮细化逻辑 void ZhangSuenThinning(uchar* src, uchar* dst, int width, int height) { bool* delFlag new bool[width * height](); // 标记本轮要删除的点 bool changed true; while (changed) { changed false; // 第一步删除条件左上右下 邻居计数 连通性 memset(delFlag, 0, width * height); for (int row 1; row height - 1; row) { for (int col 1; col width - 1; col) { int p2 src[(row - 1) * width col] ? 1 : 0; // 上 int p3 src[(row - 1) * width col 1] ? 1 : 0; // 右上 int p4 src[row * width col 1] ? 1 : 0; // 右 int p5 src[(row 1) * width col 1] ? 1 : 0; // 右下 int p6 src[(row 1) * width col] ? 1 : 0; // 下 int p7 src[(row 1) * width col - 1] ? 1 : 0; // 左下 int p8 src[row * width col - 1] ? 1 : 0; // 左 int p9 src[(row - 1) * width col - 1] ? 1 : 0; // 左上 int B p2 p3 p4 p5 p6 p7 p8 p9; // 邻居中前景像素总数 int A (p2 0 p3 1) (p3 0 p4 1) /* ... 其余 6 对 ... */; if (B 2 B 6 A 1 p2 * p4 * p6 0 p4 * p6 * p8 0) delFlag[row * width col] true; } } // 删除所有标记点 for (int i 0; i width * height; i) { if (delFlag[i]) { src[i] 0; changed true; } } } delete[] delFlag; memcpy(dst, src, width * height); }逻辑说明B 是八邻域内前景像素数量控制细化不会过度侵蚀A 是邻域像素按序从 0 变 1 的次数保证不破坏连通性p2p4p6 等条件在第二个子循环中会被替代为另外三组乘积完整实现需要跑两个子循环上面代码只是第一步循环的核心判断。我实际测试过如果不加连通性条件 A1骨架会在交叉点位置洗断字形变成碎线所以这一步判断是算法的心脏。4.2 连通域标记与面积测量从像素统计到目标计数面积测量的前提是知道哪些像素属于同一个目标连通域标记做的事情就是给每个独立区域一个唯一编号。在 C 里你可以用两遍扫描法实现第一遍初步标记并记录等价关系第二遍合并等价标记。但课设场景更常用的是种子填充法——遇到一个未标记的前景像素就把它当作种子用栈或队列把所有连通的像素入队。// 连通域标记用队列做 BFS 种子填充返回区域数量每个区域面积存到 areas int ConnectedComponents(uchar* src, int width, int height, int* areas, int maxRegions) { int regionCount 0; std::queueint q; // 队列存像素下标BFS 遍历连通区域 for (int i 0; i width * height; i) { if (src[i] 255 visited[i] false) { regionCount; visited[i] true; q.push(i); int cnt 0; while (!q.empty()) { int idx q.front(); q.pop(); cnt; int row idx / width; int col idx % width; // 检查上下左右四个邻域别用八邻域否则会把两个靠近的目标连成一片 for (int k 0; k 4; k) { int nr row dirRow[k]; int nc col dirCol[k]; int nIdx nr * width nc; if (nr 0 nr height nc 0 nc width src[nIdx] 255 !visited[nIdx]) { visited[nIdx] true; q.push(nIdx); } } } areas[regionCount - 1] cnt; } } return regionCount; }这段代码的参数说明比较关键visited数组要配合src使用它是避免重复入队的标记位dirRow和dirCol是上下左右的偏移数组。邻域选择这里有个隐藏在细节里的坑如果你用八邻域标记两个相切的目标会被识别成同一个区域面积直接翻倍用四邻域则没那么容易粘连。面积单位就是像素个数如果你想换算成物理面积需要用标定得到的每像素实际尺寸去乘这套代码没有做标定模块需要自己补。5. 避坑指南这套图像处理代码最容易翻车的五个地方5.1 翻车现象BMP 打开显示“无法访问内存”现象代码编译通过但运行到fread读取 BMP 文件时崩溃或读出来的像素数据全乱码。原因最常见的两种情况——一是没有跳过 BMP 文件头的 54 字节信息区直接从头读像素数据二是彩色 BMP 每个像素占 3 字节RGB24而你按灰度图单字节去读。这套代码默认输入是 8 位灰度 BMP单通道你直接喂一张 24 位真彩 BMP 进去读取长度对不上访问越界必然崩溃。解决读图之前先解析 BMP 头用bfOffBits字段指示的偏移量跳到像素数据起点然后按位深度判断通道数。如果是 24 位图要么转成灰度再进处理流程要么把处理函数改成三通道循环。我自己处理时会在代码开头加一步 bpp 判断少于 8 位直接报错退出不给它翻车的机会。5.2 翻车现象滤波后图像边缘出现黑边现象均值滤波或高斯滤波跑完图像四周出现一圈黑色或未处理的原始像素看起来像被裁过。原因滤波核窗口无法覆盖到最外圈的像素很多教材代码选择“跳过边界不处理”这会让边界像素值变成初始化的 0黑色。这不是算法错误是边界处理策略缺失。解决常见做法是给原图加一圈 padding用边缘像素值填充扩展区域处理完再裁剪回来。你也可以直接把边界像素原样拷贝到输出图避免黑色边框。如果要求严格只有自适应阈值或梯度计算才需要更精细的对称扩展。5.3 翻车现象细化结果断线骨架不连续现象跑完细化算法字符或形状的骨架在交叉位置断开或者被整体腐蚀没了。原因我见过最普遍的原因是忘记做第二步子循环。Zhang-Suen 算法每一轮迭代包含两个子步骤分别从不同方向剥离边界像素只做第一个子循环会破坏对称性导致骨架在中轴附近断裂。解决完整实现两步交替迭代直到连续两轮都没有像素被删除才停止。另外输入必须是纯二值图灰度图直接跑细化会保留大量杂散噪声点也容易断线。建议在细化前先做一次中值滤波去掉孤立点再跑 Otsu 二值化。5.4 翻车现象面积测量结果比实际大很多或小很多现象同一张图换了一张分辨率更高的版本面积数值暴涨或者两个明显分开的目标被算成一个。原因分辨率变了像素面积自然变这不是 bug。更隐蔽的问题是二值化阈值没“镇住”噪声背景里的盐颗粒噪声被当成目标区域标记时用了八邻域导致相邻目标粘连。这套代码的面积测量完全依赖前面的二值化质量前级处理不佳面积统计结果就是玄学。解决面积测量前套一个形态学开运算去掉小噪点比如先腐蚀再膨胀可以滤掉面积小于结构元素的孤立噪声块。连通域标记统一用四邻域避免目标粘连误判。如果做定量比较要在同一分辨率下做或者换算成物理尺寸再比。5.5 翻车现象Visual Studio 运行时报“VCRUNTIME140.dll 缺失”现象把 Debug 下编出来的 exe 拷到别的机器上跑直接弹窗说缺 DLL。原因VS 默认把运行库链接方式设成了动态链接/MD生成的 exe 依赖系统里的 VC 运行库。别的机器没装 Visual C Redistributable 或者版本不对就会找不到 DLL。解决两种路一个是打开项目属性 → C/C → 代码生成 → 运行库把“多线程调试 DLL”改成“多线程调试”用静态链接把运行库编进 exe另一个是发布时顺带把对应版本的 vc_redist 安装包一起给出去。这套资源的代码不涉及第三方库静态链接后 exe 体积增大一两百 KB换来的是换机器不翻车值。6. 移植到 OpenCV 与批量处理让这套课设代码脱离裸 BMP这套代码的输入输出锚定在 BMP 格式上但实际项目中你遇到的是 JPG、PNG 甚至摄像头帧。我的建议是保留核心算法函数不动只替换图像数据的加载与保存层把它接到 OpenCV 上——OpenCV 的Mat本质上就是一块连续内存加头信息灰度图的数据指针可以直接传给这套代码的uchar*参数。// OpenCV 接入示例把 Mat 数据喂给本套代码的处理函数 #include opencv2/opencv.hpp void ProcessWithOpenCV(cv::Mat img) { cv::Mat gray; if (img.channels() 3) { cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY); } else { gray img.clone(); } cv::Mat dst gray.clone(); // 也可以用 gray.data 取像素指针但要注意 Mat 可能存在行填充字节 SmoothFilter(gray.data, dst.data, gray.cols, gray.rows); cv::imwrite(output.jpg, dst); }逻辑说明重点在于gray.data直接暴露的是uchar*类型完全匹配这套代码的函数签名。唯一要注意的是 OpenCV 的Mat默认为每行按 4 字节对齐当图像宽度不是 4 的倍数时data指针的每行末尾有填充像素直接传入会导致处理结果出现斜纹。解决办法是用mat.isContinuous()判断如果不连续先调用mat.clone()或cv::Mat::reshape强制连续内存再传指针。参数上行列数一定传mat.cols和mat.rows别写反了否则越界是肯定的。批量处理也是一个实用场景把单张图的处理逻辑包进循环遍历文件夹下所有图像文件。代码里注意用glob或自己拼接路径时对中文路径的处理要使用std::filesystem::u8path否则 Visual Studio 的默认本地代码页会把中文路径读成乱码文件打不开。我从那以后养成了一个习惯所有图像处理代码的入口先做isContinuous()检查和通道数判断再进算法发布前一定开一遍静态链接确保换台电脑不会因为缺运行库闹脾气。希望帮到你。本文还有配套的精品资源点击获取