Skip to content

更多算法——滤波、边缘检测、几何变换与阈值

上一章我们把算法封装的基本模式建立起来了——检查输入、调用 OpenCV、用 expected 包装结果。这一章我们要在这个基础上大幅扩展算法库,把滤波、边缘检测、几何变换、阈值化和归一化全部拿下。放心,模式还是那个模式,不会突然变出什么新花样来。不过这一章我们会遇到一些更有趣的设计决策,比如什么时候用 concept 约束模板参数、为什么某些操作输出类型和输入类型不一样,这些才是真正值得聊的东西。

如不熟悉 C++20 Concepts,请参阅 primer/concepts.md。

一致的算法封装模式

在展开之前,先快速回顾一下我们的算法函数都遵循的模式。每个函数大概是这个样子:

cpp
template <is_pixel_format F>
[[nodiscard]] inline expected<Image<Something>, AlgorithmError>
some_algorithm(Image<F> img, /* 参数 */) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());
    // 可能还有 detail::require_odd 之类的参数校验
    // 调用 OpenCV
    // 返回 expected<Image<...>, AlgorithmError>
}

你会看到这一章所有的算法函数都是这个套路——先 check_non_empty,再参数校验,再调用底层实现,最后包装返回。这种一致性意味着你学会用一个函数之后,其他的都不用看文档就能猜到怎么用。接下来我们就按类别一个一个看。

滤波算法

滤波是图像处理里最基础的操作之一,我们提供了两个:高斯模糊和中值模糊,定义在 filter.hpp 里。

gaussian_blur:高斯模糊

cpp
template <is_pixel_format F>
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
gaussian_blur(Image<F> img, int kernel_size, double sigma = 0.0) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());
    ec = detail::require_odd(kernel_size);
    if (!ec) return unexpected(ec.error());

    cv::GaussianBlur(img.mat(), img.mat(), cv::Size(kernel_size, kernel_size),
                     sigma);
    return img;
}

这个函数有几个值得关注的点。首先它是一个模板函数,F 可以是任何满足 is_pixel_format concept 的类型——BGR、Gray、Float1 都行,因为 cv::GaussianBlur 本身就是格式无关的。这意味着你可以对彩色图模糊,也可以对灰度图模糊,甚至对浮点图模糊,都不需要不同的函数名。

其次注意 cv::GaussianBlur(img.mat(), img.mat(), ...)——OpenCV 文档里明确说了 GaussianBlur 支持 in-place 操作(src 和 dst 指向同一个 Mat)。所以我们不需要分配新的输出 Mat,直接在原图上操作就好。这也是为什么函数签名里输入输出类型都是 Image<F>——模糊不改变图像格式和大小,只是在同一个 buffer 上做了个卷积。

sigma 参数默认是 0.0,这告诉 OpenCV 根据 kernel_size 自动计算 sigma 值。大多数情况下这个默认行为就够用了,只有在需要精细控制模糊程度的时候才需要手动指定。

require_odd 确保核大小必须是正奇数——这是高斯模糊的硬性要求。如果你传了 4 或者 -1,函数会返回 InvalidParameter 错误。

median_blur:中值模糊

中值模糊和高斯模糊类似,但有一个关键的 concept 约束:

cpp
template <is_pixel_format F>
    requires(F::channels == 1)
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
median_blur(Image<F> img, int kernel_size) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());
    ec = detail::require_odd(kernel_size);
    if (!ec) return unexpected(ec.error());

    cv::medianBlur(img.mat(), img.mat(), kernel_size);
    return img;
}

看到那个 requires(F::channels == 1) 了吗?这是我们第一次在算法函数上使用 C++20 的 concept 约束。为什么中值模糊要限制单通道?因为 OpenCV 的 cv::medianBlur 对多通道图像的处理是每个通道独立排序取中值——这在数学上是正确的,但在实际使用中,中值模糊主要用于去椒盐噪声,而这个场景通常只关心灰度图。更重要的是,我们通过 concept 在编译期就阻止了你把 Image<BGR> 传进来,如果你试着写 median_blur(bgr_img, 3),编译器会直接报错说模板约束不满足。这比运行时才发现传入了一个不合适的格式要好太多了。

不过 Float1 是单通道的浮点格式,所以 median_blur 也接受 Image<Float1>——concept 只看通道数,不看值的类型。

边缘检测

边缘检测是我们封装里类型约束最严格的部分,定义在 edge.hpp 里。

canny:Canny 边缘检测

cpp
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
canny(Image<Gray> img, double threshold_lo, double threshold_hi,
      int aperture_size = 3) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::Mat dst;
    cv::Canny(img.mat(), dst, threshold_lo, threshold_hi, aperture_size);
    return Image<Gray>(std::move(dst));
}

Canny 不是模板函数——它严格要求 Image<Gray> 作为输入,输出也是 Image<Gray>。为什么不像 gaussian_blur 那样做成泛型的?因为 Canny 算法本身就需要单通道输入。Canny 内部要计算梯度幅值和方向,然后做非极大值抑制,这些步骤在数学上只对单通道数据有意义。如果传多通道进来,cv::Canny 本身也会报错或者产生无意义的结果,不如我们在类型系统层面就拦住。

注意 Canny 不是 in-place 的——它需要一个独立的 dst Mat。这是因为 Canny 的输出是二值图(边缘像素为 255,非边缘像素为 0),和输入的灰度梯度值不在同一个数据空间里。

sobel:Sobel 算子

cpp
[[nodiscard]] inline expected<Image<Float1>, AlgorithmError>
sobel(Image<Gray> img, int dx, int dy, int kernel_size = 3) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::Mat dst;
    cv::Sobel(img.mat(), dst, CV_32FC1, dx, dy, kernel_size);
    return Image<Float1>(std::move(dst));
}

Sobel 是这里最有意思的一个。输入是 Image<Gray>(uint8_t),但输出变成了 Image<Float1>(float)。为什么输出类型变了?因为 Sobel 算子计算的是图像的空间梯度,梯度值可以是负数,绝对值也可以远超 255——比如图像里一个从黑到白的垂直跳变,水平梯度可能达到上千。如果用 uint8_t 存这些值,要么截断要么溢出,信息就丢了。所以 OpenCV 的 Sobel 接口允许你指定输出深度,我们选择了 CV_32FC1,也就是单通道 32 位浮点。

这是一个典型的"算法改变数据类型"的例子。在我们的类型系统里,Grayuint8_t 单通道,Float1float 单通道——虽然通道数相同,但值的类型不同,编译器会在类型层面对你负责,你不可能不小心把一个 Image<Float1> 传给只接受 Image<Gray> 的函数。

dxdy 参数分别表示 x 方向和 y 方向的求导阶数。比如 sobel(img, 1, 0, 3) 是计算 x 方向的一阶导数(水平边缘),sobel(img, 0, 1, 3) 是 y 方向的(垂直边缘)。如果你两个都传 1……嗯,OpenCV 会计算两个方向的混合梯度,但这种用法比较少见。

几何变换

几何变换定义在 geometric.hpp 里,包括 resize、flip 和 rotate 三个操作。

resize:缩放

cpp
template <is_pixel_format F>
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
resize(Image<F> img, int width, int height,
       int interpolation = cv::INTER_LINEAR) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());
    ec = detail::require_positive_dim(width, height);
    if (!ec) return unexpected(ec.error());

    cv::Mat dst;
    cv::resize(img.mat(), dst, cv::Size(width, height), 0, 0, interpolation);
    return Image<F>(std::move(dst));
}

Resize 会分配新的内存——输出图像的尺寸和输入不同,in-place 操作在这里不适用。interpolation 参数让你选择插值方式,默认双线性插值(INTER_LINEAR)在大多数场景下够用,缩放图像时用 INTER_AREA 效果更好,放大图像时用 INTER_CUBICINTER_LANCZOS4 可以获得更锐利的结果。

flip:翻转

cpp
template <is_pixel_format F>
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
flip(Image<F> img, int axis) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::flip(img.mat(), img.mat(), axis);
    return img;
}

Flip 是 in-place 的,因为翻转不改变图像大小和格式,只需要在内存中重新排列像素。axis 参数的含义和 OpenCV 一致:0 是垂直翻转(上下翻转),1 是水平翻转(左右翻转),-1 是两个方向同时翻转。这个函数也是模板的,适用于任何像素格式。

rotate:旋转

cpp
template <is_pixel_format F>
[[nodiscard]] inline expected<Image<F>, AlgorithmError>
rotate(Image<F> img, double angle, bool expand = false) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::Point2f center(img.width() / 2.0f, img.height() / 2.0f);
    auto rot_mat = cv::getRotationMatrix2D(center, angle, 1.0);

    cv::Mat dst;
    if (expand) {
        cv::Rect2f bbox =
            cv::RotatedRect(center, img.mat().size(), angle).boundingRect2f();
        rot_mat.at<double>(0, 2) += bbox.width / 2.0 - center.x;
        rot_mat.at<double>(1, 2) += bbox.height / 2.0 - center.y;
        cv::warpAffine(img.mat(), dst, rot_mat, bbox.size());
    } else {
        cv::warpAffine(img.mat(), dst, rot_mat, img.mat().size());
    }
    return Image<F>(std::move(dst));
}

Rotate 是这里面最复杂的一个几何操作,主要因为 expand 参数。当 expand = false 时,输出图像和输入一样大,旋转后超出边界的部分被裁掉;当 expand = true 时,输出图像的大小会自动调整到能容纳旋转后的完整图像。计算 bounding box 并调整旋转矩阵的偏移量那段代码就是用来处理 expand 模式的——它先算出旋转后的矩形边界,然后把旋转中心平移到新图像的中心。

⚠️ angle 的单位是度数,正值表示逆时针旋转——这和数学里的惯例一致,但和某些图像处理库相反,用的时候注意一下。

阈值化

阈值化定义在 threshold.hpp 里,包含全局阈值和自适应阈值两种。

threshold:全局阈值

cpp
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
threshold(Image<Gray> img, double thresh, double max_val,
          int type = cv::THRESH_BINARY) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::threshold(img.mat(), img.mat(), thresh, max_val, type);
    return img;
}

全局阈值是最简单的二值化方法——大于阈值的像素设为 max_val,小于等于阈值的设为 0。注意这里是 in-place 操作,而且输入输出都是 Image<Gray>。默认的 THRESH_BINARY 就是标准的二值化,你也可以传 THRESH_BINARY_INVTHRESH_TRUNCTHRESH_TOZERO 等等。

adaptive_threshold:自适应阈值

cpp
[[nodiscard]] inline expected<Image<Gray>, AlgorithmError>
adaptive_threshold(Image<Gray> img, int block_size, double C,
                   int method = cv::ADAPTIVE_THRESH_GAUSSIAN_C) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());
    ec = detail::require_odd(block_size);
    if (!ec) return unexpected(ec.error());
    if (block_size < 3) {
        return unexpected(AlgorithmError::InvalidParameter);
    }

    cv::adaptiveThreshold(img.mat(), img.mat(), 255.0, method,
                          cv::THRESH_BINARY, block_size, C);
    return img;
}

自适应阈值比全局阈值更适合光照不均匀的场景——它不是用一个固定阈值对整张图二值化,而是对每个像素根据其周围 block_size x block_size 区域的统计量(均值或高斯加权均值)来计算局部阈值。C 是从局部均值中减去的常数,用来微调敏感度。

这里有双重参数校验:先用 require_odd 确保 block_size 是正奇数,然后额外检查它不能小于 3——OpenCV 的自适应阈值要求 block_size 至少为 3,只传 1 的话虽然满足"奇数"条件但实际不合法。这种嵌套的校验在实际工程中很常见,一个参数可能同时有多个约束条件。

归一化

最后一个是归一化,定义在 normalize.hpp 里:

cpp
[[nodiscard]] inline expected<Image<Float1>, AlgorithmError>
normalize(Image<Gray> img, float min_val = 0.0f, float max_val = 1.0f) {
    auto ec = detail::check_non_empty(img);
    if (!ec) return unexpected(ec.error());

    cv::Mat dst;
    cv::normalize(img.mat(), dst, min_val, max_val, cv::NORM_MINMAX, CV_32FC1);
    return Image<Float1>(std::move(dst));
}

又一个改变类型的操作:输入是 Image<Gray>(uint8_t),输出是 Image<Float1>(float)。归一化把像素值从 [0, 255] 的整数范围线性映射到 [min_val, max_val] 的浮点范围(默认 [0, 1])。为什么这会改变类型?因为归一化后的值几乎总是浮点数——0.5 这样的中间值用整数没法精确表示。而且归一化后的浮点图在机器学习流水线里是标准输入格式,模型通常期望输入在 [0, 1] 或 [-1, 1] 范围内。

和 Sobel 一样,这个类型转换是刻意的——它不是"顺便"改了类型,而是算法的语义就决定了输出应该是浮点的。在类型系统里,GrayFloat1 的转换是显式的,你不可能不经意间就把 uint8 图当 float 图用。

聚合头文件的更新

随着这些新模块的加入,algorithms.hpp 已经包含了所有算法头文件:

cpp
#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> 就能拿到全部功能。

完整工作流:从加载到边缘检测

现在让我们把这一章和上一章学到的所有东西串起来,做一个真实的图像处理流水线。假设我们要从一张彩色照片中检测边缘,标准流程是:加载 → 灰度化 → 去噪 → 边缘检测 → 保存。用我们的 API 和 monadic chain 写出来是这样的:

cpp
#include <cvw/algorithms.hpp>
#include <iostream>

using namespace cvw;

int main() {
    auto result = load<BGR>("input.jpg")
        .and_then([](Image<BGR> bgr) {
            std::cout << "step 1: loaded " << bgr.width() << "x"
                      << bgr.height() << "\n";
            return to_gray(std::move(bgr));
        })
        .and_then([](Image<Gray> gray) {
            std::cout << "step 2: converted to gray\n";
            return gaussian_blur(std::move(gray), 5);
        })
        .and_then([](Image<Gray> blurred) {
            std::cout << "step 3: blurred\n";
            return canny(std::move(blurred), 50, 150);
        })
        .and_then([](Image<Gray> edges) {
            std::cout << "step 4: canny edges detected\n";
            return save(edges, "edges.png");
        });

    if (result) {
        std::cout << "pipeline succeeded!\n";
    } else {
        std::cerr << "pipeline failed, error code: "
                  << static_cast<int>(result.error()) << "\n";
    }
}

这段代码把四步操作串成了一条链。and_then 的妙处在于:如果 load 失败了(文件不存在),后面三步根本不会执行——错误会自动传播到最终的 result。这种"错误短路"让代码既简洁又安全,你不需要在每个步骤之间写 if (!prev) return error; 的样板代码。

让我们再看看不用 monadic chain,而是手动一步步写的情况——这在调试或者需要在中间插入额外逻辑的时候更方便:

cpp
auto src = load<BGR>("input.jpg");
if (!src) { /* 处理错误 */ return; }

auto gray = to_gray(std::move(*src));
if (!gray) { /* 处理错误 */ return; }

// 你可以在这里插入任何中间操作,比如查看灰度图的信息
std::cout << "gray image: " << gray->width() << "x" << gray->height() << "\n";

auto blurred = gaussian_blur(std::move(*gray), 5);
if (!blurred) { /* 处理错误 */ return; }

auto edges = canny(std::move(blurred), 50, 150);
if (!edges) { /* 处理错误 */ return; }

auto saved = save(*edges, "edges.png");

两种写法在功能上是等价的,选择哪种取决于你的场景——简单的线性流程用 chain 更优雅,需要中间插入逻辑的用逐步写法更灵活。

验证:让测试说话

我们来看几个关键的测试用例,它们验证了这些算法的正确行为。

首先是滤波的 in-place 特性——我们检查模糊后的数据指针是否和输入相同,以此确认确实没有额外分配内存:

cpp
TEST(AlgoFilter, GaussianBlur_InPlace_NoExtraAllocation) {
    auto img = make_gray_gradient(32, 32);
    auto* ptr_before = img.data();
    auto result = gaussian_blur(std::move(img), 3);
    ASSERT_TRUE(result.has_value());
    EXPECT_EQ(result->data(), ptr_before);
}

结果和 OpenCV 原生实现的像素级对比同样必不可少:

cpp
TEST(AlgoFilter, GaussianBlur_ResultMatchesOpenCV) {
    auto img = make_gray_gradient(32, 32);
    cv::Mat ref;
    cv::GaussianBlur(img.mat(), ref, cv::Size(5, 5), 1.5);

    auto result = gaussian_blur(img.clone(), 5, 1.5);
    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));
        }
    }
}

参数校验的测试确保非法参数不会漏网:

cpp
TEST(AlgoFilter, GaussianBlur_InvalidKernel_EvenSize) {
    auto img = make_bgr_gradient(10, 10);
    auto result = gaussian_blur(std::move(img), 4);  // 偶数!
    EXPECT_EQ(result.error(), AlgorithmError::InvalidParameter);
}

阈值化的测试特别有意思——我们构造一个精确的梯度图,验证二值化的分界线在正确的位置:

cpp
TEST(AlgoThreshold, Threshold_Binary) {
    auto gray = make_gray_gradient(256, 1);
    auto result = threshold(std::move(gray), 128, 255);
    ASSERT_TRUE(result.has_value());
    // OpenCV 的 THRESH_BINARY 使用严格 > 比较而不是 >=
    // 所以 val=128 不会变成 255,val=129 才会
    for (int c = 0; c < 130; ++c) {
        EXPECT_EQ(result->at(0, c, 0), 0) << "c=" << c;
    }
    for (int c = 130; c < 256; ++c) {
        EXPECT_EQ(result->at(0, c, 0), 255) << "c=" << c;
    }
}

归一化的测试验证了输出值确实落在了 [0, 1] 范围内:

cpp
TEST(AlgoNormalize, GrayToFloat1) {
    auto gray = make_gray_gradient(64, 64);
    auto result = normalize(std::move(gray), 0.0f, 1.0f);
    ASSERT_TRUE(result.has_value());

    float min_v = 1e9f, max_v = -1e9f;
    for (int r = 0; r < 64; ++r) {
        for (int c = 0; c < 64; ++c) {
            float v = result->at(r, c, 0);
            min_v = std::min(min_v, v);
            max_v = std::max(max_v, v);
        }
    }
    EXPECT_NEAR(min_v, 0.0f, 0.01f);
    EXPECT_NEAR(max_v, 1.0f, 0.01f);
}

最后是 monadic chain 的短路测试——我们故意从错误状态开始,验证后续步骤确实不会执行:

cpp
TEST(AlgoChain, AndThenChain_ShortCircuitsOnError) {
    int call_count = 0;

    auto result = expected<Image<BGR>, AlgorithmError>(
                      unexpected(AlgorithmError::LoadFailed))
                      .and_then([&](Image<BGR> bgr) {
                          ++call_count;
                          return to_gray(std::move(bgr));
                      });

    EXPECT_FALSE(result.has_value());
    EXPECT_EQ(call_count, 0);  // lambda 没有被执行
}

Concept 约束的编译期测试也值得一提——我们在运行时验证接受合法类型,而非法类型的拒绝则通过 CMake 的 try_compile 来验证(编译失败即通过测试):

cpp
TEST(AlgoConcepts, MedianBlur_AcceptsGray) {
    auto img = make_gray_gradient(16, 16);
    auto result = median_blur(std::move(img), 3);
    EXPECT_TRUE(result.has_value());
}

TEST(AlgoConcepts, MedianBlur_AcceptsFloat1) {
    Image<Float1> img(16, 16, 0.5f);
    auto result = median_blur(std::move(img), 3);
    EXPECT_TRUE(result.has_value());
}

这两个测试能通过是因为 Gray::channels == 1Float1::channels == 1。如果你在代码里写 median_blur(bgr_img, 3),编译器会在编译阶段就报错——不需要等运行时才发现问题。

小结

到这一章结束,edgecv 的算法层已经有了一个相当完整的形态:颜色转换、I/O、滤波、边缘检测、几何变换、阈值化和归一化,覆盖了图像处理最常见的操作。每个算法都遵循同样的模式——检查输入、校验参数、调用 OpenCV、包装结果——这让整个 API 具有很强的一致性和可预测性。

更有价值的是类型安全的保障:concept 约束把格式不匹配的错误推到了编译期,expected 把运行时错误的处理方式统一了,而 in-place 操作和类型转换都在函数签名里清晰可见——你看到 expected<Image<Gray>, ...> 就知道返回的是灰度图,看到 Image<Float1> 就知道值类型变成了 float,不需要去翻文档猜。

接下来我们会在这个基础上构建更高级的抽象——pipeline 和 operator 重载,让多步算法的组合更加自然。但那都是后话了,先把这一章的内容消化好再说。

Built with VitePress