Skip to content

函数计算实现水印平铺

需求

接上文又拍云迁移阿里云OSS的实践所说,阿里云OSS这套图片处理不没有平铺水印功能,只好自己动手丰衣足食了。

方案

基于函数计算有两种实现方案,第一种是主动计算的方式,利用OSS触发器可以实现每上传一张新图马上生成他的含水印版并持久化存储在OSS。第二种是被动计算,利用HTTP请求触发,实时获取OSS中的图片再返回处理结果。

第一种的好处是生成之后用户可以非常快速的访问到,但是如果没有处理完或处理失败则回得到404,而且这种方案带来另一个问题是OSS的存储空间将翻倍,而且会有一个致命问题是如果要对水印图片做改动将带来巨大的麻烦。你得批量处理大量数据。

方案二可以说与方案一的优缺点完全相反,如果每次都实时计算,将带来超高的延时和计算量的爆表。其实这个问题不难优化,同一个图片每次的处理结果一定是相同的,所以我们只需要加一层CDN把处理结果缓存起来即可,而第一次的处理耗时显然会长一点,但是据我测试基本也能在两秒内返回结果。阿里云CDN最近“特意”支持了函数计算的回源方式,其实这东西本来就是CDN具有的能力,阿里云的多此一举可能也是为了提醒更多的人们将函数计算与CDN结合实现业务加速。

实践中的思考

继续选择了Python来做这件事,因为Python有不错的图片处理库Pillow。

实现过程其实没有什么难点,除了平铺需要思考一点点算法以让布局更好看,其他基本是查文档堆功能即可。但是要注意函数中获取原图数据的过程可能比处理图片花费更长时间,因此OSS访问一定用同可用区的内网连接地址,水印图片是不会变的东西,我把他转成base64直接存在代码中了。

在相应部分本来想做webp自适应,也就是根据Accept请求头判断是否返回webp格式,但是想到函数前面还有一层CDN,他不会把所有请求转发过来而且不知道CDN会缓存哪个格式的响应结果,后来只好统一返回jpeg。其实解决方法也很简单,把输出格式也做在URL请求参数上就可以了。同样的URL返回同样的结果。

代码请参考文末。只是一个示例,生产环境还需要异常处理,内存回收等复杂情况。

上线之后的事情

第一件大事是图片旋转出错了,原本是横图的竖着显示了。因为我们普通图片的CDN都统一设置了自动扶正。但是在这的时候坏了,没有考虑到这种情况,因此加了一层图片扶正算法。关于图片扶正的细节这里不多解释,有不解之处请大家自助学习。

在加了图片扶正之后紧接着又出一件事是不少图片返回失败,查看响应结果是爆内存了,于是改函数配置,从256M准够升到了512M,再补充了一些可以释放内存的代码,最终是成功解决了。不过这里也有一个坑是函数配置似乎也要跟着版本发布了才算数?据我试验是这样的。

自从上了测试环境之后就感到图片很模糊,后面调整各种resize滤镜效果,调整锐化,细节。。。各种对比,感觉转行做了美工。后来总算是搞到一个还能看的效果,但是效果比起OSS自带的图片处理还是差点。

代码

# -*- coding: utf-8 -*-
from PIL import ImageFilter
from PIL import Image
import logging
import base64
import oss2
import io

class ImgController:
    def __init__(self, environ, start_response):
        self.environ = environ
        self.start = start_response
    def __iter__(self):
        context = self.environ['fc.context']
        creds = context.credentials
        request_uri = self.environ['fc.request_uri'].split('!', 1)[0]
        uriStart = request_uri.find('/wm/')
        if uriStart < 0:
            return self.response404()
        objName = request_uri[uriStart+4:]
        
        endpoint = 'oss-cn-hangzhou-internal.aliyuncs.com'
        objectmeta, object_stream = self.downLoadImageFromOss(creds, endpoint, 'img', objName)

        if objectmeta.headers['Content-Type'].find('gif') >= 0:
            return self.response302('https://aimg.yidoutang.com/%s' % (objName))

        oriImage = Image.open(object_stream)
        oriImage = self.imageResize(oriImage)
        oriImage = oriImage.filter(ImageFilter.SHARPEN)
        self.waterMarkRepeat(oriImage)
        return self.response200(oriImage, self.environ['HTTP_ACCEPT'])

    # 从OSS下载图片内容和附加属性
    def downLoadImageFromOss(self, creds, endpoint, bucket, objName):
        if creds.securityToken is None: 
            auth = oss2.Auth(creds.accessKeyId, creds.accessKeySecret)
        else:
            auth = oss2.StsAuth(creds.accessKeyId, creds.accessKeySecret, creds.securityToken)
        bucket = oss2.Bucket(auth, endpoint, bucket)

        object_stream = bucket.get_object(objName)
        objectmeta = bucket.head_object(objName)
        return objectmeta, object_stream

    # 缩放
    def imageResize(self, image):
        widthO = 710
        w,h = image.size
        scale = 1 if w < widthO else widthO / w
        print(scale)
        return image.resize((int(w * scale),int(h * scale)), Image.HAMMING)

    # 水印平铺
    def waterMarkRepeat(self, image):
        wmImage = self.getWmImage()
        w,h = image.size
        mw,mh = wmImage.size
        x = y = 0
        marginX = 120
        marginY = 100
        offset = 0
        while y < h:
            while x < w:
                image.paste(wmImage, (x,y), wmImage)
                x = x + mw + marginX
            y = y + mh + marginY
            if offset == 0:
                offset = 220
            else:
                offset = 0
            x = 0 + offset
        return image

    # 返回水印图片的PIL对象
    # 图片以base64保存在代码中
    def getWmImage(self):
        wmString = 'iVBORw0KGgoAAAANSUhEUg......'
        wmBinary = base64.b64decode(wmString)
        wmData = io.BytesIO(wmBinary)
        return Image.open(wmData)

    # 返回图片。可根据accept请求头自适应webp,但是CDN不支持,遂作罢
    def response200(self, image, http_accept):
        # responseFormat = 'webp' if http_accept.find('image/webp') >= 0 else 'jpeg' 
        responseFormat = 'jpeg'
        with io.BytesIO() as output:
            image.save(output, format=responseFormat.upper())
            response_headers = [('Content-type', 'image/%s' % (responseFormat))]
            status = '200 OK'
            self.start(status, response_headers)
            yield output.getvalue()

    def response404(self):
        status = '404 Not Found'
        response_headers = [('Content-type', 'application/json; charset=utf-8')]
        self.start(status, response_headers)
        yield b'{"status":404}'

    def response302(self, url):
        status = '302 Found'
        response_headers = [('Location', url)]
        self.start(status, response_headers)
        yield b""

def handler(environ, start_response):
    return ImgController(environ, start_response)

Published in程序猿的东西

One Comment

发表评论