Skip to content

Commit 117029f

Browse files
committed
refactor: 使用 OpenCV 统一 ONNX 分类预处理并更新 CI
1 parent 5312d98 commit 117029f

9 files changed

Lines changed: 242 additions & 69 deletions

File tree

.github/workflows/cpp-cmake-ci.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,21 @@ jobs:
9696
restore-keys: |
9797
vcpkg-${{ runner.os }}-${{ matrix.vcpkg_triplet }}-
9898
99-
- name: Install ONNX Runtime on Windows
99+
- name: Install C++ dependencies on Windows
100100
if: runner.os == 'Windows'
101101
shell: pwsh
102102
run: |
103-
& "$env:VCPKG_ROOT\vcpkg.exe" install "onnxruntime:${{ matrix.vcpkg_triplet }}"
103+
& "$env:VCPKG_ROOT\vcpkg.exe" install `
104+
"onnxruntime:${{ matrix.vcpkg_triplet }}" `
105+
"opencv4:${{ matrix.vcpkg_triplet }}"
104106
105-
- name: Install ONNX Runtime on Linux
107+
- name: Install C++ dependencies on Linux
106108
if: runner.os == 'Linux'
107109
shell: bash
108110
run: |
109-
"$VCPKG_ROOT/vcpkg" install "onnxruntime:${{ matrix.vcpkg_triplet }}"
111+
"$VCPKG_ROOT/vcpkg" install \
112+
"onnxruntime:${{ matrix.vcpkg_triplet }}" \
113+
"opencv4:${{ matrix.vcpkg_triplet }}"
110114
111115
- name: Configure on Windows
112116
if: runner.os == 'Windows'

CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
1313

1414
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
1515
find_package(onnxruntime CONFIG REQUIRED)
16+
find_package(OpenCV CONFIG REQUIRED COMPONENTS core imgcodecs imgproc)
1617

1718
set(CMAKE_AUTOMOC ON)
1819
set(CMAKE_AUTORCC ON)
1920
set(CMAKE_AUTOUIC ON)
2021

2122
add_executable(${PROJECT_NAME}
2223
src/main.cpp
24+
src/ImageUtils.h
25+
src/ImageUtils.cpp
2326
src/MainWindow.h
2427
src/MainWindow.cpp
2528
src/OnnxClassifier.h
@@ -30,6 +33,9 @@ target_link_libraries(${PROJECT_NAME} PRIVATE
3033
Qt5::Core
3134
Qt5::Gui
3235
Qt5::Widgets
36+
opencv_core
37+
opencv_imgcodecs
38+
opencv_imgproc
3339
onnxruntime::onnxruntime
3440
)
3541

README.md

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# YOLO 图像分类推理工具 (C++ / Qt5 / ONNX Runtime)
1+
# YOLO 图像分类推理工具 (C++ / Qt5 / OpenCV / ONNX Runtime)
22

3-
基于 ONNX Runtime 的跨平台图像分类推理程序Demo,使用 Qt5 构建图形界面。
3+
基于 ONNX Runtime 的跨平台图像分类推理程序 Demo,使用 Qt5 构建图形界面,并通过 OpenCV 统一图片解码与预处理
44

55
当前界面支持:
66

@@ -36,16 +36,17 @@ cpp_inference/
3636

3737
| 模块 | 职责 |
3838
|------|------|
39-
| `OnnxClassifier` | 封装 ONNX Runtime,负责模型加载、图像预处理、推理执行 |
39+
| `OnnxClassifier` | 封装 ONNX Runtime,负责模型加载、基于 OpenCV 的图像预处理、推理执行 |
4040
| `MainWindow` | Qt 图形界面,负责用户交互、图片显示、单张/批量结果展示 |
41+
| `ImageUtils` | 使用 OpenCV 统一图片解码,并在 OpenCV / Qt 之间转换图像格式 |
4142
| `main.cpp` | 程序入口,初始化 QApplication |
4243

4344
### 推理流程
4445

4546
```txt
4647
用户选择图片 → MainWindow::classify()
4748
48-
QImage 传入 OnnxClassifier
49+
OpenCV 解码原图
4950
5051
preprocess() → 短边缩放到输入尺寸 → 中心裁剪到输入尺寸 → RGB转换 → /255
5152
@@ -62,6 +63,7 @@ cpp_inference/
6263
|------|------|------|
6364
| CMake | ≥ 3.16 | 构建系统 |
6465
| Qt5 | 5.12+ | GUI 框架 |
66+
| OpenCV | 4.x | 图像解码、缩放、裁剪 |
6567
| ONNX Runtime | 1.23+ | 推理引擎 |
6668
| 编译器 | MSVC / Clang / GCC | C++17 支持 |
6769

@@ -77,7 +79,7 @@ cmake --build build
7779

7880
## CI
7981

80-
仓库已提供一套仅覆盖 **C++ / Qt / ONNX Runtime** 的 GitHub Actions 流水线:
82+
仓库已提供一套覆盖 **C++ / Qt / OpenCV / ONNX Runtime** 的 GitHub Actions 流水线:
8183

8284
- Windows:Qt `5.12.12` + MSVC + CMake/Ninja 构建
8385
- Linux:Qt `5.12.12` + GCC + CMake/Ninja 构建
@@ -145,11 +147,12 @@ cmake --build build
145147
当前 `cpp_inference` 调用这份 Ultralytics 导出的分类 ONNX 之前,按下面的顺序做预处理:
146148

147149
```txt
148-
1. 读取原图并转成 RGB888
150+
1. 用 OpenCV 解码原图
149151
2. 按比例缩放,使“短边”刚好等于模型输入尺寸
150152
3. 从缩放后的图像中心裁出 HxW(本项目里是 224x224)
151-
4. 将像素从 0~255 转成 0~1 的 float
152-
5. 按 NCHW 布局喂给 ONNX Runtime
153+
4. 转成 RGB
154+
5. 将像素从 0~255 转成 0~1 的 float
155+
6. 按 NCHW 布局喂给 ONNX Runtime
153156
```
154157

155158
也就是:
@@ -167,19 +170,34 @@ cmake --build build
167170

168171
## 与 Python 结果的差异说明
169172

170-
当前版本的目标是让 **预测类别** 与 Python 侧测试结果保持一致。
173+
当前版本的目标是让 **预测类别** 与 Python 侧测试结果保持一致,并把置信度差异尽量收敛。
174+
175+
需要先明确一点:当前仓库中的 [`py/predict_gui.py`](./py/predict_gui.py) 并不是“手写 OpenCV + ONNX Runtime”的对照脚本,而是通过 Ultralytics 的 `YOLO(...)` 封装来加载模型并执行推理。
176+
177+
这意味着:
178+
179+
- C++ 侧是当前工程自己实现的 `OpenCV 解码 + 预处理 + ONNX Runtime 推理`
180+
- Python 侧是 `Ultralytics 封装 + 其内部推理流程`
181+
182+
即使 Python 最终对 `.onnx` 也可能落到 ONNX Runtime 后端,前处理、批处理组织、结果封装和显示逻辑仍然不一定与当前 C++ 代码完全一致。
171183

172184
在这个前提下,C++ / Qt / ONNX Runtime 的推理结果可能会出现下面这种情况:
173185

174186
- 最终类别一致
175187
- 置信度数值与 Python 脚本不是完全相同
176188

177-
这在当前阶段是正常现象,主要原因通常不是模型变了,而是推理前处理还没有做到和 Python 侧 **像素级完全一致**。常见差异来源包括:
189+
这在当前阶段是正常现象,主要原因通常不是模型变了,而是两边的推理链路没有做到 **完全同一实现**。常见差异来源包括:
190+
191+
1. Python 脚本不是直接调用你自己写的 `onnxruntime.InferenceSession(...)`,而是走 Ultralytics 的模型封装
192+
2. 图像解码库不同:Python 侧通常使用 PIL / OpenCV,旧版 C++ 侧使用 Qt 的 `QImageReader`
193+
3. 缩放插值实现不同:即使都是双线性插值,不同库的边界和取整策略也可能不同
194+
4. 中心裁剪取整细节不同:奇偶尺寸下,中心点可能相差 1 个像素
195+
5. EXIF、颜色通道读取、内部像素格式处理存在实现差异
196+
6. 输出结果的封装方式不同:Python 侧展示的是 Ultralytics `result.probs.top1conf`,C++ 侧直接读取模型输出 tensor
197+
198+
当前仓库已经改为在 C++ 侧使用 OpenCV 统一图片解码、缩放和裁剪,这会明显缩小和 Python 常见 OpenCV 链路之间的差异。
178199

179-
1. 图像解码库不同:Python 侧通常使用 PIL / OpenCV,C++ 侧这里使用 Qt 的 `QImageReader`
180-
2. 缩放插值实现不同:即使都是双线性插值,不同库的边界和取整策略也可能不同
181-
3. 中心裁剪取整细节不同:奇偶尺寸下,中心点可能相差 1 个像素
182-
4. EXIF 自动旋转、颜色通道读取、内部像素格式处理存在实现差异
200+
如果这份分类 ONNX 本身已经输出概率分布,那么 C++ 直接读取输出 tensor 是合理的;但如果你想做“严格对齐”,仍然不能只拿 `predict_gui.py` 的显示结果来判断,需要让 Python 侧也改成同一套 `OpenCV + ONNX Runtime` 推理流程后再比较。
183201

184202
因此,当前项目对“结果正常”的判定标准是:
185203

@@ -190,9 +208,10 @@ cmake --build build
190208

191209
如果后续需要把置信度进一步对齐到非常接近 Python,推荐做法是:
192210

193-
1. Python 导出同一张图片的预处理后输入 tensor
194-
2. C++ 导出同一张图片的预处理后输入 tensor
195-
3. 对两边 tensor 做逐元素比对,再继续调整缩放、裁剪和读图细节
211+
1. Python 侧单独写一个 `OpenCV + ONNX Runtime` 的最小推理脚本
212+
2. Python 导出同一张图片的预处理后输入 tensor
213+
3. C++ 导出同一张图片的预处理后输入 tensor
214+
4. 对两边 tensor 做逐元素比对,再继续调整缩放、裁剪和读图细节
196215

197216
## 扩展
198217

scripts/package-windows.ps1

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ foreach ($dllName in $requiredRuntimeDlls) {
7777
Copy-Item -LiteralPath $sourcePath -Destination $packageRoot
7878
}
7979

80+
$opencvDlls = @(Get-ChildItem -LiteralPath $runtimeBinDir -Filter "opencv*.dll")
81+
if ($opencvDlls.Count -eq 0) {
82+
throw "OpenCV runtime DLLs not found in: $runtimeBinDir"
83+
}
84+
85+
foreach ($dll in $opencvDlls) {
86+
Copy-Item -LiteralPath $dll.FullName -Destination $packageRoot
87+
}
88+
8089
$windeployqt = Get-Command windeployqt.exe -ErrorAction Stop
8190
& $windeployqt.Source `
8291
--release `

src/ImageUtils.cpp

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @file ImageUtils.cpp
3+
* @brief 基于 OpenCV 的图片读取与 Qt 图像转换。
4+
*/
5+
#include "ImageUtils.h"
6+
7+
#include <QFile>
8+
9+
#include <opencv2/imgcodecs.hpp>
10+
#include <opencv2/imgproc.hpp>
11+
12+
#include <vector>
13+
14+
namespace ImageUtils {
15+
16+
cv::Mat loadColorImage(const QString &imagePath) {
17+
// 先用 Qt 读取文件字节,避免直接把 QString 路径传给 OpenCV 时
18+
// 在 Windows/非 ASCII 路径场景下出现兼容性问题。
19+
QFile file(imagePath);
20+
if (!file.open(QIODevice::ReadOnly)) {
21+
return {};
22+
}
23+
24+
const QByteArray encoded = file.readAll();
25+
if (encoded.isEmpty()) {
26+
return {};
27+
}
28+
29+
// OpenCV 解码统一返回 BGR 排列的彩色图,供后续推理前处理复用。
30+
std::vector<uchar> buffer(encoded.begin(), encoded.end());
31+
return cv::imdecode(buffer, cv::IMREAD_COLOR);
32+
}
33+
34+
QImage toQImage(const cv::Mat &image) {
35+
if (image.empty()) {
36+
return {};
37+
}
38+
39+
if (image.type() == CV_8UC1) {
40+
return QImage(
41+
image.data,
42+
image.cols,
43+
image.rows,
44+
static_cast<int>(image.step),
45+
QImage::Format_Grayscale8
46+
).copy();
47+
}
48+
49+
if (image.type() == CV_8UC3) {
50+
cv::Mat rgb;
51+
// Qt 的 RGB888 与 OpenCV 的 BGR 三通道内存顺序不同,需要显式转换。
52+
cv::cvtColor(image, rgb, cv::COLOR_BGR2RGB);
53+
return QImage(
54+
rgb.data,
55+
rgb.cols,
56+
rgb.rows,
57+
static_cast<int>(rgb.step),
58+
QImage::Format_RGB888
59+
).copy();
60+
}
61+
62+
if (image.type() == CV_8UC4) {
63+
cv::Mat rgba;
64+
// 四通道场景同理,需要从 BGRA 转成 Qt 侧期望的 RGBA。
65+
cv::cvtColor(image, rgba, cv::COLOR_BGRA2RGBA);
66+
return QImage(
67+
rgba.data,
68+
rgba.cols,
69+
rgba.rows,
70+
static_cast<int>(rgba.step),
71+
QImage::Format_RGBA8888
72+
).copy();
73+
}
74+
75+
return {};
76+
}
77+
78+
} // namespace ImageUtils

src/ImageUtils.h

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @file ImageUtils.h
3+
* @brief 使用 OpenCV 统一图片解码与格式转换。
4+
*/
5+
#pragma once
6+
7+
#include <QImage>
8+
#include <QString>
9+
10+
#include <opencv2/core/mat.hpp>
11+
12+
namespace ImageUtils {
13+
14+
/**
15+
* @brief 从磁盘读取图片并解码为 BGR 三通道 Mat。
16+
*
17+
* 这里先使用 Qt 的 QFile 读取原始字节,再交给 OpenCV `imdecode()`,
18+
* 这样可以同时兼顾:
19+
* - Qt 对 QString/中文路径的处理能力
20+
* - OpenCV 的统一图像解码行为
21+
*
22+
* @param imagePath 图片路径
23+
* @return 成功时返回 `CV_8UC3` 的 BGR 图像;失败时返回空 Mat
24+
*/
25+
cv::Mat loadColorImage(const QString &imagePath);
26+
27+
/**
28+
* @brief 将 OpenCV Mat 转成可供 Qt UI 展示的 QImage。
29+
*
30+
* 当前仅处理常见的 8-bit 图像类型:
31+
* - `CV_8UC1` -> `QImage::Format_Grayscale8`
32+
* - `CV_8UC3` -> `QImage::Format_RGB888`
33+
* - `CV_8UC4` -> `QImage::Format_RGBA8888`
34+
*
35+
* 对三通道和四通道输入会先做 BGR/BGRA 到 RGB/RGBA 的颜色顺序转换。
36+
*
37+
* @param image OpenCV 图像
38+
* @return 转换后的 QImage;不支持的类型返回空 QImage
39+
*/
40+
QImage toQImage(const cv::Mat &image);
41+
42+
} // namespace ImageUtils

src/MainWindow.cpp

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
* - 执行推理并显示结果
1010
*/
1111
#include "MainWindow.h"
12+
#include "ImageUtils.h"
1213
#include <QApplication>
1314
#include <QPixmap>
14-
#include <QImageReader>
1515
#include <QDir>
1616
#include <QDirIterator>
1717
#include <QFileInfo>
@@ -20,10 +20,8 @@
2020

2121
namespace {
2222

23-
QImage readImageWithAutoTransform(const QString &path) {
24-
QImageReader reader(path);
25-
reader.setAutoTransform(true);
26-
return reader.read();
23+
QImage readImageWithOpenCV(const QString &path) {
24+
return ImageUtils::toQImage(ImageUtils::loadColorImage(path));
2725
}
2826

2927
constexpr int kImageIndexRole = Qt::UserRole;
@@ -159,7 +157,7 @@ void MainWindow::selectModel() {
159157

160158
void MainWindow::loadImage() {
161159
QString path = QFileDialog::getOpenFileName(
162-
this, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp)"
160+
this, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
163161
);
164162
if (path.isEmpty()) return;
165163

@@ -207,16 +205,16 @@ void MainWindow::runInference() {
207205
return;
208206
}
209207

210-
QImage image = readImageWithAutoTransform(m_imagePaths[m_currentIndex]);
211-
if (image.isNull()) {
208+
const QString &imagePath = m_imagePaths[m_currentIndex];
209+
OnnxClassifier::Result result = m_classifier.classify(imagePath);
210+
if (result.allScores.empty()) {
212211
QMessageBox::warning(this, "警告", "图片格式不支持: " + m_imagePaths[m_currentIndex]);
213212
return;
214213
}
215214

216-
OnnxClassifier::Result result = m_classifier.classify(image);
217215
displayResult(result);
218216
setStatusText(QString("状态: 当前图片推理完成 (%1)")
219-
.arg(QFileInfo(m_imagePaths[m_currentIndex]).fileName()));
217+
.arg(QFileInfo(imagePath).fileName()));
220218
}
221219

222220
void MainWindow::runBatchInference() {
@@ -242,8 +240,8 @@ void MainWindow::runBatchInference() {
242240
.arg(m_imagePaths.size())
243241
.arg(QFileInfo(path).fileName()));
244242

245-
QImage image = readImageWithAutoTransform(path);
246-
if (image.isNull()) {
243+
OnnxClassifier::Result result = m_classifier.classify(path);
244+
if (result.allScores.empty()) {
247245
auto *item = new QListWidgetItem(
248246
QString("第%1张 | %2 | 读取失败").arg(i + 1).arg(QFileInfo(path).fileName())
249247
);
@@ -253,7 +251,6 @@ void MainWindow::runBatchInference() {
253251
continue;
254252
}
255253

256-
OnnxClassifier::Result result = m_classifier.classify(image);
257254
addBatchResultItem(i, path, result);
258255
okCount++;
259256

@@ -294,11 +291,14 @@ void MainWindow::nextImage() {
294291
void MainWindow::showCurrentImage() {
295292
if (m_currentIndex < 0 || m_currentIndex >= m_imagePaths.size()) return;
296293

297-
QPixmap pixmap = QPixmap::fromImage(readImageWithAutoTransform(m_imagePaths[m_currentIndex]));
294+
QPixmap pixmap = QPixmap::fromImage(readImageWithOpenCV(m_imagePaths[m_currentIndex]));
298295
if (!pixmap.isNull()) {
299296
m_imageLabel->setPixmap(pixmap.scaled(
300297
m_imageLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation
301298
));
299+
} else {
300+
m_imageLabel->clear();
301+
m_imageLabel->setText("图片读取失败");
302302
}
303303

304304
// 更新图片信息

0 commit comments

Comments
 (0)