猪脸识别实战:从Softmax到ArcFace的细粒度度量学习全流程
2026/9/10 7:00:17 网站建设 项目流程

简介:面向全国大学生电子设计竞赛备赛者与算法竞赛入门学生,这份压缩包取自京东JDD猪脸识别比赛,以真实赛题为主线,提供完整可运行的实战源码与说明文档。包内共5个文件,包括4个Python脚本和1个Markdown说明,分别对应模型微调、训练、测试、生成提交结果等环节,并配有README快速说明代码结构与调用关系;整体仅6KB,轻量而紧凑,适合在电赛或同类视觉任务中直接借鉴工程组织方式。代码按功能拆分,注释清晰,便于二次修改与扩展。目前已有251人学习下载,作者zy_dreamer将竞赛经验沉淀为可复现的示例,既能帮助理解图像识别项目从数据准备到结果提交的完整流程,也可作为课程设计、毕业设计或项目起步时的参考模板。

1. JDD猪脸识别比赛:先想清楚这是分类还是度量学习

打开一个名为「京东JDD猪脸识别比赛.zip」的压缩包,绝大多数人的第一反应是解压、找 train.py、把学习率调低然后硬训。但真正决定你在排行榜上位置的,往往不是模型结构选得多新,而是你如何定义这个问题。猪脸识别和普通的人脸识别一样,本质上是一个细粒度个体识别问题:数据里可能只有几百头猪,每头猪有若干张不同角度、不同光照、甚至不同脏污程度的照片,你要做的是判断“这一张脸属于哪一头猪”。

这和 ImageNet 式的分类有本质区别。分类任务里每个类别有大量样本、类间差异大,而猪脸识别类内差异大(同一头猪在不同生长阶段脸型会变)、类间差异小(纯黑猪和纯黑猪之间五官差异极其细微)。比赛给的 zip 里通常包含训练集图片、标注 JSON 和评估脚本,但你真正需要关心的第一件事不是模型,而是评估协议:它用 Top-1 Acc 还是 Recall@K,决定了你的特征输出要不要做归一化,决定了损失函数选 Softmax 还是 Triplet。这篇文章我会从一个参赛工程师的视角,把数据处理、检测、识别、评估和部署这条链路完整拆开,每个环节都给出可直接抄走的代码和调参经验。

2. 打开JDD比赛包:猪脸检测与数据清洗

2.1 先读数据协议:标签、划分与评估口径

JDD 猪脸识别比赛的数据组织方式通常是一个 train 目录加上若干个 JSON 标注文件。标签文件里的核心字段一般是image_idpig_id和标注框坐标,部分版本还会附带猪舍编号、拍摄时间等元信息。第一步永远不是写模型,而是写一个数据检查脚本,把标注分布打印出来,确认三个事情:类别总数、每类样本数的最小值和最大值、以及图片是否存在损坏或标注越界。

这类比赛的数据集典型特点是长尾分布,可能一半的猪有上百张图,但尾部有几头猪只有三五张。直接用原始分布训练,模型会对高频个体过拟合,低频个体几乎学不到判别特征。常见的做法是先统计频次,设定一个阈值(比如少于 10 张的个体),决定是丢弃、扩充还是做类别加权。另一个高频坑是标注框边界越界,PIL 读取后坐标可能是负数或超出图像宽高,这类噪声如果不处理,数据增强时会直接报错,而且特别难排查。

import json from PIL import Image from collections import Counter with open("annotations/train.json") as f: anns = json.load(f) pig_counter = Counter() bad_images = [] for item in anns: img_path = f"train/{item['image_id']}.jpg" try: img = Image.open(img_path) w, h = img.size x, y, bw, bh = item["bbox"] # 坐标可能为负、可能超界,统一做 clip if x < 0 or y < 0 or x + bw > w or y + bh > h: item["bbox"] = [max(0, x), max(0, y), min(bw, w - x), min(bh, h - y)] pig_counter[item["pig_id"]] += 1 except Exception: bad_images.append(img_path) print("个体总数:", len(pig_counter)) print("最少样本数:", min(pig_counter.values())) print("坏图:", bad_images)

这段脚本的核心价值在于把「标注质量」变成可量化的指标。min(pig_counter.values())如果小于 10,你的数据增强策略就要偏向裁剪、旋转这类保持身份信息的变换,而不能用随机擦除这类可能把判别区域抹掉的强增强。bbox的 clip 操作一定要在数据加载器外部完成,不要在__getitem__里做,否则每次迭代重复计算会拖慢训练速度。

2.2 猪脸检测:检测框质量直接决定识别上限

猪脸识别的流程通常是两段式:先用目标检测模型从整图中裁出脸部区域,再把裁好的脸图送入识别模型。检测的质量决定了识别模型看到的是什么——框大了会把脖子和身体背景带进来,框小了会切掉耳朵和眼睛,这两种情况都会让识别模型的输入分布漂移。

比赛环境下,我一般不会去用 Cascade R-CNN 这类重模型,主要原因是训练时间和显存开销大,而猪脸检测本身不是难点,难度在于尺度。猪脸在画面中的占比可能从 5% 到 40% 不等,这要求检测器对小目标有足够的召回。YOLOv8 的sm级别即可,输入分辨率设到 960 以上,mAP 通常能到 95 以上,足够用了。

from ultralytics import YOLO model = YOLO("yolov8m.yaml").load("yolov8m.pt") model.train( data="pig_face.yaml", epochs=60, imgsz=960, batch=16, lr0=0.005, augment=True, patience=15, save_period=10, )

pig_face.yaml里的数据格式是 YOLO 标准格式,标注框需要从 JSON 转换成归一化的中心点坐标。imgsz=960是这类比赛的甜点参数,再往上显存消耗倍增但 mAP 提升有限。augment=True启用 Ultralytics 内置的马赛克和随机仿射变换,能有效应对猪只在栏舍内的姿态变化,但要注意马赛克增强会改变目标尺度分布,训练后期可以关掉。

检测模型训完后要做一次全量推理,把裁出来的猪脸图保存成独立文件。这里有个容易忽略的点:推理时的置信度阈值不要用默认的 0.25,比赛场景下误检的代价远低于漏检,建议降到 0.1,把边界样本都保留下来,让识别模型去处理难例。

2.3 数据清洗:用特征聚类找出标注错误

标注错误是这类比赛里最隐蔽的杀手。猪脸的标注如果标错了个体,模型会学到错误映射,而且这种错误在验证集上几乎发现不了,因为验证集的标注同样可能是错的。清洗标注最有效的手段不是人眼看图,而是用一个预训练好的通用视觉模型提取特征,然后对同一头猪的所有图片做聚类,找出离群样本。

from sklearn.cluster import DBSCAN import numpy as np features = np.load("features/pig_007.npy") # 形状: [N, 512] feat_norm = features / np.linalg.norm(features, axis=1, keepdims=True) clustering = DBSCAN(eps=0.35, min_samples=3, metric="cosine") labels = clustering.fit_predict(feat_norm) outliers = np.where(labels == -1)[0] print("疑似标注错误样本索引:", outliers)

DBSCAN 的好处是不需要预先指定类别数,eps=0.35这个阈值是从经验出发的起点,实际使用时要根据特征分布的相似度矩阵调整。min_samples=3意味着小于 3 张图的聚类会被视为噪声。标注错误通常表现为:同一头猪的图片特征在空间上形成多个分散的小簇,或者个别特征向量明显游离在主簇之外。

3. 猪脸识别核心:从Softmax到度量学习

3.1 为什么通用分类损失在猪脸识别上不够用

直接用 ResNet 加 Softmax 训练猪脸识别,验证集准确率可以做到 95% 以上,但线上成绩往往不理想,原因在于 Softmax 训练出的特征只是「类别可分」,而不是「度量可比较」。Softmax 的决策边界会把特征空间划分成扇形区域,但同一头猪的不同角度照片可能落在扇形边缘,而不同猪的正面照反而可能距离更近。

这个问题在测试阶段会彻底暴露:比赛通常会用测试集里的图片构建检索任务,计算 query 与图库之间的距离,这时候特征向量之间的度量方式直接决定结果。如果训练时没有显式地优化特征距离,余弦相似度和欧氏距离的结果会有很大差异。所以真正可靠的做法是切换成度量学习损失,让同类样本的特征在超球面上聚集、异类样本相互排斥,这样推理阶段无论用哪种距离度量都能得到稳定结果。

3.2 用ArcFace把特征拉进超球面

ArcFace 是当前人脸识别领域的事实标准,用在猪脸识别上同样成立。它的核心是给 Softmax 加上角度间隔,让模型在训练时就学会压缩类内角度距离、扩大类间角度差异。相比 Triplet Loss,ArcFace 不需要难例挖掘的工程复杂度,训练稳定性也好很多。

import torch import torch.nn as nn import torch.nn.functional as F class ArcFaceLoss(nn.Module): def __init__(self, in_features, out_features, s=32.0, m=0.50): super().__init__() self.weight = nn.Parameter(torch.FloatTensor(out_features, in_features)) nn.init.xavier_normal_(self.weight) self.s = s self.m = m self.cos_m = torch.cos(torch.tensor(m)) self.sin_m = torch.sin(torch.tensor(m)) self.th = torch.tensor(torch.cos(torch.pi - m)) self.mm = torch.tensor(torch.sin(torch.pi - m) * m) def forward(self, features, labels): # 特征归一化,消除样本数量带来的尺度差异 features = F.normalize(features, p=2, dim=1) weight_norm = F.normalize(self.weight, p=2, dim=1) cos_theta = torch.matmul(features, weight_norm.T) cos_theta = torch.clamp(cos_theta, -1.0 + 1e-7, 1.0 - 1e-7) sin_theta = torch.sqrt(1.0 - torch.square(cos_theta)) cos_theta_m = cos_theta * self.cos_m - sin_theta * self.sin_m # 处理角度超出范围的情况,防止梯度异常 cond = cos_theta > self.th cos_theta_m = torch.where(cond, cos_theta_m, cos_theta - self.mm) one_hot = F.one_hot(labels, num_classes=self.weight.shape[0]) output = torch.where(one_hot.bool(), cos_theta_m, cos_theta) return F.cross_entropy(output * self.s, labels)

代码里两个关键参数是s=32.0m=0.50s是缩放系数,控制特征向量的模长,值越大训练初期收敛越快,但太大容易梯度爆炸,32 是人脸识别领域经过大量实验验证的安全值。m是角度间隔,间隔越大类内约束越强,但如果训练样本过少,过大的间隔会导致模型欠拟合,尾部类别样本数小于 10 时建议把m降到 0.3。这里的thmm用于处理角度接近 π 的边界情况,不加这两个约束训练会偶发 NaN。

骨架网络建议用 ResNet50 或更轻量的 RepVGG,输出特征维度设在 512。训练时输入分辨率统一到 224x224,增强策略只保留随机水平翻转、小角度旋转和轻度颜色抖动,不要用 RandomErasing,猪脸的被遮挡区域往往是身份信息的一部分。

3.3 推理:提取特征做向量检索

训练完成后,识别阶段不再需要分类头。要做的是冻结骨干网络,去掉最后一层全连接,让每张猪脸图输出一个 512 维的归一化特征,然后把整个图库的特征存成矩阵,查询时用矩阵乘法一次性计算 query 和所有图库样本的余弦相似度。

import torch import torch.nn.functional as F import numpy as np def extract_features(model, dataloader, device): model.eval() features, ids = [], [] with torch.no_grad(): for images, labels in dataloader: images = images.to(device) feat = model(images) # 输出 [B, 512] feat = F.normalize(feat, p=2, dim=1) features.append(feat.cpu().numpy()) ids.extend(labels.numpy()) return np.vstack(features), np.array(ids) def search(feat_query, gallery_feats, gallery_ids, top_k=5): sim = feat_query @ gallery_feats.T # 余弦相似度矩阵 idx = np.argsort(sim)[::-1][:top_k] return gallery_ids[idx], sim[idx]

这里的extract_features中的归一化必须保留,因为训练时 ArcFace 也做了同样的操作,推理和训练的一致性直接决定最终效果。search函数里用矩阵乘法替代循环计算余弦相似度,图库量级在几万张以下时耗时在毫秒级,完全不需要引入 FAISS 一类的工具。如果图库规模超过十万张,才考虑用 FAISS 的 IVF 索引做近似检索。

4. 评估、调参与模型部署

4.1 用Recall@K评估个体识别效果

比赛的评估指标决定了你的调参方向。如果官方用 Top-1 Acc,那么模型输出就是每张 query 匹配到的最近邻类别;如果用的是 Recall@K,那么要求真实类别出现在前 K 个结果里就算命中。这两种指标对应不同的训练策略,前者更看重特征判别力,后者更看重特征的鲁棒性和覆盖度。

def recall_at_k(sim_matrix, labels, query_ids, k=5): """sim_matrix: [Q, N], labels: 图库标签, query_ids: query标签""" topk_idx = np.argsort(-sim_matrix, axis=1)[:, :k] correct = 0 for i, qid in enumerate(query_ids): hit = np.any(labels[topk_idx[i]] == qid) correct += int(hit) return correct / len(query_ids)

实际评估时要特别关注尾部类别的 Recall@K,普通个体准确率 99% 时,尾部可能只有 60%。一个实用的做法是把图库按样本数量分桶统计,样本数小于 10 的个体单独报告,这些数字才是你判断模型是否过拟合到高频个体的依据。

4.2 推理优化:ONNX导出与批处理

比赛演示和实际部署的场景不同,但优化路径一致。PyTorch 模型在 CPU 上跑单张推理耗时可能 200 毫秒,导出成 ONNX 后用 TensorRT 或 OpenVINO 加速可以降到 20 毫秒以内。导出前要把模型切换成 eval 模式,并把动态维度固定下来。

import torch.onnx model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "pig_face.onnx", input_names=["input"], output_names=["embedding"], dynamic_axes={"input": {0: "batch"}, "embedding": {0: "batch"}}, opset_version=17, )

dynamic_axes里把 batch 维度设成动态,这样同一个模型可以同时接受单张查询和批量图库提取的任务,避免分别导出两个模型。ONNX 导出后建议用onnxruntime自带的模型优化器做一次图优化,再把固定 batch 的版本交给 TensorRT 做 FP16 量化。如果目标环境是 CPU,优先选 OpenVINO 而不是 TensorRT。

4.3 比赛zip里的工程化陋习与对策

这类比赛项目 zip 里最常见的工程化问题有三个:一是路径写死,训练代码里硬编码了绝对路径,换机器就崩;二是随机种子未固定,导致实验结果不可复现;三是没有 checkpoint 恢复机制,训练中断必须从头来。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

deterministic=True会让卷积选择确定性的算法,训练速度稍微下降但结果可复现。benchmark=False关闭了 cuDNN 的自动调优,避免同一个模型在不同机器上产生不同行为。这两个设置加在训练脚本第一行,是比赛复现的基本功,也是工程协作的底线。

5. 三个提分技巧:硬样本挖掘、测试时增强与特征后处理

第一个技巧是困难样本挖掘。用当前最好的模型对训练集做一次前向推理,找出每个 query 的错误匹配和低置信度匹配,把这些样本的权重放大或加入一个额外的难例数据集参与训练。具体做法是维护一个「错例池」,每次 epoch 开始时从池里采样一部分样本混入训练集,比例控制在 20% 左右。这个操作在 Pig 数据集上通常能带来 1 到 3 个百分点的提升,性价比远高于换更大的骨干网络。

第二个技巧是测试时增强。对每张 query 图片做水平翻转,把原始特征和翻转特征相加后归一化,再拿去检索。实现很简单,代码只需要几行:

from torchvision import transforms flip_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=1.0), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) feat_orig = model(orig_tensor.unsqueeze(0)) feat_flip = model(flip_transform(orig_img).unsqueeze(0)) feat_fused = F.normalize(feat_orig + feat_flip, p=2, dim=1)

这里的相加不是平均,而是先加再归一化,保留了方向信息又消除了模长差异。TTA 会把推理时间加倍,但比赛环境下的特征提取通常是离线完成,线上只有查询阶段的单次前向,所以图库特征用 TTA 提取、query 用 TTA 提取,完全不影响实时性。

第三个技巧是特征后处理中的 PCA 白化。在图库上统计特征的协方差矩阵,做 PCA 降维到 256 维后再白化,能够有效消除不同拍摄设备、不同光照引入的偏移分量。这一步对单个模型的提升不如前两个技巧明显,但如果你做了多模型集成,白化能显著提高特征融合的稳定性。

最后提醒一个容易被忽略的点:所有技巧加完之后,回到测试集重新计算一次完整 pipeline 的 Recall@K。比赛包的评估脚本如果只提供了 Top-1 Acc,你自己要补上 Recall@5 和 Recall@10 的统计,这两个数字比单个准确率更能反映特征质量。用同一套评估逻辑对比加 TTA 前后的差异,再去决定是否保留这个耗时操作。

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

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

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

立即咨询