1)实验平台:正点原子MiniPro H750开发板
2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560
3)全套实验源码+手册+视频下载地址:http://www.openedv.com/thread-336836-1-1.html
4)对正点原子STM32感兴趣的同学可以加群讨论:879133275

第五十一章 视频播放器实验

本章我们将继续学习使用STM32H750自带的硬件JPEG编解码器,用于播放AVI视频(MJPEG编码),实现一个简单的视频播放器。
本章分为如下几个小节:
51.1 AVI简介
51.2 硬件设计
51.3 程序设计
51.4 下载验证

51.1 AVI简介
本章,我们使用STM32H7的硬件JPEG解码器,来实现MJPG编码的AVI格式视频播放,硬件JPEG解码器前面的实验已经介绍过了。接下来给大家简单介绍一下AVI格式。
AVI是音频视频交错(Audio Video Interleaved)的英文缩写,它是微软开发的一种符合RIFF文件规范的数字音频与视频文件格式,原先用于Microsoft Video for Windows (简称VFW)环境,现在已被多数操作系统直接支持。
AVI格式允许视频和音频交错在一起同步播放,支持256色和RLE压缩,但AVI文件并未限定压缩标准,AVI仅仅是一个容器,用不同压缩算法生成的AVI文件,必须使用相应的解压缩算法才能播放出来。比如本章,我们使用的AVI,其音频数据采用16位线性PCM格式(未压缩),而视频数据,则采用MJPG编码方式。
在介绍AVI文件前,我们要先来看看RIFF文件结构。AVI文件采用的是RIFF文件结构方式,RIFF(Resource Interchange File Format,资源互换文件格式)是微软定义的一种用于管理WINDOWS环境中多媒体数据的文件格式,波形音频WAVE,MIDI和数字视频AVI都采用这种格式存储。构造RIFF文件的基本单元叫做数据块(Chunk),每个数据块包含3个部分,
1、4字节的数据块标记(或者叫做数据块的ID)
2、数据块的大小
3、数据
整个RIFF文件可以看成一个数据块,其数据块ID为RIFF,称为RIFF块。一个RIFF文件中只允许存在一个RIFF块。RIFF块中包含一系列的子块,其中有一种子块的ID为"LIST",称为LIST块,LIST块中可以再包含一系列的子块,但除了LIST块外的其他所有的子块都不能再包含子块。
RIFF和LIST块分别比普通的数据块多一个被称为形式类型(Form Type)和列表类型(List Type)的数据域,其组成如下:
1、4字节的数据块标记(Chunk ID)
2、数据块的大小
3、4字节的形式类型或者列表类型(ID)
4、数据
下面我们看看AVI文件的结构。AVI文件是目前使用的最复杂的RIFF文件,它能同时存储同步表现的音频视频数据。AVI的RIFF块的形式类型(Form Type)是AVI,它一般包含3个子块,如下所述:
1、信息块,一个ID为"hdrl"的LIST块,定义AVI文件的数据格式。
2、数据块,一个ID为 "movi"的LIST块,包含AVI的音视频序列数据。
3、索引块,ID为"idxl"的子块,定义"movi"LIST块的索引数据,是可选块(不一定有)。
接下来,我们详细介绍下AVI文件的各子块构造,AVI文件的结构如图51.1.1所示:

图51.1.1 AVI文件结构图
从上图可以看出(注意‘AVI ’,是带了一个空格的),AVI文件,由:信息块(HeaderList)、数据块(MovieList)和索引块(Index Chunk)等三部分组成,下面,我们分别介绍这几个部分。
1、信息块(HeaderList)
信息块,即ID为“hdrl”的LIST块,它包含文件的通用信息,定义数据格式,所用的压缩算法等参数等。hdrl块还包括了一系列的字块,首先是:avih块,用于记录AVI的全局信息,比如数据流的数量,视频图像的宽度和高度等信息,avih块(结构体都有把BlockID和BlockSize包含进来,下同)的定义如下:

/* avih 子块信息 */
typedef struct
{uint32_t BlockID;                  /* 块标志:avih==0X61766968 */uint32_t BlockSize;/*块大小(不包含最初8字节,也就是BlockID和BlockSize不计算在内*/uint32_t SecPerFrame;          /* 视频帧间隔时间(单位为us) */uint32_t MaxByteSec;            /* 最大数据传输率,字节/秒 */uint32_t PaddingGranularity;  /* 数据填充的粒度 */uint32_t Flags;                    /* AVI文件的全局标记,比如是否含有索引块等 */uint32_t TotalFrame;              /* 文件总帧数 */uint32_t InitFrames;             /* 为交互格式指定初始帧数(非交互格式应该指定为0)*/uint32_t Streams;                /* 包含的数据流种类个数,通常为2 */uint32_t RefBufSize;/* 建议读取本文件的缓存大小(应能容纳最大的块)默认可能是1M字节*/uint32_t Width;                  /* 图像宽 */uint32_t Height;                   /* 图像高 */uint32_t Reserved[4];          /* 保留 */
} AVIH_HEADER;

这里有很多我们要用到的信息,比如SecPerFrame,通过该参数,我们可以知道每秒钟的帧率,也就知道了每秒钟需要解码多少帧图片,才能正常播放。TotalFrame告诉我们整个视频有多少帧,结合SecPerFrame参数,就可以很方便计算整个视频的时间了。Streams告诉我们数据流的种类数,一般是2,即包含视频数据流和音频数据流。
在avih块之后,是一个或者多个strl子列表,文件中有多少种数据流(即前面的Streams),就有多少个strl子列表。每个strl子列表,至少包括一个strh(Stream Header)块和一个strf(Stream Format)块,还有一个可选的strn(Stream Name)块(未列出)。注意:strl子列表出现的顺序与媒体流的编号(比如:00dc,前面的00,即媒体流编号00)是对应的,比如第一个strl子列表说明的是第一个流(Stream 0),假设是视频流,则表征视频数据块的四字符码为“00dc”,第二个strl子列表说明的是第二个流(Stream 1),假设是音频流,则表征音频数据块的四字符码为“01dw”,以此类推。
先看strh子块,该块用于说明这个流的头信息,定义如下:

/* strh 流头子块信息(strh∈strl) */
typedef struct
{uint32_t BlockID;       /* 块标志:strh==0X73747268 */
/* 块大小(不包含最初的8字节,也就是BlockID和BlockSize不计算在内) */
uint32_t BlockSize;uint32_t StreamType;/*数据流种类,vids(0X73646976):视频;auds(0X73647561):音频*/uint32_t Handler;   /*指定流的处理者,对于音视频来说就是解码器,比如MJPG/H264之类的*/uint32_t Flags;            /* 标记:是否允许这个流输出?调色板是否变化? */uint16_t Priority;      /* 流的优先级(当有多个相同类型的流时优先级最高的为默认流) */uint16_t Language;      /* 音频的语言代号 */uint32_t InitFrames;       /* 为交互格式指定初始帧数 */uint32_t Scale;            /* 数据量, 视频每桢的大小或者音频的采样大小 */uint32_t Rate;           /* Scale/Rate=每秒采样数 */uint32_t Start;          /* 数据流开始播放的位置,单位为Scale */uint32_t Length;            /* 数据流的数据量,单位为Scale */uint32_t RefBufSize;       /* 建议使用的缓冲区大小 */uint32_t Quality;       /* 解压缩质量参数,值越大,质量越好 */uint32_t SampleSize;    /* 音频的样本大小 */struct                     /* 视频帧所占的矩形 */{short Left;short Top;short Right;short Bottom;} Frame;
} STRH_HEADER;

这里面,对我们最有用的即StreamType 和Handler这两个参数了,StreamType用于告诉我们此strl描述的是音频流(“auds”),还是视频流(“vids”)。而Handler则告诉我们所使用的解码器,比如MJPG/H264等(实际以strf块为准)。
然后是strf子块,不过strf字块,需要根据strh字块的类型而定。
如果strh子块是视频数据流(StreamType=“vids”),则strf子块的内容定义如下:

/* BMP结构体 */
typedef struct
{uint32_t BmpSize;          /* bmp结构体大小,包含(BmpSize在内) */long Width;                 /* 图像宽 */long Height;               /* 图像高 */uint16_t  Planes;          /* 平面数,必须为1 */uint16_t  BitCount;        /* 像素位数,0X0018表示24位 */uint32_t  Compression;    /* 压缩类型,比如:MJPG/H264等 */uint32_t  SizeImage;     /* 图像大小 */long XpixPerMeter;        /* 水平分辨率 */long YpixPerMeter;       /* 垂直分辨率 */uint32_t  ClrUsed;       /* 实际使用了调色板中的颜色数,压缩格式中不使用 */uint32_t  ClrImportant; /* 重要的颜色 */
} BMP_HEADER;/* 颜色表 */
typedef struct
{uint8_t  rgbBlue;          /* 蓝色的亮度(值范围为0-255) */uint8_t  rgbGreen;        /* 绿色的亮度(值范围为0-255) */uint8_t  rgbRed;          /* 红色的亮度(值范围为0-255) */uint8_t  rgbReserved;     /* 保留,必须为0 */
} AVIRGBQUAD;/* 对于strh,如果是视频流,strf(流格式)使STRH_BMPHEADER块 */
typedef struct
{uint32_t BlockID;          /* 块标志,strf==0X73747266 */
uint32_t BlockSize;         /* 块大小(不包含最初的8字节,也就是BlockID
和本BlockSize不计算在内) */BMP_HEADER bmiHeader;       /* 位图信息头 */AVIRGBQUAD bmColors[1];  /* 颜色表 */
} STRF_BMPHEADER;

这里有3个结构体,strf子块完整内容即:STRF_BMPHEADER结构体,不过对我们有用的信息,都存放在BMP_HEADER结构体里面,本结构体对视频数据的解码起决定性的作用,它告诉我们视频的分辨率(Width和Height),以及视频所用的编码器(Compression),因此它决定了视频的解码。本章例程仅支持解码视频分辨率小于屏幕分辨率,且编解码器必须是MJPG的视频格式。
如果strh子块是音频数据流(StreamType=“auds”),则strf子块的内容定义如下:

/* 对于strh,如果是音频流,strf(流格式)使STRH_WAVHEADER块 */
typedef struct
{uint32_t BlockID;          /* 块标志,strf==0X73747266 */
uint32_t BlockSize;         /* 块大小(不包含最初的8字节,也就是BlockID
和本BlockSize不计算在内) */uint16_t FormatTag;         /* 格式标志:0X0001=PCM,0X0055=MP3 */uint16_t Channels;        /* 声道数,一般为2,表示立体声 */uint32_t SampleRate;        /* 音频采样率 */uint32_t BaudRate;       /* 波特率 */uint16_t BlockAlign;       /* 数据块对齐标志 */uint16_t Size;             /* 该结构大小 */
} STRF_WAVHEADER;

本结构体对音频数据解码起决定性的作用,他告诉我们音频信号的编码方式(FormatTag)、声道数(Channels)和采样率(SampleRate)等重要信息。本章例程仅支持PCM格式(FormatTag=0X0001)的音频数据解码。
2、数据块(MovieList)
信息块,即ID为“movi”的LIST块,它包含AVI的音视频序列数据,是这个AVI文件的主体部分。音视频数据块交错的嵌入在“movi”LIST块里面,通过标准类型码进行区分,标准类型码有如下4种:
1,“##db”(非压缩视频帧)、
2,“##dc”(压缩视频帧)、
3,“##pc”(改用新的调色板)、
4,“##wb”(音频帧)。
其中##是编号,得根据我们的数据流顺序来确定,也就是前面的strl块。比如,如果第一个strl块是视频数据,那么对于压缩的视频帧,标准类型码就是:00dc。第二个strl块是音频数据,那么对于音频帧,标准类型码就是:01wb。
紧跟着标准类型码的是4个字节的数据长度(不包含类型码和长度参数本身,也就是总长度必须要加8才对),该长度必须是偶数,如果读到为奇数,则加1即可。我们读数据的时候,一般一次性要读完一个标准类型码所表征的数据,方便解码。
3、索引块(Index Chunk)
最后,紧跟在‘hdrl’列表和‘movi’列表之后的,就是AVI文件可选的索引块。这个索引块为AVI文件中每一个媒体数据块进行索引,并且记录它们在文件中的偏移(可能相对于‘movi’列表,也可能相对于AVI文件开头)。本章我们用不到索引块,这里就不详细介绍了。
关于AVI文件,我们就介绍到这,有兴趣的朋友,可以再看看光盘:6,软件资料AVI学习资料 里面的相关文档。
最后,我们看看要实现avi视频文件的播放,主要有哪些步骤,如下:
1)初始化各外设
要解码视频,相关外设肯定要先初始化好,比如:SDMMC(驱动SD卡用)、SAI、DMA、LCD和按键等。这些具体初始化过程,在前面的例程都有介绍,大同小异,这里就不再细说了。
2)读取AVI文件,并解析
要解码,得先读取avi文件,读取出音视频关键信息,音频参数:编码方式、采样率、位数和音频流类型码(01wb/00wb)等;视频参数:编码方式、帧间隔、图片尺寸和视频流类型码(00dc/01dc)等;共同的:数据流起始地址。有了这些参数,我们便可以初始化音视频解码,为后续解码做好准备。
3)根据解析结果,设置相关参数
根据第2步解析的结果,设置SAI的音频采样率和位数,同时要让视频显示在LCD中间区域,得根据图片尺寸,设置LCD开窗时x,y方向的偏移量。
4)读取数据流,开始解码
前面三步完成,就可以正式开始播放视频了。读取视频流数据(movi块),根据类型码,执行音频/视频解码。对于音频数据(01wb/00wb),本例程只支持未压缩的PCM数据,所以,直接填充到DMA缓冲区即可,由DMA循环发送给ES8388,播放音频。对于视频数据(00dc/01dc),本例程只支持MJPG,通过硬件JPEG解码,硬件JPEG解码流程详见第五十章。然后,利用定时器来控制帧间隔,以正常速度播放视频,从而实现音视频解码。
5)解码完成,释放资源
最后在文件读取完后(或者出错了),需要释放申请的内存、恢复LCD窗口、关闭定时器、停止SAI播放音乐和关闭文件等一系列操作,等待下一次解码。
51.2 硬件设计

  1. 例程功能
    1、本实验开机后,先初始化各外设,然后检测字库是否存在,如果检测无问题,则开始播放SD卡VIDEO文件夹里面的视频(.avi格式)。
    注意:自备SD卡一张,并在SD卡根目录建立一个VIDEO文件夹,存放AVI视频(仅支持MJPG视频,音频必须是PCM,且视频分辨率必须小于等于屏幕分辨率)在里面。例程所需视频,可以通过:狸窝全能视频转换器,转换后得到,具体步骤见<<STM32H7开发指南>>。
    视频播放时,LCD上会显示视频名字、当前视频编号、总视频数、声道数、音频采样率、帧率、播放时间和总时间等信息。KEY0用于选择下一个视频,KEY1用于选择上一个视频,KEY_UP可以快进,KEY1可以快退。
    2、LED0闪烁,提示程序运行。
  2. 硬件资源
    1)RGB灯
    RED :LED0 - PB4
    GREEN :LED1 - PE6
    2)串口1(PA9/PA10连接在板载USB转串口芯片CH340上面)
    3)正点原子2.8/3.5/4.3/7/10寸TFTLCD模块(仅限MCU屏,16位8080并口驱动)
    4)独立按键 :KEY0 - PA1、KEY1 - PA15、WK_UP - PA0
    5)SD卡,通过SDMMC1(SDMMC_D0D4(PC8PC11),
    SDMMC_SCK(PC12),SDMMC_CMD(PD2))连接
    6)norflash(QSPI FLASH芯片,连接在QSPI上)
    7)硬件JPEG解码内核(STM32H750自带)
    8)定时器6、7
    51.3 程序设计
    51.3.1 程序流程图

图50.3.1.1 照相机实验程序流程图
51.3.2 程序解析

  1. MJPEG驱动代码
    这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。MJPEG驱动源码包括四个文件:avi.c、avi.h、mjpeg.c和mjpeg.h。
    avi.h头文件在51.1小节部分讲过,具体请看源码。下面来看到avi.c文件,这里总共有三个函数都很重要,首先介绍AVI解码初始化函数,该函数定义如下:
/* avi文件相关信息 */
AVI_INFO avix;
/* 视频编码标志字符串,00dc/01dc */
uint8_t *const AVI_VIDS_FLAG_TBL[2] = {"00dc", "01dc"};
/* 音频编码标志字符串,00wb/01wb */
uint8_t *const AVI_AUDS_FLAG_TBL[2] = {"00wb", "01wb"}; /*** @brief       AVI解码初始化* @param       buf  : 输入缓冲区* @param       size : 缓冲区大小* @retval      执行结果*   @arg       AVI_OK, AVI文件解析成功*   @arg       其他  , 错误代码*/
AVISTATUS avi_init(uint8_t *buf, uint32_t size)
{uint16_t offset;uint8_t *tbuf;AVISTATUS res = AVI_OK;AVI_HEADER *aviheader;LIST_HEADER *listheader;AVIH_HEADER *avihheader;STRH_HEADER *strhheader;STRF_BMPHEADER *bmpheader;STRF_WAVHEADER *wavheader;tbuf = buf;aviheader = (AVI_HEADER *)buf;if (aviheader->RiffID != AVI_RIFF_ID)return AVI_RIFF_ERR;  /* RIFF ID错误 */if (aviheader->AviID != AVI_AVI_ID)return AVI_AVI_ERR;        /* AVI ID错误 */buf += sizeof(AVI_HEADER);  /* 偏移 */listheader = (LIST_HEADER *)(buf);if (listheader->ListID != AVI_LIST_ID)return AVI_LIST_ERR; /* LIST ID错误 */if (listheader->ListType != AVI_HDRL_ID)return AVI_HDRL_ERR;/* HDRL ID错误*/buf += sizeof(LIST_HEADER); /* 偏移 */avihheader = (AVIH_HEADER *)(buf);if (avihheader->BlockID != AVI_AVIH_ID)return AVI_AVIH_ERR;/* AVIH ID错误 */avix.SecPerFrame = avihheader->SecPerFrame;   /* 得到帧间隔时间 */avix.TotalFrame = avihheader->TotalFrame;      /* 得到总帧数 */buf += avihheader->BlockSize + 8;              /* 偏移 */listheader = (LIST_HEADER *)(buf);if (listheader->ListID != AVI_LIST_ID)return AVI_LIST_ERR;  /* LIST ID错误 */if (listheader->ListType != AVI_STRL_ID)return AVI_STRL_ERR;/* STRL ID错误*/strhheader = (STRH_HEADER *)(buf + 12);if (strhheader->BlockID != AVI_STRH_ID)return AVI_STRH_ERR;/* STRH ID错误 */if (strhheader->StreamType == AVI_VIDS_STREAM)                  /* 视频帧在前 */
{/* 非MJPG视频流,不支持 */if (strhheader->Handler != AVI_FORMAT_MJPG)return AVI_FORMAT_ERR;   avix.VideoFLAG = (uint8_t *)AVI_VIDS_FLAG_TBL[0];  /* 视频流标记  "00dc" */avix.AudioFLAG = (uint8_t *)AVI_AUDS_FLAG_TBL[1];  /* 音频流标记  "01wb" */
/* strf */bmpheader = (STRF_BMPHEADER *)(buf + 12 + strhheader->BlockSize + 8);   if (bmpheader->BlockID != AVI_STRF_ID)return AVI_STRF_ERR;/*STRF ID错误*/avix.Width = bmpheader->bmiHeader.Width;avix.Height = bmpheader->bmiHeader.Height;buf += listheader->BlockSize + 8;           /* 偏移 */listheader = (LIST_HEADER *)(buf);if (listheader->ListID != AVI_LIST_ID)   /* 是不含有音频帧的视频文件 */{avix.SampleRate = 0;        /* 音频采样率 */avix.Channels = 0;          /* 音频通道数 */avix.AudioType = 0;         /* 音频格式 */}else{/* STRL ID错误 */if (listheader->ListType != AVI_STRL_ID)return AVI_STRL_ERR;strhheader = (STRH_HEADER *)(buf + 12);
/* STRH ID错误 */if (strhheader->BlockID != AVI_STRH_ID)return AVI_STRH_ERR;
/* 格式错误 */if (strhheader->StreamType != AVI_AUDS_STREAM)return AVI_FORMAT_ERR;
/* strf */ wavheader = (STRF_WAVHEADER *)(buf + 12 + strhheader->BlockSize + 8);
/* STRF ID错误 */if (wavheader->BlockID != AVI_STRF_ID)return AVI_STRF_ERR;  avix.SampleRate = wavheader->SampleRate;     /* 音频采样率 */avix.Channels = wavheader->Channels;         /* 音频通道数 */avix.AudioType = wavheader->FormatTag;       /* 音频格式 */}}else if (strhheader->StreamType == AVI_AUDS_STREAM)    /* 音频帧在前 */{avix.VideoFLAG = (uint8_t *)AVI_VIDS_FLAG_TBL[1]; /* 视频流标记  "01dc" */avix.AudioFLAG = (uint8_t *)AVI_AUDS_FLAG_TBL[0]; /* 音频流标记  "00wb" */
/* strf */wavheader = (STRF_WAVHEADER *)(buf + 12 + strhheader->BlockSize + 8);   if (wavheader->BlockID != AVI_STRF_ID)return AVI_STRF_ERR;/*STRF ID错误*/avix.SampleRate = wavheader->SampleRate;            /* 音频采样率 */avix.Channels = wavheader->Channels;             /* 音频通道数 */avix.AudioType = wavheader->FormatTag;           /* 音频格式 */buf += listheader->BlockSize + 8;                   /* 偏移 */listheader = (LIST_HEADER *)(buf);if (listheader->ListID != AVI_LIST_ID)return AVI_LIST_ERR;/*LIST ID错误*/
/* STRL ID错误 */if (listheader->ListType != AVI_STRL_ID)return AVI_STRL_ERR;strhheader = (STRH_HEADER *)(buf + 12);
/* STRH ID错误 */if (strhheader->BlockID != AVI_STRH_ID)return AVI_STRH_ERR;
/* 格式错误 */if (strhheader->StreamType != AVI_VIDS_STREAM)return AVI_FORMAT_ERR;
/* strf */    bmpheader = (STRF_BMPHEADER *)(buf + 12 + strhheader->BlockSize + 8);   if (bmpheader->BlockID != AVI_STRF_ID)return AVI_STRF_ERR;/*STRF ID错误*/if (bmpheader->bmiHeader.Compression != AVI_FORMAT_MJPG)
return AVI_FORMAT_ERR;  /* 格式错误 */avix.Width = bmpheader->bmiHeader.Width;avix.Height = bmpheader->bmiHeader.Height;}offset = avi_srarch_id(tbuf, size, "movi");     /* 查找movi ID */if (offset == 0)return AVI_MOVI_ERR;               /* MOVI ID错误 */if (avix.SampleRate)                                 /* 有音频流,才查找 */{tbuf += offset;offset = avi_srarch_id(tbuf, size, avix.AudioFLAG); /* 查找音频流标记 */if (offset == 0)return AVI_STREAM_ERR;          /* 流错误 */tbuf += offset + 4;avix.AudioBufSize = *((uint16_t *)tbuf);    /* 得到音频流buf大小 */}printf("avi init ok\r\n");printf("avix.SecPerFrame:%d\r\n", avix.SecPerFrame);printf("avix.TotalFrame:%d\r\n", avix.TotalFrame);printf("avix.Width:%d\r\n", avix.Width);printf("avix.Height:%d\r\n", avix.Height);printf("avix.AudioType:%d\r\n", avix.AudioType);printf("avix.SampleRate:%d\r\n", avix.SampleRate);printf("avix.Channels:%d\r\n", avix.Channels);printf("avix.AudioBufSize:%d\r\n", avix.AudioBufSize);printf("avix.VideoFLAG:%s\r\n", avix.VideoFLAG);printf("avix.AudioFLAG:%s\r\n", avix.AudioFLAG);return res;
}

该函数用于解析AVI文件,获取音视频流数据的详细信息,为后续解码做准备。
接下来介绍的是查找 ID函数,其定义如下:

/*** @brief       查找 ID* @param       buf  : 输入缓冲区* @param       size : 缓冲区大小* @param       id   : 要查找的id, 必须是4字节长度* @retval      执行结果*   @arg       0     , 没找到*   @arg       其他  , movi ID偏移量*/
uint32_t avi_srarch_id(uint8_t *buf, uint32_t size, uint8_t *id)
{uint32_t i;uint32_t idsize = 0;size -= 4;for (i = 0; i < size; i++){if ((buf[i] == id[0]) &&(buf[i + 1] == id[1]) &&(buf[i + 2] == id[2]) &&(buf[i + 3] == id[3])){/* 得到帧大小,必须大于16字节,才返回,否则不是有效数据 */idsize = MAKEDWORD(buf + i + 4);    if (idsize > 0X10)return i;         /* 找到"id"所在的位置 */}}return 0;
}

该函数用于查找某个ID,可以是4个字节长度的ID,比如00dc,01wb,movi之类的,在解析数据以及快进快退的时候,有用到。
接下来介绍的是得到stream流信息函数,其定义如下:

/*** @brief       得到stream流信息* @param       buf  : 流开始地址(必须是01wb/00wb/01dc/00dc开头)* @retval      执行结果*   @arg       AVI_OK, AVI文件解析成功*   @arg       其他  , 错误代码*/
AVISTATUS avi_get_streaminfo(uint8_t *buf)
{avix.StreamID = MAKEWORD(buf + 2);           /* 得到流类型 */avix.StreamSize = MAKEDWORD(buf + 4);          /* 得到流大小 */if (avix.StreamSize > AVI_MAX_FRAME_SIZE)     /* 帧大小太大了,直接返回错误 */{printf("FRAME SIZE OVER:%d\r\n", avix.StreamSize);avix.StreamSize = 0;return AVI_STREAM_ERR;}
/* 奇数加1(avix.StreamSize,必须是偶数) */if (avix.StreamSize % 2)avix.StreamSize++;
if (avix.StreamID == AVI_VIDS_FLAG || avix.StreamID == AVI_AUDS_FLAG)
return AVI_OK;return AVI_STREAM_ERR;
}
该函数用来获取当前数据流信息,重点是取得流类型和流大小,方便解码和读取下一个数据流。
mjpeg.h文件只有一些函数和变量声明,接下来,介绍mjpeg.c里面的几个函数,首先是初始化MJPEG函数,其定义如下:
/*** @brief       初始化MJPEG* @param       offx    : 显示图像在LCD上x方向的偏移量* @param       offy    : 显示图像在LCD上y方向的偏移量* @param       width   : 显示图像的宽度* @param       height  : 显示图像的高度* @retval      执行结果*   @arg       0     , 成功*   @arg       其他  , 失败*/
uint8_t mjpeg_init(uint16_t offx, uint16_t offy, uint32_t width,
uint32_t height)
{uint8_t i;uint8_t res;res = mjpeg_jpeg_core_init(&mjpeg);   /* 初始化JPEG内核,不申请IN BUF */if (res)return 1;for (i = 0; i < JPEG_DMA_OUTBUF_NB; i++){/* 最大是图片宽度的24倍,另外还可能会多需要32字节内存 */mjpeg.outbuf[i].buf = mymalloc(SRAM12, width * 24 + 32);if (mjpeg.outbuf[i].buf == NULL)return 2;}g_img_offx = offx;g_img_offy = offy;return 0;
}

该函数用于初始化jpeg解码 ,调用mjpeg_jpeg_core_init函数,对硬件JPEG解码内核进行初始化(类似jpeg_core_init函数,详见本例程源码),然后申请内存,确定视频在液晶上面的偏移(让视频显示在LCD中央)。
注意:如果是MCU屏,我们需要申请内存。如果是RGB屏,则可以直接使用RGB屏的显存。但是我们这款开发板不支持RGB屏,所以这种情况就不分析了。
下面介绍的是MJPEG释放所有申请的内存函数,其定义如下:

/*** @brief       MJPEG释放所有申请的内存* @param       无* @retval      无*/
void mjpeg_free(void)
{mjpeg_jpeg_core_destroy(&mjpeg);
}
该函数用于释放内存,解码结束后调用。
下面介绍的是填充颜色函数,其定义如下:
/*** @brief       填充颜色* @param       x, y    : 起始坐标* @param       width   : 宽度* @param       height  : 高度* @param       color   : 颜色数组* @retval      无*/
void mjpeg_fill_color(uint16_t x, uint16_t y, uint16_t width,
uint16_t height, uint16_t *color)
{/* 是MCU屏才需要填充(RGB横屏无需填充!!, 在YUV转换的时候,直接就填充了) */lcd_color_fill(x, y, x + width - 1, y + height - 1, color);
}

该函数用于解码完成后,将RGB565数据填充到液晶屏上,对于RGB屏的竖屏模式,不能用DMA2D填充,只能打点的方式填充,通过计算参量,提高打点速度。对于MCU屏则直接调用lcd_color_fill函数进行填充。
下面介绍的是解码一副JPEG图片函数,其定义如下:

/*** @brief       解码一副JPEG图片*   @note      注意事项:*              1, 待解吗图片的分辨率,必须小于等于屏幕的分辨率!*              2, 请保证图片的宽度是16的倍数,以免左侧出现花纹.* @param       buf     : jpeg数据流数组* @param       bsize   : 数组大小* @retval      执行结果*   @arg       0     , 成功*   @arg       其他  , 失败*/
uint8_t mjpeg_decode(uint8_t *buf, uint32_t bsize)
{volatile uint32_t timecnt = 0;if (bsize == 0)return 0;jpeg_decode_init(&mjpeg);         /* 初始化硬件JPEG解码器 */g_mjpeg_remain_size = bsize;     /* 记录当前图片的大小(字节数) */mjpeg.inbuf[0].buf = buf;          /* 指向jpeg数据流的首地址 */g_mjpeg_fileover = 0;               /* 标记未读完 */if (g_mjpeg_remain_size < JPEG_DMA_INBUF_LEN) /* 图片比较小,一次就可以传输完成 */{//mjpeg.inbuf[0].size=g_mjpeg_remain_size;   /* 传输大小等于总大小 *///g_mjpeg_remain_size=0;                        /* 一次传输就可以搞完 */return 0;   /* 图片尺寸比较小,直接不解码 */}else    /* 图片比较大,需要分多次传输 */{mjpeg.inbuf[0].size = JPEG_DMA_INBUF_LEN;    /* 按最大传输长度,分批次传输 */g_mjpeg_remain_size -= JPEG_DMA_INBUF_LEN;  /* 剩余长度 */}
/* 配置输入DMA */jpeg_in_dma_init((uint32_t)mjpeg.inbuf[0].buf, mjpeg.inbuf[0].size);    jpeg_in_callback = mjpeg_dma_in_callback;         /* JPEG DMA读取数据回调函数 */jpeg_out_callback = mjpeg_dma_out_callback;      /* JPEG DMA输出数据回调函数 */jpeg_eoc_callback = mjpeg_endofcovert_callback;  /* JPEG 解码结束回调函数 */jpeg_hdp_callback = mjpeg_hdrover_callback;  /* JPEG Header解码完成回调函数 */jpeg_in_dma_start();      /* 启动DMA IN传输,开始解码JPEG图片 */p_rgb565buf = 0;            /* 指针清空 */while (1){/* p_rgb565buf空,且JPEG HEAD解码完成 */if (p_rgb565buf == 0 && mjpeg.state == JPEG_STATE_HEADEROK) {p_rgb565buf = mymalloc(SRAM12, mjpeg.Conf.ImageWidth
* mjpeg.yuvblk_height * 2 + 32);   /* 申请单次输出缓冲内存 */if (p_rgb565buf == 0)return 1;}if (mjpeg.outbuf[mjpeg.outbuf_read_ptr].sta == 1)/* buf里面有数据要处理 */{SCB_CleanInvalidateDCache();    /* 清空D catch */
/* 利用DMA2D,将YUV图像转成RGB565图像 */jpeg_dma2d_yuv2rgb_conversion(&mjpeg, (uint32_t *)p_rgb565buf); mjpeg_fill_color(g_img_offx, g_img_offy + mjpeg.yuvblk_curheight,
mjpeg.Conf.ImageWidth, mjpeg.yuvblk_height, p_rgb565buf);mjpeg.yuvblk_curheight += mjpeg.yuvblk_height;  /* 列偏移 *///SCB_CleanInvalidateDCache();  /* 清空D catch */mjpeg.outbuf[mjpeg.outbuf_read_ptr].sta = 0;    /* 标记buf为空 */mjpeg.outbuf[mjpeg.outbuf_read_ptr].size = 0;   /* 数据量清空 */mjpeg.outbuf_read_ptr++;if (mjpeg.outbuf_read_ptr >= JPEG_DMA_OUTBUF_NB)
mjpeg.outbuf_read_ptr = 0;  /* 限制范围 */
/* 当前高度等于或者超过图片分辨率的高度,则说明解码完成了,直接退出 */if (mjpeg.yuvblk_curheight >= mjpeg.Conf.ImageHeight)break;    }
/* out暂停,且当前writebuf已经为空了,则恢复out输出 */else if (mjpeg.outdma_pause == 1 &&
mjpeg.outbuf[mjpeg.outbuf_write_ptr].sta == 0)  {
/* 继续下一次DMA传输 */jpeg_out_dma_resume((uint32_t) mjpeg.outbuf[mjpeg.outbuf_write_ptr].buf, mjpeg.yuvblk_size); mjpeg.outdma_pause = 0;}if (mjpeg.state == JPEG_STATE_ERROR)        /* 解码出错,直接退出 */{break;}if (mjpeg.state == JPEG_STATE_FINISHED) /* 解码结束了,检查是否异常结束 */{if (mjpeg.yuvblk_curheight < mjpeg.Conf.ImageHeight){/* 数据异常,直接退出 */
if (mjpeg.Conf.ImageHeight > (mjpeg.yuvblk_curheight + 16)) {mjpeg.state = JPEG_STATE_ERROR; /* 标记错误 */printf("early finished!\r\n");break;}}}if (g_mjpeg_fileover)   /* 文件读完了,及时退出,防止死循环 */{timecnt++;if (mjpeg.state == JPEG_STATE_NOHEADER)break;   /* 解码JPEG头失败了 */if (timecnt > 0X3FFFF)break;       /* 超时退出 */}}myfree(SRAM12, p_rgb565buf);            /* 释放内存 */return 0;
}

该函数是解码jpeg的主要函数,解码步骤参见第四十九章相关内容。解码后利用DMA2D将YUV转换成RGB565数据,存放在p_rgb565buf(MCU屏/RGB竖屏)/RGB屏显存(RGB横屏)里面,然后通过mjpeg_fill_color函数,将RGB565数据显示到LCD屏幕上。
2. APP驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。APP驱动源码包括两个文件:videoplayer.c和videoplayer.h。
videoplayer.h头文件有两个宏定义和函数声明,具体请看源码。下面来看到videoplayer.c文件中,播放一个MJPEG文件函数,其定义如下:

/*** @brief       播放一个MJPEG文件* @param       pname   : 文件名* @retval      执行结果*   @arg       KEY0_PRES , 下一曲*   @arg       KEY1_PRES , 上一曲*   @arg       其他      , 错误*/
uint8_t video_play_mjpeg(uint8_t *pname)
{uint8_t *framebuf;     /* 视频解码buf */uint8_t *pbuf;         /* buf指针 */FIL *favi;uint8_t  res = 0;uint32_t offset = 0;uint32_t nr;uint8_t key;psaibuf = mymalloc(SRAM4, AVI_AUDIO_BUF_SIZE);         /* 申请音频内存 */framebuf = mymalloc(SRAMIN, AVI_VIDEO_BUF_SIZE);       /* 申请视频buf */favi = (FIL *)mymalloc(SRAM12, sizeof(FIL));          /* 申请favi内存 */memset(psaibuf, 0, AVI_AUDIO_BUF_SIZE);if (!psaibuf || !framebuf || !favi){printf("memory error!\r\n");res = 0XFF;}while (res == 0){res = f_open(favi, (char *)pname, FA_READ);if (res == 0){pbuf = framebuf;res = f_read(favi, pbuf, AVI_VIDEO_BUF_SIZE, &nr);  /* 开始读取 */if (res){printf("fread error:%d\r\n", res);break;}/* 开始avi解析 */res = avi_init(pbuf, AVI_VIDEO_BUF_SIZE);   /* avi解析 */if (res || avix.Width > lcddev.width){printf("avi err:%d\r\n", res);res = KEY0_PRES;break;}video_info_show(&avix);
/* 10Khz计数频率,加1是100us */btim_tim7_int_init(avix.SecPerFrame / 100 - 1, 24000 - 1);
/* 寻找movi ID */offset = avi_srarch_id(pbuf, AVI_VIDEO_BUF_SIZE, "movi");avi_get_streaminfo(pbuf + offset + 4);  /* 获取流信息 */f_lseek(favi, offset + 12); /* 跳过标志ID,读地址偏移到流数据开始处 */if (lcddev.height <= avix.Height){res = mjpeg_init((lcddev.width - avix.Width) / 2,
(lcddev.height - avix.Height) / 2,
avix.Width, avix.Height); /* JPG解码初始化 */}else{res = mjpeg_init((lcddev.width - avix.Width) / 2,
110 + (lcddev.height - 110 - avix.Height) / 2,
avix.Width, avix.Height); /* JPG解码初始化 */}if (res){mjpeg_free();break;}if (avix.SampleRate)    /* 有音频信息,才初始化 */{/* 没有音频硬件,所以并不播放音频出来 */}while (1)   /* 播放循环 */{if (avix.StreamID == AVI_VIDS_FLAG) /* 视频流 */{pbuf = framebuf;
/* 读入整帧+下一数据流ID信息 */f_read(favi, pbuf, avix.StreamSize + 8, &nr);  res = mjpeg_decode(pbuf, avix.StreamSize);if (res){printf("decode error!\r\n");}while (g_frameup == 0); /* 等待时间到达(在TIM7的中断里面设置为1) */g_frameup = 0;            /* 标志清零 */g_frame++;}else if (avix.StreamID == AVI_AUDS_FLAG)  /* 音频流 */{video_time_show(favi, &avix);           /* 显示当前播放时间 */f_read(favi, psaibuf, avix.StreamSize + 8, &nr);/*填充psaibuf*/pbuf = psaibuf;}key = key_scan(0);
/* KEY0/KEY1按下,播放下一个/上一个视频 */if (key == KEY0_PRES || key == KEY1_PRES)   {res = key;break;}else if (key == KEY1_PRES || key == WKUP_PRES){video_seek(favi, &avix, framebuf);pbuf = framebuf;}if (avi_get_streaminfo(pbuf + avix.StreamSize))/* 读取下一帧 流标志*/{pbuf = framebuf;res = f_read(favi, pbuf, AVI_VIDEO_BUF_SIZE, &nr);/* 开始读取 */
/* 读取成功,且读取了指定长度的数据 */if (res == 0 && nr == AVI_VIDEO_BUF_SIZE)   {/* 寻找AVI_VIDS_FLAG,00dc */offset = avi_srarch_id(pbuf, AVI_VIDEO_BUF_SIZE, "00dc");   avi_get_streaminfo(pbuf + offset);      /* 获取流信息 */if (offset)f_lseek(favi,
(favi->fptr - AVI_VIDEO_BUF_SIZE) + offset + 8);}else{printf("g_frame error \r\n");res = KEY0_PRES;break;}}}TIM7->CR1 &= ~(1 << 0);   /* 关闭定时器7 */lcd_set_window(0, 0, lcddev.width, lcddev.height);  /* 恢复窗口 */mjpeg_free();             /* 释放内存 */f_close(favi);}}myfree(SRAM4, psaibuf);myfree(SRAMIN, framebuf);myfree(SRAM12, favi);return res;
}

该函数用来播放一个avi视频文件(mjpg编码),解码过程就是根据前面我们在55.1节最后所介绍的步骤进行。其他代码,我们就不介绍了,请大家参考本例程源码。
3. main.c代码
下面是main函数,其定义如下:

int main(void)
{sys_cache_enable();                    /* 打开L1-Cache */HAL_Init();                                 /* 初始化HAL库 */sys_stm32_clock_init(240, 2, 2, 4);    /* 设置时钟, 480Mhz */delay_init(480);                          /* 延时初始化 */usart_init(115200);                      /* 串口初始化为115200 */usmart_dev.init(240);                     /* 初始化USMART */mpu_memory_protection();                 /* 保护相关存储区域 */led_init();                               /* 初始化LED */lcd_init();                             /* 初始化LCD */key_init();                             /* 初始化按键 */my_mem_init(SRAMIN);                     /* 初始化内部内存池(AXI) */my_mem_init(SRAM12);                     /* 初始化SRAM12内存池(SRAM1+SRAM2) */my_mem_init(SRAM4);                     /* 初始化SRAM4内存池(SRAM4) */my_mem_init(SRAMDTCM);                  /* 初始化DTCM内存池(DTCM) */my_mem_init(SRAMITCM);                /* 初始化ITCM内存池(ITCM) */exfuns_init();                            /* 为fatfs相关变量申请内存 */f_mount(fs[0], "0:", 1);              /* 挂载SD卡 */f_mount(fs[1], "1:", 1);               /* 挂载FLASH */lcd_display_dir(1);                    /* 设置成横屏 */while (fonts_init())                     /* 检查字库 */{lcd_show_string(30, 50, 200, 16, 16, "Font Error!", RED);delay_ms(200);lcd_fill(30, 50, 240, 66, WHITE);   /* 清除显示 */delay_ms(200);}text_show_string(60, 50, 200, 16, "正点原子STM32开发板", 16, 0, RED);text_show_string(60, 70, 200, 16, "视频播放器 实验", 16, 0, RED);text_show_string(60, 90, 200, 16, "KEY0:NEXT   KEY1:PREV", 16, 0, RED); text_show_string(60, 110, 200, 16, "KEY_UP:FF", 16, 0, RED);delay_ms(1500);btim_timx_int_init(10000-1, 24000-1);    /* 10Khz计数,1秒钟中断一次 */while(1){ video_play();}
}

main函数只是经过一系列的外设初始化后,检查字库是否已经更新,然后显示实验的信息,就通过调用video_play函数,执行视频播放的程序了。
51.4 下载验证
本章,我们例程仅支持MJPG编码的avi格式视频,因为没有板载的音频解码芯片和扬声器等,所以只有视频播放,没有音频播放。注意:视频分辨率不能大于LCD分辨率。要满足这些要求,现成的avi文件是很难找到的,所以我们需要用软件,将通用视频(任何视频都可以)转换为我们需要的格式,这里我们通过:狸窝全能视频转换器,这款软件来实现(路径:光盘:6,软件资料软件视频转换软件狸窝全能视频转换器.exe)。安装完后,打开,然后进行相关设置,软件设置如图51.4.1和51.4.2所示:

图51.4.1 软件启动界面和设置

图51.4.2 高级设置
首先,如图51.4.1所示,点击1处,添加视频,找到你要转换的视频,添加进来。有的视频可能有独立字幕,比如我们打开的这个视频就有,所以在2处选择下字幕(如果没有的,可以忽略此步)。然后在3处,点击▼图标,选择预制方案:AVI-Audio-Video Interleaved(.avi),即生成.avi文件,然后点击4处的高级设置按钮,进入51.4.2所示的界面,设置详细参数如下:
视频编码器:选择MJPEG。本例程仅支持MJPG视频解码,所以选择这个编码器。
视频尺寸:480x272。这里得根据所用LCD分辨率来选择,假设我们用800
480的4.3寸电容屏模块,则这里最大可以设置:800x480。PS:如果是2.8屏,最大宽度只能是240)。
比特率:1000。这里设置越大,视频质量越好,解码就越慢(可能会卡),我们设置为1000,可以得到比较好的视频质量,同时也不怎么会卡。
帧率:10。即每秒钟10帧。对于480*272的视频,本例程最高能播放30帧左右的视频,如果要想提高帧率,有几个办法:1,降低分辨率;2,降低比特率;3,降低音频采样率。
音频编码器:PCMS16LE。本例程不支持音频。
采样率:这里设置为11025,即11.025Khz的采样率。这里越高,声音质量越好,不过,转换后的文件就越大,而且视频可能会卡。
其他设置,采用默认的即可。设置完以后,点击确定,即可完成设置。
点击图51.4.1的5处的文件夹图标,设置转换后视频的输出路径,这里我们设置到了桌面,这样转换后的视频,会保存在桌面。最后,点击图中6处的按钮,即可开始转换了,如图51.4.3所示:

图51.4.3 正在转换
等转换完成后,将转换后的.avi文件,拷贝到SD卡VIDEO文件夹下,然后插入开发板的SD卡接口,就可以开始测试本章例程了。
将程序下载到开发板后,程序先检测字库,只有字库已经更新才可以继续执行后面的程序。字库已更新,就可以看到LCD首先显示一些实验相关的信息,如图51.4.4所示:

图51.4.4显示实验相关信息
显示了上图的信息后,检测SD卡的VIDEO文件夹,并查找avi视频文件,在找到有效视频文件后,便开始播放视频,如图51.4.5所示:

图51.4.5 视频播放中
可以看到,屏幕显示了文件名、索引、声道数、采样率、帧率和播放时间等参数。然后,我们按KEY0/KEY1,可以切换到下一个/上一个视频,按KEY_UP,可以快进。
至此,本例程介绍就结束了。本实验,我们在开发板上实现了视频播放,体现了STM32H750强大的处理能力。
附STM32H750硬件JPEG视频解码性能:
对800480分辨率,可达50帧
对1024
600分辨率,可达20帧
对1280*800分辨率,可达10帧
最后提醒大家,转换的视频分辨率,一定要根据自己的LCD设置,不能超过LCD的尺寸!!否则无法播放。

【正点原子STM32连载】第五十一章 视频播放器实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1相关推荐

  1. 【正点原子STM32连载】第二十一章 通用定时器实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  2. 【正点原子STM32连载】第二十三章 OLED显示实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  3. 【正点原子STM32连载】第四十五章 SD卡实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  4. 【正点原子STM32连载】第五十三章 DSP测试实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  5. 【正点原子STM32连载】第五十七章 USB读卡器(Slave)实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  6. 【正点原子STM32连载】第四章 STM32初体验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  7. 【正点原子STM32连载】第六章 新建寄存器版本MDK工程 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  8. 【正点原子STM32连载】第四十七章 汉字显示实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

  9. 【正点原子STM32连载】第十四章 蜂鸣器实验 摘自【正点原子】MiniPro STM32H750 开发指南_V1.1

    1)实验平台:正点原子MiniPro H750开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=677017430560 3)全套实验源码+手册+视频 ...

最新文章

  1. mysql 提高电脑配置_Mysql配置优化浅谈
  2. 数字信号处理第一章 离散时间信号与系统
  3. 数据蒋堂 | 怎样看待存储过程的移植困难
  4. 微软MVP社区夏日巡讲诚邀您的参与: 北京,上海,西宁,成都,西安
  5. Mac 生成SSH Key
  6. Nodejs从小工到专家系列(一)
  7. Redis夺命连环11问
  8. 关于项目管理的一点体会
  9. php 的定界符 eof
  10. c8650 android2.3.3 root过程
  11. java 面向对象三个特征_[Java] 面向对象的三个特征与含义
  12. 科技 计算机 事迹 大学,计算机学院
  13. 致远oa mysql 安装_致远OA协同办公系统OA安装步骤.doc
  14. python身高体重程序代码_python EM算法4(身高体重数据集)
  15. GraphSage:Inductive Representation Learning on Large Graphs
  16. 2017关于自学PHP的方法
  17. idea html设置字体大小,intellij idea设置(字体大小、背景)
  18. 软件使用vmware虚拟机的安装步骤详细
  19. 交换机vlan配置实训心得_交换机与路由器的实训心得
  20. 现在接受参加国际创业节 DOer Express的 申请

热门文章

  1. 无人驾驶汽车技术之道路线识别
  2. 人机交互及界面设计序言
  3. 【网络】网络布线与数制转换
  4. 从上家公司离开一个多月,今天前同事竟跟我说,公司要求他们删除离职人员微信好友,不删就开除!...
  5. win7计算机虚拟内存不足,电脑虚拟内存不足怎么办,教您解决电脑虚拟内存不足...
  6. Prim / Kruskal - 局域网 - 洛谷 P2820
  7. 江苏大学计算机学院姚奕如,2017年江苏教学成果奖一等奖项目.DOC
  8. Py之scorecardpy:scorecardpy的简介、安装、使用方法之详细攻略
  9. vue+图片裁剪vue-cropper以及api
  10. 【运动学】基于matlab GUI三体运动模拟【含Matlab源码 871期】