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

一文掌握Qt Quick数字图像处理项目开发(基于Qt 6.9 C++和QML,代码开源)

目录

  • 一、概述
    • 1.1 背景介绍:让证照验证变得智能、快捷
    • 1.2 项目目标
    • 1.3 技术栈
    • 1.4 代码链接
  • 二、环境准备与项目骨架
    • 2.1 创建Qt Quick项目
    • 2.2 设置图标
      • 2.2.1 设置窗口图标 (The Window Icon)
      • 2.2.2 设置可执行文件图标 (The Executable Icon)
  • 三、UI设计 (QML)
    • 3.1 可重用的照片框组件 (`PhotoFrame.qml`)
    • 3.2 主界面布局 (`Main.qml`)
  • 四、C++核心后端 (`Backend`)
    • 4.1 创建Backend类
    • 4.2 C++后端与QML的桥梁
    • 4.3 实现模拟采集流程
    • 4.4 模拟AI识别与结果展示
  • 五、摄像头图像采集
    • 5.1 引入Qt Multimedia模块
    • 5.2 线程化的相机工作者 (`CameraWorker`)
    • 5.3 从`QImage`到QML:`QQuickImageProvider`
    • 5.4 整合与最终呈现
    • 5.5 安全的退出线程
      • 5.5.1 问题根源分析
      • 5.5.2 正确的解决方案:利用信号槽机制关闭线程
  • 六、集成OpenCV图像处理
    • 6.1 在项目中集成OpenCV
    • 6.2 增强CameraWorker以处理图像
    • 6.3 设计UI设置面板
    • 6.4 使用配置文件实现参数持久化
    • 6.5 图像保存
  • 七、HTTP通信与AI集成
    • 7.1 搭建本地OCR服务器 (Python)
    • 7.2 在Qt中实现HTTP客户端
    • 7.3 优化图像显示:双击缩放
  • 八、部署和打包
    • 8.1 使用 `windeployqt` 收集依赖项
    • 8.2 使用Inno Setup制作安装包
  • 九、全文总结

一、概述

1.1 背景介绍:让证照验证变得智能、快捷

在日常生活中,我们经常需要出示身份证、护照或驾照来证明身份。传统的人工核验不仅慢,还容易出错。为了解决这个问题,证照智能识别技术应运而生。它就像一个“数字火眼金睛”,能自动、快速地完成两项核心任务:

  1. 读出信息 (Data Extraction):自动从证件上提取姓名、号码等关键文字,免去手动输入的麻烦。
  2. 辨别真伪 (Authenticity Check):检查证件本身是不是伪造的。这是安全的第一道防线。

为什么需要特殊设备?

专业的证照,尤其是护照,隐藏着许多肉眼看不见的“防伪密码”。这些密码只有在特殊光线下才会显现:

  • 紫外光 (UV):能照出普通光下不可见的荧光图案或纤维。
  • 红外光 (IR):能穿透某些墨水,识别照片是否被替换,或检查特殊的红外防伪特征。

在这里插入图片描述

(设备采集不同光谱下的图像,图片源于网络)

本项目就是要开发一款与这种多光谱采集设备配套的桌面软件。软件将控制设备,按顺序拍摄证件在白光、红外光、紫外光下的多张图像,然后利用强大的图像处理技术和云端AI,最终实现高效、可靠的自动化证照审核。

1.2 项目目标

本文的核心任务是:

  1. 优雅的UI设计:使用Qt Quick Controls 2,创建一个简洁、美观、响应式的用户界面。
  2. 自动化采集流程:精确控制设备,按“正面(白光->红外->紫外)-> 反面(白光->红外->紫外)”顺序采集6张图像,并提供清晰的进度反馈。
  3. 高性能图像处理:集成OpenCV,对采集的图像进行自动裁剪和畸变矫正,为后续识别做准备。
  4. 与云端AI集成:通过HTTP POST请求,将处理好的图像安全地发送到远程服务器,并优雅地展示返回的JSON格式识别结果。

1.3 技术栈

  • 框架:Qt 6.9.1 (在安装组件中,务必勾选 MSVC 2022、Qt Quick、Qt Quick Controls、Qt Multimedia 和 Qt Network)
  • 语言:C++
  • 编译器:MSVC (VS 2022)
  • 图像库:OpenCV 4.11.0
  • 开发工具:Qt Creator
  • 开发系统:Windows 10

相关安装教程本文不再赘述,请参考我的另一篇博客。

1.4 代码链接

本文项目源码可从网址下载:https://github.com/qianbin1989228/SmartIdReader。

下面正式进入研发环节。

二、环境准备与项目骨架

2.1 创建Qt Quick项目

  1. 打开Qt Creator,选择 文件 -> 新建文件或项目...
  2. 模板选择: 在弹出的窗口中,选择 Application (Qt) -> Qt Quick Application
  3. 项目命名: 项目名称设为 SmartIdReader,选择一个合适的创建路径。
  4. Details:
    • Qt版本: 选择你安装的Qt6版本。
    • 构建套件: 选择 Desktop_Qt 6_9_1_MSVC2022_64bit,如下图所示。
      在这里插入图片描述
  5. 完成向导:一路点击“下一步”,完成项目创建。

创建完程序以后,切换左下角的构建套件,从Debug切换为Release模式,如下图所示:
在这里插入图片描述
然后按Ctrl+R键运行程序,初始效果如下:
在这里插入图片描述
打开Main.qml,可以对窗体标题、窗体大小、窗体颜色等进行一定的修改:

import QtQuick
import QtQuick.ControlsApplicationWindow {id: rootwidth: 960height: 720visible: truetitle: "证照智能识别软件 V1.0"color: "#1e2a38"
}

修改后重新运行,效果如下:
在这里插入图片描述

2.2 设置图标

设置图标主要包括两个:

  1. 窗口图标:指应用程序运行时,在窗口标题栏和任务栏上显示的图标。
  2. 可执行文件图标:指在文件浏览器(如Windows的资源管理器)中看到的.exe文件的图标。

这两者需要分别设置,但都很简单。

2.2.1 设置窗口图标 (The Window Icon)

第一步:准备图标文件

首先,需要一个图标文件。对于在QML中用作窗口图标,.png格式是最佳选择,因为它支持透明度且通用。建议准备一张至少 256x256 像素的PNG图片,命名为appicon.png。在项目目录中创建一个icons文件夹,然后将下载的appicon.png文件拷贝到该文件夹中。

本文推荐一个免费的图标素材网站https://icons8.com/icons/set/idcard

第二步:将图标添加到Qt资源系统
修改CMakeLists.txt,添加代码如下:

qt_add_resources(appSmartIdReader "app_images"PREFIX "/"FILESicons/appicon.png
)

第三步:main.cpp中调用
打开main.cpp,添加代码如下:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QIcon>  //添加引用int main(int argc, char *argv[])
{QGuiApplication app(argc, argv);//添加下行代码app.setWindowIcon(QIcon(":/icons/appicon.png"));//其他代码...
}

现在重新编译并运行程序,会看到窗口的左上角和任务栏上已经显示了专属图标,效果如下:
在这里插入图片描述

2.2.2 设置可执行文件图标 (The Executable Icon)

首先将png图标转换为ico图标,本文推荐一个免费的转换网站:https://www.ico51.cn/,转换完成后将转换好的appicon.ico文件也拷贝到项目根目录/icons文件夹下面。

然后在程序根目录创建文件logo.rc,使用Qt Creator编辑代码如下:

IDI_ICON_LOGO               ICON    DISCARDABLE     "icons/appicon.ico"

最后在CMakeLists.txt文件中找到qt_add_executable函数,具体修改如下:

qt_add_executable(appSmartIdReadermain.cpplogo.rc
)

重新编译后,在windows的资源管理器中可以看到下图所示(应用程序图标已经更换过来):
在这里插入图片描述

三、UI设计 (QML)

美观的界面是应用的门面。本项目将采用卡片式设计来展示图片,并提供清晰的操作指引。

3.1 可重用的照片框组件 (PhotoFrame.qml)

为了保持主界面的整洁和代码的可重用性,先创建一个照片框组件。

在Qt Creator中,右键点击Main.qml -> 添加新文件... -> Qt -> QML File (Qt Quick 2),命名为PhotoFrame.qml

// PhotoFrame.qml
import QtQuick
import QtQuick.ControlsFrame {id: rootwidth: 300height: 300padding: 0background: Rectangle {color: "#2c3e50"border.color: sourceImage.status === Image.Ready ? "#1abc9c" : "#34495e"border.width: 2}property alias imageSource: sourceImage.sourceproperty string title: "待采集"Image {id: sourceImageanchors.fill: parentanchors.margins: 2fillMode: Image.PreserveAspectFitasynchronous: true}Label {text: titleanchors.horizontalCenter: parent.horizontalCenteranchors.top: parent.topanchors.topMargin: 8color: "white"font.bold: truebackground: Rectangle {color: "#000000"opacity: 0.5radius: 3}padding: 4visible: sourceImage.status !== Image.Ready}BusyIndicator {anchors.centerIn: parentrunning: sourceImage.status === Image.Loading}
}

这个组件包含了图像显示、加载动画、边框状态反馈和标题,相对比较完备。

3.2 主界面布局 (Main.qml)

现在开始搭建主界面。用下面的代码替换Main.qml的全部内容。

// main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.LayoutsApplicationWindow {id: rootwidth: 960height: 720visible: truetitle: "证照智能识别软件 V1.0"color: "#1e2a38" // 深色背景// 状态栏,用于显示提示信息footer: Frame {height: 40background: Rectangle { color: "#2c3e50" }Label {id: statusLabeltext: "准备就绪"anchors.centerIn: parentcolor: "white"}}ColumnLayout {anchors.fill: parentanchors.margins: 10spacing: 10// 图像显示区GridLayout {id: photoGridLayout.fillWidth: trueLayout.fillHeight: truecolumns: 3columnSpacing: 15rowSpacing: 15Layout.alignment: Qt.AlignHCenterPhotoFrame {id: frameFrontWhitetitle: "正面-白光"Layout.fillWidth: trueLayout.fillHeight: true}PhotoFrame {id: frameFrontIRtitle: "正面-红外"Layout.fillWidth: trueLayout.fillHeight: true}PhotoFrame {id: frameFrontUVLayout.fillWidth: trueLayout.fillHeight: truetitle: "正面-紫外"}PhotoFrame {id: frameBackWhitetitle: "反面-白光"Layout.fillWidth: trueLayout.fillHeight: true}PhotoFrame {id: frameBackIRtitle: "反面-红外"Layout.fillWidth: trueLayout.fillHeight: true}PhotoFrame {id: frameBackUVtitle: "反面-紫外"Layout.fillWidth: trueLayout.fillHeight: true}}// 操作区RowLayout {Layout.alignment: Qt.AlignHCenterspacing: 50Button {id: scanButtonLayout.preferredWidth: 120Layout.preferredHeight: 50text: "  开始采集"icon.source: "qrc:/icons/camera.svg" // 这里需要添加一个图标onClicked: {// 调用C++后端的采集函数// backend.startScan()}background: Rectangle {color: parent.down ? "#16a085" : "#1abc9c"radius: 5}}Button {id: processButtontext: "  处理并识别"Layout.preferredWidth: 120Layout.preferredHeight: 50enabled: false // 采集完成后才可用icon.source: "qrc:/icons/zap.svg"onClicked: {// backend.processAndRecognize()}background: Rectangle {color: parent.down ? "#16a085" : "#1abc9c"radius: 5}}}}
}

为了让界面更好看,需要一些图标。在之前创建的icons文件夹,放入两个SVG图标(例如从Feather Icons下载 camera.svgzap.svg,并重命名)。然后打开CMakeLists.txt文件,修改对应的代码,把新的图标资源文件添加进去:

qt_add_resources(appSmartIdReader "app_images"PREFIX "/"FILESicons/appicon.pngicons/zap.svgicons/camera.svg
)

重新编译运行后,效果如下图所示:
在这里插入图片描述

四、C++核心后端 (Backend)

4.1 创建Backend类

前面的UI界面已经就位,但它还是一个“空壳子”。现在,需要创建一个C++后端对象,并让QML能够访问它。

在Qt Creator中,添加新文件... -> C++ -> C++ Class

  • 类名: Backend
  • 基类: 选择 QObject

然后单击完成即可。

4.2 C++后端与QML的桥梁

前面创建的Backend对象将成为我们所有业务逻辑(设备控制、图像处理、网络请求)的“大脑”。

核心目标: 将一个C++的Backend实例暴露给QML环境,使其成为一个全局可访问的对象。

第一步:在main.cpp中实例化并注册Backend

这是连接C++和QML世界最关键的一步。具体的,将创建一个Backend的实例,并使用QQmlApplicationEngine的rootContext()方法,将其注册为一个“上下文属性”。这样,在QML的任何地方,我们都可以通过属性名(我们将其命名为 backend)来调用这个C++对象。

  1. 打开 main.cpp 文件。
  2. 包含 backend.h 头文件。
  3. 在创建 QQmlApplicationEngine 之后、加载 Main.qml 之前,创建 Backend 对象并将其注册到上下文中。

修改后的完整 main.cpp 代码如下:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QIcon>
#include "backend.h" // 1. 包含 Backend 头文件
#include <QQmlContext> // 添加QML上下文内容int main(int argc, char *argv[])
{QGuiApplication app(argc, argv);app.setWindowIcon(QIcon(":/icons/appicon.png"));QQmlApplicationEngine engine;// 2. 创建 Backend 实例Backend backend;// 3. 将 C++ 对象注册为 QML 的上下文属性//    第一个参数是 QML 中使用的名字,第二个参数是 C++ 对象的引用engine.rootContext()->setContextProperty("backend", &backend);QObject::connect(&engine,&QQmlApplicationEngine::objectCreationFailed,&app,[]() { QCoreApplication::exit(-1); },Qt::QueuedConnection);engine.loadFromModule("SmartIdReader", "Main");return app.exec();
}

第二步:验证连接

虽然界面看起来和之前一样,但这一步至关重要。它意味着我们的QML现在已经“认识”了名为 backend 的C++对象。重新编译并运行程序,确保一切正常。如果没有报错,说明“桥梁”已经成功架设。

4.3 实现模拟采集流程

现在,将为“开始采集”按钮赋予生命。为了方便读者复现,本文不直接集成真实的硬件设备,而是采用一个QTimer来模拟一个耗时的、分步骤的采集过程。每一步,C++后端都会通过**信号(Signal)**通知QML前端更新状态和显示的图片。

核心目标:

  1. 在C++中创建一个可被QML调用的startScan方法。
  2. 使用信号将采集进度和新捕获的图像路径传递给QML。
  3. QML接收信号并动态更新UI。

第一步:在Backend类中定义信号和槽

  1. 打开 backend.h,添加信号和Q_INVOKABLE方法。
    • Q_INVOKABLE:这个宏让一个普通的C++成员函数可以被QML直接调用。
    • signals::信号是Qt对象间通信的基石。它们只声明,不实现。当后端状态改变时,我们就emit(发射)一个信号。
    • 我们将定义两个信号:scanProgressChanged用于更新状态栏文本,newImageCaptured用于通知QML有新图片了。
// backend.h
#ifndef BACKEND_H
#define BACKEND_H#include <QObject>
#include <QTimer> // 添加QTimer头文件
#include <QDebug>class Backend : public QObject
{Q_OBJECT
public:explicit Backend(QObject *parent = nullptr);Q_INVOKABLE void startScan(); // 宏,使其可以在QML中调用signals:// 信号:用于通知QML扫描进度改变// 参数1: 当前步骤, 参数2: 提示信息void scanProgressChanged(int step, const QString &message);// 信号:通知QML一张新的图像已被“采集”// 参数1: 图像的资源路径, 参数2: 对应的照片框索引(0-5)void newImageCaptured(const QString &imagePath, int frameIndex);private slots:void onScanStep(); // 定时器触发的槽函数private:QTimer m_scanTimer; // 用于模拟扫描过程的定时器int m_scanStep;     // 记录当前扫描到第几步
};#endif // BACKEND_H

第二步:准备模拟用的样本图片

  1. 在项目根目录创建一个sample_images文件夹。
  2. 找6张不同的图片(任何.jpg或.png格式都可以),将它们命名为sample_1.jpgsample_6.jpg,并放入sample_images文件夹。
  3. 重要:将这些图片添加到CMakeLists.txt的资源文件中,这样它们才能被qrc:路径访问。
# CMakeLists.txt
# ... (找到之前的 qt_add_resources) ...
qt_add_resources(appSmartIdReader "app_images"PREFIX "/"FILESicons/appicon.pngicons/zap.svgicons/camera.svg# 添加样本图片sample_images/sample_1.jpgsample_images/sample_2.jpgsample_images/sample_3.jpgsample_images/sample_4.jpgsample_images/sample_5.jpgsample_images/sample_6.jpg
)

第三步:在Backend.cpp中实现模拟逻辑

我们使用QTimer每隔500毫秒触发一次onScanStep槽函数,模拟采集一张图片。

// backend.cpp
#include "backend.h"Backend::Backend(QObject *parent): QObject{parent}, m_scanStep(0)
{// 连接定时器的timeout信号到我们的槽函数connect(&m_scanTimer, &QTimer::timeout, this, &Backend::onScanStep);
}void Backend::startScan()
{qDebug() << "Scan started from C++";m_scanStep = 0;m_scanTimer.start(500); // 每500毫秒触发一次
}void Backend::onScanStep()
{m_scanStep++;QString message;QString imagePath;int frameIndex = m_scanStep - 1;switch(m_scanStep) {case 1: message = "正在采集: 正面-白光..."; imagePath = "qrc:/sample_images/sample_1.jpg"; break;case 2: message = "正在采集: 正面-红外..."; imagePath = "qrc:/sample_images/sample_2.jpg"; break;case 3: message = "正在采集: 正面-紫外..."; imagePath = "qrc:/sample_images/sample_3.jpg"; break;case 4: message = "正在采集: 反面-白光..."; imagePath = "qrc:/sample_images/sample_4.jpg"; break;case 5: message = "正在采集: 反面-红外..."; imagePath = "qrc:/sample_images/sample_5.jpg"; break;case 6: message = "正在采集: 反面-紫外..."; imagePath = "qrc:/sample_images/sample_6.jpg"; break;}// 发射信号,通知QMLemit scanProgressChanged(m_scanStep, message);if (!imagePath.isEmpty()) {emit newImageCaptured(imagePath, frameIndex);}if (m_scanStep >= 6) {m_scanTimer.stop();emit scanProgressChanged(7, "采集完成");// 采集完成后,启用“处理”按钮 (我们将在下一步中用到)}
}

第四步:在QML中响应信号并更新UI

  1. 打开 Main.qml
  2. 在“开始采集”按钮的onClicked处理器中,调用backend.startScan()
  3. 添加一个Connections元素,它的target指向全局backend对象,这样就可以监听其信号了。
// main.qml
// ... (imports) ...ApplicationWindow {// ... (id, width, height etc.) ...// 添加一个Connections元素来监听C++信号Connections {target: backend // 目标是我们在main.cpp中注册的backend对象// 当C++发射scanProgressChanged信号时,这个函数会被调用function onScanProgressChanged(step, message) {statusLabel.text = message;// 当采集完成时(我们虚构了第7步作为完成标志)if (step >= 7) {processButton.enabled = true; // 启用处理按钮             }}// 当C++发射newImageCaptured信号时,这个函数会被调用function onNewImageCaptured(imagePath, frameIndex) {switch(frameIndex) {case 0: frameFrontWhite.imageSource = imagePath; break;case 1: frameFrontIR.imageSource = imagePath; break;case 2: frameFrontUV.imageSource = imagePath; break;case 3: frameBackWhite.imageSource = imagePath; break;case 4: frameBackIR.imageSource = imagePath; break;case 5: frameBackUV.imageSource = imagePath; break;}}}footer: Frame { /* ... */ }ColumnLayout {// ...GridLayout { /* ... */ }RowLayout {// ...Button {id: scanButton// ...onClicked: {// 重置UI状态processButton.enabled = false// 清空所有图片frameFrontWhite.imageSource = ""frameFrontIR.imageSource = ""frameFrontUV.imageSource = ""frameBackWhite.imageSource = ""frameBackIR.imageSource = ""frameBackUV.imageSource = ""// 调用C++后端的采集函数backend.startScan()}}Button {id: processButton// ...}}}
}

运行效果

现在,重新编译并运行程序。点击“开始采集”按钮,将看到:

  1. 状态栏的文本会随着采集步骤而更新。
  2. 六个照片框会依次、动态地显示出sample_images文件夹中的图片。
  3. 采集完成后,“处理并识别”按钮会变为可用状态,“开始采集”按钮暂时不可用(为了演示,我们也在onClicked中加入了重置逻辑)。

在这里插入图片描述
到此,我们已经成功地将UI操作连接到了C++后端,并通过信号将后端的状态变化反馈回UI,实现了动态交互。

4.4 模拟AI识别与结果展示

最后一步是模拟图像处理和云端识别的过程,并将识别结果展示在界面上。

核心目标:

  1. 在C++中创建processAndRecognize方法。
  2. 模拟网络延迟后,发射一个包含识别结果的信号。
  3. 在QML中添加一个区域,用于美观地展示这些结果。

第一步:在Backend中添加识别逻辑

我们将添加一个新的信号recognitionComplete,它携带一个QVariantMap作为参数。QVariantMap非常适合用来模拟JSON对象,因为QML可以直接将其作为JavaScript对象使用。

  1. backend.h 中添加新信号和新方法。
// backend.h
// ...
signals:// ... (之前的信号)// 信号:识别完成,携带结果void recognitionComplete(const QVariantMap &results);public slots: // 将方法改为槽,或者继续用 Q_INVOKABLEvoid processAndRecognize();
// ...
  1. backend.cpp 中实现。同样使用QTimer::singleShot来模拟网络延迟。
// backend.cpp
// ... (之前的代码)void Backend::processAndRecognize()
{emit scanProgressChanged(8, "正在处理和识别图像...");// 使用 singleShot 模拟一个 2 秒的网络/处理延迟QTimer::singleShot(2000, this, [this](){// 创建一个模拟的识别结果QVariantMap results;results["name"] = "张三 (ZHANG, SAN)";results["gender"] = "男 / M";results["birthDate"] = "1990.01.01";results["idNumber"] = "G12345678";results["nationality"] = "中国 / CHN";results["validUntil"] = "2030.01.01";results["confidence"] = "98.7%";// 发射完成信号,并携带结果emit recognitionComplete(results);emit scanProgressChanged(9, "识别完成!");});
}

第二步:在Main.qml中添加结果展示区和处理逻辑

我们需要在UI上增加一块区域来显示识别出的信息。

// main.qml
// ...
ApplicationWindow {// ...Connections {target: backend// ... (onScanProgressChanged, onNewImageCaptured) ...// 新增:处理识别完成信号function onRecognitionComplete(results) {// 将QVariantMap直接当做JS对象使用nameLabel.text = results.namegenderLabel.text = results.genderbirthDateLabel.text = results.birthDateidNumberLabel.text = results.idNumbernationalityLabel.text = results.nationalityvalidUntilLabel.text = results.validUntilconfidenceLabel.text = results.confidence// 显示结果区域resultsFrame.visible = true}}// ... (footer) ...ColumnLayout {// ...GridLayout { /* ... */ }// 新增:识别结果展示区Frame {id: resultsFrameLayout.fillWidth: trueLayout.topMargin: 20visible: false // 默认隐藏background: Rectangle { color: "#2c3e50" }GridLayout {anchors.fill: parentanchors.margins: 10columns: 2columnSpacing: 20rowSpacing: 5// 左侧标签Label { text: "姓名:"; color:"#bdc3c7"; font.bold: true }Label { id: nameLabel; color: "white" }Label { text: "性别:"; color:"#bdc3c7"; font.bold: true }Label { id: genderLabel; color: "white" }Label { text: "出生日期:"; color:"#bdc3c7"; font.bold: true }Label { id: birthDateLabel; color: "white" }Label { text: "证件号码:"; color:"#bdc3c7"; font.bold: true }Label { id: idNumberLabel; color: "white" }// 右侧标签Label { text: "国籍:"; color:"#bdc3c7"; font.bold: true }Label { id: nationalityLabel; color: "white" }Label { text: "有效期至:"; color:"#bdc3c7"; font.bold: true }Label { id: validUntilLabel; color: "white" }Label { text: "置信度:"; color:"#bdc3c7"; font.bold: true }Label { id: confidenceLabel; color: "white"; font.pixelSize: 16 }}}RowLayout {// ...Button {id: scanButtononClicked: {// ...resultsFrame.visible = false // 点击扫描时隐藏结果// ...}}Button {id: processButton// ...onClicked: {backend.processAndRecognize()}}}}
}

运行最终效果

编译并运行程序。

  1. 点击“开始采集”,等待6张图片加载完成。
  2. “处理并识别”按钮变得可用,点击它。
  3. 状态栏显示“正在处理…”,等待2秒。
  4. 识别结果区域出现,并显示出我们从C++传递过来的信息。

在这里插入图片描述
到这里,整个UI和业务流程基本建设完了。

五、摄像头图像采集

到目前为止,我们的应用程序还只是一个“演员”,通过模拟数据来表演采集流程。现在,是时候让它成为一个真正的“摄影师”了。在这一章,我们将抛开QTimer模拟,转而使用Qt Multimedia模块来控制真实的USB摄像头,并将采集到的图像显示在UI上。

5.1 引入Qt Multimedia模块

Qt Multimedia是Qt官方提供的、用于处理音频、视频、收音机和摄像头等媒体内容的跨平台框架。

第一步:配置CMakeLists.txt

要使用摄像头功能,我们必须告诉CMake链接Qt Multimedia库。打开项目的CMakeLists.txt文件,在find_package指令中添加Multimedia

# CMakeLists.txt# ... (之前的配置) ...find_package(Qt6 REQUIRED COMPONENTS Quick)
find_package(Qt6 REQUIRED COMPONENTS Core)
find_package(Qt6 REQUIRED COMPONENTS Multimedia) # 1. 添加Multimediatarget_link_libraries(appSmartIdReaderPRIVATE Qt6::Quick
)
target_link_libraries(appSmartIdReader PRIVATE Qt6::Core)
target_link_libraries(appSmartIdReaderPRIVATE Qt6::Multimedia # 2. 链接Multimedia库
)# ... (其他配置) ...

完成这一步后,项目就具备了使用摄像头API的能力。

5.2 线程化的相机工作者 (CameraWorker)

直接在主线程(UI线程)中操作摄像头,尤其是在捕获图像时,可能会导致界面卡顿,严重影响用户体验。因此,最佳实践是将所有与硬件交互的耗时操作都放到一个单独的工作线程中。

具体的,本文将创建一个CameraWorker类,它将负责所有与摄像头相关的底层操作。

第一步:创建CameraWorker类文件

在Qt Creator中,像之前创建Backend一样,添加新文件... -> C++ -> C++ Class

  • 类名: CameraWorker
  • 基类: QObject

第二步:定义cameraworker.h

这个头文件将定义CameraWorker的接口,包括它能接收的指令(槽)和它能发出的通知(信号)。

// cameraworker.h
#ifndef CAMERAWORKER_H
#define CAMERAWORKER_H#include <QObject>
#include <QCamera>
#include <QMediaCaptureSession>
#include <QImageCapture>
#include <QTimer>class CameraWorker : public QObject
{Q_OBJECT
public:explicit CameraWorker(QObject *parent = nullptr);~CameraWorker();signals:// 信号:通知UI更新进度void progressUpdated(int step, const QString &message);// 信号:一张新图像已捕获完成,传递的是QImage对象void imageCaptured(const QImage &image, int frameIndex);// 信号:全部采集流程结束void captureFinished();// 信号:发生错误void errorOccurred(const QString &errorMessage);public slots:// 槽:从Backend接收开始采集的指令void startCapture();private slots:// 槽:当QImageCapture捕获到一帧图像时被调用void processCapturedImage(int requestId, const QImage &img);// 槽:用于触发下一次拍照的辅助定时器void triggerNextCapture();private:QCamera *m_camera = nullptr;QMediaCaptureSession *m_captureSession = nullptr;QImageCapture *m_imageCapture = nullptr;int m_captureStep;const int m_totalSteps = 6;
};#endif // CAMERAWORKER_H

第三步:实现cameraworker.cpp

这是核心逻辑。

  1. 构造函数:查找可用的摄像头,并初始化QCameraQMediaCaptureSessionQImageCapture对象。
  2. startCapture:启动摄像头,并准备开始第一次拍照。
  3. processCapturedImage:这是由QImageCaptureimageCaptured信号触发的关键槽函数。它接收捕获到的QImage,通过信号将其发送出去,并判断是否需要进行下一次捕获。
  4. triggerNextCapture:我们使用一个500毫秒的单次定时器来触发下一次m_imageCapture->capture()调用,从而模拟采集间隔。
// cameraworker.cpp
#include "cameraworker.h"
#include <QMediaDevices>
#include <QDebug>CameraWorker::CameraWorker(QObject *parent): QObject{parent}, m_captureStep(0)
{// 查找默认摄像头const QList<QCameraDevice> cameras = QMediaDevices::videoInputs();if (cameras.isEmpty()) {emit errorOccurred("未找到摄像头设备");return;}m_camera = new QCamera(cameras.first(), this);m_captureSession = new QMediaCaptureSession(this);m_imageCapture = new QImageCapture(this);m_captureSession->setCamera(m_camera);m_captureSession->setImageCapture(m_imageCapture);// 连接图像捕获信号到处理槽connect(m_imageCapture, &QImageCapture::imageCaptured, this, &CameraWorker::processCapturedImage);m_camera->start(); // 预启动相机以加快首次捕获速度
}CameraWorker::~CameraWorker()
{if(m_camera && m_camera->isActive()) {m_camera->stop();}
}void CameraWorker::startCapture()
{if (!m_camera) {emit errorOccurred("摄像头未初始化");return;}if (m_camera->error() != QCamera::NoError) {emit errorOccurred("摄像头错误: " + m_camera->errorString());return;}m_captureStep = 0;// 使用 QTimer::singleShot 来确保 triggerNextCapture 在 CameraWorker 的线程中执行QTimer::singleShot(200, this, &CameraWorker::triggerNextCapture);
}void CameraWorker::triggerNextCapture()
{if (m_captureStep < m_totalSteps) {const QString messages[] = {"正在采集: 正面-白光...", "正在采集: 正面-红外...", "正在采集: 正面-紫外...","正在采集: 反面-白光...", "正在采集: 反面-红外...", "正在采集: 反面-紫外..."};emit progressUpdated(m_captureStep + 1, messages[m_captureStep]);m_imageCapture->capture();}
}void CameraWorker::processCapturedImage(int requestId, const QImage &img)
{Q_UNUSED(requestId);qDebug() << "Captured image for step" << m_captureStep + 1 ;// 发射带有QImage的信号emit imageCaptured(img, m_captureStep);m_captureStep++;if (m_captureStep < m_totalSteps) {// 启动定时器,500ms后进行下一次捕获QTimer::singleShot(500, this, &CameraWorker::triggerNextCapture);} else {// 全部完成emit progressUpdated(m_totalSteps + 1, "采集完成");emit captureFinished();}
}

5.3 从QImage到QML:QQuickImageProvider

QML的Image元素不能直接显示C++的QImage对象。这里需要一个“翻译官”,它能根据一个特殊的URL(例如"image://...")来提供QImage数据。这个角色由QQuickImageProvider扮演。

第一步:创建ImageProvider

  • 类名: ImageProvider
  • 基类: QObject,同时需要手动包含并继承 QQuickImageProvider
// imageprovider.h
#ifndef IMAGEPROVIDER_H
#define IMAGEPROVIDER_H#include <QQuickImageProvider>
#include <QImage>
#include <QHash>class ImageProvider : public QQuickImageProvider
{
public:ImageProvider();// QML引擎会调用这个函数来请求图片QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override;// 一个公共方法,用于从C++端更新图片void updateImage(const QString &id, const QImage &image);private:// 使用哈希表存储图片,用ID作为键QHash<QString, QImage> m_images;
};#endif // IMAGEPROVIDER_H

第二步:实现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(requestedSize);if (m_images.contains(id)) {QImage img = m_images.value(id);if (size) {*size = img.size();}return img;}// 如果没有找到图片,返回一个空的QImagereturn QImage();
}void ImageProvider::updateImage(const QString &id, const QImage &image)
{m_images.insert(id, image);
}

5.4 整合与最终呈现

现在,可以将所有部分组装起来。Backend将负责创建和管理CameraWorker线程,并充当CameraWorker和QML之间的中介。

第一步:更新Backend以管理CameraWorker

  1. backend.h中,移除旧的模拟用成员,添加线程和worker成员。
// backend.h
#ifndef BACKEND_H
#define BACKEND_H#include <QObject>
#include <QVariantMap>
#include <QThread> // 1. 包含QThread
#include "cameraworker.h" // 2. 包含CameraWorker
#include "imageprovider.h"class Backend : public QObject
{Q_OBJECT
public:explicit Backend(ImageProvider* provider, QObject *parent = nullptr);~Backend();Q_INVOKABLE void startScan();Q_INVOKABLE void processAndRecognize();signals:// 这些信号保持不变,QML已经连接好了void scanProgressChanged(int step, const QString &message);void newImageCaptured(const QString &imageUrl, int frameIndex); // 注意,参数类型改为URL字符串void recognitionComplete(const QVariantMap &results);// 内部信号,用于安全地启动工作线程中的任务void startCaptureRequested();public slots:// 从worker接收捕获的图像void handleCapturedImage(const QImage &image, int frameIndex);private:QThread* m_cameraThread;CameraWorker* m_cameraWorker;ImageProvider* m_imageProvider; // 后端将拥有ImageProvider
};#endif // BACKEND_H
  1. main.cpp中,除了注册Backend,我们还需要注册ImageProvider
// main.cpp
// ...
#include "imageprovider.h" // 1. 包含ImageProviderint main(int argc, char *argv[])
{// ... (app创建) ...QQmlApplicationEngine engine;// 2. 实例化ImageProviderImageProvider *imageProvider = new ImageProvider();// 3. Backend现在需要ImageProvider的指针Backend backend(imageProvider);engine.rootContext()->setContextProperty("backend", &backend);// 4. 向QML引擎注册Provider,命名为 "livefeed"engine.addImageProvider("livefeed", imageProvider);// ... (加载QML和运行app) ...
}

注意: 我们修改了Backend的构造函数,让它接收ImageProvider的指针。

  1. backend.cpp中实现最终的整合逻辑。
// backend.cpp
#include "backend.h"
#include "imageprovider.h" // 包含 ImageProvider
#include <QTimer>Backend::Backend(ImageProvider* provider, QObject *parent) // 修改构造函数: QObject{parent}, m_imageProvider(provider)
{m_cameraThread = new QThread(this);m_cameraWorker = new CameraWorker();m_cameraWorker->moveToThread(m_cameraThread);// 连接:Backend请求 -> Worker执行connect(this, &Backend::startCaptureRequested, m_cameraWorker, &CameraWorker::startCapture);// 连接:Worker信号 -> Backend槽 -> Backend信号 -> QMLconnect(m_cameraWorker, &CameraWorker::progressUpdated, this, &Backend::scanProgressChanged);connect(m_cameraWorker, &CameraWorker::imageCaptured, this, &Backend::handleCapturedImage);connect(m_cameraWorker, &CameraWorker::captureFinished, [this](){// 这里可以添加完成后的逻辑,比如自动触发识别});connect(m_cameraWorker, &CameraWorker::errorOccurred, this, [this](const QString& msg){emit scanProgressChanged(0, msg); // 将错误显示在状态栏});// 线程退出时,自动删除workerconnect(m_cameraThread, &QThread::finished, m_cameraWorker, &QObject::deleteLater);m_cameraThread->start();
}Backend::~Backend()
{m_cameraThread->quit();m_cameraThread->wait();
}void Backend::startScan()
{// 通过信号安全地调用工作线程中的方法emit startCaptureRequested();
}void Backend::handleCapturedImage(const QImage &image, int frameIndex)
{// 构造一个唯一的图片IDQString imageId = QString("capture_%1").arg(frameIndex);// 更新ImageProvider中的图片m_imageProvider->updateImage(imageId, image);// 构造给QML的URLQString imageUrl = QString("image://livefeed/%1").arg(imageId);// 发射带有URL的信号给QMLemit newImageCaptured(imageUrl, frameIndex);
}// processAndRecognize 方法保持不变
// ...

第二步:最后修改Main.qml

QML端的修改非常小,因为它之前已经设计为响应newImageCaptured(imageUrl, frameIndex)信号,而我们现在传递的正是QML Image元素可以直接使用的image:// URL。

运行最终效果

现在,编译并运行应用程序,并且确保USB摄像头已连接。

  1. 点击“开始采集”按钮。
  2. 程序会控制摄像头,每隔约半秒拍摄一张照片。
  3. 每一张新捕获的照片会实时显示在对应的PhotoFrame中,整个UI界面保持流畅响应。
  4. 采集完成后,流程与之前一样,可以点击“处理并识别”来查看模拟结果。

在这里插入图片描述

5.5 安全的退出线程

前面的代码执行后可以正常运行,但是有个很重要的Bug出现了。最后,单击程序关闭按钮后,对应的摄像头采集线程并没有关闭。这意味着主线程在等待工作线程结束时被卡住,或者工作线程的退出逻辑没有被正确触发。

根据我们之前的代码,问题很可能出在**Backend的析构函数QThread的退出机制**上。

5.5.1 问题根源分析

在之前的Backend析构函数中,我们这样写:

Backend::~Backend()
{m_cameraThread->quit();m_cameraThread->wait();
}

这段代码的意图是:

  1. m_cameraThread->quit(): 告诉m_cameraThread的事件循环(event loop)退出。当事件循环处理完所有待办事件后,它就会停止。
  2. m_cameraThread->wait(): 阻塞主线程,直到m_cameraThreadrun()函数执行完毕(即线程真正结束)。

这里存在一个潜在的死锁风险或无限等待CameraWorker是在m_cameraThread中运行的,但它本身是在主线程中创建的,并且其父对象是Backend(也在主线程)。当主线程退出,尝试析构Backend时,它会等待m_cameraThread结束。但m_cameraThread的退出可能依赖于CameraWorker中某些事件的完成,而CameraWorker的父对象Backend正在被析构,这可能导致一个复杂的依赖循环。

5.5.2 正确的解决方案:利用信号槽机制关闭线程

解决这个问题的最佳实践是确保线程的退出流程清晰、无阻塞,并正确处理对象的生命周期。

我们将利用信号槽机制来确保清理工作在正确的时机、正确的线程上执行。

// backend.cpp (修正后)
#include "backend.h"
#include "imageprovider.h"
#include <QTimer>
#include <QCoreApplication> // 包含头文件Backend::Backend(ImageProvider* provider, QObject *parent): QObject{parent}, m_imageProvider(provider)
{// ... (之前的信号槽连接保持不变) ...// 当线程结束时,让它自己删除关联的worker对象connect(m_cameraThread, &QThread::finished, m_cameraWorker, &QObject::deleteLater);// *** 这是关键的修改 ***// 当整个应用程序准备退出时,安全地停止我们的线程// QCoreApplication::aboutToQuit() 是一个全局信号,非常适合用于清理工作connect(qApp, &QCoreApplication::aboutToQuit, this, [this]() {if (m_cameraThread->isRunning()) {m_cameraWorker->m_camera->stop(); // 停止摄像头m_cameraThread->terminate(); // 最后的手段}});m_cameraThread->start();loadSettings();
}// *** 修改析构函数 ***
Backend::~Backend()
{// 析构函数现在可以非常干净,因为大部分清理工作都通过信号槽处理了。// 我们甚至可以不再手动调用 quit() 和 wait(),因为 aboutToQuit 信号会处理。qDebug() << "Backend destructor called.";
}// ... (其他方法保持不变) ...

重新运行后就会发现,在关闭程序时,线程也可以正常退出了。

通过这一节,我们用真实世界的硬件交互替换了模拟逻辑,并优雅地解决了UI线程阻塞和工作线程安全退出的问题。现在的“证照智能识别软件”现在已经具备了坚实的核心功能框架,为后续集成更高级的图像处理和网络功能铺平了道路。

六、集成OpenCV图像处理

现在我们的应用已经能从摄像头捕获图像了,但原始图像的质量往往受到光照等环境因素的影响。为了给后续的AI识别提供最佳的输入,通常需要进行图像增强处理。本节,我们将集成OpenCV,并实现一个可以动态调节图像对比度亮度的功能,同时将这些设置持久化,实现“记忆”功能。

6.1 在项目中集成OpenCV

第一步:安装OpenCV

首先,需要为开发环境安装OpenCV。推荐从OpenCV官网下载预编译好的Windows版本。本文使用**4.11.0**版本。

安装过程很简单,实际上是将下载的文件解压到一个本地目录,例如 D:\toolplace\opencv

第二步:配置CMakeLists.txt

我们需要告诉CMake在哪里找到OpenCV的头文件和库文件。这是集成第三方库的关键步骤。

打开 CMakeLists.txt 文件,在find_package(Qt6 ...)之后,添加以下代码:

# CMakeLists.txt# ... (Qt相关配置) ...# --- 开始集成OpenCV ---
# 1. 设置OpenCV的根目录,请根据您的实际安装路径修改
set(OpenCV_DIR "D:/toolplace/opencv/build/x64/vc16/lib")# 2. 查找OpenCV包,Core和Imgproc是我们需要的模块
find_package(OpenCV REQUIRED COMPONENTS core imgproc)# 3. 包含OpenCV的头文件目录
include_directories(${OpenCV_INCLUDE_DIRS})
# --- 结束集成OpenCV ---# ... (qt_add_executable) ...# 在链接Qt库之后,链接OpenCV库
target_link_libraries(appSmartIdReader PRIVATEQt6::CoreQt6::GuiQt6::QmlQt6::QuickQt6::Controls2Qt6::Multimedia${OpenCV_LIBS} # 链接OpenCV库
)# ... (其余配置) ...

重要提示:

  • set(OpenCV_DIR ...) 这一行中的路径必须指向OpenCV安装目录下包含 OpenCVConfig.cmake 文件的目录。
  • vc16 对应 Visual Studio 2019/2022。需要根据VS版本选择正确的目录。
  • 路径中的斜杠需要使用 /,即使在Windows上。

第三步:验证集成

cameraworker.h的顶部添加 #include <opencv2/core.hpp>#include <opencv2/imgproc.hpp>。然后重新构建项目。如果项目能成功编译,说明OpenCV已经成功集成!

6.2 增强CameraWorker以处理图像

现在,给CameraWorker增加处理图像的能力。

第一步:在cameraworker.h中添加参数槽

我们需要两个槽函数来接收从UI传来的对比度和亮度值。

// cameraworker.h
// ...
#include <opencv2/core.hpp>   // 包含OpenCV头文件
#include <opencv2/imgproc.hpp>class CameraWorker : public QObject
{
// ... (之前的代码) ...
public slots:void startCapture();// 新增:接收调节参数的槽void setContrast(double contrast);void setBrightness(int brightness);
// ... (私有槽和成员) ...
private:// 新增:存储处理参数double m_contrast = 1.0;int m_brightness = 0;
};

第二步:在cameraworker.cpp中应用处理

核心修改在processCapturedImage函数中。我们将在发射imageCaptured信号之前,插入一段使用OpenCV进行处理的代码。

OpenCV处理QImage需要一个转换步骤:QImage -> cv::Mat -> QImage

// cameraworker.cpp
#include "cameraworker.h"
// ...// 新增槽函数的实现
void CameraWorker::setContrast(double contrast)
{// 参数范围限制m_contrast = std::max(1.0, std::min(3.0, contrast));
}void CameraWorker::setBrightness(int brightness)
{m_brightness = std::max(-100, std::min(100, brightness));
}// --- 修改 processCapturedImage 函数 ---
void CameraWorker::processCapturedImage(int requestId, const QImage &img)
{Q_UNUSED(requestId);qDebug() << "Captured image for step" << m_captureStep + 1;// --- 开始OpenCV处理 ---// 1. 将QImage转换为cv::Mat// 注意:需要确保QImage的格式是OpenCV兼容的QImage processedImg = img.convertToFormat(QImage::Format_RGB888);cv::Mat mat(processedImg.height(), processedImg.width(), CV_8UC3, processedImg.bits(), processedImg.bytesPerLine());// 2. 应用对比度和亮度调节// convertScaleAbs 是一个高效的线性变换函数// new_pixel = alpha * old_pixel + betacv::Mat processedMat;mat.convertTo(processedMat, -1, m_contrast, m_brightness);// 3. 将处理后的cv::Mat转换回QImageQImage finalImage(processedMat.data, processedMat.cols, processedMat.rows, processedMat.step, QImage::Format_RGB888);// --- 结束OpenCV处理 ---// 发射处理后的图像emit imageCaptured(finalImage.copy(), m_captureStep); // 使用copy()确保数据所有权// ... (后续逻辑保持不变) ...
}

第三步:在backend.h中添加qml调用接口

class Backend : public QObject
{//... (之前定义的接口) ...//添加:图像参数调整接口Q_INVOKABLE void setContrast(double contrast);Q_INVOKABLE void setBrightness(int brightness);signals://... (之前定义的信号) ...// 添加:图像参数调整信号void contrastChanged(double contrast);void brightnessChanged(int brightness);// ... (后续逻辑保持不变) ...
};
#endif // BACKEND_H

第四步:在backend.cpp中添加应用

Backend::Backend(ImageProvider* provider, QObject *parent) // 修改构造函数: QObject{parent}, m_imageProvider(provider)
{//... (之前) ...// 连接:Backend请求 -> Worker执行connect(this, &Backend::startCaptureRequested, m_cameraWorker, &CameraWorker::startCapture);//添加:图像参数设置连接connect(this, &Backend::contrastChanged, m_cameraWorker, &CameraWorker::setContrast);connect(this, &Backend::brightnessChanged, m_cameraWorker, &CameraWorker::setBrightness);//... (之后) ...
}//添加:对比对设置函数
void Backend::setContrast(double contrast)
{emit contrastChanged(contrast);
}
//添加:亮度设置函数
void Backend::setBrightness(int brightness)
{emit brightnessChanged(brightness);
}

6.3 设计UI设置面板

我们将在主界面的右侧添加一个可收缩的“抽屉”(Drawer)作为设置面板。

第一步:在Main.qml中添加Drawer和滑块

// main.qml
import QtQuick
// ...ApplicationWindow {id: root// ...// --- 新增:设置抽屉 ---Drawer {id: settingsDrawerwidth: 250height: parent.height/2edge: Qt.RightEdge // 从右侧滑出background: Rectangle { color: "#1c3e50" }ColumnLayout {anchors.fill: parentanchors.margins: 10spacing: 15Label {text: "图像调节"font.bold: truecolor: "#ecf0f1"Layout.alignment: Qt.AlignHCenter}// 对比度Label { text: "对比度: " + contrastSlider.value.toFixed(2); color: "white" }Slider {id: contrastSliderfrom: 1.0to: 3.0value: 1.0 // 默认值Layout.fillWidth: true// 当滑块值变化时,实时调用C++槽函数onValueChanged: backend.setContrast(value)}// 亮度Label { text: "亮度: " + brightnessSlider.value; color: "white" }Slider {id: brightnessSliderfrom: -100to: 100value: 0 // 默认值Layout.fillWidth: trueonValueChanged: backend.setBrightness(value)}}}// --- 新增:打开设置的按钮,放在状态栏 ---footer: Frame {// ...Button {id: settingsButtonToolTip.text: "图像参数设置"icon.source: "qrc:/icons/settings.svg" // 需要一个settings.svg图标icon.color: "white"anchors.right: parent.rightanchors.verticalCenter: parent.verticalCenteranchors.rightMargin: 10flat: trueonClicked: settingsDrawer.open()}}// ... (主布局)
}

别忘了添加settings.svg图标到icons目录,并更新CMakeLists.txt的资源文件列表。

最后,将opencv_world4110.dll文件拷贝到当前项目根目录下。

运行效果

编译运行,点击右下角的设置按钮,会滑出设置面板。拖动滑块,下次再采集时就会改变对应的亮度和对比度了。
在这里插入图片描述

6.4 使用配置文件实现参数持久化

现在,我们的图像调节功能已经可以工作了,但每次重启程序,滑块都会回到默认位置。为了提升用户体验,我们希望程序能“记住”用户上一次的对比度和亮度设置。我们将使用Qt提供的QSettings类,并明确指定它使用一个INI格式的配置文件来存储数据,而不是依赖于Windows注册表。

实现流程:

  1. 程序启动时:从应用程序目录下的一个settings.ini文件中读取上一次的对比度和亮度设置。
  2. 应用初始设置:将读取到的值应用到UI滑块和CameraWorker
  3. 用户调整设置时:每当用户拖动滑块,就立即将新的值保存到settings.ini文件中。

我们将所有与QSettings相关的逻辑都集中在Backend类中。

第一步:在Backend类中引入QSettings并适配文件配置

  1. 打开 backend.h 文件,包含必要的头文件。
// backend.h
// ... (其他 #include) ...
#include <QSettings> // 1. 包含QSettings
#include <QCoreApplication> // 2. 包含QCoreApplication以获取程序路径class Backend : public QObject
{// ... (Q_OBJECT) ...
public:explicit Backend(ImageProvider* provider, QObject *parent = nullptr);// ... (其他公共方法) ...signals:// 3. 我们需要一个信号来将加载的设置传递给QMLvoid settingsLoaded(double initialContrast, int initialBrightness);// ... (其他信号) ...private:// 加载和保存配置void loadSettings();void saveSettings();// ... (其他成员变量) ...double m_currentContrast = 1.0;int m_currentBrightness = 0;QString m_settingsFilePath; // 4. 新增:存储配置文件的完整路径
};

第二步:在Backend.cpp中实现基于文件的读写逻辑

这是本节的核心修改。我们将创建一个QSettings实例,并明确告诉它我们要操作的是一个.ini文件。

  1. 在构造函数中,确定配置文件的路径,并调用loadSettings()
  2. setContrastsetBrightness槽函数中,调用saveSettings()来实时更新配置文件。
// backend.cpp
#include "backend.h"
// ... (其他 #include) ...Backend::Backend(ImageProvider* provider, QObject *parent): QObject{parent}, m_imageProvider(provider)
{// 1. 确定配置文件的路径// QCoreApplication::applicationDirPath() 获取可执行文件所在的目录m_settingsFilePath = QCoreApplication::applicationDirPath() + "/settings.ini";qDebug() << "Settings file path:" << m_settingsFilePath;// ... (CameraWorker和线程的创建、连接代码保持不变) ...m_cameraThread->start();// 2. 加载设置loadSettings();
}// ... (~Backend, startScan, handleCapturedImage, processAndRecognize 不变) ...// --- 实现设置读写方法 (基于文件) ---
void Backend::loadSettings()
{// *** 这是关键修改 ***// 创建一个QSettings实例,并指定文件路径和INI格式QSettings settings(m_settingsFilePath, QSettings::IniFormat);// "ImageSettings" 是INI文件中的一个[分组]settings.beginGroup("ImageSettings");// "contrast" 是分组下的一个键。1.0是默认值m_currentContrast = settings.value("contrast", 1.0).toDouble();m_currentBrightness = settings.value("brightness", 0).toInt();settings.endGroup();qDebug() << "Loaded settings: Contrast" << m_currentContrast << "Brightness" << m_currentBrightness;// 将加载的值应用到CameraWorkeremit contrastChanged(m_currentContrast);emit brightnessChanged(m_currentBrightness);// 发射信号来更新UI的初始状态emit settingsLoaded(m_currentContrast, m_currentBrightness);
}void Backend::saveSettings()
{// *** 这是关键修改 ***QSettings settings(m_settingsFilePath, QSettings::IniFormat);settings.beginGroup("ImageSettings");settings.setValue("contrast", m_currentContrast);settings.setValue("brightness", m_currentBrightness);settings.endGroup();
}// --- 修改参数设置的槽函数,增加保存逻辑 ---
// 这两个函数与上一个方案完全相同,因为保存逻辑被封装在了saveSettings()中
void Backend::setContrast(double contrast)
{if (m_currentContrast != contrast) {m_currentContrast = contrast;emit contrastChanged(contrast);saveSettings();}
}void Backend::setBrightness(int brightness)
{if (m_currentBrightness != brightness) {m_currentBrightness = brightness;emit brightnessChanged(brightness);saveSettings();}
}

第三步:在Main.qml中响应初始化信号

// Main.qml
// ...
ApplicationWindow {// ...Connections {target: backend// ... (其他信号处理器) ...// 响应设置加载完成的信号function onSettingsLoaded(initialContrast, initialBrightness) {console.log("Applying initial settings to UI:", initialContrast, initialBrightness)contrastSlider.value = initialContrastbrightnessSlider.value = initialBrightness}}Drawer {id: settingsDrawer// ...Slider {id: contrastSlider// value属性的初始值会在onSettingsLoaded中被设置onValueChanged: backend.setContrast(value)}// ...Slider {id: brightnessSlideronValueChanged: backend.setBrightness(value)}}// ...
}

验证上述程序:拖动面板上的进度条,可以将参数值写入到程序根目录下的settings.ini文件,即可以完成qml->C+±>ini这样的步骤。但是每次修改完参数,下次再启动后,上次的参数值并没有显示在qml面板上,qml面板上还是使用的默认的参数值。

这到底是什么问题呢

问题的核心:C++的Backend在构造时发射了settingsLoaded信号,但此时QML界面还没完全准备好接收。

最简单的解决方法就是:让QML在准备好之后,主动向C++请求一次初始设置值。

具体流程:

  1. C++ Backend:提供一个Q_INVOKABLE方法,比如 requestInitialSettings(),当QML调用它时,它会发射带有当前设置值的信号。
  2. QML Main.qml:在根组件的Component.onCompleted事件处理器中,调用这个backend.requestInitialSettings()方法。Component.onCompleted保证了在QML界面完全加载并准备好之后才执行。

这个方案非常直观,就像“QML启动后,举手说:嘿,C++,我准备好了,请把初始设置发给我!”

第一步:修改 backend.h

我们只需要添加一个可调用的方法。

// backend.h 
#ifndef BACKEND_H
#define BACKEND_H#include <QObject>
#include <QSettings>
#include <QCoreApplication>
// ...class Backend : public QObject
{Q_OBJECT
public:explicit Backend(ImageProvider* provider, QObject *parent = nullptr);~Backend();// *** 新增:一个给QML调用的方法 ***Q_INVOKABLE void requestInitialSettings();// 其他Q_INVOKABLE方法保持不变Q_INVOKABLE void startScan();Q_INVOKABLE void processAndRecognize();signals:// *** 只需要这个信号来传递设置值 ***void settingsLoaded(double initialContrast, int initialBrightness);// 其他信号保持不变void contrastChanged(double contrast);void brightnessChanged(int brightness);// ...public slots:// setContrast 和 setBrightness 保持不变void setContrast(double contrast);void setBrightness(int brightness);// ...private:void loadSettings();void saveSettings();// ... (所有成员变量保持不变) ...double m_currentContrast;int m_currentBrightness;QString m_settingsFilePath;
};#endif // BACKEND_H

第二步:修改 backend.cpp

  1. 从构造函数中移除loadSettings()的直接调用和信号发射。
  2. 实现新增的requestInitialSettings()方法。
// backend.cpp (极简方案)
#include "backend.h"
// ...Backend::Backend(ImageProvider* provider, QObject *parent): QObject{parent}, m_imageProvider(provider)
{// 1. 确定配置文件路径m_settingsFilePath = QCoreApplication::applicationDirPath() + "/settings.ini";// ... (线程和worker的创建、连接代码完全不变) ...m_cameraThread->start();// *** 构造函数现在非常干净,不再主动加载或发射信号 ***
}// 析构函数保持不变 (使用之前的健壮版本)
Backend::~Backend() { /* ... */ }// --- 新增方法的实现 ---
void Backend::requestInitialSettings()
{qDebug() << "QML requested initial settings.";// 当QML请求时,才加载并发送设置loadSettings();
}// --- 修改 loadSettings ---
void Backend::loadSettings()
{QSettings settings(m_settingsFilePath, QSettings::IniFormat);settings.beginGroup("ImageSettings");m_currentContrast = settings.value("contrast", 1.0).toDouble();m_currentBrightness = settings.value("brightness", 0).toInt();settings.endGroup();// 将加载的值应用到workeremit contrastChanged(m_currentContrast);emit brightnessChanged(m_currentBrightness);// *** 发射信号,但这次是在QML请求时才发射 ***emit settingsLoaded(m_currentContrast, m_currentBrightness);
}// --- saveSettings, setContrast, setBrightness 方法完全不变 ---
void Backend::saveSettings() { /* ... */ }
void Backend::setContrast(double contrast) { /* ... */ }
void Backend::setBrightness(int brightness) { /* ... */ }// ... (其他方法也不变) ...

第三步:修改 Main.qml

这是最简单的部分。我们只需要在根元素ApplicationWindow中添加Component.onCompleted

// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.LayoutsApplicationWindow {id: root// ... (width, height, title, etc.) ...// *** 这是关键修改 ***// 当这个ApplicationWindow组件完全加载完成后,执行这里的代码Component.onCompleted: {console.log("QML is ready. Requesting initial settings from C++.")backend.requestInitialSettings()}// Connections元素现在只处理settingsLoaded信号Connections {target: backend// 当C++响应请求并发出信号时,更新UIfunction onSettingsLoaded(initialContrast, initialBrightness) {console.log("Received initial settings:", initialContrast, initialBrightness)contrastSlider.value = initialContrastbrightnessSlider.value = initialBrightness}// 其他信号处理器保持不变// function onScanProgressChanged(...) { ... }// function onNewImageCaptured(...) { ... }// ...}// ... (Drawer, Slider等其他UI部分的代码完全不变) ...Drawer {id: settingsDrawer// ...Slider {id: contrastSlider// ...// onValueChanged也保持不变onValueChanged: backend.setContrast(value)}Slider {id: brightnessSlider// ...onValueChanged: backend.setBrightness(value)}}
}

上述“QML主动请求”的模式是解决C++与QML启动时序问题的最简单、最直观的方法。

流程回顾:

  1. 程序启动,C++ Backend被创建,但它“静静地”等待。
  2. QML引擎加载Main.qml。当整个QML树构建完成,ApplicationWindowComponent.onCompleted被触发。
  3. QML调用backend.requestInitialSettings()
  4. C++ Backend收到请求,执行loadSettings(),然后发射settingsLoaded信号,并携带从文件中读出的值。
  5. QML的Connections元素此时早已准备就绪,它捕获到settingsLoaded信号,并用接收到的值更新滑块的value

最终效果与验证

可以进行以下操作来验证功能:

  1. 首次运行:编译并运行程序。由于settings.ini文件还不存在,程序会使用默认值(对比度1.0,亮度0)。
  2. 调整设置:打开设置面板,将对比度滑块拖到2.5,亮度滑块拖到30
  3. 检查配置文件:打开项目的构建目录(通常是类似build-SmartIdReader-Desktop_Qt_...-Release的文件夹),会发现里面自动生成了一个settings.ini文件。用记事本打开它,内容应该是:
    [ImageSettings]
    contrast=2.5
    brightness=30
    
  4. 关闭程序:完全关闭应用程序。
  5. 再次运行:重新启动程序。此时,程序会读取settings.ini文件,会看到设置面板中的滑块自动恢复到了2.530的位置。
  6. 手动修改:关闭程序,手动修改settings.ini文件中的brightness-80并保存。再次运行程序,会看到亮度滑块直接定位到了-80的位置。

这个基于配置文件的方案,不仅实现了参数的持久化,还提供了极大的灵活性和透明度,是开发健壮桌面应用的常用方法。

6.5 图像保存

在完成采集和识别后,用户往往需要将处理过的图像保存下来作为凭证或存档。我们将添加一个“保存图像”按钮,点击后,会将当前显示的6张图像进行分辨率调整,并以规范的命名格式保存到用户桌面。

第一步:在Main.qml中添加“保存图像”按钮

在操作区的RowLayout中,紧挨着“处理并识别”按钮,添加一个新的Button

// Main.qml
// ...RowLayout {Layout.alignment: Qt.AlignHCenterspacing: 20 // 调整间距以容纳新按钮Button {id: scanButton// ...}Button {id: processButton// ...}// *** 新增:保存图像按钮 ***Button {id: saveButtonLayout.preferredWidth: 120Layout.preferredHeight: 50text: "  保存图像"icon.source: "qrc:/icons/save.svg" // 需要一个新的save.svg图标enabled: processButton.enabled // 当处理按钮可用时,保存按钮也可用onClicked: {// 调用C++后端的保存函数backend.saveAllImages()}background: Rectangle {color: parent.down ? "#2980b9" : "#3498db"radius: 5}}}
// ...

提示:

  • 您需要从Feather Icons或类似网站下载一个save.svg图标,放入icons文件夹,并更新CMakeLists.txt的资源列表。
  • 将保存按钮的enabled状态直接绑定到了处理按钮上,逻辑清晰。

第二步:在C++后端准备接收和处理图像

由于图像数据当前存在于QML的ImageProvider中,最直接的方法是让Backend能够访问ImageProvider来获取这些图像。幸运的是,在之前的设计中,已经在main.cpp中将ImageProvider的指针传给了Backend的构造函数。

  1. 修改 backend.h:添加saveAllImages槽,并增加一个信号用于反馈保存状态。
// backend.h
// ...
class Backend : public QObject
{// ...
public slots:// ...// *** 新增:保存所有图像的槽 ***Q_INVOKABLE void saveAllImages();signals:// ...// *** 新增:保存状态反馈信号 ***void saveStatusUpdated(const QString &status);private:// ... (所有成员变量保持不变)
};
  1. 修改 backend.cpp:实现saveAllImages的核心逻辑。具体的,将在这个函数中完成路径创建、图像获取、缩放和保存的所有工作。
// backend.cpp
#include "backend.h"
#include "imageprovider.h"
#include <QTimer>
#include <QStandardPaths> // 用于获取桌面路径
#include <QDir>
#include <QDateTime>
#include <opencv2/imgproc.hpp> // 确保包含imgproc// ... (构造函数、析构函数等保持不变) ...// *** 新增:实现保存图像的方法 ***
void Backend::saveAllImages()
{// 1. 获取桌面路径QString desktopPath = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation);if (desktopPath.isEmpty()) {emit saveStatusUpdated("错误: 无法获取桌面路径");return;}// 2. 创建主保存目录 "images"QDir desktopDir(desktopPath);QString mainFolderName = "images";if (!desktopDir.exists(mainFolderName)) {desktopDir.mkdir(mainFolderName);}desktopDir.cd(mainFolderName);// 3. 创建日期子目录 "YYYY-MM-DD"QString dateFolderName = QDate::currentDate().toString("yyyy-MM-dd");if (!desktopDir.exists(dateFolderName)) {desktopDir.mkdir(dateFolderName);}desktopDir.cd(dateFolderName);emit saveStatusUpdated("正在保存图像...");// 4. 遍历并保存6张图片bool all_saved = true;for (int i = 0; i < 6; ++i) {QString imageId = QString("capture_%1").arg(i);// 从ImageProvider获取原始图像QImage originalImage = m_imageProvider->requestImage(imageId, nullptr, QSize());if (originalImage.isNull()) {qWarning() << "Could not retrieve image for index" << i;all_saved = false;continue; // 如果某张图不存在,跳过}// --- 使用OpenCV进行缩放 ---// 将QImage转换为cv::MatQImage tempImg = originalImage.convertToFormat(QImage::Format_RGB888);cv::Mat mat(tempImg.height(), tempImg.width(), CV_8UC3, tempImg.bits(), tempImg.bytesPerLine());// 计算新的尺寸int newWidth = 1200;double scale = static_cast<double>(newWidth) / mat.cols;int newHeight = static_cast<int>(mat.rows * scale);cv::Size newSize(newWidth, newHeight);// 执行缩放cv::Mat resizedMat;cv::resize(mat, resizedMat, newSize, 0, 0, cv::INTER_AREA);// 将处理后的cv::Mat转回QImage以便保存QImage finalImage(resizedMat.data, resizedMat.cols, resizedMat.rows, resizedMat.step, QImage::Format_RGB888);// --- OpenCV处理结束 ---// 5. 生成文件名 "hh_mm_ss_index.jpg"QString timeStr = QTime::currentTime().toString("hh_mm_ss");QString fileName = QString("%1_%2.jpg").arg(timeStr).arg(i + 1);QString fullPath = desktopDir.filePath(fileName);// 6. 保存文件if (!finalImage.save(fullPath, "JPG", 90)) { // 90是JPEG质量qWarning() << "Failed to save image to" << fullPath;all_saved = false;}}if (all_saved) {emit saveStatusUpdated("所有图像已成功保存至桌面/images/" + dateFolderName);} else {emit saveStatusUpdated("部分图像保存失败,请检查日志");}
}

第三步:在QML中显示保存状态

最后,让程序的状态栏能够显示Backend发来的保存状态信息。

// Main.qml
// ...
ApplicationWindow {// ...Connections {target: backend// ... (onScanProgressChanged, onNewImageCaptured, etc.) ...// *** 新增:响应保存状态更新的信号 ***function onSaveStatusUpdated(status) {statusLabel.text = status;}}// ... (footer, ColumnLayout, etc.) ...
}

现在,应用程序已经具备了完整的“采集 -> 识别 -> 保存”流程!

  1. 点击“开始采集”,完成6张图片的捕获。
  2. 此时,“保存图像”按钮变为可用。
  3. 点击“保存图像”。
  4. 观察状态栏,它会先显示“正在保存图像…”,然后显示成功或失败的信息。
  5. 打开电脑桌面,会看到一个名为images的文件夹。
  6. 进入该文件夹,会有一个以当天日期命名的子文件夹,例如2023-10-27
  7. 进入日期文件夹,会看到6张被处理过的.jpg图片,文件名类似于14_30_05_1.jpg, 14_30_05_2.jpg…,并且它们的宽度都已经被缩放到了1200像素。

最终运行效果如下:
在这里插入图片描述

七、HTTP通信与AI集成

现在,我们的应用将从一个功能强大的原型,蜕变为一个真正接入AI能力的智能客户端。我们将替换掉第4.4节的模拟逻辑,通过HTTP协议与一个本地的Python FastAPI服务进行通信,实现真实的图像OCR识别。

实现流程:

  1. 搭建本地AI服务器:使用Python的FastAPI和PaddleOCR库,创建一个能接收图像并返回识别结果的API端点。
  2. 实现网络请求:在C++ Backend中,使用QNetworkAccessManager发送带有图像数据的POST请求。
  3. 处理并显示结果:解析服务器返回的JSON数据,将识别出的文本和带标注的图像更新到UI上。

7.1 搭建本地OCR服务器 (Python)

我们将使用FastAPI,因为它轻量、快速且易于使用。

第一步:安装依赖库

在Python环境中(推荐使用虚拟环境),安装必要的库(本文不再阐述Python的安装方法,相关教程较多,请读者自行安装好)。其中PaddlePaddle的安装推荐参考官网,本文推荐安装2.5.2版本,该版本工业应用尤其稳定。

python -m pip install paddlepaddle==2.5.2 -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install fastapi "uvicorn[standard]" python-multipart paddleocr==2.7 opencv-python numpy==1.23=0 -i https://pypi.tuna.tsinghua.edu.cn/simple

第二步:编写server.py

在项目根目录下创建一个server.py文件。这个服务只有一个API端点/ocr,它接收上传的图像,用PaddleOCR处理,然后返回一个包含文本和处理后图像(以Base64编码)的JSON。

# server.py
import base64
import cv2
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel # 1. 导入BaseModel
from paddleocr import PaddleOCR# 初始化FastAPI应用和PaddleOCR
app = FastAPI()
# 只需初始化一次,指定使用中文/英文模型
ocr = PaddleOCR(use_angle_cls=True, lang='ch') # 2. 定义一个模型,描述我们期望接收的JSON结构
class OCRRequest(BaseModel):image_base64: str@app.post("/ocr")
async def process_ocr(request: OCRRequest):"""接收图像,进行OCR,返回结果"""# 1. 读取上传的图像数据try:img_data = base64.b64decode(request.image_base64)except Exception as e:return {"error": "Invalid Base64 string"}nparr = np.frombuffer(img_data, np.uint8)img_cv = cv2.imdecode(nparr, cv2.IMREAD_COLOR)if img_cv is None:return {"error": "Failed to decode image"}# 2. 使用PaddleOCR进行识别result = ocr.ocr(img_cv, cls=True)# 3. 将识别结果绘制到图像上并提取文本boxes = [line[0] for line in result[0]]texts = [line[1][0] for line in result[0]]scores = [line[1][1] for line in result[0]]img_with_boxes = img_cv.copy()for i in range(len(boxes)):# 绘制边界框#if scores[i] > 0.5:box = boxes[i]cv2.polylines(img_with_boxes, [np.array(box).astype(np.int32)], True, color=(0, 255, 0), thickness=2)cv2.imwrite('result.jpg',img_with_boxes)# 4. 将处理后的图像编码为Base64字符串_, buffer = cv2.imencode('.jpg', img_with_boxes)img_base64 = base64.b64encode(buffer).decode('utf-8')# 5. 构造并返回JSON结果return {"text": "\n".join(texts),"annotated_image": img_base64}if __name__ == "__main__":import uvicorn# 运行服务,监听在本地8000端口uvicorn.run(app, host="127.0.0.1", port=8000)

第三步:启动服务器

在项目根目录的终端中运行:

python server.py

如果一切顺利,会看到类似Uvicorn running on http://127.0.0.1:8000的输出。现在,AI服务器已经准备就绪。

7.2 在Qt中实现HTTP客户端

具体的,将修改Backend类,用真实的QNetworkAccessManager替换掉之前模拟AI的QTimer

第一步:修改backend.h

包含网络相关的头文件,并为网络请求准备一个成员变量。

// backend.h
// ...
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QUrlQuery>
#include <QHttpMultiPart>class Backend : public QObject
{// ...
private:// ...QNetworkAccessManager *m_networkManager;
};

第二步:修改backend.cpp

  1. 构造函数中初始化QNetworkAccessManager
  2. 重写processAndRecognize方法,使其从ImageProvider获取第一张图,并发送HTTP POST请求。
  3. 处理网络回复,解析JSON,并更新UI。
// backend.cpp
#include "backend.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QBuffer>Backend::Backend(ImageProvider* provider, QObject *parent): QObject{parent}, m_imageProvider(provider)
{// ... (线程和worker的创建) ...// *** 新增:初始化网络管理器 ***m_networkManager = new QNetworkAccessManager(this);// ... (其他代码) ...
}// ... (其他方法) ...// *** 重写 processAndRecognize 方法 ***
void Backend::processAndRecognize()
{emit scanProgressChanged(8, "正在发送图像至AI服务器...");QImage image_to_process = m_imageProvider->requestImage("capture_0", nullptr, QSize());if (image_to_process.isNull()) {emit scanProgressChanged(0, "错误: 无法获取用于识别的图像");return;}// 1. 将QImage转换为JPEG格式的QByteArrayQByteArray imageData;QBuffer buffer(&imageData);buffer.open(QIODevice::WriteOnly);image_to_process.save(&buffer, "JPEG");// 2. 将图像数据进行Base64编码QString imageBase64 = QString::fromLatin1(imageData.toBase64());// 3. 创建JSON对象QJsonObject jsonObject;jsonObject["image_base64"] = imageBase64;// 4. 将JSON对象转换为QByteArrayQJsonDocument jsonDoc(jsonObject);QByteArray postData = jsonDoc.toJson();// 5. 创建HTTP请求QUrl url("http://127.0.0.1:8000/ocr");QNetworkRequest request(url);// **关键:设置正确的Content-Type头**request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");// 6. 发送POST请求,这次直接发送JSON数据QNetworkReply *reply = m_networkManager->post(request, postData);// 7. 连接回复的finished信号connect(reply, &QNetworkReply::finished, this, [=]() {if (reply->error() != QNetworkReply::NoError) {emit scanProgressChanged(0, "网络错误: " + reply->errorString());reply->deleteLater();return;}// 解析JSON回复QByteArray responseData = reply->readAll();QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);QJsonObject jsonObj = jsonDoc.object();// JSON解析、图像解码和UI更新逻辑QString recognizedText = jsonObj["text"].toString();QString returnedImageBase64 = jsonObj["annotated_image"].toString();QByteArray returnedImageData = QByteArray::fromBase64(returnedImageBase64.toUtf8());QImage annotatedImage;annotatedImage.loadFromData(returnedImageData, "JPG");if (!annotatedImage.isNull()) {m_imageProvider->updateImage("capture_0", annotatedImage);emit newImageCaptured("image://livefeed/capture_0?timestamp=" + QString::number(QDateTime::currentMSecsSinceEpoch()), 0);}QVariantMap results;results["idNumber"] = recognizedText;emit recognitionComplete(results);emit scanProgressChanged(9, "AI识别完成!");reply->deleteLater();});
}

代码解释:

  • ?timestamp=...:这是一个强制QML刷新Image源的技巧。因为image://livefeed/capture_0这个URL没有变,QML可能会使用缓存。通过附加一个每次都变化的时间戳,我们欺骗QML,让它认为这是一个新的URL,从而强制它重新向ImageProvider请求图像。

最后修改imageprovider.cpp

QImage ImageProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize)
{Q_UNUSED(requestedSize);QUrl url(id);//将传入的完整id字符串解析为URL,然后只取其路径部分作为真正的键,方便后续刷新QString imageKey = url.path();if (m_images.contains(imageKey)) {QImage img = m_images.value(imageKey);if (size) {*size = img.size();}return img;}// 如果没有找到图片,返回一个空的QImagereturn QImage();
}

最终运行效果:

现在,确保Python FastAPI服务器正在运行,然后编译并启动Qt应用程序。

  1. 点击“开始采集”,等待6张图片从摄像头捕获完成。
  2. 点击“处理并识别”。
  3. 状态栏会显示“正在发送…”,程序会将第一张图发送到本地的AI服务器。
  4. 几秒钟后(取决于电脑性能),服务器处理完毕并返回结果。
  5. Qt客户端会:
    • 结果区显示从图像中识别出的文本。
    • 将第一个照片框中的原始图像,替换为由AI服务返回的、带有识别边界框的图像

在这里插入图片描述
至此,已经完成了一个完整的、端到端的智能证照识别应用原型。它涵盖了从优雅的UI设计、多线程硬件交互、高级图像处理,到与真实AI服务的网络通信,完美地展示了Qt与Python生态结合的强大威力。

7.3 优化图像显示:双击缩放

为了方便用户查看单张图像的细节,我们将增加一个交互功能:当用户双击任何一个PhotoFrame时,该照片框将放大到占据整个网格区域,同时隐藏其他所有照片框。再次双击,则恢复到原来的6格布局。

核心思路:

  1. PhotoFrame.qml中,添加一个MouseArea来捕捉双击事件,并定义一个自定义信号doubleClicked
  2. Main.qml中,为GridLayout添加一个状态"zoomed",用于控制单个PhotoFrame的放大效果。
  3. Main.qml中,为每个PhotoFrame添加一个onDoubleClicked处理器,当接收到双击信号时,切换主界面的状态。

第一步:修改PhotoFrame.qml - 添加双击信号

给可重用的PhotoFrame组件增加发出双击信号的能力。

// PhotoFrame.qml
import QtQuick
import QtQuick.ControlsFrame {id: root// ... (之前的属性)// *** 1. 定义一个自定义信号 ***signal doubleClicked()// ... (Image, Label, BusyIndicator) ...// *** 2. 添加MouseArea来捕捉双击事件 ***MouseArea {anchors.fill: parent// 当检测到双击时,发射我们定义的信号onDoubleClicked: root.doubleClicked()}
}

第二步:修改Main.qml - 定义状态并实现切换逻辑

这是本节的核心。我们将利用QML强大的状态机功能。

// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.LayoutsApplicationWindow {id: root// ... (之前的属性和Connections)// *** 1. 定义一个属性来追踪当前是否处于放大状态,以及哪个框被放大了 ***property var zoomedFrame: null// ... (footer)ColumnLayout {// ...GridLayout {id: photoGrid// ...// --- 为每个 PhotoFrame 添加 onDoubleClicked 处理器 ---PhotoFrame {id: frameFrontWhite// ...onDoubleClicked: {// 如果当前没有放大的,就放大自己// 如果当前放大的是自己,就复原root.zoomedFrame = (root.zoomedFrame === null ? frameFrontWhite : null)}visible: root.zoomedFrame === null || root.zoomedFrame === this}PhotoFrame {id: frameFrontIR// ...onDoubleClicked: {root.zoomedFrame = (root.zoomedFrame === null ? frameFrontIR : null)}visible: root.zoomedFrame === null || root.zoomedFrame === this}PhotoFrame {id: frameFrontUV// ...onDoubleClicked: {root.zoomedFrame = (root.zoomedFrame === null ? frameFrontUV : null)}visible: root.zoomedFrame === null || root.zoomedFrame === this}PhotoFrame {id: frameBackWhite// ...onDoubleClicked: {root.zoomedFrame = (root.zoomedFrame === null ? frameBackWhite : null)}visible: root.zoomedFrame === null || root.zoomedFrame === this}PhotoFrame {id: frameBackIR// ...onDoubleClicked: {root.zoomedFrame = (root.zoomedFrame === null ? frameBackIR : null)}}PhotoFrame {id: frameBackUV// ...onDoubleClicked: {root.zoomedFrame = (root.zoomedFrame === null ? frameBackUV : null)}visible: root.zoomedFrame === null || root.zoomedFrame === this}// *** 2. 定义状态 ***states: [State {name: "zoomed"// 当 zoomedFrame 属性不为 null 时,进入此状态when: root.zoomedFrame !== nullPropertyChanges {target: photoGrid // 改变GridLayout本身的属性// 让放大的元素占据所有行和列rows: 1columns: 1}}]// *** 3. 定义状态切换时的动画(可选,但效果更好)***transitions: [Transition {// 包含所有属性变化的默认动画NumberAnimation { properties: "rows, columns"; duration: 300; easing.type: Easing.InOutQuad }OpacityAnimator { duration: 300 }}]}// ... (结果区和操作区)}
}

最终运行效果:

现在,重新编译并运行程序:

  1. 完成图像采集。
  2. 双击任何一张图片,它会平滑地放大以占据整个网格区域,其他图片则会淡出隐藏。
  3. 再次双击这张放大的图片,它会平滑地缩小,同时其他5张图片会淡入,恢复到初始的6格布局。

在这里插入图片描述
这个简洁而强大的UI增强功能,完全在QML层实现,充分展示了QML声明式编程和状态机的优雅之处。现在的程序现在不仅功能完备,交互体验也更上了一个台阶。

好的,非常荣幸能为您完成这篇教程的最后一部分。程序开发完成后,如何将其交付给最终用户是一个至关重要的环节。我们将介绍如何在Windows上进行部署和打包,让您的应用程序变成一个专业的安装包。


八、部署和打包

我们已经完成了一个功能强大的应用程序,但现在它还只存在于开发环境中。为了让没有安装Qt和OpenCV的普通用户也能使用它,需要将程序及其所有依赖项打包成一个独立的安装程序。这个过程主要分为两步:

  1. 部署 (Deployment):收集应用程序运行所需的所有文件(如DLL库)到一个干净的文件夹中。
  2. 打包 (Packaging):使用专门的工具将这个文件夹制作成一个用户友好的setup.exe安装包。

8.1 使用 windeployqt 收集依赖项

Qt提供了一个官方工具windeployqt.exe,它能自动扫描可执行文件,并把所有需要的Qt相关DLL库、QML文件、插件等复制到同一个目录下。

第一步:确保使用Release版本编译

部署前,需确保项目是在Release模式下编译的。Release版本经过优化,运行速度更快,体积也更小。

第二步:找到windeployqt和应用程序

  1. windeployqt.exe的位置:它位于Qt安装目录中,例如 C:\Qt\6.9.1\msvc2022_64\bin。最好将这个路径添加到系统的PATH环境变量中,以便在任何地方都能调用它。
  2. 程序位置:它位于项目的构建目录中,例如 build-SmartIdReader-Desktop_Qt_6_9_1_MSVC2022_64bit\release

第三步:执行部署命令

  1. 打开一个命令行终端(CMD或PowerShell)。

  2. 使用cd命令切换到应用程序所在的release目录。

    cd path\to\your\project\build-SmartIdReader-Desktop_...\release
    
  3. 运行windeployqt命令。由于我们的项目是Qt Quick应用,需要使用--qmldir参数指向QML源文件所在的目录,以确保QML模块和插件被正确复制。

    windeployqt --qmldir ../../ appSmartIdReader.exe
    

    命令解释:

    • --qmldir ../../--qmldir告诉工具QML源文件的位置。../../是相对于release目录的QML项目源文件夹的路径。需要根据实际目录结构调整这个路径
    • appSmartIdReader.exe:主程序。

第四步:手动复制非Qt依赖项

windeployqt非常智能,但它只认识Qt的依赖项。我们必须手动复制其他库,比如OpenCV。

  1. 找到您的OpenCV安装路径下的bin目录,例如 D:\toolplace\opencv\build\x64\vc16\bin
  2. opencv_world4110.dll(版本号可能不同)复制到刚才的release文件夹中,与appSmartIdReader.exe放在一起。

完成以上步骤后,您的release文件夹现在就像一个“绿色版”的软件,包含了运行所需的所有文件。

8.2 使用Inno Setup制作安装包

现在,我们将这个部署好的文件夹打包成一个专业的setup.exe。这里推荐使用免费且强大的Inno Setup工具。

第一步:下载并安装Inno Setup

从其官方网站下载并安装Inno Setup。

第二步:使用脚本向导创建安装包

  1. 打开Inno Setup,选择 File -> New... 启动脚本向导。
  2. Application Information: 填入您的应用名称(证照智能识别软件)、版本号和公司名称。

在这里插入图片描述

  1. Application Files: 这是最关键的一步。

    • Application main executable file: 点击Browse...,选择我们之前部署好的release文件夹中的appSmartIdReader.exe
    • Other application files: 点击Add folder...,选择整个release文件夹,并确认包含子文件夹。这样,所有的DLL和插件都会被包含进去。
      在这里插入图片描述
  2. Application Icons: 根据向导的提示,勾选“Create a desktop icon”和“Create a Quick Launch icon”来创建桌面和快速启动快捷方式。

  3. 完成向导: 其余步骤可以保持默认,一路“下一步”直到完成。向导会生成一个.iss脚本文件。

第三步:编译安装包

向导生成的脚本已经基本可用。在Inno Setup的编辑器中,点击工具栏上的绿色“运行”按钮(或按F9)来编译脚本。
在这里插入图片描述
编译成功后,在您指定的输出目录(默认为Output文件夹)下,就会生成一个名为setup.exe的安装文件!

第四步:测试安装包

运行这个setup.exe,就会看到一个非常专业的安装向导。完成安装后,在桌面和开始菜单中找到程序图标,双击运行,一个无需任何外部依赖的、完整的“证照智能识别软件”就成功交付了。
在这里插入图片描述

好的,非常荣幸能为您这篇精彩的教程画上一个圆满的句号。一个强有力的总结能够升华主旨,让读者回顾所学并展望未来。


九、全文总结

至此,我们已经共同走完了一段从零到一的完整开发旅程。我们不仅从一个空白的Qt Creator项目开始,逐步构建出一个功能完整、界面美观的“证照智能识别软件”原型,更重要的是,我们系统性地实践了现代桌面应用开发的全流程。

回顾我们的旅程,我们掌握了:

  • 优雅的UI构建:利用Qt Quick和QML的声明式语法,我们高效地创建了一个响应式、可重用且具现代感的图形界面。
  • 强大的C++后端:我们设计了一个清晰的C++后端,通过Qt核心的信号与槽机制,将其与QML前端无缝连接,实现了前后端逻辑的清晰分离。
  • 流畅的用户体验:通过将耗时的摄像头采集和图像处理任务放入独立线程,我们保证了主UI线程的流畅,避免了卡顿,这是开发高性能客户端应用的关键。
  • 核心功能集成:我们成功地集成了三大核心技术:
    1. Qt Multimedia:用于与真实硬件(摄像头)交互。
    2. OpenCV:用于实现强大的图像处理与增强。
    3. 网络通信:通过QNetworkAccessManager与本地AI服务进行HTTP通信,为接入云端智能铺平了道路。
  • 专业的交付流程:我们学习了如何使用windeployqtInno Setup等专业工具,将复杂的项目依赖项打包成一个对最终用户友好的、一键安装的应用程序。

这个项目不仅仅是一个“教程”,它是一个浓缩了现代软件工程思想的实战案例。它证明了Qt/C++与QML的组合,在构建需要高性能后端、复杂逻辑处理以及漂亮UI的桌面应用时,依然是无与伦比的强大选择。

未来可期,更进一步:

本文所展示的,仅仅是Qt强大生态的冰山一角。如果您对本文所涉及的技术意犹未尽,渴望在工业应用、高性能计算和复杂系统架构领域进行更深入的探索,那么我非常荣幸地向您预告——我正在撰写一本全新的书籍,暂定名为:《面向工业应用的 Qt6 实战(基于C++和QML)》

这本书将远超本博客的范畴,系统性地深入探讨:

  • 高级C++/QML交互模式:包括插件化架构、自定义模型、以及更复杂的属性绑定和所有权管理。
  • 性能优化专题:从渲染管线到数据处理,全方位剖析和优化您的Qt应用,使其在严苛的工业环境中也能流畅运行。
  • 工业级硬件集成:超越USB摄像头,讲解如何与工业相机、传感器、PLC等设备进行稳定、高效的通信。
  • 跨平台开发的陷阱与最佳实践:分享在Windows、Linux及嵌入式平台上进行部署和调试的宝贵经验。
  • 更多真实世界的项目案例:涵盖数据可视化、机器视觉、自动化控制等多个工业领域。

这本书是我多年Qt开发经验的结晶,旨在为您提供一套从入门到精通,从原型到产品的完整知识体系。如果您希望将自己的Qt技能提升到一个全新的高度,敬请关注本书的后续动态!

最后,再次感谢读者跟随本教程走到这里。编程的乐趣在于创造,希望本文能成为您创造之旅的坚实起点。编码愉快,未来可期!

http://www.xdnf.cn/news/1083331.html

相关文章:

  • 计算机网络1.1:什么是Internet?
  • 电商系统二次开发找谁做?ZKmall开源商城前后端分离技术更易升级迭代
  • leetcode 每日一题 1865. 找出和为指定值的下标对
  • uniapp实现的多种时间线模板
  • Redis存储Cookie实现爬虫保持登录 requests | selenium
  • TCP/IP协议栈实现浅析(下) 报文接收相关函数及流程分析
  • 软件版本FCCU(故障采集与控制单元)设计
  • RS触发器Multisim电路仿真——硬件工程师笔记
  • Linux命令大全:按功能分类详解(附表格速查)
  • Altium Designer使用教程 第一章(Altium Designer工程与窗口)
  • 用户中心Vue3网页开发(1.0版)
  • Attention Is All You Need论文精读(逐段解析)
  • 隐马尔可夫模型:语音识别系统的时序解码引擎
  • python优先队列使用
  • H3C WA6322 Fit AP切换为云AP或Anchor-AC(FAT AP)
  • 如何理解lambda排序
  • 自动化Prompt生成平台的研发体系设计
  • 《Redis》哨兵模式
  • 数据结构——栈的讲解(超详细)
  • python使用fastmcp包编写mcp服务端(mcp_server)和mcp客户端(mcp_client)
  • Java教程——深入学习guava之并发编程
  • 如何使用backtrace定位Linux程序的崩溃位置
  • 大数据学习2:HIve
  • 故障诊断 | CNN-GRU-Attention故障诊断
  • LINUX75 LAMP
  • 前端-CSS-day1
  • Softhub软件下载站实战开发(十三):软件管理前端分片上传实现
  • 从零构建智能ai语音助手:ESP32s3+Python+大语言模型实战指南
  • SQL128 统计2021年未完成试卷作答数大于1的有效用户
  • Linux操作系统之文件(四):文件系统(上)