詳細記錄u版YOLOv5目標檢測ncnn實現(xiàn)

極市導讀
?本文作者用 yolov5 作為例子,介紹了如何用 ncnn 實現(xiàn)出完整形態(tài)的 yolov5。完整闡述了如何用自定義層以及動態(tài)輸入的注意事項,給大家作為一個參考。?>>加入極市CV技術交流群,走在計算機視覺的最前沿
0x0 u版YOLOv5
眾所周知,原版YOLO系列是 darknet 框架訓練的,而廣泛使用的是 YOLOv4 作者 AlexeyAB 的版本
AlexeyAB 首字母是a,于是也被叫做 a版,darknet模型可以用 ncnn 自帶的 darknet2ncnn 無痛轉換,步驟比較簡單,因此本文不提
https://github.com/AlexeyAB/darknet
Ultralytics LLC 再次改進出更快更好的 YOLOv5,并且之前也有獨立實現(xiàn)的 pytorch yolov3
Ultralytics 首字母是u,于是也被叫做 u版。pytorch 大法好?。ㄔ?jīng)我以為u版的意思是能放在u盤里跑的yolo(((
https://github.com/ultralytics/yolov5
0x1 緣由
pytorch yolov5 轉 ncnn 推理,搜索下 github 便能找到好幾個,zhihu 也有文章
ncnn example 里沒有 yolov5.cpp,本打算借鑒下社區(qū)成果,結果仔細看了代碼發(fā)現(xiàn)這些實現(xiàn)都缺少了 yolov5 Focus 模塊和動態(tài)尺寸輸入,前者導致檢測精度差一截,后者導致推理速度差一截,這樣子放進官方repo當成參考代碼是不行的
這里就用 yolov5 作為例子,介紹下如何用 ncnn 實現(xiàn)出完整形態(tài)的 yolov5
0x2 pytorch測試和導出onnx
按照 yolov5 README 指引,下載 yolov5s.pt,調(diào)用 detect.py 看看檢測效果
$ python detect.py --source inference/images --weights yolov5s.pt --conf 0.25
效果沒有問題,繼續(xù)按照 README 指引,導出 onnx,并用 onnx-simplifer 簡化模型,到此都很順利
https://github.com/ultralytics/yolov5/issues/251github.com
$ python models/export.py --weights yolov5s.pt --img 640 --batch 1
$ python -m onnxsim yolov5s.onnx yolov5s-sim.onnx0x3 轉換和實現(xiàn)focus模塊
$ onnx2ncnn yolov5s-sim.onnx yolov5s.param yolov5s.bin轉換為 ncnn 模型,會輸出很多 Unsupported slice step,這是focus模塊轉換的報錯
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !
Unsupported slice step !好多人遇到這種情況,便不知所措,這些警告表明focus模塊這里要手工修復下
打開 yolov5/models/common.py 看看focus在做些什么
class Focus(nn.Module):
# Focus wh information into c-space
def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True): # ch_in, ch_out, kernel, stride, padding, groups
super(Focus, self).__init__()
self.conv = Conv(c1 * 4, c2, k, s, p, g, act)
def forward(self, x): # x(b,c,w,h) -> y(b,4c,w/2,h/2)
return self.conv(torch.cat([x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]], 1))這其實是一次 col-major space2depth 操作,pytorch 似乎并沒有對應上層api實現(xiàn)(反向的 depth2space 可以用 nn.PixelShuffle),yolov5 用 stride slice 再 concat 方式實現(xiàn),實乃不得已而為之的騷操作
用netron工具打開param,找到對應focus的部分

把這堆騷操作用個自定義op YoloV5Focus代替掉,修改param

找準輸入輸出 blob 名字,用一個自定義層 YoloV5Focus 連接 param 開頭第二行,layer_count 要對應修改,但 blob_count 只需確保大于等于實際數(shù)量即可 修改后使用 ncnnoptimize 工具,自動修正為實際 blob_count

替換后用 ncnnoptimize 過一遍模型,順便轉為 fp16 存儲減小模型體積
$ ncnnoptimize yolov5s.param yolov5s.bin yolov5s-opt.param yolov5s-opt.bin 65536接下來要實現(xiàn)這個自定義op YoloV5Focus,wiki上的步驟比較繁多
https://github.com/Tencent/ncnn/wiki/how-to-implement-custom-layer-step-by-stepgithub.com
針對 focus 這樣,沒有權重,也無所謂參數(shù)加載的 op,繼承 ncnn::Layer 實現(xiàn) forward 就可以用,注意要用 DEFINE_LAYER_CREATOR 宏定義 YoloV5Focus_layer_creator
#include "layer.h"
class YoloV5Focus : public ncnn::Layer
{
public:
YoloV5Focus()
{
one_blob_only = true;
}
virtual int forward(const ncnn::Mat& bottom_blob, ncnn::Mat& top_blob, const ncnn::Option& opt) const
{
int w = bottom_blob.w;
int h = bottom_blob.h;
int channels = bottom_blob.c;
int outw = w / 2;
int outh = h / 2;
int outc = channels * 4;
top_blob.create(outw, outh, outc, 4u, 1, opt.blob_allocator);
if (top_blob.empty())
return -100;
#pragma omp parallel for num_threads(opt.num_threads)
for (int p = 0; p < outc; p++)
{
const float* ptr = bottom_blob.channel(p % channels).row((p / channels) % 2) + ((p / channels) / 2);
float* outptr = top_blob.channel(p);
for (int i = 0; i < outh; i++)
{
for (int j = 0; j < outw; j++)
{
*outptr = *ptr;
outptr += 1;
ptr += 2;
}
ptr += w;
}
}
return 0;
}
};
DEFINE_LAYER_CREATOR(YoloV5Focus)加載模型前先注冊 YoloV5Focus,否則會報錯找不到 YoloV5Focus
ncnn::Net yolov5;
yolov5.opt.use_vulkan_compute = true;
// yolov5.opt.use_bf16_storage = true;
yolov5.register_custom_layer("YoloV5Focus", YoloV5Focus_layer_creator);
yolov5.load_param("yolov5s-opt.param");
yolov5.load_model("yolov5s-opt.bin");0x4 u版YOLOv5后處理
其實工程量最大的倒是后處理的實現(xiàn),u版的后處理和a版本是不一樣的,ncnn內(nèi)置的YoloV3DetectionOuptut是對著a版實現(xiàn)的,不能直接拿來接住,需要自己實現(xiàn)
anchor信息是在 yolov5/models/yolov5s.yaml
pytorch的后處理在 yolov5/models/yolo.py Detect類 forward函數(shù),對著改寫成 cpp
netron里找到模型的3個輸出blob,分別對應于 stride 8/16/32 的輸出
輸出shape可知
w=85,對應于bbox的dx,dy,dw,dh,bbox置信度,80種分類的置信度 h=6400,對應于整個圖片里全部anchor的xy,這個1600是stride=8的情況,輸入640的圖片,寬高劃分為640/8=80塊,80x80即6400 c=3,對應于三種anchor
sort nms 可以借鑒 YoloV3DetectionOuptut
0x5 動態(tài)尺寸推理
u版yolov5 是支持動態(tài)尺寸推理的
靜態(tài)尺寸:按長邊縮放到 640xH 或 Wx640,padding 到 640x640 再檢測,如果 H/W 比較小,會在 padding 上浪費大量運算 動態(tài)尺寸:按長邊縮放到 640xH 或 Wx640,padding 到 640xH2 或 W2x640 再檢測,其中 H2/W2 是 H/W 向上取32倍數(shù),計算量少,速度更快
ncnn天然支持動態(tài)尺寸輸入,無需reshape或重新初始化,給多少就算多少
如果直接跑小圖,會發(fā)現(xiàn)檢測框密密麻麻布滿整個畫面,或者根本檢測不到東西,就像這樣

問題出在最后 Reshape 層把輸出grid數(shù)寫死了,根據(jù) ncnn Reshape 參數(shù)含義,把寫死的數(shù)量改為 -1 便可以自適應

后處理部分也不可寫死 sqrt(num_grid),要根據(jù)圖片寬高和 stride 自適應
const int num_grid = feat_blob.h;
int num_grid_x;
int num_grid_y;
if (in_pad.w > in_pad.h)
{
num_grid_x = in_pad.w / stride;
num_grid_y = num_grid / num_grid_x;
}
else
{
num_grid_y = in_pad.h / stride;
num_grid_x = num_grid / num_grid_y;
}ncnn實現(xiàn)代碼和轉好的模型已上傳到github
0x6 android例子
https://github.com/nihui/ncnn-android-yolov5github.com

根據(jù) README 步驟就能編譯,yolov5 小目標檢測挺厲害的
0x7 總結
沒啥好總結的,寫個文章,實踐下如何用自定義層,講講動態(tài)輸入的注意事項,將來有需要可以參考著來
雖然沒有這教程,也能把 example 的 yolov5 跑起來,但里頭的過程和細節(jié)就看不到了,授人魚不如授人漁
ncnn就要1w star啦(小聲
https://github.com/Tencent/ncnngithub.com
推薦閱讀


