原文地址
通过图片处理我们可以实现很多很炫的效果,比如,修改图片颜色,混合图片等。
在这两篇教程里,我们首先了解一下基本的图片处理过程。然后做一个简单的app,实现一个幽灵图片的过滤,利用下面四个目前比较流行的方法。
1.修改原生的位图
2.使用Core Graphics库
3.使用Core Image库
4.使用第三方的GPUImage库
在这篇教程中我们把主要把精力放在处理原生位图上面。一旦你理解了基础的处理过程,你将会知道其他库是怎么工作的。在第二篇教程中,你会学习另外三种方法。
这篇教程假设你已经对iOS和Objective-C有了一个基本的了解。
Getting Started
在你编码前,我们需要对图片处理的几个概念有一个大概的理解。所以,坐下来,放松。倾听(吸收)这段短暂而痛苦的关于图片原理的讨论。
先要做的事是见一见你的新朋友,他将会陪伴你渡过这篇教程 ,他就是...Ghosty(幽灵)
现在,别害怕,这不是一个真的幽灵。事实上,它是一张图片。当你把他下载下来会发现,它只是一串0和1的组合。
什么是图片
图片是像素的集合。每个像素是一个单一的特定的颜色。
图片(像素)通常作为一个数组被排列。你能把它想象成二维数组。下面是Ghosty的压缩版
图片中的每一个方块都是像素,而且每个像素仅仅代表一个颜色。当成百上千个图片在一起,它就变成了一个数字的图片。
在内存中字节是如何代表颜色的
字节有许多方法可以代替颜色。在这篇教程中我们用最简单的原理:32-bit RGBA
正如它名字所描述的,32位RGBA存储一个颜色需要32位(bit)或者4字节(bytes),每个字节存储一个通道。这里有四个通道:
- 红的的R
- 绿色的G
- 蓝色的B
- 阿尔法通道A
你可能已经知道了,红绿蓝是数字格式的一组基本颜色。你可以用它们来合成任何颜色。
因为每个通道8bit,你使用32位RGBA中的RGB的不同值创建的不透明颜色的总数是256256256,大约是1700百万的颜色,这是一个多么大的数字啊。
alpha通道和其他的有很大的不同,你可以认为它是描述透明度的,就像UIView中的alpha属性。
你将会深入理解当你学习到下面混合相关的部分时。
已经得出了这个结论,图片是像素的集合,每个像素被编码来展示一种颜色。
你是不是对位图的方向感到迷惑,一个位图就像是一个二维像素的地图。
现在你知道我们使用字节来代表颜色。但是在编码前,还有三个概念需要我们了解。
color space 色彩空间
用RGB的方法代表颜色是颜色空间使用的一个列子。它只是许多方法中的一种。另外一个色彩空间是grayscale。
正如它名字所描述的,所有的图片在grayscale色彩空间中都是黑色和白色。你只需要保存一个值来描述颜色。
一个有渐变色的RGB图对人们来说不是能直接从数字里看出来的。
例如,你认为[0,104,55]是什么颜色,你可能认为是天蓝色,那你就大错特错了。是深绿色
另外两个色彩空间是HSV和YUV。(这两我就不翻译了,一般用不到,有兴趣的同学自己看咯)。
Coordinate Systems 坐标系统
既然图片是2D的像素图片,那么它就有方向。通常它的左上角是原点,沿着Y轴向下的方向。或者左下角是原点,沿着Y轴向上的方向。
这两个都适用,苹果在不同的地方用到了这两种方法。
目前,图片和UIView使用左上角作为原点,Core Image 和Core Graphics使用左下角作为原点。记住这点很重要,你以后可能会遇到类似的bug。
Image Compression 图片压缩
这是在编码前的最后一个概念了。每个像素被分别存储在内存中,如果你计算一个8兆像素的图片,它将会占用8*10^6pixels
*4bytes/pixel = 32兆字节,来存储。这是很大的,所以JPEG、PNG等其他图片在展示的时候都会有一个压缩。
当GPU渲染图片的时候,它们解压图片到它们原始的大小,这是很耗内存的。如果你的app占用了太多的内存。它可能被iOS终止。
像素
现在你对图片的内部原理有了一个基础的了解,你可以开始编码了。今天你要写一个可以自拍的app叫做SpookCam,这个app在你的自拍像上面放了一个Ghosty。
初始项目在这里下载,打开项目运行你将会看到下面的
控制台输出:
当前展示的是一个简易版,把Ghosty转化到像素缓冲区域,并且打印出每个像素明亮的部分。
什么是明亮的部分呢,就是RGB的平均值。看起来很整洁吧。
现在,浏览一下code。你将会注意到 ViewController.m使用
UIImagePickerController
来从相册里面选图片,或者拍照。
选完图片之后,调用
-setupWithImage:
到这里输出了每个像素的明亮值。定位到
logPixelsOfimage;
在
ViewController.m
里面重温第一部分的方法:
//
1.CGImageRef inputCGImage = [image CGImage];
NSUInteger width = CGImageGetWidth(inputCGImage);
NSUInteger height = CGImageGetHeight(inputCGImage);
//
2.NSUInteger bytesPerPixel = 4;
NSUInteger bytesPerRow = bytesPerPixel * width;
NSUInteger bitsPerComponent = 8;
UInt32 * pixels;pixels = (UInt32 *) [calloc](height * width, [sizeof](UInt32));
//
3.CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(pixels, width, height, bitsPerComponent, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
//
4.CGContextDrawImage(context, CGRectMake(0, 0, width, height), inputCGImage);
//
5.Cleanup
CGColorSpaceRelease(colorSpace);
CGContextRelease(context);
现在一步一步来看:
1.把UIImage
转化成CGImage
对象,这需要Core Graphics中的方法来实现。同时得到图片的width和height。
2.因为我们在32bitRGB环境下工作,你硬编码参数bytesPerPixel
和bitsPerComponent
,然后计算图片的 bytesPerRow
。最终你分配你一个像素数组来存储像素数据。
3.创建一个RGBCGColorSpace
和一个CGBitmapContext
,传入一个像素像素指针作为缓存区,来存储像素数据,被content锁持有。你将会对Core Graphics有一个更深入的了解,同过下面的学习。
4.在context上面画一个输出的图片。当context被创建的时候,用你指定的格式来格式化过的图片像素数据,存放在pixels
5.清空colorspace
和context
注意当你展示一个图片时,设备的GPU解码被编码的图片,出现在屏幕上。为了访问本地的数据你需要一个像素的拷贝,就像你上面做的。
pixels
持有图片所有的原始像素。下面的几行迭代打印了明亮值。
//1.
#define Mask8(x) ( (x) & 0xFF )
#define R(x) ( Mask8(x) )
#define G(x) ( Mask8(x >> 8 ) )
#define B(x) ( Mask8(x >> 16) )
NSLog(@"Brightness of image:");
// 2.
UInt32 * currentPixel = pixels;
for (NSUInteger j = 0; j < height; j++) {
for (NSUInteger i = 0; i < width; i++) {
// 3.
UInt32 color = *currentPixel;
printf("%3.0f ", (R(color)+G(color)+B(color))/3.0);
// 4.
currentPixel++;
}
printf("\n");
}
1.定义一个宏,来简化处理像素的任务。为了访问红色通道,你计算前8个位。为了访问其他通道,我们执行一个位移每次位移8位来访问。
2.获得第一个像素的指针。循环访问其他像素。一次访问一个从0-width*height个,这就很简单能解释为什么图片是两维的了。
3.通过重新引用currentPixel
来得到当前像素的颜色。然后打印明亮的像素。
4.currentPixel
加1,来移动到下一个像素。如果你对这个算法感到生疏了,记住一点:因为currentPixel
是一个指向UInt32
的指针,当你的指针加1之后,它就移动了4bytes(32-bits),把你带到下一个像素。
到这里,我们简单的打印出了原始图片的数据,还没有修改任何东西。接下来我们开始改动代码了。
SpookCam --原始位图的修改
实现这个效果有四种方法,但是我们只集中精力在其中一种方发上面,因为它涉及了图片处理的first principles。掌握这个方法将会让你明白其他库的工作原理。
在这个方法里,你将会遍历每个pixel,因为初始的项目已经做了这个。但是是时候为它赋新的值了。
正如你在starter app看到的,ImageProcessor
类已经存在。把它导入ViewController
,用-setupWithImage:
代替ViewController
里下面的的code:
- (void)setupWithImage:(UIImage*)image {
UIImage * fixedImage = [image imageWithFixedOrientation];
self.workingImage = fixedImage;
// Commence with processing!
[ImageProcessor sharedProcessor].delegate = self;
[[ImageProcessor sharedProcessor] processImage:fixedImage];
}
注释掉-viewDidLoad
里下面的code
// [self setupWithImage:[UIImage imageNamed:@"ghost_tiny.png"]];
现在看一下ImageProcessor.m
文件。正如你看到的ImageProcessor
是一个单例对象,invoking了-processUsingPixels
这个方法,一个入参image,通过delegate返回了一个image。
-processUsingPixels
是你之前看到的code的copy,它允许你方法入参图片的像素。注意两个新的宏A(x)
和RGBAMake(r,g,b,a)
。
build,run,从你的相册里面选择一张图片或者拍一张图片。你将会看到那张图片像这样:
那个人看起来很放松,是时候让Ghosty出现了!
在-processUsingPixels
return之前,添加下面的code得到一个CGImageRef
的ghosty。
UIImage * ghostImage = [UIImage imageNamed:@"ghost"];
CGImageRef ghostCGImage = [ghostImage CGImage];
现在计算出你的ghosty在图片上的rect
CGFloat ghostImageAspectRatio = ghostImage.size.width / ghostImage.size.height;
NSInteger targetGhostWidth = inputWidth * 0.25;
CGSize ghostSize = CGSizeMake(targetGhostWidth, targetGhostWidth / ghostImageAspectRatio);
CGPoint ghostOrigin = CGPointMake(inputWidth * 0.5, inputHeight * 0.2);
上面的code重新设置了Ghosty的width只占用了原图片的25%,并把它放在它自己的左上角。
下一步得到ghosty的缓存区
NSUInteger ghostBytesPerRow = bytesPerPixel * ghostSize.width;
UInt32 * ghostPixels = (UInt32 *)calloc(ghostSize.width * ghostSize.height, sizeofUInt32));
CGContextRef ghostContext = CGBitmapContextCreate(ghostPixels, ghostSize.width, ghostSize.height, bitsPerComponent, ghostBytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextDrawImage(ghostContext, CGRectMake(0, 0, ghostSize.width, ghostSize.height),ghostCGImage);
这个如何从入参图片获得pixels是相似的。但是,通过将ghosty画到一个较小的尺寸,让它变得更小了。
现在你已经把ghosty混合到你的图片里了。时候来来复习一下blending了。
Blending像上面所提到的,每个颜色都有阿尔法值来指示了一个透明度。但是当你创建图片的时候,每个像素都已经是一个明确的颜色了。
所以如何把一个像素混合到已经有背景色和半透明颜色的图片上呢?
问题的答案就是alpha blending。这里我们创建了一个float类型的通道在0-1之间。
NewColor = TopColor * TopColor.Alpha + BottomColor * (1 - TopColor.Alpha)
它是一个标准的线性关系。
- 当TopColor.Alpha是1的时候newColor是等于TopColor。
- 当TopColor.alpha是0的时候newColor是等于BottomColor。
- 最后,当TopColor.Alpha在0-1之间的时候,newColor是TopColor和BottomColor的混合值。
好,现在回到Ghosty。
和大多数位图处理的算法一样,你需要遍历所有的像素。但是不同点是,你只需要遍历你需要改变的像素。
添加下面的code到processUsingPixels:
方法下面
NSUInteger offsetPixelCountForInput = ghostOrigin.y * inputWidth + ghostOrigin.x;
for (NSUInteger j = 0; j < ghostSize.height; j++) {
for (NSUInteger i = 0; i < ghostSize.width; i++) {
UInt32 * inputPixel = inputPixels + j * inputWidth + i + offsetPixelCountForInput;
UInt32 inputColor = *inputPixel;
UInt32 * ghostPixel = ghostPixels + j * (int)ghostSize.width + i;
UInt32 ghostColor = *ghostPixel;
// Do some processing here
}
}
注意你只需要遍历在ghosty图片中的像素。通过offsetPixelCountForInput
来偏移入参图片。记住虽然你的图片是二维数组,但是在内存中还是一维数组。
接下来,在注释** Do some processing here**下面添加如下code,来完成真正的混合
// Blend the ghost with 50% alpha
CGFloat ghostAlpha = 0.5f * (A(ghostColor) / 255.0);
UInt32 newR = R(inputColor) * (1 - ghostAlpha) + R(ghostColor) * ghostAlpha;
UInt32 newG = G(inputColor) * (1 - ghostAlpha) + G(ghostColor) * ghostAlpha;
UInt32 newB = B(inputColor) * (1 - ghostAlpha) + B(ghostColor) * ghostAlpha;
// Clamp, not really useful here :p
newR = MAX(0,MIN(255, newR));
newG = MAX(0,MIN(255, newG));
newB = MAX(0,MIN(255, newB));
*inputPixel = RGBAMake(newR, newG, newB, A(inputColor));
在这部分有两点需要注意
1.通过让每个像素的alpha乘以0.5来实现50%的透明度,混合公式就是我们上面所提到的。
2.每个颜色的范围是[0-255],在这里是不需要的,因为这个值从来没有超过边界。但是大多数算法需要防护一下,防止颜色值溢出。
为了测试code,将下面的code添加到processUsingPixels
下面,然后当前的图片。
// Create a new UIImage
CGImageRef newCGImage = CGBitmapContextCreateImage(context);
UIImage * processedImage = [UIImage imageWithCGImage:newCGImage];
return processedImage;
这就从context创建了一个新的图片。你可以先忽略它的内存问题。
build ,run,你可以看到下面的图片:
完美运行了。
原文地址