☰
基于图像识别的古代艺术品朝代预测:从数据到模型实战
2026/10/5 4:28:56 网站建设 项目流程

简介:这份文档面向具备机器学习与计算机视觉基础的学生和研究者,围绕2024年秋季机器学习期末竞赛,讲解如何用图像分类技术预测中国古代书法与绘画作品的朝代。赛题数据集含3600张512×512图像,覆盖唐、宋、元、明、清及AI生成六类,其中900张为AI生成图,且训练集标签存在噪声与类别不均衡,文档据此强调数据清洗与预处理的关键作用。资源包共1个docx文件,约554KB,内容涵盖任务定义、评估指标、提交规则与时间节点等完整赛题说明。评估采用总体准确率、非AI类别F1、AI类别F1与权益共享评分的加权组合,并鼓励解决长尾难题。读者可借此掌握文物鉴定场景下的建模流程、噪声标签处理思路与竞赛排名策略,提升实际项目操作能力。目前已有67人学习。

1. 从一堆瓶瓶罐罐里认出朝代:图像识别做古代艺术品断代,到底能不能落地

你手里有一批博物馆公开的陶瓷、青铜、书画照片,标签是朝代——唐、宋、元、明、清。现在要训练一个模型,给它一张没见过的器物图,让它输出朝代。这件事听起来像典型的图像分类,但真正动手你会发现,它和猫狗分类完全不是一个难度:类间差异可能只是釉色深浅、器型弧度、纹饰密度,而类内差异却大到离谱——同样是宋代,汝窑和钧窑的颜色分布几乎不在一个色域。更麻烦的是数据量。公开可用的、带可靠朝代标签的古代艺术品图像,清洗完之后往往只有几千张,分到五个朝代,每类几百张,这对深度网络来说属于小样本场景。所以这篇文章不聊虚的,就围绕「基于图像识别的中国古代艺术品朝代预测」这条线,把数据怎么整、模型怎么选、参数怎么调、坑在哪,一步步拆开讲清楚。适合有 Python 基础、做过简单图像分类、想把这个方向当课程设计或比赛题目的读者。

2. 数据先行:古代艺术品图像的采集、清洗与标签体系

2.1 为什么这个任务的数据比模型更决定上限

图像识别项目里,数据质量决定天花板,模型只是逼近这个天花板。朝代预测尤其如此,因为标签本身带有模糊性。一件器物可能横跨两个朝代,比如五代十国到北宋早期,窑口延续、风格渐变,你让标注者硬选一个,不同人给出的答案可能不一致。常见做法是:优先选博物馆官方断代明确的藏品,官方写「北宋」就标北宋,写「宋」就归到宋,不要自己二次推断。另一个现实问题是类别不平衡。唐代瓷器存世量相对少,清代离得近、存世量大,直接按原始分布训练,模型会偏向预测清代。我一般会在划分数据集之前先统计每类数量,如果最大类与最小类比例超过 3:1,就考虑对少数类做定向增强,或者用加权采样。这里要提醒一句:不要用随机过采样复制图片,那只会让模型记住同一张图,验证集指标虚高,测试集直接翻车。

2.2 从原始图片到可训练数据集:目录结构与划分脚本

假设你已经从公开渠道收集了一批图片,按朝代放在不同文件夹里。下一步是统一尺寸、去除损坏文件、按类别分层划分训练集和验证集。下面这段脚本做三件事:遍历每个朝代文件夹、校验图片可读性、按 8:2 分层切分并复制到目标目录。

import os import shutil import random from PIL import Image # 原始数据目录:每个子文件夹是一个朝代 RAW_DIR = "raw_data" # 输出目录 OUT_DIR = "dataset" TRAIN_RATIO = 0.8 random.seed(42) dynasties = [d for d in os.listdir(RAW_DIR) if os.path.isdir(os.path.join(RAW_DIR, d))] for dynasty in dynasties: src_dir = os.path.join(RAW_DIR, dynasty) images = [] for fname in os.listdir(src_dir): fpath = os.path.join(src_dir, fname) try: # 校验图片完整性,损坏文件直接跳过 with Image.open(fpath) as img: img.verify() images.append(fname) except Exception: print(f"损坏文件跳过: {fpath}") random.shuffle(images) split = int(len(images) * TRAIN_RATIO) train_imgs = images[:split] val_imgs = images[split:] for subset, files in [("train", train_imgs), ("val", val_imgs)]: dst_dir = os.path.join(OUT_DIR, subset, dynasty) os.makedirs(dst_dir, exist_ok=True) for fname in files: shutil.copy(os.path.join(src_dir, fname), os.path.join(dst_dir, fname)) print(f"{dynasty}: 训练 {len(train_imgs)} 张, 验证 {len(val_imgs)} 张")

这段脚本的关键点有三个。第一,Image.verify()能提前筛掉下载不完整或格式损坏的图片,避免训练时 DataLoader 直接报错中断。第二,random.seed(42)保证每次划分结果一致,方便复现实验。第三,按类别分别划分而不是全量混洗,能保证每个朝代在训练集和验证集中的比例一致,这对小样本分类很重要。参数方面,TRAIN_RATIO设为 0.8 是常规起点,如果某类总数少于 200 张,建议改成 0.7,给验证集留出足够样本,否则验证指标波动会很大。

2.3 标签体系与类别映射:别让「宋」和「南宋」打架

朝代标签的粒度需要提前定死。常见做法是粗分到「唐、宋、元、明、清」五类,把南宋北宋合并为宋,把五代十国归入唐或宋的过渡期,具体归哪边取决于你的数据构成。如果你收集到的数据里南宋北宋都有且数量不少,也可以分成六类或七类,但类别越多,每类样本越少,模型越难学。我一般会先做一个类别分布表,看看每个粒度的样本量是否支撑训练。映射关系建议写成一个 JSON 文件,训练和推理共用同一份,避免训练时用一套标签、推理时用另一套,导致输出对不上。

import json label_map = { "tang": 0, "song": 1, "yuan": 2, "ming": 3, "qing": 4 } with open("label_map.json", "w", encoding="utf-8") as f: json.dump(label_map, f, ensure_ascii=False, indent=2) # 推理时加载 with open("label_map.json", "r", encoding="utf-8") as f: loaded = json.load(f) idx_to_label = {v: k for k, v in loaded.items()} print(idx_to_label)

这份映射文件看起来简单,但它是训练和部署之间的契约。曾经有一次我在训练脚本里按文件夹名称字母序生成标签,推理脚本里按手写字典映射,结果「明」和「清」的顺序反了,离线指标正常,上线全错。血泪经验就是:标签映射只维护一份,所有环节都读它。

3. 模型选型与训练:从 ResNet 到 EfficientNet 的朝代分类实战

3.1 小样本图像分类,为什么我优先选预训练模型微调

从零训练一个卷积网络需要大量数据,而古代艺术品朝代预测的数据量通常撑不起来。预训练模型在 ImageNet 上已经学到了边缘、纹理、颜色分布等底层特征,这些特征对器物图像同样有效。常见做法是加载在 ImageNet 上预训练的 ResNet50 或 EfficientNet-B0,替换最后的全连接层,输出改为你的朝代类别数,然后分阶段微调。第一阶段冻结主干,只训练分类头,学习率设 1e-3;第二阶段解冻全部层,用更小的学习率 1e-4 到 1e-5 做全局微调。这样做的原因是:随机初始化的分类头一开始梯度很大,如果直接全局微调,会把预训练学到的特征打乱。先让分类头收敛,再小学习率调整主干,是更稳的路径。EfficientNet-B0 参数量比 ResNet50 小很多,在数据量几千张的场景下,过拟合风险更低,推理速度也更快,适合课程设计或比赛提交。

3.2 用 PyTorch 搭一个可复现的训练流程

下面这段代码用 torchvision 加载预训练 EfficientNet-B0,替换分类头,定义数据增强和训练循环。数据增强只对训练集做,验证集只做 resize 和归一化。

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms, models # 数据增强:训练集用随机裁剪和翻转,验证集只做确定性变换 train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_ds = datasets.ImageFolder("dataset/train", transform=train_tf) val_ds = datasets.ImageFolder("dataset/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) num_classes = len(train_ds.classes) model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1) # 替换分类头 model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) model = model.cuda() criterion = nn.CrossEntropyLoss() # 第一阶段只训练分类头 for param in model.features.parameters(): param.requires_grad = False optimizer = torch.optim.Adam(model.classifier.parameters(), lr=1e-3) for epoch in range(5): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() print(f"阶段一 epoch {epoch+1} 完成") # 第二阶段全局微调 for param in model.parameters(): param.requires_grad = True optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for epoch in range(20): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"阶段二 epoch {epoch+1}, 验证准确率 {correct/total:.4f}")

逻辑说明:第一阶段冻结model.features,只更新model.classifier,让随机初始化的分类头先找到合理方向。第二阶段解冻全部参数,学习率降到 1e-4,避免破坏预训练特征。RandomResizedCrop的scale=(0.7, 1.0)表示随机裁剪原图 70% 到 100% 的区域再缩放到 224,这个范围对器物图像比较合适,裁太狠会丢掉器型整体信息。ColorJitter的强度设 0.2 是保守值,因为朝代判断有时依赖釉色,颜色扰动太强会引入噪声。batch_size=32在显存 8GB 以上基本够用,如果显存不足降到 16,同时把学习率按比例降到 5e-5。验证准确率每轮打印,如果连续 5 轮不升,可以考虑提前停止。

3.3 学习率、批大小与早停:三个最容易被忽视的参数

学习率是训练中最玄学的参数。阶段一用 1e-3 是因为只训练分类头,参数量少,大一点收敛快。阶段二用 1e-4 是全局微调的安全值,如果你发现验证 loss 震荡剧烈,降到 5e-5。批大小影响梯度估计的稳定性,小批量噪声大但泛化可能更好,大批量训练快但容易陷入尖锐极小值。我一般从 32 开始试,如果验证指标波动超过 3 个百分点,降到 16。早停策略建议监控验证集准确率,连续 7 轮不提升就停,同时保存验证集最优的模型权重,而不是最后一轮的权重。很多人忽略这一点,训练完直接用最后一个 epoch 的模型,结果比最优轮次差好几个点。另外,类别不平衡时可以在CrossEntropyLoss里传weight参数,按类别样本数的倒数加权,让少数类样本的损失贡献更大。

4. 避坑与排查:朝代预测项目里最容易翻车的五个地方

4.1 验证集准确率很高,测试集一塌糊涂

现象:训练时验证集准确率到 85%,提交测试集只有 50% 多。原因通常有两个:一是数据泄漏,同一件器物的多角度照片被分到了训练集和验证集,模型记住了这件器物而不是朝代特征;二是验证集和测试集分布不一致,比如验证集里清代样本多,测试集里唐代样本多。解决方法是按器物 ID 划分数据集,同一件器物的所有照片只出现在一个子集里。如果原始数据没有器物 ID,可以用图片感知哈希做去重,把相似度极高的图片归为一组再划分。

4.2 模型把所有样本都预测成数量最多的那个朝代

现象:混淆矩阵显示清代召回率接近 1,其他朝代接近 0。原因就是类别不平衡,模型发现全猜清代也能拿到不低的准确率。解决方法是加权损失或重采样。加权损失更简单,在CrossEntropyLoss里传入weight=torch.tensor([1.0, 1.0, 1.5, 1.2, 0.8])这样的权重,具体数值按类别频率的倒数调整。重采样可以用WeightedRandomSampler,让每个 batch 里各类别比例接近均衡。注意不要对验证集做重采样,验证集要保持真实分布才能反映实际性能。

4.3 图片背景干扰导致模型学偏

现象:模型在博物馆展柜照片上表现好,在去掉背景的器物图上表现差。原因是训练数据里很多图片背景是展柜、标签、灯光,模型把背景特征当成了朝代线索。解决方法是做前景分割或背景替换。简单做法是用 GrabCut 或基于颜色的分割把器物区域抠出来,贴到纯色背景上再训练。更彻底的做法是收集同一器物在不同背景下的照片,让模型无法依赖背景。如果时间有限,至少要在数据增强里加入随机背景替换,强迫模型关注器物本身。

4.4 训练 loss 不下降或者变成 NaN

现象:训练几个 batch 后 loss 变成 NaN。常见原因是学习率太大、数据归一化参数不对、或者输入图片有损坏导致异常值。先检查归一化:ImageNet 的 mean 和 std 是固定值,如果你自己算了一组但算错了,输入分布偏移会导致梯度爆炸。再检查学习率,阶段二如果误用了 1e-3,很容易 NaN。解决方法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),同时把学习率降一个数量级。另外,num_workers设太大在某些系统上会导致数据读取异常,可以先设 0 排查是否是数据加载问题。

4.5 推理时预处理和训练时不一致

现象:离线验证准确率 80%,部署后推理结果乱七八糟。原因往往是推理时的 resize、归一化、通道顺序和训练时不一致。比如训练用了 RGB,推理时 OpenCV 默认读进来是 BGR;训练用了Resize((224,224)),推理时用了Resize(256)再中心裁剪。解决方法是把预处理写成一个函数,训练和推理共用。下面是一个推理预处理的示例,确保和训练验证集变换完全一致。

from PIL import Image import torch from torchvision import transforms infer_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def predict(image_path, model, idx_to_label): img = Image.open(image_path).convert("RGB") # 强制 RGB,避免 BGR 问题 tensor = infer_tf(img).unsqueeze(0).cuda() model.eval() with torch.no_grad(): logits = model(tensor) pred = logits.argmax(dim=1).item() return idx_to_label[pred]

这段代码里convert("RGB")是关键,不管原图是什么模式,统一转成 RGB,和训练时 ImageFolder 读进来的格式对齐。unsqueeze(0)增加 batch 维度。推理时不要做随机增强,只用确定性的 resize 和归一化。

5. 把准确率再往上推一推:两个我常用的进阶技巧

第一个技巧是测试时增强。训练完之后,对同一张测试图片做多次不同变换——比如原始尺寸、水平翻转、轻微裁剪——分别推理,然后把各类别概率平均,取最大概率对应的朝代。这个方法几乎不增加训练成本,但在我做过的几个器物分类任务里,稳定带来 2 到 4 个百分点的提升。代码实现很简单:定义一个变换列表,循环推理,累加 softmax 输出。

import torch.nn.functional as F tta_transforms = [ transforms.Compose([transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]), transforms.Compose([transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=1.0), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]), transforms.Compose([transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]), ] def predict_tta(image_path, model, idx_to_label): img = Image.open(image_path).convert("RGB") probs = torch.zeros(1, len(idx_to_label)).cuda() model.eval() with torch.no_grad(): for tf in tta_transforms: tensor = tf(img).unsqueeze(0).cuda() probs += F.softmax(model(tensor), dim=1) pred = probs.argmax(dim=1).item() return idx_to_label[pred]

第二个技巧是混淆类别定向补数据。训练完第一版模型后,看混淆矩阵,找出最容易混的两个朝代,比如宋和元。然后针对这两个类别,定向收集更多区分性样本,或者请标注者重新检查这两类的标签是否有误。很多时候不是模型不行,而是宋元之间的标签本身就有争议。我一般会把这个步骤放在第一版模型跑完之后,用数据去补模型的短板,而不是盲目换更大的网络。换网络带来的提升往往不如补几百张高质量样本。

最后说一个我自己的习惯:每次实验都记录数据版本、模型权重、学习率、批大小和验证集指标,写在一个 CSV 里。朝代预测这类任务,随机种子、数据划分、增强强度都会影响结果,没有记录就无法复现,也无法判断到底是模型改进了还是运气好。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询