2

iOS平台图片编解码入门教程(Image/IO篇)

 1 year ago
source link: https://dreampiggy.com/2017/10/30/iOS%E5%B9%B3%E5%8F%B0%E5%9B%BE%E7%89%87%E7%BC%96%E8%A7%A3%E7%A0%81%E5%85%A5%E9%97%A8%E6%95%99%E7%A8%8B%EF%BC%88Image:IO%E7%AF%87%EF%BC%89/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

这篇教程是系列教程的第一篇,主要是面向于没有怎么接触过iOS平台上图像编解码的人的,不会涉及到多媒体处理中的数字信号处理、图像编码的深入知识。这是系列最简单的一篇,之后会有关于第三方编解码,以及vImage的另两篇教程。

Image/IO

Image/IO是Apple提供的一套用于图片编码解码的系统库,对外是一层非常直观易用的C的接口。上层的UIKit,Core Image,还有Core Graphics中的CGImage处理,都是依赖Image/IO库的。因此,掌握Image/IO的基本编解码操作,对一些图像相关的数据处理是非常必要的。这篇教程就主要从简单的用法,说明Image/IO的用法,完整的文档,可以参考Apple Image/IO

解码,指的是讲已经编码过的图像封装格式的数据,转换为可以进行渲染的图像数据。具体来说,iOS平台上就指的是将一个输入的二进制Data,转换为上层UI组件渲染所用的UIImage对象。

Image/IO的解码,支持了常见的图像格式,包括PNG(包括APNG)、JPEG、GIF、BMP、TIFF(具体的,可以通过CGImageSourceCopyTypeIdentifiers来打印出来,不同平台不完全一致)。在iOS 11之后另外支持了HEIC(即使用了HEVC编码的HEIF格式)。

对于解码操作,我们可以分为静态图(比如JPEG,PNG)和动态图(比如GIF,APNG)的两种,分别进行说明一下解码的过程。

静态图的解码,基本可以分为以下步骤:

  1. 创建CGImageSource
  2. 读取图像格式元数据(可选)
  3. 解码得到CGImage
  4. 生成上层的UIImage,清理

1. 创建ImageSource

CGImageSouce,表示的是一个待解码数据的输入。之后的一系列操作(读取元数据,解码)都需要到这个Source,与解码流程一一对应。

CGImageSource可以通过不同的几个接口构造(这里先忽略渐进式解码的接口):

  • CGImageSourceCreateWithData: 从一个内存中的二进制数据(CGData)中创建ImageSource,相对来说最为常用的一个
  • CGImageSourceCreateWithURL: 从一个URL(支持网络图的HTTP URL,或者是文件系统的fileURL)创建ImageSource,
  • CGImageSourceCreateWithDataProvider:从一个DataProvide中创建ImageSource,DataProvider提供了很多种输入,包括内存,文件,网络,流等。很多CG的接口会用到这个来避免多个额外的接口。

示例代码:

CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
if (!source) { // 一般这时候都是输入图像数据的格式不支持
return nil;
}

2. 读取图像格式元数据

创建好CGImageSource之后,我们是可以立即解码。但是很多情况下,我们需要获取一些相关的图像信息,包括图像的格式,图像数量,EXIF元数据等。在真正解码之前,我们可以拿到这些数据,进行一些处理,之后再开始解码过程。

其中,这些信息可以直接在CGImageSource上获取:

  • 图像格式:CGImageSourceGetType
  • 图像数量(动图):CGImageSourceGetCount

其他的,需要通过获取属性列表来查询。对于图像容器的属性(EXIF等),我们需要使用CGImageSourceCopyProperties即可,然后根据不同的Key去获取对应的信息。

其实苹果还有一套CGImageSourceCopyMetadataAtIndex,对应的数据不是字典,而是一个CGImageMetadata,再通过其他方法去取。这套API使用起来也是可以的,读取数据和前者是完全兼容一致的,优点是能够进行自定义扩展(比如说你有非标准的图像信息想自己添加和删除)。一般来说使用前者就足够了。

示例代码:

CGImageSourceRef source;
NSDictionary *properties = (__bridge NSDictionary *)CGImageSourceCopyProperties(source, NULL);
NSUInteger fileSize = [properties[kCGImagePropertyFileSize] unsignedIntegerValue]; // 没什么用的文件大小

NSDictionary *exifProperties = properties[(__bridge NSString *)kCGImagePropertyExifDictionary]; // EXIF信息
NSString *exifCreateTime = exirProperties[(__bridge NSString *)kCGImagePropertyExifDateTimeOriginal]; // EXIF拍摄时间

当然,前面这个指的是图像容器的属性,而真正的获取图像的元信息,需要使用CGImageSourceCopyPropertiesAtIndex,对于静态图来说,index始终传0即可。

示例代码:

NSDictionary *imageProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, 0, NULL);
NSUInteger width = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelWidth] unsignedIntegerValue]; //宽度,像素值
NSUInteger height = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelHeight] unsignedIntegerValue]; //高度,像素值
BOOL hasAlpha = [imageProperties[(__bridge NSString *)kCGImagePropertyHasAlpha] boolValue]; //是否含有Alpha通道
CGImagePropertyOrientation exifOrientation = [imageProperties[(__bridge NSString *)kCGImagePropertyOrientation] integerValue]; // 这里也能直接拿到EXIF方向信息,和前面的一样。如果是iOS 7,就用NSInteger取吧 :)

3. 解码得到CGImage

通过Image/IO解码到CGImage确实非常简单,整个解码只需要一个方法CGImageSourceCreateImageAtIndex。对于静态图来说,index始终是0,调用之后会立即开始解码,直到解码完成。

值得注意的是,Image/IO所有的方法都是线程安全的,而且基本上也都是同步的,因此确保大图像文件的解码最好不要放到主线程。

示例代码:

CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL);

4. 生成上层的UIImage,清理

解码得到CGImage后,就基本完成了,我们可以直接构造对应的UIImage用于UI组件渲染。其中UIImage的orientation,可以通过之前的EXIF元信息获得(注意,需要转换EXIF的方向,到UIImageOrientation的方向)。然后就完成了,比较简单。

示例代码:

// UIImageOrientation和CGImagePropertyOrientation枚举定义顺序不同,封装一个方法搞一个switch case就行
UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];

// 清理,都是C指针,避免内存泄漏
CGImageRelease(imageRef);
CFRelease(source)

前面的情况,主要介绍了是静态图(也就是说,取的index都是0的情况 )。对于动态图来说,我们可以通过CGImageSourceGetCount来获取动图的帧数,之后就比较简单了,通过循环遍历每一帧,重复2-4步骤生成对应的UIImage,最后通过UIImage自带的animatedImageWithImages:duration:来生成一张动图即可。但是关于这里有坑,在下面说明。

  1. 静态图的步骤1
  2. 遍历所有图像帧,重复静态图的步骤2-4
  3. 生成动图UIImage

1. 生成动图UIImage

由于遍历很简单,就不重复了,这里我们以一个GIF为例,简单说明一下解码过程,直观易懂。

示例代码:

NSUInteger frameCount = CGImageSourceGetCount(source); //帧数
NSMutableArray <UIImage *> *images = [NSMutableArray array];
double totalDuration = 0;
for (size_t i = 0; i < frameCount; i++) {
NSDictionary *frameProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, i, NULL);
NSDictionary *gifProperties = frameProperties[(NSString *)kCGImagePropertyGIFDictionary]; // GIF属性字典
double duration = [gifProperties[(NSString *)kCGImagePropertyGIFUnclampedDelayTime] doubleValue]; // GIF原始的帧持续时长,秒数
CGImagePropertyOrientation exifOrientation = [frameProperties[(__bridge NSString *)kCGImagePropertyOrientation] integerValue]; // 方向
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, i, NULL); // CGImage
UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];
totalDuration += duration;
[images addObject:image];
}

// 最后生成动图
UIImage *animatedImage = [UIImage animatedImageWithImages:images duration:totalDuration];

这样处理的话,大部分情况下基本是可以接受的。但是这里有一个坑:UIImage这个animatedImages的接口,只会根据你传入的images的数量,平均分配传入的totalDuration的展示时长。但是大部分动图格式(GIF,APNG,WebP等等),都是不同帧不同时长的,这就会导致最后看到的动图每帧时长乱掉。

对于这个的解决方式也有。简单来说,就是通过对特定图像帧重复特定次数,以填充满整个应该播放的时长。其实实现也比较简单,我们可以对所有帧的时长,求一个最大公约数gcd,这样的话,只需要每帧重复播放duration / gcd次数,最终的总时长各帧repeat * duraion的和,就可以实现这个了,有兴趣可以看看我参与维护的SDWebImage的代码

示例代码:

NSUInteger durations[frameCount];
NSUInteger const gcd = gcdArray(frameCount, durations);
for (size_t i = 0; i < frameCount; i++) {
NSUInteger duration = durations[i];
NSUInteger repeatCount = duration / gcd;
for (size_t j = 0; j < repeatCount; j++) {
[animatedImages addObject:image];
}
}

渐进式解码

渐进式解码(Progressive Decoding),即不需要完整的图像流数据,允许解码部分帧(大部分情况下,会是图像的部分区域),对部分使用了渐进式编码的格式(参考:渐进式编码),则更可以解码出相对模糊但完整的图像。

比如说,JPEG支持三种方式的渐进式编码,包括Baseline,interlaced,以及progressive(参考:iOS 处理图片的一些小 Tip)

Baseline Interlaced Progressive
image_baseline.gif image_interlaced.gif image_progressive.gif

对于Image/IO的渐进式解码,其实和静态图解码的过程类似。但是第一步创建CGImageSource时,需要使用专门的CGImageSourceCreateIncremental方法,之后每次有新的数据(下载或者其他流输入)输入后,需要使用CGImageSourceUpdateData(或者CGImageSourceUpdateDataProvider)来更新数据。注意这个方法需要每次传入所有至今为止解码的数据,不仅仅是当前更新的数据。

之后的过程,就和普通的解码一致,就不再说明了。

示例代码:

NSData *data;
bool finished = data.length == totalLength;
CGImageSourceRef source;
// 更新数据
CGImageSourceUpdateData(source, (__bridge CFDataRef)data, finished);

// 和普通解码过程一样
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL);

编码过程,这里指的就是将一个UIImage表示的图像,编码为对应图像格式的数据,输出一个NSData的过程。Image/IO提供的对应概念,叫做CGImageDestination,表示一个输出。之后的编码相关的操作,和这个Destination一一对应。

静态图的编码,基本可以分为以下步骤:

  1. 创建CGImageDestination
  2. 添加图像格式元数据(可选)和CGImage
  3. 编码得到NSData,清理

1. 创建CGImageDestination

CGImageDestination的创建也有三个接口,你需要提供一个输出的目标来输出解码后的数据。同时,由于编码需要提供文件格式,你需要指明对应编码的文件格式,用的是UTI Type。对于静态图来说,第三个参数的数量都写1即可。

  • CGImageDestinationCreateWithData:指定一个可变二进制数据作为输出
  • CGImageDestinationCreateWithURL:指定一个文件路径作为输出
  • CGImageDestinationCreateWithDataConsumer:指定一个DataConsumer作为输出

示例代码:

CFStringRef imageUTType; //目标格式,比如kUTTypeJPEG
// 创建一个CGImageDestination
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, imageUTType, 1, NULL);
if (! destination) {
// 无法编码,基本上是因为目标格式不支持
return nil;
}

2. 添加图像格式元数据(可选)和CGImage

接下来就是添加图像了,由于CGImage只是包含基本的图像信息,很多额外信息比如说EXIF都已经丢失了,如果我们需要,可以添加对应的元信息。不像解码那样提供了两个API分别获取元信息和图像。使用的接口是CGImageDestinationAddImage

当然,如果有自定义的元信息,可以通过另外的CGImageDestinationAddImageAndMetadata来添加CGImageMetadata,这个上面解码也说到过,这里就不解释了。

此外,还有一个ImageIO最强大的功能,叫做CGImageDestinationAddImageFromSource (这个东西可以媲美vImageConvert_AnyToAny,后续教程会谈到),这个能够从一个任意的CGImageSource,添加一个图像帧到任意一个CGImageDestination。这个一般的用途,就是专门给图像转换器用的,比如说从图像格式A,转换到图像格式B。我们不需要先解码到A的UIImage,再通过编码到B的NSData,直接在中间就进行了转换。能够极大地提升转换效率(Image/IO底层就是通过vImage,传的是Bitmap的引用,没有额外的消耗)。不过这篇教程侧重于Image/IO的编码和解码,转换可以自行参考处理,不再详细说明了。

示例代码:

CGImageRef imageRef = image.CGImage; // 待编码的CGImage

// 可选元信息,比如EXIF方向
CGImagePropertyOrientation exifOrientation = kCGImagePropertyOrientationDown;
NSMutableDictionary *frameProperties = [NSMutableDictionary dictionary];
imageProperties[(__bridge_transfer NSString *) kCGImagePropertyExifDictionary] = @(exifOrientation);
// 添加图像和元信息
CGImageDestinationAddImage(destination, imageRef, (__bridge CFDictionaryRef)frameProperties);

3. 编码得到NSData,清理

当添加完成所有需要编码的CGImage之后,最后一步,就是进行编码,得到图像格式的数据。这里直接用一个方法CGImageDestinationFinalize即可,编码得到的数据,会写入最早初始化时提供的Data或者DataConsumer。

示例代码:

if (CGImageDestinationFinalize(destination) == NO) {
// 编码失败
imageData = nil;
}
// 编码成功,清理……
CFRelease(destination);

动态图的编码,其实不像解码那样困难。只需要准备好所有的动态图的帧,按照帧的顺序进行一一添加即可。基本步骤可以概括为:

  1. 静态图的步骤1,提供帧数
  2. 遍历所有图像帧,重复静态图的步骤2
  3. 静态图的步骤3

1. 提供帧数,遍历图像帧

在进行动态图编码时,创建CGImageDestination的时候需要提供动态图的张数。即在CGImageDestinationCreateWithData的参数中,将count设置为需要编码的总张数。

另外,在遍历图像帧的过程,其实只需要不断地按顺序添加就行了,如果需要设置额外元信息,也需要按顺序设置到当前帧上。相对于解码来说简单多了。其他的没有什么大的区别。我们这里还是以GIF为例,简单说明一下。

示例代码:

NSArray<UIImage *> *images;
float durations[frameCount];
for (size_t i = 0; i < frameCount; i++) {
float frameDuration = durations[i];
CGImageRef frameImageRef = images[i].CGImage;
NSDictionary *frameProperties = @{(__bridge_transfer NSString *)kCGImagePropertyGIFDictionary : @{(__bridge_transfer NSString *)kCGImagePropertyGIFUnclampedDelayTime : @(frameDuration)}};
CGImageDestinationAddImage(imageDestination, frameImageRef, (__bridge CFDictionaryRef)frameProperties);
}

Image/IO封装了非常简单直观的接口来处理图像编解码,对于任何开发者来说都能轻易上手。而且性能方面很多格式都有Apple自己的硬件解码器来做保证。另外,对于图像转换,Image/IO所提供的这种Source-Destination的操作能够非常方便地在不同格式之间转换,有兴趣的人务必可以试试。

不过遗憾的是,Image/IO的接口设计并没有提供可以扩展或者插件化的地方,不支持的图像格式就比较无能为力了。关于这个问题,请期待系列教程第二篇——第三方编解码教程。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK