Day 5:threshold与inRange阈值分割

一、先理解 threshold():根据像素亮度分割

假设你有一张农田图片:

复制代码
cv::Mat image = cv::imread("images/field.jpg");

先将它转换为灰度图:

复制代码
cv::Mat gray;

cv::cvtColor(
    image,
    gray,
    cv::COLOR_BGR2GRAY
);

这时:

复制代码
原始 BGR 图像
    ↓
cv::cvtColor()
    ↓
单通道灰度图

对于常见的 8 位灰度图,每个像素取值为 0~255。

然后执行:

复制代码
cv::Mat binary;

cv::threshold(
    gray,
    binary,
    128,
    255,
    cv::THRESH_BINARY
);

这里五个参数分别代表:

参数 含义
gray 输入灰度图
binary 输出二值图
128 判断阈值
255 满足条件时的输出值
cv::THRESH_BINARY 二值化方式
threshold 如何判断一个像素?

对于 THRESH_BINARY,规则是:

这里要特别注意:

threshold() 的 THRESH_BINARY 是大于 阈值才输出 255,而不是大于等于。

假设一张灰度图像只有六个像素:

复制代码
30    80    120
128   180   230

阈值为 128 时:

复制代码
0     0     0
0     255   255

因为 128 本身不大于 128。

所以你可以先形成一个直观理解:

THRESH_BINARY 相当于根据单通道数值,把图像分成两部分。

对于灰度图,通常表现为将较亮与较暗的像素分开。

二、理解 inRange():判断像素是否落在指定区间

之前已经使用过:

复制代码
cv::Mat hsv, mask;

cv::cvtColor(
    image,
    hsv,
    cv::COLOR_BGR2HSV
);

cv::inRange(
    hsv,
    cv::Scalar(35, 40, 40),
    cv::Scalar(85, 255, 255),
    mask
);

这里的逻辑与灰度阈值不一样。

它并不是简单判断:

复制代码
像素是否大于某个值?

而是判断:

像素的所有通道数值,是否同时落在指定上下限之间?

对于 HSV 像素 (H,S,V),上述代码对应:

三个条件全部满足才输出 255。

否则输出 0。

例如:

复制代码
像素 A:(60,170,190)

满足全部条件,结果:

复制代码
255

而:

复制代码
像素 B:(60,20,190)

虽然 H 满足,但 S 不满足,结果:

复制代码
0

一个很重要的区别:

  • threshold(..., THRESH_BINARY) 通常是在一个灰度通道上判断是否大于某个值。
  • inRange() 可以在多个通道上同时检查上下限,也可以用于单通道数据。
    因此,你的 HSV 绿色植株分割更适合使用 inRange()。

三、实验:三张农田图像,两种分割方式

这部分是今天真正需要动手完成的内容。

我建议你选择三种不同光照情况的农田图片:

图片 拍摄环境 重点观察
image1 光照均匀 两种方法能否保留绿色植株
image2 阳光强烈、存在高光 灰度阈值是否误选明亮土壤
image3 阴影明显 是否漏掉阴影中的叶片

为了保证实验公平,先保持所有图片的原始尺寸,不使用形态学、面积过滤或其他后处理。

这样你比较的才是两种阈值方法本身,而不是后处理带来的改善。

1. 推荐的项目结构
复制代码
ThresholdDemo/
├── CMakeLists.txt
├── src/
│   └── main.cpp
├── images/
│   ├── image1.jpg
│   ├── image2.jpg
│   └── image3.jpg
├── build/
└── output/
2. 完整 C++ 代码

这份代码会批量读取三张图片,并且自动生成你要求的六张 mask,同时打印每张 mask 保留的像素数及比例。

复制代码
#include <opencv2/opencv.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <filesystem>
#include <iomanip>

namespace fs = std::filesystem;

int main() {

    // 在项目根目录执行程序时使用
    const std::string inputDir = "images";
    const std::string outputDir = "output";

    fs::create_directories(outputDir);

    std::vector<std::string> imageNames = {
        "image1",
        "image2",
        "image3"
    };

    const double grayThreshold = 128.0;

    const cv::Scalar lowerGreen(35, 40, 40);
    const cv::Scalar upperGreen(85, 255, 255);

    for (const auto& name : imageNames) {

        // 1. 读取图片
        fs::path imagePath =
            fs::path(inputDir) / (name + ".jpg");

        cv::Mat image =
            cv::imread(imagePath.string());

        if (image.empty()) {
            std::cerr
                << "Failed to load: "
                << imagePath << '\n';
            continue;
        }

        // 2. 灰度阈值分割
        cv::Mat gray;
        cv::Mat grayMask;

        cv::cvtColor(
            image,
            gray,
            cv::COLOR_BGR2GRAY
        );

        cv::threshold(
            gray,
            grayMask,
            grayThreshold,
            255,
            cv::THRESH_BINARY
        );

        // 3. HSV 绿色阈值分割
        cv::Mat hsv;
        cv::Mat hsvMask;

        cv::cvtColor(
            image,
            hsv,
            cv::COLOR_BGR2HSV
        );

        cv::inRange(
            hsv,
            lowerGreen,
            upperGreen,
            hsvMask
        );

        // 4. 保存两种 mask
        fs::path grayPath =
            fs::path(outputDir) / (name + "_gray_mask.png");

        fs::path hsvPath =
            fs::path(outputDir) / (name + "_hsv_mask.png");

        bool savedGray =
            cv::imwrite(grayPath.string(), grayMask);

        bool savedHSV =
            cv::imwrite(hsvPath.string(), hsvMask);

        if (!savedGray || !savedHSV) {
            std::cerr << "Failed to save masks for "
                      << name << '\n';
        }

        // 5. 统计白色像素
        int grayCount = cv::countNonZero(grayMask);
        int hsvCount = cv::countNonZero(hsvMask);

        double totalPixels =
            static_cast<double>(image.total());

        double grayRatio =
            100.0 * grayCount / totalPixels;

        double hsvRatio =
            100.0 * hsvCount / totalPixels;

        // 6. 输出统计信息
        std::cout << "\nImage: " << name << '\n';

        std::cout
            << "Size: "
            << image.cols << " x "
            << image.rows << '\n';

        std::cout << std::fixed
                  << std::setprecision(2);

        std::cout
            << "Gray foreground: "
            << grayCount
            << " (" << grayRatio << "%)\n";

        std::cout
            << "HSV foreground: "
            << hsvCount
            << " (" << hsvRatio << "%)\n";
    }

    return 0;
}

其中有一个你之前没有深入学过的函数:

复制代码
cv::countNonZero(mask)

它的作用是:

统计单通道图像中非零像素的数量。

因为你生成的是 0/255 二值 mask,所以它统计的就是白色像素的数量。

例如:

复制代码
图像总像素数:100000
白色像素数:25000

那么前景占比:

但这里必须注意:

白色像素比例高,不代表分割准确率高。

例如 HSV 可能把大量杂草也分割出来;灰度阈值可能把明亮土壤识别成前景。所以这个指标只是辅助观察,不是精度指标。

四、结果应该怎么分析?

当你得到六张 mask 后,不建议只说"HSV 看起来更好"。

你需要观察具体出现了哪些差异。

可以按照下面这张表整理实验记录。

条件 灰度阈值应关注的问题 HSV 应关注的问题
光照均匀 裸土与植株灰度是否重叠 绿色叶片是否完整保留
强烈光照 明亮土壤是否误分割 高光、低饱和度叶片是否漏检
阴影明显 阴影植株是否被过滤 低 V 值植株是否被过滤

建议每张图片再统计三种现象:

植株保留情况: 叶片主体是不是被保留下来了?小叶片是否缺失?

背景误分割: 土壤、阴影和其他背景有没有被错误变成白色?

光照鲁棒性: 相同类型的绿色叶片,在亮处和暗处能否都被保留?

如果想更严谨一点,还可以手动挑选若干植株区域和背景区域,统计它们各自被保留的像素比例。

这样就可以把"目测结果"进一步转化为简单的定量分析。

六、再补充两个值得掌握的知识点

1. threshold() 不止一种模式

你今天主要使用:

复制代码
cv::THRESH_BINARY

但 OpenCV 还提供其他阈值模式:

模式 作用
THRESH_BINARY 大于阈值输出最大值,否则 0
THRESH_BINARY_INV 大于阈值输出 0,否则最大值
THRESH_TRUNC 超过阈值的数值截断为阈值
THRESH_TOZERO 小于等于阈值的变为 0
THRESH_TOZERO_INV 大于阈值的变为 0

今天只需要掌握 THRESH_BINARY,并了解反向二值化即可。

另外,OpenCV 还有 Otsu 自动阈值和自适应阈值等方法,之后学习图像分割时可以再深入。

2. HSV 分割也不总比灰度分割好

这点需要建立正确认识。

例如你希望分割的是:

  • 白纸上的黑色文字;
  • 光照均匀背景中的黑色物体;
  • 具有明显灰度差异的结构。

那么灰度阈值可能已经足够,而且更简单。

但是如果目标是:

从棕色土壤中提取绿色植株。

那么颜色信息通常非常重要,HSV 的 H、S、V 分离表示就更容易设置筛选条件。

因此选择哪一种方法,关键是看:

目标与背景的主要差异到底来自亮度,还是颜色。

相关推荐
涛声依旧-底层原理研究所1 小时前
可控执行系统:企业 Agent 从演示到生产的架构重构
人工智能·架构
CQU_JIAKE1 小时前
10.31[A]
算法·深度优先
Data-Miner1 小时前
不本地部署,大模型怎么安全用?
大数据·人工智能·安全
云-缩影1 小时前
实际项目中怎么选排序算法:从面试题到工程落地
java·算法·排序算法
johnsong1 小时前
AI前沿日报 · 2026-10-30 | 重构时刻——当基础设施开始重新洗牌
人工智能·重构
呆呆敲代码的小Y1 小时前
【实战精选面试题】Unity精选面试题详细解答,按模块整理汇总
人工智能·游戏·unity·游戏引擎
小徐不徐说1 小时前
数据库存储基础:理解表结构、主键、事务与 BLOB
开发语言·数据库·c++·qt·程序设计
意图共鸣1 小时前
意图共鸣科技 · 时间胶囊(五)|认知中间层:设计思路与《认知律》
人工智能
Pniubi1 小时前
力扣46全排列(回溯(递归))
算法·leetcode·职场和发展