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

Ранее я уже изучал пакеты 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,
)
}
И конечно же, нет лучшего тестового материала для проверки его работоспособности, чем фотка нашего главного героя!

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

Я мог бы, наверное, немного доработать края обрезанного изображения, но тоненький голосок в моей голове подсказал, что фиговое качество — как раз то, что нужно в данном случае.
Итак, далее мне нужно было немного уменьшить масштаб лица. Я был уверен, что попытки вписать лицо в отмеренные моим кодом-распознавателем прямоугольные области не дали бы хорошего результата. Поскольку распознавались именно лица, а не головы, я получал «прямоугольники», не очень-то подходящие для замены всей головы. Поэтому быстренько накидав функцию, увеличивающую границу 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,
)
}
Стиснув зубы, я взял несколько новых тестовых изображений, запустил на них свой код… И все заработало!

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

Сделаем еще круче
В первой итерации программа использовала родной загрузчик 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,
)
}
Вот что получилось в итоге:

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

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

— Он правда работает?
— Да
— Ну вот, из-за твоего инжиниринга, я потерял свою работу внештатного фотошопера Криса.
Что ж, извини Мэт, но автоматизация рано или поздно добирается до любой работы.
Ну, а сам виновник происходящего оценил мою работу по достоинству.

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







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

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

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

Поделиться с друзьями
Комментарии (4)
Smitak
08.03.2017 16:52Так и как же ты разрушил продуктивность офиса?
Tabernakulov
09.03.2017 19:26Очевидно, автор здесь имеет в виду, что с помощью его решения появилось массово отвлекающее от работы развлечение.
Smitak
11.03.2017 23:09Я так думал что это развлечение существало и до этого, а он только оставил без работы местных фотожаберов. Поэтому неясно. Я всю статью читал лишь чтобы найти как же это снизило продуктивность сотрудников, а не нашел…
madf
единственная проблема (без мелких придирок) — это то, что не хватает цветовой адаптации изображения при наложении его на другое