简介面向计算机视觉课程设计与期末大作业提供了一套基于PyTorch实现的RankIQA图像质量评估模型完整源码。项目采用排序学习机制训练无参考图像质量评估模型包含从数据准备、模型构建、损失函数设计到训练评估的完整流程特别适合需要高分课程设计或希望系统学习RankIQA算法的学生与开发者。资源包共包含五十八个文件以四十个Python脚本为核心覆盖数据生成、模型定义、损失计算、训练测试等模块同时附有shell运行脚本、Markdown说明文档、图片示例及环境配置要求整体压缩包仅257KB十分轻量便于直接迁移。目前已有三百九十七人学习或下载说明该资源经过了不少实际使用检验内容可靠。源码内含项目配置、自建数据加载器、多种损失函数排序损失、回归损失、EMD损失以及多种经典模型结构并配有运行说明下载即可运行无需修改既可直接作为高分课程设计提交也可作为期末大作业便于在报告中详细分析实现细节。1. 基于PyTorch的图像质量评估模型RankIQA先学会排序再学会打分做图像质量评估IQA的人都有一个共同的痛想训练一个能预测主观评分的模型需要大量人工标注的MOS分数而标分数既贵又不稳定不同人打分差异极大。RankIQA的思路恰恰是绕开这个难点——它先让模型在图像排序对上学“哪张图质量更好”然后再用少量带分数的图像把排序能力校准成打分能力。这个思路在课程设计和实际项目里都很讨巧不需要人工标几百张图的分数只需要构造相对顺序模型就能学会质量表征。这篇文章会按“原理 → 数据构造 → 模型搭建 → 踩坑 → 进阶校准”的顺序把一套可以直接复现的PyTorch实现完整讲透适合正在做课程设计、毕业设计或者想快速在IQA方向落地一个可运行方案的人。本文基于PyTorch实现RankIQA所有代码都可以直接跑通。2. RankIQA的核心策略为什么“排序”比“打分”更容易学2.1 从分数回归到排序学习RankIQA到底改了什么传统的IQA模型比如经典的BRISQUE、NIQE或者后来基于深度网络的模型直接学习“图像 → MOS分数”的映射。这里的问题在于MOS分数本身是主观平均值噪声很大同一张图像在不同实验里得到的分数可能差出1分以上。模型拟合这种带噪声的标签要么过拟合到个别标注者的偏好上要么学到的表征不够泛化。RankIQA做了一个关键转换把回归问题变成排序问题。模型不再预测“这张图值3.2分”而是预测“这张图比那张图质量高”。排序对的标签是稳定的——对同一场景加噪声越重、压缩越狠的图像质量一定更差这个顺序几乎不会因为标注者不同而改变。用ResNet等预训练网络做特征提取再在特征之上加一个排序头用Pairwise Ranking Loss训练模型就能学到对质量敏感的表征。这一步的妙处在于排序对的构造完全不需要人工打分只需要知道“哪张图是原始图、哪张图是失真图”。2.2 两阶段训练排序预训练 回归微调RankIQA原文采用了两阶段方案这个设计直接影响源码结构阶段一是排序学习。构建孪生网络Siamese Network两个分支共享权重输入是一对图像输出各自的预测分数用一个排序损失函数约束“干净图的输出 失真图的输出”。这一步让网络学会质量排序。阶段二是回归微调。把排序头的输出换成回归头用少量有真实MOS标签的图像做监督微调。因为网络已经在排序任务上获得了良好的质量感知表征微调只需要很少的标注数据就能达到不错的效果。我在实际复现时发现阶段二不是可选项而是必选项。如果不做回归微调模型只能输出相对排序结果没法给出一张图具体的质量分数也就没法计算PSNR之外的评价指标。课程设计要交“预测分数 vs 真实分数”的对比实验阶段二必须做。提示两阶段共享同一个特征提取器但阶段二需要换掉最后的全连接层尺寸从“1个神经元”换回“1个神经元”在结构上没区别但训练方式要从Pairwise Loss换成L1或MSE Loss。2.3 网络结构怎么选ResNet18还是ResNet50RankIQA的特征提取器没有硬性规定常见做法是直接加载PyTorch预训练的ResNet。选型时考虑三点显存与速度。孪生网络一次前传要跑两张图ResNet50比ResNet18显存占用高出一倍多。课程设计一般用单卡ResNet18在1080Ti上训练256×256的patchbatch_size 16勉强能跑ResNet50就得降到8。预训练权重的影响。ImageNet预训练权重对IQA任务有正面帮助因为卷积核已经学会了纹理、边缘、结构等底层特征质量退化恰恰体现在这些特征上。但要注意预训练权重的末层分类头1000类必须换掉。微调的灵活性。阶段二微调时冻结前几层、只解冻后面几个stage的效果通常更好。ResNet的分层结构更适合这种局部解冻操作这也是我优先选它的原因。3. 构造训练数据把无标签图像变成排序对3.1 失真类型与数据集选择LIVE、TID2013还是自己造RankIQA的训练数据主要是“原始图像 各类失真图像”。常用的公开数据集有LIVE包含JPEG压缩、高斯噪声、高斯模糊等失真、TID2013失真类型更丰富含色彩失真和频段噪声。课程设计如果只想跑通流程用LIVE就够了如果论文里想强调泛化性TID2013是更好的选择。但有个现实问题LIVE的原始图像只有30张左右全部是25mm胶片时代的照片分辨率不高。直接用原始图构造排序对样本多样性不够。常见做法是先从原始图上随机裁剪patch再对每个patch施加不同强度的失真。这样一对图像从同一patch来内容完全相同只有失真程度不同排序标签绝对可靠。3.2 Patch切分与失真施加最小可执行代码我一般用OpenCV和NumPy直接写数据准备脚本不依赖额外的IQA工具库。下面这段代码做的事情是读取一张干净图随机裁剪出固定大小的patch然后对它施加两种强度的JPEG压缩失真形成一个排序对。代码里失真类型可以替换成高斯噪声、高斯模糊方法一样。import cv2 import numpy as np def random_crop(img, crop_size224): h, w img.shape[:2] y np.random.randint(0, max(1, h - crop_size)) x np.random.randint(0, max(1, w - crop_size)) return img[y:y crop_size, x:x crop_size] def jpeg_distort(patch, quality): # quality 越小压缩越狠质量越差 encode_param [int(cv2.IMWRITE_JPEG_QUALITY), quality] _, enc cv2.imencode(.jpg, patch, encode_param) return cv2.imdecode(enc, 1) def make_pair(clean_img, crop_size224, q_high80, q_low20): patch random_crop(clean_img, crop_size) # 原图patch ref jpeg_distort(patch, q_high) # 高质量版 dist jpeg_distort(patch, q_low) # 低质量版 return ref, dist # ref 的质量一定高于 dist逻辑说明随机裁剪保证两张图内容对齐JPEG重压缩会产生块效应quality参数越小块效应越明显。排序对的顺序是确定的——ref在前、dist在后训练时标签固定为“前者的质量分数必须大于后者”。这里务必保证输入的BGR格式一致OpenCV读出来是BGRPyTorch的预训练模型要求RGB后面转换时要统一。参数说明crop_size通常取224或256要和ResNet的输入尺寸匹配q_high和q_low是质量边界差距越大排序任务越简单但差距过大模型容易学不到细粒度差异。实际训练中我建议把quality区间设成[20, 90]每对随机取两个值不要固定80和20。3.3 批量生成排序对的完整脚本单张图最多只能生成有限的patch要撑起一个epoch至少几千对必须批量处理整个数据集。下面这段脚本把上述操作封装成数据集类直接对接PyTorch的DataLoader。import torch from torch.utils.data import Dataset import random class RankPairDataset(Dataset): def __init__(self, image_paths, crop_size224, distort_fnjpeg_distort, quality_range(20, 90), transformNone): self.paths image_paths self.crop_size crop_size self.distort_fn distort_fn self.quality_range quality_range self.transform transform # 必须做ToTensor和ImageNet归一化 def __len__(self): return len(self.paths) * 8 # 每张图生成8个patch def __getitem__(self, idx): img cv2.imread(self.paths[idx % len(self.paths)]) patch random_crop(img, self.crop_size) # 随机取两个质量值保证q1 q2 q1, q2 random.sample(range(*self.quality_range), 2) if q1 q2: q1, q2 q2, q1 ref self.distort_fn(patch, q1) dist self.distort_fn(patch, q2) if self.transform: ref self.transform(ref) dist self.transform(dist) # 标签1 表示 ref 比 dist 质量高 return ref, dist, torch.tensor(1.0)逻辑说明__getitem__里每次随机采样两个质量参数保证顺序正确。标签固定为1.0配合Ranking Loss使用时表示“第一个输入的质量得分应高于第二个”。transform里必须带ImageNet的均值方差归一化否则预训练权重直接废掉。参数说明len(self.paths) * 8是经验值patch越多一个epoch越长训练越稳但数据加载时间也越长。如果机器性能紧张改成*4即可。quality_range建议下限至少15低于15的JPEG压缩图像几乎没有可用视觉信息模型学不到有意义的结构特征。4. 搭建RankIQA模型并训练PyTorch实现与参数调优4.1 孪生网络与Ranking Loss核心代码逐行拆解RankIQA的排序阶段网络结构很简单共享的ResNet特征提取器 一个全连接输出头。PyTorch实现孪生网络的常见做法不是定义两个独立模型而是定义单模型输入时把两张图拼成batch一起前传再拆开计算损失。这样省显存也避免两个分支权重不同步的问题。import torch import torch.nn as nn import torchvision.models as models class RankIQA_Siamese(nn.Module): def __init__(self, baseresnet18, feat_dim512): super().__init__() if base resnet18: backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) elif base resnet50: backbone models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 去掉原始分类头 self.features nn.Sequential(*list(backbone.children())[:-2]) self.pool nn.AdaptiveAvgPool2d(1) # 排序头输出1个标量代表质量分数 self.head nn.Sequential( nn.Linear(feat_dim, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 1) ) def forward(self, x): x self.features(x) x self.pool(x) x torch.flatten(x, 1) return self.head(x)逻辑说明self.features保留了ResNet从conv1到layer4的全部卷积层最后两层AvgPool和FC被移除改用AdaptiveAvgPool2d(1)加自定义头。这样不管输入分辨率是多少池化后特征图都是1×1feat_dim只由输出通道数决定——ResNet18是512ResNet50是2048。参数说明feat_dim必须和backbone的输出通道严格对应写错会在forward时报维度不匹配。这里的排序头是一个两层的MLP中间隐层128维是平衡容量和速度的选择如果训练数据量大可以放宽到256但Phase 2微调时隐层太宽容易过拟合少量分数标签。Ranking Loss使用MarginRankingLoss这是PyTorch内置的排序损失正好适用于“输入一对得分约束第一个大于第二个”的场景。criterion nn.MarginRankingLoss(margin0.1) def rank_loss_fn(ref_score, dist_score, target): # target 全是 1.0表示 ref_score 应大于 dist_score return criterion(ref_score, dist_score, target)MarginRankingLoss的计算方式是max(0, -target * (score1 - score2) margin)。当target1时损失惩罚“ref_score ≤ dist_score”的情况margin控制两个分数的间隔要求。margin设太小0.01模型分不清细微质量差设太大1.0训练初期loss降不下去0.1是一个稳妥的起点。4.2 训练循环两阶段分别怎么跑排序阶段的训练循环和普通分类任务区别不大但有几个细节必须注意两张patch前传后要拆开计算损失梯度更新的频率要和batch_size匹配学习率要比常规分类任务低。def train_rank(model, dataloader, epochs10, lr1e-4, devicecuda): model.to(device) optimizer torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size4, gamma0.5) criterion nn.MarginRankingLoss(margin0.1) model.train() for epoch in range(epochs): total_loss 0.0 for ref, dist, target in dataloader: ref, dist, target ref.to(device), dist.to(device), target.to(device) # 拼成batch一次前传省显存 x torch.cat([ref, dist], dim0) scores model(x) ref_score, dist_score scores.chunk(2, dim0) loss criterion(ref_score, dist_score, target) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fEpoch {epoch1}/{epochs}, Loss: {total_loss/len(dataloader):.4f})逻辑说明torch.cat([ref, dist], dim0)把一对图像在batch维度拼起来前传一次再用chunk(2)拆开。这样网络每个batch只前传一次而不是两次计算图更简洁反向传播也更稳定。target在这里是标量张量全为1.0表示ref_score要大于dist_score。参数说明学习率1e-4是排序阶段的常用值比常规分类低一个数量级因为预训练权重已经足够好学习率太大会破坏底层特征。step_size4, gamma0.5的意思是每4个epoch学习率减半训练10个epoch总共衰减两次。如果loss在后期震荡把这个scheduler换成ReduceLROnPlateau会更灵活。阶段二的微调代码在结构上和上面几乎一样只有三处区别损失函数换成L1Loss或MSELoss数据加载换成带真实MOS分数图像优化器学习率降到1e-5级别并冻结backbone的前几层。下面这段是微调的核心片段。def train_regression(model, train_loader, val_loader, epochs20, lr1e-5): # 冻结前三个stage只训练最后一层 回归头 for name, param in model.named_parameters(): if layer4 not in name and head not in name: param.requires_grad False optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lrlr) criterion nn.L1Loss() for epoch in range(epochs): for img, mos in train_loader: img, mos img.to(device), mos.to(device) pred model(img) loss criterion(pred.squeeze(), mos) optimizer.zero_grad() loss.backward() optimizer.step()逻辑说明layer4 not in name and head not in name这段判断是冻结策略的核心。训练过程中只更新layer4和最后的head其他参数保持不变。这样做的原因是排序阶段已经让底层特征具有质量感知能力回归阶段只需要在顶层把相对分数映射到MOS空间。全部解冻会让模型在少量标注上过拟合。参数说明L1Loss比MSE Loss对离群标注更鲁棒MOS分数标注不稳定个别离群点不应该主导梯度。lr1e-5对只训练最后两层来说仍然偏保守如果loss在验证集上不动可以考虑解冻layer3但learning rate要降到5e-6。4.3 训练过程中的验证策略与收敛判断RankIQA训练不能只看loss因为排序loss下降只说明“模型能把图像分出高下”不代表分数准。要在验证集上同时看两个指标排序准确率pairwise accuracy和SROCCSpearman秩相关系数。排序准确率的计算方式很直观取一批测试图对每个场景生成一组不同失真的图像模型给它们打分然后计算预测排序和真实失真强度排序的一致率。SROCC则需要真实MOS分数适合在有MOS标注的数据集上评估。我一般每隔一个epoch在LIVE数据集上算一次SROCC如果SROCC连续3个epoch不涨就把学习率降到原来的三分之一。比起训练lossSROCC更接近最终评价指标也更不容易被rank loss的偶然波动误导。提示排序阶段结束时模型是不输出具体MOS分数的数字大小只有相对意义。不要在这个阶段把输出当真实分数去算PSNR类的误差指标那是阶段二微调之后才能做的事。5. RankIQA实战避坑训练翻车最常见的5个原因5.1 排序loss不降反升准确率卡在50%上下现象训练了十几个epoch排序loss在0.5附近震荡验证集的pairwise accuracy始终在55%以下和随机猜差不多。原因最大概率是数据构造时排序对“内容不一致”。如果裁剪patch时没固定随机种子或者两张图不是从同一位置裁剪出来的模型学到的是“内容不同带来的分数差异”而不是“质量不同带来的分数差异”。内容差异远大于质量差异时排序任务就变成了图像检索任务模型完全跑偏。解决检查数据加载代码确保ref和dist来自同一次random_crop的返回值。我在上面给的代码里是先裁剪一次再分别压缩这就是标准做法。如果你从预处理缓存里读图确认两张图是同一裁剪位置的产物。另一类原因是quality_range上下界差距过小比如[70, 80]这种区间内JPEG压缩的视觉差异微乎其微排序标签虽然正确但信号太弱把区间扩大到[15, 90]重新训练。5.2 验证集SROCC很高但实际图像评分效果很差现象LIVE数据集上SROCC达到0.93看起来不错但你拿自己拍的照片去测分数分布完全不合理比如严重模糊的照片反而得了高分。原因这是典型的“数据集偏差”。LIVE的失真类型有限主要是JPEG压缩、高斯噪声、高斯模糊和快衰落。模型在训练时只见过这几种失真遇到真实场景中的运动模糊、过曝、低光照噪点时特征分布超出训练分布预训练网络会把这些当作另一种“干净图”。解决训练时混合多种失真类型不要只用JPEG。在数据准备脚本里增加高斯噪声、高斯模糊、椒盐噪声几种失真函数每个batch随机选择一种。TID2013里包含24种失真课程设计如果时间允许建议至少覆盖5到6种常见的。另一个手段是数据增强里加入随机缩放和亮度抖动提高模型对内容变化的鲁棒性。5.3 显存爆炸batch_size调到4都跑不起来现象用ResNet50训练孪生网络batch_size设16程序启动没多久就报CUDA out of memory。原因孪生网络虽然只前传一次但一次前传就吃了2倍batch的图。而且torch.cat([ref, dist])让batch维度变成2NResNet50在224×224输入下2×1632张图的前传显存占用接近8GB。如果再开Adam的动量缓存显存直接翻倍。解决换ResNet18特征维度降一半显存占用直降。或者把batch_size降到8同时把pin_memoryTrue和num_workers4加上用数据加载速度弥补batch大小的不足。如果还想继续用ResNet50可以试一下梯度累积每4个小batch累计一次梯度再更新效果等同于batch_size扩大4倍显存不变。# 梯度累积写法每 accumulation_steps 个batch更新一次 accumulation_steps 4 optimizer.zero_grad() for i, (ref, dist, target) in enumerate(dataloader): # ... forward 和 loss 计算 ... loss loss / accumulation_steps # 先归一化 loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()逻辑说明loss / accumulation_steps是梯度累积的标准做法把每个小batch的梯度缩放到等效大batch的尺度避免梯度累计导致更新步长异常。这个写法对孪生网络特别有用因为它天然把单batch的显存需求翻倍。参数说明accumulation_steps4表示每4个小batch才更新一次权重训练实际effective batch size是4 × batch_size学习率可以相应调高一点但不要超过两倍否则收敛不稳定。5.4 加载预训练权重时报尺寸不匹配现象代码里models.resnet18(weights...)后修改了head但保存和加载模型时load_state_dict报size mismatch for head.0.weight之类的错误。原因PyTorch的load_state_dict默认要求key和shape完全匹配。你把ResNet的最后一层FC换掉了预训练权重里没有新head的参数反过来如果你保存了整个模型fine-tune时加载也会遇到head尺寸不一致的问题因为排序头和回归头可能结构不同。解决加载权重时加上strictFalse让缺失或不匹配的参数保持随机初始化。只保留匹配的预训练参数。pretrained models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model RankIQA_Siamese(baseresnet18) # 只复制卷积层权重跳过FC层 pretrained_dict pretrained.state_dict() model_dict model.state_dict() # 过滤掉形状不匹配的key pretrained_dict {k: v for k, v in pretrained_dict.items() if k in model_dict and model_dict[k].shape v.shape} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)逻辑说明这段代码用字典推导式把预训练权重中“key存在且shape一致”的参数筛选出来更新进网络当前权重。这样head层不会被错误赋值而卷积层的预训练权重能正确加载。相比strictFalse这种方式更安全——strictFalse会静默忽略所有缺失key一旦特征层名字写错也不会报错问题会被掩盖到训练阶段才暴露。参数说明如果你改动了backbone中间层的结构比如把layer4的stride改成1shape极可能大面积不匹配此时需要打印pretrained_dict和model_dict对比确认哪些层没加载成功。不要跳过这一步直接训练。5.5 预测分数集中在一个狭窄区间区分度不够现象阶段二微调完成后预测分数在3.4到3.6之间波动而真实MOS分数范围是0到5模型几乎把所有图都判成“中等质量”。原因这是L1Loss加少量标注数据的通病。当标注样本少、训练epoch多时模型学到的是“输出训练集MOS均值”这是一种最安全的回归策略——因为L1Loss在预测值接近中位数时总误差最小。排序阶段带来的区分能力没有传导到回归头可能因为回归头训练时学习率太高把排序头学到的相对分数差距抹平了。解决两个手段配合使用。第一回归微调前先把排序模型的输出做一次线性归一化让模型输出的分数范围映射到[0, 5]区间再用这个归一化后的分数初始化回归头的bias。第二降低学习率到1e-6回归头只训练5到10个epoch提前用验证集SROCC选模型不要傻跑完预设epoch。如果条件允许给回归训练数据加一张“质量极差图”和一张“质量极好图”作锚点能有效拉伸分数分布。6. 把RankIQA用到新场景分数校准与可复现性验证排序模型本质上输出的是一个相对分数它在不同数据集上的分布不同。换到新场景时不需要重新训练整个模型只需要在少量新场景图像上做一次分数校准。常见的校准做法是线性映射在新数据上采样几十张图像人工或基于算法给一个粗略排序然后按最小二乘拟合一条y ax b的线性映射把排序模型输出映射到目标评分区间。注意这里的采样图像要覆盖从极差到极好的全分布只取中间段会让拟合出的斜率失真。验证模型是否值得采纳我个人的习惯是计算SROCC和PLCC两个指标而不是只看单张图的预测误差。SROCC衡量排序一致性PLCC衡量线性相关性。RankIQA的典型优势是SROCC高但PLCC可能偏低因为排序学习不保证绝对分数线性。如果课程设计的汇报里被问到“为什么PLCC没那么高”可以直接说PLCC低说明回归校准还不够好可以通过增加校准样本或引入非线性映射比如sigmoid解决。复现性方面把随机种子固定是血泪经验。PyTorch的DataLoader如果不开shuffleFalse每次epoch的数据顺序都不同排序对的质量参数也是随机采样的这会导致训练结果难以稳定复现。需要在脚本开头固定所有随机源包括Python、NumPy和PyTorch的CUDDA后端。我一般把固定种子的代码写成一个函数每个训练脚本第一行就调用包括排序阶段和回归阶段都要固定否则微调的结果对不上就没法定位是参数问题还是随机问题。动手实验时先跑一个小的smoke test——用10张图、每个patch只裁剪一次、训练1个epoch确认前向和反向传播没问题再去跑完整的数据集。这样可以避免在花了两小时构建的完整数据集上发现一个低级错误。RankIQA的实现并不复杂但它的两阶段设计决定了出问题的地方往往在数据构造和阶段衔接上把上面五个坑避开这套方案是能稳定到达“课程设计高分项目”水平的。希望帮到你。本文还有配套的精品资源点击获取