ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32开发学习笔记之九【摄像头图像预处理】

STM32开发学习笔记之九【摄像头图像预处理】

STM32开发学习笔记之九【摄像头图像预处理】

前言

为了在STM32H743上实现手写数字识别,我们需要对摄像头输入的画面做一定的预处理,把摄像头图像变成神经网络能用的28×28二值图,并用USB虚拟串口把处理结果送到电脑上位机进行调试。前置的CubeMX建工程、LCD和CAM的BSP配置等内容请参考教程八,本文不再重复。

本文中不涉及MDK的下载及安装,需要的请到https://www.keil.com/download/product/自行下载安装。注意!不要下载最新版,有可能存在问题!

一、用STM32CubeMX配置USB虚拟串口

本案例使用STM32H743自带的USB虚拟串口,打开笔记八做好的cam工程,按下图配置。

(一)打开USB_OTG_FS外设

在左侧【Connectivity】栏找到【USB_OTG_FS】,模式选择【Device Only】。选择模式后CubeMX会自动把PA11分配给USB_OTG_FS_DM、PA12分配给USB_OTG_FS_DP,在NVIC Settings中勾选上中断,其它参数使用默认值。

(二)配置USB_DEVICE为CDC虚拟串口

在左侧【Middleware】栏选中【USB_DEVICE】,【Class For FS IP】选择【CDC】,也就是Virtual Com Port虚拟串口,这样电脑插上USB后就会枚举出一个COM口。

(三)时钟

本学习笔记在教程八里配好的时钟树基础上,还需要配置USB外设时钟,配置为48MHz。

(四)生成工程文件

因为在学习笔记八我们已经保存过项目工程,因此,配置完成后点击【GENERATE CODE】更新工程。点击 【Open Project】 打开 MDK 工程(如果是中文路径则会报错)。

二、STM32系统C程序设计

(一)编写图像预处理程序

1. image_process.h头文件

新建空白文件,将以下代码输入并命名为image_process.h,保存在【core\inc】文件夹

#ifndefIMAGE_PREPROCESS_H#defineIMAGE_PREPROCESS_H#include<stdint.h>#definePP_WIDTH56// 预处理工作区(从120x80中裁剪)#definePP_HEIGHT56#definePP_PIXELS(PP_WIDTH*PP_HEIGHT)#defineMODEL_W28// 神经网络输入(MNIST风格)#defineMODEL_H28#defineBG_WINDOW15// 背景估计窗口:奇数,大于笔画宽度externuint8_tImage_Binary28[MODEL_W*MODEL_H];// 输出:28x28二值图externuint8_tImage_BinaryThreshold;// 本帧阈值,排查画面异常用voidImage_PreprocessFrame(constuint8_t*rgb565);#endif
2. image_process.c文件

预处理部分通过static定义了三个缓冲区存放单步处理后的结果,再分别通过灰度化、形态学滤波、Otsu阈值二值化和降采样对图像进行简单预处理,保证输出图像能被模型准确的识别。新建BSP/Process文件夹,右击在文件夹下新建image_process.c文件并粘贴以下代码。

#include"image_process.h"uint8_tImage_Binary28[MODEL_W*MODEL_H];// 输出:28x28二值图uint8_tImage_BinaryThreshold;// 本帧Otsu阈值,调试用staticuint8_tGray[PP_PIXELS];// 灰度图(后续复用为差分图、二值图)staticuint8_tBackground[PP_PIXELS];// 背景图staticuint8_tTemp[PP_PIXELS];// 滤波临时缓冲staticuint32_tHist[256];// 差分图直方图// ① RGB565转灰度:0.299R+0.587G+0.114B,用77/150/29整数近似staticvoidToGray(constuint8_t*rgb565){for(uint32_ti=0;i<PP_PIXELS;i++){uint16_tv=(rgb565[2*i]<<8)|rgb565[2*i+1];uint32_tr=((v>>11)&31)*255/31;uint32_tg=((v>>5)&63)*255/63;uint32_tb=(v&31)*255/31;Gray[i]=(uint8_t)((77*r+150*g+29*b+128)>>8);}}// ② 可分离形态学滤波:vertical=1按列扫,maximum=1取最大(膨胀)、取最小(腐蚀)// 横竖各扫一遍,等效于方形窗口,比直接二维窗口快十几倍staticvoidMorphFilter(constuint8_t*src,uint8_t*dst,intvertical,intmaximum){intr=BG_WINDOW/2;intlimit=vertical?PP_HEIGHT:PP_WIDTH;for(inty=0;y<PP_HEIGHT;y++)for(intx=0;x<PP_WIDTH;x++){uint8_tv=maximum?0:255;for(intk=-r;k<=r;k++){intc=(vertical?y:x)+k;if(c<0)c=0;// 越界时复制边缘像素if(c>=limit)c=limit-1;uint8_ts=src[vertical?c*PP_WIDTH+x:y*PP_WIDTH+c];if(maximum?s>v:s<v)v=s;// 取窗口内最大/最小值}dst[y*PP_WIDTH+x]=v;}}// ③ Otsu阈值:遍历0~254,让黑白两类类间方差最大的阈值就是最优解staticuint8_tOtsu(void){uint32_tt,total=0,darkCnt=0,darkSum=0;doublebest=-1.0;uint8_tth=127;for(t=0;t<256;t++)total+=t*Hist[t];for(t=0;t<255;t++){uint32_tlightCnt;doubled,score;darkCnt+=Hist[t];darkSum+=t*Hist[t];lightCnt=PP_PIXELS-darkCnt;if(darkCnt==0||lightCnt==0)continue;d=(double)darkSum*PP_PIXELS-(double)total*darkCnt;score=d*d/((double)darkCnt*lightCnt);if(score>best){best=score;th=(uint8_t)t;}}returnth;}// ④ 2x2多数投票降采样:小块里白点不少于2个取白,笔画连续性保得住staticvoidDownsample28(void){for(uint32_ty=0;y<MODEL_H;y++)for(uint32_tx=0;x<MODEL_W;x++){uint32_tp=2*y*PP_WIDTH+2*x;uint32_tsum=Gray[p]+Gray[p+1]+Gray[p+PP_WIDTH]+Gray[p+PP_WIDTH+1];Image_Binary28[y*MODEL_W+x]=sum>=510?255:0;}}// 主函数:把四步串起来,输出Image_Binary28(28x28,白笔画黑背景)voidImage_PreprocessFrame(constuint8_t*rgb565){uint32_ti;ToGray(rgb565);// 灰度化MorphFilter(Gray,Temp,0,1);// 横向最大MorphFilter(Temp,Background,1,1);// 纵向最大(膨胀完成)MorphFilter(Background,Temp,0,0);// 横向最小MorphFilter(Temp,Background,1,0);// 纵向最小(腐蚀完成,Background=干净背景)for(i=0;i<256;i++)Hist[i]=0;for(i=0;i<PP_PIXELS;i++)// 背景差分,顺带统计直方图{Gray[i]=Background[i]>Gray[i]?Background[i]-Gray[i]:0;Hist[Gray[i]]++;}Image_BinaryThreshold=Otsu();// 自动求阈值for(i=0;i<PP_PIXELS;i++)// 二值化:暗笔画变白Gray[i]=Gray[i]>Image_BinaryThreshold?255:0;Downsample28();// 56x56 -> 28x28}

(二)改写myGlobal.h中的代码

在myGlobal.h中增加以下代码,学习笔记八中图像显存是120×80整幅送屏幕的,现在预处理只要中间56×56,所以先在myGlobal.h里把裁剪区域定下来。120减56除以2等于32,80减56除以2等于12,正好是居中裁剪的起点:

// 从120x80画面中裁剪56x56中心区域(水平/垂直方向居中)#defineCROP_X32#defineCROP_Y12#defineCROP_W56#defineCROP_H56

(三)改写lcd.c中的代码

在lcd.c文件头部加上三个头文件:

#include"string.h"#include"image_process.h"#include"usbd_cdc_if.h"

再加一个裁剪缓冲区,注意要放在函数外:

staticuint8_tcrop56[56*56*2];

改写显示函数LCD_DispImg:将原来地整幅120×80送屏幕,改为居中显示→预处理→USB串口查看:

voidLCD_DispImg(uint8_tx_img){if(DCMI_FrameIsReady){//ST7735_FillRGBRect(&st7735_pObj, x_img, 0, IMGBUF[1], IMGBUFWIDTH, IMGBUFHEIGHT); // 原:整幅120x80送LCD// 帧回调已把56x56裁剪区拷到IMGBUF[1]开头,这里直接取用// 1) 56x56居中显示在128x160的LCD上ST7735_FillRGBRect(&st7735_pObj,(ST7735Ctx.Width-56)/2,(ST7735Ctx.Height-56)/2,IMGBUF[1],56,56);// 2) 对裁剪区做预处理,产出Image_Binary28(28x28)Image_PreprocessFrame(IMGBUF[1]);// 3) 每5帧通过USB CDC上报一帧28x28二值图staticuint32_tusb_send_cnt=0;if(++usb_send_cnt>=5){usb_send_cnt=0;staticchartx_buf[2048];intidx=0;// 帧头memcpy(tx_buf,"===FRAME===\r\n",13);idx=13;// 784个二值值,逗号分隔for(inti=0;i<784;i++){tx_buf[idx++]=Image_Binary28[i]>128?'1':'0';if(i<783)tx_buf[idx++]=',';}// 帧尾,一次性发送tx_buf[idx++]='\r';tx_buf[idx++]='\n';CDC_Transmit_FS((uint8_t*)tx_buf,idx);}LCD_ShowString(125,4,ST7735Ctx.Width,16,16,TXTBUF[0]);LCD_ShowString(125,22,ST7735Ctx.Width,16,16,TXTBUF[1]);LCD_ShowString(125,40,ST7735Ctx.Width,16,16,TXTBUF[2]);DCMI_FrameIsReady=0;// 交接棒下半段:用完了,中断可以拷下一帧}}

(四)改写main.c程序

学习笔记八中的DCMI帧回调只把中间80列拷进显示缓冲区,现在显示和预处理都只要中间56×56,只拷裁剪区。

if(count%2==0&&DCMI_FrameIsReady==0)// 上一帧被取走才拷新的{// 只拷56x56裁剪区:每行112字节,源行首=((CROP_Y+i)*120+CROP_X)*2for(inti=0;i<CROP_H;i++)memcpy(&IMGBUF[1][i*CROP_W*2],&IMGBUF[0][((CROP_Y+i)*IMGBUFWIDTH+CROP_X)*2],CROP_W*2);DCMI_FrameIsReady=1;// 主循环来取}

同时在死循环中写入显示函数:

LCD_DispImg(20);

三、编译下载与验证

点击编译,确认下载器连接正常后点击download下载,按下复位键开发板就可以工作了。屏幕中央出现56×56的裁剪画面,右上角FPS数字照常刷新。

到这里主流程就结束了,程序会每隔5帧通过USB向电脑发一帧数据。我们可以通过python脚本直接观测预处理后的效果。

四、用电脑上位机查看预处理结果

(一)安装Python依赖

本文需要用到Python中三个库,命令行里执行:

pip install pyserial numpy matplotlib

(二)查看COM口号

USB线插上电脑,打开【设备管理器】→【端口(COM和LPT)】,会多出一个"USB串行设备(COMx)",记住这个口的号码。

(三)编写并运行python脚本

新建python脚本文件,该脚本的接收思路是按行读串口,用正则把每行里的0和1抠出来,凑够784个就还原成28×28画出来(PC_test.py):

importserialimportnumpyasnpimportmatplotlib.pyplotaspltimporttimeimportre PORT='COMX'# 改成CDC枚举出的COM口BAUD=115200# CDC虚拟串口无实际意义,任意值try:# 注意:把 timeout 改小,这样 readline 不会卡死ser=serial.Serial(PORT,BAUD,timeout=0.1)ser.reset_input_buffer()# 清空串口里积压的乱七八糟的数据print(f"已打开{ser.name}")exceptExceptionase:print(f"串口打开失败:{e}")exit()plt.ion()fig,ax=plt.subplots(figsize=(4,4))im=ax.imshow(np.zeros((28,28)),cmap='gray',vmin=0,vmax=1)ax.set_title('waiting for data...')plt.tight_layout()plt.show(block=False)frame_count=0try:whileTrue:# 按行读取,遇到 \n 就会返回line=ser.readline()ifnotline:continue# 转成字符串(忽略解析不了的乱码字符)text=line.decode('utf-8',errors='ignore')# 只要这行是以逗号隔开的 0 和 1,我们就用正则把所有的数字抠出来if','intext:# 找出所有的 0 和 1vals=re.findall(r'[01]',text)# 如果找出来的数量刚好是 784 个iflen(vals)==784:# 转成 numpy 数组并 reshapearr=np.array([float(v)forvinvals],dtype=np.float32)arr_2d=arr.reshape(28,28)frame_count+=1print(f"成功解析帧{frame_count}")# 更新图像im.set_data(arr_2d)ax.set_title(f'frame{frame_count}')fig.canvas.draw()# 强制刷新窗口,防止卡死fig.canvas.flush_events()exceptKeyboardInterrupt:print(f"\n结束。共收到{frame_count}帧")finally:ser.close()plt.ioff()plt.close()

运行该脚本,就能在电脑上实时显示预处理之后的效果啦。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进