1. 先弄清楚“性能”指什么:评估对象与指标体系
人脸识别系统的性能评估,最忌讳的就是拿一个准确率数字当结论。我在实际项目里见过太多次这种情况:某同事在自建测试集上跑出 99.2% 的准确率,汇报时很漂亮,结果上线第一天,业务侧就反馈“认错人”。问题不在模型,在于评估的对象、口径和被比较的东西从一开始就错位了。人脸识别系统从来不是一个单一模型,它是一条由人脸检测、关键点对齐、质量筛选、特征提取、向量比对、阈值决策,再加后续的人员跟踪与 ID 维持共同组成的流水线。性能评估必须把这条流水线当成一个整体来拆,否则你测的只是其中某个环节的实验室成绩。
这一章我想先把“评什么”说清楚。评估对象弄不明白,后面所有优化动作都是盲打。尤其是当标题里同时出现“人员检测与跟踪”和“人脸识别”时,很多人会把检测指标、识别指标、跟踪指标混在一张表里汇报,看起来信息量很大,实际上谁也读不懂结论从哪来、边界在哪。我习惯的做法是先画一条数据流,把每个环节的输入输出、可测指标、失败模式都列出来,再决定测哪几项、优先级怎么排。
1.1 拆解流水线:五个环节各自测什么
一条典型的人员检测与人脸识别流水线大致是这样走的:输入视频帧或图片流,检测模块给出人脸框或人体框,对齐模块根据关键点做仿射变换把人脸摆正,质量模块打分决定这张脸要不要参与后续,特征模块输出一个定长向量,比对模块在底库里做相似度检索,最后结合业务阈值输出身份结论。如果还带跟踪,那么检测结果要先经过多目标跟踪关联成轨迹,轨迹上再做人脸识别投票。每个环节的失败都会向下游放大,而最终指标是这些放大叠加后的结果。
所以评估至少有五个层次。第一层是检测:人脸框的召回率和定位精度,常用 IoU 阈值下的 AP,注意小脸、侧脸、遮挡、逆光这些子集要单独拆开看。第二层是对齐与质量:对齐失败率、关键点误差、质量分分布,这一层不产生漂亮数字,但对最终误识率的影响远比大多数人想象的大。第三层是特征与比对:1:1 验证看 ROC、EER、TAR@FAR,1:N 检索看 Rank-1、Rank-5、mAP 和误接受率。第四层是决策与业务:在特定阈值下的人均误报次数、通过率、人工复核比例。第五层是跟踪侧:MOTA、MOTP、IDF1、ID Switch 次数。
我一般会强调一个观点:面向业务的性能评估,最终只认后两层。你 EER 做到 0.3%,但业务要求的是每人每天误报不超过 0.1 次,中间还差着阈值选择、底库规模、质量分布三道坎。反过来,只看业务指标也不行,因为业务指标一旦恶化,你不知道是特征模型退化、底库污染,还是视频源的光照变了。两层一起看,才能定位。
注意:不要把不同底库规模下的 1:N 识别率横向比较。底库从 1 万涨到 10 万,Rank-1 掉几个点是正常的,掉多少取决于特征分布和检索策略,直接对比毫无意义。
1.2 指标口径不统一,评估基本等于白做
我在不同团队之间做过几次评估体系对齐,发现最容易出问题的不是算错,而是口径不一致。同一个“准确率”,有人指 Top-1 命中率,有人指阈值化之后的通过率,有人指人工复核后确认正确的比例。这三种数字放在一个会议上讨论,怎么也对不上。所以做评估的第一步不是跑脚本,而是把指标定义写成一张表,全员确认,脚本里严格按定义实现。
下面这张表是我常用的指标清单,直接照抄就能用,重点是“注意点”那一列,这是最容易踩坑的地方。
| 指标 | 含义 | 适用场景 | 注意点 |
|---|---|---|---|
| FAR / FRR | 误接受率与误拒绝率 | 1:1 验证、门禁通行 | 必须绑定具体阈值,脱离阈值报 FAR 是耍流氓 |
| TAR@FAR | 在给定误接受率下的通过率 | 安全等级要求明确的场景 | FAR 取值越小,样本量要求越高,1e-4 至少需要十万级负样本对 |
| EER | 误接受与误拒绝相等的点 | 快速横向比较模型 | 只在分数分布干净时有意义,跨域会失真 |
| Rank-1 / Rank-5 | 底库检索首位/前五位命中率 | 1:N 检索、人员查找 | 底库规模与人脸数量必须写清楚,多人多照要说明合并规则 |
| mAP | 平均精度均值 | 检索排序质量评估 | 对正样本数量敏感,单人单照时参考价值有限 |
| MOTA | 多目标跟踪总体准确度 | 视频人员跟踪 | 由漏检、误检、ID 跳变合成,单项恶化会被平均掩盖 |
| IDF1 | 身份维度的跟踪一致性 | 强调 ID 稳定的场景 | 比 MOTA 更能反映“跟丢了又找回”的问题 |
| ID Switch | 轨迹 ID 变更次数 | 长时跟踪、轨迹归档 | 统计口径要明确连续多少帧算切换 |
表格填完还要补一条约定:所有指标必须标注数据集名称、版本、底库规模、阈值、评估日期。缺任何一项,这个数字三个月后就没法复现。我吃过这个亏,某次降本把评估脚本的随机采样种子改了,季度复盘时同一份数据跑出两个差 1.7 个点的结果,查了两天才发现是负样本对采样不同。
1.3 阈值这件事,必须从业务反推
阈值不是调出来的,是算出来的。业务上常见的要求是“每人每天误报不超过 N 次”,那么可以反推需要的 FAR。假设某场景日均比对请求 2000 次,要求误报不超过 0.2 次,那么 FAR 上限就是 0.2 / 2000 = 1e-4。这个数字一出来,你就知道评估集需要多少负样本对才具备统计意义:至少要观察到十次以上的误接受事件,样本量得在十万到百万量级,否则测出来的 FAR 全是噪声。
正样本侧同样要算。假设底库 5 万人,每人平均 3 张注册照,比对时取最高分。1:N 的误接受概率粗略随底库规模线性增长,5 万人规模的 Top-1 误接受率约等于单对 FAR 乘以底库人数,所以单对 FAR 要求会比 1:1 场景严苛一两个数量级。这就是为什么很多团队在 1:1 上做得很好,一上 1:N 就崩,本质是没按检索规模重新算过阈值。
实操上我会做一张阈值-业务对照表,把 FAR、TAR、通过率、人工复核率、人均误报次数放一起,给业务方选。选完定版,写进配置,模型或数据一变就重新生成这张表。这里的关键认知是:阈值是业务参数,不是模型参数,不该由算法同学单独拍板。
2. 评估集如何搭建:它比模型更能决定结论
我经常说,评估集的质量决定了你所有结论的上限。模型再好,评估集不具代表性,结论一样是错的;反过来,评估集做扎实了,哪怕模型一般,你也能清楚知道它弱在哪、能不能用。这一章讲评估集怎么搭,包括底库分层、跨域采样、难例构造和标注质控,都是实打实能落地的做法。
2.1 三层划分:底库、探针集、负样本池
评估集我习惯分三层来组织。第一层是注册底库,模拟真实入库数据,包含人员 ID、每人多张照片、入库时的质量分布。第二层是探针集,模拟真实查询,来源要和底库有明显差异,比如不同摄像头、不同时间段、不同光照条件。第三层是负样本池,用于计算 FAR,必须保证里面的人在底库中完全不存在,这一点最容易出错——某个 ID 只改了个名字重新入库,就被当成负样本,FAR 立刻虚高。
三层的数据来源要尽量贴近生产。我一般会按摄像头、时段、光照、遮挡程度做交叉分层,每层保证最低样本量。比如某园区有 12 路摄像头,白天 8 路、夜间 4 路,那么探针集里夜间样本至少占三分之一,否则你永远发现不了夜间误识率飙升的问题。注册底库也一样,要包含一定比例的“劣质注册照”,比如侧脸、戴帽、逆光,因为真实入库时业务人员不会帮你筛选。
规模上给个参考:底库 1 万到 5 万人、探针 5000 到 2 万张、负样本池 10 万以上,这套配置跑一次完整评估大约需要十几分钟到半小时,足够支撑日常迭代。如果要验证 1e-4 级别的 FAR,负样本池得往百万级走,这时候就得上分片评估和向量化计算,不能拿单机脚本硬扛。
2.2 主动构造难例:让结果提前“变坏”
等生产环境暴露问题太被动,我习惯在评估集里主动埋难例。做法是围绕已知的薄弱维度构造子集,每个子集单独出指标,而不是混在一起看总分。常见的难例子集包括:大角度侧脸(偏航超过 45 度)、强逆光、口罩或手部遮挡、低分辨率(人脸框小于 60 像素)、跨年龄(注册照与探针相隔两年以上)、双胞胎或高相似人员。
这里有个经验:难例子集不要追求“多”,要追求“准”和“可复现”。每个子集几十到几百张就够,关键是标签准确、每次评估都用同一批。我见过团队为了凑难例,从网上随便下了几千张模糊人脸,结果这些图和业务场景完全无关,指标难看但毫无指导意义,优化方向也被带偏。
另一个做法是构建“高相似负样本对”,也就是长相接近但不同人的照片对。这类样本对是 1:N 误识的主要来源,专门拿来算 FAR 会比随机负样本对严格得多。构造方式可以借助模型自己:先用当前模型跑一遍全量负样本池,把相似度最高的一批人工复核,确认是不同人后纳入高相似子集。这样得到的子集能持续暴露模型盲区,价值很高。
2.3 标注质控:错误标签会把优化带进沟里
标注错误在评估集里是隐形杀手。一张标错的人脸,可能让你的误识率凭空多出几个百分点,然后你花两周去查模型,最后发现是数据问题。我现在的做法是双人标注加抽样复核,冲突样本进仲裁队列。人脸识别场景的标注主要是两类:身份标签和框/关键点。身份标签的冲突率一般不高,但如果底库来自多个业务系统,重名、一人多号、离职未清理这些问题会带来大量脏数据。
具体质控手段有几个。第一,用模型辅助查重:对底库做全员两两相似度检索,把相似度极高但标签不同的样本对挑出来人工确认,这一步能捞出大部分“同一人被标成两人”的问题。第二,用质量分过滤明显不可用的注册照,避免它们污染比对统计。第三,建立版本化机制,评估集每次修改都打标签、记变更日志,绝不覆盖原文件。
提示:评估集一旦定版就别轻易动。如果必须修,改完之后要把历史结论全部重跑一遍,否则前后数字不可比,复盘时会非常痛苦。
3. 从脚本到报告:一次完整的评估实操
讲完指标和数据,这一章直接上操作。我把一次完整评估拆成四步:环境与数据准备、各环节指标计算、跟踪侧指标计算、报告生成与结论判定。代码用 Python 写,依赖 numpy、scikit-learn、faiss,都是常规库。下面的代码不是示意,是我实际项目里裁出来的简化版,可以直接改改就用。
3.1 环境准备与数据组织
数据目录我固定成一套结构,避免每次换项目重新想。大致是这样:
dataset/ gallery/ # 注册底库 id_00001/ 001.jpg 002.jpg id_00002/ ... probe/ # 探针集 id_00001/ p_001.jpg ... negative/ # 负样本池,与 gallery 无交集 n_00001.jpg ... meta/ gallery.csv # id, path, quality_score, camera probe.csv split.json # 难例子集划分依赖安装很简单,要注意版本锁定,尤其是 faiss 和 numpy 的兼容性,不同版本之间的行为差异会直接影响检索结果。
pip install numpy==1.26.4 scikit-learn==1.4.2 faiss-cpu==1.8.0 pandas==2.2.2读取数据阶段我建议一次性把特征算完并缓存成.npy,后面反复评估直接加载,不然每次调阈值都重新提特征,时间全浪费在 GPU 上。缓存文件命名带上模型版本和时间戳,避免混用。
import numpy as np, pandas as pd, os def load_meta(csv_path): df = pd.read_csv(csv_path) assert {"id", "path"}.issubset(df.columns), "缺少必要列" return df def build_feature_cache(df, extractor, cache_path): if os.path.exists(cache_path): return np.load(cache_path) feats, valid = [], [] for i, row in df.iterrows(): f = extractor(row["path"]) # 返回归一化后的定长向量 if f is None: continue feats.append(f) valid.append(i) feats = np.vstack(feats).astype("float32") np.save(cache_path, feats) df.iloc[valid].reset_index(drop=True).to_csv( cache_path.replace(".npy", "_meta.csv"), index=False) return feats这里有两个细节值得说。第一,特征一定要做 L2 归一化,之后用内积就是余弦相似度,省去每次算距离。第二,提特征失败的样本必须记录下来,不能静默丢弃,因为失败率本身就是一个质量指标,检测漏掉的人脸往往就是难例。
3.2 1:1 与 1:N 指标计算
1:1 验证的指标计算比较直接,正样本对按 ID 组合,负样本对从负样本池随机取,算相似度后扫描阈值得到 ROC,进而得到 TAR@FAR 和 EER。
from sklearn.metrics import roc_curve def compute_roc(pos_scores, neg_scores): y = np.r_[np.ones(len(pos_scores)), np.zeros(len(neg_scores))] s = np.r_[pos_scores, neg_scores] fpr, tpr, thr = roc_curve(y, s) return fpr, tpr, thr def tar_at_far(fpr, tpr, target_far=1e-3): idx = np.searchsorted(fpr, target_far) idx = min(idx, len(tpr) - 1) return float(tpr[idx]) def eer(fpr, tpr): diff = np.abs(fpr - (1 - tpr)) return float(fpr[np.argmin(diff)])注意tar_at_far里的边界处理,如果测试集的负样本不够多,fpr 序列根本到不了 1e-3 以下,这时searchsorted会返回末尾值,看起来有结果,实际是外推出来的假数字。所以我在脚本里会加一条断言:负样本对数量必须大于1/target_far的十倍,不满足就直接报错,不允许出报告。
1:N 检索用 faiss 做,底库向量建索引,探针逐条查询取 Top-K。千万级以下用 IVF-Flat 就够,nlist 按4 * sqrt(N)取,nprobe 从 16 开始往上调,边调边看召回和耗时的平衡点。代码大致如下:
import faiss def build_index(gallery_feats, nlist=None): d = gallery_feats.shape[1] nlist = nlist or int(4 * np.sqrt(len(gallery_feats))) quantizer = faiss.IndexFlatIP(d) index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT) index.train(gallery_feats) index.add(gallery_feats) index.nprobe = 16 return index def eval_rank(index, probe_feats, probe_ids, gallery_ids, ks=(1, 5)): D, I = index.search(probe_feats, max(ks)) hit = {k: 0 for k in ks} for row, pid in enumerate(probe_ids): for k in ks: cand = gallery_ids[I[row, :k]] if pid in cand: hit[k] += 1 return {k: hit[k] / len(probe_ids) for k in ks}跑完这一步,顺手把“最相似但身份错误”的样本对导出来看看,通常会看到一批高相似负样本,这是最有价值的分析素材。我一般会抽 100 对人工过一遍,看看是姿态相近、光照相近,还是底层特征在某些人群上区分度不足。
3.3 跟踪侧指标:MOTA 与 IDF1 怎么算
带跟踪的场景,还需要算 MOTA、IDF1、ID Switch。这几个指标依赖“检测框与真实框的匹配”这一步,匹配用匈牙利算法基于 IoU 做。MOTA 由漏检、误检、ID 跳变三项合成,公式是1 - (FN + FP + IDSW) / GT。IDF1 则是从身份维度算全局匹配后的 F1 值,更能反映跟踪的连续性。
from scipy.optimize import linear_sum_assignment def match_frame(gt_boxes, pred_boxes, iou_thr=0.5): if len(gt_boxes) == 0 or len(pred_boxes) == 0: return [], list(range(len(gt_boxes))), list(range(len(pred_boxes))) iou = compute_iou_matrix(gt_boxes, pred_boxes) rows, cols = linear_sum_assignment(-iou) matches = [(r, c) for r, c in zip(rows, cols) if iou[r, c] >= iou_thr] matched_g = {r for r, _ in matches} matched_p = {c for _, c in matches} unmatched_g = [i for i in range(len(gt_boxes)) if i not in matched_g] unmatched_p = [i for i in range(len(pred_boxes)) if i not in matched_p] return matches, unmatched_g, unmatched_p这段代码里compute_iou_matrix需要自己实现,用 numpy 广播算就行,注意别用循环,几千个框的循环会拖到秒级。算出逐帧匹配后,再按轨迹维护身份映射,统计 ID Switch。这里有个统计口径要定清楚:同一帧内 ID 分配混乱算不算切换、轨迹短暂消失后重新出现算不算切换。我一般按“连续丢失超过 N 帧后重新分配了不同 ID”才算一次切换,N 取 5 到 10,写进评估说明。
3.4 报告组织:结论怎么下才不心虚
报告我固定成三段式。第一段是总览表,给出各指标当前值和上次版本的对比,只放变化超过阈值的项。第二段是分层诊断,按摄像头、时段、光照、人脸尺寸分组列出指标,标出最差的三个子集。第三段是样本佐证,每类失败模式贴 5 到 10 张图,注明失败原因分析。
判断能不能上线,我一般看三条线:整体指标是否达标、最差子集是否在可接受范围、关键业务指标(如人工复核率)是否稳定。三条都过才发版,任何一条红线就回退。这套流程听起来繁琐,但跑顺了也就半天时间,比上线后救火便宜太多。
4. 优化路径:先处理误识问题,再谈提升准确率
优化这件事,方向比力度重要。很多人一上来就换更大的模型、加更多的数据,结果误识率没降,算力成本先翻倍。我的经验是:先看误识的构成,从成本和收益比最高的地方下手。通常顺序是阈值与分数校准、质量门控、检索层参数、模型侧压缩、工程侧调度。这一章逐个讲,附上实测数据和参数选择的依据。
4.1 分数校准与阈值分层
不同模型输出的相似度分数分布差异很大,直接拿同一个阈值套用会出事。校准的做法是:在验证集上按人员或摄像头分组,统计分数分布,用等距分箱或逻辑回归把原始分数映射到统一的“可比分数”空间。最简单的做法是分段映射,比如按质量分档,每档单独定阈值。
def calibrate_by_quality(scores, quality, bins=(0.0, 0.3, 0.6, 1.0)): out = np.zeros_like(scores) for lo, hi in zip(bins[:-1], bins[1:]): mask = (quality >= lo) & (quality < hi) if mask.sum() < 100: out[mask] = scores[mask] continue # 用该档负样本分布的高分位点做平移 q = np.percentile(scores[mask], 99.9) out[mask] = scores[mask] - (q - 0.5) return out逻辑很朴素:质量差的档位整体分数偏高且波动大,那就把该档的高分位点对齐到同一个基准。实测下来,分层阈值能让低质量子集的误接受率下降三成左右,代价是通过率略降。如果业务不能接受通过率下降,那就得回到质量门控那一步,把明显不可用的图挡在比对之前。
注意:分档阈值一定要在生产数据上校准,实验室数据的光照和摄像头型号单一,校准出来的档位边界经常不适用。
4.2 质量门控:把垃圾挡在门外
质量门控是我认为投入产出比最高的一环。具体做法是给每张人脸算一个综合质量分,由清晰度(拉普拉斯方差)、人脸尺寸、姿态角、遮挡比例、亮度对比度加权得到,低于阈值的注册照拒绝入库,低于阈值的查询直接判“无法识别”,交给其他手段处理。这个动作看似会让识别率下降,实际上是把“高置信度错误”转化成了“明确的无法识别”,业务体验反而更好。
阈值怎么定?我一般按召回-精度的思路来:把质量分和“识别是否正确”做相关性分析,画一条累计曲线,找到错误率快速上升的拐点。实测某项目里,把质量分最低的 8% 注册照剔除后,1:N 的 Rank-1 反而提升了 1.3 个百分点,因为那些照片本来就在干扰检索排序。这个结论有点反直觉,但很好解释:脏数据在底库里就是噪声源,清掉噪声,干净数据的排序自然变好。
4.3 检索层优化:索引参数与召回平衡
底库上万以后,检索层的参数对整体指标的影响开始显现。IVF 索引的核心参数是 nlist 和 nprobe,nlist 决定粗聚类粒度,nprobe 决定每次查询扫描多少个桶。nprobe 太小,真实目标可能落在没被扫描的桶里,召回直接掉;nprobe 太大,延迟线性上升。我的调参方法是固定 nlist,把 nprobe 从 8 扫到 128,记录 Rank-1 和 P99 延迟,找拐点。
参考数据:底库 20 万、512 维、IVF-Flat,nlist 取 1200,nprobe 从 8 提到 32 时 Rank-1 提升 2.1 个点,再往上提到 64 只提升 0.3 个点,延迟却翻倍。所以 32 是这批数据上的合理点。如果业务对延迟敏感,还可以考虑 PQ 压缩,但要注意 PQ 会损失精度,量化位数越低损失越大,通常 64 字节段的 PQ 在百万级底库上 Rank-1 损失在 1 到 3 个点之间,需要实测确认能否接受。
增量更新也是个坑。底库不是静态的,每天都有新增注册,IVF 索引支持 add 但训练分布会漂移,长期不重训会出现召回衰减。我的做法是每周做一次小规模重训、每月做一次全量重建,重建期间用双索引切换,避免服务中断。
4.4 模型侧:量化、蒸馏、裁剪的取舍
模型侧优化的前提是先确认瓶颈在哪。如果评估显示指标已经达标,只是延迟或成本超预算,那优化目标就是“不掉点地提速”;如果指标本身不达标,那应该先补数据和难例,而不是压缩模型。这两种情况的方法完全不同,别混。
提速方面,INT8 量化是最常用的手段,实测在主流骨干网络上能带来两到三倍加速,精度损失通常在 0.3 到 1 个点之间,具体看量化方式和校准集质量。校准集一定要覆盖真实场景的分布,特别是低光照和侧脸,否则量化后的模型在这两类上掉点特别明显。知识蒸馏适合小模型追赶大模型,用大模型的特征输出做监督,小模型在同等延迟下能提升 1 到 2 个点。裁剪对精度的伤害相对更大,我只在端侧场景考虑,并且一定要在裁剪后做一轮短周期的微调。
有个容易忽略的点:优化后的模型必须重新跑一遍完整评估,包括所有难例子集。我见过量化后整体指标只掉 0.4 个点,但侧脸子集掉了 6 个点的情况,如果只看总分,这个风险就被掩盖了。
4.5 工程侧:流水线与资源调度
工程优化的空间往往比模型优化更大。典型问题是整条流水线串行执行,检测 20ms、对齐 5ms、特征 15ms、检索 8ms,加起来 48ms,25fps 直接跑不满。改造成流水线并行之后,吞吐能提升一倍以上,因为各阶段用的资源不同,GPU 和 CPU 可以错峰。
from concurrent.futures import ThreadPoolExecutor import queue class Pipeline: def __init__(self, stages, workers=2): self.stages = stages self.qs = [queue.Queue(maxsize=32) for _ in range(len(stages) + 1)] self.pool = ThreadPoolExecutor(max_workers=workers * len(stages)) def run(self, frame): self.qs[0].put(frame) for i, fn in enumerate(self.stages): def worker(idx=i): while True: item = self.qs[idx].get() if item is None: self.qs[idx + 1].put(None) break self.qs[idx + 1].put(fn(item)) self.pool.submit(worker)队列长度要设上限,不然内存会一路涨上去。批处理也是关键,把多路视频的帧凑成一个 batch 送 GPU,利用率能从 40% 提到 80% 以上,但要注意 batch 增大会带来排队延迟,实时场景下 batch size 一般控制在 8 到 16。
显存管理上,特征提取模型的显存占用相对固定,容易出问题的是检测模型在多人场景下的峰值。我的做法是给每个阶段设显存水位线,超过就降 batch 或者跳帧,宁可丢几帧也不要 OOM 导致整条流水线重启。
5. 跟踪链路里的性能隐患:检测抖动与 ID 跳变
标题里带“人员检测与跟踪”,这一块单独拎出来讲,因为它和人脸识别耦合之后的问题特别隐蔽。很多系统单独测检测和识别都不错,一连起来就出问题:同一个人在两秒内被识别成三个人,或者轨迹断断续续导致投票不够。这一章讲讲时序对齐、ID 跳变成因和投票策略。
5.1 检测、跟踪、识别的时序对齐
一个常见的错误是每帧都做人脸识别,然后把结果直接覆盖到轨迹上。这样做的后果是,某一帧的误识会立刻污染整条轨迹的身份,而且计算量巨大。正确做法是分层:检测每帧跑,跟踪每帧更新,人脸识别按需触发,比如轨迹首次出现时、轨迹持续 N 帧后、或者质量分达到阈值时触发一次。
触发策略我一般这样设:轨迹连续存在 10 帧以上才触发识别,触发后每 30 帧复检一次,并在轨迹上累积多次识别结果做投票。这样做有两个好处,一是过滤掉大量短暂出现的误检,二是把识别算力集中用在稳定目标上。实测在 20 路视频的场景下,识别调用量能降到原来的三分之一,识别准确率反而提升,因为参与识别的帧质量更高。
这里有个时序上的细节要注意:识别结果要绑定在“识别触发时刻”的轨迹上,而不是当前帧的轨迹。如果中间发生了 ID 切换,识别结果可能被错误归到新轨迹上。解决办法是在轨迹对象里记录一个版本号,识别结果回填时校验版本,不一致就丢弃。
5.2 ID Switch 的成因与止血
ID Switch 的成因我总结下来主要有四类。第一类是遮挡,人走过去被柱子挡住几帧,再出现时关联不上,分配了新 ID。第二类是检测框抖动,IoU 波动导致匹配失败。第三类是人群密集时的交叉,两个轨迹短暂重叠,匹配错位。第四类是检测模型在特定姿态下漏检,轨迹被迫中断。
止血手段按成本从低到高排:降低匹配的 IoU 阈值可以缓解抖动,但会增加错配;加入运动模型预测位置,用卡尔曼滤波给出预测框,再做匹配,对遮挡场景改善明显;引入外观特征,用轻量的行人重识别向量做二次匹配,能显著降低交叉场景的错误关联;最后才是换更强的检测模型。
我实测过一组对比,纯 IoU 匹配在遮挡场景下 ID Switch 大约每百帧 6 次,加卡尔曼预测降到 4 次,再加外观特征降到 1.8 次。代价是单帧关联耗时从 1ms 涨到 4ms,对于 20 路以上的场景需要评估是否值得。
5.3 多帧投票:延迟与稳定性的平衡
投票策略直接决定识别结果的稳定性。最简单的多数投票,取累计结果里出现次数最多的 ID,超过一定票数才确认。改进版是加权投票,按质量分和相似度加权,质量高的帧权重更大。我一般用加权投票,并且加一个“拒绝确认”的兜底:如果最高票的权重占比不到 60%,就标记为待定,不进业务系统。
投票窗口的大小是个权衡。窗口太短,几帧就能确认,响应快但容易被单帧错误带偏;窗口太长,稳定性好但响应慢,业务上可能等不了。我的经验值是:门禁通行这类要求快的场景,窗口取 5 到 8 帧;轨迹归档这类要求准的场景,窗口取 20 到 30 帧。同一套系统里可以按业务配置两套策略,没必要统一。
注意:投票窗口要和轨迹的生命周期对齐,如果轨迹平均只存活 12 帧,你设 30 帧的窗口,永远凑不齐票数,最后全是待定。
6. 常见问题速查与踩坑实录
6.1 问题速查表
下面这张表是我这些年攒下来的高频问题,按现象、可能原因、排查动作、处理建议四列整理,遇到问题时按现象查会快很多。
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 整体识别率正常,某时段骤降 | 光照变化、视频源切换 | 按摄像头和时段分组统计 | 补该时段样本,做光照归一化 |
| 1:1 表现好,1:N 表现差 | 阈值未按底库规模重算 | 核算单对 FAR 与底库规模 | 重算阈值,收紧 FAR 要求 |
| 误识集中在特定人员对 | 高相似负样本、双胞胎 | 导出高相似错误对人工复核 | 加入难例微调,设置人工复核兜底 |
| 评估结果前后不一致 | 数据版本变更、采样种子变化 | 对比评估集版本与脚本参数 | 建立版本化与固定种子机制 |
| 低质量图通过率异常高 | 质量门控未生效或阈值过低 | 检查质量分分布与门控逻辑 | 提高门控阈值,按档位分层定阈 |
| 检索延迟抖动大 | nprobe 过高、索引未加载满 | 监控 P99 延迟与索引状态 | 调低 nprobe,索引常驻内存 |
| 轨迹 ID 频繁切换 | 遮挡、检测抖动、密集交叉 | 统计 ID Switch 与遮挡帧占比 | 加运动预测与外观特征 |
| 批量处理后精度掉点 | 量化校准集不匹配 | 分场景对比量化前后指标 | 重做校准集,覆盖低光与侧脸 |
| 显存占用持续上涨 | 队列无上限、特征缓存未释放 | 监控队列长度与显存曲线 | 设队列上限,及时释放中间结果 |
6.2 几条不会写在文档里的经验
第一条,评估脚本要和生产代码共用同一份预处理。我见过评估脚本用了一套归一化参数,生产用了另一套,结果线下 98%、线上 91%,查了一周才发现是均值方差写错了。现在我的做法是把预处理抽成独立模块,两边 import 同一份代码,谁改都得走评审。
第二条,别迷信单一数字,养成看分布的习惯。准确率是均值,均值会掩盖长尾。我每次评估都会画一下相似度分数分布的正负样本直方图,两条曲线的重叠区域就是难例所在。如果重叠区域突然变宽,即使总分没掉,也说明模型在某类数据上已经开始退化,该提前介入了。
第三条,优化要留对照组,且对照组不能同时改多个变量。我踩过的最大的坑是一次性上了量化、换了索引、调了阈值,结果指标掉了 2 个点,完全不知道是哪一项导致的,只能全部回退重来一遍。后来我强制自己每次只改一个变量,改完跑完整评估,记录结果,再改下一个。慢是慢一点,但可靠。
第四条,评估周期要和数据更新节奏匹配。底库每周新增几千人,评估集如果三个月不更新,指标就是自欺欺人。我现在的节奏是每月做一次评估集增量更新,每季度做一次全量重建,每次模型发版跑一次完整评估。这三件事写进流程,比任何优化技巧都更能保证系统长期稳定。
第五条,给业务方看的报告,一页就够。技术细节放附录,首页只放三个数:当前的通过率、人均误报次数、最差子集的指标。业务方只关心这三个数能不能接受,其余都是过程。这一点我在多个项目里验证过,报告越短,决策越快,返工越少。