基于虹软人脸识别在微信小程序中实现人脸登录

 一、项目概述

应“广大”粉丝的强烈要求,今天我们使用虹软人脸识别(Linux Pro版)来实现微信小程序(以下简称小程序)中的人脸登录功能。

虹软(ArcSoft)是计算机视觉行业领先的算法服务提供商及解决方案供应商。公司业务遍布全球,在杭州、上海、南京、深圳、台北、硅谷、东京、都柏林等地设有商业与研发基地。其最新发布的Linux Pro引擎除支持人脸检测、人脸跟踪、人脸对比、人脸质量检测等基础功能外,还支持高并发百万人脸库的高效识别,并提供了两个全新的高精度模型,最重要的是支持离线私有化部署~

前排提示,阅读本篇文章需要有一点点的小程序开发知识或前端开发知识。

在之前的文章中,我们实现了一个在Spring Boot中集成虹软人脸识别的项目,在本篇文章中,我们将继续使用这个项目作为后端服务(前后端分离的好处就是一个后端可以适配多个前端)。当然,为了适配微信小程序,这里也对代码进行了少许修改和BUG修复,在下文中我会一一指出改动之处,如果你已经看过我的上一篇文章,那么阅读本篇文章将会非常轻松,如果你还没有阅读过,可以先粗略阅读一下来了解后端的整体思路,上一篇文章地址如下:


下面开始本篇文章的正式内容。

在当下,人脸登录已经是一个普通到不能再普通的功能,在一些安全校验严格的情况下,必须要保证是本人在登录,要实现这一点,一般会在密码或验证码登录后再进行一次人脸识别来确保。在我们日常使用微信小程序的过程中,也会经常遇到即使输入了正确的账号密码,也要进行人脸识别的情况,这并非是多此一举,而是为我们的数据安全提供了更强的保护。

人脸登录的高层级思路并不复杂,在使用人脸登录前,我们首先要使用其他方式进行人脸注册,比如用户注册时采集人脸,或者后台批量录入人脸等,并根据注册照提取人脸特征,之后将人脸特征和其他信息进行持久化保存(比如存入数据库);当用户进行登录时,我们再获取到采集照,同样进行人脸检测和特征提取,最后将采集照的特征数据和注册照的特征数据进行对比搜索,来判断正在登录的人是否是之前已经录入过信息的人。

这里借用上一篇文章的流程图:

以上流程中的人脸检测和人脸特征提取部分均在服务端完成,本文中的小程序仅实现了流程中获取采集照的部分,而注册的部分依然复用上一篇文章中的前端项目。

老规矩,先看实现效果,由于需要使用到小程序,所以此次用来测试的人只能是我自己了,不过在下实在不帅气,所以会打码处理,但是又本着让大家看文章的时候有个好心情,所以我就使用了抖音方太集成电器旗舰店直播间的年轻漂亮还有点帅气的莓主播来作为对比图片。



这样数据库中就会存有两张截然不同的人脸,登录的时候只会匹配到正确的人脸才会成功,下面是小程序的运行截图:




二、项目设计

1、流程设计

现在我们从登录功能实现的角度绘制一个流程图,这里主要是小程序端的流程图,后端对比的流程图还需要参考本文开头提到的文章:


接下来我们就根据这个流程来实现我们的小程序。

2、技术方案

因为涉及到了小程序开发,而微信官方的开发工具又没有Linux版本,所以我在这里使用了两个操作系统,分别是Windows和Linux,因为我的设备本身就是双系统,所以并没有使用虚拟机(下文只列出小程序的主要代码和一部分后端代码,文章结尾会给出项目的GitHub链接,运行前不要忘记阅读本文和上一篇文章的环境搭建部分):

Windows:主要用到了官方的微信开发者工具,下载链接如下:

微信开发者工具

Linux:我使用的是Fedora 64位桌面版,由于Java是跨平台语言,所以用其他发行版也是没关系的

Java:这里使用了OpenJDK 21,如果你使用了其他版本,修改pom.xml中的对应位置即可


Maven:这是一个Maven项目,具体的依赖信息可以参考项目的pom文件,主要使用到了Spring Boot、Spring Data JPA 和数据库驱动依赖

虹软Linux Pro SDK:在虹软开发者平台获取,此处使用Java版本

数据库:本项目的pom文件使用了PostgreSQL驱动,如果需要使用其他数据库,只需要更改数据库驱动并配置好对应的连接字符串即可


3、环境搭建

Windows系统只需要安装微信开发工具即可,Linux系统的环境请参考文章开头提到的前一篇文章的环境搭建部分。

需要注意的是,项目的配置文件application.properties倒数第二行添加了一项新的配置image-url,用来向前端返回有效的图像地址,项目运行前需要替换为服务端实际的IP地址:

spring.application.name=arcfacelinuxprojavaweb

# DB
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=
soring.datasource.driver-class-name=

# JPA
# 出于演示目的,每次启动都重新创建表
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
sprint.jpa.properties.hibernate.format-sql=true

# ArcSoft
# 提前使用
# ./mvnw install:install-file -Dfile=/usr/local/arcsoft_lib/arcsoft-sdk-face-server-1.0.0.0.jar -DgroupId=com.arcsoft -DartifactId=face-sdk -Dversion=1.0.0.0 -Dpackaging=jar
# 将SDK注册进Maven
arcsoft.appId=
arcsoft.sdkKey=
arcsoft.activeKey=
arcsoft.libPath=/usr/local/arcsoft_lib/Linux

# APP
app.image-url=http://192.168.8.136:8080/images/
app.upload-dir=/home/bfss/Program/arcfacelinuxprojavaweb/src/main/resources/static/uploads

三、项目实现

小程序端的项目结构是这样的,主要的项目文件在pages目录,pages中每一个文件夹都代表小程序的一个页面,每一个页面又包含.js文件(实际执行的代码文件,类似JavaScript)、.json文件(页面配置文件)、.wxml文件(界面文件,类似HTML)和.wxss(页面样式文件,类似CSS):


接下来列出小程序端的主要代码,如上图所示,小程序主要有三个页面,分别是:

  • face:打开前置摄像头获取照片的页面
  • login:密码登录页面
  • profile:登录成功后跳转的页面

face页面设置了一个定时器,每隔3秒钟捕获一张摄像头拍摄的图片并上传到后端服务器进行比对,一旦后端比对成功,就将后端返回的信息发送给profile页面并进行跳转,以下是face.js的代码:

// pages/face/face.js
Page({
  timer: null, // 定时器

  /**
   * 页面的初始数据
   */
  data: {

  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad(options) {

  },

  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady() {

  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow() {
    this.startFaceCheck(); // 页面显示时开启
  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide() {
    this.stopFaceCheck(); // 页面隐藏(跳转)时停止,防止内存泄漏
  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload() {
    this.stopFaceCheck(); // 页面销毁时停止
  },

  /**
   * 页面相关事件处理函数--监听用户下拉动作
   */
  onPullDownRefresh() {

  },

  /**
   * 页面上拉触底事件的处理函数
   */
  onReachBottom() {

  },

  /**
   * 用户点击右上角分享
   */
  onShareAppMessage() {

  },

  startFaceCheck() {
    const ctx = wx.createCameraContext();
    
    // 每 3 秒执行一次拍照比对
    this.timer = setInterval(() => {
      ctx.takePhoto({
        quality: 'low', // 低画质节省流量且后端处理快
        success: (res) => {
          this.uploadAndVerify(res.tempImagePath);
        }
      });
    }, 3000); 
  },

  stopFaceCheck() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },

  uploadAndVerify(path) {
    wx.uploadFile({
      url: 'http://192.168.8.136:8080/api/face/search',
      filePath: path,
      name: 'image',
      success: (res) => {
        // res.data 转成 JSON
        const result = JSON.parse(res.data);
        
        // 返回的状态码是 200 
        if (res.statusCode === 200 && result.success == true) {
          this.stopFaceCheck(); // 停止定时器
          wx.showToast({ title: '验证通过', icon: 'success' });
          // 将后端返回的人名和图片路径拼接到 URL 中
          const name = encodeURIComponent(result.personName);
          const avatar = encodeURIComponent(result.imagePath);
          // 跳转到个人信息页
          setTimeout(() => {
            wx.reLaunch({ 
              url: `/pages/profile/profile?name=${name}&avatar=${avatar}` 
            });
          }, 1000);
        }
      },
      fail: (err) => {
        console.error("验证失败", err);
      }
    });
  }
})

login页面实现了用户输入账号密码进行验证的功能(伪登录,用户名和密码写在代码文件里),一旦登录成功,就弹出对话框提示用户将要进行人脸识别。以下是login.js的代码:
// pages/login/login.js
Page({

  /**
   * 页面的初始数据
   */
  data: {
    user: '', 
    pwd: '' 
  },

  inputUser(e) { this.setData({ user: e.detail.value }) },
  inputPwd(e) { this.setData({ pwd: e.detail.value }) },

  handleLogin() {
    // 伪登录
    if (this.data.user === 'admin' && this.data.pwd === '123456') {
      // 弹出提示框
      wx.showModal({
        title: '安全验证',
        content: '为了您的账号安全,请进行人脸识别',
        showCancel: false,
        success: (res) => {
          if (res.confirm) {
            // 跳转到人脸识别页
            wx.navigateTo({ url: '/pages/face/face' });
          }
        }
      });
    } else {
      wx.showToast({ title: '账号或密码错误', icon: 'none' });
    }
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad(options) {

  },

  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady() {

  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow() {

  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide() {

  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload() {

  },

  /**
   * 页面相关事件处理函数--监听用户下拉动作
   */
  onPullDownRefresh() {

  },

  /**
   * 页面上拉触底事件的处理函数
   */
  onReachBottom() {

  },

  /**
   * 用户点击右上角分享
   */
  onShareAppMessage() {

  }
})

profile页面实现了用户信息展示的功能(简易版,因为后端只传输了注册照和图片名),一旦人脸识别通过,就将后端返回的信息展示在页面上。以下是profile.js的代码:

// pages/profile/profile.js
Page({

  /**
   * 页面的初始数据
   */
  data: {
    userName: '',
    userAvatar: ''
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad(options) {
    this.setData({
      userName: decodeURIComponent(options.name),
      userAvatar: decodeURIComponent(options.avatar)
    });
  },

  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady() {

  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow() {

  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide() {

  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload() {

  },

  /**
   * 页面相关事件处理函数--监听用户下拉动作
   */
  onPullDownRefresh() {

  },

  /**
   * 页面上拉触底事件的处理函数
   */
  onReachBottom() {

  },

  /**
   * 用户点击右上角分享
   */
  onShareAppMessage() {

  }
})

下面还有两处基于小程序前端而改动的后端代码,分别是:

AppConfig.java,添加了用来提供静态资源的addResourceHandlers方法。

package com.bfss.arcfacelinuxprojavaweb.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class AppConfig implements WebMvcConfigurer{

    @Value("${app.upload-dir}")
    private String uploadDir;

    // 由于采用前后端分离设计,所以需要配置CORS避免跨域问题
    @Override
    public void addCorsMappings(CorsRegistry registry){
        registry.addMapping("/**")
        .allowedOrigins("*")
        .allowedMethods("*")
        .allowedHeaders("*");
    }

    // 添加静态资源映射,实现前端访问图片
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry){
        registry
        .addResourceHandler("images/**")
        .addResourceLocations("file:" + uploadDir);
    }
}

ArcFaceService.java,在searchFaceByImage方法中添加了对比结果为null的判断,并且将返回的图片路径从本机路径改为静态资源路径,便于前端访问图片。

package com.bfss.arcfacelinuxprojavaweb.service;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Optional;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.arcsoft.face.FaceFeature;
import com.arcsoft.face.SearchResult;
import com.arcsoft.face.enums.ExtractType;
import com.bfss.arcfacelinuxprojavaweb.dto.ArcFaceSearchResponse;
import com.bfss.arcfacelinuxprojavaweb.engine.ArcFaceEngine;
import com.bfss.arcfacelinuxprojavaweb.entity.ArcFaceInfoEntity;
import com.bfss.arcfacelinuxprojavaweb.repository.ArcFaceInfoRepository;

import jakarta.transaction.Transactional;

@Service
public class ArcFaceService {
    // 人脸注册与搜索服务
    private static final Logger logger = LoggerFactory.getLogger(ArcFaceService.class);

    private final ArcFaceEngine arcFaceEngine;
    private final ArcFaceInfoRepository arcFaceInfoRepository;

    @Value("${app.image-url}")
    private String imageUrl;

    @Value("${app.upload-dir}")
    private String uploadDir;

    public ArcFaceService(ArcFaceEngine arcFaceEngine, ArcFaceInfoRepository arcFaceInfoRepository){
        this.arcFaceEngine = arcFaceEngine;
        this.arcFaceInfoRepository = arcFaceInfoRepository;
    }

    @Transactional
    public ArcFaceInfoEntity registerFaceFromImage(MultipartFile file){
        // 人脸注册
        try{
            Path uploadPath = Paths.get(uploadDir);
            Files.createDirectories(uploadPath);
            Path targetPath = uploadPath.resolve(file.getOriginalFilename());
            // 此处为方便演示,使用文件覆盖选项
            Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
            File storedFile = targetPath.toFile();

            FaceFeature faceFeature = arcFaceEngine.extractFaceFeatureFromImage(storedFile, ExtractType.REGISTER);

            // 这里使用文件名作为personName,在实际项目中可能需要从前端获取
            ArcFaceInfoEntity arcFaceInfo = new ArcFaceInfoEntity(file.getOriginalFilename(), faceFeature.getFeatureData(), targetPath.toString());
            arcFaceInfo = arcFaceInfoRepository.save(arcFaceInfo);

            arcFaceEngine.registerFace(arcFaceInfo.getId(), arcFaceInfo.getFaceFeature());
            return arcFaceInfo;
        } catch (IOException e){
            logger.error("注册函数发生异常", e);
            return null;
        }
    }

    public ArcFaceSearchResponse searchFaceByImage(MultipartFile file){
        // 人脸搜索
        try{
            Path tempPath = Paths.get(System.getProperty("java.io.tmpdir"), file.getOriginalFilename());
            Files.copy(file.getInputStream(), tempPath);
            File tempFile = tempPath.toFile();
            
            FaceFeature faceFeature = arcFaceEngine.extractFaceFeatureFromImage(tempFile, ExtractType.RECOGNIZE);
            SearchResult searchResult = arcFaceEngine.searchFace(faceFeature.getFeatureData());

            Files.delete(tempPath);

            ArcFaceSearchResponse arcFaceSearchResponse;
            // 此处设置阈值为0.8,可以根据实际业务场景调整
            if(searchResult != null && searchResult.getMaxSimilar() > 0.8){
                int faceId = searchResult.getFaceFeatureInfo().getSearchId();
                Optional<ArcFaceInfoEntity> faceInfo = arcFaceInfoRepository.findById((long)faceId);
                // 在这里将本地图片路径替换为网络路径
                arcFaceSearchResponse = new ArcFaceSearchResponse(
                    true, 
                    faceInfo.get().getPersonName(), 
                    faceInfo.get().getImagePath().replace(uploadDir, imageUrl)
                );
            }else{
                arcFaceSearchResponse = new ArcFaceSearchResponse(false, "", "");
            }
            return arcFaceSearchResponse;
            
        }catch(IOException e){
            logger.error("搜索函数发生异常", e);
            return null;
        }
        
    }
}

以上就是小程序项目的主要代码和一小部分后端代码,整体难度不高,下文会给出这两个项目的Github地址,clone并配置好后即可运行。

四、项目总结

按照惯例,这里首先附上项目的Github链接,欢迎大家指点,项目均采用MIT协议,可直接拿走使用,这个是小程序代码:

这个是后端代码:


在实际的小程序开发中需要注意,人脸信息属于个人生物信息,采集人脸在微信小程序中极其敏感,若小程序人脸识别功能涉及采集、存储用户生物特征(如人脸照片或视频、身份证和手持身份证、身份证照和免冠照等),此类型服务需使用微信原生接口,否则很难通过审核,本项目由于是测试版小程序,所以不涉及审核问题,小程序相关文档如下:

评论