v10模型集成+去抖+架构重构: 单线程+背景采集, ControlUpdate单出口电机控制, I2C音频持久fd, 斑马线接近去抖
This commit is contained in:
+213
-45
@@ -4,79 +4,190 @@
|
||||
* @LastEditors: ilikara 3435193369@qq.com
|
||||
* @LastEditTime: 2025-03-13 08:15:10
|
||||
* @FilePath: /2k300_smartcar/src/image_cv.cpp
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
* @Description: 视觉巡线管线 —— HSV 二值化 → 洪泛填充 → 逐行边界搜索 → EMA 滤波
|
||||
*
|
||||
* 处理分辨率: 80×60(line_tracking_width × line_tracking_height)
|
||||
* 这是 2K0300 CPU 上的最优平衡点:再大 Otsu 不稳,再小细节丢失
|
||||
*/
|
||||
#include "image_cv.h"
|
||||
|
||||
cv::Mat raw_frame;
|
||||
cv::Mat grayFrame;
|
||||
cv::Mat binarizedFrame;
|
||||
cv::Mat morphologyExFrame;
|
||||
cv::Mat track;
|
||||
// ============================================================
|
||||
// 全局图像变量
|
||||
// ============================================================
|
||||
|
||||
std::vector<int> left_line; // 左边缘列号数组
|
||||
std::vector<int> right_line; // 右边缘列号数组
|
||||
std::vector<int> mid_line; // 中线列号数组
|
||||
std::vector<double> left_line_filtered; // 中线列号数组
|
||||
std::vector<double> right_line_filtered; // 中线列号数组
|
||||
std::vector<double> mid_line_filtered; // 中线列号数组
|
||||
cv::Mat raw_frame; // 摄像头原始帧 (320×240, BGR)
|
||||
cv::Mat grayFrame; // 灰度图 (未在当前管线中使用,保留)
|
||||
cv::Mat binarizedFrame; // HSV 双通道 Otsu 二值化结果
|
||||
cv::Mat morphologyExFrame; // 形态学开运算后的图像
|
||||
cv::Mat track; // 洪泛填充后的赛道区域蒙版
|
||||
|
||||
int line_tracking_width;
|
||||
int line_tracking_height;
|
||||
// ============================================================
|
||||
// 边界线数组 — 左/右边缘和中线的逐行列坐标
|
||||
//
|
||||
// 坐标系: row=0 = 图像顶部(远处), row=59 = 底部(近处)
|
||||
// 列坐标范围: 0 ~ line_tracking_width-1 (0~79)
|
||||
// 未检测到边界 = -1
|
||||
// ============================================================
|
||||
std::vector<int> left_line; // 左边缘列号 (逐行)
|
||||
std::vector<int> right_line; // 右边缘列号 (逐行)
|
||||
std::vector<int> mid_line; // 中线列号 = (left+right)/2
|
||||
std::vector<double> left_line_filtered; // 左边缘 EMA 滤波结果
|
||||
std::vector<double> right_line_filtered; // 右边缘 EMA 滤波结果
|
||||
std::vector<double> mid_line_filtered; // 中线滤波结果
|
||||
|
||||
int line_tracking_width; // 处理宽度 = 80
|
||||
int line_tracking_height; // 处理高度 = 60
|
||||
|
||||
// ============================================================
|
||||
// image_binerize — HSV 双通道 Otsu 二值化
|
||||
//
|
||||
// 输入: BGR 彩色帧 (80×60)
|
||||
// 输出: 二值图,赛道区域 = 白色(255),边界/背景 = 黑色(0)
|
||||
//
|
||||
// 原理:
|
||||
// 蓝底赛道 → 蓝色区域饱和度高(S通道高),色调集中(H通道100~130)
|
||||
// 灰度路面 → 饱和度低,色调分散
|
||||
//
|
||||
// 1. BGR → HSV 色彩空间转换
|
||||
// 2. H 通道 Otsu 阈值 → 分离蓝色与非蓝色
|
||||
// 3. S 通道 Otsu 阈值 → 分离高饱和(蓝)与低饱和(灰)
|
||||
// 4. 两通道做 bitwise_or 取并集 (THRESH_BINARY_INV 确保赛道=255)
|
||||
// 即只要 H 或 S 任一判定为赛道,就标记为赛道区域
|
||||
//
|
||||
// THRESH_BINARY_INV + THRESH_OTSU:
|
||||
// Otsu 自动计算最优阈值 T
|
||||
// pixel > T → 0 (黑色), pixel ≤ T → 255 (白色)
|
||||
// 赛道(蓝色/高饱和)通常偏向一侧,Otsu 将其归到低值区间
|
||||
// INV 反转后赛道 = 白色(前景, 255), 背景 = 黑色(0)
|
||||
// ============================================================
|
||||
cv::Mat image_binerize(cv::Mat &frame)
|
||||
{
|
||||
cv::Mat output;
|
||||
cv::Mat binarizedFrame;
|
||||
cv::Mat hsvImage;
|
||||
|
||||
// 1. BGR → HSV:分离色调(H)、饱和度(S)、明度(V) 三个通道
|
||||
cv::cvtColor(frame, hsvImage, cv::COLOR_BGR2HSV);
|
||||
|
||||
std::vector<cv::Mat> hsvChannels;
|
||||
cv::split(hsvImage, hsvChannels);
|
||||
// hsvChannels[0] = H (色调, 0~180)
|
||||
// hsvChannels[1] = S (饱和度, 0~255)
|
||||
// hsvChannels[2] = V (明度, 0~255)
|
||||
|
||||
cv::threshold(hsvChannels[0], binarizedFrame, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
|
||||
cv::threshold(hsvChannels[1], output, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
|
||||
// 2. H 通道 Otsu 二值化:区分蓝色与非蓝色
|
||||
cv::threshold(hsvChannels[0], binarizedFrame,
|
||||
0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
|
||||
|
||||
// 3. S 通道 Otsu 二值化:区分高饱和(蓝色赛道)与低饱和(灰色路面)
|
||||
cv::threshold(hsvChannels[1], output,
|
||||
0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU);
|
||||
|
||||
// 4. 双通道融合:任一通道认为"非赛道"就排除
|
||||
// bitwise_or: 两幅图同为赛道(255)才保留,否则变为背景(0)
|
||||
cv::bitwise_or(output, binarizedFrame, output);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// find_road — 形态学去噪 + 洪泛填充提取连通赛道区域
|
||||
//
|
||||
// 输入: image_binerize 的输出 (赛道=255, 背景=0)
|
||||
// 输出: 仅保留与底边中点连通的赛道区域蒙版
|
||||
//
|
||||
// 处理步骤:
|
||||
// 1. 形态学开运算 (MORPH_OPEN): 先腐蚀后膨胀,消除孤立噪点
|
||||
// 核大小 2×2 十字形 — 极小核,避免吞没细弯
|
||||
// 2. 在底边中心上方偏移处放置种子点
|
||||
// 3. 洪泛填充 (floodFill):
|
||||
// - 从种子点出发,填充容差范围内的连通区域
|
||||
// - 填充值 = 128 (灰色),区分于原始白色(255)
|
||||
// - loDiff/upDiff = 20: 像素值在 [108, 148] 范围内的像素被填充
|
||||
// - 邻域 8 连通
|
||||
// 4. 将蒙版 mask 的 ROI 区域复制到输出图像
|
||||
// - 赛道内部 = 128 (非零),外部 = 0
|
||||
//
|
||||
// 为什么要洪泛填充?
|
||||
// 二值化后可能有多块白色区域(赛道 + 赛道外的反光/杂物)
|
||||
// 洪泛填充确保只处理"从车底到前方连通的"那块区域
|
||||
// 排除远处/边缘的伪赛道碎片
|
||||
// ============================================================
|
||||
cv::Mat find_road(cv::Mat &frame)
|
||||
{
|
||||
// 1. 形态学开运算去噪
|
||||
// MORPH_CROSS: 十字形结构元素,2×2
|
||||
// MORPH_OPEN: 先腐蚀(去除小白点) 再膨胀(恢复区域尺寸)
|
||||
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_CROSS, cv::Size(2, 2));
|
||||
cv::morphologyEx(binarizedFrame, morphologyExFrame, cv::MORPH_OPEN, kernel);
|
||||
|
||||
// 2. 创建洪泛填充蒙版
|
||||
// 尺寸比原图各边大 2 像素 (floodFill 要求)
|
||||
cv::Mat mask = cv::Mat::zeros(line_tracking_height + 2, line_tracking_width + 2, CV_8UC1);
|
||||
|
||||
// 3. 种子点位置
|
||||
// X = 图像水平中心 (line_tracking_width/2)
|
||||
// Y = 距底部 10 行 (line_tracking_height-10)
|
||||
// 假设车在赛道中央附近,底部中心一定是赛道上
|
||||
cv::Point seedPoint(line_tracking_width / 2, line_tracking_height - 10);
|
||||
|
||||
// 4. 在种子点位置画一个白色实心圆,确保种子点落在赛道区域内
|
||||
// 避免因二值化偶尔在种子位置为黑色导致洪泛失败
|
||||
cv::circle(morphologyExFrame, seedPoint, 10, 255, -1);
|
||||
|
||||
cv::Scalar newVal(128);
|
||||
// 5. 洪泛填充参数
|
||||
cv::Scalar newVal(128); // 填充值 = 128 (标记为赛道内部)
|
||||
cv::Scalar loDiff = cv::Scalar(20); // 下界: 当前像素值 - 20 = 108
|
||||
cv::Scalar upDiff = cv::Scalar(20); // 上界: 当前像素值 + 20 = 148
|
||||
|
||||
cv::Scalar loDiff = cv::Scalar(20);
|
||||
cv::Scalar upDiff = cv::Scalar(20);
|
||||
|
||||
cv::floodFill(morphologyExFrame, mask, seedPoint, newVal, 0, loDiff, upDiff, 8);
|
||||
// 6. 执行洪泛填充
|
||||
// 从种子点向 8 邻域扩散,填充像素值在 [108, 148] 之间的所有连通像素
|
||||
// 填充结果直接写入 morphologyExFrame(in-place)
|
||||
// 蒙版 mask 记录被填充的像素位置
|
||||
cv::floodFill(morphologyExFrame, mask, seedPoint, newVal,
|
||||
nullptr, loDiff, upDiff, 8);
|
||||
|
||||
// 7. 从蒙版提取赛道区域
|
||||
// mask 的外扩边框(±1) 用于 floodFill 的内部计算,实际区域在(1,1)起
|
||||
// ROI 裁掉边框后即为赛道主体蒙版
|
||||
cv::Mat outputImage = cv::Mat::zeros(line_tracking_width, line_tracking_height, CV_8UC1);
|
||||
|
||||
mask(cv::Rect(1, 1, line_tracking_width, line_tracking_height)).copyTo(outputImage);
|
||||
|
||||
return outputImage;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// image_main — 视觉巡线主函数,每帧调用一次
|
||||
//
|
||||
// 完整处理流水线:
|
||||
// raw_frame (320×240) → resize (80×60) → HSV二值化 → 形态学+洪泛
|
||||
// → 逐行最长连续段搜索 → 中线计算 → EMA 自底向上滤波 →
|
||||
// 输出 left_line[60], right_line[60], mid_line[60]
|
||||
//
|
||||
// 注意:坐标原点在左上角
|
||||
// - row=0 = 图像顶部(远处)
|
||||
// - row=59 = 图像底部(近处,车前方)
|
||||
// - 滤波从底部(row=59)往顶部(row=0)递推
|
||||
// ============================================================
|
||||
void image_main()
|
||||
{
|
||||
cv::Mat resizedFrame;
|
||||
|
||||
cv::resize(raw_frame, resizedFrame, cv::Size(line_tracking_width, line_tracking_height));
|
||||
// ── 1. 降采样 ──────────────────────────────────────
|
||||
// 320×240 → 80×60,大幅缩减计算量
|
||||
cv::resize(raw_frame, resizedFrame,
|
||||
cv::Size(line_tracking_width, line_tracking_height));
|
||||
|
||||
// ── 2. HSV 双通道 Otsu 二值化 ─────────────────────
|
||||
// 赛道区域 = 白色(255),背景/边界 = 黑色(0)
|
||||
binarizedFrame = image_binerize(resizedFrame);
|
||||
|
||||
// ── 3. 洪泛填充提取赛道主体 ────────────────────────
|
||||
// 只保留与车底中点连通的赛道区域,排除伪赛道碎片
|
||||
// 输出: track = 赛道内部(128) / 赛道外部(0)
|
||||
track = find_road(binarizedFrame);
|
||||
|
||||
// ── 4. 初始化边界线数组 ────────────────────────────
|
||||
left_line.clear();
|
||||
right_line.clear();
|
||||
mid_line.clear();
|
||||
@@ -91,20 +202,33 @@ void image_main()
|
||||
right_line_filtered.resize(line_tracking_height, -1);
|
||||
mid_line_filtered.resize(line_tracking_height, -1);
|
||||
|
||||
uchar(*IMG)[line_tracking_width] = reinterpret_cast<uchar(*)[line_tracking_width]>(track.data);
|
||||
// ── 5. 逐行最长连续段搜索 ──────────────────────────
|
||||
//
|
||||
// 将 track 的 uchar* 数据解释为二维数组 IMG[row][col]
|
||||
// track 中 赛道内部 = 128(非零), 外部 = 0
|
||||
// 对每一行: 找到最长的连续非零段
|
||||
// → 段的起点 = left_line[row]
|
||||
// → 段的终点 = right_line[row]
|
||||
// → 无赛道段的行 = -1
|
||||
//
|
||||
// 这在低分辨率下比八邻域边界跟踪更高效:
|
||||
// 80×60 只有 4800 像素,逐行扫描 O(W×H) 已足够快
|
||||
uchar(*IMG)[line_tracking_width] =
|
||||
reinterpret_cast<uchar(*)[line_tracking_width]>(track.data);
|
||||
|
||||
for (int i = 0; i < line_tracking_height; ++i)
|
||||
for (int i = 0; i < line_tracking_height; ++i) // i = row
|
||||
{
|
||||
int max_start = -1;
|
||||
int max_end = -1;
|
||||
int current_start = -1;
|
||||
int current_length = 0;
|
||||
int max_length = 0;
|
||||
int max_start = -1; // 最长连续段的起始列
|
||||
int max_end = -1; // 最长连续段的结束列
|
||||
int current_start = -1; // 当前扫描中的段起始列
|
||||
int current_length = 0; // 当前扫描中的段长度
|
||||
int max_length = 0; // 已记录的最长段长度
|
||||
|
||||
for (int j = 0; j < line_tracking_width; ++j)
|
||||
for (int j = 0; j < line_tracking_width; ++j) // j = col
|
||||
{
|
||||
if (IMG[i][j])
|
||||
if (IMG[i][j]) // 非零 = 赛道内部像素
|
||||
{
|
||||
// 赛道段的第一个像素: 记录起点
|
||||
if (current_length == 0)
|
||||
{
|
||||
current_start = j;
|
||||
@@ -112,8 +236,11 @@ void image_main()
|
||||
}
|
||||
else
|
||||
{
|
||||
// 赛道段延续: 长度+1
|
||||
current_length++;
|
||||
}
|
||||
|
||||
// 更新最长记录 (≥ 而非 >, 取最后一个最长段,靠右)
|
||||
if (current_length >= max_length)
|
||||
{
|
||||
max_length = current_length;
|
||||
@@ -121,57 +248,98 @@ void image_main()
|
||||
max_end = j;
|
||||
}
|
||||
}
|
||||
else
|
||||
else // 零值 = 非赛道
|
||||
{
|
||||
// 赛道段结束: 重置
|
||||
current_length = 0;
|
||||
current_start = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录该行的左右边界
|
||||
if (max_length > 0)
|
||||
{
|
||||
left_line[i] = max_start;
|
||||
right_line[i] = max_end;
|
||||
left_line[i] = max_start; // 最长白段的左端点
|
||||
right_line[i] = max_end; // 最长白段的右端点
|
||||
}
|
||||
else
|
||||
{
|
||||
left_line[i] = -1;
|
||||
// 该行无赛道 → 后续由中线补全逻辑处理
|
||||
left_line[i] = -1;
|
||||
right_line[i] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
double a = 0.4;
|
||||
// ── 6. 中线计算 + 丢线补全 → 自底向上 EMA 滤波 ─────
|
||||
//
|
||||
// 核心逻辑(从底部 row=59 往上到 row=10):
|
||||
//
|
||||
// 6a. 如果当前行左右边界均有效 → 中线 = (left + right) / 2
|
||||
//
|
||||
// 6b. 如果当前行丢线(左右均无效):
|
||||
// 用下一行(row+1)的中线补全:
|
||||
// → 中线下半区: 虚拟右边界在右边缘,左边界 = 下行中线
|
||||
// → 中线上半区: 虚拟左边界在左边缘,右边界 = 下行中线
|
||||
// 自动适应赛道偏左还是偏右的情况
|
||||
//
|
||||
// 6c. EMA 滤波 (指数移动平均):
|
||||
// row 本身的值权重 = a (0.4), 下行滤波值权重 = 1-a (0.6)
|
||||
// 从底部往顶部递推: 近处(底部)值稳定,远处(顶部)靠递推外推
|
||||
// a=0.4 → 近处值占主导,但保留过去趋势的惯性
|
||||
//
|
||||
double a = 0.4; // EMA 系数: 平衡当前测量与历史递推
|
||||
|
||||
for (int row = line_tracking_height - 1; row >= 10; --row)
|
||||
{
|
||||
// ── 6b. 丢线补全 ──────────────────────────────
|
||||
if (left_line[row] == -1 && right_line[row] == -1)
|
||||
{
|
||||
// 当前行完全丢线: 用下行(row+1)的中线来虚拟补线
|
||||
mid_line[row] = mid_line[row + 1];
|
||||
|
||||
if (mid_line[row] > line_tracking_width / 2)
|
||||
{
|
||||
// 中线偏右 → 实际赛道在图像右半区
|
||||
// 虚拟右边界 = 图像右边缘, 左边界 = 下行中线位置
|
||||
right_line[row] = line_tracking_width - 1;
|
||||
left_line[row] = mid_line[row + 1];
|
||||
left_line[row] = mid_line[row + 1];
|
||||
}
|
||||
else
|
||||
{
|
||||
left_line[row] = 0;
|
||||
// 中线偏左 → 实际赛道在图像左半区
|
||||
// 虚拟左边界 = 图像左边缘, 右边界 = 下行中线位置
|
||||
left_line[row] = 0;
|
||||
right_line[row] = mid_line[row + 1];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 正常行: 中线 = 左右边界中点
|
||||
mid_line[row] = (left_line[row] + right_line[row]) / 2;
|
||||
}
|
||||
|
||||
// ── 6c. EMA 滤波 (自底向上) ────────────────────
|
||||
if (row == line_tracking_height - 1)
|
||||
{
|
||||
left_line_filtered[row] = left_line[row];
|
||||
// 最底行(最近处): 无下行参考,直接使用原始值
|
||||
left_line_filtered[row] = left_line[row];
|
||||
right_line_filtered[row] = right_line[row];
|
||||
mid_line_filtered[row] = mid_line[row];
|
||||
mid_line_filtered[row] = mid_line[row];
|
||||
}
|
||||
else
|
||||
{
|
||||
left_line_filtered[row] = a * left_line[row] + (1 - a) * left_line_filtered[row + 1];
|
||||
right_line_filtered[row] = a * right_line[row] + (1 - a) * right_line_filtered[row + 1];
|
||||
// mid_line_filtered[row] = a * mid_line[row] + (1 - a) * mid_line_filtered[row + 1];
|
||||
// 公式: filtered[row] = a * raw[row] + (1-a) * filtered[row+1]
|
||||
// a=0.4: 40% 当前行实测值 + 60% 下行滤波值的递推
|
||||
// 效果: 近处值稳定,越往远处越靠递推,杜绝抖动的概率传播
|
||||
left_line_filtered[row] = a * left_line[row]
|
||||
+ (1 - a) * left_line_filtered[row + 1];
|
||||
right_line_filtered[row] = a * right_line[row]
|
||||
+ (1 - a) * right_line_filtered[row + 1];
|
||||
|
||||
// 中线的滤波值为左右滤波边界的均值(不是对原始中线做 EMA)
|
||||
// 即: 先对左右边界各自滤波, 再求平均 → 减少中线突跳
|
||||
// 原注释行: mid_line_filtered[row] = a*mid_line[row] + (1-a)*mid_line_filtered[row+1]
|
||||
mid_line_filtered[row] = (left_line_filtered[row] + right_line_filtered[row]) / 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user