简介:一套数字图像处理大作业资料围绕图像分割、人脸检测与边缘检测三大经典任务展开,面向计算机视觉入门者及需要完成课程设计的学生。内容完整覆盖Canny边缘检测的高斯滤波、梯度计算、非极大值抑制与双阈值流程,OTSU自适应二值化的类间方差原理,以及基于OpenCV Haar级联分类器的人脸检测方法,可直接参考Python实现并进行结果可视化。压缩包共11个文件,以7个Python脚本为主,包含图像预处理、数据集划分、分类训练等代码模块,另有2个Markdown说明文档和1个环境工具包,整体大小97.64MB,结构便于按步骤运行与二次修改。已有8347人学习浏览这份资料,适合希望系统动手实践图像处理算法、提升OpenCV编程能力的读者。
1. 作业里的三件套:边缘检测、图像分割和人脸检测为什么仍然要手写
现在语义分割和通用检测已经被深度学习刷榜,UNet跑医学影像、RetinaFace跑人脸,看起来经典算法没什么存在感。但这套 DIP-Project-master 大作业偏偏是反着来的:用Canny做边缘检测,用OTSU做图像分割,用OpenCV的Haar级联做人脸检测。反直觉的地方在于,这三者加起来不需要GPU,不需要训练数据,却能在一张图里完成「哪里是边界、哪里是前景、哪里有脸」三类任务,并且每个环节都能被解释和手动调参。对做工程的人来说,这类项目是练习图像预处理和调参手感的好素材;对要交大作业的同学来说,理解这三个算法能直接把preprocess.py之后的每个调用讲清楚。
2. Canny边缘检测:Prewitt与Sobel选型、梯度计算和双阈值调试
2.1 为什么选Canny而不是Prewitt或Sobel
边缘检测的核心问题是:图像里灰度突变的位置怎么被提取成一条细且连续的线。Prewitt边缘检测原理最简单,用两个3x3方向模板对图像做卷积,一个响应水平梯度、一个响应垂直梯度,然后取模。但它没有平滑项,单个噪声点就会在输出里产生一个孤立的高响应,而且边缘是几像素宽的发散带。Sobel给中心像素加了更高权重,相当于内置了一点平滑,但它输出的仍然是梯度强度图,真正的边缘线需要自己再找极大值。
Canny算法是John Canny在1986年提出的多阶段流程,先高斯滤波去噪,再用Sobel计算梯度幅度和方向,随后做非极大值抑制,最后用双阈值连接边缘。它同时满足检测完整、定位准确、单像素响应三条设计准则。四个算子的差异整理如下:
| 算子 | 计算方式 | 抗噪性 | 边缘连续性 | OpenCV调用 |
|---|---|---|---|---|
| Prewitt | 3x3方向差分 | 弱 | 粗,多断点 | filter2D自定义 |
| Sobel | 加权差分+平滑 | 中等 | 粗,存在重边 | cv2.Sobel |
| Laplacian | 二阶微分 | 弱 | 对孤立点敏感 | cv2.Laplacian |
| Canny | 高斯滤波+梯度+NMS+双阈值 | 强 | 细且连续 | cv2.Canny |
在实际的大作业里,Sobel通常只用来计算梯度图,Prewitt则很少单独出现。Canny的定位精度和连续性都更好,唯一的代价是要调两个阈值,这也是最容易卡住的地方。
2.2 用cv2.Canny跑通最小实现并理解每个参数
下面这段代码可以直接放到项目的analysis目录下跑,把图片路径换成本机的即可:
import cv2 import numpy as np img = cv2.imread('sample.jpg') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 1.0) edges = cv2.Canny( blurred, threshold1=50, threshold2=150, apertureSize=3, L2gradient=False ) cv2.imwrite('canny_output.png', edges)灰度化的目的是去掉颜色对梯度计算的干扰,后续所有处理都在单通道上进行。GaussianBlur用5x5窗口、sigma=1.0的高斯核做平滑,把传感器噪声压下去,否则这些噪声会直接参与梯度计算并在NMS阶段被当作假边缘保留。
cv2.Canny内部会先计算x和y方向的梯度,然后合成幅度和方向。非极大值抑制把梯度方向上的非最大像素置零,让边缘从「一条带」收成「一条线」。双阈值阶段的工作方式是:高于threshold2的像素判定为强边缘,低于threshold1的直接丢弃,介于两者之间的弱边缘只有在与某个强边缘像素连通时才被保留。threshold1尽量取threshold2的一半或三分之一,例如50与150、30与90,比值过大时边缘会断成碎片,过小时背景纹理全部连进来。
| 参数 | 含义 | 常用范围 |
|---|---|---|
| threshold1 | 低阈值,决定弱边缘保留条件 | 30~60 |
| threshold2 | 高阈值,决定强边缘起点 | 80~200 |
| apertureSize | Sobel卷积核大小,必须为奇数 | 3或5 |
| L2gradient | 梯度模长算法 | False为L1近似,True为欧氏距离 |
如果检测结果里全是短线,先检查是否把threshold1和threshold2写反了;如果边缘明显偏粗,则说明没有先做高斯滤波,或者滤波核开得太大。调参时优先固定threshold2,再单独动threshold1找边缘连续和噪声之间的平衡。
2.3 预处理顺序:resize、均衡化后Canny参数才可复现
项目里preprocess.py的作用不只是读图。数字图像处理大作业通常要求对不同尺寸的输入保持同一套参数,所以第一条是resize到固定尺寸,常见做法是512x512。然后是灰度化、直方图均衡化、高斯滤波,最后才进入Canny。下面这段是这种顺序的典型写法:
def edge_pipeline(path): img = cv2.imread(path) img = cv2.resize(img, (512, 512)) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) blur = cv2.GaussianBlur(gray, (5, 5), 0.8) return cv2.Canny(blur, 30, 90)说明两点。第一,直方图均衡化把灰度分布拉开,低对比度图片的边缘响应会明显增强,但噪声也会同步放大,所以高斯滤波必须放在它后面且sigma不要开太大。第二,如果预处理里做了归一化,图像变成了float型,cv2.Canny会直接报错或者输出空图;正确做法是先乘以255再转回np.uint8,阈值仍然按255尺度设置。这也是preprocess.py里最常见的错误来源。
提示:OpenCV的Canny在传入非8位单通道图像时行为不一致。统一在预处理阶段转成uint8,比在调用处补救更可靠。
3. OTSU图像分割:类间方差、threshold参数组合与光照不均的补救
3.1 OTSU的数学直觉与适用边界
OTSU是1979年由大津展之提出的全局自适应阈值算法,数字图像处理课程里常把它放在「分割」章节讲。它的思路是:在0到255的灰度范围内逐一遍历阈值t,把像素分成背景类ω0和前景类ω1,然后计算类间方差:
σ²_b(t) = ω0(t) · ω1(t) · (μ0(t) − μ1(t))²
当某个t让这个值最大,就认为此时背景和前景的灰度差被拉得最开,t就是最优阈值。这个遍历在OpenCV里是内置的,代价很低,所以整张图算一遍阈值几乎是实时的。
OTSU适用的条件是直方图呈现双峰,也就是目标区域和背景区域各自占据一个相对集中的灰度范围,且两者面积不要悬殊太大。如果目标只占画面5%,背景占95%,类间方差会被大类的统计主导,阈值会偏向背景一侧。另外噪声会改变直方图峰的位置,所以上一章的高斯滤波同样适用于OTSU的前置处理。
3.2 cv2.threshold的OTSU组合与边界情况
OpenCV里OTSU不是单独的函数,而是cv2.threshold的一个标志位。标准写法如下:
ret, otsu_mask = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) print("OTSU自动求出的阈值 =", ret)这里传入的thresh=0会被忽略,阈值由OTSU算法自己计算并放在返回值ret里。type参数用加号把两个flag组合起来:THRESH_BINARY表示大于阈值的像素置为255,THRESH_OTSU表示「先自动求阈值再按BINARY规则处理」。实际使用中几种组合的差别如下:
| type写法 | 效果 | 典型场景 |
|---|---|---|
| THRESH_BINARY | 固定阈值二值化 | 已知灰度范围 |
| THRESH_BINARY + THRESH_OTSU | 自动阈值二值化 | 双峰直方图 |
| THRESH_BINARY_INV + THRESH_OTSU | 自动阈值且前景置黑 | 白底黑字转黑底白字 |
| THRESH_TOZERO + THRESH_OTSU | 保留前景原灰度,背景置0 | 生成前景mask继续处理 |
容易踩的坑有两个。第一,传入的图像必须是8位单通道,彩色图直接传给threshold会报错。第二,THRESH_OTSU不能与cv2.adaptiveThreshold一起用,后者自己决定每个像素的邻域阈值,两者的flag互斥,混用时OTSU会被静默忽略,输出可能是一张全黑图。调试时先打印ret,看看自动阈值是否落在合理范围内,例如一张暗调图像算出ret=220基本说明光照有问题或者传入通道搞错了。
3.3 光照不均时OTSU失效的两种补救路径
OTSU是全局阈值,光照从左到右衰减时,同一目标的灰度在画面左侧可能是180、右侧只有90,全局阈值只能切下一半。两种常见补救:第一种是形态学顶帽变换去除不均匀背景,再跑OTSU;第二种是改用局部自适应阈值。代码如下:
# 方案A:顶帽变换 + OTSU kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) ret2, mask_a = cv2.threshold(tophat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 方案B:局部自适应阈值 mask_b = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize=35, C=5 )顶帽变换的定义是原图减去开运算结果,开运算是先腐蚀再膨胀,能去掉小于kernel结构的亮细节,剩下的就是变化缓慢的背景光照。原图减去背景后,目标与背景的灰度差恢复均匀,OTSU就可以继续工作。kernel结构元尺寸一般是目标宽度的1.5倍以上,太小会把目标也当成背景过滤掉。
adaptiveThreshold对每个像素用邻域内的高斯加权均值作为参考阈值,再减去常数C。blockSize必须是奇数,而且要大于目标尺寸,否则目标内部会被判定成背景;C越大判定为前景的门槛越高,输出越细碎。两张mask都生成后,可以和人工标注做个对比,指标部分放到第5章。
4. Haar人脸检测:从级联XML到detectMultiScale各参数的调优顺序
4.1 Haar级联的机制和选择理由
OpenCV提供的人脸检测器来自Viola-Jones框架,核心是Haar特征与级联结构。Haar特征的取值是白色矩形区域像素和减黑色矩形区域像素和,这个值对「眼睛比脸颊暗」「鼻梁比两侧亮」这类亮度差异很敏感。为了快速计算任意尺度下的矩形和,算法引入了积分图,任意矩形区域求和只需查表四次,这是它能在CPU上实时运行的基础。
级联结构把几百个弱分类器按难度分层组装。前几层用很少的特征快速排除大部分非人脸窗口,越往后分类器越复杂,只有真正像脸的窗口能走到最后。haarcascade_frontalface_default.xml这个文件只有不到1MB,却能在笔记本上做到实时的正脸检测,这是大作业选用它而不是深度学习模型的原因:不用训练、不用GPU、单帧几十毫秒。
补充一点:OpenCV里还有lbpcascade_frontalface.xml,LBP特征基于局部纹理,检测速度更快但精度略低;在低分辨率监控画面里,LBP有时反而比Haar表现好,因为它对灰度绝对值的敏感度更低。可以根据实际输入尝试切换。
4.2 加载模型与detectMultiScale参数表
加载模型推荐使用cv2.data.haarcascades,它是OpenCV自带模型目录,免去拼接绝对路径。标准检测流程如下:
face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' ) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray_eq = cv2.equalizeHist(gray) faces = face_cascade.detectMultiScale( gray_eq, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30), maxSize=(300, 300) ) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)detectMultiScale返回的是numpy数组,每一行是x、y、w、h四个值。先做灰度化和直方图均衡化是为了让局部对比度更突出;检测本身只接受单通道图像,在彩色图上直接调用也是先内部转灰度,但显式转换可以保证我们控制均衡化这一步。参数的影响按下表来排查:
| 参数 | 作用机制 | 调大 | 调小 |
|---|---|---|---|
| scaleFactor | 每轮检测窗口缩放比例 | 漏检小脸、速度快 | 检测更全、速度明显变慢 |
| minNeighbors | 候选框最少通过的相邻框数 | 误检减少、正脸也丢 | 误检增多 |
| minSize | 最小检测窗口 | 忽略小脸、排除背景纹理 | 小尺寸窗口误检增多 |
| maxSize | 最大检测窗口 | 大脸丢失 | 限制只保留特写 |
scaleFactor是最需要解释的参数。窗口每次放大1.1倍,意味着总共约log(原图尺寸/目标尺寸)/log(1.1)个尺度层级,1.05会接近翻倍耗时;1.3虽然快,但小人脸通常在两次缩放之间被跳过。minNeighbors则更像一个投票约束:潜在人脸位置会产生一堆重叠矩形,少于minNeighbors个重叠的一律丢弃。
4.3 从误检到漏检的调参顺序
实际大作业里,Haar最常被吐槽的就是框错。我的排查顺序是固定的:先看是不是把minNeighbors设成了0或1,这是初学者最常见的误检来源,改成5左右误检会大幅下降。接着处理小脸漏检,把scaleFactor从1.1降到1.05,minSize从(30,30)放宽到(20,20),代价是耗时上升,但对1080P以下的图仍可接受。
如果整批图都有大量误检,且调整minNeighbors无效,优先怀疑输入分辨率过高。1920宽以上的图像里背景纹理也被放大到接近人脸尺寸,误检区域往往集中在墙面纹路、树叶这类高频纹理上。处理方法是在检测前先对图像做一次高斯金字塔下采样,或者把maxSize限制在人脸可能的最大尺寸。侧脸和低头场景属于模型能力边界,此时换用haarcascade_frontalface_alt2.xml并用minNeighbors=6,通常比继续调默认模型更有效。
5. 组合流水线:三模块联动、ResNet扩展与IoU量化验证
5.1 一个入口串联三件套
单独跑两个算法看不出问题,把三者串起来才能体现前置处理一致性的价值。下面的函数把一张输入图同时喂给Canny、OTSU和Haar:
import cv2 import numpy as np def run_pipeline(path): img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 1.0) edges = cv2.Canny(blur, 50, 150) _, otsu = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') faces = cascade.detectMultiScale(blur, 1.1, 5, minSize=(30, 30)) canvas = cv2.cvtColor(np.hstack([edges, otsu]), cv2.COLOR_GRAY2BGR) for (x, y, w, h) in faces: cv2.rectangle(canvas, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.imwrite('pipeline_out.png', canvas)注意np.hstack要求两图高度一致,这里edges和otsu都来自同一个blur,尺寸天然相等。叠加人脸框前必须把拼接结果从灰度转BGR,否则框的颜色参数会按单通道解释导致异常。这个流水线的价值在于:分割出的前景mask可以作为Canny边缘的注意力范围,反过来边缘密度也可以用来验证人脸框是否落在真正的轮廓密集区。
5.2 结合项目脚本验证分割质量
项目里metric.py的存在说明大作业要求量化评估而不只是贴图。常见做法是准备少量人工标注的mask,和OTSU结果计算IoU:
import numpy as np def iou(pred, gt): pred = pred > 0 gt = gt > 0 inter = np.logical_and(pred, gt).sum() union = np.logical_or(pred, gt).sum() return inter / (union + 1e-6)IoU超过0.7通常认为分割可用,低于0.4说明预处理或阈值选择有系统性问题。人脸检测没有标注时,建议把检测框和Canny边缘叠在同一张图里存盘,逐个框查看边缘密度:人脸区域边缘密度明显高于纯色背景,若某框内边缘占比低于全图平均的三成,基本可以判定是误检。如果作业还要求跑classification.py和resnet.py做深度学习方法对比,OTSU生成的mask可以直接作为ResNet输入的辅助通道;train_3d.py对应的三维训练常见形式是类似3D UNet的滑窗方式,和OTSU这种二维全局阈值属于两条路线,前者要标注数据,后者零训练。这两个验证技巧配合管线输出图,比肉眼扫图更快暴露参数问题。
本文还有配套的精品资源,点击获取