第4章:数据预处理与同步——让传感器们“说同一种语言”
各位同学,欢迎来到第四讲。前面我们聊了传感器选型和硬件接口,今天要解决一个更头疼的问题——数据预处理与同步。
你想想看,一个激光雷达每秒出10万点,IMU每秒跑200帧,摄像头30fps。这些数据到了处理器里,时间戳对不上,坐标系各说各话,还夹杂着噪声和野值。这活儿怎么干?
我当年第一次做多传感器融合项目时,就栽在这个坑里。激光雷达和IMU的时间差没处理好,结果小车在停车场里画起了“8”字舞。嗯,从那以后,我花了大半年时间专门研究数据预处理。今天就把这些经验掰开揉碎了讲给你听。
4.1 时间戳对齐:硬件同步 vs 软件同步
时间对齐,说白了就是让所有传感器知道“现在”到底是哪个时刻。我见过太多团队,传感器数据都采回来了,结果一分析,时间基准差了50毫秒——这在高速移动的场景下,误差能到米级。
4.1.1 硬件同步(硬同步)
硬件同步是最可靠的方式。原理很简单:用一根物理信号线(比如PPS脉冲或Trigger信号)让所有传感器在同一个时刻“咔嚓”一下。
我个人习惯在以下场景必须用硬同步:
- 高速运动场景(车速>60km/h,或机器人移动速度>5m/s)
- 多传感器紧耦合(比如视觉+IMU的VIO系统)
- 需要亚毫秒级精度(比如激光雷达+IMU的SLAM)
典型的硬件同步架构是这样的:
GPS/GNSS接收机 → PPS信号(1Hz) → 主控板(STM32/FPGA)
↓
Trigger信号(10Hz/20Hz) → 激光雷达
↓
Trigger信号(30Hz) → 摄像头
↓
IMU内部时钟同步 → IMU数据
我在一个自动驾驶项目中用过这种方案。GPS的PPS信号精度在纳秒级,通过FPGA分频后,激光雷达和摄像头能在同一个PPS边沿触发。实测下来,时间同步误差小于100微秒。嗯,这个精度对于L4级自动驾驶来说,够用了。
4.1.2 软件同步(软同步)
软同步是退而求其次的方案。说白了,就是每个传感器用自己的时钟打时间戳,然后我们在软件层面做插值对齐。
软同步的典型做法:
- 统一时间基准:所有传感器都同步到同一个系统时钟(比如NTP或PTP协议)
- 时间戳插值:对于不同频率的数据,用线性插值或样条插值对齐到同一个时间点
- 滑动窗口匹配:维护一个时间窗口,找到时间差最小的数据对
举个例子,摄像头30fps(33ms一帧),IMU 200Hz(5ms一帧)。我们要把IMU数据对齐到摄像头的时间戳上:
// 伪代码:IMU数据插值到摄像头时间戳
// cam_ts: 摄像头时间戳(ms)
// imu_data: 相邻两个IMU数据 (imu_ts1, val1) 和 (imu_ts2, val2)
float interpolate_imu(float cam_ts, float imu_ts1, float val1,
float imu_ts2, float val2) {
// 线性插值
float ratio = (cam_ts - imu_ts1) / (imu_ts2 - imu_ts1);
return val1 + ratio * (val2 - val1);
}
你可能会问:软同步精度够吗?说实话,取决于你的场景。对于低速机器人(<1m/s),软同步误差在10ms以内可以接受。但对于高速场景,我建议还是上硬同步。
4.2 空间对齐:坐标系转换
时间对齐完了,接下来是空间对齐。说白了,就是让所有传感器知道“我在哪,你在哪”。
我见过一个团队,激光雷达和摄像头的外参标定差了5厘米,结果融合出来的点云投影到图像上,总是偏半个车身。嗯,这种问题在工程上很常见。
4.2.1 坐标系定义
先搞清楚几个常用坐标系:
| 坐标系 | 原点 | X轴 | Y轴 | Z轴 |
|---|---|---|---|---|
| 世界坐标系 (W) | 任意固定点 | 东/北 | 北/东 | 天 |
| 车体坐标系 (B) | 车辆质心 | 前进方向 | 左侧 | 天 |
| 传感器坐标系 (S) | 传感器光心/中心 | 传感器朝向 | 传感器左侧 | 传感器上方 |
| 像素坐标系 (P) | 图像左上角 | 列方向 | 行方向 | — |
4.2.2 外参标定与坐标转换
空间对齐的核心就是外参标定——求传感器坐标系到车体坐标系的旋转矩阵R和平移向量t。
坐标转换公式很简单:
// 点P从传感器坐标系(S)转换到车体坐标系(B)
// P_B = R * P_S + t
// 齐次坐标形式
// [P_B] = [R t] * [P_S]
// [1 ] [0 1] [1 ]
// 实际代码(Eigen库示例)
Eigen::Vector3d transform_point(const Eigen::Vector3d& pt_sensor,
const Eigen::Matrix3d& R,
const Eigen::Vector3d& t) {
return R * pt_sensor + t;
}
我个人习惯用以下方法做外参标定:
- 激光雷达-摄像头:用棋盘格标定板,提取角点后做PnP求解
- IMU-摄像头:用Kalibr工具包,做多传感器联合标定
- 激光雷达-IMU:用LIO-SAM的初始化方法,做手眼标定
4.3 数据滤波:让数据“干净”起来
传感器数据天生带噪声。激光雷达有测距噪声,IMU有零偏和随机游走,摄像头有像素噪声。滤波的目的,就是把这些噪声去掉,保留真实信号。
我刚开始做滤波时,总想着用最复杂的算法。后来发现,很多时候简单的均值滤波就够用了。嗯,这里要记住一个原则:能用简单方法解决的问题,不要用复杂方法。
4.3.1 均值滤波
均值滤波是最基础的滤波方法。说白了,就是取N个数据的平均值。
// 滑动窗口均值滤波
#define WINDOW_SIZE 5
float mean_filter(float new_data, float buffer[], int* index) {
buffer[*index] = new_data;
*index = (*index + 1) % WINDOW_SIZE;
float sum = 0;
for (int i = 0; i < WINDOW_SIZE; i++) {
sum += buffer[i];
}
return sum / WINDOW_SIZE;
}
适用场景:
- 静态或慢变信号(比如温度、气压)
- 噪声是高斯白噪声
- 对实时性要求不高
缺点也很明显:会引入延迟(窗口越大延迟越大),而且对脉冲噪声(野值)不敏感。
4.3.2 中值滤波
中值滤波对付野值特别好用。原理很简单:把窗口内的数据排序,取中间值。
// 中值滤波(快速实现,适合小窗口)
#define MEDIAN_WINDOW 5
float median_filter(float new_data, float buffer[]) {
// 更新缓冲区(循环队列)
static int idx = 0;
buffer[idx] = new_data;
idx = (idx + 1) % MEDIAN_WINDOW;
// 复制并排序
float temp[MEDIAN_WINDOW];
memcpy(temp, buffer, sizeof(temp));
// 冒泡排序(窗口小,无所谓)
for (int i = 0; i < MEDIAN_WINDOW - 1; i++) {
for (int j = 0; j < MEDIAN_WINDOW - 1 - i; j++) {
if (temp[j] > temp[j+1]) {
float t = temp[j];
temp[j] = temp[j+1];
temp[j+1] = t;
}
}
}
return temp[MEDIAN_WINDOW / 2];
}
我在激光雷达点云预处理中经常用中值滤波。激光雷达偶尔会打出一些“飞点”(比如打到玻璃边缘的反射),中值滤波能干净地去掉这些野值,同时保留边缘信息。
4.3.3 卡尔曼滤波入门
卡尔曼滤波是传感器融合的基石。说实话,我第一次看卡尔曼滤波的公式推导时,也是一头雾水。但后来我发现,工程上你不需要完全理解数学推导,只要会用就行。
卡尔曼滤波的核心思想:用模型预测 + 用观测修正。
以一维卡尔曼滤波为例:
// 一维卡尔曼滤波(用于IMU角度估计)
typedef struct {
float x; // 状态估计值
float P; // 估计误差协方差
float Q; // 过程噪声协方差
float R; // 观测噪声协方差
} Kalman1D;
void kalman_init(Kalman1D* kf, float init_x, float init_P, float Q, float R) {
kf->x = init_x;
kf->P = init_P;
kf->Q = Q;
kf->R = R;
}
float kalman_update(Kalman1D* kf, float measurement) {
// 预测步骤
float x_pred = kf->x; // 状态预测(假设匀速模型,这里简化)
float P_pred = kf->P + kf->Q;
// 更新步骤
float K = P_pred / (P_pred + kf->R); // 卡尔曼增益
kf->x = x_pred + K * (measurement - x_pred);
kf->P = (1 - K) * P_pred;
return kf->x;
}
你可能会问:Q和R怎么调?我个人的经验是:
- Q(过程噪声):反映你对模型的信任程度。Q越大,越相信观测;Q越小,越相信模型预测
- R(观测噪声):反映传感器噪声水平。可以从传感器数据手册里查,或者实际采集一段静态数据算方差
我记得第一次调卡尔曼滤波时,Q和R设得不对,结果滤波后的数据比原始数据还抖。后来我用了“试错法”:先让Q很小、R很大(相信模型),然后慢慢增大Q,直到滤波效果满意为止。
4.4 异常值处理:把“坏数据”揪出来
传感器数据里总会有一些“坏数据”——比如激光雷达打到飞鸟上、摄像头被强光过曝、IMU受到振动冲击。这些异常值如果不处理,会严重污染融合结果。
我处理异常值的思路是三步走:检测 → 标记 → 处理。
4.4.1 异常值检测方法
| 方法 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 3σ准则 | 超出均值±3倍标准差的数据视为异常 | 高斯分布数据 | 对非高斯分布效果差 |
| IQR方法 | 超出Q1-1.5*IQR或Q3+1.5*IQR的数据视为异常 | 非对称分布数据 | 对长尾分布不敏感 |
| MAD方法 | 中位数绝对偏差,鲁棒性更强 | 有大量野值的数据 | 计算量稍大 |
| 基于模型 | 用卡尔曼滤波的残差判断 | 动态系统 | 需要模型准确 |
我个人最常用的是MAD方法,因为它对野值不敏感,而且不需要假设数据分布:
// MAD异常值检测
// 输入:数据数组,长度n
// 输出:异常值索引
#define THRESHOLD_MAD 3.0
int detect_outliers_mad(float data[], int n, int outlier_idx[]) {
// 1. 计算中位数
float temp[n];
memcpy(temp, data, sizeof(float) * n);
// 排序(这里省略排序代码)
float median = temp[n/2];
// 2. 计算绝对偏差
float mad[n];
for (int i = 0; i < n; i++) {
mad[i] = fabs(data[i] - median);
}
// 排序mad数组,取中位数
float mad_median = ...; // 省略排序
// 3. 判断异常值
int count = 0;
for (int i = 0; i < n; i++) {
if (fabs(data[i] - median) > THRESHOLD_MAD * mad_median) {
outlier_idx[count++] = i;
}
}
return count;
}
4.4.2 异常值处理策略
检测到异常值后,怎么处理?我一般用以下策略:
- 直接丢弃:适用于孤立野值(比如激光雷达的飞点)
- 用前值替代:适用于短暂的数据丢失(比如IMU丢包一帧)
- 插值填补:适用于连续多个异常值(比如摄像头被遮挡几帧)
- 降低权重:适用于融合算法中,给异常值分配更小的权重
4.5 总结与工程建议
好了,这一章的内容就到这里。我们来捋一捋核心要点:
- 时间同步:高速场景用硬同步,低速场景用软同步。时钟漂移是隐藏杀手,记得定期校准。
- 空间对齐:外参标定是基础,定期检查机械位移。用齐次坐标统一处理转换。
- 数据滤波:从简单方法开始(均值/中值),不够再用卡尔曼滤波。Q和R的调参靠经验积累。
- 异常值处理:MAD方法最鲁棒。处理策略要谨慎,不要轻易丢弃数据。
下一章我们会讲传感器数据的时间序列分析,包括如何做数据关联和特征提取。到时候我会分享一个我实际项目中用过的“时间窗匹配算法”,保证让你眼前一亮。
各位同学,数据预处理看起来琐碎,但它是整个融合系统的地基。地基没打好,上层算法再牛也白搭。我见过太多团队在算法上花大功夫,结果数据预处理没做好,最后项目延期。嗯,希望你们能少走这些弯路。
有什么问题,欢迎在课后交流。我们下节课见。