做动物个体识别项目时,最大的难点往往不是“这两张图是不是同一只动物”,而是“测试阶段突然冒出来一只训练集里从来没见过的新个体”。传统 Closed-Set 分类器会把所有图片强行归入已知类别,真实野生动物监测场景里,相机部署一段时间后总会拍到新个体,模型就会在这种时刻失去可靠性。本文围绕 Calibrated Similarity 和 Graph Clustering 这套组合思路,完整拆解 Open-Set Animal Re-Identification 的方法原理、模块实现、代码示例和工程落地建议。适合正在做动物个体识别、行人重识别、开集识别方向的学生或工程师参考。
1. 开集动物个体重识别是什么
1.1 从重识别说起
重识别(Re-Identification,Re-ID)要解决的问题是:给定一张查询图,判断它是否和数据库里某一张历史图像属于同一个目标个体。对动物而言,这个“个体”就是某一只大象、某一只斑马、某一只鲸鲨,而不是“大象”这个物种。传统方法依赖人工设计的斑点、条纹、鳍部特征,近几年则主要用深度学习提取全局或局部特征,再用度量学习拉近同一个个体的特征距离。
如果把它形式化,可以理解为:
- 训练阶段:模型见过一部分个体,每个个体有多张图像。
- 测试阶段:给定候选图像集合,模型需要回答这些图像分别属于哪些个体。
问题在于,大多数 Re-ID 方法默认“测试阶段出现的个体都在训练集里出现过”,这就是 Closed-Set 假设。但真实场景几乎不满足这个假设。
1.2 为什么 Open-Set 更贴近真实场景
野生动物监测通常在一个固定区域内部署多个相机。第一次部署时,我们可能只拍到了区域内一部分个体。三个月后,一只从未出现过的雄狮进入领地,相机拍到了它。这时候系统面对的就是一个“新个体”。
如果用封闭集模型,这只雄狮的图像会被强制分配到某个已知个体 ID 上,结果是:
- 新个体没有被识别出来,反而污染了原有 ID 的图库。
- 后续聚类和统计出现偏差,种群数量统计会偏少或偏多。
- 对动物保护决策产生误导。
Open-Set 的核心目标,就是让系统既能认出已知个体,又能识别出“这是一个数据库里没有的新个体”。这比 Closed-Set 要难得多,因为模型既要保持已知类别的判别力,又要对未知类别有拒识能力。
1.3 两个核心技术名词
本文标题中的两个关键词,是解决 Open-Set 问题的两个关键环节:
- Calibrated Similarity(校准相似度):对原始特征相似度进行修正,让高不确定性的匹配分数降下来,减少“看似相似但实际不是”的误匹配。
- Graph Clustering(图聚类):把测试图像看成一个图上的节点,节点之间用校准后的相似度连接边,然后通过社区发现算法把属于同一个体的节点聚成一簇。
这套思路最直观的价值是:不再直接给每张图分配一个固定 ID,而是把整批测试图放到一起做聚类分析,个体数量也是聚类结果自然给出的,天然适合 Open-Set。
2. 整体方法框架
2.1 四个核心模块
一个完整的 Open-Set Animal Re-ID 流程,通常由四个模块组成:
| 模块 | 作用 | 关键输出 |
|---|---|---|
| 特征提取 | 将图像编码为向量表示 | 特征向量 Embedding |
| 相似度校准 | 修正原始特征相似度 | 校准后的相似度矩阵 |
| 图构建与聚类 | 将相似度转化为图结构并分组 | 聚类标签 |
| 开集后处理 | 识别并分离新个体 | 最终个体 ID |
特征提取模块在训练阶段通过度量学习得到,相似度校准模块关注如何抑制不可靠匹配,图聚类模块关注如何把“成对相似度”提升为“全局分组结果”,开集后处理则决定哪些簇足够可信、哪些簇需要被标记为新个体。
2.2 处理流程
推理阶段的处理流程如下:
- 对测试集所有图像提取特征和不确定性值。
- 对特征做 L2 归一化,计算两两相似度。
- 使用不确定性对相似度矩阵进行校准。
- 基于校准相似度构建 kNN 图。
- 在图结构上运行社区发现算法,得到初步簇。
- 对每个簇计算簇内一致性指标,判断簇是已知个体还是新个体。
这套流程不依赖预设的个体数量,因此比“先训练分类器再预测”的方法更适合动态变化的野外数据。
2.3 与通用 Re-ID 的区别
通用 Re-ID 在推理时通常只做“查询图 vs 数据库图”的两两比对,然后按分数排序。Open-Set 方法更重视全局一致性:
- 两两相似度只代表局部关系,容易受光照、遮挡、姿态影响。
- 图聚类把某张图与周围一大批图的连接关系都纳入判断,能平滑掉局部噪声。
- Open-Set 还需要回答“这个簇是不是新个体”,所以后处理比封闭集多一步。
3. 环境准备与数据准备
3.1 实验环境
本文示例代码以 Python 为基础,版本需要根据你的项目实际情况调整,重点演示实现思路。常见环境如下:
python>=3.8 torch>=2.0 torchvision>=0.15 numpy>=1.24 faiss-cpu networkx>=3.0 scikit-learn>=1.2 opencv-python安装命令可参考:
pip install torch torchvision pip install faiss-cpu pip install networkx scikit-learn opencv-python如果 GPU 环境可用,faiss 可以安装 GPU 版,处理万级规模图像时速度更快。
3.2 数据集目录结构
动物 Re-ID 数据集的常见组织方式是按个体 ID 分目录:
data/ ├── train/ │ ├── id_0001/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ ├── id_0002/ │ └── ... ├── gallery/ │ ├── id_0001/ │ └── ... └── query/ ├── 0001.jpg └── ...训练时使用 train 目录,每个子目录代表一个已知个体。测试时,把待识别图像放到一个目录里,不要求知道它属于哪个个体,由模型聚类得出结果。
3.3 图像预处理
动物图像通常来自野外相机,尺寸和清晰度差异较大。预处理阶段建议做:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), 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]) ]) test_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])需要说明的是,Resize 会丢失部分细节,实际项目中可以按数据集分辨率选择 Resize 或 CenterCrop,保留身体关键区域。
4. Calibrated Similarity 相似度校准
4.1 为什么需要校准
深度模型提取的特征不是“同等可靠”的。一张清晰正脸照的特征置信度,和一张严重遮挡、运动模糊的照片的特征置信度理应不同。但普通余弦相似度没有考虑这一点,它只计算两个向量之间的夹角,不管这两个向量本身是否可靠。
在动物 Re-ID 场景中,这种问题尤其明显:
- 同一只动物的两张模糊照片,由于背景噪声产生较高相似度,属于“可靠但碰巧相似”。
- 不同个体的特征非常接近,在度量空间中被挤压在一起,容易误判。
- 剪影、局部遮挡会让特征偏向背景信息,带来系统偏差。
因此,我们需要一种手段,让不可靠特征参与匹配时自动降低其影响力。
4.2 校准的常见方式
| 校准方式 | 思路 | 适用场景 |
|---|---|---|
| 温度缩放 | 对相似度除以温度参数,提升置信度差异 | 特征空间较规范时 |
| 不确定性加权 | 模型额外预测不确定性,按不确定性降低匹配分数 | 野外遮挡、模糊样本多时 |
| 邻域一致性校准 | 根据周边样本的相似度分布调整当前匹配分数 | 数据集规模较大时 |
| 属性辅助校准 | 利用物种、性别、年龄段等属性修正相似度 | 有额外标注信息时 |
本文采用“不确定性加权”的思路,因为它在实现上比较直接,也符合 Calibrated Similarity 的核心语义:先估计每张图像的不可靠程度,再用它修正匹配矩阵。
4.3 代码实现
首先定义带不确定性分支的特征提取网络:
# models/feature_extractor.py import torch import torch.nn as nn import torchvision.models as models class FeatureExtractor(nn.Module): def __init__(self, embed_dim=512, pretrained=True): super().__init__() backbone = models.resnet50(pretrained=pretrained) # 去掉 ResNet 最后的全局池化层和分类层 self.backbone = nn.Sequential(*list(backbone.children())[:-2]) self.embed_head = nn.Sequential( nn.Conv2d(2048, 512, kernel_size=1), nn.BatchNorm2d(512), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) ) self.feature_fc = nn.Linear(512, embed_dim) self.uncertainty_fc = nn.Linear(512, 1) def forward(self, x): feat = self.backbone(x) feat = self.embed_head(feat).flatten(1) embedding = self.feature_fc(feat) embedding = nn.functional.normalize(embedding, p=2, dim=1) uncertainty = torch.sigmoid(self.uncertainty_fc(feat)) return embedding, uncertainty网络输出两个东西:
embedding:L2 归一化后的特征向量,用于相似度度量。uncertainty:经过 sigmoid 的值,范围在 0 到 1 之间,越大表示该样本越不可靠。
接着实现校准相似度计算:
# metrics/calibrated_similarity.py import torch def calibrated_cosine_similarity(features, uncertainties, alpha=1.0): """ features: (N, D) 已归一化的特征矩阵 uncertainties: (N, 1) 不确定性值 alpha: 校准强度超参数 """ # 余弦相似度矩阵 sim = torch.mm(features, features.t()) confidence = 1.0 - uncertainties.squeeze(1) # (N,) confidence_matrix = confidence.view(-1, 1) * confidence.view(1, -1) calibrated_sim = sim * torch.pow(confidence_matrix, alpha) return calibrated_sim这段代码的原理是:当某个样本不确定性较高时,confidence较小,那么该样本对应行和列的相似度都会被压低。alpha控制压低强度,alpha=0时退化为普通余弦相似度,alpha越大,低置信度样本的匹配分数降得越明显。
4.4 参数调整建议
alpha的选择没有固定值,需要结合验证集表现来调:
alpha过小,校准效果不明显,模糊样本仍然容易误匹配。alpha过大,部分低置信度但真实匹配的正样本对也会被压低,导致召回率下降。
实际项目中建议先在不做校准的条件下跑出一批匹配结果,观察哪些错误匹配来自模糊样本,再逐步增大alpha观察指标变化。
5. Graph Clustering 图聚类
5.1 从相似度到图
有了校准后的相似度矩阵,我们可以把所有测试图像看作带权全连接图上的节点。理论上任意两张图之间都有一条边,但全连接图计算量和噪声都太大,所以一般保留每个节点的 Top-k 最近邻居作为边,得到 kNN 图。
这一步的工程意义有两个:
- 减少后续聚类算法的计算量。
- 剪掉大量低质量相似度边,减少噪声连接。
5.2 kNN 图构建代码
使用 faiss 的 Flat Index 做内积搜索,因为特征已经 L2 归一化,内积等价于余弦相似度:
# clustering/knn_graph.py import faiss import numpy as np def build_knn_graph(features, k=10): """ features: np.ndarray, shape (N, D), 必须是 float32 k: 每个节点保留的邻居数量 """ features = np.ascontiguousarray(features, dtype=np.float32) n, d = features.shape index = faiss.IndexFlatIP(d) index.add(features) sims, idxs = index.search(features, k + 1) # 多查一个,因为会包含自身 sims = sims[:, 1:] idxs = idxs[:, 1:] edges = [] for i in range(n): for j, s in zip(idxs[i], sims[i]): if s <= 0: continue edges.append((int(i), int(j), float(s))) edges.append((int(j), int(i), float(s))) # 构造无向图 return edges注释里有个容易踩的坑:k + 1是因为 IndexFlatIP 会把自己的相似度也查出来,实际使用时要去掉第一列。
5.3 Louvain 社区发现
networkx 提供了 Louvain 社区发现算法,它可以自动发现社区数量,不需要事先指定个体数,因此很适合 Open-Set 场景:
# clustering/graph_clustering.py import networkx as nx from networkx.algorithms import community def graph_clustering(edges, seed=42, resolution=1.0): G = nx.Graph() G.add_weighted_edges_from(edges) clusters = community.louvain_communities( G, weight="weight", seed=seed, resolution=resolution ) labels = [0] * G.number_of_nodes() for cluster_id, nodes in enumerate(clusters): for node in nodes: labels[node] = cluster_id return labels, clustersresolution参数可以理解为“社区分裂倾向”:
- 数值较大时,倾向于产生更多小社区。
- 数值较小时,倾向于合并成大社区。
在动物 Re-ID 中,一个个体通常有多个图像,但如果图像数量很少,聚类时很容易被拆散,可以适当调低resolution。
5.4 Open-Set 后处理
聚类完成后,还需要识别哪些簇对应“新个体”。这里给出一种简单的工程判断方法:
# clustering/open_set_postprocess.py import numpy as np def assign_open_set_ids(labels, features, sim_matrix, min_cluster_size=3, min_confidence=0.3): """ 根据簇大小和簇内平均相似度判断是否属于已知个体。 返回: final_ids: list, 每个元素为最终分配的个体 ID is_unknown: list, 每个元素是否为未知个体 """ labels = np.asarray(labels) n = len(labels) final_ids = [-1] * n is_unknown = [False] * n unique_clusters = list(set(labels)) next_id = 0 for c in unique_clusters: idx = np.where(labels == c)[0] if len(idx) < min_cluster_size: for i in idx: is_unknown[i] = True final_ids[i] = -1 continue # 计算簇内平均相似度 cluster_sim = [] for i in range(len(idx)): for j in range(i + 1, len(idx)): cluster_sim.append(sim_matrix[idx[i], idx[j]]) avg_sim = float(np.mean(cluster_sim)) if cluster_sim else 0.0 if avg_sim < min_confidence: for i in idx: is_unknown[i] = True final_ids[i] = -1 else: for i in idx: final_ids[i] = next_id next_id += 1 return final_ids, is_unknown这段代码包含两个判断条件:
- 簇内图像数量太少,无法形成可靠个体特征,视为孤立点或新个体。
- 簇内平均相似度过低,说明簇内图像可能来自多个不同个体,直接标记为未知更安全。
实际项目中,min_cluster_size和min_confidence都需要根据验证集统计得到,而不是拍脑袋定。比如统计所有已知个体在测试集上的平均簇内相似度,然后取略低于该值的数作为阈值。
6. 训练流程与损失函数
6.1 网络结构
前面已经给出了特征提取网络的代码。实际训练时,通常还需要一个分类头用于辅助学习,或者直接使用度量学习损失。
推荐的结构是:
- 主干网络:ResNet50。
- 嵌入头:输出 L2 归一化的特征向量。
- 不确定性头:输出每个样本的不确定性。
- 分类头:将特征映射到训练集中的个体 ID。
# models/full_model.py import torch.nn as nn from models.feature_extractor import FeatureExtractor class AnimalReIDModel(nn.Module): def __init__(self, num_classes, embed_dim=512, pretrained=True): super().__init__() self.extractor = FeatureExtractor(embed_dim=embed_dim, pretrained=pretrained) self.classifier = nn.Linear(embed_dim, num_classes) def forward(self, x): embedding, uncertainty = self.extractor(x) logits = self.classifier(embedding) return embedding, uncertainty, logits6.2 损失函数组合
训练阶段建议同时使用三元组损失和交叉熵损失:
- 三元组损失负责调整特征空间结构,让同类距离近、异类距离远。
- 交叉熵损失负责提供类别级别的监督信号,稳定训练过程。
一个简洁的 batch-hard 三元组损失实现如下:
# losses/triplet_loss.py import torch import torch.nn as nn def batch_hard_triplet_loss(embeddings, labels, margin=0.3): """ embeddings: (N, D) labels: (N,) """ device = embeddings.device labels = labels.view(-1) dist = torch.cdist(embeddings, embeddings, p=2) pos_mask = labels.unsqueeze(0) == labels.unsqueeze(1) eye = torch.eye(embeddings.size(0), device=device, dtype=torch.bool) pos_mask = pos_mask & ~eye neg_mask = ~pos_mask neg_mask = neg_mask & ~eye # 距离矩阵中正样本位置保留原值,其余位置为 0 hardest_positive = torch.max( torch.where(pos_mask, dist, torch.zeros_like(dist)), dim=1 ).values # 负样本位置保留原值,其余位置设为极大值 neg_dist = dist + (~neg_mask).float() * 1e6 hardest_negative = torch.min(neg_dist, dim=1).values loss = torch.clamp(hardest_positive - hardest_negative + margin, min=0.0) return loss.mean()训练时需要注意采样策略。三元组损失对 batch 内 ID 分布敏感,建议每个 batch 包含若干个个体,每个个体至少 4 张图,否则很难找到有效的 positive 对。
6.3 训练循环骨架
下面是一个简化的训练循环示例,只演示核心思路:
# train.py 核心片段 import torch import torch.nn as nn from torch.utils.data import DataLoader from models.full_model import AnimalReIDModel from losses.triplet_loss import batch_hard_triplet_loss device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = AnimalReIDModel(num_classes=num_train_ids).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) ce_loss_fn = nn.CrossEntropyLoss() for epoch in range(num_epochs): model.train() for imgs, ids in train_loader: imgs = imgs.to(device) ids = ids.to(device) embedding, uncertainty, logits = model(imgs) triplet_loss = batch_hard_triplet_loss(embedding, ids, margin=0.3) ce_loss = ce_loss_fn(logits, ids) loss = triplet_loss + 0.5 * ce_loss optimizer.zero_grad() loss.backward() optimizer.step() # 每个 epoch 结束后在验证集上评估 print(f"epoch {epoch}, loss: {loss.item():.4f}")不确定性分支在训练时不强制增加额外损失,网络会通过“低质量样本特征不可分”间接学到较高不确定性。更复杂的论文可以给不确定性加上正则约束,让它与分类置信度对齐,但这里给出的版本已经可以用于工程实验。
7. 推理与评估
7.1 完整推理流程
把前面的模块串起来,推理流程可以封装成一个函数:
# inference.py import torch import numpy as np from metrics.calibrated_similarity import calibrated_cosine_similarity from clustering.knn_graph import build_knn_graph from clustering.graph_clustering import graph_clustering from clustering.open_set_postprocess import assign_open_set_ids @torch.no_grad() def inference(model, test_loader, alpha=1.0, k=10, min_cluster_size=3, min_confidence=0.3): model.eval() all_features = [] all_uncertainties = [] for imgs, _ in test_loader: imgs = imgs.to(device) embedding, uncertainty = model.extractor(imgs) all_features.append(embedding.cpu().numpy()) all_uncertainties.append(uncertainty.cpu().numpy()) features = np.concatenate(all_features, axis=0) uncertainties = np.concatenate(all_uncertainties, axis=0) # 校准相似度 sim_matrix = calibrated_cosine_similarity( torch.from_numpy(features), torch.from_numpy(uncertainties), alpha=alpha ).numpy() # kNN 图 + 聚类 edges = build_knn_graph(features, k=k) labels, clusters = graph_clustering(edges) # 开集后处理 final_ids, is_unknown = assign_open_set_ids( labels, features, sim_matrix, min_cluster_size=min_cluster_size, min_confidence=min_confidence ) return final_ids, is_unknown, labels, sim_matrix推理流程的关键点在于:整批图像是同时参与的,而不是逐张查询。这样图聚类才能用到全局信息。
7.2 评估指标
Open-Set Animal Re-ID 的评估通常分成两个维度:
已知个体部分:
- mAP(mean Average Precision):排序结果中正样本排得越靠前,mAP 越高。
- CMC Top-1 / Top-5:查询图在候选集中命中的概率。
未知个体部分:
- 未知样本召回率:真正的新个体有多少被标记为 unknown。
- 已知个体误报率:已知个体被错误标记为 unknown 的比例。
- 聚类纯度:每个聚类簇是否只包含同一个个体。
实际项目中建议以“已知个体 mAP + 未知个体召回率”为主要指标,因为这两个指标更能反映 Open-Set 模型的真实能力。
8. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| faiss 查询结果全是自身 | 查询时包含了自身节点 | 搜索k + 1个邻居后去掉第一列 |
| 聚类结果非常碎 | kNN 图 k 值太小 | 适当增大 k,或降低 Louvain 的 resolution |
| 所有图被聚成一个大簇 | k 值太大,噪声边过多 | 减小 k,提高相似度边阈值 |
| 不确定性与特征无关 | 训练时缺少不确定性监督 | 加入不确定性伪标签或分类置信度约束 |
| 新个体被并进已知簇 | 簇内相似度阈值过低 | 统计已知个体簇内相似度分布后抬高阈值 |
| 同一只动物被拆成多个 ID | min_cluster_size 太大 | 缩小 min_cluster_size,或检查聚类参数 |
| 相似度校准后性能反而下降 | alpha 调得过大 | 在验证集上做网格搜索 |
排查这类问题有一条通用路径:先检查特征分布,再检查相似度矩阵,最后检查聚类结果。这三步可以串成一条数据管线,分别输出中间结果,快速定位是哪一环出了问题。
9. 最佳实践与工程建议
9.1 数据层面
动物 Re-ID 的数据质量往往比算法更关键。建议拍摄或收集数据时尽量覆盖多角度、多光照、多姿态。如果数据量有限,可以先用物种分类模型做预筛选,把明显不是目标物种的图像去除。
此外,训练集和测试集的采集时间最好错开。如果训练集全部来自夏季,测试集全部来自冬季,模型会学到季节背景偏差,Open-Set 表现会很差。
9.2 模型层面
- 特征维度不需要一味加大,512 维在大多数动物 Re-ID 任务上已经足够。
- 保留图像局部信息可以采用 Part-based 结构,把图像切成若干水平条分别提取特征再融合。
- 不确定性分支建议放在最后一个卷积层之后,而不是直接放在分类特征上。
9.3 聚类与后处理层面
聚类结束后不要直接相信绝对标签。建议对每个簇额外统计:
- 簇内最大相似度与最小相似度。
- 簇内图像的时间跨度。
- 簇内图像来自多少个不同的相机机位。
时间跨度和相机机位这两个信息在野外场景中非常有用。同一个体如果在一个小时内出现在两个相距很远的相机中,就需要人工复核,因为可能是误聚类。
9.4 部署与日志
推理服务建议保存每个样本的特征、不确定性、聚类标签以及被哪个阈值决策为 unknown 的完整日志。这样后续调阈值、复现线上问题时,才有据可查。
线上模型更新时,不要直接替换整个特征库。建议保留旧特征库,通过简单对比新旧特征在新样本上的聚类一致性,防止特征空间漂移导致历史识别结果混乱。
10. 总结与延伸
围绕 Calibrated Similarity 和 Graph Clustering 的组合,这套 Open-Set Animal Re-ID 方案的核心思路可以概括为四步:用特征网络提取可靠表示,用不确定性校准相似度,用图聚类完成全局分组,用一致性阈值识别新个体。相比传统封闭集方法,它的优势在于不依赖预先固定的个体数量,能够在数据动态增长的真实场景中持续工作。
训练阶段,三元组损失加上分类辅助损失基本能满足大多数动物数据集的需求;推理阶段,faiss 构建 kNN 图再配合 Louvain 聚类,是工程上比较成熟且代码量可控的路线。真正决定 Open-Set 效果上限的,往往不是某个网络结构,而是数据采集的覆盖度、相似度校准的强度以及簇级别的置信度阈值设置。
后续可以从这几个方向继续深入:在不确定性分支中引入显式的伪标签监督,把聚类结果反向用于难样本挖掘,或者在图聚类之前先做一轮基于属性的粗筛选。如果本文对你有帮助,可以收藏备用,也欢迎在评论区交流你项目中遇到的 Open-Set 识别问题。