算法封装起步——颜色转换与图像 I/O
上一章我们花了很大篇幅讨论 Image<F>、ImageView<F> 这些核心类型怎么把 OpenCV 的 cv::Mat 用类型安全的方式包了一层。现在图像有了,接下来当然就是要对图像做点事情了——灰度化、高斯模糊、边缘检测、保存文件等等,这些是任何视觉项目都会遇到的刚需。你可能觉得直接调用 cv::cvtColor 不就好了吗?为什么还要再封一层?这个问题我们得好好聊一下。
为什么我们要封装 OpenCV 的算法函数
说真的,最早我开始写 edgecv 的时候也没打算封算法层的,觉得 cv::cvtColor、cv::GaussianBlur 这些 API 用着也还行。但写着写着就发现到处都是一样的模式:先检查图像是不是空的,然后调用 OpenCV 函数,然后处理可能的异常或者返回值——每个函数开头都是这三板斧。更要命的是类型安全的问题,比如你一不小心把一个 CV_8UC3 的 Mat 传给需要单通道输入的 Canny,编译器不会拦你,运行时直接 segfault 或者给你一个莫名其妙的断言失败。
所以我们封装算法层的目标很明确:第一,利用 C++20 的编译期能力把格式错误扼杀在编译阶段,让编译器替我们把关;第二,用 expected<T, E> 统一错误处理,不再到处 try-catch 或者检查返回码;第三,建立一套一致的 API 模式,让所有算法函数用起来都是同一个套路。
错误类型:AlgorithmError
先来看最基础的东西——我们怎么表达错误。在 detail.hpp 里定义了一个枚举:
enum class AlgorithmError : uint8_t {
EmptyInput, // 传了空图像进来
InvalidParameter, // 参数不合法,比如核大小传了偶数
InternalError, // OpenCV 内部出错了
SaveFailed, // 保存文件失败
LoadFailed, // 加载文件失败
};你可能会问为什么不直接用 std::optional 或者异常。optional 的问题是它不告诉你具体出了什么错——是图像空了还是参数传错了?而异常嘛,在实时视觉处理的热路径上抛异常性能开销不小,而且 C++ 异常的 "你可以忽略它直到它炸" 这个特性在图像处理里其实挺危险的,不如 expected 强制你处理错误来得实在。
这个枚举用 uint8_t 作底层类型是为了保持轻量,反正我们的错误种类就那么几种,没必要用个 int 浪费空间。
公共基础设施:detail 名字空间
很好,有了错误类型之后我们还需要一些公共的检查逻辑。这些东西放在 detail 名字空间里,因为它们是给算法函数内部用的,不是暴露给用户的 API。
check_non_empty
第一个也是最常用的检查——确认图像不是空的。每个算法函数的第一步几乎都是这个:
template <is_pixel_format F>
inline auto check_non_empty(const Image<F>& img)
-> expected<void, AlgorithmError> {
if (img.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
return {};
}注意到返回类型是 expected<void, AlgorithmError>,这里的 void 表示"成功的话没什么好返回的"。这个函数对 ImageView<F> 也有一个完全相同的重载,因为有些算法(比如 to_gray)可以直接接受 ImageView 作为输入,我们不想让用户先 copy 一份再传进来。
你可能会觉得"就检查个 empty 写个函数是不是小题大做了"——还真不是。当你有十几个算法函数都需要做这个检查的时候,把逻辑抽出来好处是双重的:一来不用每个地方都写一遍 if (img.empty()),二来如果以后要加日志或者换检查策略,改一个地方就够了。
require_odd 和 require_positive_dim
接下来是参数校验。很多图像处理算法要求核大小必须是正奇数(高斯模糊、中值滤波、自适应阈值都是这样),所以我们有:
inline auto require_odd(int k) -> expected<void, AlgorithmError> {
if (k <= 0 || k % 2 == 0) {
return unexpected(AlgorithmError::InvalidParameter);
}
return {};
}还有一个 require_positive_dim 用来检查 resize 之类操作的宽高参数:
inline auto require_positive_dim(int w, int h)
-> expected<void, AlgorithmError> {
if (w <= 0 || h <= 0) {
return unexpected(AlgorithmError::InvalidParameter);
}
return {};
}这些小工具看着不起眼,但它们是整个算法层错误处理一致性的基石。有了它们,每个算法函数的错误检查就变得非常干净和标准化了。
颜色转换
有了基础设施之后,我们来看看第一个真正干活的算法模块——颜色转换,定义在 color.hpp 里。
to_gray:BGR/RGB 转灰度
最常用的颜色转换莫过于 BGR 转灰度了。我们提供了两个 Image 版本的重载,分别对应 BGR 和 RGB 输入:
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
to_gray(Image<BGR> img) {
if (img.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
cv::Mat dst;
cv::cvtColor(img.mat(), dst, cv::COLOR_BGR2GRAY);
return Image<Gray>(std::move(dst));
}这里的模式是我们所有算法函数的典型范式:参数按值传入(Image<BGR> img),这意味着调用者要么 std::move 进来要么 clone() 一份;函数内部先检查空值,然后调用 OpenCV 的底层实现,最后把结果包进 Image<Gray> 返回。因为参数是按值传的,输入图像的所有权转移给了函数,函数执行完毕后输入图像的 cv::Mat 会被自动析构,不存在内存泄漏的风险。
[[nodiscard]] 属性很重要——它告诉你不要无视返回值,因为返回值里要么是转换好的灰度图,要么是错误信息,两种情况你都得处理。
RGB 版本完全类似,只是 OpenCV 的转换码不同:
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
to_gray(Image<RGB> img) {
if (img.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
cv::Mat dst;
cv::cvtColor(img.mat(), dst, cv::COLOR_RGB2GRAY);
return Image<Gray>(std::move(dst));
}这里有个设计上的取舍值得说一说。为什么不做成一个模板函数 template <is_pixel_format F> to_gray(Image<F> img)?因为并不是所有格式都能转灰度——比如你传一个 Image<Float3> 进来,cv::cvtColor 能不能正确处理取决于具体的 OpenCV 版本和编译选项。我们选择显式列出支持的格式(BGR 和 RGB),这样调用者一眼就知道哪些类型是合法输入,编译器也能在传错类型的时候给出清晰的错误。
to_gray 的 ImageView 重载
事情到这里还没完。有时候你的图像数据并不在一个 Image 对象里——可能来自摄像头回调、可能来自共享内存、可能来自解码器的输出 buffer。这时候你手里只有一块裸内存和一个指针,如果非要先 copy 成 Image<BGR> 再调用 to_gray 就太浪费了。
所以我们为 ImageView<BGR> 提供了一个专门的重载:
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
to_gray(ImageView<BGR> view) {
if (view.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
cv::Mat src(view.height(), view.width(), CV_8UC3,
const_cast<uint8_t*>(view.data()),
view.stride() * sizeof(uint8_t));
cv::Mat dst;
cv::cvtColor(src, dst, cv::COLOR_BGR2GRAY);
return Image<Gray>(std::move(dst));
}这里有一个小细节需要注意:我们用 view 的数据指针构造了一个不拥有数据的 cv::Mat(也就是一个 "header-only" 的 Mat),这个 Mat 的生命周期由我们控制,不会去释放底层内存。const_cast 看着有点吓人,但 cv::cvtColor 的输入参数本身就是 const cv::Mat&,不会修改源数据,所以这里的安全性是有保证的。
to_bgr:灰度转 BGR
反方向的转换也有——灰度图转 BGR:
[[nodiscard]] inline expected<Image<BGR>, AlgorithmError>
to_bgr(Image<Gray> img) {
if (img.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
cv::Mat dst;
cv::cvtColor(img.mat(), dst, cv::COLOR_GRAY2BGR);
return Image<BGR>(std::move(dst));
}不过要理解一点:灰度转 BGR 并不能恢复色彩信息。OpenCV 做的事情只是把单通道的值复制三份变成三通道——每个像素的 B、G、R 三个值都是一样的。这种转换通常是为了满足某些算法接口的输入要求(比如某些深度学习模型要求三通道输入),而不是真的在"上色"。
yuyv_to_gray:YUYV 格式的手动转换
真正的坑在后面。YUYV 是一种常见的摄像头原始输出格式,它把两个像素的亮度(Y)和色度(U、V)打包在 4 个字节里:[Y0, U, Y1, V]。OpenCV 并没有直接提供 YUYV 转灰度的函数,所以我们手动实现了像素级的提取:
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
yuyv_to_gray(ImageView<YUYV> view) {
if (view.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
int w = view.width();
int h = view.height();
Image<Gray> out(w, h);
for (int row = 0; row < h; ++row) {
const uint8_t* src_row = view.data() + row * view.stride();
uint8_t* dst_row = out.data() + row * out.stride();
for (int col = 0; col < w; col += 2) {
int idx = col * 2;
dst_row[col] = src_row[idx];
if (col + 1 < w) {
dst_row[col + 1] = src_row[idx + 2];
}
}
}
return out;
}这个实现只提取 Y 通道(亮度),丢弃 U 和 V(色度),因为如果你只要灰度图的话色度信息本来就没用。循环每次处理两个像素,因为 YUYV 格式本来就是每两个像素共享一组色度的。注意内层循环里那个 if (col + 1 < w) 的检查——这是为了处理宽度为奇数的边界情况,虽然实际场景中 YUYV 的宽度几乎总是偶数,但作为库代码我们不能做这个假设。
⚠️ 这个函数只接受 ImageView<YUYV> 而不是 Image<YUYV>,因为 YUYV 数据几乎总是来自外部 buffer(V4L2 摄像头、USB 设备等),用 ImageView 可以避免无谓的内存拷贝。
图像 I/O
颜色转换聊完了,接下来看另一个基本需求——从文件加载图像和把图像保存到文件。这些定义在 io.hpp 里。
load:从文件加载图像
template <is_pixel_format F = BGR>
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
load(std::string_view path) {
auto result = ImageBuilder<F>{}.load(path);
if (!result) {
return unexpected(AlgorithmError::LoadFailed);
}
return std::move(*result);
}load 是一个模板函数,默认格式是 BGR——这也符合 OpenCV 的惯例,cv::imread 默认就是加载成 BGR。它内部委托给了 ImageBuilder<F> 来做实际的加载工作,如果 ImageBuilder 返回错误(文件不存在、格式不支持等),我们就把它映射成 AlgorithmError::LoadFailed。
使用的时候你可以这样:
auto bgr_img = load<BGR>("photo.jpg"); // 显式指定格式
auto gray_img = load<Gray>("photo.jpg"); // 直接加载为灰度
auto default_img = load("photo.jpg"); // 默认 BGRsave:保存图像到文件
保存就相对直接了:
template <is_pixel_format F>
[[nodiscard]] inline expected<void, AlgorithmError>
save(const Image<F>& img, std::string_view path) {
if (img.empty()) {
return unexpected(AlgorithmError::EmptyInput);
}
if (!cv::imwrite(std::string{path}, img.mat())) {
return unexpected(AlgorithmError::SaveFailed);
}
return {};
}注意 save 接受的是 const Image<F>& 而不是按值传入——因为保存操作不需要获取图像的所有权,只需要读数据就行了。返回类型是 expected<void, AlgorithmError>,成功的时候没有返回值,失败的时候(路径不存在、磁盘满了、编码格式不支持等)返回 SaveFailed。
聚合头文件:algorithms.hpp
现在我们有了 color.hpp 和 io.hpp,后面还会有 filter.hpp、edge.hpp 等等。为了方便用户,我们提供了一个聚合头文件 algorithms.hpp:
#pragma once
#include "algorithms/color.hpp"
#include "algorithms/detail.hpp"
#include "algorithms/edge.hpp"
#include "algorithms/filter.hpp"
#include "algorithms/geometric.hpp"
#include "algorithms/io.hpp"
#include "algorithms/normalize.hpp"
#include "algorithms/threshold.hpp"用户只需要一行 #include <cvw/algorithms.hpp> 就能拿到所有算法函数。当然,如果你知道只需要颜色转换,直接 #include <cvw/algorithms/color.hpp> 也完全没问题,这样编译更快。
用起来:完整示例
说了这么多设计上的考量,让我们看一段实际使用的代码。下面这个例子来自我们的示例程序,展示了从加载到灰度转换再到保存的完整流程:
#include <cvw/algorithms.hpp>
#include <iostream>
using namespace cvw;
int main() {
// 从文件加载一张 BGR 图像
auto img = load<BGR>("test.jpg");
if (!img) {
std::cerr << "加载失败\n";
return 1;
}
std::cout << "加载成功: " << img->width() << "x" << img->height() << "\n";
// BGR 转灰度
auto gray = to_gray(std::move(*img));
if (!gray) {
std::cerr << "灰度转换失败\n";
return 1;
}
std::cout << "灰度图通道数: " << gray->channels() << "\n";
// 保存灰度图
auto saved = save(*gray, "output_gray.png");
if (!saved) {
std::cerr << "保存失败\n";
return 1;
}
std::cout << "保存成功\n";
}你会发现整个流程非常线性:每一步都可能失败,每一步的返回值都通过 expected 强制你检查。这看起来代码量比直接用 cv::imread + cv::cvtColor + cv::imwrite 多了不少,但换来的是每一行代码都能清晰地表达意图和错误处理,不会出现"明明失败了却继续执行"的情况。
不过如果你觉得一步一步检查 if (!result) 太啰嗦了,expected 还支持 monadic chain 风格的链式调用:
auto result = load<BGR>("test.jpg")
.and_then([](Image<BGR> bgr) { return to_gray(std::move(bgr)); })
.and_then([](Image<Gray> gray) { return save(gray, "output.png"); });
if (result) {
std::cout << "整条链执行成功\n";
}and_then 的好处是如果中间任何一步失败了,后续步骤会自动跳过——这叫"错误短路",和 Rust 的 ? 操作符、Swift 的 try? 是同一个思路。这种风格在组合多步操作的时候特别清爽。
验证:测试告诉我们什么
光说不够,我们来看测试代码怎么验证这些功能是否正确。首先是最基本的——颜色转换后通道数是否正确:
TEST(AlgoColor, ToGray_BGR_Image) {
auto bgr = make_bgr_gradient(64, 48);
auto result = to_gray(std::move(bgr));
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->width(), 64);
EXPECT_EQ(result->height(), 48);
EXPECT_EQ(result->channels(), 1); // 灰度图必须单通道
}然后是和 OpenCV 原生实现的对比——我们的封装结果必须和直接调用 OpenCV 完全一致:
TEST(AlgoColor, ToGray_BGR_MatchesOpenCV) {
auto bgr = make_bgr_gradient(32, 32);
cv::Mat ref;
cv::cvtColor(bgr.mat(), ref, cv::COLOR_BGR2GRAY);
auto result = to_gray(bgr.clone());
ASSERT_TRUE(result.has_value());
for (int r = 0; r < 32; ++r) {
for (int c = 0; c < 32; ++c) {
EXPECT_EQ(result->at(r, c, 0), ref.at<uint8_t>(r, c));
}
}
}错误路径也不能忘——传空图进来必须返回 EmptyInput:
TEST(AlgoColor, ToGray_EmptyInput_ReturnsError) {
Image<BGR> empty;
auto result = to_gray(std::move(empty));
EXPECT_FALSE(result.has_value());
EXPECT_EQ(result.error(), AlgorithmError::EmptyInput);
}I/O 的 round-trip 测试也类似——保存再加载,像素值应该在 PNG 无损压缩的误差范围内一致:
TEST(AlgoIO, SaveAndLoadRoundTrip_BGR) {
auto img = make_bgr_gradient(64, 48);
auto path = tmp_path("round_bgr.png");
auto saved = save(img, path);
ASSERT_TRUE(saved.has_value());
auto loaded = load<BGR>(path);
ASSERT_TRUE(loaded.has_value());
EXPECT_EQ(loaded->width(), 64);
EXPECT_EQ(loaded->height(), 48);
for (int r = 0; r < 48; ++r) {
for (int c = 0; c < 64; ++c) {
for (int ch = 0; ch < 3; ++ch) {
EXPECT_NEAR(img.at(r, c, ch), loaded->at(r, c, ch), 2);
}
}
}
}这里用 EXPECT_NEAR(..., 2) 而不是 EXPECT_EQ 是因为 PNG 压缩虽然是无损的,但有时候加载过程中的颜色空间转换可能引入微小的量化误差,给 2 个像素值的容差是比较稳妥的做法。
YUYV 转灰度的测试验证了手动像素提取的正确性——构造一个已知的 YUYV buffer,检查每个像素位置的 Y 值是否被正确提取:
TEST(AlgoColor, YuyvToGray_ManualData) {
int w = 4, h = 2;
int row_bytes = w * 2; // YUYV 每像素 2 字节
std::vector<uint8_t> yuyv_data(h * row_bytes);
// 手动设置 Y 值: 10, 20, 30, 40 (row 0)
yuyv_data[0] = 10; yuyv_data[2] = 20;
yuyv_data[4] = 30; yuyv_data[6] = 40;
// row 1: 50, 60, 70, 80
yuyv_data[8] = 50; yuyv_data[10] = 60;
yuyv_data[12] = 70; yuyv_data[14] = 80;
ImageView<YUYV> view(yuyv_data.data(), w, h, row_bytes);
auto result = yuyv_to_gray(view);
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->at(0, 0, 0), 10);
EXPECT_EQ(result->at(0, 1, 0), 20);
EXPECT_EQ(result->at(0, 2, 0), 30);
EXPECT_EQ(result->at(0, 3, 0), 40);
// ... row 1 类似
}到这里我们已经把算法封装的基本模式建立起来了:检查输入 → 调用 OpenCV → 包装结果为 expected。下一章我们会用同样的模式来封装滤波、边缘检测、几何变换等更多算法,你会发现一旦模式固定下来,添加新的算法函数其实非常机械化——这正是好的抽象该有的样子。