做这个野生保护动物识别项目之前,我一直觉得图像识别离现实很远,觉得那只是论文里的Benchmark和排行榜上的数字。直到某次看到一个保护区巡护员的工作日常,才发现他们的物种监测大部分还是靠肉眼一格格翻照片,成堆的野外相机数据堆在硬盘里,真正被利用起来的少得可怜。那一瞬间我突然想通了一个事:深度学习、卷积神经网络不应该只活在比赛里,它更值得变成一个能直接跑起来的工具。
于是就有了这个项目:基于 ResNet50、ResNet34、VGG16 三种经典卷积神经网络,用 Python 完成图像识别,再通过 Django 封装成网页端识别系统,最终把保护动物识别这件事做成了一套“上传图片,立刻出结果”的 Web 应用。项目配套了完整技术文档,从数据处理、模型训练到系统部署都有逐条记录。如果你正在做图像识别相关的课程设计、毕业设计,或者你只是想知道一套 CNN 识别应用从零到一到底怎么落地,这篇内容可以直接作为参考。
1. 项目整体思路与方案设计
1.1 核心需求与选题动机
先聊为什么非要做这个项目。野生动物保护领域长期存在一个很尴尬的痛点:图像数据量越来越大,但分析效率完全跟不上。红外触发相机放在野外,一拍就是几万张,里面可能只有几百张是真的捕捉到了目标物种,其余全是风吹草动、空镜头、或者是人类活动的画面。巡护员如果一张张人工筛选,一个批次的数据就能耗掉好几天,更不要提识别哪些动物属于保护物种、哪些个体来自不同种群这类更复杂的判断。
传统图像处理手段对这种场景基本无能为力。早些年有人试着用颜色直方图、SIFT特征点做识别,结果放到野外真实环境里就崩了:光线一变、角度一歪、背景一杂乱,特征就完全对不上。而卷积神经网络天然适合这类任务,它能从大量标注好的图片中自动学出“这个物种的纹理、轮廓、体态大概长什么样”,而不是靠工程师手工设计特征。也就是说,只要数据足够,它就能应对野外图片里常见的遮挡、模糊、光照变化。
所以这个项目的核心需求可以拆成三条:第一,做一个能判别保护动物物种的模型;第二,把模型封装成普通用户也能操作的界面,不能指望巡护员去跑代码;第三,整个流程要有技术文档支撑,让项目能复现、能继续扩展。需求清楚了,方案就跟着明确:用 Python 做模型训练和推理,用 Django 做 Web 外壳,把深度学习模型嵌进 Web 请求处理链路里。
1.2 技术选型与对比
模型这块,我一开始就没打算用最新、最花哨的网络结构,而是刻意选了 ResNet50、ResNet34、VGG16 这三款经典网络。原因很直接:这类项目最看重的是可复现性和大规模应用时的稳定性,经典模型不仅结构成熟、预训练权重好找,而且各种部署方案都被踩过无数遍,出了问题好排查。
框架选了 PyTorch,理由不多说,动态图在调试训练流程时真的能省不少命。TorchVision 里直接带预训练权重,ResNet 和 VGG 都有现成的入口,一行代码就能加载 ImageNet 上训好的参数。如果用 TensorFlow,也不是不行,但生态和写法就比较绕,尤其在自定义数据加载和后续部署上,个人体感 PyTorch 更顺手。
后端框架选了 Django,这是深思熟虑过的。识别系统不是只做一个接口就完事,我们还需要一个简单的管理后台、一套能展示结果的前端页面、以及后续给不同用户分配任务权限的可能。Django 自带 Admin 后台和 ORM,文件上传处理也很成熟,虽然相比 FastAPI 它更重一些,但实际写起来反而省心,尤其是模板渲染和表单处理这种 Web 基础能力,不用额外造轮子。
下面是三种网络模型的核心对比,对一个以迁移学习为主的项目来说,这种表足够支撑选型判断:
| 模型 | 深度 | 核心结构特点 | 参数量 | 实际表现特点 |
|---|---|---|---|---|
| VGG16 | 16层 | 全3x3卷积堆叠,结构极端规整 | 约1.38亿 | 参数量最大,训练较慢,但特征提取能力不弱 |
| ResNet34 | 34层 | 基础残差块,残差连接 | 约2130万 | 参数量小,训练快,在轻量场景下很均衡 |
| ResNet50 | 50层 | 瓶颈残差块,含1x1降维 | 约2560万 | 深度增加,表达能力强,更适合细粒度识别 |
实际项目中这三款模型都训了一遍,最后默认主模型用了 ResNet50,后面我会细说为什么。
1.3 开发环境与依赖清单
这个项目对环境的要求不算苛刻,CPU 也能跑,只是训练速度会很煎熬。我的建议是:训练阶段必须有一块还算可靠的 GPU,推理部署阶段反而无所谓,CPU 完全够用。开发环境的依赖清单大概长这样:
torch>=1.13.0 torchvision>=0.14.0 Django>=4.1 Pillow>=9.0.0 numpy>=1.21 opencv-python>=4.6 pandas>=1.5 scikit-learn>=1.2 tqdm>=4.64几个重点依赖解释一下。Torch 和 TorchVision 这个不用多说,是整个模型的根基;Django 负责 Web 端逻辑,4.x 版本已经原生支持 Python 3.10/3.11,用起来省事;OpenCV 虽然最后正式版里用得不多,但它对图片格式兼容性和一些预处理操作支持得比 Pillow 更广,所以留着备用。
这里有一个新手很容易踩的坑:Torch 的 CUDA 版本要和显卡驱动匹配,否则会出现“检测不到 GPU”的情况。安装 Torch 的时候不要直接pip install torch,应该先去官网按 CUDA 版本选对应的安装命令。我从没在这件事上少吃过亏,后面排查章节会重点提。
2. 数据集构建与图像预处理
2.1 数据来源与类别设计
做识别项目最怕的就是“模型没上线,数据先玩死你”。野生保护动物识别的数据比普通图片分类难搞,因为某些物种本身就很罕见,野外影像一图难求。所以我在类别设计上花了不少心思,基本原则是:宁可少选类别,也要保证每个类别有足够的有效样本。
我最终圈定了十几个常见且影像资料相对充足的本土保护物种作为识别对象,包括一些大型猫科动物、鹤类、林麝类等。每个类别至少收集了 150 张有效图片,多的类目到 300 张以上。数据来源主要三个方向:一是公开生态影像资料库,二是保护区官方发布的野外相机监测图库,三是一些合法开放的摄影图库。这里要注意,使用任何图片都要留意版权和公开授权问题,学术研究用途也不能理所当然地白嫖,最好优先选用明确标注了可自由使用的素材,或者直接使用保护区自己采集的原始监测影像。
类别太多不是好事。我之前试过把类别扩到三十几个,结果训练集里某些类别还是只有五六张图片,模型直接把这个类学成了一个噪声记忆器,验证集上这个类的精确率惨不忍睹。后来把类别压回十几个,每个类都保证有足够数据,效果立刻正常了。
2.2 图像清洗与数据划分
收集完原始图片后,千万别急着训练。原始图片里混杂着大量脏数据,比如:重叠的连拍图、带水印的图、低分辨率截图、以及人和动物一起入镜的画面。不清理干净,模型会把水印和物种强行绑定,或者见到人就知道旁边有动物,这种莫名其妙的特征在验证时根本发现不了。
我的清洗流程分几步走。第一步是去重,用感知哈希算法把内容相似的图片筛出来,只留质量最好的一张。第二步是人工跑一遍,逐个打开图片,把模糊的、严重遮挡的、主体占比过小的全部删掉。第三步是统一格式,把所有图片重命名成“类别名_编号.jpg”的规范格式,方便后续读取和问题回溯。这个过程虽然枯燥,但我可以负责任地说,数据清洗花掉的三个小时,至少给训练过程省掉了七八轮无效调参。
清洗完之后,按 7:2:1 的比例划分训练集、验证集和测试集。注意划分时要确保同一个体的多张图片尽量落在同一个集合里,不然模型在训练阶段见过某个个体的画面,测试时又拿这个个体的新画面来考它,性能会被虚高估计。这个坑我用过很长一段时间才意识到。
2.3 数据增强与扩充策略
野生动物图片的拍摄条件比普通图像分类恶劣得多,光照、角度、距离的变化非常大。为了让模型不至于换了个角度就认不出动物,数据增强是必备环节,不是可选项。
我用 TorchVision 的 Transform 组件搭了一套数据预处理流程,训练集上做得比较狠,验证集和测试集只做标准化:
from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = 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]) ])用到的增强策略都很常规:随机裁剪模拟拍摄距离变化,水平翻转模拟动物从不同方向入镜,随机旋转处理红外相机轻微角度偏移,颜色抖动应对不同时段的光照差异。标准化用 ImageNet 数据集上的均值和标准差,这是迁移学习的通用做法,我们后面加载的是 ImageNet 预训练权重,输入数据的分布保持对齐,特征的传递效率才最高。
这里要补一句:数据增强不是越多越好。有一次我把旋转角度调到了 45 度,结果地面上跑的动物被旋转成头朝下的姿态,模型开始学迷惑特征,验证准确率不升反降。幅度控制在 10 到 20 度是比较稳的区间。
3. 模型选型与训练调参
3.1 三个网络结构对比
很多人做迁移学习的时候,对网络内部原理不关心,只会无脑调包。但做项目跟刷榜不一样,理解了网络差在哪儿,才知道为什么同一个数据集上结果差了几个百分点。
VGG16 是纯粹靠卷积层堆出来的,全部用 3x3 小卷积核,结构极其规整。它的优点是设计简单清晰,缺点是参数量太大,1.38 亿个参数里全连接层占了很大一部分,训练和推理都慢。在实际实验里,VGG16 的收敛速度明显比 ResNet 系慢,而且训练时显存占用高出一大截。
ResNet 系列的灵魂是残差连接。简单来说,网络每一层不光学习新的特征变换,还会把原始的输入直接加到输出上,保留信息的通道。这样做最大的好处是缓解了深层网络的梯度消失问题,让几十层的网络不至于越深效果越差。ResNet34 用的是基础残差块,每个块只有两个卷积层;ResNet50 换成了瓶颈结构,先用 1x1 卷积降维,再 3x3 卷积提取特征,最后再用 1x1 卷积恢复维度,整体深度增加,但参数量没有比 ResNet34 大太多,计算效率反而更高。
从实验来看,这三者在同一批数据上的表现差异正好印证了结构设计的影响。VGG16 能学出不错的结果,但需要更多轮次才能收敛,而且准确率上限稍低;ResNet34 收敛快,很有辨识力,适合快速验证;ResNet50 在细粒度特征上明显更强,对于那些毛色花纹相近的物种,判别准确率的差距就出来了。
3.2 迁移学习与超参数设置
这个项目从头训练一个卷积网络是不可能的,数据量完全不够,纯属自欺欺人。正确的做法是加载 ImageNet 预训练权重,然后替换最后的全连接分类层,只在我们的数据集上做迁移学习。
TorchVision 里加载预训练权重非常简单:
import torch.nn as nn from torchvision import models def build_model(num_classes, model_name='resnet50'): if model_name == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif model_name == 'resnet34': model = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif model_name == 'vgg16': model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) in_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(in_features, num_classes) return model训练时做了两层策略:骨干网络的学习率设置得比较低,只做微调;新加的分类层学习率适当调高。我用的是 SGD 优化器,momentum=0.9,weight_decay=1e-4,初始学习率带骨干网络部分设为 0.001,分类层设为 0.01。在第一个 epoch 里用了一个小的 warmup,让学习率线性上升,防止早期震荡太剧烈。
超参数大概这样:
| 参数 | 设置值 | 说明 |
|---|---|---|
| 输入尺寸 | 224x224 | 与 ImageNet 预训练输入保持一致 |
| Batch Size | 32 | 显存不够时降到16,稳定性反而更好 |
| Epochs | 25 | 早停验证集不再提升则提前终止 |
| 优化器 | SGD | 对比过 Adam 和 SGD,SGD 在迁移学习场景下更稳 |
| 学习率 | 骨干0.001,分类层0.01 | 使用余弦退火衰减,迭代结束时降到一个较低值 |
| 损失函数 | CrossEntropyLoss | 多分类标准损失 |
训练过程中还用了每 10 个 epoch 保存一次 checkpoint 的习惯,这样即使中间显存溢出或者断电,也不用全部重来。最后保存最佳验证集准确率的那个权重,而不是最后一个 epoch 的权重。
3.3 模型评估与效果对比
训练完成后,我用测试集做了最终评估。测试集是训练时完全没见过的图片,所以指标比较真实。主要看三个指标:准确率、宏平均 F1 分数、以及单张图片平均推理时间。
三次实验的对比结果如下:
| 模型 | 测试集准确率 | 宏平均F1 | GPU推理耗时/张 | CPU推理耗时/张 |
|---|---|---|---|---|
| VGG16 | 89.7% | 0.892 | 约4.5ms | 约180ms |
| ResNet34 | 92.1% | 0.917 | 约3.2ms | 约95ms |
| ResNet50 | 94.5% | 0.941 | 约4.8ms | 约130ms |
看到了吗,VGG16 在最均衡的思路上其实并不占便宜。ResNet34 比 VGG16 参数量小很多,准确率反而高出 2.4 个百分点,这是因为残差结构在中等规模数据集上的泛化能力确实好。ResNet50 准确率最高,推理时效方面在 CPU 上也没有比 ResNet34 慢到不可接受。
最终我把 ResNet50 定为主模型,理由很直白:识别系统的核心价值是准确率,不是几毫秒的推理速度。用 5ms 左右 GPU 推理或者 130ms 左右 CPU 推理换取 94% 以上的准确率,在野外动物识别这个场景里是非常划算的买卖。
4. Django 系统集成与部署
4.1 Web 应用整体架构
模型训完只是第一步,真正让项目“能用”,是把模型塞进 Django 里跑起来。我对系统架构的设计并不复杂:一个 Django 项目,里面创建一个识别应用,用 HTML 模板做表单上传页面,识别结果通过 JSON 返回。没有引入 Celery、Redis 这些重组件,因为单机处理一张图片的推理时间在秒级以内,不需要异步任务队列。
Django 项目的核心结构是这个样子的:
wildlife_reco/ # Django 项目 ├── manage.py ├── config/ # 项目配置 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── reco/ # 识别应用 │ ├── models.py │ ├── views.py │ ├── utils.py │ └── templates/ │ └── reco/ │ ├── index.html │ └── result.html ├── media/ # 上传图片保存目录 └── model_files/ └── best_model.pthDjango 的settings.py里需要配置MEDIA_ROOT和MEDIA_URL,这样上传的图片就有统一的保存路径。另外要给ALLOWED_HOSTS加上测试用的 IP 地址,否则局域网内通过 IP 访问会被拦掉。
4.2 图像上传与推理接口实现
推理逻辑我单独放进了utils.py,避免把模型相关代码混杂在视图函数里。模型只加载一次,放到全局变量里,这一点太关键了,我在初版的时候吃过亏,每个请求进来都重新加载一次模型,单张图片要等十几秒才能出结果。后面会专门讲怎么处理。
核心代码分两个文件。utils.py负责模型构建、数据处理和推理:
import torch from PIL import Image import torchvision from torchvision import transforms class AnimalClassifier: def __init__(self, model_path, num_classes): self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') self.model = torchvision.models.resnet50(weights=None) self.model.fc = torch.nn.Linear(self.model.fc.in_features, num_classes) self.model.load_state_dict(torch.load(model_path, map_location=self.device)) self.model.to(self.device) self.model.eval() self.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 predict(self, image_bytes): img = Image.open(io.BytesIO(image_bytes)).convert('RGB') tensor = self.transform(img).unsqueeze(0).to(self.device) with torch.no_grad(): logits = self.model(tensor) prob = torch.softmax(logits, dim=1).squeeze(0) conf, idx = torch.max(prob, dim=0) return {'label_id': int(idx), 'status': '识别成功'}, float(conf)这里有一个很关键的细节:用Image.open(io.BytesIO(image_bytes)).convert('RGB')强制转 RGB 三通道,避免某些图片是 RGBA 格式或者黑白图导致通道数不一致直接报错。
视图层views.py是这么写的:
import io from django.conf import settings from django.shortcuts import render from django.http import JsonResponse from .utils import AnimalClassifier # 项目启动时初始化一次推理器 classifier = AnimalClassifier( model_path=settings.MODEL_PATH, num_classes=18 ) def index(request): return render(request, 'reco/index.html') def predict(request): if request.method != 'POST': return JsonResponse({'status': 'error'}) image_file = request.FILES.get('image') if not image_file: return JsonResponse({'status': 'error'}) image_bytes = image_file.read() result, confidence = classifier.predict(image_bytes) # 这里可以做低置信度拦截 if confidence < 0.7: result['status'] = '置信度过低,未识别' result['confidence'] = confidence return JsonResponse(result)前端模板就简单了,一个<form method="post" enctype="multipart/form-data">,里面放一个<input type="file" name="image">和一个提交按钮。识别完直接在页面上用 JavaScript 把 JSON 结果显示出来。对于没有专业前端背景的开发者来说,这个方案完全够用。
4.3 模型加载与性能优化
Django 是同步处理请求的,模型推理天然会阻塞请求线程。如果同时有多个用户上传图片,Django 自带的多线程机制已经能支持并行处理,因为模型在内存中是同一个实例,推理过程只是调用同一个 forward。只要不用torch.no_grad()包裹的时候不小心开了梯度,推理速度和线程安全都不是大问题。
但有个坑必须提:在开发服务器里,Django 的自动重载机制会导致模型重复加载。每次修改代码,开发服务器重启,模型就重新加载一遍,如果这个模型比较大,重启一次要等好几秒。解决方案是可以通过环境变量关闭自动重载,或者接受这个重启成本只在开发阶段出现。
推理性能方面,我从两个方向优化。一是图片尺寸限制,上传的图片先压缩到最长边不超过 1024 像素再保存和推理,不然一张十几兆的野外相机原图直接塞进 ResNet50,CPU 算起来会非常慢。二是在 GPU 环境下,把输入数据直接放到 GPU 上,在 CPU 环境下推理时把模型加载用上map_location='cpu',避免 CUDA 环境下训练出来的权重在无 GPU 机器上无法加载。
部署阶段我用 Gunicorn 替代了runserver,因为后者是纯开发用的,不支持承受实际访问压力。Gunicorn 的配置也很简单,指定 worker 数量和绑定端口就行。如果你需要服务器后台常驻,配合 systemd 服务或者 Supervisor 都能实现。这块技术文档里也单独写了部署步骤,照着抄即可。
5. 常见问题与排查技巧实录
5.1 数据集不平衡导致模型偏科
这是我在训练过程中最先遇到的大问题。项目早期阶段,某些物种的图片数量特别少,模型训练时看到的这类样本不够多,结果就是模型在整体准确率上看起来还行,但一检查那一两个稀有物种,精确率低得吓人,几乎把所有相近的动物都判定成了常见物种。
排查思路很简单,看混淆矩阵。TorchVision 本身不带混淆矩阵工具,我用 scikit-learn 里的confusion_matrix配合 matplotlib 画了一张热力图,每个类别的预测分布一目了然。
解决办法我用了两招一起上:第一,给损失函数加类别权重,CrossEntropyLoss(weight=class_weights),让稀有类别在计算损失时权重更高;第二,对稀有类别做加权采样,WeightedRandomSampler,让每个 batch 里稀有类别出现的比例不至于太低。这两招配合使用后,稀有类别的精确率往回拉了很多,整体准确率也没有出现明显下滑。
5.2 显存不足与训练速度过慢
训练时 Batch Size 设置为 32,用的是 12GB 显存的显卡,ResNet50 勉强能跑,但是一开混合精度或者把输入尺寸调大,立刻报“CUDA out of memory”。新手遇到这种情况第一反应是把模型变小,但其实很多时候只需要做两个操作就行。
第一个操作是降低 Batch Size,从 32 降到 16,显存压力立减一半,准确率通常不会因此下降太多,因为我们的数据集没有那么大量级,梯度噪声的影响有限。第二个操作是开启 AMP(自动混合精度),只需要在 PyTorch 训练循环里加一个torch.cuda.amp.GradScaler和 autocast 上下文,显存占用能再降 30% 以上,训练速度反而提升。这个方法在技术文档里也写得很详细。
5.3 Django 部署中的模型加载路径问题
训练好模型后很开心地往 Django 项目里搬运,结果一启动就报找不到文件,或者加载权重时报size mismatch。前一个问题大概率是因为路径写成了绝对路径,项目挪到别的机器上就找不到模型;后一个问题是因为保存模型权重时带上了model.前缀,而加载时模型结构里没有这个前缀。
我最终的解决方案是:模型文件放在项目根目录下的model_files/目录里,在settings.py里用BASE_DIR / 'model_files' / 'best_model.pth'来动态拼接路径,这样项目无论放到哪台机器跑,只要相对结构不变就能正确读取。权重保存统一用state_dict方法,加载时像上面代码那样先构建同结构模型再load_state_dict,不要保存整个model对象。
5.4 识别置信度的阈值到底设多少
很多人部署完成后就万事大吉,但忽略了置信度阈值的设置。模型对所有图片都会输出一个预测结果,哪怕图片里是一块石头,它也能自信地给个“这是某种动物”的结论。所以必须给识别结果加一道门槛。
阈值设置过低,会把大量无关图片误判为保护动物;设置过高,又会漏掉真正捕捉到动物的图片。我反复试验后,最终的策略是:默认阈值 0.75,低于阈值直接显示“未识别出可信结果”;同时在返回结果里展示置信度数值,让使用者可以自行判断。如果你面对的场景是红外相机自动筛选,建议阈值还要再调高,到 0.85 左右,毕竟筛错一张图,后续审核成本很高。
5.5 上传图片格式兼容问题
Django 默认的request.FILES处理图片,Pillow 能支持常见格式,但野外相机的图片偶尔会有一些特殊后缀。我遇到过.bmp、.jpeg、.webp混在数据集里的情况,Pillow 大部分能处理,但转 RGB 那一步必须写牢,否则灰度图会直接让通道数崩掉。
如果还要处理更特殊的格式,比如相机 RAW 文件或者某些专有格式,建议先用 OpenCV 做一个格式转换预处理再喂给模型。不过对绝大多数场景来说,统一点convert('RGB')就够了。技术文档里我专门列了一个格式兼容排查表,提醒读者别在图片格式上浪费太多时间。
写在最后的一个小经验
做了这个项目之后,我对“技术文档”这件事有了很深的体会。一开始觉得写文档浪费时间,后来发现所有调试心得、参数记录、踩坑记录都在文档里,项目暂停半个月回来,全靠它恢复上下文。现在我会建议每个做类似项目的人都养成一个习惯:训练前记下每个类的样本数,训练中记录每一轮的损失和准确率,部署时把每一步操作截图存档。这些看起来琐碎,但最后会成为项目最值钱的资产。
还有一点更根本的体会:模型选型不要盲目追新。ResNet50 在深度网络里早就被各种新结构超过了,但它在这个项目中依然是最合适的那个。识别系统的稳定性和可维护性,永远比排行榜上高出的 0.1% 重要得多。如果你正在犹豫用什么模型,我建议你先把自己手上的数据质量理清楚,再用经典结构跑通全链路,之后再做替换升级,这才是比较聪明的推进顺序。