假设这样一个场景:一张图片中有一朵白花,我们想要把它变成红花;或者一张图片中有一段黑色的文字,我们想要把它变成红色,应该怎么做?
想要实现这个需求,就需要从像素尺度上对图片进行修改,将指定区域内的像素的色值改为我们需要的颜色。但是,如何从这张图上找到那段文字或者那朵花,并不在本文的讨论范围内,那是OCR和机器学期的事ㄟ( ▔, ▔ )ㄏ。
进入正题
假设我们要把一张有一段黑色文字的图片中的文字修改为红色:
示例图片 修改后
要实现这个需求,我们应该怎么做?
- 创建一个画布,并将原始图片平铺在画布上
- 遍历图片上的像素,找到目标区域内的黑色文字的像素,将它改为红色
- 输出修改后的图片,并清理内存
我们需要哪些信息才足够实现这个功能?
- 一个Rect:需要修改这张图片上哪个区域的像素
- 需要被修改的色值区域:需要把哪个色值范围内的像素修改为目标颜色
- 目标颜色:需要将符合上述两点的像素修改为什么颜色
具体实现
在贴代码之前,先讲一些废话:
- 图片的分辨率代表着它的像素个数,比如上图的分辨率为1054 * 316,那么它的像素个数就是 1054 * 316 = 333064;
- 图片的宽度代表这张图一共有多少列像素,高度代表一共有多少行像素;即宽度代表列数,高度代表行数
- 在像素尺度上,图片中元素边缘的颜色并不如我们肉眼看到的那样。比如上图中的文字是纯黑色的,但是如果你放大放大再放大,会发现文字边缘的颜色其实是灰色的(这也是上面为什么说需要一个色值区域的原因);
- 图片转为2进制的数据时,每个像素为最小单元,从左上角开始,到右下角结束,从左到右从上到下排列像素,但它并不是二维,而是一维的。
- alpha通道:一个像素的色值是由RGBA四个值确定的。如果不含alpha通道的话,则是由RGB三个值确定,而A则一直是0xFF,即RGBX(X代表不含alpha通道,X一直为0xFF)
下面就是实现这个功能的核心代码了,这里是作为UIImage的一个category方法实现的:
/**
解释一下前两个参数的含义:
想象一个数轴,最左边是黑色(RGBX:0x000000FF),最右边是白色(0xFFFFFFFF),
nearBlackColor是靠近左边边界的色值,nearWhiteColor是靠近右边边界的色值,
它们中间则是需要被修改的色值范围
*/
- (UIImage *)translatePixelColorByTargetNearBlackColorRGBA:(UInt32)nearBlackRGBA
nearWhiteColorRGBA:(UInt32)nearWhiteRGBA
transColorRGBA:(UInt32)transRGBA
inRect:(CGRect)rect {
// 第一步:判断传入的rect是否在图片的bounds内
CGRect canvas = CGRectMake(0, 0, self.size.width, self.size.height);
if (!CGRectContainsRect(canvas, rect)) {
if (CGRectIntersectsRect(canvas, rect)) {
rect = CGRectIntersection(canvas, rect); // 取交集
} else {
return self;
}
}
UIImage *transImage = nil;
int imageWidth = self.size.width;
int imageHeight = self.size.height;
// 第二步:创建色彩空间、画布上下文,并将图片以bitmap(不含alpha通道)的方式画在画布上。
size_t bytesPerRow = imageWidth * 4;
uint32_t *rgbImageBuf = (uint32_t *)malloc(bytesPerRow * imageHeight);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(rgbImageBuf, imageWidth, imageHeight, 8, bytesPerRow, colorSpace,
kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipLast);
CGContextDrawImage(context, CGRectMake(0, 0, imageWidth, imageHeight), self.CGImage);
// 第三步:遍历并修改像素
uint32_t *pCurPtr = rgbImageBuf;
pCurPtr += (long)(rect.origin.y*imageWidth); // 将指针移动到初始行的起始位置
// 空间复杂度:O(rect.size.width * rect.size.height)
for (int i = rect.origin.y; i < CGRectGetMaxY(rect); i++) { // row
pCurPtr += (long)rect.origin.x; // 将指针移动到当前行的起始列
for (int j = rect.origin.x; j < CGRectGetMaxX(rect); j++, pCurPtr++) { // column
if (*pCurPtr < nearBlackRGBA || *pCurPtr > nearWhiteRGBA) { continue; }
// 将图片转成想要的颜色
uint8_t *ptr = (uint8_t *)pCurPtr;
ptr[3] = (transRGBA >> 24) & 0xFF; // R
ptr[2] = (transRGBA >> 16) & 0xFF; // G
ptr[1] = (transRGBA >> 8) & 0xFF; // B
}
pCurPtr += (long)(imageWidth - CGRectGetMaxX(rect)); // 将指针移动到下一行的起始列
}
// 第四步:输出图片
CGDataProviderRef dataProvider = CGDataProviderCreateWithData(NULL, rgbImageBuf, bytesPerRow * imageHeight, providerReleaseDataCallback);
CGImageRef imageRef = CGImageCreate(imageWidth, imageHeight, 8, 32, bytesPerRow, colorSpace,
kCGImageAlphaLast | kCGBitmapByteOrder32Little, dataProvider,
NULL, true, kCGRenderingIntentDefault);
CGDataProviderRelease(dataProvider);
transImage = [UIImage imageWithCGImage:imageRef];
// end:清理空间
CGImageRelease(imageRef);
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
return transImage ? : self;
}
void providerReleaseDataCallback (void *info, const void *data, size_t size) {
free((void*)data);
}
怎么调用呢?
[image translatePixelColorByTargetNearBlackColorRGBA:0x000000FF nearWhiteColorRGBA:0x323232FF transColorRGBA:0xFF0000FF inRect:rect];
看起来有些麻烦是吗?色值要写那么长,而且既然是以不含alpha通道的方式实现的,那么alpha值便没有意义,所以我们还可以再封装几个方法以便使用起来更方便:
- (UIImage *)translatePixelColorByTargetNearBlackColor:(UIColor *)nearBlackColor
nearWhiteColor:(UIColor *)nearWhiteColor
transColor:(UIColor *)transColor {
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
return [self translatePixelColorByTargetNearBlackColor:nearBlackColor nearWhiteColor:nearWhiteColor transColor:transColor inRect:rect];
}
- (UIImage *)translatePixelColorByTargetNearBlackColor:(UIColor *)nearBlackColor
nearWhiteColor:(UIColor *)nearWhiteColor
transColor:(UIColor *)transColor
inRect:(CGRect)rect {
// UIColor 转 RGBA
UInt32 nearBlackRGBA = nearBlackColor.RGBA;
UInt32 nearWhiteRGBA = nearWhiteColor.RGBA;
UInt32 transRGBA = transColor.RGBA;
return [self translatePixelColorByTargetNearBlackColorRGBA:nearBlackRGBA nearWhiteColorRGBA:nearWhiteRGBA transColorRGBA:transRGBA inRect:rect];
}
- (UIImage *)translatePixelColorByTargetNearBlackColorHex:(UInt32)nearBlackRGB
nearWhiteColorHex:(UInt32)nearWhiteRGB
transColorHex:(UInt32)transRGB {
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
return [self translatePixelColorByTargetNearBlackColorHex:nearBlackRGB nearWhiteColorHex:nearWhiteRGB transColorHex:transRGB inRect:rect];
}
- (UIImage *)translatePixelColorByTargetNearBlackColorHex:(UInt32)nearBlackRGB
nearWhiteColorHex:(UInt32)nearWhiteRGB
transColorHex:(UInt32)transRGB
inRect:(CGRect)rect {
// RGB 转 RGBA
UInt32 nearBlackRGBA = (nearBlackRGB << 8) + 0xFF;
UInt32 nearWhiteRGBA = (nearWhiteRGB << 8) + 0xFF;
UInt32 transRGBA = (transRGB << 8) + 0xFF;
return [self translatePixelColorByTargetNearBlackColorRGBA:nearBlackRGBA nearWhiteColorRGBA:nearWhiteRGBA transColorRGBA:transRGBA inRect:rect];
}
另外,这是上面使用到的UIColor转RGBA的方法,它是作为UIColor的category方法实现的:
- (UInt32)RGBA {
CGFloat red = 0;
CGFloat green = 0;
CGFloat blue = 0;
CGFloat alpha = 0;
BOOL succ = [self getRed:&red green:&green blue:&blue alpha:&alpha];
UInt32 r = round(red*255);
UInt32 g = round(green*255);
UInt32 b = round(blue*255);
UInt32 a = round(alpha*255);
r = (r << 24);
g = (g << 16);
b = (b << 8);
UInt32 rgba = r + g + b + a;
return succ ? rgba : 0x00000000;
}
如果上述正好能符合你目前遇到的问题,而你又急于验证能否解决问题的话,把上面的代码copy一下就可以了。如果你既想知其然,又想知其所以然,那么我们继续。
上述核心代码中分四步实现了修改图片像素色值,其中第一、二、四没有什么可说的,都是固定代码。
但第三步的算法我认为有必要解释一下,所以有了下面这些内容。
当然,如果你已经从代码中看明白了,那么我可以负责任的告诉你,本文已经结束啦~!
如果你觉得有些懵哔,那太好了!我又可以继续讲(zhuang)解(bi)了!那么,来嘛客官,咱们继续~
首先先来看下面一张图:
像素矩阵示例
前面已经说过了,我们采用不含alpha通道的方式实现。那么一个像素就是由RGBX四个值确定,其中X是无效的。这是上图中“Pixel”想要表示的含义。
“Image Raw Data”想要表示的是,图片在转为2进制后,像素在其中是怎样排列的。其中的数字表示的是像素在整张图片中的索引。前面也说过,是由一个二维的图片像素矩阵(就是上图最后那个4*4的“Image Pixel Matrix”)从左到右从上到下转换成的一维队列。
可以看出,在二维的图片上,我们需要修改的区域是连续的一块,但是在转化为二进制的数据中,它们则是断续的。
我把上面的那段代码再贴一下,以便对照解释:
// 第三步:遍历并修改像素
uint32_t *pCurPtr = rgbImageBuf;
pCurPtr += (long)(rect.origin.y*imageWidth); // 将指针移动到初始行的起始位置
// 空间复杂度:O(rect.size.width * rect.size.height)
for (int i = rect.origin.y; i < CGRectGetMaxY(rect); i++) { // row
pCurPtr += (long)rect.origin.x; // 将指针移动到当前行的起始列
for (int j = rect.origin.x; j < CGRectGetMaxX(rect); j++, pCurPtr++) { // column
if (*pCurPtr < nearBlackRGBA || *pCurPtr > nearWhiteRGBA) { continue; }
// 将图片转成想要的颜色
uint8_t *ptr = (uint8_t *)pCurPtr;
ptr[3] = (transRGBA >> 24) & 0xFF; // R
ptr[2] = (transRGBA >> 16) & 0xFF; // G
ptr[1] = (transRGBA >> 8) & 0xFF; // B
}
pCurPtr += (long)(imageWidth - CGRectGetMaxX(rect)); // 将指针移动到下一行的起始列
}
所以按上图所示,整张图片的bounds为(0, 0, 4, 4),我们需要修改rect(1, 1, 2, 2)内的像素色值。下面所讲要学会自动脑补二维图片转换一维二进制数据,凡是指出坐标的都是二维图片,而说指针的都是在说一维的二进制数据中某个像素的指针。
- 我们的空间复杂度为O(rect.size.width * rect.size.height),所以遍历时第一层的for循环遍历次数为rect.size.width(即2),而i是从rect.origin.y(即1)开始的;第二层for循环的遍历次数为rect.size.height(也是2),而j是从rect.origin.x(即1)开始的。总之,我们是从point(1, 1)位置开始遍历的。
- 首先需要将指针移动到初始行的起始列:
pCurPtr += (long)(rect.origin.y*imageWidth);
,即像素4的所在的位置。目的是为了跳过目标区域上方的无关行。 - 只跳过了上面的无关行还不够,我们还需要跳过左边的无关列,即
pCurPtr += (long)rect.origin.x;
,这时候指针指到了像素5的位置(就是步骤1中所说point(1, 1)的位置),然后我们就可以开始真正的遍历了。 - 在遍历完这一行的目标区域后,指针指到了像素7的位置;然后还需要跳过右边的无关列:
pCurPtr += (long)(imageWidth - CGRectGetMaxX(rect));
,这时候指针指到了像素8的位置。此时这一行已经完全遍历结束,跳到了下一行的起始位置,又回到了步骤3的状态(只是row+1了) - 然后重复执行3、4步骤,直到
i >= CGRectGetMaxY(rect)
结束
至此,这个算法解释完毕~
唉~,这一块我也是想破头该怎么描述,可是写出来发现还是不太理想。。。
我只能祈祷我太低估读者的水平,其实大家都是能直接看懂代码的,根本不需要我解释ㄟ( ▔, ▔ )ㄏ。
如果大家看完之后还是有不理解的地方;还有一些我没详细解释的地方,如果有不理解的,都欢迎在留言区讨论。
本人作为写文章的新手,如果有错误的地方,也欢迎大家在留言区指正!
最后,这里是Demo地址
网友评论