第五篇 嵌入式AI系统与应用学习篇
1.数据层-数据采集
1.1 视频数据采集-V4L2
1.1.1 数据采集流程
可以参考这些文件:
- mjpg-streamer\mjpg-streamer-experimental\plugins\input_control\input_uvc.c
- video2lcd\video\v4l2.c
Video for Linux two(Video4Linux2)简称V4L2,是V4L的改进版。V4L2支持三种方式来采集图像:内存映射方式(mmap)、直接读取方式(read)和用户指针。内存映射的方式采集速度较快,一般用于连续视频数据的采集,实际工作中的应用概率更高;直接读取的方式相对速度慢一些,所以常用于静态图片数据的采集;用户指针使用较少,如有兴趣可自行研究。
1.1.2 buffer的管理
使用摄像头时,核心是"获得数据"。所以先讲如何获取数据,即如何得到buffer。
摄像头采集数据时,是一帧又一帧地连续采集。所以需要申请若干个buffer,驱动程序把数据放入buffer,APP从buffer得到数据。这些buffer可以使用链表来管理。
驱动程序周而复始地做如下事情:
- 从硬件采集到数据
- 把"空闲链表"取出buffer,把数据存入buffer
- 把含有数据的buffer放入"完成链表"
APP也会周而复始地做如下事情:
- 监测"完成链表",等待它含有buffer
- 从"完成链表"中取出buffer
- 处理数据
- 把buffer放入"空闲链表"
链表操作示意图如下:
1.1.2 完整的使用流程
参考mjpg-streamer和video2lcd,总结了摄像头的使用流程,如下:
- open:打开设备节点/dev/videoX
- ioctl VIDIOC_QUERYCAP:Query Capbility,查询能力,比如
- 确认它是否是"捕获设备",因为有些节点是输出设备
- 确认它是否支持mmap操作,还是仅支持read/write操作
- ioctl VIDIOC_ENUM_FMT:枚举它支持的格式
- ioctl VIDIOC_S_FMT:在上面枚举出来的格式里,选择一个来设置格式
- ioctl VIDIOC_REQBUFS:申请buffer,APP可以申请很多个buffer,但是驱动程序不一定能申请到
- ioctl VIDIOC_QUERYBUF和mmap:查询buffer信息、映射
- 如果申请到了N个buffer,这个ioctl就应该执行N次
- 执行mmap后,APP就可以直接读写这些buffer
- ioctl VIDIOC_QBUF:把buffer放入"空闲链表"
- 如果申请到了N个buffer,这个ioctl就应该执行N次
- ioctl VIDIOC_STREAMON:启动摄像头
- 这里是一个循环:使用poll/select监测buffer,然后从"完成链表"中取出buffer,处理后再放入"空闲链表"
- poll/select
- ioctl VIDIOC_DQBUF:从"完成链表"中取出buffer
- 处理:前面使用mmap映射了每个buffer的地址,处理时就可以直接使用地址来访问buffer
- ioclt VIDIOC_QBUF:把buffer放入"空闲链表"
- ioctl VIDIOC_STREAMOFF:停止摄像头
1.2 控制流程
使用摄像头时,我们可以调整很多参数,比如:
-
对于视频流本身:
- 设置格式:比如V4L2_PIX_FMT_YUYV、V4L2_PIX_FMT_MJPEG、V4L2_PIX_FMT_RGB565
- 设置分辨率:1024*768等
-
对于控制部分:
- 调节亮度
- 调节对比度
- 调节色度
1.2.1 APP接口
就APP而言,对于这些参数有3套接口:查询或枚举(Query/Enum)、获得(Get)、设置(Set)。
1.2.1.1 数据格式
以设置数据格式为例,可以先枚举:
1.2.1.1 数据格式
以设置数据格式为例,可以先枚举:
struct v4l2_fmtdesc fmtdesc;
fmtdesc.index = 0; // 比如从0开始
fmtdesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 指定type为"捕获"
ioctl(vd->fd, VIDIOC_ENUM_FMT, &fmtdesc);
#if 0
/*
* F O R M A T E N U M E R A T I O N
*/
struct v4l2_fmtdesc {
__u32 index; /* Format number */
__u32 type; /* enum v4l2_buf_type */
__u32 flags;
__u8 description[32]; /* Description string */
__u32 pixelformat; /* Format fourcc */
__u32 reserved[4];
};
#endif
还可以获得当前的格式:
struct v4l2_format currentFormat;
memset(¤tFormat, 0, sizeof(struct v4l2_format));
currentFormat.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ioctl(vd->fd, VIDIOC_G_FMT, ¤tFormat);
#if 0
struct v4l2_format {
__u32 type;
union {
struct v4l2_pix_format pix; /* V4L2_BUF_TYPE_VIDEO_CAPTURE */
struct v4l2_pix_format_mplane pix_mp; /* V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE */
struct v4l2_window win; /* V4L2_BUF_TYPE_VIDEO_OVERLAY */
struct v4l2_vbi_format vbi; /* V4L2_BUF_TYPE_VBI_CAPTURE */
struct v4l2_sliced_vbi_format sliced; /* V4L2_BUF_TYPE_SLICED_VBI_CAPTURE */
struct v4l2_sdr_format sdr; /* V4L2_BUF_TYPE_SDR_CAPTURE */
__u8 raw_data[200]; /* user-defined */
} fmt;
};
/*
* V I D E O I M A G E F O R M A T
*/
struct v4l2_pix_format {v4l2_format
__u32 width;
__u32 height;
__u32 pixelformat;
__u32 field; /* enum v4l2_field */
__u32 bytesperline; /* for padding, zero if unused */
__u32 sizeimage;
__u32 colorspace; /* enum v4l2_colorspace */
__u32 priv; /* private data, depends on pixelformat */
__u32 flags; /* format flags (V4L2_PIX_FMT_FLAG_*) */
__u32 ycbcr_enc; /* enum v4l2_ycbcr_encoding */
__u32 quantization; /* enum v4l2_quantization */
__u32 xfer_func; /* enum v4l2_xfer_func */
};
#endif
也可以设置当前的格式:
struct v4l2_format fmt;
memset(&fmt, 0, sizeof(struct v4l2_format));
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 1024;
fmt.fmt.pix.height = 768;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG;
fmt.fmt.pix.field = V4L2_FIELD_ANY;
int ret = ioctl(vd->fd, VIDIOC_S_FMT, &fmt);
1.2.1.2 选择输入源
可以获得当期输入源、设置当前输入源:
int value;
ioctl(h->fd,VIDIOC_G_INPUT,&value); // 读到的value从0开始, 0表示第1个input源
int value = 0; // 0表示第1个input源
ioctl(h->fd,VIDIOC_S_INPUT,&value)
1.2.1.3 其他参数
如果每一参数都提供一系列的ioctl cmd,那使用起来很不方便。
对于这些参数,APP使用对应ID来选中它,然后使用VIDIOC_QUERYCTRL、VIDIOC_G_CTRL、VIDIOC_S_CTRL来操作它。
不同参数的ID值不同。
以亮度Brightness为例,有如下调用方法:
- 查询:
struct v4l2_queryctrl qctrl;
memset(&qctrl, 0, sizeof(qctrl));
qctrl.id = V4L2_CID_BRIGHTNESS; // V4L2_CID_BASE+0;
ioctl(fd, VIDIOC_QUERYCTRL, &qctrl);
/* Used in the VIDIOC_QUERYCTRL ioctl for querying controls */
struct v4l2_queryctrl {
__u32 id;
__u32 type; /* enum v4l2_ctrl_type */
__u8 name[32]; /* Whatever */
__s32 minimum; /* Note signedness */
__s32 maximum;
__s32 step;
__s32 default_value;
__u32 flags;
__u32 reserved[2];
};
- 获得当前值
struct v4l2_control c;
c.id = V4L2_CID_BRIGHTNESS; // V4L2_CID_BASE+0;
ioctl(h->fd, VIDIOC_G_CTRL, &c);
/*
* C O N T R O L S
*/
struct v4l2_control {
__u32 id;
__s32 value;
};
- 设置
struct v4l2_control c;
c.id = V4L2_CID_BRIGHTNESS; // V4L2_CID_BASE+0;
c.value = 99;
ioctl(h->fd, VIDIOC_S_CTRL, &c);
1.2.2 理解接口
1.2.2.1 概念
以USB摄像头为例,它的内部结构如下:
一个USB摄像头必定有一个VideoControl接口,用于控制。有0个或多个VideoStreaming接口,用于传输视频。
在VideoControl内部,有多个Unit或Terminal,上一个Unit或Terminal的数据,流向下一个Unit或Terminal,多个Unit或Terminal组成一个完整的UVC功能设备。
-
只有一个输出引脚
-
可以Fan-out,不能Fan-in
-
Terminal:位于边界,用于联通外界。有:IT(Input Terminal)、OT(Output Terminal)、CT(Camera Terminal)。模型如下,有一个输出引脚:
-
Unit:位于VideoControl内部,用来进行各种控制
-
SU:Selector Unit(选择单元),从多路输入中选择一路,比如设备支持多种输入源,可以通过SU进行选择切换。模型如下
-
PU:Porocessing Unit(处理单元),用于调整亮度、对比度、色度等,有如下控制功能:
- User Controls
- Brightness 背光
- Hue 色度
- Saturation 饱和度
- Sharpness 锐度
- Gamma 伽马
- Digital Multiplier (Zoom) 数字放大
- Auto Controls
- White Balance Temperature 白平衡色温
- White Balance Component 白平衡组件
- Backlight Compensation 背光补偿
- Contrast 对比度
- Other
- Gain 增益
- Power Line Frequency 电源线频率
- Analog Video Standard 模拟视频标准
- Analog Video Lock Status 模拟视频锁状态
- 模型如下
- User Controls
-
EU:Encoding Unit(编码单元),对采集所得的数据进行个性化处理的功能。编码单元控制编码器的属性,该编码器对通过它流式传输的视频进行编码。它具有如下功能:
-
模型如下
-
-
XU:Extension Unit(扩展单元),厂家可以在XU上提供自定义的操作,模型如下:
1.2.2.2 操作方法
我们使用ioctl操作设备节点"/dev/video0"时,不同的ioctl操作的可能是VideoControl接口,或者VideoStreaming接口。
跟视频流相关的操作,比如:VIDIOC_ENUM_FMT、VIDIOC_G_FMT、VIDIOC_S_FMT、VIDIOC_STREAMON、VIDIOC_STREAMOFF,是操作VideoStreaming接口。
其他ioctl,大多都是操作VideoControl接口。
从底层驱动和硬件角度看,要操作VideoControl接口,需要指明:
- entity:你要操作哪个Terminal或Unit,比如PU
- Control Selector:你要操作entity里面的哪个控制项?比如亮度PU_BRIGHTNESS_CONTROL
- 控制项里哪些位:比如CT(Camera Terminal)里的CT_PANTILT_RELATIVE_CONTROL控制项对应32位的数据,其中前16位对应PAN控制(左右转动),后16位对应TILE控制(上下转动)
但是APP不关注这些细节,使用一个ID来指定entity、Control Selector、哪些位:
/*
* C O N T R O L S
*/
struct v4l2_control {
__u32 id;
__s32 value;
};
驱动程序里,会解析APP传入的ID,找到entity、Control Selector、那些位。
但是有了上述知识后,我们才能看懂mjpg-streamer的如下代码:
-
XU:使用比较老的UVC驱动时,需要APP传入厂家的XU信息;新驱动里可以解析出XU信息,无需APP传入
-
mapping:无论新老UVC驱动,都需要提供更细化的mapping信息
-
代码
-
如下
1.3 编写APP
参考:mjpg-streamer,https://github.com/jacksonliam/mjpg-streamer
1.3.1 列出帧细节
调用ioctl VIDIOC_ENUM_FMT可以枚举摄像头支持的格式,但是无法获得更多细节(比如支持哪些分辨率),
调用ioctl VIDIOC_G_FMT可以获得"当前的格式",包括分辨率等细节,但是无法获得其他格式的细节。
需要结合VIDIOC_ENUM_FMT、VIDIOC_ENUM_FRAMESIZES这2个ioctl来获得这些细节:
- VIDIOC_ENUM_FMT:枚举格式
- VIDIOC_ENUM_FRAMESIZES:枚举指定格式的帧大小(即分辨率)
示例代码如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <linux/types.h> /* for videodev2.h */
#include <linux/videodev2.h>
/* ./video_test </dev/video0> */
int main(int argc, char **argv)
{
int fd;
struct v4l2_fmtdesc fmtdesc;
struct v4l2_frmsizeenum fsenum;
int fmt_index = 0;
int frame_index = 0;
if (argc != 2)
{
printf("Usage: %s </dev/videoX>, print format detail for video device\n", argv[0]);
return -1;
}
/* open */
fd = open(argv[1], O_RDWR);
if (fd < 0)
{
printf("can not open %s\n", argv[1]);
return -1;
}
while (1)
{
/* 枚举格式 */
fmtdesc.index = fmt_index; // 比如从0开始
fmtdesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 指定type为"捕获"
if (0 != ioctl(fd, VIDIOC_ENUM_FMT, &fmtdesc))
break;
frame_index = 0;
while (1)
{
/* 枚举这种格式所支持的帧大小 */
memset(&fsenum, 0, sizeof(struct v4l2_frmsizeenum));
fsenum.pixel_format = fmtdesc.pixelformat;
fsenum.index = frame_index;
if (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &fsenum) == 0)
{
printf("format %s,%d, framesize %d: %d x %d\n", fmtdesc.description, fmtdesc.pixelformat, frame_index, fsenum.discrete.width, fsenum.discrete.height);
}
else
{
break;
}
frame_index++;
}
fmt_index++;
}
return 0;
}
1.3.2 获取数据
根据《1.2 完整的使用流程》来编写程序,步骤如下:
- 打开设备
- ioctl VIDIOC_QUERYCAP:Query Capbility,查询能力
- 枚举格式、设置格式
- ioctl VIDIOC_REQBUFS:申请buffer
- ioctl VIDIOC_QUERYBUF和mmap:查询buffer信息、映射
- ioctl VIDIOC_QBUF:把buffer放入"空闲链表"
- ioctl VIDIOC_STREAMON:启动摄像头
- 这里是一个循环:使用poll/select监测buffer,然后从"完成链表"中取出buffer,处理后再放入"空闲链表"
- poll/select
- ioctl VIDIOC_DQBUF:从"完成链表"中取出buffer
- 处理:前面使用mmap映射了每个buffer的地址,把这个buffer的数据存为文件
- ioclt VIDIOC_QBUF:把buffer放入"空闲链表"
- ioctl VIDIOC_STREAMOFF:停止摄像头
1.3.3 控制亮度
2. 数据层-数据预处理
我们从摄像头采集到图像数据后,需要对图像进行格式的转换或图像的缩放等就需要操作图像,在图像处理领域,OpenCV库提供了很多常用的图像预处理方法,用于准备图像数据以用于计算机视觉和深度学习任务。
1.1 OpenCV计算机视觉库
OpenCV(开源计算机视觉库)是一个基于Apache2.0许可(开源)的计算机视觉和机器学习软件库。OpenCV旨在为计算机视觉应用提供一个公共基础设施,并加速机器感知在商业产品中的应用。
OpenCV库拥有超过2500种优化算法,包括一套全面的经典和最先进的计算机视觉和机器学习算法。这些算法可用于检测和识别人脸、识别物体、对视频中的人类动作进行分类、跟踪相机运动、跟踪运动物体、提取物体的3D模型、从立体相机产生3D点云、将图像拼接在一起以产生整个场景的高分辨率图像、从图像数据库中找到相似的图像、从使用闪光灯拍摄的图像中去除红眼、跟随眼球运动、识别风景并建立标记以用增强现实覆盖等。
它轻量级而且高效——由一系列 C 函数和少量 C++ 类构成,同时提供了Python、Ruby、MATLAB等语言的接口,实现了图像处理和计算机视觉方面的很多通用算法。它拥有C++、Python、Java和MATLAB接口,支持Windows、Linux、安卓和Mac OS。OpenCV主要倾向于实时视觉应用,并在可用时利用MMX和SSE指令。功能齐全的CUDA和OpenCL现在正在积极开发接口。有500多种算法和大约10倍多的函数组成或支持这些算法。OpenCV是用C++原生编写的,它有一个模板化的接口,可以与STL容器无缝协作。
OpenCV官方网站:https://opencv.org/
OpenCV开源代码仓库:https://github.com/opencv/opencv
OpenCV官方文档站点:https://docs.opencv.org/
1.2 OpenCV入门
1.2.1 OpenCV开发环境搭建
在开始学习OpenCV时都会遇到一个问题,那就是如何搭建OpenCV的C++和Python开发环境,那么我们就给大家准备好了一个Ubuntu虚拟机,可以统一大家的环境,让大家学习可以快速把环境搭建起来。如果您想直接查看OpenCV官方文档安装可访问:https://opencv.org/get-started/
下面我们展示如何在虚拟机中安装OpenCV的C++版本,打开终端命令行,输入
sudo apt install libopencv-dev -y
等待安装完成即可。
如果你是使用我们准备在资料光盘2_DongshanPI-Vision_配套工具/【VMware】Ubuntu-20.04虚拟机系统镜像-AI应用开发
中的虚拟机镜像已经默认安装好了python版的OpenCV,可直接使用。如果您使用自己的虚拟机可输入
pip3 install opencv-python
注意:执行后会使用pip安装opencv,需要您提前安装好pip!
1.2.2 第一个OpenCV开发程序
本章节在Linux中使用一个简单的OpenCV演示程序,通过读取图像数据的过程来讲解OpenCV中常见用的几个函数。
python 版本:
新建python程序文件hello-opencv.py,程序文件内容为:
#导入opencv模块
import cv2 as cv
#读取当前目录图像,支持 bmp、jpg、png、tiff 等常用格式
img = cv.imread("test.jpg")
#创建一个hello-opencv窗口用于显示图像
cv.namedWindow("hello-opencv")
#在hello-opencv窗口中展示输入图像
cv.imshow("hello-opencv",img)
#将RGB彩色图像转化为灰度图
img_gray = cv.cvtColor(img,cv.COLOR_RGB2GRAY)
#创建一个Img_gray窗口用于显示图像
cv.namedWindow("Img_gray")
#在Img_gray窗口中展示输入图像
cv.imshow("Img_gray",img_gray)
#将灰度图像在当前目录下保存为jpg文件
cv.imwrite("test_gray.jpg",img_gray)
#创建的窗口持续显示,直至按下键盘中任意键
cv.waitKey(0)
#释放窗口
cv.destroyAllWindows();
C++版本:
新建c++程序文件hello-opencv.cpp,程序文件内容为:
#include<opencv2/opencv.hpp> //包含opencv库
int main()
{
std::string image_path = "/home/ubuntu/work/hello-opencv-c++/test.jpg"; //获得图片路径
//读取当前目录图像,支持 bmp、jpg、png、tiff 等常用格式
cv::Mat img = cv::imread(image_path);
//创建一个hello-opencv窗口用于显示图像
cv::namedWindow("hello-opencv");
//在hello-opencv窗口中展示输入图像
cv::imshow("hello-opencv", img);
//创建Mat对象,用于存储灰度图像
cv::Mat img_gray;
//将RGB彩色图像转化为灰度图
cv::cvtColor(img,img_gray,cv::COLOR_RGB2GRAY);
//创建一个Img_gray窗口用于显示图像
cv::namedWindow("Img_gray");
//在Img_gray窗口中展示输入图像
cv::imshow("Img_gray",img_gray);
//将灰度图像在当前目录下保存为jpg文件
cv::imwrite("test_gray.jpg",img_gray);
//创建的窗口持续显示,直至按下键盘中任意键
cv::waitKey(0);
//释放窗口
cv::destroyAllWindows();
return 0;
}
编写完成程序后,我们需要将将测试图像test.jpg
放在对应文件夹中,那么下面我们来看一下两个程序分别如何运行。
python程序编译运行:
打开终端界面,输入
python hello-opencv.py
C++程序编译运行:
新建一个CMakeLists.txt文件,填入以下内容
cmake_minimum_required(VERSION 2.8)
project( hello-opencv )
find_package( OpenCV REQUIRED )
include_directories( ${OpenCV_INCLUDE_DIRS} )
add_executable( hello-opencv hello-opencv.cpp )
target_link_libraries( hello-opencv ${OpenCV_LIBS} )
打开终端界面,新建一个build目录,存放编译后的缓存和可执行程序:
mkdir build
新建完成后,进入build目录后,使用cmake生成编译规则
cd build/
cmake..
生成完成编译规则即可开始编译程序
make
编译完成后可以看到build目录下会生成hello-opencv
可执行文件,如下所示,执行可执行文件。
./hello-opencv
上述代码运行完成后会弹出两个窗口显示图像。分别是输入图像的hello-opencv窗口和图像经过灰度化处理的Img_gray窗口,如下所示:
如果您想结束两个窗口,可以按下任意键结束这两个窗口。我们可以看到程序运行完成之后,可以在当前目录下看到灰度图像文件test_gray.jpg
。如下所示:
下面我们简单介绍每个函数的作用:
- imread:从图像文件中读取图像数据并返回图像数据,返回的图像数据存储为Mat对象。输入参数为:图像文件路径
- namedWindow:创建一个窗口,传入窗口的名称。输入参数为:窗口名称
- imshow:可以在窗口中显示图像,该窗口和图像原始的大小自适应。输入参数为:窗口名称、输入图像
- cvtColor:将输入图像从一个色彩空间转化为另一个色彩空间。输入参数为:输入图像、转化格式
- imwrite:保存图像并写入指定的文件中,输入参数为:文件路径、输入图像
- waitKey:用于等待用户按键输入的一个函数,给定的时间内等待用户按键触发,如果用户没有按下键,就继续等待。设置waitKey(0),则表示程序会无限制的等待用户的按键时间。
- destroyAllWindows:该函数用于删除窗口的,()里不指定任何参数,则删除所有窗口,删除特定的窗口,往()输入特定的窗口值。
1.2.3 读取摄像头并保存图像
OpenCV提供了操作视频的接口类VideoCapture,VideoCapture类可以从文件或者摄像设备中读取视频。VideoCapture提供了常用的三种构造函数:
VideoCapture::VideoCapture()
VideoCapture::VideoCapture(int device)
VideoCapture::VideoCapture(const string& filename);
说明:
-
第一个是默认无参构造函数
-
第二个中参数device指定要打开的摄像头设备
-
第三个构造函数中filename 是指要打开的视频文件路径以及名称;
VideoCapture类还提供了read方法并且使用了重载运算符>> 获取视频的每一帧。
本章节介绍在虚拟机Ubuntu端的Linux系统中,使用OpenCV访问USB摄像头获取图像数据,并实时显示摄像头数据,按下t
拍照。
#include <opencv2/opencv.hpp>
using namespace cv;
using namespace std;
int main()
{
//读取视频或摄像头
VideoCapture capture;
//capture.open("/dev/video0");
capture.open(0);
if (!capture.isOpened())//判断摄像头是否可以正常打开
return -1;
cout<<"open video0 success!!"<<endl;
int cap_width = capture.get(CAP_PROP_FRAME_WIDTH);//获取摄像头参数-宽度
int cap_height = capture.get(CAP_PROP_FRAME_HEIGHT);//获得摄像头参数-高度
cout<<"capture width:"<<cap_width<<"capture height:"<<cap_height<<endl;
capture.set(CAP_PROP_FRAME_WIDTH, cap_width); //设置摄像头获得图像的宽度
capture.set(CAP_PROP_FRAME_HEIGHT, cap_height); //设置摄像头获得图像的高度
capture.set(CAP_PROP_FOURCC, VideoWriter::fourcc('M', 'J', 'P', 'G'));//视频的编码格式
namedWindow("capture"); //创建capture窗口
string Path = "./take_pictures"; //设置照片保存路径
string Imgname;
int i = 0;
while (true)
{
Mat frame;
int ret = capture.read(frame);//读取摄像头资源存入frame对象中
if (!ret)
return 0;
if ('t' == waitKey(20)) //如果按下`t`键,就进入拍照
{
Imgname = Path + to_string(i)+".jpg"; //图像路径
i++;
imwrite(Imgname, frame);//将frame图像存在Imgname图像路径中
cout << Imgname << endl;
}
imshow("capture", frame);
}
capture.release();//释放摄像头资源
cv::destroyAllWindows();//释放窗口
return 0;
}
执行程序后,会弹出capture窗口,该窗口就会实时预览USB摄像头数据
按下t
即可对图像进行拍照,拍照后的图像文件会保存在当前路径。
1.2.4 Mat数据结构
本章节重点介绍Mat数据结构,我们上一小节已经通过imread读取了一张图像,并存储在Mat数据类型中。OpenCV对于Mat数据类型介绍:Mat - 基本图像容器。
我们先来从官方文档中来看,Mat对象的用途:
- 图像容器类(可以存储图像)
- 通用的矩阵类(可以创建和操作多维矩阵)
那么为什么又可以存储图像又可以存储矩阵呢?我们从官方文档来看。我们通过电子设备从现实世界获得的数字图像,那么我们通过设备记录下来的就是每个点的数值构成的图像。如下所示:
也就说相机记录的数据集是像上图所示的数据,也就是像矩阵一样的数据,记录每个像素点中的数据,而Mat保存的图像数据就是保存图像中各个通道的像素值,即Mat将图像数据按行按列的顺序进行存放数据矩阵。如下图所示:
Mat数据类型的结构分为两部分:信息头和指向像素数据的矩阵指针。
信息头包括数字图像的矩阵尺寸(row、cols)、存储方法、存储地址。
指向存储所有像素值的矩阵的指针。
我们就可以发现,如果我们去改变图像中的像素值时,通常改变的数据量是很大的,我们需要对图像像素数据的矩阵中的每一个值进行修改。因此,如果我们需要在程序中传递图像并拷贝时会产生很大的内存开销,因为我们知道我们在进行图像处理的时候需要在很多函数中传递图像,所以需要用到信息头,让每个传递的Mat对象都有自己的信息头,但使用矩阵指针指向同一个地址从而共享一个图像矩阵,这样传递时只会拷贝信息头和矩阵指针,而不是拷贝图像像素指针。
下面使用官方示例代码来学习Mat对象。
Mat A; // 只创建信息头部分
A = imread("test.jpg"); // 这里为矩阵开辟内存
/*Mat的成员属性data:uchar类型的指针,指向Mat数据矩阵的首地址*/
float* A_ptr = (float*)A.data;
imshow("A",A);
Mat B(A); // 使用拷贝构造函数
float* B_ptr = (float*)B.data;
imshow("B",B);
Mat C;
C = A; // 赋值运算符
float* C_ptr = (float*)C.data;
cout<<"Mat A 矩阵指针:"<<A_ptr<<endl;
cout<<"Mat B 矩阵指针:"<<B_ptr<<endl;
cout<<"Mat C 矩阵指针:"<<C_ptr<<endl;
我们在初始化Mat对象时,只是创建了信息头,只是申请了一小块内存用于存放矩阵尺寸、存储方法、存储地址等数据。直到使用imread函数读取图像文件后,会为矩阵开辟内存,将图像文件以矩阵数据的方式存放在开辟的内存中。通过打印Mat对象A/B/C的矩阵指针(指向图像矩阵数据的指针),可以发现我们使用拷贝构造函数或者赋值运算符只是新建了一个信息头,信息头指向的图像矩阵数据是一样的,这就说明了传递图像可以减少内存的开销,但是因为是A/B/C都共享一个图像矩阵,那么任何一个对象进行图像数据的修改都会影响到其他对象。
如果你在读取图像时只想获取其中其他对象中的部分图像,可以创建一个信息头引用部分数据。
Mat D (A, Rect(10, 10, 100, 100) ); // using a rectangle
float* D_ptr = (float*)D.data;
imshow("D",D);
cout<<"Mat D 矩阵指针:"<<D_ptr<<endl;
其中Rect函数为矩形类,可以在绘制一个矩形框。函数用法:Rect(int x, int y, int width, int height),其中第一个参数为绘制矩阵的x坐标,第二个参数为绘制矩阵的y坐标,第三个参数为绘制矩阵的宽度,第三个参数为绘制矩阵的高度。例如:
Rect(10, 10, 100, 100)
通过上图我们可以看到黄色矩形为Rect函数创建出来的矩形,前两个参数xy确定矩阵的位置(绿色点),通过宽高确定矩形绘制的大小。通过在Mat对象调用该函数可以去创建一个感兴趣区域(ROI),即提取图片某一区域的图像。
Mat E = A(Range::all(), Range(1,30)); // using row and column boundaries
float* E_ptr = (float*)E.data;
imshow("E",E);
cout<<"Mat E 矩阵指针:"<<E_ptr<<endl;
Range对象可以用来表示矩阵的多个连续的行或者多个连续的列,其中Range::all(), Range(1,30)
,可以提取图像矩阵中第一列到30列(不包括30列)。提取的结果如下所示:
那么一个图像数据矩阵属于那么多的Mat的对象,那么谁来最后负责清理呢?是最后一个使用它的对象,通过引用计数器来实现,无论谁来拷贝Mat对象的信息头,都会增加矩阵的引用次数,反之当一个信息头被释放之后,这个引用计数器就减一,当数值为0时,图像矩阵就会被清理。
如果你某些情况需要拷贝图像矩阵本身,而不是信息头和矩阵指针的时候,也就是想使用一个信息图像矩阵存储拷贝过来的图像矩阵,此时就可以使用函数clone()
和copyTo()
。
Mat F = A.clone();
float* F_ptr = (float*)F.data;
cout<<"Mat F 矩阵指针:"<<F_ptr<<endl;
imshow("F",F);
Mat G;
A.copyTo(G);
float* G_ptr = (float*)G.data;
cout<<"Mat G 矩阵指针:"<<G_ptr<<endl;
imshow("G",G);
我们可以从打印信息看到,通过函数clone()
和copyTo()
拷贝的图像矩阵会使用新的矩阵指针指向新的内存空间。那么此时我们去改变F对象或者G对象就不会影响原来A对象信息头指向的图像数据矩阵。
总结:
- OpenCV函数中输出图像的内存分配是自动完成的(如果不特别指定的话)。
- 使用OpenCV的C++接口时不需要考虑内存释放问题。
- 赋值运算符和拷贝构造函数( ctor )只拷贝信息头。
- 使用函数 clone() 或者 copyTo() 来拷贝一副图像的矩阵。
1.2.5 Mat图像通道和位深
上一小节我们讨论了Mat对象可以作为通用矩阵类和图像容器,那么使用Mat对象去存储图像时,容器中的元素存储的是像素值,在OpenCV中有专门的数据格式来描述这些像素值参数,例如通道数、整型、字符类型等。例如:
Mat Img(640, 640, CV_8UC3);
上述代码,我们创建了一个Mat对象作为图像容器,存储的是图像高(行row)640,图像宽(列col)640,存储的数据类型是CV_8UC3。
这里的数据类型CV_8UC3的数据格式为:基本数据类型+通道数。
CV_8UC3数据格式:
CV_:数据前缀
8:比特数
U:数据类型
C3:通道数
位深表示每个值由多少位来存储,是一个精度问题,一般图片是8bit(位)的,则深度是8。
比特数(bit_depth): 图像中像素点在内存空间所占的内存大小。参数有8bits、16bites、32bites、64bites。
数据类型 | 说明 |
---|---|
S | signed int 有符号整型 |
U | unsigned int 无符号整型 |
F | float 单精度浮点型 |
通道表示每个点能存放多少个数,类似于RGB彩色图中的每个像素点有三个值,即三通道的。
通道 | 说明 |
---|---|
1 | 灰度图像(单通道图像) |
2 | 双通道图像 |
3 | RGB图像(3通道图像) |
4 | 带Alph通道的RGB图像(4通道图像) |
这里的CV_8UC3就表示:每个像素8bit(位)的unsigned int (无符号整型)数据存储RGB(3通道)的像素值。
下面我们来看看常见的数据格式:
数据格式 | 说明 |
---|---|
CV_8UC1 | 每个像素8bit(位)的unsigned int (无符号整型)数据存储单通道的像素值。 |
CV_8UC2 | 每个像素8bit(位)的unsigned int (无符号整型)数据存储双通道的像素值。 |
CV_8UC3 | 每个像素8bit(位)的unsigned int (无符号整型)数据存储三通道的像素值。 |
1.2.6 图像通道操作
我们端设备上获取图像通常都是3通道的RGB图像,但对于OpenCV默认使用BGR而非RGB格式来表示图像的颜色通道。这是因为在OpenCV的早期开发阶段,BGR格式在相机制造厂商和软件提供商之间比较受欢迎。
对于多通道的图像我们在图像处理操作中可能会将图像分段存储,这需要涉及到通道的分离和合并。例如我们需要将图像拆分不同的通道,并对每个通道进行填充以达到模型输入对应尺寸。那么下面我们来介绍通道处理中的通道合并和通道分离。
通道分离函数-split函数
void cv::split(InputArray m, OutputArrayOfArrays mv);
参数说明:
- InputArray m :为需要进行分离的多通道数据,一般填入Mat对象。
- OutputArrayOfArrays mv :为函数输出的数组,一般填入Vector容器。
示例程序:
cv::split(Mat Img,Vecotr<Mat> Ori);
通道合并函数-merge
void cv::merge(const vector& mv, OutputArray dst );
参数说明:
- const vector& mv:图像矩阵向量Vector容器。
- OutputArray dst:输出图像Mat对象。
示例程序:
cv::merge(Vecotr<Mat> Ori, Mat dstImg );
下面通过摄像头获取到图像后,将图像进行拆分后再进行合并,学习通道分离和合并函数,如下所示:
#include<opencv2/opencv.hpp> //包含opencv库
using namespace cv;
using namespace std;
int main()
{
Mat img = imread("test.jpg");
Mat res;
resize(img, res, Size(640, 480));
vector<Mat> ori_img;
split(res,ori_img);
cout<<"OpenCV Version:"<<CV_VERSION<<endl;
//读取视频或摄像头
VideoCapture capture;
capture.open("/dev/video0");
//capture.open(0);
if (!capture.isOpened())
return -1;
cout<<"open video0 success!!"<<endl;
int cap_width = capture.get(CAP_PROP_FRAME_WIDTH);
int cap_height = capture.get(CAP_PROP_FRAME_HEIGHT);
cout<<"capture width:"<<cap_width<<"capture height:"<<cap_height<<endl;
capture.set(CAP_PROP_FRAME_WIDTH, cap_width);
capture.set(CAP_PROP_FRAME_HEIGHT, cap_height);
capture.set(CAP_PROP_FOURCC, VideoWriter::fourcc('M', 'J', 'P', 'G'));
namedWindow("capture");
while (true)
{
Mat frame;
capture >> frame;
if (frame.empty())
return 0;
imshow("capture", frame);
vector<Mat> ori_cam;
split(frame,ori_cam);
imshow("blue channel",ori_cam[0]);
//imshow("green channel",ori_cam[1]);
//imshow("red channel",ori_cam[2]);
ori_cam.erase(ori_cam.begin() + 1);
ori_cam.insert(ori_cam.begin() + 1,ori_img[1]);
Mat dstFrame;
merge(ori_cam,dstFrame);
imshow("dstFrame",dstFrame);
if ('q' == waitKey(10)) { //'q'退出
break;
}
}
//释放窗口
cv::destroyAllWindows();
capture.release();
return 0;
}
本程序先将本地图片分为三个颜色通道,并存入Vector容器。然后读取摄像头数据,也分为三个通道,再用本地图片的G通道替换摄像头数据的G通道,最后显示出来。程序的具体流程如下所示:
- 读取本地图像并修改分辨率
- 使用split函数拆分原图像的通道数据存放到Vector容器
- 读取视频或摄像头并设置摄像头参数
- 读取摄像头数据并将图像数据拆分通道数据存放在Vector容器
- 删除视频通道数据Vector容器中的G通道数据并将本地图像中的G通道数据填入Vector容器中
- 使用merge函数函数融合Vector容器中的数据并显示出来。
1.2.7 图像处理常用函数-几何变换
OpenCV 几何变换是指对图像进行一些基本的空间变换,如缩放、平移、旋转、仿射和透视等。这些变换可以用矩阵运算来表示,OpenCV 提供了一些函数来方便地实现这些变换。例如:
cv::resize
可以对图像进行缩放,可以指定输出图像的尺寸或者缩放比例,以及插值方法。cv::warpAffine
可以对图像进行仿射变换,需要指定一个 2×3 的变换矩阵,以及输出图像的大小。cv::getRotationMatrix2D
可以生成一个旋转变换矩阵,需要指定旋转中心、旋转角度和缩放比例。cv::flip
可以实现图像的水平或垂直方向的翻转。
下面我们通过一个程序来讲解OpenCV如何实现图像的几何变换,如下所示:
#include <opencv2/opencv.hpp>
using namespace cv;
int main()
{
Mat img = imread("test.jpg"); // 读取图像
imshow("Original", img); // 显示原图
//缩放
Mat res;
resize(img, res, Size(300, 300)); // 将图像缩放到 300x300
imshow("Resized", res); // 显示缩放后的图像
//旋转
// 创建仿射变换矩阵
Mat M_rev = getRotationMatrix2D(Point2f(img.cols/2, img.rows/2), 45, 0.8);
// 应用仿射变换
Mat rev_dst;
warpAffine(img, rev_dst, M_rev, img.size());
imshow("Affine", rev_dst);
//平移
// 获取图像的高度和宽度
int rows = img.rows;
int cols = img.cols;
// 生成平移矩阵
Mat M_ts = getRotationMatrix2D(Point2f(0, 0), 0, 1);
M_ts.at<double>(0, 2) = 50; // x轴偏移量
M_ts.at<double>(1, 2) = 100; // y轴偏移量
// 应用平移变换
Mat ts_dst;
warpAffine(img, ts_dst, M_ts, cv::Size(cols, rows));
//展示将图像沿x轴移动50像素,沿y轴移动100像素
imshow("Translate", ts_dst);
//翻转
Mat flip_dst;
// 水平翻转
flip(img, flip_dst, 1);
imshow("flip", flip_dst);
waitKey(0); // 等待按键
destroyAllWindows(); // 关闭窗口
return 0;
}
程序运行后分别会展示原始图像/缩放图像/旋转图像/平移图像/翻转图像。如下图所示:
下面我们分别来看程序中的缩放/旋转/平移/翻转是如何实现的。通过程序来看图像的缩放是通过resize函数实现的,它是OpenCV中的一个函数,用于对图像进行缩放或调整大小。它可以根据给定的宽度和高度,或者根据给定的缩放因子,来改变图像的尺寸。它还可以根据不同的插值方法,来保证图像的质量和效果。cv::resize的函数原型如下:
void cv::resize(InputArray src, OutputArray dst, Size dsize, double fx = 0, double fy = 0, int interpolation = INTER_LINEAR)
参数说明:
- src:输入图像,可以是任意类型和通道数的Mat对象
- dst:输出图像,与输入图像有相同的类型,但大小由dsize参数指定,或者由fx和fy参数计算
- dsize:输出图像的大小,如果为零,则根据fx和fy参数的值来确定
- fx:水平方向的缩放因子,如果为零,则根据dsize参数的值来确定
- fy:垂直方向的缩放因子,如果为零,则根据dsize参数的值来确定
- interpolation:插值方法,常用的有以下几种:
- INTER_NEAREST:最近邻插值,速度最快,但效果最差
- INTER_LINEAR:双线性插值,速度较快,效果较好,是默认的插值方法
- INTER_AREA:区域插值,适用于图像缩小,可以避免波纹现象
- INTER_CUBIC:双三次插值,适用于图像放大,效果最好,但速度最慢
- INTER_LANCZOS4:Lanczos插值,一种高级的插值方法,可以产生更好的图像质量,但速度更慢
我们经过了一个resize函数可以将原图缩小成300×300像素大小的图像,resize函数不仅可以缩小图像,也可以放大图像,只要指定合适的dsize参数或者fx和fy参数。resize函数可以根据不同的插值方法,来保证图像的质量和效果,例如,如果你想放大图像,你可以使用INTER_CUBIC或者INTER_LANCZOS4来得到更好的结果。resize函数可以处理任意类型和通道数的图像,例如,你可以对彩色图像、灰度图像、二值图像等进行缩放或调整大小。
对于旋转和平移都可以通过仿射变换来实现,那么 我们先来看什么是仿射变换?仿射变换是指在几何中,对一个向量空间进行一次线性变换并接上一个平移,变换为另一个向量空间。简单来说,就是“线性变换”+“平移”。仿射变换保持了直线的平行性和比例,但不一定保持角度和距离。
仿射变换可以用矩阵和向量来表示,例如:
v
e
c
y
=
A
v
e
c
x
+
v
e
c
b
vec {y}=Avec {x}+vec {b}
vecy=Avecx+vecb
其中,A是一个线性变换矩阵,vecb是一个平移向量,vecx和vecy是变换前后的向量。
总的来说就是使用原来的图像矩阵乘一个矩阵,从而实现图像的旋转和缩放。具体可以参考OpenCV官方文档:仿射变换 — OpenCV 2.3.2 documentation
getRotationMatrix2D
是用于生成一个2x3的仿射变换矩阵,该矩阵可以用于对图像进行旋转和缩放操作。该函数的语法如下:
Mat cv::getRotationMatrix2D(Point2f center, double angle, double scale)
参数说明:
- center:旋转的中心点,可以是图像的中心或任意点
- angle:旋转的角度,单位为度,正值表示逆时针旋转,负值表示顺时针旋转
- scale:缩放的比例,大于1表示放大,小于1表示缩小,等于1表示不变
warpAffine
是OpenCV中的一个函数,用于对图像进行仿射变换。仿射变换是一种图像的几何变换,它可以对图像进行旋转、缩放、平移、翻转、错切等操作,但必须保持图像的平行性。warpAffine函数的语法如下:
void cv::warpAffine(InputArray src, OutputArray dst, InputArray M, Size dsize, int flags = INTER_LINEAR, int borderMode = BORDER_CONSTANT, const Scalar & borderValue = Scalar())
参数说明:
- src:输入图像,可以是任意类型和通道数的Mat对象
- dst:输出图像,与输入图像有相同的类型,但大小由dsize参数指定
- M:2x3的变换矩阵,可以用cv::getAffineTransform或cv::getRotationMatrix2D来生成,或者自己构造一个数组
- dsize:输出图像的大小,如果为零,则根据src的大小和M的值计算
- flags:插值方法,常用的有cv::INTER_LINEAR(双线性插值)和cv::INTER_NEAREST(最近邻插值)
- borderMode:边界处理方式,常用的有cv::BORDER_CONSTANT(用常数填充)和cv::BORDER_REPLICATE(用边界像素复制)
- borderValue:当borderMode为cv::BORDER_CONSTANT时,用于填充的常数值,默认为0
对于图像旋转,我们用getRotationMatrix2D函数,以图像中心点(由Point2f创建)为旋转中心,创建一个逆时针旋转45度,缩放比例为0.7的仿射矩阵。然后,我们用warpAffine函数,把这个矩阵和输入图像一起传入,得到变换后的输出图像。
对于图像平移,我们通过改变变换矩阵的x轴和y轴分量,实现了图像的平移,而不改变其旋转和缩放。图像的平移是通过调整变换矩阵在x轴和y轴上的偏移来完成的,不涉及旋转和缩放的变化。为了平移图像,我们只需修改变换矩阵的x轴和y轴偏移,不用改变其旋转和缩放。
对于图像翻转就可以通过cv::flip()方法,它可以用来翻转二维数组的。这个函数可以根据不同的参数,在垂直、水平或者两个轴上翻转图像。语法如下:
cv::flip(src, dst, flipCode)
参数说明:
- src: 输入数组,可以是图像或者矩阵。
- dst: 输出数组,和src的大小和类型相同。
- flipCode: 一个标志,指定如何翻转数组;0表示沿着x轴翻转,正值(例如,1)表示沿着y轴翻转。负值(例如,-1)表示沿着两个轴翻转。
返回值:
- 它返回一个翻转后的图像数组。
我们使用flip函数主要传入输入图像,输出图像以及沿着x轴或者y轴翻转,执行完成后即可得到输出图像。
本节主要介绍OpenCV的几何变换方法,让大家感受opencv的开发流程,学习图像变换的基础操作。opencv提供了多种几何变换方法,包括图像的翻转、平移、旋转、仿射和透视变换。图像的几何变换就是将一组图像数据经过某种数学运算,映射成另外一组图像数据的操作,其关键是确定空间映射关系,一般用变换矩阵来表示。opencv提供了cv2.warpAffine和cv2.warpPerspective两个函数来实现几何变换,前者用于仿射变换,后者用于透视变换,它们都需要指定变换矩阵、输出图像大小和插值方法。插值方法是指在图像缩放或旋转时,如何填充或删去一些像素值,opencv提供了多种插值方法,如最近邻插值、双线性插值、双三次插值、区域插值等,它们对图像的质量和速度有不同的影响。
2.模型层和算力层
2.1 模型推理框架-nnaces
嘉楠K510芯片中内置有神经网络加速器KPU,其使用的推理框架为nncase。本章节主要介绍nncase是什么?nncase神经网络编译器的功能有哪些?该如何使用nncase神经网络加速器的功能?
下面我们先介绍nncase神经网络编译器的基础概念,nncase是一个为 AI 加速器设计的神经网络编译器, 目前支持的 target有cpu/K210/K510/k230等。nncase提供的功能:
- 支持多输入多输出网络,支持多分支结构
- 静态内存分配,不需要堆内存
- 算子合并和优化
- 支持 float 和uint8/int8量化推理
- 支持训练后量化,使用浮点模型和量化校准集
- 平坦模型,支持零拷贝加载
2.1.1 nncase功能详细介绍
单输入单输出的网络可如下所示:
多输入单输出的网络课如下所示:
多输入多输出的网络如下所示:
多输入多输出的网络它具有多个输入和多个输出。这种网络结构可以处理具有复杂关系的问题,并且能够从多个不同的数据源中学习并提取有用的特征。
在多输入多输出神经网络中,每个输入都可以被视为一个独立的特征,并且每个输出可以对应于一个特定的任务或目标。这种网络结构可以利用多个输入之间的关系来学习更复杂的特征表示,从而更好地处理具有多个相关任务的问题。
多输入多输出神经网络通常由多个神经元组成,这些神经元被组织成不同的层。每个神经元都接收来自其他神经元的输入,并根据其权重和偏差计算输出。这种网络结构可以学习从输入到输出的映射,从而实现对数据的分类、回归或其他任务。
多分支结构通过采用多分支结构,网络能够更好地处理输入的复杂信息,从而实现更准确、更高效的学习和分类。多分支神经网络通常由多个神经元组成,这些神经元被组织成不同的层。每个神经元都接收来自其他神经元的输入,并根据其权重和偏差计算输出。
在图像分类任务中,多分支神经网络可以通过对图像的不同特征进行提取和处理,从而得到更丰富的特征表示。这种网络结构可以学习从输入到输出的映射,从而实现对图像的分类和识别。如下图Fast-SCNN所示:
静态内存分配在nncase中,静态内存分配主要用于分配模型权重和偏置等静态数据。这些数据在程序执行期间是固定的,不需要在运行时动态分配。通过使用静态内存分配,nncase可以确保这些数据在程序执行期间始终位于固定的内存位置,从而提高了程序的稳定性和可靠性。静态内存分配还可以提高模型的执行效率。由于不需要在运行时动态分配内存,因此可以减少内存分配和释放的开销,从而提高模型的推理速度。
算子合并是深度学习编译器中的一种重要技术,主要用于优化神经网络的计算和内存访问。算子合并的主要目的是减少内存访问,让数据更快地被复用。一般而言,被合并的算子之间需要有数据依赖,才有可能能够起到减少内存访问的效果。
在深度学习编译器中,算子合并技术可以应用于多个场景。例如,可以将多个卷积层和池化层融合为一个大的卷积层,以减少内存访问和计算的开销。也可以将多个卷积层和批归一化层融合为一个大的卷积层,以减少内存访问和计算的开销。此外,还可以将多个卷积层和激活函数层融合为一个大的卷积层,以减少内存访问和计算的开销。
算子优化不仅包括算子合并,还包括其他优化技术。例如,可以使用常量折叠技术将常量值折叠到模型中,以减少模型大小和计算量。也可以使用矩阵化技术将模型中的某些操作转换为矩阵运算,以提高计算效率。此外,还可以使用其他优化技术,如向量化、融合计算等,以进一步提高模型的性能和效率。
**支持float和uint8/int8量化推理:**使用float类型进行推理时,nncase会将输入数据和模型权重都转换为float类型,然后进行推理计算。这种推理方式可以提供较高的精度和灵活性,但同时也需要更多的内存和计算资源。使用uint8或int8类型进行推理时,nncase会将输入数据和模型权重都转换为相应的量化类型,然后进行推理计算。这种推理方式可以减少内存占用和计算资源消耗,但同时也可能会引入一定的精度损失。
**训练后量化,使用浮点模型和量化校准集。**在nncase中,量化校准集用于校准量化模型,以确保量化模型的精度和稳定性。量化校准集通常由一组浮点模型和相应的量化模型组成,用于比较量化模型和浮点模型的性能。在训练后量化过程中,nncase会将浮点模型转换为量化模型,然后使用量化校准集进行校准。通过比较量化模型和浮点模型的性能,可以调整量化模型的参数,以获得更好的精度和稳定性。
量化校准集是指一组用于校准量化模型的浮点模型和相应的量化模型。这些模型在相同的输入数据上运行,并比较其输出结果,以评估量化模型的精度和稳定性。
平坦模型是指将神经网络的模型参数以二进制文件的形式存储,并直接加载到内存中。这种方式可以避免在运行时进行内存分配和拷贝操作,从而提高了程序的执行效率。
零拷贝加载是指将模型参数从磁盘加载到内存时,不需要进行任何数据拷贝操作。这种方式可以减少CPU和内存之间的数据传输开销,进一步提高程序的执行效率。
2.1.2 支持的神经网络框架
nncase支持的神经网络框架包括TensorFlow Lite(tflite)、ONNX和Caffe。
-
TensorFlow Lite(tflite):nncase支持TensorFlow Lite模型,可以加载和运行在嵌入式设备上。通过使用tflite模型转换为kmodel,nncase可以在各种不同的硬件平台上运行神经网络推理,包括CPU、KPU神经网络加速器。
-
ONNX(Open Neural Network Exchange):nncase也支持ONNX模型,这是一种用于表示深度学习模型的开放标准。ONNX模型可以在不同的深度学习框架之间互操作,包括TensorFlow、PyTorch、Keras等。通过使用ONNX模型转换为kmodel,nncase可以加载和运行各种不同的深度学习模型,从而扩展了其应用范围。
-
Caffe:Caffe是一种深度学习框架,由Berkeley Vision and Learning Center开发。nncase也支持加载和运行Caffe模型。通过使用Caffe模型转换为kmodel,nncase可以在嵌入式设备上进行图像分类、目标检测等计算机视觉任务。
2.2 nncase模块-编译模型
Compiler: 用于在PC上编译神经网络模型,最终生成kmodel文件。主要包括importer, IR, Evaluator, Quantize, Transform优化, Tiling, Partition, Schedule, Codegen等模块。
2.2.1 importer模块
Importer是一个用于加载和转换模型的工具。它可以从各种不同的格式中导入模型,并将其转换为nncase可以使用的格式。具体来说,nncase的Importer可以从TensorFlow Lite、ONNX和Caffe等格式中导入模型。导入后,模型会被转换为nncase可以使用的内部格式,以便在嵌入式设备上进行推理。使用nncase的Importer,可以方便地将训练好的模型转换为nncase可以使用的格式,从而在嵌入式设备上部署和运行模型。同时,Importer还支持对模型进行优化和压缩,以减少模型的大小和推理时间,提高模型的性能和效率。
2.2.2 IR模块
nncase的IR(中间表示)确实分为两个部分:Importer导入的Neutral IR(设备无关)和Neutral IR经lowering转换生成的Target IR(设备相关)。
- Importer导入的Neutral IR(设备无关):
- 这是nncase的IR的第一层,也称为设备无关层。它表示的是从外部模型格式(如TensorFlow Lite、ONNX等)导入的原始模型。
- 在这一层,模型的结构和计算逻辑被完整地保留,但与特定的硬件平台无关。这意味着无论是在CPU、GPU还是其他类型的硬件上,都可以使用相同的Importer导入相同的Neutral IR。
- 这为nncase提供了一个统一的、通用的模型表示,使得它可以轻松地支持各种不同的神经网络框架和模型格式。
- Neutral IR经lowering转换生成的Target IR(设备相关):
- 当Importer导入的Neutral IR被加载后,nncase会进行lowering转换,将其转换为与特定硬件平台相关的Target IR。
- Target IR是nncase的IR的第二层,也称为设备相关层。在这一层,模型的结构和计算逻辑会根据目标硬件平台的特点进行优化和调整。
- 通过lowering转换,nncase可以对模型进行一系列的优化操作,如算子融合、常量折叠、内存优化等,以提高模型的性能和效率。
- 这一层的设计使得nncase可以针对不同的硬件平台进行优化,从而在各种不同的嵌入式设备上实现高效的神经网络推理。
nncase的IR通过Importer导入的Neutral IR和经lowering转换生成的Target IR两个层次,实现了对神经网络模型的统一表示和针对不同硬件平台的优化。这使得nncase成为一种高效、灵活的神经网络推理引擎,适用于各种嵌入式系统。
2.2.3 Evaluator模块
nncase的Evaluator模块提供了IR的解释执行能力。Evaluator模块可以加载和执行nncase的IR,对模型进行评估和推断。
在Constant Folding和PTQ Calibration等场景中,Evaluator模块可以用于对模型进行验证和校准。Constant Folding是一种在模型训练过程中将常量值折叠到模型中的技术,可以减少模型的大小和计算量。而PTQ Calibration则是一种对模型进行校准的技术,以提高模型的精度和稳定性。
通过使用Evaluator模块,可以对nncase的IR进行解释执行,从而对模型进行评估和推断。这可以帮助开发者验证模型的正确性和性能,并进行必要的调整和优化。
需要注意的是,使用Evaluator模块时,需要确保IR的正确性和完整性,以避免出现错误或异常情况。同时,也需要根据实际情况选择合适的解释执行方式和参数配置,以获得最佳的性能和效率。
2.2.4 Transform模块
nncase的Transform模块用于IR转换和图的遍历优化等操作。
在nncase中,IR转换是指将模型的IR从一种格式转换为另一种格式,以便在不同的平台或框架之间进行模型推理。Transform模块提供了转换功能,可以将Importer导入的Neutral IR转换为Target IR,也可以将Target IR转换为其他格式。
除了IR转换外,Transform模块还支持对图的遍历进行优化。在神经网络模型中,图的遍历是指按照一定的顺序访问模型中的节点和边。遍历优化是一种常见的优化技术,可以减少计算量和内存占用,提高模型的性能和效率。
Transform模块可以对模型图进行遍历优化,例如对相邻节点进行合并、消除冗余的计算等操作。这些优化可以显著提高模型的推理速度和效率,特别是在嵌入式系统中。
nncase的Transform模块用于IR转换和图的遍历优化等操作,可以帮助开发者对模型进行必要的调整和优化,提高模型的性能和效率。
2.2.5 Quantize模块
nncase的Quantize模块用于训练后量化,对要量化的tensor加入量化标记,并根据输入的校正集调用Evaluator进行解释执行,收集tensor的数据范围,插入量化/反量化结点,最后优化消除不必要的量化/反量化结点等。
在nncase中,量化是一种将浮点数转换为低精度整数的过程,以减少模型的大小和计算量。Quantize模块提供了训练后量化的功能,可以在模型训练完成后对模型进行量化操作。
在Quantize模块中,首先会对要量化的tensor加入量化标记,以便后续识别和处理。然后,根据输入的校正集调用Evaluator进行解释执行,收集tensor的数据范围。接下来,根据收集到的数据范围插入量化/反量化结点,将浮点数转换为低精度整数或从低精度整数转换回浮点数。
最后,Quantize模块会对插入的量化/反量化结点进行优化,消除不必要的结点。这样可以减少模型的计算量和内存占用,提高模型的性能和效率。
需要注意的是,使用Quantize模块时,需要确保输入的校正集的正确性和完整性,以避免出现错误或异常情况。同时,也需要根据实际情况选择合适的量化参数和配置,以获得最佳的性能和效率。
2.2.6 Tiling模块
nncase的Tiling模块用于将大块计算进行拆分,以适应NPU较低的存储器容量。
在深度学习模型中,尤其是在嵌入式设备上,NPU(神经网络处理单元)的存储器容量通常是有限的。为了充分利用NPU的计算能力,同时避免存储器溢出,需要对大块计算进行拆分。
Tiling模块的作用是将大块计算拆分成较小的小块,然后将这些小块逐个进行处理。这种拆分的方式可以减少单个操作的数据量,从而降低存储器的使用量。
然而,选择Tiling参数会对时延和带宽产生影响。如果Tiling参数设置不当,可能会导致计算时间增加或带宽占用过多。因此,在使用Tiling模块时,需要根据实际情况选择合适的Tiling参数和配置,以平衡计算效率和存储器使用量。
nncase的Tiling模块用于将大块计算进行拆分,以适应NPU较低的存储器容量。选择合适的Tiling参数和配置对于优化深度学习模型的性能和效率至关重要。
2.2.7 Partition模块
nncase的Partition模块用于将模型图按照ModuleType进行切分,将切分后的每个子图映射到对应的RuntimeModule。不同类型的RuntimeModule可以对应不同的设备(如CPU、K510等)。
在nncase中,模型图由一系列的节点和边组成,每个节点表示一个操作或计算,边表示数据的传递。Partition模块根据ModuleType对模型图进行切分,将不同的子图映射到不同的RuntimeModule中。
在切分过程中,Partition模块会考虑每个子图中的节点和边的计算复杂度和数据依赖关系。每个子图对应一个RuntimeModule,而RuntimeModule是与设备相关的执行单元,可以运行在不同的设备上。
通过将模型图切分为不同的子图并映射到不同的RuntimeModule中,Partition模块可以实现模型的并行计算和分布式执行。在不同的设备上运行不同的RuntimeModule,可以充分利用设备的计算资源和并行处理能力,提高模型的推理速度和效率。
需要注意的是,使用Partition模块时,需要考虑到不同子图的计算复杂度和数据依赖关系,以及不同设备的计算能力和内存限制。选择合适的切分方式和配置参数,可以平衡模型的计算效率和设备的资源利用率,以实现最佳的性能和效率。
2.2.8 Schedule模块
nncase的Schedule模块根据优化后图中的数据依赖关系生成计算顺序并分配Buffer。
在深度学习模型中,计算顺序和Buffer分配是影响模型性能和效率的重要因素。计算顺序决定了模型中各个操作的执行顺序,而Buffer分配则决定了数据在内存中的存储和访问方式。
Schedule模块的作用是根据优化后的图中的数据依赖关系生成计算顺序。它考虑了节点之间的数据依赖关系和计算复杂度,以确定最佳的计算顺序。这样可以减少计算过程中的数据依赖冲突,提高计算的并行性和效率。
在计算顺序确定之后,Schedule模块还需要分配Buffer。Buffer是用于存储模型中的数据和中间结果的内存区域。通过合理的Buffer分配,可以减少内存访问的开销,提高数据的访问速度和效率。
在nncase中,Schedule模块会根据优化后的图的结构和数据依赖关系,为每个节点分配一个或多个Buffer。这些Buffer可以用于存储节点的输入数据、中间结果和输出数据。通过优化Buffer的分配和管理,可以减少内存的占用和提高数据的访问效率。
nncase的Schedule模块根据优化后图中的数据依赖关系生成计算顺序并分配Buffer,以优化模型的计算过程和提高性能和效率。
2.2.9 Codegen模块
nncase的Codegen模块会对每个子图分别调用ModuleType对应的codegen方法,生成对应的RuntimeModule。
在nncase中,模型图被切分为不同的子图,每个子图对应一个RuntimeModule。每个RuntimeModule是由一系列计算节点组成的,每个计算节点对应一个操作或计算。
Codegen模块会根据每个子图的ModuleType调用相应的codegen方法,生成对应的RuntimeModule。不同的ModuleType可能具有不同的计算特性和优化方法,因此需要使用相应的codegen方法来生成对应的RuntimeModule。
在生成RuntimeModule的过程中,Codegen模块会考虑设备的特性和限制,对生成的代码进行优化和调整。例如,针对特定的CPU或NPU,Codegen模块可能会生成相应的汇编代码或中间表示,以充分利用硬件的特性。
生成的RuntimeModule可以作为可执行代码的一部分,与其他代码和库一起编译和链接,最终生成可执行文件。通过将模型图切分为不同的子图并生成对应的RuntimeModule,nncase可以实现模型的并行计算和分布式执行,提高模型的推理速度和效率。
需要注意的是,Codegen模块的具体实现和功能可能会因不同的硬件平台和nncase版本而有所不同。因此,在使用Codegen模块时,建议参考相关的文档和示例,以了解其具体的使用方法和要求。
2.3 nncase模块-推理模型
nncase的神经网络编译器Runtime模块是集成于用户的应用程序中,提供了加载kmodel、设置输入数据、KPU执行和获取输出数据等功能。
-
提供了加载kmodel的功能。kmodel是nncase中用于表示神经网络模型的文件格式。通过使用Runtime模块的API,用户可以在应用程序中加载kmodel文件,并将其加载到内存中。这样,用户就可以在应用程序中使用加载的模型进行推理和计算。
-
提供了设置输入数据的功能。在神经网络模型推理过程中,需要将输入数据传递给模型进行计算。Runtime模块提供了API来设置输入数据,用户可以通过这些API将数据传递给模型进行推理。
-
提供了KPU执行的功能。KPU是nncase中用于执行神经网络计算的内核处理器。通过使用Runtime模块的API,用户可以调用KPU来执行模型的推理计算。KPU可以充分利用硬件的并行计算能力和高效执行神经网络计算的能力,从而提高模型的推理速度和效率。
-
提供了获取输出数据的功能。在模型推理完成后,输出数据会被计算并存储在内存中。通过使用Runtime模块的API,用户可以获取模型的输出数据。这些数据可以用于后续的处理或分析。
nncase的神经网络编译器Runtime模块为应用程序提供了加载kmodel、设置输入数据、KPU执行和获取输出数据等功能,使得用户可以在自己的应用程序中轻松地集成和运行神经网络模型。这些功能使得nncase成为一种高效、灵活的神经网络编译器,适用于各种应用场景。
3. 功能模块
3.1 推理结果分析
3.1.1 解码预测结果
模型解码预测结果是指将神经网络模型输出的预测结果进行解析和转换,以得到最终的预测结果。
具体来说,模型解码预测结果包括以下步骤:
- 获取预测结果:神经网络模型在推理过程中会输出预测结果,这些结果通常以概率或得分的形式表示。
- 解码分类预测:对于分类任务,神经网络模型会输出每个类别的概率。我们需要将这些概率解码为具体的类别标签。通常,解码的方法是将概率最高的类别作为预测结果。
- 解码位置信息:对于回归任务,神经网络模型会输出预测的位置信息,如边界框的坐标、尺寸等。我们需要将这些位置信息解码为具体的数值。
下面我们以基于锚框的yolo算法为例,我们来看其模型的输入和输出内容。
模型的输入节点,接收的tensor为[1,3,640,640]表示[batchsize,channel,height,weight],即批次大小为1,也就是一次读1张图像;channel为3表示通道数,3通道彩色图像;高度和宽度则是单张图像的大小320*320像素。
模型的输出节点,输出的tensor为[1,25200,85],其中1表示批次大小为1,每次输出一张图像的数据;25200表示有25k的锚框数量;85表示数据集有80个类/4个框坐标/1个类别置信度。我们来看这个85中的数据集有80个类/4个框坐标/1个类别置信度。由于yolov5使用的coco数据集,其中的图片的类别数是80个类。4个框坐标表示:中心坐标x,中心坐标y,宽度w,高度y。1个类别置信度表示:所有锚框对应的类别的检测概率值。
3.1.2 非极大值抑制
非极大值抑制(NMS)是一种在目标检测中常用的后处理技术。它的主要目的是去除冗余的检测框,保留最优的检测结果。
在目标检测中,通常会使用滑动窗口或类似的方法来生成候选框,然后对这些候选框进行分类和定位精度的评估。但是,由于这种方法会产生大量的候选框,其中很多可能是重叠的,因此需要进行后处理来去除冗余和低质量的候选框,以得到最终的目标检测结果。
NMS的基本思想是保留分类得分最高的候选框,并将其与周围候选框进行比较,去除与它重叠面积超过阈值的候选框。这个过程不断重复,直到所有候选框都被处理完。NMS可以有效地去除冗余和低质量的候选框,提高目标检测的准确率和效率。它通常被应用于目标检测的最后一步,用于生成最终的目标检测结果。
在NMS中,需要设定一个阈值来决定哪些候选框需要被去除。这个阈值的选择需要根据具体的应用场景和数据集来确定。如果阈值设置得过高,可能会导致漏检;如果阈值设置得过低,可能会导致误检。因此,选择合适的阈值是NMS的关键。
非极大值抑制NMS是一种在目标检测中常用的后处理技术,可以有效地去除冗余和低质量的候选框,提高目标检测的准确率和效率。常见的NMS算法有:最基本的贪心式NMS算法,改进的NMS算法,有Soft-NMS1,Matrix NMS2,Adaptive NMS3,DIoU-NMS4,IoU-guided NMS5等。
在开始学习NMS之前我们需要了解并集交叉点(Intersection Over Union) 或简称 IOU。通常用于量化真实值 BBox(边界框)和预测 BBox 之间重叠百分比的方法。由于目前很多目标检测算法都是基于锚框来进行预测的,也就是将图像分成多个网格,可以是密集的网格或者在感兴趣区域内的规则网格。每个网格的中心点是放置锚框的位置。锚框就是我们预先定义好的框,我们就可以将图像划分为一个个网格,基于每个网格放锚框,进行预测。
现在我们就需要考虑IOU的问题就是考虑真实框和预测框重叠的百分比,其数学公式为:
I
O
U
=
T
a
r
g
e
t
∩
P
r
e
d
i
c
t
i
o
n
T
a
r
g
e
t
∪
P
r
e
d
i
c
t
i
o
n
IOU= \frac{Target \cap Prediction}{Target \cup Prediction}
IOU=Target∪PredictionTarget∩Prediction
其中交集与并集的区域可如下图所示:
真实框和预测框的交集区域是蓝色部分,并集区域是紫色部分,IOU的值可以通过将蓝色区域除以紫色区域得到。IOU就可以衡量真实框和预测框之间的相似性。它的值域再[0,1],其中0表示没有重叠,1表示全部重叠。
下面我们使用C++来实现一个简单IOU的计算,我们计算两个Box的交并比,假设A框的左下角的坐标为(10,10),右上角坐标为(100,100)。B框的左下角的坐标为(30,30),右上角坐标为(150,150)。那么就可以计算出对应交集框的坐标值。A框和B框在坐标轴下的表示,如下图所示:
下面展示C++程序源码:
#include <iostream>
#include <algorithm>
using namespace std;
// 定义一个矩形框的结构体,包含左上角和右下角的坐标,以及置信度
struct Bbox {
int x1;
int y1;
int x2;
int y2;
float score;
};
// 计算两个矩形框的IOU
float iou(const Bbox& a, const Bbox& b) {
// 计算两个矩形框的交集的左上角和右下角的坐标
int inter_x1 = max(a.x1, b.x1);
int inter_y1 = max(a.y1, b.y1);
int inter_x2 = min(a.x2, b.x2);
int inter_y2 = min(a.y2, b.y2);
// 判断是否有交集,如果没有,返回0
if (inter_x1 >= inter_x2 || inter_y1 >= inter_y2) {
return 0;
}
// 计算交集的面积
int inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1);
// 计算两个矩形框的面积
int area_a = (a.x2 - a.x1) * (a.y2 - a.y1);
int area_b = (b.x2 - b.x1) * (b.y2 - b.y1);
// 计算并集的面积
int union_area = area_a + area_b - inter_area;
// 计算IOU
return inter_area * 1.0 / union_area;
}
//主函数
int main() {
// 创建两个矩形框
Bbox a = {10, 10, 100, 100, 0.9};
Bbox b = {30, 30, 150, 150, 0.8};
// 计算IOU
float iou_value = iou(a, b);
// 输出结果
cout << "IOU = " << iou_value << endl;
return 0;
}
将矩阵A和矩阵B的参数传入iou函数中计算交并比,在iouh函数中分别计算交集的面积和并集的面积,最后计算IOU的值并返回。
下面我们使用YOLOV5目标检测算法中的开源NMS示例来讲解NMS。前面我们学习了IOU交并比,nms就是在其基础上,按照预测的置信度分数对矩形框进行排序,然后依次选择最高置信度的矩形框,并删除与它重叠度超过一定阈值的其他矩形框。这样可以有效地减少重复或冗余的预测框,提高检测的效率和精度。我们需要定义提前好nms阈值,用于判断是否要删除一个矩形框的标准,一般在0.3到0.5之间。如果一个矩形框与已经选择的最高置信度的矩形框的iou大于阈值,那么它就被认为是多余的,需要被删除。
下面展示nms核心代码:
#include "nms.hpp"
#include <algorithm>
float iou(const std::vector<float>& boxA, const std::vector<float>& boxB)
{
// The format of box is [top_left_x, top_left_y, bottom_right_x, bottom_right_y]
const float eps = 1e-6;
float iou = 0.f;
float areaA = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]);
float areaB = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]);
float x1 = std::max(boxA[0], boxB[0]);
float y1 = std::max(boxA[1], boxB[1]);
float x2 = std::min(boxA[2], boxB[2]);
float y2 = std::min(boxA[3], boxB[3]);
float w = std::max(0.f, x2 - x1);
float h = std::max(0.f, y2 - y1);
float inter = w * h;
iou = inter / (areaA + areaB - inter + eps);
return iou;
}
void nms(std::vector<std::vector<float>>& boxes, const float iou_threshold)
{
// The format of boxes is [[top_left_x, top_left_y, bottom_right_x, bottom_right_y, score, class_id], ...]
//“分数+class_id”排序是为了确保将具有相同class_id的框分组在一起,并按分数排序
std::sort(boxes.begin(), boxes.end(), [](const std::vector<float>& boxA, const std::vector<float>& boxB) { return boxA[4] + boxA[5] > boxB[4] + boxB[5];});
for (int i = 0; i < boxes.size(); ++i)
{
if (boxes[i][4] == 0.f)
{
continue;
}
for (int j = i + 1; j < boxes.size(); ++j)
{
if (boxes[i][5] != boxes[j][5])
{
break;
}
if (iou(boxes[i], boxes[j]) > iou_threshold)
{
boxes[j][4] = 0.f;
}
}
}
std::erase_if(boxes, [](const std::vector<float>& box) { return box[4] == 0.f; });
}
运行全部代码需要额外安装fmt库,输入:sudo apt install libfmt-dev
在Modern-Cpp-NMS目录下新建build目录存放编译文件,使用cmake编译程序,如下所示:
ubuntu@ubuntu2004:~/Modern-Cpp-NMS$ mkdir build
ubuntu@ubuntu2004:~/Modern-Cpp-NMS$ cd build/
ubuntu@ubuntu2004:~/Modern-Cpp-NMS/build$ cmake ..
-- The CXX compiler identification is GNU 9.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/ubuntu/work/Modern-Cpp-NMS/build
ubuntu@ubuntu2004:~/Modern-Cpp-NMS/build$ make
[ 33%] Building CXX object CMakeFiles/example.dir/example.cpp.o
[ 66%] Building CXX object CMakeFiles/example.dir/nms.cpp.o
[100%] Linking CXX executable example
[100%] Built target example
运行完成后我们可以在build
目录下看到example
可执行程序,执行可执行程序会在当前目录下生成boxes_nms.txt
文本文件。
ubuntu@ubuntu2004:~/Modern-Cpp-NMS/build$ ls
boxes.txt CMakeCache.txt CMakeFiles cmake_install.cmake example Makefile
ubuntu@ubuntu2004:~/Modern-Cpp-NMS/build$ ./example
ubuntu@ubuntu2004:~/Modern-Cpp-NMS/build$ ls
boxes_nms.txt CMakeCache.txt cmake_install.cmake Makefile
boxes.txt CMakeFiles example、
我们可以对比boxes.txt
和boxes_nms.txt
两个文件可以发现我们通过nms已经减少了预测框。将boxes_nms.txt
文件拷贝到Modern-Cpp-NMS
目录下,我们使用绘图程序查看plot.py
经过nms前后的图像。
ubuntu@ubuntu2004:~/work/Modern-Cpp-NMS/build$ cp boxes_nms.txt ../
ubuntu@ubuntu2004:~/work/Modern-Cpp-NMS/build$ cd ../
ubuntu@ubuntu2004:~/work/Modern-Cpp-NMS$ ls
boxes_nms.txt CMakeLists.txt img_with_boxes.jpg nms.cpp README.md
boxes.txt example.cpp img_with_boxes_nms.jpg nms.hpp
build img.jpg LICENSE plot.py
ubuntu@ubuntu2004:~/work/Modern-Cpp-NMS$ python plot.py
执行完成后会自动弹出两个窗口显示经过nms前后的图像,如下图所示:
经过NMS之后的图像,可以看到目标周围只有一个或少数几个边界框,而不是大量的重叠框。左图是NMS之前的图像,右图是NMS之后的图像,可以看到NMS有效地减少了冗余的边界框,提高了检测的效果。
3.2 DRM显示
在视觉AI任务中,我们获得了目标检测框,一般我们还需要将检测框展示出来给用户看到检测结果,那么我们想在显示屏上进行显示,就需要了解DMR显示框架,怎么将检测框和摄像头获取到的图像叠加显示在显示屏上。
DRM(Direct Rendering Manager)是Linux主流的图形显示框架,它支持多图层合成,为用户图层提供统一的API(libdrm),来访问GPU,实现统一管理。在DRM显示框架中,KMS(Kernel Mode Setting)负责相关参数的设置(包括分辨率、刷新率、电源状态等)和显示画面的切换(显示buffer的切换,多图层的合成方式,以及每个图层的显示位置)。具体来说,DRM显示框架可以处理底层硬件和软件的交互,通过提供通用的API接口,使得软件架构更为统一,方便管理和维护。此外,DRM框架还可以支持多种显示硬件,包括液晶显示器、投影仪等。
DRM显示流程涉及到以下几个步骤:
-
打开DRM设备文件:DRM框架成功加载后,会创建一个设备文件/dev/dri/cardX,上层用户应用可以通过该文件节点,获取显卡的各种操作。
-
获取显卡资源句柄:通过drmModeGetResources函数,获取显卡的资源句柄,进而进行显卡资源的操作,如获取CRTC、Encoder、Connector等对象的信息。
-
分配和映射显示缓冲区:通过drmModeAddFB2函数,分配一个显示缓冲区(Framebuffer),并将其映射到用户空间,用于存储图像数据。
-
设置显示模式:通过drmModeSetCrtc函数,设置显示控制器(CRTC)的显示模式,如分辨率、刷新率、时序等,以及关联显示缓冲区和输出转换器(Encoder)。
-
设置输出连接器:通过drmModeSetConnector函数,设置输出连接器(Connector)的状态,如连接或断开,以及关联输出转换器(Encoder)。
-
绘制图像:通过drmModeMapDumb函数,将显示缓冲区映射到用户空间,然后通过memcpy或其他方式,将图像数据拷贝到显示缓冲区中。
-
切换显示缓冲区:通过drmModePageFlip函数,切换显示缓冲区,使得绘制的图像能够显示在屏幕上,同时注册一个回调函数,用于接收切换完成的通知。
-
等待垂直消影区:通过drmWaitVBlank函数,等待垂直消影区(VBLANK)的到来,这是显示器件软件和硬件的同步机制,通常基于硬件的VSYNC信号来实现。
-
释放显示缓冲区:通过drmModeRmFB函数,释放显示缓冲区,回收内存资源。