简介:基于卷积神经网络与Transformer的图像质量评估Python项目,面向计算机相关专业学生、老师及企业开发者,用于解决海量图像中自动筛选高质量图片的实际需求。项目以清晰度评分为切入点,不依赖美学特征,通过卷积神经网络的局部感知与Transformer的全局特征学习相结合,构建回归预测模型,可适配LIVE、KONIQ、CSIQ、LIVEC、BID等多个公开数据集。压缩包共22个文件,包括16个Python源码、2个Shell运行脚本、2个Markdown说明文档以及文本说明,整体大小仅29KB,结构紧凑,便于快速部署和二次开发。目前已有269人学习下载,代码经测试运行成功,功能完整,可支撑毕业设计、课程设计、期末大作业或初期项目立项演示;若基础较好,还可在此代码基础上修改扩展,实现其他图像质量评估功能。模型基于PyTorch框架,采用Adam优化器,训练入口清晰,是学习图像质量评估与Transformer应用的优质参考资料。
1. 基于CNN+Transformer的图像质量评估:清晰度评分不是简单的高频能量统计
清晰度评分,传统做法大家都很熟:Laplacian 方差、Tenengrad、FFT 高频能量,OpenCV 几行就能出分。快归快,这类指标对光照、压缩、内容类型极其敏感——同一张图压一遍 JPEG,分数可能掉一大截;人像背景虚化和一张纯纹理场景,高频能量差不多但观感完全不同。基于 CNN+Transformer 的图像质量评估,就是把这件用人眼判断的事交给模型学:CNN 负责把边缘、纹理、噪声这些局部高频特征提出来,Transformer 在整图上下文里判断「这个模糊是景深虚化还算合理,还是真的没对上焦」,最后回归成 0~1 的清晰度分数。监控抓拍质量过滤、OCR 前的模糊图筛选、相机预览实时提示这几类场景,这套方案比传统指标更扛造。适合手里有 python 源码和项目说明就想快速落地的人:只要装了 PyTorch、有一批自然图像就能开始训练,不需要专门的 IQA 数据集。下面按「网络怎么搭、数据怎么造、参数怎么调、坑在哪里」一步步讲。
2. 网络结构拆解:ResNet18 提局部纹理,Transformer 编码器建模全局感知
2.1 清晰度评分为啥不能只靠 CNN:全局感知和局部细节一个都不能少
清晰度在信号层面是高频能量强度,边缘越锐、纹理越清楚,高频分量就越大。CNN 天生适合干这件事:卷积核就是一组可学习的滤波器,底层学到的是边缘、角点这类高频响应,层数越深越能组合出纹理模式。直接用 ResNet 最后一层特征接个回归头,也能做出一个能用的清晰度评分器,很多简化方案就是这么做的。
但只用 CNN 的问题在于它缺少「跨区域比较」的能力。卷积的感受野是有限的,即使 ResNet50 最后一层感受野能覆盖全图,特征图上也已经把位置信息压得很扁,模型很难区分「整张图都糊」和「只有背景糊、主体是锐的」。这两种情况在局部高频统计上可能非常接近,但观感完全不同,清晰度评分恰恰需要这种全局判断。
Transformer 编码器在这里的作用不是替代 CNN,而是把 CNN 不同层级输出的特征当成一组 token,让自注意力去做全局依赖建模。模型可以学会:主体区域的边缘响应高,那么背景区域的低响应就合理;反过来,主体区域的边缘响应也低,那整张图就真模糊。这种规则用卷积层去拟合需要很深的网络和大量数据,用自注意力一层就能表达。这就是标题里 CNN+Transformer 组合的根本动机,不是把两个模型叠起来显得高级,而是各管一段。
2.2 最小可跑模型:CNN+Transformer 全流程代码与参数说明
常见做法是选一个轻量 CNN 主干做特征提取。我用 ResNet18,看重的是它预训练权重好找、推理快。Transformer 部分直接用 PyTorch 的 TransformerEncoder,不用自己手写注意力,代码量能省一大半。以下是我在项目里跑通的一份最小结构代码:
import torch import torch.nn as nn from torchvision.models import resnet18 class IQA_Regressor(nn.Module): def __init__(self, token_dim=512, num_heads=8, num_layers=2, dropout=0.1): super().__init__() # CNN backbone:保留多层特征,不要最后的全连接分类头 backbone = resnet18(pretrained=True) self.stage2 = nn.Sequential(*list(backbone.children())[:5]) # (B, 128, 28, 28) self.stage3 = nn.Sequential(*list(backbone.children())[5:6]) # (B, 256, 14, 14) self.stage4 = nn.Sequential(*list(backbone.children())[6:7]) # (B, 512, 7, 7) # 1x1 卷积把三个尺度统一到 token_dim,方便进 Transformer self.proj2 = nn.Conv2d(128, token_dim, kernel_size=1) self.proj3 = nn.Conv2d(256, token_dim, kernel_size=1) self.proj4 = nn.Conv2d(512, token_dim, kernel_size=1) # 可学习位置编码:token 数固定为 28*28 + 14*14 + 7*7 = 1029 self.pos_embed = nn.Parameter(torch.randn(1, 1029, token_dim) * 0.02) encoder_layer = nn.TransformerEncoderLayer( d_model=token_dim, nhead=num_heads, dim_feedforward=2048, dropout=dropout, activation='gelu', batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.head = nn.Sequential( nn.LayerNorm(token_dim), nn.Linear(token_dim, 128), nn.GELU(), nn.Dropout(dropout), nn.Linear(128, 1) # 输出清晰度分数,按回归任务训练 ) def forward(self, x): # x: (B, 3, 224, 224),输入需做 ImageNet 同款归一化 f2 = self.stage2(x) # (B, 128, 28, 28) f3 = self.stage3(f2) # (B, 256, 14, 14) f4 = self.stage4(f3) # (B, 512, 7, 7) # 展平成 token 序列:保留空间位置,后续由位置编码补充 t2 = self.proj2(f2).flatten(2).transpose(1, 2) # (B, 784, token_dim) t3 = self.proj3(f3).flatten(2).transpose(1, 2) # (B, 196, token_dim) t4 = self.proj4(f4).flatten(2).transpose(1, 2) # (B, 49, token_dim) tokens = torch.cat([t2, t3, t4], dim=1) # (B, 1029, token_dim) tokens = tokens + self.pos_embed[:, : tokens.size(1), :] out = self.transformer(tokens) # (B, 1029, token_dim) feat = out.mean(dim=1) # 全局平均池化,聚合所有 token score = self.head(feat).squeeze(-1) # (B,) return score这段结构的核心逻辑是先把图像拆成三个尺度的特征图,把它们拼成一个 1029 token 的序列:28×28 的 token 承载边缘和纹理细节,14×14 的 token 承载物体部件结构,7×7 的 token 承载全局构图。Transformer 自注意力让每个 token 都能看到其他所有 token 的信息,因此模型可以选择性地忽略背景的低频区域、聚焦到主体区域的纹理衰减上。最后用全局平均池化把注意力输出聚合成一个向量,再回归分数,比直接取 CLS token 更稳,适合这种没有明确「类别」的回归任务。
参数上 token_dim=512、8 头、2 层编码器是我的起步配置。显存有限时优先把 token_dim 降到 256、head 降到 4,这比减少层数更划算,因为自注意力的计算量是 token 数的平方,通道数降一半显存和速度都能明显改善。dim_feedforward 2048 是 PyTorch 默认值,改小到 1024 能让模型小一截。清晰度任务不是强语义任务,feedforward 容量过大反而容易过拟合到训练用的合成模糊分布上,这一点在调参时值得盯一下。
2.3 位置编码和分辨率:两个容易被忽视的细节
位置编码对清晰度评分的影响比对人脸识别这类任务更微妙。可学习位置编码训练完之后,会把「图中心区域」和「图边缘区域」区分对待,这符合拍照习惯——摄影构图里主体通常在中心附近,边缘虚化是常见现象。我做过关闭位置编码的对比实验,也就是注释掉 tokens 加 pos_embed 那一行,模型分数整体下降 0.02 左右,但对「中心清晰、边缘模糊」这类典型照片的判断力明显变弱。所以我的结论是:清晰度评分保留位置编码是有收益的,尤其是处理真实拍摄照片的时候。
但位置编码数量是写死的 1029 个。一旦推理时输入分辨率不是 224×224,token 数量变化,位置编码长度就对不上。常见做法是训练和推理都固定 224×224,但这会带来另一个问题:原图缩放会改变高频能量分布,一张 4000×3000 的相机原图缩到 224×224,模糊和清晰的差异会被压缩,模型分数会整体向中间值靠拢。我一般不在整图上直接缩放,而是把原图分成若干个 224×224 的块分别评分,或者用正方形中心裁剪后缩放。这个做法后面第 5 章会展开,这里先记住结论:位置编码不要随意删,输入尺寸要全链路固定。
3. 训练与 Loss 实现:合成退化造数据,相对误差做回归
3.1 训练数据的三类合成模糊:高斯模糊、运动模糊、JPEG 压损
图像质量评估有 LIVE、TID2013 这类公开数据集,但面向的是「多种失真类型混合」的问题。标题明确落在清晰度评分上,所以更可控的做法是拿自然图像自己退化合成训练集,这样每个样本的退化程度是已知的,可以生成精确的标签。我习惯从 COCO、DIV2K 这类公开自然图里取一批内容多样的图片,再按下面的方式做退化:
import cv2 import numpy as np def synth_blur(img, mode='gaussian', severity=0.5): """ 对归一化到 [0,1] 的 RGB 图做退化,返回退化图和清晰度标签。 severity 控制在 0~1,越大越模糊,同时直接作为回归目标。 """ if mode == 'gaussian': sigma = severity * 3.0 + 0.3 # 0.3~3.3 的高斯核 ksize = int(sigma * 4) | 1 # 保证奇数核大小 blur = cv2.GaussianBlur(img, (ksize, ksize), sigma) elif mode == 'motion': length = int(severity * 20) + 2 # 运动模糊像素长度 kernel = np.zeros((length, length)) kernel[int((length - 1) / 2), :] = 1 # 水平运动方向 kernel = kernel / kernel.sum() blur = cv2.filter2D(img, -1, kernel) elif mode == 'jpeg': quality = int(95 - severity * 85) # quality 95~10 encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality] _, enc = cv2.imencode('.jpg', (img * 255).astype(np.uint8), encode_param) blur = cv2.imdecode(enc, 1) / 255.0 else: raise ValueError(mode) return blur.astype(np.float32), severity合成退化的核心是让 severity 同时扮演退化参数和标签:高斯模糊里 σ 越大目标越接近 0,JPEG 里 quality 越低目标越接近 1,注意方向别搞反。三种模式在训练时轮流抽,避免模型只学会识别某一类模糊。运动模糊的 kernel 我只写了水平方向,实际训练里最好每张图随机旋转一个角度,否则模型会对「水平拖影」过拟合,真实照片里的手持抖动方向是任意的。
这里有个跟直觉相反的经验:severity 直接当回归标签,比用一堆人眼评分的 MOS 值做标签更稳。因为合成退化的物理参数和人眼感知基本单调,模型学会的是「退化有多强」,而不是去拟合一批充满标注噪声的主观分数。真实照片虽然比合成退化复杂,但这个单调性让模型有了一个很好的初始化。
3.2 训练配置与 Loss 设计:相对回归比 MSE 稳
Loss 我不用 MSE,用的是相对 L1。清晰度分数如果落在 0.2~0.8 区间,MSE 会倾向于把大误差留在大分数样本上,而清晰度评分最怕的是「模糊图给高分」这种小概率大误差。相对 L1 让不同分数段的样本对梯度贡献尽量均衡:
def relative_l1_loss(pred, target, eps=1e-5): # 预测和目标都是 0~1 之间的分数 diff = torch.abs(pred - target) denom = target + eps return torch.mean(diff / denom)配合这个 Loss,训练配置我通常这样落:
| 配置项 | 取值 | 说明 |
|---|---|---|
| 输入尺寸 | 224×224 随机裁剪 | 固定分辨率,匹配位置编码 |
| 数据增强 | RandomCrop、RandomHorizontalFlip、ColorJitter | 颜色抖动很关键,真实照片色调差异大 |
| Batch Size | 16 | 1029 token 的 Transformer 编码器很吃显存,8G 卡建议 8 |
| 优化器 | AdamW,lr=1e-4 | 预训练骨干再用 1e-5,见第 4.5 条 |
| weight decay | 1e-5 | 防止回归头过拟合 |
| Epoch | 30~50,按验证集早停 | 合成数据量大,几十轮就能收敛 |
训练循环本身没有特别之处,关键在两点:一是如果用了带 BatchNorm 的骨干,batch size 太小时统计量抖动大,建议至少 8;二是混合精度训练对 Transformer 很友好,开 AMP 能省近一半显存,分数精度损失几乎测不出来。数据加载时别在 CPU 上做高斯模糊,合成退化放 GPU 前用多进程 DataLoader 预生成好,否则训练速度会被 OpenCV 拖慢。
3.3 验证集的正确建法:合成分布不能直接验收真实效果
很多项目翻车就在这一步:训练集是合成模糊,验证集也拿合成模糊测,指标好看到 0.98,一到真实场景就露馅。我的经验是验证集必须掺入两种数据:一是留出约 2 成的合成退化样本,用它监控训练有没有过拟合;二是单独准备一批真实拍摄的模糊/清晰对照。真实样本没有精确标签也没关系,用排序对比来评估——比如找同一场景的对焦、失焦两张照片,模型应该给对焦的更高分,统计「判断正确」的比例,再和 Laplacian 方差这类传统指标比一下排序一致性。
另一个原因是清晰度评分应用场景差异很大:监控摄像头拍的图有全局噪声,手机相册的图有景深和算法锐化,扫描文档又是另一套分布。如果不能确定真实输入长什么样,模型在部署后大概率会翻车。所以我建议先在项目说明里写清楚目标输入来源,再决定合成训练的退化范围。如果目标输入是夜间监控,训练时还要额外加高斯噪声和低光亮度扰动,否则训练集和真实域差距太大,CNN+Transformer 再强也拉不回来。
4. 避坑与排查:清晰度评分模型常见的 5 个翻车现场
4.1 训练 loss 很低,真实照片评分发飘
现象:训练集上 loss 降到 0.02 以下,验证集合成样本也表现正常,但拿手机实拍图一测,分数忽高忽低,明显模糊的图能拿到 0.8,清晰锐利的图反而给 0.4。
原因:典型的合成分布和真实分布之间的 domain gap。合成高斯模糊对真实世界的运动模糊、镜头像差、噪点叠加几乎没有覆盖,模型学到了「高斯核卷积痕迹」这个伪特征,而不是通用的清晰度概念。另一个隐性原因是训练时颜色抖动做得不够,真实照片的色彩分布远宽于随机裁剪出来的合成样本。
解决:增大合成退化的多样性,高斯模糊之外至少加入运动模糊和 JPEG 压损,并在训练时叠加少量高斯噪声(σ 0.01 左右),模拟摄像头感光噪声。最关键的一步是每训练几个 epoch 就拿出真实照片子集做一次人工排序抽查,不要只看 loss 数字。如果项目说明里没有给出现成脚本,自己写一个 20 行的排序工具也很快,但这一步不能省。
4.2 分数全部压在 0.3~0.4,没有区分度
现象:模型输出集中在很小的区间,清晰和模糊的分数差不到 0.1,没法直接用作筛选阈值。线上系统设 0.5 为阈值,结果一半图都在 0.48 到 0.52 之间打转,阈值形同虚设。
原因:多半是标签分布出了问题。severity 从 0 到 1 直接映射时,如果合成时随机抽得不够均匀,训练数据大量集中在 severity 0.2~0.6 之间,回归头就会学着输出一个「平均分」。另一个常见原因是最后回归头没有加激活函数,预测值虽然无界,但训练中梯度很小,权重长期不更新,输出就会在某个常数附近摆动。
解决:先画一下标签直方图,确认训练集里 severity 在 0、0.5、1 附近都有样本覆盖。再把回归头的输出做一次线性缩放或 Sigmoid,人为扩大中段梯度。我一般会在 head 最后加 Sigmoid,并用标签抖动(给 severity 加 ±0.03 的均匀噪声)来打破模型对精确标签的依赖。排查时直接把训练样本按标签分桶统计预测均值,一眼就能看出是不是某个分数段完全没学会。
4.3 显存 OOM 与训练太慢
现象:batch size 设 16 直接 OOM,或者一个 epoch 奇慢,8G 显存的卡跑不了几步就崩。
原因:Transformer 编码器自注意力的复杂度是 token 数的平方,1029 个 token、token_dim 512,单层注意力矩阵就是 1029×1029×8,四个头的中间变量在 batch 大时非常可观。相比纯 CNN 模型,这套结构的显存开销大头在 Transformer 而不在 CNN 骨干,很多人第一次跑没概念就直接爆卡了。
解决:优先把 token_dim 从 512 降到 256,head 从 8 降到 4,batch size 降到 8。再开 torch.cuda.amp 自动混合精度,可以把 Transformer 前向的激活内存减半。如果还想更极限,可以去掉 stage2 的 28×28 特征,只用 14×14 和 7×7 两级,token 数直接从 1029 降到 245,计算量是原来的十几分之一,清晰度评分精度只掉一点点。这一步在项目说明里值得明确写出,因为服务器资源不同,配置差异非常大。显存实在不够的时候,也可以换用 Swin Transformer 这类带窗口注意力的结构,但改动就大了,不建议第一版就上。
4.4 同一张图换个分辨率,分数明显变化
现象:同一张真实照片,1920×1080 和 1280×720 两个分辨率分别推理,输出分数差 0.2 以上,阈值筛选完全不可用。
原因:任何缩放操作都会改变图像的高频能量分布。放大时边缘变软,缩小时高频分量被破坏,而 CNN 的卷积核响应实际上就是在统计高频分量,所以缩放前后特征分布必然不同。再加上位置编码固定 1029 个 token,非 224×224 的输入如果靠 resize 强行对齐,形状是匹配了,但物理意义上的清晰度已经被改变。
解决:全链路固定训练和推理的统一预处理。更稳妥的做法是切块评分:把大图按 224×224 滑窗切块(重叠 32 像素),每个块单独推理,用最低分或低分位数(比如 P10)代表整图清晰度。这样分辨率变化只会影响切块数量,不会影响单个块的分数分布。第 5 章会演示这个流程。排查时先固定一个标准分辨率做基准测试,排除掉预处理差异再说模型问题。
4.5 微调时 loss 前低后高,最后发散
现象:训练刚开始 loss 掉得很快,几个 epoch 后开始震荡,再往后 loss 直接升上去不回来了,模型彻底报废,只能重新加载权重再来。
原因:ResNet18 是预训练的,而 Transformer 编码器和回归头是随机初始化的。随机初始化的 Transformer 层梯度大,反向传播时会把这些大梯度一路传回 CNN 骨干,骨干预训练出来的特征很快被冲刷掉,相当于拿一个没训练好的新网络从零开始学,当然发散。特征被污染后靠调低学习率也救不回来。
解决:前 5 个 epoch 冻结 backbone,只训练 proj 卷积、位置编码、Transformer 编码器和回归头,让新加的部分先找到大致正确的特征组合方向。5 个 epoch 之后再解冻骨干,并且把骨干学习率设为 1e-5,Transformer 学习率保持 1e-4。PyTorch 里实现很简单,遍历骨干参数设置 requires_grad=False,解冻时再改回来即可。这个坑在标题指向的源码方案里特别常见,因为很多人在 ImageNet 分类任务里养成了全量微调的习惯,迁移到回归任务就翻车。
5. 进阶验收与部署技巧:一致性验证和分组打分
5.1 用 Laplacian 方差做排序一致性验证
模型训完第一件事不是看 loss,而是验证「预测分数和传统指标的排序是否一致」。做法是随机抽 100 张真实图片,分别计算 Laplacian 方差和模型预测分数,再算 Spearman 相关系数。两者相关性高,说明模型确实学到了和模糊程度强相关的东西;完全无关,就要警惕模型是否在学图像内容而不是清晰度——这种情况在合成训练数据上特别容易发生。
相关系数低于 0.5 时我会去查预测分数和图像类别的相关性,比如风景图普遍高分、人像图普遍低分,这就说明模型被内容带偏了。Laplacian 方差虽然不能直接当分数用,但它是一个很好的「清晰度先验」,拿来做验收基准比拿合成验证集的回归误差更有说服力。
5.2 分组打分:切块取最低分,贴近监控和拍照场景
整图缩放到 224×224 会丢失局部模糊信息,这是上一章 4.4 那条坑的根源。部署时我通常把推理封装成「分组打分」模式:把原图切分成多个 224×224 的块,每块独立推理,最后取所有块分数的低分位。比如 4×4 共 16 块,P25(倒数第 4 低的分)比平均分更能反映「画面里最糊的关键区域」——监控场景里人脸区域刚好糊了的图,平均分可能还有 0.6,但 P25 已经掉到 0.3,一下就能识别出来。
切块还有个额外的好处:它顺便解决了分辨率适配问题,1MP 和 12MP 的图都切成同样大小的块,块与块之间可比。推理较快时建议每块做 ±8 像素的四角平移再取均值,能压一压 JPEG 块效应带来的分数抖动。这套封装做完,模型才算真正从「实验室能跑」变成「线上能筛」。
我现在的习惯是每次迭代后先随机抽 50 张图做人工快速排序,再跑指标,这个习惯帮我拦下过两次「指标好看、实际翻车」的版本。整套方案做下来,最深的感受是:清晰度评分比的不是 CNN 还是 Transformer 谁更强,而是数据分布有没有贴近你的真实输入。希望帮到你。
本文还有配套的精品资源,点击获取