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

Java后台基于RTSP与虹软ArcFace的多路视频人脸识别方案

Java后台基于RTSP与虹软ArcFace的多路视频人脸识别方案 ★ FEATURED ARTICLE
几个月前接了一个有点特别的需求客户那边有一批网络摄像头需要在不更换设备、不加边缘盒子、不依赖云服务的前提下实现多路视频的实时人脸识别识别结果还要统一汇总到后台。摄像头本身只能输出标准的RTSP视频流这就让方案被限定在“后台服务端拉流、抽帧、再识别”这条路上。当时我选了Java技术栈加虹软ArcFace离线SDK来落地整个过程踩了不少坑也沉淀出一些可以直接复用的经验。这篇文章就从选型、拉流、SDK接入、底库检索到后台服务化把完整链路拆开讲清楚适合正在做类似“摄像头实时分析”项目的Java开发、安防集成人员以及想了解服务端人脸识别实现细节的读者。1. 为什么把识别放到后台服务端关于选型的一次真实权衡1.1 端侧识别与服务端识别的取舍人脸识别放在哪里跑是这个项目里最先要决定的事。常见有三条路摄像头端侧识别、客户端播放时识别、后台服务端识别。摄像头端侧最省事但市面上大部分摄像头只提供RTSP取流不开放算法能力强行要求端侧识别等于换设备客户端播放时识别识别逻辑散落在每个看视频的终端上底库同步、日志上报、权限管理都很麻烦而且如果客户端不播放视频识别就跟着停了。后台服务端识别在这三个方案里属于“慢但稳”的那一类。摄像头只负责采集和推流后台统一拉流、统一抽帧、统一跑模型好处非常直接所有识别结果天然集中底库只需要维护一套算法升级不用动终端后续加摄像头也只是新增一路拉流任务。坏处也明显——所有视频流都要汇聚到后台对服务器的CPU、带宽和SDK使用姿势都有要求。这一点我在后面几章会展开讲。1.2 这个方案到底要解决哪三件事把“基于RTSP视频流的Java后台服务端虹软人脸识别”这个标题拆开核心其实就三件事一是拉流。Java后端要能从RTSP地址连续不断拿到视频帧不能因为网络抖动、摄像头重启就挂掉。二是识别。拿到帧之后要交给虹软ArcFace SDK做人脸检测、特征提取和1:N比对。三是服务化收口。拉流和识别不能只是写个Demo跑通要能作为后台服务常驻运行有接口、有监控、能排障。这三件事叠在一起才构成一个可上线的完整系统而不是一个孤零零的“人脸识别算法调用Demo”。接下来我就按这个顺序逐步讲。2. RTSP拉流与抽帧JavaCV实战参数与断流重连策略2.1 从摄像头取流的准备工作绝大多数网络摄像头都支持RTSP协议取流地址一般是rtsp://用户名:密码IP:端口/路径这种格式。不同厂商的路径规则差别很大我这边的实际使用中既有/Streaming/Channels/101这种常见格式也有/cam/realmonitor?channel1subtype0这种带query参数的方式。没有统一标准实操时到摄像头厂商的SDK文档里查对应型号的URL格式就行。这里我先给一个建议识别用的码流优先取子码流而不是主码流。主码流通常是1080P甚至更高的清晰度画质好但解码CPU开销大子码流一般是640x360或704x576对做人脸识别来说完全够用CPU占用却能降下来不少。我当时测试过同一条走廊的摄像头主码流1080P抽帧识别时CPU飙升到接近满载换成子码流后CPU一下子降到30%左右识别率并没有肉眼可见的下降。另外尽量选H.264编码的流。现在很多摄像头默认推H.265JavaCV底层的FFmpeg也能软解H.265但CPU开销明显更高。如果摄像头可以配置优先让子码流走H.264会省下一大笔计算资源。2.2 基于FFmpegFrameGrabber的拉流实现Java这边拉RTSP流最省事的方案是JavaCV它把FFmpeg的C接口封装成了Java类。核心类就是FFmpegFrameGrabber。我写了一个最简可用的拉流循环大概是这个样子FFmpegFrameGrabber grabber new FFmpegFrameGrabber(rtspUrl); // 设置网络超时防止断流时一直阻塞在那里 grabber.setOption(rtsp_transport, tcp); grabber.setOption(stimeout, 5000000); // 微秒5秒读超时 grabber.setOption(reorder_queue_size, 0); try { grabber.start(); while (running) { Frame frame grabber.grabImage(); if (frame null) { // 拿不到帧大概率是断流了 break; } // 转成BufferedImage后交给识别模块 try (Java2DFrameConverter converter new Java2DFrameConverter()) { BufferedImage image converter.convert(frame); // 这里送入识别队列 } } } catch (Exception e) { // 记录异常进入重连逻辑 } finally { grabber.stop(); grabber.release(); }有几个细节要提醒一下。第一rtsp_transport我建议设成tcpUDP模式在弱网环境下花屏和丢包更明显TCP虽然延迟略高但数据完整。第二stimeout必须设置否则摄像头断开后grabber会一直阻塞在底层socket上线程活活卡死。第三grabImage()拿到的是Frame不能直接交给ArcFace需要先转成BufferedImage。这里我用Java2DFrameConverter注意这个转换器在多线程环境下比较重每个拉流线程自己持有一份就好不要全局共享。2.3 抽帧策略每秒几帧抽大图还是小图这是整个项目里最容易拍脑袋做错的地方。有的同事一上来就把每一帧都送去做人脸检测结果25fps的视频流把CPU瞬间打满识别线程队列越积越长最后画面延迟半分钟。人脸检测和识别是有计算开销的实时视频流处理的目标不是“每一帧都识别”而是“保持合理的识别频率不漏掉人”。我采用的策略是按时间抽帧目标每秒2~3帧。也就是说不管摄像头帧率是25fps还是30fps我只在系统时钟距离上一次抽帧超过400毫秒时才真正丢一帧给识别模块。具体实现时可以在拉流循环里记一个System.currentTimeMillis()而不是通过帧计数去算。这是因为网络流的帧率并不完全均匀尤其弱网下会有抖动靠时间更可靠。抽帧之后还要做一次缩放。ArcFace对输入图像尺寸没有特别严格的要求但输入越小检测越快。我通常是拿到BufferedImage之后直接用Graphics2D缩放到宽度640宽高比保持不变再转成RGB字节数组丢给识别模块。BufferedImage scaled new BufferedImage(640, (int)(640.0 / src.getWidth() * src.getHeight()), BufferedImage.TYPE_3BYTE_BGR); Graphics2D g scaled.createGraphics(); g.drawImage(src, 0, 0, scaled.getWidth(), scaled.getHeight(), null); g.dispose();这里的逻辑是子码流一般也就640x360左右再缩到640宽基本不损失有效信息但后续SDK做灰度化和金字塔缩放的计算量能低不少。如果是1080P主码流这个缩放带来的收益就更明显。2.4 断流重连不把线程拖死RTSP流长期运行一定会遇到断流摄像头重启、网络闪断、交换机端口震荡都会导致取流中断。重连策略如果写得太随意就会出现“识别服务刚启动好好的跑了一天后某路摄像头再也不出结果”的情况。我的重连循环是这种做法当grabber读取异常或拿到null帧时不立刻死盯重连而是先释放当前grabber然后按递增策略sleep再重新创建。重连间隔从3秒开始每次失败翻倍最大到60秒就不再增长。恢复成功后再把间隔重置回3秒。int retryDelay 3000; while (running) { try { startAndLoop(grabber); // 内部是一个完整的拉流循环 retryDelay 3000; } catch (Exception e) { log.warn(拉流失败{}ms后重连, retryDelay); } Thread.sleep(retryDelay); retryDelay Math.min(retryDelay * 2, 60_000); }这里有个很容易被忽略的坑每次重连一定要确保旧的FFmpegFrameGrabber已经release()过。如果只是把引用丢掉重新new底层FFmpeg打开的socket和解码器不会被立刻释放跑几天就会把文件描述符耗尽进程开始报Too many open files。我在重构前的旧代码里就吃过这个亏排障时发现ls /proc/进程号/fd | wc -l的数字一路涨到几千罪魁祸首就是重连时没有释放旧grabber。3. 虹软ArcFace接入激活、初始化、图像数据格式与串行化调用3.1 SDK版本与激活准备ArcFace是虹软出的离线人脸识别SDK有Windows、Linux、Android等平台版本Java后台跑在Linux上就用Linux版。它通过APP_ID SDK_KEY来激活需要先在开发者平台注册应用获取一对密钥。正式使用前要先确认激活码支持当前机器的CPU类型和操作系统绑定关系一旦确认换机器后通常需要重新申请。放好SDK后Java这边要能加载到native库。我是在启动类里手动把SDK动态库所在的目录设置好UserInfo.setLibPath(/opt/arcsoft/lib);对应目录下放的是libarcsoft_face_engine.so这类文件。如果加载失败启动日志会直接抛UnsatisfiedLinkError这时候先检查路径、位数32/64和依赖库是否齐全不要一上来怀疑代码。3.2 引擎初始化与参数需要注意的点ArcFace的引擎概念有点类似于一个算法上下文。人脸检测、特征提取、比对这些操作都要在某个引擎上执行。初始化的代码大致如下ArcFace arcFace new ArcFace(appId, sdkKey); int engine arcFace.init(engine, ASF_DETECT_MODE_IMAGE, 20, 10); arcFace.setDetectFaceScale(16); // 可选的检测人脸最小尺寸这里有两个参数值得单独说。第一个是检测模式ASF_DETECT_MODE_IMAGE适用于对静态图片做检测。SDK另一个常用模式是ASF_DETECT_MODE_VIDEO它会在连续帧之间做跟踪和去重如果客户端能拿到连续帧序列用VIDEO模式能减少重复检测。但我在服务端是按时间抽帧的帧与帧之间间隔较长VIDEO模式的优势发挥不出来所以直接用IMAGE模式逻辑更简单可控。第二个是人脸最大检测数和检测角度。20表示一帧最多检测20张脸10表示支持全角度检测。如果场景是门禁进出一般正脸为主角度参数可以适当收紧检测性能会好一些。如果是在走廊或开放区域建议留着全角度。3.3 把一帧图像变成一组人脸特征ArcFace在Java里常用的图像输入格式是RGB24也就是连续的byte[]每三个字节表示一个像素的R、G、B分量。我们从BufferedImage转成这种格式时注意BufferedImage可能是BGR排列要做一个通道顺序的转换。这里贴一段我实际在用的转换代码private static byte[] bufferedImageToRgb24(BufferedImage image) { int w image.getWidth(); int h image.getHeight(); byte[] rgb new byte[w * h * 3]; int idx 0; for (int y 0; y h; y) { for (int x 0; x w; x) { int argb image.getRGB(x, y); rgb[idx] (byte) ((argb 16) 0xFF); // R rgb[idx] (byte) ((argb 8) 0xFF); // G rgb[idx] (byte) (argb 0xFF); // B } } return rgb; }注意这个方法用了image.getRGB()逐像素获取慢是慢一点但它能保证像素排列正确兼容各种BufferedImage类型。处理效率要求高的场景可以考虑用字节数组直接踩内存但那是优化阶段的事先把流程跑通更重要。拿到RGB数据后先检测人脸再提取特征FaceInfo faceInfo new FaceInfo(); int detectCode arcFace.detectFaces(engine, w, h, ImageFormat.CP_PAF_RGB24, rgb, faceInfo); if (detectCode 0 faceInfo.getFaceNum() 0) { FaceFeature feature new FaceFeature(); Rect rect faceInfo.getFaceInfo(0).getRect(); int angle faceInfo.getFaceInfo(0).getAngle(); int extractCode arcFace.extractFaceFeature( engine, rgb, w, h, ImageFormat.CP_PAF_RGB24, rect, angle, feature); if (extractCode 0) { float[] featureData feature.getFeatureData(); // 这组featureData就是这张脸的特征向量 } }上面是按“只取第一张人脸”写的。实际上如果一帧里有多个人就遍历faceInfo.getFaceNum()逐个人提取特征。还有就是SDK返回码大多数方法返回0表示成功非0对应不同的错误日志里记录一下返回码排障会快很多。3.4 引擎必须串行化调用一个崩溃案例换来的教训ArcFace的引擎不是线程安全的。这个限制官方文档里有写但很多人在第一次写后台服务时还是会踩坑。我当时第一版为了追求吞吐把识别模块放进了线程池同一时间可能有3~4个线程同时调用同一个引擎做检测或比对。结果运行时偶尔会返回错误码更严重的是跑几小时后JVM直接崩掉崩溃日志指向native方法。排查下来就是同一个引擎被并发调用导致的。解决方式有两种一是所有引擎调用集中到一个单线程的识别Worker里天然串行二是如果摄像头路数多、并发要求高就初始化多个引擎实例每个实例服务一路或一个线程各自串行。我后来实际采用的是第二种。服务器24核开了4个引擎实例每个引擎绑定一个识别线程四路摄像头可以并行识别同时每个引擎内部严格单线程。这里需要特别注意多引擎实例也会消耗算力。引擎数量一旦超过CPU物理核数性能不升反降。如果想在单引擎上并发处理只能借助外部手段把底库检索等Java侧逻辑并行化SDK自身的检测和特征提取始终留在同一个线程里。4. 人脸底库与实时1:N检索从MySQL存储到内存匹配4.1 特征数据怎么存才靠谱ArcFace提取出的人脸特征是一个float[]数组可以理解为这张脸在高维空间的坐标。这个数组不能直接塞进普通数据库字段最常见做法是转成字节数组后存BLOB类型。我这里建了一张很简单的底库表CREATE TABLE face_person ( id BIGINT PRIMARY KEY AUTO_INCREMENT, person_name VARCHAR(64) NOT NULL, feature_blob BLOB NOT NULL, feature_len INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(255) );插入时先把float[]转换成byte[]。反过来查询时把feature_blob读出来再还原成float[]用于加载进内存。要注意float的字节顺序Java里通常用ByteBuffer.allocate(4 * length).order(ByteOrder.LITTLE_ENDIAN)来读写保证和C侧SDK的内存布局一致。4.2 把底库加载到内存1:N检索怎么做底库数量不大几千个人以内时没必要上向量数据库。我每次服务启动时把整张底库表load到内存维护一个ListFaceFeature列表。实时识别时拿当前抓到的特征向量和列表里的每一条算相似度取最高分。ArcFace Java SDK里提供了compareFace方法传两个FaceFeature返回相似度分数。这个分数范围一般是0到1越接近1越像同一个人。检索逻辑大概这样float bestScore 0f; Long bestPersonId null; for (FaceFeature candidate : faceLibrary) { float score arcFace.compareFace(engine, currentFeature, candidate); if (score bestScore) { bestScore score; bestPersonId candidate.getPersonId(); } } if (bestScore threshold) { // 命中底库 } else { // 陌生人 }底库数量在几千条时这个线性扫描足够快。我用2路视频流、底库约3000人的场景测试一次全库比对耗时在十几毫秒到几十毫秒之间完全能接受。如果将来底库增长到几十万再考虑向量索引或专门的向量检索库那个是后话。4.3 阈值怎么定严谨场景偏严宽松场景偏松ArcFace的相似度阈值没有一个万能值。官方文档给过参考值1:1场景比如人证比对推荐0.8左右1:N场景推荐0.8附近但具体还是看你的现场环境。光照稳定、正脸居多的门禁场景可以设到0.82甚至更高减少误识别走廊、多人、侧脸较多的场景阈值太高容易漏0.75~0.78会更实用。我这里摸索了一个小技巧上线第一周把阈值调低到0.7同时把所有“低分命中”和“陌生人”的抓拍图都记录下来每天人工扫一遍看哪些是真正的误报哪些是漏报再动态调阈值。不要一上来就追求一个完美的数字实际环境的数据才最有说服力。4.4 陌生人判定与重复告警抑制底库比对后如果最高分低于阈值就判定为陌生人。但这里有一个很现实的问题一个人在摄像头前停留10秒我们按每秒2帧抽帧就会产生20次识别结果如果每次都告警后端会被重复消息淹没。我用的方案是“记录时间窗口去重”。每条识别结果记录cameraId 识别人标识 时间如果同一摄像头识别到同一个人的时间差小于30秒就只保留第一条不重复下发告警。陌生人跟踪稍微特殊一点因为每一帧可能都提取出不同的陌生人特征我用的是简化方案对陌生人特征做一次临时缓存如果后续帧的陌生人与缓存中特征相似度超过0.7就认为还是同一人只在窗口外才新建陌生人事件。5. 把处理流程收口成服务异步线程模型与接口设计5.1 拉流、识别、落库的管线怎么串到了这一步拉流和识别都有了剩下的是把它们组合成一个能长跑的后台服务。我采用的线程模型是“每路一个拉流线程 全局识别线程池 结果落库线程”。文字描述有点枯燥但逻辑其实很简单每个摄像头对应一个CaptureTask循环拉流、抽帧、转RGB把结果塞进一个有界阻塞队列。识别线程池从队列里取帧调用ArcFace检测和特征提取然后做底库比对。比对结果不直接写库放到另一个结果队列由落库线程批量写入MySQL。这里最有必要强调的是队列一定要有界。我第一版用了无界队列某路摄像头断流重连期间突然恢复积压的帧瞬间全部涌进来识别线程根本消化不掉最后内存被积压的图片顶到OOM。改成有界队列、并且新帧到来时丢弃队列中最旧的一帧之后系统就稳定了很多。做实时视频流识别错过几帧不算错误堆积延迟才是灾难。5.2 对外接口起停摄像头和人脸注册服务化之后摄像头和人脸底库都需要可管理。我按最小可用集设计了几个REST接口POST /api/camera/register注册一个RTSP地址创建对应的拉流任务入参是摄像头编号、RTSP地址、名称。POST /api/camera/{id}/start、POST /api/camera/{id}/stop动态启动和停止拉流。POST /api/person/register录入人员照片内部从图片提取特征后写入底库表同时刷新内存底库。POST /api/person/delete按人员ID删除。需要注意一个问题底库表更新后已加载到内存的ListFaceFeature不会自动同步所以注册和删除接口在操作数据库后要主动触发一次内存底库的刷新。我用的是一个简单的ConcurrentHashMap加volatile引用替换避免加锁对检索性能的影响。volatile ListFaceFeature library new ArrayList(); PostMapping(/person/register) public Result register(RequestBody RegisterRequest req) { FaceFeature feature extractFromImage(req.getBase64Image()); dbService.insertPerson(req.getPersonName(), feature); library dbService.loadAll(); return Result.ok(); }5.3 运行监控线程状态、队列积压和句柄数服务能跑起来只是第一步能长期稳定跑才是真本事。我常看几个指标每个摄像头拉流线程是否活着。线程死了往往意味着重连逻辑没生效监控线程池的时候要能看到每个CaptureTask的状态。识别队列剩余容量。如果长期接近满说明识别能力跟不上需要加引擎实例或降低抽帧率。进程文件描述符数量。Linux下可以用ls /proc/进程号/fd | wc -l看如果持续上涨不用想肯定有grabber没释放。把这些指标接到一个简单的健康检查接口里值班时排查问题会省很多力气。其实很多线上问题在指标上都能提前看出苗头等用户报“某路没画面了”再来查往往已经晚了。6. 实测中的几个坑长时间运行、内存上涨和SDK加载失败6.1 长时间运行后视频延迟越来越大这个坑是我跑了48小时后遇到的。现象是某路摄像头画面里的动作和实际时间差了十几秒重启服务后又恢复。排查链路是这样的先看了识别队列发现这路摄像头对应的队列长期积压消费者线程明明没死但消费速度跟不上生产速度。再查抽帧逻辑发现当时用的是“按帧数抽帧”也就是每隔N帧抽一帧。这台摄像头帧率是30fps但实际推送并不均匀由于网络抖动某个时间窗口内瞬间送来了大量帧按帧数抽帧就会在短时间内把大量图片塞进队列。修复方案就是前面说的改成按系统时钟抽帧并且队列设为有界、满了丢旧帧。改完之后画面延迟基本稳定在1~2秒以内。6.2 JavaCV版本不一致导致的native崩溃有段时间服务会偶发SIGSEGV直接崩掉没有任何Java堆栈。排查中发现Maven里javacv-platform版本比较新但某个传递依赖的FFmpeg native包版本被另一个中间依赖覆盖到了旧版JavaCV启动时加载到的FFmpeg库混了新老版本。整个现象非常隐蔽因为编码解码在大多数帧上都正常只在特定编码参数下触发了底层崩溃。最后是把JavaCV相关依赖全部显式统一到同一个版本并且用mvn dependency:tree检查了native包的传递关系崩的问题不再出现。给其他项目同行的建议是JavaCV这种带native库的依赖一定要锁住版本不能有选择性地用新版本覆盖旧版本。6.3 SDK初始化失败的完整排查过程有一台服务器始终初始化不了ArcFace引擎报错信息指向“激活码无效”相关错误。我一开始以为是密钥写错了反复核对也没发现问题。后来把上报的机器CPU信息跟激活申请时填写的CPU对比才发现申请激活时填的CPU型号和实际机器不一致。国内的服务器经常有不同类型的CPU型号跨型号复用同一激活是不行的。重新申请一次匹配当前机器的激活码问题消失。这个过程提醒我遇到激活类错误不要一上来怀疑代码先确认native库加载、CPU绑定、库路径这几个硬条件。6.4 多引擎实例与授权限制为了提升并发我在一台服务器上初始化了多个引擎实例结果SDK开始报授权相关错误。后来才意识到免费授权通常限制了可初始化的引擎数量不是随心所欲开几十个引擎的。所以多路视频的并发方案要从两个方向权衡要么通过降低抽帧率让单引擎串行处理所有路要么在授权允许范围内多创建几个引擎实例。考虑到人脸识别不是每一帧都必须处理我的实际选择是先算总需要的识别吞吐再决定引擎数量绝不无脑按摄像头路数堆引擎。写到这里这次“RTSP拉流 Java后台 虹软ArcFace”的实战链路就完整了。如果让我再做一遍我最想保留的经验其实就三条拉流重连务必释放干净、ArcFace引擎务必串行调用、队列务必有界并及时丢弃旧帧。这三点是从多次线上故障里换来的比任何花哨的架构设计都实在。项目现在从最初的3路摄像头扩到了16路识别延迟和稳定性都还在可接受范围内。后续如果底库数量再涨一个量级我可能会把线性比对换掉但那是下一个阶段要考虑的事了。
阅读完成 · 觉得有帮助?
咨询建站