目录

企业级实战构建基于QtC与YOLOv8的模块化工业视觉检测系统

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

一、概述

在追求高效与精密的现代制造业中,自动化光学检测(AOI)已成为保障产品质量的核心技术。传统的质检流程往往受限于人工效率与主观判断,难以满足大规模、高精度的生产需求。本文旨在研发一套完整的、企业级的工业视觉异常检测解决方案,通过构建一个功能强大的桌面应用程序,实现对金属冲压件关键特征的自动化、高精度检测。

该项目将采用模块化的软件工程思想,将核心的AI算法逻辑与前端用户界面彻底分离。算法部分将封装为一个独立的C++动态链接库(DLL),而用户交互界面则使用Qt 5.15.2Widget框架进行开发。这种架构不仅厘清了职责,也极大地便利了团队协作开发与后期的功能维护。

二、项目目标与技术架构

2.1 核心目标

开发一个桌面端AOI应用程序,该程序需具备以下核心功能:

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

2.2 技术选型

  • UI框架Qt 5.15.2 Widgets。选用此版本因为它对Windows 7等传统工业环境保持着良好的兼容性,且其成熟稳定的Widgets模块非常适合开发传统的桌面应用程序。
  • 开发环境Qt Creator 17.0.1,其集成的Copilot AI辅助编程功能可以显著提升开发效率。
  • 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语言风格的函数接口,不暴露任何OpenCV或特定库的数据类型。这种设计确保了接口的稳定与通用性,使得UI开发者无需关心底层算法实现细节。
  2. Qt GUI应用程序

    • 职责:负责所有用户交互。包括窗口、按钮、图像显示控件的创建,响应用户加载图像、绘制ROI的操作,调用DLL执行检测,以及将返回的结果进行可视化展示。
    • 开发工具:使用Qt Creator进行开发。
    • DLL集成:采用动态链接的方式,在项目的.pro文件中直接配置DLL的头文件(.h)和库文件(.lib),实现对DLL函数的调用。

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

首先,在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)

这是DLL的核心实现。它包含了模型加载、图像处理、推理和逻辑判断的全部代码。

#include "pch.h" // VS项目预编译头
#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;
    }

    // 1. 将输入数据转换为OpenCV的Mat格式
    cv::Mat source_image(height, width, CV_8UC3, (void*)in_image_data);
    cv::Mat result_image = source_image.clone(); // 复制一份用于绘制结果

    // 2. 遍历每个ROI进行处理
    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在图像范围内
        roi_rect &= cv::Rect(0, 0, width, height);
        if (roi_rect.width <= 0 || roi_rect.height <= 0) {
            results[i] = { true }; // 无效ROI视为异常
            continue;
        }

        cv::Mat roi_image = source_image(roi_rect);

        // 3. 图像预处理和模型推理
        cv::Mat blob;
        cv::dnn::blobFromImage(roi_image, blob, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(), true, false); //倒数第二个参数表明进行通道转换  BGR转RGB
        net.setInput(blob);
        std::vector<cv::Mat> outs;
        net.forward(outs, net.getUnconnectedOutLayersNames());

        // 4. 后处理
        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);

        // 5. 业务逻辑判断
        int counts[4] = { 0, 0, 0, 0 }; // chongdian, baoxiansi, dianpian, chaxiao
        bool object_found = !indices.empty();

        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 };

        // 6. 绘制检测结果到大图上
        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];
            // 坐标转换:从ROI内部坐标转换到大图坐标
            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);
        }
    }

    // 7. 准备输出数据
    *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 Widget GUI应用程序的开发

现在,切换到Qt Creator,创建一个新的“Qt Widgets Application”项目。项目使用qmake编译器,并且选择visual studio 2019 Release 64 bit套件。

4.1 项目配置 (.pro 文件)

为了让Qt项目能够找到并使用之前创建的DLL,需要修改.pro文件,指定头文件路径、库文件路径和要链接的库。

QT += core gui widgets

CONFIG += c++11

TARGET = IndustrialDetectorGUI
TEMPLATE = app

SOURCES += main.cpp\
        mainwindow.cpp

HEADERS  += mainwindow.h

FORMS    += mainwindow.ui

# 链接AI推理DLL,路径需要根据实际位置进行修改
INCLUDEPATH += $$PWD/../SDK/ # 指向DetectorAPI.h所在的目录
LIBS += -L$$PWD/../SDK/ -lDetectorAPI # 指向DetectorAPI.lib所在的目录

# 在文件最后添加编译选项,防止报错
QMAKE_PROJECT_DEPTH = 0

4.2 UI设计 (mainwindow.ui)

使用Qt Designer拖拽控件,设计一个简单的界面:

  • 一个QLabel (imageLabel) 用于显示图像。
  • 一个QPushButton (loadButton) 用于加载图像。
  • 一个QPushButton (detectButton) 用于执行检测。
  • 一个QPushButton (clearButton) 用于清除已绘制的ROIs。

好的,遵照您的要求,我将根据我们最终确定的正确方案(子类化QLabel),为您完整地重写整个4.3节。这个版本将包含所有必要的代码,无任何省略,并整合了正确的架构说明。


4.3 交互逻辑实现

这是GUI应用程序的核心。为了解决在QLabel上正确、高效地绘制图形(如ROI矩形框)的难题,我们采用最符合Qt框架设计思想的方案:创建QLabel的子类

这个自定义的Label将专门负责绘制图像和其上层的ROI矩形框,而MainWindow则退居二线,只负责处理用户输入、管理ROI数据和调用AI算法。这种职责分离的架构使得代码更清晰、更健壮。

4.3.1 自定义ROI绘制标签 (roilabel.h & roilabel.cpp)

首先,我们需要在项目中创建一个新的C++类,命名为ROILabel,并使其继承自QLabel

文件: roilabel.h

这个头文件定义了ROILabel的接口。它重写了paintEvent以实现自定义绘制,并提供了一个公共方法setRois,用于从MainWindow接收需要绘制的矩形数据。

#ifndef ROILABEL_H
#define ROILABEL_H

#include <QLabel>
#include <QList>
#include <QRect>
#include <QPainter>

class ROILabel : public QLabel
{
    Q_OBJECT

public:
    explicit ROILabel(QWidget *parent = nullptr);

    /**
     * @brief 设置需要绘制的ROI矩形列表
     * @param rois 已确定的ROI列表 (已转换为视图坐标)
     * @param currentRoi 当前正在绘制的ROI (已转换为视图坐标)
     */
    void setRois(const QList<QRect>& rois, const QRect& currentRoi);

protected:
    // 重写父类的 paintEvent 来绘制矩形
    void paintEvent(QPaintEvent *event) override;

private:
    QList<QRect> m_roisToDraw;      // 存储要绘制的已确定ROI
    QRect m_currentRoiToDraw; // 存储要绘制的当前ROI
};

#endif // ROILABEL_H

文件: roilabel.cpp

这是ROILabel的实现。setRois函数接收数据后,立即调用update()来触发一次重绘请求。在paintEvent中,我们首先调用基类QLabel::paintEvent来确保背景图像被正确绘制,然后在其上层绘制我们自己的矩形。

#include "roilabel.h"

ROILabel::ROILabel(QWidget *parent) : QLabel(parent)
{
    // 构造函数可以保持为空
}

void ROILabel::setRois(const QList<QRect>& rois, const QRect& currentRoi)
{
    m_roisToDraw = rois;
    m_currentRoiToDraw = currentRoi;
    // 请求Qt在下一个事件循环中重绘此控件,这将自动调用paintEvent
    this->update();
}

void ROILabel::paintEvent(QPaintEvent *event)
{
    // 1. 必须首先调用基类的paintEvent,这会负责绘制QLabel本身的内容(如pixmap)
    QLabel::paintEvent(event);

    // 2. 在图像之上,为这个控件自身创建一个QPainter
    QPainter painter(this);

    // 3. 绘制所有已经确定的ROI(蓝色实线)
    painter.setPen(QPen(Qt::blue, 2));
    for (const QRect& roi : m_roisToDraw) {
        painter.drawRect(roi);
    }

    // 4. 如果当前正在绘制ROI,则实时显示它(红色虚线)
    if (!m_currentRoiToDraw.isNull()) {
        painter.setPen(QPen(Qt::red, 2, Qt::DashLine));
        painter.drawRect(m_currentRoiToDraw);
    }
}

4.3.2 在UI设计器中提升控件

这是将UI与我们新代码关联起来的关键一步。

  1. 打开mainwindow.ui文件。
  2. 在界面上右键单击imageLabel控件。
  3. 从菜单中选择 “Promote to…” (提升为…)。
  4. 在弹出的对话框中,将 “Promoted class name” 设置为 ROILabel,“Header file” 设置为 roilabel.h
  5. 点击 “Add”,然后点击 “Promote”。
  6. 保存UI文件。现在,ui->imageLabel在代码中的类型将自动变为ROILabel*

4.3.3 主窗口逻辑实现 (mainwindow.h & mainwindow.cpp)

现在,我们更新MainWindow的代码。它不再处理任何paintEvent,而是专注于管理数据和响应用户操作,并在数据变化时通知ROILabel进行重绘。

头文件 mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QImage>
#include <QRect>
#include <QList>
#include <QMouseEvent>

#include "DetectorAPI.h" // 包含检测SDK的接口头文件
#include "roilabel.h"    // 包含我们自定义Label的头文件

QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

protected:
    // 重写事件处理函数以实现ROI绘制
    void mousePressEvent(QMouseEvent *event) override;
    void mouseMoveEvent(QMouseEvent *event) override;
    void mouseReleaseEvent(QMouseEvent *event) override;
    void resizeEvent(QResizeEvent* event) override;
    // paintEvent 已被移除

private slots:
    // 按钮的槽函数
    void on_loadButton_clicked();
    void on_detectButton_clicked();
    void on_clearButton_clicked();

private:
    // 将QImage转换为DLL所需的BGR格式数据
    unsigned char* convertQImageToBGR(const QImage& image);

    // 更新图像在Label中的显示
    void updateImageDisplay();

    // 通知ROILabel更新其绘制内容
    void updateLabelRois();

    // 坐标映射函数
    QPoint mapPointToImage(const QPoint& viewPoint); // 将视图(Label)坐标点映射到原始图像坐标点
    QRect mapRectFromImage(const QRect& imageRect);  // 将原始图像矩形映射到视图(Label)矩形

    Ui::MainWindow *ui;

    QImage m_originalImage;   // 用于存储原始的、未被修改的图像
    QImage m_image;           // 存储加载的原始图像
    QPixmap m_pixmap;         // 存储用于显示的缩放后图像

    // 注意:m_rois 和 m_currentRoi 存储的都是【原始图像】坐标系下的矩形
    QList<QRect> m_rois;
    QRect m_currentRoi;

    bool m_isDrawing;
    QPoint m_startPoint;      // 存储鼠标按下时在【视图】坐标系下的点

    // 用于坐标转换的参数
    double m_scaleFactor;     // 图像缩放比例
    QPoint m_pixmapOffset;    // 缩放后图像在Label内的偏移量
};
#endif // MAINWINDOW_H
实现文件 mainwindow.cpp

新增了一个辅助函数updateLabelRois(),它的作用是将在MainWindow中以图像坐标存储的ROI,转换为视图坐标,然后传递给ROILabel去绘制。所有修改了ROI数据的操作(鼠标事件、清空按钮)都会调用这个函数来确保界面同步刷新。

#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QFileDialog>
#include <QMessageBox>
#include <QDebug>
#include <vector>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , m_isDrawing(false)
    , m_scaleFactor(1.0)
{
    ui->setupUi(this);

    // 让Label能够响应鼠标事件,并让坐标计算更精确
    ui->imageLabel->setMouseTracking(true);
    ui->imageLabel->setAlignment(Qt::AlignCenter); // 让图像居中显示

    // 在程序启动时初始化模型
    const char* model_path = "best.onnx"; // 假设模型文件在程序运行目录下
    if (InitializeModel(model_path) != 0) {
        QMessageBox::critical(this, "Error", "Failed to initialize AI model. Make sure 'best.onnx' is in the correct path.");
        QApplication::quit();
    }
}

MainWindow::~MainWindow()
{
    // 在程序退出前释放模型
    ReleaseModel();
    delete ui;
}

void MainWindow::on_loadButton_clicked()
{
    QString fileName = QFileDialog::getOpenFileName(this, "Open Image", "", "Image Files (*.png *.jpg *.bmp)");
    if (!fileName.isEmpty()) {
        // 加载图像到两个变量中
        if (m_originalImage.load(fileName)) {
            m_image = m_originalImage; // m_image也设为原始图像
            m_rois.clear();
            updateImageDisplay();
            updateLabelRois();
        }
    }
}

void MainWindow::on_clearButton_clicked()
{
    // 如果没有加载过原始图像,则不执行任何操作
    if (m_originalImage.isNull()) {
        return;
    }

    // 1. 将当前显示图像恢复为原始的干净图像
    m_image = m_originalImage;

    // 2. 清空数据模型中的所有ROI
    m_rois.clear();
    m_currentRoi = QRect();

    // 3. 更新图像显示,此时会使用干净的m_image
    updateImageDisplay();

    // 4. 通知ROILabel清除其上层绘制的所有矩形
    updateLabelRois();
}

void MainWindow::on_detectButton_clicked()
{
    if (m_image.isNull() || m_rois.isEmpty()) {
        QMessageBox::warning(this, "Warning", "Please load an image and draw at least one ROI first.");
        return;
    }

    // 1. 将QImage转换为DLL期望的BGR格式
    unsigned char* bgr_data = convertQImageToBGR(m_image);
    if (!bgr_data) return;

    // 2. 将QList<QRect>转换为ROIInfo数组
    std::vector<ROIInfo> roi_infos;
    for (const QRect& rect : m_rois) {
        roi_infos.push_back({rect.x(), rect.y(), rect.width(), rect.height()});
    }

    // 3. 准备接收结果的变量
    unsigned char* out_image_data = nullptr;
    int out_width = 0, out_height = 0;
    std::vector<ROIResult> results(roi_infos.size());

    // 4. 调用DLL执行检测
    int status = PerformDetection(
        bgr_data, m_image.width(), m_image.height(),
        roi_infos.data(), roi_infos.size(),
        &out_image_data, &out_width, &out_height,
        results.data()
        );

    delete[] bgr_data; // 释放转换时分配的内存

    // 5. 处理结果
    if (status == 0 && out_image_data != nullptr) {
        // 将返回的BGR数据转换为QImage并更新
        QImage resultImage(out_image_data, out_width, out_height, QImage::Format_RGB888);
        m_image = resultImage.rgbSwapped(); // 更新底图为结果图
        m_rois.clear(); // 清除ROI,因为结果已绘制在图上
        updateImageDisplay();
        updateLabelRois(); // 清除label上的ROI

        // 释放DLL分配的内存
        ReleaseImageData(out_image_data);

        // 可选:显示每个ROI的逻辑判断结果
        QString result_summary = "Detection Results:\n";
        for (size_t i = 0; i < results.size(); ++i) {
            result_summary += QString("ROI %1: %2\n").arg(i + 1).arg(results[i].is_abnormal ? "Abnormal" : "Normal");
        }
        QMessageBox::information(this, "Detection Complete", result_summary);
    } else {
        QMessageBox::critical(this, "Error", "Detection failed.");
    }
}

unsigned char* MainWindow::convertQImageToBGR(const QImage& image)
{
    if (image.isNull()) return nullptr;

    QImage convertedImage = image.convertToFormat(QImage::Format_RGB888);
    int width = convertedImage.width();
    int height = convertedImage.height();
    size_t data_size = width * height * 3;
    unsigned char* bgr_data = new unsigned char[data_size];

    for (int y = 0; y < height; ++y) {
        const uchar* line = convertedImage.scanLine(y);
        for (int x = 0; x < width; ++x) {
            bgr_data[(y * width + x) * 3 + 0] = line[x * 3 + 2]; // Blue
            bgr_data[(y * width + x) * 3 + 1] = line[x * 3 + 1]; // Green
            bgr_data[(y * width + x) * 3 + 2] = line[x * 3 + 0]; // Red
        }
    }
    return bgr_data;
}

void MainWindow::updateImageDisplay()
{
    if (m_image.isNull()) {
        ui->imageLabel->clear();
        return;
    }

    QPixmap pixmap = QPixmap::fromImage(m_image);
    m_pixmap = pixmap.scaled(ui->imageLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);

    double scaleX = (double)m_pixmap.width() / m_image.width();
    double scaleY = (double)m_pixmap.height() / m_image.height();
    m_scaleFactor = 1.0 / scaleX; // 更新为正确的比例因子

    m_pixmapOffset.setX((ui->imageLabel->width() - m_pixmap.width()) / 2);
    m_pixmapOffset.setY((ui->imageLabel->height() - m_pixmap.height()) / 2);

    ui->imageLabel->setPixmap(m_pixmap);
}

void MainWindow::updateLabelRois()
{
    QList<QRect> view_rois;
    for(const QRect& img_roi : m_rois) {
        view_rois.append(mapRectFromImage(img_roi));
    }

    QRect view_current_roi;
    if(!m_currentRoi.isNull()) {
        view_current_roi = mapRectFromImage(m_currentRoi);
    }

    // 调用 ROILabel 的公共接口来传递转换后的视图坐标矩形
    ui->imageLabel->setRois(view_rois, view_current_roi);
}

void MainWindow::resizeEvent(QResizeEvent* event)
{
    QMainWindow::resizeEvent(event);
    updateImageDisplay();
    updateLabelRois(); // 窗口变化时也要更新矩形位置
}

QPoint MainWindow::mapPointToImage(const QPoint& viewPoint)
{
    QPoint parentPoint = viewPoint - m_pixmapOffset;
    return QPoint(parentPoint.x() * m_scaleFactor, parentPoint.y() * m_scaleFactor);
}

QRect MainWindow::mapRectFromImage(const QRect& imageRect)
{
    QPoint topLeft = QPoint(imageRect.left() / m_scaleFactor, imageRect.top() / m_scaleFactor);
    QPoint bottomRight = QPoint(imageRect.right() / m_scaleFactor, imageRect.bottom() / m_scaleFactor);
    return QRect(topLeft, bottomRight).translated(m_pixmapOffset);
}

void MainWindow::mousePressEvent(QMouseEvent *event)
{
    QPoint localPos = ui->imageLabel->mapFrom(this, event->pos());
    QRect pixmapRect(m_pixmapOffset, m_pixmap.size());

    if (pixmapRect.contains(localPos) && event->button() == Qt::LeftButton) {
        m_isDrawing = true;
        m_startPoint = localPos;
        m_currentRoi = QRect(mapPointToImage(localPos), QSize());
        updateLabelRois();
    }
}

void MainWindow::mouseMoveEvent(QMouseEvent *event)
{
    if (m_isDrawing) {
        QPoint localPos = ui->imageLabel->mapFrom(this, event->pos());
        QPoint imageEndPoint = mapPointToImage(localPos);
        m_currentRoi.setBottomRight(imageEndPoint);
        updateLabelRois();
    }
}

void MainWindow::mouseReleaseEvent(QMouseEvent *event)
{
    if (m_isDrawing && event->button() == Qt::LeftButton) {
        m_isDrawing = false;
        m_currentRoi = m_currentRoi.normalized();
        if (m_currentRoi.width() > 5 && m_currentRoi.height() > 5) {
            m_rois.append(m_currentRoi);
        }
        m_currentRoi = QRect(); // 清空当前正在绘制的ROI
        updateLabelRois();
    }
}

五、编译与部署

  1. 编译DLL:在Visual Studio中,选择Release配置,编译DetectorAPI项目,生成DetectorAPI.dllDetectorAPI.lib
  2. 编译GUI:在Qt Creator中,选择Release配置,构建qtDemo项目。
  3. 部署:创建一个部署文件夹,并将以下文件放入:
    • qtDemo.exe (Qt程序)
    • DetectorAPI.dll (AI推理库)
    • opencv_world4120.dll (OpenCV运行库)
    • best.onnx (模型文件)
    • 使用Qt官方的windeployqt.exe工具,将所有Qt相关的依赖库(platforms, imageformats等插件)自动复制到部署文件夹中。代码如下所示:
windeployqt qtDemo.exe

最终部署文件夹的结构应如下:

Deployment/
├── qtDemo.exe
├── DetectorAPI.dll
├── opencv_world4120.dll
├── best.onnx
├── platforms/
│   └── qwindows.dll
├── ... (其他Qt依赖项)

六、结语

通过将AI推理逻辑封装到独立的C++ DLL中,并由Qt Widgets应用程序进行调用,成功构建了一个模块化、易于维护和扩展的工业视觉检测系统。该架构充分利用了Visual Studio在C++和OpenCV开发上的优势,以及Qt在跨平台GUI开发上的强大能力,为开发复杂的企业级桌面应用提供了一个清晰且高效的范例。

此项目框架不仅可以应对当前的检测需求,也为未来的功能升级奠定了坚实的基础,例如集成更复杂的算法、连接生产数据库、生成详细的质量报告等,都可以在不改动UI代码的情况下,通过升级DLL来实现。