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.
morheus9
Вы уверены что это страх? :D