Выиграв Рождество, богине не нужно гадать!

внешний интерфейс TensorFlow
Выиграв Рождество, богине не нужно гадать!

В этой статье будут объединены возможности камеры мобильных устройств с TensorFlow.js для реализации классификатора лицевых эмоций в реальном времени в браузере. Ввиду длинного предыстории статьи учащиеся, которых больше интересует сама реализация, могут сразу перейти кОбзор технических решений.

ДЕМО-демо

предисловие

После наблюдения за 25-летней снежной луной без цветов Бен Ван больше не грустит и не радуется. Неожиданно в канун Рождества богиня фактически согласилась на просьбу о свидании. Но перед лицом такой прекрасной возможности этот фронтенд-инженер внезапно запаниковал. Я думаю, это похоже на некоторых здешних чиновников, хотя Юшу близок к ветру и учтив, но поскольку он не может угадать мысли девушки, он окажется в утробе матери-одиночки, если не будет осторожен. Сейчас 8102 года, и если такой мальчишка, как я, не выйдет из списка, у партии и народа будет 10 000 разногласий! Подумав о боли, я полностью выложусь на своих технических преимуществах, заполню древо навыков наблюдения «Ян» и цвета, буду отличным подростком, понимающим радости, печали и печали богини, и выиграю вершину Рождества. !

перейти к делу

анализ спроса

Наши фронтенд-инженеры, наконец, представили TensorFlow.js в 2018 году, а это означает, что независимо от того, насколько слаб алгоритм и он не знает, как выполнять транзакции py, мы можем положиться на js, чтобы следовать за студентами-алгоритмами, чтобы изучить трюк и полурежим. Если мы можем получить фотографии богини по официальным каналам в период свиданий, можем ли мы использовать алгоритм, чтобы проанализировать, счастлива ли богиня, когда видит ее, или... нет, она должна быть счастлива!

Однако поле битвы на свиданиях быстро меняется. Мы не можем фотографировать и помещать их в наши мобильные телефоны. После встречи мы вернемся в наш тихий дом и проведем анализ кода. Тогда будет «слишком молодо, слишком поздно». "! Время - это жизнь, если мы не можем узнать настроение богини на месте, мы можем поставить себе -1!

Поэтому наша цель — увидеть этот эффект в реальном времени на мобильном телефоне (ну, это немного сыровато, но в этой статье речь пойдет о реализации функции, ха-ха):

Обзор технических решений

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

Наш процесс показан на следующем рисунке (следующее будет оптимизировано для проблемы скорости вычислений):

Теперь разберемся, как это реализовать по этой блок-схеме!

Ядро 1: сбор и отображение изображений

Получение изображения

как мы используемМобильные устройствапровестиизображениеиливидеопотокколлекция? Это требует использованияWebRTC. WebRTC, или веб-связь в реальном времени, — это API, который поддерживает веб-браузеры для голосовых или видео-разговоров в реальном времени. Он был открыт 1 июня 2011 года и был включен в рекомендацию W3C консорциума World Wide Web при поддержке Google, Mozilla, Opera.

Поднимите камеру и получите захваченный видеопоток, это именно та возможность, которую нам нужно использовать, предоставляемая WebRTC, и основной APInavigator.mediaDevices.getUserMedia.

Совместимость этого метода следующая: видно, что для обычных мобильных телефонов он все еще может хорошо поддерживаться. Однако между разными мобильными телефонами, типами и версиями систем, а также типами и версиями браузеров могут существовать некоторые различия. Если вам нужна лучшая совместимость, вы можете рассмотреть возможность использованияAdapter.jsЧтобы сделать прокладку, она изолирует наше приложение от различий API. Кроме того, вздесьМожно увидеть несколько интересных примеров. С конкретной реализацией Adapter.js можно ознакомиться самостоятельно.

Так как же используется этот метод? мы можем пройтиMDNПриходите проверить это.MediaDevices getUserMedia()Он будет запрашивать разрешение у пользователя, использовать медиа-ввод для получения MediaStream указанного типа (например, аудиопотока, видеопотока) и разрешать объект MediaStream. сообщать:

navigator.mediaDevices.getUserMedia(constraints)
.then(function(stream) {
  /* use the stream */
})
.catch(function(err) {
  /* handle the error */
});

Следовательно, мы можем сделать это единообразно в файле ввода:

class App extends Component {
  constructor(props) {
    super(props);
    // ...
    this.stream = null;
    this.video = null;
    // ...
  }

  componentDidMount() {
    // ...
    this.startMedia();
  }

  startMedia = () => {
    const constraints = {
      audio: false,
      video: true,
    };
    navigator.mediaDevices.getUserMedia(constraints)
      .then(this.handleSuccess)
      .catch(this.handleError);
  }

  handleSuccess = (stream) => {
    this.stream = stream; // 获取视频流
    this.video.srcObject = stream; // 传给 video
  }
  
  handleError = (error) => {
    console.log('navigator.getUserMedia error: ', error);
  }
  // ...
}

Отображение в реальном времени

зачем нужноthis.videoНу а нам нужно не просто отобразить отснятый видеопоток, но и интуитивно обозначить выражение лица богини.Поэтому нам необходимо отобразить видеопоток и отрисовать основную графику через холст.Метод соединения этих двух точек следующий:

canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);

Конечно, на самом деле нам не нужно предоставлять видео DOM в представлении, но приложение поддерживает его внутри экземпляра. Canvas.width и canvas.height должны учитывать размер мобильных устройств, которые здесь не указаны.

Нарисовать прямоугольную рамку и текстовую информацию очень просто, нам нужно только получить информацию о положении, рассчитанную моделью алгоритма:

export const drawBox = ({ ctx, x, y, w, h, emoji }) => {
  ctx.strokeStyle = EmojiToColor[emoji];
  ctx.lineWidth = '4';
  ctx.strokeRect(x, y, w, h);
}

export const drawText = ({ ctx, x, y, text }) => {
  const padding = 4
  ctx.fillStyle = '#ff6347'
  ctx.font = '16px'
  ctx.textBaseline = 'top'
  ctx.fillText(text, x + padding, y + padding)
}

Ядро 2: предсказание модели

Здесь нам нужно разобрать проблему. Учитывая, что «распознавание эмоций, стоящих за выражениями богинь», упомянутое в этой статье, является проблемой классификации изображений, эта проблема требует от нас выполнения двух вещей:

  • Извлеките изображение части лица из изображения;
  • Извлеченные фрагменты изображения используются в качестве входных данных для модели для расчета классификации.

Давайте обсудим эти два пункта шаг за шагом.

извлечение лица

мы будем использоватьface-api.jsобрабатывать. face-api.js — это библиотека обнаружения и распознавания лиц, реализованная в среде браузера на основе основного API tensorflow.js (@tensorflow/tfjs-core). SSD Mobilenet V1, Tiny Yolo V2, MTCNN — три очень легкие модели, подходящие для мобильных устройств. Понятно, что эффект, естественно, много скидок.Эти модели были упрощены с точки зрения размера модели, вычислительной сложности, энергопотребления машины и т. д. Хотя некоторые мобильные устройства, специально используемые для вычислений, все еще могут управлять полной моделью. но наши обычные мобильные телефоны, конечно, не имеют карты, поэтому, естественно, мы можем использовать только мобильную версию модели.

Здесь мы будем использовать MTCNN. Мы можем взглянуть на дизайн модели, как показано на изображении ниже. Видно, что наши кадры изображения будут преобразованы в тензоры разного размера и переданы в разные сети, и будет выполнена куча Max-pooling, и, наконец, классификация лиц, регрессия bb box и позиционирование ориентиров будут завершены одновременно. время. Грубо говоря, вводя изображение, мы можем получить категорию всех лиц на изображении, информацию о местоположении кадра обнаружения и более подробную информацию о местоположении, такую ​​как глаза, нос и губы.

Конечно, когда мы используем face-api.js, нам не нужно думать об этом слишком тщательно, он делает много абстракции и инкапсуляции, и даже очень жестко ограждает фронтенд-студентов.ТензорКонцепция, вам нужно только получить img DOM, да, img DOM, который уже загрузил src в качестве входных данных метода инкапсуляции (загрузка img — это обещание немного), который будет преобразован в требуемые тензоры сам по себе. С помощью следующего кода мы можем извлечь лицо из видеокадра.

export class FaceExtractor {
  constructor(path = MODEL_PATH, params = PARAMS) {
    this.path = path;
    this.params = params;
  }

  async load() {
    this.model = new faceapi.Mtcnn();
    await this.model.load(this.path);
  }

  async findAndExtractFaces(img) {
    // ...一些基本判空保证在加载好后使用
    const input = await faceapi.toNetInput(img, false, true);
    const results = await this.model.forward(input, this.params);
    const detections = results.map(r => r.faceDetection);
    const faces = await faceapi.extractFaces(input.inputs[0], detections);
    return { detections, faces };
  }
}

классификация эмоций

Ну наконец-то к основной функции! «Хорошая» привычка — заглянуть на GitHub, чтобы узнать, есть ли какой-нибудь открытый исходный код, на который вы можете сослаться.Если вы большой парень, пожалуйста, притворитесь, что я этого не говорил. Здесь мы будем использоватьМодели распознавания лиц и классификации эмоций в реальном времениЧтобы выполнить нашу основную функцию, эта модель может различать счастливых, злых, грустных, отвратительных, невыразительных и т. д.

Для использования TensorFlow.js в браузере во много раз большеприменениесуществующие модели черезtfjs-converterЧтобы преобразовать существующие модели TensorFlow и модели Keras в модели, которые могут использоваться tfjs. Стоит отметить, что сам мобильный телефон интегрирует множество датчиков и может собирать много данных.Я считаю, что в будущем у tfjs будет место для игры. Конкретные методы преобразования можно найти в документации, и мы продолжим рассказывать об этом.

Итак, можем ли мы передать img DOM в модель, как в случае с face-api.js? Нет, на самом деле вход в модель, которую мы используем, — это не случайное изображение, а тензор, который нужно преобразовать к заданному размеру и который сохраняет только изображение в градациях серого. Поэтому, прежде чем продолжить, нам нужно выполнить предварительную обработку исходного изображения.

Ха-ха, с первого дня первого года можно спрятаться, а с пятнадцатого нет, давайте выясним, что это такое.ТензорБар! Официальный сайт TensorFlow объясняет это так:

Тензоры — это обобщения векторов и матриц на потенциально более высокие размерности. TensorFlow внутренне представляет тензоры как n-мерные массивы примитивных типов данных.

Забудьте об этом, давайте нарисуем картинку, чтобы понять, как выглядит тензор:

Следовательно, мы можем просто понимать его как матрицу более высокой размерности, и когда она хранится, это массив массивов. Конечно, изображения RGB, которые мы обычно используем, имеют три канала. Значит ли это, что данные нашего изображения представляют собой трехмерный тензор (ширина, высота, канал)? Нет, в TensorFlow первое измерение обычно равно n, а именно количество изображений (точнее, пакетных), поэтому форма тензора изображения обычно [n, высота, ширина, канал], а также то есть четыре -мерный тензор.

Итак, как мы делаем изображениепредварительная обработкаШерстяная ткань? Сначала мы центрируем значения пикселей, распределенные в [0, 255], до [-127,5, 127,5], а затем нормализуем до [-1, 1].

const NORM_OFFSET = tf.scalar(127.5);

export const normImg = (img, size) => {
  // 转换成张量
  const imgTensor = tf.fromPixels(img);

  // 从 [0, 255] 标准化到 [-1, 1].
  const normalized = imgTensor
    .toFloat()
    .sub(NORM_OFFSET) // 中心化
    .div(NORM_OFFSET); // 标准化

  const { shape } = imgTensor;
  if (shape[0] === size && shape[1] === size) {
    return normalized;
  }

  // 按照指定大小调整
  const alignCorners = true;
  return tf.image.resizeBilinear(normalized, [size, size], alignCorners);
}

Затем преобразуйте изображение в оттенки серого:

export const rgbToGray = async imgTensor => {
  const minTensor = imgTensor.min()
  const maxTensor = imgTensor.max()
  const min = (await minTensor.data())[0]
  const max = (await maxTensor.data())[0]
  minTensor.dispose()
  maxTensor.dispose()

  // 灰度图则需要标准化到 [0, 1],按照像素值的区间来标准化
  const normalized = imgTensor.sub(tf.scalar(min)).div(tf.scalar(max - min))

  // 灰度值取 RGB 的平均值
  let grayscale = normalized.mean(2)

  // 扩展通道维度来获取正确的张量形状 (h, w, 1)
  return grayscale.expandDims(2)
}

Таким образом, наш вход переходит от 3-канального цветного изображения к 1-канальному черно-белому изображению.

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

Когда изображение готово, нам нужно начать подготовкуМодель! Наша модель в основном должна предоставлять методы для загрузки модели.loadи классифицировать изображенияclassifyэти два метода. Загрузка модели так же проста, как вызовtf.loadModelТо есть следует отметить, что загрузка модели — это асинхронный процесс. Мы используем create-react-app для сборки проекта, а упакованная конфигурация Webpack уже поддерживает метод async-await.

class Model {
  constructor({ path, imageSize, classes, isGrayscale = false }) {
    this.path = path
    this.imageSize = imageSize
    this.classes = classes
    this.isGrayscale = isGrayscale
  }

  async load() {
    this.model = await tf.loadModel(this.path)

    // 预热一下
    const inShape = this.model.inputs[0].shape.slice(1)
    const result = tf.tidy(() => this.model.predict(tf.zeros([1, ...inShape])))
    await result.data()
    result.dispose()
  }

  async imgToInputs(img) {
    // 转换成张量并 resize
    let norm = await prepImg(img, this.imageSize)
    // 转换成灰度图输入
    norm = await rgbToGrayscale(norm)
    // 这就是所说的设置 batch 为 1
    return norm.reshape([1, ...norm.shape])
  }

  async classify(img, topK = 10) {
    const inputs = await this.imgToInputs(img)
    const logits = this.model.predict(inputs)
    const classes = await this.getTopKClasses(logits, topK)
    return classes
  }

  async getTopKClasses(logits, topK = 10) {
    const values = await logits.data()
    let predictionList = []

    for (let i = 0; i < values.length; i++) {
      predictionList.push({ value: values[i], index: i })
    }

    predictionList = predictionList
      .sort((a, b) => b.value - a.value)
      .slice(0, topK)

    return predictionList.map(x => {
      return { label: this.classes[x.index], value: x.value }
    })
  }
}

export default Model

Мы видим, что наша модель возвращает количество, называемое логитами, и чтобы узнать результат классификации, мы выполняем операцию getTopKClasses. Это может сбить с толку учащихся, которые меньше знают об этой области. Фактически, для модели классификации мы возвращаем не конкретный класс, а распределение вероятностей для каждого класса, например:

// 示意用
const classifyResult = [0.1, 0.2, 0.25, 0.15, 0.3];

То есть результат нашей классификации на самом деле не говорит о том, что что-то на изображении «должно быть человеком или собакой», а «может быть человеком или, может быть, собакой». Взяв приведенный выше схематический код в качестве примера, если наша метка соответствует ['женщина', 'мужчина', 'большая собака', 'собака', 'два га'], то приведенный выше результат на самом деле следует понимать как: Объект на изображении с вероятностью 25 % большая собака и с вероятностью 20 % мужчина.

Поэтому нам нужно сделать getTopKClasses, Согласно нашему сценарию, нас интересует только наиболее вероятная эмоция, тогда мы также возьмем значение распределения вероятности top1, чтобы узнать наиболее вероятный результат прогноза.

Как насчет того, является ли расширенный метод, инкапсулированный tfjs, более понятным семантически?

Наконец, мы интегрируем упомянутую выше функцию извлечения лиц с моделью классификации эмоций и добавляем базовый рисунок на холсте:

  // 略有调整
  analyzeFaces = async (img) => {
    // ...
    const faceResults = await this.faceExtractor.findAndExtractFaces(img);
    const { detections, faces } = faceResults;

    // 对提取到的每一个人脸进行分类
    let emotions = await Promise.all(
      faces.map(async face => await this.emotionModel.classify(face))
    );
    // ...
  }

  drawDetections = () => {
    const { detections, emotions } = this.state;
    if (!detections.length) return;

    const { width, height } = this.canvas;
    const ctx = this.canvas.getContext('2d');
    const detectionsResized = detections.map(d => d.forSize(width, height));
    detectionsResized.forEach((det, i) => {
      const { x, y } = det.box
      const { emoji, name } = emotions[i][0].label;
      drawBox({ ctx, ...det.box, emoji });
      drawText({ ctx, x, y, text: emoji, name });
    });
  }

Готово!

оптимизация в реальном времени

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

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

Автор использует флаг, чтобы отметить, выполняется ли в данный момент расчет модели.Если да, то введите следующий цикл обработки событий, в противном случае введите асинхронную операцию расчета модели. В то же время каждый цикл событий выполняет операцию холста, чтобы гарантировать, что поле маркера всегда отображается, и каждое отображение фактически является предыдущим результатом расчета модели, кэшированным в состоянии. Эта операция разумна, потому что движение лица обычно непрерывное (если оно не непрерывное, возможно, придется пересмотреть мир), этот метод обработки может лучше отображать результаты и не будет рассчитываться моделью. Блокирование, приводящее к заиканию, по сути является методом дискретной выборки.

  handleSnapshot = async () => {
    // ... 一些 canvas 准备操作
    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
    this.drawDetections(); // 绘制 state 中维护的结果
    
    // 利用 flag 判断是否有正在进行的模型预测
    if (!this.isForwarding) {
      this.isForwarding = true;
      const imgSrc = await getImg(canvas.toDataURL('image/png'));
      this.analyzeFaces(imgSrc);
    }

    const that = this;
    setTimeout(() => {
      that.handleSnapshot();
    }, 10);
  }

  analyzeFaces = async (img) => {
    // ...其他操作
    const faceResults = await this.models.face.findAndExtractFaces(img);
    const { detections, faces } = faceResults;

    let emotions = await Promise.all(
      faces.map(async face => await this.models.emotion.classify(face))
    );
    this.setState(
      { loading: false, detections, faces, emotions },
      () => {
        // 获取到新的预测值后,将 flag 置为 false,以再次进行预测
        this.isForwarding = false;
      }
    );
  }

Показать результаты

Давайте посмотрим на эффект испытания богини:

Ну так себе! Хотя иногда я все еще распознаю улыбки как лишенные выражения, эй, это актерские способности Гакки все еще немного... Ладно, время уходит, поторопитесь, возьмите свое оружие и приготовьтесь идти на встречу. Наденьте красивую клетчатую рубашку и наденьте костюм программиста~

конец

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

В этот момент я вспомнил песню Исона:

Lonely Lonely christmas
Merry Merry christmas
Кому я могу отправить карту?
Разбитое сердце, как конфетти на улице

Ссылаться на

Статья может быть воспроизведена по желанию, но просьба сохранитьОригинальная ссылка.

Добро пожаловать!ES2049 Studio, отправьте свое резюме на caijun.hcj(at)alibaba-inc.com.