目录
- 一、概述
-
- [1.1 背景介绍:让证照验证变得智能、快捷](#1.1 背景介绍:让证照验证变得智能、快捷)
- [1.2 项目目标](#1.2 项目目标)
- [1.3 技术栈](#1.3 技术栈)
- [1.4 代码链接](#1.4 代码链接)
- 二、环境准备与项目骨架
-
- [2.1 创建Qt Quick项目](#2.1 创建Qt Quick项目)
- [2.2 设置图标](#2.2 设置图标)
-
- [2.2.1 设置窗口图标 (The Window Icon)](#2.2.1 设置窗口图标 (The Window Icon))
- [2.2.2 设置可执行文件图标 (The Executable Icon)](#2.2.2 设置可执行文件图标 (The Executable Icon))
- 三、UI设计 (QML)
-
- [3.1 可重用的照片框组件 (`PhotoFrame.qml`)](#3.1 可重用的照片框组件 (
PhotoFrame.qml
)) - [3.2 主界面布局 (`Main.qml`)](#3.2 主界面布局 (
Main.qml
))
- [3.1 可重用的照片框组件 (`PhotoFrame.qml`)](#3.1 可重用的照片框组件 (
- 四、C++核心后端 (`Backend`)
-
- [4.1 创建Backend类](#4.1 创建Backend类)
- [4.2 C++后端与QML的桥梁](#4.2 C++后端与QML的桥梁)
- [4.3 实现模拟采集流程](#4.3 实现模拟采集流程)
- [4.4 模拟AI识别与结果展示](#4.4 模拟AI识别与结果展示)
- 五、摄像头图像采集
-
- [5.1 引入Qt Multimedia模块](#5.1 引入Qt Multimedia模块)
- [5.2 线程化的相机工作者 (`CameraWorker`)](#5.2 线程化的相机工作者 (
CameraWorker
)) - [5.3 从`QImage`到QML:`QQuickImageProvider`](#5.3 从
QImage
到QML:QQuickImageProvider
) - [5.4 整合与最终呈现](#5.4 整合与最终呈现)
- [5.5 安全的退出线程](#5.5 安全的退出线程)
-
- [5.5.1 问题根源分析](#5.5.1 问题根源分析)
- [5.5.2 正确的解决方案:利用信号槽机制关闭线程](#5.5.2 正确的解决方案:利用信号槽机制关闭线程)
- 六、集成OpenCV图像处理
-
- [6.1 在项目中集成OpenCV](#6.1 在项目中集成OpenCV)
- [6.2 增强CameraWorker以处理图像](#6.2 增强CameraWorker以处理图像)
- [6.3 设计UI设置面板](#6.3 设计UI设置面板)
- [6.4 使用配置文件实现参数持久化](#6.4 使用配置文件实现参数持久化)
- [6.5 图像保存](#6.5 图像保存)
- 七、HTTP通信与AI集成
-
- [7.1 搭建本地OCR服务器 (Python)](#7.1 搭建本地OCR服务器 (Python))
- [7.2 在Qt中实现HTTP客户端](#7.2 在Qt中实现HTTP客户端)
- [7.3 优化图像显示:双击缩放](#7.3 优化图像显示:双击缩放)
- 八、部署和打包
-
- [8.1 使用 `windeployqt` 收集依赖项](#8.1 使用
windeployqt
收集依赖项) - [8.2 使用Inno Setup制作安装包](#8.2 使用Inno Setup制作安装包)
- [8.1 使用 `windeployqt` 收集依赖项](#8.1 使用
- 九、全文总结
一、概述
1.1 背景介绍:让证照验证变得智能、快捷
在日常生活中,我们经常需要出示身份证、护照或驾照来证明身份。传统的人工核验不仅慢,还容易出错。为了解决这个问题,证照智能识别技术应运而生。它就像一个"数字火眼金睛",能自动、快速地完成两项核心任务:
- 读出信息 (Data Extraction):自动从证件上提取姓名、号码等关键文字,免去手动输入的麻烦。
- 辨别真伪 (Authenticity Check):检查证件本身是不是伪造的。这是安全的第一道防线。
为什么需要特殊设备?
专业的证照,尤其是护照,隐藏着许多肉眼看不见的"防伪密码"。这些密码只有在特殊光线下才会显现:
- 紫外光 (UV):能照出普通光下不可见的荧光图案或纤维。
- 红外光 (IR):能穿透某些墨水,识别照片是否被替换,或检查特殊的红外防伪特征。
(设备采集不同光谱下的图像,图片源于网络)
本项目就是要开发一款与这种多光谱采集设备 配套的桌面软件。软件将控制设备,按顺序拍摄证件在白光、红外光、紫外光下的多张图像,然后利用强大的图像处理技术和云端AI,最终实现高效、可靠的自动化证照审核。
1.2 项目目标
本文的核心任务是:
- 优雅的UI设计:使用Qt Quick Controls 2,创建一个简洁、美观、响应式的用户界面。
- 自动化采集流程:精确控制设备,按"正面(白光->红外->紫外)-> 反面(白光->红外->紫外)"顺序采集6张图像,并提供清晰的进度反馈。
- 高性能图像处理:集成OpenCV,对采集的图像进行自动裁剪和畸变矫正,为后续识别做准备。
- 与云端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项目
- 打开Qt Creator,选择
文件 -> 新建文件或项目...
。 - 模板选择 : 在弹出的窗口中,选择 Application (Qt) -> Qt Quick Application。
- 项目命名 : 项目名称设为
SmartIdReader
,选择一个合适的创建路径。 - Details :
- Qt版本: 选择你安装的Qt6版本。
- 构建套件 : 选择 Desktop_Qt 6_9_1_MSVC2022_64bit ,如下图所示。
- 完成向导:一路点击"下一步",完成项目创建。
创建完程序以后,切换左下角的构建套件,从Debug切换为Release模式,如下图所示:
然后按Ctrl+R键运行程序,初始效果如下:
打开Main.qml,可以对窗体标题、窗体大小、窗体颜色等进行一定的修改:
cpp
import QtQuick
import QtQuick.Controls
ApplicationWindow {
id: root
width: 960
height: 720
visible: true
title: "证照智能识别软件 V1.0"
color: "#1e2a38"
}
修改后重新运行,效果如下:
2.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,添加代码如下:
cpp
qt_add_resources(appSmartIdReader "app_images"
PREFIX "/"
FILES
icons/appicon.png
)
第三步:main.cpp中调用
打开main.cpp,添加代码如下:
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编辑代码如下:
cpp
IDI_ICON_LOGO ICON DISCARDABLE "icons/appicon.ico"
最后在CMakeLists.txt文件中找到qt_add_executable函数,具体修改如下:
cpp
qt_add_executable(appSmartIdReader
main.cpp
logo.rc
)
重新编译后,在windows的资源管理器中可以看到下图所示(应用程序图标已经更换过来):
三、UI设计 (QML)
美观的界面是应用的门面。本项目将采用卡片式设计来展示图片,并提供清晰的操作指引。
3.1 可重用的照片框组件 (PhotoFrame.qml
)
为了保持主界面的整洁和代码的可重用性,先创建一个照片框组件。
在Qt Creator中,右键点击Main.qml
-> 添加新文件...
-> Qt
-> QML File (Qt Quick 2)
,命名为PhotoFrame.qml
。
cpp
// PhotoFrame.qml
import QtQuick
import QtQuick.Controls
Frame {
id: root
width: 300
height: 300
padding: 0
background: Rectangle {
color: "#2c3e50"
border.color: sourceImage.status === Image.Ready ? "#1abc9c" : "#34495e"
border.width: 2
}
property alias imageSource: sourceImage.source
property string title: "待采集"
Image {
id: sourceImage
anchors.fill: parent
anchors.margins: 2
fillMode: Image.PreserveAspectFit
asynchronous: true
}
Label {
text: title
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 8
color: "white"
font.bold: true
background: Rectangle {
color: "#000000"
opacity: 0.5
radius: 3
}
padding: 4
visible: sourceImage.status !== Image.Ready
}
BusyIndicator {
anchors.centerIn: parent
running: sourceImage.status === Image.Loading
}
}
这个组件包含了图像显示、加载动画、边框状态反馈和标题,相对比较完备。
3.2 主界面布局 (Main.qml
)
现在开始搭建主界面。用下面的代码替换Main.qml
的全部内容。
cpp
// main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: root
width: 960
height: 720
visible: true
title: "证照智能识别软件 V1.0"
color: "#1e2a38" // 深色背景
// 状态栏,用于显示提示信息
footer: Frame {
height: 40
background: Rectangle { color: "#2c3e50" }
Label {
id: statusLabel
text: "准备就绪"
anchors.centerIn: parent
color: "white"
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
// 图像显示区
GridLayout {
id: photoGrid
Layout.fillWidth: true
Layout.fillHeight: true
columns: 3
columnSpacing: 15
rowSpacing: 15
Layout.alignment: Qt.AlignHCenter
PhotoFrame {
id: frameFrontWhite
title: "正面-白光"
Layout.fillWidth: true
Layout.fillHeight: true
}
PhotoFrame {
id: frameFrontIR
title: "正面-红外"
Layout.fillWidth: true
Layout.fillHeight: true
}
PhotoFrame {
id: frameFrontUV
Layout.fillWidth: true
Layout.fillHeight: true
title: "正面-紫外"
}
PhotoFrame {
id: frameBackWhite
title: "反面-白光"
Layout.fillWidth: true
Layout.fillHeight: true
}
PhotoFrame {
id: frameBackIR
title: "反面-红外"
Layout.fillWidth: true
Layout.fillHeight: true
}
PhotoFrame {
id: frameBackUV
title: "反面-紫外"
Layout.fillWidth: true
Layout.fillHeight: true
}
}
// 操作区
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 50
Button {
id: scanButton
Layout.preferredWidth: 120
Layout.preferredHeight: 50
text: " 开始采集"
icon.source: "qrc:/icons/camera.svg" // 这里需要添加一个图标
onClicked: {
// 调用C++后端的采集函数
// backend.startScan()
}
background: Rectangle {
color: parent.down ? "#16a085" : "#1abc9c"
radius: 5
}
}
Button {
id: processButton
text: " 处理并识别"
Layout.preferredWidth: 120
Layout.preferredHeight: 50
enabled: false // 采集完成后才可用
icon.source: "qrc:/icons/zap.svg"
onClicked: {
// backend.processAndRecognize()
}
background: Rectangle {
color: parent.down ? "#16a085" : "#1abc9c"
radius: 5
}
}
}
}
}
为了让界面更好看,需要一些图标 。在之前创建的icons
文件夹,放入两个SVG图标(例如从Feather Icons下载 camera.svg
和 zap.svg
,并重命名)。然后打开CMakeLists.txt文件,修改对应的代码,把新的图标资源文件添加进去:
cpp
qt_add_resources(appSmartIdReader "app_images"
PREFIX "/"
FILES
icons/appicon.png
icons/zap.svg
icons/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++对象。
- 打开
main.cpp
文件。 - 包含
backend.h
头文件。 - 在创建
QQmlApplicationEngine
之后、加载Main.qml
之前,创建Backend
对象并将其注册到上下文中。
修改后的完整 main.cpp
代码如下:
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前端更新状态和显示的图片。
核心目标:
- 在C++中创建一个可被QML调用的
startScan
方法。 - 使用信号将采集进度和新捕获的图像路径传递给QML。
- QML接收信号并动态更新UI。
第一步:在Backend
类中定义信号和槽
- 打开
backend.h
,添加信号和Q_INVOKABLE
方法。Q_INVOKABLE
:这个宏让一个普通的C++成员函数可以被QML直接调用。signals:
:信号是Qt对象间通信的基石。它们只声明,不实现。当后端状态改变时,我们就emit
(发射)一个信号。- 我们将定义两个信号:
scanProgressChanged
用于更新状态栏文本,newImageCaptured
用于通知QML有新图片了。
h
// 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
第二步:准备模拟用的样本图片
- 在项目根目录创建一个
sample_images
文件夹。 - 找6张不同的图片(任何.jpg或.png格式都可以),将它们命名为
sample_1.jpg
到sample_6.jpg
,并放入sample_images
文件夹。 - 重要 :将这些图片添加到
CMakeLists.txt
的资源文件中,这样它们才能被qrc:
路径访问。
cmake
# CMakeLists.txt
# ... (找到之前的 qt_add_resources) ...
qt_add_resources(appSmartIdReader "app_images"
PREFIX "/"
FILES
icons/appicon.png
icons/zap.svg
icons/camera.svg
# 添加样本图片
sample_images/sample_1.jpg
sample_images/sample_2.jpg
sample_images/sample_3.jpg
sample_images/sample_4.jpg
sample_images/sample_5.jpg
sample_images/sample_6.jpg
)
第三步:在Backend.cpp
中实现模拟逻辑
我们使用QTimer
每隔500毫秒触发一次onScanStep
槽函数,模拟采集一张图片。
cpp
// 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;
}
// 发射信号,通知QML
emit scanProgressChanged(m_scanStep, message);
if (!imagePath.isEmpty()) {
emit newImageCaptured(imagePath, frameIndex);
}
if (m_scanStep >= 6) {
m_scanTimer.stop();
emit scanProgressChanged(7, "采集完成");
// 采集完成后,启用"处理"按钮 (我们将在下一步中用到)
}
}
第四步:在QML中响应信号并更新UI
- 打开
Main.qml
。 - 在"开始采集"按钮的
onClicked
处理器中,调用backend.startScan()
。 - 添加一个
Connections
元素,它的target
指向全局backend
对象,这样就可以监听其信号了。
cpp
// 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
// ...
}
}
}
}
运行效果
现在,重新编译并运行程序。点击"开始采集"按钮,将看到:
- 状态栏的文本会随着采集步骤而更新。
- 六个照片框会依次、动态地显示出
sample_images
文件夹中的图片。 - 采集完成后,"处理并识别"按钮会变为可用状态,"开始采集"按钮暂时不可用(为了演示,我们也在
onClicked
中加入了重置逻辑)。

到此,我们已经成功地将UI操作连接到了C++后端,并通过信号将后端的状态变化反馈回UI,实现了动态交互。
4.4 模拟AI识别与结果展示
最后一步是模拟图像处理和云端识别的过程,并将识别结果展示在界面上。
核心目标:
- 在C++中创建
processAndRecognize
方法。 - 模拟网络延迟后,发射一个包含识别结果的信号。
- 在QML中添加一个区域,用于美观地展示这些结果。
第一步:在Backend
中添加识别逻辑
我们将添加一个新的信号recognitionComplete
,它携带一个QVariantMap
作为参数。QVariantMap
非常适合用来模拟JSON对象,因为QML可以直接将其作为JavaScript对象使用。
- 在
backend.h
中添加新信号和新方法。
h
// backend.h
// ...
signals:
// ... (之前的信号)
// 信号:识别完成,携带结果
void recognitionComplete(const QVariantMap &results);
public slots: // 将方法改为槽,或者继续用 Q_INVOKABLE
void processAndRecognize();
// ...
- 在
backend.cpp
中实现。同样使用QTimer::singleShot
来模拟网络延迟。
cpp
// 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上增加一块区域来显示识别出的信息。
qml
// main.qml
// ...
ApplicationWindow {
// ...
Connections {
target: backend
// ... (onScanProgressChanged, onNewImageCaptured) ...
// 新增:处理识别完成信号
function onRecognitionComplete(results) {
// 将QVariantMap直接当做JS对象使用
nameLabel.text = results.name
genderLabel.text = results.gender
birthDateLabel.text = results.birthDate
idNumberLabel.text = results.idNumber
nationalityLabel.text = results.nationality
validUntilLabel.text = results.validUntil
confidenceLabel.text = results.confidence
// 显示结果区域
resultsFrame.visible = true
}
}
// ... (footer) ...
ColumnLayout {
// ...
GridLayout { /* ... */ }
// 新增:识别结果展示区
Frame {
id: resultsFrame
Layout.fillWidth: true
Layout.topMargin: 20
visible: false // 默认隐藏
background: Rectangle { color: "#2c3e50" }
GridLayout {
anchors.fill: parent
anchors.margins: 10
columns: 2
columnSpacing: 20
rowSpacing: 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: scanButton
onClicked: {
// ...
resultsFrame.visible = false // 点击扫描时隐藏结果
// ...
}
}
Button {
id: processButton
// ...
onClicked: {
backend.processAndRecognize()
}
}
}
}
}
运行最终效果
编译并运行程序。
- 点击"开始采集",等待6张图片加载完成。
- "处理并识别"按钮变得可用,点击它。
- 状态栏显示"正在处理...",等待2秒。
- 识别结果区域出现,并显示出我们从C++传递过来的信息。

到这里,整个UI和业务流程基本建设完了。
五、摄像头图像采集
到目前为止,我们的应用程序还只是一个"演员",通过模拟数据来表演采集流程。现在,是时候让它成为一个真正的"摄影师"了。在这一章,我们将抛开QTimer
模拟,转而使用Qt Multimedia模块来控制真实的USB摄像头,并将采集到的图像显示在UI上。
5.1 引入Qt Multimedia模块
Qt Multimedia是Qt官方提供的、用于处理音频、视频、收音机和摄像头等媒体内容的跨平台框架。
第一步:配置CMakeLists.txt
要使用摄像头功能,我们必须告诉CMake链接Qt Multimedia库。打开项目的CMakeLists.txt
文件,在find_package
指令中添加Multimedia
。
cpp
# CMakeLists.txt
# ... (之前的配置) ...
find_package(Qt6 REQUIRED COMPONENTS Quick)
find_package(Qt6 REQUIRED COMPONENTS Core)
find_package(Qt6 REQUIRED COMPONENTS Multimedia) # 1. 添加Multimedia
target_link_libraries(appSmartIdReader
PRIVATE Qt6::Quick
)
target_link_libraries(appSmartIdReader PRIVATE Qt6::Core)
target_link_libraries(appSmartIdReader
PRIVATE Qt6::Multimedia # 2. 链接Multimedia库
)
# ... (其他配置) ...
完成这一步后,项目就具备了使用摄像头API的能力。
5.2 线程化的相机工作者 (CameraWorker
)
直接在主线程(UI线程)中操作摄像头,尤其是在捕获图像时,可能会导致界面卡顿,严重影响用户体验。因此,最佳实践是将所有与硬件交互的耗时操作都放到一个单独的工作线程中。
具体的,本文将创建一个CameraWorker
类,它将负责所有与摄像头相关的底层操作。
第一步:创建CameraWorker
类文件
在Qt Creator中,像之前创建Backend
一样,添加新文件...
-> C++
-> C++ Class
。
- 类名 :
CameraWorker
- 基类 :
QObject
第二步:定义cameraworker.h
这个头文件将定义CameraWorker
的接口,包括它能接收的指令(槽)和它能发出的通知(信号)。
cpp
// 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
这是核心逻辑。
- 构造函数 :查找可用的摄像头,并初始化
QCamera
、QMediaCaptureSession
和QImageCapture
对象。 startCapture
:启动摄像头,并准备开始第一次拍照。processCapturedImage
:这是由QImageCapture
的imageCaptured
信号触发的关键槽函数。它接收捕获到的QImage
,通过信号将其发送出去,并判断是否需要进行下一次捕获。triggerNextCapture
:我们使用一个500毫秒的单次定时器来触发下一次m_imageCapture->capture()
调用,从而模拟采集间隔。
cpp
// 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
。
h
// 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
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;
}
// 如果没有找到图片,返回一个空的QImage
return QImage();
}
void ImageProvider::updateImage(const QString &id, const QImage &image)
{
m_images.insert(id, image);
}
5.4 整合与最终呈现
现在,可以将所有部分组装起来。Backend
将负责创建和管理CameraWorker
线程,并充当CameraWorker
和QML之间的中介。
第一步:更新Backend
以管理CameraWorker
- 在
backend.h
中,移除旧的模拟用成员,添加线程和worker成员。
h
// 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
- 在
main.cpp
中,除了注册Backend
,我们还需要注册ImageProvider
。
cpp
// main.cpp
// ...
#include "imageprovider.h" // 1. 包含ImageProvider
int main(int argc, char *argv[])
{
// ... (app创建) ...
QQmlApplicationEngine engine;
// 2. 实例化ImageProvider
ImageProvider *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
的指针。
- 在
backend.cpp
中实现最终的整合逻辑。
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信号 -> QML
connect(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); // 将错误显示在状态栏
});
// 线程退出时,自动删除worker
connect(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)
{
// 构造一个唯一的图片ID
QString imageId = QString("capture_%1").arg(frameIndex);
// 更新ImageProvider中的图片
m_imageProvider->updateImage(imageId, image);
// 构造给QML的URL
QString imageUrl = QString("image://livefeed/%1").arg(imageId);
// 发射带有URL的信号给QML
emit newImageCaptured(imageUrl, frameIndex);
}
// processAndRecognize 方法保持不变
// ...
第二步:最后修改Main.qml
QML端的修改非常小,因为它之前已经设计为响应newImageCaptured(imageUrl, frameIndex)
信号,而我们现在传递的正是QML Image
元素可以直接使用的image://
URL。
运行最终效果
现在,编译并运行应用程序,并且确保USB摄像头已连接。
- 点击"开始采集"按钮。
- 程序会控制摄像头,每隔约半秒拍摄一张照片。
- 每一张新捕获的照片会实时显示在对应的
PhotoFrame
中,整个UI界面保持流畅响应。 - 采集完成后,流程与之前一样,可以点击"处理并识别"来查看模拟结果。

5.5 安全的退出线程
前面的代码执行后可以正常运行,但是有个很重要的Bug出现了。最后,单击程序关闭按钮后,对应的摄像头采集线程并没有关闭。这意味着主线程在等待工作线程结束时被卡住,或者工作线程的退出逻辑没有被正确触发。
根据我们之前的代码,问题很可能出在**Backend
的析构函数和 QThread
的退出机制**上。
5.5.1 问题根源分析
在之前的Backend
析构函数中,我们这样写:
cpp
Backend::~Backend()
{
m_cameraThread->quit();
m_cameraThread->wait();
}
这段代码的意图是:
m_cameraThread->quit()
: 告诉m_cameraThread
的事件循环(event loop)退出。当事件循环处理完所有待办事件后,它就会停止。m_cameraThread->wait()
: 阻塞 主线程,直到m_cameraThread
的run()
函数执行完毕(即线程真正结束)。
这里存在一个潜在的死锁风险或无限等待 :CameraWorker
是在m_cameraThread
中运行的,但它本身是在主线程中创建的,并且其父对象是Backend
(也在主线程)。当主线程退出,尝试析构Backend
时,它会等待m_cameraThread
结束。但m_cameraThread
的退出可能依赖于CameraWorker
中某些事件的完成,而CameraWorker
的父对象Backend
正在被析构,这可能导致一个复杂的依赖循环。
5.5.2 正确的解决方案:利用信号槽机制关闭线程
解决这个问题的最佳实践是确保线程的退出流程清晰、无阻塞,并正确处理对象的生命周期。
我们将利用信号槽机制来确保清理工作在正确的时机、正确的线程上执行。
cpp
// 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 ...)
之后,添加以下代码:
cmake
# 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 PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Quick
Qt6::Controls2
Qt6::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传来的对比度和亮度值。
h
// 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
。
cpp
// 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 + beta
cv::Mat processedMat;
mat.convertTo(processedMat, -1, m_contrast, m_brightness);
// 3. 将处理后的cv::Mat转换回QImage
QImage finalImage(processedMat.data, processedMat.cols, processedMat.rows, processedMat.step, QImage::Format_RGB888);
// --- 结束OpenCV处理 ---
// 发射处理后的图像
emit imageCaptured(finalImage.copy(), m_captureStep); // 使用copy()确保数据所有权
// ... (后续逻辑保持不变) ...
}
第三步:在backend.h
中添加qml调用接口
cpp
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
中添加应用
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
和滑块
qml
// main.qml
import QtQuick
// ...
ApplicationWindow {
id: root
// ...
// --- 新增:设置抽屉 ---
Drawer {
id: settingsDrawer
width: 250
height: parent.height/2
edge: Qt.RightEdge // 从右侧滑出
background: Rectangle { color: "#1c3e50" }
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 15
Label {
text: "图像调节"
font.bold: true
color: "#ecf0f1"
Layout.alignment: Qt.AlignHCenter
}
// 对比度
Label { text: "对比度: " + contrastSlider.value.toFixed(2); color: "white" }
Slider {
id: contrastSlider
from: 1.0
to: 3.0
value: 1.0 // 默认值
Layout.fillWidth: true
// 当滑块值变化时,实时调用C++槽函数
onValueChanged: backend.setContrast(value)
}
// 亮度
Label { text: "亮度: " + brightnessSlider.value; color: "white" }
Slider {
id: brightnessSlider
from: -100
to: 100
value: 0 // 默认值
Layout.fillWidth: true
onValueChanged: backend.setBrightness(value)
}
}
}
// --- 新增:打开设置的按钮,放在状态栏 ---
footer: Frame {
// ...
Button {
id: settingsButton
ToolTip.text: "图像参数设置"
icon.source: "qrc:/icons/settings.svg" // 需要一个settings.svg图标
icon.color: "white"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: 10
flat: true
onClicked: settingsDrawer.open()
}
}
// ... (主布局)
}
别忘了添加settings.svg
图标到icons
目录,并更新CMakeLists.txt
的资源文件列表。
最后,将opencv_world4110.dll文件拷贝到当前项目根目录下。
运行效果
编译运行,点击右下角的设置按钮,会滑出设置面板。拖动滑块,下次再采集时就会改变对应的亮度和对比度了。
6.4 使用配置文件实现参数持久化
现在,我们的图像调节功能已经可以工作了,但每次重启程序,滑块都会回到默认位置。为了提升用户体验,我们希望程序能"记住"用户上一次的对比度和亮度设置。我们将使用Qt提供的QSettings
类,并明确指定它使用一个INI格式的配置文件来存储数据,而不是依赖于Windows注册表。
实现流程:
- 程序启动时 :从应用程序目录下的一个
settings.ini
文件中读取上一次的对比度和亮度设置。 - 应用初始设置 :将读取到的值应用到UI滑块和
CameraWorker
。 - 用户调整设置时 :每当用户拖动滑块,就立即将新的值保存到
settings.ini
文件中。
我们将所有与QSettings
相关的逻辑都集中在Backend
类中。
第一步:在Backend
类中引入QSettings
并适配文件配置
- 打开
backend.h
文件,包含必要的头文件。
cpp
// 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. 我们需要一个信号来将加载的设置传递给QML
void 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
文件。
- 在构造函数中 ,确定配置文件的路径,并调用
loadSettings()
。 - 在
setContrast
和setBrightness
槽函数中 ,调用saveSettings()
来实时更新配置文件。
cpp
// 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;
// 将加载的值应用到CameraWorker
emit 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
中响应初始化信号
cpp
// Main.qml
// ...
ApplicationWindow {
// ...
Connections {
target: backend
// ... (其他信号处理器) ...
// 响应设置加载完成的信号
function onSettingsLoaded(initialContrast, initialBrightness) {
console.log("Applying initial settings to UI:", initialContrast, initialBrightness)
contrastSlider.value = initialContrast
brightnessSlider.value = initialBrightness
}
}
Drawer {
id: settingsDrawer
// ...
Slider {
id: contrastSlider
// value属性的初始值会在onSettingsLoaded中被设置
onValueChanged: backend.setContrast(value)
}
// ...
Slider {
id: brightnessSlider
onValueChanged: backend.setBrightness(value)
}
}
// ...
}
验证上述程序:拖动面板上的进度条,可以将参数值写入到程序根目录下的settings.ini文件,即可以完成qml->C+±>ini这样的步骤。但是每次修改完参数,下次再启动后,上次的参数值并没有显示在qml面板上,qml面板上还是使用的默认的参数值。
这到底是什么问题呢?
问题的核心 :C++的Backend
在构造时发射了settingsLoaded
信号,但此时QML界面还没完全准备好接收。
最简单的解决方法就是:让QML在准备好之后,主动向C++请求一次初始设置值。
具体流程:
- C++
Backend
:提供一个Q_INVOKABLE
方法,比如requestInitialSettings()
,当QML调用它时,它会发射带有当前设置值的信号。 - QML
Main.qml
:在根组件的Component.onCompleted
事件处理器中,调用这个backend.requestInitialSettings()
方法。Component.onCompleted
保证了在QML界面完全加载并准备好之后才执行。
这个方案非常直观,就像"QML启动后,举手说:嘿,C++,我准备好了,请把初始设置发给我!"
第一步:修改 backend.h
我们只需要添加一个可调用的方法。
cpp
// 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
- 从构造函数中移除 对
loadSettings()
的直接调用和信号发射。 - 实现新增的
requestInitialSettings()
方法。
cpp
// 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();
// 将加载的值应用到worker
emit 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
。
cpp
// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
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++响应请求并发出信号时,更新UI
function onSettingsLoaded(initialContrast, initialBrightness) {
console.log("Received initial settings:", initialContrast, initialBrightness)
contrastSlider.value = initialContrast
brightnessSlider.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启动时序问题的最简单、最直观的方法。
流程回顾:
- 程序启动,C++
Backend
被创建,但它"静静地"等待。 - QML引擎加载
Main.qml
。当整个QML树构建完成,ApplicationWindow
的Component.onCompleted
被触发。 - QML调用
backend.requestInitialSettings()
。 - C++
Backend
收到请求,执行loadSettings()
,然后发射settingsLoaded
信号,并携带从文件中读出的值。 - QML的
Connections
元素此时早已准备就绪,它捕获到settingsLoaded
信号,并用接收到的值更新滑块的value
。
最终效果与验证
可以进行以下操作来验证功能:
-
首次运行 :编译并运行程序。由于
settings.ini
文件还不存在,程序会使用默认值(对比度1.0,亮度0)。 -
调整设置 :打开设置面板,将对比度滑块拖到
2.5
,亮度滑块拖到30
。 -
检查配置文件 :打开项目的构建目录(通常是类似
build-SmartIdReader-Desktop_Qt_...-Release
的文件夹),会发现里面自动生成了一个settings.ini
文件。用记事本打开它,内容应该是:ini[ImageSettings] contrast=2.5 brightness=30
-
关闭程序:完全关闭应用程序。
-
再次运行 :重新启动程序。此时,程序会读取
settings.ini
文件,会看到设置面板中的滑块自动恢复 到了2.5
和30
的位置。 -
手动修改 :关闭程序,手动修改
settings.ini
文件中的brightness
为-80
并保存。再次运行程序,会看到亮度滑块直接定位到了-80
的位置。
这个基于配置文件的方案,不仅实现了参数的持久化,还提供了极大的灵活性和透明度,是开发健壮桌面应用的常用方法。
6.5 图像保存
在完成采集和识别后,用户往往需要将处理过的图像保存下来作为凭证或存档。我们将添加一个"保存图像"按钮,点击后,会将当前显示的6张图像进行分辨率调整,并以规范的命名格式保存到用户桌面。
第一步:在Main.qml
中添加"保存图像"按钮
在操作区的RowLayout
中,紧挨着"处理并识别"按钮,添加一个新的Button
。
cpp
// Main.qml
// ...
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 20 // 调整间距以容纳新按钮
Button {
id: scanButton
// ...
}
Button {
id: processButton
// ...
}
// *** 新增:保存图像按钮 ***
Button {
id: saveButton
Layout.preferredWidth: 120
Layout.preferredHeight: 50
text: " 保存图像"
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
的构造函数。
- 修改
backend.h
:添加saveAllImages
槽,并增加一个信号用于反馈保存状态。
h
// backend.h
// ...
class Backend : public QObject
{
// ...
public slots:
// ...
// *** 新增:保存所有图像的槽 ***
Q_INVOKABLE void saveAllImages();
signals:
// ...
// *** 新增:保存状态反馈信号 ***
void saveStatusUpdated(const QString &status);
private:
// ... (所有成员变量保持不变)
};
- 修改
backend.cpp
:实现saveAllImages
的核心逻辑。具体的,将在这个函数中完成路径创建、图像获取、缩放和保存的所有工作。
cpp
// 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::Mat
QImage 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
发来的保存状态信息。
cpp
// Main.qml
// ...
ApplicationWindow {
// ...
Connections {
target: backend
// ... (onScanProgressChanged, onNewImageCaptured, etc.) ...
// *** 新增:响应保存状态更新的信号 ***
function onSaveStatusUpdated(status) {
statusLabel.text = status;
}
}
// ... (footer, ColumnLayout, etc.) ...
}
现在,应用程序已经具备了完整的"采集 -> 识别 -> 保存"流程!
- 点击"开始采集",完成6张图片的捕获。
- 此时,"保存图像"按钮变为可用。
- 点击"保存图像"。
- 观察状态栏,它会先显示"正在保存图像...",然后显示成功或失败的信息。
- 打开电脑桌面,会看到一个名为
images
的文件夹。 - 进入该文件夹,会有一个以当天日期命名的子文件夹,例如
2023-10-27
。 - 进入日期文件夹,会看到6张被处理过的
.jpg
图片,文件名类似于14_30_05_1.jpg
,14_30_05_2.jpg
...,并且它们的宽度都已经被缩放到了1200像素。
最终运行效果如下:
七、HTTP通信与AI集成
现在,我们的应用将从一个功能强大的原型,蜕变为一个真正接入AI能力的智能客户端。我们将替换掉第4.4节的模拟逻辑,通过HTTP协议与一个本地的Python FastAPI服务进行通信,实现真实的图像OCR识别。
实现流程:
- 搭建本地AI服务器:使用Python的FastAPI和PaddleOCR库,创建一个能接收图像并返回识别结果的API端点。
- 实现网络请求 :在C++
Backend
中,使用QNetworkAccessManager
发送带有图像数据的POST请求。 - 处理并显示结果:解析服务器返回的JSON数据,将识别出的文本和带标注的图像更新到UI上。
7.1 搭建本地OCR服务器 (Python)
我们将使用FastAPI,因为它轻量、快速且易于使用。
第一步:安装依赖库
在Python环境中(推荐使用虚拟环境),安装必要的库(本文不再阐述Python的安装方法,相关教程较多,请读者自行安装好)。其中PaddlePaddle的安装推荐参考官网,本文推荐安装2.5.2
版本,该版本工业应用尤其稳定。
bash
python -m pip install paddlepaddle==2.5.2 -i https://pypi.tuna.tsinghua.edu.cn/simple
bash
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。
python
# 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)
第三步:启动服务器
在项目根目录的终端中运行:
bash
python server.py
如果一切顺利,会看到类似Uvicorn running on http://127.0.0.1:8000
的输出。现在,AI服务器已经准备就绪。
7.2 在Qt中实现HTTP客户端
具体的,将修改Backend
类,用真实的QNetworkAccessManager
替换掉之前模拟AI的QTimer
。
第一步:修改backend.h
包含网络相关的头文件,并为网络请求准备一个成员变量。
h
// backend.h
// ...
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QUrlQuery>
#include <QHttpMultiPart>
class Backend : public QObject
{
// ...
private:
// ...
QNetworkAccessManager *m_networkManager;
};
第二步:修改backend.cpp
- 在构造函数 中初始化
QNetworkAccessManager
。 - 重写
processAndRecognize
方法,使其从ImageProvider
获取第一张图,并发送HTTP POST请求。 - 处理网络回复,解析JSON,并更新UI。
cpp
// 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格式的QByteArray
QByteArray 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对象转换为QByteArray
QJsonDocument 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
:
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;
}
// 如果没有找到图片,返回一个空的QImage
return QImage();
}
最终运行效果:
现在,确保Python FastAPI服务器正在运行,然后编译并启动Qt应用程序。
- 点击"开始采集",等待6张图片从摄像头捕获完成。
- 点击"处理并识别"。
- 状态栏会显示"正在发送...",程序会将第一张图发送到本地的AI服务器。
- 几秒钟后(取决于电脑性能),服务器处理完毕并返回结果。
- Qt客户端会:
- 结果区显示从图像中识别出的文本。
- 将第一个照片框中的原始图像,替换为由AI服务返回的、带有识别边界框的图像。

至此,已经完成了一个完整的、端到端的智能证照识别应用原型。它涵盖了从优雅的UI设计、多线程硬件交互、高级图像处理,到与真实AI服务的网络通信,完美地展示了Qt与Python生态结合的强大威力。
7.3 优化图像显示:双击缩放
为了方便用户查看单张图像的细节,我们将增加一个交互功能:当用户双击任何一个PhotoFrame
时,该照片框将放大到占据整个网格区域,同时隐藏其他所有照片框。再次双击,则恢复到原来的6格布局。
核心思路:
- 在
PhotoFrame.qml
中,添加一个MouseArea
来捕捉双击事件,并定义一个自定义信号doubleClicked
。 - 在
Main.qml
中,为GridLayout
添加一个状态"zoomed"
,用于控制单个PhotoFrame
的放大效果。 - 在
Main.qml
中,为每个PhotoFrame
添加一个onDoubleClicked
处理器,当接收到双击信号时,切换主界面的状态。
第一步:修改PhotoFrame.qml
- 添加双击信号
给可重用的PhotoFrame
组件增加发出双击信号的能力。
cpp
// PhotoFrame.qml
import QtQuick
import QtQuick.Controls
Frame {
id: root
// ... (之前的属性)
// *** 1. 定义一个自定义信号 ***
signal doubleClicked()
// ... (Image, Label, BusyIndicator) ...
// *** 2. 添加MouseArea来捕捉双击事件 ***
MouseArea {
anchors.fill: parent
// 当检测到双击时,发射我们定义的信号
onDoubleClicked: root.doubleClicked()
}
}
第二步:修改Main.qml
- 定义状态并实现切换逻辑
这是本节的核心。我们将利用QML强大的状态机功能。
cpp
// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
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 !== null
PropertyChanges {
target: photoGrid // 改变GridLayout本身的属性
// 让放大的元素占据所有行和列
rows: 1
columns: 1
}
}
]
// *** 3. 定义状态切换时的动画(可选,但效果更好)***
transitions: [
Transition {
// 包含所有属性变化的默认动画
NumberAnimation { properties: "rows, columns"; duration: 300; easing.type: Easing.InOutQuad }
OpacityAnimator { duration: 300 }
}
]
}
// ... (结果区和操作区)
}
}
最终运行效果:
现在,重新编译并运行程序:
- 完成图像采集。
- 双击任何一张图片,它会平滑地放大以占据整个网格区域,其他图片则会淡出隐藏。
- 再次双击这张放大的图片,它会平滑地缩小,同时其他5张图片会淡入,恢复到初始的6格布局。

这个简洁而强大的UI增强功能,完全在QML层实现,充分展示了QML声明式编程和状态机的优雅之处。现在的程序现在不仅功能完备,交互体验也更上了一个台阶。
好的,非常荣幸能为您完成这篇教程的最后一部分。程序开发完成后,如何将其交付给最终用户是一个至关重要的环节。我们将介绍如何在Windows上进行部署和打包,让您的应用程序变成一个专业的安装包。
八、部署和打包
我们已经完成了一个功能强大的应用程序,但现在它还只存在于开发环境中。为了让没有安装Qt和OpenCV的普通用户也能使用它,需要将程序及其所有依赖项打包成一个独立的安装程序。这个过程主要分为两步:
- 部署 (Deployment):收集应用程序运行所需的所有文件(如DLL库)到一个干净的文件夹中。
- 打包 (Packaging) :使用专门的工具将这个文件夹制作成一个用户友好的
setup.exe
安装包。
8.1 使用 windeployqt
收集依赖项
Qt提供了一个官方工具windeployqt.exe
,它能自动扫描可执行文件,并把所有需要的Qt相关DLL库、QML文件、插件等复制到同一个目录下。
第一步:确保使用Release版本编译
部署前,需确保项目是在Release模式下编译的。Release版本经过优化,运行速度更快,体积也更小。
第二步:找到windeployqt
和应用程序
windeployqt.exe
的位置 :它位于Qt安装目录中,例如C:\Qt\6.9.1\msvc2022_64\bin
。最好将这个路径添加到系统的PATH环境变量中,以便在任何地方都能调用它。- 程序位置 :它位于项目的构建目录中,例如
build-SmartIdReader-Desktop_Qt_6_9_1_MSVC2022_64bit\release
。
第三步:执行部署命令
-
打开一个命令行终端(CMD或PowerShell)。
-
使用
cd
命令切换到应用程序所在的release
目录。bashcd path\to\your\project\build-SmartIdReader-Desktop_...\release
-
运行
windeployqt
命令。由于我们的项目是Qt Quick应用,需要使用--qmldir
参数指向QML源文件所在的目录,以确保QML模块和插件被正确复制。bashwindeployqt --qmldir ../../ appSmartIdReader.exe
命令解释:
--qmldir ../../
:--qmldir
告诉工具QML源文件的位置。../../
是相对于release
目录的QML项目源文件夹的路径。需要根据实际目录结构调整这个路径。appSmartIdReader.exe
:主程序。
第四步:手动复制非Qt依赖项
windeployqt
非常智能,但它只认识Qt的依赖项。我们必须手动复制其他库,比如OpenCV。
- 找到您的OpenCV安装路径下的
bin
目录,例如D:\toolplace\opencv\build\x64\vc16\bin
。 - 将
opencv_world4110.dll
(版本号可能不同)复制到刚才的release
文件夹中,与appSmartIdReader.exe
放在一起。
完成以上步骤后,您的release
文件夹现在就像一个"绿色版"的软件,包含了运行所需的所有文件。
8.2 使用Inno Setup制作安装包
现在,我们将这个部署好的文件夹打包成一个专业的setup.exe
。这里推荐使用免费且强大的Inno Setup工具。
第一步:下载并安装Inno Setup
从其官方网站下载并安装Inno Setup。
第二步:使用脚本向导创建安装包
- 打开Inno Setup,选择
File -> New...
启动脚本向导。 - Application Information: 填入您的应用名称(证照智能识别软件)、版本号和公司名称。

-
Application Files: 这是最关键的一步。
- Application main executable file : 点击
Browse...
,选择我们之前部署好的release
文件夹中的appSmartIdReader.exe
。 - Other application files : 点击
Add folder...
,选择整个release
文件夹,并确认包含子文件夹 。这样,所有的DLL和插件都会被包含进去。
- Application main executable file : 点击
-
Application Icons: 根据向导的提示,勾选"Create a desktop icon"和"Create a Quick Launch icon"来创建桌面和快速启动快捷方式。
-
完成向导 : 其余步骤可以保持默认,一路"下一步"直到完成。向导会生成一个
.iss
脚本文件。
第三步:编译安装包
向导生成的脚本已经基本可用。在Inno Setup的编辑器中,点击工具栏上的绿色"运行"按钮(或按F9
)来编译脚本。
编译成功后,在您指定的输出目录(默认为Output
文件夹)下,就会生成一个名为setup.exe
的安装文件!
第四步:测试安装包
运行这个setup.exe
,就会看到一个非常专业的安装向导。完成安装后,在桌面和开始菜单中找到程序图标,双击运行,一个无需任何外部依赖的、完整的"证照智能识别软件"就成功交付了。
好的,非常荣幸能为您这篇精彩的教程画上一个圆满的句号。一个强有力的总结能够升华主旨,让读者回顾所学并展望未来。
九、全文总结
至此,我们已经共同走完了一段从零到一的完整开发旅程。我们不仅从一个空白的Qt Creator项目开始,逐步构建出一个功能完整、界面美观的"证照智能识别软件"原型,更重要的是,我们系统性地实践了现代桌面应用开发的全流程。
回顾我们的旅程,我们掌握了:
- 优雅的UI构建:利用Qt Quick和QML的声明式语法,我们高效地创建了一个响应式、可重用且具现代感的图形界面。
- 强大的C++后端 :我们设计了一个清晰的C++后端,通过Qt核心的信号与槽机制,将其与QML前端无缝连接,实现了前后端逻辑的清晰分离。
- 流畅的用户体验 :通过将耗时的摄像头采集和图像处理任务放入独立线程,我们保证了主UI线程的流畅,避免了卡顿,这是开发高性能客户端应用的关键。
- 核心功能集成 :我们成功地集成了三大核心技术:
- Qt Multimedia:用于与真实硬件(摄像头)交互。
- OpenCV:用于实现强大的图像处理与增强。
- 网络通信 :通过
QNetworkAccessManager
与本地AI服务进行HTTP通信,为接入云端智能铺平了道路。
- 专业的交付流程 :我们学习了如何使用
windeployqt
和Inno Setup
等专业工具,将复杂的项目依赖项打包成一个对最终用户友好的、一键安装的应用程序。
这个项目不仅仅是一个"教程",它是一个浓缩了现代软件工程思想的实战案例。它证明了Qt/C++与QML的组合,在构建需要高性能后端、复杂逻辑处理以及漂亮UI的桌面应用时,依然是无与伦比的强大选择。
未来可期,更进一步:
本文所展示的,仅仅是Qt强大生态的冰山一角。如果您对本文所涉及的技术意犹未尽,渴望在工业应用、高性能计算和复杂系统架构领域进行更深入的探索,那么我非常荣幸地向您预告------我正在撰写一本全新的书籍,暂定名为:《面向工业应用的 Qt6 实战(基于C++和QML)》
这本书将远超本博客的范畴,系统性地深入探讨:
- 高级C++/QML交互模式:包括插件化架构、自定义模型、以及更复杂的属性绑定和所有权管理。
- 性能优化专题:从渲染管线到数据处理,全方位剖析和优化您的Qt应用,使其在严苛的工业环境中也能流畅运行。
- 工业级硬件集成:超越USB摄像头,讲解如何与工业相机、传感器、PLC等设备进行稳定、高效的通信。
- 跨平台开发的陷阱与最佳实践:分享在Windows、Linux及嵌入式平台上进行部署和调试的宝贵经验。
- 更多真实世界的项目案例:涵盖数据可视化、机器视觉、自动化控制等多个工业领域。
这本书是我多年Qt开发经验的结晶,旨在为您提供一套从入门到精通,从原型到产品的完整知识体系。如果您希望将自己的Qt技能提升到一个全新的高度,敬请关注本书的后续动态!
最后,再次感谢读者跟随本教程走到这里。编程的乐趣在于创造,希望本文能成为您创造之旅的坚实起点。编码愉快,未来可期!