全景视频播放器代码分析
全景視頻播放器代碼分析
- 一、前期準備
- (1)FFmpeg新舊接口對照使用一覽
- (2)libswscale圖片像素數據處理類庫
- (3)OpenGL相關記錄
- (4)列隊與線程
- 二、代碼分析
來總結一下最近研究的全景視頻播放器代碼
平臺:Windows
軟件:vs2019
代碼來源:OpenGL全景視頻.
一、前期準備
剛開始的時候想先從代碼入手,和想象的不太一樣,本來以為C語言的代碼撐死每句指令都百度,打斷點看變量應該也能看懂。于是先在b站找到了C++編寫視頻播放器的視頻,看的我一頭霧水,里面用了vlc的庫,代碼可以說是一句不懂。之后在csdn,博客園搜了好多,發現全景視頻播放器好多都是基于安卓開發的,里面許多名詞也都不懂。
大作業+期末的日子結束,回家后美賽練習過程中才又重新開始找資料看,看過一些音視頻入門的文章后才知道音視頻開發,進行的是采集、渲染、處理、傳輸等一系列的開發和應用,這時候才有大致框架,有了解封裝,解碼,音頻流,視頻流等相關概念。和做大作業的時候一樣,習慣了直接上手,有什么問題再說,但其實首先做的應該是弄清楚大的框架和要求,再著眼細節。
再之后就是知道了FFmpeg和OpenGL,拋下代碼先學了相關的理論。
FFmpeg參考了雷神的博客和在b站的視頻講解
把庫和函數列出來了:FFmpeg原理介紹與代碼實現.
后來發現了一個寫的比較系統的教程:ffmpeg和SDL教程.
OpenGL有一個很系統的講解:LearnOpenGL CN.
萬字長文詳解如何用 Python 玩轉 OpenGL | CSDN 博文精選.坐標系、投影、變換的概念都有
另外有一些找到的寫的不錯的文章:
最簡單的視音頻播放示例5:OpenGL播放RGB/YUV.有OpenGL渲染管線的步驟,窗口相關函數都有提到。
OpenGL正面剔除,深度測試,混合.有實際圖片示例
還有《OpenGL編程指南》好像也多人推薦
但我的感覺是代碼里好多東西這書和網站都沒有講到,像glut庫里函數都是分散的百度出來的,很雜很散的樣子。
除了這些,零零碎碎搜到的一些東西也大致記錄了一下。
(1)FFmpeg新舊接口對照使用一覽
FFmpeg新舊接口對照使用一覽.
(2)libswscale圖片像素數據處理類庫
sws_getContext():初始化一個SwsContext。
sws_scale():處理圖像數據。
sws_freeContext():釋放一個SwsContext。
關于avpicture_fill與sws_scale:
關于avpicture_fill與sws_scale.對應于FFmpeg解碼之后像素格式變換的代碼
FFmpeg解碼H264及swscale縮放詳解.函數解釋很細
(3)OpenGL相關記錄
總結到了另一篇:全景視頻播放器中OpenGL的相關記錄.
(4)列隊與線程
總結到了:列隊與線程(全景視頻播放器).
二、代碼分析
主要的思路是將全景視頻利用FFmpeg解封裝解碼后,將視頻幀利用OpenGL渲染顯示在一個球上。
值得注意的點:
(1)FFmpeg解碼后還進行了像素幀的格式轉換,好像OpenGL只能渲染RGB,yuv要轉成rgb?
(2)線程及列隊的使用
程序中使用兩個線程分別實現解碼和渲染,它們之間相互獨立。但解碼和渲染的速度我們無法控制,就通過列隊實現均衡。我們自己設定列隊的size,手動將解碼出的數據存到列隊中。解碼出的視頻幀保存在列隊中,即向列隊中輸入使size增加,而OpenGL的渲染消耗列隊中的元素,使size減小。當解碼的數據將列隊容量占滿時,解碼線程會稍作等待,等渲染的線程繼續執行使列隊中有剩余位置時,解碼線程才會繼續運行。
另外,臨界區就是一段不會被中斷的代碼,可以避免數據沖突而使程序崩潰的情況。在本程序中,列隊中元素增加和減少是都會在臨界區進行操作。OpenGL用解碼出的圖像生成紋理后就已經消耗掉了列隊中的元素,就可以離開臨界區了,之后計算各點坐標將紋理對應成像素點并進行繪制。
(3)OpenGL繪制球體
通過設置經線和緯線的數量,我們可將一個球分成數個長方形,OpenGL基本的繪制單元是三角形,一個矩形分兩個三角形,即6個頂點。每個頂點有xyz三維的空間坐標和(s, t)二維紋理坐標。空間坐標就是數學上的坐標表示,而紋理坐標是根據
[0,1]分份數算出來的。
(4)關于glut中的回調函數,以對該事件或條件進行響應
(5)兩個線程打斷點調試不能反映真實的程序運行情況,斷點打下這個線程不動了另一個跑,但實際是兩個線程都在跑。(或者確實可以真實反映程序的調試俺不知道)
具體代碼及注釋如下:
// glPanorama.cpp : 定義控制臺應用程序的入口點。 //#include "stdafx.h"#define PI 3.1415926GLfloat xangle = 0.0; //X 旋轉量,之后可通過鼠標或鍵盤的控制改變 GLfloat yangle = 0.0; //Y 旋轉量 GLfloat zangle = 0.0; //Z 旋轉量//交叉點的坐標 int cx = 0; int cy = 0;GLfloat distance =0;//0或1100.0; GLuint texturesArr;int cap_H = 1;//必須大于0,且cap_H應等于cap_W int cap_W = 1;//繪制球體時,每次增加的角度float* verticals; float* UV_TEX_VERTEX;void init(void); void reshape(int w, int h); void display(void); void getPointMatrix(GLfloat radius);#define MAXSIZE 10//列隊的最大容量//定義了一個結構體Frame用于保存一幀視頻畫面、音頻 typedef struct Vid_Frame {AVFrame *frame;//視頻或音頻的解碼數據int serial;double pts; /* presentation timestamp for the frame */double duration; /* estimated duration of the frame */int64_t pos; /* byte position of the frame in the input file */uint8_t *buffer;int width;int height;AVRational sar; } Vid_Frame;//FrameQueue不是用鏈表實現隊列,而是用數組實現隊列(環形緩沖區)。 typedef struct FrameQueue{Vid_Frame queue[MAXSIZE];隊列元素,用數組模擬隊列,其中就有AVFrame的解碼數據int front;int rear;//后int size;//當前存儲的節點個數(或者說,當前已寫入的節點個數)CRITICAL_SECTION cs;//critica_section定義一個臨界區對象cs,它是全局變量 }FrameQueue;FrameQueue frame_queue; //frame_queue是一個循環隊列,解碼的時候入隊,渲染的時候出隊void initQueue(FrameQueue *q)//初始化列隊 {int i;for (i = 0; i<MAXSIZE; i++){if (!(q->queue[i].frame = av_frame_alloc()))//為數組queue中的每個元素的frame(AVFrame*)的字段分配內存return ;q->queue[i].buffer = NULL;}q->front = 0;q->rear = 0;q->size = 0;InitializeCriticalSection(&q->cs);//初始化臨界區,創立了一個叫cs的臨界區對象 }void deQueue(FrameQueue *q) {free(q); }void init(void) {initQueue(&frame_queue);//初始化列隊//創建紋理,輸入生成紋理的數量1,然后把它們儲存在第二個參數的unsigned int數組中glGenTextures(1, &texturesArr); glBindTexture(GL_TEXTURE_2D, texturesArr);//綁定它,讓之后任何的紋理指令都可以配置當前綁定的紋理//IplImage *image = cvLoadImage("5.png", 1);//生成一個紋理//glTexImage2D(GL_TEXTURE_2D, 0, 3, image->width, image->height, 0, GL_BGR_EXT, GL_UNSIGNED_BYTE, image->imageData);//我們需要自己告訴OpenGL在紋理中采取哪種采樣方式//紋理被放大和縮小時都使用了線性過濾glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); //線形濾波glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //線形濾波glClearColor(0.0, 0.0, 0.0, 0.0);//設置當前使用的清除顏色值,這里為黑色,參數為RGBaglClearDepth(1);// 清除深度緩存 1.0是最大深度([0.0,1.0])glShadeModel(GL_SMOOTH);//設定opengl中繪制指定兩點間其他點顏色的過渡模式,啟用柵格化glEnable(GL_TEXTURE_2D);//允許采用 2D 紋理技術glEnable(GL_DEPTH_TEST);//啟用深度測試,決定何時覆蓋一個像素glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);//表示顏色和紋理坐標插補的質量。 如果角度更正參數插值不有效地支持由 OpenGL 實現,提示 GL_DONT_CARE 或 GL_FASTEST 可以導致簡單線性插值的顏色和/或紋理坐標。getPointMatrix(500);//用函數得到頂點坐標和紋理坐標 }void getPointMatrix(GLfloat radius) {//開辟空間用來儲存頂點坐標和紋理坐標,頂點坐標每個頂點有3個維度,紋理坐標2個(一個矩形中按6個頂點)verticals = new float[(180 / cap_H) * (360 / cap_W) * 6 * 3];UV_TEX_VERTEX = new float[(180 / cap_H) * (360 / cap_W) * 6 * 2];float x = 0;float y = 0;float z = 0;int index = 0;int index1 = 0;float r = radius;//球體半徑double d = cap_H * PI / 180;//每次遞增的弧度for (int i = 0; i < 180; i += cap_H) {double d1 = i * PI / 180;for (int j = 0; j < 360; j += cap_W) {double d2 = j * PI / 180;//獲得球體上切分的超小片矩形的頂點坐標(兩個三角形組成,所以有六點頂點) verticals[index++] = (float)(x + r * sin(d1 + d) * cos(d2 + d));verticals[index++] = (float)(y + r * cos(d1 + d));verticals[index++] = (float)(z + r * sin(d1 + d) * sin(d2 + d));//獲得球體上切分的超小片三角形的紋理坐標,紋理坐標范圍是(0,1)UV_TEX_VERTEX[index1++] = (j + cap_W) * 1.0f / 360;UV_TEX_VERTEX[index1++] = (i + cap_H) * 1.0f / 180;verticals[index++] = (float)(x + r * sin(d1) * cos(d2));verticals[index++] = (float)(y + r * cos(d1));verticals[index++] = (float)(z + r * sin(d1) * sin(d2));UV_TEX_VERTEX[index1++] = j * 1.0f / 360;UV_TEX_VERTEX[index1++] = i * 1.0f / 180;verticals[index++] = (float)(x + r * sin(d1) * cos(d2 + d));verticals[index++] = (float)(y + r * cos(d1));verticals[index++] = (float)(z + r * sin(d1) * sin(d2 + d));UV_TEX_VERTEX[index1++] = (j + cap_W) * 1.0f / 360;UV_TEX_VERTEX[index1++] = i * 1.0f / 180;verticals[index++] = (float)(x + r * sin(d1 + d) * cos(d2 + d));verticals[index++] = (float)(y + r * cos(d1 + d));verticals[index++] = (float)(z + r * sin(d1 + d) * sin(d2 + d));UV_TEX_VERTEX[index1++] = (j + cap_W) * 1.0f / 360;UV_TEX_VERTEX[index1++] = (i + cap_H) * 1.0f / 180;verticals[index++] = (float)(x + r * sin(d1 + d) * cos(d2));verticals[index++] = (float)(y + r * cos(d1 + d));verticals[index++] = (float)(z + r * sin(d1 + d) * sin(d2));UV_TEX_VERTEX[index1++] = j * 1.0f / 360;UV_TEX_VERTEX[index1++] = (i + cap_H) * 1.0f / 180;verticals[index++] = (float)(x + r * sin(d1) * cos(d2));verticals[index++] = (float)(y + r * cos(d1));verticals[index++] = (float)(z + r * sin(d1) * sin(d2));UV_TEX_VERTEX[index1++] = j * 1.0f / 360;UV_TEX_VERTEX[index1++] = i * 1.0f / 180;}} }void reshape(int w, int h) {glViewport(0, 0, (GLsizei)w, (GLsizei)h);glMatrixMode(GL_PROJECTION);//接下來要做投影相關的操作glLoadIdentity();//在進行變換前把當前矩陣設置為單位矩陣//glOrtho(-250.0, 250, -250.0, 250, -500, 500);//glFrustum(-250.0, 250, -250.0, 250, -5, -500);gluPerspective(60, (GLfloat)w / h, 1.0f, 1000.0f); //設置投影矩陣glMatrixMode(GL_MODELVIEW);//對模型視景的操作glLoadIdentity(); }//渲染時把解出來的數據從隊列中取出生成新的紋理。 //渲染采用glDrawArrays函數,使用的GL_TRIANGLES參數,使用這個參數 //對于計算球的頂點坐標和紋理坐標來說不需要考慮很多,比較方便,就是點數過多的時候可能會影響渲染的效率。 void display(void) {glLoadIdentity();//恢復初始坐標系 注釋掉后會一直閃glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //清除兩個緩沖區,clear all pixels像素gluLookAt(0, 0, distance, 0, 0, 500, 0, 1, 0);//在球里面的時候,500處的值越小,離得越近//前三個是腦袋的位置,中間三個是眼睛看向的位置,后三個是頭頂朝向的位置printf("distance: %f \n", distance);//glRotatef(Angle, Xvector, Yvector, Zvector) 用于繞軸旋轉物體。 //Angle 是一個用于指定旋轉角度的數字(通常存儲于變量中)。 //Xvector, Yvector 和 Zvector 這三個參數用于描述一條向量, 以規定物體的旋轉軸。//在鼠標或鍵盤操控時,xangle會隨之變化所以才會旋轉glRotatef(xangle, 1.0f, 0.0f, 0.0f); //繞X軸旋轉glRotatef(yangle, 0.0f, 1.0f, 0.0f); //繞Y軸旋轉glRotatef(zangle, 0.0f, 0.0f, 1.0f); //繞Z軸旋轉EnterCriticalSection(&frame_queue.cs);printf("display size = %d \n", frame_queue.size);if (frame_queue.size > 0){Vid_Frame *vp = &frame_queue.queue[frame_queue.front];//vp指向隊首//glGenTextures(1, &texturesArr);glBindTexture(GL_TEXTURE_2D, texturesArr);//glTexImage2D第七第八個參數定義了源圖的格式和數據類型,最后一個參數是真正的圖像數據//當前綁定的紋理對象就會被附加上紋理圖像,生成紋理glTexImage2D(GL_TEXTURE_2D, 0, 3, vp->width, vp->height, 0, GL_BGR_EXT, GL_UNSIGNED_BYTE, vp->buffer);frame_queue.size--;frame_queue.front = (frame_queue.front + 1) % MAXSIZE;}LeaveCriticalSection(&frame_queue.cs);//glColor3f(1.0, 0.0, 0.0); //繪制物體所使用的顏色// 啟用頂點數組glEnableClientState(GL_VERTEX_ARRAY);//啟用紋理數組 頂點坐標+紋理坐標glEnableClientState(GL_TEXTURE_COORD_ARRAY);/*glVertexPointer指定頂點數組的位置,3表示每個頂點由三個量構成(x, y,z),GL_FLOAT表示每個量都是一個GLfloat類型的值。第三個參數0表示緊密排列。最后一個指明了數組實際的位置。*/glVertexPointer(3, GL_FLOAT, 0, verticals);glTexCoordPointer(2, GL_FLOAT, 0, UV_TEX_VERTEX);//繪制,第二個參數是從數組緩存中的哪一位開始繪制,一般為0。第三個參數為數組中頂點的數量。glDrawArrays(GL_TRIANGLES, 0, (180 / cap_H) * (360 / cap_W) * 6);glDisableClientState(GL_TEXTURE_COORD_ARRAY);glDisableClientState(GL_VERTEX_ARRAY); // disable vertex arraysglFlush();//保證繪圖命令將實際進行,而不是存儲在緩沖區等待其他命令}DWORD WINAPI ThreadFunc(LPVOID n) {AVFormatContext* pFormatCtx;int i, videoindex;AVCodec* pCodec;//解碼器AVCodecContext* pCodecCtx = NULL;char filepath[] = "cuc_ieschool.mp4";av_register_all();//注冊組件avformat_network_init();//支持網絡流pFormatCtx = avformat_alloc_context();//創建AVFormatContext結構體//該函數讀取文件頭并將有關文件格式的信息存儲在我們提供的AVFormatContext結構中。//最后兩個參數用于指定文件格式,緩沖區大小和格式選項,但是通過將其設置為NULL或0,libavformat將自動檢測它們。if (avformat_open_input(&pFormatCtx, filepath, NULL, NULL) != 0){//打開一個輸入流printf("Couldn't open input stream.(無法打開輸入流)\n");return -1;}if (avformat_find_stream_info(pFormatCtx, NULL) < 0)//獲取流信息{printf("Couldn't find stream information.(無法獲取流信息)\n");return -1;}videoindex = -1;for (i = 0; i < pFormatCtx->nb_streams; i++){//找到流隊列中,視頻流所在位置if (pFormatCtx->streams[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO){videoindex = i;break;}}if (videoindex == -1){printf("Didn't find a video stream.(沒有找到視頻流)\n");return -1;}//查找解碼器pCodecCtx = pFormatCtx->streams[videoindex]->codec;pCodec = avcodec_find_decoder(pCodecCtx->codec_id);if (pCodec == NULL){printf("Codec not found.(沒有找到解碼器)\n");return -1;}if (avcodec_open2(pCodecCtx, pCodec, NULL)<0){printf("Could not open codec.(無法打開解碼器)\n");return -1;}AVFrame *pFrame;pFrame = av_frame_alloc();//分配視頻幀,存儲從packet中解碼出來的原始視頻幀int ret, got_picture;AVPacket *packet = (AVPacket *)av_malloc(sizeof(AVPacket));AVFrame *pFrameBGR = NULL;pFrameBGR = av_frame_alloc();struct SwsContext *img_convert_ctx;int index = 0;while (av_read_frame(pFormatCtx, packet) >= 0)//return 0 if OK, < 0 on error or end of file{if (packet->stream_index == videoindex)//判斷是不是來自視頻流的數據包,不是會直接跳出{//解碼,將數據包轉換為幀。輸入為packet,輸出為original_video_frame//其中的pFrame存儲解碼視頻的AVFrame。ret = avcodec_decode_video2(pCodecCtx, pFrame, &got_picture, packet);if (ret < 0){printf("Decode Error.(解碼錯誤)\n");continue;}if (got_picture){index++;flag_wait:if (frame_queue.size >= MAXSIZE)//如果解碼過快列隊存儲不下,則此線程暫緩,讓主線程渲染后再繼續運行{printf("size = %d I'm WAITING ... \n", frame_queue.size);Sleep(100);goto flag_wait;}EnterCriticalSection(&frame_queue.cs);//防止數據錯亂Vid_Frame *vp;vp = &frame_queue.queue[frame_queue.rear];//vp指向列隊的尾部,自動就知道rear?//vp->frame->pts = pFrame->pts;/* alloc or resize hardware picture buffer *///令vp的buffer的size width height都等于pFrame,就是給存儲數據的buffer賦值if (vp->buffer == NULL || vp->width != pFrame->width || vp->height != pFrame->height){if (vp->buffer != NULL){av_free(vp->buffer);vp->buffer = NULL;}//int iSize = avpicture_get_size(AV_PIX_FMT_BGR24, pFrame->width, pFrame->height);int iSize = av_image_get_buffer_size(AV_PIX_FMT_BGR24, pFrame->width, pFrame->height, 1);av_free(vp->buffer);vp->buffer = (uint8_t *)av_mallocz(iSize);vp->width = pFrame->width;vp->height = pFrame->height;}av_image_fill_arrays(vp->frame->data, vp->frame->linesize, vp->buffer, AV_PIX_FMT_BGR24, pCodecCtx->width, pCodecCtx->height, 1);//frame和buffer都是已經申請到的一段內存, 會將frame的數據按BGR24的格式自動"關聯"到buffer。//avpicture_fill((AVPicture *)vp->frame, vp->buffer, AV_PIX_FMT_BGR24, pCodecCtx->width, pCodecCtx->height);if (vp->buffer){//用sws_getContext初始化SwsContex/*srcW:源圖像的寬srcH:源圖像的高srcFormat:源圖像的像素格式dstW:目標圖像的寬dstH:目標圖像的高dstFormat:目標圖像的像素格式flags:設定圖像拉伸使用的算法*/img_convert_ctx = sws_getContext(vp->width, vp->height, (AVPixelFormat)pFrame->format, vp->width, vp->height,AV_PIX_FMT_BGR24, SWS_BICUBIC, NULL, NULL, NULL); //AV_PIX_FMT_YUV420P, AV_PIX_FMT_BGR24//轉換一幀圖像,轉換完成的數據保存到了vp,也自動到了buffer里面。sws_scale(img_convert_ctx, pFrame->data, pFrame->linesize, 0, vp->height, vp->frame->data, vp->frame->linesize);//釋放SwsContext結構體sws_freeContext(img_convert_ctx);//vp->pts = pFrame->pts;}frame_queue.size++;frame_queue.rear = (frame_queue.rear + 1) % MAXSIZE;LeaveCriticalSection(&frame_queue.cs);}}av_free_packet(packet);}avcodec_close(pCodecCtx);avformat_close_input(&pFormatCtx);return 0; }void reDraw(int millisec) {glutTimerFunc(millisec, reDraw, millisec);glutPostRedisplay(); }void keyboard(unsigned char key, int x, int y) {switch (key){case 'x': //當按下鍵盤上x時,以沿X軸旋轉為主xangle += 1.0f; //設置旋轉增量break;case 'X':xangle -= 1.0f; //設置旋轉增量break;case 'y':yangle += 1.0f;break;case 'Y':yangle -= 1.0f;break;case 'z':zangle += 1.0f;break;case 'Z':zangle -= 1.0f;break;case 'd':distance += 10.0f;break;case 'D':distance -= 10.0f;break;default:return;}glutPostRedisplay(); //重繪函數 }//處理鼠標點擊 void Mouse(int button, int state, int x, int y) {if (state == GLUT_DOWN) //第一次鼠標按下時,記錄鼠標在窗口中的初始坐標{//記住鼠標點擊后光標坐標cx = x;cy = y;} }//處理鼠標拖動 void onMouseMove(int x, int y) {float offset =0.3;// 0.18;值越大,到相同位置轉的角度就越大,即需要拖得越長才能到相應的位置//計算拖動后的偏移量,然后進行xy疊加減yangle -= ((x - cx) * offset);if ( y > cy) {//往下拉xangle += ((y - cy) * offset);}else if ( y < cy) {//往上拉xangle += ((y - cy) * offset);}glutPostRedisplay();//保存好當前拖放后光標坐標點cx = x;cy = y; }int main(int argc, char* argv[]){printf("可通過按鍵或者鼠標控制視頻旋轉\n");glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB | GLUT_DEPTH);//創建窗口的模式:單緩沖區和RGB模式、使用深度緩存glutInitWindowSize(1280, 720);//窗口大小glutInitWindowPosition(50, 50);//窗口左上角屏幕位置glutCreateWindow("OpenGL全景");init();glutReshapeFunc(reshape); //窗口大小改變或窗口位置改變時候要調用的函數glutDisplayFunc(display); //指定當窗口內容需要重繪時要調用的函數,在窗口剛打開,彈出,改變位置,點擊等,都會觸發事件。glutKeyboardFunc(keyboard);//當一個能生成ASCII字符的鍵按下時,keyboard函數會被調用glutMouseFunc(Mouse);//當一個鼠標按鈕按下或釋放,調用Mouse函數glutMotionFunc(onMouseMove);//當鼠標按下并在窗口移動鼠標時,調用onMouseMove函數glutTimerFunc(25, reDraw,1);//原最后一個參數為25HANDLE hThrd = NULL;DWORD threadId;hThrd = CreateThread(NULL, 0, ThreadFunc, 0, 0, &threadId);//創建一個新線程//該函數才真正進入GLUT事件循環,語句阻塞在此。//當對應的事件發生時,被注冊的回調函數如glutDisplayFunc中注冊的就會被調用。glutMainLoop();//無限執行的循環,判斷窗口是否需要重繪//WaitForSingleObject(hThrd, INFINITE);if (hThrd){CloseHandle(hThrd);//線程句柄生命周期結束}return 0; }總結
以上是生活随笔為你收集整理的全景视频播放器代码分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于Arduino开发板的光学指纹识别模
- 下一篇: C++第一次实验(基础班)项目2