使用Qt6 QML/C++ 和CMake构建海康威视摄像头应用(代码开源)

发布于:2025-07-21 ⋅ 阅读:(17) ⋅ 点赞:(0)

一、任务概述

本教程将一步步学习如何摆脱传统的 QWidget,使用现代的 Qt6 QML 技术栈,并结合强大的 C++ 后端和 CMake 构建系统,来创建一个用于控制海康威视 USB 工业相机的教学示例程序。我们将把官方的MFC C++ Demo移植为全新的 Qt6 QML 版本。

最终,将实现一个如下图所示的应用程序,它能够发现、连接、控制相机并实时显示视频流。

项目开源地址https://github.com/qianbin1989228/HikQtDemo

1.1 技术栈

  • UI: Qt6 QML
  • 后端: C++
  • 构建系统: CMake
  • 相机 SDK: 海康威视机器视觉工业相机 SDK (MVS)

1.2 准备工作

  1. 安装 Qt6:确保已经安装了 Qt6,并且包含了 msvc (Windows) C++ 编译器套件以及 CMake。
  2. 下载海康 MVS SDK:从 海康机器人官网 下载并安装机器视觉工业相机 SDK。安装时记下安装路径,后续会用到。
  3. 准备一台海康 USB 工业相机:用于实际测试。

二、开发

2.1 创建基础 QML 项目 (CMake)

先用Qt Creator创建一个Qt Quick项目,项目名称为 HikQtDemo。创建完成后,文件目录结构如下:

    HikQtDemo/
    ├── CMakeLists.txt
    ├── main.cpp
    └── Main.qml

修改Main.qml,将程序名称修改为“海康摄像头应用”:

import QtQuick

Window {
    width: 640
    height: 480
    visible: true
    title: qsTr("海康摄像头应用")  
}

编译并运行项目,正常情况下能看到一个显示 "“海康摄像头应用” 的窗口。
在这里插入图片描述


2.2 集成海康 SDK 并封装 C++ 控制器

现在有了一个基本的 Qt Quick 窗口,下一步是让我们的 C++ 代码能够与海康 SDK 对话。为此,我们将采取两个关键步骤:

  1. 将海康官方 Demo 中与平台无关的 MvCamera.h/.cpp 文件引入到我们的项目中。
  2. 创建一个专门的 C++ CameraController 类,作为 QML 界面和底层相机控制之间的“桥梁”。

2.2.1 引入 SDK 封装文件

海康官方提供的 MFC Demo 中,已经将复杂的 SDK C 接口封装成了一个易于使用的 C++ 类 CMvCamera。这是一个非常好的实践,我们直接“拿来主义”,将其集成到我们的项目中。

  1. 找到你下载并解压的海康 MVS SDK。进入 BasicDemo 目录。
  2. MvCamera.hMvCamera.cpp 这两个文件拷贝到我们 HikQtDemo 项目的根目录下(与 main.cppCMakeLists.txt 文件放在一起)。

2.2.2 配置 CMakeLists.txt 以链接 SDK

我们需要修改 CMakeLists.txt 文件,告诉它海康 SDK 的头文件和库文件在哪里。

打开 CMakeLists.txt 文件,进行如下修改:

cmake_minimum_required(VERSION 3.16)

project(HikQtDemo VERSION 0.1 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Quick)
find_package(Qt6 REQUIRED COMPONENTS Core)

qt_standard_project_setup(REQUIRES 6.8)

# --- 1. 添加海康 SDK 配置 ---
# !! 这里必须修改为自己 MVS SDK 的安装路径 !!
set(HIK_SDK_PATH "C:/Program Files (x86)/MVS/Development") # 例如 Windows 路径
# set(HIK_SDK_PATH "/opt/MVS") # 例如 Linux 路径

# 添加 SDK 头文件搜索路径
include_directories(${HIK_SDK_PATH}/Includes)

# 查找 SDK 库文件 (Windows x64)
find_library(HIK_LIB MvCameraControl HINTS "${HIK_SDK_PATH}/Libraries/win64")

if(NOT HIK_LIB)
    message(FATAL_ERROR "Hikvision MvCameraControl library not found!")
endif()
# --- 结束 SDK 配置 ---

qt_add_executable(appHikQtDemo
    main.cpp
    # --- 2. 添加我们新增的 C++ 文件 ---
    MvCamera.cpp
)

qt_add_qml_module(appHikQtDemo
    URI HikQtDemo
    VERSION 1.0
    QML_FILES
        Main.qml
        SOURCES cameracontroller.h cameracontroller.cpp
)

# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
# If you are developing for iOS or macOS you should consider setting an
# explicit, fixed bundle identifier manually though.
set_target_properties(appHikQtDemo PROPERTIES
#    MACOSX_BUNDLE_GUI_IDENTIFIER com.example.appHikQtDemo
    MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
    MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
    MACOSX_BUNDLE TRUE
    WIN32_EXECUTABLE TRUE
)

target_link_libraries(appHikQtDemo
    PRIVATE Qt6::Quick
    # --- 3. 链接海康 SDK 库 ---
    ${HIK_LIB}
)
target_link_libraries(appHikQtDemo PRIVATE Qt6::Core)

include(GNUInstallDirs)
install(TARGETS appHikQtDemo
    BUNDLE DESTINATION .
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

重要提示:

  • 务必set(HIK_SDK_PATH ...) 这一行中的路径修改为自己电脑上 MVS SDK 的实际安装路径。
  • 如果使用的是32位系统,库路径可能需要修改为 ${HIK_SDK_PATH}/Libraries/win32

2.2.3 创建 C++ CameraController

这个类是我们应用的核心。它将负责所有与相机相关的操作(初始化 SDK、查找设备、打开/关闭相机等),并向 QML 提供简洁的接口和信号。

在 Qt Creator 中,右键点击项目 -> “添加新文件…” -> “C++” -> “C++ Class”,然后按如下设置:

  • Class name: CameraController
  • Base class: 选择 QObject

点击“下一步”并完成创建。Qt Creator 会自动生成 cameracontroller.hcameracontroller.cpp 文件。

1. 修改 cameracontroller.h 头文件:

我们需要包含 MvCamera.h,并添加一个信号,用于将来向界面报告错误。

// cameracontroller.h
#ifndef CAMERACONTROLLER_H
#define CAMERACONTROLLER_H

#include <QObject>
#include "MvCamera.h" // 包含海康的封装类头文件

class CameraController : public QObject
{
    Q_OBJECT // Q_OBJECT 宏是所有使用信号和槽的 QObject 子类都必须包含的
public:
    explicit CameraController(QObject *parent = nullptr);
    ~CameraController();

signals:
    // 定义一个信号,用于在发生错误或需要通知用户时,向 QML 发送消息
    void errorOccurred(const QString &message);

};

#endif // CAMERACONTROLLER_H

2. 修改 cameracontroller.cpp 实现文件:

我们在构造函数中初始化 SDK,在析构函数中反初始化 SDK。这利用了 C++ 的 RAII (Resource Acquisition Is Initialization) 原则,确保资源被正确管理。

// cameracontroller.cpp
#include "cameracontroller.h"
#include <QDebug> // 用于在控制台打印调试信息

CameraController::CameraController(QObject *parent)
    : QObject{parent}
{
    // 构造函数:初始化 SDK
    int nRet = CMvCamera::InitSDK();
    if (MV_OK != nRet) {
        qCritical() << "Failed to initialize SDK! Error code:" << nRet;
        emit errorOccurred("初始化SDK失败!");
    } else {
        qInfo() << "SDK initialized successfully.";
    }
}

CameraController::~CameraController()
{
    // 析构函数:反初始化 SDK
    CMvCamera::FinalizeSDK();
    qInfo() << "SDK finalized.";
}

2.2.4 在 main.cpp 中注册 C++ 对象到 QML 环境

最后一步,也是最关键的一步,是让 QML 知道我们的 CameraController 对象存在。我们在 main.cpp 中创建它的一个实例,并将其设置为 QML 引擎的上下文属性 (Context Property)。

修改 main.cpp 文件:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext> // 1. 包含 QQmlContext 头文件
#include "cameracontroller.h" // 2. 包含我们的控制器头文件

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;

    // --- 3. 创建并注册 CameraController ---
    CameraController cameraController;
    engine.rootContext()->setContextProperty("cameraController", &cameraController);
    // --- 注册结束 ---

    QObject::connect(
        &engine,
        &QQmlApplicationEngine::objectCreationFailed,
        &app,
        []() { QCoreApplication::exit(-1); },
        Qt::QueuedConnection);
    engine.loadFromModule("HikQtDemo", "Main");

    return app.exec();
}

这行代码 engine.rootContext()->setContextProperty("cameraController", &cameraController); 的作用是,在 QML 的全局上下文中,创建一个名为 cameraController 的变量,这个变量就是我们在 C++ 中创建的 cameraController 对象的引用。之后,在 QML 文件中,我们就可以直接使用 cameraController 来调用它暴露出来的方法和属性了。

2.2.5 编译并运行

现在,再次编译并运行项目。

虽然程序的窗口看起来和之前一模一样,但打开 Qt Creator 下方的“应用程序输出”面板。如果一切顺利,应该能看到我们用 qInfo() 打印的两条消息:
在这里插入图片描述
这标志着我们已经成功打通了 C++ 后端和 SDK 的连接,CameraController 已经作为我们应用程序的“大脑”开始工作了。下一步就可以开始实现真正的相机功能了!


2.3 枚举与连接设备

“大脑”已经开始工作,现在要让它拥有“眼睛”和“耳朵”。在这一节,我们将实现查找连接到计算机上的所有海康相机,并在 QML 界面上把它们显示出来,然后实现打开和关闭所选设备的功能。

2.3.1 在 C++ 中实现设备枚举逻辑

我们将扩展 CameraController 类,添加一个可被 QML 调用的函数 enumerateDevices()。为了将设备列表传递给 QML,我们使用 Q_PROPERTY 宏。这是一个非常强大的 Qt 特性,它可以将一个 C++ 成员变量“暴露”给 QML,实现无缝的数据绑定。

1. 修改 cameracontroller.h 头文件

// cameracontroller.h
#ifndef CAMERACONTROLLER_H
#define CAMERACONTROLLER_H

#include <QObject>
#include <QStringList> // 1. 包含 QStringList
#include "MvCamera.h"

class CameraController : public QObject
{
    Q_OBJECT
    // 2. 定义一个只读属性 deviceList,QML 可以通过它访问设备列表
    //    READ 关键字指定了读取该属性值的 C++ 函数
    //    NOTIFY 关键字指定了一个信号,当属性值改变时,会发射这个信号通知 QML 更新
    Q_PROPERTY(QStringList deviceList READ deviceList NOTIFY deviceListChanged)

public:
    explicit CameraController(QObject *parent = nullptr);
    ~CameraController();

    // 3. 添加一个 Q_INVOKABLE 宏,让这个 C++ 函数可以被 QML 调用
    Q_INVOKABLE void enumerateDevices();

    // 4. deviceList 属性的 READ 函数
    QStringList deviceList() const;

signals:
    void errorOccurred(const QString &message);
    // 5. deviceList 属性的 NOTIFY 信号
    void deviceListChanged();

private:
    // 用于存储从 SDK 获取的原始设备信息
    MV_CC_DEVICE_INFO_LIST m_stDevList;
    // 用于在 QML 中显示的、格式化后的设备列表
    QStringList m_deviceList;
};

#endif // CAMERACONTROLLER_H

2. 修改 cameracontroller.cpp 实现文件

现在我们来实现 enumerateDevices()deviceList() 这两个函数。

// cameracontroller.cpp
#include "cameracontroller.h"
#include <QDebug>

// ... 构造和析构函数不变 ...

// deviceList 属性的 GETTER 函数,直接返回成员变量
QStringList CameraController::deviceList() const
{
    return m_deviceList;
}

// 核心的设备枚举函数
void CameraController::enumerateDevices()
{
    // 每次枚举前先清空旧列表
    m_deviceList.clear();
    memset(&m_stDevList, 0, sizeof(MV_CC_DEVICE_INFO_LIST));

    // 枚举所有 GigE 和 USB 设备
    int nRet = CMvCamera::EnumDevices(MV_GIGE_DEVICE | MV_USB_DEVICE, &m_stDevList);
    if (MV_OK != nRet) {
        emit errorOccurred("枚举设备失败!");
        return;
    }

    if (m_stDevList.nDeviceNum == 0) {
        emit errorOccurred("未找到任何设备。");
        // 即使没有找到设备,也要发射信号通知UI刷新为空列表
        emit deviceListChanged();
        return;
    }

    // 遍历设备列表,格式化设备名称并存入 m_deviceList
    for (unsigned int i = 0; i < m_stDevList.nDeviceNum; i++)
    {
        MV_CC_DEVICE_INFO* pDeviceInfo = m_stDevList.pDeviceInfo[i];
        if (pDeviceInfo == nullptr) {
            continue;
        }

        QString deviceName = "Unknown Device";
        if (pDeviceInfo->nTLayerType == MV_GIGE_DEVICE) {
            // 对于 GigE 相机,显示用户自定义名称或型号 + IP 地址
            char chUserDefinedName[256] = {0};
             if (strcmp((char*)pDeviceInfo->SpecialInfo.stGigEInfo.chUserDefinedName, "") != 0) {
                snprintf(chUserDefinedName, sizeof(chUserDefinedName), "%s",
                         pDeviceInfo->SpecialInfo.stGigEInfo.chUserDefinedName);
            } else {
                snprintf(chUserDefinedName, sizeof(chUserDefinedName), "%s",
                         pDeviceInfo->SpecialInfo.stGigEInfo.chModelName);
            }
            // 将 IP 地址转换为点分十进制格式
            uint ip = pDeviceInfo->SpecialInfo.stGigEInfo.nCurrentIp;
            QString ipAddress = QString("%1.%2.%3.%4")
                                    .arg((ip >> 24) & 0xFF)
                                    .arg((ip >> 16) & 0xFF)
                                    .arg((ip >> 8) & 0xFF)
                                    .arg(ip & 0xFF);
            deviceName = QString("[%1] %2 (%3)").arg(i).arg(QString::fromLocal8Bit(chUserDefinedName)).arg(ipAddress);
        }
        else if (pDeviceInfo->nTLayerType == MV_USB_DEVICE) {
            // 对于 USB 相机,显示用户自定义名称或型号
            char chUserDefinedName[256] = {0};
            if (strcmp((char*)pDeviceInfo->SpecialInfo.stUsb3VInfo.chUserDefinedName, "") != 0) {
                snprintf(chUserDefinedName, sizeof(chUserDefinedName), "%s",
                         pDeviceInfo->SpecialInfo.stUsb3VInfo.chUserDefinedName);
            } else {
                snprintf(chUserDefinedName, sizeof(chUserDefinedName), "%s",
                         pDeviceInfo->SpecialInfo.stUsb3VInfo.chModelName);
            }
            // 注意:SDK 返回的是 GBK/Local8bit 编码,需要转换为 UTF-8
            deviceName = QString("[%1] %2").arg(i).arg(QString::fromLocal8Bit(chUserDefinedName));
        }

        m_deviceList.append(deviceName);
    }

    // 所有设备都处理完毕后,发射信号,通知 QML 界面更新
    emit deviceListChanged();
}

2.3.2 在 QML 中显示设备列表并添加控制按钮

现在 C++ 部分已经准备就绪,我们可以修改 Main.qml 来添加一个“枚举设备”按钮和一个用于显示设备列表的下拉框 (ComboBox)。

// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 800
    height: 600
    visible: true
    title: qsTr("海康摄像头应用")

    // 1. 添加 Connections 元素来监听来自 C++ 的信号
    //    把它放在窗口的顶层,可以响应全局事件
    Connections {
        target: cameraController // 目标是我们在 main.cpp 中注册的 C++ 对象

        // 当 cameraController 发射 errorOccurred 信号时,自动调用这个函数
        // 函数的参数 message 就是 C++ emit 时传递过来的 QString
        function onErrorOccurred(message) {
            // 将接收到的消息文本设置给状态栏的标签
            statusLabel.text = message
        }
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 10

        RowLayout {
            Layout.fillWidth: true

            Button {
                id: enumButton
                text: qsTr("枚举设备")
                onClicked: cameraController.enumerateDevices()
            }

            ComboBox {
                id: deviceComboBox
                model: cameraController.deviceList
                Layout.fillWidth: true
            }

            Button {
                id: openButton
                text: qsTr("打开设备")
            }

            Button {
                id: closeButton
                text: qsTr("关闭设备")          
            }
        }

        Rectangle {
            // ... 视频显示区的占位符...
            color: "black"
            Layout.fillWidth: true
            Layout.fillHeight: true
        }
    }

    // 2. 在窗口底部添加一个 footer,里面放置状态栏
    footer: Frame {
        height: 40
        background: Rectangle { color: "#2c3e50" }
        Label {
            id: statusLabel
            text: "准备就绪"
            anchors.centerIn: parent
            color: "white"
        }
    }
}

编译并运行:点击“枚举设备”按钮。如果相机已正确连接,它的名字现在应该会出现在下拉框中了!
在这里插入图片描述

2.3.3 实现设备的打开与关闭

现在我们来实现“打开设备”和“关闭设备”按钮的功能,并管理 UI 状态(例如,当设备打开后,“打开设备”按钮应该被禁用)。

1. 再次扩展 CameraController

  • cameracontroller.h:添加 openDevicecloseDevice 函数,并添加一个 isDeviceOpen 属性来追踪设备状态。
// cameracontroller.h
// ...
class CameraController : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QStringList deviceList READ deviceList NOTIFY deviceListChanged)
    // 1. 添加一个布尔属性,用于追踪设备是否已打开
    Q_PROPERTY(bool isDeviceOpen READ isDeviceOpen NOTIFY deviceOpenChanged)

public:
    // ...
    // 2. 添加打开和关闭设备的 Q_INVOKABLE 函数
    Q_INVOKABLE void openDevice(int index);
    Q_INVOKABLE void closeDevice();

    // 3. isDeviceOpen 属性的 READ 函数
    bool isDeviceOpen() const;

signals:
    // ...
    // 4. isDeviceOpen 属性的 NOTIFY 信号
    void deviceOpenChanged();

private:
    // ...
    // 5. 添加 CMvCamera 对象指针和设备打开状态的标志位
    CMvCamera* m_pcMyCamera = nullptr;
    bool m_isDeviceOpen = false;
};
  • cameracontroller.cpp:实现具体的打开和关闭逻辑。
// cameracontroller.cpp
// ...

bool CameraController::isDeviceOpen() const
{
    return m_isDeviceOpen;
}

void CameraController::openDevice(int index)
{
    // 防止重复打开或索引无效
    if (m_isDeviceOpen || index < 0 || (unsigned int)index >= m_stDevList.nDeviceNum) {
        return;
    }

    // 检查设备是否有效
    if (m_stDevList.pDeviceInfo[index] == nullptr) {
        emit errorOccurred("所选设备无效。");
        return;
    }

    // 创建并打开设备
    m_pcMyCamera = new CMvCamera();
    int nRet = m_pcMyCamera->Open(m_stDevList.pDeviceInfo[index]);
    if (MV_OK != nRet) {
        delete m_pcMyCamera;
        m_pcMyCamera = nullptr;
        emit errorOccurred(QString("打开设备失败! 错误码: 0x%1").arg(nRet, 0, 16));
        return;
    }

    // 更新状态并通知 QML
    m_isDeviceOpen = true;
    emit deviceOpenChanged();
    emit errorOccurred("设备已打开");
}

void CameraController::closeDevice()
{
    if (!m_isDeviceOpen || !m_pcMyCamera) {
        return;
    }

    // 停止采集、关闭设备、释放资源
    // (注意:这里我们还没实现采集,但先把逻辑写好)
    m_pcMyCamera->StopGrabbing();
    m_pcMyCamera->Close();
    delete m_pcMyCamera;
    m_pcMyCamera = nullptr;

    // 更新状态并通知 QML
    m_isDeviceOpen = false;
    emit deviceOpenChanged();
    emit errorOccurred("设备已关闭");
}

2. 最终更新 Main.qml 以添加 UI 逻辑

我们现在利用 isDeviceOpen 属性来控制各个按钮的 enabled 状态,让界面更加智能。

// Main.qml
// ...
RowLayout {
    // ...
    Button {
        id: enumButton
        text: qsTr("枚举设备")
        // 当设备打开时,禁止再次枚举
        enabled: !cameraController.isDeviceOpen
        onClicked: cameraController.enumerateDevices()
    }

    ComboBox {
        id: deviceComboBox
        model: cameraController.deviceList
        Layout.fillWidth: true
        // 当设备打开时,禁止切换设备
        enabled: !cameraController.isDeviceOpen
    }

    Button {
        id: openButton
        text: qsTr("打开设备")
        // 仅当设备未打开且已选择一个有效设备时才可用
        enabled: !cameraController.isDeviceOpen && deviceComboBox.currentIndex !== -1
        onClicked: {
            // 调用 C++ 函数,并传入当前下拉框选择的索引
            cameraController.openDevice(deviceComboBox.currentIndex)
        }
    }

    Button {
        id: closeButton
        text: qsTr("关闭设备")
        // 仅当设备打开后才可用
        enabled: cameraController.isDeviceOpen
        onClicked: cameraController.closeDevice()
    }
}
// ...

最终效果
现在,你的应用程序界面逻辑已经非常完善了。

  1. 启动时,只有“枚举设备”可用。
  2. 点击枚举后,如果找到设备,下拉框和“打开设备”按钮变为可用。
  3. 选择一个设备并点击“打开设备”后,“打开设备”、“枚举设备”和下拉框被禁用,而“关闭设备”按钮变为可用。
  4. 点击“关闭设备”后,界面恢复到第2步的状态。

在这里插入图片描述
我们已经成功地建立了 QML 界面和 C++ 后端之间完整的控制链路。接下来最核心的部分,就是在那个黑色的矩形里显示出摄像头的实时画面!


2.4 实时视频流显示

这是本教程的核心。我们的目标是将海康 SDK 在后台线程捕获到的图像数据,高效、安全地传递给前台 QML 界面并显示出来。

Qt Quick 提供了一个专门为此设计的完美解决方案:QQuickImageProvider。它就像一个C++与QML之间的图像桥梁。

工作原理:

  1. 在 C++ 中创建一个 ImageProvider 类,并注册到 QML 引擎。
  2. QML 中的 Image 控件的 source 属性可以设置为一个特殊的 URL,如 "image://myprovider/live"
  3. 当 QML 需要显示这张图片时,它会自动调用我们 C++ ImageProvider 中的 requestImage() 函数来获取 QImage 对象。
  4. 当 C++ 后端有新的一帧图像时,我们只需更新 ImageProvider 中的 QImage,然后通知 QML 刷新即可。

这个过程分为四步:创建 ImageProvider -> 改造 CameraController 以处理图像回调 -> 在 main.cpp 中连接所有组件 -> 在 QML 中显示图像。

2.4.1 创建 ImageProvider 图像供应器

首先,我们创建一个新的 C++ 类,专门负责向 QML 提供图像。

  1. 在 Qt Creator 中,右键点击项目 -> “添加新文件…” -> “C++” -> “C++ Class”。
  2. Class name: ImageProvider
  3. Base class: 选择 QQuickImageProvider (需要手动输入,它不在下拉列表里)。
  4. 完成创建,并确保新生成的 imageprovider.himageprovider.cpp 文件被添加到了 CMakeLists.txtqt_add_qml_moduleSOURCES 列表中,就像 cameracontroller 一样。

1. 修改 imageprovider.h 头文件:

// imageprovider.h
#ifndef IMAGEPROVIDER_H
#define IMAGEPROVIDER_H

#include <QQuickImageProvider>
#include <QImage>
#include <QMutex>

class ImageProvider : public QQuickImageProvider
{
    Q_OBJECT // Q_OBJECT 宏是所有使用信号和槽的 QObject 子类都必须包含的
public:
    ImageProvider();

    // QML 引擎会调用这个虚函数来请求图像
    QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override;

public slots:
    // 我们定义一个槽函数,用于从其他线程接收新的图像
    void updateImage(const QImage &image);

signals:
    // 当有新图像并准备好被 QML 获取时,发射此信号
    void imageUpdated();

private:
    QImage m_image;
    QMutex m_imageMutex; // 使用互斥锁保护图像数据,防止多线程访问冲突
};

#endif // IMAGEPROVIDER_H

2. 修改 imageprovider.cpp 实现文件:

// imageprovider.cpp
#include "imageprovider.h"

ImageProvider::ImageProvider()
    : QQuickImageProvider(QQuickImageProvider::Image)
{
}

QImage ImageProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize)
{
    Q_UNUSED(id);
    Q_UNUSED(requestedSize);

    QMutexLocker locker(&m_imageMutex); // 加锁
    if (size) {
        *size = m_image.size();
    }
    return m_image;
    // 解锁会在 locker 析构时自动发生
}

void ImageProvider::updateImage(const QImage &image)
{
    if (!image.isNull()) {
        QMutexLocker locker(&m_imageMutex); // 加锁
        m_image = image;
    }
    emit imageUpdated(); // 发射信号,通知 QML 刷新
}

2.4.2 改造 CameraController 以处理图像

现在,我们需要让 CameraController 能够启动相机采集,并处理从 SDK 回调函数中传来的每一帧图像数据。

1. 修改 cameracontroller.h

// cameracontroller.h
// ... (包含之前的头文件)
#include <QImage>
#include <QMutex>

class CameraController : public QObject
{
    Q_OBJECT
    // ... (包含之前的 Q_PROPERTY)
public:
    // ...
    // 1. 添加开始和停止采集的函数
    Q_INVOKABLE void startGrabbing();
    Q_INVOKABLE void stopGrabbing();

signals:
    // ...
    // 2. 添加一个新信号,用于将处理好的 QImage 发送出去
    void newImageReady(const QImage &image);

private:
    // ...
    // 3. 声明一个静态的回调函数(C-Style),用于注册给 SDK
    static void __stdcall ImageCallBackEx(unsigned char *pData, MV_FRAME_OUT_INFO_EX* pFrameInfo, void* pUser);
    // 4. 声明一个普通的成员函数,用于在类的实例中实际处理图像数据
    void processFrame(unsigned char *pData, MV_FRAME_OUT_INFO_EX* pFrameInfo);

    // 5. 添加状态位和用于图像格式转换的缓冲区
    bool m_isGrabbing = false;
    unsigned char* m_pConvertBuf = nullptr;
    quint32 m_nConvertBufSize = 0;
    QMutex m_imageMutex; // 同样需要一个互斥锁
};

2. 修改 cameracontroller.cpp

这是本节最核心的代码。我们需要实现回调函数、图像格式转换和线程安全的数据发射。

// cameracontroller.cpp
// ...

// 在析构函数中添加清理代码
CameraController::~CameraController()
{
    if (m_isDeviceOpen) {
        closeDevice(); // 确保设备已关闭
    }
    if (m_pConvertBuf) {
        delete[] m_pConvertBuf; // 释放缓冲区
        m_pConvertBuf = nullptr;
    }
    CMvCamera::FinalizeSDK();
    qInfo() << "SDK finalized.";
}

// 静态回调函数,作为 C++ 成员函数的中转站
void __stdcall CameraController::ImageCallBackEx(unsigned char *pData, MV_FRAME_OUT_INFO_EX *pFrameInfo, void *pUser)
{
    if (pUser) {
        // pUser 就是我们注册回调时传递的 this 指针
        auto* pCam = static_cast<CameraController*>(pUser);
        // 调用成员函数来处理
        pCam->processFrame(pData, pFrameInfo);
    }
}

// 实际的图像处理函数,运行在 SDK 的子线程中
void CameraController::processFrame(unsigned char *pData, MV_FRAME_OUT_INFO_EX *pFrameInfo)
{
    QMutexLocker locker(&m_imageMutex); // 加锁

    // 图像格式转换为 RGB888,这是 QImage 常用且高效的格式
    MV_CC_PIXEL_CONVERT_PARAM_EX stConvertParam = {0};
    stConvertParam.nWidth = pFrameInfo->nWidth;
    stConvertParam.nHeight = pFrameInfo->nHeight;
    stConvertParam.pSrcData = pData;
    stConvertParam.nSrcDataLen = pFrameInfo->nFrameLen;
    stConvertParam.enSrcPixelType = pFrameInfo->enPixelType;
    stConvertParam.enDstPixelType = PixelType_Gvsp_RGB8_Packed; // 目标格式

    // 确保我们的转换缓冲区足够大
    quint32 nDstBufSize = pFrameInfo->nWidth * pFrameInfo->nHeight * 3;
    if (nDstBufSize > m_nConvertBufSize) {
        if(m_pConvertBuf) delete[] m_pConvertBuf;
        m_pConvertBuf = new unsigned char[nDstBufSize];
        m_nConvertBufSize = nDstBufSize;
    }
    stConvertParam.pDstBuffer = m_pConvertBuf;
    stConvertParam.nDstBufferSize = m_nConvertBufSize;

    if (!m_pcMyCamera) { return; } // 安全检查
    int nRet = m_pcMyCamera->ConvertPixelType(&stConvertParam);
    if (MV_OK != nRet) {
        qWarning() << "ConvertPixelType failed!";
        return;
    }

    // 将转换后的数据包装成 QImage
    // 注意 QImage::Format_RGB888 对应 RGB8_Packed
    QImage image(stConvertParam.pDstBuffer, stConvertParam.nWidth, stConvertParam.nHeight, QImage::Format_RGB888);

    // 发射信号,将 QImage 的深拷贝传递出去。
    // .copy() 确保了数据的线程安全,因为发送出去的是一个独立副本。
    emit newImageReady(image.copy());
}

// 开始采集
void CameraController::startGrabbing()
{
    if (!m_isDeviceOpen || m_isGrabbing) return;

    // 注册回调函数,将 this 指针作为用户数据(pUser)传递进去
    int nRet = m_pcMyCamera->RegisterImageCallBack(ImageCallBackEx, this);
    if (MV_OK != nRet) {
        emit errorOccurred("注册回调函数失败!");
        return;
    }

    nRet = m_pcMyCamera->StartGrabbing();
    if (MV_OK != nRet) {
        emit errorOccurred("开始采集失败!");
        return;
    }
    m_isGrabbing = true;
    emit errorOccurred("已开始采集");
}

// 停止采集
void CameraController::stopGrabbing()
{
    if (!m_isDeviceOpen || !m_isGrabbing) return;

    m_pcMyCamera->StopGrabbing();
    m_isGrabbing = false;
    emit errorOccurred("已停止采集");
}

// 在 closeDevice 中也要确保停止采集
void CameraController::closeDevice()
{
    if (!m_isDeviceOpen || !m_pcMyCamera) return;

    if (m_isGrabbing) {
        stopGrabbing();
    }
    // ... (后续的 Close 和 delete 代码不变) ...
    m_pcMyCamera->Close();
    delete m_pcMyCamera;
    m_pcMyCamera = nullptr;
    m_isDeviceOpen = false;
    emit deviceOpenChanged();
    emit errorOccurred("设备已关闭");
}

2.4.3 在 main.cpp 中连接所有组件

现在,我们需要在 main.cpp 中创建 ImageProvider 实例,注册它,并把它和 CameraController 连接起来。

// main.cpp
// ... (包含之前的头文件)
#include "imageprovider.h" // 1. 包含 ImageProvider 头文件

int main(int argc, char *argv[])
{
    // ...
    QQmlApplicationEngine engine;

    // 2. 创建 CameraController 实例并注册
    CameraController cameraController;
    engine.rootContext()->setContextProperty("cameraController", &cameraController);

    // 3. 创建 ImageProvider 实例
    auto *imageProvider = new ImageProvider();
    // 4. 将 provider 注册到 QML 引擎,并命名为 "camera"
    //    这个名字将作为 QML 中 image source URL 的 scheme (主机名)
    engine.addImageProvider(QLatin1String("camera"), imageProvider);

    // 5. 关键连接:当控制器处理完一帧新图像后,调用 provider 的更新函数
    QObject::connect(&cameraController, &CameraController::newImageReady,
                     imageProvider, &ImageProvider::updateImage);

    // ... (加载 QML 的代码不变) ...
}

2.4.4 在 QML 中显示图像

万事俱备,只欠东风。最后一步就是修改 Main.qml,添加开始/停止按钮,并让 Image 控件显示来自 ImageProvider 的图像。

// Main.qml
// ...
RowLayout {
    // ...
    Button {
        id: openButton
        // ...
    }
    Button {
        id: closeButton
        // ...
    }
    // 添加开始和停止按钮
    Button {
        id: startButton
        text: qsTr("开始采集")
        enabled: cameraController.isDeviceOpen
        onClicked: cameraController.startGrabbing()
    }
    Button {
        id: stopButton
        text: qsTr("停止采集")
        enabled: cameraController.isDeviceOpen
        onClicked: cameraController.stopGrabbing()
    }
}

// 将之前的黑色矩形替换为 Image 控件
Image {
    id: liveImage
    Layout.fillWidth: true
    Layout.fillHeight: true
    // source: "image://<provider_name>/<image_id>"
    // "camera" 是我们在 main.cpp 中注册的名字
    // "live" 是一个虚拟的 ID,可以随意命名
    source: "image://camera/live"
    // 禁止缓存,确保每次都请求新图像
    cache: false
    // 背景填充,以防图像尺寸不匹配
    fillMode: Image.PreserveAspectFit

    // 再次使用 Connections 来监听 ImageProvider 的信号
    Connections {
        target: imageProvider // 无法直接在 QML 中访问 imageProvider
                               // 我们需要一种间接的方式来刷新图像...
                               // 实际上,监听 CameraController 的信号更直接
    }

    // 更好的刷新方式:
    Connections {
         target: cameraController
         // 当 C++ 发出 newImageReady 信号时...
         function onNewImageReady() {
             // 技巧:通过改变 source 的一个无关紧要的部分来强制刷新
             // 每次信号传来,我们都给 URL 附加一个不同的时间戳,
             // QML 会认为 source 改变了,从而强制调用 requestImage()
             liveImage.source = "image://camera/live?" + new Date().getTime()
         }
    }
}
//...

注意:QML无法直接访问我们在 main.cpp 中创建的 imageProvider 指针,因此监听它的信号比较麻烦。但我们可以直接监听 cameraController 发出的 newImageReady 信号,这是一个更直接、更可靠的刷新策略。

最终效果:
现在,编译并运行你的应用。按照流程:枚举设备 -> 选择设备 -> 打开设备 -> 点击“开始采集”。此时那个黑色的矩形区域已经充满了来自海康相机的实时视频流!

在这里插入图片描述
至此,我们的教学应用已经具备了最核心的功能。但是可以看到,显示的图像曝光等参数明显不合适,需要调整这些参数。下一步,我们将继续完善它,添加参数调整、图像保存等高级功能。

三、功能完善

3.1 与 QML 交互的参数控制 (曝光、增益)

一个合格的相机控制软件,必须能让用户实时调整关键参数,如曝光时间 (Exposure) 和增益 (Gain)。我们将利用 Qt 强大的 Q_PROPERTY 宏来实现 QML 与 C++ 之间流畅的双向数据绑定

工作原理:

  1. CameraController 中为“曝光”和“增益”定义 Q_PROPERTY
  2. 每个属性都包含一个 READ 函数(供 QML 读取 C++ 的值)、一个 WRITE 函数(供 QML 修改 C++ 的值)和一个 NOTIFY 信号(当值在 C++ 端改变时,通知 QML 刷新)。
  3. 在 QML 中,我们使用 SpinBox 控件,并将其 value 属性直接绑定到 C++ 的属性上。

3.1.1 扩展 CameraController 以暴露参数

1. 修改 cameracontroller.h 头文件:
我们为曝光和增益分别添加一个 Q_PROPERTY,并声明对应的 READ/WRITE/NOTIFY 函数和信号。

// cameracontroller.h
class CameraController : public QObject
{
    Q_OBJECT
    // ... (之前的属性)
    // 1. 为曝光和增益添加可读可写的属性
    Q_PROPERTY(double exposure READ exposure WRITE setExposure NOTIFY exposureChanged)
    Q_PROPERTY(double gain READ gain WRITE setGain NOTIFY gainChanged)

public:
    // ...
    // 2. 添加一个函数,用于一次性从相机获取所有参数
    Q_INVOKABLE void getParameters();

    // 3. 声明 READ 函数
    double exposure() const;
    double gain() const;

public slots:
    // 4. 声明 WRITE 函数 (作为槽函数)
    void setExposure(double value);
    void setGain(double value);

signals:
    // ...
    // 5. 声明 NOTIFY 信号
    void exposureChanged();
    void gainChanged();

private:
    // ...
    // 6. 添加成员变量来存储参数值
    double m_exposure = 0.0;
    double m_gain = 0.0;
};

2. 修改 cameracontroller.cpp 实现文件:
实现参数的获取和设置逻辑。

// cameracontroller.cpp
// ...

// --- 1. 实现 READ 函数 ---
double CameraController::exposure() const { return m_exposure; }
double CameraController::gain() const { return m_gain; }

// --- 2. 实现 WRITE 函数 ---
void CameraController::setExposure(double value)
{
    if (!m_isDeviceOpen || !m_pcMyCamera || m_exposure == value) return;
    // 调用 SDK 的接口设置浮点型参数 "ExposureTime"
    m_pcMyCamera->SetFloatValue("ExposureTime", static_cast<float>(value));
    // 立即重新获取一次参数,以确认相机是否接受了该值 (有些值会被相机自动调整)
    getParameters();
}

void CameraController::setGain(double value)
{
    if (!m_isDeviceOpen || !m_pcMyCamera || m_gain == value) return;
    m_pcMyCamera->SetFloatValue("Gain", static_cast<float>(value));
    getParameters();
}

// --- 3. 实现 getParameters 函数 ---
void CameraController::getParameters()
{
    if (!m_isDeviceOpen || !m_pcMyCamera) return;

    MVCC_FLOATVALUE stFloatValue = {0};
    // 获取曝光值
    if (m_pcMyCamera->GetFloatValue("ExposureTime", &stFloatValue) == MV_OK) {
        // 只有当值发生变化时才更新并发送信号,避免不必要的刷新
        if (m_exposure != stFloatValue.fCurValue) {
            m_exposure = stFloatValue.fCurValue;
            emit exposureChanged();
        }
    }

    // 获取增益值
    if (m_pcMyCamera->GetFloatValue("Gain", &stFloatValue) == MV_OK) {
        if (m_gain != stFloatValue.fCurValue) {
            m_gain = stFloatValue.fCurValue;
            emit gainChanged();
        }
    }
}

// --- 4. 在 openDevice 成功后,自动获取一次参数 ---
void CameraController::openDevice(int index)
{
    // ... (之前的 openDevice 代码)
    if (MV_OK != nRet) {
        // ...
    } else {
        m_isDeviceOpen = true;
        emit deviceOpenChanged();
        emit errorOccurred("设备已打开");
        getParameters(); // 在这里调用!
    }
}

3.1.2 在 QML 中添加参数控制界面

现在,我们在界面上添加两个 SpinBox(数字调节框)来控制曝光和增益。我们将它们放置在一个 GroupBox(分组框)中,使其更整洁,并且只在相机打开时显示。

修改 Main.qml 文件:

// Main.qml
// ...
ColumnLayout {
    // ...
    RowLayout {
        // ... (顶部的按钮栏不变)
    }

    Image {
        // ... (Image 控件不变)
    }

    // --- 新增参数控制区域 ---
    GroupBox {
        title: qsTr("参数控制")
        Layout.fillWidth: true
        visible: cameraController.isDeviceOpen // 关键:仅在设备打开时显示

            // 使用 GridLayout 来对齐标签和输入框
            GridLayout {
                columns: 2 // 两列

                Label { text: qsTr("曝光 (us):") }
                SpinBox {
                    id: exposureSpinBox
                    Layout.fillWidth: true
                    from: 10      // 设置合理的最小值 (单位:微秒)
                    to: 1000000  // 设置合理的最大值
                    stepSize: 500 // 步进值
                    editable: true // 确保 SpinBox 是可编辑的
                    // 2. 当用户完成编辑时 (按回车、失去焦点、点箭头),
                    //    将最终值发送给 C++
                    onValueModified: cameraController.setExposure(value)
                    Connections {
                        target: cameraController
                        function onExposureChanged() {
                            exposureSpinBox.value = cameraController.exposure
                        }
                    }
                }

                Label { text: qsTr("增益 (dB):") }
                SpinBox {
                    id: gainSpinBox
                    Layout.fillWidth: true
                    from: 0
                    to: 16       // 增益范围通常较小
                    stepSize: 1 // 步进值
                    editable: true // 确保 SpinBox 是可编辑的
                    // 当用户完成编辑时,将最终值发送给 C++
                    onValueModified: cameraController.setGain(value)
                    // 当 C++ 的 gainChanged 信号传来时,更新 SpinBox 的值
                    Connections {
                        target: cameraController
                        function onGainChanged() {
                            gainSpinBox.value = cameraController.gain
                        }
                    }
                }
            }
        }
    }
    // ... (footer: Frame 状态栏不变)
}
//...

3.1.3 最终效果

编译并运行程序。当打开相机后:

  1. 下方的“参数控制”区域会显示出来。
  2. 曝光和增益的 SpinBox 会自动显示从相机读取到的当前值。
  3. 可以通过点击上下箭头或直接输入数字来修改曝光和增益,视频画面会实时产生变化。

在这里插入图片描述

通过 Q_PROPERTY,我们以一种非常优雅和高效的方式实现了复杂的双向数据同步,这正是 Qt/QML 框架的魅力所在。接下来,我们将继续添加图像保存功能。

3.2 图像保存功能

现在我们的应用已经能实时显示视频并调整参数了,下一步自然就是添加“拍照”功能——将当前的视频帧保存为图片文件。我们将使用 Qt 提供的 QFileDialog 来让用户选择保存路径和文件名。

工作原理:

  1. CameraController 中,需要一个变量来“暂存”从回调函数收到的最新一帧 QImage
  2. 添加一个 saveImage() 函数,它接收一个文件路径作为参数,并将暂存的 QImage 保存到该路径。
  3. 在 QML 中,添加一个“保存图像”按钮。点击它时,弹出一个 FileDialog(文件保存对话框)。
  4. 当用户在对话框中选择好路径并点击“保存”后,QML 会调用 C++ 的 saveImage() 函数,并将选择的文件路径传递过去。

3.2.1 扩展 CameraController 以支持保存

1. 修改 cameracontroller.h 头文件

// cameracontroller.h
// ...

class CameraController : public QObject
{
    // ...
public:
    // ...
    // 1. 添加一个可调用的函数用于保存图像
     Q_INVOKABLE bool saveImage(const QString &filePathUrl);

private:
    // ...
    // 2. 添加一个成员变量,用于缓存最新一帧图像,以备保存
    QImage m_lastImage;
};

2. 修改 cameracontroller.cpp 实现文件

我们需要修改 processFrame 函数,让它在发射信号前先把最新的图像缓存到 m_lastImage 中,然后实现 saveImage 函数。

// cameracontroller.cpp
// ...
#include <QUrl> // 1. 在 .cpp 文件中包含 QUrl

// 修改 processFrame 函数,增加缓存逻辑
void CameraController::processFrame(unsigned char *pData, MV_FRAME_OUT_INFO_EX *pFrameInfo)
{
    QMutexLocker locker(&m_imageMutex);
    // ... (之前的图像转换逻辑不变) ...

    QImage image(stConvertParam.pDstBuffer, stConvertParam.nWidth, stConvertParam.nHeight, QImage::Format_RGB888);

    // 关键一步:在发射信号前,将图像的深拷贝保存到成员变量中
    m_lastImage = image.copy();

    // 发射信号更新UI。
    // 这里传递一个副本,而不是直接传递 m_lastImage,是良好的多线程编程习惯。
    emit newImageReady(image.copy());
}

bool CameraController::saveImage(const QString &filePathUrl)
{
    // filePathUrl 是从 QML 传来的 URL 字符串,例如 "file:///C:/..."
    // 2. 使用 QUrl::fromUserInput() 这个强大的函数来解析它。
    //    它可以处理各种用户输入,包括 URL 字符串和本地路径。
    const QUrl url = QUrl::fromUserInput(filePathUrl);

    // 3. 检查 URL 是否是有效的本地文件
    if (!url.isLocalFile()) {
        emit errorOccurred("提供的路径不是一个有效的本地文件路径。");
        return false;
    }

    // 4. 从 URL 获取本地文件系统路径
    QString localPath = url.toLocalFile();
    if (localPath.isEmpty()) {
        emit errorOccurred("文件路径无效。");
        return false;
    }

    bool success = false;
    {
        QMutexLocker locker(&m_imageMutex);
        if (m_lastImage.isNull()) {
            emit errorOccurred("没有可保存的图像。");
            return false;
        }
        success = m_lastImage.save(localPath);
    }

    if (success) {
        emit errorOccurred(QString("图像已保存至: %1").arg(localPath));
    } else {
        emit errorOccurred("图像保存失败!");
    }
    return success;
}

3.2.2 在 QML 中添加保存按钮和文件对话框

现在我们来修改界面,添加一个“保存图像”按钮,并在点击时弹出文件对话框。

1. 导入 QtQuick.Dialogs 模块

为了使用 FileDialog,必须在 Main.qml 文件顶部导入相应的模块。

2. 添加 FileDialog 和按钮

// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs // 1. 导入 Dialogs 模块
import QtCore //2.导入核心模块

ApplicationWindow {
    // ... (之前的 Window 属性和 Connections 不变)

    // --- 在顶部的 RowLayout 中添加“保存图像”按钮 ---
    RowLayout {
        // ... (之前的按钮)
        Button {
            id: stopButton
            text: qsTr("停止采集")
            enabled: cameraController.isDeviceOpen
            onClicked: cameraController.stopGrabbing()
        }
        // 新增按钮
        Button {
            id: saveButton
            text: qsTr("保存图像")
            // 仅当相机正在采集(即 liveImage 控件有有效图像时)才可点击
            enabled: cameraController.isDeviceOpen && liveImage.status === Image.Ready
            onClicked: fileDialog.open() // 点击时打开文件对话框
        }
    }

    // ... (Image 控件和 GroupBox 控件不变)

    // --- 在 Window 的任意位置(通常是底部)添加 FileDialog ---
    // FileDialog 是一个不可见控件,所以放在哪里都可以
    FileDialog {
        id: fileDialog
        title: qsTr("请选择保存路径")
        // 设置为保存模式
        fileMode: FileDialog.SaveFile
        // 默认文件夹
        currentFolder: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
        // 文件类型过滤器
        nameFilters: [ "JPEG 图片 (*.jpg *.jpeg)", "PNG 图片 (*.png)", "BMP 图片 (*.bmp)" ]

        // 当用户选择好文件并点击“保存”后,会触发 onAccepted 信号
        onAccepted: {
            // 调用 C++ 的 saveImage 函数,并把对话框返回的 file (是一个 QUrl) 传递过去
            cameraController.saveImage(fileDialog.selectedFile)
        }
    }

    // ... (footer 不变)
}

3.2.3 最终效果

现在,当相机正在采集中时,“保存图像”按钮会变为可用。点击它:

  1. 会弹出一个标准的“文件另存为”对话框。
  2. 您可以选择保存的格式(JPG, PNG, 或 BMP),并输入文件名。
  3. 点击“保存”后,C++ 函数会被调用,将当前显示的视频帧保存到指定的位置。同时,底部的状态栏会给出成功或失败的提示。

在这里插入图片描述
我们的应用现在不仅能看、能调,还能保存了。


四、小结与展望

本文从零开始,一步步地完成了一个功能完整、代码现代、交互流畅的相机控制应用。我们不仅成功地将一个基于老式 MFC 框架的 Demo 移植到了现代化的 Qt6 QML 平台,更重要的是,我们掌握了一整套使用 QML/C++ 混合编程来解决实际问题的思路和方法。

4.1 架构回顾

让我们再次梳理一下这个项目的最终架构,这正是 QML/C++ 混合编程的精髓所在:

  • Main.qml (视图层 - View):作为应用的“脸面”,它完全专注于 UI 的布局、外观和用户交互。QML 的声明式语法让我们能快速构建出美观且响应式的界面,而无需关心底层的实现细节。它通过信号属性绑定与后端通信,做到了彻底的“前后端分离”。

  • CameraController.cpp (控制/逻辑层 - Controller/ViewModel):作为项目名副其实的“大脑”,它扮演着连接 QML 前端和底层 SDK 的核心桥梁角色。它负责处理所有的业务逻辑:

    • 向上:通过 Q_PROPERTYQ_INVOKABLE 将数据和功能“暴露”给 QML。
    • 向下:调用 CMvCamera 封装好的接口来与硬件 SDK 打交道。
    • 内部:处理复杂的数据转换、线程同步和状态管理,将粗糙的原始数据“烹饪”成 QML 可以直接“享用”的“菜肴”(如 QImage, QStringList)。
  • ImageProvider.cpp (专用服务层 - Service):这是一个典型的“专人专事”设计模式。它不处理任何业务逻辑,只专注于一件事:为 QML 高效地提供 QImage 数据。这种分离让 CameraController 的职责更清晰。

  • MvCamera.cpp (模型/适配器层 - Model/Adapter):我们几乎原封不动地复用了官方的 SDK 封装。它很好地扮演了“适配器”的角色,将底层的、纯 C 风格的、复杂的 SDK 接口,适配成了我们 C++ 代码更易于调用的、面向对象的接口。

  • CMakeLists.txt (构建系统):作为项目的“管家”,它清晰地管理了所有的源文件、依赖项(Qt 模块、海康 SDK)和编译选项,保证了项目在不同平台下的可移植性和可维护性。

整个项目完美地体现了**“逻辑在 C++,显示在 QML”**的核心思想。

4.2 关键知识点梳理

在本教程中,我们反复实践并最终掌握了几个关键的 QML/C++ 交互技术:

  1. 上下文属性 (setContextProperty):将 C++ 对象注册到 QML 全局上下文,是实现二者通信的第一步。
  2. Q_INVOKABLE:让 QML 可以像调用 JavaScript 函数一样,直接调用 C++ 的成员函数,是实现从 QML 到 C++ 控制的最直接方式。
  3. Q_PROPERTY:实现了强大的双向数据绑定。通过 READ, WRITE, NOTIFY 的组合,构建了健壮的数据同步机制。
  4. 信号与槽 (signal/slot):Qt 的灵魂。我们用它实现了 C++ 内部的线程安全通信(newImageReady),也用它实现了从 C++ 到 QML 的事件通知(errorOccurred)。
  5. QQuickImageProvider:掌握了在 QML 中显示由 C++ 实时生成的图像的“标准姿势”,解决了多线程渲染的核心难题。

4.3 未来展望

这个项目虽然功能已经齐备,但它仅仅是一个起点。基于当前的框架,你可以轻松地向更深、更广的方向探索:

  • 更丰富的参数控制:仿照曝光和增益的实现方式,添加对白平衡、帧率、ROI(感兴趣区域)等更多相机参数的控制。
  • 触发模式扩展:实现硬触发模式。这可能需要监听一个外部 IO 信号,可以扩展 CameraController 来处理。
  • 图像处理集成:在 processFrame 函数中,获取到 QImage 后,可以调用 OpenCV 或其他图像处理库进行实时的缺陷检测、测量、识别等操作,并将结果绘制在 QImage 上再显示出来。
  • 多相机支持:将当前的 CameraController 改造为可以管理一个 CMvCamera 列表的模式,并在 QML 中创建可以动态加载和切换的多视图界面。
  • 性能优化:对于更高分辨率和帧率的相机,可以探索使用 Qt6 的 QML anvas 或直接与 OpenGL/Vulkan 交互的方式进行渲染,以获得极致性能。
  • 用户体验优化:添加更详细的设置页面、保存和加载相机配置参数的功能、更美观的自定义 UI 控件等。

希望这篇详尽的、充满实战排错经验的教程能够为你打开一扇通往现代工业视觉应用开发的大门。祝你在 Qt 的世界里探索愉快,编码顺利!


网站公告

今日签到

点亮在社区的每一天
去签到