From 81d586c032eb2f08336c4d2a1bab791844381f05 Mon Sep 17 00:00:00 2001 From: luochen570 <1160510664@qq.com> Date: Fri, 29 May 2026 22:29:30 +0800 Subject: [PATCH] Initial Linux Qt OpenCV project --- .gitignore | 47 +++ CMakeLists.txt | 58 +++ README.md | 407 +++++++++++++++++++++ app/include/ImageConverter.h | 11 + app/include/ImageProcessor.h | 23 ++ app/include/ImageView.h | 35 ++ app/include/ImageWorker.h | 33 ++ app/include/MainWindow.h | 72 ++++ app/src/ImageConverter.cpp | 47 +++ app/src/ImageProcessor.cpp | 149 ++++++++ app/src/ImageView.cpp | 89 +++++ app/src/ImageWorker.cpp | 35 ++ app/src/MainWindow.cpp | 369 +++++++++++++++++++ app/src/main.cpp | 20 + docs/windows/DEPENDENCIES.md | 144 ++++++++ docs/windows/build-windows.ps1 | 86 +++++ docs/windows/setup-deps-windows.ps1 | 19 + docs/windows/setup-python-venv-windows.ps1 | 29 ++ scripts/build-linux-binary.sh | 30 ++ scripts/build-linux-local.sh | 5 + scripts/setup-linux-binary-deps.sh | 35 ++ scripts/setup-python-venv-linux.sh | 5 + 22 files changed, 1748 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 app/include/ImageConverter.h create mode 100644 app/include/ImageProcessor.h create mode 100644 app/include/ImageView.h create mode 100644 app/include/ImageWorker.h create mode 100644 app/include/MainWindow.h create mode 100644 app/src/ImageConverter.cpp create mode 100644 app/src/ImageProcessor.cpp create mode 100644 app/src/ImageView.cpp create mode 100644 app/src/ImageWorker.cpp create mode 100644 app/src/MainWindow.cpp create mode 100644 app/src/main.cpp create mode 100644 docs/windows/DEPENDENCIES.md create mode 100644 docs/windows/build-windows.ps1 create mode 100644 docs/windows/setup-deps-windows.ps1 create mode 100644 docs/windows/setup-python-venv-windows.ps1 create mode 100755 scripts/build-linux-binary.sh create mode 100755 scripts/build-linux-local.sh create mode 100755 scripts/setup-linux-binary-deps.sh create mode 100755 scripts/setup-python-venv-linux.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02240e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# 构建输出 +/build*/ +/dist*/ + +# 项目内二进制依赖与下载缓存 +/tools/linux-build-env/ +/tools/micromamba/ +/tools/cmake-linux +/tools/ninja-linux +/tools/compiler-linux +/tools/python-bin +/tools/python-venv +/third_party/qt-linux +/third_party/opencv-linux +/downloads/ + +# 历史 Windows 二进制依赖,体积很大,不纳入 Git +/third_party/opencv-windows +/third_party/opencv-windows*/ +/third_party/qt-windows +/third_party/qt-windows-msvc +/third_party/qt-windows*/ +/third_party/qt-tools/ +/tools/cmake-windows/ +/tools/cmake-current-windows + +# 日志和临时文件 +*.log +*.tmp +*.bak +*.swp +.DS_Store + +# CMake / Ninja +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +install_manifest.txt +.ninja_* +build.ninja +compile_commands.json + +# IDE +.vscode/ +.idea/ +*.user +*.pro.user diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..280768c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.21) + +project(chengnan VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC OFF) + +# 本项目目标为 Linux 本地构建。Windows 工具链文件仅作为历史参考,不参与构建。 +if (WIN32) + message(FATAL_ERROR "chengnan 当前按 README 要求只支持 Linux 构建,请不要使用 Windows 编译工具链。") +endif() + +find_package(Qt6 QUIET COMPONENTS Widgets Concurrent) +if (NOT Qt6_FOUND) + find_package(Qt5 REQUIRED COMPONENTS Widgets Concurrent) +endif() + +find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs) + +set(PROJECT_SOURCES + app/src/main.cpp + app/src/MainWindow.cpp + app/src/ImageView.cpp + app/src/ImageConverter.cpp + app/src/ImageProcessor.cpp + app/src/ImageWorker.cpp + + app/include/MainWindow.h + app/include/ImageView.h + app/include/ImageConverter.h + app/include/ImageProcessor.h + app/include/ImageWorker.h +) + +if (Qt6_FOUND) + qt_add_executable(chengnan ${PROJECT_SOURCES}) + target_link_libraries(chengnan PRIVATE Qt6::Widgets Qt6::Concurrent ${OpenCV_LIBS}) +else() + add_executable(chengnan ${PROJECT_SOURCES}) + target_link_libraries(chengnan PRIVATE Qt5::Widgets Qt5::Concurrent ${OpenCV_LIBS}) +endif() + +target_include_directories(chengnan PRIVATE + ${OpenCV_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR}/app/include +) + +target_compile_definitions(chengnan PRIVATE + QT_NO_KEYWORDS + CHENGNAN_APP_VERSION="${PROJECT_VERSION}" +) + +target_compile_options(chengnan PRIVATE -Wall -Wextra -Wpedantic) + +install(TARGETS chengnan RUNTIME DESTINATION bin) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fe9d1b --- /dev/null +++ b/README.md @@ -0,0 +1,407 @@ +# chengnan + +## 项目名称 + +chengnan:基于 Qt 与 OpenCV 的图像显示与处理系统 + +## 当前实现状态 + +本项目已初始化为一个 Qt Widgets + OpenCV + CMake 的 C++ 图像处理应用。当前构建策略已调整为:**在 Linux 中编译,不使用 Windows 编译工具链;所有构建项目所需工具和依赖均采用项目目录内的二进制安装方式,不使用源码编译安装依赖。** + +已实现功能: + +- 打开图片 +- 保存图片 +- 另存图片 +- 使用 QWidget 体系显示图像,不使用 `cv::imshow` +- 左右对比显示处理前 / 处理后图像 +- 图像视图支持鼠标滚轮缩放、双击适配窗口、拖拽平移视图 +- 图像处理功能: + - 放大 / 缩小 + - 平移 + - 指定角度旋转 + - 阈值分割 + 轮廓目标提取 + - 基于 HSV 均值的简单规则分类 +- 使用 `QThread` + 信号槽执行 OpenCV 处理任务,避免阻塞 UI + +## Linux 二进制构建原则 + +本项目后续构建以 Linux 环境为准,要求如下: + +- 在 Linux 中编译本项目 +- 不使用 Windows 编译工具链 +- 不使用 `cl.exe`、`windeployqt.exe`、Windows `cmake.exe`、Windows `ninja.exe` 构建本项目 +- 不使用当前系统上的 GCC / G++ 作为项目构建工具链 +- 不全局安装 Qt、OpenCV、CMake、Ninja、Python 包等依赖 +- 不污染系统 `/usr`、`/usr/local`、系统 Python site-packages 或全局环境变量 +- 所有构建项目所需工具和依赖必须放在项目目录内 +- 所有构建项目所需依赖必须优先使用官方或可信来源的二进制包 +- 不使用源码编译安装 Qt / OpenCV 等依赖 +- Python 使用项目目录内虚拟环境,不使用 uv 安装或管理环境 +- 构建脚本只能临时设置当前进程环境变量,不能修改系统环境变量 + +推荐目录约定: + +```text +chengnan/ +├── tools/ +│ ├── cmake-linux/ # Linux 版 CMake 二进制 +│ ├── ninja-linux/ # Linux 版 Ninja 二进制 +│ ├── compiler-linux/ # Linux 版二进制编译器工具链,如 LLVM/Clang +│ ├── python-bin/ # Python 二进制包 +│ └── python-venv/ # Python 虚拟环境 +├── third_party/ +│ ├── qt-linux/ # Linux 版 Qt 二进制包 +│ └── opencv-linux/ # Linux 版 OpenCV 二进制包 +└── build-linux/ # Linux 构建输出目录 +``` + +> 注意:如果 OpenCV 没有适配当前 Linux 发行版的官方二进制包,不应退回源码编译安装。应改为寻找可信的预编译包、容器化二进制 SDK、发行版无污染解包方案,或更换依赖来源。 + +## 已清理的历史依赖 + +之前为了尝试构建曾产生过源码编译或非目标平台相关内容,目前已删除: + +```text +third_party/opencv-src +third_party/opencv-build +third_party/opencv +third_party/qt-sdk +third_party/qt +third_party/opengl-stub +tools/uv +tools/pyenv +tools/cmake +tools/cmake.tar.gz +``` + +这些内容被删除的原因: + +- `opencv-src` / `opencv-build` / `opencv` 属于源码编译安装路径,不符合“只使用二进制安装”的要求 +- `qt-sdk` / `qt` 是 Linux 本地尝试阶段的旧 Qt 目录,需要按新的 Linux 二进制依赖规范重新放置 +- `opengl-stub` 是临时构建验证用内容,不属于正式项目依赖 +- `tools/uv` / `tools/pyenv` 是 uv 相关环境,不符合“Python 使用虚拟环境,不使用 uv 安装”的要求 +- `tools/cmake` 是旧 Linux CMake 工具目录,后续应按 `tools/cmake-linux/` 重新放置二进制工具 + +## 当前项目内已有的二进制依赖记录 + +当前目录中仍保留过 Windows 方案准备的二进制包和脚本,作为历史记录和可选参考,但**后续 Linux 构建不应使用这些 Windows 工具链**: + +```text +third_party/qt-windows-msvc-sdk/ +third_party/qt-windows-msvc +third_party/opencv-windows-extract/ +third_party/opencv-windows +third_party/qt-tools/ +tools/cmake-windows/ +tools/cmake-current-windows +tools/python-bin/ +scripts/build-windows.ps1 +scripts/setup-python-venv-windows.ps1 +DEPENDENCIES.md +``` + +已验证这些文件曾存在: + +```text +third_party/qt-windows-msvc/bin/qmake.exe +third_party/qt-windows-msvc/bin/windeployqt.exe +third_party/opencv-windows/x64/vc16/lib/OpenCVConfig.cmake +third_party/opencv-windows/x64/vc16/lib/opencv_world4100.lib +third_party/opencv-windows/x64/vc16/bin/opencv_world4100.dll +third_party/qt-tools/Tools/Ninja/ninja.exe +tools/cmake-current-windows/bin/cmake.exe +tools/python-bin/python.exe +``` + +这些 Windows 二进制内容不满足“在 Linux 里面编译,不使用 Windows 编译工具链”的新要求,因此 Linux 构建流程不应引用它们。 + +## Linux 构建目标 + +后续应补充或替换为 Linux 二进制依赖: + +- Linux 版 CMake 二进制包 +- Linux 版 Ninja 二进制包 +- Linux 版二进制 C++ 编译器工具链,例如 LLVM/Clang 预编译包 +- Linux 版 Qt 二进制包 +- Linux 版 OpenCV 二进制包 +- 项目内 Python 二进制包和虚拟环境 + +构建脚本建议命名为: + +```text +scripts/setup-python-venv-linux.sh +scripts/build-linux-binary.sh +``` + +示例构建命令应类似: + +```bash +./scripts/build-linux-binary.sh +``` + +该脚本应只使用项目内路径,例如: + +```bash +export PATH="$PWD/tools/cmake-linux/bin:$PWD/tools/ninja-linux:$PWD/tools/compiler-linux/bin:$PATH" +export CMAKE_PREFIX_PATH="$PWD/third_party/qt-linux;$PWD/third_party/opencv-linux" +``` + +但脚本不得写入系统环境,也不得调用系统 GCC/G++ 作为项目编译器。 + +## 项目目标 + +使用 Qt 设计图形化界面,结合 OpenCV 实现图像的读取、显示、保存、基础几何处理,以及分类或目标提取功能。程序需要支持处理前后图像对比显示,并在图像处理流程中尽量使用 Qt 线程,避免阻塞界面。 + +## 技术要求 + +### 1. 开发框架 + +- GUI 框架:Qt(建议 Qt 6.x,使用 Qt Widgets) +- 图像处理库:OpenCV +- 构建系统:CMake +- 编程语言:C++17 或更高版本 +- 图像显示:必须使用 Qt 的 QWidget / QLabel / QGraphicsView 等控件显示图像 +- 禁止使用:`cv::imshow`、`cv::waitKey` 等 OpenCV 窗口显示方式 + +### 2. 界面功能要求 + +界面由 Qt 设计实现,至少应包含以下基础功能: + +- 打开图片 +- 保存图片 +- 另存图片 +- 显示原始图片 +- 显示处理后的图片 +- 支持处理前、处理后图像的对比观看 +- 显示当前操作状态或处理结果提示 + +建议界面布局: + +- 左侧显示原始图片 +- 右侧显示处理后图片 +- 顶部或侧边栏放置操作按钮 +- 底部状态栏显示当前文件路径、处理状态、图片尺寸等信息 + +### 3. 图像基础处理要求 + +图片处理应包含以下简单操作: + +- 缩放 + - 放大 + - 缩小 + - 按比例缩放 +- 平移 + - 向上、下、左、右移动图像显示区域或图像内容 +- 旋转 + - 顺时针旋转 + - 逆时针旋转 + - 指定角度旋转 + +可选增强功能: + +- 灰度化 +- 二值化 +- 亮度 / 对比度调节 +- 滤波 / 模糊 +- 边缘检测 +- 撤销 / 重做 + +### 4. 分类或目标提取功能要求 + +程序应至少实现以下两项功能之一,两者皆有最佳: + +#### 方案 A:图像分类 + +可实现简单图像分类,例如: + +- 根据颜色占比进行分类 +- 根据亮度、纹理、边缘数量进行规则分类 +- 使用 OpenCV DNN 加载轻量模型进行分类 + +分类结果应显示在界面中,例如: + +- 图片类型 +- 置信度或规则判断依据 +- 分类耗时 + +#### 方案 B:目标提取 + +可实现目标提取,例如: + +- 阈值分割 +- 颜色区域提取 +- 轮廓检测 +- 边缘检测 +- 前景目标提取 + +目标提取结果应显示在处理后图像窗口中,并可叠加以下信息: + +- 目标轮廓 +- 外接矩形 +- 目标数量 +- 面积、中心点等简单特征 + +建议优先实现目标提取,再扩展简单分类功能。 + +### 5. 显示要求 + +- 必须具有图像显示功能 +- 必须显示处理前、处理后的图像对比 +- 显示控件必须基于 QWidget 体系 +- 不允许使用 OpenCV 的 `cv::imshow` 显示图片 +- OpenCV 的 `cv::Mat` 与 Qt 的 `QImage` / `QPixmap` 之间应封装转换函数 + +### 6. 线程要求 + +考虑图像处理使用 Qt 线程,避免大图像处理时卡住主界面。 + +建议设计: + +- 主线程:负责 UI 显示、按钮响应、用户交互 +- 工作线程:负责 OpenCV 图像处理、分类、目标提取 +- 线程通信:使用 Qt 信号与槽 +- 禁止在工作线程中直接操作 QWidget 控件 + +建议类设计: + +- `MainWindow`:主窗口,负责 UI 和用户操作 +- `ImageProcessor`:图像处理逻辑类 +- `ImageWorker`:运行在 QThread 中的图像处理工作对象 +- `ImageConverter`:负责 `cv::Mat` 与 `QImage` 转换 + +### 7. 环境与依赖安装要求 + +所有安装的依赖不得污染本地系统环境,必须采用项目文件夹内二进制安装或指定路径解压的方式。 + +要求: + +- 在 Linux 中编译本项目 +- 不使用 Windows 编译工具链 +- 不使用系统 GCC / G++ 作为项目构建工具链 +- 不使用源码编译安装 Qt / OpenCV 等依赖 +- 不建议直接全局安装 Qt 或 OpenCV +- 不建议污染系统 `/usr`、`/usr/local` 等全局路径 +- 建议将依赖安装到项目目录下,例如: + - `chengnan/third_party/qt-linux/` + - `chengnan/third_party/opencv-linux/` + - `chengnan/tools/cmake-linux/` + - `chengnan/tools/ninja-linux/` + - `chengnan/tools/compiler-linux/` + - `chengnan/tools/python-venv/` +- CMake 通过 `CMAKE_PREFIX_PATH` 指向项目内依赖路径 +- 构建目录使用 `chengnan/build-linux/` +- 可使用脚本统一配置环境变量,但只能影响当前进程,不修改系统环境变量 + +示例目录: + +```text +chengnan/ +├── README.md +├── CMakeLists.txt +├── .gitignore +├── app/ +│ ├── include/ +│ │ ├── MainWindow.h +│ │ ├── ImageView.h +│ │ ├── ImageProcessor.h +│ │ ├── ImageWorker.h +│ │ └── ImageConverter.h +│ └── src/ +│ ├── main.cpp +│ ├── MainWindow.cpp +│ ├── ImageView.cpp +│ ├── ImageProcessor.cpp +│ ├── ImageWorker.cpp +│ └── ImageConverter.cpp +├── docs/ +│ └── windows/ # 历史 Windows 依赖说明和脚本,仅作参考,不参与 Linux 构建 +├── scripts/ +│ ├── setup-linux-binary-deps.sh +│ ├── setup-python-venv-linux.sh +│ └── build-linux-binary.sh +├── tools/ +│ ├── linux-build-env/ # 项目内 Linux 二进制构建环境,Git 忽略 +│ ├── cmake-linux -> linux-build-env +│ ├── ninja-linux -> linux-build-env +│ ├── compiler-linux -> linux-build-env +│ ├── python-bin -> linux-build-env +│ └── python-venv -> linux-build-env +├── third_party/ +│ ├── qt-linux -> ../tools/linux-build-env +│ └── opencv-linux -> ../tools/linux-build-env +└── build-linux/ # Linux 构建输出目录,Git 忽略 +``` + +示例 CMake 配置方式应写入脚本中统一执行,不能依赖系统工具链: + +```bash +./scripts/setup-linux-binary-deps.sh +./scripts/build-linux-binary.sh +``` + +脚本会在项目目录内使用 Linux 二进制包创建隔离环境,并只在当前进程内设置环境变量。核心等价配置如下: + +```bash +export PATH="$PWD/tools/linux-build-env/bin:$PATH" +export CMAKE_PREFIX_PATH="$PWD/tools/linux-build-env" +export PKG_CONFIG_PATH="$PWD/tools/linux-build-env/lib/pkgconfig:$PWD/tools/linux-build-env/share/pkgconfig:$PKG_CONFIG_PATH" +export LD_LIBRARY_PATH="$PWD/tools/linux-build-env/lib:$LD_LIBRARY_PATH" +export CC="$PWD/tools/linux-build-env/bin/clang" +export CXX="$PWD/tools/linux-build-env/bin/clang++" + +cmake -S . -B build-linux \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIX_PATH" \ + -DCMAKE_C_COMPILER="$CC" \ + -DCMAKE_CXX_COMPILER="$CXX" + +cmake --build build-linux -j$(nproc) +``` + +如需临时设置运行环境,建议写入脚本,不写入系统全局配置: + +```bash +export PATH="$PWD/third_party/qt-linux/bin:$PWD/tools/cmake-linux/bin:$PWD/tools/ninja-linux:$PWD/tools/compiler-linux/bin:$PATH" +export LD_LIBRARY_PATH="$PWD/third_party/qt-linux/lib:$PWD/third_party/opencv-linux/lib:$LD_LIBRARY_PATH" +``` + +## 推荐实现步骤 + +1. 创建 Qt Widgets + CMake 项目结构 +2. 实现主窗口和基本按钮:打开、保存、另存 +3. 实现图片显示控件,完成原图和处理图对比显示 +4. 封装 `cv::Mat` 与 `QImage` 的转换工具 +5. 实现缩放、平移、旋转等基础处理 +6. 将图像处理逻辑迁移到 `QThread` 工作线程 +7. 实现目标提取功能,例如阈值分割 + 轮廓检测 +8. 可选实现简单分类功能 +9. 完成保存处理结果图片 +10. 编写 Linux 本地二进制工具链构建脚本,确保 Qt、OpenCV、CMake、Ninja、编译器等工具均放在项目目录内 +11. 测试大图处理时界面是否保持响应 +12. 完善 README、运行说明和截图 + +## 验收标准 + +- 可以打开常见图片格式,例如 PNG、JPG、BMP +- 可以保存当前处理后的图片 +- 可以另存为指定路径 +- 可以在同一界面中对比显示处理前和处理后图像 +- 支持缩放、平移、旋转操作 +- 至少实现分类或目标提取功能之一 +- 图像显示使用 QWidget 体系,不使用 `cv::imshow` +- 图像处理任务不会明显阻塞 UI +- 依赖安装和构建必须限制在项目目录内,不污染系统环境 +- 在 Linux 中编译,不使用 Windows 编译工具链 +- 所有工具和依赖使用二进制包或解压式安装,不使用源码编译安装 + +## 注意事项 + +- QWidget 只能在主线程中创建和更新 +- 工作线程只处理数据,不直接改 UI +- 大图处理时应避免频繁深拷贝,可合理使用 `cv::Mat::clone()` 控制生命周期 +- `QImage` 与 `cv::Mat` 共享内存时要注意对象生命周期 +- 保存图片时应保存处理后的图像,而不是原图 +- 若使用 OpenCV DNN 模型,模型文件应放入项目目录,例如 `models/` +- 所有脚本应避免执行破坏性系统操作 diff --git a/app/include/ImageConverter.h b/app/include/ImageConverter.h new file mode 100644 index 0000000..2f3001c --- /dev/null +++ b/app/include/ImageConverter.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class ImageConverter final +{ +public: + static QImage matToQImage(const cv::Mat &mat); + static cv::Mat qImageToMat(const QImage &image); +}; diff --git a/app/include/ImageProcessor.h b/app/include/ImageProcessor.h new file mode 100644 index 0000000..be19dd4 --- /dev/null +++ b/app/include/ImageProcessor.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +struct ProcessResult +{ + cv::Mat image; + QString description; +}; + +class ImageProcessor final +{ +public: + static ProcessResult rotate(const cv::Mat &source, double angleDegrees); + static ProcessResult scale(const cv::Mat &source, double factor); + static ProcessResult translate(const cv::Mat &source, int dx, int dy); + static ProcessResult extractTargets(const cv::Mat &source); + static ProcessResult classifySimple(const cv::Mat &source); +}; + +Q_DECLARE_METATYPE(ProcessResult) diff --git a/app/include/ImageView.h b/app/include/ImageView.h new file mode 100644 index 0000000..41e4fe5 --- /dev/null +++ b/app/include/ImageView.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +class QGraphicsPixmapItem; +class QGraphicsScene; +class QWheelEvent; +class QMouseEvent; + +class ImageView final : public QGraphicsView +{ + Q_OBJECT + +public: + explicit ImageView(QWidget *parent = nullptr); + + void setImage(const QImage &image); + void clearImage(); + void fitImageToView(); + bool hasImage() const; + +protected: + void wheelEvent(QWheelEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + +private: + void resetSceneRect(); + + QGraphicsScene *scene_ = nullptr; + QGraphicsPixmapItem *pixmapItem_ = nullptr; + QPixmap pixmap_; + double zoomFactor_ = 1.0; +}; diff --git a/app/include/ImageWorker.h b/app/include/ImageWorker.h new file mode 100644 index 0000000..3f8de2c --- /dev/null +++ b/app/include/ImageWorker.h @@ -0,0 +1,33 @@ +#pragma once + +#include "ImageProcessor.h" + +#include +#include +#include + +Q_DECLARE_METATYPE(cv::Mat) + +class ImageWorker final : public QObject +{ + Q_OBJECT + +public: + enum class Operation { + Rotate, + Scale, + Translate, + ExtractTargets, + ClassifySimple + }; + Q_ENUM(Operation) + +public Q_SLOTS: + void process(cv::Mat image, ImageWorker::Operation operation, double value, int dx, int dy); + +Q_SIGNALS: + void finished(ProcessResult result); + void failed(QString message); +}; + +Q_DECLARE_METATYPE(ImageWorker::Operation) diff --git a/app/include/MainWindow.h b/app/include/MainWindow.h new file mode 100644 index 0000000..4389efe --- /dev/null +++ b/app/include/MainWindow.h @@ -0,0 +1,72 @@ +#pragma once + +#include "ImageWorker.h" + +#include +#include +#include + +class QAction; +class QLabel; +class QSpinBox; +class QDoubleSpinBox; +class QPushButton; +class ImageView; + +class MainWindow final : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + +Q_SIGNALS: + void processRequested(cv::Mat image, ImageWorker::Operation operation, double value, int dx, int dy); + +private Q_SLOTS: + void openImage(); + void saveImage(); + void saveImageAs(); + void resetToOriginal(); + void onProcessFinished(ProcessResult result); + void onProcessFailed(const QString &message); + void rotateLeft(); + void rotateRight(); + void scaleUp(); + void scaleDown(); + void translateUp(); + void translateDown(); + void translateLeft(); + void translateRight(); + void extractTargets(); + void classifyImage(); + +private: + void buildUi(); + void setupWorkerThread(); + void updateViews(); + void setBusy(bool busy); + bool ensureHasImage() const; + void requestProcess(ImageWorker::Operation operation, double value = 0.0, int dx = 0, int dy = 0); + bool writeCurrentImageTo(const QString &path); + + ImageView *originalView_ = nullptr; + ImageView *processedView_ = nullptr; + QLabel *infoLabel_ = nullptr; + QLabel *statusLabel_ = nullptr; + QDoubleSpinBox *scaleSpin_ = nullptr; + QDoubleSpinBox *angleSpin_ = nullptr; + QSpinBox *translateSpin_ = nullptr; + + QAction *saveAction_ = nullptr; + QAction *saveAsAction_ = nullptr; + + QThread workerThread_; + ImageWorker *worker_ = nullptr; + + cv::Mat originalImage_; + cv::Mat processedImage_; + QString currentPath_; + bool busy_ = false; +}; diff --git a/app/src/ImageConverter.cpp b/app/src/ImageConverter.cpp new file mode 100644 index 0000000..ac188a7 --- /dev/null +++ b/app/src/ImageConverter.cpp @@ -0,0 +1,47 @@ +#include "ImageConverter.h" + +#include + +QImage ImageConverter::matToQImage(const cv::Mat &mat) +{ + if (mat.empty()) { + return {}; + } + + switch (mat.type()) { + case CV_8UC1: { + QImage image(mat.data, mat.cols, mat.rows, static_cast(mat.step), QImage::Format_Grayscale8); + return image.copy(); + } + case CV_8UC3: { + cv::Mat rgb; + cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB); + QImage image(rgb.data, rgb.cols, rgb.rows, static_cast(rgb.step), QImage::Format_RGB888); + return image.copy(); + } + case CV_8UC4: { + QImage image(mat.data, mat.cols, mat.rows, static_cast(mat.step), QImage::Format_ARGB32); + return image.copy(); + } + default: { + cv::Mat converted; + mat.convertTo(converted, CV_8U); + return matToQImage(converted); + } + } +} + +cv::Mat ImageConverter::qImageToMat(const QImage &image) +{ + if (image.isNull()) { + return {}; + } + + const QImage converted = image.convertToFormat(QImage::Format_RGB888); + cv::Mat rgb(converted.height(), converted.width(), CV_8UC3, + const_cast(converted.bits()), + static_cast(converted.bytesPerLine())); + cv::Mat bgr; + cv::cvtColor(rgb, bgr, cv::COLOR_RGB2BGR); + return bgr.clone(); +} diff --git a/app/src/ImageProcessor.cpp b/app/src/ImageProcessor.cpp new file mode 100644 index 0000000..ec71952 --- /dev/null +++ b/app/src/ImageProcessor.cpp @@ -0,0 +1,149 @@ +#include "ImageProcessor.h" + +#include +#include + +ProcessResult ImageProcessor::rotate(const cv::Mat &source, double angleDegrees) +{ + if (source.empty()) { + return {}; + } + + const cv::Point2f center(static_cast(source.cols) / 2.0F, static_cast(source.rows) / 2.0F); + cv::Mat matrix = cv::getRotationMatrix2D(center, angleDegrees, 1.0); + + const double absCos = std::abs(matrix.at(0, 0)); + const double absSin = std::abs(matrix.at(0, 1)); + const int boundW = static_cast(source.rows * absSin + source.cols * absCos); + const int boundH = static_cast(source.rows * absCos + source.cols * absSin); + + matrix.at(0, 2) += boundW / 2.0 - center.x; + matrix.at(1, 2) += boundH / 2.0 - center.y; + + cv::Mat output; + cv::warpAffine(source, output, matrix, cv::Size(boundW, boundH), cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar(30, 30, 30)); + return {output, QStringLiteral("旋转 %1° 完成").arg(angleDegrees)}; +} + +ProcessResult ImageProcessor::scale(const cv::Mat &source, double factor) +{ + if (source.empty() || factor <= 0.0) { + return {}; + } + + cv::Mat output; + cv::resize(source, output, cv::Size(), factor, factor, factor >= 1.0 ? cv::INTER_CUBIC : cv::INTER_AREA); + return {output, QStringLiteral("缩放 %1 倍完成,尺寸:%2 × %3").arg(factor).arg(output.cols).arg(output.rows)}; +} + +ProcessResult ImageProcessor::translate(const cv::Mat &source, int dx, int dy) +{ + if (source.empty()) { + return {}; + } + + const cv::Mat matrix = (cv::Mat_(2, 3) << 1, 0, dx, 0, 1, dy); + cv::Mat output; + cv::warpAffine(source, output, matrix, source.size(), cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar(30, 30, 30)); + return {output, QStringLiteral("平移完成:dx=%1, dy=%2").arg(dx).arg(dy)}; +} + +ProcessResult ImageProcessor::extractTargets(const cv::Mat &source) +{ + if (source.empty()) { + return {}; + } + + cv::Mat gray; + if (source.channels() == 1) { + gray = source.clone(); + } else { + cv::cvtColor(source, gray, cv::COLOR_BGR2GRAY); + } + + cv::Mat blurred; + cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0); + + cv::Mat binary; + cv::threshold(blurred, binary, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); + + std::vector> contours; + cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); + + cv::Mat output; + if (source.channels() == 1) { + cv::cvtColor(source, output, cv::COLOR_GRAY2BGR); + } else { + output = source.clone(); + } + + int validCount = 0; + double totalArea = 0.0; + const double minArea = std::max(80.0, source.cols * source.rows * 0.0005); + + for (const auto &contour : contours) { + const double area = cv::contourArea(contour); + if (area < minArea) { + continue; + } + ++validCount; + totalArea += area; + const cv::Rect box = cv::boundingRect(contour); + cv::rectangle(output, box, cv::Scalar(0, 0, 255), 2); + cv::drawContours(output, std::vector>{contour}, -1, cv::Scalar(0, 255, 0), 2); + const cv::Moments moments = cv::moments(contour); + if (moments.m00 != 0.0) { + const int cx = static_cast(moments.m10 / moments.m00); + const int cy = static_cast(moments.m01 / moments.m00); + cv::circle(output, cv::Point(cx, cy), 4, cv::Scalar(255, 0, 0), -1); + } + } + + return {output, QStringLiteral("目标提取完成:检测到 %1 个目标,总面积约 %2 像素") + .arg(validCount) + .arg(static_cast(totalArea))}; +} + +ProcessResult ImageProcessor::classifySimple(const cv::Mat &source) +{ + if (source.empty()) { + return {}; + } + + cv::Mat hsv; + if (source.channels() == 1) { + cv::cvtColor(source, hsv, cv::COLOR_GRAY2BGR); + cv::cvtColor(hsv, hsv, cv::COLOR_BGR2HSV); + } else { + cv::cvtColor(source, hsv, cv::COLOR_BGR2HSV); + } + + const cv::Scalar meanHsv = cv::mean(hsv); + const double hue = meanHsv[0]; + const double saturation = meanHsv[1]; + const double value = meanHsv[2]; + + QString type; + if (value < 70) { + type = QStringLiteral("偏暗图像"); + } else if (saturation < 45) { + type = QStringLiteral("低饱和 / 灰度感图像"); + } else if (hue < 15 || hue > 160) { + type = QStringLiteral("红色系图像"); + } else if (hue < 45) { + type = QStringLiteral("黄色 / 橙色系图像"); + } else if (hue < 85) { + type = QStringLiteral("绿色系图像"); + } else if (hue < 130) { + type = QStringLiteral("蓝色系图像"); + } else { + type = QStringLiteral("紫色系图像"); + } + + cv::Mat output = source.clone(); + return {output, QStringLiteral("简单分类:%1(平均 H=%2, S=%3, V=%4)") + .arg(type) + .arg(hue, 0, 'f', 1) + .arg(saturation, 0, 'f', 1) + .arg(value, 0, 'f', 1)}; +} diff --git a/app/src/ImageView.cpp b/app/src/ImageView.cpp new file mode 100644 index 0000000..2f221c1 --- /dev/null +++ b/app/src/ImageView.cpp @@ -0,0 +1,89 @@ +#include "ImageView.h" + +#include +#include +#include +#include +#include +#include + +ImageView::ImageView(QWidget *parent) + : QGraphicsView(parent), scene_(new QGraphicsScene(this)) +{ + setScene(scene_); + pixmapItem_ = scene_->addPixmap(QPixmap()); + pixmapItem_->setTransformationMode(Qt::SmoothTransformation); + + setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); + setDragMode(QGraphicsView::ScrollHandDrag); + setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); + setBackgroundBrush(QColor(36, 36, 36)); + setFrameShape(QFrame::StyledPanel); + setAlignment(Qt::AlignCenter); +} + +void ImageView::setImage(const QImage &image) +{ + if (image.isNull()) { + clearImage(); + return; + } + + pixmap_ = QPixmap::fromImage(image); + pixmapItem_->setPixmap(pixmap_); + resetSceneRect(); + fitImageToView(); +} + +void ImageView::clearImage() +{ + pixmap_ = QPixmap(); + pixmapItem_->setPixmap(QPixmap()); + scene_->setSceneRect(QRectF()); + resetTransform(); + zoomFactor_ = 1.0; +} + +void ImageView::fitImageToView() +{ + if (!hasImage()) { + return; + } + resetTransform(); + fitInView(pixmapItem_, Qt::KeepAspectRatio); + zoomFactor_ = transform().m11(); +} + +bool ImageView::hasImage() const +{ + return !pixmap_.isNull(); +} + +void ImageView::wheelEvent(QWheelEvent *event) +{ + if (!hasImage()) { + QGraphicsView::wheelEvent(event); + return; + } + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + const int delta = event->angleDelta().y(); +#else + const int delta = event->delta(); +#endif + const double factor = delta > 0 ? 1.15 : 1.0 / 1.15; + zoomFactor_ *= factor; + scale(factor, factor); + event->accept(); +} + +void ImageView::mouseDoubleClickEvent(QMouseEvent *event) +{ + Q_UNUSED(event) + fitImageToView(); +} + +void ImageView::resetSceneRect() +{ + scene_->setSceneRect(pixmap_.rect()); +} diff --git a/app/src/ImageWorker.cpp b/app/src/ImageWorker.cpp new file mode 100644 index 0000000..dfd2992 --- /dev/null +++ b/app/src/ImageWorker.cpp @@ -0,0 +1,35 @@ +#include "ImageWorker.h" + +void ImageWorker::process(cv::Mat image, ImageWorker::Operation operation, double value, int dx, int dy) +{ + if (image.empty()) { + Q_EMIT failed(QStringLiteral("没有可处理的图片")); + return; + } + + ProcessResult result; + switch (operation) { + case Operation::Rotate: + result = ImageProcessor::rotate(image, value); + break; + case Operation::Scale: + result = ImageProcessor::scale(image, value); + break; + case Operation::Translate: + result = ImageProcessor::translate(image, dx, dy); + break; + case Operation::ExtractTargets: + result = ImageProcessor::extractTargets(image); + break; + case Operation::ClassifySimple: + result = ImageProcessor::classifySimple(image); + break; + } + + if (result.image.empty()) { + Q_EMIT failed(QStringLiteral("图像处理失败")); + return; + } + + Q_EMIT finished(result); +} diff --git a/app/src/MainWindow.cpp b/app/src/MainWindow.cpp new file mode 100644 index 0000000..27ac3ad --- /dev/null +++ b/app/src/MainWindow.cpp @@ -0,0 +1,369 @@ +#include "MainWindow.h" + +#include "ImageConverter.h" +#include "ImageView.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) +{ + qRegisterMetaType("ProcessResult"); + qRegisterMetaType("ImageWorker::Operation"); + qRegisterMetaType("cv::Mat"); + + buildUi(); + setupWorkerThread(); + setWindowTitle(QStringLiteral("chengnan - Qt/OpenCV 图像处理")); +} + +MainWindow::~MainWindow() +{ + workerThread_.quit(); + workerThread_.wait(); +} + +void MainWindow::buildUi() +{ + auto *openAction = new QAction(QStringLiteral("打开图片"), this); + saveAction_ = new QAction(QStringLiteral("保存图片"), this); + saveAsAction_ = new QAction(QStringLiteral("另存图片"), this); + auto *resetAction = new QAction(QStringLiteral("还原原图"), this); + auto *exitAction = new QAction(QStringLiteral("退出"), this); + + connect(openAction, &QAction::triggered, this, &MainWindow::openImage); + connect(saveAction_, &QAction::triggered, this, &MainWindow::saveImage); + connect(saveAsAction_, &QAction::triggered, this, &MainWindow::saveImageAs); + connect(resetAction, &QAction::triggered, this, &MainWindow::resetToOriginal); + connect(exitAction, &QAction::triggered, qApp, &QApplication::quit); + + auto *fileMenu = menuBar()->addMenu(QStringLiteral("文件")); + fileMenu->addAction(openAction); + fileMenu->addAction(saveAction_); + fileMenu->addAction(saveAsAction_); + fileMenu->addSeparator(); + fileMenu->addAction(exitAction); + + auto *editMenu = menuBar()->addMenu(QStringLiteral("处理")); + editMenu->addAction(resetAction); + + auto *toolbar = addToolBar(QStringLiteral("主工具栏")); + toolbar->addAction(openAction); + toolbar->addAction(saveAction_); + toolbar->addAction(saveAsAction_); + toolbar->addAction(resetAction); + + originalView_ = new ImageView(this); + processedView_ = new ImageView(this); + + auto *leftBox = new QGroupBox(QStringLiteral("处理前图像"), this); + auto *leftLayout = new QVBoxLayout(leftBox); + leftLayout->addWidget(originalView_); + + auto *rightBox = new QGroupBox(QStringLiteral("处理后图像"), this); + auto *rightLayout = new QVBoxLayout(rightBox); + rightLayout->addWidget(processedView_); + + auto *splitter = new QSplitter(Qt::Horizontal, this); + splitter->addWidget(leftBox); + splitter->addWidget(rightBox); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 1); + + scaleSpin_ = new QDoubleSpinBox(this); + scaleSpin_->setRange(0.1, 5.0); + scaleSpin_->setSingleStep(0.1); + scaleSpin_->setValue(1.25); + scaleSpin_->setSuffix(QStringLiteral(" 倍")); + + angleSpin_ = new QDoubleSpinBox(this); + angleSpin_->setRange(-360.0, 360.0); + angleSpin_->setSingleStep(5.0); + angleSpin_->setValue(90.0); + angleSpin_->setSuffix(QStringLiteral("°")); + + translateSpin_ = new QSpinBox(this); + translateSpin_->setRange(1, 1000); + translateSpin_->setValue(40); + translateSpin_->setSuffix(QStringLiteral(" px")); + + auto *scaleUpButton = new QPushButton(QStringLiteral("放大"), this); + auto *scaleDownButton = new QPushButton(QStringLiteral("缩小"), this); + auto *rotateLeftButton = new QPushButton(QStringLiteral("逆时针旋转"), this); + auto *rotateRightButton = new QPushButton(QStringLiteral("顺时针旋转"), this); + auto *upButton = new QPushButton(QStringLiteral("上移"), this); + auto *downButton = new QPushButton(QStringLiteral("下移"), this); + auto *leftButton = new QPushButton(QStringLiteral("左移"), this); + auto *rightButton = new QPushButton(QStringLiteral("右移"), this); + auto *extractButton = new QPushButton(QStringLiteral("目标提取"), this); + auto *classifyButton = new QPushButton(QStringLiteral("简单分类"), this); + + connect(scaleUpButton, &QPushButton::clicked, this, &MainWindow::scaleUp); + connect(scaleDownButton, &QPushButton::clicked, this, &MainWindow::scaleDown); + connect(rotateLeftButton, &QPushButton::clicked, this, &MainWindow::rotateLeft); + connect(rotateRightButton, &QPushButton::clicked, this, &MainWindow::rotateRight); + connect(upButton, &QPushButton::clicked, this, &MainWindow::translateUp); + connect(downButton, &QPushButton::clicked, this, &MainWindow::translateDown); + connect(leftButton, &QPushButton::clicked, this, &MainWindow::translateLeft); + connect(rightButton, &QPushButton::clicked, this, &MainWindow::translateRight); + connect(extractButton, &QPushButton::clicked, this, &MainWindow::extractTargets); + connect(classifyButton, &QPushButton::clicked, this, &MainWindow::classifyImage); + + auto *controlBox = new QGroupBox(QStringLiteral("图像处理操作"), this); + auto *controlLayout = new QHBoxLayout(controlBox); + controlLayout->addWidget(new QLabel(QStringLiteral("缩放比例:"), this)); + controlLayout->addWidget(scaleSpin_); + controlLayout->addWidget(scaleUpButton); + controlLayout->addWidget(scaleDownButton); + controlLayout->addSpacing(12); + controlLayout->addWidget(new QLabel(QStringLiteral("旋转角度:"), this)); + controlLayout->addWidget(angleSpin_); + controlLayout->addWidget(rotateLeftButton); + controlLayout->addWidget(rotateRightButton); + controlLayout->addSpacing(12); + controlLayout->addWidget(new QLabel(QStringLiteral("平移距离:"), this)); + controlLayout->addWidget(translateSpin_); + controlLayout->addWidget(upButton); + controlLayout->addWidget(downButton); + controlLayout->addWidget(leftButton); + controlLayout->addWidget(rightButton); + controlLayout->addSpacing(12); + controlLayout->addWidget(extractButton); + controlLayout->addWidget(classifyButton); + controlLayout->addStretch(1); + + infoLabel_ = new QLabel(QStringLiteral("请打开一张图片开始处理。提示:鼠标滚轮可缩放视图,双击图片可适配窗口。"), this); + infoLabel_->setWordWrap(true); + + auto *central = new QWidget(this); + auto *mainLayout = new QVBoxLayout(central); + mainLayout->addWidget(splitter, 1); + mainLayout->addWidget(controlBox); + mainLayout->addWidget(infoLabel_); + setCentralWidget(central); + + statusLabel_ = new QLabel(QStringLiteral("就绪"), this); + statusBar()->addPermanentWidget(statusLabel_, 1); + + setBusy(false); +} + +void MainWindow::setupWorkerThread() +{ + worker_ = new ImageWorker(); + worker_->moveToThread(&workerThread_); + + connect(&workerThread_, &QThread::finished, worker_, &QObject::deleteLater); + connect(this, &MainWindow::processRequested, worker_, &ImageWorker::process, Qt::QueuedConnection); + connect(worker_, &ImageWorker::finished, this, &MainWindow::onProcessFinished, Qt::QueuedConnection); + connect(worker_, &ImageWorker::failed, this, &MainWindow::onProcessFailed, Qt::QueuedConnection); + + workerThread_.start(); +} + +void MainWindow::openImage() +{ + const QString path = QFileDialog::getOpenFileName( + this, + QStringLiteral("打开图片"), + QString(), + QStringLiteral("图片文件 (*.png *.jpg *.jpeg *.bmp *.tif *.tiff);;所有文件 (*.*)")); + if (path.isEmpty()) { + return; + } + + const std::string localPath = QFile::encodeName(path).toStdString(); + cv::Mat image = cv::imread(localPath, cv::IMREAD_COLOR); + if (image.empty()) { + QMessageBox::warning(this, QStringLiteral("打开失败"), QStringLiteral("无法读取图片:%1").arg(path)); + return; + } + + originalImage_ = image; + processedImage_ = image.clone(); + currentPath_ = path; + updateViews(); + infoLabel_->setText(QStringLiteral("已打开:%1,尺寸:%2 × %3") + .arg(QFileInfo(path).fileName()) + .arg(image.cols) + .arg(image.rows)); + statusLabel_->setText(QStringLiteral("已打开图片")); +} + +void MainWindow::saveImage() +{ + if (!ensureHasImage()) { + return; + } + if (currentPath_.isEmpty()) { + saveImageAs(); + return; + } + writeCurrentImageTo(currentPath_); +} + +void MainWindow::saveImageAs() +{ + if (!ensureHasImage()) { + return; + } + + const QString path = QFileDialog::getSaveFileName( + this, + QStringLiteral("另存图片"), + currentPath_.isEmpty() ? QStringLiteral("processed.png") : currentPath_, + QStringLiteral("PNG 图片 (*.png);;JPEG 图片 (*.jpg *.jpeg);;BMP 图片 (*.bmp);;所有文件 (*.*)")); + if (path.isEmpty()) { + return; + } + if (writeCurrentImageTo(path)) { + currentPath_ = path; + } +} + +void MainWindow::resetToOriginal() +{ + if (!ensureHasImage()) { + return; + } + processedImage_ = originalImage_.clone(); + updateViews(); + infoLabel_->setText(QStringLiteral("已还原为原始图片")); +} + +void MainWindow::onProcessFinished(ProcessResult result) +{ + processedImage_ = result.image; + updateViews(); + infoLabel_->setText(result.description); + statusLabel_->setText(QStringLiteral("处理完成")); + setBusy(false); +} + +void MainWindow::onProcessFailed(const QString &message) +{ + QMessageBox::warning(this, QStringLiteral("处理失败"), message); + statusLabel_->setText(QStringLiteral("处理失败")); + setBusy(false); +} + +void MainWindow::rotateLeft() +{ + requestProcess(ImageWorker::Operation::Rotate, -angleSpin_->value()); +} + +void MainWindow::rotateRight() +{ + requestProcess(ImageWorker::Operation::Rotate, angleSpin_->value()); +} + +void MainWindow::scaleUp() +{ + requestProcess(ImageWorker::Operation::Scale, scaleSpin_->value()); +} + +void MainWindow::scaleDown() +{ + requestProcess(ImageWorker::Operation::Scale, 1.0 / scaleSpin_->value()); +} + +void MainWindow::translateUp() +{ + requestProcess(ImageWorker::Operation::Translate, 0.0, 0, -translateSpin_->value()); +} + +void MainWindow::translateDown() +{ + requestProcess(ImageWorker::Operation::Translate, 0.0, 0, translateSpin_->value()); +} + +void MainWindow::translateLeft() +{ + requestProcess(ImageWorker::Operation::Translate, 0.0, -translateSpin_->value(), 0); +} + +void MainWindow::translateRight() +{ + requestProcess(ImageWorker::Operation::Translate, 0.0, translateSpin_->value(), 0); +} + +void MainWindow::extractTargets() +{ + requestProcess(ImageWorker::Operation::ExtractTargets); +} + +void MainWindow::classifyImage() +{ + requestProcess(ImageWorker::Operation::ClassifySimple); +} + +void MainWindow::updateViews() +{ + originalView_->setImage(ImageConverter::matToQImage(originalImage_)); + processedView_->setImage(ImageConverter::matToQImage(processedImage_)); +} + +void MainWindow::setBusy(bool busy) +{ + busy_ = busy; + saveAction_->setEnabled(!busy && !processedImage_.empty()); + saveAsAction_->setEnabled(!busy && !processedImage_.empty()); + if (busy) { + statusLabel_->setText(QStringLiteral("处理中...")); + } +} + +bool MainWindow::ensureHasImage() const +{ + if (processedImage_.empty()) { + QMessageBox::information(const_cast(this), QStringLiteral("提示"), QStringLiteral("请先打开图片。")); + return false; + } + return true; +} + +void MainWindow::requestProcess(ImageWorker::Operation operation, double value, int dx, int dy) +{ + if (busy_) { + return; + } + if (!ensureHasImage()) { + return; + } + setBusy(true); + Q_EMIT processRequested(processedImage_.clone(), operation, value, dx, dy); +} + +bool MainWindow::writeCurrentImageTo(const QString &path) +{ + if (processedImage_.empty()) { + return false; + } + + const std::string localPath = QFile::encodeName(path).toStdString(); + if (!cv::imwrite(localPath, processedImage_)) { + QMessageBox::warning(this, QStringLiteral("保存失败"), QStringLiteral("无法保存图片:%1").arg(path)); + return false; + } + + statusLabel_->setText(QStringLiteral("已保存图片")); + infoLabel_->setText(QStringLiteral("已保存处理后图片:%1").arg(path)); + return true; +} diff --git a/app/src/main.cpp b/app/src/main.cpp new file mode 100644 index 0000000..ad3d3d2 --- /dev/null +++ b/app/src/main.cpp @@ -0,0 +1,20 @@ +#include +#include + +#include "MainWindow.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QApplication::setApplicationName("chengnan"); + QApplication::setOrganizationName("luochen570"); + QApplication::setApplicationVersion(CHENGNAN_APP_VERSION); + + qRegisterMetaType("cv::Mat"); + + MainWindow window; + window.resize(1280, 760); + window.show(); + + return QApplication::exec(); +} diff --git a/docs/windows/DEPENDENCIES.md b/docs/windows/DEPENDENCIES.md new file mode 100644 index 0000000..8fcdc17 --- /dev/null +++ b/docs/windows/DEPENDENCIES.md @@ -0,0 +1,144 @@ +# chengnan 本地二进制依赖说明 + +本项目原则:**构建项目所用依赖使用二进制包放在项目目录内,不在本机编译安装依赖,不使用系统 GCC 等编译工具链参与本项目构建。** + +## 已删除的非目标依赖 + +已删除之前为 Linux 本地尝试构建而产生的内容: + +- `third_party/opencv-src/`:OpenCV 源码 +- `third_party/opencv-build/`:OpenCV 编译目录 +- `third_party/opencv/`:由源码编译安装出的 Linux OpenCV +- `third_party/qt-sdk/`:Linux Qt 包 +- `third_party/qt`:Linux Qt 软链接 +- `third_party/opengl-stub/`:Linux 构建验证用 stub +- `tools/uv/`:uv 工具 +- `tools/pyenv/`:uv 创建的 Python 环境 +- `tools/cmake/`:Linux CMake 二进制 + +## 当前保留的 Windows 二进制依赖 + +### Qt 6.7.3 MSVC 2019 64-bit + +```text +third_party/qt-windows-msvc-sdk/6.7.3/msvc2019_64/ +third_party/qt-windows-msvc -> qt-windows-msvc-sdk/6.7.3/msvc2019_64 +``` + +关键文件: + +```text +third_party/qt-windows-msvc/bin/qmake.exe +third_party/qt-windows-msvc/bin/windeployqt.exe +``` + +### OpenCV 4.10.0 Windows 官方预编译包 + +```text +third_party/opencv-windows-extract/opencv/build/ +third_party/opencv-windows -> opencv-windows-extract/opencv/build +``` + +关键文件: + +```text +third_party/opencv-windows/x64/vc16/lib/OpenCVConfig.cmake +third_party/opencv-windows/x64/vc16/lib/opencv_world4100.lib +third_party/opencv-windows/x64/vc16/bin/opencv_world4100.dll +``` + +### Ninja / MinGW 工具包 + +```text +third_party/qt-tools/ +``` + +关键文件: + +```text +third_party/qt-tools/Tools/Ninja/ninja.exe +third_party/qt-tools/Tools/mingw1120_64/bin/g++.exe +``` + +> 注意:项目保留 MinGW 二进制只是备用。当前推荐 MSVC,因为 OpenCV 官方 Windows 二进制是 `vc16`,不能直接给 MinGW 链接。 + +### Windows CMake 二进制 + +```text +tools/cmake-windows/cmake-3.31.6-windows-x86_64/ +tools/cmake-current-windows -> cmake-windows/cmake-3.31.6-windows-x86_64 +``` + +关键文件: + +```text +tools/cmake-current-windows/bin/cmake.exe +``` + +### Windows Python 二进制与虚拟环境脚本 + +```text +tools/python-bin/ +scripts/setup-python-venv-windows.ps1 +``` + +说明: + +- `tools/python-bin/` 使用 Python 官方 Windows embeddable zip 二进制包解压得到 +- 不使用 uv +- 如果需要 Python 包,请在 Windows 上运行: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\setup-python-venv-windows.ps1 +``` + +该脚本会优先尝试创建: + +```text +tools/python-venv/ +``` + +如果 embeddable Python 不支持 `venv`,则会在 `tools/python-bin/` 内安装 pip/aqtinstall,仍然限制在项目目录内。 + +## Windows 推荐构建方式 + +由于当前 OpenCV 是官方 MSVC `vc16` 二进制包,推荐使用 MSVC 构建。 + +在 Windows 上打开: + +```text +x64 Native Tools Command Prompt for VS 2019/2022 +``` + +进入项目目录后运行: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1 -Toolchain msvc -Deploy +``` + +脚本会使用项目内: + +- `tools/cmake-current-windows/bin/cmake.exe` +- `third_party/qt-tools/Tools/Ninja/ninja.exe` +- `third_party/qt-windows-msvc/` +- `third_party/opencv-windows/` + +## 关于“不使用系统工具链” + +当前已经避免使用 Linux 系统 GCC 编译本项目,也删除了通过系统 GCC 编译出来的 OpenCV。 + +但 MSVC 编译器 `cl.exe` 通常来自 Visual Studio Build Tools。微软没有像 Qt/OpenCV 一样提供适合随项目简单解压携带的独立 MSVC 工具链包。因此: + +- 如果允许使用 Windows 已安装的 Visual Studio Build Tools:可直接构建 +- 如果要求连 MSVC 编译器也必须项目内携带:建议改用 MinGW,但需要额外准备 MinGW 版 OpenCV 二进制包 + +## 当前 Linux 服务器限制 + +当前服务器是 Linux,只能整理 Windows 二进制依赖和脚本,不能直接运行 Windows 的: + +- `cmake.exe` +- `ninja.exe` +- `cl.exe` +- `windeployqt.exe` + +因此最终 `.exe` 需要在 Windows 上执行构建脚本生成。 diff --git a/docs/windows/build-windows.ps1 b/docs/windows/build-windows.ps1 new file mode 100644 index 0000000..c5108e4 --- /dev/null +++ b/docs/windows/build-windows.ps1 @@ -0,0 +1,86 @@ +param( + [ValidateSet("msvc", "mingw")] + [string]$Toolchain = "msvc", + [string]$BuildDir = "$PSScriptRoot\..\build-windows", + [switch]$Deploy +) + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Resolve-Path "$PSScriptRoot\.." +$BuildDir = [System.IO.Path]::GetFullPath($BuildDir) +$ThirdParty = Join-Path $ProjectRoot "third_party" +$Tools = Join-Path $ProjectRoot "tools" + +$CMakeExe = Join-Path $Tools "cmake-current-windows\bin\cmake.exe" +if (!(Test-Path $CMakeExe)) { + $CMakeExe = Join-Path $Tools "cmake-windows\cmake-3.31.6-windows-x86_64\bin\cmake.exe" +} +if (!(Test-Path $CMakeExe)) { + throw "未找到项目内 Windows CMake:$CMakeExe" +} + +$NinjaExe = Join-Path $ThirdParty "qt-tools\Tools\Ninja\ninja.exe" +if (!(Test-Path $NinjaExe)) { + $NinjaExe = Join-Path $ThirdParty "qt-tools\Tools\ninja\ninja.exe" +} + +if ($Toolchain -eq "msvc") { + $QtPrefix = Join-Path $ThirdParty "qt-windows-msvc" + $OpenCvPrefix = Join-Path $ThirdParty "opencv-windows\x64\vc16\lib" + $OpenCvBin = Join-Path $ThirdParty "opencv-windows\x64\vc16\bin" + $Generator = "Ninja" + + if (!(Test-Path $QtPrefix)) { throw "未找到 Qt MSVC:$QtPrefix" } + if (!(Test-Path $OpenCvPrefix)) { throw "未找到 OpenCV MSVC:$OpenCvPrefix" } + if (!(Get-Command cl.exe -ErrorAction SilentlyContinue)) { + throw "未检测到 cl.exe。请在 x64 Native Tools Command Prompt for VS 中运行本脚本,或先运行 vcvars64.bat。" + } +} else { + $QtPrefix = Join-Path $ThirdParty "qt-windows" + $OpenCvPrefix = Join-Path $ThirdParty "opencv-windows\x64\vc16\lib" + $OpenCvBin = Join-Path $ThirdParty "opencv-windows\x64\vc16\bin" + $MingwBin = Join-Path $ThirdParty "qt-tools\Tools\mingw1120_64\bin" + $Generator = "Ninja" + + if (!(Test-Path $QtPrefix)) { throw "未找到 Qt MinGW:$QtPrefix" } + if (!(Test-Path $MingwBin)) { throw "未找到项目内 MinGW:$MingwBin" } + Write-Warning "OpenCV 官方 Windows 预编译包是 MSVC vc16,不适配 MinGW 链接。若要 MinGW 构建,需要另备 MinGW 版 OpenCV 二进制包。" + + $env:PATH = "$MingwBin;$env:PATH" + $env:CC = Join-Path $MingwBin "gcc.exe" + $env:CXX = Join-Path $MingwBin "g++.exe" +} + +if (!(Test-Path $NinjaExe)) { + throw "未找到项目内 Ninja:$NinjaExe" +} + +$env:PATH = "$QtPrefix\bin;$OpenCvBin;$(Split-Path $NinjaExe);$env:PATH" +$PrefixPath = "$QtPrefix;$OpenCvPrefix" + +Write-Host "ProjectRoot: $ProjectRoot" +Write-Host "Toolchain: $Toolchain" +Write-Host "QtPrefix: $QtPrefix" +Write-Host "OpenCV: $OpenCvPrefix" +Write-Host "BuildDir: $BuildDir" +Write-Host "Ninja: $NinjaExe" + +& $CMakeExe -S $ProjectRoot -B $BuildDir -G $Generator ` + -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_MAKE_PROGRAM="$NinjaExe" ` + -DCMAKE_PREFIX_PATH="$PrefixPath" + +& $CMakeExe --build $BuildDir --config Release + +if ($Deploy) { + $Exe = Join-Path $BuildDir "chengnan.exe" + $DeployDir = Join-Path $BuildDir "deploy" + New-Item -ItemType Directory -Force -Path $DeployDir | Out-Null + Copy-Item $Exe $DeployDir -Force + & (Join-Path $QtPrefix "bin\windeployqt.exe") --release --no-translations (Join-Path $DeployDir "chengnan.exe") + Copy-Item (Join-Path $OpenCvBin "*.dll") $DeployDir -Force + Write-Host "已部署到:$DeployDir" +} + +Write-Host "构建完成。" diff --git a/docs/windows/setup-deps-windows.ps1 b/docs/windows/setup-deps-windows.ps1 new file mode 100644 index 0000000..44cb38b --- /dev/null +++ b/docs/windows/setup-deps-windows.ps1 @@ -0,0 +1,19 @@ +param( + [string]$InstallRoot = "$PSScriptRoot\..\third_party" +) + +$ErrorActionPreference = "Stop" + +$InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null + +Write-Host "本脚本不会写入系统目录,只在项目 third_party 下准备依赖。" +Write-Host "建议方式:" +Write-Host "1. 安装 Qt Online Installer 时,将安装目录选择为:$InstallRoot\qt-sdk" +Write-Host "2. 安装组件选择 Qt 6.x / MinGW 或 MSVC 版本。" +Write-Host "3. 将实际 Qt 编译套件目录复制或软链接到:$InstallRoot\qt" +Write-Host " 例如:$InstallRoot\qt-sdk\6.7.3\mingw_64 -> $InstallRoot\qt" +Write-Host "4. 下载 OpenCV Windows 预编译包,解压到:$InstallRoot\opencv" +Write-Host "5. 然后运行:powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1" +Write-Host "" +Write-Host "如使用 vcpkg,也请将 vcpkg clone 到项目 tools/vcpkg,不要装到系统目录。" diff --git a/docs/windows/setup-python-venv-windows.ps1 b/docs/windows/setup-python-venv-windows.ps1 new file mode 100644 index 0000000..b50b6e8 --- /dev/null +++ b/docs/windows/setup-python-venv-windows.ps1 @@ -0,0 +1,29 @@ +param( + [string]$PythonBin = "$PSScriptRoot\..\tools\python-bin", + [string]$VenvDir = "$PSScriptRoot\..\tools\python-venv" +) + +$ErrorActionPreference = "Stop" + +$PythonBin = [System.IO.Path]::GetFullPath($PythonBin) +$VenvDir = [System.IO.Path]::GetFullPath($VenvDir) +$PythonExe = Join-Path $PythonBin "python.exe" + +if (!(Test-Path $PythonExe)) { + throw "未找到项目内 Python 二进制:$PythonExe" +} + +# Windows embeddable Python 不一定带 venv 模块。优先尝试 venv;失败则使用同目录作为隔离 Python 环境安装 pip。 +try { + & $PythonExe -m venv $VenvDir + $VenvPython = Join-Path $VenvDir "Scripts\python.exe" + & $VenvPython -m pip install --upgrade pip setuptools wheel + & $VenvPython -m pip install aqtinstall + Write-Host "已创建虚拟环境:$VenvDir" +} catch { + Write-Warning "venv 创建失败,改用项目内 embeddable Python 自身作为隔离环境。" + & $PythonExe (Join-Path $PythonBin "get-pip.py") + & $PythonExe -m pip install --upgrade pip setuptools wheel + & $PythonExe -m pip install aqtinstall + Write-Host "已在项目内 Python 二进制目录安装 pip/aqtinstall:$PythonBin" +} diff --git a/scripts/build-linux-binary.sh b/scripts/build-linux-binary.sh new file mode 100755 index 0000000..c4bf86a --- /dev/null +++ b/scripts/build-linux-binary.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_DIR="$ROOT_DIR/tools/linux-build-env" +BUILD_DIR="$ROOT_DIR/build-linux" + +if [[ ! -x "$ENV_DIR/bin/cmake" || ! -x "$ENV_DIR/bin/ninja" || ! -x "$ENV_DIR/bin/clang++" ]]; then + echo "[build] 未发现项目内 Linux 二进制构建环境,先执行 setup-linux-binary-deps.sh" + "$ROOT_DIR/scripts/setup-linux-binary-deps.sh" +fi + +export PATH="$ENV_DIR/bin:$PATH" +export CMAKE_PREFIX_PATH="$ENV_DIR" +export PKG_CONFIG_PATH="$ENV_DIR/lib/pkgconfig:$ENV_DIR/share/pkgconfig:${PKG_CONFIG_PATH:-}" +export LD_LIBRARY_PATH="$ENV_DIR/lib:${LD_LIBRARY_PATH:-}" +export CC="$ENV_DIR/bin/clang" +export CXX="$ENV_DIR/bin/clang++" + +cmake -S "$ROOT_DIR" -B "$BUILD_DIR" \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIX_PATH" \ + -DCMAKE_C_COMPILER="$CC" \ + -DCMAKE_CXX_COMPILER="$CXX" \ + -DCMAKE_INSTALL_PREFIX="$ROOT_DIR/dist-linux" + +cmake --build "$BUILD_DIR" --parallel "$(nproc)" + +echo "[build] 构建完成:$BUILD_DIR/chengnan" diff --git a/scripts/build-linux-local.sh b/scripts/build-linux-local.sh new file mode 100755 index 0000000..56c368c --- /dev/null +++ b/scripts/build-linux-local.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$ROOT_DIR/scripts/build-linux-binary.sh" diff --git a/scripts/setup-linux-binary-deps.sh b/scripts/setup-linux-binary-deps.sh new file mode 100755 index 0000000..3cec08a --- /dev/null +++ b/scripts/setup-linux-binary-deps.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOWNLOAD_DIR="$ROOT_DIR/downloads" +TOOLS_DIR="$ROOT_DIR/tools" +MICROMAMBA_DIR="$TOOLS_DIR/micromamba" +MICROMAMBA_BIN="$MICROMAMBA_DIR/bin/micromamba" +ENV_DIR="$TOOLS_DIR/linux-build-env" + +mkdir -p "$DOWNLOAD_DIR" "$TOOLS_DIR" "$MICROMAMBA_DIR" + +if [[ ! -x "$MICROMAMBA_BIN" ]]; then + echo "[setup] 下载 Linux micromamba 二进制包..." + ARCHIVE="$DOWNLOAD_DIR/micromamba-linux-64.tar.bz2" + curl -L --fail -o "$ARCHIVE" "https://micro.mamba.pm/api/micromamba/linux-64/latest" + tar -xjf "$ARCHIVE" -C "$MICROMAMBA_DIR" bin/micromamba +fi + +if [[ ! -x "$ENV_DIR/bin/cmake" || ! -x "$ENV_DIR/bin/ninja" || ! -x "$ENV_DIR/bin/clang++" ]]; then + echo "[setup] 在项目目录内创建 Linux 二进制构建环境..." + "$MICROMAMBA_BIN" create -y -p "$ENV_DIR" -c conda-forge \ + cmake ninja clangxx qt-main opencv pkg-config +fi + +# README 中约定的路径,使用软链接指向隔离环境,便于 CMake 查找。 +ln -sfn "linux-build-env" "$TOOLS_DIR/compiler-linux" +ln -sfn "linux-build-env" "$TOOLS_DIR/cmake-linux" +ln -sfn "linux-build-env" "$TOOLS_DIR/ninja-linux" +ln -sfn "linux-build-env" "$TOOLS_DIR/python-bin" +ln -sfn "linux-build-env" "$TOOLS_DIR/python-venv" +ln -sfn "../tools/linux-build-env" "$ROOT_DIR/third_party/qt-linux" +ln -sfn "../tools/linux-build-env" "$ROOT_DIR/third_party/opencv-linux" + +echo "[setup] 完成。所有 Linux 构建工具位于:$ENV_DIR" diff --git a/scripts/setup-python-venv-linux.sh b/scripts/setup-python-venv-linux.sh new file mode 100755 index 0000000..89e2b89 --- /dev/null +++ b/scripts/setup-python-venv-linux.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$ROOT_DIR/scripts/setup-linux-binary-deps.sh"