Opencv предоставляет широкие возможности по обработке изображений и работе с нейросетями. В данной статье мы напишем сервис, который позволит извлекать из изображений ряд параметров человека: пол, возраст, эмоции, а также местонахождение лица на фотографии. Получение данных характеристик бывает полезно для автоматического анализа видео и фото. Например, на конференции мы можем определить средний возраст участников, процентное соотношение мужчин и женщин, а также реакцию на конкретный доклад. Для демонстрации будем использовать модели caffe и onnx. Сервис напишем с использованием golang.

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

Для начала нужно установить пакет для работы с opencv в go. Для этого прописываем команду:

go get -u -d gocv.io/x/gocv

Затем переходим в директорию с пакетом и пишем:

make install

Если всё прошло нормально, то в конце будет выведено:

gocv version: 0.37.0
opencv lib version: 4.10.0

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

Теперь кратко рассмотрим структуру пакетов в нашем сервисе. Сервис будет разделён на несколько пакетов:

/config — в данной папке располагается файл с конфигом для запуска приложения

/internal — код проекта

/internal/api — код для апи сервис

/internal/config — код для работы с конфигом

/internal/recognizers — код для работы с предобученными моделями для распознавания параметров человека

/models — папка для размещения предобученных моделей (скачать модели можно тут)

/test_image — тестовые изображения

Написание кода начнем с модуля для работы с моделями. В сервисе будем использовать модели нескольких типов — Caffe, Onnx и pb. Для начала напишем код для обнаружения лиц на изображениях. Для этого необходимо использовать модель с расширением .pb. Сперва определим структуры необходимые для распознавания:

type FaceConfig struct {
   Ratio      float64
   Scalar     gocv.Scalar
   swapRGB    bool
   Pt         image.Point
   Confidence float32
}


type Facebox struct {
   Model   string
   Config  string
   FaceNet gocv.Net
   Conf    FaceConfig
}

Структура Facebox будет хранить информацию о пути к модели и ее конфигурации, объект, который будет использоваться для распознавания, и отдельный конфиг, который будет определять ряд параметров при распознавании лиц на конкретном изображении.

Далее напишем функции инициализации для Facebox:

func NewFacebox(model, config string) (*Facebox, error) {


   faceNet := gocv.ReadNet(model, config)
   if faceNet.Empty() {
       return nil, fmt.Errorf("reading model error: %v %v\n", model, config)
   }


   var facebox = Facebox{
       Model:   model,
       Config:  config,
       FaceNet: faceNet,
       Conf: FaceConfig{
           Ratio:      1,
           Scalar:     gocv.Scalar{Val1: 104, Val2: 177, Val3: 123},
           swapRGB:    false,
           Pt:         image.Pt(300, 300),
           Confidence: 0.6,
       },
   }


   err := faceNet.SetPreferableBackend(gocv.NetBackendDefault)
   if err != nil {
       return nil, err
   }
   err = faceNet.SetPreferableTarget(gocv.NetTargetCPU)


   if err != nil {
       return nil, err
   }


   return &facebox, nil
}

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

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

type FaceBoxResult struct {
   Left   int
   Top    int
   Right  int
   Bottom int
}


func (f *Facebox) ExtractFacesImg(img *gocv.Mat, coord []FaceBoxResult) []gocv.Mat {
   var faces = make([]gocv.Mat, 0, len(coord))
   for _, faceCoord := range coord {
       r := image.Rect(faceCoord.Left, faceCoord.Top, faceCoord.Right, faceCoord.Bottom)
       mat := img.Region(r)
       faces = append(faces, mat)
   }
   return faces
}

Для получения координат мы конвертируем изображение в Blob, после помещаем его на вход нейросети. После того как нейросеть отработает, мы проходимся по всем точкам, в нашем изображении нужно итерироваться через каждые 7 точек. После для каждого лица проверяем уровень уверенности в том, что перед нами было лицо, если значение ниже порогового (которое мы сами устанавливаем в параметрах), то мы просто отсеиваем изображение, в ином случае получаем координаты, проверяем их на корректность и после этого добавляем в слайс с координатами.

func (f *Facebox) GetFaces(img *gocv.Mat) ([]FaceBoxResult, error) {
   if img.Empty() {
       return nil, ErrEmptyImage
   }


   blob := gocv.BlobFromImage(*img, f.Conf.Ratio, f.Conf.Pt,
       f.Conf.Scalar, f.Conf.swapRGB, false)


   f.FaceNet.SetInput(blob, "data")


   var faces []FaceBoxResult
   var faceErrors []error
   faceImg := f.FaceNet.Forward("detection_out")
   for i := 0; i < faceImg.Total(); i += 7 {
       confidence := faceImg.GetFloatAt(0, i+2)
       if confidence > f.Conf.Confidence {
           left := int(faceImg.GetFloatAt(0, i+3) * float32(img.Cols()))
           top := int(faceImg.GetFloatAt(0, i+4) * float32(img.Rows()))
           right := int(faceImg.GetFloatAt(0, i+5) * float32(img.Cols()))
           bottom := int(faceImg.GetFloatAt(0, i+6) * float32(img.Rows()))
           r := image.Rect(left, top, right, bottom)
           if r.Max.X < img.Cols() && r.Max.Y < img.Rows() && r.Min.X > 0 && r.Min.Y > 0 {
               faces = append(faces, FaceBoxResult{
                   Left:   left,
                   Top:    top,
                   Right:  right,
                   Bottom: bottom,
               })
           } else {
               faceErrors = append(faceErrors, fmt.Errorf("facebox: bad coordinates rectangle: %v", r))
           }


       }


   }


   return faces, errors.Join(faceErrors...)
}

Далее мы напишем код для определения эмоций человека. Для этого будем использовать нейронные сети на основе разных архитектур Caffe и ONNX. Для начала рассмотрим нейронную сеть на основе Caffe. В целом инициализация не имеет каких-то больших отличий от инициализации предыдущей модели за исключением того, что тут мы передаём только пути к модели и её конфигу, не задавая дополнительных параметров.

type EmotionCaffe struct {
   Model  string
   Config string
   Net    gocv.Net
}


func (e *EmotionCaffe) Close() error {
   return e.Net.Close()
}


func NewEmotionCaffe(model, config string) (*EmotionCaffe, error) {


   emotionNet := gocv.ReadNet(model, config)
   if emotionNet.Empty() {
       return nil, fmt.Errorf("%v %v %v\n", ErrModelReading, model, config)
   }


   var emotion = EmotionCaffe{
       Model:  model,
       Config: config,
       Net:    emotionNet,
   }


   err := emotionNet.SetPreferableBackend(gocv.NetBackendDefault)
   if err != nil {
       return nil, err
   }
   err = emotionNet.SetPreferableTarget(gocv.NetTargetCPU)


   if err != nil {
       return nil, err
   }


   return &emotion, nil
}

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

var EmotionsCaffe = []string{"Angry", "Disgust", "Fear", "Happy", "Neutral", "Sad", "Surprise"}


func (e *EmotionCaffe) GetEmotion(face *gocv.Mat) string {
   scalar := gocv.NewScalar(0, 0, 0, 0)
   blob := gocv.BlobFromImage(*face, ratio, image.Pt(227, 227), scalar, swapRGB, false)
   e.Net.SetInput(blob, "")
   emoPreds := e.Net.Forward("")
   _, _, _, emoLoc := gocv.MinMaxLoc(emoPreds)
   return EmotionsCaffe[emoLoc.X]
}

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

func (r *Recognizer) GetCaffeEmotion(mat *gocv.Mat) ([]EmotionResponse, error) {
   faces, errs := r.Facebox.GetFaces(mat)
   if len(faces) == 0 && errs != nil {
       return nil, errs
   }
   imgs := r.Facebox.ExtractFacesImg(mat, faces)


   var res = make([]EmotionResponse, 0, len(faces))


   for i, img := range imgs {
       res = append(res, EmotionResponse{
           Coordinates: faces[i],
           Emotion:     r.EmotionCaffe.GetEmotion(&img),
       })
   }


   return res, errs


}

Далее добавим распознавание эмоций при помощи другой модели. Работа с моделью ONNX немного отличается от предыдущей. Для инициализации модели нам не нужен конфиг, достаточно самой модели:

type EmotionONNX struct {
   Model string
   Net   gocv.Net
}


func (e *EmotionONNX) Close() error {
   return e.Net.Close()
}


func NewEmotionONNX(model string) (*EmotionONNX, error) {


   emotionNet := gocv.ReadNetFromONNX(model)
   if emotionNet.Empty() {
       return nil, fmt.Errorf("%v %v\n", ErrModelReading, model)
   }


   var emotion = EmotionONNX{
       Model: model,
       Net:   emotionNet,
   }


   err := emotionNet.SetPreferableBackend(gocv.NetBackendDefault)
   if err != nil {
       return nil, err
   }
   err = emotionNet.SetPreferableTarget(gocv.NetTargetCPU)


   if err != nil {
       return nil, err
   }


   return &emotion, nil
}

При работе с Onnx есть некоторые особенности. Прежде чем получать Blob из изображения, мы должны конвертировать изображение Mat в другой формат при помощи функций CvtColor и ConvertTo. Если этого не сделать, то при обработке изображения моделью приложение упадет. Также важно создавать переменную с типом Mat при помощи специальной функции gocv.NewMat(). Если создать переменную без этой функции, то такой объект также может привести к падению приложения.

func (e *EmotionONNX) GetEmotion(face *gocv.Mat) string {


   meanVal := gocv.Scalar{Val1: 78.4263377603,
       Val2: 87.7689143744,
       Val3: 114.895847746}


   //создавать нужно так
   var imgFER = gocv.NewMat()


   gocv.CvtColor(*face, &imgFER, gocv.ColorBGRToGray)
   imgFER.ConvertTo(&imgFER, gocv.MatTypeCV32FC1)
   blobFER := gocv.BlobFromImage(imgFER, ratio, image.Pt(64, 64),
       meanVal, false, swapRGB)


   e.Net.SetInput(blobFER, "")
   output := e.Net.Forward("")


   _, _, _, emoONNXLoc := gocv.MinMaxLoc(output)
   return EmotionsONNX[emoONNXLoc.X]
}

Код для работы с общим изображением практически такой же, как и для моделей Caffe:

var EmotionsONNX = []string{"Neutral", "Happy", "Surprise", "Sad", "Anger", "Disgust", "Fear", "Contempt"}


func (r *Recognizer) GetOnnxEmotion(mat *gocv.Mat) ([]EmotionResponse, error) {
   faces, errs := r.Facebox.GetFaces(mat)
   if len(faces) == 0 && errs != nil {
       return nil, errs
   }
   imgs := r.Facebox.ExtractFacesImg(mat, faces)


   var res = make([]EmotionResponse, 0, len(faces))


   for i, img := range imgs {
       res = append(res, EmotionResponse{
           Coordinates: faces[i],
           Emotion:     r.EmotionONNX.GetEmotion(&img),
       })
   }


   return res, errs


}

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

type Gender struct {
   Model  string
   Config string
   Net    gocv.Net
}


func (g *Gender) Close() error {
   return g.Net.Close()
}


func NewGender(model, config string) (*Gender, error) {


   genderNet := gocv.ReadNet(model, config)
   if genderNet.Empty() {
       return nil, fmt.Errorf("%v %v %v\n", ErrModelReading, model, config)
   }


   var emotion = Gender{
       Model:  model,
       Config: config,
       Net:    genderNet,
   }


   err := genderNet.SetPreferableBackend(gocv.NetBackendDefault)
   if err != nil {
       return nil, err
   }
   err = genderNet.SetPreferableTarget(gocv.NetTargetCPU)


   if err != nil {
       return nil, err
   }


   return &emotion, nil
}


func (g *Gender) GetGender(face *gocv.Mat) string {
   scalar := gocv.NewScalar(0, 0, 0, 0)
   blob := gocv.BlobFromImage(*face, ratio, image.Pt(227, 227), scalar, swapRGB, false)
   g.Net.SetInput(blob, "")
   genderPreds := g.Net.Forward("")
   _, _, _, ageLoc := gocv.MinMaxLoc(genderPreds)
   return Genders[ageLoc.X]
}


func (r *Recognizer) GetGender(mat *gocv.Mat) ([]GenderResponse, error) {
   faces, errs := r.Facebox.GetFaces(mat)
   if len(faces) == 0 && errs != nil {
       return nil, errs
   }
   imgs := r.Facebox.ExtractFacesImg(mat, faces)


   var res = make([]GenderResponse, 0, len(faces))


   for i, img := range imgs {
       res = append(res, GenderResponse{
           Coordinates: faces[i],
           Gender:      r.Gender.GetGender(&img),
       })
   }


   return res, errs


}

Далее рассмотрим код для определения возраста человека:

type Age struct {
   Model  string
   Config string
   Net    gocv.Net
}


func (a *Age) Close() error {
   return a.Net.Close()
}


func NewAge(model, config string) (*Age, error) {


   ageNet := gocv.ReadNet(model, config)
   if ageNet.Empty() {
       return nil, fmt.Errorf("%v %v %v\n", ErrModelReading, model, config)
   }


   var age = Age{
       Model:  model,
       Config: config,
       Net:    ageNet,
   }


   err := ageNet.SetPreferableBackend(gocv.NetBackendDefault)
   if err != nil {
       return nil, err
   }
   err = ageNet.SetPreferableTarget(gocv.NetTargetCPU)


   if err != nil {
       return nil, err
   }


   return &age, nil
}


func (a *Age) GetAge(face *gocv.Mat) string {
   scalar := gocv.NewScalar(0, 0, 0, 0)
   blob := gocv.BlobFromImage(*face, ratio, image.Pt(227, 227), scalar, swapRGB, false)
   a.Net.SetInput(blob, "")
   agePreds := a.Net.Forward("")
   _, _, _, ageLoc := gocv.MinMaxLoc(agePreds)
   return Ages[ageLoc.X]
}


func (r *Recognizer) GetAge(mat *gocv.Mat) ([]AgeResponse, error) {
   faces, errs := r.Facebox.GetFaces(mat)
   if len(faces) == 0 && errs != nil {
       return nil, errs
   }
   imgs := r.Facebox.ExtractFacesImg(mat, faces)


   var res = make([]AgeResponse, 0, len(faces))


   for i, img := range imgs {
       res = append(res, AgeResponse{
           Coordinates: faces[i],
           Age:         r.Age.GetAge(&img),
       })
   }


   return res, errs


}

Единственное, что стоит отметить: возраст определяется не в виде конкретного числа, а как интервал. То есть модель говорит о том, что возраст человека предположительно от 20 до 36 лет. Ниже приведены все интервалы в виде слайса:

var Ages = []string{"0-2", "3-7", "8-12", "13-20", "20-36", "37-47", "48-55", "56-100"}

Теперь рассмотрим основной код для самого сервиса. Прежде всего напишем конфиг и код для работы с ним:

{
 "server":{
   "host": "127.0.0.1",
   "port": 8080
 },


 "facebox": {
   "model":"models/face_detector_uint8.pb",
   "config":"models/face_detector.pbtxt"
 },


 "emotion_caffe": {
   "model":"models/emotion.caffemodel",
   "config":"models/emotion.prototxt"
 },


 "emotion_onnx": {
   "model":"models/emotion.onnx"
 },


 "gender": {
   "model":"models/gender.caffemodel",
   "config":"models/gender.prototxt"
 },


 "age": {
   "model":"models/age.caffemodel",
   "config":"models/age.prototxt"
 }


}

В данном конфиге мы определяем путь к нашим моделям, а также адрес и порт, на котором будет работать приложение. Код для работы с конфигом представлен ниже:

package config


import (
   "encoding/json"
   "os"
)


type Config struct {
   Server struct {
       Host string `json:"host"`
       Port int    `json:"port"`
   } `json:"server"`
   Facebox struct {
       Model  string `json:"model"`
       Config string `json:"config"`
   } `json:"facebox"`
   EmotionCaffe struct {
       Model  string `json:"model"`
       Config string `json:"config"`
   } `json:"emotion_caffe"`
   EmotionOnnx struct {
       Model string `json:"model"`
   } `json:"emotion_onnx"`
   Gender struct {
       Model  string `json:"model"`
       Config string `json:"config"`
   } `json:"gender"`
   Age struct {
       Model  string `json:"model"`
       Config string `json:"config"`
   } `json:"age"`
}


func ReadConfig(path string) (*Config, error) {
   b, err := os.ReadFile(path)
   if err != nil {
       return nil, err
   }


   var cfg Config


   err = json.Unmarshal(b, &cfg)
   if err != nil {
       return nil, err
   }


   return &cfg, nil


}

Для написания REST APi мы выбрали fiber. Для начала определим структуру App, которая будет хранить роутер, объект с моделями и конфиг.  

type App struct {
   rec       *recognizers.Recognizer
   routerApi *fiber.App
   cfg       *config.Config
}

Затем напишем функцию инициализации основного объекта приложения, в ней будем инициализировать наши модели и API.

func NewApp(cfg *config.Config, routerApi *fiber.App) (*App, error) {


   r, err := recognizers.New(cfg)
   if err != nil {
       return nil, err
   }


   app := &App{
       rec:       r,
       routerApi: routerApi,
       cfg:       cfg,
   }


   app.InitApi()


   return app, nil


}

API приложения позволит получать информацию при помощи различных моделей, например, если нужно получить информацию только о возрасте, то мы можем отправить запрос на /age. Если нам будет нужна вся возможная информация об изображении, то нам нужно отправить запрос на /full/info.

func (a *App) InitApi() {
   a.routerApi.Post("/facepos", a.GetFacePos)
   a.routerApi.Post("/emotion/onnx", a.GetEmotionONNX)
   a.routerApi.Post("/emotion/caffe", a.GetCaffeEmotion)
   a.routerApi.Post("/age", a.GetAge)
   a.routerApi.Post("/gender", a.GetGender)
   a.routerApi.Post("/full/info", a.GetFullInfo)
}

Также напишем функцию для запуска приложения на определенном адресе:

func (a *App) Start() error {
   return a.routerApi.Listen(fmt.Sprintf("%s:%d", a.cfg.Server.Host, a.cfg.Server.Port))
}

Далее рассмотрим несколько конкретных функций, которые реализуют точки API. Для начала разберем функцию, которая возвращает координаты лица. Сперва мы получаем изображение тела запроса в функции extractImageFrom и там же конвертируем его в Mat при помощи функции gocv.IMDecode с флагом gocv.IMReadUnchanged. После отправляем изображение на вход функции для распознавания координат и отправляем ответ пользователю с результатами.

Обратите внимание, что изображение нужно помещать в forms в запросе и указывать у них поле face (ниже будет приведён пример, как отправить запрос в postman).

func (a *App) GetFacePos(c *fiber.Ctx) error {


   mat, err := extractImageFrom(c)
   if err != nil {
      return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
           "error": err.Error(),
       })
   }


   res, err := a.rec.Facebox.GetFaces(mat)
   if err != nil {
       return err
   }


   return c.Status(fiber.StatusOK).JSON(fiber.Map{
       "facebox": res})
}

func extractImageFrom(c *fiber.Ctx) (*gocv.Mat, error) {
   file, err := c.FormFile("face")
   if err != nil {
       return nil, err
   }


   f, err := file.Open()
   if err != nil {
       return nil, err
   }
   defer f.Close()


   b, err := io.ReadAll(f)
   if err != nil {
       return nil, err
   }


   mat, err := gocv.IMDecode(b, gocv.IMReadUnchanged)
   if err != nil {
       return nil, err
   }


   return &mat, err
}

В целом код для остальных функций API не сильно отличается, в основном отличается только функция для анализа изображения, например, функция GetFullInfo, которая отвечает за получение всей возможной информации об изображении.

func (a *App) GetFullInfo(c *fiber.Ctx) error {


   mat, err := extractImageFrom(c)
   if err != nil {
       return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
           "error": err.Error(),
       })
   }


   res, err := a.rec.GetFullIno(mat)
   if err != nil {
       return err
   }


   return c.Status(fiber.StatusOK).JSON(fiber.Map{
       "results": res})
}

Теперь проверим как работает наш сервис, для этого возьмем например такое изображение:

При отправке запроса помещаем изображение в form-data и указывает ключ face. Запрос сделаем  при помощи Postman, в ответе мы видим JSON со всеми необходимыми нам характеристиками.

В данной статье мы разобрали, как использовать opencv в go с моделями Caffe и ONNX, а также написали простой сервис для демонстрации его работы. Исходный код размещен тут.

Автор статьи @yurii_habr


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

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


  1. morheus9
    06.08.2024 14:45

    Вы уверены что это страх? :D