波形发生识别系统开发日志 —— F401 采集与识别

波形发生识别系统开发日志 —— F401 采集与识别
Kanwuqing这是 F401 这一半的事. 接收 G431 吐过来的波形, 采下来, 认出是什么波, 再画到屏幕上
工程叫 Wave2, 代码都在
Core/Src下
这块板子在干嘛
F401 拿到的是 G431 送过来的模拟信号. 它要干四件事
- 用 ADC 采下来, 每帧 500 点
- 从这 500 点里算出几个特征
- 靠特征判断这是什么波形
- 画到 OLED 上, 屏幕得能同时看到波形形状和识别结果
采集和 G431 是硬件同步的, 两边都用 100 kHz 触发, 所以采样率和信号频率是严格的整数比
1 | 1 kHz 的信号, 100 kHz 采样 |
5 个整周期这个数很舒服, 不整周期的话做频谱要加窗, 现在直接就能用
文件清单
| 文件 | 角色 | 关键内容 |
|---|---|---|
waveid.c |
识别算法全部实现 | 特征提取 + 顺序判定, 不依赖任何 HAL 类型 |
app.c |
业务逻辑 | 采集, 识别, 多数表决, 刷新节流, 按键 |
oled_ui.c |
显示 | 波形绘图, 结果, 特征值 |
adc.c |
ADC1 配置 | 单通道 PA4, T2_TRGO 触发, OVR 中断必须开 |
tim.c |
TIM2 | 纯时基, 840-1, 只出 TRGO |
dma.c |
DMA2 | Stream0 / Channel0, 循环模式 |
usart.c |
USART1 | 921600 8N1, 只发不收 |
main.c |
主入口 | 初始化 + 调用 App_Run |
四个特征
识别算法是这个作品的核心, 展开讲
做完之后需要的是”能区分这 5 种波形的最少信息”. 试了很多候选特征, 最后留下 4 个, 每个都有明确的分工:
| 特征 | 含义 | 谁最大 | 谁最小 |
|---|---|---|---|
down_frac |
下降点数占运动点数的比例 | 锯齿 1.000 | 三角 0.000 |
peak_share |
直方图里最密一桶占整帧的比例 | 方波 0.494 | 三角 0.064 |
flat |
平坦点占 (平坦+运动) 的比例 | 三角 1.000 | 正弦 0.813 |
nlevel |
中段被占用的电平桶数 | 正弦/三角/锯齿 4 | 阶梯 2 |
实测数据(amp=1800, 噪声 ±3):
1 | wave flat down cv mids peak Vpp -> decided |
分离度都很大. 锯齿的 down 是 1.000, 第二名才 0.538,
中间空了一大截. 方波的 peak_share 是 0.494, 第二名 0.160,
差三倍
为什么这些特征能抗幅度变化
这是整个作品最重要的一点
所有阈值都是实测 Vpp 的分数, 没有任何一个绝对电压值
把幅度放大 k 倍, 采样点、Vpp、差分全部同比放大 k 倍. 而比值型特征的分子分母同时乘 k, 直接约掉
1 | down = 下降点数 / (上升点数 + 下降点数) |
分子分母都是”点数”, 跟幅度压根没关系.
flat、peak_share、nlevel
也一样
所以”改幅度识别结果不变”是数学上的必然, 不是调参调出来的
验证一下, 5 种波形 × 5 档幅度 = 25 组:
1 | wave 900 1200 1500 1800 2047 |
全对. 再加 ±120 码值的直流偏置, 5 种波形也全部正确
flat 是怎么算的
1 | /* Core/Src/waveid.c —— WaveId_Extract() */ |
相邻两点差值小于 3% Vpp 就算”没动”, 超过 5% 才算”真的在动”
两个阈值不一样, 中间留了个空隙. 这是故意的, 避免抖动点被反复计入/排除
1 | /* Core/Src/waveid.c —— WaveId_Extract() 中求绝对值 */ |
这里有个小陷阱. buf 是 uint16_t,
相减之后赋给 uint32_t. 如果是负数, 高位全是 1,
所以要靠最高位判断符号, 不能直接转 int 再
abs
采样对齐陷阱
这是整个项目最难的一个坑, 也是最值得讲的一个
一开始用的是一阶导数(相邻差分)判断斜率, 结果还行. 但想更稳一点, 就换成了教科书上的二阶差分, 也就是曲率
1 | /* 最初用的写法 (Core/Src/waveid.c), 已废弃 */ |
结果 4 种平滑波形全部识别失败
查了很久才想明白. 采样率 / 信号频率 = 100, 是整数比. 在这个比例下, 对齐的二阶差分对所有平滑波都恒等于 0
实测正弦的最大残差只有 8 个码值, 相对 Vpp 3600 只占 0.2%. 等于把最有用的信息整体抹掉了
原因是二阶差分测的是”曲率”, 而整数比采样时每个周期的采样点位置完全重复, 相邻三点的曲率被系统性地抵消了
解法是改用 ±2 基线差分
1 | /* Core/Src/waveid.c —— WaveId_Extract(), 就是绕开陷阱的关键 */ |
用 x[k+2] - x[k] 而不是
x[k+2] - 2x[k+1] + x[k]. 前者两个样点间隔 2, 取自同一相位,
能保留形状信息
这个坑我印象最深. 表面上看二阶差分”更精确”, 实际上在这个特定的采样比下反而是最坏的选择
判定链
四个特征怎么组合成结论
1 | /* Core/Src/waveid.c —— WaveId_Decide() */ |
用的是顺序 if, 不是打分或者最近邻
顺序的判断原则是分离度最大的放最前面, 先粗分再细分:
down > 0.80判锯齿, 因为 1.000 对 0.538, 间隔 0.46peak_share > 0.30判方波, 因为 0.494 对 0.160, 差三倍flat > 0.965且中段满, 判三角- 中段空, 判阶梯
- 剩下的兜底判正弦
第 3 条和第 4 条条件是互补的, 都围着 nlevel 转,
所以一定能落到其中一支
用顺序 if 而不是打分还有个好处, 可以解释为什么这么判. 屏幕上显示的
flat、down 数值就是这条链用到的输入,
评委能当场验证推理过程
阈值都定义在一起
1 | /* Core/Src/waveid.c —— 调参常量 */ |
阈值集中放在文件开头, 和 PC 上的 Python 模型共用同一套数值
这样做是为了保证”PC 上测过的代码就是烧进芯片的代码”.
waveid.c 不依赖任何 HAL 类型, 输入只有
uint16_t*, 输出是个纯 C 结构体, 所以能直接在 PC 上用 gcc
编译运行
中段电平数的由来
nlevel 这个特征有点特别, 单独说一下
1 | /* Core/Src/waveid.c —— WaveId_Extract() 第三遍扫描 */ |
归一化这一步是关键, 除以 vpp 之后直方图形状就和绝对幅度无关了
然后只看中间三分之一, 也就是第 6 到 9 桶
1 | /* Core/Src/waveid.c —— WaveId_Extract(), 中段占用统计 */ |
正弦三角是连续曲线, 中段每桶都有点, 计数是 4. 阶梯只有 8 个离散台阶, 中段大量留空, 计数是 2
只看中段是为了避开两端的饱和区. 方波在两端堆了很多点, 如果算上两端会干扰判断
测频
顺便也测了个频率
1 | /* Core/Src/waveid.c —— WaveId_EstimateFreq() */ |
原理是数穿越中值电平的上升沿次数. 500 点覆盖 5 个周期, 所以:
1 | 频率 = 穿越次数 × 采样率 / 帧长 = 5 × 100000 / 500 = 1000 Hz |
hyst 是迟滞, 取 Vpp/8. 如果只用单点比较,
噪声会让信号在中值附近反复穿越, 一次上升沿被数成好几次, 频率直接翻倍.
加了迟滞之后必须跌到 mid - hyst 以下才重新武装
无信号的处理
幅度被调成 0 的时候, 输入是一条直流
1 | /* Core/Src/waveid.c —— WaveId_Classify() */ |
Vpp 小于 8 个码值直接判 FLAT
这个判断很重要. 如果不做, 恒定的直流会因为噪声被判成某种波形, 屏幕上就会显示一个看起来挺正常的假结果
多数表决
单帧识别偶尔会因为噪声抖一下, 所以加了表决
1 | /* Core/Src/app.c —— vote_push() */ |
连续取 5 帧结论做多数表决, 过半一致才更新显示
滑动窗口用的是”每个计数减半”而不是”整套清零”. 这样切换波形时能很快跟上, 不会拖好几帧才变
代价是响应慢约 25 ms. 换来的是屏幕上不闪来闪去, 观感稳得多
显示
屏幕上分了四行::
| 行 | 内容 |
|---|---|
| 1 | 识别结果 + 置信度, 比如 SQUARE [OK] |
| 2 | 实时波形图 |
| 3 | 特征值, 比如 flat0.96 dn0.54 |
| 4 | 实测参数, 比如 3.60V 1000Hz a1800 |
第 3 行是”钩子”. 把算法内部用的中间量直接给评委看, 可信度比只给一个结论高得多
波形怎么画到 128 像素上
1 | /* Core/Src/oled_ui.c —— plot_waveform() */ |
有三个点要注意:
逐列等比抽样. 128 列 < 500 点, 用
idx = x * 500 / 128 的等比映射. 不能简单每 4 个点扔 3 个,
那样方波的窄边沿可能被整段丢掉
相邻列之间要连线. 只画散点的话,
方波那种一列之内从最低跳到最高的陡沿会断线, 看起来像波形坏了. 用
OLED_DrawLine 连起来, 陡沿就成了一条竖线
每次按当前帧的 lo/hi 重新定标. 纵向映射用的是这一帧自己的最大最小值. 好处是无论幅度多大波形都铺满绘图区. 副作用是波形看起来”一样高”, 所以另外用文字标出了真实 Vpp
这个副作用反而是个演示亮点. 幅度改来改去, 屏幕上的波形形状不变、识别结果也不变, 直观印证了幅度不变性
刷新为什么要节流
1 | /* Core/Src/app.c —— 刷新节流 */ |
1 | /* Core/Src/app.c —— app_process_frame() 中的重绘判断 */ |
SSD1306 挂的是软件模拟 I2C, 刷一次全屏 1 KB 要 23.7 ms, 相当于 4 到 5 个采集帧周期
如果每帧都刷, 主循环几乎全耗在等 I2C 上, 而且会诱发 ADC 溢出
所以正常情况下 60 ms 才刷一次, 约 16 fps, 肉眼看已经够流畅
s_ui_dirty 是为了按键反馈. 按键之后必须让用户立刻看到,
不能等下一个 60 ms 周期
踩过的坑
坑一, ADC 溢出后采集永久停止
这个坑最阴, 现象是屏幕突然卡死不动, 按复位才好
1 | /* Core/Src/adc.c —— MspInit 里必须手动加的两行 */ |
F4 上 ADC 的 OVR 标志不清, ADC 就不再发出 DMA 请求, 而且是永久性的
HAL_ADC_Start_DMA() 会打开溢出中断, 但前提是 NVIC
得使能. CubeMX 不会自动生成这两行
这两行是放在 USER CODE 区里的, 因为之前用 CubeMX
重新生成过一次, 把这两行冲掉了, 卡死的 bug 悄悄回来了.
如果哪天它又不见了, 就是这个 bug 又回来了
坑二, TIM2 占用了 PA0
继承过来的工程里 TIM2 被配成了 PWM 输出在 CH1, 结果
HAL_TIM_MspPostInit() 把 PA0 驱动起来了
但 F401 这边 TIM2 只是个纯时基, 只需要出 TRGO 给 ADC, 根本不需要输出引脚
1 | /* Core/Src/tim.c —— MX_TIM2_Init() 末尾的说明 */ |
PA0 这个引脚很要命, 因为最早的方案里 ADC 就打算用 PA0. 而 PA0 同时是 TIM2_CH1, 被 PWM 占着就没法当 ADC 用了
后来 ADC 换到了 PA4, 才算绕开. 但 PA0 被无用驱动这个问题本身也得改掉
坑三, 状态文本被当成命令
G431 的串口是双向复用的, 它一边收命令, 一边往外发状态文本
1 | wave=SQUARE amp=1800 Vpp=3.601 min=0.200 max=3.801 avg=1.648 |
F401 如果去读 USART1, 就会读到这段文本, 然后当成命令解析, 造成莫名其妙的波形跳变
规避办法很简单: 本固件只发不收, 从不读取 USART1. G431 的 TX 也就是 PC4 故意不接
坑四, 遥控线没接
remote_*() 那套代码写得是对的,
按键消抖、长按重复、串口命令都有
但实物上 F401 和 G431 之间只接了两根线, 模拟信号和地. 遥控线没接
1 | F401 G431 |
所以 F401 上的 PA2、PA3 短按发出去的命令没人收, 按下去没反应
现在切波形和调幅度都在 G431 那边操作, 按 G431 的 PB0 切波形
代码没删, 留着是因为补一根线就能用, 一行都不用改
1 | F401.PA9 (TX) ──→ G431.PA10 (RX) |
注意是交叉接, F401 的 TX 对 G431 的 RX
F401 的 PA2 长按是跑自检, 这个是本地操作, 能用
关键参数
| 项目 | 数值 | 出处 |
|---|---|---|
| 帧长 | 500 点 | waveid.h WAVEID_FRAME_LEN |
| 采样率 | 100 kHz | TIM2 ARR=839 |
| 识别频率 | 1000 Hz | 5 周期 / 帧 |
| Vpp 下限 | 8 码值 | WV_MIN_VPP |
| 直方图桶数 | 16 | WV_NBUCKET |
| 中段范围 | 第 6~9 桶 | WV_MID_LO / WV_MID_HI |
| 表决帧数 | 5 | APP_VOTE_FRAMES |
| 刷新周期 | 60 ms | APP_REDRAW_MS |
| 参考电压 | 3300 mV | WAVEID_VREF_MV |
时钟和 ADC:
| 项目 | 数值 |
|---|---|
| 主频 | 84 MHz |
| TIM2 | PSC=0, ARR=839, 得到 100 kHz |
| ADC1 时钟 | PCLK2/4 = 21 MHz |
| 采样时间 | 84 周期 |
| 总转换 | 4.60 µs |
| 触发间隔 | 10 µs, 余量 5.40 µs |
测试结果:
20 次连续测量
1 | SINE 20/20 = 100.0% PASS |
这些数据是 PC 上跑模型得出的, 用的是和固件完全相同的那份 C 代码
