AcmeX's Blog

装甲板灯条提取:通道相减、伽玛 LUT 与形态学取舍

为什么用 R−B 通道相减而不是 HSV 阈值,红蓝两色为何一个腐蚀一个膨胀,以及查表法伽玛校正的实现细节。

TABLE OF CONTENTS01 / 01

敌方色灯条二值化

自瞄的第一步是把装甲板两侧的灯条从画面里抠出来。灯条是高亮度的红色或蓝色 LED,看起来是个很简单的颜色分割问题,但赛场光照条件恶劣——顶灯直射、对手灯条反光、相机自动曝光跳变,任何依赖绝对色相的方法都会翻车。

1. 问题定义

  • 输入cv::Mat BGR8,1280 × 1024
  • 输出:单通道二值图,敌方色灯条为白
  • 约束:整个二值化过程必须远快于帧间隔,因为后面还有配对、角点修正、PnP、BA 一整条链

2. 数学原理

不走 HSV,直接在 BGR 上做通道相减:

再对差值图做固定阈值二值化:

通道相减比 HSV 好在哪?灯条是自发光的高亮区域,在 BGR 空间里主色通道会显著压过另一个通道,这个”差值”对整体亮度变化不敏感。而 HSV 的 H 分量在高饱和高亮度区反而不稳定——LED 中心过曝成白色时 H 完全失去意义,但 R−B 依然能给出正值。

3. 工程实现

// vision_detector/src/armor_detector.cpp
void ArmorDetector::color_split(const Mat &img, const string enemy_color, Mat &out_img)
{
    vector<Mat> channels;
    split(img, channels);
    Mat B = channels[0];
    Mat R = channels[2];

    double maxval1 = 255;
    if (detector_params.enemy_color == "red") {
        Mat R_thresh;
        subtract(R, B, R_thresh);
        threshold(R_thresh, out_img, detector_params.red_thresh, maxval1, THRESH_BINARY);
        cv::erode(out_img, out_img, kernel);
    }
    else if (detector_params.enemy_color == "blue") {
        Mat B_thresh;
        subtract(B, R, B_thresh);
        threshold(B_thresh, out_img, detector_params.blue_thresh, maxval1, THRESH_BINARY);
        cv::dilate(out_img, out_img, kernel2);
    }
}

cv::subtract 而不是 R - B:OpenCV 的 subtractCV_8U 做饱和截断,负值自动归零,正好对应上面公式里的 。用运算符重载写成 R - B 也是饱和的,但显式调用更清楚。

红蓝为什么处理不对称

这是代码里最反直觉的一处:红色走 erode,蓝色走 dilate,用的还是两个不同的核。

原因在于两种灯条在相机上的成像特性不一样。红色 LED 在这套相机的 Bayer 滤镜下响应更强,容易过曝晕开,R−B 的高值区比真实灯条要胖一圈,所以腐蚀一次收回来。蓝色相反——蓝通道响应偏弱,远距离时灯条会断成几截,膨胀能把断点接上。

这个不对称不是理论推出来的,是把两种颜色的二值图并排看了一下午调出来的。换一款相机大概率要重调。

伽玛校正走查表

构造函数里预生成 LUT:

ArmorDetector::ArmorDetector(const DetectorParams &dp) : detector_params(dp)
{
    cv::Mat lookUpTable(1, 256, CV_8U);
    uchar* p = lookUpTable.ptr();
    for (int i = 0; i < 256; ++i) {
        p[i] = cv::saturate_cast<uchar>(pow(i / 255.0, 1.0 / gamma) * 255.0);
    }
    lut = lookUpTable;
    // ... lut2 同理,用于数字 ROI 的对比度增强
}

逐像素调 pow() 对 130 万像素来说是不可接受的开销,查表把它降成一次内存访问。这是 OpenCV 里最经典的优化模式之一。

这里有个真实的 bug,写在这里给自己长记性:

cv::Mat lookUpTable2(1, 256, CV_8U);
uchar* p2 = lookUpTable.ptr();      // ← 应该是 lookUpTable2
for (int i = 0; i < 256; ++i) {
    p2[i] = cv::saturate_cast<uchar>(pow(i / 255.0, 1.0 / gamma2) * 255.0);
}
lut2 = lookUpTable2;

p2 指向了第一个表,第二个循环把 gamma2 的结果写进了 lookUpTable,而 lut2 拿到的是一张从未初始化的表。复制粘贴改名漏了一个字符,编译器不会报——两个变量类型完全一样。表现是数字识别的对比度增强时好时坏(取决于未初始化内存的内容),排查了很久才发现问题不在分类器上。

4. 调参经验

参数取值范围最优值调整依据
red_thresh40 – 12075低于 60 会把地板红色反光收进来;高于 90 远距离灯条丢失
blue_thresh40 – 12075与红色同值纯属巧合,两者是分别调的
min_contour_area3 – 205.0面积阈值,滤掉噪点。设太大会丢远距离目标
max_contours20 – 10050轮廓数上限,防止极端光照下 findContours 返回上千个轮廓拖垮后续配对(配对是
max_aspect_ratio2.0 – 4.02.5单个灯条外接矩形的宽高比上限

max_contours 这个保护是被现实教育出来的:有一场比赛场地边上有红色横幅,检测节点直接卡死。配对循环是两两组合,1000 个轮廓就是 50 万次判定。

5. 验证方法

  • 静态图集:存一批不同距离、不同曝光的图,二值化后目视检查灯条是否完整且无粘连
  • 在线调参:通过 Foxglove 动态参数 实时拖阈值,看二值图变化
  • 回归:改完参数跑一遍录制的 bag,统计检测率是否退化

评价指标:灯条召回率、单帧二值化耗时、误检轮廓数。


下一篇是配对:灯条配对的几何判据