UWB非视距信号分类:CNN模型部署与定位融合指南
简介一份基于卷积神经网络的UWB非视距信号分类器源码与部署教程文档面向通信、计算机等相关专业学生与开发者解决UWB室内定位中NLOS信号识别与分类问题。项目已在macOS、Windows10/11上测试运行适合作为毕业设计、课程设计或入门深度学习项目。资源共32个文件以16个CSV数据集、4个Python源码、3个pdparams模型权重、3个ipynb实验教程为主压缩包约157MB涵盖数据划分、模型定义、训练测试全流程。内置FCN、ResNet、CNN三种网络结构的训练好的模型参数并提供激活函数对比和dropout实验便于深入理解CNN调参思路。目前已有43人学习可在此基础上二次开发也可直接用于项目演示与答辩展示。1. 一个只靠测距值做UWB定位的工程99%的误差都来自“测得很准但我不知道它不准”UWB测距在室内定位里标称精度能做到厘米级但这句话有个前提信道得是视距(LOS)。一旦标签和人、货架、金属柜之间隔了一堵墙或者一个纸箱脉冲首径会被吸收、反射径却很强测距值看起来稳定却可能偏了十几厘米甚至半米以上。这个场景下判断“这一帧测距值能不能信”比“这一帧测距值是多少”更重要。基于卷积神经网络的UWB非视距信号分类器就是干这件事的输入UWB接收机解调出的信道冲激响应(CIR)原始信号输出一个0到1之间的概率告诉下游定位算法这一帧是视距还是非视距。这篇博文会从CNN怎么给UWB信号建模讲起把源码目录怎么部署、数据怎么预处理、训练好的模型怎么接进定位系统讲完整适合做室内定位、机器人导航或者UWB芯片应用开发的工程师参考。2. 卷积神经网络是如何在UWB非视距信号分类器里生效的2.1 分类器真正看的不是测距值而是CIR信号的形状UWB接收机在完成测距的同时通常会给出每帧信号的CIR(信道冲激响应)采样序列。CIR反映了电磁波从发射机到接收机之间所有传播路径的幅度、时延和能量分布。视距场景下CIR的特点是首径清晰、幅值高后续多径分量快速衰减非视距场景下首径经过遮挡后幅度明显削弱或者完全淹没在多径里同时后续反射径变多、能量在时延轴上铺得更开。传统做法是手工设计一组特征首径幅值、最大径与首径能量比、信号能量、峰度、平均超量时延。这些特征在部分场景下有效但换一个板材墙体、换一套天线就常常失效。CNN的做法是跳过手工特征设计直接把原始CIR序列当成一维图像来处理。一帧CIR通常有几百到上千个采样点模型会自动学习“什么样的脉冲形状意味着有遮挡”“什么样的反射分布更像NLOS”。这样分类器对具体硬件和环境的适配能力更强也是这个项目把卷积神经网络选为分类器内核的原因。2.2 为什么用一维卷积而不是全连接网络或二分类树CIR是一维序列卷积核天然适合处理局部波形特征。首径是否尖锐、反射从哪个时延点开始堆叠这些都是局部模式一维卷积可以通过滑动窗口提取局部响应。全连接网络则会把相距很远的采样点同等对待忽略信号在时延轴上的局部连续性而且参数数量大很多小数据集上更容易过拟合。这和普通机器学习分类器有本质差别。Logistic回归或支持向量机也能把CIR分两类但它们需要先做特征工程CNN把特征提取和分类合并在同一个网络里。训练时反向传播会同时调整底层卷积核决定提取什么波形和顶层全连接权重决定怎么组合这些波形。整个分类器看起来是一个端到端模块输入原始信号采样值输出NLOS概率中间不需要人工介入。2.3 CNN结构怎么定一维卷积加池化加全连接的最小可用模型下面是这个分类器常见的网络结构我一般会保持三层卷积加两层全连接参数规模控制在几十万级别避免在几千条数据的小样本上过拟合。import torch import torch.nn as nn class CIRClassifier(nn.Module): def __init__(self, input_len512, num_classes1): super().__init__() self.features nn.Sequential( nn.Conv1d(1, 32, kernel_size7, stride2, padding3), nn.BatchNorm1d(32), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), nn.Conv1d(32, 64, kernel_size5, stride2, padding2), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), nn.Conv1d(64, 128, kernel_size3, stride1, padding1), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(1), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128, 64), nn.ReLU(inplaceTrue), nn.Dropout(p0.5), nn.Linear(64, num_classes), ) def forward(self, x): return self.classifier(self.features(x))模型输入形状是(batch, 1, input_len)其中input_len是CIR截取长度代码写的是512个采样点。前两层卷积用stride2搭配padding3/2让序列长度在卷积后减半同时保留边缘波形信息第三层卷积保持长度不变再由AdaptiveAvgPool1d(1)把整个特征图压成一个128维向量。这样无论原始信号长度是多少全连接层之前输出维度都固定部署时不用改网络结构。全连接部分最后只有1个输出节点配合BCEWithLogitsLoss做二分类。注意这里没有在最后一层加Sigmoid推理时再用torch.sigmoid()把输出转成概率这是数值稳定性更好的一种写法。这个结构的要点是核大小从7逐步缩到3初期用大核看重首径和早期多径的整体包络后期用小核细看局部细节池化采用最大值池化因为NLOS特征往往体现为“某个时延点上有尖峰”最大池化对尖峰更敏感。分类器部分的Dropout(0.5)是为了让小样本训练时不依赖某一组神经元换数据集时不容易崩。层核大小输出通道步长输出长度Conv1d_17322256MaxPool_12322128Conv1d_2564264MaxPool_2264232Conv1d_33128132AdaptiveAvgPool全局128-1一个提醒是CIR输入前要做归一化。CNN对输入量级敏感同一套硬件在近距离视距和远距离非视距下CIR幅度能差出几个数量级。常见做法是每帧单独做最大最小归一化把峰值拉平到[0,1]。这不丢失NLOS判别的核心信息因为遮挡主要体现在波形相对形状上而不是绝对幅度。3. 源码部署把训练好的UWB非视距信号分类器快速跑起来3.1 拿到源码包先认目录模型、脚本、数据、权重各归其位这个标题里的源码部署教程文档通常会把工程按下面这种方式组织。看目录先确认入口文件和数据路径不要在“跑起来”之前去逐行读模型代码。uwb_nlos_classifier/ ├── config/ │ └── config.yaml # 训练与推理参数配置 ├── data/ │ ├── raw/ # 原始CIR数据按LOS/NLOS目录分开 │ ├── train.npy # 预处理后的训练特征 │ ├── train_label.npy # 对应的标签 │ ├── test.npy │ └── test_label.npy ├── models/ │ ├── model.py # CNN网络定义 │ └── train.py # 训练入口 ├── inference/ │ └── predict.py # 单帧推理脚本 ├── weights/ │ └── best_model.pth # 训练好的模型权重 ├── utils/ │ └── cir_processing.py # 归一化、截断、滑窗增强 ├── requirements.txt └── README.mdweights/best_model.pth就是标题里说的“训练好的模型”落盘位置。这个文件只保存了state_dict也就是每一层学习到的参数不含网络结构定义。加载时必须先实例化CIRClassifier再调用load_state_dict否则会报key不匹配。如果遇到Missing key(s) in state_dict的报错检查是不是直接把整个模型对象save又load了这个坑在PyTorch项目里很常见。3.2 环境安装只装推理依赖比装全套训练依赖更快如果只做部署不重新训练依赖非常少torch、numpy、pyyaml就够训练好的模型做推理如果还要复现训练和画混淆矩阵再加scikit-learn和matplotlib。建议先用虚拟环境隔离避免跟本机已装的其他学习框架产生依赖冲突。python -m venv venv_uwb source venv_uwb/bin/activate pip install torch numpy pyyaml scikit-learn matplotlib安装完成后先进python验证一下torch和模型能不能加载。检查版本是个好习惯模型用什么版本的PyTorch训练的不一定写进文档如果load_state_dict报错又看不出原因就把torch.save时的_use_new_zipfile_serialization参数问题当成前置排查项。3.3 最小推理闭环加载权重读取一帧CIR输出LOS和NLOS概率部署时最核心的脚本就是下面的推理逻辑。它的输入可以是一帧原始CIR的npy文件输出是一个概率值import numpy as np import torch from models.model import CIRClassifier def load_model(weight_path, devicecpu): model CIRClassifier(input_len512) state torch.load(weight_path, map_locationdevice) model.load_state_dict(state) model.to(device) model.eval() return model def preprocess(cir: np.ndarray, target_len512): # 原始CIR长度不定先截断或补零到固定长度 if len(cir) target_len: cir cir[:target_len] elif len(cir) target_len: cir np.pad(cir, (0, target_len - len(cir)), modeconstant) # 最大最小归一化保留波形相对形状 cir (cir - cir.min()) / (cir.max() - cir.min() 1e-8) return cir.astype(np.float32) def predict(model, cir, devicecpu): x torch.from_numpy(preprocess(cir)).unsqueeze(0).unsqueeze(0) x x.to(device) with torch.no_grad(): logit model(x) prob torch.sigmoid(logit).item() return prob model load_model(weights/best_model.pth) cir np.load(data/raw/test_data.npy) prob predict(model, cir) print(fNLOS probability: {prob:.4f})推理时的几个参数需要说清楚。input_len512必须与训练时一致模型第一层卷积的核大小为7padding3stride2输入长度不匹配不会报错但特征分布会变结果不可信。归一化用每帧的最大最小值而不是全局统计值。全局归一化的数据如果和训练集来自不同信道幅度偏移会直接把分布整体拉偏导致预测全部一边倒。model.eval()在PyTorch里是必调用的。eval()会关掉Dropout和BatchNorm的训练态统计推理时若忘了写这一行同样的权重每次预测结果都可能有微小抖动。torch.no_grad()则是避免构建计算图省内存也提速。概率阈值默认取0.5大于等于0.5判为NLOS小于0.5判为LOS。对定位融合来说可以把阈值调高到0.6或0.7宁可漏判一些非视距也不能把视距帧错杀。阈值怎么选留到文章最后一部分结合定位场景继续讲。4. 全部数据怎么组织从原始信号到训练集的关键步骤4.1 数据原始形态和标签怎么对应标题里强调“全部数据”但这批数据通常不会是以单条文件形式存好的训练集。它更大的可能性是raw目录下按LOS和NLOS两个字文件夹存放的CIR序列文件文件名带着采集环境和测距距离。下面是一段常见的预处理脚本import os import numpy as np from sklearn.model_selection import train_test_split def build_dataset(raw_dirdata/raw, seq_len512, stride256): X, y [], [] for label, category in enumerate([LOS, NLOS]): category_dir os.path.join(raw_dir, category) for fname in os.listdir(category_dir): cir np.load(os.path.join(category_dir, fname)) # 滑窗截取兼顾长度对齐和数据量扩增 for start in range(0, max(len(cir) - seq_len, 1), stride): window cir[start:start seq_len] if len(window) seq_len: window np.pad(window, (0, seq_len - len(window))) X.append((window - window.min()) / (window.max() - window.min() 1e-8)) y.append(label) X np.stack(X).astype(np.float32)[:, np.newaxis, :] y np.array(y, dtypenp.int64) return train_test_split(X, y, test_size0.2, stratifyy, random_state42)这里用滑窗来扩样本一条长的CIR能切出多帧训练样本stride256意味着相邻窗口一半重叠对多径结构相似的波形有轻微增强作用。stratifyy保证划分后训练集和测试集里LOS/NLOS比例一致这是UWB数据里必须做的不然一个几十帧的房间数据全部落在测试集里评测结果就会虚高。4.2 数据泄漏是最容易被忽略的坑同一段信号不能同时进训练集和测试集预处理脚本做得简单就会踩一个隐蔽的问题如果一段CIR被滑窗切成20帧直接把所有帧随机划分到训练和测试那训练集里已经见到了同一信道的几乎全部波形测试结果不能反映真实场景。正确做法是先把连续CIR序列按“段”分组整段序列划到同一侧。也就是说按文件级别划分而不是按窗口级别划分。文件的文件级划分代码比上面的示例多一层逻辑先拿所有文件名做train_test_split再逐个文件滑窗。这个顺序不能颠倒。从数据采集上看训练集里最好覆盖多个距离点、多个不同朝向和几种不同的遮挡物。金属柜对UWB信号的遮挡模式与人体遮挡完全不同只覆盖其中一种的联网模型到现场常常直接失效。数据量不够时优先采集“场景多样性”而不是增加同一场景下的样本数量。4.3 训练超参数的参考值与调整顺序训练脚本里最值得关注的是下面这段循环其他部分数据加载、评估各项目基本类似import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset train_loader DataLoader(TensorDataset(X_train, y_train), batch_size64, shuffleTrue) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) loss_fn nn.CrossEntropyLoss() for epoch in range(50): model.train() total_loss 0.0 for batch_x, batch_y in train_loader: batch_x batch_x.to(device).unsqueeze(1) optimizer.zero_grad() out model(batch_x) loss loss_fn(out, batch_y) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 10 0: print(fEpoch [{epoch1}/50], Loss: {total_loss/len(train_loader):.4f})这里lr1e-3是常用起点配合Adam用没有问题。UWB数据集一般不大500到2000条样本常见batch_size64不算大如果训练集只有几百条要下调到32甚至16。weight_decay1e-4是用来抑制过拟合的小样本训练时这个参数很关键。调参顺序我一般这样排先固定lr1e-3跑50个epoch观察训练集和验证集的loss曲线。如果训练集loss降不动先加大模型容量把第一层输出通道从32提到64如果训练集好但验证集差先加大Dropout到0.6或增加weight_decay到1e-3再考虑做数据增广。UWB信号上的增广不要用图像那种随机裁剪常见做法是对原始CIR做小幅时移平移几个采样点、幅度缩放乘0.9到1.1、叠加高斯白噪声。超参数推荐值调整方向学习率1e-3验证集震荡时降为1e-4batch_size32~64训练集小则取小epoch50配合早停监控验证集lossdropout0.5过拟合增加到0.6/0.7weight_decay1e-4过拟合时升到1e-3kernel_size第一层7输入序列长可加大到11训练到一半如果loss变成nan先查归一化。CIR在做最大最小归一化时如果整段信号全为0max-min是0除以0就会出nan。代码里加了1e-8的目的就在这。5. 把训练好的模型接进真实UWB定位系统的几个关键验证5.1 用独立数据验证模型泛化能力不能只信loss模型训练结束后先用测试集算综合指标。只输出准确率远远不够因为LOS和NLOS类别比例可能不均衡准确率会偏向样本多的那一类。重点看混淆矩阵和每一类的精确率、召回率from sklearn.metrics import classification_report, confusion_matrix def evaluate_model(model, loader, devicecpu): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in loader: logits model(batch_x.to(device).unsqueeze(1)) preds torch.argmax(logits, dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, digits4)) print(confusion_matrix(all_labels, all_preds)) from torch.utils.data import DataLoader, TensorDataset test_loader DataLoader(TensorDataset(X_test, y_test), batch_size64) evaluate_model(model, test_loader)对UWB非视距分类器而言一个高召回率但要关注的指标是“非视距召回率”——所有真实NLOS帧里有多大比例被正确识别。这个值如果低于0.9定位融合那边会持续吞入错误距离值。另一个方向“LOS误判率”也要控制它决定了正常视距定位时是否频繁被旁路。5.2 在定位融合中的输出价值超带宽测距值质量预筛训练好的分类器在实际产线部署时最合理的接入位置是测距值处理流水线的最前端。下游扩展卡尔曼滤波或粒子滤波之前先跑一个快速推理只有概率低于某个阈值才允许当前测距值真正进入滤波器更新。这样做的效果比把分类结果当成一个观测维度更干净因为NLOS概率本身不携带位置信息它只是个质量控制信号。核心接法是简单的一段逻辑prob predict(model, cir, device) if prob 0.6: # 标记本帧为NLOS不进入距离融合记录原始值备用 quality_flag invalid fused_distance last_valid_distance else: fused_distance current_distance这里的阈值不是固定的。如果下游是卡尔曼滤波滤波器本身对短时间异常值有一定的抑制作用阈值可以放松到0.5到0.6之间让更多测距值参与校正如果下游是静止定位的平均滤波对错误测距值几乎没有抵抗能力阈值建议收紧到0.7以上。阈值选取干脆利落的方法是扫一批阈值计算“过滤后定位精度”这个最终指标选定位误差最小的阈值而不是看分类器的F1值毕竟分类器只是中间模块定位精度才是最终业务指标。测试集上建议挑一帧已知LOS和已知NLOS的CIR各一条把推理输出打出来对比。LOS概率应该在0.2以下NLOS概率在0.8以上。如果某个方向差距不大优先检查有没有做model.eval()和归一化方式是否正确。这两个是部署中最频繁出现的入口错误。本文还有配套的精品资源点击获取