上一篇把 tinyML 的采集和训练走通了,这篇继续——把训练好的模型塞进 RA8D1 里。
首先推理时不该留 BatchNorm。BN 在推理阶段就退化成一个逐通道的仿射变换,而它前面的卷积也是线性的,两个线性变换合成一个:

作用在卷积的输出通道轴上。
折完立刻拿原模型对拍,把两边的 logits 逐条减:
折叠后 logits 最大误差 1.907e-06 预测一致率 100.00% (无损)
1.9e-6 就是 fp32 的舍入噪声。
训练时输入做了归一化——加速度除 4000,角速度除 500000,不这么做,陀螺 mdps 比加速度 mg 大 125 倍,第一层几乎全被陀螺通道支配。既然 BN 能折,归一化按理也能,因为卷积对输入也是线性的:

把 conv1.weight 的第 ccc 个输入通道整个除以 scs_csc,归一化就消失了,设备端直接喂原始整数。
折叠后是2000 多个参数,8.4 KB。
.tflite 的转换器必须在 TensorFlow 里,必须装 TF。要注意主环境里 numpy 版本,新版 TF 要求更高的 numpy。
单开一个转换专用的 venv,两边用 .npz 传数据:
uv venv --python 3.11 .venv-tfliteuv pip install --python .venv-tflite/bin/python tensorflow-cpu
PyTorch 是 channels-first([6, 300]),Keras/TFLite 是 channels-last([300, 6])。
channels-last 正是数据在设备上到达的方式——IMU 每个时刻吐 6 个值,天然就是 [时刻, 6]。原来 Dataset 里那句 x = x.T 的转置,到这儿自然就消失了,一行 C 都不用写
权重布局跟着换:
PyTorch Conv1d weight [out, in, k] → Keras Conv1D kernel [k, in, out] PyTorch Linear weight [out, in] → Keras Dense kernel [in, out]
翻大车:int8 量化后准确率从 100% 掉到 75%
转 fp32 的 TFLite,对拍 100% 一致,顺得很。接着转 int8,想着归一化都折进权重了,设备端喂原始 mg/mdps 多干净——就按这个建了模型,量化,对拍。
结果:
从 100% 掉到 75%,掉了整整 24 个点。
第一反应是怪量化本身——"int8 精度不够"。但 fp32 明明是无损的,8 位不至于崩成这样。
问题就在前面那个"折叠"上。fp32 下折进权重是无损的,int8 下per-tensor 量化要求整个张量数值范围接近,把陀螺的 50 万和加速度的 4000 塞进同一个张量,加速度直接被量化台阶碾平。
把两个变体都建出来实测:
变体大小与 PyTorch 预测一致数据集准确率| TFLite fp32 | 12.9 KB | 100% | 100% |
| TFLite int8(归一化输入) | 8.5 KB | 100% | 100% |
| int8(原始量纲输入) | 8.5 KB | 75.68% | 75.68% |
所以fp32 和 int8 应该走相反的折叠策略
fp32 路线 → 归一化折进权重 → 设备喂原始值 → 少一步,不会错
int8 路线 → 归一化留在设备端 → 六个通道都在 [-1,1] → per-tensor 量化才公平
量纲的坑填完,导出算子清单,又愣了一下:
CONV_2D MAX_POOL_2D FULLY_CONNECTED MEAN EXPAND_DIMS RESHAPE
我明明写的是一维卷积,清单里一个 CONV_1D 都没有,凭空多出 CONV_2D 和几个 EXPAND_DIMS/RESHAPE。
查了才知道:TFLite 根本没有原生 Conv1D,转换器把它映射成 H=1 的 CONV_2D,前后配 EXPAND_DIMS/RESHAPE 把形状凑出来。这正是我之前犹豫要不要走框架路线时担心的事——转换器会不会悄悄改了我的图。好在这次它处理得干净,数值逐条一致,虚惊一场。
但这一下也把框架路线的问题暴露了:每一次格式转换,都是希望它对了。 这个模型小、算子简单,转换器没出岔子;模型再复杂点.........
( ´_ゝ`)
坑都填完,导出最终产物:imu_net_int8.tflite,8752 字节
然后转换成.c.h就结束了
然后打开e2studio,需要加TFLM相关组件
(我这个工程使用的是github.com/renesas/cpk_examples/tree/main/cpkexp_ekra8x1/mipi_cpkexp_ra8d1_ep,官方给的mipi屏幕的例程)

因为 TFLM 是用 C++ 写的,它的接口(API)也是 C++ 的——而 C++ 代码只能由 C++ 文件来调,所以如果RA工程是C的话,需要转成C++工程
右键项目,选择新建-其他

选择转换成C/C++项目

点击完成

如果这时候编译,就会出现一堆错误,因为这个工程的底座是官方mipi屏幕的例程,而这个例程的BSP 和 HAL 全是 5.3.0,而 TFLM 和 Flatbuffers 是 6.5.0 的。这个工程是 FSP 5.3.0 的底座,被塞进了 FSP 6.5.0 的 TFLM。
本地装了哪些包我查了:
Arm.CMSIS-NN.4.1.0+fsp.5.3.0.pack ← 配 5.3.0 的 Arm.CMSIS-NN.7.0.0+fsp.6.5.0.pack ← 配 6.5.0 的 Google.TFLM-Core-Lib.25.2.0+renesas.0.fsp.6.5.0.pack Google.TFLM-CMSIS-NN-Kernel.25.2.0+renesas.0.fsp.6.5.0.pack Google.TFLM-Ethos-U-Kernel.25.2.0+renesas.0.fsp.6.5.0.pack
TFLM 只有 6.5.0 版本,没有 5.3.0 版本。而 TFLM 25.2 是照 CMSIS-NN 7.x 的 API 写的。在 5.3.0 工程里能勾到的 CMSIS-NN 只有 4.1.0——头文件是能找到了,但 arm_convolve_wrapper_s8 这类函数从 4.x 到 7.x 签名和 context/buffer 约定都改过,接着就会在参数上报一堆新错。从一个错换成二十个错。
所以需要在bsp里更新fsp版本到最新的6.5.0
这个方案风险巨大,我不太清楚fsp版本更替兼容性融合,但是跨三个大版本,mipi屏幕显示有翻车风险
还需要做的就是在这里将C++11改成C++17

这些全做完再编译应该就不会再出错了
编译过了之后,写推理胶水层,把模型跑起来:
#include <new>
#include "imu_net_model.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"
#define WINDOW 300
#define CHANNELS 6
#define CLASSES 3
#define ACCEL_DIV 4000.0f
#define GYRO_DIV 500000.0f
alignas(16) static uint8_t s_arena[40 * 1024];
alignas(8) static uint8_t s_interp_buf[sizeof(tflite::MicroInterpreter)];
/* 导出的 6 个算子 + 顺带几个 shape op,省得漏了报 AllocateTensors 错 */
static tflite::MicroMutableOpResolver<8> s_resolver;
static tflite::MicroInterpreter *sp_interp = nullptr;
static const char *sp_names[CLASSES] = { "still", "shake", "pickup" };
bool imu_infer_init(void)
{
const tflite::Model *model = tflite::GetModel(imu_net_model);
if (model->version() != TFLITE_SCHEMA_VERSION) return false;
s_resolver.AddConv2D();
s_resolver.AddMaxPool2D();
s_resolver.AddFullyConnected();
s_resolver.AddMean();
s_resolver.AddExpandDims();
s_resolver.AddReshape();
s_resolver.AddRelu();
s_resolver.AddSoftmax();
sp_interp = new (s_interp_buf)
tflite::MicroInterpreter(model, s_resolver, s_arena, sizeof(s_arena));
return sp_interp->AllocateTensors() == kTfLiteOk;
}
/* 零点为 0,量化就是除量纲 + 饱和 */
static int8_t sat(float v)
{
v = (v >= 0) ? v + 0.5f : v - 0.5f;
if (v > 127) return 127;
if (v < -128) return -128;
return (int8_t)v;
}
int8_t imu_quant_accel(int32_t mg) { return sat(mg / ACCEL_DIV); }
int8_t imu_quant_gyro (int32_t mdps) { return sat(mdps / GYRO_DIV); }
/* p_input: 已量化好的 [300,6] int8;返回类别下标 */
int32_t imu_infer_run(const int8_t *p_input, int8_t *p_out)
{
int8_t *dst = sp_interp->input(0)->data.int8;
for (int i = 0; i < WINDOW * CHANNELS; i++) dst[i] = p_input[i];
if (sp_interp->Invoke() != kTfLiteOk) return -1;
const int8_t *src = sp_interp->output(0)->data.int8;
int32_t best = 0;
for (int i = 1; i < CLASSES; i++)
if (src[i] > src[best]) best = i;
if (p_out)
for (int i = 0; i < CLASSES; i++) p_out[i] = src[i];
return best;
}
const char *imu_infer_class_name(int32_t c)
{
return (c >= 0 && c < CLASSES) ? sp_names[c] : "?";
}然后写了个测试脚本进行测试
00> [2] capturing 300 samples at 100 Hz (3 s) - move the board now 00> captured 300 samples, overruns 0 00> sample[0]: ax=-47 ay=-43 az=1081 mg, gx=-3251 gy=1992 gz=1236 mdps 00> quantised[0..5]: -1 -1 34 -1 1 0 00> live -> pickup q=[-31,-12,37] 18376 us 00> [2] capturing 300 samples at 100 Hz (3 s) - move the board now 00> captured 300 samples, overruns 0 00> sample[0]: ax=-234 ay=-240 az=1092 mg, gx=18671 gy=22000 gz=10496 mdps 00> quantised[0..5]: -7 -8 35 5 6 3 00> live -> shake q=[-118,108,-9] 18374 us 00> [2] capturing 300 samples at 100 Hz (3 s) - move the board now 00> captured 300 samples, overruns 0 00> sample[0]: ax=-2 ay=28 az=1075 mg, gx=-3335 gy=1946 gz=1290 mdps 00> quantised[0..5]: 0 1 34 -1 0 0 00> live -> still q=[7,0,-12] 18374 us
这是三次不同动作的采集加推理,可以看到,三次均准确识别了,平均耗时18ms
至此,从采集、训练到端侧部署的完整链路全部跑通。
我要赚赏金
