传统 FMCW 雷达识别管线通常先对 ADC 数据执行距离、速度或角度 FFT,再把频谱送入神经网络。这项工作换了一条路线:模型直接接收原始复值 ADC 立方体,用 (2+1)D 复值卷积从时域数据中学习空间与时间特征,同时引入 Monte Carlo Dropout 和 Deep Ensemble 估计预测不确定性。
结果很有工程吸引力:10 类手势识别准确率达到 99.38%,模型只有约 6.75k 参数,在 Jetson Nano 上实现 2.75 ms 端到端时延。与多种依赖 FFT 的管线相比,总时延缩短到原来的约 1/4 至 1/86。
省掉 FFT,不等于省掉特征提取
FFT 管线显式构造距离、多普勒或角度特征。它的优势是物理含义清楚,也方便工程人员检查中间结果;代价则是多级变换、张量搬运和后续网络推理共同占用时间。
直接处理 ADC 数据时,模型必须自己完成类似的工作:
- 从 I/Q 两路输入中保留幅度和相位关系;
- 在不同接收通道或空间维度之间提取相关性;
- 沿 chirp、帧或慢时间轴捕捉手势运动;
- 把这些低层模式压缩成可分类的表示。
因此,这项工作的关键不是简单删除 FFT,而是把固定的频域变换替换成可学习的复值时空算子。复值卷积对输入 x = x_r + jx_i 和卷积核 w = w_r + jw_i 的计算可以拆成四次实值卷积:
y_r = conv(x_r, w_r) - conv(x_i, w_i)
y_i = conv(x_r, w_i) + conv(x_i, w_r)
这种实现方式可以复用现有深度学习框架的实值卷积内核,不必等待专用的复数卷积算子。
为什么选择 (2+1)D 复值卷积
完整 3D 卷积同时覆盖时间和两个空间维度,但参数量与计算量都容易增长。(2+1)D 的思路是把它拆成两个阶段:
- 使用形如
(1, 3, 3)的卷积提取帧内或空间特征; - 使用形如
(3, 1, 1)的卷积沿时间轴聚合运动信息。
这种分解使空间建模和时间建模的职责更清晰,也能在边缘设备上控制参数量与中间张量规模。论文摘要给出的约 6.75k 参数说明,性能并不是靠堆叠大模型换来的。
不过,直接 ADC 学习也改变了系统边界。模型可能对雷达配置更加敏感,包括采样点数、chirp 数量、天线排列、帧率和增益设置。更换硬件或采集参数后,输入分布可能整体移动,不能默认原模型仍然有效。
可以这样实践:最小复值 (2+1)D 网络
下面是一个可运行的 PyTorch 示例。它不是论文模型的逐层复现,而是演示如何用实值 Conv3d 组合复值卷积、共享掩码 Dropout 和 MC Dropout 推理。
示例假设 ADC 张量形状为 [batch, 2, time, height, width],其中第二维的两个通道分别是实部和虚部。接入真实数据时,需要把 time/height/width 替换成项目中的 chirp、接收通道和采样点布局,并重新核对轴顺序。
python -m venv .venv
source .venv/bin/activate
pip install torch
将下面内容保存为 complex_cvnet_demo.py,然后运行 python complex_cvnet_demo.py:
import torch
import torch.nn as nn
import torch.nn.functional as F
class ComplexConv3d(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, padding):
super().__init__()
self.real = nn.Conv3d(
in_channels, out_channels, kernel_size,
padding=padding, bias=False
)
self.imag = nn.Conv3d(
in_channels, out_channels, kernel_size,
padding=padding, bias=False
)
def forward(self, xr, xi):
yr = self.real(xr) - self.imag(xi)
yi = self.real(xi) + self.imag(xr)
return yr, yi
class ComplexBlock2p1D(nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.spatial = ComplexConv3d(
in_channels, out_channels,
kernel_size=(1, 3, 3), padding=(0, 1, 1)
)
self.temporal = ComplexConv3d(
out_channels, out_channels,
kernel_size=(3, 1, 1), padding=(1, 0, 0)
)
def forward(self, xr, xi):
xr, xi = self.spatial(xr, xi)
xr, xi = F.relu(xr), F.relu(xi)
xr, xi = self.temporal(xr, xi)
return F.relu(xr), F.relu(xi)
class SharedComplexDropout(nn.Module):
def __init__(self, p=0.2):
super().__init__()
self.p = p
def forward(self, xr, xi):
if not self.training or self.p == 0:
return xr, xi
mask = F.dropout(torch.ones_like(xr), p=self.p, training=True)
return xr * mask, xi * mask
class TinyComplexCVNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.block1 = ComplexBlock2p1D(1, 4)
self.block2 = ComplexBlock2p1D(4, 8)
self.dropout = SharedComplexDropout(0.2)
self.classifier = nn.Linear(8, num_classes)
def forward(self, adc):
xr = adc[:, 0:1]
xi = adc[:, 1:2]
xr, xi = self.block1(xr, xi)
xr, xi = self.dropout(xr, xi)
xr, xi = self.block2(xr, xi)
magnitude = torch.sqrt(xr.square() + xi.square() + 1e-8)
features = F.adaptive_avg_pool3d(magnitude, 1).flatten(1)
return self.classifier(features)
@torch.no_grad()
def mc_predict(model, adc, samples=30):
model.eval()
model.dropout.train() # 推理时只重新启用随机 Dropout
probabilities = []
for _ in range(samples):
logits = model(adc)
probabilities.append(logits.softmax(dim=-1))
stacked = torch.stack(probabilities)
mean_probability = stacked.mean(dim=0)
predictive_entropy = -(
mean_probability * mean_probability.clamp_min(1e-8).log()
).sum(dim=-1)
return mean_probability, predictive_entropy
def main():
torch.manual_seed(7)
model = TinyComplexCVNet(num_classes=10)
# 示例输入:2 个样本、I/Q 两路、8 个时间片、4x32 空间网格。
adc = torch.randn(2, 2, 8, 4, 32)
probability, uncertainty = mc_predict(model, adc, samples=20)
parameter_count = sum(p.numel() for p in model.parameters())
print("parameters:", parameter_count)
print("predicted classes:", probability.argmax(dim=-1).tolist())
print("predictive entropy:", uncertainty.tolist())
if __name__ == "__main__":
main()
这里给实部和虚部使用同一份 Dropout 掩码,避免随机丢弃破坏两者的对应位置。示例中的分通道 ReLU、网络宽度和池化方式只是便于运行的实现选择,并不代表论文采用了完全相同的结构。
不确定性需要进入业务决策
MC Dropout 会对同一个输入执行多次带随机掩码的前向计算,再统计预测分布。Deep Ensemble 则训练多个独立初始化的模型,并合并它们的输出。二者都增加了推理成本,但能提供单次 softmax 无法表达的模型分歧。
在手势控制系统中,不确定性不应该只显示在实验图表里。可以为预测熵或类别概率设置拒识规则:
predicted_class = probability.argmax(dim=-1)
confidence = probability.max(dim=-1).values
accept = (confidence >= 0.85) & (uncertainty <= 0.60)
for index in range(len(predicted_class)):
if accept[index]:
print("execute gesture:", predicted_class[index].item())
else:
print("reject sample and request another gesture")
阈值不能直接照搬示例数字,应在独立验证集上根据误触发成本、漏识别成本和环境变化确定。若目标是严格维持 2.75 ms 级延迟,还需要核算多次 MC 前向或多个 ensemble 成员带来的额外时间。工程上可以只对低置信度样本启动第二阶段不确定性估计,而不是让所有输入都运行完整 ensemble。
Jetson 上的延迟要按完整链路测量
GPU 推理通常异步执行,只用普通计时器包住 model(adc) 会低估耗时。可以这样测量单次批量为 1 的 CUDA 推理:
import time
import torch
model = model.cuda().eval()
adc = torch.randn(1, 2, 8, 4, 32, device="cuda")
for _ in range(50):
_ = model(adc)
torch.cuda.synchronize()
latencies_ms = []
for _ in range(200):
start = time.perf_counter()
_ = model(adc)
torch.cuda.synchronize()
latencies_ms.append((time.perf_counter() - start) * 1000)
values = torch.tensor(latencies_ms)
print("median ms:", values.median().item())
print("p95 ms:", values.quantile(0.95).item())
复现实验时还要明确“端到端”的范围:是否包括雷达数据搬运、张量重排、归一化、分类后处理和不确定性采样。Jetson 的功耗模式、时钟频率、预热次数、PyTorch 或 TensorRT 版本也会显著影响数字。
落地前的检查清单
这项工作的价值在于证明:对于受控的 FMCW 手势识别任务,原始复值 ADC 数据可以直接驱动一个很小的时空网络,并取得高准确率和低边缘延迟。准备采用类似路线时,应重点检查:
- 保留原始 I/Q 数据,避免在数据加载阶段丢失相位关系;
- 固定并记录采样点、chirp、天线和帧维度的排列;
- 分别报告网络推理延迟与真正的传感器到决策延迟;
- 在不同人员、距离、朝向、背景和雷达设备上测试分布偏移;
- 用验证集校准拒识阈值,而不是把最大 softmax 概率直接当成可信度;
- 比较单模型、MC Dropout 和 Deep Ensemble 的准确率、校准误差、尾延迟与功耗。
跳过 FFT 可以减少固定前处理,但也把更多责任交给了数据、模型和部署验证。真正值得复制的不是单个 2.75 ms 数字,而是从复值输入、轻量时空建模到不确定性决策的完整设计思路。