把整体进行框架性的封装, 最后实现下面的使用方法
#封装logger
logger的作用是用于记录和打印日志的工具, 跟之前一样是继承了nvinfer1::ILogger的, 这个里面是封装了6个不同的级别的严重程度,在写的时候可以定义说,这里的DEBUG可以换成很多不同的,如LOGF、LOGE、LOGW、LOG、LOGV和LOGD
根据日志级别返回对应的严重性级别,这里的Severity是来源于nvinfer,这个类还提供了一些方法,如get_severity和get_level,用于在Level和Severity之间进行转换
转换的原因是因为你的理解是正确的。Severity是TensorRT库中定义的,它是nvinfer1::ILogger接口的一部分。当TensorRT运行时遇到问题或需要提供信息时,它会调用ILogger::log方法,并传入一个Severity参数来表示消息的重要性。
另一方面,Level是这个项目中定义的,它是Logger类的一部分。这个项目中的其他部分使Level来控制日志的打印级别。
这些都是用来确认东西的, 确认完东西之后就需要把这些东西打印出来, 下面就是输出的内容了
#理解本框架三个最重要的模块: worker, classifier, model
trt_worker.cpp
这个文件实现了Worker类,这个类可以看作是一个工作线程,它负责加载ONNX模型,创建分类器,并执行推理。在Worker类的构造函数中,根据传入的任务类型参数,会创建相应的分类器实例。在inference方法中,会调用分类器的load_image和inference方法进行推理。
trt_model.cpp
这个文件实现了Model类,这是一个抽象基类,定义了所有模型类的公共接口和一些共享的实现。这个类的主要方法包括load_image(加载图像)、init_model(初始化模型)、build_engine(构建TensorRT引擎)、load_engine(加载已经构建的TensorRT引擎)和inference(执行推理)。这个类的实现主要是关于如何使用TensorRT库来加载和执行模型的。
trt_classifier.cpp
这个文件实现了Classifier类,这是一个具体的模型类,继承自Model类,用于分类任务。这个类重写了Model类的setup、preprocess_cpu、preprocess_gpu、postprocess_cpu和postprocess_gpu等方法,以实现分类任务的特定需求。从这三个文件的角色和功能来看,它们之间的关系可以简单地描述为:Worker类使用Model类(或其子类,如Classifier类)的实例来执行具体的任务。Model类定义了所有模型类的公共接口和一些共享的实现,而Classifier类则是Model类的一个具体实现,用于分类任务。
#Worker类的分析
从程序的执行流程来看,main.cpp中首先创建了Worker类的实例,然后通过Worker类的方法来使用Model类,所以我们先看怎么去使用worker这个工作线程,学习怎么去使用这个Model, 然后再去深入分析Model这个类
在Worker类中,有以下几个主要的成员:
- m_logger
- 一个Logger类的智能指针,用于处理TensorRT的日志信息。
- m_params
- 一个Params类的智能指针,用于存储模型的参数,里面包含了设备信息,类别信息,预处理的方式: 双线性插值/最近邻插值... 模型输入,任务类型, 精度,WorkSpace。
- m_classifier
- 一个Classifier类的智能指针,用于执行分类任务,里面有做分类任务的参数,以及各种前后处理函数,包括cpu版本跟GPU版本。
- m_scores
- 一个浮点数向量,用于保存分类任务的结果。Worker类的实现在 trt_worker.cpp 中。在Worker类的构造函数中,会根据传入的任务类型参数,创建相应的分类器实例。在inference方法中,会调用分类器的load_image和inference方法进行推理。
worker实例化的时候读取onnx文件, 然后log的等级,还有传入的是param的参数,这些参数,然后剩下的是inference推理和create_worker创造一个工作的线程
inference整体上就是调用model命名空间下classifier命名空间下的Classfier(这里是多个命名控件嵌套), 这个类别里面有load_image和inference方法来完成读取图片和推理
然后创造一个工作线程create_worker()函数, 这个方法会使用一个智能指针指向一个实例, 然后这个实例就可以执行inference了具体的使用方法在main.cpp里面可以看到,简单的两行就可以实现图片的推理
#Model模块
首先在头文件里面这里 定义了多个枚举 task_type, device, precision 来表示任务类型(如分类、检测)、计算设备(CPU/GPU)、精度(FP32/FP16)。
这里其实希望每一个model都希望有自己的image_info, 因为可能不同的model会有不同的优化策略, 前后处理的策略, 可能说都是分类但是会做不同的定制化处理
结构体定义:
image_info: 存储图像的高度、宽度和通道数。
Params: 存储模型构建时的参数,如设备类型、类别数、处理策略等。
这边加了一个函数,用来管理trt的指针,全部的trt api创建出来的指针都通过这个释放
当然后面会看到类似下面的输出, 这里就能够看出来我们把这里的runtime, engine, context的指针释放掉了, 不过要把输出改成debug模式才可以
Model类分析:
下面是一些通用的方法, 这些方法因为分类检测模型其实是差不多的,所以我们并不是很需要重写这些东西
load_image(std::string image_path): 加载用于推理的图像,保存其路径到 m_imagePath。
init_model(): 初始化模型,包括构建推理引擎、分配内存、创建执行上下文(context)和设置bindings(数据输入和输出的GPU地址)。
inference(): 执行模型推理,包括数据预处理、将数据传递到GPU(enqueue)进行推理计算、和后处理。
build_engine(): 构建TensorRT推理引擎,这是一个关键步骤,包括设置网络结构、优化配置和序列化引擎。load_engine(): 加载已存在的TensorRT引擎,如果引擎文件存在,则直接加载,否则会调用 build_engine 来构建。save_plan(nvinfer1::IHostMemory& plan): 保存序列化后的TensorRT引擎到磁盘,以便将来可以重新加载和使用。
print_network(nvinfer1::INetworkDefinition &network, bool optimized): 打印TensorRT网络的详细信息,可以选择在优化前后进行打印。
enqueue_bindings(): 执行推理计算,将预处理后的数据传递给GPU,并从GPU获取推理后的结果。
下面是一些纯虚方法, 这些方法因为不同类型的任务他是不一样的,所以需要在后面继承的时候自己写一个特定的方法, 例如说检测是640x640, 分类224x224
下面是一些nvinfer的成员变量
Model类的具体实现
构造函数初始化
加载模型图片的信息输出到控制台
输出如下
还是跟之前一样的结构:
createInferBuilder->builder
builder->network
builder->config
network, config->parser
config->setMaxWorkspaceSize
config->setProfilingVerbosity
builder->platformHasFastFp16
config->setFlag
parser->parseFromFile
builder->buildEngineWithConfig
builder->buildSerializedNetwork
createInferRuntime->runtime
save_plan
setup
print_network
重点是这里的API调用都使用了之前的自动释放的函数
下面是加载engine, 如果没有在文件路径找到这个engine, 就停止,然后自己的反序列化一个engine, 因为我们的目的是为了后面直接inference一个engine, 所以这里的话需要生成一个context, 创建一个cuda stream, 在下面没有看到是因为我们会把这个操作放在子类中的setup去实现,之前解释过,setup是必须实现的因为是纯虚函数
这里是把engine保存到磁盘上的一个操作
下面这里是一个推理,推理之前需要提前做一个预处理,这个预处理可以是CPU的也可以是GPU的,多种选择
下面就是打印信息,这里就是在构建引擎的时候是否选择打印优化过的层的信息
#Classfier模块
Classifier类的分析
这里就是Classifier继承了Model类,但是要留意的是这里的Classifer仍然是处于model的namespace命名空间,这里的构造函数就是我们在main.cpp会写到的东西, 使用就是使用这些
这里是要继承的纯虚函数的内容override重写一次, 因为不同的模型写不同的setup, 前后处理是不一样的
Classifier的实现
这里不同模型之间可能性不一样的地方应该是input, output的dimension, 虽然现在检测器的输出也是一个了,但是我们提高性能的时候就直接sigmoid后面直接取了,就会有多个输出头,详情可以参考这个repo: NVIDIA-AI-IOT/yolov5_gpu_optimization
然后是前处理后处理的选择,这里可以选择GPU, 也可以选择CPU的这里的内容太多了,如果在写框架的时候不确定是否有对应的,那这里就直接return cpu版本吧,或者可以直接写不分设备的前后处理接口然后再重写,这里严谨只是为了之后添加模型一定有这些步骤
#总结
在本次文章总基于之前的的代码进行了许多改动,以提高代码的可复用性、可读性、安全性、可扩展性和可调试性。
代码可复用性:设计了一个推理框架,可以支持多种任务,如分类、检测、分割、姿态估计等。所有这些任务都有一个共同的流程:前处理 -> DNN推理 -> 后处理。这个框架设计了一个基类来实现这个基本操作,然后不同的任务可以继承这个基类,完成每个任务需要单独处理的内容。这是通过C++工厂模式的设计思路实现的。
可读性:为了提高代码的可读性,设计了一个接口worker进行推理。在主函数中,只需要创建一个worker,然后让worker读取图片,然后让worker做推理。worker内部可以根据主函数传入的参数,启动多种不同的任务,如分类推理、检测、分割等。
安全性:在设计框架的时候,需要做很多初始化,释放内存,以及处理错误调用。为了避免忘记释放内存、忘记处理某种错误调用、没有分配内存却释放了内存等问题,可以使用unique pointer或者shared pointer这种智能指针帮助我们管理内存的释放。同时,使用RAII设计机制,将资源的申请封装在一个对象的生命周期内,方便管理。这里通过下面这种方法调用destory来实现
可扩展性:一个好的框架需要有很强的扩展性。这就意味着设计需要尽量模块化。当有新的任务出现的时候,可以做到最小限度的代码更改。在这里我们后面的检测器增加了setup, 前后处理就可以添加进来了。
可调试性:在设计框架的时候,希望能够为了让开发效率提高,在设计中比较关键的几个点设置debug信息,方便我们查看我们的模型是否设计的有问题。可以实现一个logger来方便我们管理这些。logger可以通过传入的不同参数显示不同级别的日志信息。这里我们通过转换Level, Servity来实现跟TensorRT上下文通信解决这个问题。