当前位置: 首页 > news >正文

企业级实战:构建基于Qt、C++与YOLOv8的模块化工业视觉检测系统(基于QML)

目录

  • 一、概述
  • 二、项目目标与技术架构
    • 2.1 核心目标
    • 2.2 技术选型
    • 2.3 软件架构
  • 三、AI推理DLL的开发 (Visual Studio 2019)
    • 3.1 定义DLL接口 (`DetectorAPI.h`)
    • 3.2 实现核心功能 (`DetectorAPI.cpp`)
  • 四、Qt QML GUI应用程序的开发
    • 4.1 项目配置 (`.pro` 文件)
    • 4.2 QML界面设计 (`main.qml`)
      • 4.2.1 自定义图像交互组件 (`ImageCanvas.qml`)
      • 4.2.2 主界面布局 (`main.qml`)
    • 4.3 ROI坐标换算和检验
      • 4.3.1 创建C++后端 (`AoiBackend`) 用于图像处理
      • 4.3.2 注册C++后端到QML环境
      • 4.3.3 升级`ImageCanvas.qml`以实现坐标换算与绘制约束
      • 4.3.4 在主界面中调用后端功能
    • 4.4 集成AI检测功能
      • 4.4.1 扩展C++后端以调用DLL
      • 4.4.2 管理模型生命周期
      • 4.4.3 连接前端以显示结果
      • 4.4.4 最终效果展示
  • 五、部署
    • 5.1 编译Release版本
    • 5.2 使用`windeployqt`工具打包Qt依赖
    • 5.3 手动添加第三方依赖
    • 5.4 最终部署目录结构
  • 六、小结

一、概述

在现代工业自动化生产线中,自动化光学检测(AOI)是确保产品质量、提升生产效率的关键环节。为了满足大规模、高精度的质检需求,本文将详细阐述一套完整的、企业级的工业视觉异常检测解决方案的研发过程。该方案旨在构建一个功能强大的桌面应用程序,实现对金属冲压件关键特征的自动化、高精度检测。

此项目采纳了现代软件工程中前后端分离的设计理念,将系统的用户界面(UI)与核心算法逻辑进行解耦。UI部分将采用声明式的Qt Quick (QML)技术构建,以实现流畅的用户体验与灵活的界面布局。核心的AI算法逻辑则被封装为一个独立的C++动态链接库(DLL),供主应用程序调用。这种架构不仅实现了职责分离,也为并行开发与未来维护带来了极大的便利。

二、项目目标与技术架构

2.1 核心目标

研发一个基于QML的桌面端AOI应用程序,该程序需具备以下核心功能:

  1. 图像加载与显示:支持用户从本地文件系统加载待检测的产品图像。
  2. 交互式ROI定义:允许用户通过鼠标在图像上拖拽,灵活地定义一个或多个感兴趣区域(ROI)。
  3. 一键式智能检测:通过按钮触发,调用后端AI算法DLL,对每个ROI区域独立执行目标检测与业务逻辑判断。
  4. 可视化结果呈现:在原始图像上,直观地展示所有检测到的目标(边界框、类别、置信度),并以高亮形式标记出判定为异常的ROI区域。

2.2 技术选型

  • UI框架Qt 5.15.2 Qt Quick (QML)。选用QML技术,因为它能提供更现代、更流畅的UI体验,并通过其强大的属性绑定机制简化UI与后端逻辑的交互。
  • 开发环境Qt Creator 17.0.1。可以使用Copilot大模型提示助手,提升开发效率。
  • AI推理引擎OpenCV 4.12.0 DNN。利用其DNN模块,在CPU上对ONNX格式的YOLOv8模型进行高效推理。
  • 算法模型:基于Ultralytics框架训练的YOLOv8模型,已转换为跨平台兼容的ONNX格式。
  • 检测类别:模型可识别四个类别:chongdian (冲压点), baoxiansi (保险丝), dianpian (垫片), chaxiao (插销)。

2.3 软件架构

项目采用前后端分离的设计,具体划分为三个核心模块:

  1. AI推理动态链接库 (DLL)

    • 职责:封装所有计算机视觉与AI推理的复杂逻辑,包括模型加载/释放、图像预处理、ONNX模型推理、结果后处理及业务逻辑判断。
    • 开发工具:使用Visual Studio C++进行开发和编译。
    • 接口设计:提供纯C语言风格的函数接口,确保了接口的稳定与通用性,使得上层应用无需关心底层实现细节。
  2. Qt C++后端

    • 职责:作为QML前端与AI推理DLL之间的桥梁。它负责加载DLL、管理应用状态(如加载的图像、ROI列表)、处理来自QML的用户输入(如按钮点击、鼠标坐标),调用DLL执行检测,并处理返回的结果。
  3. QML GUI前端

    • 职责:负责所有用户界面的展示与交互。使用声明式的QML语言构建窗口、按钮、图像显示区,并通过信号与槽、属性绑定机制与C++后端进行通信。

三、AI推理DLL的开发 (Visual Studio 2019)

此模块负责核心的AI计算,其开发过程独立于UI框架。

首先,在Visual Studio 2019中创建一个新的“动态链接库(DLL)”项目,配置工程生成属性为 (Release x64),并配置好OpenCV 4.12.0的包含目录、库目录和链接器输入。

  1. C/C++ -> 常规 -> 附加包含目录:
D:\toolplace\opencv\build\include
  1. 链接器 -> 常规 -> 附加库目录:
D:\toolplace\opencv\build\x64\vc16\lib
  1. 链接器 -> 输入 -> 附加依赖项:
opencv_world4120.lib 

3.1 定义DLL接口 (DetectorAPI.h)

创建头文件以声明从DLL中导出的函数和数据结构。采用extern "C"确保C风格的函数命名,避免C++的名称修饰,增强跨语言兼容性。

#ifndef DETECTOR_API_H
#define DETECTOR_API_H#ifdef DETECTOR_EXPORTS
#define DETECTOR_API __declspec(dllexport)
#else
#define DETECTOR_API __declspec(dllimport)
#endif// 定义检测对象的类别
enum ObjectType {CHONGDIAN = 0,BAOXIANSI = 1,DIANPIAN = 2,CHAXIAO = 3,UNKNOWN = 4
};// 定义传入的ROI信息结构体
struct ROIInfo {int x;int y;int width;int height;
};// 定义返回的单个ROI的检测结果
struct ROIResult {bool is_abnormal; // true表示异常,false表示正常
};extern "C" {/*** @brief 初始化检测模型* @param model_path ONNX模型文件的绝对或相对路径* @return 0表示成功,-1表示失败*/DETECTOR_API int InitializeModel(const char* model_path);/*** @brief 释放模型资源*/DETECTOR_API void ReleaseModel();/*** @brief 执行检测* @param in_image_data 输入的图像数据 (BGR格式)* @param width 图像宽度* @param height 图像高度* @param rois ROI信息数组* @param roi_count ROI的数量* @param out_image_data 输出的带有绘制结果的图像数据 (BGR格式,由DLL内部分配内存,调用方需使用ReleaseImageData释放)* @param out_width 输出图像的宽度* @param out_height 输出图像的高度* @param results 每个ROI的检测结果数组 (由调用方分配内存)* @return 0表示成功,-1表示失败*/DETECTOR_API int PerformDetection(const unsigned char* in_image_data, int width, int height,const ROIInfo* rois, int roi_count,unsigned char** out_image_data, int* out_width, int* out_height,ROIResult* results);/*** @brief 释放由PerformDetection函数分配的图像数据内存* @param image_data 指向图像数据的指针*/DETECTOR_API void ReleaseImageData(unsigned char* image_data);
}#endif // DETECTOR_API_H

3.2 实现核心功能 (DetectorAPI.cpp)

此文件包含模型加载、图像处理、推理和逻辑判断的完整代码。

#include "pch.h"
#include "DetectorAPI.h"
#include <opencv2/opencv.hpp>
#include <vector>
#include <string>static cv::dnn::Net net;
static std::vector<std::string> classNames;int InitializeModel(const char* model_path) {try {net = cv::dnn::readNetFromONNX(model_path);net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);classNames = { "chongdian", "baoxiansi", "dianpian", "chaxiao" };return 0;}catch (const cv::Exception& e) {return -1;}
}void ReleaseModel() {net.~Net();classNames.clear();
}void ReleaseImageData(unsigned char* image_data) {if (image_data) {delete[] image_data;}
}int PerformDetection(const unsigned char* in_image_data, int width, int height,const ROIInfo* rois, int roi_count,unsigned char** out_image_data, int* out_width, int* out_height,ROIResult* results
) {if (net.empty() || in_image_data == nullptr || rois == nullptr || roi_count == 0) {return -1;}cv::Mat source_image(height, width, CV_8UC3, (void*)in_image_data);cv::Mat result_image = source_image.clone();for (int i = 0; i < roi_count; ++i) {ROIInfo roi = rois[i];cv::Rect roi_rect(roi.x, roi.y, roi.width, roi.height);roi_rect &= cv::Rect(0, 0, width, height);if (roi_rect.width <= 0 || roi_rect.height <= 0) {results[i] = { true };continue;}cv::Mat roi_image = source_image(roi_rect);cv::Mat blob;cv::dnn::blobFromImage(roi_image, blob, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(), true, false);net.setInput(blob);std::vector<cv::Mat> outs;net.forward(outs, net.getUnconnectedOutLayersNames());cv::Mat output_buffer = outs[0];output_buffer = output_buffer.reshape(1, { output_buffer.size[1], output_buffer.size[2] });cv::transpose(output_buffer, output_buffer);float conf_threshold = 0.5f;float nms_threshold = 0.4f;std::vector<int> class_ids;std::vector<float> confidences;std::vector<cv::Rect> boxes;float x_factor = (float)roi_image.cols / 640.f;float y_factor = (float)roi_image.rows / 640.f;for (int j = 0; j < output_buffer.rows; j++) {cv::Mat row = output_buffer.row(j);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));}}std::vector<int> indices;cv::dnn::NMSBoxes(boxes, confidences, conf_threshold, nms_threshold, indices);int counts[4] = { 0, 0, 0, 0 };for (int idx : indices) {int class_id = class_ids[idx];if (class_id >= 0 && class_id < 4) {counts[class_id]++;}}bool is_abnormal = false;if (counts[CHONGDIAN] + counts[BAOXIANSI] + counts[DIANPIAN] + counts[CHAXIAO] == 0)is_abnormal = true;else {if (counts[CHONGDIAN] > 0 && counts[CHONGDIAN] != 2)is_abnormal = true;}results[i] = { is_abnormal };cv::Scalar color = is_abnormal ? cv::Scalar(0, 0, 255) : cv::Scalar(0, 255, 0);cv::rectangle(result_image, roi_rect, color, 2);for (int idx : indices) {cv::Rect box = boxes[idx];box.x += roi_rect.x;box.y += roi_rect.y;cv::rectangle(result_image, box, cv::Scalar(255, 178, 50), 2);std::string label = cv::format("%.2f", confidences[idx]);label = classNames[class_ids[idx]] + ":" + label;cv::putText(result_image, label, cv::Point(box.x, box.y - 10), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(255, 178, 50), 2);}}*out_width = result_image.cols;*out_height = result_image.rows;size_t data_size = result_image.total() * result_image.elemSize();*out_image_data = new unsigned char[data_size];memcpy(*out_image_data, result_image.data, data_size);return 0;
}

编译此项目,生成DetectorAPI.dllDetectorAPI.lib文件。

四、Qt QML GUI应用程序的开发

在Qt Creator中,创建一个新的QML项目。注意,在选择模板时,需要选择"Qt Quick Application(compat)"作为项目模板。如果选择"Qt Quick Application”,下一步就只出现Qt6的版本选项,无法创建适配Qt 5.15.2的项目了。在选择最小Qt版本时,可以选择Qt 5.12。

4.1 项目配置 (.pro 文件)

修改.pro文件,以链接先前创建的DLL。

QT += quick# You can make your code fail to compile if it uses deprecated APIs.
# In order to do so, uncomment the following line.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000    # disables all the APIs deprecated before Qt 6.0.0SOURCES += \main.cppRESOURCES += qml.qrc# Additional import path used to resolve QML modules in Qt Creator's code model
QML_IMPORT_PATH =# Additional import path used to resolve QML modules just for Qt Quick Designer
QML_DESIGNER_IMPORT_PATH =# 链接AI推理DLL,路径需根据实际位置修改
INCLUDEPATH += $$PWD/../SDK/ # 指向DetectorAPI.h所在的目录
LIBS += -L$$PWD/../SDK/ -lDetectorAPI # 指向DetectorAPI.lib所在的目录# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target# 在文件最后添加编译选项,防止报错
QMAKE_PROJECT_DEPTH = 0

4.2 QML界面设计 (main.qml)

QML界面的核心在于main.qml文件,它负责定义应用程序的整体布局、视觉元素和用户交互。界面将划分为两个主要区域:顶部的图像显示与ROI(感兴趣区域)交互区,以及底部的操作按钮区。

为实现灵活的ROI绘制功能,将创建一个可复用的自定义组件ImageCanvas.qml。这个组件不仅负责显示图像,还封装了所有与鼠标交互相关的逻辑,如绘制、管理和清除ROI矩形框。主视图main.qml则负责整体布局,并将ImageCanvas与控制按钮集成在一起。

4.2.1 自定义图像交互组件 (ImageCanvas.qml)

首先,在项目资源中(qml.qrc)创建一个新的QML文件,命名为ImageCanvas.qml。该组件的核心是一个Image元素用于显示图片,以及一个覆盖其上的MouseArea用于捕捉用户的鼠标操作来定义ROI。

该组件对外暴露了三个关键的公共方法,以便main.qml可以调用它们:

  • loadImage(path): 加载并显示指定路径的图像。
  • getRois(): 获取当前所有已绘制ROI的几何信息(x, y, width, height)列表。
  • clearRois(): 清除图像上所有已绘制的ROI矩形框。
import QtQuick 2.15
import QtQuick.Controls 2.15Item {id: rootproperty var rois: [] // 存储所有ROI矩形的数组property color roiColor: "red" // 绘制ROI的颜色// 创建一个ROI画框Component {id: roiComponentRectangle {width: 0height: 0border.color: root.roiColorborder.width: 2color: "transparent"}}// 对外暴露的公共API:加载图片function loadImage(path) {displayImage.source = path;clearRois(); // 加载新图片时清除旧的标记}// 对外暴露的公共API:获取所有ROI信息function getRois() {var roiData = [];for (var i = 0; i < rois.length; ++i) {roiData.push({x: rois[i].x,y: rois[i].y,width: rois[i].width,height: rois[i].height});}return roiData;}// 对外暴露的公共API:清除所有ROIfunction clearRois() {// 销毁所有动态创建的Rectangle对象for (var i = 0; i < rois.length; ++i) {if (rois[i] !== null) {rois[i].destroy();}}rois = []; // 清空数组}// 用于显示图像Image {id: displayImageanchors.fill: parentfillMode: Image.PreserveAspectFit // 保持宽高比适应}// 鼠标交互区域MouseArea {id: mouseAreaanchors.fill: parenthoverEnabled: trueproperty var startPoint: Qt.point(0, 0)property var currentRect: nullonPressed: (mouse) => {startPoint = Qt.point(mouse.x, mouse.y);currentRect = roiComponent.createObject(root);if (currentRect) {currentRect.x = startPoint.x;currentRect.y = startPoint.y;}}onPositionChanged: (mouse) => {if (currentRect) {var newX = Math.min(startPoint.x, mouse.x);var newY = Math.min(startPoint.y, mouse.y);var newWidth = Math.abs(mouse.x - startPoint.x);var newHeight = Math.abs(mouse.y - startPoint.y);currentRect.x = newX;currentRect.y = newY;currentRect.width = newWidth;currentRect.height = newHeight;}}onReleased: (mouse) => {if (currentRect) {// 检查矩形大小是否有效if (currentRect.width > 5 && currentRect.height > 5) {rois.push(currentRect); // 将有效的矩形添加到数组} else {currentRect.destroy(); // 销毁无效的小矩形}currentRect = null; // 清除当前矩形引用}}}
}

4.2.2 主界面布局 (main.qml)

接下来,修改main.qml文件。它将使用ColumnLayout来组织ImageCanvas和底部的按钮栏。按钮栏则使用RowLayout来水平排列三个功能按钮。为美化界面,按钮将进行统一样式设计。

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Dialogs 1.3ApplicationWindow {id: rootWindowwidth: 1280height: 720visible: truetitle: qsTr("工业视觉异常检测系统")// 文件对话框,用于选择图像FileDialog {id: fileDialogtitle: "请选择一个图像文件"folder: shortcuts.picturesnameFilters: ["Image files (*.jpg *.jpg *.png *.bmp)", "All files (*)"]onAccepted: {// 将URL转换为本地文件路径并加载imageCanvas.loadImage(fileDialog.fileUrl.toString())}}// 整体使用列布局ColumnLayout {anchors.fill: parentspacing: 5// 自定义的图像显示与交互区域ImageCanvas {id: imageCanvasLayout.fillWidth: trueLayout.fillHeight: true // 占据大部分空间}// 底部按钮栏RowLayout {id: buttonBarLayout.fillWidth: trueLayout.alignment: Qt.AlignHCenter // 按钮居中对齐spacing: 20// “加载图像”按钮Button {id: loadButtontext: qsTr("加载图像")Layout.preferredWidth: 120Layout.preferredHeight: 40font.pointSize: 12onClicked: fileDialog.open()background: Rectangle {color: loadButton.pressed ? "#1E88E5" : "#2196F3"radius: 5border.color: "#1976D2"border.width: 1}}// “推理”按钮Button {id: inferButtontext: qsTr("推理")Layout.preferredWidth: 120Layout.preferredHeight: 40font.pointSize: 12onClicked: {// 此处将调用C++后端的检测函数// var rois = imageCanvas.getRois();// aoiBackend.performDetection(rois);console.log("执行推理...");}background: Rectangle {color: inferButton.pressed ? "#43A047" : "#4CAF50"radius: 5border.color: "#388E3C"border.width: 1}}// “清除标记”按钮Button {id: clearButtontext: qsTr("清除标记")Layout.preferredWidth: 120Layout.preferredHeight: 40font.pointSize: 12onClicked: imageCanvas.clearRois()background: Rectangle {color: clearButton.pressed ? "#E53935" : "#F44336"radius: 5border.color: "#D32F2F"border.width: 1}}}//占位itemItem {Layout.fillWidth: trueLayout.preferredHeight: 10}}
}

完成以上代码后,运行应用程序,即可看到一个功能完善的界面。用户可以通过“加载图像”按钮载入本地图片,通过鼠标在图片上拖拽来定义一个或多个ROI区域,并通过“清除标记”按钮一键清除所有已绘制的矩形框。“推理”按钮的点击事件目前仅输出日志,后续章节将把它与C++后端逻辑连接起来,以触发真正的AI检测流程。
在这里插入图片描述

4.3 ROI坐标换算和检验

在前面的步骤中,ImageCanvas.qml已经具备了绘制ROI矩形框的基本功能。然而,一个关键问题尚未解决:坐标系的差异。用户在QML界面上绘制的矩形框,其坐标是相对于ImageCanvas这个UI组件的。但AI算法需要处理的是相对于原始图像文件的像素坐标。当原始图像因尺寸过大或与ImageCanvas宽高比不一致,而被Image组件以PreserveAspectFit模式缩放显示时,UI坐标与原始图像坐标之间会存在缩放和偏移。

此外,由于Image组件的缩放显示特性,图像内容可能不会完全填满ImageCanvas的边界,从而产生留白区域。必须限制用户的绘制操作,确保ROI的起点和终点都落在有效的图像显示区域内,并将绘制的矩形严格约束在此区域边界之内。

为解决以上问题并验证坐标换算的正确性,本节将实现以下目标:

  1. ImageCanvas.qml中,精确计算图像在UI中的实际显示区域(位置和缩放比例),并据此约束鼠标的绘制行为。
  2. 实现一个从UI坐标到原始图像坐标的换算函数。
  3. 创建一个C++后端类,通过QML调用,将换算后的ROI坐标应用到原始图像上,裁剪出对应的图像区域并保存为本地文件。这为后续将ROI数据传递给AI DLL进行推理打下了坚实的基础。

4.3.1 创建C++后端 (AoiBackend) 用于图像处理

首先,创建一个C++类作为QML与底层逻辑交互的桥梁。这个类将负责接收来自QML的图像路径和ROI数据,并使用Qt的图像处理功能来保存ROI区域。

aoibackend.h
在Qt Creator中,通过“文件 -> 新建文件或项目 -> C++ -> C++ Class”创建一个名为AoiBackend的新类,基类选择QObject

#ifndef AOIBACKEND_H
#define AOIBACKEND_H#include <QObject>
#include <QString>
#include <QVariantList>class AoiBackend : public QObject
{Q_OBJECT
public:explicit AoiBackend(QObject *parent = nullptr);public slots:Q_INVOKABLE void saveRois(const QString &imageUrl, const QVariantList &rois);};#endif // AOIBACKEND_H

aoibackend.cpp
实现saveRois方法。该方法使用QImage加载原始图像,然后遍历传入的ROI列表,根据每个ROI的坐标信息裁剪图像并保存。

#include "aoibackend.h"
#include <QImage>
#include <QRect>
#include <QDebug>
#include <QUrl>AoiBackend::AoiBackend(QObject *parent) : QObject(parent)
{
}void AoiBackend::saveRois(const QString &imageUrl, const QVariantList &rois)
{// 将QML传来的URL路径转换为本地文件路径QString imagePath = QUrl(imageUrl).toLocalFile();QImage originalImage(imagePath);if (originalImage.isNull()) {qWarning() << "Failed to load image:" << imagePath;return;}for (int i = 0; i < rois.size(); ++i) {QVariantMap roiMap = rois[i].toMap();// 从QVariantMap中提取ROI的几何信息int x = roiMap["x"].toInt();int y = roiMap["y"].toInt();int width = roiMap["width"].toInt();int height = roiMap["height"].toInt();QRect roiRect(x, y, width, height);// 从原始图像中复制出ROI区域QImage roiImage = originalImage.copy(roiRect);// 构建保存路径QString savePath = QString("roi_%1.jpg").arg(i + 1);if (roiImage.save(savePath)) {qInfo() << "Successfully saved" << savePath;} else {qWarning() << "Failed to save" << savePath;}}
}

4.3.2 注册C++后端到QML环境

为了让QML能够访问AoiBackend对象,需要在main.cpp中实例化该类,并将其注册到QML的根上下文中。

main.cpp (修改后)

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext> // 包含头文件
#include "aoibackend.h" // 包含头文件int main(int argc, char *argv[])
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
#endifQGuiApplication app(argc, argv);QQmlApplicationEngine engine;// 实例化并注册AoiBackendAoiBackend aoiBackend;engine.rootContext()->setContextProperty("aoiBackend", &aoiBackend);const QUrl url(QStringLiteral("qrc:/main.qml"));QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,&app, [url](QObject *obj, const QUrl &objUrl) {if (!obj && url == objUrl)QCoreApplication::exit(-1);}, Qt::QueuedConnection);engine.load(url);return app.exec();
}

4.3.3 升级ImageCanvas.qml以实现坐标换算与绘制约束

现在,对ImageCanvas.qml进行关键性升级。增加用于计算真实图像显示区域的逻辑,并基于此来约束鼠标绘制行为和转换坐标。

ImageCanvas.qml (完整升级版)

import QtQuick 2.15
import QtQuick.Controls 2.15Item {id: root// 定义用户画的ROI区域及颜色property var rois: []  // 存储所有ROI的数组,坐标是相对于画布的property color roiColor: "red"// 原始图像在Item内的实际显示区域的几何信息property real imagePaintX: 0property real imagePaintY: 0property real imagePaintWidth: 0property real imagePaintHeight: 0property real imageScale: 1.0// 计算图像实际显示区域的函数function calculateImageGeometry() {if (displayImage.sourceSize.width === 0 || displayImage.sourceSize.height === 0) return;var sourceRatio = displayImage.sourceSize.width / displayImage.sourceSize.height;var canvasRatio = root.width / root.height;if (sourceRatio > canvasRatio) { // 图像比画布更宽(上下留白)imageScale = root.width / displayImage.sourceSize.width;imagePaintWidth = root.width;imagePaintHeight = displayImage.sourceSize.height * imageScale;imagePaintX = 0;imagePaintY = (root.height - imagePaintHeight) / 2;} else { // 图像比画布更高(左右留白)imageScale = root.height / displayImage.sourceSize.height;imagePaintHeight = root.height;imagePaintWidth = displayImage.sourceSize.width * imageScale;imagePaintY = 0;imagePaintX = (root.width - imagePaintWidth) / 2;}}// 展示用户画的ROI框Component {id: roiComponentRectangle {width: 0; height: 0border.color: root.roiColor; border.width: 2color: "transparent"}}// 加载图像function loadImage(path) {displayImage.source = path;clearRois();}// 获取所有ROI在【原始图像】上的坐标信息function getOriginalImageRois() {var roiData = [];for (var i = 0; i < rois.length; ++i) {var rect = rois[i];// 坐标转换:从UI坐标映射到原始图像坐标roiData.push({x: Math.round((rect.x - imagePaintX) / imageScale),y: Math.round((rect.y - imagePaintY) / imageScale),width: Math.round(rect.width / imageScale),height: Math.round(rect.height / imageScale)});}return roiData; // 返回的是相对于原始图像的ROI框}// 清除ROI框function clearRois() {for (var i = 0; i < rois.length; ++i) {if (rois[i] !== null) {rois[i].destroy();}}rois = [];}// 图像控件Image {id: displayImageanchors.fill: parentfillMode: Image.PreserveAspectFit // 保持原始图像宽高比缩放// 当图像加载完成或尺寸变化时,重新计算几何信息onStatusChanged: {if (displayImage.status === Image.Ready) {calculateImageGeometry();}}}// 当容器尺寸变化时也需要重新计算onWidthChanged: calculateImageGeometry()onHeightChanged: calculateImageGeometry()// 鼠标区域,用于绘制ROIMouseArea {id: mouseAreaanchors.fill: parenthoverEnabled: trueproperty var startPoint: Qt.point(0, 0)property var currentRect: null// 检查一个点是否在图像显示区域内function isWithinImage(point) {return point.x >= imagePaintX && point.x <= (imagePaintX + imagePaintWidth) &&point.y >= imagePaintY && point.y <= (imagePaintY + imagePaintHeight);}onPressed: (mouse) => {// 只有在图像区域内点击才开始绘制if (!isWithinImage(Qt.point(mouse.x, mouse.y))) {currentRect = null;return;}startPoint = Qt.point(mouse.x, mouse.y);currentRect = roiComponent.createObject(root);if (currentRect) {currentRect.x = startPoint.x;currentRect.y = startPoint.y;}}onPositionChanged: (mouse) => {if (currentRect) {// 将鼠标坐标严格限制在图像显示区域内var clampedX = Math.max(imagePaintX, Math.min(mouse.x, imagePaintX + imagePaintWidth));var clampedY = Math.max(imagePaintY, Math.min(mouse.y, imagePaintY + imagePaintHeight));var newX = Math.min(startPoint.x, clampedX);var newY = Math.min(startPoint.y, clampedY);var newWidth = Math.abs(clampedX - startPoint.x);var newHeight = Math.abs(clampedY - startPoint.y);currentRect.x = newX;currentRect.y = newY;currentRect.width = newWidth;currentRect.height = newHeight;}}onReleased: (mouse) => {if (currentRect) {if (currentRect.width > 5 && currentRect.height > 5) {rois.push(currentRect);} else {currentRect.destroy();}currentRect = null;}}}
}

4.3.4 在主界面中调用后端功能

最后,修改main.qml中“推理”按钮的onClicked事件,使其调用新实现的坐标换算函数,并将结果传递给C++后端。

main.qml (“推理”按钮部分修改)

            // “推理”按钮Button {id: inferButtontext: qsTr("推理")Layout.preferredWidth: 120Layout.preferredHeight: 40font.pointSize: 12onClicked: {// 1. 获取相对于【原始图像】的ROI坐标var originalRois = imageCanvas.getOriginalImageRois();if (originalRois.length === 0) {console.log("没有定义任何ROI区域。");return;}if (!fileDialog.fileUrl) {console.log("请先加载一张图片。");return;}// 2. 调用C++后端的saveRois函数aoiBackend.saveRois(fileDialog.fileUrl.toString(), originalRois);}background: Rectangle {color: inferButton.pressed ? "#43A047" : "#4CAF50"radius: 5border.color: "#388E3C"border.width: 1}}

至此,本小节的功能开发全部完成。重新编译并运行应用程序,执行以下操作:

  1. 点击“加载图像”按钮,选择一张图片。
  2. 在图片上用鼠标拖拽绘制一个或多个ROI矩形框。可以尝试从留白区域开始拖动(将无法成功绘制),或者将矩形拖动到留白区域(矩形将被限制在图像边界)。
  3. 点击“推理”按钮。

操作完成后,检查应用程序的构建目录(例如,build-YourProject-Desktop_Qt_5_15_2_MSVC2019_64bit-Release),会发现生成了名为roi_1.jpg, roi_2.jpg等文件。打开这些文件,可以看到它们正是从原始图像上精确裁剪下来的ROI区域。

在这里插入图片描述
这一步骤的成功,验证了从QML前端到C++后端的数据流转是通畅的,并且核心的坐标换算逻辑是准确无误的。这为下一步将这些ROI数据送入AI推理DLL,并接收处理结果奠定了坚实可靠的基础。

4.4 集成AI检测功能

前续章节已经成功打通了从QML前端到C++后端的数据链路,并验证了ROI坐标换算的准确性。本节将完成最后一步,也是最核心的一步:将C++后端与AI推理DLL深度集成。其目标是实现一个完整的检测流程:在QML界面点击“推理”按钮后,将原始图像和ROI数据传递给DLL进行处理,然后接收带有检测结果的可视化图像,并将其更新回QML界面,同时确保AI模型的生命周期得到妥善管理。

4.4.1 扩展C++后端以调用DLL

首先,需要对AoiBackend类进行功能扩展,使其具备加载模型、执行检测、释放模型的能力,并能将检测结果回传给QML。

aoibackend.h (扩展后)
头文件中需包含DetectorAPI.h,并增加新的槽函数和信号。

#ifndef AOIBACKEND_H
#define AOIBACKEND_H#include <QObject>
#include <QString>
#include <QVariantList>
#include "DetectorAPI.h" // 包含SDK头文件class AoiBackend : public QObject
{Q_OBJECT
public:explicit AoiBackend(QObject *parent = nullptr);public slots:// 初始化AI模型Q_INVOKABLE bool initializeModel(const QString &modelPath);// 释放AI模型Q_INVOKABLE void releaseModel();// 执行检测Q_INVOKABLE void performDetection(const QString &imageUrl, const QVariantList &rois);signals:// 检测完成信号,携带结果图像的路径void detectionCompleted(const QString &resultImageUrl);
};#endif // AOIBACKEND_H

aoibackend.cpp (扩展后)
实现新增的槽函数。performDetection方法是核心,它负责数据格式的转换(QImage -> BGR unsigned char*),调用DLL的C接口,处理返回结果,并最终发出信号。

#include "aoibackend.h"
#include <QImage>
#include <QDebug>
#include <QUrl>
#include <QStandardPaths>
#include <QDir>
#include <vector>AoiBackend::AoiBackend(QObject *parent) : QObject(parent)
{
}bool AoiBackend::initializeModel(const QString &modelPath)
{// 将QString转换为C风格的char*字符串QByteArray ba = modelPath.toUtf8();if (InitializeModel(ba.constData()) == 0) {qInfo() << "Successfully initialized model:" << modelPath;return true;} else {qWarning() << "Failed to initialize model:" << modelPath;return false;}
}void AoiBackend::releaseModel()
{ReleaseModel();qInfo() << "Model released.";
}void AoiBackend::performDetection(const QString &imageUrl, const QVariantList &rois)
{QString imagePath = QUrl(imageUrl).toLocalFile();QImage originalImage(imagePath);if (originalImage.isNull()) {qWarning() << "Detection failed: cannot load image" << imagePath;return;}// 确保图像是3通道24位格式,并转换为BGR顺序QImage bgrImage = originalImage.convertToFormat(QImage::Format_RGB888).rgbSwapped();std::vector<ROIInfo> roiInfos;for (const QVariant &roi : rois) {QVariantMap roiMap = roi.toMap();roiInfos.push_back({roiMap["x"].toInt(),roiMap["y"].toInt(),roiMap["width"].toInt(),roiMap["height"].toInt()});}if (roiInfos.empty()) {qWarning() << "Detection failed: no ROIs provided.";return;}unsigned char* out_image_data = nullptr;int out_width = 0;int out_height = 0;std::vector<ROIResult> results(roiInfos.size());// 调用DLL执行检测int status = PerformDetection(bgrImage.constBits(), bgrImage.width(), bgrImage.height(),roiInfos.data(), roiInfos.size(),&out_image_data, &out_width, &out_height,results.data());if (status != 0 || out_image_data == nullptr) {qWarning() << "PerformDetection in DLL failed.";return;}// 将DLL返回的BGR数据转换为QImage// 注意:这里创建的QImage共享了DLL分配的内存,需要确保在使用完QImage后才释放内存QImage resultImage(out_image_data, out_width, out_height, QImage::Format_RGB888);// 将BGR转回RGB以正确显示和保存resultImage = resultImage.rgbSwapped();// 将结果图像保存到临时文件QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation);QString resultImagePath = tempPath + QDir::separator() + "detection_result.jpg";if (resultImage.save(resultImagePath, "JPG", 95)) {qInfo() << "Detection result saved to:" << resultImagePath;// 发射信号,通知QML更新界面emit detectionCompleted("file:///" + resultImagePath);} else {qWarning() << "Failed to save result image.";}// 释放DLL分配的图像数据内存ReleaseImageData(out_image_data);
}

4.4.2 管理模型生命周期

为了提升效率,AI模型应在程序启动时加载一次,在程序退出时释放一次,而不是每次检测都重复加载。这可以通过修改main.cpp来实现。

main.cpp (最终版)
在此文件中,于应用启动时调用initializeModel,并利用QGuiApplication::aboutToQuit信号确保在应用关闭前调用releaseModel

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QDir>
#include "aoibackend.h"int main(int argc, char *argv[])
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
#endifQGuiApplication app(argc, argv);QQmlApplicationEngine engine;AoiBackend aoiBackend;engine.rootContext()->setContextProperty("aoiBackend", &aoiBackend);// 在应用启动时初始化模型// 假设best.onnx模型文件与可执行文件在同一目录下QString modelPath = QCoreApplication::applicationDirPath() + QDir::separator() + "best.onnx";if (!aoiBackend.initializeModel(modelPath)) {// 模型加载失败,可以根据需要处理,例如退出应用或禁用推理功能qFatal("CRITICAL: Failed to load the AI model. The application will now exit.");return -1;}// 关联应用退出信号,用于释放模型资源QObject::connect(&app, &QGuiApplication::aboutToQuit, &aoiBackend, &AoiBackend::releaseModel);const QUrl url(QStringLiteral("qrc:/main.qml"));QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,&app, [url](QObject *obj, const QUrl &objUrl) {if (!obj && url == objUrl)QCoreApplication::exit(-1);}, Qt::QueuedConnection);engine.load(url);return app.exec();
}

重要提示:请确保将编译好的DetectorAPI.dll以及训练好的best.onnx模型文件,拷贝到Qt项目的构建目录(例如 build-YourProject-Desktop...-Release/release/),与生成的可执行文件放在一起。

4.4.3 连接前端以显示结果

最后一步是修改main.qml,使其能够响应AoiBackend发出的detectionCompleted信号,并调用performDetection函数。

main.qml (集成AI功能)
在根元素ApplicationWindow中添加一个Connections组件来监听来自C++的信号。同时,修改“推理”按钮的点击事件。

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Dialogs 1.3ApplicationWindow {id: rootWindowwidth: 1280height: 720visible: truetitle: qsTr("工业视觉异常检测系统")// 连接C++后端信号Connections {target: aoiBackendfunction onDetectionCompleted(resultImageUrl) {// 当检测完成时,加载并显示结果图像console.log("Detection complete. Updating image view with:", resultImageUrl)imageCanvas.loadImage(resultImageUrl)}}// 文件对话框... (代码与之前相同)// 整体使用列布局ColumnLayout {// ...// ImageCanvas ... (代码与之前相同)// 底部按钮栏RowLayout {// ...// “加载图像”按钮 ... (代码与之前相同)// “推理”按钮 (修改后)Button {id: inferButtontext: qsTr("推理")Layout.preferredWidth: 120Layout.preferredHeight: 40font.pointSize: 12onClicked: {var originalRois = imageCanvas.getOriginalImageRois();if (originalRois.length === 0) {console.log("没有定义任何ROI区域。");return;}if (!fileDialog.fileUrl || fileDialog.fileUrl.toString().length === 0) {console.log("请先加载一张图片。");return;}// 调用C++后端的检测函数aoiBackend.performDetection(fileDialog.fileUrl.toString(), originalRois);}background: Rectangle {color: inferButton.pressed ? "#43A047" : "#4CAF50"radius: 5border.color: "#388E3C"border.width: 1}}// ... “清除标记”按钮 ... (代码与之前相同)}// ...}
}

4.4.4 最终效果展示

至此,整个应用程序的开发工作已全部完成。重新编译并运行项目。执行以下操作流程:

  1. 程序启动,后台会自动加载ONNX模型。
  2. 点击“加载图像”按钮,选择待检测的金属冲压件图片。
  3. 在图像上用鼠标拖拽,定义一个或多个感兴趣的区域(ROI)。
  4. 点击“推理”按钮。

此时,应用程序会将图像和ROI数据传递给AI推理DLL。DLL处理后,返回一幅新的图像,其中包含了所有检测到的目标(如“冲压点”、“保险丝”等)的边界框、类别和置信度。同时,每个ROI区域的外框会根据其内部的检测结果被高亮标记:正常为绿色,异常为红色。这幅结果图像将自动替换掉原图,在界面上显示出来,实现了直观、高效的视觉反馈。

在这里插入图片描述

五、部署

开发完成的应用程序通常需要在未安装开发环境的目标计算机上运行。部署过程旨在创建一个独立的软件包,其中包含可执行文件以及其运行所需的所有依赖项,如库文件、插件、模型文件等。本章节将详细阐述如何为这个工业视觉检测应用创建一个可分发的部署包。

5.1 编译Release版本

在进行部署之前,必须以“Release”模式编译应用程序。Release构建会开启代码优化,移除调试符号,从而使生成的可执行文件体积更小、运行效率更高。

在Qt Creator中,切换到Release构建模式的步骤如下:

  1. 在主界面左侧,点击显示构建套件的图标(通常是一个显示器或锤子图标)。
  2. 在弹出的菜单中,选择带有“Release”字样的构建配置。
  3. 点击“构建”按钮(锤子图标)或直接运行项目,以生成Release版本的可执行文件。

编译完成后,可以在项目的构建目录下找到一个名为release(或Release)的子目录,其中包含了最终的可执行文件(例如,AOI_Detector.exe)。

5.2 使用windeployqt工具打包Qt依赖

Qt提供了一个官方的部署工具windeployqt,它可以自动扫描可执行文件,并将所有必需的Qt运行时库、QML模块、插件和翻译文件复制到可执行文件所在的目录。

需要从Qt提供的特定环境命令行工具中运行windeployqt

  • 打开Windows开始菜单,找到与项目所用Qt版本和编译器匹配的命令行工具,例如 “Qt 5.15.2 (MSVC 2019 64-bit)”。
  • 在该命令行窗口中,使用cd命令切换到生成的release目录。
cd /d D:\Deployment\release
  • 执行windeployqt命令,目标为应用程序的可执行文件。
windeployqt --qmldir D:\code\qmlDemo qmlDemo.exe

执行完毕后,工具会自动将大量Qt5*.dll文件、platformsqml等文件夹复制到部署目录中。此时,应用程序已经具备了运行所需的全部Qt依赖。

5.3 手动添加第三方依赖

windeployqt工具只能处理Qt自身的依赖项,无法识别项目所依赖的第三方库。因此,必须手动将这些文件复制到部署目录中。

  1. AI推理库 (DetectorAPI.dll)
    此文件是项目的核心算法库。从其Visual Studio项目的生成目录(例如 ...\DetectorAPI\x64\Release\DetectorAPI.dll)中,将其复制到D:\Deployment\release目录下,与主程序qmlDemo.exe放在一起。

  2. OpenCV运行时库
    DetectorAPI.dll依赖于OpenCV。根据第三章的配置,需要opencv_world4120.dll。从OpenCV的安装路径中找到此文件(例如 D:\toolplace\opencv\build\x64\vc16\bin\opencv_world4120.dll),并将其同样复制到部署目录中。

  3. ONNX模型文件
    根据main.cpp中的代码,应用程序在启动时会查找名为best.onnx的模型文件。将此模型文件也复制到部署目录的根目录下。

  4. MSVC++运行时库
    windeployqt通常会自动包含应用程序所需的MSVC++运行时库(如 VCRUNTIME140.dll 等)。然而,OpenCV库也可能依赖特定版本的运行时。如果将部署包移动到一台纯净的Windows系统上无法运行时,通常是因为缺少“Microsoft Visual C++ Redistributable”。需要根据编译时使用的Visual Studio版本(本项目为VS2019),在该目标机器上安装对应的VC++可再发行组件包。

5.4 最终部署目录结构

完成以上所有步骤后,部署目录D:\Deployment\release的结构应如下所示(部分文件和目录已省略):

D:/Deployment/release/
├── qmlDemo.exe          # 应用程序主程序
├── best.onnx                 # AI模型文件
├── DetectorAPI.dll           # 自定义AI推理库
├── opencv_world4120.dll      # OpenCV运行时库
├── Qt5Core.dll               # Qt核心库
├── Qt5Gui.dll                # Qt GUI库
├── Qt5Qml.dll                # Qt QML库
├── Qt5Quick.dll              # Qt Quick库
├── ... (其他Qt DLLs)
├── platforms/                # 平台插件 (如 qwindows.dll)
│   ├── qwindows.dll
│   └── ...
├── qml/                      # QML模块目录
│   ├── QtQuick/
│   ├── QtQuick.2/
│   └── ...
└── ... (其他由windeployqt生成的目录)

此时,整个release文件夹就是一个完整的绿色软件包。可以将其压缩后分发,用户在任何满足硬件和系统要求的Windows 64位计算机上,无需安装任何开发环境,直接双击qmlDemo.exe即可运行。

六、小结

本文详细阐述了一套完整的、企业级的工业视觉异常检测解决方案的研发全过程,从顶层设计、技术选型,到分模块开发、系统集成,最终完成了部署。该方案成功构建了一个功能强大且交互流畅的桌面应用程序,能够满足对金属冲压件进行自动化、高精度质检的实际需求。

项目的核心在于其清晰、现代的软件架构。通过严格遵循前后端分离的设计理念,将负责用户界面与交互的QML前端,同封装了复杂AI推理与业务逻辑的C++动态链接库(DLL)进行了解耦。中间的Qt C++后端则作为高效的桥梁,负责数据流转与状态管理。这种架构不仅使得各模块职责单一、易于维护,也为并行开发和未来的功能扩展奠定了坚实的基础。

在实现过程中,项目循序渐进地攻克了多个关键技术点:

  1. AI算法封装:在Visual Studio环境中,利用OpenCV DNN模块,将YOLOv8模型的核心推理能力封装成一个具有稳定C语言接口的DLL,实现了算法与应用的分离。
  2. 现代化UI构建:利用Qt Quick (QML)的声明式特性,构建了响应式、用户友好的图形界面,并实现了灵活的ROI交互式绘制功能。
  3. 坐标系统换算:精确解决了QML界面坐标与原始图像像素坐标之间的映射关系,确保了在不同显示缩放比下,用户定义的ROI都能准确地对应到原始图像数据上,这是保证检测精度的核心前提。
  4. 系统集成与优化:通过信号与槽机制,实现了QML前端与C++后端的无缝通信。同时,通过在应用程序启动和退出时管理AI模型的加载与释放,优化了系统性能,避免了重复性操作带来的资源浪费。
  5. 应用部署:通过使用windeployqt工具并手动添加第三方依赖,最终生成了一个可独立运行的软件包,确保了解决方案的完整性和可交付性。

综上所述,本文档不仅提供了一份详尽的技术实现指南,更展示了一套行之有效的工业软件开发方法论。它将先进的AI视觉技术与成熟的桌面应用开发框架相结合,为开发高性能、高可靠性的工业自动化检测系统提供了一个可复现的范例和坚实的参考。

http://www.dtcms.com/a/389426.html

相关文章:

  • 【Java】Ubuntu上发布Springboot 网站
  • 【入门级-算法-3、基础算法:贪心法】
  • Linux 网络
  • 【LVS入门宝典】探秘LVS透明性:客户端如何“看不见”后端服务器的魔法
  • 23届考研-C++面经(OD)
  • 运维安全06,服务安全
  • C++篇(9)list的模拟实现
  • Bugku-宽带信息泄露
  • LeetCode 845.数组中的最长山脉
  • 分布式存储与NFS:现代架构选型指南
  • SpringBoot三级缓存如何解决循环依赖的问题
  • 火山引擎 veCLI 发布,开启智能开发新模式
  • UE学习记录11----地形数据获取等高线
  • 【C++】STL--priority_queue(优先级队列)使用及其模拟实现、容器适配器和deque(双端队列)了解
  • 数学差能学人工智能吗?
  • Verilog语法学习EP10:串口接收模块
  • 使用obs同步录制窗口的高质量游戏模式视频
  • Qt语言家的简单使用记录
  • Taro + vue3项目,如何生成安卓 apk 安装包
  • Hive HQL命令
  • 智慧医疗新纪元:快瞳科技如何用OCR技术重塑医疗单据处理体验
  • 4.1软件工程管理-CMM2软件项目规划-思考题
  • 知识图谱对自然语言处理深层语义分析的影响与启示:2025年研究综述
  • 4.1软件工程管理-CMM2软件项目规划
  • 《手搓动态顺序表:从数组到自动扩容的华丽转身》
  • 【Day 60】Linux-LVS负载均衡
  • bash zsh sh与shell 有什么关系
  • AI时代格局重构:2025 GEO服务公司Top3
  • GEO 优化重构数字营销格局 孟庆涛技术创新引领行业突破
  • 思迈特 Agent BI 发布,重构数据应用新范式