樹莓派攝像頭 C++ OpenCV YoloV3 實現實時目標檢測
本文將實現樹莓派攝像頭 C++ OpenCV YoloV3 實現實時目標檢測,我們會先實現樹莓派對視頻文件的逐幀檢測來驗證算法流程,成功后,再接入攝像頭進行實時目標檢測。
先聲明一下筆者的主要軟硬件配置:
樹莓派4B 4GB內存
CSI 攝像頭
Ubuntu 20.04
OpenCV 的安裝
不多講,參考 Ubuntu 18.04 安裝OpenCV C++ 。
準備YoloV3模型權重文件和視頻文件
模型配置文件和權重、COCO數據集名稱文件
我們先下載作者官方發布的 YoloV3 模型配置文件、權重文件:
wget https://pjreddie.com/media/files/yolov3.weights
wget https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg?raw=true -O ./yolov3.cfg
上面是比較大的網絡,由于我們的樹莓派算力比較一遍,所以建議使用輕量型的網絡 yolov3-tiny:
wget https://pjreddie.com/media/files/yolov3-tiny.weights
wget https://github.com/pjreddie/darknet/blob/master/cfg/yolov3-tiny.cfg?raw=true -O ./yolov3-tiny.cfg
另外,由于模型權重是在 COCO 數據集上進行預訓練的,所以我們還要準備 COCO 的類別名稱文件,方便在模型輸出檢測結果后進行后處理,將類別顯示在檢測結果框上。
wget https://github.com/pjreddie/darknet/blob/master/data/coco.names?raw=true -O ./coco.names
注:如果上面的 github 中的配置文件在命令行下載的比較慢的話,可以直接去 github 網頁復制粘貼下來。
準備視頻文件
我們會先對一個視頻文件進行逐幀檢測來驗證算法的流程,在之后再使用攝像頭進行實時檢測。
我們直接通過 you-get 工具去B站下載視頻文件并改個名:
pip install you-get
you-get https://www.bilibili.com/video/av32184680
rm fileName.cmt.xml
mv fileName.mp4 demo.mp4
如果是 flv 文件,可以用 ffmpeg 轉為 mp4 文件:
ffmpeg -i input.flv output.mp4
視頻文件的檢測
一切準備就緒我們開始先測試一下視頻文件的檢測,我們先講解一遍代碼,在最后會給出整個源碼。
1 初始化參數
YOLOv3算法的預測結果就是邊界框。每一個邊界框都旁隨著一個置信值。第一階段中,全部低于置信度閥值的都會排除掉。 對剩余的邊界框執行非最大抑制算法,以去除重疊的邊界框。非最大抑制由一個參數 nmsThrehold
控制。讀者可以嘗試改變這個數值,觀察輸出的邊界框的改變。 接下來,設置輸入圖片的寬度inpWidth
和高度 inpHeight
。這里設置為416。如果想要更快的速度,可以把寬度和高度設置為320。如果想要更準確的結果,可改變為608。
#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <opencv.hpp>
using namespace std;float confThreshold = 0.5; //置信度閾值
float nmsThreshold = 0.4; //非最大抑制閾值
int inpWidth = 416; //網絡輸入圖片寬度
int inpHeight = 416; //網絡輸入圖片高度
2 讀取模型和COCO類別名
接下來我們讀入COCO 類別名并存入 classes
容器。并加載模型與權重文件 yolov3-tiny.cfg
和 yolov3-tiny.weights
。這里用到的幾個文件就是我們剛才下載好的,讀者需要改為自己的路徑。最后把DNN的后端設置為OpenCV,目標設置CPU。這里我們樹莓派沒有GPU等加速推理硬件,就用CPU,如果有GPU,可改為OpenCL、CUDA等
//將類名存進容器
vector<string> classes; //儲存名字的容器
string classesFile = "./coco.names"; //coco.names包含80種不同的類名
ifstream ifs(classesFile.c_str());
string line;
while(getline(ifs,line))classes.push_back(line);//取得模型的配置和權重文件
cv::String modelConfiguration = "./yolov3-tiny.cfg";
cv::String modelWeights = "./yolov3-tiny.weights";//加載網絡
cv::dnn::Net net = cv::dnn::readNetFromDarknet(modelConfiguration,modelWeights);
net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
net.setPreferableTarget(cv::dnn::DNN_TARGET_OPENCL); // 這里我們樹莓派沒有GPU等加速推理硬件,就用CPU,如果有GPU,可改為OpenCL、CUDA等
3 讀取輸入
這里我們先讀入下載好的視頻文件,一會兒再使用本地攝像頭測試。這里如果是樹莓派外接顯示器的話可以用創建GUI窗口來查看,但是我們通常是命令行SSH連接樹莓派,這時我們就直接將每一幀的檢測結果保存為圖像文件查看:
// 打開視頻文件或者本地攝像頭來讀取輸入
string str, outputFile;
cv::VideoCapture cap("./demo.mp4");
cv::VideoWriter video;
cv::Mat frame,blob;
// 開啟攝像頭
// cv::VideoCapture capture(0);
// 創建窗口
// static const string kWinName = "YoloV3 OpenCV";
// cv::namedWindow(kWinName,cv::WINDOW_AUTOSIZE);
// 非GUI界面不需要創建窗口
4 循環處理每一幀
OpenCV中,輸入到神經網絡的圖像需要以一種叫 bolb 的格式保存。 讀取了輸入圖片或者視頻流的一幀圖像后,這幀圖像需要經過bolbFromImage()
函數處理為神經網絡的輸入類型 bolb。在這個過程中,圖像像素以一個 1/255 的比例因子,被縮放到0到1之間。同時,圖像在不裁剪的情況下,大小調整到 416x416。注意我們沒有降低圖像平均值,因此傳遞 [0,0,0] 到函數的平均值輸入,保持swapRB 參數到默認值1。 輸出的bolb傳遞到網絡,經過網絡正向處理,網絡輸出了所預測到的一個邊界框清單。這些邊界框通過后處理,濾除了低置信值的。我們隨后再詳細的說明后處理的步驟。我們在每一幀的左上方打印出了推斷時間。伴隨著最后的邊界框的完成,圖像保存到硬盤中,之后可以作為圖像輸入或者通過 VideoWriter 作為視頻流輸入。
while(cv::waitKey(1)<0){// 取每幀圖像cap>>frame;// 如果視頻播放完則停止程序if(frame.empty()){break;}// 在dnn中從磁盤加載圖片cv::dnn::blobFromImage(frame,blob,1/255.0,cv::Size(inpWidth,inpHeight));// 設置輸入net.setInput(blob);// 設置輸出層vector<cv::Mat> outs; //儲存識別結果net.forward(outs,getOutputNames(net));// 移除低置信度邊界框postprocess(frame,outs);// 顯示s延時信息并繪制vector<double> layersTimes;double freq = cv::getTickFrequency()/1000;double t=net.getPerfProfile(layersTimes)/freq;string label = cv::format("Infercence time for a frame:%.2f ms",t);cv::putText(frame,label,cv::Point(0,15),cv::FONT_HERSHEY_SIMPLEX,0.5,cv::Scalar(0,255,255));cout << "Frame: " << frame_cnt++ << ", time: " << t << "ms" << endl;// 繪制識別框,在這里如果我們用的是GUI界面,并且剛才創建了窗口的話,可以imshow,否則是命令行SSH連接樹莓派的話就imwrite保存圖像// cv::imshow(kWinName,frame);cv::imwrite("output.jpg",frame);
}
5-1 得到輸出層的名字
第五步我們給出幾個用到的函數的實現。
OpenCV 的網絡類中的前向功能需要結束層,直到它在網絡中運行。因為我們需要運行整個網絡,所以我們需要識別網絡中的最后一層。我們通過使用 getUnconnectedOutLayers()
獲得未連接的輸出層的名字,該層基本就是網絡的最后層。然后我們運行前向網絡,得到輸出,如前面的代碼片段 net.forward(getOutputsNames(net))
。
//從輸出層得到名字
vector<cv::String> getOutputNames(const cv::dnn::Net& net){static vector<cv::String> names;if(names.empty()){//取得輸出層指標vector<int> outLayers = net.getUnconnectedOutLayers();vector<cv::String> layersNames = net.getLayerNames();//取得輸出層名字names.resize(outLayers.size());for(size_t i =0;i<outLayers.size();i++){names[i] = layersNames[outLayers[i]-1];}}return names;
}
5-2 后處理
網絡輸出的每個邊界框都分別由一個包含著類別名字和5個元素的向量表示。 頭四個元素代表center_x, center_y, width, height
。第五個元素表示包含著目標的邊界框的置信度。 其余的元素是和每個類別(如目標種類)有關的置信度。邊界框分配給最高分數對應的那一種類。 一個邊界框的最高分數也叫做它的置信度 confidence
。如果邊界框的置信度低于規定的閥值,算法上不再處理這個邊界框。 置信度大于或等于置信度閥值的邊界框,將進行非最大抑制。這會減少重疊的邊界框數目。
// 移除低置信度邊界框
void postprocess(cv::Mat& frame,const vector<cv::Mat>& outs){vector<int> classIds; // 儲存識別類的索引vector<float> confidences;// 儲存置信度vector<cv::Rect> boxes; // 儲存邊框for(size_t i=0;i<outs.size();i++){//從網絡輸出中掃描所有邊界框//保留高置信度選框//目標數據data:x,y,w,h為百分比,x,y為目標中心點坐標float* data = (float*)outs[i].data;for(int j=0;j<outs[i].rows;j++,data+=outs[i].cols){cv::Mat scores = outs[i].row(j).colRange(5,outs[i].cols);cv::Point classIdPoint;double confidence;//置信度//取得最大分數值與索引cv::minMaxLoc(scores,0,&confidence,0,&classIdPoint);if(confidence>confThreshold){int centerX = (int)(data[0]*frame.cols);int centerY = (int)(data[1]*frame.rows);int width = (int)(data[2]*frame.cols);int height = (int)(data[3]*frame.rows);int left = centerX-width/2;int top = centerY-height/2;classIds.push_back(classIdPoint.x);confidences.push_back((float)confidence);boxes.push_back(cv::Rect(left, top, width, height));}}}//低置信度vector<int> indices;//保存沒有重疊邊框的索引//該函數用于抑制重疊邊框cv::dnn::NMSBoxes(boxes,confidences,confThreshold,nmsThreshold,indices);for(size_t i=0;i<indices.size();i++){int idx = indices[i];cv::Rect box = boxes[idx];drawPred(classIds[idx],confidences[idx],box.x,box.y,box.x+box.width,box.y+box.height,frame);}
}
5-3 畫出邊界框
最后,經過非最大抑制后,得到了邊界框。我們把邊界框在輸入幀上畫出,并標出種類名和置信值。
//繪制預測邊界框
void drawPred(int classId,float conf,int left,int top,int right,int bottom,cv::Mat& frame){//繪制邊界框cv::rectangle(frame,cv::Point(left,top),cv::Point(right,bottom),cv::Scalar(255,178,50),3);string label = cv::format("%.2f",conf);if(!classes.empty()){CV_Assert(classId < (int)classes.size());label = classes[classId]+":"+label;//邊框上的類別標簽與置信度}//繪制邊界框上的標簽int baseLine;cv::Size labelSize = cv::getTextSize(label,cv::FONT_HERSHEY_SIMPLEX,0.5,1,&baseLine);top = max(top,labelSize.height);cv::rectangle(frame,cv::Point(left,top-round(1.5*labelSize.height)),cv::Point(left+round(1.5*labelSize.width),top+baseLine),cv::Scalar(255,255,255),cv::FILLED);cv::putText(frame, label,cv::Point(left, top), cv::FONT_HERSHEY_SIMPLEX, 0.75,cv::Scalar(0, 0, 0), 1);
}
文件全部源碼在文末。
寫好之后我們編譯執行即可,關于 OpenCV 眾多頭文件包含、鏈接庫鏈接時、運行時的鏈接,對初學者來說可能會遇到一些問題,可以參考:
Linux下編譯、鏈接、加載運行C++ OpenCV的兩種方式及常見問題的解決
Linux下C/C++程序編譯鏈接加載過程中的常見問題及解決方法
可以從左上角和標準輸出看到,每幀的檢測時間大概在 280ms,速度還可以,精度大體也不錯。但是由于模型較小,性能受限,對于一些邊緣小物體會有誤差,如上圖中右側的小車。
樹莓派攝像頭實時檢測
樹莓派攝像頭調試參考:樹莓派攝像頭基礎配置及測試 。
在視頻文件的檢測順利完成后,實時樹莓派的檢測就很簡單了,只需要將讀取輸入部分從視頻文件改為本地攝像頭即可。
主要就是這一行修改:
// cv::VideoCapture cap("./video/demo.mp4");
// cv::VideoWriter video;
// 改為
cv::VideoCapture cap(0);
另外,我們需要設置一些 OpenCV 讀取攝像頭輸入的尺寸大小,否則筆者親測是有一些小bug:
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
在筆者實驗室中實測,速度和精度也都還可以。
全部代碼
全部代碼可參考:https://github.com/Adenialzz/Hello-AIDeployment
如有錯誤或遺漏,歡迎留言指正。
Ref:
https://blog.csdn.net/cuma2369/article/details/107666559
https://ryanadex.github.io/2019/01/15/opencv-yolov3/