MatrixNMS
MatrixNMS为实例分割SOLO中提出的nms算法,原版MatrixNMS非常巧妙地通过一个矩阵乘法求掩码两两之间的iou,只需将求掩码两两之间的iou改成求预测框两两之间的iou,即可将MatrixNMS应用于目标检测算法的后处理。MatrixNMS的优点是不用设置nms_iou这个比较敏感的超参数;以及,理论速度比multiclass_nms快,因为它用了矩阵乘法求掩码两两之间的iou,矩阵乘法可用gpu并行高速计算;multiclass_nms对每个类别会选出1个得分最高的预测框(该预测框肯定会保留下来),然后分别与得分比它低的同类预测框计算iou,iou高于nms_iou的将会被舍弃,然后进行第二次迭代,从剩余的预测框里再次选出得分最高的,重复上述过程。multiclass_nms需要进行多次迭代,每一次迭代依赖于上一次迭代,无法做到并行,因为你不能提前预知哪个预测框会被保留。MatrixNMS就没有这种迭代过程,其理论速度要快于multiclass_nms。MatrixNMS采用了「减分」机制,对于每一个类别的每一个预测框,如果和得分比它高的同类预测框有iou(重叠),它的得分会被扣掉一些,之后,通过post_threshold分数阈值过滤掉低分数的预测框,剩下的就是最后的预测框了。「talk is cheap, show me the code」,我们来看一下ncnn中MatrixNMS的代码!
// examples/test2_06_ppyolo_ncnn.cpp ... struct Bbox { float x0; float y0; float x1; float y1; int clsid; float score; }; bool compare_desc(Bbox bbox1, Bbox bbox2) { return bbox1.score > bbox2.score; } float calc_iou(Bbox bbox1, Bbox bbox2) { float area_1 = (bbox1.y1 - bbox1.y0) * (bbox1.x1 - bbox1.x0); float area_2 = (bbox2.y1 - bbox2.y0) * (bbox2.x1 - bbox2.x0); float inter_x0 = std::max(bbox1.x0, bbox2.x0); float inter_y0 = std::max(bbox1.y0, bbox2.y0); float inter_x1 = std::min(bbox1.x1, bbox2.x1); float inter_y1 = std::min(bbox1.y1, bbox2.y1); float inter_w = std::max(0.f, inter_x1 - inter_x0); float inter_h = std::max(0.f, inter_y1 - inter_y0); float inter_area = inter_w * inter_h; float union_area = area_1 + area_2 - inter_area + 0.000000001f; return inter_area / union_area; } ... class PPYOLODecodeMatrixNMS : public ncnn::Layer { public: ... virtual int forward(const std::vector<ncnn::Mat>& bottom_blobs, std::vector<ncnn::Mat>& top_blobs, const ncnn::Option& opt) const { ... // keep bbox whose score > score_threshold std::vector<Bbox> bboxes_vec; for (int i = 0; i < out_num; i++) { float x0 = bboxes[i * 4]; float y0 = bboxes[i * 4 + 1]; float x1 = bboxes[i * 4 + 2]; float y1 = bboxes[i * 4 + 3]; for (int j = 0; j < num_classes; j++) { float score = scores[i * num_classes + j]; if (score > score_threshold) { Bbox bbox; bbox.x0 = x0; bbox.y0 = y0; bbox.x1 = x1; bbox.y1 = y1; bbox.clsid = j; bbox.score = score; bboxes_vec.push_back(bbox); } } } if (bboxes_vec.size() == 0) { ncnn::Mat& pred = top_blobs[0]; pred.create(0, 0, elemsize, opt.blob_allocator); if (pred.empty()) return -100; return 0; } // sort and keep top nms_top_k int nms_top_k_ = nms_top_k; if (bboxes_vec.size() < nms_top_k) nms_top_k_ = bboxes_vec.size(); size_t count {(size_t)nms_top_k_}; std::partial_sort(std::begin(bboxes_vec), std::begin(bboxes_vec) + count, std::end(bboxes_vec), compare_desc); if (bboxes_vec.size() > nms_top_k) bboxes_vec.resize(nms_top_k); // ---------------------- Matrix NMS ---------------------- // calc a iou matrix whose shape is [n, n], n is bboxes_vec.size() int n = bboxes_vec.size(); float* decay_iou = new float[n * n]; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (j < i + 1) { decay_iou[i * n + j] = 0.f; }else { bool same_clsid = bboxes_vec[i].clsid == bboxes_vec[j].clsid; if (same_clsid) { float iou = calc_iou(bboxes_vec[i], bboxes_vec[j]); decay_iou[i * n + j] = iou; }else { decay_iou[i * n + j] = 0.f; } } } } // get max iou of each col float* compensate_iou = new float[n]; for (int i = 0; i < n; i++) { float max_iou = decay_iou[i]; for (int j = 0; j < n; j++) { if (decay_iou[j * n + i] > max_iou) max_iou = decay_iou[j * n + i]; } compensate_iou[i] = max_iou; } float* decay_matrix = new float[n * n]; // get min decay_value of each col float* decay_coefficient = new float[n]; if (kernel == 0) // gaussian { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { decay_matrix[i * n + j] = static_cast<float>(expf(gaussian_sigma * (compensate_iou[i] * compensate_iou[i] - decay_iou[i * n + j] * decay_iou[i * n + j]))); } } }else if (kernel == 1) // linear { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { decay_matrix[i * n + j] = (1.f - decay_iou[i * n + j]) / (1.f - compensate_iou[i]); } } } for (int i = 0; i < n; i++) { float min_v = decay_matrix[i]; for (int j = 0; j < n; j++) { if (decay_matrix[j * n + i] < min_v) min_v = decay_matrix[j * n + i]; } decay_coefficient[i] = min_v; } for (int i = 0; i < n; i++) { bboxes_vec[i].score *= decay_coefficient[i]; } // ---------------------- Matrix NMS (end) ---------------------- std::vector<Bbox> bboxes_vec_keep; for (int i = 0; i < n; i++) { if (bboxes_vec[i].score > post_threshold) { bboxes_vec_keep.push_back(bboxes_vec[i]); } } n = bboxes_vec_keep.size(); if (n == 0) { ncnn::Mat& pred = top_blobs[0]; pred.create(0, 0, elemsize, opt.blob_allocator); if (pred.empty()) return -100; return 0; } // sort and keep keep_top_k int keep_top_k_ = keep_top_k; if (n < keep_top_k) keep_top_k_ = n; size_t keep_count {(size_t)keep_top_k_}; std::partial_sort(std::begin(bboxes_vec_keep), std::begin(bboxes_vec_keep) + keep_count, std::end(bboxes_vec_keep), compare_desc); if (bboxes_vec_keep.size() > keep_top_k) bboxes_vec_keep.resize(keep_top_k); ncnn::Mat& pred = top_blobs[0]; pred.create(6 * n, elemsize, opt.blob_allocator); if (pred.empty()) return -100; float* pred_ptr = pred; for (int i = 0; i < n; i++) { pred_ptr[i * 6] = (float)bboxes_vec_keep[i].clsid; pred_ptr[i * 6 + 1] = bboxes_vec_keep[i].score; pred_ptr[i * 6 + 2] = bboxes_vec_keep[i].x0; pred_ptr[i * 6 + 3] = bboxes_vec_keep[i].y0; pred_ptr[i * 6 + 4] = bboxes_vec_keep[i].x1; pred_ptr[i * 6 + 5] = bboxes_vec_keep[i].y1; } pred = pred.reshape(6, n); return 0; }
...
第一步,将得分超过score_threshold的预测框保存到bboxes_vec里,这是第一次分数过滤;如果没有预测框的得分超过score_threshold,直接返回1个形状是(0, 0)的Mat代表没有物体。
第二步,将bboxes_vec中的前nms_top_k个预测框按照得分降序排列,bboxes_vec中只保留前nms_top_k个预测框。
第三步,进入MatrixNMS,设此时bboxes_vec里有n个预测框,我们计算一个n * n的矩阵decay_iou,下三角部分(包括对角线)是0,表示的是bboxes_vec中的预测框两两之间的iou,而且,只计算同类别预测框的iou,非同类的预测框iou置为0;
接下来的代码比较难以理解,我举个例子说明,比如经过第一次分数过滤和得分降序排列后,剩下编号为0、1、2的3个同类的预测框,假设此时的decay_iou值为:
如果某个预测框与比它分高的同类预测框有较高的iou,它应该减去更多的分,这该怎么实现呢?一个比较简单的做法是对矩阵1-decay_iou每一列求最小值,即对矩阵:
每一列求最小,得到衰减系数向量decay_coefficient=[1, 0.1, 0.2],然后每个bbox的得分再和衰减系数向量里相应的值相乘,就实现减分的效果了!
比如0号预测框,它的得分应该乘以1,这很好理解,它是得分最高的预测框,应该被保留,不应该减分。对于1号预测框,它的得分应该乘以0.1,这很好理解,它与0号预测框的iou高达0.9,应该减去很多分。对于2号预测框,它的得分应该乘以0.2,这很好理解,它与1号预测框的iou高达0.8,应该减去很多分。但是这样做真的正确吗?如果用multiclass_nms做nms算法,假设设定的nms_iou=0.6,第0次迭代,首先保留得分最高的0号预测框,发现1号预测框和0号预测框的iou高达0.9,所以舍弃1号预测框,发现2号预测框和0号预测框的iou是0.2,保留2号预测框;第1次迭代,首先保留得分最高的2号预测框,发现没有预测框了,nms算法结束。所以最后保留的是0号预测框和2号预测框。上面的分析中,仅仅是因为2号预测框与1号预测框的iou高达0.8,就让2号预测框的分数乘以0.2,是非常不正确的做法,因为1号预测框与0号预测框的iou高达0.9,1号预测框有很大概率是会被舍弃的,不能因为2号预测框与可能被舍弃的1号预测框的iou高达0.8,就让2号预测框减去很多分。那么怎么解决这个问题呢?补偿!1-0.8没有什么参考意义,我们应该将它放大,可以让它除以(1-0.9)实现,0.9表示1号预测框与0号预测框的iou高达0.9,这样逐列取最小的时候就可能取不到它了。而且,不应该只有1号预测框与2号预测框这么做,预测框两两之间都应该这么做。我们看接下来的代码,逐列取decay_iou的最大值得到补偿向量compensate_iou,在这个示例中compensate_iou=[0, 0.9, 0.8],然后求一个n * n的矩阵decay_matrix,当kernel == 1时,是linear,它的计算公式是(1-decay_iou)矩阵的每一行元素都除以(1-compensate_iou的第i个值)(假设当前行id是i),所以在这个示例中,decay_matrix的值是:
逐列取decay_matrix的最小值,即可得到decay_coefficient=[1, 0.1, 0.8],你看,2号预测框的得分应该乘以0.8,是由于它和0号预测框的iou是0.2导致的,它减去的分数就比较少。而此时1号预测框和2号预测框在decay_matrix中的值被补偿(被放大)到2,参考意义不大,逐列取最小时取不到它。
现在你应该能更好地理解代码中decay_matrix的计算公式了吗?
decay_matrix[i * n + j] = (1.f - decay_iou[i * n + j]) / (1.f - compensate_iou[i]);
第i个预测框和第j个预测框的iou是decay_iou[i * n + j],第i个预测框它觉得第j个预测框的衰减系数应该是(1.f - decay_iou[i * n + j]),但是第i个预测框它觉得的就是对的吗?
还要看第i个预测框是否被抑制,第i个预测框如果没有被抑制,那么(1.f - decay_iou[i * n + j])就有参考意义,第i个预测框如果被抑制,那么(1.f - decay_iou[i * n + j])就没有什么参考意义。
所以需要除以(1.f - compensate_iou[i])作为补偿,compensate_iou[i]表示的是第i个预测框与比它分高的预测框的最高iou:
如果这个max_iou很大,衰减系数就会被放大,第i个预测框它觉得第j个预测框的衰减系数是xxx就没什么参考意义;如果这个max_iou很小,衰减系数就会放大得很小(max_iou==0时不放大),第i个预测框它觉得第j个预测框的衰减系数是xxx就有参考意义。
然后,逐列取decay_matrix的最小值,第j列的最小值应该是decay_iou[i * n + j]越大越好、compensate_iou[i]越小越好的那个第i个预测框提供。
当kernel == 0,也仅仅表示用其它的函数表示衰减系数和补偿而已。所有的预测框的得分乘以decay_coefficient相应的值实现减分,MatrixNMS结束。
第四步,将得分超过post_threshold的预测框保存到bboxes_vec_keep里,这是第二次分数过滤;如果没有预测框的得分超过post_threshold,直接返回1个形状是(0, 0)的Mat代表没有物体。
第五步,将bboxes_vec_keep中的前keep_top_k个预测框按照得分降序排列,bboxes_vec_keep中只保留前keep_top_k个预测框。
最后,写1个形状是(n, 6)的Mat表示最终所有的预测框后处理结束。
如何导出
(1)第一步,在miemiedetection根目录下输入这些命令下载paddle模型:
wget https://paddledet.bj.bcebos.com/models/ppyolo_r50vd_dcn_2x_coco.pdparams
wget https://paddledet.bj.bcebos.com/models/ppyolo_r18vd_coco.pdparams
wget https://paddledet.bj.bcebos.com/models/ppyolov2_r50vd_dcn_365e_coco.pdparams
wget https://paddledet.bj.bcebos.com/models/ppyolov2_r101vd_dcn_365e_coco.pdparams
(2)第二步,在miemiedetection根目录下输入这些命令将paddle模型转pytorch模型:
python tools/convert_weights.py -f exps/ppyolo/ppyolo_r50vd_2x.py -c ppyolo_r50vd_dcn_2x_coco.pdparams -oc ppyolo_r50vd_2x.pth -nc 80
python tools/convert_weights.py -f exps/ppyolo/ppyolo_r18vd.py -c ppyolo_r18vd_coco.pdparams -oc ppyolo_r18vd.pth -nc 80
python tools/convert_weights.py -f exps/ppyolo/ppyolov2_r50vd_365e.py -c ppyolov2_r50vd_dcn_365e_coco.pdparams -oc ppyolov2_r50vd_365e.pth -nc 80
python tools/convert_weights.py -f exps/ppyolo/ppyolov2_r101vd_365e.py -c ppyolov2_r101vd_dcn_365e_coco.pdparams -oc ppyolov2_r101vd_365e.pth -nc 80
(3)第三步,在miemiedetection根目录下输入这些命令将pytorch模型转ncnn模型:
python tools/demo.py ncnn -f exps/ppyolo/ppyolo_r18vd.py -c ppyolo_r18vd.pth --ncnn_output_path ppyolo_r18vd --conf 0.15
python tools/demo.py ncnn -f exps/ppyolo/ppyolo_r50vd_2x.py -c ppyolo_r50vd_2x.pth --ncnn_output_path ppyolo_r50vd_2x --conf 0.15
python tools/demo.py ncnn -f exps/ppyolo/ppyolov2_r50vd_365e.py -c ppyolov2_r50vd_365e.pth --ncnn_output_path ppyolov2_r50vd_365e --conf 0.15
python tools/demo.py ncnn -f exps/ppyolo/ppyolov2_r101vd_365e.py -c ppyolov2_r101vd_365e.pth --ncnn_output_path ppyolov2_r101vd_365e --conf 0.15
-c代表读取的权重,--ncnn_output_path表示的是保存为NCNN所用的 *.param 和 *.bin 文件的文件名,--conf 0.15表示的是在PPYOLODecodeMatrixNMS层中将score_threshold和post_threshold设置为0.15,你可以在导出的 *.param 中修改score_threshold和post_threshold,分别是PPYOLODecodeMatrixNMS层的5=xxx 7=xxx属性。
然后,下载ncnn_ppyolov2 这个仓库(它自带了glslang和实现了ppyolov2推理),按照官方how-to-build 文档进行编译ncnn。
编译完成后, 将上文得到的ppyolov2_r50vd_365e.param、ppyolov2_r50vd_365e.bin、...这些文件复制到ncnn_ppyolov2的build/examples/目录下,最后在ncnn_ppyolov2根目录下运行以下命令进行ppyolov2的预测:
cd build/examples
./test2_06_ppyolo_ncnn ../../my_tests/000000013659.jpg ppyolo_r18vd.param ppyolo_r18vd.bin 416
./test2_06_ppyolo_ncnn ../../my_tests/000000013659.jpg ppyolo_r50vd_2x.param ppyolo_r50vd_2x.bin 608
./test2_06_ppyolo_ncnn ../../my_tests/000000013659.jpg ppyolov2_r50vd_365e.param ppyolov2_r50vd_365e.bin 640
./test2_06_ppyolo_ncnn ../../my_tests/000000013659.jpg ppyolov2_r101vd_365e.param ppyolov2_r101vd_365e.bin 640
每条命令最后1个参数416、608、640表示的是将图片resize到416、608、640进行推理,即target_size参数。会弹出一个这样的窗口展示预测结果:
test2_06_ppyolo_ncnn的源码位于ncnn_ppyolov2仓库的examples/test2_06_ppyolo_ncnn.cpp。PPYOLOv2和PPYOLO算法目前在Linux和Windows平台均已成功预测。