STM32H7+TensorFlow Lite Micro垃圾分类原型:模型部署与舵机控制实战

发布时间:2026/9/16 6:48:53
STM32H7+TensorFlow Lite Micro垃圾分类原型:模型部署与舵机控制实战 简介面向2024年全国大学生电子设计竞赛智能垃圾分类赛题的开源代码项目基于STM32H7微控制器设计适合正在备赛的高校学生、嵌入式开发者以及相关技术爱好者快速对照赛题要求搭建原型。压缩包共6个文件由Python脚本、模型文件和说明文档构成其中脚本分别负责主程序流程、舵机驱动与分类动作控制模型文件用于垃圾图像推理标签文件提供类别映射说明文档则指引环境配置与使用步骤。整个资源包仅34KB体积小巧、结构清晰便于逐行阅读和在嵌入式设备上二次移植。已有239人学习代码清晰展示了模型加载、外设初始化和电机联动等关键环节直接复用可节省备赛时间也为后续扩展物联网上报或交互界面留出清晰的改造空间。1. 用 STM32H7 TensorFlow Lite Micro 搭一个能直接跑的垃圾分类原型拿到这个stm32H7_rubbish_code.zip时我先确认了一件事它跑的不是常见意义上的 STM32 裸机 C 工程而是一套 MicroPython 脚本配合 TensorFlow Lite Micro 的推理方案。压缩包里main.py、servo.py、pca9685.py、trained.tflite、labels.txt就是全部家当。这意味着主控上刷的是 MicroPython 固件TensorFlow Lite Micro 负责跑图像分类模型PCA9685 驱动板通过 PWM 信号控制舵机做物理分类投放。对参赛团队来说这套组合的优势非常直接模型训练和调参在 PC 端完成部署时只需要把.tflite文件扔进文件系统、写几段 Python 胶水代码就能完成整机联调省掉了 C 语言工程里最耗时的交叉编译和内存调试。本文从模型文件、推理流程、舵机控制到整机排错逐个拆开讲清楚。2. 为什么是 STM32H7 MicroPython TFLite Micro算力、存力与开发效率的平衡点2.1 STM32H7 的资源边界决定了这套方案的可行性STM32H7 系列的内核是 Cortex-M7主频能跑到 480 MHz带 DP-FPU 双精度浮点单元。和 F4、F1 系列比它的亮点是内置了 1 MB 以上的 RAM具体型号有差异H743 是 1 MB这对 TensorFlow Lite Micro 至关重要。TFLite Micro 的推理过程需要一个连续的 Tensor Arena 内存池来存放中间计算结果和激活值模型本身再小这个 arena 也要留足空间。许多 F4 系列只有 128 KB 或 192 KB RAM跑一个稍大的图像分类模型就会直接 OOM。而 H7 的 1 MB RAM 给足了两百 KB 到四百 KB 的 arena 余量还能同时跑摄像头采集缓冲区和显示缓冲区。从开发效率上看MicroPython 固件把底层 GPIO、I2C、PWM 的寄存器操作全部封装掉了。以pca9685.py为例它只需要通过 I2C 写寄存器就能控制 16 路 12-bit PWM 输出不需要查看参考手册去配置定时器和 DMA。2.2 TensorFlow Lite Micro 在这套代码里的角色trained.tflite是已经过训练并转换好的模型文件。TensorFlow 训练得到的模型.h5或 SavedModel 格式需要经过两个关键步骤才能部署到 MCU 上先转换为 TensorFlow Lite 格式再通过xxd工具或文件系统挂载的方式让运行时读取。在这个项目中.tflite被存放在设备文件系统中MicroPython 代码通过文件读取的方式加载模型字节流然后交给 TFLite Micro 解释器执行推理。TFLite Micro 和完整版 TensorFlow Lite 的差异在于它去掉了所有平台依赖层不需要操作系统支持内存分配完全由开发者提供的 arena 决定。上板前最好在 PC 端用tflite-runtime跑一次同样的.tflite文件确认输入张量是uint8还是float32、输出张量的 shape 是[1, num_classes]还是其他维度。否则在板子上排查输入格式问题会非常痛苦。2.3 labels.txt 里藏着分类体系cardboard glass metal paper plastic trash这个文件是分类标签的索引表每行一个类别名称行号就是模型输出层每个节点的类别索引。常见比赛方案的分类体系是三到五类可回收物但这个项目采用了六个类别其中还包含了trash其他垃圾。多出来的这个类别非常关键它给了模型一个拒识出口当摄像头看到无法归入前五类的物体时模型可以输出trash而不是强行把它分进某个错误类别。labels.txt的文件编码和行顺序必须与训练时的数据集保持一致。如果训练时代码是用os.listdir()排序生成的标签集那么在编辑文件时要格外小心任何增删行都会导致分类错位实际分类结果和舵机动作完全对不上。3. 从模型到动作解析 main.py 的推理流程与分类决策逻辑3.1 模型加载与解释器初始化的常见写法在main.py中模型加载和推理核心逻辑通常长这样import tensorflow as tf import tflite_runtime.interpreter as tflite import gc MODEL_PATH /sd/trained.tflite LABELS_PATH /sd/labels.txt # 加载模型并分配 Tensor Arena interpreter tflite.Interpreter( model_pathMODEL_PATH, experimental_op_resolver_typetflite.OpResolverType.BUILTIN ) # 分配显存/内存区域 interpreter.allocate_tensors() # 获取输入输出张量信息 input_details interpreter.get_input_details() output_details interpreter.get_output_details() print(Input shape:, input_details[0][shape]) print(Output shape:, output_details[0][shape]) print(Input dtype:, input_details[0][dtype]) # 读取标签 with open(LABELS_PATH, r, encodingutf-8) as f: labels [line.strip() for line in f.readlines()]这块代码有两个参数值得关注。第一个是experimental_op_resolver_type如果模型里包含自定义算子比如某些量化感知训练引入的算子需要额外注册对应的 OpResolver否则解释器会在allocate_tensors()阶段直接抛异常。第二个是input_details[0][dtype]——如果模型是整型量化版本dtype 是uint8这意味着送入推理的像素数据必须先做归一化到 0~255 的整型转换不能直接塞浮点数组。许多选手在 PC 端验证无误上板后准确率骤降原因就在这个数据类型不匹配。3.2 图像预处理链路与常见坑import image def preprocess_image(buf, width, height): # 从摄像头缓冲区创建灰度图 img image.Image(buf[0:width * height], width, height, image.GRAYSCALE) # 缩放到模型输入尺寸通常为 96x96 或 128x128 img img.resize(INPUT_WIDTH, INPUT_HEIGHT) # 转换为一维数组 pixels img.to_bytes() # 归一化到 0~255 的 uint8 数组供量化模型使用 pixel_array list(pixels) return pixel_array预处理的核心操作是两步缩放和灰度化。模型训练时如果使用 ImageNet 之类的自然图像数据集输入可能是 224x224 的 RGB 图而嵌入式摄像头模块采集到的往往是小分辨率灰度帧。直接把原始帧送入模型几乎必然导致推理错误因为输入张量 shape 对不上。image.Image()构造函数中的GRAYSCALE参数对应 OpenMV 的摄像头 API 封装。如果你用的是其他摄像头模块例如 OV2640 通过 DCMI 接口需要自行完成 YUV 到灰度的转换否则像素排列错位会让模型输出完全随机。预处理之后建议将pixel_array的长度与input_details[0][shape][1]打印对比这是排查 shape 不匹配最直接的方式。3.3 推理与决策的工程实现MAX_CLASSES 6 CONFIDENCE_THRESHOLD 0.6 # 置信度阈值低于该值判定为不确定 def classify(image_data): # 设置输入张量 interpreter.set_tensor(input_details[0][index], image_data) # 执行推理 interpreter.invoke() # 获取输出张量 output_data interpreter.get_tensor(output_details[0][index]) # 找到最大概率类别和置信度 max_idx 0 max_score 0.0 for i in range(MAX_CLASSES): score output_data[i] if score max_score: max_score score max_idx i # 置信度过低时返回未知类别 if max_score CONFIDENCE_THRESHOLD: return -1, max_score return max_idx, max_score这段代码里CONFIDENCE_THRESHOLD 0.6是一个需要根据实际准确率调整的超参数。如果模型对某些类别区分能力较弱阈值太高会导致大量帧被判定为不确定舵机不会动作阈值太低又会让明显错误的分类结果直接触发投放动作。我一般建议在调试阶段把阈值设到 0.3观察错误分类的模式再逐步提高。输出张量可能是float32的 softmax 概率分布也可能是量化后的uint8数值后者需要除以 255 还原成概率这一点取决于训练时的量化配置。4. 让舵机按分类结果执行动作PCA9685 与 servo.py 的运动控制4.1 PCA9685 驱动板的工作原理PCA9685 是一颗 I2C 接口的 16 通道 PWM 驱动芯片内置 25 MHz 振荡器可以输出 12 位分辨率的 PWM 波形频率范围约 24 Hz ~ 1526 Hz。在舵机控制场景下通常设置 50 Hz 的 PWM 频率对应 20 毫秒的周期。标准模拟舵机的控制脉宽范围是 1 ms0°到 2 ms180°但市面上大部分舵机实际可用的脉宽范围和标称略有差异需要实测这是所有舵机控制项目的第一道坎。pca9685.py的内核就是把 I2C 写入和 PWM 输出封装成便捷方法from machine import I2C, Pin import time PCA9685_ADDR 0x40 # I2C 地址A5-A0 全接地时为 0x40 MODE1 0x00 PRESCALE 0xFE CHANNEL_ON_L 0x06 # 第 0 通道 ON 寄存器起始 class PCA9685: def __init__(self, i2c, address0x40): self.i2c i2c self.address address self.reset() # 设置 PWM 频率为 50Hz适合舵机 self.set_pwm_freq(50) def reset(self): self.i2c.writeto_mem(self.address, MODE1, bytes([0x00])) def set_pwm_freq(self, freq): # 根据数据手册计算 prescale 值 prescale round(25000000 / (4096 * freq)) - 1 old_mode self.i2c.readfrom_mem(self.address, MODE1, 1)[0] new_mode (old_mode 0x7F) | 0x10 # 设置 SLEEP 位以便修改 prescale self.i2c.writeto_mem(self.address, MODE1, bytes([new_mode])) self.i2c.writeto_mem(self.address, PRESCALE, bytes([prescale])) self.i2c.writeto_mem(self.address, MODE1, bytes([old_mode])) time.sleep_ms(1) self.i2c.writeto_mem(self.address, MODE1, bytes([old_mode | 0x80])) def set_pwm(self, channel, on, off): # 每个通道有 4 个寄存器ON_L, ON_H, OFF_L, OFF_H reg_base CHANNEL_ON_L channel * 4 self.i2c.writeto_mem(self.address, reg_base, bytes([on 0xFF, (on 8) 0xFF])) self.i2c.writeto_mem(self.address, reg_base 2, bytes([off 0xFF, (off 8) 0xFF]))prescale寄存器的计算公式是25000000 / (4096 * freq) - 1其中 25 MHz 是 PCA9685 内部振荡器频率4096 是 12 位计数器满值。如果舵机出现抖动或啸叫优先检查实际输出频率可以用示波器或逻辑分析仪测量 PWM 波形周期是否为 20 ms。另一个常见问题是 I2C 总线上多个设备地址冲突PCA9685 的 A0~A5 地址引脚提供了 64 个可选地址默认全接地是0x40如果和板载 EEPROM 或其他传感器冲突需要焊接跳线改地址。4.2 servo.py 中角度到 PWM 占空比的映射SERVO_MIN_PULSE 1000 # 对应 0 度的脉宽微秒 SERVO_MAX_PULSE 2000 # 对应 180 度的脉宽微秒 PWM_CYCLE_US 20000 # 50Hz 对应的周期 20ms def angle_to_pulse(angle): # 将角度线性映射到脉宽 pulse SERVO_MIN_PULSE (angle / 180.0) * (SERVO_MAX_PULSE - SERVO_MIN_PULSE) return int(pulse * 4096 / PWM_CYCLE_US) # 转换为 12 位计数器值 def set_servo_angle(pca, channel, angle): pulse_us SERVO_MIN_PULSE (angle / 180.0) * (SERVO_MAX_PULSE - SERVO_MIN_PULSE) off_count int(pulse_us * 4096 / PWM_CYCLE_US) pca.set_pwm(channel, 0, off_count)set_servo_angle的核心公式要理解两个单位转换。舵机控制以脉宽微秒数定义角度而 PCA9685 的寄存器只认 0~4095 的计数器值转换关系就是用脉宽除以 20 毫秒周期再乘以 4096。这里的SERVO_MIN_PULSE和SERVO_MAX_PULSE是默认值实际安装时应该在舵机通电后单步调试确认。我实际做这一类机械臂投放结构时最常见的机械问题是角度映射错位。如果舵机安装时臂杆初相位不在 0°上电后第一次收到 0° 指令就可能撞上机械限位。稳妥做法是机械结构上电前手动把舵机臂转到中间位置然后在代码中定义一个偏移函数def set_servo_with_offset(pca, channel, angle, offset_deg0): adjusted max(0, min(180, angle offset_deg)) set_servo_angle(pca, channel, adjusted)4.3 投放流程从分类结果到舵机轨迹def drop_to_bin(category): # category - 舵机角度映射表 angle_map { 0: 30, # cardboard 1: 60, # glass 2: 90, # metal 3: 120, # paper 4: 150, # plastic 5: 180 # trash } if category not in angle_map: return False angle angle_map[category] # 第一步转盘转到目标位置 set_servo_angle(pca, ROTATE_CHANNEL, angle) time.sleep_ms(500) # 等待转盘到位 # 第二步翻板执行投放 set_servo_angle(pca, GATE_CHANNEL, GATE_OPEN_ANGLE) time.sleep_ms(300) set_servo_angle(pca, GATE_CHANNEL, GATE_CLOSE_ANGLE) time.sleep_ms(200) return True这一段写清楚了两级执行机构的分工。旋转舵机负责把垃圾桶转盘对准目标分类口翻板舵机负责开合闸门。这个流程虽然简单却是面向比赛最不容易出错的方案。这里面的time.sleep_ms依赖舵机实际转速如果舵机速度慢延时不足会导致转盘还没到位就翻板垃圾掉进错误的桶里。实际调整时可以采用一种简单有效的闭环方式在转盘上加光电传感器每个分类位对应一个遮光槽到位检测成功后再执行翻板可靠性远高于纯延时控制。4.4 舵机供电的硬伤与 stabilizer 电容舵机是整机功耗的主要来源比赛现场的电池供电方案如果直接给舵机和主控共用一个稳压源舵机启动瞬间的大电流冲击会导致控制板重启或 I2C 通信失败。常见做法是舵机独立供电或者用大容量电容做缓冲——在主控电源轨靠近舵机接线端并联一个 470 μF 到 2200 μF 的电解电容。如果程序运行过程中摄像头画面突然花屏或舵机完全不动优先怀疑电源问题。5. 主循环的时序调度main.py 的实时性设计5.1 用轮询 定时排队替代多任务import time CLASSIFY_INTERVAL_MS 1000 # 分类周期 1 秒 SERVO_SETTLE_TIME 0.8 # 舵机到位等待时间 last_classify_time 0 lockout_until 0 while True: current_time time.ticks_ms() # 如果处于舵机动作锁定期跳过分类 if current_time lockout_until: time.sleep_ms(50) continue # 周期性采集并分类 if time.ticks_diff(current_time, last_classify_time) CLASSIFY_INTERVAL_MS: # 拍摄一帧 img sensor.snapshot() # 预处理 input_data preprocess_image(img, img.width(), img.height()) # 推理 category, conf classify(input_data) # 如果置信度足够则执行投放 if category 0: drop_to_bin(category) lockout_until time.ticks_ms() int(SERVO_SETTLE_TIME * 1000) last_classify_time current_time time.sleep_ms(20)这段代码用两个时间戳完成了任务调度。last_classify_time控制分类周期避免摄像头和推理持续占用 CPUlockout_until是舵机动作的保护窗口防止投放过程中新的分类结果打断当前动作。这里用time.ticks_diff而不是直接相减的原因是 MicroPython 的ticks_ms()在计数溢出时约 49 天后会回绕直接做小整数减法会出错。帧率方面灰度图 96x96 输入到 H7 上单次推理大约耗时 120 ~ 250 ms加上图像采集和预处理一帧总耗时在 300 ms 上下。因此把分类周期设为 1000 ms 或更长是合理的留出了线程安全的裕量。5.2 交互设计引入竞态条件赛场常见的附加要求是按键触发或显示屏观察模式这时需要在意的是全局变量共享问题。MicroPython 中没有线程安全的共享变量按键回调函数里直接修改current_mode主循环同时读取它就可能出现模式切换瞬间的分类动作。好在这个项目的代码量不大最务实的解法是主循环轮询按键状态而不是依赖中断回调KEY_MODE_PIN Pin(P5, Pin.IN, Pin.PULL_UP) def read_mode(): if not KEY_MODE_PIN.value(): time.sleep_ms(20) # 简单消抖 if not KEY_MODE_PIN.value(): return auto return idle mode idle while True: new_mode read_mode() if new_mode ! mode: mode new_mode print(Mode changed to:, mode)5.3 I2C 总线异常时的应急处理PCA9685 和摄像头共用 I2C 总线的项目里总线被舵机信号干扰导致通信中断是常见故障。在每次设置舵机角度之前检查 I2C 总线是否有响应比盲发指令更安全def safe_set_angle(channel, angle): try: pca.set_servo_angle(channel, angle) except OSError as e: print(I2C error:, e) # 重新初始化 I2C 或进入错误状态 i2c_init()如果出现OSError: [Errno 19] ENODEV表示 I2C 设备无应答大概率是接线松动或供电不足。此时直接重置 PCA9685 芯片重新初始化 PWM 频率比复位整个主控影响面小得多。6. 赛前校准与稳定性验证三个不被注意却决定分数的技巧系统联调后的校准环节是区分能用和稳定发挥的分水岭。第一件事是逐舵机标定零点。给舵机上电后用上位机串口发送 90° 指令手动把舵机臂拧到机械 90° 位置并锁紧螺丝然后依次下发 0°、45°、90°、135°、180° 五个角度用角度尺或量角器记录实际角度差异修改SERVO_MIN_PULSE和SERVO_MAX_PULSE通常能把误差控制在 ±1° 以内。第二件事是验证模型对真实光照的鲁棒性。比赛场地照明强度和实验室可能差别很大模型在实验室测试时可能准确率接近 100%但在现场强光下就开始误判。这里有个低成本的补救措施——在代码里预留白平衡和曝光调节接口通过串口命令在赛前快速切换曝光模式比重新训练模型省事得多。# 调整摄像头曝光值以 OpenMV 输入框架为例 sensor.set_auto_exposure(False) sensor.set_exposure(500) # 单位微秒数值越小画面越暗第三件事是给整体流程加看门狗。投放动作完成到下一次分类开始之间加一个 5 秒超时判定。如果在规定时间内设备没有完成动作自动复位初始化而不是卡在某个中间状态。比赛现场轻微的机械卡涩、舵机堵转都很常见能够自动恢复的系统往往能多拿一次完整运行时长的分数。本文还有配套的精品资源点击获取