使用 CGImageDestinationFinalize 创建大型 GIF - 内存不足
Posted
技术标签:
【中文标题】使用 CGImageDestinationFinalize 创建大型 GIF - 内存不足【英文标题】:Creating a large GIF with CGImageDestinationFinalize - running out of memory 【发布时间】:2015-06-25 17:03:50 【问题描述】:我正在尝试解决创建具有大量帧的 GIF 时的性能问题。例如,一些 GIF 可能包含 > 1200 帧。使用我当前的代码,我的内存不足。我试图弄清楚如何解决这个问题;这可以分批完成吗?我的第一个想法是是否可以将图像附加在一起,但我不认为有一种方法可以解决这个问题,或者ImageIO
框架是如何创建 GIF 的。如果有复数 CGImageDestinationAddImages
方法但没有,那就太好了,所以我不知道如何尝试解决这个问题。我感谢提供的任何帮助。很抱歉代码冗长,但我觉得有必要展示一步一步创建 GIF。
我可以制作视频文件而不是 GIF,只要视频中可能存在不同的 GIF 帧延迟并且录制所需的时间不会与每帧中所有动画的总和一样长。
p>注意:跳至下方的最新更新标题可跳过背景故事。
更新 1 - 6:
使用GCD
修复了线程锁,但内存问题仍然存在。 100% 的 CPU 利用率在这里不是问题,因为我在执行工作时显示UIActivityIndicatorView
。使用drawViewHierarchyInRect
方法可能比renderInContext
方法更有效/更快,但是我发现您不能在afterScreenUpdates
属性设置为YES 的后台线程上使用drawViewHierarchyInRect
方法;它锁定了线程。
必须有某种方法可以批量写入 GIF。我相信我已经将内存问题缩小到:CGImageDestinationFinalize
这种方法对于制作具有大量帧的图像似乎非常低效,因为所有内容都必须在内存中才能写出整个图像。我已经确认了这一点,因为我在抓取渲染的 containerView 层图像并调用CGImageDestinationAddImage
时使用的内存很少。
我打电话给CGImageDestinationFinalize
的那一刻,内存条立即飙升;根据帧数,有时最高可达 2GB。制作大约 20-1000KB 的 GIF 所需的内存量似乎很疯狂。
更新 2: 我发现有一种方法可能会带来一些希望。它是:
CGImageDestinationCopyImageSource(CGImageDestinationRef idst,
CGImageSourceRef isrc, CFDictionaryRef options,CFErrorRef* err)
我的新想法是,对于每 10 帧或其他任意 # 帧,我会将它们写入一个目标,然后在下一个循环中,之前完成的 10 帧目标将成为我的新源。但是有一个问题;阅读它声明的文档:
Losslessly copies the contents of the image source, 'isrc', to the * destination, 'idst'.
The image data will not be modified. No other images should be added to the image destination.
* CGImageDestinationFinalize() should not be called afterward -
* the result is saved to the destination when this function returns.
这让我觉得我的想法行不通,但可惜我试过了。继续更新 3。
更新 3:
我一直在尝试使用下面更新的代码的CGImageDestinationCopyImageSource
方法,但是我总是得到只有一帧的图像;这很可能是因为上面更新 2 中所述的文档。还有一种方法可以尝试:CGImageSourceCreateIncremental
但我怀疑这是我需要的。
似乎我需要某种方式将 GIF 帧增量写入/附加到磁盘,以便我可以将每个新块从内存中清除。也许 CGImageDestinationCreateWithDataConsumer
带有适当的回调来增量保存数据是理想的?
更新 4:
我开始尝试CGImageDestinationCreateWithDataConsumer
方法,看看我是否可以使用NSFileHandle
来管理写入字节,但同样的问题是调用CGImageDestinationFinalize
会一次性发送所有字节,这是和以前一样 - 我的内存用完了。我真的需要帮助来解决这个问题,并且会提供大量的赏金。
更新 5:
我已经发布了一大笔赏金。我希望看到一些没有第三方库或框架的出色解决方案,将原始NSData
GIF 字节彼此附加并使用NSFileHandle
将其逐渐写入磁盘 - 本质上是手动创建 GIF。或者,如果您认为可以使用ImageIO
找到解决方案,就像我尝试过的那样,那也太棒了。 Swizzling、子类化等。
更新 6: 我一直在研究如何在最低级别制作 GIF,并且我编写了一个小测试,该测试与我在赏金帮助下的目标一致。我需要获取渲染的 UIImage,从中获取字节,使用 LZW 对其进行压缩,并附加字节以及其他一些工作,例如确定全局颜色表。信息来源:http://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html。
最新更新:
我花了整整一周的时间从各个角度研究这个问题,以了解究竟是如何在限制(例如最大 256 色)的基础上构建质量不错的 GIF。我相信并假设ImageIO
正在做的是在引擎盖下创建一个单一的位图上下文,将所有图像帧合并为一个,并在该位图上执行颜色量化以生成要在 GIF 中使用的单个全局颜色表。在由ImageIO
制作的一些成功的 GIF 上使用十六进制编辑器可以确认它们具有全局颜色表并且永远不会有本地颜色表,除非您自己为每个帧设置它。在这个巨大的位图上执行颜色量化以构建调色板(再次假设,但坚信)。
我有一个奇怪而疯狂的想法:我的应用程序中的帧图像每帧只能相差一种颜色,更好的是,我知道我的应用程序使用了哪些小颜色集。第一个/背景框架是一个包含我无法控制的颜色的框架(用户提供的内容,如照片)所以我想我会快照这个视图,然后用我的应用处理的已知颜色快照另一个视图并使其成为一个单一的位图上下文,我可以将其传递到正常的ImaegIO
GIF 制作例程中。有什么好处?好吧,通过将两个图像合并为一个图像,可以将其从约 1200 帧降低到一个。 ImageIO
然后将在更小的位图上执行它的操作,并用一帧写出单个 GIF。
现在我可以做些什么来构建实际的 1200 帧 GIF?我想我可以采用单帧 GIF 并很好地提取颜色表字节,因为它们位于两个 GIF 协议块之间。我仍然需要手动构建 GIF,但现在我不必计算调色板。我将窃取调色板ImageIO
认为是最好的并将其用于我的字节缓冲区。在赏金的帮助下,我仍然需要一个 LZW 压缩器实现,但这应该比颜色量化容易得多,颜色量化可能会非常缓慢。 LZW 也可能很慢,所以我不确定它是否值得;不知道 LZW 将如何以约 1200 帧顺序执行。
你的想法是什么?
@property (nonatomic, strong) NSFileHandle *outputHandle;
- (void)makeGIF
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^
NSString *filePath = @"/Users/Test/Desktop/Test.gif";
[[NSFileManager defaultManager] createFileAtPath:filePath contents:nil attributes:nil];
self.outputHandle = [NSFileHandle fileHandleForWritingAtPath:filePath];
NSMutableData *openingData = [[NSMutableData alloc]init];
// GIF89a header
const uint8_t gif89aHeader [] = 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 ;
[openingData appendBytes:gif89aHeader length:sizeof(gif89aHeader)];
const uint8_t screenDescriptor [] = 0x0A, 0x00, 0x0A, 0x00, 0x91, 0x00, 0x00 ;
[openingData appendBytes:screenDescriptor length:sizeof(screenDescriptor)];
// Global color table
const uint8_t globalColorTable [] = 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00 ;
[openingData appendBytes:globalColorTable length:sizeof(globalColorTable)];
// 'Netscape 2.0' - Loop forever
const uint8_t applicationExtension [] = 0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00 ;
[openingData appendBytes:applicationExtension length:sizeof(applicationExtension)];
[self.outputHandle writeData:openingData];
for (NSUInteger i = 0; i < 1200; i++)
const uint8_t graphicsControl [] = 0x21, 0xF9, 0x04, 0x04, 0x32, 0x00, 0x00, 0x00 ;
NSMutableData *imageData = [[NSMutableData alloc]init];
[imageData appendBytes:graphicsControl length:sizeof(graphicsControl)];
const uint8_t imageDescriptor [] = 0x2C, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x00 ;
[imageData appendBytes:imageDescriptor length:sizeof(imageDescriptor)];
const uint8_t image [] = 0x02, 0x16, 0x8C, 0x2D, 0x99, 0x87, 0x2A, 0x1C, 0xDC, 0x33, 0xA0, 0x02, 0x75, 0xEC, 0x95, 0xFA, 0xA8, 0xDE, 0x60, 0x8C, 0x04, 0x91, 0x4C, 0x01, 0x00 ;
[imageData appendBytes:image length:sizeof(image)];
[self.outputHandle writeData:imageData];
NSMutableData *closingData = [[NSMutableData alloc]init];
const uint8_t appSignature [] = 0x21, 0xFE, 0x02, 0x48, 0x69, 0x00 ;
[closingData appendBytes:appSignature length:sizeof(appSignature)];
const uint8_t trailer [] = 0x3B ;
[closingData appendBytes:trailer length:sizeof(trailer)];
[self.outputHandle writeData:closingData];
[self.outputHandle closeFile];
self.outputHandle = nil;
dispatch_async(dispatch_get_main_queue(),^
// Get back to main thread and do something with the GIF
);
);
- (UIImage *)getImage
// Read question's 'Update 1' to see why I'm not using the
// drawViewHierarchyInRect method
UIGraphicsBeginImageContextWithOptions(self.containerView.bounds.size, NO, 1.0);
[self.containerView.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *snapShot = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// Shaves exported gif size considerably
NSData *data = UIImageJPEGRepresentation(snapShot, 1.0);
return [UIImage imageWithData:data];
【问题讨论】:
关键字:@autoreleasepool - ***.com/search?q=[objective-c]%40autoreleasepool 是的,但删除它也无济于事。 找到插入池的正确位置 - 您也可以嵌套它们。 您尝试过以下方法吗?对于每一帧:将帧图像写入单个图像文件,从该文件创建一个CGImageSource
,将该图像从该源添加到目标,释放源。完成目标后,删除临时图像文件。这个想法是图像由文件支持,因此它们不必一直在内存中。可以根据需要清除和重新加载它们。
我认为您在 ImageIO 上不会有太多的运气。你试过Giraffe吗?它是 ANGif library 的 Objective-C 包装器,它以增量方式写入输出文件(根据我的检查)。
【参考方案1】:
如果您将kCGImagePropertyGIFHasGlobalColorMap
设置为NO
,则不会发生内存不足的情况。
【讨论】:
应该更加注意这个答案,因为它是创建全局颜色图,在从许多大图像创建 GIF 时需要巨大的内存占用。禁用它会增加 GIF 的大小,但它会解决这里的部分或大部分内存问题。 我需要在哪里设置? @ScottH - 嗯,不是真的。kCGImagePropertyGIFHasGlobalColorMap
不会在多图像 GIF 中做任何事情。 '多图像 GIF 文件需要设置每个图像的单独属性,这意味着当源图像本身不是 GIF 文件时,kCGImagePropertyGIFImageColorMap
将不起作用。'
@Sebyddd 在我的测试中使用该设置我可以从近 200 帧创建 gif。
我同意@hsafarya。我的测试表明,这个设置对内存占用有很大的影响。我的假设是,在为动画 GIF 构建全局颜色图时,算法会一次将许多(可能是全部)帧加载到内存中,以便为它们创建一个通用(全局)颜色图。关闭此设置后,一次加载的帧似乎不多,因为每个图像都用于构建自己的颜色图。【参考方案2】:
您可以使用 AVFoundation 用您的图像编写视频。我已经上传了一个完整的工作测试项目to this github repository。当您在模拟器中运行测试项目时,它将打印到调试控制台的文件路径。在视频播放器中打开该路径以检查输出。
我将在这个答案中介绍代码的重要部分。
首先创建一个AVAssetWriter
。我会给它AVFileTypeAppleM4V
文件类型,以便视频可以在 ios 设备上播放。
AVAssetWriter *writer = [AVAssetWriter assetWriterWithURL:self.url fileType:AVFileTypeAppleM4V error:&error];
使用视频参数设置输出设置字典:
- (NSDictionary *)videoOutputSettings
return @
AVVideoCodecKey: AVVideoCodecH264,
AVVideoWidthKey: @((size_t)size.width),
AVVideoHeightKey: @((size_t)size.height),
AVVideoCompressionPropertiesKey: @
AVVideoProfileLevelKey: AVVideoProfileLevelH264Baseline31,
AVVideoAverageBitRateKey: @(1200000) ;
您可以调整比特率来控制视频文件的大小。我在这里非常保守地选择了编解码器配置文件 (it supports some pretty old devices)。您可能想选择以后的个人资料。
然后使用媒体类型AVMediaTypeVideo
和您的输出设置创建一个AVAssetWriterInput
。
NSDictionary *outputSettings = [self videoOutputSettings];
AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:outputSettings];
设置像素缓冲区属性字典:
- (NSDictionary *)pixelBufferAttributes
return @
fromCF kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
fromCF kCVPixelBufferCGBitmapContextCompatibilityKey: @YES ;
您不必在此处指定像素缓冲区的尺寸; AVFoundation 将从输入的输出设置中获取它们。我在这里使用的属性(我相信)最适合使用 Core Graphics 进行绘图。
接下来,使用像素缓冲区设置为您的输入创建一个AVAssetWriterInputPixelBufferAdaptor
。
AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor
assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input
sourcePixelBufferAttributes:[self pixelBufferAttributes]];
将输入添加到编写器并告诉编写器开始:
[writer addInput:input];
[writer startWriting];
[writer startSessionAtSourceTime:kCMTimeZero];
接下来我们将告诉输入如何获取视频帧。是的,在我们告诉作者开始写作之后,我们可以这样做:
[input requestMediaDataWhenReadyOnQueue:adaptorQueue usingBlock:^
这个块将完成我们需要用 AVFoundation 做的所有其他事情。输入每次准备好接受更多数据时都会调用它。它可能能够在一次调用中接受多个帧,所以只要它准备好,我们就会循环:
while (input.readyForMoreMediaData && self.frameGenerator.hasNextFrame)
我正在使用self.frameGenerator
来实际绘制框架。稍后我将展示该代码。 frameGenerator
决定视频何时结束(通过从 hasNextFrame
返回 NO)。它还知道每一帧应该何时出现在屏幕上:
CMTime time = self.frameGenerator.nextFramePresentationTime;
要实际绘制帧,我们需要从适配器获取像素缓冲区:
CVPixelBufferRef buffer = 0;
CVPixelBufferPoolRef pool = adaptor.pixelBufferPool;
CVReturn code = CVPixelBufferPoolCreatePixelBuffer(0, pool, &buffer);
if (code != kCVReturnSuccess)
errorBlock([self errorWithFormat:@"could not create pixel buffer; CoreVideo error code %ld", (long)code]);
[input markAsFinished];
[writer cancelWriting];
return;
else
如果我们无法获得像素缓冲区,我们会发出错误信号并中止一切。如果我们确实得到了一个像素缓冲区,我们需要在它周围包裹一个位图上下文并要求frameGenerator
在上下文中绘制下一帧:
CVPixelBufferLockBaseAddress(buffer, 0);
CGColorSpaceRef rgb = CGColorSpaceCreateDeviceRGB();
CGContextRef gc = CGBitmapContextCreate(CVPixelBufferGetBaseAddress(buffer), CVPixelBufferGetWidth(buffer), CVPixelBufferGetHeight(buffer), 8, CVPixelBufferGetBytesPerRow(buffer), rgb, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
[self.frameGenerator drawNextFrameInContext:gc];
CGContextRelease(gc);
CGColorSpaceRelease(rgb);
现在我们可以将缓冲区附加到视频中。适配器会这样做:
[adaptor appendPixelBuffer:buffer withPresentationTime:time];
CVPixelBufferUnlockBaseAddress(buffer, 0);
CVPixelBufferRelease(buffer);
上面的循环通过适配器推送帧,直到输入说它已经足够了,或者直到frameGenerator
说它的帧用完了。如果frameGenerator
有更多帧,我们只需返回,当输入准备好更多帧时,它会再次调用我们:
if (self.frameGenerator.hasNextFrame)
return;
如果frameGenerator
超出帧,我们关闭输入:
[input markAsFinished];
然后我们告诉作者完成。完成后它会调用完成处理程序:
[writer finishWritingWithCompletionHandler:^
if (writer.status == AVAssetWriterStatusFailed)
errorBlock(writer.error);
else
dispatch_async(dispatch_get_main_queue(), doneBlock);
];
];
相比之下,生成帧非常简单。这是生成器采用的协议:
@protocol DqdFrameGenerator <NSObject>
@required
// You should return the same size every time I ask for it.
@property (nonatomic, readonly) CGSize frameSize;
// I'll ask for frames in a loop. On each pass through the loop, I'll start by asking if you have any more frames:
@property (nonatomic, readonly) BOOL hasNextFrame;
// If you say NO, I'll stop asking and end the video.
// If you say YES, I'll ask for the presentation time of the next frame:
@property (nonatomic, readonly) CMTime nextFramePresentationTime;
// Then I'll ask you to draw the next frame into a bitmap graphics context:
- (void)drawNextFrameInContext:(CGContextRef)gc;
// Then I'll go back to the top of the loop.
@end
为了我的测试,我画了一张背景图片,并随着视频的进行慢慢地用纯红色覆盖它。
@implementation TestFrameGenerator
UIImage *baseImage;
CMTime nextTime;
- (instancetype)init
if (self = [super init])
baseImage = [UIImage imageNamed:@"baseImage.jpg"];
_totalFramesCount = 100;
nextTime = CMTimeMake(0, 30);
return self;
- (CGSize)frameSize
return baseImage.size;
- (BOOL)hasNextFrame
return self.framesEmittedCount < self.totalFramesCount;
- (CMTime)nextFramePresentationTime
return nextTime;
Core Graphics 将原点放在位图上下文的左下角,但我使用的是UIImage
,而 UIKit 喜欢将原点放在左上角。
- (void)drawNextFrameInContext:(CGContextRef)gc
CGContextTranslateCTM(gc, 0, baseImage.size.height);
CGContextScaleCTM(gc, 1, -1);
UIGraphicsPushContext(gc);
[baseImage drawAtPoint:CGPointZero];
[[UIColor redColor] setFill];
UIRectFill(CGRectMake(0, 0, baseImage.size.width, baseImage.size.height * self.framesEmittedCount / self.totalFramesCount));
UIGraphicsPopContext();
++_framesEmittedCount;
我调用我的测试程序用来更新进度指示器的回调:
if (self.frameGeneratedCallback != nil)
dispatch_async(dispatch_get_main_queue(), ^
self.frameGeneratedCallback();
);
最后,为了演示可变帧速率,我以每秒 30 帧的速度发出前半帧,每秒以 15 帧发出后半帧:
if (self.framesEmittedCount < self.totalFramesCount / 2)
nextTime.value += 1;
else
nextTime.value += 2;
@end
【讨论】:
这似乎是一个奇怪的问题,但是它可以在 CALayer 动画发生时捕获它吗?这给我带来了另一个问题,如果动画没有完成,我怎样才能更快地记录一些东西,这样结果就好像用户观看了整个动画一样?假设有一个动画需要 5 分钟来填充路径,我绝对不希望录制需要 5 分钟,但我需要在最终视频中保留动画。 从nextFramePresentationTime
返回的次数决定了播放速度。 AVFoundation 将尽可能快地对视频进行编码(这取决于您的 drawNextFrameInContext:
方法的速度和压缩帧的难度)。我的测试程序(在我的 Mac Pro 上)在几分之一秒内对视频进行编码,但视频在 QuickTime Player 中播放了几秒钟。
至于捕捉 CALayer 动画,它并不是真的为此而设计的。可以通过摆弄其CAMediaTiming
属性来逐步完成 CALayer 动画,但我不记得细节,目前无法研究。
“Advanced Animation Tricks” in the Core Animation Programming Guide 的两个小节似乎与单步执行 CALayer 动画有关。
AVFoundation 的本质是否将数据增量写入磁盘?否则我可能会遇到同样的问题。以上是关于使用 CGImageDestinationFinalize 创建大型 GIF - 内存不足的主要内容,如果未能解决你的问题,请参考以下文章
在使用加载数据流步骤的猪中,使用(使用 PigStorage)和不使用它有啥区别?
Qt静态编译时使用OpenSSL有三种方式(不使用,动态使用,静态使用,默认是动态使用)