上交自瞄算法开源代码-装甲板识别功能分析

前排声明:markdown文档中所有的图片都是通过typora+PicGo上传到我自己的腾讯云储存空间中了,由于没有将文档发给别人的经验所以我不知道别人能不能成功查看到文档中的图片。如果图片查看不了,可以去我的博客园中看这次考试的文档:上交自瞄算法开源代码-装甲板识别功能分析 - Zaughter - 博客园 (cnblogs.com)

前言

开源代码github网址:GitHub - xinyang-go/SJTU-RM-CV-2019: 上海交通大学 RoboMaster 2019赛季 视觉代码

这里着重分析主函数main.cpp与装甲板识别部分的工程文件armer文件夹。

由于未完全分析,所以对于其中log.h、options.h中的函数并不清楚,在分析时会跳过这些内容。

该文章重点分析的是思路,部分函数中的数学逻辑、数据处理思路这里不详细解释(没能力)


所有思维导图通过 知犀知犀思维导图 - 知犀官网 (zhixi.com) 原创

image-20230126200546472


框架分析

首先看一下该文章要分析的部分

image-20230126200737472

头文件

这里我们只分析寻找装甲板的armor_finder.h和分类器classifier.h,而分类器其实是为寻找装甲板服务的

classifier.h

image-20230126205916974

  • load_conv_w/b:加载卷积层

    • 先以只读方式打开文件。如果打开失败则输出日志提示打开失败,然后将state设为false,返回一个未分配空间的result
    • 如果成功,就根据文件中的参数进行操作,通过emplace_back将数据添加到result中,最后return result
  • load_fc_w/b:加载全连接层

    • 基本思路与卷积层相同,不同的是:如果文件打开失败,返回一个现创建的1x1零向量而不是动态vector
  • relu:relu激活函数

    • 第一个relu用了三元表达式实现,后面的重载使用了这个relu方法
  • leaky_relu:relu函数的变形

    • image-20230126210319011
    • 用来防止训练过程中ReLu函数负半轴导致神经元死亡的问题
  • softmax函数:

    • 对于多分类问题,给每种结果赋予一个概率值
    • ReLu就是一种非softmax函数(hardmax),处理后所有线性值的和可能大于1,此时不能将线性值当作概率值
  • 有参构造

    • 这里用到了state,有参构造中会调用所有前面写的load_xxx_x方法来给私有属性赋值
    • 如果最终state为true,说明参数加载成功
  • calculate:

    • 在operator中使用
    • operator将图片RGB三通道值都转换到0-1之间,传给calculate计算
      1. 经过relu,平均池化,relu,平局池化,,relu后,将其压平到一维
      2. 通过wx+b算出y,使用relu,再算下一层,进行softmax
      3. 将最后得到的矩阵返回
  • operator

    1. 通道拆分
    2. 所有通道值/255,进行归一化
    3. 通过calculate进行CNN处理,得到代表概率的一维向量
    4. 找到最大概率的行,如果概率大于0.5,认为就是该类型
      如果最大概率小于0.5,认为匹配失败,返回0(筛)

armor_finder.h

这里面写了三个类,每个类都有着自己的属性与方法,最后功能都会集合到findArmorBox上

image-20230126210914047

  • 里面的灯条类和装甲板类都通过typedef给自己类型的vector容器起了别名。他们被广泛用于后续的函数中,借助引用来将函数处理后的数据储存起来
  • 自瞄类中很多属性、方法与装甲板识别功能无关,所以流程图中省略了

源码

  • classifier.cpp是为了实现classifier.h中的各项函数
  • armor_finder.cpp最关键的作用就是实现了有参构造和自瞄类的核心方法run
  • 其他.cpp文件都是在实现armor_finder.h中的某个或某些函数,他们大多都有一下规律:先创建一下属于自己的函数作为铺垫,然后再通过这些函数去实现armor_finder.h的函数

具体分析

函数调用情况

图中的几个函数是装甲板识别功能中极为重要的函数,也是接下来我们要分析的函数。图中展示了他们的调用关系方便理清逻辑

image-20230126204359032

  1. main.cpp中调用run来实现自瞄功能
  2. run中当状态为SEARCHING_STATE时,会使用装甲板识别的功能。这时候他会先判断是否能从图片中搜索到装甲板、搜索到的是否与上次搜索到的是同一目标,只有判断成功才会进行下一步tracker对象的创建(进入追踪状态的基础)。而这个判断功能就交给stateSearchingTarget来处理
  3. stateSearchingTarget对上面两个问题做出判断,其中第一个问题:”是否能从图片中搜索到装甲板“就交给了findArmorBox来处理。他会告诉程序能否搜索到,如果搜索到了他还会把装甲板的信息传递过来,以便进行第二个问题的判断。后面装甲板的信息还会传递到run中用来追踪
  4. findArmorBox主要进行一下操作:寻找灯条,通过灯条得到候选装甲板,通过分类器来筛选。上面三步分别交给了图中的三个函数

这是对整个装甲板识别流程的简要说明,详细的内容会在下文一一阐述


main.cpp

刨除掉其他功能,其实关于寻找装甲板的功能就是通过run体现的


自瞄主函数run()

run中对于不同的机器状态会有不同的处理,这里我们只分析搜索状态下的流程,流程图如下:

image-20230126204714508

  • anti_switch_cnt防止乱切目标计数器在装甲板识别中未使用,所以不进行讨论
  • run中关于装甲板识别的内容其实只有stateSearchingTarget,所以这里只放出流程图,对于具体细节实现不讨论

我们根据函数调用情况图了解到是findArmorBox调用了findLightBlobs,但是为了逻辑的通畅,我们先分析灯条识别功能,不再按照情况图从下往上分析


灯条识别

image-20230126211600228

函数

  • lw_rate:旋转矩形的长宽比
    • 利用三元运算符,保证输出的比值>1
  • areaRatio:轮廓面积和其最小外接矩形面积比
    • 其中的Point类以前也用过,就是用来记录坐标点的。将轮廓的所有坐标点保存到vector容器中,这时候就可以认为这个容器代表了这个轮廓
    • 直接调用contourArea得到轮廓面积
  • isValidLightBlob:判断轮廓是否为一个灯条
    • 分析判断条件
      • A:旋转矩形的长宽比在1.2与10之间
      • B:最小外接矩形面积<50 并且 轮廓面积和其最小外接矩形面积之比>0.4
      • C:最小外接矩形面积>=50 并且 轮廓面积和其最小外接矩形面积之比>0.6
      • A && ( B || C)
    • 判断方式的原理
      • A是对灯条长宽比的基本数据限定,灯条什么角度都基本上是个长方形,而且比值不可能超过10
      • 轮廓面积和其最小外接矩形面积之比 其实就是灯条识别图像与外接矩形的契合度
        • 面积<50说明灯条比较远,那么杂光的干扰会大一些,灯条外形特征不明显,契合度就会相对较低
        • 同理C为较近的情况,契合度理应更高,所以要求的最小值就会高一些
  • get_blob_color:判断灯条的颜色
    • 整体思路是通过比较图片中红蓝的比例来判断灯条颜色(只看红蓝是因为灯条只可能是这两个颜色)
  • isSameBlob:判断两个灯条区域是否为同一个灯条
    • 如果两个灯条外接矩形的中心点坐标满足:(x坐标差值的平方+y坐标差值的平方)<9,则认为是同一个灯条
  • imagePreProcess:开闭运算
    • 其中用的都是3x5的矩阵卷积核
    • 依次对图像进行腐蚀,膨胀,膨胀,腐蚀

findLightBlobs

通道拆分

  1. 通道拆分为RGB三个,根据敌人的颜色选择使用哪个颜色的通道
  2. 根据敌人颜色设置light_threshold(二值化的阈值)的值

二值化处理

  • 方式一:对图像进行二值化处理,大于阈值(light_threshold)部分设置为255,小于部分设置为0,如果处理后图像为空返回false.对处理后图片进行一次开闭运算(imagePreProcess函数)

  • 方式二:对图像以140为阈值进行一次二值化处理,然后一次开闭运算

使用两个不同的二值化阈值同时进行灯条提取,减少环境光照对二值化这个操作的影响。
同时剔除重复的灯条,剔除冗余计算,即对两次找出来的灯条取交集。

取交集

  1. 找两次处理的轮廓,检索模式为CV_RETR_CCOMP;定义轮廓的近似方法为CV_CHAIN_APPROX_NONE
    • CV_RETR_CCOMP: 检测所有的轮廓,但所有轮廓只建立两个等级关系,外围为顶层,若外围内的内围轮廓还包含了其他的轮廓信息,则内围内的所有轮廓均归属于顶层
    • CV_CHAIN_APPROX_NONE:保存物体边界上所有连续的轮廓点到contours向量内
  2. 分别给两个灯条容器对象(利用了typedef std::vector\<LightBlob> LightBlobs)添加内容:最小外接矩形,轮廓面积和最小外接矩阵面积之比,灯条的颜色
  3. 最后通过一系列取交集操作判断是否找到灯条(灯条数大于等于2),同时灯条的数据都存在了light_blobs这个LightBlobs类型的vectorr中

与灯条匹配成装甲板

image-20230126213256736

matchArmorBoxes

  1. 清空ArmorBox类型的vector容器
  2. 读取light_blobs(里面存储着灯条的数据)的内容,这里的嵌套for循环是为了将所有灯条进行两两配对处理
  3. 先判断两个灯条是否匹配,不配对则继续
  4. 将两个灯条的矩形一个作为左矩形一个作为右矩形
  5. 通过一系列计算处理,如果一系列数据限制条件都通过,则将这个候选装甲板的坐标,宽高,两个相应的灯条数据,敌人颜色都存入armor_boxes这个容器中

findArmorBox

  1. 创建light_blobs,armor_boxes两个容器,初始化box对象
  2. 调用findLightBlobs函数,将找到的所有可能的灯条存到bight_blobs容器中
  3. 调用matchArmorBoxes函数,得到装甲板候选区
  4. 使用分类器classifier对装甲板候选去进行筛选(概率小于0.5的都会被筛选掉,导致id=0)
  5. 按照优先级对装甲板进行排序:利用bool operator<(const ArmorBox &box) const,选择优先级最大的作为结果(id不能为0,为0代表被分类器筛选掉了),如果结果是空,返回false。如果分类器不可用就默认选择候选区第一个区域作为目标

上交的灯条识别与自己灯条识别的区别

自己灯条识别过程简要

总体流程:色彩通道分离,二值化,降噪,边缘检测

  • 色彩通道分离:由于只是对一张图片进行处理,所以直接通过肉眼得知图片中为蓝色灯条。所以在通道分离时,split的第二个参数mvt其实是写死了的,并没有写判断灯条颜色的功能。相当于色彩通道分离时泛性极差
  • 二值化:二值化的阈值的取值有大问题,根本就是针对单一图片取的(当时我就意识到了这个问题,但是想不到解决方案)。当时我直接设置了一个bar动态调整二值化的阈值来看效果,通过调整发现我测试的那张图片的最佳阈值是239,但其实这个值根本不就不合适其他的图片
  • 降噪:我这里只用了一次morphologyEx开闭运算,处理的效果并不是很好,会有一些杂点
  • 边缘检测:基本与上交源码思路相同,利用minAreaRect函数

上交代码优点

  • 色彩通道分离:由于实际使用中会出现红蓝两种颜色,所以先通过自定义函数get_blob_color判断灯条的颜色,然后分离出灯条的颜色
  • 二值化与降噪:
    • 这里有两条线路,第一条线路的阈值参数针对灯条颜色有不同的值,第二条线路的阈值则是一个固定的、比较靠中间的140。两条线路二值化后都经过了开闭运算处理,然后得到了两张来自不同线路的图片。
    • 开闭运算中,采用腐蚀,膨胀,膨胀,腐蚀的处理,通过多次开闭运算来提高降噪的精确度

C++学习记录

C++新知识

数值计算库Eigen

参考:

Matrix: (16条消息) Eigen教程2----MatrixXd和VectorXd的用法_MaybeTnT的博客-CSDN博客_eigen::matrixxd

.colise: (16条消息) Eigen初学相关介绍_小白要努力的博客-CSDN博客_colwise函数

maxCoeff()与索引:(16条消息) Eigen库使用之矩阵的最大/小值及其位置_Chen-Sh的博客-CSDN博客_maxcoeff

  • Marix类:在Eigen,所有的矩阵和向量都是Matrix模板类的对象,Vector只是一种特殊的矩阵(一行或者一列)

  • MatrixXd:Matrix的一种构造函数,X代表动态,d代表double类型,也就是说生成一个类型的double类型的动态大小的矩阵

    • Matrix3f意味着生成一个3x3的类型为float的矩阵
    • MatrixXd f(row, col)意味着f是一个类型的double的row*col矩阵(注意不是int类型,int是MatrixXi)
  • VectorXd:与MatrixXd同理,生成一个类型的double类型的动态大小的向量

  • .colwise:Mat.colwise()理解为分别去看矩阵的每一列,然后再作用maxCoeff()函数,即求每一列的最大值。

    需要注意的是,colwise返回的是一个行向量(列方向降维),rowwise返回的是一个列向量(行方向降维)。

  • image-20230126103711287

    • 先通过calculate方法得到矩阵result
    • 设置最小值的索引
    • 通过.maxCoeff计算矩阵中的最大值最小值,这时得到了最大值的坐标
    • 如果最大值>0.5返回行坐标,反之返回0

容器知识

参考:

emplace_back:(16条消息) C++的emplace_back函数介绍_Jason_Lee155的博客-CSDN博客_c++ emplace_back

  • emplace_back:传统对vector进行尾插入都是使用push_back,但是其中有很多冗余的计算。而C++11引入了emplace_back函数,它通过完美转发实现了在vector中插入时直接在容器内构造对象,省略了创建临时对象的操作

文件读写函数

  • fscanf:按“格式字符串”所指定的格式,从“文件类型指针”所指向的文件的当前位置读取数据,然后按“输入项地址表列”的顺序,将读取来的数据存入指定的内存单元中。
    • fscanf(fp,"%d,%f",&i,&t)为例,意为从指针fp所指向的文件中以%d,%f格式读取两个值,分别存储在地址&i,&t的内存中

OOP知识

参考:

=default:(16条消息) C++ =default_c++ default_TABE_的博客-CSDN博客

转换运算符:关于c ++:“ operator bool()const”是什么意思 | 码农家园 (codenong.com)

构造函数冒号语法:(16条消息) C++子类的构造函数后面加:冒号的作用_lusirking的博客-CSDN博客_c++ 构造函数 冒号

  • default 函数。程序员只需在函数声明后加上=default,就可将该函数声明为 default 函数,编译器将为显式声明的default函数自动生成函数体。
  • operator bool()const:当使用时可以将对象转化为bool类型
  • 该源码为第二种使用场景:对类成员进行初始化。正常写法也可以做到,但是这样会更加简便

其他

参考:

typedef:(16条消息) C++ typedef详解_jupeiii的博客-CSDN博客_c++ typedef

enum:(16条消息) C++枚举enum使用详解_赵大宝字的博客-CSDN博客_c++ enum

extern:(7条消息) C++中的extern_老胡写代码的博客-CSDN博客_c++ extern

auto类型:(7条消息) C++ auto类型总结_emper丶z的博客-CSDN博客_c++ auto

.erase:C++中erase函数的使用,可以用来删除内存擦除 - 初见不如不相见 - 博客园 (cnblogs.com)

  • fmax,fmin是c语言中用来快速比较两数大小的函数,会返回两个浮点数中更x的那个
  • auto类型:可以在声明变量时自动推断被声明变量的类型,更适用于类型冗长复杂、变量使用范围专一时,使程序更清晰易读
  • .erase:可以用来更新(删除内容)迭代器

OpenCV新知识

参考:

轮廓拟合函数:(7条消息) opencv--轮廓拟合函数 boundingRect(),minAreaRect(),minEnclosingCircle(),fitEllipse(),fitLine()_Haohao fighting!的博客-CSDN博客_boundingrect()

getStructuringElement():(7条消息) getStructuringElement函数以及开、闭、腐蚀、膨胀原理讲解_SerendipityMIT的博客-CSDN博客

  • RotatedRect是一个存储平面上旋转矩形的类,通常用来存储最小外包矩形函数minAreaRect( )和椭圆拟合函数fitEllipse( )返回的结果。以前给灯条找最小外接矩阵的时候用过一次
    • .boundingRect():轮廓拟合函数中的一个,能够返回包围轮廓的矩形的边界信息。
  • getStructuringElement():返回一个结构元素(卷积核)。第一个参数会决定卷积核的形状,源码中imagePreProcess方法使用的MORPH_RECT会使函数返回矩形卷积核

收获

  1. 如果一个函数作用是信息处理,那么一般返回值为bool,表示这个函数是否成功处理了数据。处理后的数据不作为返回值,而是通过参数中的引用直接存放到变量(容器)中。就比如findLightBlobs这个函数,通过容器是否为空来作为bool类型的返回,通过函数得到的灯条信息在函数体中已经存放到了容器中(容器参数利用了引用)。
  2. 在写完一个头文件后,并不一定要只写一个.cpp文件来实现,可以将头文件中的函数进行功能分类,通过不同的.cpp来专门实现某一功能的函数。
  3. 读开源代码的思路:
    1. 先看头文件,分析一下头文件中的函数大概作用
    2. 看.cpp,确认好每个源码实现的是哪个头文件的哪些功能,哪些函数
    3. 分析.cpp中的函数,首先整理清楚哪些是当前源码中创建的,哪些是用来实现头文件函数的。一般都是先理清源码中创建的函数,他们都是实现头文件函数的铺垫,理解他们的功能与原理,那么最后分析实现头文件函数的思路就会简单很多
    4. 看main.cpp,找到在主函数中调用的关于这个功能的函数,这些函数将会成为我们分析某个功能源码的路线开端
    5. 顺着主函数中的要研究的功能的函数层层递进,理清调用关系,认清各个函数的创造原因与服务对象,最后就可以形成一个完整的功能实现架构图了
      • 一定要好好画思维导图,这玩意对于理解开源代码架构有大作用
      • 分析源码的时候看一下他都调用了哪些头文件,更方便理清调用关系
  4. 经历了读开源代码的痛苦后,就更加难感受到写注释的重要性。同时函数名和变量名一定要见名知意,不然别人理解起来巨痛苦,如果可以,最好每个函数都写个注释文档造福后人
  5. 有些功能函数原理的理解是建立在现实经验之上的,他们是通过现实中事物的规律而产生的,所以有时候理解原理不要老想着从数学层面上理解,要考虑现实规律
posted @ 2023-01-26 21:44  Zaughter  阅读(335)  评论(0编辑  收藏  举报