Skip to content

Pipeline API——让算法像管道一样串起来

到这一章为止,我们已经能用 and_then 把算法一步步串起来了:加载图片、缩放、灰度化、滤波、边缘检测,最后保存——逻辑没问题,但代码写出来是这样的:

cpp
auto result = load<BGR>(path)
                  .and_then([](Image<BGR> bgr) {
                      return resize(std::move(bgr), 160, 120);
                  })
                  .and_then([](Image<BGR> bgr) {
                      return to_gray(std::move(bgr));
                  })
                  .and_then([](Image<Gray> gray) {
                      return gaussian_blur(std::move(gray), 5);
                  })
                  .and_then([](Image<Gray> blurred) {
                      return canny(std::move(blurred), 50, 150);
                  })
                  .and_then([](Image<Gray> edges) {
                      return save(edges, "output.png");
                  });

能用,但说实话,每个步骤都要套一层 and_then 加一个 lambda,缩进越来越深,读起来像在看俄罗斯套娃。而且这段代码里真正有意义的业务逻辑只有那几个函数名和参数,其余全是模板代码。我们在写 C++,不是在写 Lisp——这种嵌套层次应该有更优雅的表达方式。这一章我们就要解决这个问题,把算法链变成像 Unix 管道一样干净利落的流水线。

从动机到设计:我们到底想要什么

如果你用过 Unix 命令行,一定写过类似 cat file | grep pattern | sort | uniq -c 这样的管道。它的妙处在于每个命令只管做自己的事,| 负责把前一个的输出传给下一个——简单、直观、可组合。我们想在 edgecv 里实现的就是这种体验:

cpp
auto result = img | resize(160, 120) | to_gray() | gaussian_blur(5) 
                   | canny(50, 150) | save("output.png");

一行从左到右读完,数据流向一目了然,没有任何多余的 lambda 或嵌套。这就是 Pipeline API 的目标。而要实现这个目标,我们需要三个组件协同工作:步骤工厂steps:: 命名空间)负责生成可调用的函数对象,管道运算符pipe_ops::operator|)负责把步骤串联起来并处理错误传播,以及 组合工具make_pipeline)负责把多个步骤打包成可复用的流水线对象。接下来我们一个一个拆开来看。

如不了解 Expected 的单子操作,请先阅读 primer/expected.md——这一章大量依赖 and_then 的短路语义,如果你不清楚它的工作原理,后面的代码会有点难懂。

steps:: 命名空间——算法的函数对象工厂

我们先看第一个组件。steps:: 命名空间里的每个函数都是一个工厂——它不执行算法,而是返回一个可以执行算法的函数对象。听起来有点绕?我们看代码就明白了。以 steps::resize 为例:

cpp
[[nodiscard]] inline auto resize(int width, int height,
                                 int interpolation = cv::INTER_LINEAR) {
    return [=](auto img) -> expected<decltype(img), AlgorithmError> {
        return cvw::resize(std::move(img), width, height, interpolation);
    };
}

steps::resize(160, 120) 做的事情很简单:它把 160120 这两个参数捕获到一个 lambda 里面,然后返回这个 lambda。当你之后调用 step(some_image) 的时候,这个 lambda 才会真正去调用 cvw::resize。这里有个巧妙的设计值得注意——lambda 用的是 auto img 泛型参数,返回类型是 expected<decltype(img), AlgorithmError>,意味着输入什么格式的 Image,输出就是什么格式的 Expected。这是因为 resize 操作不改变像素格式,BGR 进去 BGR 出来,Gray 进去 Gray 出来,所以可以用泛型 lambda 来统一处理。

但格式转换类的步骤就不一样了。看 steps::to_gray()

cpp
[[nodiscard]] inline auto to_gray() {
    return [](Image<BGR> img) -> expected<Image<Gray>, AlgorithmError> {
        return cvw::to_gray(std::move(img));
    };
}

这里 lambda 的参数明确写死了 Image<BGR>,返回类型是 expected<Image<Gray>, AlgorithmError>——因为灰度化操作必然把格式从 BGR 变成 Gray,这个类型变化必须在编译期就确定下来。如果你试图对一个 Image<Gray> 调用 steps::to_gray(),编译器会直接报错,因为参数类型不匹配。这就是 edgecv 一贯的设计哲学:格式不匹配的问题,能在编译期发现就绝不拖到运行时

所以你会发现 steps:: 里的工厂函数分成了两类。一类是同格式步骤,比如 resizefliprotategaussian_blur,它们用泛型 lambda(auto img),输入输出格式保持一致。另一类是格式转换步骤,比如 to_grayBGR → Gray)、to_bgrGray → BGR)、sobelGray → Float1)、normalizeGray → Float1),它们明确声明了输入和输出的像素格式类型。这种区分不是随意的,它精确反映了每个算法的类型行为。

我们来看看 steps:: 里都有哪些可用步骤。几何变换类包括 resize(width, height) 缩放、flip(axis) 翻转、rotate(angle, expand) 旋转。颜色转换类有 to_gray() 从 BGR 到灰度、to_gray_rgb() 从 RGB 到灰度、to_bgr() 从灰度到 BGR、yuyv_to_gray() 从 YUYV 视图到灰度(这个在嵌入式摄像头场景很有用)。滤波类包括 gaussian_blur(kernel_size, sigma) 高斯模糊和 median_blur<Format>(kernel_size) 中值滤波。边缘检测和阈值类有 canny(lo, hi) Canny 边缘检测、sobel(dx, dy) Sobel 梯度、threshold(thresh, max_val) 固定阈值、adaptive_threshold(block_size, C) 自适应阈值。最后还有 normalize(min_val, max_val) 归一化到指定范围。每个工厂函数的参数都和对应的底层算法函数一一对应,用起来基本零学习成本。

⚠️ 有一个特别的步骤需要注意——steps::save(path)。它是一个 sink 步骤,返回的是 expected<void, AlgorithmError> 而不是某格式的 Image。这意味着 save 之后你不能再往下接图像处理步骤了,因为管道里流过的已经不是一个图像对象了。这一点后面讲到 pipe_ops 的时候还会再提,但先在心里留个印象:save 是管道的终点,不是中间站

pipe_ops::operator|——管道的连接件

有了步骤工厂,我们还需要一个机制把它们串起来。这就是 pipe_ops 命名空间里的 operator| 重载。我们把 operator| 放在一个独立的子命名空间里,是为了遵循 ADL(参数依赖查找)的 opt-in 设计——只有当你 using namespace cvw::pipe_ops 之后,管道运算符才会生效,不会污染全局命名空间。

pipe_ops 里一共有三个重载,我们按数据流的形态逐一讲解。

第一个重载处理最简单的情况——一个原始的 Image<F> 进入管道:

cpp
template <is_pixel_format F, typename Step>
auto operator|(Image<F> img, Step&& step) -> decltype(step(std::move(img))) {
    return std::forward<Step>(step)(std::move(img));
}

逻辑很直白:把图片 move 进步骤函数,返回步骤函数的结果。因为输入是一个确定有值的 Image<F>(不是 Expected),所以这里不存在错误处理的考量,直接调用就行。它的返回类型通过 trailing return type decltype(step(std::move(img))) 自动推导——如果步骤返回 expected<Image<Gray>, AlgorithmError>,那整个表达式的类型就是它。

第二个重载是真正有意思的部分——处理 Expected<Image<F>> 进入管道的情况:

cpp
template <is_pixel_format F, typename Step>
auto operator|(expected<Image<F>, AlgorithmError> input, Step&& step)
    -> decltype(step(std::move(*input))) {
    if (!input) {
        return decltype(step(std::move(*input)))(unexpected(input.error()));
    }
    return std::forward<Step>(step)(std::move(*input));
}

这里做的事情就非常关键了。首先检查 input.has_value()——如果为 false,说明上游已经出错了,这时候直接把错误原封不动地包进返回类型的 Expected 里返回,完全跳过当前步骤。只有 input 里确实有一个有效图像时,才会取出值(*input)并传给步骤函数。这就是所谓的短路行为,和 and_then 的铁路模型一模一样。假如你的 load() 返回了一个错误,那么后面不管接多少个 | steps::xxx(),每一个都会直接透传这个错误,不会有任何一个步骤被真正执行。

我们把测试代码里的短路验证拿出来看看就能确认这一点:

cpp
int call_count = 0;
auto bad_step = [&call_count](Image<BGR> img) -> expected<Image<BGR>, AlgorithmError> {
    ++call_count;
    return unexpected(AlgorithmError::EmptyInput);
};
auto noop = [&call_count](Image<BGR> img) -> expected<Image<BGR>, AlgorithmError> {
    ++call_count;
    return img;
};

auto result = make_bgr() | bad_step | noop;
EXPECT_FALSE(result.has_value());
EXPECT_EQ(call_count, 1);  // noop 根本没被调用

bad_step 返回错误之后,noopcall_count 没有增加——短路确实生效了。这在实际使用中非常重要:当你在管道里写了七八个步骤,而第一步就加载失败了,你不希望剩下的六七步还傻傻地去执行,既浪费时间又可能触发更诡异的错误。

第三个重载处理的是 expected<void, AlgorithmError> 的情况——专门为 steps::save 之后还想继续的场景准备的。不过说实话,steps::save 返回的是 expected<void>,你已经拿到了最终结果,通常不会再往后接了。这个重载更多是为了保持接口的一致性和未来扩展的可能性。

实际使用管道的时候,记得加上 using namespace cvw::pipe_ops;,然后就可以自由地使用 | 运算符了:

cpp
using namespace cvw::pipe_ops;

auto result = load<BGR>(path)
              | steps::resize(160, 120)
              | steps::to_gray()
              | steps::gaussian_blur(5)
              | steps::canny(50, 150)
              | steps::save(output_path);

和开头那个 and_then 嵌套版对比一下——同样的处理流程,代码量减少了大半,可读性也不可同日而语。每一个步骤紧跟着它的参数,从左到右读下来就是数据流动的方向,不需要在脑子里追踪 lambda 的嵌套层级。

make_pipeline——可复用的流水线对象

管道运算符解决了"单次使用"的问题,但在实际项目中,我们经常需要对多张图片执行同一套处理流程。比如你有 100 张监控截图,每张都要经过 resize → 灰度化 → 高斯模糊 → Canny 这套标准流程——如果每次都写一遍 | steps::resize(...) | steps::to_gray() | ...,代码虽然比 and_then 好读,但还是有大量重复。make_pipeline 就是来解决这个问题的。

make_pipeline 接受任意个步骤对象,把它们组合成一个可调用的整体。它的实现藏在 detail::make_pipeline_impl 里,用到了变参模板的递归实例化技巧。如不熟悉变参模板和递归实例化,请参阅 primer/template-metaprogramming.md。不过即使你暂时不想深究实现细节,只用接口也完全没问题。

我们先看使用方式,再看它是怎么做到的:

cpp
auto edge_prep = make_pipeline(
    steps::resize(128, 128),
    steps::to_gray(),
    steps::gaussian_blur(5, 1.0),
    steps::canny(40, 120)
);

// 对第一张图使用
auto out1 = edge_prep(std::move(img1));

// 对第二张图使用同一个 pipeline
auto out2 = edge_prep(std::move(img2));

edge_prep 现在就是一个"打包好的处理流水线",你把它当函数调用就行,传进去一张图,它返回处理结果。而且这个对象可以反复使用,不需要每次都重新构造。

那么 make_pipeline_impl 是怎么把多个步骤组合起来的呢?核心逻辑其实不复杂,就是变参模板的经典递归模式。先看递归终止条件(零个步骤):

cpp
inline auto make_pipeline_impl() {
    return [](auto input) {
        return expected<decltype(input), AlgorithmError>(std::move(input));
    };
}

没有步骤的情况下,返回一个 identity lambda——把输入原封不动地包进 expected 返回。然后是递归情况:

cpp
template <typename First, typename... Rest>
auto make_pipeline_impl(First&& first, Rest&&... rest) {
    auto tail = make_pipeline_impl(std::forward<Rest>(rest)...);
    return [first = std::forward<First>(first),
            tail = std::move(tail)](auto input) mutable {
        return first(std::move(input)).and_then([&tail](auto next) {
            return tail(std::move(next));
        });
    };
}

逻辑是这样的:先递归地把剩余步骤(Rest...)打包成 tail,然后返回一个新的 lambda,这个 lambda 先执行 first 步骤,拿到结果后用 and_then 把结果传给 tail。注意这里用的是 and_then,所以如果 first 失败了,tail 根本不会被调用——短路语义通过 and_then 自然地传递了下来。make_pipeline(s1, s2, s3) 展开后实际上就是 s1(input).and_then(s2).and_then(s3),只不过把调用方从手写嵌套变成了自动生成。

事情到这里还没完。make_pipeline 生成的对象本身也是一个合法的步骤——它是一个可调用对象,接受 Image 返回 Expected。这意味着你可以把它放进管道运算符里混合使用:

cpp
using namespace cvw::pipe_ops;

auto prep = make_pipeline(steps::resize(32, 32), steps::to_gray());
auto result = make_bgr(128, 128) | prep | steps::canny(50, 150);

先用 make_pipeline 预打包一个"预处理"流水线,然后用 | 把它和其他步骤串联起来。这在复杂的处理流程中非常实用——你可以把通用预处理、特定算法、后处理分别打包成 pipeline 对象,然后像搭积木一样自由组合。

三种风格对比——同一个流程,三种写法

我们拿示例程序里的实际代码来做一个完整的对比。同一个图像处理流程——加载 BGR 图像、缩放到 160x120、灰度化、高斯去噪、Canny 边缘检测、保存结果——用三种方式实现,看看各自的优缺点。

Monadic chain 风格(示例 act5):

cpp
auto result = load<BGR>(kInputPath)
                  .and_then([](Image<BGR> bgr) {
                      return to_gray(std::move(bgr));
                  })
                  .and_then([](Image<Gray> gray) {
                      return gaussian_blur(std::move(gray), 5);
                  })
                  .and_then([](Image<Gray> blurred) {
                      return canny(std::move(blurred), 50, 150);
                  })
                  .and_then([](Image<Gray> edges) {
                      return save(edges, kOutputDir + "chain_edges.png");
                  });

能工作,错误传播也是自动的,但每个步骤都要手写 lambda 来做类型转换,代码显得冗长。中间如果你想加一步 resize,得再嵌套一层。这种风格适合需要在每个步骤之间插入自定义逻辑的场景(比如打印日志),但纯粹做数据变换时有点过于啰嗦了。

Pipeline operator 风格(示例 act6):

cpp
using namespace cvw::pipe_ops;

auto src = load<BGR>(kInputPath);
auto result = std::move(src) | steps::resize(160, 120) |
              steps::to_gray() | steps::gaussian_blur(3) |
              steps::canny(30, 100) | 
              steps::save(kOutputDir + "pipe_edges.png");

同样的流程,从七八行嵌套缩成了一行扁平的管道。每个步骤的名字和参数一目了然,从左到右就是数据流的方向,没有多余的语法噪音。如果你不需要在步骤之间插自定义逻辑,这就是最推荐的写法。

make_pipeline 可复用风格(示例 act6 后半段):

cpp
auto edge_prep = make_pipeline(
    steps::resize(128, 128),
    steps::to_gray(),
    steps::gaussian_blur(5, 1.0),
    steps::canny(40, 120)
);

auto img1 = load<BGR>(kInputPath);
if (img1) {
    auto out1 = edge_prep(std::move(*img1));
    if (out1) { save(*out1, kOutputDir + "pipeline_reuse_1.png"); }
}

auto img2 = load<BGR>(kInputPath);
if (img2) {
    auto out2 = edge_prep(std::move(*img2));
    if (out2) { save(*out2, kOutputDir + "pipeline_reuse_2.png"); }
}

同一个 edge_prep 被调用了两次,处理流程完全一致。如果你需要对一批图片做相同的处理,这种写法避免了重复代码,也让流程的修改变得集中——只需要改 make_pipeline 的参数,所有调用点自动生效。

三种风格不是互相排斥的,你可以根据场景自由选择甚至混用。简单的单次处理用 | 管道最顺手,需要自定义中间逻辑时用 and_then,批量处理同一个流程就用 make_pipeline。它们底层的错误传播机制都是统一的 Expected 短路模型,不管你用哪种写法,任何一步出错都不会影响后续步骤的执行——因为后续步骤根本就不会被执行。

避坑指南

讲完了正确的用法,我们来聊几个容易踩的坑。

第一,steps::save() 是管道终点。save 返回 expected<void, AlgorithmError>,不是图像对象。你不能在 save 后面再接 | steps::resize() 之类的图像处理步骤——编译器会报错,因为 expected<void>Image<F> 的类型不匹配。这其实是好事,意味着编译器在帮你守住这条线:save 就是 save,存完就结束了。

第二,格式转换的编译期检查steps::to_gray() 只接受 Image<BGR>,不接受 Image<Gray>Image<RGB>。如果你写 Image<Gray> | steps::to_gray(),编译错误会直接告诉你参数类型不匹配。同样,steps::canny() 只接受 Image<Gray>,你不能对一个 Image<BGR> 直接做 Canny。这些约束看起来严格,但它们替你挡住了一整类"格式搞混"的 bug——在 OpenCV 里这种错误要跑到运行时才能发现,而在 edgecv 里编译都过不了。

第三,pipe_ops 需要显式引入operator| 定义在 cvw::pipe_ops 命名空间里,你必须在作用域内写 using namespace cvw::pipe_ops; 才能使用管道语法。这是有意为之的设计——把管道运算符做成 opt-in,避免和项目中其他库的 operator| 重载产生冲突。忘了加这行的话,编译器会告诉你找不到匹配的 operator|,加上就行。

第四,make_pipeline 的步骤参数是值捕获。你在 make_pipeline 里传的步骤对象会被 move 或 copy 到返回的 lambda 闭包里,所以不用担心临时对象的生命周期问题。但如果你传了一个持有引用的 lambda(比如捕获了局部变量的引用),那就是你自己的责任了——尽量用值捕获,跟 steps:: 工厂函数的风格保持一致。

验证——测试告诉我们什么

最后我们看看测试对 Pipeline 系统的验证覆盖了哪些方面,以确保我们的实现是可靠的。

步骤工厂的测试验证了每个 steps::xxx() 都能正确生成可调用对象,并且调用后的结果和直接调用底层算法函数一致。比如 steps::resize(32, 32) 对一个 64x64 的 BGR 图像调用后,输出尺寸确实是 32x32;steps::to_gray() 的输出通道数确实是 1;steps::canny(50, 150) 能正常接受灰度图并返回灰度图。

管道运算符的测试覆盖了三个关键场景。基本的链式调用验证了多步管道能正确执行到底,中间格式转换(BGR → Gray → Canny)都能正确衔接。错误短路测试确认了当管道中间某一步返回错误时,后续步骤不会被调用。错误传播测试验证了一个已经是错误状态的 Expected 进入管道后,错误码会被原封不动地传递到最终结果里——LoadFailed 进去,LoadFailed 出来,不会被悄悄篡改。

make_pipeline 的测试包括了基本组合、可复用性、单步骤退化、空管道退化,以及最关键的数值正确性验证。最后一个测试分别用顺序调用和 make_pipeline 对同一张图做相同的处理,然后逐像素比较两种方式的结果——完全一致。这说明 pipeline 的组合过程没有引入任何数值偏差或数据丢失,它只是把调用方式变了,算法行为跟手写顺序调用完全等价。

综合测试还验证了 make_pipelinepipe_ops 可以混合使用——用 make_pipeline 预打包的 pipeline 对象可以作为管道运算符的一个步骤参与链式调用,接口一致性没有问题。

到这里,edgecv 的 Pipeline API 就全部讲完了。我们从一个简单的动机出发——厌倦了 and_then 的嵌套写法——设计出了步骤工厂、管道运算符和可复用流水线三个组件,它们各司其职又互相配合,让你可以用最直观的方式组装图像处理流程,同时保留了 Expected 的完整错误处理能力。在后续的嵌入式和 Qt 桥接章节里,你会看到 Pipeline API 在真实场景中发挥更大的威力。

Built with VitePress