简介:一份聚焦基于深度学习的中华白海豚照片个体识别PDF,适合野生动物保护研究者、图像识别开发者和海洋生物学专业师生,也适用于需要了解鲸豚类照片识别算法的算法工程师。资源为单个PDF文件,大小约3.33MB,目前已有266人学习。文档以中国特有物种中华白海豚为对象,从保护需求切入,简述了深度学习在计算机视觉中的应用背景,并对比了手工识别与计算机识别方法的优缺点;同时系统性地介绍了用于该任务的数据集建立过程,包括广西钦州、广东汕头两地的背鳍图像采集、质量筛选与类别标注,以及OBIS-SEAPAM、Kaggle座头鲸识别等公开数据集的参考价值。核心内容围绕背鳍定位、质量评估、个体识别、背鳍分割四个步骤展开,针对每个步骤测试了多种主流深度卷积神经网络并对比其性能,还提出了基于群信息的背鳍识别方法,利用拍摄时的先验信息分组处理,可有效改善低质量照片的识别效果。此外,文档还介绍了面向Windows平台的中华白海豚识别软件及直观的人工校验界面,为相关保护研究和实际应用提供了一套较完整的技术方案。
1. 用照片给中华白海豚做个体识别:为什么传统方法快撑不住了
某保护区的照片库里堆了超过三十万张中华白海豚野外照片,人工比对员靠背鳍轮廓、缺口、疤痕和斑点来辨认个体,一个人一天最多核对三百张,连续看两小时眼睛就花了。基于深度学习的中华白海豚照片个体识别,就是把这套“靠经验认背鳍”的过程变成一条可复现算法链路:检测背鳍、提取特征、匹配个体。下面不空谈方向,直接从方案选型讲到数据标注、训练配置、推理部署,以及我在实操里踩过的坑。适合两类人:手头有海豚照片、想摆脱纯人工比对的研究者,以及想把动物个体识别当作实战项目练手的算法工程师。
2. 先做检测再做识别:把背鳍从照片里干净地抠出来
2.1 为什么选择背鳍作为识别锚点,而不是整只海豚
中华白海豚个体识别的物理基础,在于背鳍具有足够高的区分度和稳定性。背鳍在生长过程中形态基本不变,边缘的缺刻、凹陷、弯曲弧度,加上皮肤表面的陈旧疤痕和色素斑,组合起来就像人类的指纹。实践中,背鳍也是野外拍摄中最容易捕捉的部位——海豚换气时背鳍会周期性露出水面,使用长焦镜头就能拍到清晰的侧面轮廓。相比头部、眼睛、体表花纹,背鳍露出时间长、拍摄机会多,因此成为绝大多数照片识别方案的首选锚点。
我见过不少刚开始做这个方向的开发者,习惯直接拿整只海豚的照片训练分类模型。一测试就发现问题:海豚的身体姿态变化比个体差异还大,弯曲的体轴和翻滚的角度让同一个体的图像差异远大于不同个体。将识别锚点收窄到背鳍后,模型的 Rank-1 识别率明显提升。原因很直接:背鳍的姿态变化范围比身体小得多,形状信息受躯干弯曲和拍摄角度影响更小,特征更稳定。
为什么不选尾巴?尾部边缘同样有个体差异,但只有海豚尾击或深潜瞬间才能拍到,野外获取率太低。体表斑点呢?色斑会随年龄显著变化,且需要近距离平视视角,很难标准化采集。综合来看,背鳍是“拍摄可得性”和“特征稳定性”之间的最优平衡点。
2.2 目标检测模型怎么选:从 YOLO 到轻量化方案的取舍
背鳍检测是一个典型的小目标检测任务:在动辄 4000×3000 像素的照片里,背鳍可能只占几十到一百像素的宽度,且水面反光、浪花、船只甲板等都会形成干扰。选检测模型时,要在精度、速度、标注成本和部署难度之间做取舍。
| 方案 | 定位 | 速度 | 部署难度 | 适用场景 |
|---|---|---|---|---|
| YOLO 系列 | 单阶段,端到端 | 快,可实时 | 低,生态成熟 | 大部分项目首选 |
| Faster R-CNN | 双阶段,精度高 | 慢,不适合大规模推理 | 中 | 小数据集、极端目标 |
| SSD / 轻量检测器 | 单阶段,模型小 | 快 | 低 | 边缘设备、低算力 |
我一般会用 YOLO 系列作为第一版。它标注格式简单、训练脚本成熟、推理速度快,在几百张背鳍照片的小数据集上也能较快收敛。若后续要在无 GPU 的现场设备上部署,再考虑把检测器替换为轻量版本。这里的关键是:检测不是最终目标,识别才是。检测框只要稳定圈出背鳍即可,不必在检测精度上过度投入,因为在识别阶段还会对裁剪后的背鳍图片再做一次特征提取。
实际使用中,YOLO 输出的每个检测框附带置信度,业务逻辑里通常把置信度低于 0.5 的框直接丢掉,再按宽高比做一次过滤。背鳍检测框基本是横向长方形,宽高比大多在 1.5 到 3 之间,过滤掉明显过宽或过窄的框能减少误检。
2.3 用 YOLO 训练背鳍检测器:配置文件和命令
假设已经准备好了背鳍数据,目录结构如下:images/train、images/val 下放原始照片,labels/train、labels/val 下放 YOLO 格式的 txt 标注。每张图片对应一个同名 txt 文件,每行内容为 class x_center y_center width height,坐标均为归一化值。
先写数据配置:
# fin_dataset.yaml path: ./fin_data train: images/train val: images/val nc: 1 names: ['dorsal_fin']参数说明:nc 为类别数,这里只有背鳍一类;names 列表与类别 ID 对应。路径建议用绝对路径锚定,避免训练时找不到文件。
训练命令:
yolo detect train data=fin_dataset.yaml model=yolov8n.pt epochs=80 imgsz=640 batch=16 patience=10命令里 model=yolov8n.pt 表示加载 COCO 预训练权重做迁移学习,而不是从零开始训练。背鳍与 COCO 中的很多物体形态差异大,但底层边缘、纹理特征仍然可迁移,是数据量少时最有效的初始化方式。
参数方面,epochs 设为 80 足够,背鳍检测任务相对简单,通常在 40 轮左右 mAP 就不再有明显提升,超过 120 轮大概率过拟合。imgsz=640 是 YOLO 的默认训练尺寸,但如果原始照片是几千万像素,直接缩放会导致背鳍目标太小,建议先用滑动窗口切块训练,或者把 imgsz 提高到 1280,代价是显存占用增大。batch=16 需要根据显卡显存调整:6GB 显存建议降到 8,12GB 以上可以开到 32。patience=10 表示 10 轮 mAP 无提升就提前结束训练,省时间。
提示:训练完成后,优先看 val 混淆矩阵和 F1-曲线,而不是只看 mAP。背鳍检测只有一类,分类混淆矩阵没有意义,核心关注的是“背鳍被漏掉的比例”和“背景被误判为背鳍的比例”。
如果背鳍训练样本本身不足 100 张,先别急着调参,优先补充不同光照、不同海况下的正样本;检测模型对数据的饥渴程度远高于识别模型。
3. 把背鳍变成向量:特征嵌入与个体匹配
3.1 分类模型与度量学习:两条路线怎么选
检测模型解决的是“背鳍在哪”,识别模型解决的是“这个背鳍是谁”。两种主流路线:一种是把背鳍图片直接当作分类任务,用 Softmax 输出个体 ID;另一种是用度量学习把背鳍图片映射成特征向量,通过向量距离判断是否为同一个体。
| 对比项 | 分类模型 | 度量学习 |
|---|---|---|
| 个体数固定 | 简单、可靠 | 稍复杂 |
| 个体数持续增加 | 每次需重训全模型 | 仅需新增图库向量 |
| 存在未知个体 | 无法判断,总会输出一个已知类 | 用相似度阈值判定为未知 |
| 训练数据量 | 每个个体样本越多越好 | 对长尾分布更稳健 |
分类模型只有在“个体集合固定、个体数量基本不变”的实验场景下才合适。比如某片海域一个月内追踪固定的 20 只个体,用分类模型能快速出结果,精度也高。但保护区不是这个逻辑:每个月都会有新个体进入,旧照片会被不断清洗归档。分类模型每遇到新个体都要重新训练,维护成本很高。度量学习的优势就在这里:新个体到来时,只需要用现成的特征提取器计算它几张照片的向量,加入图库即可,模型不用动。
还有一个常见疑问:用孪生网络(Siamese Network)行不行?孪生网络本质是度量学习的一种实现形式,输入一对图片输出相似度分数。与 Triplet 方式相比,孪生网络训练对构造简单,但收敛速度通常比三元组慢,因为缺少“负样本对比”时的中间梯度。建议直接用三元组方式,效果和效率都更好。
3.2 基于 Triplet Loss 的特征提取模型训练
度量学习最常见的做法之一是 Triplet Loss。每次同时取三张图片:锚点(Anchor)、正样本(Positive,与锚点同一个体)、负样本(Negative,不同个体)。训练目标是把锚点与正样本的距离拉近,把锚点与负样本的距离推远。
import torch import torch.nn as nn import torch.nn.functional as F import torchvision.models as models class FinEncoder(nn.Module): """输出256维归一化特征向量的背鳍编码器""" def __init__(self, embed_dim=256): super().__init__() backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) # 去掉ResNet的全局池化后的全连接分类层 self.backbone = nn.Sequential(*list(backbone.children())[:-1]) self.fc = nn.Linear(2048, embed_dim) def forward(self, x): x = self.backbone(x).flatten(1) x = self.fc(x) # L2归一化,让向量之间的内积等价于余弦相似度 return F.normalize(x, p=2, dim=1)这里有两个关键点。一是 embed_dim 取 256:相对 20~200 个个体的中型图库,128~256 维足够表达个体差异,维数太高反而容易在少样本情况下过拟合;二是最后做 L2 归一化:后续计算余弦相似度时不需要额外处理,距离阈值也更直观。
再定义三元组数据集:
import random from torch.utils.data import Dataset class TripletFinDataset(Dataset): """根据个体ID构造三元组样本""" def __init__(self, image_paths, labels): self.image_paths = image_paths self.labels = labels self.group = {} for path, label in zip(image_paths, labels): self.group.setdefault(label, []).append(path) self.ids = list(self.group.keys()) def __getitem__(self, idx): anchor_path = self.image_paths[idx] label = self.labels[idx] # 同类中随机取一张作为正样本 pos_path = random.choice(self.group[label]) # 异类中随机取一个个体,再从中取一张作为负样本 neg_id = random.choice([i for i in self.ids if i != label]) neg_path = random.choice(self.group[neg_id]) return load_image(anchor_path), load_image(pos_path), load_image(neg_path)load_image 函数需要自行实现,建议统一做 resize 到 224×224、标准化到 ImageNet 均值和方差。
训练循环:
model = FinEncoder().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for epoch in range(60): for anchor, pos, neg in dataloader: anchor, pos, neg = anchor.to(device), pos.to(device), neg.to(device) anchor_vec = model(anchor) pos_vec = model(pos) neg_vec = model(neg) loss = F.triplet_margin_loss(anchor_vec, pos_vec, neg_vec, margin=0.3) optimizer.zero_grad() loss.backward() optimizer.step()margin=0.3 是常用经验值,区间一般在 0.2~0.5。过小会导致正负样本边界不清晰,过大则训练前期损失难以下降。dataloader 要注意 batch 是由多个三元组拼成的,batch_size 建议设 16 或 32,并且每个 batch 尽量覆盖多个不同个体,否则梯度方向偏向当前 batch 的少数个体,训练不稳。显存有限时调小 batch_size,同时把学习率同步调低。
训练集和验证集要按照个体 ID 划分,而不是按图片划分。同一只海豚的照片如果同时出现在训练集和验证集,评估出来的 Rank-1 虚高,真实场景根本没有参考价值。
3.3 建立个体图库与最近邻匹配流程
训练完成后,模型被冻结,下一步是构建图库。图库可以理解为“已知个体特征向量索引”:每个个体取 3~10 张代表性背鳍照片,分别通过 FinEncoder 得到多个向量,全部放进图库。匹配时把查询图片通过同一模型得到查询向量,再与图库中所有向量计算余弦相似度,取 Top-K。
class FinGallery: """背鳍特征向量库,支持增量添加和Top-K匹配""" def __init__(self, threshold=0.5): self.ids = [] self.vectors = [] self.threshold = threshold def add(self, individual_id, vector): self.ids.append(individual_id) self.vectors.append(vector.detach().cpu()) def match(self, query_vector, top_k=5): import torch vectors = torch.stack(self.vectors) sims = torch.cosine_similarity(query_vector.cpu(), vectors, dim=1) top_idx = sims.argsort(descending=True)[:top_k] results = [(self.ids[i], sims[i].item()) for i in top_idx] if results[0][1] < self.threshold: return "unknown", results return resultsthreshold=0.5 是“宁缺毋滥”的默认值。实际运行时,如果图库中每个个体有多张模板,最高相似度通常会落在 0.7 以上;低于 0.5 的匹配基本不可信。但阈值最终要依赖你自己的数据校准:取一批已知个体照片做验证集,画出相似度分布,把阈值选在“类内最低相似度”和“类间最高相似度”之间。这里有一个容易被忽略的点:图库中一个个体放多张模板,最高相似度会自然偏高,匹配结果也更稳定,所以不要为了省计算量只给每个个体存一张图。
图库存储上,个体数量少时直接落在内存数组里没问题;个体数超过几千、向量几十万时,建议用向量检索库或简单把向量写入 SQLite 表,按需加载。
4. 数据是识别的上限:照片筛选、标注规范与增强策略
4.1 什么照片能用,什么照片不能用——先定标准再动手
做数据之前最重要的动作是定义可用样本。海上拍回来的照片大量是废片,全部拿来训练只会拖垮模型。我一般按三条标准筛:
- 背鳍主体清晰,边缘轮廓能肉眼辨认,没有严重失焦或运动模糊。
- 背鳍露出水面的部分完整,至少能看到整体轮廓,缺损不超过三分之一。
- 个体特征(缺刻、疤痕、斑点)有一定可见度;如果照片里只能看到一个黑色三角形剪影,纹理信息为零,不能用。
标注层面,检测框一定只框背鳍,不要框进头或身体。识别模型吃的是背鳍裁剪图,如果框里混入了大面积海水和身体边缘,特征提取会被无关纹理干扰。标注字段除了检测框,还需要记录个体 ID。一个常见做法是:先按照片目录归属整理个体,再用标注工具把同一个个体的照片分配到同一 ID 下,避免标注员之间对同一个体建了多个 ID。
标注管理上,目录结构可以按“个体 ID/日期/原始照片”三层组织,后续做图库更新和训练集划分都方便。检测标注文件与个体 ID 分开存放:检测训练只需要 bbox,识别训练需要 bbox 裁剪图加个体 ID 标签。
4.2 数据增强:光照、水雾和模糊怎么扛过去
海上拍摄的照片光照极不稳定:正午强光、早晚逆光、水面反光、薄雾遮挡都会对识别造成干扰。数据增强需要有针对性地模拟这些环境扰动。我用的是 albumentations 库,按检测和识别两个阶段分别配置。
import albumentations as A # 识别模型增强 fin_identify_aug = A.Compose([ A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.8), A.GaussNoise(var_limit=(10.0, 50.0), p=0.3), A.GaussianBlur(blur_limit=(3, 7), p=0.4), A.RandomRotate90(p=0.5), A.Affine(translate_percent=0.1, scale=(0.9, 1.2), p=0.5), ])这里的顺序是有讲究的:先做亮度对比度扰动模拟光照差异,再加高斯噪声模拟水面的感光噪声,再加模糊模拟失焦,最后做旋转和轻微缩放模拟姿态变化。增强不是越猛越好——旋转超过 30 度或缩放超过 1.3 倍会破坏背鳍的真实几何形态,模型学到的可能是错误形状。
检测模型的增强策略与识别不同。检测阶段核心是抗尺度变化,我会用 Mosaic 拼接和随机缩放,让模型在不同分辨率的背景下找到背鳍;识别阶段核心是抗光照和模糊,不需要 Mosaic,因为裁剪图里的背鳍已经居中。两套增强分开配置,效果比一套走天下好得多。
数据增强无法凭空创造信息,只是让模型在现有信息的边界内更鲁棒。如果某个个体只有三张照片,增强一百个变体也不会产生新形态信息,这是后面讲类别不平衡时要面对的现实。
4.3 类别不平衡与少样本个体
个体照片数量天然是长尾分布:某些个体频繁出现在监测点位,图库里有几百张;另一些个体只被拍到过三次,共三五张照片。如果训练时完全随机采样,少数个体的样本在 loss 中被淹没,最终识别结果几乎不会命中这些稀有类。
处理方式我一般按优先级排:采样器层面对稀有类别加权,训练时每个 batch 中稀有类的三元组出现概率提高;对稀有类的少数照片做更强的增强,生成近似样本;评估时分别统计常见类和稀有类的 Top-1/Top-5,而不是只看整体均值。
from torch.utils.data import WeightedRandomSampler import numpy as np counts = np.bincount(labels) # 每个样本的采样权重与类别出现次数成反比 sample_weights = [1.0 / counts[label] for label in labels] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(image_paths), replacement=True )关键参数是 replacement=True,允许同一个个体的照片在同一个 epoch 里被重复采样,这对少样本类别的梯度贡献很关键。稀有类别权重拉太高也会引发过拟合,极端情况下模型对少样本个体过度记忆,真实场景出现轻微姿态变化就识别失败。我的习惯是稀有类权重不超过常见类的 5 倍,如果还不行,优先补充真实照片而不是强行加重采样权重。
需要特别提醒:不要为了平衡直接把常见类的照片删掉,常见类的多样性正是模型学到稳定特征的基础,删了反而让特征空间不完整。
5. 野外实战最常见的 5 个坑:从现象到排查
5.1 现象:检测器把浪花、礁石和船桨当成背鳍
检测器在验证集上 mAP 不错,一到野外汇总照片就框出一堆浪花。
原因:训练集的负样本太单一。背鳍检测框的正样本都是干净背景下的清晰背鳍,模型没见过带纹理的水面和水花,容易把高对比度、弯月形边缘的物体误判为背鳍。
解决:收集大量不包含背鳍的野外海面照片作为负样本,参与检测训练;或者在做数据标注时,把模型容易误检的难例截图也标成负样本。另一个有效手段是提高输入分辨率——imgsz 从 640 提到 1280 后,小尺寸浪花产生的高频纹理更容易被模型正确分类。排查时先把置信度阈值调高到 0.6 或 0.7,看误检率是否下降:如果下降明显,说明模型本身是犹豫的;如果仍然高,说明训练数据负样本不足,必须回数据层面解决。
5.2 现象:同一个体的不同照片被识别成不同个体
现象是查同一只海豚的近期照片,系统给了两个不同的个体 ID。
原因:姿态和光照差异过大时,背鳍图像在像素空间的差异超过了模型能容忍的范围。尤其是俯拍和侧拍得到的背鳍外观差异极大,再加上太阳反光,模型学到的是“场景相似度”而不是“背鳍相似度”。
解决:训练数据里刻意增加每个个体的多角度样本,让模型学习姿态无关的特征;匹配时使用多模板,不只看单张最相似,而是取前三张模板的平均相似度。另外检查增强配置,如果旋转和光照扰动过强,模型可能被训练得对关键细节钝化,适当降低增强强度反而更有效。我会把增强后的图片抽样打印出来人工看一眼,确认背鳍轮廓没有明显变形,这一步能省掉很多后期排查时间。
5.3 现象:同一个体隔了几年后,新照片识别不出
回查某只个体过去三年的照片,早期照片匹配得分都在阈值以下,系统判为“未知”。
原因:背鳍形态虽然长期稳定,但疤痕会新增、旧伤会愈合,色素斑也会变化。对照多年前的图库模板,特征距离越来越大,跌到阈值以下。
解决:图库不是静态的,需要按时间窗口更新模板。常见做法是保留每个个体近半年到一年的几张高清照片,定期把更老的模板降权或移出。不要迷信“首次建档照片永远有效”,个体外观的漂移是生物识别系统必须接受的现实。排查这类问题时,把该个体每个时期的特征向量做可视化对比,可以看到特征随时间缓慢漂移的方向,用量化漂移速度来决定模板更新周期。
5.4 现象:新个体加入后,原来可用的分类模型突然失效
某个月新增了三只个体,示意图分类模型的准确率从 90% 掉到 70%。
原因:分类模型的输出维度固定等于训练时的个体数。新个体要么被错误映射到旧类别,要么强迫模型重训,重训后旧类别的特征分布被扰动,整体精度下降。
解决:从一开始就选择度量学习而不是分类模型。如果已经用分类模型跑了一段时间,迁移路径是:把分类模型去掉最后的全连接层,保留特征提取部分,额外拼接一个度量学习头重新微调。也可以折中,给分类模型加一个“未知类”头,但新个体数量多了仍然需要重训。这个坑在项目启动时最容易规避,因为迁移的成本比从零做度量学习更高。
5.5 现象:训练时精度不错,部署到 CPU 上单张推理接近一秒
现象是 GPU 上跑得飞快,换到现场无 GPU 的电脑,单张照片跑完检测加识别要 800 多毫秒。
原因:整体链路是“检测 + 识别”串行,且识别骨干是 ResNet50 这类大模型。每张照片不仅要做一次检测前向,还要对每个背鳍裁剪图再跑一次识别前向。CPU 上这个耗时会被放大。
解决:先把识别骨干换成轻量网络(MobileNetV3 或 EfficientNet-Lite),Top-1 精度可能下降 2~3 个百分点,但单张推理时间可以降到 100 毫秒以内;再用 ONNX Runtime 做推理加速,避免 PyTorch 的调度开销。如果一次性处理成批照片,把检测全部完成后统一提取特征,比单张串行更高效。量化是最后的优化手段,INT8 量化通常能再提速 2 倍,但要注意量化后的特征向量与原始模型不完全一致,图库里的向量需要重新生成,不然距离计算有偏差。
6. 落地最小系统:从单张照片到 Top-K 候选名单
6.1 完整推理链路:检测、编码、匹配三步串联
把前面的模块串成一条完整的推理链路,输入一张照片,输出 Top-K 候选个体和相似度。
def run_identify_pipeline(image_path, detector, encoder, gallery, top_k=5): img = cv2.imread(image_path) det_result = detector(img)[0] if not det_result.boxes: return {"found_fin": False, "predictions": []} predictions = [] for box in det_result.boxes: x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) crop = img[y1:y2, x1:x2] if crop.size == 0: continue tensor = fin_preprocess(crop).unsqueeze(0).to(device) vec = encoder(tensor).squeeze(0) match_result = gallery.match(vec, top_k=top_k) match = match_result[0] if isinstance(match_result, tuple) else match_result predictions.append( {"bbox": [x1, y1, x2, y2], "score": float(box.conf[0]), "match": match} ) return {"found_fin": True, "predictions": predictions}需要注意三点。一是检测置信度 score 和识别相似度是两类不同尺度,不要混在同一个分数里做排序。二是照片中有多只海豚时,对每个检测框独立提取特征并匹配,输出多个候选项。三是 match 返回的如果是 “unknown”,说明该背鳍不在已知图库中,后续系统可以提示“疑似新个体”,触发人工建档流程。
6.2 半自动入库与复核:让系统今天就能用起来
我建议实际业务里不要把识别结果直接写成最终答案,而是输出 Top-5 候选加上相似度,交给熟悉个体的研究员二次确认。这个“人机结合”的流程可以把误判率降到接近零,同时比纯人工核对快一个数量级。
新增个体入库时,至少拍三张不同角度的背鳍照片,全部提取特征后一起加入图库,而不是单张入库。单张入库很容易因为照片质量差引入一个偏远的特征点,后续出现“假阳性”匹配。
我自己的习惯是先把阈值调得宁高勿低,识别系统只输出高置信候选,输出结果就是“这几张照片最像哪几个个体”,剩下的交给人工确认。那些低置信结果单独存到一个“待复核”文件夹,每周集中处理一次。这套流程最大的价值不是省掉人工,而是把几万张照片的筛查变成先让机器排掉八成,人只需要看两成候选,时间和眼睛都省下来了。太早追求全自动反而会翻车。先跑通半自动,再逐步用反馈数据微调阈值和模型。希望帮到你。
本文还有配套的精品资源,点击获取