见图识物:cv::Mat 内存本质、HSV 色彩提取与 GUI 调参窗口

4301 字
22 分钟
见图识物:cv::Mat 内存本质、HSV 色彩提取与 GUI 调参窗口

从这一章开始,我们正式进入计算机视觉的世界。前面学的 C++ 语法和 CMake 配置,在这里都会变成你的生产力工具。
很多新人觉得图像处理很玄学,其实把底层的窗户纸捅破之后非常简单:在计算机眼里,根本没有什么风景、小球或道具,只有一张由长宽坐标和数字组成的二维或三维矩阵。
这一章把图像在内存里的存储结构、怎么提取特定颜色的道具、以及如何在实际调试中动态调参彻底拆开讲透。


一、数字图像的本质#

现实世界的光线通过镜头投射到相机的感光元件(CMOS)上,CMOS 上的每个感光点把光强转换成电压信号,再经过模数转换(ADC),就变成了计算机能读取的离散数字。

图像尺寸:1920 × 1080
意味着横向有 1920 个像素点,纵向有 1080 行,整张图一共有约 207 万个像素格子。
  • 灰度图(单通道):每个像素点只需要一个数字表示明暗程度。通常用一个 uint8_t(0~255),0 代表纯黑,255 代表纯白。
  • 彩色图(三通道):人眼的视网膜对红、绿、蓝三种波长的光最敏感。为了还原真实色彩,每个像素点需要 3 个数字分别记录蓝、绿、红三原色的强度分量。

二、cv::Mat:OpenCV 的核心数据结构#

在 OpenCV 的 C++ 接口中,几乎所有图像操作都是围绕 cv::Mat(Matrix 的缩写)展开的。

2.1 Mat 的内部组成#

一个 cv::Mat 对象在内存里分为两部分:

┌─────────────────────────────────┐
│ cv::Mat 矩阵头 │
│ - 尺寸 (rows=480, cols=640) │
│ - 通道数 (channels=3) │
│ - 数据类型 (CV_8UC3) │
│ - 引用计数器 │
│ - data 指针 ───────────────────┼──┐
└─────────────────────────────────┘ │
│ 指向堆内存
▼
┌────────────────────────────────────────────────────────┐
│ 连续的像素内存块 │
│ [B0, G0, R0] [B1, G1, R1] [B2, G2, R2] ... (共 640×480)│
└────────────────────────────────────────────────────────┘
  1. 矩阵头(Header):存储图像的基本元信息(长、宽、通道数、步长等),体积很小,通常只有几十个字节,分配在栈上。
  2. 像素数据区(Data):存储真正的像素字节流,体积很大(一张 1080P 彩色图约占 6MB 内存),分配在堆上。

2.2 为什么是 BGR 而不是 RGB?#

刚接触 OpenCV 的人经常会遇到颜色反常的问题:把图画出来发现红色的东西全变成了蓝色。

这是因为 OpenCV 默认的色彩通道排列顺序是 B-G-R(蓝-绿-红),而不是日常习惯的 R-G-B。每个像素在内存中挨着的 3 个字节依次是:

  • 第 1 字节:Blue(蓝色分量,0~255)
  • 第 2 字节:Green(绿色分量,0~255)
  • 第 3 字节:Red(红色分量,0~255)

这是早期相机硬件和 Windows DIB 位图格式留下的历史习惯,OpenCV 一直沿用至今。调用标准函数时必须记住这个顺序。

2.3 浅拷贝与深拷贝陷阱#

这是用 C++ 写 OpenCV 最容易写出隐蔽 Bug 的地方。

cv::Mat img1 = cv::imread("ball.jpg");
// 浅拷贝:直接用赋值号
cv::Mat img2 = img1;

上面的代码执行完后,img2 并不会复制一份新的像素内存!它只是把 img1 的矩阵头复制了一份,底下的 data 指针依然指向同一块内存区域。
如果你对 img2 涂抹画线,img1 里的图像也会被一并修改!

如果确实需要克隆一份完全独立、互不干扰的图像副本,必须显式调用 clone():

// 深拷贝:开辟全新内存并完整复制像素
cv::Mat img3 = img1.clone();

2.4 像素访问方式#

虽然大部分时候我们调用现成算子,但在写一些特定逻辑(比如检查某点颜色)时,需要直接读写某个像素。常用的有两种方式:

方式 A:at 方法(带类型检查,写着直观)#

// 获取第 100 行、第 200 列像素的 BGR 分量
// 注意传参顺序是 (y, x),即 (行, 列)
cv::Vec3b pixel = img.at<cv::Vec3b>(100, 200);
uint8_t b = pixel[0]; // 蓝
uint8_t g = pixel[1]; // 绿
uint8_t r = pixel[2]; // 红
// 修改该像素为纯红色
img.at<cv::Vec3b>(100, 200) = cv::Vec3b(0, 0, 255);

方式 B:指针遍历(最高效,处理整张图时使用)#

// 逐行获取裸数据指针,适合写性能敏感的图像遍历算法
for (int row = 0; row < img.rows; row++) {
const uint8_t* ptr = img.ptr<uint8_t>(row);
for (int col = 0; col < img.cols; col++) {
uint8_t b = ptr[col * 3 + 0];
uint8_t g = ptr[col * 3 + 1];
uint8_t r = ptr[col * 3 + 2];
// ...
}
}

三、色彩空间转换:为什么赛场不能用 BGR 抠图?#

在 RC 赛场上,我们要抓取的道具通常有鲜艳的颜色(比如红球、蓝方块、黄圆环)。新手最容易犯的错误是直接根据 BGR 通道的值做判断:

// 典型翻车写法:用 RGB 判定红色道具
if (r > 150 && g < 80 && b < 80) {
// 以为这样就能稳定找到红色
}

这段代码在实验室自己的台灯下测试可能完全正常,但一到比赛现场就会彻底失效。

为什么 BGR 判定非常脆弱?#

因为 RGB 模型的三个通道都与光照强度(Brightness)强耦合:

  • 在强烈射灯直射下,红色道具表面会泛白反光,相机拍到的 RGB 可能会变成 (220, 200, 200),此时 GG 和 BB 很大,上述判断失效;
  • 在阴影遮挡或者背光处,光线很暗,拍到的 RGB 可能会降到 (60, 20, 20),此时 RR 变小,上述判断再次失效。

救场方案:HSV 色彩空间#

为了解决光照变化的问题,在机器视觉中普遍采用 HSV 色彩模型。

H (Hue,色相) : 颜色本身的种类(红、黄、绿、青、蓝、紫),按圆周 0°~360° 排布
S (Saturation,饱和度): 颜色的鲜艳纯度(0=发灰发白,255=极鲜艳纯色)
V (Value,明度) : 光线的明亮程度(0=全黑,255=极亮)
HSV 模型示意:
红色 (0° / 360°)
▲
紫色 │ 黄色
\ │ /
\ │ /
青色 ─────┼───── 绿色
│
│
蓝色

HSV 的关键优势#

它把“颜色到底是什么颜色”(H 分量)与“光照有多亮”(V 分量)解耦开了:

  • 当赛场光线发生明暗变化时,主要是 V 和 S 分量在上下波动;
  • 只要道具本体还是红色的,它的 H(色相) 分量基本保持稳定在一个狭窄区间内。

OpenCV 中 HSV 的数值范围定义#

在数学上 HH 的范围是 0∘∼360∘0^\circ \sim 360^\circ。但在 OpenCV 里,为了让一个像素分量能塞进一个 8 位的 uint8_t(最大值 255)中,OpenCV 把 H 值整体除以了 2:

分量OpenCV 数值范围对应实际物理含义
H (色相)0 ~ 1800/180附近为红色,30附近为黄色,60为绿色,100~120为蓝色
S (饱和度)0 ~ 255越接近 0 越接近灰白色,越接近 255 颜色越浓艳
V (明度)0 ~ 255越接近 0 越暗(纯黑),越接近 255 越亮

在代码中做色彩转换非常简单:

cv::Mat hsv_img;
cv::cvtColor(bgr_img, hsv_img, cv::COLOR_BGR2HSV);

避坑警示:相机的自动曝光与自动白平衡(AWB / AEC)#

这是所有视觉新手在赛场实操时必定踩穿的暗坑:
很多普通 USB 摄像头或工业相机在出厂默认状态下,自动曝光(AEC)和自动白平衡(AWB)是默认开启的!

  • 灾难场景 1:当你把一个鲜艳的红色道具突然拿到镜头前时,画面红光变多,相机会认为环境色温偏暖,自动白平衡机制会强行把色温拉回去,导致道具的 HSV 数值剧烈漂移;
  • 灾难场景 2:车子开到赛场略暗处时,相机自动加长曝光时间提亮画面,原本很浓的颜色变淡发白。你在 A 点调好的阈值,车往前开半米就全部失效。

在做任何颜色阈值分割之前,必须把相机的自动曝光和自动白平衡锁死为固定值:

// 常见 USB 摄像头在 OpenCV 中的锁死命令(具体视驱动支持而定)
cap.set(cv::CAP_PROP_AUTO_EXPOSURE, 0.25); // 关闭自动曝光
cap.set(cv::CAP_PROP_EXPOSURE, -6); // 手动固定曝光值
cap.set(cv::CAP_PROP_AUTO_WB, 0); // 关闭自动白平衡

在后续第九章讲工业相机时,我们会专门讲解如何在厂商驱动层彻底锁死这些参数。


四、二值化与颜色掩膜:cv::inRange#

二值化(Binarization)是指把一张彩色的图像变成非黑即白的单通道图像。

在提取道具时,最核心的算子是 cv::inRange。它的逻辑非常直白:遍历图像的每一个像素,只要它的 (H, S, V) 全部落在我们设定的上下限区间内,就把输出图像对应位置置为 255(白);任何一个分量超出范围,就置为 0(黑)。

cv::Mat mask;
// 设定蓝色的 HSV 范围阈值
cv::Scalar lower_blue(100, 100, 50); // 下限:H在100以上,S大于100,V大于50
cv::Scalar upper_blue(130, 255, 255); // 上限:H在130以下,S/V最大
// 阈值筛选,输出二值掩膜图像 mask
cv::inRange(hsv_img, lower_blue, upper_blue, mask);

输出的 mask 就是一张黑白图片,里面的白色孤岛就是可能符合条件的道具区域,黑色区域全部是被过滤掉的背景。


红色道具的特殊环绕问题#

这是提取红色道具时必须处理的数学细节。

观察上面的色环图,红色恰好位于 0∘0^\circ 附近。这意味着在 OpenCV 的 0~180 体系中:

  • 偏橙红色的数值在 0∼100 \sim 10 之间;
  • 偏紫红色的数值在 170∼180170 \sim 180 之间。

红色的有效范围跨越了 0 点边界,不能用单个 inRange 区间直接覆盖。标准解法是取两次区间,然后做一次按位或运算(OR):

cv::Mat mask1, mask2, red_mask;
// 区间 1:靠近 0 的低红段 (0 ~ 10)
cv::inRange(hsv, cv::Scalar(0, 100, 50), cv::Scalar(10, 255, 255), mask1);
// 区间 2:靠近 180 的高红段 (170 ~ 180)
cv::inRange(hsv, cv::Scalar(170, 100, 50), cv::Scalar(180, 255, 255), mask2);
// 合并两个区间的二值结果
cv::bitwise_or(mask1, mask2, red_mask);

五、图像预处理:滤波降噪与 ROI 裁剪#

二值化出来的黑白图像往往不够干净:摄像头传感器本身带有微小的电气热噪点,反光的地面也可能有些细碎的白色反光杂点。在做后续形状识别前,先做简单的预处理能大幅提升稳定性。

5.1 图像模糊滤波(二选一候选方案)#

根据现场噪点的类型,通常选择以下两种滤波之一:

cv::Mat blurred;
// 方案 A:高斯滤波(适合滤除连续的高斯热噪声,平滑柔化整体边缘)
// 核大小通常为 3x3 或 5x5(必须为奇数)
cv::GaussianBlur(bgr_img, blurred, cv::Size(3, 3), 0);
// 方案 B:中值滤波(计算邻域像素的中位数替代中心点,专门克制画面上的孤立黑白椒盐噪点)
// cv::medianBlur(bgr_img, blurred, 3);

通常的处理流水线顺序是:

原始彩色帧 ──> 滤波模糊 (去除细碎噪点) ──> 转 HSV ──> inRange 二值化

5.2 ROI(感兴趣区域)截取与坐标还原警告#

车上的摄像头拍到的画面,上半部分通常是场馆的天花板、射灯或者远处的裁判席,这些区域不可能有我们想抓取的地面道具。如果让算法处理全图,不仅容易被天花板的红蓝条幅误导,还会白白消耗计算资源。

通过裁剪 ROI,只处理图像下半部分:

// 假设图像大小为 640x480
// 我们只关心画面下方从 y=180 开始到 y=480 的区域
int roi_x = 0;
int roi_y = 180;
int roi_width = 640;
int roi_height = 300;
// 直接通过 Rect 切片截取,这是纯指针操作,零内存拷贝!
cv::Rect roi_rect(roi_x, roi_y, roi_width, roi_height);
cv::Mat roi_img = bgr_img(roi_rect);
// 后续所有的图像处理只针对 roi_img 展开,计算量减少近 40%

致命坐标系陷阱:
在 roi_img 上检测提取出的目标中心点 (uroi,vroi)(u_{roi}, v_{roi}),是相对于该 ROI 局部小图的坐标。
如果要传给第八章的 PnP 解算,必须把 ROI 的左上角原点偏移量加回去,还原为全图物理像素:
ufull=uroi+roi_x,vfull=vroi+roi_yu_{full} = u_{roi} + roi\_x, \quad v_{full} = v_{roi} + roi\_y
否则相机的全图光学中心 (cx,cy)(c_x, c_y) 会与局部点发生错位,导致算出的 3D 空间位置完全失真!


六、搭建实用的 GUI 实时调参窗口#

赛场的光照条件每次都有所不同,晴天、阴天、展馆大灯开启时,最佳的 HSV 阈值都不完全一样。把阈值写死在代码里每次重新编译是不现实的。

在实际开发中,最实用的做法是用 OpenCV 自带的 GUI 接口做一个带滑块的调参窗口,一边看着画面,一边拖拽滑块把道具的轮廓抠干净。

为了解决普通单区间无法调节红色道具的问题,下面的调参工具内置了数值防倒挂保护与按键 ‘r’ 一键切换红色双段跨界模式功能:

hsv_tuner.cpp
#include <opencv2/opencv.hpp>
#include <iostream>
struct HsvThresholds {
int h_min = 0;
int h_max = 180;
int s_min = 50;
int s_max = 255;
int v_min = 50;
int v_max = 255;
} thresholds;
void on_trackbar(int, void*) {}
int main(int argc, char** argv) {
cv::VideoCapture cap(0);
if (!cap.isOpened()) {
std::cerr << "无法打开摄像头!请检查设备连接或权限。" << std::endl;
return -1;
}
// 尝试锁定相机曝光和白平衡(若硬件驱动支持)
cap.set(cv::CAP_PROP_AUTO_EXPOSURE, 0.25);
cap.set(cv::CAP_PROP_AUTO_WB, 0);
const std::string control_win = "HSV Tuner Controls";
const std::string result_win = "Threshold Result";
cv::namedWindow(control_win, cv::WINDOW_AUTOSIZE);
cv::namedWindow(result_win, cv::WINDOW_AUTOSIZE);
cv::createTrackbar("H Min", control_win, &thresholds.h_min, 180, on_trackbar);
cv::createTrackbar("H Max", control_win, &thresholds.h_max, 180, on_trackbar);
cv::createTrackbar("S Min", control_win, &thresholds.s_min, 255, on_trackbar);
cv::createTrackbar("S Max", control_win, &thresholds.s_max, 255, on_trackbar);
cv::createTrackbar("V Min", control_win, &thresholds.v_min, 255, on_trackbar);
cv::createTrackbar("V Max", control_win, &thresholds.v_max, 255, on_trackbar);
std::cout << "调参工具已启动:" << std::endl;
std::cout << " - 按键盘 'r' 键:切换 常规模式 / 红色双段跨界模式" << std::endl;
std::cout << " - 按键盘 's' 键:导出当前 C++ 配置代码" << std::endl;
std::cout << " - 按键盘 'q' 或 ESC:退出程序" << std::endl;
cv::Mat frame, hsv, mask;
bool is_red_mode = false; // 是否开启红色跨 0 点双段模式
while (true) {
cap >> frame;
if (frame.empty()) break;
// 1. 滑动条数值倒挂保护:防止 Min > Max 导致画面意外全黑
if (thresholds.h_min > thresholds.h_max) {
cv::setTrackbarPos("H Max", control_win, thresholds.h_min);
thresholds.h_max = thresholds.h_min;
}
if (thresholds.s_min > thresholds.s_max) {
cv::setTrackbarPos("S Max", control_win, thresholds.s_min);
thresholds.s_max = thresholds.s_min;
}
if (thresholds.v_min > thresholds.v_max) {
cv::setTrackbarPos("V Max", control_win, thresholds.v_min);
thresholds.v_max = thresholds.v_min;
}
// 2. 预处理去噪
cv::GaussianBlur(frame, frame, cv::Size(3, 3), 0);
cv::cvtColor(frame, hsv, cv::COLOR_BGR2HSV);
// 3. 根据模式进行二值化
if (!is_red_mode) {
// 常规单区间模式 (适用于蓝、黄、绿等绝大多数单色道具)
cv::Scalar lower(thresholds.h_min, thresholds.s_min, thresholds.v_min);
cv::Scalar upper(thresholds.h_max, thresholds.s_max, thresholds.v_max);
cv::inRange(hsv, lower, upper, mask);
} else {
// 红色双段跨界模式:[0, H_min] 并上 [H_max, 180]
cv::Mat mask1, mask2;
cv::inRange(hsv, cv::Scalar(0, thresholds.s_min, thresholds.v_min),
cv::Scalar(thresholds.h_min, thresholds.s_max, thresholds.v_max), mask1);
cv::inRange(hsv, cv::Scalar(thresholds.h_max, thresholds.s_min, thresholds.v_min),
cv::Scalar(180, thresholds.s_max, thresholds.v_max), mask2);
cv::bitwise_or(mask1, mask2, mask);
}
// 4. 在原图画面上提示当前模式
std::string mode_str = is_red_mode ? "Mode: RED Dual-Range [0,Hmin] U [Hmax,180]"
: "Mode: Normal [Hmin, Hmax]";
cv::putText(frame, mode_str, cv::Point(20, 30), cv::FONT_HERSHEY_SIMPLEX, 0.6,
is_red_mode ? cv::Scalar(0, 0, 255) : cv::Scalar(0, 255, 0), 2);
cv::putText(frame, "Press 'r' to toggle mode, 's' to export", cv::Point(20, 60),
cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(255, 255, 0), 1);
cv::imshow(control_win, frame);
cv::imshow(result_win, mask);
char key = (char)cv::waitKey(30);
if (key == 'q' || key == 27) {
break;
} else if (key == 'r') {
is_red_mode = !is_red_mode;
std::cout << "[模式切换] " << (is_red_mode ? "已切换至 红色双段模式" : "已切换至 常规单区间模式") << std::endl;
} else if (key == 's') {
std::cout << "\n--- 当前调定参数代码 ---" << std::endl;
if (!is_red_mode) {
std::cout << "cv::Scalar lower(" << thresholds.h_min << ", "
<< thresholds.s_min << ", " << thresholds.v_min << ");" << std::endl;
std::cout << "cv::Scalar upper(" << thresholds.h_max << ", "
<< thresholds.s_max << ", " << thresholds.v_max << ");" << std::endl;
std::cout << "cv::inRange(hsv, lower, upper, mask);" << std::endl;
} else {
std::cout << "// 红色双段提取代码:" << std::endl;
std::cout << "cv::Mat mask1, mask2, red_mask;" << std::endl;
std::cout << "cv::inRange(hsv, cv::Scalar(0, " << thresholds.s_min << ", " << thresholds.v_min << "), "
<< "cv::Scalar(" << thresholds.h_min << ", " << thresholds.s_max << ", " << thresholds.v_max << "), mask1);" << std::endl;
std::cout << "cv::inRange(hsv, cv::Scalar(" << thresholds.h_max << ", " << thresholds.s_min << ", " << thresholds.v_min << "), "
<< "cv::Scalar(180, " << thresholds.s_max << ", " << thresholds.v_max << "), mask2);" << std::endl;
std::cout << "cv::bitwise_or(mask1, mask2, red_mask);" << std::endl;
}
std::cout << "------------------------\n" << std::endl;
}
}
cap.release();
cv::destroyAllWindows();
return 0;
}

对应的 CMakeLists.txt 配置#

将上面的程序放进上一章讲的标准工程结构中,CMakeLists.txt 只需增加一个可执行目标即可:

cmake_minimum_required(VERSION 3.16)
project(color_tuner LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(hsv_tuner hsv_tuner.cpp)
target_include_directories(hsv_tuner PRIVATE ${OpenCV_INCLUDE_DIRS})
target_link_libraries(hsv_tuner PRIVATE ${OpenCV_LIBS})

🎯 本章通关小作业#

请在本地完成以下动手实操:

  1. 编译运行调参程序:
    • 建立目录并保存上面的 hsv_tuner.cpp 与 CMakeLists.txt;
    • 编译生成 hsv_tuner 可执行文件并运行。
  2. 提取真实道具实操:
    • 拿出身边一个具有单一鲜艳颜色的物体(如果测试红色道具,先按键盘 r 键切到红色模式;如果是蓝球/黄块,使用默认常规模式);
    • 在光线正常的环境下,拖动 6 个滑块,观察 Threshold Result 窗口的变化;
    • 目标:让目标物体在结果窗口中呈现完整清晰的白色实心块,而周围的背景、桌面和衣服完全变黑;
    • 按键盘 s 键,将终端打印出的代码记录下来,保留给下一章的轮廓识别使用。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

见图识物:cv::Mat 内存本质、HSV 色彩提取与 GUI 调参窗口
https://firefly-7a0.pages.dev/posts/rc_basic/06_opencv_mat_and_color/
作者
lumivers
发布于
2026-10-08
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
几何特征提取:形态学去噪、轮廓分析与旋转矩形筛选
RC上位机入门 从二值掩膜到物理目标:形态学腐蚀膨胀与开闭运算、findContours 轮廓提取、面积与长宽比几何筛选、cv::RotatedRect 旋转矩形与道具角点提取。
2
工程化构建:现代 CMakeLists 编写与 VSCode 单步断点调试
RC上位机入门 从手敲 g++ 到现代 CMake 模块化构建:CMakeLists.txt 基础语法、头文件与源文件分离、第三方库链接与查找、VS Code 一键编译与 GDB 单步断点调试。
3
从 2D 像素到 3D 现实:小孔成像模型、张氏标定与 solvePnP 解算
RC上位机入门 机器视觉核心分水岭:四大坐标系转换推导、小孔成像数学模型、内参矩阵与畸变系数物理意义、张氏棋盘格标定实操、多边形拟合获取透视角点、鲁棒极角排序法、相机坐标系欧拉角推导与 solvePnP 解算。
4
终端开荒:Ubuntu 基础操作与 VSCode 远程开发
RC上位机入门 专为 Windows 用户设计的 Linux 破冰指南:双系统与 WSL2 抉择、国内换源、高频终端命令、免密 SSH 与 VSCode Remote 远程丝滑编码。
5
软硬件握手:Linux 串口通信、结构体内存打包与校验
RC上位机入门 从上位机视觉算法到下位机电控单片机:Linux 串口设备原理(/dev/ttyUSB* 与 udev 规则)、termios 原始模式配置、二进制协议帧结构设计、#pragma pack(1) 与 memcpy 安全封包、CRC8/和校验以及回环自测实战。
随机文章 随机推荐

评论区

Profile Image of the Author
lumivers
Hello, I'm lumivers.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
182
分类
14
标签
576
总字数
384,899
运行时长
0 天
最后活动
0 天前

目录