目錄
- 一、概述
- 1.1 背景介紹:從“訓練”到“部署”
- 1.2 學習目標
- 二、在C++中集成ONNX模型
- 2.1 準備模型文件
- 2.2 修改`Backend`以加載和運行模型
- 三、關鍵一步:輸出結果的后處理
- 四、運行與驗證
- 五、總結與展望
一、概述
1.1 背景介紹:從“訓練”到“部署”
在上一篇文章中,我們成功地跨入了Python的世界,完整地經歷了一次AI模型從數據標注到訓練、再到導出的全過程。我們最終的產出是一個名為best.onnx
的模型文件——這是AI算法工程師工作的結晶。
然而,這個模型本身還只是一個靜態的權重文件,無法獨立工作。本篇文章的核心任務,就是完成從“算法研發”到“軟件部署”這至關重要的一躍。我們將回歸C++的主戰場,學習如何使用OpenCV強大的DNN(深度神經網絡)模塊,在我們的Qt應用程序中加載并運行這個ONNX模型。這個過程,我們稱之為模型推理(Inference)。
1.2 學習目標
通過本篇的學習,讀者將能夠:
- 在C++中加載ONNX模型,并對輸入的圖像進行預處理,使其符合模型的輸入要求。
- 執行模型的前向傳播(推理),獲取模型的原始輸出。
- 掌握關鍵的后處理技術:解析YOLOv8復雜的輸出張量,提取出邊界框、置信度和類別信息。
- 將識別出的瑕疵信息,可視化地繪制在QML界面顯示的圖像上,讓AI的結果“看得見”。
二、在C++中集成ONNX模型
2.1 準備模型文件
首先,將上一章生成的best.onnx
模型文件,從runs/detect/train/weights
目錄中,拷貝到項目根目錄下,方便程序訪問。
2.2 修改Backend
以加載和運行模型
我們將為Backend
類增加一個YOLOv8
的封裝,用于處理所有與模型推理相關的邏輯。
1. 編寫代碼 (backend.h)
// backend.h
#ifndef BACKEND_H
#define BACKEND_H#include <QObject>
#include <QImage>
#include <opencv2/dnn.hpp> // 1. 包含OpenCV DNN模塊頭文件class ImageProvider;class Backend : public QObject
{Q_OBJECT
public:explicit Backend(ImageProvider *provider, QObject *parent = nullptr);Q_INVOKABLE void startScan();signals:void imageReady(const QString &imageId);void statusMessageChanged(const QString &message);private:// 2. 添加一個私有方法用于AI推理cv::Mat runInference(const cv::Mat &inputImage);ImageProvider *m_imageProvider;cv::dnn::Net m_net; // 3. 添加一個Net對象,用于表示我們的神經網絡std::vector<std::string> m_classNames; // 4. 用于存儲類別名稱
};#endif // BACKEND_H
2. 編寫代碼 (backend.cpp)
這是本章的核心。我們將修改構造函數以加載模型,并實現runInference
方法。
// backend.cpp
#include "backend.h"
#include "imageprovider.h"
#include <QDebug>
#include <QDir>
#include <opencv2/imgcodecs.hpp>// ... (matToQImage輔助函數保持不變)Backend::Backend(ImageProvider *provider, QObject *parent): QObject(parent), m_imageProvider(provider)
{// --- 1. 加載ONNX模型 ---QString modelPath = QDir::currentPath() + "/../../best.onnx";try {m_net = cv::dnn::readNetFromONNX(modelPath.toStdString());if (m_net.empty()) {qWarning() << "Failed to load ONNX model!";} else {qDebug() << "ONNX model loaded successfully.";// 設置計算后端。CPU是默認選項,但可以顯式指定m_net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);m_net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);}} catch (const cv::Exception& e) {qWarning() << "Error loading model:" << e.what();}// --- 2. 加載類別名稱 ---// 這個順序必須與訓練時的.yaml文件嚴格一致!m_classNames = {"neck_defect", "thread_defect", "head_defect"};
}cv::Mat Backend::runInference(const cv::Mat &inputImage)
{if (m_net.empty()) {qDebug() << "Network not loaded.";return inputImage;}// --- 3. 圖像預處理 ---// YOLOv8需要一個640x640的方形輸入const int inputWidth = 640;const int inputHeight = 640;cv::Mat blob;// 將圖像轉換為blob格式:調整尺寸、歸一化(像素值/255)、通道重排(BGR->RGB)cv::dnn::blobFromImage(inputImage, blob, 1./255., cv::Size(inputWidth, inputHeight), cv::Scalar(), true, false);// --- 4. 執行推理 ---m_net.setInput(blob);std::vector<cv::Mat> outputs;m_net.forward(outputs, m_net.getUnconnectedOutLayersNames());// outputs[0]是模型的原始輸出,我們需要對其進行后處理// ... 后處理代碼將在下一節添加 ...// 暫時返回原始圖像return inputImage;
}void Backend::startScan()
{// ... (加載圖像的代碼保持不變)QString imagePath = QDir::currentPath() + "/../../dataset/screw/test/scratch_head/000.png";cv::Mat sourceMat = cv::imread(imagePath.toStdString());if (sourceMat.empty()) { /* ... */ return; }// --- 5. 調用推理函數 ---cv::Mat resultMat = runInference(sourceMat);QImage imageQ = matToQImage(resultMat);if (imageQ.isNull()){ /* ... */ return; }m_imageProvider->updateImage(imageQ);emit imageReady("screw_processed");emit statusMessageChanged("AI推理完成!");
}
關鍵代碼分析:
(1) cv::dnn::readNetFromONNX(...)
: OpenCV DNN模塊中用于從ONNX文件加載模型的函數。加載成功后會返回一個cv::dnn::Net
對象。
(2) m_classNames
: 我們手動定義了一個std::vector<std::string>
來存儲類別名稱。注意:這里的順序必須與訓練時dataset.yaml
文件中的names
順序嚴格一致,因為模型輸出的類別ID是基于這個順序的。
(3) cv::dnn::blobFromImage(...)
: 這是一個強大的預處理函數,它能一步到位地完成YOLOv8所需的幾項操作:
- 1./255.
:歸一化因子,將像素值從0-255范圍縮放到0-1范圍。
- cv::Size(640, 640)
:將圖像縮放或填充到640x640的尺寸。
- cv::Scalar()
: 減去均值,此處不減。
- true
: 交換R和B通道(BGR -> RGB),因為YOLOv8是在RGB圖像上訓練的。
- false
: 不裁剪。
(4) m_net.forward(...)
: 執行網絡的前向傳播,即推理。推理結果會存放在outputs
這個std::vector<cv::Mat>
中。
三、關鍵一步:輸出結果的后處理
YOLOv8的原始輸出是一個cv::Mat
,其維度通常是1 x (4 + num_classes) x 8400
。我們需要編寫代碼來解析這個復雜的張量,提取出我們真正需要的信息。
【核心概念:解析YOLOv8輸出】
對于輸出矩陣的每一列(共8400列,代表8400個可能的檢測框):
- 前4行是邊界框的坐標(中心x, 中心y, 寬, 高)。
- 后面的N行(N是類別數)是該框屬于每個類別的置信度分數。
我們的后處理流程是:
- 遍歷所有8400個可能的檢測框。
- 找到每個框置信度最高的類別。
- 如果這個最高置信度大于一個閾值(例如0.5),則認為這是一個有效的檢測。
- 將所有有效的檢測框及其信息收集起來。
- 由于同一個物體可能被多個框檢測到,最后使用**非極大值抑制(NMS)**來剔除重疊的多余框。
【例8-1】 實現后處理并可視化結果。
1. 編寫代碼 (backend.cpp)
我們將用完整的后處理邏輯來替換runInference
函數中的注釋部分。
// backend.cppcv::Mat Backend::runInference(const cv::Mat &inputImage)
{if (m_net.empty()) { /* ... */ return inputImage; }cv::Mat blob;// --- 圖像預處理 (代碼同上) ---cv::dnn::blobFromImage(inputImage, blob, 1./255., cv::Size(640, 640), cv::Scalar(), true, false);m_net.setInput(blob);std::vector<cv::Mat> outputs;m_net.forward(outputs, m_net.getUnconnectedOutLayersNames());cv::Mat output_buffer = outputs[0]; // [1, num_classes + 4, 8400]output_buffer = output_buffer.reshape(1, {output_buffer.size[1], output_buffer.size[2]}); // [num_classes + 4, 8400]cv::transpose(output_buffer, output_buffer); // [8400, num_classes + 4]// --- 1. 后處理 ---float conf_threshold = 0.5f; // 置信度閾值float nms_threshold = 0.4f; // NMS閾值std::vector<int> class_ids;std::vector<float> confidences;std::vector<cv::Rect> boxes;float x_factor = (float)inputImage.cols / 640.f;float y_factor = (float)inputImage.rows / 640.f;for (int i = 0; i < output_buffer.rows; i++) {cv::Mat row = output_buffer.row(i);cv::Mat scores = row.colRange(4, output_buffer.cols);double confidence;cv::Point class_id_point;cv::minMaxLoc(scores, nullptr, &confidence, nullptr, &class_id_point);if (confidence > conf_threshold) {confidences.push_back(confidence);class_ids.push_back(class_id_point.x);float cx = row.at<float>(0,0);float cy = row.at<float>(0,1);float w = row.at<float>(0,2);float h = row.at<float>(0,3);int left = (int)((cx - 0.5 * w) * x_factor);int top = (int)((cy - 0.5 * h) * y_factor);int width = (int)(w * x_factor);int height = (int)(h * y_factor);boxes.push_back(cv::Rect(left, top, width, height));}}// --- 2. 非極大值抑制 (NMS) ---std::vector<int> indices;cv::dnn::NMSBoxes(boxes, confidences, conf_threshold, nms_threshold, indices);// --- 3. 結果可視化 ---cv::Mat resultImage = inputImage.clone();for (int idx : indices) {cv::Rect box = boxes[idx];int class_id = class_ids[idx];// 繪制邊界框cv::rectangle(resultImage, box, cv::Scalar(0, 255, 0), 2);// 繪制標簽std::string label = cv::format("%s: %.2f", m_classNames[class_id].c_str(), confidences[idx]);cv::putText(resultImage, label, cv::Point(box.x, box.y - 10), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 255, 0), 2);}return resultImage;
}
關鍵代碼分析:
(1) 坐標還原: 模型的輸出是基于640x640輸入的歸一化坐標,我們必須乘以x_factor
和y_factor
將其還原到原始圖像的坐標系。
(2) cv::minMaxLoc(...)
: 一個方便的函數,用于在單行/列的Mat中快速找到最大值(置信度)及其位置(類別ID)。
(3) cv::dnn::NMSBoxes(...)
: OpenCV DNN模塊內置的非極大值抑制函數。它接收原始的框和置信度,返回一個indices
向量,其中包含了最終保留下來的框的索引。
(4) cv::rectangle(...)
和 cv::putText(...)
: OpenCV的繪圖函數,用于在最終結果圖上畫出邊界框和帶有類別、置信度的標簽文本。
四、運行與驗證
現在,一切準備就緒。重新編譯并運行ScrewDetector
項目。
1. 運行結果
點擊“開始檢測”按鈕。稍等片刻,界面上將會顯示出帶有綠色邊界框和標簽的螺絲圖像。程序成功地識別出了圖片中的head_defect
(頭部瑕疵)!同時,狀態欄也會更新為“AI推理完成!”。
2. 嘗試其他圖片
可以嘗試修改Backend::startScan()
中的imagePath
,換成數據集中其他的圖片(例如good
文件夾下的圖片),重新運行,觀察AI模型是否會誤檢。運行效果如下:
可以看到,對于沒有瑕疵的圖片,AI模型不會檢測出瑕疵,驗證了模型的有效性。
五、總結與展望
在本篇文章中,我們成功地跨越了Python與C++之間的鴻溝,將上一章訓練的AI模型部署到了我們的Qt應用程序中。我們掌握了使用OpenCV DNN模塊加載ONNX模型、對輸入圖像進行預處理以及最關鍵的YOLOv8輸出后處理技術。
至此,我們的應用程序已經擁有了真正的“AI大腦”,能夠對靜態圖片進行智能瑕疵檢測。然而,在真實的工業場景中,產品是連續不斷地在傳送帶上移動的。如何處理來自攝像頭的實時視頻流?如何保證處理速度?
這將是我們下一篇文章【《使用Qt Quick從零構建AI螺絲瑕疵檢測系統》——9. 接入真實硬件:驅動USB攝像頭】的核心主題。我們將讓程序從處理單張圖片,升級為處理實時動態視頻。