
简介基于STM32f407与OpenMV的激光打靶源码包面向嵌入式、机器视觉及自动化控制方向的开发者与电子设计竞赛备赛者。系统借助OpenMV识别矩形或圆形目标由STM32f407通过闭环算法驱动步进电机带动激光笔在图形区域内循迹并能判断目标距离、计算图形面积适合模拟训练与二次开发。代码中封装了USART、OLED、LCD、MPU6050、PWM、键盘及超声波等常用外设固件库读者可直接调用快速搭建原型。资源共277个文件主要包括55个头文件与52个C源代码辅以Keil工程配置uvprojx/uvoptx、编译生成的axf/hex/map、调试信息crf、o、d和Python脚本等压缩包约12.22MB目录结构完整。预览中可见标准外设库stm32f4xx_tim.c、stm32f4xx_adc.c等与MPU6050驱动便于理解系统底层组织。已有104人学习适合具备一定STM32与OpenMV基础、希望获取完整工程参考的开发者。1. 从 OpenMV 看到靶纸到激光命中中间隔着一条标定链路激光打靶设备初看是玩具拆开就是一个视觉引导的双轴运动系统OpenMV 找到靶心和激光光斑的像素坐标STM32F407 把像素误差换算成步进电机的脉冲数步进电机带动激光器指向目标最后点亮激光完成出光。多数人卡住的不在电机驱动也不在 OpenMV 识别而在像素坐标到步数的映射镜头安装角度、靶纸倾斜、XY 平台回程间隙都会让看到和打到对不上。这套方案定位中等精度靶环适合课程设计、电子竞赛和小型娱乐设备复用。F407 的实时性兜底思路很明确串口收坐标、定时器发脉冲、方向控制、脉冲计数全部交给硬件外设主循环只做控制逻辑。源码结构按视觉识别、串口协议、脉冲控制、标定补偿四层展开下面逐层把每段代码的参数依据和改动方向讲清楚。2. OpenMV 端的靶心识别与激光光斑检测2.1 选 OpenMV 而不是树莓派加摄像头的理由打靶系统只有两路识别任务OpenMV 的 Micropython 环境比树莓派加 OpenCV 的开发链路短得多。树莓派的优势在复杂图像理解但这里没有场景分割、目标分类这类需求颜色阈值加亮度阈值就能完成目标提取在 IDE 里对着实物调参数比在 SSH 里改脚本再重启服务快一个量级。另一个常被忽略的点是启动时间PC 方案从上电到画面稳定要十几秒OpenMV 三秒内完成自检打靶设备每次开机的等待体验差别很大。2.2 靶心检测颜色阈值筛选加最大色块确认靶心做成红色比黑色好检红色在 Lab 颜色空间和激光光斑的白色高亮区域分得很开两路识别逻辑不会互相串扰。以红色靶心为例import sensor, image, time, ustruct from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240打靶分辨率足够 sensor.skip_frames(time2000) sensor.set_auto_whitebal(False) # 关键关白平衡否则阈值随色温漂移 # 阈值在 IDE 的 Threshold Editor 里对着红色靶心取 RED_THRESHOLD (30, 90, 15, 60, 10, 55) ROI (40, 60, 240, 180) # 只处理画面中间区域 def find_target(): img sensor.snapshot() blobs img.find_blobs([RED_THRESHOLD], roiROI, pixels_threshold80) if not blobs: return None b max(blobs, keylambda x: x.area()) # 取面积最大的色块 img.draw_rectangle(b.rect()) img.draw_cross(b.cx(), b.cy()) return (b.cx(), b.cy())pixels_threshold80过滤面积小于 80 像素的噪声依据是靶心在 QVGA 画面里的最小投影100mm 靶纸放在 60mm 距离时靶心约 900 像素留了十倍余量阈值太小会放进反光噪点太大则远距离目标直接丢失。max(blobs, keylambda x: x.area())取最大色块用来避开靶纸印刷文字或污渍的干扰大面积目标在亮度波动下最稳定。set_auto_whitebal(False)是稳定性的关键自动白平衡会让红色阈值随环境色温漂移同一张靶纸在日光和 LED 灯下测出的 Lab 阈值相差很大打靶系统又常在室内外切换场地这行必须写。2.3 激光光斑检测亮度阈值加质心计算激光在图像里是极亮的白色斑块灰度值通常超过 220与靶纸反射亮部有明显断层。识别时先转灰度再按亮度阈值取块def find_laser(): img sensor.snapshot().to_grayscale() blobs img.find_blobs([(220, 255)], pixels_threshold3, mergeTrue) if not blobs: return None b max(blobs, keylambda x: x.area()) return (b.cx(), b.cy())光斑的pixels_threshold只取 3弱激光器在远靶距时光斑可能只有几个像素阈值设大了直接检不到。mergeTrue把光斑高亮核心和边缘半亮区合并成一个整体质心计算才不会被中心空洞拉偏。两个识别任务的关键差异可以这样对比识别对象颜色空间关键阈值最小像素红色靶心RGB565Lab 红色分量80激光光斑灰度亮度大于 2203如果现场光照很强把激光检测换成固定曝光sensor.set_auto_exposure(False)后手动调曝光值否则强光下整个画面亮度抬高220 的亮度阈值会大片误检。两个函数在同一个主循环里交替执行每调用一次 snapshot 都会拿一张新图整体帧率约 35fps激光点运动时每帧位移只有 2~3 像素瞄准更新率足够。3. OpenMV 与 STM32F407 的串口协议与状态机解析3.1 帧格式设计帧头帧尾加累加和校验两芯片用 UART 通信波特率 115200。这个档位对 20cm 以内的板间连线足够稳定一帧数据几十字节1ms 内传完不会成为帧率瓶颈。协议要解决的是边界识别和粘包固定帧头帧尾加校验是最常见的做法字段长度说明帧头1 字节0xAA数据长度1 字节载荷字节数固定 8类型1 字节0x01 表示坐标帧数据区8 字节目标x、目标y、激光x、激光y各 2 字节大端校验1 字节数据区字节累加和取低 8 位帧尾1 字节0x55帧长固定 13 字节解析按状态机逐字节推进不依赖定时判断也不会出现等不到结束符卡死的问题。坐标用有符号 16 位覆盖 QVGA 的 320x240 有余用大端序是为了 C 端能直接按地址把缓冲区转成 int16_t 数组省去字节换序。注意校验字节必须单独占一个接收状态不能混进数据区累加否则最后一个数据字节会被多计算一次校验必然失败。3.2 OpenMV 端组帧与发送uart UART(3, 115200, timeout_char1000) # UART3 对应 P4/P5按板型查引脚图 def send_coord(tx, ty, lx, ly): data ustruct.pack(hhhh, tx, ty, lx, ly) checksum sum(data) 0xFF frame bytes([0xAA, len(data), 0x01]) data bytes([checksum, 0x55]) uart.write(frame) while True: t find_target() l find_laser() if t and l: send_coord(t[0], t[1], l[0], l[1]) time.sleep_ms(10)ustruct.pack(hhhh, ...)把四个坐标打包成 8 字节表示大端h是有符号 16 位整数。校验只对数据区求和帧头帧尾不参与串口线上的干扰一旦改动了某个数据字节累加和就对不上F407 会整帧丢弃不会把错误坐标喂给电机。主循环里find_target和find_laser分别取自上一章各自内部完成一次 snapshot10ms 的 sleep 保持整体节奏在 30fps 以上。3.3 STM32F407 端的中断状态机接收F407 端用 USART 接收中断加状态机不在主循环里轮询读串口这样视觉帧率和运动控制逻辑互不阻塞。以 USART2 为例volatile uint8_t rx_state 0; volatile uint8_t rx_len 0, rx_cnt 0, rx_sum 0; volatile uint8_t rx_buf[16]; volatile int16_t target_x, target_y, laser_x, laser_y; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) RESET) return; uint8_t byte USART_ReceiveData(USART2); switch (rx_state) { case 0: // 等帧头 if (byte 0xAA) rx_state 1; break; case 1: // 收长度准备收数据区 rx_len byte; rx_cnt 0; rx_sum 0; rx_state 2; break; case 2: // 收数据区并累加校验 rx_buf[rx_cnt] byte; rx_sum byte; if (rx_cnt rx_len) rx_state 3; break; case 3: // 校验字节 if (byte ! (rx_sum 0xFF)) { rx_state 0; break; } rx_state 4; break; case 4: // 帧尾确认后解析 if (byte 0x55) { int16_t *p (int16_t *)rx_buf; target_x p[0]; target_y p[1]; laser_x p[2]; laser_y p[3]; } rx_state 0; break; } }rx_sum在进入数据区前清零数据收满后先到状态 3 比对校验字节再进状态 4 等帧尾。代码里没有阻塞延时115200 波特率下中断频率约 1.15 万次/秒中断函数只有状态切换和赋值F407 的负载可以忽略。解析结果放在全局变量主循环在每个控制周期开始时读一次读到的一定是完整的最新坐标帧不会出现半包数据参与控制的情况。4. STM32F407 的步进电机脉冲控制与像素映射4.1 定时器 PWM 发脉冲方向引脚独立控制步进电机控制的本质是喂脉冲串一个脉冲走一个步距角方向由 DIR 电平决定。F407 用定时器 PWM 输出 STEP 信号比 GPIO 翻转省 CPU频率由硬件保证不受中断延迟影响。驱动器用常见的 A4988 或 DRV8825 模块都只认 STEP/DIR 两路信号方向引脚用独立普通 GPIO在启动脉冲之前先置好电平。细分由驱动器跳线决定细分越大每转脉冲数越多、单步越细。打靶常用 8 细分0.225° 的单步角度对厘米级靶环足够还留出了速度余量细分每转脉冲数单步角度适用场景12001.8°高速大行程精度差816000.225°打靶常用速度和精度均衡1632000.1125°低速精密定位以 X 轴为例STEP 接 TIM1_CH1PA8DIR 接任意普通 GPIO。TIM1 挂在 APB2定时器时钟 84MHz先预分频到 1MHz 再算周期void TIM1_PWM_Init(uint32_t pwm_freq) // pwm_freq 单位 Hz { GPIO_InitTypeDef g; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); g.GPIO_Pin GPIO_Pin_8; // TIM1_CH1 - PA8 g.GPIO_Mode GPIO_Mode_AF; g.GPIO_Speed GPIO_Speed_50MHz; g.GPIO_OType GPIO_OType_PP; g.GPIO_PuPd GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, g); GPIO_PinAFConfig(GPIOA, GPIO_PinSource8, GPIO_AF_TIM1); tb.TIM_Prescaler 83; // 84MHz / 84 1MHz tb.TIM_Period (uint16_t)(1000000 / pwm_freq - 1); tb.TIM_ClockDivision 0; tb.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, tb); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse (1000000 / pwm_freq) / 2; TIM_OC1Init(TIM1, oc); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开主输出 TIM_Cmd(TIM1, ENABLE); }预分频 83 把计数频率降到 1MHz周期值1000000 / pwm_freq - 1pwm_freq 取 1000 时输出 1kHz 方波即每秒 1000 个脉冲对应 8 细分 42 步进电机约每秒半转打靶移动速度合适。占空比 50% 对驱动器不重要驱动器只看上升沿但对称波形能保证驱动器输入端的光耦在高低电平间可靠切换。TIM_CtrlPWMOutputs是 TIM1 这类高级定时器的专属要求忘了写 PWM 波形完全出不来排查走线前先检查这行。提示代码用标准外设库书写HAL 库搭建时对应HAL_TIM_PWM_Start和句柄里的__HAL_TIM_SET_COMPARE配置思路完全一致。脉冲计数用更新中断实现每产生一个脉冲进一次中断剩余步数归零就关定时器。1kHz 脉频对应每秒 1000 次中断不会挤占 F407 的时间片volatile uint32_t step_remain 0; void TIM1_UP_TIM10_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) RESET) return; TIM_ClearITPendingBit(TIM1, TIM_IT_Update); if (step_remain 1) { step_remain--; } else { TIM_Cmd(TIM1, DISABLE); // 发完最后一个脉冲就停 } }step_remain是剩余脉冲数主循环在启动前赋值目标步数并使能定时器。判断条件用 1而不是 0是因为 PWM 的更新事件和实际输出边沿有一个周期的延迟提前一个脉冲关断才能保证输出脉冲总数和设定值一致。别忘了在 NVIC 里使能TIM1_UP_TIM10_IRQnY 轴用同样方法挂在 TIM2 上两路独立计数、独立启停。4.2 像素偏差到步数的换算与分次逼近OpenMV 发来像素坐标电机需要步数中间是线性映射电机每走 N 步、激光点在画面移动 M 像素N/M 就是单位像素的脉冲数。取画面中心 (160, 120) 为零点目标坐标减中心得像素偏差乘以标定系数得到目标步数#define PULSE_PER_PIXEL_X 8.6f // X 轴标定系数单位脉冲/像素 #define PULSE_PER_PIXEL_Y 7.2f // Y 轴标定系数 void process_coord(int16_t tx, int16_t ty) { int16_t dx tx - 160; // QVGA 中心 x int16_t dy ty - 120; // QVGA 中心 y int32_t steps_x (int32_t)(dx * PULSE_PER_PIXEL_X); int32_t steps_y (int32_t)(dy * PULSE_PER_PIXEL_Y); set_dir_x(steps_x 0 ? DIR_POS : DIR_NEG); set_dir_y(steps_y 0 ? DIR_POS : DIR_NEG); start_pulses_x(abs(steps_x)); // 只取绝对值方向已由 GPIO 决定 start_pulses_y(abs(steps_y)); }这里有一个方案分支一次算完总步数走到底还是分次逼近。打靶场景推荐后者。机械回程间隙和光斑检测噪声会让每帧偏差抖动几个像素一步到位会把抖动放大成过冲分次逼近每轮只走当前偏差的一部分视觉更新一轮约 30ms三到四轮收敛总耗时不到 150ms。逼近比例取 0.6~0.8比例太小收敛慢太大接近一步到位反而失去分次的意义。5. 标定、回程误差补偿与出光时机验证5.1 三点标定把脉冲/像素系数从猜变成算PULSE_PER_PIXEL_X不能靠猜。我一般做三点标定靶纸放上平台手动控制电机让激光分别对准画面左上、中心、右下三个点每对准一个点记录电机步数和光斑像素坐标用两点间的步数差除以像素差得到该轴系数。粗算一行就够# X 轴两点标定步数差 / 像素差 k_x (step_left - step_center) / (px_left - 160)多取几组求平均系数能稳定到小数点后一位。如果镜头有透视畸变像素位移和步数就非线性需要在 OpenMV 端对 snapshot 加img.lens_corr(1.8)预校正再送坐标给 F407。5.2 回程误差单向逼近抵消机械间隙丝杠或同步带正反转存在间隙正转到位后掉头反转头几步其实在吃间隙光斑不动。最简单的补偿是规定激光最终总从左侧逼近 X 目标、从上方逼近 Y 目标偏差方向要求反向走就先反向往回退过头再正向走回来命中保证最后一小段方向恒定。换向时先停脉冲、切方向、延时 1ms 再启动这 1ms 在吃间隙阶段尤其重要方向刚切换就发脉冲最容易丢步。5.3 出光时机与验证方法激光点亮时机不在电机到位瞬间而是连续三帧确认激光坐标与目标坐标偏差小于 3 像素且偏差不再递减再延时 50ms 等机械停稳。这 50ms 能避免电机停止瞬间的残余震荡被 OpenMV 误判为稳定出光点偏移的问题经常出在这个细节。验证时把目标坐标、激光坐标和步数通过第二个串口或 OLED 打出来观察偏差收敛曲线每轮更新偏差应单调缩小最终稳定在 3 像素内。收敛慢检查 ROI 是否太宽、逼近比例是否过小出现震荡降低步进速度上限、把死区从 2 像素放宽到 4 像素。系统验证通过后再点亮激光出光激光器和视觉对齐之前反复照射靶纸既伤镜头镀膜也没有调试价值。本文还有配套的精品资源点击获取