ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CNN+Transformer图像清晰度评分实战:从网络设计到部署

CNN+Transformer图像清晰度评分实战:从网络设计到部署 简介基于卷积神经网络与Transformer的图像质量评估Python项目面向计算机相关专业学生、老师及企业开发者用于解决海量图像中自动筛选高质量图片的实际需求。项目以清晰度评分为切入点不依赖美学特征通过卷积神经网络的局部感知与Transformer的全局特征学习相结合构建回归预测模型可适配LIVE、KONIQ、CSIQ、LIVEC、BID等多个公开数据集。压缩包共22个文件包括16个Python源码、2个Shell运行脚本、2个Markdown说明文档以及文本说明整体大小仅29KB结构紧凑便于快速部署和二次开发。目前已有269人学习下载代码经测试运行成功功能完整可支撑毕业设计、课程设计、期末大作业或初期项目立项演示若基础较好还可在此代码基础上修改扩展实现其他图像质量评估功能。模型基于PyTorch框架采用Adam优化器训练入口清晰是学习图像质量评估与Transformer应用的优质参考资料。1. 基于CNNTransformer的图像质量评估清晰度评分不是简单的高频能量统计清晰度评分传统做法大家都很熟Laplacian 方差、Tenengrad、FFT 高频能量OpenCV 几行就能出分。快归快这类指标对光照、压缩、内容类型极其敏感——同一张图压一遍 JPEG分数可能掉一大截人像背景虚化和一张纯纹理场景高频能量差不多但观感完全不同。基于 CNNTransformer 的图像质量评估就是把这件用人眼判断的事交给模型学CNN 负责把边缘、纹理、噪声这些局部高频特征提出来Transformer 在整图上下文里判断「这个模糊是景深虚化还算合理还是真的没对上焦」最后回归成 0~1 的清晰度分数。监控抓拍质量过滤、OCR 前的模糊图筛选、相机预览实时提示这几类场景这套方案比传统指标更扛造。适合手里有 python 源码和项目说明就想快速落地的人只要装了 PyTorch、有一批自然图像就能开始训练不需要专门的 IQA 数据集。下面按「网络怎么搭、数据怎么造、参数怎么调、坑在哪里」一步步讲。2. 网络结构拆解ResNet18 提局部纹理Transformer 编码器建模全局感知2.1 清晰度评分为啥不能只靠 CNN全局感知和局部细节一个都不能少清晰度在信号层面是高频能量强度边缘越锐、纹理越清楚高频分量就越大。CNN 天生适合干这件事卷积核就是一组可学习的滤波器底层学到的是边缘、角点这类高频响应层数越深越能组合出纹理模式。直接用 ResNet 最后一层特征接个回归头也能做出一个能用的清晰度评分器很多简化方案就是这么做的。但只用 CNN 的问题在于它缺少「跨区域比较」的能力。卷积的感受野是有限的即使 ResNet50 最后一层感受野能覆盖全图特征图上也已经把位置信息压得很扁模型很难区分「整张图都糊」和「只有背景糊、主体是锐的」。这两种情况在局部高频统计上可能非常接近但观感完全不同清晰度评分恰恰需要这种全局判断。Transformer 编码器在这里的作用不是替代 CNN而是把 CNN 不同层级输出的特征当成一组 token让自注意力去做全局依赖建模。模型可以学会主体区域的边缘响应高那么背景区域的低响应就合理反过来主体区域的边缘响应也低那整张图就真模糊。这种规则用卷积层去拟合需要很深的网络和大量数据用自注意力一层就能表达。这就是标题里 CNNTransformer 组合的根本动机不是把两个模型叠起来显得高级而是各管一段。2.2 最小可跑模型CNNTransformer 全流程代码与参数说明常见做法是选一个轻量 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_dim512, num_heads8, num_layers2, dropout0.1): super().__init__() # CNN backbone保留多层特征不要最后的全连接分类头 backbone resnet18(pretrainedTrue) 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_size1) self.proj3 nn.Conv2d(256, token_dim, kernel_size1) self.proj4 nn.Conv2d(512, token_dim, kernel_size1) # 可学习位置编码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_modeltoken_dim, nheadnum_heads, dim_feedforward2048, dropoutdropout, activationgelu, batch_firstTrue ) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_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], dim1) # (B, 1029, token_dim) tokens tokens self.pos_embed[:, : tokens.size(1), :] out self.transformer(tokens) # (B, 1029, token_dim) feat out.mean(dim1) # 全局平均池化聚合所有 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_dim512、8 头、2 层编码器是我的起步配置。显存有限时优先把 token_dim 降到 256、head 降到 4这比减少层数更划算因为自注意力的计算量是 token 数的平方通道数降一半显存和速度都能明显改善。dim_feedforward 2048 是 PyTorch 默认值改小到 1024 能让模型小一截。清晰度任务不是强语义任务feedforward 容量过大反而容易过拟合到训练用的合成模糊分布上这一点在调参时值得盯一下。2.3 位置编码和分辨率两个容易被忽视的细节位置编码对清晰度评分的影响比对人脸识别这类任务更微妙。可学习位置编码训练完之后会把「图中心区域」和「图边缘区域」区分对待这符合拍照习惯——摄影构图里主体通常在中心附近边缘虚化是常见现象。我做过关闭位置编码的对比实验也就是注释掉 tokens 加 pos_embed 那一行模型分数整体下降 0.02 左右但对「中心清晰、边缘模糊」这类典型照片的判断力明显变弱。所以我的结论是清晰度评分保留位置编码是有收益的尤其是处理真实拍摄照片的时候。但位置编码数量是写死的 1029 个。一旦推理时输入分辨率不是 224×224token 数量变化位置编码长度就对不上。常见做法是训练和推理都固定 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, modegaussian, severity0.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 同时扮演退化参数和标签高斯模糊里 σ 越大目标越接近 0JPEG 里 quality 越低目标越接近 1注意方向别搞反。三种模式在训练时轮流抽避免模型只学会识别某一类模糊。运动模糊的 kernel 我只写了水平方向实际训练里最好每张图随机旋转一个角度否则模型会对「水平拖影」过拟合真实照片里的手持抖动方向是任意的。这里有个跟直觉相反的经验severity 直接当回归标签比用一堆人眼评分的 MOS 值做标签更稳。因为合成退化的物理参数和人眼感知基本单调模型学会的是「退化有多强」而不是去拟合一批充满标注噪声的主观分数。真实照片虽然比合成退化复杂但这个单调性让模型有了一个很好的初始化。3.2 训练配置与 Loss 设计相对回归比 MSE 稳Loss 我不用 MSE用的是相对 L1。清晰度分数如果落在 0.2~0.8 区间MSE 会倾向于把大误差留在大分数样本上而清晰度评分最怕的是「模糊图给高分」这种小概率大误差。相对 L1 让不同分数段的样本对梯度贡献尽量均衡def relative_l1_loss(pred, target, eps1e-5): # 预测和目标都是 0~1 之间的分数 diff torch.abs(pred - target) denom target eps return torch.mean(diff / denom)配合这个 Loss训练配置我通常这样落配置项取值说明输入尺寸224×224 随机裁剪固定分辨率匹配位置编码数据增强RandomCrop、RandomHorizontalFlip、ColorJitter颜色抖动很关键真实照片色调差异大Batch Size161029 token 的 Transformer 编码器很吃显存8G 卡建议 8优化器AdamWlr1e-4预训练骨干再用 1e-5见第 4.5 条weight decay1e-5防止回归头过拟合Epoch30~50按验证集早停合成数据量大几十轮就能收敛训练循环本身没有特别之处关键在两点一是如果用了带 BatchNorm 的骨干batch size 太小时统计量抖动大建议至少 8二是混合精度训练对 Transformer 很友好开 AMP 能省近一半显存分数精度损失几乎测不出来。数据加载时别在 CPU 上做高斯模糊合成退化放 GPU 前用多进程 DataLoader 预生成好否则训练速度会被 OpenCV 拖慢。3.3 验证集的正确建法合成分布不能直接验收真实效果很多项目翻车就在这一步训练集是合成模糊验证集也拿合成模糊测指标好看到 0.98一到真实场景就露馅。我的经验是验证集必须掺入两种数据一是留出约 2 成的合成退化样本用它监控训练有没有过拟合二是单独准备一批真实拍摄的模糊/清晰对照。真实样本没有精确标签也没关系用排序对比来评估——比如找同一场景的对焦、失焦两张照片模型应该给对焦的更高分统计「判断正确」的比例再和 Laplacian 方差这类传统指标比一下排序一致性。另一个原因是清晰度评分应用场景差异很大监控摄像头拍的图有全局噪声手机相册的图有景深和算法锐化扫描文档又是另一套分布。如果不能确定真实输入长什么样模型在部署后大概率会翻车。所以我建议先在项目说明里写清楚目标输入来源再决定合成训练的退化范围。如果目标输入是夜间监控训练时还要额外加高斯噪声和低光亮度扰动否则训练集和真实域差距太大CNNTransformer 再强也拉不回来。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 降到 256head 从 8 降到 4batch 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-5Transformer 学习率保持 1e-4。PyTorch 里实现很简单遍历骨干参数设置 requires_gradFalse解冻时再改回来即可。这个坑在标题指向的源码方案里特别常见因为很多人在 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 谁更强而是数据分布有没有贴近你的真实输入。希望帮到你。本文还有配套的精品资源点击获取
返回列表