Demo
前言
起初是看到项目中多张PNG图片组成的Loading动画(是给到MJRefresh
用的)觉得挺可爱的,于是用代码弄成一个GIF,打算用来当做微信的表情包:
可是看着好像缺了点东西... 哦,影子!原图是带影子的:
为什么会没了影子呢?经调研发现,原来这部分影子是带有透明度的,而制作的GIF是不能带有半透明的部分(至少通过代码生成的GIF是这样,如果能做到求告知😔)。
没有影子其实也啥所谓,但我这个人有点强迫症,非得要把影子加回去,于是才有下面做法:
PS:以下都是通过CGContext
的方式进行绘制的,这部分绘制的代码比较多并且都很常见,所以就不放这里了,具体可看Demo。
实现方案
方案一:先填充背景色,再绘制图像
直接给出结论,是可以的:
但这并不是我想要的效果,我是希望能像微信的表情包那样只有图像内容,不想要这一大块背景,应该像这样:
方案二:先绘制具有图像轮廓的颜色块,再绘制图像
同样直接给出结论,也是可以的,而且确实不会有一大块背景了:
我这里的做法是先绘制白色的图像轮廓图片,再绘制原图像盖上去。
绘制白色的图像轮廓图片的具体做法:
objc
#pragma mark 转换成白色轮廓的图片(镂空图片区域:透明->白色+不透明->透明)
+ (UIImage *)convertWhiteImage:(UIImage *)image {
if (!image) return nil;
CGRect rect = (CGRect){CGPointZero, image.size};
UIGraphicsBeginImageContextWithOptions(image.size, NO, 0);
[UIColor.whiteColor setFill];
UIRectFill(rect);
// 设置混色模式
[image drawInRect:rect blendMode:kCGBlendModeDestinationOut alpha:1.0];
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}
但这还不完全是我想要的效果,仔细跟微信的表情包对比,还是缺了一样东西,那就是白色描边:
这些白色描边有什么用?我猜是为了能完整展示图像,防止原来的描边色跟背景色重叠,相当于加强了原图的抗锯齿效果吧。
怎么添加描边呢?放大白色的图像轮廓图片?如果图像内容只有一个那倒可行,但如果是多个不相连的内容呢?例如:
中点放大的话,其他字母的描边就对不准了,这种情况根本就没有准确的锚点。
经过一系列的调研,OpenCV
可以做到,但很可惜,本人水平实属低下,不会用... 去学吧,这可是很漫长的过程,难道CGContext
就没有简单粗暴的方式吗?
又经过一系列的调研发现,应该说以我的水平,发现确实办不到。
但是!可以以另一种方式实现:找到图像中的每一个非透明像素点的坐标点,然后对每一个坐标点都"放大",再填充(白色)。
举个例子说明:🟩为目标像素点,🟦为扩大的填充点,假设以像素点为中点向外扩大2点,那么该渲染范围则是这样:
有了思路就立即开干... 跳过过程,直接看结果:
得出结论:还行!
PS:由于是白色的描边,而网站背景色也是白色的,为了更好地展示效果,特意加了黑色背景。
再对Supreme
试一下看看效果:
还行还行。
如何实现
总结起来就一句:首先是遍历图片每个像素的颜色值,也就是RGBA,然后判断其中的Alpha值,只要非0,就扩大该像素点进行颜色填充,遍历填充完,再把图片绘制盖上去即可。
核心方法是获取每个像素的颜色值,自己查阅资料尝试后,为了能应付不同类型的图片也能添加描边的处理,最终参考了SDWebImage
的做法:
SDWebImage
有个SDGetColorFromRGBA
的函数,可以通过坐标来获取目标像素的颜色值。值得注意的是,颜色通道的排列方式会有不同顺序,例如RGBA和ARBG,这些都会根据当前是大端还是小端从而有不同的排布顺序,不仅如此,颜色通道的数量也各不相同,例如灰白图片的颜色通道就两个。总而言之,该函数很好的进行了各种判定,最终能获取正确的颜色值(PS:这里真的踩了好多坑,要不是发现了这个方法,我估计会气晕,不过因此了解了这些颜色通道的排布方式也是挺乐的)。
我是几乎照搬该函数,并且做了一些定制化的修改,例如我改成了只获取alpha值,代码如下:
objc
/// 对图片进行描边绘制
/// - outlineStrokeWidth: 描边大小
/// - outlineStrokeColor: 描边颜色
static void JPDrawOutlineStroke(CGImageRef imageRef, CGContextRef context, size_t outlineStrokeWidth, CGColorRef outlineStrokeColor, CGFloat diffX, CGFloat diffY) {
if (!imageRef || !context) {
return;
}
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
if (width == 0 || height == 0) return;
// 每一行的总字节数
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
if (bytesPerRow == 0) return;
// 每个像素包含的颜色通道数 = 一个像素的总位数 / 每个颜色通道使用的位数
// 在32位像素格式下,每个颜色通道固定占8位,所以算出的「每个像素包含的颜色通道数」相当于「每个像素占用的字节数」
size_t components = CGImageGetBitsPerPixel(imageRef) / CGImageGetBitsPerComponent(imageRef);
// greyscale有2个,RGB有3个,RGBA有4个,其他则是无法识别的颜色空间了
if (components != 2 && components != 3 && components != 4) return;
// 获取指向图像对象的字节数据的指针
CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
if (!dataProvider) return;
CFDataRef data = CGDataProviderCopyData(dataProvider);
if (!data) return;
const UInt8 *bytePtr = CFDataGetBytePtr(data);
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
// 获取透明信息
CGImageAlphaInfo alphaInfo = bitmapInfo & kCGBitmapAlphaInfoMask;
// 获取字节排序(大端or小端)
CGBitmapInfo byteOrderInfo = bitmapInfo & kCGBitmapByteOrderMask;
BOOL byteOrderNormal = NO;
switch (byteOrderInfo) {
case kCGBitmapByteOrderDefault:
case kCGBitmapByteOrder32Big:
byteOrderNormal = YES;
break;
default:
break;
}
// 渲染一个像素的大小:以像素点为中点,线宽为外边距,向外扩展
CGFloat fillWH = (CGFloat)outlineStrokeWidth + 1 + (CGFloat)outlineStrokeWidth;
/**
* 🌰🌰🌰 其中🟩为像素点,🟦为`outlineStrokeWidth`,
* 假设`outlineStrokeWidth = 2`,那么渲染的大小为:
🟦 🟦 🟦 🟦 🟦
🟦 🟦 🟦 🟦 🟦
🟦 🟦 🟩 🟦 🟦
🟦 🟦 🟦 🟦 🟦
🟦 🟦 🟦 🟦 🟦
*/
// 渲染所有非透明的像素点
CGContextSetFillColorWithColor(context, outlineStrokeColor);
for (size_t x = 0; x < width; x++) {
for (size_t y = 0; y < height; y++) {
// 像素下标 = 第几行 * 每一行的总字节数 + 第几列 * 每个像素占用的字节数
size_t byteIndex = y * bytesPerRow + x * components;
// 获取透明度
CGFloat alpha = 255.0;
if (components == 2) { // greyscale
alpha = JPGetAlphaFromGrayscaleAtPixel(bytePtr, byteIndex, byteOrderNormal, alphaInfo);
} else if (components == 3 || components == 4) { // RGB || RGBA
alpha = JPGetAlphaFromRGBAAtPixel(bytePtr, byteIndex, byteOrderNormal, alphaInfo);
}
alpha /= 255.0;
// 非透明的地方就涂色(注意:这里的xy是基于图像的坐标,需要适配成context的坐标进行填充)
if (alpha > 0.1) { // 透明度0.1以下人眼【几乎】看不见,直接忽略吧
CGFloat fillX = (CGFloat)x - (CGFloat)outlineStrokeWidth;
CGFloat fillY = (CGFloat)y - (CGFloat)outlineStrokeWidth;
// 此处的y轴跟UIKit的上下颠倒,y = h - maxY
CGFloat fillMaxY = fillY + fillWH;
fillY = (CGFloat)height - fillMaxY;
fillX += diffX;
fillY += diffY;
// 填充颜色
CGContextFillRect(context, CGRectMake(fillX, fillY, fillWH, fillWH));
}
}
}
// 释放内存
CFRelease(data);
}
/// 获取目标像素的Alpha值(参考SDWebImage)
static CGFloat JPGetAlphaFromRGBAAtPixel(const UInt8 *bytePtr, size_t byteIndex, BOOL byteOrderNormal, CGImageAlphaInfo alphaInfo) {
CGFloat a = 255.0;
switch (alphaInfo) {
case kCGImageAlphaPremultipliedFirst:
case kCGImageAlphaFirst:
{
if (byteOrderNormal) {
// ARGB
a = (CGFloat)bytePtr[byteIndex];
} else {
// BGRA
a = (CGFloat)bytePtr[byteIndex + 3];
}
break;
}
case kCGImageAlphaPremultipliedLast:
case kCGImageAlphaLast:
{
if (byteOrderNormal) {
// RGBA
a = (CGFloat)bytePtr[byteIndex + 3];
} else {
// ABGR
a = (CGFloat)bytePtr[byteIndex];
}
break;
}
case kCGImageAlphaNone:
case kCGImageAlphaNoneSkipLast:
case kCGImageAlphaNoneSkipFirst:
break;
case kCGImageAlphaOnly:
{
// A
a = (CGFloat)bytePtr[byteIndex];
break;
}
default:
break;
}
return a;
}
/// 这部分代码跟`JPGetAlphaFromRGBAAtPixel`大同小异,只是换成了双通道方式获取,这里就不具体展示了,想看可以参考Demo
static CGFloat JPGetAlphaFromGrayscaleAtPixel(const UInt8 *bytePtr, size_t byteIndex, BOOL byteOrderNormal, CGImageAlphaInfo alphaInfo) {
......
}
使用(单张图片的处理):
objc
UIImage *image = XXX;
CGFloat outlineStrokeWidth = 3;
UIColor *outlineStrokeColor = UIColor.whiteColor;
CGImageRef imageRef = image.CGImage;
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
if (width == 0 || height == 0) return;
// 内容可能有贴边的情况,为了让边缘的描边能完整展示,额外添加描边大小的内边距
UIEdgeInsets padding = UIEdgeInsetsMake(outlineStrokeWidth, outlineStrokeWidth, outlineStrokeWidth, outlineStrokeWidth);
size_t renderWidth = padding.left + width + padding.right;
size_t renderHeight = padding.top + height + padding.bottom;
CGContextRef context = CGBitmapContextCreate(NULL,
renderWidth,
renderHeight,
CGImageGetBitsPerComponent(imageRef),
0, // 这里不能用CGImageGetBytesPerRow(imageRef),
// →→ 因为`renderWidth`跟`width`可能不一样,
// →→ 要么重新计算`(renderWidth * 4)`、要么传0交给系统自动计算。
CGImageGetColorSpace(imageRef),
CGImageGetBitmapInfo(imageRef));
if (!context) return;
CGFloat diffX = padding.left;
CGFloat diffY = padding.bottom; // 此处的y轴跟UIKit的上下颠倒,所以是bottom
// 1.给图像内容添加轮廓描边(填充非透明部分)
JPDrawOutlineStroke(imageRef, context, outlineStrokeWidth, outlineStrokeColor.CGColor, diffX, diffY);
// 2.绘制原图像(盖在轮廓描边上)
CGContextDrawImage(context, CGRectMake(diffX, diffY, width, height), imageRef);
// 3.取出新图像
CGImageRef newImageRef = CGBitmapContextCreateImage(context);
// 4.释放内存
CGContextRelease(context);
if (!newImageRef) {
return;
}
// 完成!
UIImage *newImage = [UIImage imageWithCGImage:newImageRef];
GIF的应用效果
有了上面的方法,只要对GIF的每一张图片添加描边就可以实现GIF描边了,看看在微信上的效果:
还行还行,但这种方式添加的描边实际上很粗糙,所以也只能应用在GIF上。
最后
以上方法和实现我都一起打包放到我的JPImageresizerView中,除了给GIF添加描边,还额外扩展了背景色、圆角、边框、内边距的添加,另外还有可持续获取图片目标像素的颜色值(图片测色器),有兴趣可以去看看。