导言:多媒体实验,一个基于WIN32平台开发的YUV视频播放器
设计要求
设计实现一个基于Win32的应用程序,包括如下功能(满分100分):
- 能够通过文件选择对话框选定两个视频文件。如果你使用OpenCV则可以选择常规的mp4或avi格式视频;如果你不使用OpenCV则建议载入yuv视频。我们在第十二周的实验代码压缩包“YUV视频播放的参考代码和YUV视频文件.zip”中包含了两个yuv视频文件,可供你使用。(占10分)
- 能通过菜单选择任意一个视频进行播放(占10分),具有播放(占10分)、暂停(占10分)、停止功能(占10分)。
- 能通过菜单选择画中画效果,即在播放某一个视频时,同时把另外一个视频缩小至前一个视频画面的右上角一同播放(占30分,其中缩小占10分、右上角占10分、一同播放占10分)。在画中画播放过程中,任意视频如果播放到结尾,该视频就从头重播,周而复始(占20分,其中若两个视频都能分别周而复始播放给20分,若只有一个可以则给10分)。
- 如果实现了额外的视频效果,则最多可以奖励15分。可以选择的视频效果: 1) 可以用鼠标拖拽的方式改变画中画的小视频的位置(5分) 2) 可以用鼠标在视频画面上绘制线条(5分) 3) 可以将画中画的小视频改为边缘图显示(即显示其画面的边缘图而不是原图)(5分)
设计过程
本项目以老师在第12章的发布的 VideoProcDemo 项目为基础开发。
1. 播放YUV视频(10分)。老师的Demo已经可以进行YUV视频的播放,此项完成。
2. 通过菜单选择任意一个视频进行播放(10分)。第一反应是使用一个资源管理器进行文件的浏览和选择,于是选择了 OPENFILENAME 浏览文件,实现了通过菜单选择任意一个视频进行播放。
//浏览文件后保存选择文件名字
OPENFILENAME ofn; // 文件浏览框
TCHAR szFile[MAX_PATH]; // 保存文件的名字
定义全局变量
完整的打开视频操作如下,读取 szFile 即可获得路径。
ZeroMemory(&ofn, sizeof(OPENFILENAME));
ofn.lStructSize = sizeof(OPENFILENAME);
ofn.hwndOwner = hWnd;
ofn.lpstrFile = szFile;
ofn.nMaxFile = sizeof(szFile);
ofn.lpstrFilter = _T("YUV视频文件(*.yuv)\0*.yuv\0\0");
ofn.nFilterIndex = 1;
ofn.lpstrFileTitle = NULL;
ofn.nMaxFileTitle = 0;
ofn.lpstrInitialDir = NULL;
ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST;
if (GetOpenFileName(&ofn) == TRUE) {
//TODO
}
注意:如果按照上述代码的方法,需要在 “项目” —— “项目设置” 中将 “字符集” 由 “使用Unicode字符集” 改为 “使用字节字符集”。
3. 播放、暂停和停止(30分)。本项目中视频播放依靠的是定时器TIMER,在case WM_TIMER分支中对定时器事件进行处理,于是我使用两个变量实现对大小视频的状态记录。
static int vedioFirstStatus = 0; // 视频1的播放状态 1 播放 2 暂停 3 停止
定义全局变量
当变量为1时,case WM_TIMER 分支就会继续播放视频,当变量为其他值时,则不会进行播放;点击暂停,变量会变为2,但此时不对变量n做出改动,当点击播放时,变量变为1,实现视频的播放效果,视频会继续播放,实现暂停的效果;点击停止,变量会变为3,此时设置变量n为0,当点击播放时,视频会从头播放,实现停止的效果。
case ID_PAUSE:
if (vedioFirstStatus == 1) {
vedioFirstStatus = 2;
}
break;
case ID_STOP:
if (vedioFirstStatus != 3) {
n = 0;
vedioFirstStatus = 3;
}
break;
4. 能通过菜单选择画中画效果,即在播放某一个视频时,同时把另外一个视频缩小至前一个视频画面的右上角一同播放(占30分,其中缩小占10分、右上角占10分、一同播放占10分)。在 case WM_TIMER 分支中,首先对大视频进行渲染,在大视频渲染完成后,再对小视频进行渲染,可以实现同时播放的效果。对渲染函数 SetDIBitsToDevice 的传入变量进行修改,可以实现视频位置的改变,实现右上角的效果。缩小效果我采用隔行采样实现,即通过横向和纵向的各行采样,并将采样到的值存入长宽均为原视频一半的变量中,实现图像的缩小,比例为1:4。
5. 在画中画播放过程中,任意视频如果播放到结尾,该视频就从头重播,周而复始(占20分,其中若两个视频都能分别周而复始播放给20分,若只有一个可以则给10分)。在视频的播放过程中,全局变量n对当前播放视频的帧数进行计数,设置n超过一定大小时归零,实现两个视频的循环播放。
case WM_TIMER:
hdc = GetDC(hWnd);
if (vedioFirstStatus == 1) {
n = n + 1;
if (n > 299) n = 0;
6. 可以用鼠标拖拽的方式改变画中画的小视频的位置(5分)。首先定义 currentxPos 和 currentyPos 来记录当前鼠标点击的x和y的坐标,当落点在小视频范围内时,拖拽鼠标,记录下移动的距离,并计算更新获得当前的 currentxPos 和 currentyPos ,下一帧就会在新的位置进行渲染,从而实现拖拽改变小视频位置的效果。
bool mouse_L_pressed = false; // 鼠标左键是否处于被按下的状态
static int lastxPos = NULL; // 上一个X坐标
static int lastyPos = NULL; // 上一个Y坐标
//移动
static int currentxPos = 176;
static int currentyPos = -144;
定义全局变量,currentyPos 为什么是-144 会在难点解析中细说
case WM_LBUTTONDOWN:
mouse_L_pressed = true;
break;
case WM_LBUTTONUP:
mouse_L_pressed = false;
lastxPos = NULL;
lastyPos = NULL;
break;
控制鼠标按下的 case 分支
// 拖拽移动
else if (mouse_L_pressed && draw_mode == 0) {
int xPos = GET_X_LPARAM(lParam); //当前鼠标所在的X位置
int yPos = GET_Y_LPARAM(lParam); //当前鼠标所在的Y位置
int xMove;
int yMove;
if (lastxPos != NULL && lastyPos != NULL) {
if (xPos > currentxPos && xPos < currentxPos + 176 && yPos > currentyPos && currentyPos < currentyPos + 144) {
xMove = lastxPos - xPos;
yMove = lastyPos - yPos;
if (currentxPos - xMove > 0 && currentxPos - xMove < 176 && currentyPos - yMove > -144 && currentyPos - yMove < 0) {
currentxPos -= xMove;
currentyPos -= yMove;
}
}
}
lastxPos = xPos;
lastyPos = yPos;
}
break;
上述代码写在 case WM_MOUSEMOVE 中
SetDIBitsToDevice(hdc,
currentxPos,
currentyPos,
176,
288,
0,
0,
0,
144,
det_image2,
pbmi2,
DIB_RGB_COLORS);
7. 可以用鼠标在视频画面上绘制线条(5分)。定义一个变量 draw_mode,当点击开始绘制时,draw_mode 变为1,通过 MoveToEx 和 LineTo 函数实现起点和终点之间的连线。由于视频播放时会使线条消失,于是我使用数组 startx、starty、endx、endy 记录下所有的起点和终点,在图片渲染时进行重绘,实现在播放的视频上绘制线条。
int draw_mode = 0; // 绘图模式,0代表不绘图,1代表画线
定义全局变量
case ID_START_DRAW_LINE:
if (szFile[0] != '\0')
draw_mode = 1;
break;
case ID_STOP_DRAW_LINE:
draw_mode = 0;
break;
// 绘图
if (mouse_L_pressed && draw_mode == 1) {
HDC hdc = GetDC(hWnd);
int xPos = GET_X_LPARAM(lParam);
int yPos = GET_Y_LPARAM(lParam);
if (lastxPos != NULL && lastyPos != NULL) {
MoveToEx(hdc, lastxPos, lastyPos, NULL);
LineTo(hdc, xPos, yPos);
startx[count] = lastxPos;
starty[count] = lastyPos;
endx[count] = xPos;
endy[count] = yPos;
count++;
}
lastxPos = xPos;
lastyPos = yPos;
}
上述代码写在 case WM_MOUSEMOVE 中。
注意:由于使用 MoveToEx 需要鼠标上次停留的坐标,而如果不存在上次停留的坐标时,会默认坐标位(0,0),于是我定义初始坐标为NULL,再加以判断,以此忽略第一次划线。
8. 可以将画中画的小视频改为边缘图显示(即显示其画面的边缘图而不是原图)(5分)。使用老师在第九周实验中提供的平滑滤波卷积核,对图像进行平滑滤波处理。
//边缘图
float smooth_kernel[3][3] = { {0.11,0.11,0.11},
{0.11,0.11,0.11},
{0.11,0.11,0.11} }; //用于图像模糊的滤波核
int smooth = 0;
定义全局变量
将原图与处理过后的模糊图对应像素相减,实现边缘图显示的效果。
#define ABS(a) a < 0 ? -a : a
#define EDGE(a) a < 0 ? 0 : a
void FilterBmp(float kernel[3][3])
{
int i, j, x, y, idx, idy;
byte r, g, b, r2, g2, b2;
float sumR, sumG, sumB;
int finalR, finalG, finalB;
float weight;
for (i = 0; i < 288; i++)
{
for (j = 0; j < 176; j++)
{
//以每个像素为中心进行3x3的加权求和
sumR = sumG = sumB = 0;
for (y = i - 1; y <= i + 1; y++)
{
for (x = j - 1; x <= j + 1; x++)
{
if (y < 0 || y >= 288 || x < 0 || x >= 176)
{
//如果待加权位置越界,则其“像素”值视为0,0,0
r = g = b = 0;
}
else {
//获得待加权位置的像素值
r = det_image2[y][x].r;
g = det_image2[y][x].g;
b = det_image2[y][x].b;
}
//与滤波核的对应位置进行加权求和
idy = y - (i - 1);
idx = x - (j - 1);
weight = kernel[idy][idx];
sumR += weight * r;
sumG += weight * g;
sumB += weight * b;
}
}
//将结果输出
r = ABS(sumR);
g = ABS(sumG);
b = ABS(sumB);
finalR = det_image2[i][j].r - r;
finalG = det_image2[i][j].g - g;
finalB = det_image2[i][j].b - b;
edge_det_image2[i][j].r = EDGE(finalR);
edge_det_image2[i][j].g = EDGE(finalG);
edge_det_image2[i][j].b = EDGE(finalB);
}
}
}
难点解析
在本次实验中,我遇到最难以处理的问题是视频的缩小问题。按照我原先的想法,在我进行隔行采样后,应该就能得到一个大小比为1:4的小视频,但是在实际的操作过程中,小视频显示出来的图像总是只显示一半,我调整了很多参数,要么显示上半部分,要么显示下半部分,就是无法完全显示
在尝试多次无果后,我采用了一种很取巧的解决办法,我将缓存下一帧的变量设置为:
static COLOR det_image2[IMAGE_HIGHT][IMAGE_WIDTH/2]; //要显示的目标图像
即IMAGE_HIGHT不除2,于是我得到了一个上半部分带有黑边的完整视频。
最后在 SetDIBitsToDevice 函数中将传入的扫描线数量变量由 IMAGE_HIGHT 设置为 IMAGE_HIGHT/2,由于这个函数渲染图片时从底部进行扫描,于是将扫描线数量设置为一半后,上半部分的黑色部分消失,获得一个理想的缩小视频。
因此当设置位置时,需要计算黑色的上半部分,因此 currentyPos 需要设置为 -144 。
成果展示
Bilibili视频链接:基于WIN32的画中画视频程序演示
Github源码地址:VideoProcDemo
实验总结
通过这次实验,我对这学期所学的WIN32的程序开发有了更深的了解,同时也熟悉了YUV视频的结构和存储方式和对位图的处理,我非常高兴能在实践中利用所学知识。
在实验中,我也遇到了不少的困难,因为是初次接触WIN32的程序开发,很多地方还不太熟悉,但我还是努力克服了困难,最终完成了程序的开发。
纸上得来终觉浅,绝知此事要躬行。学习不能只拘泥于课本,还是要以实践为主。书本上学到的终究是理论,只有多实践,才能从不断的失败中汲取经验,从而获得进步。