Как внедрить систему распознования лиц в приложение на react-native

Введение

Я работаю в команде, которая занимается поддержкой и развитием мобильного приложения, написанного на React Native. Однажды, в беклоге оказалась довольно любопытная задача — было необходимо реализовать систему распознавания лиц для автоматической отметки сотрудников на рабочем месте

На первых порах предполагалось, что со стороны фронтенда необходимо будет сделать фото, обработать его и отправить на бекенд, где и будет происходить основное действие. И наверно, это было бы самое правильное решение, но сложилось все иначе.

Бизнес хотел найти максимально бюджетное решение, без сторонних сервисов. При этом у заказчиков были опасения по поводу передачи фотографий по сети — в местах использования приложения часто возникали проблемы с пропускной способностью. Поэтому очень желательно было реализовать основную часть системы прямо на фронтенде. Бэкенд оставался, но скорее как хранилище данных.

Когда я взялся за исследование, то был настроен скептически. Казалось, что без полноценного сервиса распознавания на сервере задачу не решить. Особенно учитывая, что у нашей команды отсутствовала экспертиза в данной области. По этой же причине приведенное ниже решение далеко от идеала. Но в тоже время - оно работает.

Подход к реализации

Мы выбрали подход, основанный на embedding-ах. Для изображения лица можно построить уникальный вектор признаков (embedding). И если мы научимся получать этот вектор на устройстве, то дальше сравнение становится вполне решаемой задачей.

Таким образом, задача сводилась к следующему:

  1. Получить изображение лица.
  2. Сгенерировать для него embedding.
  3. Отправить embedding на бэкенд для сохранения или поиска в векторной базе данных.

Чтобы сгенерировать embedding, нужна специальная нейросеть — эмбеддер. Здесь встаёт два ключевых вопроса:

  1. Какую модель выбрать?
  2. Как запустить её в React-Native приложении?

Ответы неожиданно нашлись на странице проекта react-native-fast-tflite. Эта библиотека позволяет запускать модели в формате TensorFlow Lite прямо на устройстве. Более того, автор проекта собрал подборку полезных ресурсов, включая репозиторий с моделями. Там я и нашёл подходящую модель под названием FaceNet.

Стоит уточнить, что модель в Kaggle опубликована не в том формате, который мне был нужен. В сети можно найти уже сконвертированные версии (с расширением .tflite), но некоторые из них у меня просто не заработали. В итоге я решил сконвертировать модель самостоятельно. Процесс конвертации описывать здесь не буду — информации об этом достаточно в открытых источниках.

Куда важнее — разобраться, как работать с моделью. Для этого отлично подходит сервис Netron, который показывает входные и выходные параметры.

описание входных и выходных параметров
Описание входных и выходных параметров

С выходами всё понятно — в моём случае модель возвращала embedding размером 128. А вот входы сначала вызвали вопросы.

мыслительный процесс
Когда пытаешь понять что такое float32[-1, 160, 160, 3]

Разобравшись, я понял: модель ожидает массив Float32Array размером 160 × 160 × 3. То есть изображение нужно привести к размеру 160×160 пикселей и для каждого пикселя выделить три значения (RGB-каналы).

Для преобразования изображения в массив RGB есть разные подходы. Мы использовали canvas, поскольку он уже был в проекте.

Реализация

Перейдём непосредственно к коду. Чтобы не перегружать статью, я буду опускать детали, не связанные с распознаванием (например, управление состоянием компонента).

Получаем фото с камеры

Сначала используем react-native-camera, чтобы сделать снимок и передать его в дальнейшую обработку:

import { useTensorflowModel } from "react-native-fast-tflite";
import { Face, RNCamera } from "react-native-camera";
import Canvas from "react-native-canvas";

interface Props {
  onEmbeddingReceived: (embedding: Float32Array) => Promise<void>;
}

const FaceRecognitionCamera = ({ onEmbeddingReceived }: Props) => {
  const canvasRef = useRef<Canvas>(null);
  const cameraRef = useRef<RNCamera>(null);
  const plugin = useTensorflowModel(require("./facenet.tflite"));
  const model = plagin.state === "loaded" ? plugin.model : undefined;

  const makePhoto = async () => {
    if (!cameraRef.current) return;
    const { uri } = await cameraRef.current.takePictureAsync({
      fixOrientation: true,
      mirrorImage: false,
    });
    runEmbeddingCalculation(uri);
  };
};

На этом шаге мы получаем путь к сохранённому фото (uri) и передаём его в функцию runEmbeddingCalculation.


Вычисляем embedding

Прежде чем получить результат, нужно подготовить вход для модели:

import { useTensorflowModel } from "react-native-fast-tflite";
import { Face, RNCamera } from "react-native-camera";
import Canvas from "react-native-canvas";

interface Props {
  onEmbeddingReceived: (embedding: Float32Array) => Promise<void>;
}

const FaceRecognitionCamera = ({onEmbeddingReceived}: Props) => {
  const canvasRef = useRef<Canvas>(null);
  const cameraRef = useRef<RNCamera>(null);
  const plugin = useTensorflowModel(require('./facenet.tflite'));
  const model = plagin.state === 'loaded' ? plugin.model : undefined;

  const makePhoto = () => {...}

  const runEmbeddingCalculation = async (photoUrl: string) => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    try {
      const { base64Image, face } = await getFaceWithImage(photoUrl);
      const float32Array = await base64ImgToFloat32Array(canvas, base64Image, face);

      const [result] = (await model?.run([float32Array])) as Float32Array[];
      if (!result) return;

      await onEmbeddingReceived(result);
    } catch (e) {
      //error handling
    } finally {
      //do something anyway
    }
  };
};

Здесь:

  • функция getFaceWithImage извлекает лицо на фото,
  • base64ImgToFloat32Array преобразует фото лица в массив для модели,
  • model.run вычисляет и возвращает embedding.

Рассмотрим все по порядку


Детекция лица

Для выделения области лица я использовал библиотеку @react-native-ml-kit/face-detection. Функция getFaceWithImage принимает на вход адрес изображение, и возвращает изображение в формате base64 а также объект Face который, в том числе, содержит информацию о координатах лица на фото:

import FaceDetection, { Face } from "@react-native-ml-kit/face-detection";
import RNFS from "react-native-fs";
import { RECOGNITION_ERRORS } from "./constants";

const getFaceWithImage = async (
  photoPath: string,
): Promise<{ base64Image: string; face: Face }> => {
  const [face] = await FaceDetection.detect(photoPath, {
    classificationMode: "none",
    performanceMode: "accurate",
    landmarkMode: "all",
  });

  const base64Image = await RNFS.readFile(photoPath, "base64");
  if (!face) throw new Error(RECOGNITION_ERRORS.MISSING_FACE_IN_FRAME);

  return { base64Image, face };
};

Важно упомнуть: react-native-camera может искать лица, но в нашем проекте после патча координаты возвращались некорректные, поэтому пришлось использовать отдельную библиотеку.


Подготовка изображения для модели

Теперь нужно обрезать область лица на фото и привести его к размеру 160×160 а также преобразовать в массив чисел:

import Canvas, { Image } from "react-native-canvas";
import { Face } from "@react-native-ml-kit/face-detection";
import { PHOTO_SIZE } from "./constants";
import { rgbaToNormalizedRGB } from "./rgbaToNormalizedRGB";

export const base64ImgToFloat32Array = async (
  canvas: Canvas,
  base64Img: string,
  face: Face,
) => {
  const rgba = await base64ImgToRGBA(canvas, base64Img, face);
  return rgbaToNormalizedRGB(rgba);
};

/*
  Рисуем области лица на canvas и экспортируем в Uint8ClampedArray
*/
const base64ImgToRGBA = async (
  canvas: Canvas,
  base64Img: string,
  face: Face,
) => {
  const ctx = canvas.getContext("2d");

  canvas.width = PHOTO_SIZE;
  canvas.height = PHOTO_SIZE;
  const img = new Image(canvas);

  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  img.src = "data:image/png;base64," + base64Img;
  ctx.drawImage(
    img,
    face.frame.left,
    face.frame.top,
    face.frame.width,
    face.frame.height,
    0,
    0,
    canvas.width,
    canvas.height,
  );

  return (await ctx.getImageData(0, 0, canvas.width, canvas.height)).data;
};

На выходе мы получаем Uint8ClampedArray представляющий собой одномерный массив, содержащий данные цветовой модели RGBA с целыми значениями от 0 до 255. Следующий шаг — убрать альфа-канал и нормализовать значения.


Конвертация RGBA → Float32Array

Facenet модель на вход ожидает массив Float32Array в формате 160 × 160 × 3. Для этого преобразуем RGBA в RGB и нормализуем:

import { MODEL_INPUT_SIZE } from "./constants"; // 160 * 160 * 3
import { isNumber } from "./utils";

export const rgbaToNormalizedRGB = (
  rgba: Uint8ClampedArray,
  resultLength = MODEL_INPUT_SIZE,
) => {
  const result = new Float32Array(resultLength);

  for (let i = 0, j = 0; j <= result.length - 1; i += 4, j += 3) {
    const [red, green, blue] = [rgba[i], rgba[i + 1], rgba[i + 2]];

    if (isNumber(red) && isNumber(green) && isNumber(blue)) {
      result[j] = red / 255;
      result[j + 1] = green / 255;
      result[j + 2] = blue / 255;
    }
  }

  return result;
};

Эта функция:

  • игнорирует альфа-канал,
  • переводит каждый цветовой канал из диапазона [0..255] в [0..1],
  • возвращает массив Float32Array(160×160×3 = 76800), который можно подать в FaceNet.

Для чего мы делим на 255? Большинство моделей компьютерного зрения обучены на входных данных, нормализованных в диапазон [0,1] (или иногда в [-1,1]). Деление позволяет привести пиксельные значения (изначально 0–255) к тому же масштабу, на котором модель обучалась. Если подать «сырые» значения, распределение входа будет другим, и точность модели резко упадёт. Я это понял опытным путем.

Полученный эмбеддинг из нейросети мы отравляли на бекенд, где он сохранялся в векторной базе данных для последующего сравнения. Механизм сравнения и поиск похожих (близких) эмбедингов ложился на плечи бекенда.

Первые проблемы

В целом, приведённый выше код работал корректно. Однако довольно быстро проявился нюанс, о котором я не подумал заранее.

Любой, кто сталкивался с задачами face recognition, знает: перед подачей изображения в модель лицо нужно выровнять. Идеально, если линия глаз расположена строго горизонтально и занимает примерно одно и то же положение на всех фото. Без этого embedding одного и того же человека может сильно отличаться.

В моей реализации выравнивания не было. Это усложняло распознавание. Стоило чуть наклонить голову — и расстояние между эмбеддингами для одного и того же человека резко росло. Совпадения не находились.

Изучив тему глубже, я понял что одним из решений может быть библиотека OpenCV, где уже есть готовые методы для face alignment. Более того, под React Native существует порт этой библиотеки: react-native-fast-opencv. Но тут возникла проблема: наша версия фреймворка не поддерживалась. Обновить React Native мы не могли — это было слишком трудозатратно. Пришлось искать более лёгкий способ.

Поворот фотографии

Таким решением стал «ручной» поворот снимка в противоположную сторону от наклона головы. Для этого я переработал функцию getFaceWithImage и переименовал её в getAlignedFaceWithImage.

import FaceDetection, { Face } from "@react-native-ml-kit/face-detection";
import RNFS from "react-native-fs";
import { rotate } from "@meedwire/react-native-image-rotate";

import { RECOGNITION_ERRORS, ALLOWED_ANGLES } from "./constants";

export const getAlignedFaceWithImage = async (
  photoPath: string,
): Promise<{ base64Image: string; face: Face }> => {
  const { face, photoPath: alignmentPhotoPath } =
    await detectAndAlignFace(photoPath);
  const base64Image = await RNFS.readFile(alignmentPhotoPath, "base64");

  if (!face) throw new Error(RECOGNITION_ERRORS.MISSING_FACE_IN_FRAME);
  const { rotationX } = face;

  if (Math.abs(rotationX) > ALLOWED_ANGLES.X) {
    throw new Error(
      rotationX > 0
        ? RECOGNITION_ERRORS.FACE_TILTED_UP
        : RECOGNITION_ERRORS.FACE_TILTED_DOWN,
    );
  }

  return { base64Image, face };
};

const detectAndAlignFace = async (photoPath: string) => {
  const face = await FaceDetection.detect(photoPath, {
    classificationMode: "none",
    performanceMode: "accurate",
    landmarkMode: "all",
  });

  if (!face[0]) return { face: null, photoPath };
  const alignmentPhotoPath = await rotate({
    type: "file",
    content: photoPath,
    angle: -getHorizontalEyeAngle(face[0]),
  });

  const alignmentFace = await FaceDetection.detect(alignmentPhotoPath, {
    classificationMode: "none",
    performanceMode: "accurate",
  });

  return { face: alignmentFace[0], photoPath: alignmentPhotoPath };
};

const getHorizontalEyeAngle = (face: Face) => {
  const dx =
    face.landmarks!.rightEye.position.x - face.landmarks!.leftEye.position.x;
  const dy =
    face.landmarks!.rightEye.position.y - face.landmarks!.leftEye.position.y;
  return Math.atan2(dy, dx) * (180.0 / Math.PI);
};

Ключевая идея проста: функция getHorizontalEyeAngle вычисляет угол наклона линии глаз, и мы компенсируем его с помощью библиотеки @meedwire/react-native-image-rotate. После поворота заново детектируем лицо — уже на выровненном фото — и используем его для дальнейшей обработки.

Да, это не самый изящный подход, скорее костыль упрощённый workaround. Но он реально повысил качество распознавания.

Демонстрация выравнивания

Отдельно стоит упомянуть вертикальные наклоны головы (например, когда человек сильно поднял или опустил нос). В этом случае компенсировать искажённый ракурс не получится. Поэтому в коде есть проверка: если угол отклонения слишком велик, мы выбрасываем ошибку и показываем её пользователю в интерфейсе.

Заключение

В результате мы получили в целом работоспособную систему для распознавания лиц. Можно сказать прямо: система не идеальна. Мы не использовали продвинутые пайплайны, не применяли оптимизации по ускорению. Но в рамках ограничений — решение оказалось надёжным и стабильным.

EN