一、引言
在《moviepy音视频剪辑:moviepy中的剪辑基类Clip的属性和方法详解》介绍了fl方法是对剪辑进行变换处理返回一个新剪辑的方法,新剪辑是调用剪辑的一个浅拷贝,但新剪辑的内容是原剪辑内容的变换,具体变换由fl的参数指定的函数fun来实现。
fl变换一般包括两种:
- 基于时间线的变换,一般通过fl_time方法进行,但fl_time方法会调用fl方法来实现(关于fl_time方法的使用请参考《moviepy音视频剪辑:使用fl_time进行诸如快播、慢播、倒序播放等时间特效处理的原理和可能遇到的坑》)
- 基于内容的变换,基于内容的变换实际上就是基于帧的变换,因此也带有时间特性。
本文介绍基于帧实现剪辑内容变换的原理和案例。
二、fl变换的原理
2.1、视频帧的本质
在《音视频处理基础知识扫盲:数字视频YUV像素表示法以及视频帧和编解码概念介绍》介绍了数字视频的基础知识,我们知道视频流本质上是基于YUV表示的原始流或编码流,流的内容就是帧流,一个个连续的帧对应的静态图像连续播放构成的帧流就是播放的视频,内容的变换本质上就是对帧的处理。
帧本身是YUV表示的图像,在程序处理中,这些帧对应的图像本质是什么呢?我们来看案例:
>>> from moviepy.editor import *
>>> from moviepy.Clip import *
>>> clip = VideoFileClip(r"F:\video\WinBasedWorkHard.mp4")
>>> clip.size #看剪辑的大小(也即分辨率)
[540, 960]
>>> frame = clip.get_frame(1) #取剪辑时间为1秒位置的帧
>>> type(frame)
<class 'numpy.ndarray'>
>>> frame.ndim
3
>>> frame.shape
(960, 540, 3)
>>>
可以看到,上述脚本加载了一个视频,该视频的分辨率大小为540*960,从该剪辑取了一个帧,可以看到帧的类型为numpy数组,其维度为3即三维数组,通过这个数组的shape可以看到,这个数组是由960行(高)数据构成,每行数据包含540列数据,这就对应了分辨率大小,数组的每个基本元素是一个三元组,应该就对应一个像素点的YUV值。
由以上案例可以看出来,视频帧在程序中被表示为与视频分辨率大小对应的一个矩阵,这个矩阵的行数是视频的高、列数是视频的宽,元素是YUV表示的像素点。
2.2、fl内容变换的原理
内容变换本质上就是对帧数据的处理,也就是对帧对应的三维数组数据的操作:
- 调整行的位置就是在垂直方向上调整视频内容的位置
- 调整列的位置就是在水平方向上调整视频内容的位置
- 同时调整行和列的位置就是调整视频空间的内容位置
- 调整三元组YUV就是对视频显示内容的变化
- 调整行高或列宽就是对视频的内容进行裁剪或扩充并对分辨率进行调整
2.3、进行变换处理需要注意的地方
对视频进行变换处理时,实际上处理的是帧的内容,需要注意:
- 视频的大小(分辨率),处理视频时,要避免行和列超出分辨率限制的情况出现,代码要有对此的处理;
- 对内容进行变换不能只考虑一个帧自身的变换,一定要基于帧流来考虑,因为单独一个帧的呈现时间非常短,只有帧流都是连续变换的才能保证变换在播放时的可见。如果这种连续变换是针对位置的,一定要记住变换处理是针对每帧的,相关变换位置必须在上一帧变换位置之上再变换才有意义。关于这段话是老猿在进行变换处理的经验总结,单独理解有点困难,请结合下面的案例再理解这段话;
- 变换处理要平滑,否则视频播放会感知差。
三、滚屏案例的实现波折
2.1、案例说明
在《moviepy音视频剪辑:moviepy中的剪辑基类Clip详解》介绍fl方法时介绍了一个案例这个案例其实是参考moviepy的样例实现,想要达到一种屏幕视频向上滚动的效果。
下面是moviepy关于这个案例的有关介绍:
In the following ``newclip`` a 100 pixels-high clip whose video
content scrolls from the top to the bottom of the frames of
``clip``.
>>> fl = lambda gf,t : gf(t)[int(t):int(t)+50, :]
>>> newclip = clip.fl(fl, apply_to='mask')
2.2、模拟案例的实现
老猿模拟上述案例对滚屏实现进行了改动,改成了如下:
src = VideoFileClip(r"F:\video\抖音-爱拼才会赢.mp4")
h = src.size[1]
fl = lambda gf,t : gf(t)[int(t):int(t)+h/2, :]
newclip = src.fl(fl, apply_to='mask')
newclip.write_videofile(r"F:\video\抖音-爱拼才会赢_re.mp4"
上述案例在实现后,发现视频播放时并没有滚屏,而是将视频内容剪裁掉了一半,分辨率的高度变成了原视频的一半,百思不得其解,最后在moviepy网站上作为一个问题提出(详见https://github.com/Zulko/moviepy/issues/1213),人家次日就进行了答复,说是这个变化太小了,每秒才滚动了一个像素,肉眼是不可见的,建议改为如下:
newclip = clipVideo.fl(lambda gf, t: gf(t)[int(50*t):int(50*t) + h, :], apply_to='mask').set_duration(clipVideo.duration)
2.2、模拟案例的改良
按照网站就问题的答复,将上述代码改成了如下:
src = VideoFileClip(r"F:\video\抖音-爱拼才会赢.mp4")
h = src.size[1]
fl = lambda gf,t : gf(t)[int(t*50):int(t*50)+h/2, :]
newclip = src.fl(fl, apply_to='mask')
newclip.write_videofile(r"F:\video\抖音-爱拼才会赢_re.mp4")
运行后播放输出文件,才开始播放时看到确实实现了滚屏效果,但随着播放时间的延长,滚屏越来越快,最后就卡在某个页面不变换了。仔细分析了一些,发现这个改良方法太粗暴,才开始时间线很小所以变化不大,而随着播放时间线越来越大,t*50的值会越来越大,到后来已经超出了视频分辨率的高,导致这个播放就卡主了。
四、滚屏案例的最终实现
4.1、实现思路
4.1.1、滚屏的处理
- 相邻帧绝大多数情况下画面内容基本一致,只有少数内容发生变化,通过连续播放才能体现变化;
- 单帧是无法体现滚屏效果的,滚屏就是下一帧相对上一帧将下面的行往上面移动,如果将帧数据的数组首尾环接,相当于每个帧的所有行数据在这个环内滚动流动,但实际上数组没有环接,因此需要应用实现环接的处理;
- 假设在屏幕上每秒要滚动n个像素点,从第1帧来看是数据帧的行环绕前进n行,第2帧则需要环绕移动2n行(如果第二帧还是只移动n行,则第二帧的变化位置相对第一帧没有变化,这样连续播放时就无法体现滚动效果),…,第k行则要环绕移动kn行。如果记录了上个帧移动的行数(假设为m),则下个帧需要移动m+n行;
- 当一次移动的行数超出了帧高,则需要回到帧的头重新开始,形成环绕移动的效果;
- 帧的环绕移动通过数组的切片和数组的叠加进行处理,这样简洁高效。
4.1.2、怎么在变换函数内获取帧高和fps
moviepy提供的变换方法fl的参数fn只能带两个参数,一个是类似get_frame获取帧的方法名,一个是帧的时间位置变量t。但上面说到,要实现环绕移动,需要知道帧高,在这个变换函数内怎么获取呢?另外滚屏的速度与帧率fps相关,同样移动的行数fps越大感知到的移动就越快,可能需要参考帧的这些属性来进行变换处理。
但fl对应的参数fun函数是无法通过自身的属性直接获取帧相关的数据的,同时上一帧移动位置到哪里里也不能通过函数变量进行传递。解决这个问题老猿想到两个办法:
- 通过全局变量进行变量传递(请参考《第5.4节 Python函数中的变量及作用域》),本文的样例就是这样来传递的;
- 帧数据通过fun函数的参数对应函数gf的返回值来获取,因为gf返回的是原剪辑参数t指定时刻的帧,通过这个可以获取到帧高、fps等数据,而滚动行数通过公式计算,假设每次滚动k行,具体公式为:
rows = int(t*fps*k)%帧高
。
4.2、代码实现
4.2.1、主程序
from moviepy.editor import *
import numpy as np
if __name__=='__main__':
clipVideo = VideoFileClip(r"F:\video\WinBasedWorkHard_src.mp4")
global framePos,clipHeight #存储滚动位置和帧高的全局变量定义,下面两行为初始化值
framePos = 0
clipHeight = clipVideo.size[1]
newclip = clipVideo.fl(flFunc , apply_to=['mask']).set_duration(clipVideo.duration) #使用fl进行变换处理,每个帧的变换调用flFunc函数进行
newclip.write_videofile(r"F:\video\WinBasedWorkHard_new.mp4")
newclip.close()
clipVideo.close()
4.2.2、实现变换函数及其子函数
def flFunc(gf,t): #变换主函数
return frameScroll(gf(t),2) #一帧往前滚动2行
def frameScroll(frame,x): #实现帧数据环绕滚动x行
global framePos,clipHeight
moveCount = framePos+x #本次移动行数在前一次行数基础上加x行
if moveCount>clipHeight: #环绕判断处理,其实用moveCount %= clipHeight语句更简洁效果相同,但取余的运算效率低一些
moveCount -= clipHeight
framePos = moveCount #记录本次移动的行数
remainFrame = frame[:moveCount] #取前moveCount-1行
exceedFrame = frame[moveCount:] #取后面剩余行
return np.vstack((exceedFrame,remainFrame)) #将两部分数据叠加实现环绕效果,返回作为变换后的帧
五、滚屏效果
通过以上代码就实现了滚屏效果,我们截取3张结果视频文件的播放图对比看一下。
可以看到视频确实在滚动。
六、小结
本文详细介绍了视频帧的数据存储本质、moviepy的剪辑基类Clip的帧变换方法fl的原理,并通过实现视频播放从下往上滚动的案例详细介绍了帧内容变换处理的实现。写作不易,如果觉得从这篇文章能有所收获,敬请点赞!谢谢!
本文的滚屏案例其实还可以用vfx.scroll方法来实现,这个方法老猿还没使用,且和讲解fl方法关系不大,在此暂不进行介绍。
更多moviepy的介绍请参考《PyQt+moviepy音视频剪辑实战文章目录》或《moviepy音视频开发专栏》。
关于收费专栏
老猿的付费专栏《使用PyQt开发图形界面Python应用》专门介绍基于Python的PyQt图形界面开发基础教程,付费专栏《moviepy音视频开发专栏》详细介绍moviepy音视频剪辑合成处理的类相关方法及使用相关方法进行相关剪辑合成场景的处理,两个专栏加起来只需要19.9元,都适合有一定Python基础但无相关专利知识的小白读者学习。
对于缺乏Python基础的同仁,可以通过老猿的免费专栏《专栏:Python基础教程目录》从零开始学习Python。
如果有兴趣也愿意支持老猿的读者,欢迎购买付费专栏。