【iOS】日常笔记:使用CGContext给GIF添加白色描边

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添加描边,还额外扩展了背景色、圆角、边框、内边距的添加,另外还有可持续获取图片目标像素的颜色值(图片测色器),有兴趣可以去看看。

相关推荐
西柚与蓝莓2 小时前
任务【浦语提示词工程实践】
github
Good_Starry7 小时前
Git介绍--github/gitee/gitlab使用
git·gitee·gitlab·github
云端奇趣12 小时前
探索 3 个有趣的 GitHub 学习资源库
经验分享·git·学习·github
杨荧12 小时前
【JAVA开源】基于Vue和SpringBoot的水果购物网站
java·开发语言·vue.js·spring boot·spring cloud·开源
运营黑客14 小时前
发现一超级Prompt:让GPT-4o、Claude3.5性能再升级(附保姆级教程)
github
記億揺晃着的那天15 小时前
Github优质项目推荐-第二期
github
x-cmd15 小时前
[241005] 14 款最佳免费开源图像处理库 | PostgreSQL 17 正式发布
数据库·图像处理·sql·安全·postgresql·开源·json
奇客软件18 小时前
如何从相机的记忆棒(存储卡)中恢复丢失照片
深度学习·数码相机·ios·智能手机·电脑·笔记本电脑·iphone
Uncertainty!!19 小时前
GitHub入门与实践
github
罗曼蒂克在消亡19 小时前
github项目——gpt-pilot自动创建应用
gpt·github·github项目