imageАвтор материала знакомит нас со своим коллегой Крисом — @Malakhor9000

Крис работает в офисе, где есть целая куча сотрудников, которым нравится «лепить» его лицо фотошопом на самые разные фотки, и постить все это в Slack-канале компании.

Однако постоянно открывать редактор и «копипастить» вырезки лица — дело нудное, особенно когда Крис пытается отвлечь коллег рассказами о своих геройствах в Smite. И вот после многих ночей, проведенных в фотошопе на протяжении нескольких недель, автор материала решительно захотел найти более удобный способ. Так на свет появилась идея написания @Chrisbot. Подробности этой истории ниже.


Изначально, когда я обдумывал идею, я знал, что в проекте будет три главных компонента:

  1. Простая обработка изображения.
  2. Интеграция со Slack.
  3. Распознавание лиц.

image

Ранее я уже изучал пакеты image и image/draw для Go, прочел несколько статей о них и потому был уверен, что смогу воспользоваться ими для задуманной цели. Так я нашел решение для компонента №1.

Кроме того, в прошлом у меня уже был опыт написания пробного Slack-бота на Go по инструкции, найденной в Google. Отсутствие официального Go клиента для Slack несколько усложняло процесс, но учитывая базовый уровень моих потребностей, я был уверен, что смогу написать бота, способного скачать изображения и загрузить их в Slack. Так я определился с решением для компонента №2.

Единственная часть проекта, в простоте которой я не был уверен — распознавание лиц. Я загуглил golang face detect и кликнул на первый попавшийся результат — вопрос на StackOverflow о библиотеке машинного зрения go-opencv. Беглое ознакомление с примером использования позволило мне узнать все, что надо. Так нашлось решение для компонента №3.

Распознавание лиц


Начал я именно с распознавания как с наименее понятной мне части. Это была самая значимая неизвестная в проекте и было бы почти бессмысленно работать над остальной частью, если бы я не смог сообразить, как «вставлять» лица.

Я решил максимально инкапсулировать библиотеку go-opencv. Имея представление о том, что типы данных opencv не похожи на стандартные библиотеки Go, во всяком случае с точки зрения определения интерфейсов Image и Rectangle, я понимал, что определенная конверсия необходима.

Немного покопавшись, нашел отсылку к методу opencv.FromImage, производящему конверсию из пакета image.Image в понятный библиотеке opencv формат. Такой подход имел дополнительное преимущество, поскольку не требовал передачи пути до файла методу opencv.LoadImage, позволяя вместо этого вести работу с изображением, хранимым в памяти. Это избавило меня от необходимости сохранения изображения в файловой системе после его получения из Slack.

К сожалению, мне не удалось найти способ применить тот же удобный подход к XML-файлу Haar классификации лиц, но поскольку мне не терпелось увидеть конечный результат, я решил что сойдет и так. В итоге смастерил нечто похожее на приведенный ниже пакет facefinder, гораздо более сырой несколько итераций тому назад:

package facefinder

import (
  "image"

  "github.com/lazywei/go-opencv/opencv"
)

var faceCascade *opencv.HaarCascade

type Finder struct {
  cascade *opencv.HaarCascade
}

func NewFinder(xml string) *Finder {
  return &Finder{
    cascade: opencv.LoadHaarClassifierCascade(xml),
  }
}

func (f *Finder) Detect(i image.Image) []image.Rectangle {
  var output []image.Rectangle

  faces := f.cascade.DetectObjects(opencv.FromImage(i))
  for _, face := range faces {
    output = append(output, image.Rectangle{
      image.Point{face.X(), face.Y()},
      image.Point{face.X() + face.Width(), face.Y() + face.Height()},
    })
  }

  return output
}

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

imageReader, _ := os.Open(imageFile)
baseImage, _, _ := image.Decode(imageReader)

finder := facefinder.NewFinder(haarCascadeFilepath)
faces := finder.Detect(baseImage)

for _, face := range faces {
  // [...]
}

Для проверки кода на работоспособность и корректность выполнения задачи я скопипастил из гугла простенький код рисования прямоугольника и оно сработало! Вооруженный информацией о расположении лица, я оптимизировал функцию загрузки изображений. Она теперь на самом деле следила за ошибками вместо того, чтобы кидать их в _ bin.

func loadImage(file string) image.Image {
  reader, err := os.Open(file)
  if err != nil {
    log.Fatalf("error loading %s: %s", file, err)
  }
  img, _, err := image.Decode(reader)
  if err != nil {
    log.Fatalf("error loading %s: %s", file, err)
  }
  return img
}

Обработка изображений


В итоге мой новый цикл выглядел как-то так:

baseImage := loadImage(imageFile)
chrisFace := loadImage(chrisFaceFile)

bounds := baseImage.Bounds()

finder := facefinder.NewFinder(haarCascadeFilepath)
faces := finder.Detect(baseImage)

// Convert image.Image to a mutable image.ImageRGBA
canvas := image.NewRGBA(bounds)
draw.Draw(canvas, bounds, baseImage, bounds.Min, draw.Src)

for _, face := range faces {
  draw.Draw(
    canvas,
    face,
    chrisFace,
    bounds.Min,
    draw.Src,
  )
}

И конечно же, нет лучшего тестового материала для проверки его работоспособности, чем фотка нашего главного героя!

image

Итак, моя программа сработала гораздо лучше чем я ожидал от первого ее запуска. Ощутимый прогресс! Для начала мне надо было избавиться от черного фона. И поскольку я использовал PNG с прозрачностью фона, я не сомневался что такой способ был. Немного гугления и я наткнулся на draw.Over для функции draw.Draw. Я заменил им использованный до этого draw.Src и вуаля!

image

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

Итак, далее мне нужно было немного уменьшить масштаб лица. Я был уверен, что попытки вписать лицо в отмеренные моим кодом-распознавателем прямоугольные области не дали бы хорошего результата. Поскольку распознавались именно лица, а не головы, я получал «прямоугольники», не очень-то подходящие для замены всей головы. Поэтому быстренько накидав функцию, увеличивающую границу image.Rectangle до определенного процентного значения, я попробовал методом «тыка» несколько значений и остановился на 30%.

Определившись с этим моментом, я приступил к регулированию размера «головы Криса» и того, как она «садится» на другие фотки. Как выяснилось вариантов было несколько, но я остановился на пакете disintegration/imaging, поскольку в нем была простая функция imaging.Fit и, кроме того, он предлагал некоторые другие операции преобразования, такие как горизонтальное зеркалирование. Вариантов лиц у меня было немного, и потому я посчитал, что случайное зеркалирование позволит увеличить количество вариантов вдвое.

После импортирования новый цикл выглядел примерно так:

for _, face := range faces {
  // Увеличиваем прямоугольник на 30%
  rect := rectMargin(30.0, face)

  // Берем случайный вариант лица (50% шанс, что оно будет отзеркалено)
  newFace := chrisFaces.Random()

  chrisFace := imaging.Fit(newFace, rect.Dx(), rect.Dy(), imaging.Lanczos)

  draw.Draw(
    canvas,
    rect,
    chrisFace,
    bounds.Min,
    draw.Over,
  )
}

Стиснув зубы, я взял несколько новых тестовых изображений, запустил на них свой код… И все заработало!

image

В этот момент я понял: назревает что-то толковое.

Интеграция со Slack


Я превратил код обработки изображения в исполняемый бинарник, который намеревался обернуть Slack-ботом. Я работал с бинарником с самого начала, еще во время тестирования, и не хотел тратить время на его конвертирование в импортируемый пакет для Slack-бота. На этом этапе я посчитал заменяющий лица код «довольно годным» и потому сосредоточился на создании Slack-бота, который мог бы его исполнять.

И снова я обратился к Google.

И опять же, я узнал все что надо из первого же результата выдачи, и мне понадобилось лишь внести незначительные изменения, которые учитывали бы скачивание и загрузку файлов. Я долго читал документацию Slack API, еще больше времени ругал ее, а потом и вовсе застрял и долго не мог продвинуться дальше. А потом в один прекрасный момент у меня получилось это:

image

Сделаем еще круче


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

Все это упростило экспериментирование с обработчиком лица. Вскоре я понял, что в случаях, когда программа не находит на изображении лиц, она просто возвращает никак не измененный оригинал. А это не круто! Поэтому я добавил еще кое-что после основного цикла:

if len(faces) == 0 {
  // Берем то или иное лицо из набора и уменьшаем его размер до 1/3 
  // ширины базового изображения
  face := imaging.Resize(
    chrisFaces[0],
    bounds.Dx()/3,
    0,
    imaging.Lanczos,
  )

  face_bounds := face.Bounds()

  draw.Draw(
    canvas,
    bounds,
    face,
    // Скажу честно, этот код придумал после пары бутылок пива, поэтому понятия не имею 
    // как он работает, но он помещает лицо в нижнюю часть изображения, центрирует его 
    // по горизонтали и обрезает нижнюю его часть
    bounds.Min.Add(image.Pt(
      -bounds.Max/2+face_bounds.Max.X/2,
      -bounds.Max.Y+int(float64(face_bounds.Max.Y)/1.9),
    )),
    draw.Over,
  )
}

Вот что получилось в итоге:

image

Довольно-таки интересное решение, если хотите знать мое мнение.

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

Представляем @Chrisbot


image

— Привет, ребята! Я знаю, каким нудным делом бывает создание фотожаб с лицом Криса, поэтому я решил оптимизировать процесс.
— Chrisbot присоединился по приглашению @jhutchinson.

Мой менеджер был до последнего времени самым ярым любителем «зафотожабить» Криса вручную.

image

— Он правда работает?
— Да
— Ну вот, из-за твоего инжиниринга, я потерял свою работу внештатного фотошопера Криса.

Что ж, извини Мэт, но автоматизация рано или поздно добирается до любой работы.

Ну, а сам виновник происходящего оценил мою работу по достоинству.

image

И вскоре весь офис начал отправлять фотки @Chrisbot’у.

image

image

image

image

image

image

image

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

Тем не менее несмотря на то, что автоматизация «фотошопинга» серьезно увеличила количество Криса в нашем Slack’e, есть среди нас и сторонники ручного подхода, считающие, что лучший результат по-прежнему достигается только с помощью персонального подхода к процессу создания «фотожаб».

image

— Вот вам, глупые роботы, вот что значит глаз художника. Качество против количества!

Не могу не согласиться: в некоторых случаях это действительно так.

image

https://github.com/zikes/chrisify
https://github.com/zikes/mybot

image
Поделиться с друзьями
-->

Комментарии (4)


  1. madf
    07.03.2017 18:25

    единственная проблема (без мелких придирок) — это то, что не хватает цветовой адаптации изображения при наложении его на другое


  1. Smitak
    08.03.2017 16:52

    Так и как же ты разрушил продуктивность офиса?


    1. Tabernakulov
      09.03.2017 19:26

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


      1. Smitak
        11.03.2017 23:09

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