ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

編輯:好睏 LRS

【新智元導讀】

今天給大家安利一個寶藏倉庫miemiedetection ,

該倉庫集合了PPYOLO、PPYOLOv2、PPYOLOE三個演算法pytorch實現三合一

,其中的PPYOLOv2和PPYOLO演算法剛剛支援了匯出ncnn

眾所周知,PPYOLO和PPYOLOv2的匯出部署非常困難,因為它們使用了可變形卷積、MatrixNMS等對部署不太友好的運算元。

而作者在ncnn中實現了可變形卷積DCNv2、CoordConcat、PPYOLO Decode MatrixNMS等自定義層,使得使用ncnn部署PPYOLO和PPYOLOv2成為了可能。

其中的可變形卷積層也已經被合入ncnn官方倉庫。

在ncnn中對圖片預處理時,先將圖片從BGR格式轉成RGB格式,然後用cv2。INTER_CUBIC方式將圖片插值成640x640的大小,再使用相同的均值和標準差對圖片進行歸一化。以上全部

與原版PPYOLOv2一樣,從而

確保了C++端和python端輸入神經網路的圖片張量是完全一樣的。

最後,ncnn的輸出與miemiedetection的輸出對比如下圖所示:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

其中,右邊是miemiedetection的輸出,為ppyolov2_r50vd_365e。pth這個模型預測的結果。

在miemiedetection根目錄下輸入以下內容即可得到。

python tools/demo。py image -f exps/ppyolo/ppyolov2_r50vd_365e。py -c ppyolov2_r50vd_365e。pth ——path assets/000000013659。jpg ——conf 0。15 ——tsize 640 ——save_result ——device gpu

左邊則是ncnn相同的模型ppyolov2_r50vd_365e的結果,ncnn的運算結果與pytorch有細微差別,影響不大。

pytorch直接轉ncnn

讀了一部分ncnn的原始碼,確保對 *。bin 和 *。param 檔案充分了解之後,封裝了1個工具ncnn_utils,原始碼位於miemiedetection的mmdet/models/ncnn_utils。py,

它支援寫一次前向傳播就能匯出ncnn使用的 *.bin 和 *.param 檔案

,你只需給每個pytorch層增加1個export_ncnn()方法,export_ncnn()方法幾乎只要照抄farward()方法就能把模型匯出到ncnn。

以下是ncnn_utils工具的使用示例:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

是不是很牛x?你只要照著farward()方法寫,在export_ncnn()方法裡用ncnn_utils的api寫一次前向傳播就能把pytorch模型匯出到ncnn。

在這個示例中,我展示瞭如何將resnet中使用的ConvNormLayer層匯出到ncnn,ConvNormLayer層裡包含了卷積層、bn層、啟用層(當self。dcn_v2==False),或者是卷積層、可變形卷積層、bn層、啟用層(當self。dcn_v2==True)。

為了提升ncnn的推理速度,我將卷積層(可變形卷積層)和bn層合併,另外,當啟用函式是relu、leakyrelu、clip、sigmoid、mish、hardswish這些時,還可以將啟用層合併到卷積層當中,這樣就將3個層合併成了1個層,大大提高推理速度。

可變形卷積

卷積層可以視為可變形卷積在offset==0,mask==1時的特例。

一個形狀為[in_c, h, w]的特徵圖inputs,經過普通卷積層(卷積核形狀是[num_output, in_c, kernel_h, kernel_w],w方向的步長、相鄰卷積取樣點的距離、卷積步長、左填充、右填充分別是kernel_w、dilation_w、stride_w、pad_left、pad_right,h方向的步長、相鄰卷積取樣點的距離、卷積步長、上填充、下填充分別是kernel_h、dilation_h、stride_h、pad_top、pad_bottom)後,得到的特徵圖形狀是[num_output, out_h, out_w],其中out_h = (h + pad_top + pad_bottom - dilation_h * (kernel_h - 1) + 1) / stride_h + 1,out_w = (w + pad_left + pad_right - dilation_w * (kernel_w - 1) + 1) / stride_w + 1。

一個形狀為[in_c, h, w]的特徵圖inputs,經過可變形卷積層(卷積核形狀是[num_output, in_c, kernel_h, kernel_w],w方向的步長、相鄰卷積取樣點的距離、卷積步長、左填充、右填充分別是kernel_w、dilation_w、stride_w、pad_left、pad_right,h方向的步長、相鄰卷積取樣點的距離、卷積步長、上填充、下填充分別是kernel_h、dilation_h、stride_h、pad_top、pad_bottom)後,得到的特徵圖形狀也是[num_output, out_h, out_w],其中out_h = (h + pad_top + pad_bottom - dilation_h * (kernel_h - 1) + 1) / stride_h + 1,out_w = (w + pad_left + pad_right - dilation_w * (kernel_w - 1) + 1) / stride_w + 1。

但不同的是在可變形卷積層之前,inputs需要經過一個普通卷積層,獲得可變形卷積需要的offset和mask,offset和mask的形狀分別是[kernel_h * kernel_w * 2, out_h, out_w]、[kernel_h * kernel_w, out_h, out_w]。為什麼是這個形狀呢?

我們知道,inputs經過卷積層,卷積窗是不是滑動了out_h * out_w次?是的,因為每一行卷積窗滑動了out_w次,每一列卷積窗滑動了out_h次,所以總共滑動了out_h * out_w次。

此外,卷積取樣點是不是有kernel_h * kernel_w個?

是的,offset表示的是卷積窗停留在每一個位置的時候,每個卷積取樣點的偏移(有y、x兩個座標),所以offset的形狀是[kernel_h * kernel_w * 2, out_h, out_w]。

但是,offset是浮點數,你怎麼取原圖inputs裡的畫素?雙線性插值!對取樣點的x、y座標分別進行上取整和下取整,得到最近的4個取樣點的座標,然後將4個取樣點的畫素進行雙線性插值,得到所求的畫素val。

mask是0到1之間的值(進入可變形卷積層之前會經過sigmoid層),表示的是每個val的重要程度,所以它的形狀是[kernel_h * kernel_w, out_h, out_w]。

offset和mask會和inputs一起進入可變形卷積層參與後續計算。

「talk is cheap, show me the code」,我們來看一下ncnn中可變形卷積的程式碼!

。。。

#include “deformableconv2d。h”

#include “fused_activation。h”

namespace ncnn {

DeformableConv2D::

DeformableConv2D

()

{

one_blob_only =

false

support_inplace =

false

}

int DeformableConv2D::load_param(const ParamDict& pd)

{

num_output = pd。get(0, 0);

kernel_w = pd。get(1, 0);

kernel_h = pd。get(11, kernel_w);

dilation_w = pd。get(2, 1);

dilation_h = pd。get(12, dilation_w);

stride_w = pd。get(3, 1);

stride_h = pd。get(13, stride_w);

pad_left = pd。get(4, 0);

pad_right = pd。get(15, pad_left);

pad_top = pd。get(14, pad_left);

pad_bottom = pd。get(16, pad_top);

bias_term = pd。get(5, 0);

weight_data_size = pd。get(6, 0);

activation_type = pd。get(9, 0);

activation_params = pd。get(10, Mat());

return

0;

}

int DeformableConv2D::load_model(const ModelBin& mb)

{

weight_data = mb。load(weight_data_size, 0);

if

(weight_data。empty())

return

-100;

if

(bias_term)

{

bias_data = mb。load(num_output, 1);

if

(bias_data。empty())

return

-100;

}

return

0;

}

int DeformableConv2D::forward(const std::vector& bottom_blobs, std::vector& top_blobs, const Option& opt) const

{

const Mat& bottom_blob = bottom_blobs[0];

const Mat& offset = bottom_blobs[1];

const bool has_mask = (bottom_blobs。size() == 3);

const int w = bottom_blob。w;

const int h = bottom_blob。h;

const int in_c = bottom_blob。c;

const size_t elemsize = bottom_blob。elemsize;

const int kernel_extent_w = dilation_w * (kernel_w - 1) + 1;

const int kernel_extent_h = dilation_h * (kernel_h - 1) + 1;

const int out_w = (w + pad_left + pad_right - kernel_extent_w) / stride_w + 1;

const int out_h = (h + pad_top + pad_bottom - kernel_extent_h) / stride_h + 1;

// output。shape is [num_output, out_h, out_w] (

in

python)。

Mat& output = top_blobs[0];

output。create(out_w, out_h, num_output, elemsize, opt。blob_allocator);

if

(output。empty())

return

-100;

const

float

* weight_ptr = weight_data;

const

float

* bias_ptr = weight_data;

if

(bias_term)

bias_ptr = bias_data;

// deformable conv

#pragma omp parallel for num_threads(opt。num_threads)

for

(int h_col = 0; h_col < out_h; h_col++)

{

for

(int w_col = 0; w_col < out_w; w_col++)

{

int h_in = h_col * stride_h - pad_top;

int w_in = w_col * stride_w - pad_left;

for

(int oc = 0; oc < num_output; oc++)

{

float

sum = 0。f;

if

(bias_term)

sum = bias_ptr[oc];

for

(int i = 0; i < kernel_h; i++)

{

for

(int j = 0; j < kernel_w; j++)

{

const

float

offset_h = offset。channel((i * kernel_w + j) * 2)。row(h_col)[w_col];

const

float

offset_w = offset。channel((i * kernel_w + j) * 2 + 1)。row(h_col)[w_col];

const

float

mask_ = has_mask ? bottom_blobs[2]。channel(i * kernel_w + j)。row(h_col)[w_col] : 1。f;

const

float

h_im = h_in + i * dilation_h + offset_h;

const

float

w_im = w_in + j * dilation_w + offset_w;

// Bilinear

const bool cond = h_im > -1 && w_im > -1 && h_im < h && w_im < w;

int h_low = 0;

int w_low = 0;

int h_high = 0;

int w_high = 0;

float

w1 = 0。f;

float

w2 = 0。f;

float

w3 = 0。f;

float

w4 = 0。f;

bool v1_cond =

false

bool v2_cond =

false

bool v3_cond =

false

bool v4_cond =

false

if

(cond)

{

h_low = floor(h_im);

w_low = floor(w_im);

h_high = h_low + 1;

w_high = w_low + 1;

float

lh = h_im - h_low;

float

lw = w_im - w_low;

float

hh = 1 - lh;

float

hw = 1 - lw;

v1_cond = (h_low >= 0 && w_low >= 0);

v2_cond = (h_low >= 0 && w_high <= w - 1);

v3_cond = (h_high <= h - 1 && w_low >= 0);

v4_cond = (h_high <= h - 1 && w_high <= w - 1);

w1 = hh * hw;

w2 = hh * lw;

w3 = lh * hw;

w4 = lh * lw;

}

for

(int c_im = 0; c_im < in_c; c_im++)

{

float

val = 0。f;

if

(cond)

{

float

v1 = v1_cond ? bottom_blob。channel(c_im)。row(h_low)[w_low] : 0。f;

float

v2 = v2_cond ? bottom_blob。channel(c_im)。row(h_low)[w_high] : 0。f;

float

v3 = v3_cond ? bottom_blob。channel(c_im)。row(h_high)[w_low] : 0。f;

float

v4 = v4_cond ? bottom_blob。channel(c_im)。row(h_high)[w_high] : 0。f;

val = w1 * v1 + w2 * v2 + w3 * v3 + w4 * v4;

}

sum += val * mask_ * weight_ptr[((oc * in_c + c_im) * kernel_h + i) * kernel_w + j];

}

}

}

output。channel(oc)。row(h_col)[w_col] = activation_ss(sum, activation_type, activation_params);

}

}

}

return

0;

}

} // namespace ncnn

forward()函式即可變形卷積的前向程式碼,bottom_blobs是可變形卷積的輸入,當bottom_blobs裡有3個輸入時,分別是inputs、offset、mask,表示是DCNv2,當bottom_blobs裡有2個輸入時,分別是inputs、offset,表示是DCNv1。

接下來的程式碼,我計算了out_h、out_w,即輸出特徵圖的高度和寬度。接下來是對輸出張量output開闢空間,獲取可變形卷積層的權重、偏置的指標weight_ptr、bias_ptr。最後進入for迴圈。

第1個for迴圈表示的是卷積窗在h方向滑動,滑了out_h次。

第2個for迴圈表示的是卷積窗在w方向滑動,滑了out_w次;之後計算的h_in、w_in分別表示當前卷積窗位置左上角取樣點在pad之後的inputs的y座標、x座標(實際上inputs不需要pad,之後你會看到,取樣點超出inputs的範圍時,取樣得到的畫素強制取0)。

第3個for迴圈表示的是填寫輸出特徵圖的每一個通道,填了num_output次;首先讓sum=0,當使用偏置時,sum=bias_ptr[oc],即第oc個偏置。

第4、第5、第6個for迴圈遍歷了卷積核的高度、寬度、通道數,計算卷積層權重weight每個卷積取樣點每個通道和原圖inputs相應位置的畫素val(雙線性插值得到)和積,再累加到sum中。offset_h、offset_w是當前卷積取樣點的y、x偏移,mask_是雙線性插值得到的val的重要程度。

真正取樣位置的y座標是h_im = 當前卷積窗左上角y座標h_in + 卷積核內部y偏移i * dilation_h + y偏移offset_h;真正取樣位置的x座標是w_im = 當前卷積窗左上角x座標w_in + 卷積核內部x偏移j * dilation_w + x偏移offset_w。

之後,計算好雙線性插值中h_im、w_im上下取整的結果h_low、w_low、h_high、w_high,雙線性插值中4個畫素的權重w1、w2、w3、w4等。注意,不要在for (int c_im = 0; c_im < in_c; c_im++){}中計算,因為在每一個輸入通道中,取樣位置h_im、w_im是相等的,所以h_low、w_low、h_high、w_high、w1、w2、w3、w4也是相等的,提前計算好就不用在每個輸入通道重複計算,提高計算速度和演算法效率。

第6個for迴圈中,遍歷每個輸入通道,求取樣得到畫素val,如果取樣位置超出inputs的範圍,取0;對比cond和v1_cond、v2_cond、v3_cond、v4_cond,會發現cond的邊界會比v1_cond、v2_cond、v3_cond、v4_cond的邊界大一點,比如當h_im==-1且w_im==-1時, cond是true。

這是因為,h_im和w_im會經過上下取整,其中上取整得到的取樣點位置是(0, 0),剛好是在inputs範圍內,所以cond的邊界會比v1_cond、v2_cond、v3_cond、v4_cond的邊界大一點。

計算好val之後,將val * mask_ * weight_ptr[((oc * in_c + c_im) * kernel_h + i) * kernel_w + j]累加到sum之中。

PPYOLOv2輸出解碼

PPYOLOv2輸出解碼比YOLOv3複雜一些,它使用了iou_aware和Grid Sensitive。

在YOLOv3中,輸出3個特徵圖,表示3種感受野(大中小)的預測結果,每個特徵圖的每個格子輸出3個bbox,對應3個聚類出來的anchor進行解碼。

當資料集類別數是80時候,YOLOv3每個特徵圖通道數是3 * (4+1+80),3表示每個格子輸出3個bbox,4表示未解碼的xywh,1表示未解碼的objness,80表示80個類別未解碼的條件機率。PPYOLOv2使用了iou_aware,每個特徵圖通道數是3 * (1+4+1+80),即每個bbox多出1個ioup屬性。共有258個通道,但是前3個通道才是每個bbox的ioup,後255個通道和YOLOv3的排列一樣。

透過閱讀IouAwareLoss的程式碼,ioup使用F。binary_cross_entropy_with_logits()訓練,解碼時需要用sigmoid()啟用,使用當前預測框和它所學習的gt的iou作為監督資訊,所以ioup其實預測的是當前預測框和它所學習的gt的iou。

所以,當然是希望ioup越大越好。

在mmdet(ppdet)中,用了1條曲線救國的道路對輸出解碼:

# mmdet/models/heads/yolov3_head。py

。。。

if

self。iou_aware:

na = len(self。anchors[i])

ioup, x = out[:, 0:na, :, :], out[:, na:, :, :]

b, c, h, w = x。shape

no = c // na

x = x。reshape((b, na, no, h * w))

ioup = ioup。reshape((b, na, 1, h * w))

obj = x[:, :, 4:5, :]

ioup = torch。sigmoid(ioup)

obj = torch。sigmoid(obj)

obj_t = (obj**(1 - self。iou_aware_factor)) * (

ioup**self。iou_aware_factor)

obj_t = _de_sigmoid(obj_t)

loc_t = x[:, :, :4, :]

cls_t = x[:, :, 5:, :]

y_t = torch。cat([loc_t, obj_t, cls_t], 2)

out = y_t。reshape((b, c, h, w))

box, score = paddle_yolo_box(out, self。_anchors[self。anchor_masks[i]], self。downsample[i],

self。num_classes, self。scale_x_y, im_size, self。clip_bbox,

conf_thresh=self。nms_cfg[

‘score_threshold’

])

即分別對ioup和obj進行sigmoid啟用,再obj_t = (obj ** (1 - self。iou_aware_factor)) * (ioup ** self。iou_aware_factor)作為新的obj,新的obj經過sigmoid的反函式還原成未接碼狀態,未接碼的新obj貼回x中。

最後out的通道數是255,只要像原版YOLOv3那樣解碼out就行了。

這麼做的原因是paddle_yolo_box()的作用是對原版YOLOv3的輸出進行解碼,充分利用paddle_yolo_box()的話就不用自己寫解碼的程式碼。

所以就走了曲線救國的道路。

從中我們可以得到一些資訊,ioup只不過是和obj經過表示式obj_t = (obj ** (1 - self。iou_aware_factor)) * (ioup ** self。iou_aware_factor)得到新的obj,其餘只要像YOLOv3一樣解碼就ok了!

所以在ncnn中,我這樣實現PPYOLOv2的解碼:

// examples/test2_06_ppyolo_ncnn。cpp

。。。

class PPYOLODecodeMatrixNMS : public ncnn::Layer

{

public:

PPYOLODecodeMatrixNMS

()

{

// miemie2013:

if

num of input tensors > 1 or num of output tensors > 1, you must

set

one_blob_only =

false

// And ncnn will use forward(const std::vector& bottom_blobs, std::vector& top_blobs, const Option& opt) method

// or forward_inplace(std::vector& bottom_top_blobs, const Option& opt) method

one_blob_only =

false

support_inplace =

false

}

virtual int load_param(const ncnn::ParamDict& pd)

{

num_classes = pd。get(0, 80);

anchors = pd。get(1, ncnn::Mat());

strides = pd。get(2, ncnn::Mat());

scale_x_y = pd。get(3, 1。f);

iou_aware_factor = pd。get(4, 0。5f);

score_threshold = pd。get(5, 0。1f);

anchor_per_stride = pd。get(6, 3);

post_threshold = pd。get(7, 0。1f);

nms_top_k = pd。get(8, 500);

keep_top_k = pd。get(9, 100);

kernel = pd。get(10, 0);

gaussian_sigma = pd。get(11, 2。f);

return

0;

}

virtual int forward(const std::vector& bottom_blobs, std::vector& top_blobs, const ncnn::Option& opt) const

{

const ncnn::Mat& bottom_blob = bottom_blobs[0];

const int tensor_num = bottom_blobs。size() - 1;

const size_t elemsize = bottom_blob。elemsize;

const ncnn::Mat& im_scale = bottom_blobs[tensor_num];

const

float

scale_x = im_scale[0];

const

float

scale_y = im_scale[1];

int out_num = 0;

for

(size_t b = 0; b < tensor_num; b++)

{

const ncnn::Mat& tensor = bottom_blobs[b];

const int w = tensor。w;

const int h = tensor。h;

out_num += anchor_per_stride * h * w;

}

ncnn::Mat bboxes;

bboxes。create(4 * out_num, elemsize, opt。blob_allocator);

if

(bboxes。empty())

return

-100;

ncnn::Mat scores;

scores。create(num_classes * out_num, elemsize, opt。blob_allocator);

if

(scores。empty())

return

-100;

float

* bboxes_ptr = bboxes;

float

* scores_ptr = scores;

// decode

for

(size_t b = 0; b < tensor_num; b++)

{

const ncnn::Mat& tensor = bottom_blobs[b];

const int w = tensor。w;

const int h = tensor。h;

const int c = tensor。c;

const bool use_iou_aware = (c == anchor_per_stride * (num_classes + 6));

const int channel_stride = use_iou_aware ? (c / anchor_per_stride) - 1 : (c / anchor_per_stride);

const int cx_pos = use_iou_aware ? anchor_per_stride : 0;

const int cy_pos = use_iou_aware ? anchor_per_stride + 1 : 1;

const int w_pos = use_iou_aware ? anchor_per_stride + 2 : 2;

const int h_pos = use_iou_aware ? anchor_per_stride + 3 : 3;

const int obj_pos = use_iou_aware ? anchor_per_stride + 4 : 4;

const int cls_pos = use_iou_aware ? anchor_per_stride + 5 : 5;

float

stride = strides[b];

#pragma omp parallel for num_threads(opt。num_threads)

for

(int i = 0; i < h; i++)

{

for

(int j = 0; j < w; j++)

{

for

(int k = 0; k < anchor_per_stride; k++)

{

float

obj = tensor。channel(obj_pos + k * channel_stride)。row(i)[j];

obj = static_cast<

float

>(1。f / (1。f + expf(-obj)));

if

(use_iou_aware)

{

float

ioup = tensor。channel(k)。row(i)[j];

ioup = static_cast<

float

>(1。f / (1。f + expf(-ioup)));

obj = static_cast<

float

>(pow(obj, 1。f - iou_aware_factor) * pow(ioup, iou_aware_factor));

}

if

(obj > score_threshold)

{

// Grid Sensitive

float

cx = static_cast<

float

>(scale_x_y / (1。f + expf(-tensor。channel(cx_pos + k * channel_stride)。row(i)[j])) + j - (scale_x_y - 1。f) * 0。5f);

float

cy = static_cast<

float

>(scale_x_y / (1。f + expf(-tensor。channel(cy_pos + k * channel_stride)。row(i)[j])) + i - (scale_x_y - 1。f) * 0。5f);

cx *= stride;

cy *= stride;

float

dw = static_cast<

float

>(expf(tensor。channel(w_pos + k * channel_stride)。row(i)[j]) * anchors[(b * anchor_per_stride + k) * 2]);

float

dh = static_cast<

float

>(expf(tensor。channel(h_pos + k * channel_stride)。row(i)[j]) * anchors[(b * anchor_per_stride + k) * 2 + 1]);

float

x0 = cx - dw * 0。5f;

float

y0 = cy - dh * 0。5f;

float

x1 = cx + dw * 0。5f;

float

y1 = cy + dh * 0。5f;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4] = x0 / scale_x;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 1] = y0 / scale_y;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 2] = x1 / scale_x;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 3] = y1 / scale_y;

for

(int r = 0; r < num_classes; r++)

{

float

score = static_cast<

float

>(obj / (1。f + expf(-tensor。channel(cls_pos + k * channel_stride + r)。row(i)[j])));

scores_ptr[((i * w + j) * anchor_per_stride + k) * num_classes + r] = score;

}

}

else

{

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4] = 0。f;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 1] = 0。f;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 2] = 1。f;

bboxes_ptr[((i * w + j) * anchor_per_stride + k) * 4 + 3] = 1。f;

for

(int r = 0; r < num_classes; r++)

{

scores_ptr[((i * w + j) * anchor_per_stride + k) * num_classes + r] = -1。f;

}

}

}

}

}

bboxes_ptr += h * w * anchor_per_stride * 4;

scores_ptr += h * w * anchor_per_stride * num_classes;

}

。。。

只要在obj那裡動手腳,其餘像YOLOv3那樣解碼就行了,而且,只對obj > score_threshold的bbox解碼,其餘bbox敷衍處理,提升後處理速度。

Grid Sensitive的提出是為了解決訓練過程中gt中心點落在格子線上的問題,它允許解碼後的x、y超出0~1的範圍一點點。

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& bottom_blobs, std::vector& top_blobs, const ncnn::Option& opt) const

{

。。。

// keep bbox whose score > score_threshold

std::vector 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 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值為:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

如果某個預測框與比它分高的同類預測框有較高的iou,它應該減去更多的分,這該怎麼實現呢?

一個比較簡單的做法是對矩陣1-decay_iou每一列求最小值,即對矩陣:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

每一列求最小,得到衰減係數向量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的值是:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

逐列取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引數。會彈出一個這樣的視窗展示預測結果:

ncnn+PPYOLOv2首次結合!全網最詳細程式碼解讀來了

test2_06_ppyolo_ncnn的原始碼位於ncnn_ppyolov2倉庫的examples/test2_06_ppyolo_ncnn。cpp。

PPYOLOv2和PPYOLO演算法目前在Linux和Windows平臺均已成功預測。