用opencv的DNN模塊做Yolov5目標檢測(純干貨,源碼已上傳Github)


https://mp.weixin.qq.com/s/xceYQZ3xEG1tT7xL-dFuYQ

最近在微信公眾號里看到多篇講解yolov5在openvino部署做目標檢測文章,但是沒看到過用opencv的dnn模塊做yolov5目標檢測的。

 

圖片

長按掃描二維碼關注我們

 

圖片

最近在微信公眾號里看到多篇講解yolov5在openvino部署做目標檢測文章,但是沒看到過用opencv的dnn模塊做yolov5目標檢測的。於是,我就想着編寫一套用opencv的dnn模塊做yolov5目標檢測的程序。在編寫這套程序時,遇到的bug和解決辦法,在這篇文章里講述一下。

圖片

在yolov5之前的yolov3和yolov4的官方代碼都是基於darknet框架的實現的,因此opencv的dnn模塊做目標檢測時,讀取的是.cfg和.weight文件,那時候編寫程序很順暢,沒有遇到bug。但是yolov5的官方代碼(https://github.com/ultralytics/yolov5)是基於pytorch框架實現的,但是opencv的dnn模塊不支持讀取pytorch的訓練模型文件的。如果想要把pytorch的訓練模型.pth文件加載到opencv的dnn模塊里,需要先把pytorch的訓練模型.pth文件轉換到.onnx文件,然后才能載入到opencv的dnn模塊里。

因此,用opencv的dnn模塊做yolov5目標檢測的程序,包含兩個步驟:(1).把pytorch的訓練模型.pth文件轉換到.onnx文件。(2).opencv的dnn模塊讀取.onnx文件做前向計算。

(1).把pytorch的訓練模型.pth文件轉換到.onnx文件

在做這一步時,我得吐槽一下官方代碼:https://github.com/ultralytics/yolov5,這套程序里的代碼混亂,在pytorch里,通常是在.py文件里定義網絡結構的,但是官方代碼是在.yaml文件定義網絡結構,利用pytorch動態圖特性,解析.yaml文件自動生成網絡結構。在.yaml文件里有depth_multiple和width_multiple,它是控制網絡的深度和寬度的參數。這么做的好處是能夠靈活的配置網絡結構,但是不利於理解網絡結構,假如你想設斷點查看某一層的參數和輸出數值,那就沒辦法了。因此,在我編寫的轉換到.onnx文件的程序里,網絡結構是在.py文件里定義的。其次,在官方代碼里,還有一個奇葩的地方,那就是.pth文件。起初,我下載官方代碼到本地運行時,torch.load讀取.pth文件總是出錯,后來把pytorch升級到1.7,就讀取成功了。可以看到版本兼容性不好,這是它的一個不足之處。設斷點查看讀取的.pth文件里的內容,可以看到ultralytics的.pt文件里既存儲有模型參數,也存儲有網絡結構,還儲存了一些超參數,包括anchors,stride等等的。第一次見到有這種操作的,通常情況下,.pth文件里只存儲了訓練模型參數的。

查看models\yolo.py里的Detect類,在構造函數里,有這么兩行代碼:

圖片

我嘗試過把這兩行代碼改成self.anchors = a 和 self.anchor_grid = a.clone().view(self.nl, 1, -1, 1, 1, 2),程序依然能正常運行,但是torch.save保存模型文件后,可以看到.pth文件里沒有存儲anchors和anchor_grid了,在百度搜索register_buffer,解釋是:pytorch中register_buffer模型保存和加載的時候可以寫入和讀出。

在這兩行代碼的下一行:

圖片

它的作用是做特征圖的輸出通道對齊,通過1x1卷積把三種尺度特征圖的輸出通道都調整到 num_anchors*(num_classes+5)。

閱讀Detect類的forward函數代碼,可以看出它的作用是根據偏移公式計算出預測框的中心坐標和高寬,這里需要注意的是,計算高和寬的代碼:

pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i]

沒有采用exp操作,而是直接乘上anchors[i],這是yolov5與yolov3v4的一個最大區別(還有一個區別就是在訓練階段的loss函數里,yolov5采用鄰域的正樣本anchor匹配策略,增加了正樣本。其它的是一些小區別,比如yolov5的第一個模塊采用FOCUS把輸入數據2倍下采樣切分成4份,在channel維度進行拼接,然后進行卷積操作,yolov5的激活函數沒有使用Mish)。

現在可以明白Detect類的作用是計算預測框的中心坐標和高寬,簡單來說就是生成proposal,作為后續NMS的輸入,進而輸出最終的檢測框。我覺得在Detect類里定義的1x1卷積是不恰當的,應該把它定義在Detect類的外面,緊鄰着Detect類之前定義1x1卷積。

在官方代碼里,有轉換到onnx文件的程序:

python models/export.py --weights yolov5s.pt --img 640 --batch 1

在pytorch1.7版本里,程序是能正常運行生成onnx文件的。觀察export.py里的代碼,在執行torch.onnx.export之前,有這么一段代碼:

圖片

注意其中的for循環,我試驗過注釋掉它,重新運行就會出錯,打印出的錯誤如下:

圖片

由此可見,這段for循環代碼是必需的。SiLU其實就是swish激活函數,而在onnx模型里是不直接支持swish算子的,因此在轉換生成onnx文件時,SiLU激活函數不能直接使用nn.Module里提供的接口,而需要自定義實現它。

(2).opencv的dnn模塊讀取.onnx文件做前向計算

在生成.onnx文件后,就可以用opencv的dnn模塊里的cv2.dnn.readNet讀取它。然而,在讀取時,出現了如下錯誤:

圖片

我在百度搜索這個問題的解決辦法,看到一篇知乎文章(Pytorch轉ONNX-實戰篇2(實戰踩坑總結) - 知乎),文章里講述的第一條:

圖片

於是查看yolov5的代碼,在common.py文件的Focus類,torch.cat的輸入里有4次切片操作,代碼如下:

圖片

那么現在需要更換索引式的切片操作,觀察到注釋的Contract類,它就是用view和permute函數完成切片操作的,於是修改代碼如下:

圖片

其次,在models\yolo.py里的Detect類里,也有切片操作,代碼如下:

圖片

前面說過,Detect類的作用是計算預測框的中心坐標和高寬,生成proposal,這個是屬於后處理的,因此不需要把它寫入到onnx文件里。

總結一下,按照上面的截圖代碼,修改Focus類,把Detect類里面的1x1卷積定義在緊鄰着Detect類之前的外面,然后去掉Detect類,組成新的model,作為torch.onnx.export的輸入,

torch.onnx.export(model, inputs, output_onnx, verbose=False, opset_version=12, input_names=['images'], output_names=['out0', 'out1', 'out2'])

最后生成的onnx文件,opencv的dnn模塊就能成功讀取了,接下來對照Detect類里的forward函數,用python或者C++編寫計算預測框的中心坐標和高寬的功能。

周末這兩天,我在win10+cpu機器里編寫了用opencv的dnn模塊做yolov5目標檢測的程序,包含Python和C++兩個版本的。程序都調試通過了,運行結果也是正確的。我把這套代碼發布在github上,地址是:

https://github.com/hpc203/yolov5-dnn-cpp-python

后處理模塊,python版本用numpy array實現的,C++版本的用vector和數組實現的,整套程序只依賴opencv庫(opencv4版本以上的)就能正常運行,徹底擺脫對深度學習框架pytorch,tensorflow,caffe,mxnet等等的依賴。用openvino作目標檢測,需要把onnx文件轉換到.bin和.xml文件,相比於用dnn模塊加載onnx文件做目標檢測是多了一個步驟的。因此,我就想編寫一套用opencv的dnn模塊做yolov5目標檢測的程序,用opencv的dnn模塊做深度學習目標檢測,在win10和ubuntu,在cpu和gpu上都能運行,可見dnn模塊的通用性更好,很接地氣。

生成yolov5s_param.pth 的步驟,首先下載https://github.com/ultralytics/yolov5 的源碼到本地,在yolov5-master主目錄(注意不是我發布的github代碼目錄)里新建一個.py文件,把下面的代碼復制到.py文件里

import torchfrom collections import OrderedDictimport pickleimport osdevice = 'cuda' if torch.cuda.is_available() else 'cpu'if __name__=='__main__': choices = ['yolov5s', 'yolov5l', 'yolov5m', 'yolov5x'] modelfile = choices[0]+'.pt' utl_model = torch.load(modelfile, map_location=device) utl_param = utl_model['model'].model torch.save(utl_param.state_dict(), os.path.splitext(modelfile)[0]+'_param.pth') own_state = utl_param.state_dict() print(len(own_state)) numpy_param = OrderedDict() for name in own_state: numpy_param[name] = own_state[name].data.cpu().numpy() print(len(numpy_param)) with open(os.path.splitext(modelfile)[0]+'_numpy_param.pkl', 'wb') as fw:        pickle.dump(numpy_param, fw)

運行這個.py文件,這時候就可以生成yolov5s_param.pth文件。之所以要進行這一步,我在上面講到過:ultralytics的.pt文件里既存儲有模型參數,也存儲有網絡結構,還儲存了一些超參數,包括anchors,stride等等的。torch.load加載ultralytics的官方.pt文件,也就是utl_model = torch.load(modelfile, map_location=device)這行代碼,在這行代碼后設斷點查看utl_model里的內容,截圖如下

圖片

可以看到utl_model里含有既存儲有模型參數,也存儲有網絡結構,還儲存了一些超參數等等的,這會嚴重影響轉onnx文件。此外,我還發現,如果pytorch的版本低於1.7,那么在torch.load加載.pt文件時就會出錯的。

因此在程序里,我把模型參數轉換到cpu.numpy形式的,最后保存在.pkl文件里。這時候在win10系統cpu環境里,即使你的電腦沒有安裝pytorch,也能通過python程序訪問到模型參數。

pytorch轉onnx常見坑:

1. onnx只能輸出靜態圖,因此不支持if-else分支。一次只能走一個分支。如果代碼中有if-else語句,需要改寫。
2. onnx不支持步長為2的切片。例如a[::2,::2]
3. onnx不支持對切片對象賦值。例如a[0,:,:,:]=b, 可以用torch.cat改寫
4. onnx里面的resize要求output shape必須為常量。可以用以下代碼解決:

if isinstance(size, torch.Size):
    size = tuple(int(x) for x in size)

此外,在torch.onnx.export(model, inputs, output_onnx)的輸入參數model里,應該只包含網絡結構,也就是說model里只含有nn.Conv2d, nn.MaxPool2d, nn.BatchNorm2d, F.relu等等的這些算子組件,而不應該含有后處理模塊的。圖像預處理和后處理模塊需要自己使用C++或者Python編程實現。

在明白了這些之后,在轉換生成onnx文件,你需要執行兩個步驟,第一步把原始訓練模型.pt文件里的參數保存到新的.pth文件里,第二步編寫yolov5.py文件,把yolov5的往來結構定義在.py文件里,此時需要注意網絡結構里不能包含切片對象賦值操作,F.interpolate里的size參數需要加int強制轉換。在執行完這兩步之后才能生成一個opencv能成功讀取並且做前向推理的onnx文件。

不過,最近我發現在yolov5-pytorch程序里,其實可以直接把原始訓練模型.pt文件轉換生成onnx文件的,而且我在一個yolov5檢測人臉+關鍵點的程序里實驗成功了。

        這套程序發布在github上,地址是 :

https://github.com/hpc203/yolov5-face-landmarks-opencv

https://github.com/hpc203/yolov5-face-landmarks-opencv-v2

這套程序只依賴opencv庫就可以運行yolov5檢測人臉+關鍵點,程序依然是包含C++和Python兩個版本的,這套程序里還有一個轉換生成onnx文件的python程序文件。只需運行這一個.py文件就可以生成onnx文件,而不需要之前講的那樣執行兩個步驟,這樣大大簡化了生成onnx文件的流程,使用方法可以閱讀程序里的README文檔。

在這個新的轉換生成onnx文件的程序里,需要重新定義yolov5網絡結構,主要是修改第一個模塊Focus,用Contract類替換索引式的切片操作,在最后一個模塊Detect類里,只保留三個1x1卷積,剩下的make_grid和decode屬於后處理,不能包含在網絡結構里,代碼截圖如下

圖片

如果要轉換生成onnx文件,需要設置export = True,這時候Detect模塊的forward就只進行1x1卷積,這時的網絡結構就可以作為torch.onnx.export(model, inputs, output_onnx)的輸入參數model。不過由於ultralytics的yolov5代碼倉庫幾乎每天都在更新,因此你現在看到的ultralytics的yolov5里的Detect類很有可能不是這么寫的,那這是需要你手動修改程序,然后再運行。

        看到最近曠視發布的anchor-free系列的YOLOX,而在github開源的代碼里,並沒有使用opencv部署的程序。因此,我就編寫了一套使用OpenCV部署YOLOX的程序,支持YOLOX-S、YOLOX-M、YOLOX-L、YOLOX-X、YOLOX-Darknet53五種結構,包含C++和Python兩種版本的程序實現。在今天我在github發布了這套程序,地址是

 https://github.com/hpc203/yolox-opencv-dnn

在曠視發布的YOLOX代碼里,提供了在COCO數據集上訓練出來的.pth模型文件,並且也提供了導出onnx模型的export_onnx.py文件,起初我運行export_onnx.py生成onnx文件之后Opencv讀取onnx文件失敗了,報錯原因跟文章最開始的第(2)節里的一樣,這說明在YOLOX的網絡結構里有切片操作,經過搜索后,在 yolox\models\network_blocks.py 里有個Focus類,它跟YOLOv5里的Focus是一樣的,都是把輸入張量切分成4份,然后concat+conv。這時按照第(2)節里講述的解決辦法,修改Focus類,重新運行export_onnx.py生成onnx文件,Opencv讀取onnx文件就不會再出錯了。

        在github發布了一套使用OpenCV部署Yolo-FastestV2的程序,依然是包含C++和Python兩種版本的程序實現。地址是

https://github.com/hpc203/yolo-fastestv2-opencv

經過運行,體驗到這個Yolo-FastestV2的速度確實很快,而且onnx文件只有957kb大小,不超過1M。在官方代碼https://github.com/dog-qiuqiu/Yolo-FastestV2里,學習它的網絡結構。設斷點調試,查看中間變量可以看到,在model/detector.py,網絡輸出了6個張量

圖片

 它們的形狀分別是

torch.Size([1, 12, 22, 22])
torch.Size([1, 3, 22, 22])
torch.Size([1, 80, 22, 22])
torch.Size([1, 12, 11, 11])
torch.Size([1, 3, 11, 11])
torch.Size([1, 80, 11, 11])

結合配置文件data/coco.data,可以看到模型輸入是352x352的圖片,而輸出有22x22和11x11這兩種尺度的特征圖,這說明Yolo-FastestV2的輸出只有縮放16倍和縮放32倍這兩種尺度的特征圖,比yolov3,v4,v5系列的都要少一個尺度特征圖。其次在配置文件data/coco.data還可以看到anchor一共有6個,分別給兩個尺度特征圖里的網格點分配3個。觀察輸出的6個張量的形狀信息,很明顯前3個張量是22x22尺度特征圖的檢測框坐標回歸量bbox_reg,檢測框目標置信度obj_conf,檢測框類別置信度cls_conf。由於給每個網格點分配3個anchor,檢測框坐標包含(center_x, center_y, width, height),因此維數是4*3=12,這也就明白了bbox_reg的第1個維度是12,obj_conf的第1個維度是3,而COCO數據集有80類,那么cls_conf的第1個維度應該是3*80=240,但是在上面調試信息里顯示的是80類。繼續設斷點調試代碼,在utils/utils.py里,第326行有這么一行代碼

圖片

類別置信度復制了3份,結合這個后處理代碼,可以看出類別置信度對3個anchor是共享的。

        在觀察出Yolo-FastestV2的這些特性之后,可以理解為何它的速度快和模型文件小的原因了。主要是因為它的輸入圖片尺寸比傳統yolov3,v4,v5系列的要小,它的輸出特征圖尺寸個數,也比傳統yolo的要少,最后對網格點上的3個anchor是共享類別置信度的,這也減少了特種通道數。

        8月29日,我在github發布了一套使用OpenCV部署全景駕駛感知網絡YOLOP,可同時處理交通目標檢測、可駕駛區域分割、車道線檢測,三項視覺感知任務,依然是包含C++和Python兩種版本的程序實現。地址是:

https://github.com/hpc203/YOLOP-opencv-dnn

在這里我講一下生成onnx文件需要注意的地方,YOLOP的官方代碼地址是 https://github.com/hustvl/YOLOP  ,它是華中科技大學視覺團隊發布的,它的代碼是使用pytorch作為深度學習框架。仔細閱讀和運行調試他的代碼,可以看出,它的代碼是在ultralytics的yolov5里修改的,添加了可行駛區域分割和車道線分割這兩個分割頭,在bdd100k數據集上的訓練的,不過YOLOP的檢測類別只保留了bdd100k數據集里的車輛這一個類別。生成onnx文件,第一步是把我發布的代碼里的export_onnx.py拷貝到https://github.com/hustvl/YOLOP的主目錄里。第二步,在https://github.com/hustvl/YOLOP的主目錄里,打開lib/models/common.py,首先修改Focus類,原始的Focus類的forward函數里是由切片操作的,那么這時按照第(2)節里講述的解決辦法,修改Focus類,示例代碼如下

class Contract(nn.Module): # Contract width-height into channels, i.e. x(1,64,80,80) to x(1,256,40,40) def __init__(self, gain=2): super().__init__() self.gain = gain def forward(self, x): N, C, H, W = x.size() # assert (H / s == 0) and (W / s == 0), 'Indivisible gain' s = self.gain x = x.view(N, C, H // s, s, W // s, s) # x(1,64,40,2,40,2) x = x.permute(0, 3, 5, 1, 2, 4).contiguous() # x(1,2,2,64,40,40) return x.view(N, C * s * s, H // s, W // s) # x(1,256,40,40)class Focus(nn.Module): # Focus wh information into c-space # slice concat conv 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) self.contract = Contract(gain=2) 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)) return self.conv(self.contract(x))

接下來修改Detect類里的forward函數,示例代碼如下

def forward(self, x): if not torch.onnx.is_in_onnx_export(): z = [] # inference output for i in range(self.nl): x[i] = self.m[i](x[i]) # conv # print(str(i)+str(x[i].shape)) bs, _, ny, nx = x[i].shape # x(bs,255,w,w) to x(bs,3,w,w,85) x[i] = x[i].view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous() # print(str(i)+str(x[i].shape)) if not self.training: # inference if self.grid[i].shape[2:4] != x[i].shape[2:4]: self.grid[i] = self._make_grid(nx, ny).to(x[i].device) y = x[i].sigmoid() # print("**") # print(y.shape) #[1, 3, w, h, 85] # print(self.grid[i].shape) #[1, 3, w, h, 2] y[..., 0:2] = (y[..., 0:2] * 2. - 0.5 + self.grid[i].to(x[i].device)) * self.stride[i] # xy y[..., 2:4] = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i] # wh """print("**") print(y.shape) #[1, 3, w, h, 85] print(y.view(bs, -1, self.no).shape) #[1, 3*w*h, 85]""" z.append(y.view(bs, -1, self.no)) return x if self.training else (torch.cat(z, 1), x) else: for i in range(self.nl): x[i] = self.m[i](x[i]) # conv # print(str(i)+str(x[i].shape)) bs, _, ny, nx = x[i].shape # x(bs,255,w,w) to x(bs,3,w,w,85) x[i] = x[i].view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous() x[i] = torch.sigmoid(x[i]) x[i] = x[i].view(-1, self.no)        return torch.cat(x, dim=0)

修改完之后,運行export_onnx.py就能生成onnx文件,並且opencv讀取正常的。

        9月18日,我在github上發布了一套使用ONNXRuntime部署anchor-free系列的YOLOR,依然是包含C++和Python兩種版本的程序。起初我是想使用OpenCV部署的,但是opencv讀取onnx文件總是出錯,於是我換用ONNXRuntime部署。地址是:

https://github.com/hpc203/yolor-onnxruntime


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM