简介:基于深度学习的图像美学质量评价系统完整Python实现,面向毕业设计、人工智能课程项目及图像质量研究初学者,解决如何通过深度学习模型对图像美学进行自动化评分、分类与排序的问题。资源共39个文件,压缩包仅346KB,主体为28个Python源码与6个Jupyter Notebook,另含QML界面文件、项目文档、HTML报告及JSON配置,分别覆盖模型定义、训练流程、数据预处理、人机交互界面和结果展示等环节。已有110人学习浏览,适合作为快速复现与二次开发的基础。工程代码按照datasets、models、ui、notebooks等模块清晰组织,支持AVA、AADB、CUHK-PQ等常用美学数据集,并集成CBAM注意力模块、损失函数与评估指标等关键实现;从数据预处理、模型训练、评估测试到界面交互均有对应脚本,notebook还提供数据集分析与观测过程,结合README和report.html可快速上手,便于逐模块理解深度学习美学评价的完整链路。
1. 图像美学质量评价:把“美不美”变成一套可复现的深度学习打分
图像美学是个非常主观的领域,同一张照片,有人觉得构图舒服,有人觉得光线刺眼,但偏偏这种玄学任务,在深度学习框架下反而能训练出与人工评分高度相关的模型。这套基于深度学习的图像美学质量评价系统是一份完整的 Python 源代码,覆盖数据预处理、模型训练、评估测试和可视化界面,不是只有模型文件的半成品。它适合正在做毕业设计、需要完整工程骨架的学生,也适合想进入图像美学评价方向的工程师。系统的主线很清晰:用 AVA 这类带评分分布的数据集训练卷积网络,输出从 1 分到 10 分的分布,再按期望值换算最终得分,既保留评分的模糊性,又让回归更稳定。拆这份资源时我印象最深的是它把 CBAM 注意力模块嵌进了主干网络,在有限数据上换来了一点涨点空间。下面按数据、模型、训练、排错、落地的顺序,把关键步骤和参数逐个讲清楚。
2. 数据先行:AVA、AADB、CUHK-PQ 怎么选、怎么吃进模型
2.1 三个数据集差异和选型理由
仓库里 datasets 目录下放了三个数据集的加载器:ava.py、aadb.py、cuhk_pq.py。我一开始有点困惑,为什么一个项目要同时兼容三套数据,跑通之后才明白,这三套数据恰好覆盖了三种典型的标注形态,对应三种不同的训练目标。
| 数据集 | 样本量 | 标注形式 | 适合的任务 |
|---|---|---|---|
| AVA | 约 25 万张 | 1 到 10 分的投票分布 | 分布预测、回归 |
| AADB | 约 1 万张 | 平均分加构图、光线等属性 | 多任务学习、属性解释 |
| CUHK-PQ | 约 1.7 万张 | 高质量/低质量二分类 | 分类基线、快速验证 |
主训练建议用 AVA,原因是它的评分分布信息量最大。模型输出的不是单个分数,而是每个分数档的投票比例,这样能告诉使用者“这张图 7 分的概率是 40%,5 分的概率是 25%”,而不是冰冷地丢一个 6.3。AADB 的定位是辅助,它自带属性标签,适合做多任务扩展,比如同时预测美学分数和构图质量。CUHK-PQ 则适合做快速冒烟测试,数据小、跑一轮很快,先把代码流程验证通了再上 AVA。
2.2 从 ava.txt 到训练列表:预处理脚本
AVA 数据集的标注文件是纯文本,每一行代表一张图,格式大致是:图像 ID,然后是语义标签编号、风格标签编号,接着才是 1 到 10 分各自的投票数,最后还有两个水印和安全标记位。新手最容易翻车的地方就在这里,容易把前两列的数字误当成评分列,导致标签整体错位,训练出来的模型预测结果和真实审美分布完全对不上。
项目里 process_ava.ipynb 这个 notebook 干的事情就是把 ava.txt 解析成干净的训练列表。我在复现时用同样的逻辑整理了一份脚本,核心代码如下:
import random def parse_ava(path): samples = [] with open(path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) < 12: continue image_id = parts[0] # 第 3 到第 12 列才是 1~10 分的投票数 distribution = [int(x) for x in parts[2:12]] samples.append((image_id, distribution)) return samples def split_and_dump(samples, val_ratio=0.1, seed=42): random.seed(seed) random.shuffle(samples) val_cnt = int(len(samples) * val_ratio) train, val = samples[val_cnt:], samples[:val_cnt] for name, subset in [('train_list.csv', train), ('val_list.csv', val)]: with open(name, 'w') as f: for img_id, dist in subset: dist_str = ','.join(map(str, dist)) f.write(f'{img_id}|{dist_str}\n') if __name__ == '__main__': data = parse_ava('ava.txt') split_and_dump(data)代码逻辑说明:parse_ava 按空格切分每一行,前两个数字是语义和风格标签,所以取 parts[2:12] 作为评分分布。过滤条件 len(parts) < 12 是为了跳过残缺行,这类坏行在网上下载的标注文件里经常出现,不过滤会让后面的数据集对齐直接崩掉。split_and_dump 按 9:1 划分训练和验证集,把结果写成 CSV 格式,每个字段用竖线分隔,这样后续 dataset.py 读取时不容易和打分里的逗号混淆。
参数说明:val_ratio 取 0.1 是经验值,25 万张图留 2.5 万张做验证足够稳定。如果你机器显存小、训练集加载慢,可以适当缩到 0.05,但验证集太小会导致 SRCC 指标抖动很厉害,不建议低于 0.05。seed 固定是为了可复现,换数据集时记得改一个不同值,否则随机打乱的顺序每次都一样,反而不利于后续做交叉验证实验。
2.3 图像送入网络前的尺寸处理和标准化
数据集列表准备好之后,下一步是图像加载。项目里 dataset.py 和 utils.py 主要就做两件事:把原始图片读进来,转成模型需要的张量格式。主干网络用的是 ResNet 结构,输入尺寸是 224x224。我见过不少人在这个尺寸上偷懒直接缩放,结果训练集和验证集都用同样方式缩放,模型很容易记住缩放痕迹而不是美学特征。
import torch from torchvision import transforms from PIL import Image train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def load_image_into_tensor(path, transform): img = Image.open(path).convert('RGB') return transform(img).unsqueeze(0)代码逻辑说明:先缩放到 256,再随机裁剪 224,这是 ImageNet 时代最经典的做法。直接 Resize(224) 虽然省事,但会把图像的全局比例破坏掉,裁剪到 224 能保留更多局部结构,同时给模型提供平移不变性。RandomHorizontalFlip 对美学任务影响不大,风景和建筑构图在水平翻转后依然合理,但如果你在做人像美学评价,翻转后人脸朝向变化可能有副作用,建议关掉。
参数说明:Fill 填充值、裁剪范围这些细节我没有写进代码,因为它们对最终指标影响不大,真正影响大的是 ColorJitter 的幅度。美学评价本身对色彩敏感,亮度调到 0.2 已经是上限,再大会让模型把过曝和欠曝当成正常美学特征,评分会出现系统性偏移。Normalize 用 ImageNet 的 mean 和 std,这是预训练模型的默认配置,不用改,改了反而会让预训练权重失效。
2.4 踩坑:AADB 图片名对不上
AADB 数据集的文件命名比 AVA 混乱得多,notebooks 里的 process_aadb.ipynb 处理的就是这个问题。常见情况是标注 CSV 里的文件名和实际图片文件不完全一致,有后缀差异也有路径层级差异。我一般会在加载器里加一个文件名清洗函数,把扩展名去掉、统一小写,再和目录里的实际文件名做映射。这个步骤不做,训练集加载时会有 30% 以上的图找不到,程序通常不会直接报错,而是静默跳过,最后你会发现 loss 曲线忽高忽低,但怎么查都查不出问题。
3. 核心模型:主干网络加 CBAM 注意力,把美学评分做成分布预测
3.1 为什么输出分布而不是直接输出分数
美学评分最常见的翻车方式是当作回归任务直接预测一个浮点数,然后用 L2 损失训练。表面上损失在收敛,实际预测结果往往集中在 5 到 6 分之间,因为回归模型倾向于学习到均值附近,把高分和低分都拉向中间。这也是 AVA 数据集的特殊性决定的,每张图都有多个人的打分,本身就是分布形态。
项目里的 model.py 把最后一层改成输出 10 维向量,经过 softmax 得到每个分数档的概率,再用期望值计算最终得分。这样做的好处有三个:一是保留了人为评分的多样性,模型可以学出“这张图有争议”这类信息;二是损失函数在分布层面做比较,梯度更平滑;三是评测时可以同时看准确率和相关性,指标更丰富。常见做法是把主干网络的 Global Average Pooling 后面的全连接层换成两层,中间接一个 BN 和 ReLU,最后一层输出 10 个节点。
3.2 CBAM 注意力模块的作用和插入位置
仓库里 models/cbam.py 实现的是 CBAM,也就是 Convolutional Block Attention Module。它的核心思想是先在通道维度上做注意力,告诉网络哪些特征通道更重要,再在空间维度上做注意力,告诉网络图像的哪些区域更重要。对于美学任务来说,空间注意力尤其关键,比如一张人像照片,脸部区域的美学权重明显高于背景的天空,空间注意力能让模型自动学会关注这些区域。
CBAM 的插入位置很讲究,我一般只加在最后两个残差块后面,而不是每个 stage 都塞。加得太多,参数量上去了,训练速度变慢,偶尔还会掉点;加得少,模型无法在高层语义特征上做有效筛选。项目里采用的做法是把 CBAM 模块插入到 ResNet 后两个 block 的输出处,这是一个收益和开销均衡的位置选择。
3.3 EMD 损失:分布距离怎么算
训练分布预测模型时,项目里用的是 EMD 损失,也就是 Earth Mover's Distance,中文叫推土机距离。它衡量的是把预测分布变成真实分布所需要移动的最小工作量。相比 KL 散度,EMD 对分数档位之间的距离更敏感,比如预测 7 分的概率很高但真实是 6 分,这时 EMD 的惩罚会相对小一些,而 KL 散度会把这种相邻档位的误差当成完全错误来惩罚,不利于美学评分这种相邻档位本就模糊的任务。
import torch import torch.nn as nn class EMDLoss(nn.Module): def __init__(self, num_bins=10): super().__init__() self.num_bins = num_bins def forward(self, pred, target): # pred: [batch, 10] 已经过 softmax # target: [batch, 10] 真实分布 pred_cdf = torch.cumsum(pred, dim=1) target_cdf = torch.cumsum(target, dim=1) # 每一档的累计分布差值的绝对值求和取平均 emd = torch.mean(torch.abs(pred_cdf - target_cdf)) return emd代码逻辑说明:累计分布差值的绝对值求和,实际上就是推土机距离在离散分布下的一种简化形式。前面三行看似简单,清空的坑在于 pred 必须是 softmax 输出,不能直接拿全连接层的 logits 进来算,否则 cumsum 出来的值没有一点概率意义。target 也必须是归一化后的分布,AVA 的原始投票数是频次,需要先除以总数,这一步经常被忽略。
参数说明:num_bins 取 10 对应 1 到 10 分的分数档。如果你的数据集只有 5 档分数,要把这个参数改成 5,同时模型最后一层的输出维度也要同步修改,否则会在 cumsum 时维度不匹配。loss 的数值量级在 0 到 1 之间,训练时一般不需要额外加权。
3.4 最终分数的换算
训练结束后,实际部署时我们需要一个直观的分数,而不是一组分布。换算方式就是按概率做加权期望,代码如下:
import torch def distribution_to_score(prob, device='cuda'): # prob: [batch, 10] 概率分布 bins = torch.arange(1, 11, dtype=torch.float32, device=device) scores = torch.sum(prob * bins, dim=1) return scores def score_to_grade(score): # 项目里把 1~4 分归为差,5~7 归为中,8~10 归为好 if score >= 8.0: return 'good' elif score >= 5.0: return 'medium' else: return 'bad'参数说明:bins 的起点是 1 而不是 0,因为 AVA 的评分体系就是 1 到 10 分。score_to_grade 的阈值是项目里 report 模块用的一套标准,你可以按业务需求调整,比如只关心高分精品图,就把 good 的阈值推到 8.5。注意这里 score 是浮点数,分布极端的两张图可能得到相同期望值,如果业务上需要区分,最好同时保留分布信息。
4. 训练与评估:配置项、训练循环和指标解析
4.1 config.json 里的关键参数
项目根目录下有一个 config.json,这是整个系统的总配置入口。我拆过的不少开源项目把超参数散落在代码各处,改起来非常痛苦,这份资源把能用 JSON 表达的参数都收拢到了一起,训练前只需要改这个文件。
| 参数名 | 取值示例 | 作用 |
|---|---|---|
| data_root | /data/ava_images | 图片存放根目录 |
| train_list | train_list.csv | 训练集列表路径 |
| val_list | val_list.csv | 验证集列表路径 |
| batch_size | 64 | 单次送入 GPU 的样本数 |
| epochs | 30 | 最大训练轮数 |
| lr | 1e-4 | Adam 初始学习率 |
| weight_decay | 1e-5 | 权重衰减系数 |
| backbone | resnet50 | 主干网络类型 |
| num_workers | 8 | 数据加载线程数 |
| device | cuda:0 | 训练设备 |
这里我重点讲几个容易出问题的参数。batch_size 在单卡 11GB 显存下,ResNet50 输入 224x224,设置为 64 刚刚好,如果用 16GB 显存可以开到 128 提速。num_workers 建议和 CPU 核心数匹配,但如果你在 Windows 下跑,num_workers 超过 0 有时会触发多进程启动异常,设置成 0 虽然慢一点,但胜在稳定。lr 用 1e-4 是 Adam 配合预训练权重时的保守选择,从头训练建议调到 3e-4。
4.2 从 main.py 入口跑一次训练
项目里 train.py 负责读配置,main.py 负责组装模型和启动训练。训练流程是标准的:加载预训练权重、替换最后一层、初始化 trainer、按 epoch 循环。trainers.py 里保存了每个 epoch 的 checkpoint,包含模型权重和优化器状态。
python train.py --config config.json我自己在复现时的习惯是先跑一个 5 epoch 的短训练,确认 loss 在下降、验证集指标在波动,再开完整训练。这个动作在项目里可以通过在 config.json 里临时修改 epochs 实现,不用改代码。训练日志会输出到终端,内容包括每个 epoch 的平均损失、SRCC、预测准确率,同时 trainers.py 会把指标写入跑批目录下的 txt 文件。
# 伪代码展示 trainer 的核心循环,实际实现参考 trainers.py for epoch in range(epochs): model.train() train_loss = 0.0 for batch in train_loader: images, dists = batch images = images.to(device) dists = dists.to(device) pred = model(images) loss = criterion(pred, dists) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() val_srcc, val_acc = evaluate(model, val_loader) scheduler.step()参数说明:scheduler 在项目里用的是多步衰减,每 10 个 epoch 学习率乘 0.1。如果你把 epochs 缩短到 5,scheduler 永远触发不到,所以短训练只用来排错,不能作为最终实验结果。optimizer 用的是 Adam,beta 默认值就好,weight_decay 设 1e-5 是防止高分区过拟合的一个折中值,太大会让模型欠拟合。
4.3 评估指标:SRCC 和分类准确率怎么看
metrics.py 里封装了两个核心指标,一个是 SRCC,也就是 Spearman 秩相关系数,另一个是预测分布和真实分布的准确率。SRCC 衡量的是排序一致性,不关心具体分数差多少,只关心两张图相对谁高谁低,这一点和美学评价的本质高度契合。
from scipy.stats import spearmanr from sklearn.metrics import accuracy_score def compute_srcc(pred_scores, true_scores): # 期望分数和真实平均分 srcc, _ = spearmanr(pred_scores, true_scores) return srcc def compute_distribution_acc(pred_dist, true_dist): # 每张图取概率最高的档位作为预测档位 pred_label = pred_dist.argmax(dim=1) true_label = true_dist.argmax(dim=1) return accuracy_score(true_label.cpu(), pred_label.cpu())代码逻辑说明:SRCC 的计算不需要手动实现排序比较,scipy 的 spearmanr 直接搞定。distribution_acc 的作用是看模型的峰值概率是否能命中真实分布的最高档位,这个指标在论文里常用,但在业务里参考价值有限,因为美学评分的相邻档位本来就模糊。真正实用的是 SRCC,一般在 AVA 测试集上能到 0.6 到 0.7 之间就算不错的基线。
注意这里 pred_scores 必须先经过 distribution_to_score 换算,不能用分布原始值直接算相关性,否则返回值没有意义。真实分数是投票分布的加权平均,数据集在预处理时就要算好,不要放到评估阶段重复计算,容易出错。
5. 避坑:我复现时踩过的五个典型问题
5.1 Loss 在降但 SRCC 是负的
现象:训练了几个 epoch,EMD loss 一路下降,但验证集上的 SRCC 却是负值,模型排名能力比随机还差。
原因:AVA 的 ava.txt 列序理解错误。我之前在解析时把语义标签列当成了评分列,模型学到的是一个和美学无关的分布。loss 能下降是因为它拟合了一个固定模式的噪声分布,排名自然没有意义。
解决:回到 parse_ava,确保只取 parts[2:12],也就是跳过前两个标签列。建议写一个单元测试,固定输入三行样本,断言 distribution 的总和等于该图的投票人数,这是最直接的自检方式。test_dataset.py 里已经有类似的测试用例,跑一遍能提前发现问题。
5.2 显存溢出,改小 batch_size 后指标波动变大
现象:batch_size 从 64 降到 16 后,训练不溢出了,但每个 epoch 的验证指标跳来跳去,很不稳定。
原因:batch_size 变小,每个 step 的梯度估计噪声变大,BN 层的统计量也变差,最后导致验证集上的评估方差变大。
解决:不要只降 batch_size,要同步调整学习率。常见做法是学习率按 batch_size 比例缩放,64 配 1e-4,32 配 5e-5,16 配 2.5e-5。另外可以把梯度累积步数设成 4,模拟 64 的 batch_size。项目里 trainers.py 支持累积梯度开关,开启后显存占用没变,但训练稳定性恢复很多。
5.3 QML 界面上图片无法显示
现象:UI 能启动,评分也能算出来,但界面上的图片区域是空白的,控制台也没有报错。
原因:main.qml 里用的图片路径是相对路径,从命令行启动时工作目录不对,图片加载失败。这类问题在 Qt 的 Image 组件里很常见,相对路径在 UI 项目里就是一颗定时炸弹。
解决:把图片路径在 Python 侧转换成绝对路径,再传给 QML。做法是在 context.py 里调用 os.path.abspath 提前解析,或者在 QML 里改用 file:// 前缀拼接绝对路径。我从那以后所有演示项目的图片加载都强制用绝对路径。
5.4 验证集表现很好,测试集一塌糊涂
现象:验证集上 SRCC 到 0.65,换到另一批测试图后直接崩到 0.5 以下。
原因:AVA 划分验证集时没有按摄影作品分组,同一组系列照片可能同时出现在训练集和验证集。模型实际是记住了摄影师风格,而不是美学规律。
解决:按图集分组切分,一个系列的照片全部进同一侧。较快的做法是看图片 ID 的路径前缀,把同名目录下所有图划到一起。代价是训练集变小,模型收敛变慢,但泛化能力明显改善。
5.5 PyTorch CUDA 版本和驱动不匹配
现象:import torch 正常,但第一次把张量搬到 GPU 时报错,提示 CUDA driver version is insufficient。
原因:pip 安装的 PyTorch 默认带 CUDA 12.x 运行库,而我机器的驱动只支持 CUDA 11.x。
解决:先去 NVIDIA 官网查显卡算力和驱动支持版本,再到 PyTorch 官网用对应版本的安装命令重装。优先推荐装 CPU 版本做功能验证,确认代码没逻辑问题后再换 GPU 版本,省得两小时都在解决环境问题。
6. 进阶落地:用 QML 界面展示评分分布,把训练结果导出成 HTML 报表
训练完成的模型最终要给非技术背景的人使用,或者自己快速预览一批图的打分情况。项目里的 ui 目录用 PyQt 加 QML 做了一套简单的可视化界面,main.qml 负责界面布局,context.py 负责把 Python 侧的推理结果暴露给 QML。核心交互是选择一张图片,界面显示预测的平均分和各分数档的概率分布柱状图。
# ui/context.py 中暴露给 QML 的调用示例 import os from PyQt5.QtCore import QUrl from PyQt5.QtQuick import QQuickView def show_image_with_score(view, image_path, score): abs_path = os.path.abspath(image_path) view.rootContext().setContextProperty('currentImage', QUrl.fromLocalFile(abs_path)) view.rootContext().setContextProperty('scoreText', f'Score: {score:.2f}')参数说明:QUrl.fromLocalFile 是必须的,它能把本地绝对路径转成 QML Image 组件认识的 file:// 形式,避免相对路径问题。scoreText 用 f-string 保留了两位小数,界面上如果能同时显示分布柱状图,建议把分布数组一次性传给 QML,让前端用 ListView 渲染,效率比逐条赋值高得多。
报表输出方面,项目里 outputs 目录会生成一个 report.html,trainers.py 在每个 epoch 后把当前模型的验证指标插入到 HTML 模板中。这个做法的实用价值在于,训练后不用再开 TensorBoard,直接用浏览器打开 HTML 就能回顾整个训练过程。我习惯在 report.html 里额外记录每个 epoch 的预测示例图和真实分数,这样后期排查问题时会方便很多。
如果你需要批量评价一批图片,不要在 Python 脚本里一张张调用 model.forward,先把所有图路径读进列表,一次性构造一个 batch,按 32 或者 64 张一组送入 GPU。批量推理不仅能提高吞吐量,还能减少 CPU 和 GPU 之间的数据拷贝次数。评价结果可以写成 CSV,字段包括图片路径、预测分数、等级,后面接一个简单的排序脚本,就能输出一批图的好坏排行榜。
这个项目我第一次跑的时候,光数据集对齐就折腾了一个晚上,原因就是 ava.txt 的列序理解错了。从那以后我每次接手新的数据集,都会先打印几行样本,确认字段含义和取值范围,再写加载器。做图像美学评价这类标注形态多样的任务,数据端的谨慎比模型端的调参更能决定最终效果。希望这篇拆解能帮你把这份源码顺利跑通。
本文还有配套的精品资源,点击获取