
简介基于Python的无参考图像质量评价完整源码主要面向图像质量评估、人脸分析方向的开发者与算法工程师解决不依赖原始参考图的质量评分问题。项目以RankIQA思路为核心既有用于生成不同等级失真图像的Matlab数据准备脚本又提供了原版224输入与面向人脸尺寸重新训练128输入两套网络实现regression_network可用小网络拟合RankIQA排序结果train_pose_qua.py则为一人脸角度图像质量的多任务联合学习脚本。包内还详细给出训练技巧先分任务预训练观察loss下限再做loss加权以避免任务间相互干扰同时针对低质量图像效果差的问题将质量label非线性拉伸到0-10区间。资源共274个文件、约12.37MB以163个Python源码文件为主配合prototxt网络结构、TensorFlow checkpoint模型、shell脚本及说明文档等目录划分清楚。目前已有1280人学习下载适合需要复现无参考质量评价流程、研究多任务训练策略或进行源码级调参的读者。1. 无参考图像质量评价一个Python源码包解决“没原图”的打分问题做图像质量评价的从业者大概率都遇到过这种困境手里只有一批监控抓拍或人脸图片原图不存在判断哪张画质能用全靠肉眼。无参考图像质量评价NR-IQA解决的就是这个场景不借助参考图仅凭单张图像输出质量分。这个Python源码项目提供了一套完整的无参考图像质量评价方案按RankIQA的两阶段思路实现先用排序数据训练Rank网络学会比较图像清晰度让网络在“这张比那张干净”的相对监督信号里学会质量表征再用一个轻量回归网络把相对分拟合为绝对质量分。附带失真图像生成脚本和多任务训练代码可直接用于人脸角度和画质联合预测。适合做人脸识别前处理、图像质量筛选的工程师也适合拿NR-IQA做毕设的研究者新手按步骤能复现熟手能直接替换数据集。2. 数据制造用Matlab批量生成失真等级把排序监督信号造出来2.1 Rank网络为什么需要排序数据先解决一个基础问题为什么不直接让网络回归人工打分质量分本身是主观的两个人看同一张图给出的分差可能很大标注一致性差而且低质量样本在真实场景里占比小直接回归时网络很容易把高质量区间学得很准、低质量区间学成一团浆糊。这一点在后续验证阶段会暴露得特别明显模型对模糊图输出完全失控根源就是训练阶段的标签分布没处理好。排序监督信号则不同。给定同一张原图高斯模糊sigma从1加到5谁更清晰是确定的不存在争议。把这种确定性的相对关系送进网络网络不需要知道绝对分只需要让输出分数满足质量高低顺序。RankIQA这类两阶段方案的核心就是利用自动生成的排序数据把标注成本压到几乎为零。训练的时候每次从同一张原图的失真版本里抽一对图例如level1和level3label记为1再抽一对反序的label记为0。数据对的数量远大于原图数量模型见过的质量过渡样本更多。这也是为什么这个项目把data目录下的Matlab失真脚本放在第一步。没有这些不同等级的图像Rank网络完全没有监督信号可学。如果你手头已经有带MOS标注的数据集也可以在后期把人工分数当作回归目标微调但先跑排序阶段模型初始化更好收敛更稳。数据制造这一步决定后面所有训练能否成立值得多花时间把目录和命名规则定清楚。2.2 data目录下的distortion脚本说明data目录里的distortion.m就是干这件事读一张原图按指定失真类型和强度生成一张降质图像。常见失真类型与参数区间如下表实际使用时按项目面对的退化场景裁剪。这里的失真类型不是随意选的宁少勿滥——没有业务场景对应的失真训出来的排序能力换到线上等于白训。失真类型 mode质量等级映射参数区间说明blursigma 0.5 0.9 * level0.5 ~ 5等级越高越模糊jpegquality 90 - 12 * level30 ~ 90压缩率逐级升高noisesigma 0.01 * level0.01 ~ 0.05高斯加性噪声downsamplescale 1 / (1 0.15 * level)0.5 ~ 0.87先缩小再放大回原尺寸对应脚本的核心逻辑提炼如下完整文件在data目录里% distort.m 按照失真类型和等级生成低质量图像 % img : 原始高清图uint8 % mode : blur | jpeg | noise | downsample % level : 1~5数字越大失真越重 function out distort(img, mode, level) switch mode case blur sigma 0.5 0.9 * level; out imgaussfilt(img, sigma); % 高斯模糊 case jpeg quality 90 - 12 * level; out imwrite2jpg(img, quality); % 先落盘再读回模拟JPEG编解码 case noise sigma 0.01 * level; out imnoise(img, gaussian, 0, sigma); case downsample scale 1 / (1 0.15 * level); out imresize(img, scale); out imresize(out, [size(img, 1), size(img, 2)]); otherwise error(unknown distortion mode: %s, mode); end end % imwrite2jpg 用临时文件模拟JPEG有损编码 function out imwrite2jpg(img, quality) tmp_path fullfile(tempdir, sprintf(distort_q%d.jpg, quality)); imwrite(img, tmp_path, jpg, Quality, quality); out imread(tmp_path); end代码说明blur分支用imgaussfilt参数sigma随level线性增长模拟对焦不准或运动模糊带来的观感下降jpeg分支先写临时文件再读回这一步会把压缩痕迹块效应、振铃真实地带回图像比直接在矩阵上做DCT更省事noise分支用imnoise加高斯噪声level越高信噪比越低downsample分支先缩小再放大回原尺寸制造分辨率不足的模糊感。这些是质量评价任务里最常出现的真实退化类型。注意临时文件路径里带上了quality参数。最早版本如果写死tmp.jpg多级循环时不同等级的JPEG图像会互相覆盖轻则生成数据错乱重则imread直接读到损坏文件。批量生成时我一般会对每张干净原图循环4种失真、5个等级输出目录固定为data/clean和data/distorted/{mode}/level_N。这个结构后续Python读数据时直接按路径索引不需要额外维护数据库缺点是文件数量膨胀快1张原图对应20张降质图1000张原图就是2万张文件。磁盘不充裕就只生成level_1、level_3、level_5三个等级排序间隔拉大监督信号反而更强。2.3 把图像整理成训练pair目录与标签生成Matlab负责把图制造出来Python这边负责把图变成训练样本。我习惯先扫一遍目录把所有pair预生成到一个文本里而不是在Dataloader里实时随机组合。预生成的好处是训练中途排查某个batch时可以直接定位是哪些图产生了异常loss不用反查随机种子。另一个好处是pair列表可以提前做清洗比如把文件缺失、尺寸异常的组合直接过滤掉训练时少一半莫名其妙的报错。# build_pairs.py import random from pathlib import Path root Path(data/distorted) levels [1, 3, 5] pairs [] # 每种失真内部从不同等级里抽高质量/低质量对 for mode in [blur, jpeg, noise, downsample]: for level_high, level_low in zip(levels[:-1], levels[1:]): high_dir root / mode / flevel_{level_high} low_dir root / mode / flevel_{level_low} for high_path in sorted(high_dir.glob(*.jpg)): # 找到同一原图对应的低等级文件命名规则orig_001_level_1.jpg low_path low_dir / (high_path.stem.replace(flevel_{level_high}, flevel_{level_low}) .jpg) if low_path.exists(): pairs.append((str(high_path), str(low_path), 1)) # 前清晰后模糊 pairs.append((str(low_path), str(high_path), 0)) # 反序 random.shuffle(pairs) with open(train_pairs.txt, w, encodingutf-8) as f: for p in pairs: f.write(f{p[0]} {p[1]} {p[2]}\n) print(ftotal pairs: {len(pairs)})逻辑说明第一个循环遍历失真类型第二个循环把等级1、3、5两两配对生成“高等级图在前、低等级图在后”的正序pairlabel为1再把顺序反过来生成反序pairlabel为0。这样每个清晰度关系都被模型同时学习正向和反向Rank网络不会偏向某一个方向。文件命名规则要求Matlab导出时带上原图编号和等级例如orig_001_level_3.jpg如果实际命名不一致把replace那段改成对应规则即可。Dataloader读取train_pairs.txt后使用RandomCrop从两张图里裁同一位置的小块来训练这样既做了数据增强也避免全图计算导致显存吃紧。人脸场景下裁剪尺寸一般就是128x128和后面网络输入尺寸保持一致车牌、遥感这类大幅面场景可以适当调到160或192。pair对内部的裁剪必须用同一个随机种子否则两张图裁自不同区域质量差异就不是纯粹由失真等级决定了模型会学偏到内容差异上。3. 训练链路从Rank比较到绝对质量分两阶段参数怎么设3.1 网络输入从224降到128结构改动与权重处理项目里有_src和src两套代码区别就在网络输入尺寸_src里是224src里为了适配人脸区域裁剪尺寸把输入降到了128。人脸检测框裁出来通常不到224像素强行resize到224不仅损失细节还会改变五官比例降采样到128反而更接近真实输入分布。改动本身不复杂网络前后端通常都是全卷积加全局池化把数据预处理里的resize尺寸和随机裁剪尺寸从224改成128就行最后全连接层的输入特征维度跟着全局池化走不受分辨率影响。但这里有个极易翻车的点如果直接加载在224输入下预训练的权重第一层卷积核的统计分布是在224尺度下习得的喂128像素的图早期特征的感受野和统计含义都变了效果通常不是小幅下降而是明显变差。我一般会把预训练权重当作初始化然后用128输入在小数据集上额外跑5到10轮finetune让BN的均值和方差重新估计。如果项目里没有预训练权重就老老实实从零训Rank阶段对绝对精度要求不高先训出来一个相对排序正确的模型反而比硬套预训练权重更稳妥。3.2 Rank阶段pair采样与排序损失Rank网络输出的是一个标量质量得分训练目标让清晰图的得分高于模糊图。常见损失用hinge排序形式PyTorch里实现很直接# rank_loss.py import torch def pairwise_rank_loss(score_high, score_low, margin0.3): # score_high: 清晰图的网络输出, score_low: 模糊图的网络输出 # 目标: score_high score_low margin loss torch.clamp(margin - (score_high - score_low), min0) return loss.mean()参数说明margin控制两张图输出分数的间隔取0.3是经验值。margin太小比如0.1模型只要把两张图分出微小差异就停止优化学到的排序置信度不足后续回归拟合时噪声很大margin太大比如1.0网络对easy pair过度苛求训练震荡低等级差距小的pair反而长期无法收敛。一般我会先设0.3跑50轮观察loss曲线平稳后再扫一轮margin在0.1到0.5之间的取值看哪个让验证SRCC最高。pair采样要均衡。如果数据生成时4种失真、每类5个等级按2.3的预生成文本每次迭代随机取一个batch批次内最好保证同一失真类型的pair不超过一半。否则网络可能只学会分辨“模糊vs清晰”这一类差异而对噪声和JPEG压缩无感。采样权重按失真类型等概率抽再在类型内部随机选pair。训练日志里建议加上当前batch里各失真类型的pair数量方便定位是哪一类数据拖慢了收敛。3.3 回归网络小网络拟合RankIQA的评价效果Rank网络训练完成后它的输出只是相对分最大问题是分数尺度不确定同一张图在两次forward里可能因为batch统计不同输出不同没法直接当绝对质量分用。项目里regression_network做的工作就是把Rank输出拟合到一个稳定刻度。常见做法有两类一类是拿Rank网络的倒数第二层特征接一个回归头直接训练另一类是像项目里这样单独用一个更小的网络输入原图输出绝对分用Rank网络打分当伪标签。第二种训练更快显存占用低便于部署时把质量分支嵌入人脸流水线。一个可抄作业的小回归网络结构如下# regression_network/model.py import torch.nn as nn class SmallRegressionNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.AdaptiveAvgPool2d(1), ) self.head nn.Sequential( nn.Linear(128, 32), nn.ReLU(), nn.Linear(32, 1), ) def forward(self, x): feat self.features(x).flatten(1) return self.head(feat).squeeze(1)结构说明三层卷积加全局平均池化最后接一个32维隐藏层的回归头。比Rank主干网络小一个量级因为这里只是把Rank网络已经编码好的相对质量信号重新标定到绝对分数不需要再学复杂纹理。训练时输入用128x128的随机裁剪优化器Adam初始学习率1e-4weight_decay设1e-5防止在小数据集上过拟合loss用smooth L1而不是MSEMSE对个别极端伪标签的惩罚太狠一个小网络扛不住。训练循环里要保存阶段性checkpoint不能只留最后一轮# train_regression.py 核心片段 import torch import torch.nn.functional as F best float(inf) for epoch in range(epochs): for img, rank_score in loader: pred model(img) loss F.smooth_l1_loss(pred, rank_score) optimizer.zero_grad() loss.backward() optimizer.step() val_loss evaluate(model, val_loader) if val_loss best: best val_loss torch.save({ epoch: epoch, model: model.state_dict(), val_loss: val_loss, }, fcheckpoints/regression_epoch{epoch:03d}_val{val_loss:.4f}.pt)这段代码里最容易被忽略的是保存文件名带着epoch和val_loss便于回滚。模型比较小多存几个文件也不占空间真正的大模型才需要考虑定期清理旧权重。val_loss用smooth L1在验证集上的均值和训练loss同口径直接比较才有意义。这个阶段要把Rank网络冻结不要让回归阶段的反向传播回去扰动排序网络冻结后Rank网络相当于一个固定的伪标签生成器回归网络只负责拟合它。4. 多任务训练一个网络同时输出人脸角度与图像质量4.1 先跑单任务拿到这个网络结构的性能下限项目里的train_pose_qua.py是重头戏一个网络同时预测人脸角度和图像质量。多任务不是把两个loss加起来就完事第一步应该先跑单任务同一种网络结构分别只用角度标签和只用质量标签各训练一遍记录最终能达到的最低loss。这个值代表当前网络结构拟合单个任务的性能上限也是后续多任务调优的基准线。如果没有这个基准多任务训练时你根本不知道两个任务之间的互相干扰到底有没有被消化掉。我在实际训练里会把单任务的结果写在一个小表里pose loss最低多少、quality loss最低多少、分别是第几轮达到的。到了多任务阶段每隔几个epoch就对比一次两个任务的当前loss和各自单任务下限之间的差距如果多任务里pose loss长期比单任务下限高出一截说明质量任务在跟角度任务抢梯度不是单纯的权重设置问题可能要从网络结构上分离两个分支。反过来如果两个任务都能逼近单任务下限说明共享特征提取部分学到的表征对两个任务都够用就不需要拆分支。4.2 loss加权与分别打印观察两个loss是否逼近单任务多任务训练容易掉进“总loss好看单任务崩掉”的坑。总loss是加权和一个任务loss特别低时总loss也会低另一个任务可能早已卡死。所以train_pose_qua.py这类训练脚本里正向传播后一定要分别打印两个loss而不是只打一个加权和。下面是我常用的模板# 多任务训练主循环角度质量 import torch.nn.functional as F for epoch in range(epochs): for img, pose_gt, qua_gt in loader: pose_pred, qua_pred model(img) loss_pose F.smooth_l1_loss(pose_pred, pose_gt) loss_qua F.smooth_l1_loss(qua_pred, qua_gt) w_pose, w_qua 1.0, 1.0 # 先等权起跑 loss_total w_pose * loss_pose w_qua * loss_qua optimizer.zero_grad() loss_total.backward() optimizer.step() # 每条日志分开打盯住与单任务基线的差距 if step % 50 0: print(fepoch {epoch} step {step}: fpose{loss_pose.item():.4f} fqua{loss_qua.item():.4f} ftotal{loss_total.item():.4f})逻辑说明w_pose和w_qua先把两个loss拉到同一数量级再相加。角度回归的数值如果是欧拉角范围大概在-45到45度质量分经过拉伸后落在0到10。两者数值范围差一个量级不等权直接相加时质量loss会主导梯度。打印两行单独loss后和单任务时的最低loss对比假设单任务qua最低是0.35多任务跑到第10轮还在0.9以上就说明质量任务没吃到足够梯度要么把w_qua加大要么检查两个任务是否共享了太多前端特征。提示权重微调范围建议控制在0.5到2.0之间一次只动一个权重频繁双向调整会让两个loss都在抖动里浪费时间。权重的调法我一般遵循“一次只调一个”的原则固定在0.1、0.5、1.0、2.0几个档位里扫每个档位跑10到20轮看趋势而不是每轮都改权重。多任务训练对权重变化很敏感频繁改动会把模型推向一个不稳定区域表现就是loss在小步低频震荡怎么等也等不到收敛。调整权重时保留训练日志观察两个loss的变化方向如果增大w_qua后pose明显反弹就得回落寻找两边都能接受的折中点。4.3 多任务的checkpoint策略多任务的可复现难度比单任务高一截两个loss的动态范围不同最优epoch也不一定同步。单任务时看val loss选最优很容易多任务时就要专门定义“什么叫最好”。我会把保存条件设为两个任务的val loss都低于各自单任务基线的1.1倍且两者之和最小才覆盖上一份checkpoint。达不到这个条件宁可保留旧权重继续训不盲目保存。保存时把两个val loss、epoch数、权重文件都写进文件名格式类似pose0.42_qua0.38_ep024.pt。这比只存一个best_model.pt多几个字符但回滚时能一眼看出这份权重的质量构成。还有一个细节多任务训练收敛慢学习率调度上不要用StepLR那种固定步长衰减常见做法是等两个loss都进入平缓期再手动降一个量级。降太早网络还在适应两个任务的均衡点梯度方向乱降太晚收敛速度太慢等一轮要好几小时。5. 避坑记录低质量崩预测、loss失衡、checkpoint覆盖五条踩坑经验5.1 低质量图像预测崩掉quality label要非线性拉伸到0到10现象训练时验证集里清晰图预测和真实分接近一旦输入有雾、严重过曝或重度噪声的图输出分数要么贴着0要么贴着10完全乱套。loss在低质量区间长期降不下去。原因label分布不均衡自然采集的图大部分落在中等偏高质量段低质量样本稀少原始分数尺度跨度大时网络在同一组权重里很难同时拟合两个密度差异巨大的区间。这是回归任务的经典问题不只是NR-IQA独有。解决对质量label做非线性拉伸到0到10让低分区间占据更大的数值空间典型映射加一个幂指数# label_mapping.py import numpy as np def stretch_label(q_raw, q_min0, q_max100, gamma0.6): q_norm (q_raw - q_min) / (q_max - q_min) return 10.0 * np.power(q_norm, gamma)这段函数把原始分先归一化到0到1再开gamma次方。gamma小于1时低分段被拉伸gamma越大越接近线性低分段越挤。我一般先从gamma0.6起步画出拉伸前后label的直方图确保低质量区间不再是一条细线。注意预测出来的是拉伸后的分数对外输出时要么同时公布反变换公式要么保留一份拉伸分数到真实MOS的映射表否则业务方拿到的0到10分和他脑子里那个0到10不是一回事。5.2 两个任务互相打架总loss降得很好单个loss不动现象多任务训练日志里total loss一路下降看起来很美单独看pose loss却停在某个数值上不挪窝qua loss反而在降。原因两个loss数值量级不同加权相加后梯度被较大的一方主导。反向传播时共享卷积层收到的梯度几乎全来自质量任务角度任务的分支只是陪跑。这就是多任务训练最常见的梯度“绑架”。解决先分别打印两个loss记下等权条件下的量级差根据量级差把权重修正为接近1比1的梯度贡献再继续训练。如果修正后仍然不降检查角度分支是否只有一个全连接头分支容量太小拟合不了常见做法是把共享特征层复制一份作为角度分支的输入给角度任务独立的高层特征。做权重归一化时我习惯每隔50个step输出一次两个分支的grad_norm而不是只看loss。5.3 checkpoint被覆盖想回滚时只剩最后一轮现象训练到第30轮发现loss不稳想回滚到第20轮的权重打开checkpoints目录只剩一个文件。原因保存代码里文件名写死成checkpoint.pt每轮覆盖或者保存条件是“val_loss更低才保存”而第20轮不是最优val早被覆盖了。很多训练脚本默认带着这两个坏习惯。解决文件名带上epoch和两个关键指标每轮都保存一份小文件回归网络单模型几MB随便存多任务网络参数量大就保留最近5份然后轮转清理。保存时同时存optimizer状态和loss回滚时不只是模型权重学习率状态也要一起恢复只恢复模型不恢复optimizer等于半套后悔药。torch.save({ epoch: epoch, model: model.state_dict(), optimizer: optimizer.state_dict(), loss_pose: loss_pose.item(), loss_qua: loss_qua.item(), }, save_dir / fep{epoch:03d}_pose{loss_pose:.3f}_qua{loss_qua:.3f}.pt)5.4 输入尺寸降到128后精度掉一截别直接拿224权重跑现象把resize尺寸从224改成128在原有checkpoint上续训或直接推理准确率下降明显且下降幅度远超预期。原因卷积层对输入尺寸有一定弹性但BN层统计量、第一层卷积的有效感受野都是按224尺度校准的直接换输入尺寸等于让模型在一个分布外输入上推理。解决如果已经训好了224的Rank网络先把它当作初始化载入再用128输入finetune 5到10轮期间BN参数重新估计不要从224训练好的模型直接跳到128继续用。如果两个尺寸都要作为输入最省事的方式是在网络结构里强制加AdaptiveAvgPool保证全连接层输入维度不随尺寸变化。5.5 Matlab批量生成数据中途报错或文件错乱现象跑distort脚本时生成的图文件大小明显异常或者imread读回时报文件不存在批量跑到一半直接error中断。原因多数情况是临时文件重名和路径问题。JPEG临时文件如果没带quality参数区分两个level会互相覆盖另一个常见原因是工程拷贝到Windows后有中文路径或带空格的路径Matlab函数拼接路径时踩坑。解决临时文件命名带上失真类型和level比如distort_blur_l3.jpg把data目录统一放到英文无空格路径下批处理前先对10张原图跑冒烟测试确认四种失真的输出文件大小都落在合理区间JPEG一般在几十KB到几百KB不可能只有0字节再铺开全量。6. 验证评价效果排序一致性和主观打分的对拍流程6.1 用SRCC衡量排序能力而不是只看MSE质量评价模型验证的常用指标不只MSE。MSE衡量的是绝对分数接近程度但业务上更关心排序是否可信用户要挑出最差的一批图模型误判顺序比分数偏差更致命。所以对拍时我会用Spearman秩相关系数SRCC来看预测分和人工分排序是否一致。# eval_correlation.py from scipy.stats import spearmanr preds [model.predict(img) for img in test_images] mos get_mos(test_images) # 人工平均意见分 rho, p spearmanr(preds, mos) print(fSRCC{rho:.4f} p{p:.2e})操作步骤挑30到50张覆盖清晰、模糊、噪声、压缩各等级的测试图找3到5个人打分取平均然后和模型预测算SRCC。结果分三档看待SRCC 0.9以上说明排序基本可靠0.8到0.9可以用于粗筛0.8以下不要上生产先回训练阶段找问题。对拍时注意预测分数是否经过了和人工分相同的单调变换如果训练时做了非线性拉伸评估时也要把预测分做逆变换再算相关否则相关系数会被非线性关系干扰。6.2 控制变量排查先定位是谁崩了再改训练多任务模型出现整体效果差时最有效的排查方法是控制变量把angle分支权重设成0先让模型退化成纯质量分支看单任务性能是否恢复到此前水平。如果退化了还不行说明问题出在共享特征层或数据配比而不是多任务权重要么调整两个数据集的采样比例要么把共享层拆开。我见过太多人一上来就调loss权重调了两天发现是数据里把level_1和level_3的命名顺序搞反了排序pair全错。所以每次改完数据生成脚本都要先抽几对pair人工确认再进训练。从那以后我每次换数据集训练NR-IQA模型前都强制自己走一遍固定流程先检查pair文本里五对样本的正确性再跑单任务下限再算一轮SRCC对拍最后才轮到多任务权重。顺序不许乱乱一次就多付一次翻车学费。希望帮到你。本文还有配套的精品资源点击获取