为什么要重构成类
前几天你的 main.cpp 里可能已经有:
读图
resize
BGR → HSV
阈值分割
形态学
轮廓提取
面积过滤
画框
统计
保存
如果全部堆在 main.cpp:
main.cpp
├── 图像路径
├── 一堆 cv::Mat
├── HSV 参数
├── kernel
├── contours
├── minArea
├── rectangle
└── imwrite
代码虽然能跑,但以后会越来越难维护。
重构后:
main.cpp
→ 只负责"做什么"
ImageProcessor.cpp
→ 负责"怎么做"
这就是今天最重要的变化。
最终目录结构
建议:
CropImageProcessor/
├── CMakeLists.txt
├── include/
│ └── ImageProcessor.h
├── src/
│ ├── ImageProcessor.cpp
│ └── main.cpp
├── images/
│ └── test.jpg
└── output/
三个代码文件职责分别是:
ImageProcessor.h
→ 类的声明
ImageProcessor.cpp
→ 类的实现
main.cpp
→ 程序流程调度
类整体设计
class ImageProcessor {
public:
explicit ImageProcessor(
const std::string& imagePath
);
bool load();
void resize(double scale);
void convertToHSV();
void segmentGreen(
const cv::Scalar& lower,
const cv::Scalar& upper
);
void morphology(
int kernelSize
);
void extractContours(
double minArea
);
void drawResult();
bool saveResult(
const std::string& outputPath
) const;
private:
std::string imagePath_;
cv::Mat image_;
cv::Mat resizedImage_;
cv::Mat hsvImage_;
cv::Mat mask_;
cv::Mat cleanMask_;
cv::Mat resultImage_;
std::vector<
std::vector<cv::Point>
> validContours_;
};
你可以先把它分成两部分:
public
→ 外界允许调用的功能
private
→ 类内部自己维护的数据
public 是"这个类能做什么"
比如:
bool load();
表示:
ImageProcessor 有"加载图像"的能力。
void resize(double scale);
表示:
它可以缩放图像。
void segmentGreen(...);
表示:
它可以做绿色阈值分割。
所以 public 很像这个类对外暴露的一套:
API。
以后别人用你的类,只需要看这些函数。
private 是"这个对象内部保存什么"
例如:
std::string imagePath_;
保存图像路径。
cv::Mat image_;
保存原图。
cv::Mat resizedImage_;
保存缩放结果。
cv::Mat mask_;
保存阈值分割后的 mask。
这就是:
把原来散落在
main()里的变量,变成对象自己的状态。
为什么成员变量后面加 _
例如:
imagePath_
image_
mask_
最后这个 _ 不是 C++ 语法要求。
只是很常见的一种命名习惯:
表示"这是成员变量"。
比如构造函数里:
imagePath_(imagePath)
你就能很容易区分:
imagePath_
→ 成员变量
imagePath
→ 函数参数
构造函数
你的:
explicit ImageProcessor(
const std::string& imagePath
);
表示:
创建
ImageProcessor对象时,需要给它一条图像路径。
比如:
ImageProcessor processor(
"../images/test.jpg"
);
这时候构造函数被自动调用。
实现可以写:
ImageProcessor::ImageProcessor(
const std::string& imagePath
)
: imagePath_(imagePath)
{
}
这里:
: imagePath_(imagePath)
就是你第二周学过的:
成员初始化列表。
为什么参数是 const std::string&
const std::string& imagePath
你之前已经学过。
拆开:
std::string
→ 字符串
&
→ 引用,不复制整份字符串
const
→ 函数不能通过这个参数修改原字符串
所以这特别适合:
只读取一个较大的对象参数。
同理后面的:
const std::string& outputPath
也是同样思路。
explicit 是什么
这个是今天可以顺便认识的新东西。
explicit ImageProcessor(
const std::string& imagePath
);
explicit 可以先理解成:
不允许编译器偷偷把一个
string自动转换成ImageProcessor。
比如没有 explicit 时,某些单参数构造函数可能允许隐式转换。
你现在不用深究。
只记:
单参数构造函数经常建议写
explicit,避免意外隐式转换。
这是一个很好的现代 C++ 习惯。
load() 为什么返回 bool
bool load();
因为读图可能成功,也可能失败。
实现:
bool ImageProcessor::load() {
image_ = cv::imread(imagePath_);
if (image_.empty()) {
std::cerr
<< "Failed to load image: "
<< imagePath_
<< '\n';
return false;
}
return true;
}
于是 main.cpp:
if (!processor.load()) {
return -1;
}
逻辑非常自然:
加载成功
→ 继续
加载失败
→ 结束程序
resize() 怎么设计
你现在写:
void resize(double scale);
这个设计比固定:
cv::Size(640, 480)
更灵活。
例如:
processor.resize(0.25);
表示:
宽和高都缩小到原来的 25%。
实现可以:
void ImageProcessor::resize(double scale) {
cv::resize(
image_,
resizedImage_,
cv::Size(),
scale,
scale
);
}
这里:
fx = scale
fy = scale
所以:
原图 4000 × 3000
scale = 0.25
→ 1000 × 750
对象内部状态在逐步变化
调用:
processor.load();
之后:
image_
有数据。
调用:
processor.resize(0.25);
之后:
resizedImage_
有数据。
调用:
processor.convertToHSV();
之后:
hsvImage_
有数据。
所以整个对象内部其实在保存一条处理流水线:
image_
↓
resizedImage_
↓
hsvImage_
↓
mask_
↓
cleanMask_
↓
validContours_
↓
resultImage_
这也是类重构之后很自然的一点。
convertToHSV()
声明:
void convertToHSV();
实现:
void ImageProcessor::convertToHSV() {
cv::cvtColor(
resizedImage_,
hsvImage_,
cv::COLOR_BGR2HSV
);
}
这里你会发现:
不需要再传
resizedImage。
因为它已经是当前对象的成员变量。
这就是类的一个直接好处:
相关数据和相关操作绑在一起。
segmentGreen()
声明:
void segmentGreen(
const cv::Scalar& lower,
const cv::Scalar& upper
);
实现:
void ImageProcessor::segmentGreen(
const cv::Scalar& lower,
const cv::Scalar& upper
) {
cv::inRange(
hsvImage_,
lower,
upper,
mask_
);
}
注意:
const cv::Scalar& lower
和:
const cv::Scalar& upper
又是在复习:
不复制 + 不修改。
为什么 lower/upper 不存成成员变量
你现在这种设计:
segmentGreen(lower, upper);
表示:
HSV 阈值由调用者每次传进来。
这很好。
因为这样:
processor.segmentGreen(
cv::Scalar(35, 40, 40),
cv::Scalar(85, 255, 255)
);
以后也可以换:
processor.segmentGreen(
anotherLower,
anotherUpper
);
不用改类内部代码。
morphology()
声明:
void morphology(int kernelSize);
实现思路:
void ImageProcessor::morphology(
int kernelSize
) {
cv::Mat kernel =
cv::getStructuringElement(
cv::MORPH_RECT,
cv::Size(
kernelSize,
kernelSize
)
);
cv::Mat opened;
cv::morphologyEx(
mask_,
opened,
cv::MORPH_OPEN,
kernel
);
cv::morphologyEx(
opened,
cleanMask_,
cv::MORPH_CLOSE,
kernel
);
}
流程:
mask_
↓ OPEN
opened
↓ CLOSE
cleanMask_
为什么 opened 可以是局部变量
注意:
cv::Mat opened;
没有放进:
private:
成员变量里。
因为它只在:
morphology()
这个函数执行过程中临时使用。
函数结束以后就不需要了。
所以:
长期需要保存
→ 成员变量
只在一个函数里临时使用
→ 局部变量
这个工程习惯非常重要。
extractContours()
声明:
void extractContours(
double minArea
);
这一步建议不要直接把所有 contours 保存成成员变量。
可以这样:
void ImageProcessor::extractContours(
double minArea
) {
std::vector<
std::vector<cv::Point>
> contours;
cv::findContours(
cleanMask_,
contours,
cv::RETR_EXTERNAL,
cv::CHAIN_APPROX_SIMPLE
);
validContours_.clear();
for (const auto& contour : contours) {
double area =
cv::contourArea(contour);
if (area < minArea) {
continue;
}
validContours_.push_back(
contour
);
}
}
这里很值得你理解。
contours 和 validContours_ 的区别
contours
→ findContours 找到的所有轮廓
→ 临时使用
validContours_
→ 经过面积过滤后的轮廓
→ 后面 drawResult() 还需要
所以:
contours
是局部变量。
而:
validContours_
是成员变量。
这就是在真正练习:
哪些数据应该属于对象状态,哪些只是临时中间量。
为什么先 validContours_.clear()
validContours_.clear();
因为假设你以后又调用一次:
processor.extractContours(200);
如果不清空,
之前:
minArea = 100
留下来的轮廓还会存在。
然后新的轮廓又追加进去。
结果会重复。
所以每次重新提取前:
validContours_.clear();
很合理。
drawResult()
声明:
void drawResult();
实现可以:
void ImageProcessor::drawResult() {
resultImage_ =
resizedImage_.clone();
for (
const auto& contour :
validContours_
) {
cv::Rect box =
cv::boundingRect(contour);
cv::rectangle(
resultImage_,
box,
cv::Scalar(0, 0, 255),
2
);
}
}
这里:
resultImage_ =
resizedImage_.clone();
表示:
每次绘制都从一张干净的 resized 图开始。
这是比较稳妥的设计。
怎么统计植株区域数量
其实你现在已经有:
validContours_
那么有效区域数量天然就是:
validContours_.size()
所以不一定还需要额外:
int plantCount_;
例如在:
drawResult()
里:
std::cout
<< "Detected plant regions: "
<< validContours_.size()
<< '\n';
就可以。
这也是一个很好的设计意识:
如果一个信息能够从已有数据直接算出来,就不一定要额外保存一份状态。
saveResult() 为什么是 const
声明:
bool saveResult(
const std::string& outputPath
) const;
注意最后:
const
不是修饰 outputPath。
前面的:
const std::string&
才是修饰参数。
最后这个:
) const
是在说:
这个成员函数承诺不修改当前 ImageProcessor 对象的状态。
两个 const 一定分开理解
bool saveResult(
const std::string& outputPath
) const;
第一个:
const std::string& outputPath
表示:
不能通过
outputPath修改外面的字符串。
第二个:
) const
表示:
saveResult()不应该修改image_、mask_、validContours_等成员状态。
这是两个完全不同的 const。
saveResult() 实现
可以:
bool ImageProcessor::saveResult(
const std::string& outputPath
) const {
if (resultImage_.empty()) {
std::cerr
<< "Result image is empty\n";
return false;
}
return cv::imwrite(
outputPath,
resultImage_
);
}
逻辑:
结果图为空
→ 保存失败
有结果图
→ imwrite
→ 返回成功/失败
为什么 imwrite 不算修改对象
它虽然会:
向磁盘写一个文件
但它没有修改:
ImageProcessor 对象内部成员状态
所以:
saveResult(...) const
是合理的。
这里顺便建立一个概念:
const 成员函数限制的是对象本身的逻辑状态,不是说函数完全不能产生任何外部行为。
ImageProcessor.h 可以这样组织
头文件大概是:
#pragma once
#include <opencv2/opencv.hpp>
#include <string>
#include <vector>
class ImageProcessor {
public:
explicit ImageProcessor(
const std::string& imagePath
);
bool load();
void resize(double scale);
void convertToHSV();
void segmentGreen(
const cv::Scalar& lower,
const cv::Scalar& upper
);
void morphology(
int kernelSize
);
void extractContours(
double minArea
);
void drawResult();
bool saveResult(
const std::string& outputPath
) const;
private:
std::string imagePath_;
cv::Mat image_;
cv::Mat resizedImage_;
cv::Mat hsvImage_;
cv::Mat mask_;
cv::Mat cleanMask_;
cv::Mat resultImage_;
std::vector<
std::vector<cv::Point>
> validContours_;
};
.h 里为什么要 include 这些东西
因为你的类声明直接用了:
std::string
所以需要:
#include <string>
直接用了:
std::vector
所以需要:
#include <vector>
直接用了:
cv::Mat
cv::Scalar
cv::Point
所以需要 OpenCV 相关头文件。
你可以先继续:
#include <opencv2/opencv.hpp>
最简单。
ImageProcessor.cpp 第一行
通常:
#include "ImageProcessor.h"
然后:
#include <iostream>
后面实现:
ImageProcessor::ImageProcessor(...)
bool ImageProcessor::load()
void ImageProcessor::resize(...)
等等。
所以:
.h
→ 告诉别人类有什么
.cpp
→ 真正实现这些成员函数
你第三周学的"声明/实现分离"现在完全用上了。
main.cpp 应该明显变短
现在:
#include "ImageProcessor.h"
int main() {
ImageProcessor processor(
"../images/test.jpg"
);
if (!processor.load()) {
return -1;
}
processor.resize(0.25);
processor.convertToHSV();
processor.segmentGreen(
cv::Scalar(35, 40, 40),
cv::Scalar(85, 255, 255)
);
processor.morphology(5);
processor.extractContours(100);
processor.drawResult();
if (!processor.saveResult(
"../output/result.jpg"
)) {
return -1;
}
return 0;
}
现在看 main(),你几乎不用看内部算法,就能读懂:
加载
↓
缩放
↓
HSV
↓
绿色分割
↓
形态学
↓
轮廓
↓
画框
↓
保存
这就是重构的价值。
好的 main() 应该像"流程说明书"
你现在这个:
processor.load();
processor.resize(...);
processor.convertToHSV();
processor.segmentGreen(...);
processor.morphology(...);
processor.extractContours(...);
processor.drawResult();
processor.saveResult(...);
实际上非常接近:
一份可执行的流程图。
这比原来 main.cpp 里混着几十行 OpenCV API 更容易维护。
CMake 也要更新
目录:
include/ImageProcessor.h
src/ImageProcessor.cpp
src/main.cpp
那么:
cmake_minimum_required(VERSION 3.16)
project(CropImageProcessor)
set(CMAKE_CXX_STANDARD 17)
find_package(OpenCV REQUIRED)
add_executable(
app
src/main.cpp
src/ImageProcessor.cpp
)
target_include_directories(
app
PRIVATE
include
${OpenCV_INCLUDE_DIRS}
)
target_link_libraries(
app
PRIVATE
${OpenCV_LIBS}
)
这里又把第三周内容完整串起来:
add_executable
→ 哪些 cpp 参与构建
target_include_directories
→ ImageProcessor.h 去哪里找
target_link_libraries
→ OpenCV 库怎么链接
今天你还要理解"顺序依赖"
你的类现在实际上要求:
load
↓
resize
↓
convertToHSV
↓
segmentGreen
↓
morphology
↓
extractContours
↓
drawResult
↓
saveResult
如果直接:
processor.convertToHSV();
但还没:
processor.resize(...)
那:
resizedImage_
可能为空。
所以第一版可以要求调用者:
按正确顺序调用。
以后更工程化时,可以增加:
empty() 检查
异常
状态验证
但今天不用过度设计。
可以适当加入防御性检查
例如:
void ImageProcessor::convertToHSV() {
if (resizedImage_.empty()) {
std::cerr
<< "Resized image is empty\n";
return;
}
cv::cvtColor(
resizedImage_,
hsvImage_,
cv::COLOR_BGR2HSV
);
}
这样调用顺序错了,不至于完全摸不着头脑。
不过今天不要每个函数写得特别复杂。
重点还是:
学会类重构。
为什么不用一个 process() 全做完
你完全可以写:
processor.process();
然后类内部一次性:
resize
HSV
segment
morphology
contours
draw
但对学习阶段不建议。
因为拆成:
resize()
convertToHSV()
segmentGreen()
...
有几个好处:
每一步职责清楚
方便单独调试
方便换参数
方便保存中间结果
方便以后扩展
这就是所谓:
单一职责思维。
以后换分割策略就很容易
现在:
segmentGreen(...)
以后可能加:
segmentByExG(...)
或者:
segmentByModel(...)
其他流程:
morphology
extractContours
drawResult
可以继续复用。
这就是类和模块化开始产生价值的地方。
这个类目前还有一个小设计点
你写:
void extractContours(double minArea);
这个函数同时做了:
轮廓提取
+
面积过滤
第一版完全没问题。
如果以后项目复杂,可以拆成:
extractContours();
filterContours(minArea);
但现在没必要。
不要为了"看起来专业"把类切得过碎。
"数据归谁"
重构前:
main.cpp
image
resized
hsv
mask
cleanMask
result
contours
全部是散落变量。
重构后:
ImageProcessor 对象
image_
resizedImage_
hsvImage_
mask_
cleanMask_
resultImage_
validContours_
这些数据都有了明确"主人":
ImageProcessor对象。
这其实就是面向对象里很重要的一件事:
把数据和操作这些数据的方法放在一起。
和 RAII 思维有联系
你的:
cv::Mat
std::vector
std::string
本身都属于会自动管理资源的对象。
当:
ImageProcessor processor(...)
离开作用域时,
它的成员:
string
Mat
vector
也会依次自动析构。
所以你完全不需要:
delete image_;
delete mask_;
这又和第二周学的:
RAII
连起来了。
特别留意一种错误
比如 .h:
void resize(double scale);
但是 .cpp 写成:
void ImageProcessor::resize(int scale)
这不是同一个函数。
可能最终报:
undefined reference
或者出现其他声明/定义不匹配问题。
所以一定注意:
返回类型
函数名
参数类型
const
声明和实现都要对应。
尤其:
saveResult(...) const
实现时也必须:
bool ImageProcessor::saveResult(
const std::string& outputPath
) const
最后的 const 不能漏。