项目背景: 

公司自有小程序, 员工考勤打卡 / 审批单据时进行人脸识别操作, 不想花钱, 希望自行实现,公司OA系统保存了员工入职照片, 这里只做对比就可以

开发历程: 

可见我前一篇文章,  在 vue项目中实现了 tensorflowJS + faceapi 的人脸对比 + 情绪识别功能, 便认为在微信小程序中可轻松实现, 但事实不然, canvas 方法 微信小程序实现跟浏览器完全不一样, 通过 monkeypatch 也无法兼容, 只能退一步, faceapi 和 tensorflow 都是支持在 node 环境中运行的, 故使用 nodejs 实现, 小程序端调用接口通信

功能介绍:

开启接口, 接收前端上传的照片和用户手机号码. 通过手机号码查询到数据库中人脸图, 将人脸图和本次上传的照片进行相似度对比, 性别分析, 年龄分析, 情绪分析, 将验证结果返回前端

如果能满足您的需求, 下面直接放完整代码:

由于本人是前端, 只是用 node 实现后端功能, 代码写的不好见谅

如果需要完整项目,  或者小程序调用部分可以私聊我

const express = require("express");
const multer = require("multer");
const path = require("path");
const app = express();
// 添加全局编码中间件(必须放在所有路由前)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
  res.header('Content-Type', 'application/json; charset=utf-8');
  next();
});

const port = 3000;
const faceapi = require("face-api.js");
const fs = require("fs");
const storage = multer.diskStorage({
  destination: "uploads/",
  filename: function (req, file, cb) {
    const ext = path.extname(file.originalname); // 获取原始后缀
    const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(null, uniqueSuffix + ext); // 保留文件后缀
  },
});

const upload = multer({
  storage: storage,
  fileFilter: (req, file, cb) => {
    if (file.mimetype.startsWith("image/")) {
      cb(null, true);
    } else {
      cb(new Error("仅支持图片文件"), false);
    }
  },
});
// const axios = require('axios');

let ResultCache = [];
// 对环境进行 monkey patch
const { Canvas, Image, ImageData, createCanvas, loadImage } = require("canvas");
faceapi.env.monkeyPatch({ Canvas, Image, ImageData });

// 格式化检测结果
const processBasicInfo = (detections) => {
  return detections.map((detection, index) => ({
    descriptor: detection.descriptor,
  }));
};

// 加载模型方法
const loadModels = async () => {
  try {
    await Promise.all([
      faceapi.nets.tinyFaceDetector.loadFromDisk(__dirname + "/models"),
      faceapi.nets.faceLandmark68Net.loadFromDisk(__dirname + "/models"),
      faceapi.nets.faceRecognitionNet.loadFromDisk(__dirname + "/models"),
      faceapi.nets.faceExpressionNet.loadFromDisk(__dirname + "/models"),
      faceapi.nets.ageGenderNet.loadFromDisk(__dirname + "/models"),
    ]);
  } catch (error) {
    console.error("模型加载失败:", error);
  }
};
// 加载员工档案的人脸方法
const initBanseImage = async (phone) => {
  return new Promise(async (resolve, reject) => {
    try {
      if (typeof phone !== "string") {
        throw new Error("phone参数类型错误");
      }
      // 是否有结果已被保存 加载基础图片
      let noHaveDescriptor = true;
      ResultCache.forEach((item) => {
        if (item.phone === phone) {
          noHaveDescriptor = false;
        }
      });
      if (noHaveDescriptor) {
        // 这是后端返回base64的方法
        // let imgRes = await axios.get('https://xxxxxxx/api/xxxxxxxxx/GetPhotoForAI', { // 这里是获取基础照片的链接, 替换为您自己的
        //   params: {
        //     phone: phone
        //   }
        // })
        // const sharp = require('sharp');
        // const buffer =  Buffer.from(imgRes.data.replace(/^data:image\/\w+;base64,/, ''), 'base64');
        // const processedImage = await sharp(buffer)
        // .toFormat('png')
        // .toBuffer();
        // const img = await loadImage(processedImage);
        // 这是后端直接返回图片的方法
        const img = await loadImage(
          "https://xxxxxxx/api/xxxxxxxxx/GetPhotoForAI" +  // 这里是获取基础照片的链接, 替换为您自己的
            phone
        );
        const canvas = createCanvas(img.width, img.height);
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);
        const detections = await faceapi
          .detectAllFaces(
            canvas,
            new faceapi.TinyFaceDetectorOptions({
              inputSize: 256,
              scoreThreshold: 0.5,
            })
          )
          .withFaceLandmarks()
          .withFaceDescriptors();

        const chineseResults = processBasicInfo(detections);
        chineseResults.forEach((item) => {
          ResultCache.push({
            ...item,
            phone: phone,
          });
        });
        
      }
      resolve(); // 初始化成功
    } catch (error) {
      reject(new Error(`基准图初始化失败: ${error.message}`));
    }
  });

  // 接口进来 检测图片相似度
  // resiveImage("FB5E692B-640B-4928-A9C6-873C5647D1B6.png", phone);
};

// 查询本次进来的人脸
const resiveImage = async (url, phone) => {
  return new Promise(async (resolve, reject) => {
    try {
      // 加载员工档案的人脸方法
      await initBanseImage(phone);
      const img = await loadImage(url);
      if (!img || isNaN(img.width) || isNaN(img.height)) {
        throw new Error("员工档案照片无法解析");
      }
      const canvas = createCanvas(img.width, img.height);
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0);

      const detections = await faceapi
        .detectAllFaces(
          canvas,
          new faceapi.TinyFaceDetectorOptions({
            inputSize: 256,
            scoreThreshold: 0.5,
          })
        )
        .withFaceLandmarks()
        .withFaceDescriptors()
        .withFaceExpressions()
        .withAgeAndGender();

        if (detections.length === 0) {
          throw new Error("照片未检测到人脸");
        }
      // 计算两张图片的相似度

      const distance = faceapi.euclideanDistance(
        ResultCache.filter((item) => item.phone === phone)[0].descriptor,
        detections[0].descriptor
      );
      let similarity = Math.max(0, 100 - distance * 100).toFixed(1);

      const result = {
        similarity: similarity,
        age: Math.round(detections[0].age),
        gender: detections[0].gender === "male" ? "男" : "女",
        dominantEmotion: (() => {
          const [emotion, confidence] = Object.entries(
            detections[0].expressions
          ).reduce((max, curr) => (curr[1] > max[1] ? curr : max), ["", 0]);

          return {
            neutral: "中性",
            happy: "开心",
            sad: "悲伤",
            angry: "生气",
            fearful: "害怕",
            disgusted: "厌恶",
            surprised: "惊讶",
          }[emotion];
        })(),
      };
      resolve(result); 
    } catch (error) {
      reject(new Error(error.message));
    }
  });
};

// 初始化模型  项目启动调用
loadModels().then(() => {
  console.log("模型加载成功");
});

// 开启接口
app.post("/api/faceDetect", upload.single("file"), async (req, res) => {
  try {
    const imagePath = req.file.path;
    let result = await resiveImage(imagePath, req._parsedUrl.search);
    console.log(req._parsedUrl.search, result);
    let msg = {
      success: true,
      msg: '',
      detail: result,
    }
    if(result.similarity && Number(result.similarity) < 60) { // 相似度达到 60, 其实 50 也行
      msg.msg = '人脸对比失败';
      msg.success = false;
    } else {
      msg.msg = '人脸对比成功';
      msg.success = true;
    }
    res.status(200).json(msg);
  } catch (err) {
    res.status(200).json({ success: false, msg: err.message, detail: null });
  } finally {
    // 删除临时文件
    const imagePath = req.file.path;
    fs.unlinkSync(imagePath);
  }
});

app.listen(port, () => {
  console.log(`Server running at http://10.8.1.16:${port}`);
});

更多推荐