OpenMV+STM32实战:从图像采集到LCD显示的端到端物体识别系统搭建
1. 项目概述:一个能“看见”并“说出来”的嵌入式小系统
大家好,我是老张,在嵌入式视觉这块摸爬滚打十来年了。今天想和大家分享一个特别有意思、也特别有成就感的实战项目:用OpenMV和STM32搭建一个完整的物体识别系统。简单来说,就是让OpenMV这个“眼睛”看到东西,识别出是什么,然后通过串口“告诉”STM32这个“大脑”,最后由STM32驱动LCD屏幕“说出来”识别结果。
听起来是不是挺酷的?这可不是简单的模块堆叠,而是一个从图像采集、模型训练、数据传输到最终显示的端到端闭环系统。对于想入门嵌入式AI或者做毕业设计、参加竞赛的同学来说,这个项目堪称“黄金练手项目”。它能让你一次性把嵌入式开发里的几个核心技能点——图像处理、单片机编程、通信协议、外设驱动——全都串起来,形成一个完整的认知。我当年就是从类似的项目开始,一步步踩坑过来的,今天就把我的经验,尤其是那些容易出问题的地方,掰开揉碎了讲给你听。
整个系统的流程非常清晰:OpenMV负责前端视觉,采集图像并用内置的算法进行识别;识别出的结果(比如是“手机”、“人”还是“书本”)被打包成数据帧,通过串口发送出去;STM32作为主控,稳稳地接收这些数据,解析后,调用LCD驱动库,把对应的文字或图形显示在屏幕上。我们最终要实现的效果是,你拿着这个系统,对准一个物体,按一下按键,屏幕上就能立刻显示出识别结果,整个过程一气呵成。
2. 硬件选型与连接:给系统搭好“骨架”
工欲善其事,必先利其器。硬件是项目的基石,选对了,事半功倍;连错了,debug到怀疑人生。下面我就详细说说硬件该怎么选、怎么连。
2.1 核心硬件清单与选型思路
首先列一下我这次演示用的硬件清单,你也可以根据自己的手头资源灵活调整:
- 主控单元:正点原子STM32F103ZET6精英开发板。选择它是因为资源丰富,自带TFTLCD接口,而且社区资料多,遇到问题容易找到答案。其实STM32F1系列的其他型号,比如F407,甚至更基础的F103C8T6(需要额外接LCD模块)也完全能胜任。
- 视觉模块:OpenMV Cam H7。这是当前的主流型号,算力比早期的M7强不少,运行我们今天的LBP特征识别算法绰绰有余。如果你的项目需要跑更复杂的神经网络(比如TensorFlow Lite Micro),H7也是更好的选择。
- 显示单元:正点原子3.5寸TFTLCD屏(电阻触摸屏)。这块屏是并口驱动的,速度很快,STM32的FSMC外设可以直接驱动,显示字符和图形非常流畅。市面上常见的SPI接口小屏也可以,但刷新率会低一些。
- 连接与调试:杜邦线(公对公、母对母都需要)、USB数据线(分别给OpenMV和STM32供电下载)、ST-LINK V2仿真器(用于给STM32下载程序和调试)。
- 电源:开发板和OpenMV都可以通过USB线从电脑或充电宝取电,非常方便。
这里有个选型坑我踩过:早期有些OpenMV M7模块的串口电平是3.3V,而一些STM32开发板的串口可能没有做电平转换。如果直接连接,虽然可能能通信,但长期工作不稳定,甚至可能损坏IO口。好在现在的OpenMV H7和主流STM32开发板(如正点原子、野火)的IO口基本都是3.3V电平,直接相连即可。如果不放心,用万用表量一下双方TX/RX引脚在空闲时的电压,确认是3.3V就OK。
2.2 硬件连接详解与避坑指南
连接是关键一步,线接错了,代码写得再好也白搭。我们的连接主要分为电源和信号两部分。
1. 电源连接:
- STM32开发板:用Micro USB线连接电脑,或者用板载的DC接口接5V电源适配器。
- OpenMV:用Micro USB线连接电脑。注意:这里强烈建议将OpenMV和STM32的GND(地线)连接在一起。这是很多新手忽略的一点,共地是保证串口通信稳定的前提,否则可能会遇到数据乱码或者根本无法通信的情况。用一根杜邦线,将OpenMV的一个GND引脚(比如扩展排针上的GND)连接到STM32开发板上的任一个GND引脚即可。
2. 串口通信连接: 这是核心。OpenMV和STM32通过异步串口(UART)通信。
- OpenMV端:我们使用其UART3,对应的引脚是
P4 (TX)和P5 (RX)。你可以在OpenMV IDE的“工具”->“串口终端”里查看引脚定义。 - STM32端:我们使用USART3,对应引脚是
PB10 (TX)和PB11 (RX)。这个映射在STM32的芯片数据手册和开发板原理图上都能查到。 - 连接规则:TX接RX,RX接TX。千万记住要交叉连接!
- OpenMV的
P4 (TX)接 STM32的PB11 (RX) - OpenMV的
P5 (RX)接 STM32的PB10 (TX)
- OpenMV的
- 实物操作:用两根杜邦线(建议用不同颜色,如黄色接TX,绿色接RX)按照上述规则连接好。连接前最好关闭设备电源。
3. LCD屏连接: 正点原子的LCD屏是直接插在开发板的LCD接口上的,方向别插反就行,一般都有防呆设计。如果是其他SPI屏,则需要根据屏幕手册,连接CS、DC、RESET、MOSI、SCLK等引脚到STM32的对应GPIO。
连接好后,硬件部分就准备好了。上电前,务必再三检查TX-RX是否交叉连接,GND是否共地。我见过太多因为这两根线接反,而对着代码纠结半天的案例了。
3. OpenMV端开发:教你的摄像头“认识”物体
OpenMV端的工作是整个系统的感知源头,我们要做的就是教会它区分不同的物体。这里我们采用经典的LBP(局部二值模式)特征进行识别。这种方法不需要强大的GPU,在OpenMV这样的微控制器上就能实时运行,非常适合识别纹理有差异的物体,比如手机、书本、水杯等。
3.1 数据集采集:高质量的“教材”是成功的一半
模型识别的好坏,首先取决于你喂给它的“教材”——也就是数据集的质量。采集过程虽然有点枯燥,但至关重要。
第一步:创建文件系统。
将Micro SD卡插入OpenMV(如果板载没有的话)。在OpenMV IDE中,我们可以通过脚本创建文件夹,但更简单直接的方法是:在OpenMV IDE的“工具”菜单下打开“文件系统”,然后在根目录下新建一个名为 object 的文件夹,再在 object 里新建三个子文件夹,分别命名为 sample1, sample2, sample3。它们将分别存放我们三类物体(比如手机、人、书本)的图片。
第二步:编写并运行采集脚本。
下面的代码是基于官方示例修改的采集脚本。它的逻辑是:初始化摄像头,设置成灰度、128x128分辨率(LBP算法常用),然后等待按键(OpenMV背面的KEY按钮)被按下。每按一次,就拍摄一张照片,并以 序号.pgm 的格式保存到对应的 sample 文件夹中。
import sensor, image, machine, time
from machine import Pin
# 初始化摄像头
sensor.reset()
sensor.set_pixformat(sensor.GRAYSCALE) # 灰度图,LBP需要
sensor.set_framesize(sensor.B128X128)
sensor.set_windowing((92, 112)) # 开窗,取中间部分,减少干扰
sensor.skip_frames(time=2000) # 让相机稳定一下
# 硬件初始化
KEY = Pin('P9', Pin.IN, Pin.PULL_UP) # 按键引脚
red_led = machine.Pin('LED_RED', machine.Pin.OUT)
blue_led = machine.Pin('LED_BLUE', machine.Pin.OUT)
num = 1 # **关键变量**:对应sample1文件夹。采集不同物体时一定要改!
n = 20 # 每个物体打算采集的图片数量,建议15-20张
def take_snapshot():
red_led.on()
time.sleep_ms(100) # 小小延时,防抖
red_led.off()
blue_led.on()
global n
print("拍摄第 %d 张" % n)
img = sensor.snapshot() # 抓取一帧图像
# 保存到指定路径。格式为PGM,是一种简单的灰度图格式
img.save("object/sample%s/%s.pgm" % (num, n))
n -= 1
blue_led.off()
print("拍摄完成!")
# 主循环
while n > 0:
if KEY.value() == 0: # 按键被按下
take_snapshot()
time.sleep(0.5) # 按键去抖,防止一次按下触发多次
else:
# 实时预览,方便对准物体
img = sensor.snapshot()
time.sleep(0.1)
采集时的实战技巧与坑:
- 变量
num是核心:采集第一类物体(如手机)时,设num=1,图片会存到sample1。拍完后,必须将num改为2,再采集第二类物体(如人),否则所有图片都会混到一个文件夹里,导致训练失败。 - 采集环境:尽量保证光线均匀,避免强光直射或阴影。同一个物体,可以稍微变换角度、远近多拍几张,增加数据的多样性。
- 图片数量
n:不是越多越好,每类15-20张高质量图片通常比50张模糊、重复的图片更有效。拍完后,记得去文件系统里检查一下图片是否都保存成功了。
3.2 物体识别与串口发送:让结果“说”出去
数据集准备好后,就到了编写识别与通信脚本的时候了。这段代码会实时运行LBP算法,并与STM32通信。
import sensor, time, image, machine
from pyb import UART
from machine import Pin
# 摄像头初始化(同上)
sensor.reset()
sensor.set_pixformat(sensor.GRAYSCALE)
sensor.set_framesize(sensor.B128X128)
sensor.set_windowing((92, 112))
sensor.skip_frames(time=2000)
# 关键参数
NUM_SUBJECTS = 3 # 我们有3类物体
NUM_SUBJECTS_IMGS = 20 # 每类物体的图片数量,与采集时n的最大值一致
# 硬件初始化
KEY = Pin('P9', Pin.IN, Pin.PULL_UP)
uart = UART(3, 115200) # 使用UART3,波特率115200
blue_led = machine.Pin('LED_BLUE', machine.Pin.OUT)
def object_detection():
blue_led.on()
pmin = 999999 # 最小差异度初始值
match_id = 0 # 匹配到的物体ID
# 1. 捕获当前画面
img = sensor.snapshot()
# 计算当前画面的LBP特征
d0 = img.find_lbp((0, 0, img.width(), img.height()))
# 2. 与数据集中的每类物体进行比对
for s in range(1, NUM_SUBJECTS + 1):
dist = 0
# 计算与当前类所有样本图片的平均差异度
for i in range(1, NUM_SUBJECTS_IMGS + 1):
sample_img = image.Image("object/sample%s/%s.pgm" % (s, i))
d1 = sample_img.find_lbp((0, 0, sample_img.width(), sample_img.height()))
dist += image.match_descriptor(d0, d1)
avg_dist = dist / NUM_SUBJECTS_IMGS
print("物体 %d 的平均差异度: %d" % (s, avg_dist))
# 3. 找出差异度最小的,即最匹配的
if avg_dist < pmin:
pmin = avg_dist
match_id = s
print('最小差异度:', pmin)
# 4. 根据ID判断物体类型
if match_id == 1:
result_str = 'Phone'
elif match_id == 2:
result_str = 'People'
elif match_id == 3:
result_str = 'Book'
else:
result_str = 'Unknown'
print('识别结果: ', result_str)
# 5. 核心:打包数据并通过串口发送
# 我们定义一个简单的数据帧协议:0xA5 (帧头) + 物体ID (1字节) + 0xA6 (帧尾)
output_packet = bytearray([0xA5, match_id, 0xA6])
uart.write(output_packet)
print("发送数据包: ", output_packet)
blue_led.off()
# 主循环:等待按键触发识别
while True:
if KEY.value() == 0:
object_detection()
time.sleep(0.5) # 按键防抖
else:
# 维持实时预览
img = sensor.snapshot()
time.sleep(0.1)
代码精讲与调试要点:
- 通信协议设计:我们设计了一个最简单的帧结构
0xA5 + ID + 0xA6。0xA5和0xA6是帧头和帧尾,用于帮助STM32从数据流中准确截取一帧有效数据。ID就是识别出的物体编号(1,2,3)。这种加头尾的方式是串口通信中防止数据错位的常见手段。 - 波特率设置:
UART(3, 115200)这里的波特率必须和STM32端设置的波特率完全一致,否则接收到的全是乱码。115200是常用值,通信速度够快且稳定。 - 调试方法:在连接STM32之前,可以先用OpenMV IDE自带的“串口终端”工具进行测试。运行上述代码,按下按键,看看终端里是否打印出了正确的识别结果和发送的数据包(如
[165, 1, 166])。这能先确保OpenMV端工作正常。 - 识别不准怎么办:如果识别率低,首先回去检查数据集图片质量。其次,可以调整
sensor.set_windowing的参数,确保物体在画面中的大小和位置与采集时相近。还可以尝试增加图片数量或调整LBP算法的参数(如半径、邻域点数),但这需要更深入的理解。
4. STM32端开发:可靠的数据管家与显示控制
STM32在这里扮演着“中枢神经”的角色。它需要可靠地接收来自OpenMV的数据包,准确解析出物体ID,然后驱动LCD屏显示对应的信息。稳定性是这里的第一要务。
4.1 串口接收与解析:稳字当头
串口通信是异步的,数据是一个字节一个字节传来的。STM32必须能够从连续的字节流中,准确地挑出我们约定好的那一帧数据(0xA5, ID, 0xA6)。我强烈推荐使用 “空闲中断+环形缓冲区” 的方式,这是工业上很成熟、很稳定的方案。
首先,初始化USART3。 这部分代码主要配置GPIO、波特率、中断等。
// usart3.c 部分代码
void USART3_Init(uint32_t baudrate) {
// ... 时钟使能、GPIO配置(PB10:复用推挽输出-TX, PB11:上拉输入-RX)
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = baudrate; // 务必与OpenMV一致:115200
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_Init(USART3, &USART_InitStructure);
// 关键中断配置
USART_ITConfig(USART3, USART_IT_RXNE, ENABLE); // 使能接收中断(每收到一个字节触发一次)
USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); // 使能空闲中断(总线空闲时触发,标志一帧数据结束)
NVIC_EnableIRQ(USART3_IRQn); // 使能串口3全局中断
USART_Cmd(USART3, ENABLE);
}
其次,编写中断服务函数。 这是实现稳定接收的核心。
// usart3.c 中断服务函数
#define UART3_RX_BUF_SIZE 64
uint8_t u3_rx_buffer[UART3_RX_BUF_SIZE]; // 最终给主程序用的数据缓冲区
volatile uint16_t u3_rx_cnt = 0; // 接收到的字节数
uint8_t u3_rx_temp[UART3_RX_BUF_SIZE]; // 临时缓存,在中断里使用
volatile uint8_t u3_rx_flag = 0; // 一帧数据接收完成标志位
void USART3_IRQHandler(void) {
uint16_t tmp;
static uint16_t rx_len = 0; // 静态变量,用于记录本次帧长度
// 1. 接收中断处理
if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET) {
USART_ClearITPendingBit(USART3, USART_IT_RXNE);
tmp = USART_ReceiveData(USART3);
if(rx_len < UART3_RX_BUF_SIZE) {
u3_rx_temp[rx_len] = tmp; // 存入临时缓存
rx_len++;
}
// 如果缓存满了,可以在这里做溢出处理
}
// 2. 空闲中断处理 - 标志一帧数据接收完毕
if(USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) {
USART_ReceiveData(USART3); // 读DR是清除IDLE中断标志的必要操作
USART_ClearITPendingBit(USART3, USART_IT_IDLE);
if(rx_len > 0) {
memcpy(u3_rx_buffer, u3_rx_temp, rx_len); // 将临时缓存数据拷贝到主缓冲区
u3_rx_cnt = rx_len; // 记录数据长度
u3_rx_flag = 1; // 置位标志,通知主循环处理
rx_len = 0; // 临时长度清零,准备接收下一帧
memset(u3_rx_temp, 0, UART3_RX_BUF_SIZE); // 清空临时缓存
}
}
}
最后,在主循环中解析数据。 我们创建一个专门的文件 openmv.c/h 来处理。
// openmv.c
#include "openmv.h"
#include "string.h"
uint8_t g_object_id = 0; // 全局变量,存储解析出的物体ID
extern volatile uint8_t u3_rx_flag;
extern uint8_t u3_rx_buffer[];
extern volatile uint16_t u3_rx_cnt;
void OpenMV_Receive_Data(void) {
if(u3_rx_flag == 1) {
u3_rx_flag = 0; // 清除标志
// 简单的协议解析:寻找帧头0xA5和帧尾0xA6
if(u3_rx_cnt >= 3) { // 至少要有3个字节
for(int i = 0; i <= u3_rx_cnt - 3; i++) {
if((u3_rx_buffer[i] == 0xA5) && (u3_rx_buffer[i+2] == 0xA6)) {
g_object_id = u3_rx_buffer[i+1]; // 提取ID
// 可以在这里加一些校验,比如ID范围(1-3)
break; // 找到一帧就退出
}
}
}
// 解析完成后,可以清空缓冲区,但更常见的做法是交给中断自己管理
}
}
4.2 LCD显示驱动与界面设计
数据解析出来后,显示就相对简单了。正点原子的LCD驱动库已经封装得很好,我们直接调用即可。重点是设计一个清晰、稳定的显示界面。
// openmv.c 续
#include "lcd.h"
void OpenMV_Display_Result(void) {
char display_str[20];
switch(g_object_id) {
case 1:
sprintf(display_str, "Object: Phone");
break;
case 2:
sprintf(display_str, "Object: People");
break;
case 3:
sprintf(display_str, "Object: Book");
break;
default:
sprintf(display_str, "Object: Unknown");
break;
}
// 设置显示区域和颜色
POINT_COLOR = BLUE;
BACK_COLOR = WHITE; // 设置背景色为白色,再画一个实心矩形覆盖旧内容,实现清屏效果
LCD_ShowString(10, 50, 200, 24, 24, (uint8_t*)"OpenMV+STM32 Demo");
LCD_Fill(10, 80, 230, 110, WHITE); // 局部清空结果区域,避免残留
LCD_ShowString(10, 80, 200, 32, 32, (uint8_t*)display_str); // 显示识别结果
// 还可以增加一些辅助信息,比如帧计数
static int frame_count = 0;
frame_count++;
LCD_Fill(10, 120, 150, 140, WHITE);
LCD_ShowString(10, 120, 200, 16, 16, (uint8_t*)"Frame:");
LCD_ShowNum(70, 120, frame_count, 5, 16);
}
主函数 main.c 的最终整合:
#include "stm32f10x.h"
#include "delay.h"
#include "usart.h"
#include "lcd.h"
#include "openmv.h"
int main(void) {
delay_init();
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
USART3_Init(115200); // 串口初始化
LCD_Init(); // LCD初始化
LED_Init(); // 指示灯初始化,可选
// 显示初始界面
LCD_Clear(WHITE);
POINT_COLOR = RED;
LCD_ShowString(30, 10, 200, 24, 24, (uint8_t*)"Waiting for Data...");
while(1) {
OpenMV_Receive_Data(); // 不断检查并解析串口数据
if(g_object_id != 0) { // 如果有新的有效ID
OpenMV_Display_Result(); // 更新显示
g_object_id = 0; // 处理完清零,等待下一次
}
delay_ms(10); // 给系统一点喘息时间
}
}
5. 系统联调与实战问题排查
代码都写好了,硬件也连上了,最后一步就是上电联调。这一步往往是问题集中爆发的地方,别慌,按照以下步骤和思路来,大部分问题都能解决。
第一步:分模块调试,各个击破。
- 单独测试OpenMV:不接STM32,只给OpenMV上电,用USB连接电脑。在OpenMV IDE中运行物体识别脚本,通过IDE的串口终端和帧缓冲区查看识别结果是否正确。同时,用逻辑分析仪或者另一个USB转TTL模块(连接到电脑串口助手)监听OpenMV的TX(P4)引脚,看是否有符合协议(
0xA5, XX, 0xA6)的数据包发出。这一步确保OpenMV的“输出”是正常的。 - 单独测试STM32的串口接收:暂时注释掉OpenMV的识别代码,只保留发送固定数据包(比如循环发送
0xA5, 0x01, 0xA6)的代码。将OpenMV与STM32连接,在STM32程序中,将接收到的数据通过其另一个串口(如USART1)打印到电脑的串口助手。观察是否能稳定收到完整的数据帧。这一步确保STM32的“输入”和解析逻辑是正常的。 - 单独测试STM32的LCD显示:写一个简单的测试程序,让LCD循环显示几个固定的字符串,确保屏幕本身和驱动没问题。
第二步:全系统联调。 将完整的OpenMV识别程序和STM32程序分别烧录,连接好线路,上电。按一下OpenMV的按键,观察LCD屏幕。
常见问题与排查清单:
- 问题1:LCD屏无任何显示。
- 检查:STM32电源指示灯亮了吗?LCD背光亮了吗(有些屏需要单独控制背光)?LCD排线是否插紧?
LCD_Init()函数里的初始化序列是否与你的屏幕型号匹配?
- 检查:STM32电源指示灯亮了吗?LCD背光亮了吗(有些屏需要单独控制背光)?LCD排线是否插紧?
- 问题2:LCD一直显示“Waiting for Data...”或“Unknown”,不更新。
- 检查:这是最典型的问题,说明通信没成功。
- 物理连接:TX和RX是否交叉连接?GND是否共地?用万用表通断档检查连线。
- 波特率:双方代码里的波特率是否都是115200?一个字节都不能错。
- 协议解析:在STM32的中断里,把接收到的每一个原始字节都通过调试串口打印出来(
printf十六进制格式)。看看是否收到了数据?数据是不是A5 XX A6的格式?帧头帧尾是否正确?这能直接定位是传输问题还是解析问题。 - 中断优先级:如果STM32程序里还有其他中断(如定时器中断),要确保串口接收中断的优先级设置合理,不会被长时间阻塞。
- 检查:这是最典型的问题,说明通信没成功。
- 问题3:识别结果时对时错。
- 检查:回到OpenMV端。检查数据集图片质量、光线环境是否与当前识别环境差异过大。尝试在
object_detection()函数里打印出pmin(最小差异度)的值,如果这个值非常大(比如接近初始值999999),说明当前画面与所有样本都不匹配,可能是物体不在画面中央、大小不合适或光线太暗。 - 优化:可以设置一个阈值。只有当
pmin小于某个经验值(比如50000)时,才认为识别有效,否则显示“Unknown”。这能过滤掉一些误识别。
- 检查:回到OpenMV端。检查数据集图片质量、光线环境是否与当前识别环境差异过大。尝试在
- 问题4:系统运行一段时间后死机。
- 检查:重点排查内存泄漏和中断冲突。在STM32的串口接收中断中,避免使用
printf、sprintf等耗时长的函数。确保临时缓冲区不会溢出。检查堆栈大小是否设置合理。
- 检查:重点排查内存泄漏和中断冲突。在STM32的串口接收中断中,避免使用
这个项目虽然小,但涵盖了嵌入式视觉系统从感知到执行的全链条。当你按下按键,看到LCD屏上准确显示出物体名称的那一刻,那种把代码和硬件“打通”的快乐,是纯软件编程难以比拟的。希望这份详细的指南能帮你少走弯路。在实际动手时,最关键的就是耐心和细致的调试,每一个灯、每一个打印信息都是你解决问题的线索。如果遇到卡住的地方,不妨放一放,从头再理一遍流程,或者去论坛社区搜搜类似的问题,你踩的坑,很可能别人也踩过。
更多推荐


所有评论(0)