陆其明 2004-08-18
http://hqtech.nease.net/Article/DS_VideoTransition.htm
在一段主题视频内容的开始部分或者结束部分加入渐变特效,是当今很多影视片制作的惯用手法。这种特效处理,带给观众的将是更自然、更舒适的视觉享受。作为程序员,你想过如何来实现这种特效吗?本文介绍的就是一种实现视频渐入渐出的简单易行的编程方法。
一. 渐入渐出算法与实现
本文将介绍一种类似于时针扫描的渐入渐出效果。首先,我们来描述一下时针扫描的运动过程。
图1 时针扫描过程
如图1,直线L1保持不动,直线L2以恒定的速率逆时针转动。L1和L2将视频图像帧分成两个区域:扇形区域1和扇形区域2。随着L2的旋转,扇形区域1的面积越来越大,扇形区域2的面积越来越小;直到L2旋转360度,最终与L1重合。如果是视频渐入,扇形区域1显示的就是主题视频内容,扇形区域2显示的就是背景色;总体的效果是:从一开始整幅的背景色,逐渐过渡到主题视频内容。如果是视频渐出,扇形区域1显示的就是背景色,扇形区域2显示的就是主题视频内容;总体的效果是:正在播放的主题视频内容上被一点一点覆盖上背景色,直至背景色占据整个图像帧范围(表示主题视频内容播放完毕)。
下面,笔者将给出上述这个渐入渐出过程的C++实现。为了方便起见,我们将一幅图像分成如图2的4个区域。
图2 时针扫描图像帧的区域划分
当视频渐入时,随着直线L2的旋转,它会依次落在第1区域、第2区域、第3区域和第4区域。当L2位于第1区域,第2、3、 4区域应该完全填上背景色,还有第1区域内L2上面的部分(通过计算L2的斜率来判断)也要填上背景色(第1区域内剩余的部分自然是显示主题视频内容);当L2位于第2区域,第3、 4区域应该完全填上背景色,另加第2区域内L2下面的部分;当L2位于第3区域,第 4区域应该完全填上背景色,另加第3区域内L2下面的部分;当L2位于第4区域,仅第4区域内L2上面的部分填上背景色。整个视频渐入过程如图3。(注:黑色为背景色,白色区域显示主题视频内容。)
图3 L2分别落在4个区域的情况(视频渐入)
视频渐入的C++实现
// 图像帧数据(注意:图像数据是以从下往上、从左往右的顺序存储的!)
unsigned char * pData;
unsigned char * m_pPixel; // 像素指针
unsigned char * m_pSubLine; // 行指针
// m_nWidth和m_nHeight为图像的宽度和高度(以像素为单位)
// m_nOriginalX 和m_nOriginalY为所分区域的宽度和高度
int m_nOriginalX = m_nWidth / 2;
int m_nOriginalY = m_nHeight / 2;
//……
// 假设整个运动过程在36个视频帧的时间内完成,
// 则L2每次步进的角度m_dStepAngle为10度
double m_dStepAngle = 360. / 36.;
// m_lProgress表示运动的进度,每次递增1,取值范围0~36
// Alpha表示L1和L2当前形成的角度,如图1所示
double Alpha = m_lProgress * m_dStepAngle;
// m_dSlope表示L2的斜率
const double m_pi = 3.1415926535;
double m_dSlope = fabs(tan(Alpha * m_pi / 180.));
// m_pxlConverter为一个自定义的像素转化器,
// GetPixelSize()函数返回每个像素使用的字节数
// 计算图像帧的宽度(以字节为单位)
int m_nLineBytes = m_nWidth * m_pxlConverter->GetPixelSize();
if (Alpha < 90) // L2位于第1区域
{
// 第2区域应该填上背景色
DrawSecondRegion(pData);
// 第3、4区域应该填上背景色
DrawBelowHalf(pData);
// 第1区域的一部分应该填上背景色
// 扫描图像帧第1区域中的各个像素
for (int y = m_nOriginalY; y < m_nHeight; y++)
{
for (int x = m_nOriginalX; x < m_nWidth; x++)
{
// 将图像帧第1区域中L2上面的像素替换为背景色
if ((y - m_nOriginalY) >= m_dSlope * (x - m_nOriginalX))
{
// 定位到(x,y)表示的像素
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
// 将(x,y)位置的像素替换为背景色
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 90 && Alpha < 180) // L2位于第2区域
{
// 第3、4区域应该填上背景色
DrawBelowHalf(pData);
// 第2区域的一部分应该填上背景色
for (int y = m_nOriginalY; y < m_nHeight; y++)
{
for (int x = 0; x < m_nOriginalX; x++)
{
// 将图像帧第2区域中L2下面的像素替换为背景色
if ((y - m_nOriginalY) <= m_dSlope * (m_nOriginalX - x))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 180 && Alpha < 270) // L2位于第3区域
{
// 第4区域应该填上背景色
DrawFourthRegion(pData);
// 第3区域的一部分应该填上背景色
for (int y = 0; y < m_nOriginalY; y++)
{
for (int x = 0; x < m_nOriginalX; x++)
{
// 将图像帧第3区域中L2下面的像素替换为背景色
if ((m_nOriginalY - y) >= m_dSlope * (m_nOriginalX - x))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 270 && Alpha < 360) // L2位于第4区域
{
// 第4区域的一部分应该填上背景色
for (int y = 0; y < m_nOriginalY; y++)
{
for (int x = m_nOriginalX; x < m_nWidth; x++)
{
// 将图像帧第4区域中L2上面的像素替换为背景色
if ((m_nOriginalY - y) <= m_dSlope * (x - m_nOriginalX))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
m_lProgress++;
当视频渐出时,随着直线L2的旋转,它同样会依次落在第1、2、3、4区域。当L2位于第1区域,仅第1区域内L2下面的部分填上背景色;当L2位于第2区域,第1区域应该完全填上背景色,另加第2区域内L2上面的部分;当L2位于第3区域,第1、2区域应该完全填上背景色,另加第3区域内L2上面的部分;当L2位于第4区域,第1、2、3区域应该完全填上背景色,另加第4区域内L2下面的部分。整个视频渐出过程如图4。(注:黑色为背景色,白色区域显示主题视频内容。)
图4 L2分别落在4个区域的情况(视频渐出)
视频渐出的C++实现
if (Alpha < 90) // L2位于第1区域
{
// 第1区域的一部分应该填上背景色
for (int y = m_nOriginalY; y < m_nHeight; y++)
{
for (int x = m_nOriginalX; x < m_nWidth; x++)
{
// 将图像帧第1区域中L2下面的像素替换为背景色
if ((y - m_nOriginalY) <= m_dSlope * (x - m_nOriginalX))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 90 && Alpha < 180) // L2位于第2区域
{
// 第1区域应该填上背景色
DrawFirstRegion(pData);
// 第2区域的一部分应该填上背景色
for (int y = m_nOriginalY; y < m_nHeight; y++)
{
for (int x = 0; x < m_nOriginalX; x++)
{
// 将图像帧第2区域中L2上面的像素替换为背景色
if ((y - m_nOriginalY) >= m_dSlope * (m_nOriginalX - x))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 180 && Alpha < 270) // L2位于第3区域
{
// 第1、2区域应该填上背景色
DrawAboveHalf(pData);
// 第3区域的一部分应该填上背景色
for (int y = 0; y < m_nOriginalY; y++)
{
for (int x = 0; x < m_nOriginalX; x++)
{
// 将图像帧第3区域中L2上面的像素替换为背景色
if ((m_nOriginalY - y) <= m_dSlope * (m_nOriginalX - x))
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 270 && Alpha < 360) // L2位于第4区域
{
// 第1、2区域应该填上背景色
DrawAboveHalf(pData);
// 第3区域应该填上背景色
DrawThirdRegion(pData);
// 第4区域的一部分应该填上背景色
for (int y = 0; y < m_nOriginalY; y++)
{
for (int x = m_nOriginalX; x < m_nWidth; x++)
{
// 将图像帧第4区域中L2下面的像素替换为背景色
if ((m_nOriginalY - y) >= m_dSlope * (x - m_nOriginalX)) // Below the line...
{
m_pPixel = pData + y * m_nLineBytes;
m_pPixel += x * m_pxlConverter->GetPixelSize();
m_pxlConverter->Convert(m_pPixel);
}
}
}
}
else if (Alpha >= 360) // L2与L1重合之后…
{
// 将整个图像帧都填上背景色
m_pSubLine = pData;
for (int y = 0; y < m_nHeight; y++)
{
m_pPixel = m_pSubLine;
for (int x = 0; x < m_nWidth; x++)
{
// 将当前像素替换为背景色
m_pxlConverter->Convert(m_pPixel);
// 指向当前像素的下一个像素
m_pPixel = m_pxlConverter->NextPixel(m_pPixel);
}
// 指向下一行
m_pSubLine += m_nLineBytes;
}
}
m_lProgress++;
二.组件开发与演示
有了时针扫描渐入渐出的算法实现,接下去的问题就是,如何来获取连续的视频图像帧数据?在这里我们可以借助于DirectX SDK自带的一个工具软件GraphEdit(即SDK目录下的Bin\DXUtils\graphedt.exe)。运行GraphEdit,如图5:
图5 GraphEdit工具软件
执行菜单命令File | Render Media File…,在随后弹出的对话框中选择一个多媒体文件(比如选定一个MPEG1文件butterfly.mpg),自动构建如图6的链路:
图6 使用GraphEdit构建的播放链路然后执行菜单命令Graph | Play就可以对butterfly.mpg文件实现播放了。同样执行Graph | Pause或Graph | Stop就可以暂停或停止当前的播放。
值的注意的是,GraphEdit播放butterfly.mpg文件采用的就是DirectShow技术!大家知道,DirectX是微软公司提供的一套在Windows平台上开发高性能图形、声音、输入、输出和网络游戏的编程接口;而DirectShow就是DirectX的一个成员,专门用于音视频数据采集、多媒体文件播放等方面的应用。DirectShow中最基本的功能模块叫做Filter(图6中每个矩形块都代表一个Filter);每个Filter都至少有一个Pin,用于接收数据或者输出数据;Filter总是完成一定的功能(图6中左边第一个Filter是文件源,MPEG-1 Stream Splitter负责将MPEG1数据流中的音频和视频分离,MPEG Audio Decoder负责将MPEG格式的音频数据解码,MPEG Video Decoder负责将MPEG格式的视频数据解码,Default DirectSound Device负责音频播放,Video Renderer负责视频显示);各种Filter按照一定的顺序串联起来,相互协作;数据在Filter之间沿着箭头的方向流动,直到Default DirectSound Device和Video Renderer。
DirectShow是一个模块化的、开放性的应用框架。我们可以开发自己的Filter组件,然后插入到Filter链路中的某个位置,以获得处理数据流的机会。拿本文需要实现的视频渐入渐出来说,我们完全可以将渐入渐出算法实现在一个Filter中,然后将其连接到视频解码Filter后面,以获取连续的、非压缩的图像帧数据。我们把这个Filter取名为“HQ Video Transition”;因为这个Filter可以在输入的图像帧上“就地”修改数据,因此Filter可以采用Trans-In-Place模型;Filter接受16位、24位和32位RGB格式的数据输入。HQ Video Transition开发完成后生成HqVidTrans.ax文件(假设放在C:\下),然后使用系统的Regsvr32.exe注册(方法是:执行命令行Regsvr32 C:\HqVidTrans.ax)。(注意:关于DirectShow Filter开发方法更细节的介绍,限于篇幅,笔者在这里就不作展开了;有兴趣的读者可以参考笔者的两本拙作《DirectShow开发指南》和《DirectShow实务精选》。HQ Video Transition Filter的源代码请读者到http://hqtech.nease.net下载。)
Filter组件开发完成并且成功注册之后就可以在GraphEdit中使用了。首先还是构建如图6的Filter链路。然后执行菜单命令Graph | Insert Filters…,在随后弹出的对话框中点开“DirectShow Filters”目录,然后找到“HQ Video Transition”一项双击将其加入。接着将MPEG Video Decoder与Video Renderer的连接断开(用鼠标选中这两个Filter之间的箭头后按下键盘的Delete键)。然后将MPEG Video Decoder连向HQ Video Transition,再将HQ Video Transition连向Video Renderer。(两个Filter之间的连接方法:首先在欲连接的上一级Filter的输出Pin上按住鼠标左键不放,拖动鼠标到下一级Filter的输入Pin上,最后放开鼠标左键。)最终的Filter链路如图7:
图7 在GraphEdit中使用渐入渐出Filter现在,执行菜单命令Graph | Play,我们就可以看到视频渐入渐出的演示了,如图8和图9所示:
图8 视频渐入的演示
图9 视频渐出的演示
三. 小结
本文介绍了视频渐入渐出的原理,以及一种时针扫描效果的算法和实现。随后借助于DirectShow,本文还完成了视频渐入渐出的效果演示。