1. 项目缘起当ESP32-CAM遇上货币识别最近在捣鼓一个挺有意思的玩意儿用ESP32-CAM这块小小的开发板结合边缘AI技术来实现一个离线、低成本的货币识别系统。你可能觉得现在手机App扫一扫就能查汇率这玩意儿有啥用但实际场景里还真有不少地方需要这种“离线、嵌入式”的识别能力。比如一些自动售货机、自助兑换机或者是一些特定场所的财务盘点设备它们需要快速、准确地识别不同面额的纸币或硬币但又不可能、也不方便时刻保持联网。再比如一些面向视障人士的辅助设备如果能集成一个本地化的货币识别模块就能在不依赖网络、不泄露隐私的情况下帮助他们独立完成日常支付。这就是边缘AI的魅力所在——把智能计算从云端“拉”到设备端让设备自己就能“看懂”世界。ESP32-CAM是个绝佳的选择。它集成了ESP32这颗强大的Wi-Fi/蓝牙双模芯片以及一颗OV2640摄像头模组体积小巧、功耗低最关键的是价格极其亲民。而“Currency Recognition with Edge AI”这个标题就点明了项目的核心在资源受限的嵌入式设备上部署一个轻量级的神经网络模型让它能实时“看见”并“理解”眼前的货币。这不仅仅是跑通一个Demo更是对嵌入式AI应用落地的一次深度探索涉及到模型选择、裁剪、量化、部署、优化等一系列实战环节。2. 核心挑战与方案选型为何是TensorFlow Lite Micro要实现这个目标我们面临几个核心挑战算力与内存限制ESP32-CAM的主频通常为240MHzSRAM大约520KB这远低于我们熟悉的PC或手机。模型尺寸限制程序需要和模型一起存储在有限的Flash中通常4MB或16MB。实时性要求从采集图像到输出识别结果需要在几百毫秒内完成才能有较好的交互体验。离线运行所有计算必须在设备端完成不能依赖网络API。面对这些挑战TensorFlow Lite for Microcontrollers (TF Lite Micro)几乎是当前的最优解。它是一个专为微控制器和嵌入式设备设计的推理框架核心优势在于极致的轻量级运行时库本身非常小核心引擎可以压缩到仅几十KB。算子支持优化针对ARM Cortex-M系列处理器ESP32的内核基于Xtensa但TF Lite Micro有较好的移植支持进行了算子优化。模型量化支持支持将训练好的浮点模型转换为8位整数INT8格式在几乎不损失精度的情况下将模型大小减少至1/4并大幅提升推理速度。跨平台部署模型格式统一可以在PC端训练、转换然后部署到嵌入式设备上。为什么不选其他框架比如PyTorch Mobile更适合移动端Android/iOS其微控制器版本生态相对TF Lite Micro仍不成熟。至于一些更轻量的推理引擎如NNoM、TinyML虽然更小但社区支持、工具链完整度和模型兼容性上TF Lite Micro目前仍是主流和更稳妥的选择。因此我们的技术栈就明确了在PC端使用TensorFlow/Keras训练一个货币分类模型然后使用TF Lite Converter将其转换为TF Lite格式并进行量化最后集成到ESP32-CAM的Arduino工程中通过TF Lite Micro解释器进行推理。3. 从零构建货币数据集与模型训练模型要识别的准首先得“吃”得好。数据是AI的基石。对于货币识别理想的数据集应该包含多角度同一张货币从不同角度、不同距离拍摄的照片。多光照在明亮、昏暗、背光、侧光等不同光照条件下的照片。多状态崭新、陈旧、轻微褶皱、部分遮挡的货币照片。背景复杂货币放在桌子、钱包、手掌等不同背景上。3.1 数据采集与预处理实战由于公开的、包含多国货币且符合我们需求的数据集很少自制数据集往往是必经之路。这里分享我的采集经验采集工具直接使用ESP32-CAM本身进行采集是最佳选择。编写一个简单的Arduino程序将摄像头拍摄的图片通过Wi-Fi传输到电脑端保存。这样可以确保训练数据的成像特性如镜头畸变、颜色偏差、分辨率与最终应用场景完全一致这是提升模型最终精度的关键技巧。采集脚本示例Arduino端思路// 伪代码思路连接Wi-Fi后启动一个Web服务器 // 当访问特定URL如 /capture时拍摄一张照片并返回 #include WiFi.h #include WebServer.h #include esp_camera.h WebServer server(80); void handleCapture() { camera_fb_t *fb esp_camera_fb_get(); if (!fb) { server.send(500, text/plain, Camera capture failed); return; } server.send(200, image/jpeg, (const char *)fb-buf, fb-len); esp_camera_fb_return(fb); } void setup() { // 初始化摄像头和Wi-Fi // ... server.on(/capture, handleCapture); server.begin(); }在电脑端你可以用Python脚本循环访问ESP32-CAM的IP地址比如http://192.168.1.100/capture来批量保存图片。数据标注每张图片需要对应一个标签如“USD_1”、“EUR_10”、“CNY_100”。建议使用文件夹结构进行管理每个类别的图片放入一个以标签名命名的文件夹。可以使用ImageDataGenerator来自动读取这种结构。预处理流程统一尺寸将图片缩放到模型输入尺寸如96x96或160x160。更大的尺寸意味着更多的计算量需要权衡。数据增强在训练时实时进行以扩充数据集多样性。包括随机旋转小角度、水平翻转、亮度/对比度微调、添加微小噪声等。特别注意货币识别中垂直翻转通常不适用因为纸币倒置可能代表不同含义虽然很少见且硬币正反面图案不同。归一化将像素值从[0, 255]缩放到[0, 1]或[-1, 1]有助于模型稳定训练。3.2 轻量级模型选择与训练在嵌入式设备上我们无法使用ResNet、EfficientNet这类大型模型。我们的选择集中在MobileNet、MobileNetV2/V3的极简版本或者自定义的微小CNNConvolutional Neural Network上。MobileNetV2 (Alpha0.35, input96x96)这是一个非常平衡的选择。通过深度可分离卷积Depthwise Separable Convolution大幅减少参数量和计算量。Alpha0.35是宽度乘子进一步压缩了通道数。96x96的输入在精度和速度间取得了良好平衡。这个模型经过ImageNet预训练通过迁移学习能更快地在我们的货币数据集上收敛。自定义微型CNN如果识别类别很少比如只区分3-5种货币一个4-6层的浅层CNN可能就足够了模型尺寸可以做到更小50KB。但需要自己设计结构且特征提取能力较弱。这里以MobileNetV2为例展示训练核心代码框架import tensorflow as tf from tensorflow.keras import layers, models from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.preprocessing.image import ImageDataGenerator # 参数 IMG_SIZE (96, 96) BATCH_SIZE 32 NUM_CLASSES 10 # 假设识别10种货币 DATA_DIR path/to/your/currency_dataset # 数据生成器包含增强 train_datagen ImageDataGenerator( rescale1./255, rotation_range15, width_shift_range0.1, height_shift_range0.1, brightness_range[0.9, 1.1], horizontal_flipTrue, # 谨慎使用见上文说明 validation_split0.2 ) train_generator train_datagen.flow_from_directory( DATA_DIR, target_sizeIMG_SIZE, batch_sizeBATCH_SIZE, class_modecategorical, subsettraining ) val_generator train_datagen.flow_from_directory( DATA_DIR, target_sizeIMG_SIZE, batch_sizeBATCH_SIZE, class_modecategorical, subsetvalidation ) # 构建模型使用预训练的MobileNetV2去掉顶层自定义分类头 base_model MobileNetV2(input_shape(96, 96, 3), include_topFalse, weightsimagenet, alpha0.35) # 宽度乘子越小模型越轻量 # 冻结基础模型先只训练顶层 base_model.trainable False model models.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.2), # 防止过拟合 layers.Dense(NUM_CLASSES, activationsoftmax) ]) model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) # 第一阶段训练只训练自定义的顶层 initial_epochs 10 history model.fit(train_generator, validation_dataval_generator, epochsinitial_epochs) # 第二阶段解冻部分基础模型进行微调 base_model.trainable True # 通常解冻最后几十层具体数量需要实验 fine_tune_at 100 for layer in base_model.layers[:fine_tune_at]: layer.trainable False model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-5), # 使用更小的学习率 losscategorical_crossentropy, metrics[accuracy]) total_epochs initial_epochs 10 history_fine model.fit(train_generator, validation_dataval_generator, initial_epochhistory.epoch[-1], epochstotal_epochs)注意在货币识别中正反面是不同的类别。例如“USD_1_obverse”和“USD_1_reverse”应该被视为两个独立的类别进行训练除非你的应用场景能确保只看到一面。4. 模型量化、转换与部署到ESP32-CAM训练得到浮点模型.h5后我们需要对它进行“瘦身”和“加速”才能放进ESP32-CAM。4.1 训练后整数量化Post-Training Quantization这是最关键的一步。量化将模型权重和激活值从32位浮点数float32转换为8位整数int8。好处显而易见模型大小减少约75%推理速度提升2-3倍并且许多嵌入式硬件如ESP32的某些指令对整数运算有更好的支持。import tensorflow as tf # 加载训练好的模型 model tf.keras.models.load_model(your_trained_model.h5) # 创建一个代表性数据集用于校准量化过程 # 通常从验证集中取100-200张图片即可 def representative_data_gen(): for image_batch, _ in val_generator.take(100): # 假设val_generator是你的验证集生成器 yield [image_batch] # 转换器 converter tf.lite.TFLiteConverter.from_keras_model(model) # 启用默认优化包括量化感知训练的一些图优化 converter.optimizations [tf.lite.Optimize.DEFAULT] # 指定代表性数据集进行校准 converter.representative_dataset representative_data_gen # 确保输入输出也是int8如果支持否则保持float32输入 converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] # 设置输入输出类型可选如果希望输入输出也是int8 converter.inference_input_type tf.int8 # or tf.uint8 converter.inference_output_type tf.int8 # or tf.uint8 # 转换模型 tflite_quant_model converter.convert() # 保存量化模型 with open(currency_model_quant.tflite, wb) as f: f.write(tflite_quant_model)4.2 将模型集成到Arduino项目TF Lite Micro模型在Arduino中是以C字节数组的形式存在的。我们需要使用一个工具将.tflite文件转换为.cpp文件。使用xxd命令Linux/Mac或在线工具xxd -i currency_model_quant.tflite currency_model_data.cpp这会生成一个包含unsigned char数组的C文件。你需要打开这个文件将数组名改为一个更友好的名字比如g_currency_model_data并添加数组长度的变量g_currency_model_len。Arduino项目结构ESP32_CAM_Currency_Recognition/ ├── ESP32_CAM_Currency_Recognition.ino ├── model/ │ └── currency_model_data.cpp # 转换后的模型数组 └── ...安装必要的Arduino库ESP32 Arduino Core提供对ESP32芯片的支持。TensorFlowLite_ESP32这是一个针对ESP32移植的TF Lite Micro库。你可以在Arduino库管理中搜索安装或者从GitHub手动安装。EloquentTinyML可选一个优秀的Arduino TinyML封装库提供了更友好的API但底层仍是TF Lite Micro。4.3 编写推理代码框架以下是基于EloquentTinyML库的简化示例它封装了细节更易于上手#include EloquentTinyML.h #include model/currency_model_data.h // 包含模型数组 // 定义模型输入输出尺寸 #define NUMBER_OF_INPUTS (96 * 96 * 3) // 96x96 RGB #define NUMBER_OF_OUTPUTS 10 // 10种货币类别 #define TENSOR_ARENA_SIZE 80 * 1024 // 张量竞技场大小根据模型调整建议70KB // 实例化TinyML解释器 Eloquent::TinyML::TfLiteNUMBER_OF_INPUTS, NUMBER_OF_OUTPUTS, TENSOR_ARENA_SIZE ml; // 类别标签 const char* LABELS[] {USD_1, USD_5, EUR_10, CNY_100, /* ... */}; void setup() { Serial.begin(115200); delay(1000); // 初始化模型 if (!ml.begin(g_currency_model_data, g_currency_model_len)) { Serial.println(模型初始化失败); while (true); } Serial.println(模型加载成功); // 初始化摄像头 if (!initCamera()) { Serial.println(摄像头初始化失败); while (true); } Serial.println(摄像头就绪); } void loop() { // 1. 捕获一帧图像 camera_fb_t *fb esp_camera_fb_get(); if (!fb) { Serial.println(摄像头捕获失败); return; } // 2. 图像预处理 // 将图像从摄像头缓冲区例如JPEG或RGB565转换为模型需要的格式96x96 RGB uint8数组 uint8_t input_tensor[NUMBER_OF_INPUTS]; preprocessImage(fb-buf, fb-width, fb-height, fb-format, input_tensor); // 3. 运行推理 float predictions[NUMBER_OF_OUTPUTS]; uint32_t start micros(); ml.predict(input_tensor, predictions); uint32_t inference_time micros() - start; // 4. 解析结果 int predicted_class ml.probaToClass(predictions); float confidence predictions[predicted_class]; Serial.print(推理耗时: ); Serial.print(inference_time / 1000.0); Serial.println( ms); Serial.print(识别结果: ); Serial.print(LABELS[predicted_class]); Serial.print( (置信度: ); Serial.print(confidence * 100); Serial.println(%)); // 5. 释放帧缓冲区 esp_camera_fb_return(fb); delay(2000); // 每2秒识别一次 } // 图像预处理函数需要根据你的摄像头格式实现 void preprocessImage(const uint8_t* src, int src_width, int src_height, pixformat_t format, uint8_t* dst) { // 这是一个复杂但关键的函数 // 1. 如果fb是JPEG格式需要先解码可以使用TJpgDec库。 // 2. 将图像缩放到96x96。 // 3. 可能需要进行颜色空间转换如RGB565 to RGB888。 // 4. 将像素值从[0,255]调整到模型输入范围例如对于int8量化模型可能需要减去128。 // 以下是一个高度简化的伪代码逻辑 // for (int y 0; y 96; y) { // for (int x 0; x 96; x) { // int src_x map(x, 0, 95, 0, src_width-1); // int src_y map(y, 0, 95, 0, src_height-1); // uint16_t pixel getPixelFromRGB565(src, src_width, src_x, src_y); // 假设是RGB565 // uint8_t r (pixel 11) 0x1F; // uint8_t g (pixel 5) 0x3F; // uint8_t b pixel 0x1F; // // 转换为8位并调整范围如果模型输入是int8且均值为0 // dst[(y*96 x)*3 0] (uint8_t)((r * 255.0 / 31.0) - 128); // R // dst[(y*96 x)*3 1] (uint8_t)((g * 255.0 / 63.0) - 128); // G // dst[(y*96 x)*3 2] (uint8_t)((b * 255.0 / 31.0) - 128); // B // } // } }关键提示preprocessImage函数是整个项目中最容易出错、也最影响精度的部分。必须确保在PC端训练时的预处理流程缩放、归一化与设备端的预处理流程完全一致。一个常见的错误是训练时用(x / 255.0)归一化到[0,1]而设备端却忘了做。对于量化模型更要注意输入数据类型的匹配uint8还是int8。5. 性能优化与实战调试技巧当代码跑起来后你会发现离“好用”还有距离。以下是几个关键的优化和调试方向5.1 内存管理张量竞技场Tensor ArenaTF Lite Micro需要一个连续的内存块张量竞技场来分配中间张量。TENSOR_ARENA_SIZE的大小至关重要太小会导致AllocateTensors()失败报错kTfLiteError。太大会浪费宝贵的SRAM。 如何确定合适的大小一个笨但有效的方法是先设置一个较大的值如100KB在初始化后打印出实际使用量。// 在EloquentTinyML中可以尝试调用其内部方法获取信息或者直接修改库文件添加日志。 // 更通用的方法是在TF Lite Micro的源码中找到记录内存使用峰值的地方。通常一个用于96x96输入的MobileNetV2量化模型需要70-90KB的竞技场。ESP32-CAM的520KB SRAM中还需要为摄像头缓冲区、Wi-Fi栈等留出空间所以模型和竞技场必须足够精简。5.2 推理速度优化使用ESP32的硬件加速ESP32具有双核处理器和硬件乘法器。确保在Arduino IDE的“工具”菜单中将“CPU Frequency”设置为最高如240MHz并将“Optimization”设置为“-O2”或“-Os”尺寸优化。降低图像分辨率如果96x96的推理速度仍不理想可以尝试降低到64x64。但这会牺牲精度需要重新训练模型。模型剪枝在训练后移除模型中权重接近零的连接可以进一步压缩模型。可以使用TensorFlow Model Optimization Toolkit。定点化而非量化对于极致的速度可以探索使用TensorFlow Lite for Microcontrollers的定点算子如果支持你的模型结构但这需要更深入的工程工作。5.3 提升识别精度数据数据还是数据模型在真实场景中识别不准首要怀疑对象就是训练数据不够代表性。补充在目标环境光线、角度、背景下采集的数据。预处理对齐再次强调确保设备端的预处理与训练时百分百一致。可以先将设备端预处理后的图像通过串口发送到PC用Python脚本可视化看是否与训练样本看起来一致。后处理不要只取最高置信度的类别。可以设置一个置信度阈值如0.7低于阈值则判定为“未知”。还可以加入简单的时序滤波比如连续3帧都识别为同一类别才输出结果以平滑抖动。区域聚焦如果货币在图像中的位置相对固定可以先进行目标检测如使用一个非常轻量级的检测模型或传统的图像处理技术找到货币区域然后只对该区域进行裁剪和分类可以减少背景干扰。5.4 串口调试与可视化将调试信息通过串口打印出来是必不可少的打印预处理前后图像的统计信息均值、方差。打印每一帧的推理时间和置信度。在识别错误时将捕获的图像保存到SD卡如果ESP32-CAM模块有SD卡槽便于后续分析。6. 从原型到产品系统集成与扩展思考当单个货币识别模块稳定工作后我们可以考虑将其集成到一个更大的系统中。6.1 系统集成示例设想一个简单的自助收银台原型触发通过红外传感器或重量传感器检测到有货币放入。采集与识别ESP32-CAM拍摄照片并进行本地识别得到货币面额和币种。通信通过ESP32的Wi-Fi将识别结果如{“currency”: “CNY”, “value”: 10}以JSON格式发送给后台服务器如运行在树莓派或云端的服务。业务逻辑后台服务器累加金额更新账单并控制显示屏反馈给用户。低功耗设计在没有检测到物体时ESP32-CAM可以进入深度睡眠模式仅由传感器中断唤醒极大节省能耗。6.2 扩展方向多模态识别除了图像是否可以结合重量传感器用于硬币或红外/紫外光检测用于防伪特征来提升准确率和安全性模型在线更新能否设计一个机制当发现新的假币或新版本货币时可以通过OTAOver-The-Air方式安全地更新设备端的模型更复杂的模型如果需要同时识别货币的正反面、新旧程度甚至破损情况可能需要更复杂的模型结构如物体检测YOLO Tiny或分割模型这对ESP32-CAM的算力将是巨大挑战可能需要升级到ESP32-S3带向量指令或使用外置AI加速芯片。6.3 避坑总结回顾整个项目以下几个坑是我亲身踩过值得你特别注意的预处理不一致是精度损失的元凶务必写一个测试脚本对比PC端预处理后的张量和设备端预处理后的张量确保它们之间的差异极小。内存不足的诡异表现内存不足不一定直接崩溃。它可能表现为模型解释器初始化失败、推理结果全零或随机值。始终监控堆内存使用情况ESP.getFreeHeap()。量化模型的输入输出尺度量化模型通常有输入/输出的零点zero point和尺度scale。在使用EloquentTinyML或纯TF Lite Micro API时要清楚你提供的输入数据是否需要已经是量化后的值如int8以及输出的predictions数组如何通过尺度和零点还原为浮点概率。EloquentTinyML的predict方法通常帮你处理了这些但如果自己写底层代码这是必须搞清楚的。摄像头初始化失败ESP32-CAM的引脚配置有多种变体如AI-THINKER版本。务必根据你的具体模块型号在代码中正确配置camera_config_t结构体中的pin定义。一个错误的引脚定义会导致摄像头无法初始化。这个项目就像是在螺丝壳里做道场充满了限制但也正是这些限制逼着我们去深入理解AI模型从训练到部署的每一个细节。当你看到那小小的板子闪烁着灯光准确地报出“CNY 100”时那种成就感是单纯调云API无法比拟的。它不只是一个识别功能更是一个完整的、在边缘运行的智能感知系统。