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)
  • 实物操作:用两根杜邦线(建议用不同颜色,如黄色接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)

采集时的实战技巧与坑:

  1. 变量 num 是核心:采集第一类物体(如手机)时,设 num=1,图片会存到 sample1。拍完后,必须将 num 改为2,再采集第二类物体(如人),否则所有图片都会混到一个文件夹里,导致训练失败。
  2. 采集环境:尽量保证光线均匀,避免强光直射或阴影。同一个物体,可以稍微变换角度、远近多拍几张,增加数据的多样性。
  3. 图片数量 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)

代码精讲与调试要点:

  1. 通信协议设计:我们设计了一个最简单的帧结构 0xA5 + ID + 0xA6。0xA5和0xA6是帧头和帧尾,用于帮助STM32从数据流中准确截取一帧有效数据。ID就是识别出的物体编号(1,2,3)。这种加头尾的方式是串口通信中防止数据错位的常见手段。
  2. 波特率设置:UART(3, 115200) 这里的波特率必须和STM32端设置的波特率完全一致,否则接收到的全是乱码。115200是常用值,通信速度够快且稳定。
  3. 调试方法:在连接STM32之前,可以先用OpenMV IDE自带的“串口终端”工具进行测试。运行上述代码,按下按键,看看终端里是否打印出了正确的识别结果和发送的数据包(如 [165, 1, 166])。这能先确保OpenMV端工作正常。
  4. 识别不准怎么办:如果识别率低,首先回去检查数据集图片质量。其次,可以调整 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. 系统联调与实战问题排查

代码都写好了,硬件也连上了,最后一步就是上电联调。这一步往往是问题集中爆发的地方,别慌,按照以下步骤和思路来,大部分问题都能解决。

第一步:分模块调试,各个击破。

  1. 单独测试OpenMV:不接STM32,只给OpenMV上电,用USB连接电脑。在OpenMV IDE中运行物体识别脚本,通过IDE的串口终端和帧缓冲区查看识别结果是否正确。同时,用逻辑分析仪或者另一个USB转TTL模块(连接到电脑串口助手)监听OpenMV的TX(P4)引脚,看是否有符合协议(0xA5, XX, 0xA6)的数据包发出。这一步确保OpenMV的“输出”是正常的。
  2. 单独测试STM32的串口接收:暂时注释掉OpenMV的识别代码,只保留发送固定数据包(比如循环发送 0xA5, 0x01, 0xA6)的代码。将OpenMV与STM32连接,在STM32程序中,将接收到的数据通过其另一个串口(如USART1)打印到电脑的串口助手。观察是否能稳定收到完整的数据帧。这一步确保STM32的“输入”和解析逻辑是正常的。
  3. 单独测试STM32的LCD显示:写一个简单的测试程序,让LCD循环显示几个固定的字符串,确保屏幕本身和驱动没问题。

第二步:全系统联调。 将完整的OpenMV识别程序和STM32程序分别烧录,连接好线路,上电。按一下OpenMV的按键,观察LCD屏幕。

常见问题与排查清单:

  • 问题1:LCD屏无任何显示。
    • 检查:STM32电源指示灯亮了吗?LCD背光亮了吗(有些屏需要单独控制背光)?LCD排线是否插紧?LCD_Init()函数里的初始化序列是否与你的屏幕型号匹配?
  • 问题2:LCD一直显示“Waiting for Data...”或“Unknown”,不更新。
    • 检查:这是最典型的问题,说明通信没成功。
      1. 物理连接:TX和RX是否交叉连接?GND是否共地?用万用表通断档检查连线。
      2. 波特率:双方代码里的波特率是否都是115200?一个字节都不能错。
      3. 协议解析:在STM32的中断里,把接收到的每一个原始字节都通过调试串口打印出来(printf 十六进制格式)。看看是否收到了数据?数据是不是 A5 XX A6 的格式?帧头帧尾是否正确?这能直接定位是传输问题还是解析问题。
      4. 中断优先级:如果STM32程序里还有其他中断(如定时器中断),要确保串口接收中断的优先级设置合理,不会被长时间阻塞。
  • 问题3:识别结果时对时错。
    • 检查:回到OpenMV端。检查数据集图片质量、光线环境是否与当前识别环境差异过大。尝试在 object_detection() 函数里打印出 pmin(最小差异度)的值,如果这个值非常大(比如接近初始值999999),说明当前画面与所有样本都不匹配,可能是物体不在画面中央、大小不合适或光线太暗。
    • 优化:可以设置一个阈值。只有当 pmin 小于某个经验值(比如50000)时,才认为识别有效,否则显示“Unknown”。这能过滤掉一些误识别。
  • 问题4:系统运行一段时间后死机。
    • 检查:重点排查内存泄漏和中断冲突。在STM32的串口接收中断中,避免使用 printf、sprintf 等耗时长的函数。确保临时缓冲区不会溢出。检查堆栈大小是否设置合理。

这个项目虽然小,但涵盖了嵌入式视觉系统从感知到执行的全链条。当你按下按键,看到LCD屏上准确显示出物体名称的那一刻,那种把代码和硬件“打通”的快乐,是纯软件编程难以比拟的。希望这份详细的指南能帮你少走弯路。在实际动手时,最关键的就是耐心和细致的调试,每一个灯、每一个打印信息都是你解决问题的线索。如果遇到卡住的地方,不妨放一放,从头再理一遍流程,或者去论坛社区搜搜类似的问题,你踩的坑,很可能别人也踩过。

更多推荐