资讯动态

SpringBoot+Vue实现在线人脸识别:摄像头到后端全链路解析

发布时间:2026/9/16 10:07:07 来源:尧图企业网站定制
简介一份基于SpringBoot与Vue的前后端分离在线人脸识别Web系统源码适合正在学习Java后端、Vue前端以及人脸识别接口集成的开发者。系统通过前端调用笔记本或网络摄像头每秒截帧并以Base64传给后端后端结合虹软离线SDK提取人脸特征进行比对从而完成实时识别。压缩包共51个文件约1.08MB包含Vue前端源码js/vue、SpringBoot后端工程java/xml/properties、虹软SDK相关jar与dat文件以及mvnw、package.json等构建脚本和README说明目录清晰便于按模块阅读。该项目从摄像头取流、数据编码传输到后端特征比对均有完整实现适合作为课程设计或毕业设计参考。已有429人浏览学习是一份实操性较强的人脸识别Web应用示例。1. 在 SpringBootVue 里一套在线人脸识别 Web 系统的关键不是模型而是链路笔记本摄像头和网络摄像头这两类设备已经把标题里最需要区分的问题说清楚了本地 USB 摄像头由浏览器调用自带的 getUserMedia 就能取流网络摄像头大多走 RTSP/RTMP 协议不是浏览器原生能直接读的。把这条链路分给 SpringBoot 和 Vue前端负责采集、抽帧和结果展示后端负责检测、特征提取和比对可以做出一个真正能用的在线人脸识别 Web 系统。做企业应用的人不用从零训练模型但要清楚一帧人脸从摄像头到屏幕要经过哪几个环节、每一步的延迟从哪来否则项目会卡在“模型能识别但页面动不了”这种尴尬阶段。文中代码和参数可直接落到本地联调适合做门禁、访客签到、考勤打卡这类场景。2. 摄像头到浏览器再到后端实时识别的基础数据链路2.1 识别引擎放前端还是后端常见做法有两种前端用 face-api.js 或 MediaPipe 直接在浏览器里跑模型后端只存结果后端用 OpenCV DNN 或 ONNX Runtime 跑模型前端只推帧。这个标题锁定了 SpringBootVue说明识别引擎天然放在后端更合适一是模型和底库集中管理换模型不用重新发前端包二是多人同时在线时特征向量检索在后端更容易做统一扩展三是笔记本摄像头和网络摄像头可以用同一套后端接口接收前端不用关心 RTSP 的差异。浏览器端模型也不是不能用但代价是每个客户端都要加载模型文件低配电脑上 GPU 不可用时会明显掉帧。后端推理则可以把模型预热放到 SpringBoot 启动阶段CPU 版本的 OpenCV DNN 在 640x480 输入下单帧检测加特征提取大约 80 到 120 毫秒足够支撑 10 帧以下的实时识别。2.2 一帧画面的识别时间线从按下摄像头启动按钮到页面显示识别框数据流是固定的摄像头采集画面video 元素实时播放canvas 周期性截取当前画面转成 JPEG通过 WebSocket 推给 SpringBoot 后端后端解码图像并完成检测和比对再把结果 JSON 推回前端。参照下面这张时间线表能快速判断瓶颈落在哪个节点阶段承担方单帧耗时参考摄像头采集与渲染浏览器约 10 毫秒canvas 截帧与 JPEG 压缩浏览器约 20 到 40 毫秒WebSocket 上传浏览器到后端约 5 到 20 毫秒图像解码与检测SpringBoot OpenCV约 40 到 60 毫秒特征提取与比对SpringBoot ONNX约 30 到 50 毫秒结果推送与渲染后端到浏览器约 5 到 10 毫秒单看每一项都不算慢但帧率一旦超过每秒 10 帧WebSocket 消息队列和线程池就会迅速积压。所以链路的设计核心是帧率要可控处理要异步结果要有序。SpringBoot 端不要把识别逻辑直接写进 WebSocket 的 onMessage 里否则一帧的处理时间会把后面所有帧全部堵住。2.3 笔记本摄像头和网络摄像头的两种接入路线笔记本摄像头是标准 UVC 设备浏览器里通过navigator.mediaDevices.getUserMedia可以直接拿到 MediaStream这个方案最稳定因为视频流从头到尾没有离开用户本机延迟最低。而网络摄像头分两类一类是 USB 摄像头插在迷你主机上浏览器依然按本地摄像头处理另一类是真正的 IP 摄像头比如海康、大华设备它们对外暴露的是 RTSP 地址例如rtsp://admin:password192.168.1.64:554/Streaming/Channels/101这类设备无法被浏览器直接读取需要后端先拉流再转发。我一般会在 SpringBoot 项目里集成 FFmpeg 拉取 RTSP 流并转成 HLS前端用支持 m3u8 播放的 videojs 来播放再从 video 元素里抽帧。这里有一个特别容易踩的坑RTSP 转 HLS 默认的切片延迟可能在 2 到 8 秒直接拿来做识别会觉得摄像头“卡顿”。解决方式是调整 hls_time 和 hls_list_size 参数把切片压到 0.5 秒以内或者干脆把 RTSP 转成 MJPEG 流让后端逐帧读取。命令可以参考ffmpeg -rtsp_transport tcp -i rtsp://admin:password192.168.1.64:554/Streaming/Channels/101 \ -c:v copy -f hls -hls_time 0.5 -hls_list_size 3 -hls_flags delete_segments \ /data/hls/camera01/index.m3u8逻辑说明-rtsp_transport tcp强制走 TCP 通道避免 UDP 丢包导致画面花屏-c:v copy表示不转码直接从 H.264 原流封装进 HLS降低 CPU 占用hls_time 0.5是关键它把每个视频切片切成 0.5 秒播放器才能获得接近实时的体验。如果摄像头输出的是 H.265 编码-c:v copy会失效因为大多数浏览器不直接支持 H.265 的 HLS 播放这种情况必须加上-c:v libx264 -preset ultrafast做转码。本地联调时先跑后端再跑前端分别启动两个终端mvn spring-boot:run -Dspring-boot.run.profilesdev cd frontend npm install npm run dev启动正常后浏览器打开 Vue 开发服务器地址摄像头权限弹窗出现确认这一步后再往后端识别服务里加逻辑就顺理成章了。3. SpringBoot 后端实现WebSocket 接收帧与 OpenCV 推理服务3.1 WebSocket 端点和消息缓冲区的设置SpringBoot 里做 WebSocket 可以用原生ServerEndpoint也可以走 Spring 的WebSocketHandler。实时推帧场景推荐前者因为它直接面向 Session处理消息的代码更短和线程池配合也更直观。一个能接收 JPEG 帧的人脸识别端点要处理的第一件事并不是识别而是把消息体缓冲调大。Tomcat 对 WebSocket 文本消息默认限制在 8192 字节左右一张 JPEG 图即使压缩到 0.7 质量也经常超过 50KB不设置缓冲区会直接抛异常。ServerEndpoint(/ws/face) Component public class FaceWsEndpoint { private static final ExecutorService RECOGNIZE_POOL new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(500), new ThreadPoolExecutor.CallerRunsPolicy() ); private final FaceRecognitionService faceRecognitionService; public FaceWsEndpoint(FaceRecognitionService faceRecognitionService) { this.faceRecognitionService faceRecognitionService; } OnOpen public void onOpen(Session session) { session.setMaxTextMessageBufferSize(4 * 1024 * 1024); session.setMaxBinaryMessageBufferSize(4 * 1024 * 1024); } OnMessage public void onMessage(String base64Data, Session session) throws Exception { String[] parts base64Data.split(,); byte[] imageBytes Base64.getDecoder().decode(parts.length 1 ? parts[1] : parts[0]); RECOGNIZE_POOL.submit(() - { try { FaceResult result faceRecognitionService.recognize(imageBytes); session.getBasicRemote().sendText( new ObjectMapper().writeValueAsString(result) ); } catch (Exception e) { // 单帧识别失败不要影响连接打日志后继续 Thread.currentThread().interrupt(); } }); } }逻辑说明RECOGNIZE_POOL是核心它把耗时推理从 WebSocket 线程里剥离出来。CallerRunsPolicy表示队列满了之后由调用方线程自己执行防止任务无限堆积把内存撑爆。setMaxTextMessageBufferSize设置为 4MB是为了容纳高分辨率截图推流。base64 数据如果带data:image/jpeg;base64,前缀要先按逗号拆开再解码。这里有个容易被忽略的点前端如果频繁推帧服务端 Session 可能已经断开但任务还在线程池里排队。提交任务前应判断session.isOpen()推送结果前也要再检查一次否则会漏掉大量IOException异常连接数也会被异常堆积拖垮。3.2 OpenCV DNN 检测人脸与关键点坐标检测部分我用 OpenCV 的 FaceDetectorYN它读的是 ONNX 格式的 YuNet 模型对比传统的 Haar Cascade 在侧脸、暗光下的表现好太多而且输出本身就带五个人脸关键点坐标方便后面做对齐。模型文件放在resources/models/face_detection_yunet_2023mar.onnx项目启动时一次性加载到内存避免每帧都读一次磁盘。Service public class FaceDetectionService { private FaceDetectorYN detector; PostConstruct public void init() throws IOException { InputStream modelStream getClass().getResourceAsStream(/models/face_detection_yunet_2023mar.onnx); File modelFile File.createTempFile(yunet, .onnx); Files.copy(modelStream, modelFile.toPath(), StandardCopyOption.REPLACE_EXISTING); detector FaceDetectorYN.create( modelFile.getAbsolutePath(), , new Size(320, 320), 0.8f, // scoreThreshold 检测置信度 0.4f, // nmsThreshold 非极大值抑制阈值 5000 // topK 最多保留的人脸数 ); } public Rect detectLargestFace(Mat frame) { Mat faces new Mat(); detector.setInputSize(new Size(frame.width(), frame.height())); detector.detect(frame, faces); if (faces.rows() 0) { return null; } float[] face new float[15]; faces.get(0, 0, face); return new Rect((int) face[0], (int) face[1], (int) face[2], (int) face[3]); } }逻辑说明detect前必须调用setInputSize传入当前帧的实际尺寸否则模型会一直按初始化时的 320x320 处理坐标映射会偏。FaceDetectorYN 输出的每行前 4 个值是 x、y、w、h后面 10 个值是两眼的四个坐标以及鼻尖、嘴角的关键点取最大人脸时直接读第一行即可。同一个模型文件用临时文件加载而不是直接加载 InputStream是因为 OpenCV Java 的FaceDetectorYN.create只接受文件路径这种写法最省事。检测到最大人脸框后要把它裁剪出来并缩放到固定尺寸再交给下一步特征提取。裁剪时建议在框的四周各向外扩大 20%把额头和下颚完整包进来否则特征值会丢失信息直接影响比对准确率。3.3 ArcFace 特征提取与参数约束特征提取部分使用 ONNX Runtime 加载 InsightFace 的 ArcFace 模型输入统一为 112x112 的 RGB 人脸图输出是 512 维的特征向量。识别比对时用待测向量的欧氏距离和底库向量逐一计算距离距离小于阈值就说明是同一个人。这个思路和 SpringBoot 的业务代码没什么关系但接入方式值得单独说明public FaceResult recognize(byte[] imageBytes) { Mat frame Imgcodecs.imdecode(new MatOfByte(imageBytes), Imgcodecs.IMREAD_COLOR); Rect faceBox faceDetectionService.detectLargestFace(frame); if (faceBox null) { return FaceResult.noFace(); } Mat cropped new Mat(frame, faceBox); Mat resized new Mat(); Imgproc.resize(cropped, resized, new Size(112, 112)); float[] feature faceRecognitionService.extractFeature(resized); double minDistance faceRepository.findMinDistance(feature); return minDistance threshold ? FaceResult.hit(minDistance) : FaceResult.unknown(); }逻辑说明这里的extractFeature内部用 ONNX Runtime 的OrtSession.run执行推理输入张量格式是[1, 3, 112, 112]也就是把 HWC 转成 CHW 并归一化到 0 到 1 区间。底库查找如果只在几百人规模用内存 List 遍历 512 维浮点距离完全够用上万人的底库才需要引入 Faiss 或向量数据库初期不需要把这个复杂度带进来。ArcFace 在实际项目里最容易踩的坑是输入预处理不一致训练时用的是人脸对齐后的 112x112推理时如果只把裁剪框强行缩放特征分布会和底库对不上。所以检测出关键点之后要按两眼角度做旋转校正再缩放到 112x112这一步对识别率的影响比换任何模型都大。人脸识别链路里真正影响成败的参数并不算多按作用范围整理如下参数推荐值说明Yunet 检测置信度0.6 到 0.9调高减少误检调低找回侧脸人脸裁剪外扩比例20%避免额头和下巴被切掉ArcFace 输入尺寸112x112模型固定要求不能随意改大识别距离阈值0.45 左右需要按实际摄像头成像调整OpenCV 线程数等于 CPU 核数过高反而增加线程切换开销以上参数都放在 SpringBoot 的application.yml里通过ConfigurationProperties注入后续调优不需要改代码只需要改配置重启。4. Vue 前端实现摄像头调用、抽帧压缩与 WebSocket 推流4.1 getUserMedia 打开笔记本摄像头并限制分辨率Vue 端第一步是拿到摄像头权限。调用getUserMedia时最需要注意的一点是识别场景不要一上来就申请 1080p 的高清流。1080p 对 canvas 抽帧、JPEG 编码和 WebSocket 传输都是无意义的负担后端模型输入只有几百像素再清晰也派不上用场。640x480 或 800x600 是实时识别的最佳区间。async function startCamera () { stream.value await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640, max: 800 }, height: { ideal: 480, max: 600 }, frameRate: { ideal: 15, max: 20 }, facingMode: user }, audio: false }) videoRef.value.srcObject stream.value await videoRef.value.play() requestAnimationFrame(sendFrame) }逻辑说明facingMode: user表示强制使用前置摄像头笔记本场景一般只有一个摄像头但外接 USB 摄像头时这个约束能让浏览器优先选择正确设备。frameRate.max设置为 20 能避免部分摄像头在弱光下自动把帧率降到个位数保持推帧节奏稳定。音频必须显式关闭否则有些机器会弹出麦克风权限请求用户一旦拒绝摄像头权限也会受影响。如果页面上有多个摄像头设备需要切换要用enumerateDevices()列出所有 videoinput 设备然后通过deviceId字段指定具体设备这个字段必须在getUserMedia的 constraints 里传给浏览器。4.2 canvas 按帧率抽帧并用 toBlob 压缩video 元素拿到 MediaStream 后不能直接把整个视频流推给后端必须用 canvas 把当前画面截成 JPEG。抽帧频率通常控制在每秒 2 到 5 帧也就是 200 到 500 毫秒一次。交互式实时识别不需要 30 帧业务上也不要求每帧都出结果频率太高只会让后端线程池排队结果推送反而更乱。let lastSendTime 0 const FRAME_INTERVAL 300 function sendFrame (timestamp) { if (timestamp - lastSendTime FRAME_INTERVAL !wsBusy.value) { lastSendTime timestamp const scale 320 / videoRef.value.videoWidth canvasRef.value.width 320 canvasRef.value.height Math.round(videoRef.value.videoHeight * scale) const ctx canvasRef.value.getContext(2d) ctx.drawImage(videoRef.value, 0, 0, canvasRef.value.width, canvasRef.value.height) canvasRef.value.toBlob(blob { if (blob socket.value socket.value.readyState WebSocket.OPEN) { socket.value.send(blob) } }, image/jpeg, 0.7) } requestAnimationFrame(sendFrame) }逻辑说明wsBusy是一个布尔标记表示上一次推送的结果还没有回来。加这个开关的作用是把帧率从“按时间发”改成“按响应发”后端处理不过来时前端自动降帧不会形成消息队列堆积。canvas 缩放系数按 320 宽度计算这样推到后端的 JPEG 基本在 20KB 以下比直接发原图画质更合适。toBlob比toDataURL更省内存前者生成 Blob 直接走二进制 WebSocket后者会多一次 base64 编码开销。用requestAnimationFrame控制循环而不是setInterval是因为浏览器切换到后台标签页时 rAF 会自动暂停避免页面不可见时还在白白推帧消耗服务器资源。4.3 网络摄像头的接入HLS 播放 m3u8 后再抽帧接入 IP 网络摄像头时前端不能直接getUserMedia。常见做法是让 SpringBoot 后台用 FFmpeg 把 RTSP 转成 HLS m3u8 流Vue 页面用 videojs 完成播放随后从同一个 video 元素里抽帧。这个方案的好处是播放逻辑只用一套代码笔记本摄像头走 MediaStream网络摄像头走 URL 播放抽帧逻辑完全复用。template video refipCamera classvideo-js controls autoplay muted/video /template script setup import videojs from video.js import video.js/dist/video-js.css onMounted(() { const player videojs(ipCameraRef.value, { sources: [{ src: /api/hls/camera01/index.m3u8, type: application/x-mpegURL }], autoplay: true, muted: true }) player.ready(() { requestAnimationFrame(sendIpCameraFrame) }) }) /script逻辑说明muted: true必须设置浏览器自动播放策略只允许静音视频自动播放摄像头流没有音频这个限制不影响识别。前端抽帧时从ipCameraRef.value取视频帧走的就是和笔记本摄像头完全相同的 canvas toBlob 流程后端接口也不用区分来源。RTSP 转 HLS 有一个必须提前接受的现实即使把切片时间压到最低整体延迟也会在 1 秒左右如果业务上要求看见画面后的 200 毫秒内就给出识别框这条路子行不通。因此对延迟敏感的场景我建议在后端直接把 RTSP 解码成 Mat识别后再把识别结果和画面一起推给前端这是海康、大华设备接入门禁系统的更可靠方式虽然实现上多写点代码但至少不再受播放器缓冲策略制约。5. 联调验证与实时识别的关键参数调优5.1 不打开浏览器也能测试的 WebSocket 推帧脚本前后端联调时每次都打开摄像头太慢了。我习惯先写一段 Python 脚本用本地一张图片模拟前端推流确认后端识别接口通了再回到浏览器调页面。脚本里用 websocket-client 直接推 base64和 Vue 前端发的数据格式保持一致。import base64 import json import websocket frame_path capture.jpg with open(frame_path, rb) as f: image_b64 base64.b64encode(f.read()).decode() ws websocket.create_connection(ws://127.0.0.1:8080/ws/face, timeout10) ws.send(json.dumps({image: image_b64})) result ws.recv() print(result) ws.close()逻辑说明如果一个后端能接受 base64 图片并推回识别结果前端表单和摄像头抽帧的问题就能被隔离排查。脚本返回的 JSON 里如果带faceCount和distance字段说明检测和特征提取链路没问题如果返回noFace问题出在图片质量或检测阈值和 Vue 无关。也可以顺带做一个延迟测量在脚本里记录发送和接收之间的时间差多次取平均。通常本地单帧识别延迟在 150 到 250 毫秒属于正常范围超过 500 毫秒就要关注线程池排队和模型推理耗时。5.2 可复现的实时识别调优参数表调优的顺序有讲究先保证识别准确率再考虑降低延迟最后再去调并发。一步到位的参数如下参数推荐值调优说明前端抽帧间隔300 毫秒降低到 200 毫秒会提升一点平滑度但 CPU 占用上升明显JPEG 压缩质量0.7低于 0.5 时人脸纹理丢失ArcFace 特征漂移明显后续帧率限制每秒 3 帧服务端处理不过来时优先降帧保准确Yunet scoreThreshold0.7门禁场景可调到 0.8 减少误报安防画面可降到 0.6线程池核心线程数CPU 核数 - 1保留一个核给 JVM GC 和 WebSocket 收发线程池最大线程数核心线程数 x 2避免创建过多线程引发 OpenCV 内部竞争实际调整时前三个参数在 Vue 前端改后三个在 SpringBoot 的 yml 里改。前端改参数不需要重启刷新页面就生效这是排查识别问题时最快的验证手段。5.3 摄像头异常与识别异常的排查顺序笔记本摄像头场景最常见的报错是NotReadableError这表示摄像头被其他进程占用了。排查时先关掉腾讯会议、钉钉、直播软件里的摄像头开关再到浏览器设置里清除摄像头权限记录重新请求一次即可。如果getUserMedia始终拿不到流还要检查访问页面是否走了 HTTPS 或 localhost浏览器安全策略要求非安全上下文不能使用摄像头接口。RTSP 网络摄像头如果出现画面黑屏或反复重连先用 ffprobe 确认地址和编码格式ffprobe -rtsp_transport tcp -i rtsp://admin:password192.168.1.64:554/Streaming/Channels/101重点看输出里的Video: h264还是hevc。如果是 H.265 编码转 HLS 时必须加 libx264 转码参数否则前端播放器只会黑屏。海康设备如果想降低资源占用可以到摄像头后台把主码流分辨率降为 1280x720编码改成 H.264节能模式打开。后端频繁收到连接断开异常时检查两个地方一是nacos或网关层的连接空闲超时二是 Nginx 的proxy_read_timeout。WebSocket 长连接保活要同时设置 TCP 层和 Nginx 层的超时时间不能只调后端。6. 把识别做成业务注册底库、阈值选择与部署前最后一步6.1 用一次请求完成人脸注册入库识别用的底库要先有人脸特征。注册接口的输入是一张清晰人脸照片后端走一遍相同的检测和特征提取最终把 512 维的 float 数组和用户名绑定存入数据库。最常见的做法是把特征向量序列化成二进制的 BLOB 字段和 MySQL 或 PostgreSQL 放在一起几千人的规模下全表扫描比组件向量数据库简单得多。PostMapping(/api/person/register) public Long register(RequestBody RegisterRequest request) { byte[] imageBytes Base64.getDecoder().decode(request.getImageBase64()); Mat frame Imgcodecs.imdecode(new MatOfByte(imageBytes), Imgcodecs.IMREAD_COLOR); Rect faceBox faceDetectionService.detectLargestFace(frame); if (faceBox null) { throw new BusinessException(照片中未检测到人脸); } float[] feature faceRecognitionService.extractFeature(cropAndAlign(frame, faceBox)); Person person new Person(); person.setName(request.getName()); person.setFaceFeature(toBytes(feature)); return personRepository.save(person).getId(); }逻辑说明注册和识别共用extractFeature保证特征提取的预处理一致。cropAndAlign内部完成关键点对齐和 112x112 缩放这部分必须和识别链路完全一致否则注册时提取的特征在识别时距离会被拉大结果就是同一个人也匹配不上。6.2 用欧氏距离快速选阈值特征比对的距离计算用 Python 或 Java 都一样核心是先算距离再选阈值。底库建好后拿同一个人的多张照片计算类内距离分布再拿不同人的照片计算类间距离分布阈值应该取两类分布的中间点。import numpy as np f1 np.array([0.12, 0.45, 0.86, ...]) f2 np.array([0.15, 0.42, 0.81, ...]) dist np.linalg.norm(f1 - f2) print(fdistance: {dist:.4f}) matched dist 0.45逻辑说明阈值的初值用 0.45 是通用做法但实际必须按摄像头成像质量调整。同一个摄像头下不同人的特征距离通常分布在 0.8 到 1.4同一个人的距离分布在 0.2 到 0.5。阈值调大容易把陌生人放进来调小则导致本人都识别不了。建议把判定接口做成可视化页面能看到具体距离数值再根据统计结果调整阈值配置而不是凭感觉改代码。6.3 模型文件外部化与绝对路径加载部署前最后一步是把模型加载路径从 classpath 改成外部绝对路径。SpringBoot 打包成 jar 后resources里的 ONNX 模型会被打进 jar每次启动都要解压到临时目录并且临时目录可能被系统清理。正确做法是增加一个配置项face: backend: onnx model-dir: /opt/face-service/models detect-model: face_detection_yunet_2023mar.onnx arcface-model: w600k_r50.onnx启动时优先从外部目录加载模型文件不存在再回退到 classpath。这么做还有一个隐藏好处以后换模型只需要替换外部的 onnx 文件再重启应用不用重新打包整个后端服务。模型加载完成后用一个最小的自检接口验证整条链路确保模型文件没有被破坏。curl -X POST http://localhost:8080/api/face/check \ -H Content-Type: application/json \ -d {imageBase64: $(base64 -w0 self_test.jpg)}如果返回matched: true整个在线识别系统从摄像头取流到特征比对就已经完整跑通剩下的工作就是对业务方交付页面和接口对接了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价