1. 前语

很久没更新博客,这次正好趁着这次机会来更新一个稍微有点意思的内容,运用C++中Opencv、TensorRT等库编译出动态库供Go调用,再写个简略的api对上传的车辆图片进行车牌辨认。究其原因,天下苦Java久矣,每次写JNI去给公司Java后端服务调用,而我不喜欢Java那我每次写好的模型动态库就到此为止了?白白浪费之前那么多核算资源于心不忍,因而计划搜集一些已有模型,做一个自己的模型服务仓库。

首要内容如下:

  • C++部分:完结TensorRT推理以及对应模型的前后处理,终究写cgo对应接口以及完结
  • Go部分:调用C++编译后的动态库,加载模型,完结辅佐功用函数以及完结接口

2 . 开始

2.1 模型部分

翻开上面的链接,README中也提到了pytorch1.8以上的版别会有问题,实践测验确实如此,总会在一个Conv的地方报错,魔改了一番代码还是无法处理,因而下载了车牌检测的数据集本地从头练习模型。需求留意的是,和官方yolov8-pose的输出成果中类别数目不同,由于依照该仓库的yaml文件设置会有两类,因而后处理阶段需求留意。

练习参数等不过多介绍,yolov8文档非常具体能够自己去查看。来看看终究导出的onnx模型。

Go调用C++动态库完结车牌辨认

终究输出为14*8400,其间14=4+2+8,意义分别是bbox的四个点,对应两个类别概率以及四个要害点的(x,y)坐标,后处理阶段就要留意对应的偏移量分别是4,2,8.

然后OCR模型直接用它供给的预练习权重导出就好,精度根本一致。得到onnx之后能够直接运用trtexec转为对应的engine文件。

2.2 C++部分

为推理引擎反序列化构建,host以及device的内存分配等共有操作完结基类,然后重载不同模型的结构函数和前后处理函数。这个部分能够去参考网上一些开源教程,大多模板一致。在这儿有两个点需求留意:

  1. 假如期望两个模型运行在不同显卡上,记住在一切有关上下文操作前后加上cudaSetDevice()
  2. 关于不同模型,结构函数传参大多不一致,目前几种处理办法:工厂模式输入modelType对应不同实例化,读取json/yaml等配置文件参数实例化,终究一种厌恶办法无脑一致实例化接口,大不了某些参数不用。最优办法当然是写配置文件,用yaml-cpp或者其他文件解析库完结对配置文件参数解析,然后入参就一致为配置文件路径以及一些共有参数(如deviceId能够在服务端或者前端设置因而保存)。惋惜这个定见没被接受,不得已提交的那一版写的是最厌恶的方法,后来改成了第一种通过传入模型类别去实例化。

稍微说说前后处理部分,关于yolov8-pose之前说了留意偏移量的问题,别的便是对输出转置处理一下便利解析,当然这个操作也能够在模型导出前改一下源码完结。偏移部分完结大致如下

 auto row_ptr  = output.row(i).ptr<float>();
 auto bboxes_ptr = row_ptr;
 auto scores_ptr = row_ptr + 4;
 auto max_s_ptr = std::max_element(scores_ptr, scores_ptr + this->class_nums);
 auto kps_ptr  = row_ptr + 6;

然后将一切成果经过nms筛选,得到终究保存成果。保存方针的结构体界说如下:

struct Object {
  int       label = 0;
  float      prob = 0.0;
  std::vector<cv::Point2f> kps;
  cv::Rect_<int> rect;
  std::string plateContent;
  std::string colorType;
};

关于OCR模型

Go调用C++动态库完结车牌辨认

模型输入大小为(48,168),输出为5和(21,78),其间5代表黑蓝绿白黄五种车牌色彩,78代表78个可辨认的字符包含最初的#号占位符,0-9的数字,英文字母以及中文汉字,21为最大辨认车牌字符长度。然后来看看OCR模型的前后处理,由于大货车存在双行车牌的状况,因而需求对车牌上下部分切分然后横向拼接再给模型推理,大致完结如下:

// merge double plate
void mergePlate(const cv::Mat& src,cv::Mat& dst) {
  int width = src.cols;
  int height = src.rows;
  cv::Mat upper = src(cv::Rect(0,0,width,int(height*5.0/12)));
  cv::Mat lower = src(cv::Rect(0,int(height*1.0/3.),width,height-int(height*1.0/3.0)));
​
  cv::resize(upper,upper,lower.size());
  dst = cv::Mat(lower.rows,lower.cols+upper.cols,CV_8UC3,cv::Scalar(114,114,114));
  upper.copyTo(dst(cv::Rect(0,0,upper.cols,upper.rows)));
  lower.copyTo(dst(cv::Rect(upper.cols,0,lower.cols,lower.rows)));
}
​
​
/*
preprocess
​
0. Perspective
1. merge plate if label is double
2. resize to (48,168)
3. normalize to 0-1 and standard (mean = 0.588 , std = 0.193)
*/
if(obj.label == 1) {
    mergePlate(dst,dst);
}

仅仅关于label为1也便是双行车牌进行拼接操作,当然这个是透视变换后的车牌。关于透视变换能够依据仓库中Python代码翻译出对应的C++版别代码,

// Perspective
// the kps means pose model's KeyPoints,which is (tl,tr,br,bl)
void Transform(const cv::Mat& src,cv::Mat& dst,const std::vector<cv::Point2f>& kps) {
  float widthA = sqrt(pow((kps[2].x-kps[3].x),2)+pow((kps[2].y-kps[3].y),2));
  float widthB = sqrt(pow((kps[1].x-kps[0].x),2)+pow((kps[1].y-kps[0].y),2));
  float maxWidth = std::max(int(widthA),int(widthB));
​
  float heightA = sqrt(powf((kps[1].x-kps[2].x),2)+powf((kps[1].y-kps[2].y),2));
  float heightB = sqrt(powf((kps[0].x-kps[3].x),2)+powf((kps[0].y-kps[3].y),2));
  float maxHeight = std::max(int(heightA),int(heightB));
​
  std::vector<cv::Point2f> dstTri {
    cv::Point2f(0,0),cv::Point2f(maxWidth,0),
    cv::Point2f(maxWidth,maxHeight),cv::Point2f(0,maxHeight)
   };
  cv::Mat M = cv::getPerspectiveTransform(kps,dstTri);                   cv::warpPerspective(src,dst,M,cv::Size(maxWidth,maxHeight),cv::INTER_LINEAR,cv::BORDER_REPLICATE);
}

Blob部分和Python一样,减去均值除以方差。然后后处理解析部分,0输出的是5维色彩,1输出的是(21,78),和分类使命后处理一致,找最大值下标即为对应类别。留意遍历辨认字符时需求过滤操作,即关于下标0和已辨认出的相邻相同字符进行过滤。找最大值下标能够运用std::distance()很便利的找到。

终究便是书写对应的cgo接口,相比起JNI直接依据类界说运用javah生成的头文件来写而言,cgo并没有生成头文件的工具,这也让我们有更多的灵活性去界说对应的接口。比方我的接口界说如下:

#include<stdio.h>
#include<string.h>
#ifndef GOWRAP_H
#define GOWRAP_H
#ifdef __cplusplus
extern "C"
{
#endif
extern void* init(const char* modelType, const char* enginePath, int deviceId, int classNums, int kps);
extern char* detect(void* model1,void* model2,const char* base64Img,float score,float iou);
extern void release(void*);
​
#ifdef __cplusplus
}
#endif#endif //GOWRAP_H

由于go不能调用c++的类,也不能运用c++的std::string等,所以这儿全部是char*。然后完结对应接口

#include "../include/gowrap.h"
#include "../include/plate.hpp"
#include "../include/pose.hpp"
#include "../include/factory.hpp"
#include "../include/base64.h"
void* init(const char* modelType, const char* enginePath, int deviceId, int classNums, int kps) {
  std::string type(modelType);
  std::string engine(enginePath);
  auto model = modelInit(type,engine,deviceId,classNums,kps);
  model->make_pipe(true);
  return (void*)model;
}
​
char* detect(void* m1, void* m2,const char* base64Img, float score, float iou) {
  std::string base64(base64Img);
  cv::Mat image = Base2Mat(base64);
  std::vector<Object> objs;
​
  // get model
  auto* model1 = (YOLOV8_Pose*)m1;
  auto* model2 = (Plate*)m2;
​
  model1->predict(image, objs, score,iou,100);
  model2->predict(image,objs);
​
  // obj trans to json
  Json::Value root;
  Json::Value resObjs;
  Json::Value resObj;
  Json::Value objRec;
  Json::FastWriter writer;
​
  for(const auto&obj : objs){
    Json::Value attrObj;
    attrObj["color"] = obj.colorType;
    attrObj["lineType"] = obj.label;
    attrObj["plate"] = obj.plateContent;
    resObj["attr"] = Json::Value(attrObj);
    resObj["class_id"] = (int)obj.label;
    resObj["conf"] = (float)obj.prob;
    int x = (int)obj.rect.x;
    int y = (int)obj.rect.y;
    int width = (int)obj.rect.width;
    int height = (int)obj.rect.height;
​
    objRec["x"] = x;
    objRec["y"] = y;
    objRec["width"] = width;
    objRec["height"] = height;
​
    resObj["position"]=Json::Value(objRec);
    resObjs.append(resObj);
   }
​
  root["result"] = Json::Value(resObjs);
  std::string resObjs_str = writer.write(root);
  return strdup(resObjs_str.c_str());
}
​
​
void release(void* modelHandle) {
  auto model = (TRTInfer*) modelHandle;
  delete model;
}

这儿分别完结了模型实例化,推理以及模型毁掉,终究推理成果返回的是json格式的字符串,这部分大多还是沿用之前JNI的写法。终究便是写个CMakeLists然后编译,现在来看看C++上的推理成果图

Go调用C++动态库完结车牌辨认

Go调用C++动态库完结车牌辨认

关于这种角度的车牌人眼都需求细看才能辨认正确,模型竟然也能正确辨认,看来模型还是能够的,而且在家里这个服务器上推理耗时也仅仅1.3ms左右,速度与精度都完全能够接受。

2.3 Go部分

经过一系列操作,我们总算编译得到了.so动态库文件,现在便是加载这个动态库然后写个服务今日的使命就算完结啦。来看看go调用动态库的部分,首先需求调用C的库,而且上面需求添加编译注释,一起保证二者之间不能有空行

/*
#cgo LDFLAGS: -L./ -lshelgi_plate -lstdc++
#cgo CPPFLAGS: -I ../include -I /usr/include -I /usr/local/include
#cgo CFLAGS: -std=gnu11
#include<stdio.h>
#include<stdlib.h>
#include "gowrap.h"
*/
import "C"

其实最首要便是第一行LDFLAGS去加载对应的动态库。剩下的步骤便是依据刚才C++界说的函数来对应写Go的完结

type Object struct {
    p unsafe.Pointer
}
​
func NewModel(modelType, enginePath string, deviceId int, classNums int, kps int) *Object {
    obj := &Object{p: C.init(C.CString(modelType), C.CString(enginePath), C.int(deviceId), C.int(classNums), C.int(kps))}
    return obj
}
​
func detect(m1, m2 *Object, img string, score, iou float32) string {
    res := C.detect(m1.p, m2.p, C.CString(img), C.float(score), C.float(iou))
    result := C.GoString(res)
    return result
}
​
func release(m *Object) {
    C.release(m.p)
}

剩余一些函数,比方base64,unicode与string的转化,关于推理后json字符串的解析等等略过,终究用gin写个简略的POST推理路由以及上传路由。下面来看看效果:

Go调用C++动态库完结车牌辨认

传入图片:

Go调用C++动态库完结车牌辨认

推理成果:

Go调用C++动态库完结车牌辨认

成功辨认出两辆车的车牌,响应延时为153ms,经过屡次测验,平均在100ms左右,关于单个车辆的图片延时在50ms左右,根本满意需求。

3. 终究

其实这部分内容也是暂时想到的,后期计划用Rust也试试,看看究竟哪个完结功能最高,再次挖坑。