· 动画屏幕是视图元素,由JMFMovieScreen类描述。
· 动画是模型部分,由JMFSnapper类所管理。
· 一个Java 3D Behavior类TimeBehavior是控制器,由它完成从动画中的周期性帧检索,然后在屏幕上画出。
在这篇文章中,我将revisit该动画组件,用 QuickTime for Java (QTJ)来重新实现之。QTJ在QuickTime API之上提供了一个面向对象的Java 层,使之有可能实现播放,编辑和创建QuickTime 动画;捕获音频与视频;执行2D和3D动画。QuickTime在Mac 和Windows上平台都可以使用。关于QTJ的安装,文档和举例的细节信息请参见developer.apple.com/quicktime/qtjava。
由QTJ 来取代JMF 设计模式的结果对应用程序影响很小-只有动画类JMFSnapper分离出来,由QuickTime for Java版本的QTSnapper所代替。
图 1展示了QTJ 版本的Movie3D应用程序中的两幅屏幕快照,其中右边的那幅是屏幕从后面看上去的视图。
图 1.QTJ Movie3D应用程序中的两幅视图 |
如果快点回顾一下第一部分中的图1,你会发现基于QTJ的应用程序和JMF 版本的实现没有太明显的区别。
但是,如果细致比较一下这两处执行程序会发现有两个变化:QTJ版本的动画像素化pixelation 更明显,且播放速度更慢些。像素化的出现是由于原始的动画被从MPEG 格式翻译成QuickTime的MOV格式所致,可以借助于一个更好的转换工具来实现修补。速度问题更为根本性:它与QTSnapper的基本实现有关。
该文中主要涉及到:
·讨论QTSnapper实现中的两种主要方法。一种方法是把动画的每一帧都着色到屏幕上去,另一种方法是基于当前时间选择一帧。 后一种方法意味着,可以跳过一些帧,使得动画颤动一点,但是却使动画播放速度加快。
·介绍几种简单的帧/秒(FPS)计算方法,我将用之来判断不同实现方式的相对速度的不同,并用来检测跳过的帧。
我不会再细致地介绍动画屏幕和动画更新行为,因为这些与第一部分中是一致的。
下面我将详细介绍的是我应用在QTSnapper中的用于从动画中提取帧的QTJ技术。
1. 程序实现中的两幅轮廓图
下面的图2描述了该应用程序中的场景图。
图 2. Movie3D场景图 |
该图几乎和第一部分中的一模一样。
QuickTime动画由QTSnapper类负责装载。动画屏幕由QTMovieScreen创建,它管理一个放置在跳棋盘地板上的Java 3D四边形。每隔40毫秒,TimeBehavior 对象调用QTMovieScreen中的nextFrame()方法一次,该方法调用中QTSnapper 的getFrame()方法来取得动画中的一帧,该帧最后被放置到由QTMovieScreen管理的四边形上。
JMFSnapper和QTSnapper之间有一个重要的不同。JMFSnapper返回当前正播放动画的当前帧,而QTSnapper依赖于一个递增的索引值返回动画中的当前帧。
例如,当getFrame()在JMFSnapper中被反复调用时,它可以检索帧1,3,6,9,11,等等,具体依赖于调用的方法和动画的播放速度。当在QTSnapper中调用getFrame()时,它将返回帧1,2,3,4,等等。
下面的图3描述了该应用程序的UML类图,其中仅显示了类的公共方法。
图 3. Movie3D类图 |
除了动画屏幕QTMovieScreen类和动画QTSnapper类名字的区别外,这里的应用程序类继承图与第一部分中的没有区别。事实上,只有Snapper类的内部实现变更了一些。
把应用程序从JMF Movie3D版本迁移到QTJ版本要求代替Snapper类。另外,还需要把动画屏幕类中的两行代码改变一下,其中声明了Snapper 类并被实例化:
//全局变量定义 private QTSnapper snapper; //以前是JMFSnapper //在构造器中以fnm方式装入动画 snapper = new QTSnapper(fnm); |
这两处变化是把JMFMovieScreen 改名为QTMovieScreen的唯一原因。
所有该示例的代码以及本文的一个早期版本,都能在KGPJ website处找到。
2. 一部帧到帧Frame-By-Frame 的动画
理解了QTSnapper的内部工作机理,有助于对QuickTime动画的结构有一个基本了解。每个动画可能由多个音频和视频轨道合成,而在时间上相重叠。下图4展示了这种基本思想。
图 4.一个QuickTime动画的内部结构 |
每个轨道管理自己的数据,如它包含的媒体的类型和媒体本身。该媒体容器具有它自己的数据结构,包括持续时间和播放速率(也就是,每秒要显示多少个样本)。这里的媒体实际上是一系列的样本(或者帧),第一个样本从时间0开始(with respect to media time)。样本被索引化了,如第一个样本在位置1处(而不是0)。
简化后的QuickTime轨道和媒体结构如下图5所示。
图 5. 一个QuickTime轨道和媒体的内部结构 |
要想更全面的了解,请参考QuickTime教程的动画部分。
存取动画的视频媒体
在QTSnapper类构造器中打开动画:
// 全局变量 private boolean isSessionOpen = false; private OpenMovieFile movieFile; private Movie movie; //在构造器中启动一个QuickTime会话 QTSession.open(); isSessionOpen = true; //打开动画 movieFile =OpenMovieFile.asRead( new QTFile(fnm) ); movie = Movie.fromFile(movieFile); |
QTSession.open()的执行来实现使用QuickTime 之前对其初始化。在程序最后的终止时刻应该有一个相应的QTSession.close()调用。
视频轨道的定位(如果存在的话)及其媒体的存取如下:
//更多的全局变量 private Track videoTrack; private Media vidMedia; //在构造器中,从动画中提取视频轨道 videoTrack = movie.getIndTrackType(1, StdQTConstants.videoMediaType, StdQTConstants.movieTrackMediaType); if (videoTrack == null) { System.out.println("Sorry, not a video"); System.exit(0); } //取得由视频轨道使用的媒体 vidMedia = videoTrack.getMedia(); |
一旦媒体被暴露出来exposed,就可以从中提取各种信息:
//更多的全局变量 private MediaSample mediaSample; private int numSamples; //样本数 private int sampIdx; //当前样本索引 private int width; // 帧宽度 private int height; //帧高度 //在构造器中 numSamples = vidMedia.getSampleCount(); sampIdx = 1; //取得轨道中的第一个样本 mediaSample = vidMedia.getSample(0, vidMedia.sampleNumToMediaTime(sampIdx).time,1); //把图像的宽度和高度值存储在该样本中 ImageDescription imgDesc = ImageDescription) mediaSample.description; width = imgDesc.getWidth(); height = imgDesc.getHeight(); |
sampIdx用作计数器,实现样本(第一个样本从位置1开始)的反复提取。
动画图像的宽度和高度信息通过分析第一个样本得到,不过假设前提是所有的样本使用相同的尺寸。
计算FPS
由QTSnapper返回的每秒帧数将用于后面的对该类不同实现策略的比较。在构造器中实现必需元素的初始化的代码如下:
//帧速率全局变量 private long startTime; private long numFramesMade; //在构造器中初始化它们 startTime = System.currentTimeMillis(); numFramesMade = 0; |
收尾处理
当应用程序即将终止时,QTSnapper中的stopMovie()方法被调用,它给出FPS值并关闭QuickTime。
// 全局变量 private DecimalFormat frameDf=new DecimalFormat("0.#");//1dp synchronized public void stopMovie() { if (isSessionOpen) { //报告帧速率 long duration =System.currentTimeMillis() - startTime; double frameRate = ((double) numFramesMade*1000.0)/duration; System.out.println("FPS: " +frameDf.format(frameRate)); QTSession.close(); //关闭QuickTime isSessionOpen = false; } } |
stopMovie()和getFrame()均被同步化处理,以确保正在从动画中进行帧复制时不会出现QuickTime会话终止的情形。
抓帧
getFrame()方法从动画中以BufferedImage 对象的形式返回一个样本(一帧)。该帧的选取是通过存储在变量sampIdx(其取值范围从1到numSamples,然后重复)中的索引号实现的。
// 全局变量 private BufferedImage img, formatImg; synchronized public BufferedImage getFrame() { if (!isSessionOpen) return null; if (sampIdx > numSamples) //往回从第一个样本重新开始 sampIdx = 1; try { /*取得从指定的索引时间开始的样本*/ TimeInfo ti = vidMedia.sampleNumToMediaTime(sampIdx); mediaSample=vidMedia.getSample(0,ti.time,1); sampIdx++; writeToBufferedImage(mediaSample, img); //调整img的大小,把它写入formatImg Graphics g = formatImg.getGraphics(); g.drawImage(img,0,0,FORMAT_SIZE,FORMAT_SIZE,null); //覆盖掉图像上的当前时间 g.setColor(Color.RED); g.setFont(new Font("Helvetica", Font.BOLD, 12)); g.drawString(timeNow(), 5, 14); g.dispose(); numFramesMade++; //帧计数 } catch (Exception e) { System.out.println(e); formatImg = null; } return formatImg; } //getFrame()方法结束BufferedImage |
通过调用QTJ媒体类中的getSample()方法可以很容易地得到样本。不幸的是,把样本转化成BufferedImage仍然存在一定的困难--我把这些都藏在了我的writeToBufferedImage()方法的实现之中了。
这个方法的实现使用一些奇特的办法,这是从Chris W.Johnson的MovieFrameExtractor.java例子(你可以在quicktime-java 邮件列表中找到)中迁移过来的。
比较奇特的实现思路和细节!但都加上了良好的注释,你可以详细地研究这些代码。一个"原始的"图像从样本中提取出来,然后当被写入一个QuickTime 版本的Graphics 对象时解压。Graphics对象中未压缩的数据被复制到另一个"原始"图像中去,然后又被复制到一个像素数组(一个PixMap)中去。最后,该数组被写进一个空的BufferedImage的DataBuffer部分。
应用程序能运行吗?是否运行良好?
的确,Movie3D可以播放动画,但是大型的动画播放起来速度很慢。这是由于getFrame()方法提供帧的速度太慢了,此可以通过观察FPS来定量地计算出。
对于图1中的动画,程序在一台较慢的Windows 98机器上报出的FPS范围在每秒15到17帧。然而,TimeBehavior对象要求每40毫秒作一次更新,这转换成帧速的话,看上去大约接近25 FPS。
由于耗时的样本到BufferedImage的转换,getFrame()方法运行缓慢。由于当前对getFrame()的调用在作帧转化时陷于困境,所以下一步的请求因等待当前请求的完成而被耽误。
下面,我将分析克服这一问题的两种方法:其一是,当最终开始处理某一请求时,允许getFrame()进行帧跳跃处理;其二是,在getFrame()中尝试用一种不同的转换策略。下面我将轮流分析这两种方法,先看帧跳跃处理。
3. 帧跳跃处理的动画
新的Snapper类,QTSnapper1,在调用方法getFrame()时仍然返回一帧。该类与QTSnapper的不同在于,它相应于动画的当前运行时间而提供一帧。
例如,getFrame()方法可能检索第1,2,5,8,12帧,等等,具体依赖于什么时候调用该方法。这样以来,动画以良好的速度播放,但是漏掉的帧可能使得动画出现颤动现象。
相比较之下,QTSnapper将返回所有的帧(1,2,3,4,等等),但是在getFrame()方法调用之间的延误将使得动画运行缓慢。只是由于没有砍掉帧,所以不会出现一些颤动现象。
QTSnapper1中的一个关键元素是动画的"当前运行时间"的概念。我采用的解决途径是,当调用getFrame()方法时为QTSnapper对象计算当前运行时间,并把它转换成一个动画时间,最后转换成一个样本索引值。
QTSnapper1与QTSnapper一样有相同的公共方法,所以只要最少的修改即可应用在QTMovieScreen中。只有在动画播放时它们的区别才变得明显起来。在后面将详述的定量计算表明,现在的QTSnapper1方法显示图1中"表面上"的帧速率达21 FPS,而QTSnapper方法只能达到帧速率16 FPS。
存取动画的视频媒体
QTSnapper1遵循了与QTSnapper相同的步骤来实现动画视频的存取。一旦视频可用,好几个媒体值作为全局变量存储,以备后面getFrame()方法所用:
// 全局变量 private Media vidMedia; private int numSamples; private int timeScale; //媒体的时间刻度 private int duration; //媒体的持续时间 //在构造器中,取得由视频轨道使用的媒体 vidMedia = videoTrack.getMedia(); //存储视频细节以备后用 numSamples = vidMedia.getSampleCount(); timeScale = vidMedia.getTimeScale(); duration = vidMedia.getDuration(); |
获取帧
getFrame()方法中的新的编码部分在于如何计算用来存取一个特别样本的索引值;而该方法中的其余部分,对于writeToBufferedImage()的调用,以及把当前时间写到图像上等等,都和QTSnapper中一样。
// 全局变量 private MediaSample mediaSample; private BufferedImage img, formatImg; private int prevSampNum; private int sampNum = 0; private int numCycles = 0; private int numSkips = 0; // 在getFrame()函数中, //以秒为单位取得从QTSnapper1开始以来的时间 double currTime =((double)(System.currentTimeMillis()-startTime))/1000.0; // 使用视频的时间刻度 int videoCurrTime=((int)(currTime*timeScale)) % duration; try { // 备份前一个样本号 prevSampNum = sampNum; //计算新的样本号 sampNum = vidMedia.timeToSampleNum(videoCurrTime).sampleNum; //如果没有样本变化,则不生成一幅新的图像 if (sampNum == prevSampNum) return formatImg; if (sampNum < prevSampNum) numCycles++; //动画刚刚开始播放 // 记下跳过的帧号 int skipSize = sampNum - (prevSampNum+1); if (skipSize > 0) //跳过的帧 numSkips += skipSize; //取得从样本号的时间开始的一个样本 TimeInfo ti =vidMedia.sampleNumToMediaTime(sampNum); mediaSample = vidMedia.getSample(0,ti.time,1); getFrame()以秒为单位计算出当前的时间,从QTSnapper1的启动开始计算: double currTime =((double)(System.currentTimeMillis()-startTime))/1000.0; |
每一段QuickTime媒体都有它自己的时间刻度-ts,这样,一个时间单位就是1/ts秒。用时间刻度常数乘以currTime的值即得到在动画时间单位中的当前时间值:
int videoCurrTime=((int)(currTime*timeScale)) % duration; |
该时间值被用媒体持续时间为模作模运算加以修正,允许动画在当前时间到达动画末尾时重复播放。
这个时间值被通过调用Media的timeToSampleNum()方法映射到一个样本索引号:
sampNum=vidMedia.timeToSampleNum(videoCurrTime).sampleNum; |
前面使用的样本号存储在变量prevSampNum中,这就允许进行一些测试和计算。
如果"新的"样本号与前一个相同,那么就没有必要费劲地把这个样本转化成一个BufferedImage;getFrame()方法能够返回存在的formatImg参照。
如果新的样本号小于前一个相同,这意味着动画已经循环播放了,动画最开始的一帧将被播出。该循环通过增加变量numCycles的值来登记。
如果新的样本号大于前一个帧号加1,那就记下跳过的样本号。
收尾处理
stopMovie()打印出FPS值并关闭QuickTime会话,其实现方式同QTSnapper中的stopMovie()方法很相近。该函数还给出一些额外信息:
long totalFrames =(numCycles * numSamples) + sampNum; //报告跳过帧的百分比数 double skipPerCent=(double)(numSkips*100)/totalFrames; System.out.println("Percentage frames skipped: "+ frameDf.format(skipPerCent) + "%"); //"表面上的"FPS (AFPS) double appFrameRate = ((double) totalFrames * 1000.0) / duration; System.out.println("AFPS: "+frameDf.format(appFrameRate));//1dp |
appFrameRate代表了"表面上的"帧速率,该值是从QTSnapper1的创建开始算起,直到迭代结束得到的总帧数。所谓"表面上的",其意指并非所有的样本必须被着色到屏幕上。
应用程序能运行吗?是否运行良好?
以QTSnapper1取代QTSnapper,原来慢速的动画(如图1所示)现在的播放加快了。在播放快结束时,程序报出的表面上的帧速率数字达到31 FPS,实际的帧速率大约在16 FPS,跳过的帧数将近达到总帧数的50%。惊人的是,这么巨大数目的帧丢失在屏幕上的影响看上去并不明显。
对于另外一些小型动画来说,速度的提升并不那么引人注目;跳过的帧数所占百分比大约在5-10%。
不幸的是,存在两个问题:当跳跃帧时可能出现混杂的像素;对可以跳过的帧号缺乏有效的控制。
混杂的像素
这种效果如图6所示,不准确的像素使用了来自于视频中前一阶段的值。任何时候当QTSnapper1跳过一个动画帧时,下一幅被检索到的帧将会包含一些混杂的像素。其效果可以在图6中看到。这些不准确的像素使用了来自于视频中前一阶段的值。
图 6. 部分混杂的图像 |
借助于来自quicktime-java 邮件列表的人们(特别感谢George Birbilis和Dean Perry)的帮助,我找到了一个解决办法。
该问题是,我原先所有的动画样本都使用了temporal compression压缩算法,该压缩算法主要是利用了相邻视频帧的相似性。如果两个连续的帧有相同的背景,那么就没有必要再次存储背景了,而是仅存起这两帧的不同处即可。
这项技术,几乎为所有流行的视频格式所应用,意味着从帧中提取的一幅图像将依赖于该帧,也潜在地依赖于前面几个帧。
Temporal decompression解压算法在一个QuickTime DSequence对象中完成,该对象又为我自己的writeToBufferedImage()方法中所用。在Dsequence的构造器指定,在解压过程中QuickTime应该使用脱屏图像缓冲区进行操作。
帧中的图像被写向缓冲区,在此其又与早期的帧数据相结合。结果图像被传递到转换过程的下一步。
这种方式在QTSnapper1以顺序方式(不进行帧跳过,例如:1,2,3,4)解压样本时运行良好,但是在有跳帧的情况下报出错误。例如,当QTSnapper1跳过第5、6帧,然后解压第7帧,情况会怎样呢?该帧被写向一个QuickTime 图像缓冲区,然后又与早期的帧数据相结合。不幸的是,第5、6帧中的数据丢失了,因此结果图像是不正确的。
简单地说,图像中进来的混杂像素是由于动画中使用了temporal compression压缩算法的结果。一种选择是使用spatial compression压缩算法,这种算法独立地压缩每一帧数据,就象对单一图像进行JPEG或者GIF压缩的方式很相似。这种算法意味着,用于解压一帧的所有信息均来自于该帧本身;没有必要对前面的帧进行检查。
QuickTime MOV动画格式支持一种称作Motion-JPEG (M-JPEG)的a spatial compression压缩方式。我使用 QuickTime 6 Pro中的输出工具,选取"M-JPEG A codec"编码方式后,把图1中的样本保存成一种MOV文件。当Movie3D应用程序播放该动画时,没有混杂现象发生。
限制帧跳跃
关于QTSnapper1的另外一个问题是,getFrame()方法并不限制能够跳过的帧数。在我的测试过程中,跳过帧数的上限是3时,其效果并不显著。但是,如果getFrame()方法中使用一个较大的样本(例如,较大的尺寸和分辨率)加以转换,那么,代码运行速度的明显变慢将会由更多的帧跳越数所补偿,只是动画质量非常明显地变差。
4. 尽量加快图像的生成
上面在QTSnapper和QTSnapper1中使用的sample-to-BufferedImage转换方法(writeToBufferedImage())来源于Chris W. Johnson的一个例程。是否还存在更快的从样本中提取图像的方法?
关于QTJ的标准参考书是《QuickTime for Java: A Developer’s Notebook》(作者Chris Adamson,O’Reilly,出版时间2005.1)。本书的第五章介绍了QuickDraw内容,其中包含一个例子ConvertToJavaImageBetter.java,该例说明了怎样把一个样本抓取成一个PICT图像,然后把它转化成一个Java Image对象。你也可以在quicktime-java邮件列表处发现这个例子。
其中的转化方法并不直截易懂,这依赖于加在PICT对象前面的一个虚构的512字节的头部,这样该对象就可以被QuickTime 版本的ImageProducer当作一个PICT文件对待。
我借用了Adamson的代码作为我的另一个称作QTSnapper2的Snapper类的基本代码组成。该类负责一个没有帧跳跃的帧系列的着色,其工作方式同QTSnapper,但是使用了PICT-to-Image转换方法。
对于一些小的动画,QTSnapper2的性能与QTSnapper相差无几,但是对于一些稍微大些的如图1中的动画,与QTSnapper的16 FPS帧速率相比其平均帧速率下降到大约9 FPS。也就是说,基于PICT的转换技术慢于Johnson所用的技术。