首页 / 资讯中心 / 文章详情

百万级人脸库毫秒检索与跨摄像头轨迹追踪的工程落地

百万级人脸库毫秒检索与跨摄像头轨迹追踪的工程落地 ★ FEATURED ARTICLE
百万级人脸库毫秒检索、跨摄像头轨迹追踪这几个词凑在一起乍看像是个安防大脑级别的项目。但当你把 Java 后端、IoT 设备接入和 AI 推理链路真正串起来做一遍会发现最难的从来不是某个模型有多新而是整条数据流水线上每个环节能不能在高压下不脱节。这篇文章记录的是我最近一次完整落地的项目复盘业务场景是一个商业园区需要把常驻人员、商户员工、临时访客和少量重点防控对象统一管起来底库规模逼近百万级。核心要求有两个——任意摄像头抓拍到一张人脸要在 1 秒内给出身份结果同一身份在不同摄像头之间出现时系统能自动拼接出完整活动轨迹。项目组主力是 Java 工程师摄像头是海康、大华混用的存量设备AI 部分用 Python 生态实现。我尽量把从选型到实战的全过程写清楚包括那些只有踩进去才看得见的坑希望对正在做类似事情的团队有帮助。1. 项目背景与整体架构三个技术栈到底怎么分工1.1 需求拆解百万级、毫秒级、跨摄像头分别是什么含义先说清楚几个关键词的真实含义因为业务方提需求和开发理解之间往往存在巨大偏差。百万级人脸库指的是底库中注册的人脸底图数量级在百万上下。我们项目里按一个人 1~5 张底图估算实际档案数大约 30 万底图总量约 85 万最终按 100 万的规模做设计和压测。这个量级对存储压力不大但对检索链路的设计非常关键。毫秒检索不是指抓拍后整个业务链路 1 秒内完成而是特指底库向量比对这个环节要控制在毫秒级。我们给业务方交付的体验指标是抓拍事件从产生到身份结果回写全链路 500ms 以内其中纯底库检索控制在 10~30ms。余量要留出来因为网络传输、AI 推理、消息队列都各有延迟。跨摄像头轨迹追踪比普通的人脸识别难一个维度。单摄像头识别只是回答这个人是谁跨摄像头追踪还要回答这个人刚才从哪来、现在到哪去、经过哪些点位。它要求系统把所有抓拍事件在时空维度上关联起来这本质上是一个在线聚合问题不像底库检索那样有现成组件可以直接用。需求拆解到这一步团队内部基本达成共识这不是一个纯 AI 项目而是一个重工程的数据管道项目。1.2 选型逻辑为什么是 Java、IoT、AI 三件事一起做网上很多文章喜欢把 Java、IoT、AI 这几个词堆在一起当噱头但实际上它们在这个项目里的分工非常清晰。Java 负责的是业务大脑人员档案、设备管理、用户权限、轨迹查询接口、告警推送、事务性操作。选 Java 不是因为它的算法生态好而是因为它有足够成熟的微服务基础设施、连接池和运维工具链团队维护成本低。 Spring Boot 加 Spring Cloud Alibaba 的组合在这个场景下完全够用。IoT 负责的是数据源头摄像头接入、流媒体拉取、设备发现、心跳监控、断流重连。这一层最容易被低估。很多团队的误区是把摄像头当成一个简单的 RTSP 地址开发一个 FFmpeg 命令就直接拉流结果上线后设备离线、码流卡顿、断流不自愈问题全堆在这里。IoT 接入层的稳定性决定了上游 AI 和业务层有没有数据可用。AI 负责的是感知能力人脸检测、关键点对齐、特征提取、行人再识别。训练模型是 AI 工程师的活但落地时更多精力要花在推理服务的接口设计、GPU 资源分配、模型迭代发布上。一句话总结Java 是骨架IoT 是血管AI 是感官。三样东西缺一个整个链路就跑不通。1.3 全链路组件地图先看清数据从哪来到哪去在动手编码之前我强烈建议团队先画一张数据流转图。不需要很精确但要把每个环节的输入输出定义清楚链路层级组件核心职责选型参考设备层网络摄像头、抓拍机采集视频流、上报设备状态存量海康/大华设备RTSP/ONVIF接入层流媒体网关、设备接入服务统一拉流、协议适配、设备鉴权ZLMediaKit / SRSNetty消息管道消息队列转发电事件、设备状态、告警Kafka MQTT 双链路AI 推理层检测/识别/ReID 服务抽帧、人脸检测、特征提取Python PyTorch/ONNX Runtime检索引擎向量索引服务百万底库的快速比对FAISS 单机索引预留 Milvus业务层Java 微服务档案管理、轨迹聚合、告警、APISpring Boot / Spring Cloud存储层各类数据库元数据、热轨迹、历史记录MySQL Redis MongoDB这张表里最容易忽略的是消息管道的角色。抓拍事件不是同步请求摄像头拉着流AI 推理完把结构化特征打给 Kafka业务层再消费这样做的好处是削峰填谷。园区高峰期的人流量波动很大如果所有环节都是同步调用任何一环抖动都会直接放大到用户端。2. AI 推理链路从视频帧到结构化特征2.1 抓拍触发与抽帧策略别让无效计算吃掉 GPU很多人第一次做人脸识别项目上来就在每路摄像头上定时抽帧每 200ms 抽一帧送去做检测。这个思路在小规模测试时没问题但上了 30 路以上摄像头就会出事——大量画面是静止的走廊、停车场、空房间这些帧都在白白消耗 GPU 算力。我们最终的抽帧策略分了三层设备端优先海康、大华的摄像头自带移动侦测和人脸抓拍能力尽量开启设备的动态检测只在画面中有目标移动时才产生抓拍事件。流媒体端兜底对于不支持智能事件的旧设备在流媒体网关侧做轻量帧差分连续几帧画面变化超过阈值才把帧推到 AI Server。推理服务端过滤AI Server 接到的帧再跑一次快速人脸检测没有人脸的帧直接丢弃不进入后续特征提取。三层下来实际进入特征提取的帧大概只有原始流量的 8%~15%GPU 利用率反而上去了因为每一帧都是有效计算。2.2 人脸检测、对齐与质量过滤检索之前最容易被忽略的关口特征提取模型再好如果输入的人脸图是糊的、偏的、尺寸太小的底库检索再快也没有意义。所以我们把质量过滤放在特征提取之前。检测阶段我们用的是 RetinaFace 系模型在 GPU 上速度和精度平衡得比较好。最近也有团队用 YOLOv8-face效果也不错。检测到了人脸之后用 5 个关键点左眼、右眼、鼻尖、左嘴角、右嘴角做仿射变换把脸对齐到 112x112 的标准输入尺寸这一步对特征提取效果影响极大。过滤规则上几个经验值可以参考人脸像素宽度小于 40px 直接丢掉这个尺寸下特征提取已经基本不可靠。模糊度分数高于阈值直接丢掉。Opencv 的 Laplacian 方差可以当简单的模糊指标用进阶做法是训练一个小模型打分。左右偏转角超过 30 度、俯仰角超过 25 度的脸要谨慎处理极端侧面脸即使识别出来置信度也偏低。质量过滤的收益是隐性的。表面上你只是丢掉了一些难样本但实际上你保住了底库检索的精度也大幅减少了无效比对带来的 CPU/GPU 开销。2.3 特征向量与相似度512 维的 Embedding 是怎么工作的人脸识别的核心思想是把一张人脸图像压缩成一个固定维度的向量让同一个人的不同照片在向量空间中距离很近不同人的照片距离很远。我们用 ArcFace 系模型输出 512 维的 float 向量推理时得到的就是这张脸的数学指纹。为什么是 512 维而不是 128 维或 2048 维这是一个精度和工程成本的平衡。128 维在人脸大规模底库下区分度不足容易产生更多跨身份误配2048 维的精度收益有限却让内存占用和检索计算量直接翻了四倍。百万人脸底库512 维 float 向量全量加载大约是 2GB 内存这是单机内存可以轻松承接的量级。模型输出的向量经过 L2 归一化也就是模长变成 1。归一化之后两个向量的余弦相似度就等于它们的点积这个性质让向量检索组件可以直接用内积距离来近似余弦相似度不需要每次实时计算模长。相似度阈值是一个必须实测调优的东西。不同模型、不同训练数据、不同底库人种分布阈值都不一样。项目里我们最初沿用其他人分享的 0.6 阈值结果误识率偏高后来用验证集画 ROC 曲线重新标定把阈值调到 0.68 才达到业务预期。2.4 Java 与 AI Server 的桥接选对调用方式能省一半心模型本身是 Python 生态但业务系统是 Java两者之间怎么通信是个绕不开的问题。我们对比过三种方案gRPCJava 端通过 grpc-java 调用 Python 推理服务。性能好、接口约束清晰、支持流式传输但需要写 proto 文件并生成代码。HTTP JSON实现最简单但 JSON 序列化和反序列化开销大高并发下延迟和 CPU 消耗都不理想。Java 内嵌模型用 TensorFlow Java API 或者 ONNX Runtime Java 直接在 Java 进程内推理省掉了跨进程网络开销但模型部署和 GPU 显存管理会很别扭且模型迭代涉及 Java 侧发版。我们最终选了 gRPC。原因很实际AI Server 单独部署在 GPU 机器上Java 业务服务可以水平扩展两边独立发版互不干扰。gRPC 连接池复用之后一次特征提取的网络开销在几毫秒量级比 HTTP 方案稳定一个数量级。3. 百万级人脸库的毫秒检索落地3.1 为什么暴力遍历在百万级底库上行不通先算一笔账。100 万张底图每张提取 512 维 float 特征那就是 100 万 x 512 次浮点乘加。用 Java 循环做暴力比对单次查询大约需要 5 亿次浮点计算。就算你的机器每秒能跑 10 亿次浮点运算一次查询也要 500ms 起步这还只是一个请求当并发请求上来CPU 直接打满服务立刻不可用。还有内存问题。100 万条 float[512] 的对象如果放在 Java 堆上每个对象有对象头和数组长度等额外开销实际占用会膨胀到 4~6 倍2GB 的数据轻松变成 8~10GB 堆内存GC 必然爆炸。所以百万级召回向量索引和堆外内存管理几乎是必须的不能靠硬算。3.2 向量索引选型FAISS、Milvus、Elasticsearch 怎么选主流的向量检索引擎有三个方向各自对应不同团队的技术底色方案优点缺点适合场景FAISS单机内存索引性能极强部署轻量Meta 维护无内置分布式和高可用动态更新要自己处理百万量级、单机可扛、更新频率低Milvus分布式向量数据库内置动态更新、标量过滤、数据持久化组件多、运维重小规模项目属于杀鸡用牛刀千万级以上、需要多节点扩展Elasticsearch已有 ES 的团队接入成本低支持向量字段与传统过滤组合检索性能比 FAISS 差一个量级内存开销大检索过滤条件复杂、不想引入新组件我们最终选了 FAISS。理由很朴素底库规模 100 万加索引全量在内存里也就 2GB 出头更新频率是日常小批量增量 每天一次全量重建不是在线实时高频变更单机 64GB 内存的机器完全够用不用为这个量级引入一套分布式系统。如果你团队已经重度使用 ES也可以考虑 ES 的 dense_vector 加 HNSW 索引。代价是延迟会高一些但对几百万级规模仍然可用。3.3 FAISS 的索引结构与 Java 桥接FAISS 索引类型很多实际落地我们重点对比了 IndexIVFFlat 和 IndexHNSW。IndexIVFFlat 的思路是先把底库向量聚类分成 N 个桶nlist查询时只搜索距离最近的 K 个桶nprobe而不是全量遍历。它的内存开销和原始向量一致构建速度快代价是 nprobe 设置过小时会丢失精度。我们最终用 nlist1024nprobe32在 100 万底库上的召回率能达到 95% 以上。IndexHNSW 是图结构索引检索延迟低、召回率高但构建时间较长参数多调优需要更多经验。单机百万级场景两者都能胜任看团队偏好。FAISS 是 C 内核Python 接口最成熟。Java 直接用 JNI 调 C 库维护成本太高我们的做法是把 FAISS 包成一个独立的检索服务对外提供 gRPC 接口Java 侧只负责传特征向量、收 topK 结果。核心代码示意import faiss import numpy as np d 512 # 特征维度 index faiss.index_factory(d, IVF1024,Flat) # 训练聚类中心 index.train(np.random.rand(200000, d).astype(float32)) # 添加底库向量id 用业务主键 index.add_with_ids(feat_np.astype(float32), ids) # 查询返回 top20 的相似度和对应 id D, I index.search(query_vec.astype(float32), k20)Java 侧把这段逻辑封装成服务后一次检索的 RPC 耗时基本在 5ms 以内加上 FAISS 本身的计算时间单次 top20 检索整体 10~20ms完全满足毫秒级的要求。3.4 底库动态更新增量、删除与索引重建的工程处理FAISS 的 IndexIVFFlat 训练好之后追加新向量很容易直接 add_with_ids 就行。但要注意两个隐患第一聚类中心是训练时确定的如果线上增量数据的分布和训练集差异很大新增向量可能归错桶导致检索召回率下降。我们的对策是每天凌晨低峰期用全量底库重建一次索引训练过程大约 2~3 分钟可接受。第二删除操作比较麻烦。FAISS 的 remove_ids 可以删但删除会造成索引内部布局稀疏化反复增删之后性能会劣化。工程上更稳妥的方案是底库索引本身不做物理删除只在 Java 侧维护一个已删除 ID 集合检索拿到候选结果后再过滤掉已删除 ID。这样索引保持稳定业务层承担过滤责任逻辑还更清晰。索引重建期间不能让检索服务停下我们的做法是双缓冲。一个 hot 索引对外服务另一个 cold 索引在后台重建构建完成后原子切换。切换动作对调用方完全透明不会出现查询抖动。3.5 实测性能百万人脸库到底能跑多快压测环境是一台 32 核 CPU 的物理机内存 64GBFAISS 服务独占。底库 100 万向量512 维IndexIVFFlat 参数为 nlist1024、nprobe32场景单次检索耗时说明单请求 top205~12ms加上网络与 gRPC 开销约 15ms并发 50 QPSP99 18msP95 12ms检索服务无明显瓶颈并发 200 QPSP99 35msCPU 占用约 60%仍稳定全链路含推理180~350ms含拉流、推理、检索、轨迹聚合这个结果说明百万级底库在单机 FAISS 上完全可以撑住毫秒检索。真正的性能瓶颈在整条链路而不是 FAISS 本身。4. 跨摄像头轨迹追踪从认得出人到拼得出路4.1 单摄像头跟踪和跨摄像头追踪的差别很多开始接触这个领域的人会把跨摄像头追踪理解成多目标跟踪MOT但两者是不同的层次。单摄像头 MOT 解决的是同一画面里这个框一直跟着那个人走的问题。它依赖帧间 IoU、运动模型、外观特征输出的是摄像头内部 ID比如 Camera-01-Track-001。这个 ID 一旦目标离开画面就作废了换一个摄像头全部重新编号。跨摄像头追踪要解决的是这个人出现在摄像头 1 之后几分钟后出现在摄像头 3再过几分钟出现在摄像头 7的串联问题。只靠单摄 MOT 无法做到因为不同摄像头之间的外观差异非常大光照、角度、分辨率完全不一样。这需要 ReID行人再识别能力的参与不是看目标在相邻帧之间运动是否连续而是看两段出现在不同画面的目标外观特征是否匹配同一身份。我们项目的做法是人脸为主、人体为辅。正脸清晰时优先人脸识别人脸太小或角度不好时用人体的全局特征做补充关联。4.2 跨摄像头轨迹接力的完整流程一次真实的跨摄像头接力在系统内部是这么流转的摄像头 A 抓拍到一张清晰正脸AI Server 提取特征后送到底库检索命中身份 ID P10001。结果写入 Redis以 P10001 为 key记录摄像头 A、时间 T1、位置坐标、抓拍图 URL。摄像头 B 在几分钟后抓拍到同一身份的疑似目标但人脸分值没有直接超过硬阈值只达到了候选级别。轨迹聚合服务把候选身份拿去查询 Redis 中该身份最近 30 分钟内的轨迹点发现有摄像头 A 的记录且两者在拓扑和时间上满足通行条件。系统把摄像头 B 的这次抓拍也归入 P10001 的轨迹并计算相似度打分确认关联。这个流程的关键不是某一次识别是否命中而是通过识别 时空校验的配合把单独置信度不够的抓拍事件也串起来形成完整的移动路线。4.3 时空约束防止张三认成李四的兜底手段ReID 和人脸特征相似度都做不到 100% 准。实际跑下来纯特征匹配会出现不少看起来像但实际不是同一人的情况。要控制误关联必须在空间和时间上加约束。我们的约束规则有三条时间窗口约束同一摄像头相邻两次出现同一身份的时间差一般不超过目标离开视野的合理时间跨摄像头的出现时间差不能超过两台摄像头之间步行或车行所需时间的 1.5 倍。空间拓扑约束摄像头在园区内的位置关系形成一张图。两台摄像头物理上相邻且有路径连通才允许出现跨镜头跳变如果两栋楼之间没有连接通道轨迹不能出现直接跳转。速度约束跨镜头时间差和空间距离换算出来的移动速度超过正常人的步行速度上限比如 3m/s时匹配直接废弃。实际项目里加时空约束之前轨迹关联的误报率在 10% 左右加完之后降到了 1% 以内。这比换更强模型性价比高得多而且逻辑清晰、可解释。4.4 轨迹存储与回放轨迹数据是典型的时间序列写入、按身份聚合查询。我们选择了双层存储热轨迹存 Redis每个 person_id 维护一个按时间排序的 ZSET记录最近 30 分钟的抓拍点位。查询这个人现在在哪时毫秒级返回。冷轨迹落 MongoDB每 5 分钟把 Redis 中的数据归档一次按 person_id、时间范围建索引支持历史轨迹回放。回放接口的核心逻辑很简单输入 person_id 和时间范围查询出所有抓拍记录按时间升序排列把摄像头 ID、位置坐标、抓拍图拼成一条可播放的轨迹。真正费功夫的是处理中间断点——比如目标经过了一个没有摄像头的区域轨迹会有一段空缺。我们处理方式是不做猜测补全只在界面上用虚线标注该路段无监控覆盖把诚实展示给用户比伪装完整更重要。5. IoT 设备接入与数据管道摄像头上云的第一公里5.1 摄像头接入协议的取舍ONVIF、RTSP、GB28181 各管一摊存量园区摄像头品牌混杂协议能力参差不齐。我们没有追求用一套协议通吃而是按管理维度做了分工协议主要用途我们怎么用ONVIF设备发现、能力协商、参数配置新设备上线时自动发现、获取拉流地址、配置画质RTSP实时视频流拉取实际取帧通道走流媒体网关统一拉流GB/T 28181国标设备目录、注册管理、平台对接对存量国标设备做目录同步和状态管理这套组合的好处是设备初始化用 ONVIF 自动完成流传输统一走 RTSP国标存量设备通过 28181 协议接入平台目录三方互补。如果只用 RTSP 裸拉流设备管理会非常累如果只走国标又没有现成的流媒体处理能力。5.2 流媒体与抽帧管道带宽和算力怎么平衡流媒体网关是我们的视频总线选型用了 ZLMediaKit部署简单、协议支持全。摄像头推送 RTSP 流到网关网关负责转封装AI Server 从网关按需取帧而不是每路摄像头都直连推理服务。带宽得算清楚。假设一路 1080p 视频码率 4Mbps如果 30 路摄像头 7x24 小时全量拉流光上行带宽就接近 120Mbps很多园区办公室网络根本扛不住。我们最终的策略是按需拉流设备侧发现画面有移动才上报事件流媒体网关再即时拉取对应摄像头的高清流平时只保持低码率的预览流或直接不拉流。这样平均带宽降到 20%~30%但事件响应仍然及时。5.3 设备心跳、断流重连和补偿机制设备在线率和链路稳定性是 IoT 层的核心指标。我们在这里踩过不少坑几个关键点是心跳机制摄像头通过 MQTT 每 30 秒上报一次心跳超过 3 个心跳周期未上报则标记离线触发运维告警。断流重连RTSP 拉流断开后必须有退避重连策略。直接每 1 秒重连一次会把流媒体网关打崩正确做法是 1s、2s、4s、8s 指数退避最多到 60s 封顶。事件补偿AI Server 来不及处理导致消息积压时不能丢弃事件。Kafka 的持久化在这里起了大作用服务恢复后可以从 offset 继续消费抓拍识别的结果不会丢。5.4 消息链路选型Kafka 和 MQTT 的分工很多物联网项目把 MQTT 和 Kafka 对立起来其实它们适合不同的消息类型。我们的做法是双链路并行MQTT 负责设备控制面心跳、上下线通知、设备配置下发。消息量小、实时性要求高MQTT 的发布订阅模型天然适配。Kafka 负责数据面抓拍事件、识别结果、轨迹数据。消息量大、需要持久化与回放Kafka 的高吞吐和 offset 管理正好满足。两套消息系统划清边界之后数据管道变得异常清爽。6. 性能调优与实战中踩过的坑6.1 Java 堆内存装不下百万特征向量GC 之痛项目早期有一个严重的性能问题团队想省掉独立的检索引擎直接用 Java 内置一个 ConcurrentHashMapLong, float[] 存特征向量再写循环做余弦比对。结果启动后 Java 堆内存直接飙到 10GBFull GC 频繁接口 P99 超过 1 秒完全无法用。后来查原因问题不在 HashMap而在 float[] 的对象开销。512 个 float 是 2KB但每个数组对象在 JVM 里还有对象头、对齐填充等额外空间再加上 HashMap 的 Node 节点和扩容开销实际膨胀远超出预期。这个教训说明像百万级特征向量这种数据应该交给堆外内存或者 C 侧管理。我们用 FAISS 独立服务后Java 堆内存被彻底解放2GB 数据完全由 C 进程管理Java 侧只处理业务元数据和过滤逻辑GC 问题销声匿迹。6.2 索引重建不能影响在线服务前面提到每天凌晨要重建索引但最初实现时太粗暴停服重建重建完再启动。结果每天早上 3 点到 4 点之间检索接口是断的自助查询和夜间告警全部失效运维被投诉了好几次。改成双缓冲索引后问题解决。具体做法是维护两个 FAISS 索引对象一个对外服务一个在后台构建。构建完成调用原子切换让新的索引对象上线老的释放。这个模式在写多读少、需要平滑升级的场景里非常好用不只是 FAISS很多内存态数据结构的更新都可以用。6.3 相似度阈值不是拍脑袋定的人脸识别的阈值直接影响误识率和漏识率两者此消彼长。阈值设太高真实匹配会被漏掉阈值设太低陌生人会被误认成库里的人。业务方的要求是宁可漏报不可错报这个约束直接把阈值往高处推。我们标定流程是从底库中抽 1 万条真实档案构造 10 万对正负样本对跑一遍检索得到全部相似度分布画出 ROC 曲线取误识率十万分之一对应的阈值。实测下来最终值落在 0.70 左右和最初拍脑袋定的 0.6 差了整整 0.1。这个差距在百万底库上意味着每天多出几十次错误命中。6.4 敏感数据的最小化处理不能省人脸数据属于强敏感信息商用项目必须把合规意识放进架构里。我们项目里做了几件事人脸特征向量和原始照片分开存储检索服务只保留向量和业务 ID原始抓拍图走对象存储加密存储按业务需求设置保留周期到期自动清理轨迹数据不保留超过业务要求的时间窗口摄像头覆盖区域设置醒目提示标识访客数据单独授权管理。这些不是麻烦事而是这个领域的基本盘。疏忽任何一环项目上线后都可能面临巨大风险甚至直接导致项目停摆。回看这个项目真正的门槛不在单个 AI 模型的精度也不在 Java 服务有多复杂而在于把 Java、IoT、AI 三方的数据规范、接口约定、异常处理节奏对齐。个人经验是如果团队之前没做过类似的链路千万别一上来就挑战百万底库先用 1 万条底图把摄像头拉流 → AI 推理 → 向量检索 → 轨迹聚合这条最小闭环跑通性能问题后面再逐步优化。核心链路通了剩下的所有问题都只是工程问题。
阅读完成 · 觉得有帮助?
咨询建站