讲义新增:实训项目集六项目深度解析(04/11/02/05/06/08)

wiki「04. 项目集」(pageId 183664900)中学生关注度较高的六个项目,
每个按「定位 → 题目重点 → 技术难点拆解 → 高频坑与排查 →
里程碑启动路径」展开,并附 Qt5/Qt6 版本差异说明;
分层原则与 ASSIGNMENTS.md 一致(先保最小可运行闭环,再逐层加分)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
张宗平
2026-07-10 03:53:10 +08:00
parent d94d9175b1
commit 8d69cbebd5
+659
View File
@@ -0,0 +1,659 @@
# 实训项目集深度解析(按学生关注度:04 / 11 / 02 / 05 / 06 / 08
> 题目来源:wiki「02. Qt方向 → 04. 项目集」(pageId 18366490016 个项目)。
> 本文对其中学生关注度较高的 6 个项目做深入拆解:题目重点是什么、技术难点在哪、
> 哪些地方容易卡住、卡住了往哪个方向查。目标是让学生顺利启动、按里程碑推进,
> 不在环境和 API 细节上消耗整周时间。
>
> 每个项目按固定结构展开:**定位 → 题目重点 → 技术难点拆解 →
> 高频坑与排查 → 建议启动路径(里程碑)**。里程碑设计原则与
> [`ASSIGNMENTS.md`](ASSIGNMENTS.md) 相同:先保最小可运行闭环,再逐层加功能。
## 六个项目总览
| 项目 | 难度 | 核心考点 | 最容易卡住的点 |
|---|---|---|---|
| 04 简易本地音乐播放器 | ⭐⭐⭐⭐⭐ | QtMultimedia、Model/View、异步元数据 | 元数据是异步到达的,setMedia 后立即读为空 |
| 11 OpenCV 面部识别系统 | ⭐⭐⭐⭐⭐⭐ | OpenCV+Qt 集成、图像格式转换、多线程 | 环境搭建:MinGW 的 Qt 链不上 MSVC 编译的 OpenCV |
| 02 基于QT的自动售卖机 | ⭐⭐⭐⭐⭐ | QStateMachine、Model/View、互斥锁 | 带条件的状态跳转(守卫)Qt 没有现成 API |
| 05 个人相册管理与智能标签 | ⭐⭐⭐⭐⭐ | 缩略图性能、多线程加载、SQLite 索引 | 主线程同步加载大图,几十张照片就把界面卡死 |
| 06 多功能抽奖与随机点名 | ⭐⭐⭐⭐ | 随机算法正确性、定时器动画 | CSV 导入中文乱码(Excel 导出的是 GBK) |
| 08 双人飞机大战 | ⭐⭐⭐⭐⭐ | QGraphicsView、游戏循环、碰撞检测 | 键盘多键同时按:直接在 keyPressEvent 里移动会卡顿、双人抢键 |
## 开工前的三条通用提醒(六个项目都适用)
1. **先确认 Qt 版本再写第一行代码。** 课程环境是 Qt 5.14.2。自己电脑装了 Qt 6 的
同学注意:这批题目里 04`QMediaPlaylist` 在 Qt6 被删除)和 02`QStateMachine`
在 Qt6 挪进了独立的 StateMachine 模块)受 Qt5/Qt6 差异影响最大,各项目小节末尾
有版本说明。网上的 Qt5 和 Qt6 示例代码混着抄,是编译报错查半天的常见原因。
2. **资源路径不要用裸相对路径。** 双击 exe 和从 IDE 里运行,"当前目录"不一样。
图片、音效等只读资源进 `.qrc``:/imgs/xxx.png`);存档、配置等可写数据用
`QCoreApplication::applicationDirPath()``QStandardPaths` 拼绝对路径。
"IDE 里正常、双击就白屏/没声音"多数是这个原因。
3. **每完成一个里程碑就 git commit。** 这批题目都要在一份代码上连续叠功能,
没有提交点,一次重构失败就退不回去。验收时 git log 也能说明你是按里程碑推进的。
---
## 04. 简易本地音乐播放器 ⭐⭐⭐⭐⭐
### 定位
用 QtMultimedia 做一个能实际使用的播放器:播放控制链路(QMediaPlayer)、
播放列表(Model/View)、元数据展示三块拼起来。没有算法难度,考的是
把 Qt 各模块正确地连在一起,六个项目里属于投入产出比较高的一个。
### 题目重点
- 这道题的骨架是两条方向相反的状态同步链:
1. UI → 播放器:按钮/滑块操作调用 `play()/pause()/setPosition()/setVolume()`
2. 播放器 → UI`positionChanged`/`durationChanged`/`stateChanged`/
`metaDataChanged` 等信号回写界面。
两条链各自单向、靠信号槽解耦,这正是题面"各UI组件间通过信号与槽高效解耦通信"
要考的。写成"按钮槽里既改播放器又顺手改十个控件",这部分分就没有了。
- 播放列表用 Model/View`QAbstractTableModel` 或至少 `QStandardItemModel` +
`QTableView`),不要用 `QTableWidget` 逐格塞字符串,题面明确点了 Model/View。
- 播放模式(列表循环/单曲循环/随机)在 Qt 5.14 里有现成 API
`QMediaPlaylist::setPlaybackMode(Loop / CurrentItemInLoop / Random)`,一行完成。
### 技术难点拆解
**难点 1:元数据(ID3 标签)是异步到达的。** `setMedia()` 之后媒体要经历加载
过程,立即调 `metaData()` 拿到的是空值。正确做法:连接
`QMediaPlayer::metaDataAvailableChanged``mediaStatusChanged`
(等 `LoadedMedia`)信号,在槽里再读
`metaData(QMediaMetaData::Title / ContributingArtist / AlbumTitle)`
标题一直显示空白的,基本都是在 `setMedia` 后下一行就去读了。
**难点 2:进度条的拖动回环。** 直观的写法是把 `positionChanged` 连到
`slider->setValue()`、把 `slider->valueChanged` 连到 `player->setPosition()`
结果两个信号互相触发,拖动时来回跳动甚至卡顿。标准解法:
- 播放器 → 滑块用 `setValue`(程序触发的 `valueChanged` 不做 seek);
- 用户 → 播放器只用 `sliderMoved`(仅用户拖动才发出),或
`sliderPressed` 时暂停回写、`sliderReleased` 时一次性 `setPosition()`
**难点 3:专辑封面在很多平台后端拿不到。** `QMediaMetaData::CoverArtImage`
依赖系统解码后端,Windows/Linux 上对 MP3 内嵌封面经常返回空。题面已经给了
降级路径,按题面做即可:拿不到就在歌曲同目录找 `cover.jpg`/`folder.jpg`
再拿不到显示默认占位图。想真正解析 ID3 内嵌封面(APIC 帧)可引入 TagLib
算加分项,不是及格线。
**难点 4:解码后端决定能不能出声。** QMediaPlayer 自己不解码,依赖系统后端:
WindowsDirectShow/WMF)放 MP3/WAV 没问题,FLAC 未必;Linux 走 GStreamer
`gstreamer1.0-plugins-good/bad/ugly、gst-libav` 就没有声音。
建议先用 WAV 文件打通全链路,再测 MP3/FLAC,把"代码错"和"后端缺解码器"
两类问题分开。
**难点 5(可选算法项):LRC 歌词同步。** 题面点了二分查找:把 LRC 解析成
按时间戳排序的 `QVector<QPair<qint64, QString>>``positionChanged` 里用
`std::upper_bound` 定位当前行。这是题目"算法与数据结构优化"一节里
最容易落地的一项(Fisher-Yates 随机也是,见项目 06 的展开)。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| 编译报 `QMediaPlayer: No such file` | 没链 multimedia 模块 | qmake: `QT += multimedia`CMake: `find_package(... Multimedia)` + `target_link_libraries(... Qt5::Multimedia)` |
| 不报错但没声音 | ① 后端缺解码器 ② 文件路径错(相对路径) ③ 音量为 0 | 先播 WAV;`QMediaPlayer::error` 信号连上打印;路径用 `QUrl::fromLocalFile(绝对路径)`,不要手拼 `file://` |
| 歌曲标题/歌手一直空白 | 元数据异步,读早了 | 挪进 `metaDataAvailableChanged` 槽里读 |
| 拖进度条时来回跳动 | 双向信号回环 | 用 `sliderMoved`/`sliderReleased`,见难点 2 |
| 显示时间是一串大数字 | `position()` 返回毫秒(qint64 | `QTime(0,0).addMSecs(pos).toString("mm:ss")` |
| 关闭再打开,列表没了 | 只存了内存 | 关闭时(`closeEvent`)把路径列表写 `QSettings` 或 JSON,启动时读回;注意存绝对路径 |
| 随机模式来回就那两首 | 用 `rand()%n` 且每次独立随机 | 用 `QMediaPlaylist::Random`,或自己实现 Fisher-Yates 洗牌序列 |
### 建议启动路径(里程碑)
1. **M1(半天)**:一个按钮 + 一个 `QMediaPlayer`,点击播放一个写死路径的 WAV。
打通"模块链接 + 能出声",这一步没过不要写任何 UI。
2. **M21 天)**`QFileDialog` 多选添加 → `QMediaPlaylist` + Model/View 列表 →
双击列表任意行播放。
3. **M31 天)**:播放/暂停/上下曲按钮、进度条(含拖动 seek)、音量条、
时间标签。到这里已经可以日常使用。
4. **M41 天)**:三种播放模式切换 + 元数据/封面展示(含降级链)+ 列表持久化。
5. **M5(加分,量力)**:LRC 歌词二分同步、系统托盘、QSS 明暗双主题、LRU 最近播放。
### Qt5 / Qt6 版本说明(本项目受影响最大)
- `QMediaPlaylist` 在 Qt6 被删除,没有替代类。用 Qt6 就得自己维护
`QList<QUrl>` + 当前索引 + 播放模式逻辑(`mediaStatusChanged` 收到
`EndOfMedia` 时按模式算下一首),工作量不小,选 Qt6 前想清楚。
- Qt6 里 `setMedia()` 改为 `setSource()`,且必须 `setAudioOutput(new QAudioOutput)`
才有声音——Qt6 下没有声音,先查这一条。
- Qt6 的 `metaData()` 返回 `QMediaMetaData` 对象,取值用
`.value(QMediaMetaData::Title)`,与 Qt5 的 API 不同。
- 结论:此题用课程环境 Qt 5.14.2 做,问题最少。
---
## 11. OpenCV)基于 OpenCV 的面部识别系统 ⭐⭐⭐⭐⭐⭐
### 定位
六个里唯一的六星题。难度不在识别算法(模型是现成的),而在工程集成:
OpenCV 环境搭建、`cv::Mat``QImage` 互转、视频流不阻塞 UI、
"检测→特征→比对"流水线的正确串联。选这题前要有准备:第一周必须把环境跑通,
跑不通后面无从谈起。
### 题目重点
- 先分清两个概念,题目是两级流水线,不要混为一谈:
1. **人脸检测(detection**:找出画面里哪里有脸(矩形框)。传统方案
`cv::CascadeClassifier`(Haar),现代方案 DNN(如 YuNet / ResNet-SSD)。
2. **人脸识别(recognition**:判断这张脸是谁。用预训练模型
SFace/FaceNet/ArcFace 的 ONNX)把人脸编码成 128/512 维特征向量,
与注册库中向量算余弦相似度,超过阈值即判定为该人。
- 识别不是训练神经网络。模型是下载来的 `.onnx`/`.xml` 文件,
`cv::dnn::readNet``cv::FaceRecognizerSF` 加载。在想"要不要自己训练模型"
的同学可以打住:那不是本题要求,实训周期内也做不完。
- 题面明说"可选只通过图片识别",这是官方给的降级出口。摄像头/实时流
做不下来时,"导入图片→检测→识别→标注展示"的静态版本仍是一个完整、
可以及格的系统。
### 技术难点拆解
**难点 0(第一关):环境。** OpenCV 官网给 Windows 的预编译包是 MSVC 编译的,
MinGW 版 Qt 链不上(C++ ABI 不兼容,症状是链接期大量 `undefined reference`)。
三条出路,按推荐排序:
1. Qt 用 MSVC kit(装 Visual Studio Community / Build ToolsQt 安装时勾选
MSVC 组件),直接用官方 OpenCV 包,最省事;
2. 坚持 MinGW,就要用 CMake + 同版本 MinGW 自己编译 OpenCV(预留一个晚上);
3. Linux/WSL`apt install libopencv-dev`,基本没有这个问题。
第一周内跑不通"读一张图并显示",就应该向老师求助或换项目。环境是本题
最大的淘汰因素,及时求助不丢人。
**难点 1`cv::Mat` → `QImage` 的三个常见错误。**
- 通道序:OpenCV 是 BGRQt 是 RGB。直接
`QImage(mat.data, w, h, QImage::Format_RGB888)` 得到的画面偏蓝。
`QImage::Format_BGR888`Qt 5.14 起可用),或先 `cv::cvtColor(BGR2RGB)`
- 行对齐:`QImage` 构造必须显式传 `mat.step` 作 bytesPerLine
否则某些分辨率下图像出现斜纹/错位(Mat 行末可能有 padding)。
- 生命周期:这种构造是浅拷贝,`QImage` 借用 Mat 的内存。Mat 先析构
(比如函数局部变量)就会花屏或崩溃。跨函数传递前 `.copy()` 一次。
三个问题合起来,建议直接采用这个写法:
```cpp
QImage matToQImage(const cv::Mat &bgr) {
return QImage(bgr.data, bgr.cols, bgr.rows,
static_cast<int>(bgr.step),
QImage::Format_BGR888).copy(); // copy() 断开内存借用
}
```
**难点 2:视频流驱动方式。** 入门版:`QTimer` 每 33ms 触发
"`cap.read()` → 检测 → 转 QImage → `label->setPixmap()`"。能跑,但检测一慢
(DNN 单帧可能超过 100ms)整个 UI 就冻住。正式版:把 `VideoCapture` + 检测识别
放进 `QThread` 工作线程,处理完一帧发 `frameReady(QImage)` 信号回主线程
(QImage 隐式共享,跨线程信号传值安全)。注意子线程里不能直接操作任何 QWidget。
建议先用 QTimer 版打通流程,再整体搬进线程,一步到位容易两头出错。
**难点 3:识别流水线的数据设计。** 注册(录入)与识别共用前半段:
```
帧/图片 → 检测得人脸框 → 裁剪+对齐 → 特征提取模型 → 128/512维向量
注册:向量 + 姓名 存库(多张照片可求平均向量,题面提示了)
识别:向量与库中所有向量算余弦相似度 → 最高分且超阈值 → 判定身份
```
数据库不必上 SQL:人数少时 `QVector<Person{name, QVector<float> feat}>` +
JSON 持久化完全够用(题面也允许)。余弦相似度手写只有几行(点积/模长),
阈值从 0.36(SFace 官方推荐值附近)起调。建议做一个滑块让阈值可调,
演示时展示"调阈值看误识率变化",本身就是加分点。
**难点 4:级联检测器文件路径。** `CascadeClassifier::load("haarcascade_frontalface_default.xml")`
返回 false 不抛异常,不检查返回值的话,程序照常运行但永远检测不到脸。
xml 在 OpenCV 安装目录 `etc/haarcascades/` 下,复制到程序目录并用绝对路径加载,
load 后一定要检查返回值。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| 链接期大量 `undefined reference to cv::...` | MinGW Qt 配 MSVC OpenCV | 见难点 0,换 MSVC kit 或自编译 |
| 程序一启动就闪退 | 运行时找不到 opencv_worldXXX.dll | 把 dll 拷到 exe 旁,或把 OpenCV bin 目录加进 PATH |
| 画面人脸偏蓝 | BGR/RGB 通道序 | `Format_BGR888` 或 cvtColor |
| 图像斜纹/花屏/随机崩溃 | 没传 `mat.step` / QImage 借用已析构的 Mat | 用上面 matToQImage 的写法 |
| 摄像头打不开 | 索引不对 / 被其它程序占用 / 权限 | `VideoCapture cap(0)` 换 1、2 试;检查 `cap.isOpened()` |
| 检测一直没结果 | 级联 xml 没加载成功 | 检查 `load()` 返回值 |
| 检测有框但识别乱认 | 没做人脸对齐/裁剪就送识别模型;阈值太松 | 用检测框裁剪(DNN 方案用 `FaceRecognizerSF::alignCrop`);调高阈值 |
| 一开检测 UI 就明显卡顿 | 检测在主线程 | 降低检测频率(每 3 帧检测 1 次,框沿用上一次)或移到工作线程 |
### 建议启动路径(里程碑)
1. **M1(第一周内必须完成)**:环境验证——Qt 程序里用 `cv::imread` 读一张图、
转 QImage 显示在 QLabel。这是本题第一个必须通过的节点。
2. **M2**QTimer + VideoCapture 实时显示摄像头画面(不检测)。
3. **M3**:加 Haar 级联检测,画面上实时画人脸框(`cv::rectangle` 或转 QPixmap 后
QPainter 画)。
4. **M4**:接入特征提取模型(推荐 OpenCV 4.5.4+ 自带的 YuNet 检测 + SFace 识别,
API 最顺);做注册界面:拍照/导入 → 提特征 → 存 JSON。
5. **M5**:实时比对 + 画面标注姓名/相似度 + 识别日志列表。到此即完整达标。
6. **M6(加分,量力)**:多张照片平均特征、阈值滑块、识别事件抓拍存盘。
跟踪/活体检测等题面"提升"项投入大、产出小,谨慎选择。
### 版本说明
- OpenCV 用 4.x(推荐 ≥4.5.4,才有 `FaceDetectorYN`/`FaceRecognizerSF`)。
网上大量教程是 3.x 的 `cv::face::LBPHFaceRecognizer`(在 contrib 模块里,
官方预编译包不含),照抄编译不过,而且 LBPH 效果也远不如 DNN 方案。
- `QImage::Format_BGR888` 需要 Qt ≥ 5.14,课程环境刚好够用;更老的 Qt 只能 cvtColor。
---
## 02. 基于 QT 的自动售卖机 ⭐⭐⭐⭐⭐
### 定位
题面是售卖机,考的其实是软件设计。没有硬技术门槛(不需要多媒体、不需要
第三方库),看的是状态机建模、Model/View 数据流、并发保护、JSON 持久化
四个工程概念用得对不对。它是六个项目里最不容易被环境卡住的选择;
全用 if-else 也能跑起来,但设计分就没有了。
### 题目重点
- 核心是用状态机管交易流程:`待机 → 已投币 → 已选商品 → 出货中 → 找零中 → 完成`
题面点名 `QStateMachine`。关键在于:"当前允许做什么"由状态决定,而不是由
散落各处的 if 判断决定——待机态投币按钮可用、选货按钮禁用;已投币态反之。
评分时首先看的就是这一点。
- 第二个重心是数据与界面分离:商品/库存/零钱集中在一个数据管理类
(单例或注入均可),前台购买界面和后台管理界面共享同一个模型——
前台买走一瓶,后台表格里的库存立刻变。这是 Model/View 真正的考点:
一份 `QAbstractTableModel`,两处视图。
- `QMutex` 那条要求,本质是要求把"扣库存+扣零钱"做成一个原子操作:
`deductStock()``QMutexLocker locker(&mutex);` 检查+扣减一起完成。
加分做法:开一个 `QThread` 模拟并发购买,演示"不加锁会超卖、加锁不会"。
### 技术难点拆解
**难点 1QStateMachine 是事件驱动异步的,和直觉不同。**
- `machine.start()` 之后状态并不立即进入,要等事件循环跑起来。
start 后下一行断言当前状态,必然失败。
- 状态迁移用 `state->addTransition(sender, &Sender::signal, targetState)` 声明,
UI 更新挂在 `QState::entered`/`exited` 信号上,或用
`state->assignProperty(widget, "enabled", true)` 让状态机自动改属性。
这套接对了,代码里基本不需要手写"if 当前状态是 X"。
**难点 2:带条件的迁移(守卫条件)没有现成 API。** "投币额 ≥ 商品价才允许出货"
这种条件迁移,Qt 的标准做法是自定义 Transition:继承 `QSignalTransition`
重写 `eventTest()`,不满足条件返回 false(迁移不发生)。嫌重的话有个务实变通:
在槽里判断条件、满足才 `emit sufficientPayment()`,让状态机只监听这个信号。
两种都算对,后者更容易讲清楚。这是本题最隐蔽的设计问题,动手前先想好,
不要写到一半才发现 addTransition 不支持带条件,回头改架构。
**难点 3:找零算法的正确性边界。** 贪心(从大面额往小配)对常规币制
{50,20,10,5,1} 是最优的,但零钱库存有限时贪心会失败:库存没有 5 元、有 3 张
2 元时找 6 元,贪心先拿 5 就走进死路。及格线:贪心 + 失败时整单退币(题面明确
允许"无法找零则退币");加分线:库存受限的找零本质是完全背包可行性问题,
用 DP 或回溯保证"能找开就一定找得开"。答辩时能说清贪心什么时候会错,
比闷头写 DP 更有价值。
**难点 4:持久化的时机与原子性。** "数据变更时自动保存"不要真的每次变更都
全量写盘(快速连点可能写出半截 JSON)。简单可靠的做法:变更只置脏标记 +
`QTimer` 500ms 防抖落盘;写盘用 `QSaveFile` 替代 `QFile`(写完才原子替换,
崩溃不损坏存档)。`QSaveFile` 知道的学生不多,用上可以作为一个细节亮点。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| `machine.start()` 后状态没进入 | 状态机异步启动 | 初始化逻辑挂到 `initialState``entered` 信号,不要 start 后同步读状态 |
| 迁移不触发 | 信号签名不匹配 / 当前不在源状态 | 迁移只在源状态激活时监听;连 `QState::entered` 打日志看状态流 |
| 忘了 `setInitialState` | 状态机启动即静默停止 | `machine.setErrorState`/日志;new 完状态第一件事设初始态 |
| 后台改库存前台不动 | 两界面各持一份数据副本 | 共享同一个 model 实例;改数据走 `setData` 或发 `dataChanged` |
| 表格改了数字界面不刷 | 自定义 model 改数据没 emit `dataChanged` | 所有修改路径统一收口到一个函数,里面负责 emit |
| 偶发库存变负数 | 检查与扣减分离,中间被插队 | "检查+扣减"放进同一个 `QMutexLocker` 作用域 |
| JSON 读回来全是 0 | `QJsonObject` 取值类型不对(如 `toInt()` 用在 double 上)/ 键名拼写 | 先 `qDebug() << doc.toJson()` 核对;封装 load/save 对称函数 |
| 程序异常退出后存档损坏 | 直接 QFile 覆盖写 | 换 `QSaveFile` |
### 建议启动路径(里程碑)
1. **M11 天)**:不带界面,先写 `VendingData` 数据类(商品表、零钱表、
加锁的 deduct/refund 接口)+ JSON load/save,用 qDebug 自测。数据层立住,
后面基本是接线工作。
2. **M21 天)**:前台购买界面(商品按钮阵列 + 投币按钮 + 信息提示区),
先用普通槽函数把"投币→选货→出货→找零"跑通(暂时用 if-else 没关系)。
3. **M312 天)**:引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
`assignProperty`,流程迁移交给 transition。先能跑再重构,比一步到位稳。
4. **M41 天)**:管理员后台(密码 QDialog + QTableView 库存表,共享模型)+
售罄置灰、无法找零拦截两条异常流。
5. **M5(加分)**:交易流水 + 报表(QSortFilterProxyModel 排序即可,QtCharts 可选)、
多线程购买压测演示、折扣规则引擎。
### 版本说明
- Qt6 中 `QStateMachine` 被移出 QtCore,进了独立的 statemachine 模块
`QT += statemachine` / `find_package(Qt6 COMPONENTS StateMachine)`)。
Qt 5.14 下在 QtCore 里直接用,无需额外配置。
- QtCharts 在 Qt 5.14 是独立模块(`QT += charts`,且要在安装器里勾选过)。
没装的话报表用 QTableView 呈现即可,不必为一张柱状图重装 Qt。
---
## 05. 个人相册管理与智能标签应用 ⭐⭐⭐⭐⭐
### 定位
一道性能敏感的 Model/View 大题:核心挑战不是显示图片(一行 setPixmap 的事),
而是几百上千张照片下界面依然流畅——缩略图异步加载、元数据索引、组合筛选。
"智能标签"名字唬人,实际是可裁剪的加分层:题目允许从"手动标签 + 搜索"
起步,ONNX 智能识别放在最后做。
### 题目重点
- 本题真正的关键是索引:把图片路径、拍摄日期、评分、标签、收藏这些
元数据放进一个独立于文件系统的库(SQLite 或 JSON 起步),所有浏览、搜索、
筛选都查库,不实时扫磁盘。想通这一点,"虚拟相册"(同一张图归入多个相册
而不复制文件)、组合筛选、倒排索引都是顺理成章的查询而已。
- 第二个关键是缩略图网格的性能。评分演示时老师多半会导入一大批照片,
滚动一卡,前面做得再多也减分。这是本题技术含量最高的部分(见难点 1)。
- 智能标签的合理做法:先把手动标签的完整链路做穿(加标签→存库→按标签搜索),
智能标签只是标签的另一个生产者——接 ONNX 后生成的标签走完全相同的
存储/搜索链路。架构对了,"智能"是插件;架构不对,"智能"意味着重写。
### 技术难点拆解
**难点 1:缩略图异步加载。** 直观的写法:遍历文件夹,每张
`QPixmap(path).scaled(...)` 塞进 `QListWidget`。一张 12MP 的 JPEG 解码要
几十上百毫秒,100 张照片主线程就冻结十几秒。正确做法分三层:
1. 解码离开主线程:用 `QThreadPool` + `QRunnable`(或 `QtConcurrent::run`
在后台解码并缩放,完成后发信号把缩略图(QImage,跨线程传值安全)交回主线程。
注意 QPixmap 不能在非 GUI 线程创建,后台一律用 QImage,回主线程再转。
2. 读小不读大:`QImageReader reader(path); reader.setScaledSize(QSize(200,200));`
——JPEG 可以按比例降采样解码,比"解全图再 scaled"快一个数量级、省数倍内存。
这是本题最值得记住的一个 API。
3. 缩略图磁盘缓存:首次生成后存到缓存目录(文件名用路径哈希),
第二次打开应用直接读缓存。加分项。
视图层用 `QListView::IconMode` + 自定义 model(配合按需供数),
不要用 `QListWidget` 一次性塞几千个 item。
**难点 2:SQLite 表设计(标签是多对多)。** 三张表就够:
`photos(id, path, date, rating, favorite)``tags(id, name)`
`photo_tags(photo_id, tag_id)`。"按标签找图"就是一个 JOIN,这本身就是
题面"倒排索引"的数据库实现,答辩时可以直接这样讲。组合筛选(日期范围 +
评分 + 标签)就是动态拼 WHERE 子句。不想上 QtSql 的同学用 JSON + 内存
`QHash<QString标签, QSet<int>图片id>` 也能达标,但数据量大后启动变慢。
**难点 3ONNX Runtime 集成(可选层,放最后做)。** 流程:下载 squeezenet /
mobilenet 分类 ONNX 模型 → C++ 里 `Ort::Session` 加载 → 图片缩放到 224×224、
归一化成 float tensor → 推理 → top-5 类别按 ImageNet 标签表映射成中文标签存库。
坑主要在预处理:通道序(NCHW vs NHWC)、归一化均值方差要和模型训练时一致,
不一致不报错但输出全错。人脸分组(聚类)工作量再上一个台阶,
建议只有已完成全部基础功能、确有余力的同学做。
**难点 4:非破坏性编辑 + 撤销。** 题面点了命令模式:每个编辑操作
(旋转/裁剪/调色)封装成命令对象压栈,undo 出栈反做。Qt 有现成的
`QUndoStack`/`QUndoCommand`,不必自己实现栈管理。"非破坏性"最省事的实现:
不覆盖原图,编辑结果另存副本,或只存操作参数列表、显示时重放。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| 导入文件夹时界面冻死 | 主线程同步解码大图 | QImageReader::setScaledSize + 线程池,见难点 1 |
| 后台线程崩溃:`QPixmap: It is not safe...` | 子线程创建了 QPixmap | 子线程只用 QImage |
| 内存涨到几个 GB | 缓存了全尺寸 QImage | 只留缩略图;看大图时才解码全图,且用 `QPixmapCache` 限额 |
| 部分照片没有拍摄日期 | 截图/微信图片本无 EXIF | 降级链:EXIF DateTimeOriginal → 文件修改时间;Qt 自带 EXIF 读取很弱,完整 EXIF 建议引入 TinyEXIF/easyexif(单头文件) |
| 手机竖拍照片横过来了 | 没处理 EXIF Orientation | `QImageReader::setAutoTransform(true)`,一行解决 |
| 搜索一次卡一秒 | 每次搜索全量扫描 | 元数据进 SQLite 建索引,或内存哈希 |
| 删除照片后列表错乱 | 直接操作文件没同步库 | 所有增删走数据层统一入口,UI 只发请求 |
| 中文路径图片读不出 | 用了 `std::string`/`fopen` 链路 | 全程用 QString/QFile;给 OpenCV/stb 传路径时注意 UTF-8 转换 |
### 建议启动路径(里程碑)
1. **M11 天)**:选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 +
双击全屏看图(先不管性能,打通视觉闭环)。
2. **M212 天)**:把 M1 的加载改成 QImageReader 降采样 + 线程池异步。
验收标准:导入 500 张照片,滚动不卡。这是本题技术难度最高的一步,值得花足时间。
3. **M31 天)**:元数据层——SQLite/JSON 三表结构,手动标签、星级、收藏的
增删改查 + 持久化。
4. **M41 天)**:搜索框(文件名/标签实时过滤)+ 组合筛选面板(日期范围/评分/
收藏)。到此为完整达标的相册管理器。
5. **M5(加分)**:虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、
缩略图磁盘缓存。
6. **M6(大加分,量力)**:ONNX 自动标签;人脸检测分组(可参考项目 11 的
OpenCV 部分,环境成本也相同)。
### 版本说明
- `QImageReader::setAutoTransform` 在 Qt5 默认 falseQt6 默认 true)。
课程环境下不手动打开,竖拍照片就会横着显示。
- QtSql 的 SQLite 驱动随 Qt 发行版自带,无需另装 SQLite;MySQL 等驱动不自带,
不要把时间花在配数据库服务上,本题 SQLite 文件库就够了。
---
## 06. 多功能抽奖与随机点名程序 ⭐⭐⭐⭐
### 定位
六个里唯一的四星题,功能面窄,但考正确性:随机算法是否真的公平、
不重复抽取是否真的不重复、权重抽奖概率是否真的和权重成正比。
适合想稳妥完成、把精力花在细节上的同学。注意题面的算法项
Fisher-Yates、轮盘赌)是答辩必问点,只写一个 `rand()%n` 过不了关。
### 题目重点
- UI 上的"滚动"只是演出,抽取结果在点"开始"那一刻就该定了。合理的架构:
按下开始 → 先用算法抽出结果 → QTimer 驱动名字快速轮播制造悬念 →
按下停止 → 动画减速定格到预定结果。让"停止那一刻的随机数"决定中奖者,
公平性就依赖用户手速,答辩说不清。这一条想通了,逻辑层(抽取)和
表现层(动画)自然就分开了。
- 三个算法是题目的核心,各 20 行以内,务必自己写、写到能白板复现:
1. Fisher-Yates 洗牌(从 N 人不重复抽 M 人:洗牌取前 M);
2. 轮盘赌选择(权重抽奖:随机数落在累计权重区间上);
3. 随机数源:`QRandomGenerator::global()->bounded(n)`,自动用系统熵播种。
`rand()` 不 srand 的话每次运行结果相同,演示时会当场暴露。
- 名单管理(增删改、txt/CSV 导入、持久化)是体力活,但中文编码有一个
几乎必踩的坑,见下。
### 技术难点拆解
**难点 1CSV/文本导入的编码。** Windows Excel 导出的 CSV 默认 GBK/ANSI
编码,`QTextStream` 按默认设置读(Qt5 是 Locale,而记事本另存的 UTF-8 又带
BOM),中文名很容易乱码。稳妥做法:读入字节后探测——有 UTF-8 BOM 按 UTF-8
否则先按 UTF-8 严格解码、失败再退 GBK(`QTextCodec::codecForName("GBK")`)。
另外 CSV 字段可能带引号包裹和内嵌逗号,课程场景用 `line.split(',')` 可以接受,
但至少要 `trimmed()` 并跳过空行。(xlsx 是压缩包格式,Qt 原生读不了,
题面说"CSV 或 xlsx",选 CSV 即可,不必为 xlsx 引第三方库。)
**难点 2:滚动动画的两种实现,选简单的那种。** 题面提示了 QPropertyAnimation
但对"名字快速切换"这个效果,QTimer + 每 tick 换 QLabel 文本才是对口工具
3050ms 一换足够快)。QPropertyAnimation 适合做结果揭晓的放大/滑入动画
(配合 `QGraphicsOpacityEffect` 或对 geometry 做动画)。"减速定格"用变周期
QTimer:每次 tick 后 `interval *= 1.15`,超过 300ms 时停在预定结果,
二十行代码就有不错的现场效果。
**难点 3:不重复抽取的状态一致性。** "抽过的人从池子里移除"说起来简单,
坑在多处状态要同步:候选池、已中奖列表、UI 上两个列表控件。要先定好
数据以谁为准:数据层一个 `QList<Person>` + 一个 `QSet<int> drawnIds`
两个视图都从它渲染。再叠加"中途重置""切换可重复模式""删除已中奖的人"
这些操作时才不会乱。先想清数据结构,UI 只是它的投影。
**难点 4:权重抽奖 + 不重复的组合。** 各自单独都好写,组合时注意:抽走一人后
总权重要重算(或轮盘赌遍历时跳过已中者)。验证方法值得写进报告:
循环抽 10 万次统计频率,和权重比例对照,这个小实验就是公平性的答辩证据。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| 导入名单中文乱码 | Excel CSV 是 GBK | 编码探测降级链,见难点 1 |
| 每次运行抽中的人一样 | `rand()` 没播种 | 换 `QRandomGenerator::global()` |
| "不重复"偶尔重复 | 从 UI 控件读名单,删除项后索引错位 | 状态收口到数据层,UI 只渲染 |
| 滚动动画卡顿 | tick 里做了重活(如重排全列表) | tick 只改一个 QLabel 文本 |
| 多人抽取结果有重复 | 循环 M 次独立随机 | Fisher-Yates 洗牌取前 M |
| 全屏后布局错乱 | 固定坐标写死 | 全程用布局管理器;全屏用 `showFullScreen()`,记得处理 Esc 退出 |
| 音效不响 | 路径/模块问题 | `QSoundEffect` 只支持 WAV,不解码 MP3`QT += multimedia`;资源走 qrc |
### 建议启动路径(里程碑)
1. **M1(半天)**:控制台级验证——名单硬编码,Fisher-Yates 抽 M 人、轮盘赌
带权抽 1 人,qDebug 打印 + 万次频率统计自检。算法先于界面。
2. **M21 天)**:主界面:名单列表(增删改)+ txt/CSV 导入(含编码处理)+
JSON 持久化。
3. **M31 天)**:单人抽取全流程:开始滚动 → 停止减速定格 → 结果进
"已中奖"列表;可重复/不可重复开关。
4. **M4(半天)**:多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
5. **M5(加分)**:权重列 + 轮盘赌抽奖、分组抽奖、结果揭晓 QPropertyAnimation
动画、背景音乐音效、全屏模式、自定义背景图。
### 版本说明
- `QTextCodec` 在 Qt6 移入 core5compat 兼容模块(Qt6 正统是
`QStringConverter`/`QStringDecoder`)。课程环境 Qt 5.14 直接用 QTextCodec 即可。
- `QRandomGenerator` 需要 Qt ≥ 5.10,课程环境没问题;教程里出现
`qsrand/qrand`(已废弃)的可以跳过不看。
---
## 08. 双人飞机大战 ⭐⭐⭐⭐⭐
### 定位
一道实时程序架构题:游戏循环、大量对象的生命周期管理、键盘多键并发、
碰撞检测,和前五个事件驱动的表单程序是完全不同的思维方式。
它是六个里代码量最大、最容易烂尾的:功能清单(双人、三模式、道具、
BOSS、弹幕)是开放的,没有里程碑约束很容易把时间耗在素材和关卡设计上。
另一面是演示效果最好,适合愿意投入的同学。
### 题目重点
- 先选定框架路线,两条路二选一,不要混:
- **路线 AQGraphicsView 框架**(题面推荐)——`QGraphicsScene` 管对象、
`QGraphicsItem` 派生飞机/子弹、场景自带 `collidingItems()` 碰撞检测。
省很多底层代码,但要理解 item 坐标系和场景坐标系的区别。
- **路线 BQWidget + QPainter 手绘**——一个大 widget`paintEvent` 里画所有
对象,自己维护对象列表和矩形碰撞。原始一些,但每一步都在自己掌控里,
配套教材 ch10 的绘图知识直接够用。
- 建议:跟着题面走 A;如果 QGraphicsView 两天内没找到感觉,及时退到 B。
B 路线做完,好过 A 路线做一半。
- 游戏循环是全部逻辑的驱动源:一个 `QTimer`16ms ≈ 60FPS)驱动每帧
`处理输入 → 移动所有对象 → 生成敌机/子弹 → 碰撞检测 → 清理死亡对象 → 重绘`
所有会动的东西都由这一个 timer 统一驱动。给每个子弹/敌机各开一个 QTimer
是常见错误,几百个 timer 会把事件循环拖垮,而且相位不齐。
- 对象生命周期是本题隐含的 C++ 考点:子弹飞出屏幕、敌机被击落,都要从
场景移除并 delete。忘删,内存与遍历量持续膨胀,越玩越卡;
删早了(还在容器遍历中),程序崩溃。见难点 3。
### 技术难点拆解
**难点 1:键盘多键同时按(双人模式的关键)。** 直接在 `keyPressEvent`
`player.move()` 有两个问题:一是依赖系统键盘重复,第一下和连发之间有停顿,
手感卡;二是玩家1 按住 W 时玩家2 再按方向键,系统键盘重复只服务最后按下的键,
另一个玩家会直接失控。标准解法是按键状态表 + 游戏循环采样:
```cpp
QSet<int> pressedKeys;
void keyPressEvent(QKeyEvent *e) override {
if (e->isAutoRepeat()) return; // 屏蔽系统自动重复
pressedKeys.insert(e->key());
}
void keyReleaseEvent(QKeyEvent *e) override {
if (e->isAutoRepeat()) return;
pressedKeys.remove(e->key());
}
// 游戏循环每帧:
if (pressedKeys.contains(Qt::Key_W)) p1.moveUp();
if (pressedKeys.contains(Qt::Key_Up)) p2.moveUp(); // 互不干扰,天然支持斜向
```
双人模式能不能玩取决于这一段,建议一开始就按这个写法来。另外游戏主
widget 要 `setFocusPolicy(Qt::StrongFocus)`,否则按键事件不会分发到它。
**难点 2:碰撞检测的量级与精度。**
- 起步:矩形相交(`QRect::intersects` 或 QGraphicsView 的 `collidingItems`
完全够用。QGraphicsItem 默认按 `boundingRect`+`shape()` 判定;飞机贴图
四角是透明的,会出现"看着没碰到就爆了"——重写 `shape()` 返回缩小的
`QPainterPath`,或直接把判定框缩成机身核心(弹幕游戏的通行做法,
玩家只会觉得手感变好)。
- 量级:每帧"所有子弹 × 所有敌机"是 O(N²),课程规模(500 个对象以内)
暴力检测没有问题——先暴力,卡了再优化。题面的四叉树是加分项,报告里
给出"对象数-帧耗时"对比数据,比只谈复杂度有说服力。
- 帧率稳定性:`QTimer` 精度默认约 15ms 且会漂移,位移量写死"每帧 5 像素"
在卡顿时会变慢。进阶写法:用 `QElapsedTimer` 测真实帧间隔 dt
位移 = 速度 × dt。及格线不强求,答辩能说出这个问题在哪就是加分。
**难点 3:容器遍历中删除对象。** 每帧要清理出界子弹/死亡敌机,
`for (auto *b : bullets) { if (出界) { bullets.removeOne(b); delete b; } }`
是遍历中修改容器,未定义行为,迟早崩溃。两个安全做法:
一,标记-清扫:遍历只打 `dead` 标记,遍历结束后统一
`removeIf + delete`(或 `QMutableListIterator`);
二,碰撞回调里不直接 delete 对方,只标记。另外 QGraphicsScene 删 item
`scene->removeItem(item); delete item;`,只 delete 的话场景还留着悬空指针。
这是本题最常见的崩溃原因,写清理代码前先把这段看明白。
**难点 4:对象设计与生成节奏。** 建一个基类(如 `Entity`:位置/速度/血量/
`advance()`/`rect()`),玩家机、敌机、子弹、道具全部派生。多态在这里不是
为了展示技巧,是实际需要:游戏循环只面对 `QList<Entity*>`。敌机生成用
"生成计时器 + 关卡脚本"(第几秒出什么阵型),数据驱动(写在表/JSON 里)
比硬编码 if 好调平衡。关于素材:飞机/子弹贴图用开源素材包(如 Kenney.nl
免费素材),自己画像素图非常耗时;实在没素材,纯色几何图形(QPainter
画三角形飞机)完全不影响技术评分。
**难点 5:双人对战模式的裁剪。** 题面三模式(单人/合作/对战)全做工作量很大。
架构上留好口子即可:碰撞规则表里"玩家子弹是否伤害另一玩家"是一个 bool,
合作为 false、对战为 true——对战模式本质是碰撞规则的一次配置变更。
想通这层,第三模式的边际成本很低;想不通就先做单人+合作。
### 高频坑与排查
| 症状 | 真实原因 | 排查/解法 |
|---|---|---|
| 按方向键飞机不动 | 焦点不在游戏 widget 上 | `setFocusPolicy(Qt::StrongFocus)`;场景/视图结构下注意谁拿焦点 |
| 移动一顿一顿 / 双人互相抢键 | 直接在 keyPressEvent 移动 | 按键状态表 + 循环采样,见难点 1 |
| 玩一会儿越来越卡 | 出界子弹没清理,对象只增不减 | 每帧清理;调试时在窗口标题实时显示对象数,方便发现泄漏 |
| 随机崩溃在碰撞/清理附近 | 遍历中删除 / 双重 delete | 标记-清扫,见难点 3 |
| 子弹出生位置不对 | item 坐标系与场景坐标系混淆 | 生成子弹用 `mapToScene()`/`item->scenePos()` 换算 |
| 看着没碰到就判死 | 判定框是含透明边的整图矩形 | 重写 `shape()` 或缩小判定矩形 |
| 背景滚动有裂缝 | 两张背景图衔接坐标差 1 像素 / 非整数位移 | 两张图首尾相接循环复位;位移取整 |
| 全屏炸弹一放帧率骤降 | 一帧内 delete 几百对象 + 特效 | 分帧清理,或炸弹只做标记、由正常清理流程消化 |
| CPU 占一核 100% | timer 间隔设 0 或忙循环 | 16ms 间隔足够;`QGraphicsView``setViewportUpdateMode` 用默认值即可 |
### 建议启动路径(里程碑)
1. **M11 天)**:窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机
(按键状态表一步到位)。这一步的手感决定整个项目的手感。
2. **M21 天)**:玩家子弹(按住 J 连发、出界清理)+ 敌机从顶部随机生成
直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
3. **M31 天)**:敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 →
Game Over 判定。加第二名玩家(方向键 + 小键盘0),合作模式成立。
4. **M41 天)**:滚动背景、开始菜单/暂停/结束三场景切换(简单 FSM:
一个 enum + QStackedWidget 或场景切换)、最高分持久化(QSettings)。
5. **M5(加分)**:道具掉落、BOSS(多阶段血条 + 正弦/极坐标弹幕)、
对战模式(碰撞规则开关)、音效(QSoundEffect + WAV)。
6. 提醒一条执行纪律:M2 没完成前不做素材、不写 BOSS。本题烂尾的原因
几乎都是顺序反了。
### 版本说明
- 本题用到的 QGraphicsView/QPainter/事件系统在 Qt5/Qt6 间差异很小,
是六题中版本风险最低的。注意音效走 `QSoundEffect`(只支持 WAV),
背景音乐才用 QMediaPlayer——后者在 Qt6 的差异见项目 04 的版本说明。
- 高分屏(Windows 缩放 150%)下坐标可能被缩放干扰,演示机若有此问题,
入口处 `QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)`Qt 5.14
或环境变量 `QT_SCALE_FACTOR=1` 兜底。
---
## 怎么选、怎么起步
- 想稳妥拿分、不被环境问题拖住:选 **02 售卖机****06 抽奖点名**
纯 Qt 标准模块,问题集中在设计和细节,本文的排查表能覆盖大部分。
- 想做完自己还能用:选 **04 音乐播放器****05 相册管理**
多媒体和性能两个方向各有一个硬难点(异步元数据 / 异步缩略图),
啃下来收获扎实。
- 接受环境风险、想上技术高度:选 **11 面部识别**。第一周先过环境验证(M1),
过不了及时降级到图片识别版或换题。
- 看重答辩演示效果:选 **08 飞机大战**。用里程碑约束进度,M2 前不碰素材。
无论选哪个:第一周结束时必须有一个能运行的 M1。这批项目的失败方式高度一致:
前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/
键盘不卡)有硬问题。先做最难的部分。