commit c658fa4265490c9c702788cd5a5ed86e34e8619a Author: luochen570 <1160510664@qq.com> Date: Sun May 31 12:10:10 2026 +0800 Initial Qt OpenCV image tool diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3d1d11f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.16) + +project(QtOpenCVImageTool LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) +find_package(OpenCV REQUIRED) + +add_executable(QtOpenCVImageTool + main.cpp + MainWindow.h + MainWindow.cpp +) + +target_link_libraries(QtOpenCVImageTool PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets + ${OpenCV_LIBS} +) + +target_include_directories(QtOpenCVImageTool PRIVATE ${OpenCV_INCLUDE_DIRS}) diff --git a/MainWindow.cpp b/MainWindow.cpp new file mode 100644 index 0000000..c82e610 --- /dev/null +++ b/MainWindow.cpp @@ -0,0 +1,433 @@ +#include "MainWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +QImage matToQImage(const cv::Mat &mat) +{ + if (mat.empty()) { + return {}; + } + + cv::Mat rgb; + if (mat.channels() == 1) { + cv::cvtColor(mat, rgb, cv::COLOR_GRAY2RGB); + } else if (mat.channels() == 3) { + cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB); + } else if (mat.channels() == 4) { + cv::cvtColor(mat, rgb, cv::COLOR_BGRA2RGBA); + } else { + return {}; + } + + const QImage::Format format = rgb.channels() == 4 ? QImage::Format_RGBA8888 : QImage::Format_RGB888; + return QImage(rgb.data, rgb.cols, rgb.rows, static_cast(rgb.step), format).copy(); +} + +cv::Mat rotateWithBounds(const cv::Mat &image, double angle) +{ + if (image.empty() || std::abs(angle) < 0.01) { + return image.clone(); + } + + const cv::Point2f center(image.cols / 2.0F, image.rows / 2.0F); + cv::Mat matrix = cv::getRotationMatrix2D(center, angle, 1.0); + const cv::Rect2f bounds = cv::RotatedRect(cv::Point2f(), image.size(), angle).boundingRect2f(); + matrix.at(0, 2) += bounds.width / 2.0 - image.cols / 2.0; + matrix.at(1, 2) += bounds.height / 2.0 - image.rows / 2.0; + + cv::Mat rotated; + const cv::Size outputSize(static_cast(bounds.width), static_cast(bounds.height)); + cv::warpAffine(image, rotated, matrix, outputSize, cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar(30, 30, 30)); + return rotated; +} + +cv::Mat translateImage(const cv::Mat &image, int dx, int dy) +{ + if (image.empty() || (dx == 0 && dy == 0)) { + return image.clone(); + } + + cv::Mat translated; + cv::Mat matrix = (cv::Mat_(2, 3) << 1, 0, dx, 0, 1, dy); + cv::warpAffine(image, translated, matrix, image.size(), cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar(30, 30, 30)); + return translated; +} + +cv::Mat extractTargets(const cv::Mat &image, int &targetCount) +{ + targetCount = 0; + if (image.empty()) { + return {}; + } + + cv::Mat gray; + cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY); + cv::GaussianBlur(gray, gray, cv::Size(5, 5), 0); + + cv::Mat binary; + cv::threshold(gray, binary, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); + if (cv::countNonZero(binary) > binary.rows * binary.cols / 2) { + cv::bitwise_not(binary, binary); + } + + const cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); + cv::morphologyEx(binary, binary, cv::MORPH_OPEN, kernel); + cv::morphologyEx(binary, binary, cv::MORPH_CLOSE, kernel); + + std::vector> contours; + cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); + + cv::Mat extracted = cv::Mat::zeros(image.size(), image.type()); + image.copyTo(extracted, binary); + + for (const auto &contour : contours) { + const double area = cv::contourArea(contour); + if (area < 300.0) { + continue; + } + + ++targetCount; + cv::rectangle(extracted, cv::boundingRect(contour), cv::Scalar(0, 255, 0), 2); + } + + return extracted; +} + +QString classifyImage(const cv::Mat &image, int targetCount) +{ + if (image.empty()) { + return QStringLiteral("未载入图片"); + } + + cv::Scalar mean = cv::mean(image); + const double brightness = (mean[0] + mean[1] + mean[2]) / 3.0; + const QString light = brightness >= 170.0 ? QStringLiteral("偏亮") + : brightness <= 85.0 ? QStringLiteral("偏暗") + : QStringLiteral("亮度适中"); + + QString color = QStringLiteral("颜色均衡"); + if (mean[2] > mean[1] + 18.0 && mean[2] > mean[0] + 18.0) { + color = QStringLiteral("红色占优"); + } else if (mean[1] > mean[2] + 18.0 && mean[1] > mean[0] + 18.0) { + color = QStringLiteral("绿色占优"); + } else if (mean[0] > mean[2] + 18.0 && mean[0] > mean[1] + 18.0) { + color = QStringLiteral("蓝色占优"); + } + + return QStringLiteral("分类结果:%1,%2;目标数量:%3").arg(light, color).arg(targetCount); +} + +class ImageProcessorWorker : public QObject +{ + Q_OBJECT + +public slots: + void process(const cv::Mat &source, const ProcessParams ¶ms) + { + if (source.empty()) { + emit processed(cv::Mat(), QStringLiteral("未载入图片")); + return; + } + + cv::Mat output = source.clone(); + + if (std::abs(params.scale - 1.0) > 0.001) { + cv::resize(output, output, cv::Size(), params.scale, params.scale, cv::INTER_LINEAR); + } + + output = translateImage(output, params.translateX, params.translateY); + output = rotateWithBounds(output, params.angle); + + int targetCount = 0; + if (params.extractTarget) { + output = extractTargets(output, targetCount); + } else { + cv::Mat gray; + cv::cvtColor(output, gray, cv::COLOR_BGR2GRAY); + cv::Mat binary; + cv::threshold(gray, binary, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); + std::vector> contours; + cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); + targetCount = static_cast(std::count_if(contours.begin(), contours.end(), [](const auto &contour) { + return cv::contourArea(contour) >= 300.0; + })); + } + + const QString classText = params.classifyImage ? classifyImage(output, targetCount) : QStringLiteral("处理完成"); + emit processed(output, classText); + } + +signals: + void processed(const cv::Mat &image, const QString &classText); +}; +} + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) +{ + qRegisterMetaType("cv::Mat"); + qRegisterMetaType("ProcessParams"); + + buildUi(); + + auto *worker = new ImageProcessorWorker; + workerThread = new QThread(this); + worker->moveToThread(workerThread); + connect(this, &MainWindow::processRequested, worker, &ImageProcessorWorker::process); + connect(worker, &ImageProcessorWorker::processed, this, &MainWindow::onProcessed); + connect(workerThread, &QThread::finished, worker, &QObject::deleteLater); + workerThread->start(); +} + +MainWindow::~MainWindow() +{ + if (workerThread) { + workerThread->quit(); + workerThread->wait(); + } +} + +void MainWindow::buildUi() +{ + setWindowTitle(QStringLiteral("Qt + OpenCV 图像显示与处理")); + + auto *central = new QWidget(this); + auto *rootLayout = new QVBoxLayout(central); + + auto *buttonLayout = new QHBoxLayout; + openButton = new QPushButton(QStringLiteral("打开图片")); + saveButton = new QPushButton(QStringLiteral("保存图片")); + saveAsButton = new QPushButton(QStringLiteral("另存图片")); + resetButton = new QPushButton(QStringLiteral("重置处理")); + buttonLayout->addWidget(openButton); + buttonLayout->addWidget(saveButton); + buttonLayout->addWidget(saveAsButton); + buttonLayout->addStretch(); + buttonLayout->addWidget(resetButton); + + auto *controlBox = new QGroupBox(QStringLiteral("图像处理")); + auto *controlLayout = new QHBoxLayout(controlBox); + + scaleSlider = new QSlider(Qt::Horizontal); + scaleSlider->setRange(25, 200); + scaleSlider->setValue(100); + rotateSlider = new QSlider(Qt::Horizontal); + rotateSlider->setRange(-180, 180); + translateXSlider = new QSlider(Qt::Horizontal); + translateXSlider->setRange(-200, 200); + translateYSlider = new QSlider(Qt::Horizontal); + translateYSlider->setRange(-200, 200); + extractCheckBox = new QCheckBox(QStringLiteral("目标提取")); + classifyCheckBox = new QCheckBox(QStringLiteral("图像分类")); + + auto addSlider = [controlLayout](const QString &name, QSlider *slider) { + auto *box = new QWidget; + auto *layout = new QVBoxLayout(box); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(new QLabel(name)); + layout->addWidget(slider); + controlLayout->addWidget(box, 1); + }; + + addSlider(QStringLiteral("缩放"), scaleSlider); + addSlider(QStringLiteral("旋转"), rotateSlider); + addSlider(QStringLiteral("水平平移"), translateXSlider); + addSlider(QStringLiteral("垂直平移"), translateYSlider); + controlLayout->addWidget(extractCheckBox); + controlLayout->addWidget(classifyCheckBox); + + auto *imageLayout = new QHBoxLayout; + originalLabel = new QLabel(QStringLiteral("原图")); + resultLabel = new QLabel(QStringLiteral("处理后图像")); + originalLabel->setAlignment(Qt::AlignCenter); + resultLabel->setAlignment(Qt::AlignCenter); + originalLabel->setMinimumSize(480, 420); + resultLabel->setMinimumSize(480, 420); + originalLabel->setStyleSheet("QLabel { background:#202124; color:white; border:1px solid #555; }"); + resultLabel->setStyleSheet("QLabel { background:#202124; color:white; border:1px solid #555; }"); + + auto *originalScroll = new QScrollArea; + auto *resultScroll = new QScrollArea; + originalScroll->setWidget(originalLabel); + resultScroll->setWidget(resultLabel); + originalScroll->setWidgetResizable(true); + resultScroll->setWidgetResizable(true); + + imageLayout->addWidget(originalScroll, 1); + imageLayout->addWidget(resultScroll, 1); + + rootLayout->addLayout(buttonLayout); + rootLayout->addWidget(controlBox); + rootLayout->addLayout(imageLayout, 1); + setCentralWidget(central); + + statusLabel = new QLabel(QStringLiteral("请打开一张图片")); + statusBar()->addWidget(statusLabel, 1); + + connect(openButton, &QPushButton::clicked, this, &MainWindow::openImage); + connect(saveButton, &QPushButton::clicked, this, &MainWindow::saveImage); + connect(saveAsButton, &QPushButton::clicked, this, &MainWindow::saveImageAs); + connect(resetButton, &QPushButton::clicked, this, &MainWindow::resetParams); + + connect(scaleSlider, &QSlider::valueChanged, this, &MainWindow::scheduleProcessing); + connect(rotateSlider, &QSlider::valueChanged, this, &MainWindow::scheduleProcessing); + connect(translateXSlider, &QSlider::valueChanged, this, &MainWindow::scheduleProcessing); + connect(translateYSlider, &QSlider::valueChanged, this, &MainWindow::scheduleProcessing); + connect(extractCheckBox, &QCheckBox::toggled, this, &MainWindow::scheduleProcessing); + connect(classifyCheckBox, &QCheckBox::toggled, this, &MainWindow::scheduleProcessing); +} + +void MainWindow::openImage() +{ + const QString fileName = QFileDialog::getOpenFileName( + this, + QStringLiteral("打开图片"), + QString(), + QStringLiteral("Images (*.png *.jpg *.jpeg *.bmp *.tif *.tiff)")); + + if (fileName.isEmpty()) { + return; + } + + originalImage = cv::imread(fileName.toLocal8Bit().constData(), cv::IMREAD_COLOR); + if (originalImage.empty()) { + QMessageBox::warning(this, QStringLiteral("打开失败"), QStringLiteral("无法读取该图片文件。")); + return; + } + + savePath.clear(); + updateOriginalView(); + scheduleProcessing(); + statusLabel->setText(QStringLiteral("已打开:%1").arg(QFileInfo(fileName).fileName())); +} + +void MainWindow::saveImage() +{ + if (savePath.isEmpty()) { + saveImageAs(); + return; + } + + if (writeResultToFile(savePath)) { + statusLabel->setText(QStringLiteral("已保存:%1").arg(QFileInfo(savePath).fileName())); + } +} + +void MainWindow::saveImageAs() +{ + if (resultImage.empty()) { + QMessageBox::information(this, QStringLiteral("提示"), QStringLiteral("请先打开并处理图片。")); + return; + } + + const QString fileName = QFileDialog::getSaveFileName( + this, + QStringLiteral("另存图片"), + QStringLiteral("processed.png"), + QStringLiteral("PNG (*.png);;JPEG (*.jpg *.jpeg);;BMP (*.bmp);;TIFF (*.tif *.tiff)")); + + if (fileName.isEmpty()) { + return; + } + + if (writeResultToFile(fileName)) { + savePath = fileName; + statusLabel->setText(QStringLiteral("已另存为:%1").arg(QFileInfo(fileName).fileName())); + } +} + +void MainWindow::resetParams() +{ + scaleSlider->setValue(100); + rotateSlider->setValue(0); + translateXSlider->setValue(0); + translateYSlider->setValue(0); + extractCheckBox->setChecked(false); + classifyCheckBox->setChecked(false); + scheduleProcessing(); +} + +void MainWindow::scheduleProcessing() +{ + if (originalImage.empty()) { + return; + } + + emit processRequested(originalImage.clone(), currentParams()); +} + +void MainWindow::onProcessed(const cv::Mat &image, const QString &classText) +{ + resultImage = image.clone(); + updateResultView(); + statusLabel->setText(classText); +} + +void MainWindow::updateOriginalView() +{ + setImageOnLabel(originalLabel, originalImage); +} + +void MainWindow::updateResultView() +{ + setImageOnLabel(resultLabel, resultImage); +} + +void MainWindow::setImageOnLabel(QLabel *label, const cv::Mat &mat) +{ + const QImage image = matToQImage(mat); + if (image.isNull()) { + label->setText(QStringLiteral("无图像")); + return; + } + + label->setPixmap(QPixmap::fromImage(image).scaled(label->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); +} + +ProcessParams MainWindow::currentParams() const +{ + ProcessParams params; + params.scale = scaleSlider->value() / 100.0; + params.angle = rotateSlider->value(); + params.translateX = translateXSlider->value(); + params.translateY = translateYSlider->value(); + params.extractTarget = extractCheckBox->isChecked(); + params.classifyImage = classifyCheckBox->isChecked(); + return params; +} + +bool MainWindow::writeResultToFile(const QString &fileName) +{ + if (resultImage.empty()) { + QMessageBox::information(this, QStringLiteral("提示"), QStringLiteral("没有可保存的处理结果。")); + return false; + } + + if (!cv::imwrite(fileName.toLocal8Bit().constData(), resultImage)) { + QMessageBox::warning(this, QStringLiteral("保存失败"), QStringLiteral("无法写入该图片文件。")); + return false; + } + + return true; +} + +#include "MainWindow.moc" diff --git a/MainWindow.h b/MainWindow.h new file mode 100644 index 0000000..dfbe70d --- /dev/null +++ b/MainWindow.h @@ -0,0 +1,76 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include + +#include + +class QLabel; +class QPushButton; +class QSlider; +class QCheckBox; +class QThread; + +struct ProcessParams +{ + double scale = 1.0; + int translateX = 0; + int translateY = 0; + double angle = 0.0; + bool extractTarget = false; + bool classifyImage = false; +}; + +Q_DECLARE_METATYPE(cv::Mat) +Q_DECLARE_METATYPE(ProcessParams) + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + +signals: + void processRequested(const cv::Mat &image, const ProcessParams ¶ms); + +private slots: + void openImage(); + void saveImage(); + void saveImageAs(); + void resetParams(); + void scheduleProcessing(); + void onProcessed(const cv::Mat &image, const QString &classText); + +private: + void buildUi(); + void updateOriginalView(); + void updateResultView(); + void setImageOnLabel(QLabel *label, const cv::Mat &mat); + ProcessParams currentParams() const; + bool writeResultToFile(const QString &fileName); + + QLabel *originalLabel = nullptr; + QLabel *resultLabel = nullptr; + QLabel *statusLabel = nullptr; + QPushButton *openButton = nullptr; + QPushButton *saveButton = nullptr; + QPushButton *saveAsButton = nullptr; + QPushButton *resetButton = nullptr; + QSlider *scaleSlider = nullptr; + QSlider *rotateSlider = nullptr; + QSlider *translateXSlider = nullptr; + QSlider *translateYSlider = nullptr; + QCheckBox *extractCheckBox = nullptr; + QCheckBox *classifyCheckBox = nullptr; + + cv::Mat originalImage; + cv::Mat resultImage; + QString savePath; + + QThread *workerThread = nullptr; +}; + +#endif diff --git a/QtOpenCVImageTool.zip b/QtOpenCVImageTool.zip new file mode 100644 index 0000000..116fd4d Binary files /dev/null and b/QtOpenCVImageTool.zip differ diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..d0ee6f2 --- /dev/null +++ b/main.cpp @@ -0,0 +1,14 @@ +#include "MainWindow.h" + +#include + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + MainWindow window; + window.resize(1180, 760); + window.show(); + + return app.exec(); +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..422bfad --- /dev/null +++ b/readme.md @@ -0,0 +1,39 @@ +# Qt + OpenCV 图像显示与处理 + +本工程使用 Qt Widgets 设计界面,使用 OpenCV 完成图像处理。程序支持打开图片、保存图片、另存图片,支持缩放、平移、旋转,并提供目标提取和简单图像分类功能。图像显示使用 Qt 的 `QLabel`/`QWidget`,没有使用 `cv::imshow`。 + +## 文件说明 + +- `CMakeLists.txt`:CMake 工程配置。 +- `main.cpp`:程序入口。 +- `MainWindow.h`:主窗口类和处理参数声明。 +- `MainWindow.cpp`:界面、图像读写、Qt 线程和 OpenCV 处理实现。 + +## 功能 + +- 打开图片:读取 `png/jpg/bmp/tif` 等常见格式。 +- 保存图片:保存当前处理结果。 +- 另存图片:将处理结果保存为新文件。 +- 显示对比:左侧显示处理前图像,右侧显示处理后图像。 +- 基本处理:缩放、水平平移、垂直平移、旋转。 +- 目标提取:使用灰度化、Otsu 阈值、形态学处理和轮廓检测提取前景目标,并用绿色矩形框标记目标。 +- 图像分类:根据平均亮度和主色通道给出简单分类,同时显示目标数量。 +- 线程处理:图像处理工作对象运行在 `QThread` 中,避免阻塞界面。 + +## 编译运行 + +需要先安装 Qt5 或 Qt6、OpenCV、CMake 和 C++17 编译器。 + +```bash +mkdir build +cd build +cmake .. +cmake --build . +./QtOpenCVImageTool +``` + +Windows 下可以使用 Qt Creator 打开本目录的 `CMakeLists.txt`,配置好 Qt 和 OpenCV 后直接构建运行。 + +## 说明 + +本示例中的分类属于简单规则分类,适合作为课程作业演示:根据图像整体亮度判断“偏亮/偏暗/亮度适中”,根据 BGR 平均值判断主色。若需要更高精度分类,可替换为训练好的 OpenCV DNN 或传统机器学习模型。