# 实训项目集深度解析（按学生关注度：04 / 11 / 02 / 05 / 06 / 08）

> 题目来源：wiki「02. Qt方向 → 04. 项目集」（pageId 183664900，16 个项目）。
> 本文对其中学生关注度较高的 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 自己不解码，依赖系统后端：
Windows（DirectShow/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. **M2（1 天）**：`QFileDialog` 多选添加 → `QMediaPlaylist` + Model/View 列表 →
   双击列表任意行播放。
3. **M3（1 天）**：播放/暂停/上下曲按钮、进度条（含拖动 seek）、音量条、
   时间标签。到这里已经可以日常使用。
4. **M4（1 天）**：三种播放模式切换 + 元数据/封面展示（含降级链）+ 列表持久化。
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 Tools，Qt 安装时勾选
   MSVC 组件），直接用官方 OpenCV 包，最省事；
2. 坚持 MinGW，就要用 CMake + 同版本 MinGW 自己编译 OpenCV（预留一个晚上）；
3. Linux/WSL：`apt install libopencv-dev`，基本没有这个问题。

第一周内跑不通"读一张图并显示"，就应该向老师求助或换项目。环境是本题
最大的淘汰因素，及时求助不丢人。

**难点 1：`cv::Mat` → `QImage` 的三个常见错误。**

- 通道序：OpenCV 是 BGR，Qt 是 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` 模拟并发购买，演示"不加锁会超卖、加锁不会"。

### 技术难点拆解

**难点 1：QStateMachine 是事件驱动异步的，和直觉不同。**

- `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. **M1（1 天）**：不带界面，先写 `VendingData` 数据类（商品表、零钱表、
   加锁的 deduct/refund 接口）+ JSON load/save，用 qDebug 自测。数据层立住，
   后面基本是接线工作。
2. **M2（1 天）**：前台购买界面（商品按钮阵列 + 投币按钮 + 信息提示区），
   先用普通槽函数把"投币→选货→出货→找零"跑通（暂时用 if-else 没关系）。
3. **M3（1–2 天）**：引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
   `assignProperty`，流程迁移交给 transition。先能跑再重构，比一步到位稳。
4. **M4（1 天）**：管理员后台（密码 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>` 也能达标，但数据量大后启动变慢。

**难点 3：ONNX 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. **M1（1 天）**：选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 +
   双击全屏看图（先不管性能，打通视觉闭环）。
2. **M2（1–2 天）**：把 M1 的加载改成 QImageReader 降采样 + 线程池异步。
   验收标准：导入 500 张照片，滚动不卡。这是本题技术难度最高的一步，值得花足时间。
3. **M3（1 天）**：元数据层——SQLite/JSON 三表结构，手动标签、星级、收藏的
   增删改查 + 持久化。
4. **M4（1 天）**：搜索框（文件名/标签实时过滤）+ 组合筛选面板（日期范围/评分/
   收藏）。到此为完整达标的相册管理器。
5. **M5（加分）**：虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、
   缩略图磁盘缓存。
6. **M6（大加分，量力）**：ONNX 自动标签；人脸检测分组（可参考项目 11 的
   OpenCV 部分，环境成本也相同）。

### 版本说明

- `QImageReader::setAutoTransform` 在 Qt5 默认 false（Qt6 默认 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 导入、持久化）是体力活，但中文编码有一个
  几乎必踩的坑，见下。

### 技术难点拆解

**难点 1：CSV/文本导入的编码。** 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 文本才是对口工具
（30–50ms 一换足够快）。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. **M2（1 天）**：主界面：名单列表（增删改）+ txt/CSV 导入（含编码处理）+
   JSON 持久化。
3. **M3（1 天）**：单人抽取全流程：开始滚动 → 停止减速定格 → 结果进
   "已中奖"列表；可重复/不可重复开关。
4. **M4（半天）**：多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
5. **M5（加分）**：权重列 + 轮盘赌抽奖、分组抽奖、结果揭晓 QPropertyAnimation
   动画、背景音乐音效、全屏模式、自定义背景图。

### 版本说明

- `QTextCodec` 在 Qt6 移入 core5compat 兼容模块（Qt6 正统是
  `QStringConverter`/`QStringDecoder`）。课程环境 Qt 5.14 直接用 QTextCodec 即可。
- `QRandomGenerator` 需要 Qt ≥ 5.10，课程环境没问题；教程里出现
  `qsrand/qrand`（已废弃）的可以跳过不看。

---

## 08. 双人飞机大战 ⭐⭐⭐⭐⭐

### 定位

一道实时程序架构题：游戏循环、大量对象的生命周期管理、键盘多键并发、
碰撞检测，和前五个事件驱动的表单程序是完全不同的思维方式。
它是六个里代码量最大、最容易烂尾的：功能清单（双人、三模式、道具、
BOSS、弹幕）是开放的，没有里程碑约束很容易把时间耗在素材和关卡设计上。
另一面是演示效果最好，适合愿意投入的同学。

### 题目重点

- 先选定框架路线，两条路二选一，不要混：
  - **路线 A：QGraphicsView 框架**（题面推荐）——`QGraphicsScene` 管对象、
    `QGraphicsItem` 派生飞机/子弹、场景自带 `collidingItems()` 碰撞检测。
    省很多底层代码，但要理解 item 坐标系和场景坐标系的区别。
  - **路线 B：QWidget + 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. **M1（1 天）**：窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机
   （按键状态表一步到位）。这一步的手感决定整个项目的手感。
2. **M2（1 天）**：玩家子弹（按住 J 连发、出界清理）+ 敌机从顶部随机生成
   直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
3. **M3（1 天）**：敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 →
   Game Over 判定。加第二名玩家（方向键 + 小键盘0），合作模式成立。
4. **M4（1 天）**：滚动背景、开始菜单/暂停/结束三场景切换（简单 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。这批项目的失败方式高度一致：
前两周画界面调样式，最后一周才发现核心链路（能出声/能链上库/状态机能转/
键盘不卡）有硬问题。先做最难的部分。
