一、概述

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

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

与传统将结果绘制在图像上再返回的方式不同,本方案中的AI推理DLL专注于核心计算,仅返回结构化的检测数据(如边界框、类别、置信度)。所有可视化工作,包括绘制检测框、显示类别标签以及添加版权水印,全部由Qt前端负责。这种架构彻底分离了计算与显示,不仅实现了职责分离,也为并行开发、未来维护以及跨平台部署带来了极大的便利。

二、项目目标与技术架构

2.1 核心目标

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

  1. 图像加载与显示:支持用户从本地文件系统加载待检测的产品图像,并在图像指定位置显示半透明的版权水印。
  2. 交互式ROI定义:允许用户通过鼠标在图像上拖拽,灵活地定义一个或多个感兴趣区域(ROI)。
  3. 一键式智能检测:通过按钮触发,调用后端AI算法DLL。DLL仅返回检测对象的原始数据(坐标、类别、置信度)。
  4. 前端可视化呈现:Qt应用程序接收检测数据后,负责将坐标换算至UI界面,并在原始图像上动态绘制所有检测到的目标(边界框、类别、置信度),同时以高亮形式标记出判定为异常的ROI区域。

2.2 技术选型

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

2.3 软件架构

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

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

    • 职责:封装所有计算机视觉与AI推理的复杂逻辑。接收原始图像和ROI信息,执行推理,并返回结构化的检测结果数据。不进行任何绘图操作
    • 开发工具:使用Visual Studio C++进行开发和编译。
    • 接口设计:提供纯C语言风格的函数接口,确保了接口的稳定与通用性。
  2. Qt C++后端

    • 职责:作为QML前端与AI推理DLL之间的桥梁。它负责加载DLL、管理应用状态、处理用户输入、调用DLL执行检测,并将返回的原始检测数据转换为QML可识别的格式(QVariantList)。
  3. QML GUI前端

    • 职责:负责所有用户界面的展示与交互。接收来自C++后端的数据后,动态创建并渲染检测框、标签文本和ROI状态。同时,负责在图像上叠加版权水印。

三、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中导出的函数和数据结构。该接口现在定义了用于返回检测对象信息的结构体,检测函数返回一个DetectedObject数组。

#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表示正常
};

// 定义返回的单个检测对象的信息
struct DetectedObject {
    int roi_index;      // 所属ROI的索引
    int class_id;       // 类别ID
    float confidence;   // 置信度
    int x;              // 边界框左上角x坐标 (相对于原始图像)
    int y;              // 边界框左上角y坐标 (相对于原始图像)
    int width;          // 边界框宽度
    int height;         // 边界框高度
};


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_roi_results 每个ROI的检测结果数组 (由调用方分配内存)
     * @param out_objects 检测到的对象数组 (由DLL内部分配内存,调用方需使用ReleaseDetections释放)
     * @param out_object_count 检测到的对象数量
     * @return 0表示成功,-1表示失败
     */
    DETECTOR_API int PerformDetection(
        const unsigned char* in_image_data, int width, int height,
        const ROIInfo* rois, int roi_count,
        ROIResult* out_roi_results,
        DetectedObject** out_objects, int* out_object_count
    );

    /**
     * @brief 释放由PerformDetection函数分配的DetectedObject数组内存
     * @param objects 指向对象数组的指针
     */
    DETECTOR_API void ReleaseDetections(DetectedObject* objects);
}

#endif // DETECTOR_API_H

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

此文件实现接口函数。PerformDetection实现AI推理,最后将检测结果封装为DetectedObject结构体列表,然后返回。

#include "pch.h"
#include "DetectorAPI.h"
#include <opencv2/opencv.hpp>
#include <vector>
#include <string>

static cv::dnn::Net net;

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);
        return 0;
    }
    catch (const cv::Exception& e) {
        return -1;
    }
}

void ReleaseModel() {
    net.~Net();
}

void ReleaseDetections(DetectedObject* objects) {
    if (objects) {
        delete[] objects;
    }
}

int PerformDetection(
    const unsigned char* in_image_data, int width, int height,
    const ROIInfo* rois, int roi_count,
    ROIResult* out_roi_results,
    DetectedObject** out_objects, int* out_object_count
) {
    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);
    std::vector<DetectedObject> all_detections;

    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) {
            out_roi_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 box_width = (int)(w * x_factor);
                int box_height = (int)(h * y_factor);
                boxes.push_back(cv::Rect(left, top, box_width, box_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]++;
            }
            // Populate DetectedObject structure
            DetectedObject obj;
            obj.roi_index = i;
            obj.class_id = class_id;
            obj.confidence = confidences[idx];
            obj.x = boxes[idx].x + roi_rect.x; // Convert to full image coordinates
            obj.y = boxes[idx].y + roi_rect.y;
            obj.width = boxes[idx].width;
            obj.height = boxes[idx].height;
            all_detections.push_back(obj);
        }
        
        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;
        }

        out_roi_results[i] = { is_abnormal };
    }

    *out_object_count = all_detections.size();
    if (*out_object_count > 0) {
        *out_objects = new DetectedObject[*out_object_count];
        memcpy(*out_objects, all_detections.data(), *out_object_count * sizeof(DetectedObject));
    } else {
        *out_objects = nullptr;
    }

    return 0;
}

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

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

在Qt Creator中,选择"Qt Quick Application(compat)"模板创建新项目,以确保兼容Qt 5.15.2。

4.1 项目配置 (.pro 文件)

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

QT += quick

SOURCES += \
        main.cpp \
        aoibackend.cpp

HEADERS += \
        aoibackend.h

RESOURCES += qml.qrc

# ... (rest of the default content)

# 链接AI推理DLL
INCLUDEPATH += $$PWD/../SDK/ # 指向DetectorAPI.h所在的目录
LIBS += -L$$PWD/../SDK/ -lDetectorAPI # 指向DetectorAPI.lib所在的目录

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

# 防止中文字符报错
msvc {
    QMAKE_CFLAGS += /utf-8
    QMAKE_CXXFLAGS += /utf-8
}

4.2 创建C++后端 (AoiBackend)

此类作为QML与DLL之间的桥梁,负责调用DLL并转换数据。

aoibackend.h

#ifndef AOIBACKEND_H
#define AOIBACKEND_H

#include <QObject>
#include <QString>
#include <QVariantList>
#include "DetectorAPI.h" // 检测SDK的API头文件

class AoiBackend : public QObject
{
    Q_OBJECT
public:
    explicit AoiBackend(QObject *parent = nullptr);

public slots:
    Q_INVOKABLE bool initializeModel(const QString &modelPath);//初始化模型
    Q_INVOKABLE void releaseModel();//释放模型
    Q_INVOKABLE void performDetection(const QString &imageUrl, const QVariantList &rois);//执行检测

signals:
    void detectionCompleted(const QVariantList &detections, const QVariantList &roiResults);//检测完成信号

private:
    // 检测类别:4个
    const std::vector<std::string> classNames = { "冲压点", "保险丝", "垫片", "插销" };
};

#endif // AOIBACKEND_H

aoibackend.cpp

#include "aoibackend.h"
#include <QImage>
#include <QDebug>
#include <QUrl>
#include <vector>

AoiBackend::AoiBackend(QObject *parent) : QObject(parent)
{
}

bool AoiBackend::initializeModel(const QString &modelPath)
{
    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;
    }

    QImage bgrImage = originalImage.convertToFormat(QImage::Format_RGB888).rgbSwapped(); // 在检测前需要先把图像转换成3通道的BGR格式

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

    std::vector<ROIResult> v_roi_results(roiInfos.size());
    DetectedObject* out_objects = nullptr;
    int out_object_count = 0;

    int status = PerformDetection(
        bgrImage.constBits(), bgrImage.width(), bgrImage.height(),
        roiInfos.data(), roiInfos.size(),
        v_roi_results.data(),
        &out_objects, &out_object_count
        );

    if (status != 0) {
        qWarning() << "PerformDetection in DLL failed.";
        return;
    }

    // 接收返回结果
    QVariantList qmlDetections;
    if (out_object_count > 0 && out_objects != nullptr) {
        for (int i = 0; i < out_object_count; ++i) {
            DetectedObject obj = out_objects[i];
            QVariantMap detectionMap;
            detectionMap["roi_index"] = obj.roi_index;
            detectionMap["class_id"] = obj.class_id;
            detectionMap["className"] = QString::fromStdString(classNames.at(obj.class_id));
            detectionMap["confidence"] = QString::asprintf("%.2f", obj.confidence);
            detectionMap["x"] = obj.x;
            detectionMap["y"] = obj.y;
            detectionMap["width"] = obj.width;
            detectionMap["height"] = obj.height;
            qmlDetections.append(detectionMap);
        }
    }

    QVariantList qmlRoiResults;
    for(const auto& res : v_roi_results) {
        QVariantMap resultMap;
        resultMap["is_abnormal"] = res.is_abnormal;
        qmlRoiResults.append(resultMap);
    }

    emit detectionCompleted(qmlDetections, qmlRoiResults);

    // 重要: 最后需要释放资源
    ReleaseDetections(out_objects);
}

4.3 注册C++后端到QML环境 (main.cpp)

main.cpp中实例化AoiBackend,注册到QML上下文,并管理模型的生命周期。

#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);
#endif
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;

    AoiBackend aoiBackend;
    engine.rootContext()->setContextProperty("aoiBackend", &aoiBackend);

    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 QML界面设计

4.4.1 自定义图像交互与显示组件 (ImageCanvas.qml)

此组件负责图像显示、ROI绘制、坐标换算以及结果的可视化渲染。

import QtQuick 2.15
import QtQuick.Controls 2.15

Item {
    id: root

    // --- Public Properties ---
    property var rois: [] // Drawn ROI rectangles (UI coordinates)
    property var drawnRois: [] // Data for drawn ROI status borders (UI coordinates)
    property var detectionResults: [] // Detection data from backend (original image coordinates)

    // --- Private Properties ---
    property real imagePaintX: 0
    property real imagePaintY: 0
    property real imagePaintWidth: 0
    property real imagePaintHeight: 0
    property real imageScale: 1.0

    // --- Geometry Calculation ---
    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;
        }
    }

    // --- Coordinate Conversion ---
    function mapToUi(originalPoint) {
        return Qt.point(originalPoint.x * imageScale + imagePaintX, originalPoint.y * imageScale + imagePaintY);
    }

    // --- Public API ---
    Component { id: roiComponent; Rectangle { border.color: "red"; border.width: 2; color: "transparent" } }

    function loadImage(path) {
        displayImage.source = path;
        clearAll();
    }

    function getOriginalImageRois() {
        var roiData = [];
        for (var i = 0; i < rois.length; ++i) {
            var rect = rois[i];
            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;
    }

    function clearAll() {
        for (var i = 0; i < rois.length; ++i) { if (rois[i] !== null) rois[i].destroy(); }
        rois = [];
        detectionResults = [];
        drawnRois = [];
    }
    
    function updateResults(detections, roiResults) {
        detectionResults = detections;
        var newDrawnRois = [];
        for(var i=0; i < rois.length; ++i) {
            newDrawnRois.push({
                x: rois[i].x,
                y: rois[i].y,
                width: rois[i].width,
                height: rois[i].height,
                is_abnormal: roiResults[i].is_abnormal
            });
        }
        drawnRois = newDrawnRois;
    }


    // --- UI Elements ---
    Image {
        id: displayImage
        anchors.fill: parent
        fillMode: Image.PreserveAspectFit
        onStatusChanged: { if (displayImage.status === Image.Ready) calculateImageGeometry(); }
    }
    
    onWidthChanged: calculateImageGeometry()
    onHeightChanged: calculateImageGeometry()

    // Repeater for ROI Status (Green/Red boxes)
    Repeater {
        model: drawnRois
        delegate: Rectangle {
            x: modelData.x
            y: modelData.y
            width: modelData.width
            height: modelData.height
            color: "transparent"
            border.color: modelData.is_abnormal ? "red" : "lime"
            border.width: 2
        }
    }
    
    // Repeater for Detected Objects (Boxes and Labels)
    Repeater {
        model: detectionResults
        delegate: Item {
            property point topLeft: mapToUi(Qt.point(modelData.x, modelData.y))
            x: topLeft.x
            y: topLeft.y
            width: modelData.width * imageScale
            height: modelData.height * imageScale

            Rectangle {
                anchors.fill: parent
                color: "transparent"
                border.color: "#3498db" // A nice blue color
                border.width: 2
            }
            Rectangle {
                x: 1
                y: 1
                width: labelText.implicitWidth + 4
                height: labelText.implicitHeight + 2
                color: "#3498db"
                
                Text {
                    id: labelText
                    anchors.centerIn: parent
                    text: modelData.className + ": " + modelData.confidence
                    color: "white"
                    font.pixelSize: 12
                }
            }
        }
    }
    
    // Watermark
    Text {
        text: "© Company Name - Internal Use Only"
        anchors.centerIn: parent
        font.pointSize: 48
        font.bold: true
        color: "white"
        opacity: 0.25
        transform: Rotation { angle: -30 }
    }

    // MouseArea for drawing ROIs
    MouseArea {
        id: mouseArea
        anchors.fill: parent
        hoverEnabled: true
        property 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));
                currentRect.x = Math.min(startPoint.x, clampedX);
                currentRect.y = Math.min(startPoint.y, clampedY);
                currentRect.width = Math.abs(clampedX - startPoint.x);
                currentRect.height = Math.abs(clampedY - startPoint.y);
            }
        }

        onReleased: (mouse) => {
            if (currentRect) {
                if (currentRect.width > 5 && currentRect.height > 5) rois.push(currentRect);
                else currentRect.destroy();
                currentRect = null;
            }
        }
    }
}

4.4.2 主界面布局 (main.qml)

主界面负责整体布局,并连接C++后端的信号与ImageCanvas的槽函数。

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Dialogs 1.3

ApplicationWindow {
    id: rootWindow
    width: 1280
    height: 720
    visible: true
    title: qsTr("工业视觉异常检测系统")

    Connections {
        target: aoiBackend
        function onDetectionCompleted(detections, roiResults) {
            console.log("Detection complete. Received", detections.length, "objects.")
            imageCanvas.updateResults(detections, roiResults)
        }
    }

    FileDialog {
        id: fileDialog
        title: "请选择一个图像文件"
        folder: shortcuts.pictures
        nameFilters: ["Image files (*.jpg *.png *.bmp)"]
        onAccepted: {
            imageCanvas.loadImage(fileDialog.fileUrl.toString())
        }
    }

    ColumnLayout {
        anchors.fill: parent
        spacing: 5

        ImageCanvas {
            id: imageCanvas
            Layout.fillWidth: true
            Layout.fillHeight: true
        }

        RowLayout {
            Layout.fillWidth: true
            Layout.alignment: Qt.AlignHCenter
            spacing: 20

            Button {
                id: loadButton
                text: qsTr("加载图像")
                onClicked: fileDialog.open()
                // --- Style ---
                Layout.preferredWidth: 120; Layout.preferredHeight: 40; font.pointSize: 12
                background: Rectangle { color: loadButton.pressed ? "#1E88E5" : "#2196F3"; radius: 5; border.color: "#1976D2"; border.width: 1 }
            }

            Button {
                id: inferButton
                text: qsTr("推理")
                onClicked: {
                    var originalRois = imageCanvas.getOriginalImageRois();
                    if (originalRois.length === 0) { console.log("没有定义任何ROI区域。"); return; }
                    if (!fileDialog.fileUrl || fileDialog.fileUrl.toString().length === 0) { console.log("请先加载一张图片。"); return; }
                    aoiBackend.performDetection(fileDialog.fileUrl.toString(), originalRois);
                }
                // --- Style ---
                Layout.preferredWidth: 120; Layout.preferredHeight: 40; font.pointSize: 12
                background: Rectangle { color: inferButton.pressed ? "#43A047" : "#4CAF50"; radius: 5; border.color: "#388E3C"; border.width: 1 }
            }

            Button {
                id: clearButton
                text: qsTr("清除标记")
                onClicked: imageCanvas.clearAll()
                // --- Style ---
                Layout.preferredWidth: 120; Layout.preferredHeight: 40; font.pointSize: 12
                background: Rectangle { color: clearButton.pressed ? "#E53935" : "#F44336"; radius: 5; border.color: "#D32F2F"; border.width: 1 }
            }
        }
        Item { Layout.fillWidth: true; Layout.preferredHeight: 10 }
    }
}

4.4.3 最终效果展示

至此,应用程序的开发工作已全部完成。编译并运行项目:

  1. 程序启动,后台加载ONNX模型。
  2. 点击“加载图像”,选择待检图片。图片加载后,中心会出现半透明的版权水印。
  3. 在图像上拖拽定义ROI。
  4. 点击“推理”按钮。

应用程序将调用DLL进行计算,接收返回的结构化数据,然后在QML前端实时绘制出所有检测框、类别标签以及置信度。同时,每个ROI区域会根据业务逻辑判断结果显示为绿色(正常)或红色(异常)边框,实现了高效、直观的视觉反馈。

五、部署

开发完成的应用程序需要在未安装开发环境的目标计算机上运行。部署过程旨在创建一个包含可执行文件及其所有依赖项的独立软件包。

5.1 编译Release版本

在Qt Creator中,将构建模式切换为“Release”,然后构建项目。这会生成经过优化的、体积更小的可执行文件,位于构建目录下的release(或Release)子目录中。

5.2 使用windeployqt工具打包Qt依赖

windeployqt是Qt官方的部署工具,能自动复制所需的Qt运行时库、QML模块和插件。

  • 打开与项目Qt版本和编译器匹配的命令行工具,例如 “Qt 5.15.2 (MSVC 2019 64-bit)”。
  • 切换到生成的可执行文件所在的release目录。
cd /d D:\Deployment\release
  • 执行windeployqt命令。--qmldir参数指向QML源文件目录,以确保所有QML依赖被正确打包。
windeployqt --qmldir D:\path\to\your\project\source qmlDemo.exe

5.3 手动添加第三方依赖

windeployqt无法处理非Qt的依赖项,需要手动复制。

  1. AI推理库 (DetectorAPI.dll): 从其Visual Studio项目的x64\Release目录中,复制到部署目录。
  2. OpenCV运行时库 (opencv_world4120.dll): 从OpenCV安装路径的build\x64\vc16\bin目录中,复制到部署目录。
  3. ONNX模型文件 (best.onnx): 将模型文件复制到部署目录的根目录下,与可执行文件放在一起。
  4. MSVC++运行时库: 如果目标计算机是纯净系统,可能缺少VC++可再发行组件。需要根据编译时使用的Visual Studio版本(本项目为VS2019),在该机器上安装对应的包。

5.4 最终部署目录结构

完成所有步骤后,部署目录结构应如下所示(部分省略):

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

整个release文件夹即为一个完整的绿色软件包,可压缩分发,在目标Windows 64位计算机上直接运行。

六、小结

本文详细阐述了一套完整的、企业级的工业视觉异常检测解决方案的研发全过程。该方案成功构建了一个功能强大且交互流畅的桌面应用程序,能够满足对金属冲压件进行自动化、高精度质检的实际需求。

项目的核心在于其清晰、现代的软件架构。通过严格遵循前后端分离的设计理念,将负责UI和可视化的QML前端,与纯粹负责AI计算和业务逻辑的C++动态链接库(DLL)进行了解耦。这种架构将计算与显示彻底分离,使得各模块职责高度内聚、易于维护,为并行开发和未来功能扩展奠定了坚实的基础。

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

  1. 纯计算AI引擎封装:将YOLOv8模型的核心推理能力封装为一个无UI依赖的DLL,该DLL仅输出结构化的检测数据,实现了算法的平台无关性和高度可复用性。
  2. 前端动态渲染:利用QML的强大能力,在前端接收原始数据后进行动态的可视化渲染,包括检测框、类别标签以及ROI状态,实现了灵活的UI呈现。
  3. 版权保护集成:通过在QML层直接添加半透明文本水印,以一种低耦合的方式实现了软件的版权保护功能。
  4. 坐标系统精确换算:解决了QML界面坐标与原始图像像素坐标之间的双向映射关系,确保了用户交互的准确性和检测结果的精确可视化。
  5. 系统集成与部署:通过Qt C++后端作为桥梁,实现了前后端的无缝通信,并利用windeployqt工具链完成了应用的可靠部署。

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

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐