简介这是一份数字图像处理课程大作业的完整工程代码包围绕Canny边缘检测、OTSU图像分割与OpenCV人脸检测三大任务展开适合正在学习OpenCV与Python图像处理的学生和开发者既能用于课程项目实践也能作为入门综合案例。整包约97.64MB共11个文件核心为7个Python脚本覆盖图像预处理、数据集划分、模型训练与评估等模块另含2份Markdown说明文档、1个zip压缩包和Git忽略配置便于对照文档梳理代码并快速复现运行环境。已有8350人浏览/学习实用性有较高认可度。代码中演示了高斯滤波、cv2.Canny边缘检测、cv2.threshold的OTSU二值化以及基于Haar级联分类器的人脸检测完整流程还提供了数据预处理与训练脚本可直接跑通从原始图像到分割、检测结果的完整管线也可作为课程大作业模板继续扩展。1. 数字图像处理大作业的三个硬骨头图像分割、人脸检测、边缘检测代码就摆在你面前很多 DIP 大作业包打开之后是一堆没头没尾的.py但 DIP-Project 这个包不一样它把 Canny 边缘检测、OTSU 图像分割、OpenCV 人脸检测三条主链拆成了preprocess.py、dataset.py、metric.py这类文件顺手还给你留了一个基于 ResNet 的分类训练入口。换句话说你不需要从零开始拼装算法而是要先把它的数据流看懂再把断掉的环节补起来。这份资源适合两类人正在做图像分割与人脸检测课程设计的学生和想在一个晚上把 OpenCV Python 图像处理管线跑通、再往分类方向延伸的从业者。2. 先把工程拆清楚DIP-Project 的目录结构与三条调用链2.1 目录与文件职责哪个文件管数据哪个管模型每一个叫DIP-Project-master的工程包第一眼看上去都是「打开即劝退」。zip 里既有classification目录、resnet.py、train_3d.py又躺着gradle-6.5-bin.zip这种明显不属于图像算法主线的压缩包。我的习惯是先把名字过一遍给每个文件贴一个职责标签再决定先看谁。下表是我读这个包时的文件分工文件职责猜测入手优先级README.md项目说明与运行入口第一个打开Preliminaries.md前置知识或环境准备记录第二个打开preprocess.py图像预处理灰度化、滤波、归一化高divide_trdev.py训练集/验证集划分中dataset.py数据加载与批量读取中classification.py分类任务入口看 README 决定metric.py评估指标准确率、F1 等高resnet.pyResNet 网络结构定义低train_3d.py训练脚本名字里的 3d 可能是第三个实验维度低classification/分类方向附属代码低gradle-6.5-bin.zip构建工具缓存先隔离最低这里重点说preprocess.py和metric.py因为它们决定了算法链的输入和输出。合格的预处理函数不会只做一次cv2.cvtColor它一般会顺带处理灰度转换、尺寸归一化、像素值缩放甚至把训练和推理阶段共用的预处理逻辑收拢到同一个函数里避免两边各写一套逻辑。而metric.py是你在交作业时最容易被扣分的地方边缘检测是否定位准确、分割掩码和标注是否对得上、人脸框是否有冗余这些不是肉眼看两张图就能说明的事得落到 IOU、准确率、召回率这种能打印出来的数上。divide_trdev.py看起来不起眼但它决定分类结果是否可信。随机打乱划分在数据集类别不平衡或者同一对象出现在多张图时会泄漏信息训练集和验证集基本等价验证指标虚高。后面我会在第 5 章回到这个坑。2.2 三条调用链从原图到分割、检测、分类把文件名看懂之后下一步是把「数据流」串起来。这个包其实只有三条链路分割链、边缘链、人脸检测链外加一条可选的分类链。分割链是原图进preprocess.py做高斯滤波再走cv2.threshold(..., THRESH_OTSU)得到二值掩码边缘链是灰度图直接走cv2.Canny得到边缘图人脸检测链则是灰度化加直方图均衡后走CascadeClassifier得到一组人脸框。分类链更靠后dataset.py从数据目录读图preprocess归一化resnet.py前向传播train_3d.py训练最后用metric.py评估。这些链路在代码里通常会被封装成函数我可以给一个路由风格的示意# 三条主链的路由示意实际调用以 README 为准 def gray(img): return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) pipeline { segment: lambda img: run_otsu(gray(img)), # 分割链 edge: lambda img: run_canny(gray(img)), # 边缘链 face: lambda img: run_haar(gray(img)) # 检测链 }逻辑说明这个路由表把所有算法统一成「输入一张图输出一个结果」的接口方便后续在classification.py里按需组合。参数说明gray(img)统一走cv2.COLOR_BGR2GRAY这是后面所有算法的前置条件三条链共用同一个灰度入口之后想加直方图均衡或 CLAHE也只要改这一处。我一般会先拿一张 512×512 左右的图把三条链各跑一遍确认输出的形状和范围分割掩码应该是 0/255 的单通道图边缘图也是 0/255人脸检测结果是(x, y, w, h)的列表。这个「形状自检」是我每次拆新工程的必做动作能提前发现一半以上的低级错误。2.3 环境准备与可复现配置OpenCV 版本与 Python 依赖这个包的主线是 OpenCV Python环境是最容易踩雷的环节。如果目录环境里已有一个旧版opencv-pythoncv2.Canny和cv2.threshold的接口行为会稍有差异新人会误以为是代码写错。我的做法是新建一个虚拟环境再按最小集安装python -m venv .venv source .venv/bin/activate pip install --upgrade pip setuptools wheel pip install opencv-python4.5.* numpy matplotlib pillow逻辑说明opencv-python建议锁大版本不锁小版本4.5 之后的版本都能满足cv2.THRESH_OTSU和cv2.CascadeClassifier的用法而锁大版本是为了避免你参考的教程和实际装的 API 差异太大。参数说明numpy不要单独锁太老的版本否则和 OpenCV 的数组接口可能不兼容pillow的作用是补足部分环境里的图像解码能力。如果你之后要走resnet.py那条分类线再补一行pip install torch torchvisionCPU 版就够交大作业。环境检查用这段python -c import cv2, numpy; print(cv2.__version__, numpy.__version__)能打印出版本号就说明基础环境通了。gradle-6.5-bin.zip从命名看是 JVM 构建工具的发行包很可能只是上传者把整个工作目录打包时顺手带进去的和 Python 主线无关先隔离起来别让它干扰依赖分析。3. 图像分割与边缘检测OTSU 与 Canny 的 OpenCV 落地3.1 OTSU 分割原理类间方差最大化的直觉拿一张图做二值化时第一个念头是「设个固定阈值」。但光照变一下、对比度低一点同一个阈值就失效。OTSU 的思路是不猜而是遍历所有可能的灰度级把像素按阈值分成前景、背景两类算「两类均值与整体均值之间的差异」差异最大的那一个灰度级就是最优阈值。用术语说OTSU 最大化类间方差、最小化类内方差。直觉是一个阈值如果能同时让前景里的像素尽量相似、背景里的像素尽量相似那两类之间的差异自然最大分割的质量也最好。它不关心图像里的语义只关心灰度直方图的分布形态所以叫自适应二值化。光照不均匀但直方图仍然有双峰时它比固定阈值稳得多如果直方图是单峰或者前景占比极低OTSU 也会翻车这就是后面避坑章要处理的事。3.2 OTSU 的代码实现与参数边界OpenCV 里的写法非常短但几个细节决定成败import cv2 img cv2.imread(sample.jpg, cv2.IMREAD_GRAYSCALE) # 直接读成灰度避免三通道问题 blur cv2.GaussianBlur(img, (5, 5), 0) # 先高斯去噪让直方图更接近双峰 th, mask cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) print(auto threshold:, th)逻辑说明cv2.imread的第二个参数传cv2.IMREAD_GRAYSCALE可以在源头避免threshold处理三通道图像时行为失控GaussianBlur抹掉纹理噪声让直方图更容易形成双峰cv2.threshold的第二个参数原本是手动阈值但在 OTSU 模式下传 0 即可OpenCV 会把实际算出的阈值写入th并返回。参数说明(5, 5)是高斯核大小必须是正奇数核越大细节丢得越多拿到像素尺寸较小的图时建议降到(3, 3)。cv2.THRESH_BINARY cv2.THRESH_OTSU两个 flag 必须同时存在只写THRESH_OTSU不会得到二值图。打印出来的th通常在 120~180 区间如果落在这个范围外先回头查预处理。如果原图本身对比度低我一般会在高斯之前先做一次cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8))让暗部细节浮出来再做 OTSU效果比直接调阈值参数好。注意 CLAHE 输入也必须是灰度图。3.3 Canny 边缘检测双阈值与迟滞的直觉Canny 常被误解成「一个cv2.Canny就完事」其实它是一套流程先去掉噪声再用 Sobel 算子算梯度幅值和方向接着做非极大值抑制把「宽」边缘压成「细」线最后用双阈值做迟滞连接。双阈值的意思是梯度幅值超过高阈值的像素一定是边缘低于低阈值的一定不是介于两者之间的像素只有跟已确认边缘相连才保留。参数影响力最大的是两个阈值的选择我先给一个完整调用blur cv2.GaussianBlur(img, (5, 5), 1.0) edges cv2.Canny(blur, threshold150, threshold2150, apertureSize3, L2gradientTrue)逻辑说明先高斯再 Canny 是稳妥做法虽然 Canny 内部也有平滑但预先去噪能减少碎短线。threshold150, threshold2150是 13 的比例常见起点是 12 或 13。参数说明apertureSize3是 Sobel 算子的孔径只能取 3、5、7 这些奇数值越大边缘越粗定位精度也越低L2gradientTrue表示用 L2 范数计算梯度幅值对噪声更稳健但耗时稍高默认的False用 L1 近似噪声多时短线段会明显变碎。我调 Canny 的顺序是先固定高阈值 150低阈值从 50 往上扫边缘太碎就同时提高低阈值和高阈值边缘缺失就降低高阈值同时把L2gradient打开。还有个小技巧用cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)做一次闭运算可以把断开的轮廓补上适合最后出图时用。3.4 可视化三连把分割结果和边缘结果叠到原图上交作业时评委最想看到的是算法效果对比不是光秃秃的数组。用 matplotlib 并排展示原图、OTSU 掩码、Canny 边缘和叠加图是最常见的出图方式import matplotlib.pyplot as plt fig, axes plt.subplots(1, 4, figsize(16, 4)) axes[0].imshow(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)) axes[0].set_title(original) axes[1].imshow(mask, cmapgray) axes[1].set_title(OTSU mask) axes[2].imshow(edges, cmapgray) axes[2].set_title(Canny edges) overlay cv2.addWeighted(mask, 0.5, edges, 0.5, 0) axes[3].imshow(overlay, cmapgray) axes[3].set_title(overlay) plt.tight_layout() plt.savefig(result.png, dpi150)逻辑说明cv2.addWeighted把掩码和边缘图按 0.5 : 0.5 混合能一眼看出分割边界与边缘位置是否对齐如果不混直接画两张并排图也行。参数说明dpi150是为了打印到报告里不糊cmapgray对单通道图强制灰度显示否则 matplotlib 会套一个伪彩色 colormap容易被误读成热力图。保存前留意img_bgr是 BGR 顺序imshow前必须转成 RGB。4. 人脸检测Haar 特征级联的原理、调用与边界4.1 为什么用 Haar 而不是深度学习模型在这种 DIP 大作业里人脸检测选 Haar 级联几乎是标准答案。理由有三第一模型文件是 OpenCV 自带的 XML 描述不需要外部权重也不依赖 GPU第二检测速度足够实时对 CPU 非常友好演示阶段不会卡顿第三Haar 特征本身非常直观——它用黑白矩形的像素和差描述人脸局部亮度关系比如眼睛区域比脸颊暗、鼻梁比两侧亮。从实现角度说Haar 检测器会先把图像缩放到多个尺度用滑动窗口扫过整张图窗口内的 Haar 特征通过积分图快速计算再送入级联分类器。级联的意思是前几级用最便宜的特征快速淘汰大量背景窗口只有通过所有级的候选窗口才会输出为人脸。这种设计让误检率低且速度快代价是检测角度受限侧脸和大幅度旋转基本无解所以演示时尽量给正脸近景图。4.2 CascadeClassifier 的加载与 detectMultiScale 参数加载模型时最容易踩的坑是路径写错import cv2 cascade_path haarcascade_frontalface_default.xml face_cascade cv2.CascadeClassifier(cascade_path) if face_cascade.empty(): raise RuntimeError(failed to load cascade: %s % cascade_path)逻辑说明CascadeClassifier构造失败不会抛异常而是留下一个空的分类器对象所以必须用empty()显式检查。参数说明路径字符串里如果放在工程根目录直接写文件名就行如果放在子目录写成相对路径时要注意当前工作目录最好用os.path.join拼接绝对路径。检测调用和画框一体写出来gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) gray_eq cv2.equalizeHist(gray) # 均衡直方图提升低光下的召回率 faces face_cascade.detectMultiScale( gray_eq, scaleFactor1.1, minNeighbors5, minSize(30, 30), maxSize(200, 200) ) for (x, y, w, h) in faces: cv2.rectangle(img_bgr, (x, y), (x w, y h), (0, 255, 0), 2)逻辑说明detectMultiScale返回所有候选人脸框的(x, y, w, h)四元组列表直接循环画框即可。参数说明scaleFactor1.1表示每次把检测窗口缩小 10%数值越小检测越细但越慢minNeighbors5表示每个候选框至少要得到相邻窗口的 5 次支持它是控制误检率最有效的旋钮minSize和maxSize直接限定窗口最小和最大像素尺寸尺寸不符合的窗口根本不进入后续计算。这里有个反直觉点如果画面里的小脸没被框住第一反应往往是调scaleFactor但更常见的原因是minSize设得太大把 40×40 以下的小脸直接剪掉了。我一般会先把minSize降到(20, 20)看效果再逐步回调到(30, 30)压误检。4.3 画框与保存如何输出可提交的结果图画框的 API 本身简单但通道顺序是个经典坑。cv2.imread读入的图是 BGRcv2.rectangle直接用的就是 BGR 颜色所以(0, 255, 0)是纯绿色而 matplotlib 的imshow期望 RGB直接显示会偏色。cv2.imwrite(detected_result.jpg, img_bgr) # 给报告用的展示图必须转 RGB rgb_show cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) plt.figure(figsize(8, 8)) plt.imshow(rgb_show) plt.axis(off) plt.savefig(report_face.png, dpi150, bbox_inchestight)逻辑说明一份图存给老师detected_result.jpg一份存给人看report_face.png后者的cvtColor必须在画框之后做否则框的颜色会被转掉。参数说明bbox_inchestight会把空白边裁掉适合放进实验报告如果老师需要的是带坐标的文本结果把faces列表用np.save(faces.npy, faces)存下来后面做统计分析也用得上。5. 避坑与常见问题排查数字图像处理大作业最常翻车的五个点5.1 现象OTSU 输出全黑或全白把cv2.threshold跑完mask里要么全是 0要么全是 255肉眼看不到任何前景。第一次遇到这个现象我第一反应是查输入img是不是三通道cv2.threshold在 OTSU 模式下接受多通道输入并不会立即报错但阈值算法是按单通道直方图设计的行为不受控。原因是多层的可能是没转灰度可能是高斯核太大把目标细节全部抹掉更常见的其实是直方图没有双峰——比如一张图 90% 是黑色背景OTSU 会把整体均值附近那个峰当作阈值前景目标直接消失。解决在threshold之前强制img cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)并且打印cv2.calcHist看直方图是否存在两个明显的峰如果没有先做 CLAHE 拉伸对比度或者把目标区域的 ROI 裁出来单独分割。5.2 现象Canny 边缘太碎断成一片短线边缘图看起来像撒了一地的芝麻该连续的轮廓全成了虚线。原因通常是低阈值设置过低把传感器噪声和纹理梯度都当成了边缘次要原因是原图没有预先平滑或者L2gradientFalse导致梯度幅值计算粗糙。解决把threshold1从 50 提到 100threshold2从 150 提到 250先让边缘「稀疏干净」再做增强随后用开运算处理毛刺用闭运算连接断裂的线段。建议顺序是GaussianBlur - Canny - morphologyEx(闭运算) - 骨架化而不是一上来就把阈值压得很低。5.3 现象detectMultiScale 框不住人脸要么漏检要么误检同样的代码A同学在笔记本上框得挺好换一台电脑同一张图就疯狂漏检或误检。原因不是电脑也不是随机种子而是参数之间的相互作用scaleFactor太大跳过了中间尺度minNeighbors太大把低置信度的框全杀掉minSize与人脸实际尺寸不匹配。解决用固定经验值scaleFactor1.08, minNeighbors4, minSize(24, 24)作为起点在背景杂乱的测试图上把minNeighbors上调到 8在相机距离远的场景下把minSize下调。调参顺序是先确认minSize能框住最小目标再调scaleFactor控制计算量最后调minNeighbors压误检不要三个参数同时乱试。5.4 现象cv2.imread 返回 None程序在加载阶段就崩cv2.imread读不到文件时不抛异常而是返回None下一步cvtColor直接报错。常见原因有两个路径写错或者路径里带中文。OpenCV 的imread在部分环境里对非 ASCII 路径很敏感中文目录名会让它找不到文件。解决先用os.path.exists(path)判断文件是否存在路径里避免出现中文如果确实挪不了就用np.fromfile配合cv2.imdecode读入二进制再解码。更省事的做法是把所有图像统一放到data/images/这种纯英文目录并在代码里用os.path.join拼接。5.5 现象train/dev 划分后评估指标虚高分类结果好得不真实divide_trdev.py跑完分类准确率一下涨到 95% 以上可实际部署效果一塌糊涂。这是典型的「划分泄漏」随机划分没有考虑类别分布也没有考虑同一对象的图像不能同时出现在训练集和验证集。原因如果数据里同一对象拍了多张相似图随机划分会把同一对象的图分到两边模型相当于在验证集上「见过」训练样本的副本评估指标自然漂亮。解决用StratifiedShuffleSplit保证每个类别的比例一致再按对象 ID 分组对象级别的划分比样本级别划分更稳妥。拿到包之后先打印divide_trdev的划分结果检查每个 class 在 train/dev 的样本量是否接近原始比例。6. 进阶用法把边缘、掩码和人脸框导成数据集喂给 ResNet 做消融验证6.1 把三路特征导出为统一尺寸的 NumPy 数组resnet.py和train_3d.py的存在意味着这个作业可能不止要求跑通算法还要求做一个小分类实验。常见做法是把 OTSU 掩码、Canny 边缘图和人脸框区域作为额外通道和原图一起堆叠成多通道输入验证「加了分割特征之后分类准确率是否提升」。导出代码很短关键是尺寸对齐# faces 来自 detectMultiScale 的返回结果取第一张人脸做演示 x, y, w, h faces[0] face_patch cv2.resize(rgb[y:yh, x:xw], (224, 224)) edge_patch cv2.resize(edges[y:yh, x:xw], (224, 224)) sample np.stack([face_patch, edge_patch], axis-1).astype(np.float32) / 255.0 np.save(sample_feature.npy, sample)逻辑说明face_patch是人脸框裁剪后的彩色区域edge_patch是对应位置的边缘图两者统一到 224×224 再归一化到 0~1堆叠成[224, 224, 4]的张量刚好能对接大多数 ResNet 的输入前处理。参数说明/ 255.0是常见的归一化方式如果分类器要求三通道输入把edge_patch平均到三个通道而不是堆成第四通道。6.2 用 metric.py 的接口做一个小型消融验证有了特征文件就能做一个干净的对比一组只用原图一组用原图加边缘跑同样轮数、同样批次最后用metric.py里的接口打印准确率和 F1。差异越大越能说明分割或边缘操作对下游任务真的有帮助。说实话拿到的包往往只有算法代码缺的是验证清单。从那以后我每次拿到新 DIP 工程第一件事不再是直接跑train_3d.py而是把dataset.py的__getitem__和metric.py的输入都打印一遍形状确认三个信号的尺寸、范围、通道数一致再开始训练。这个小习惯省掉了我大量后面翻车的时间希望帮到你。本文还有配套的精品资源点击获取