• 基于 Serverless 架构的头像漫画风处理小程序


    作者:刘宇(花名:江昱)

    前言

    我一直都想要有一个漫画版的头像,奈何手太笨,用了很多软件 “捏不出来”,所以就在想着,是否可以基于 AI 实现这样一个功能,并部署到 Serverless 架构上让更多人来尝试使用呢?

    后端项目

    后端项目采用业界鼎鼎有名的动漫风格转化滤镜库 AnimeGAN 的 v2 版本,效果大概如下:

    在这里插入图片描述

    关于这个模型的具体的信息,在这里不做详细的介绍和说明。通过与 Python Web 框架结合,将 AI 模型通过接口对外暴露:

    from PIL import Image
    import io
    import torch
    import base64
    import bottle
    import random
    import json
    cacheDir = '/tmp/'
    modelDir = './model/bryandlee_animegan2-pytorch_main'
    getModel = lambda modelName: torch.hub.load(modelDir, "generator", pretrained=modelName, source='local')
    models = {
        'celeba_distill': getModel('celeba_distill'),
        'face_paint_512_v1': getModel('face_paint_512_v1'),
        'face_paint_512_v2': getModel('face_paint_512_v2'),
        'paprika': getModel('paprika')
    }
    randomStr = lambda num=5: "".join(random.sample('abcdefghijklmnopqrstuvwxyz', num))
    face2paint = torch.hub.load(modelDir, "face2paint", size=512, source='local')
    @bottle.route('/images/comic_style', method='POST')
    def getComicStyle():
        result = {}
        try:
            postData = json.loads(bottle.request.body.read().decode("utf-8"))
            style = postData.get("style", 'celeba_distill')
            image = postData.get("image")
            localName = randomStr(10)
            # 图片获取
            imagePath = cacheDir + localName
            with open(imagePath, 'wb') as f:
                f.write(base64.b64decode(image))
            # 内容预测
            model = models[style]
            imgAttr = Image.open(imagePath).convert("RGB")
            outAttr = face2paint(model, imgAttr)
            img_buffer = io.BytesIO()
            outAttr.save(img_buffer, format='JPEG')
            byte_data = img_buffer.getvalue()
            img_buffer.close()
            result["photo"] = 'data:image/jpg;base64, %s' % base64.b64encode(byte_data).decode()
        except Exception as e:
            print("ERROR: ", e)
            result["error"] = True
        return result
    app = bottle.default_app()
    if __name__ == "__main__":
        bottle.run(host='localhost', port=8099)
    

    整个代码是基于 Serverless 架构进行了部分改良的:

    1. 实例初始化的时候,进行模型的加载,已经可能的减少频繁的冷启动带来的影响情况;
    2. 在函数模式下,往往只有/tmp目录是可写的,所以图片会被缓存到/tmp目录下;
    3. 虽然说函数计算是“无状态”的,但是实际上也有复用的情况,所有数据在存储到tmp的时候进行了随机命名;
    4. 虽然部分云厂商支持二进制的文件上传,但是大部分的 Serverless 架构对二进制上传支持的并不友好,所以这里依旧采用 Base64 上传的方案;

    上面的代码,更多是和 AI 相关的,除此之外,还需要有一个获取模型列表,以及模型路径等相关信息的接口:

    import bottle
    @bottle.route('/system/styles', method='GET')
    def styles():
        return {
          "AI动漫风": {
            'color': 'red',
            'detailList': {
              "风格1": {
                'uri': "images/comic_style",
                'name': 'celeba_distill',
                'color': 'orange',
                'preview': 'https://serverless-article-picture.oss-cn-hangzhou.aliyuncs.com/1647773808708_20220320105649389392.png'
              },
              "风格2": {
                'uri': "images/comic_style",
                'name': 'face_paint_512_v1',
                'color': 'blue',
                'preview': 'https://serverless-article-picture.oss-cn-hangzhou.aliyuncs.com/1647773875279_20220320105756071508.png'
              },
              "风格3": {
                'uri': "images/comic_style",
                'name': 'face_paint_512_v2',
                'color': 'pink',
                'preview': 'https://serverless-article-picture.oss-cn-hangzhou.aliyuncs.com/1647773926924_20220320105847286510.png'
              },
              "风格4": {
                'uri': "images/comic_style",
                'name': 'paprika',
                'color': 'cyan',
                'preview': 'https://serverless-article-picture.oss-cn-hangzhou.aliyuncs.com/1647773976277_20220320105936594662.png'
              },
            }
          },
        }
    app = bottle.default_app()
    if __name__ == "__main__":
        bottle.run(host='localhost', port=8099)
    

    可以看到,此时我的做法是,新增了一个函数作为新接口对外暴露,那么为什么不在刚刚的项目中,增加这样的一个接口呢?而是要多维护一个函数呢?

    1. AI 模型加载速度慢,如果把获取AI处理列表的接口集成进去,势必会影响该接口的性能;
      2.AI 模型所需配置的内存会比较多,而获取 AI 处理列表的接口所需要的内存非常少,而内存会和计费有一定的关系,所以分开有助于成本的降低;

    关于第二个接口(获取 AI 处理列表的接口),相对来说是比较简单的,没什么问题,但是针对第一个 AI 模型的接口,就有比较头疼的点:

    1. 模型所需要的依赖,可能涉及到一些二进制编译的过程,所以导致无法直接跨平台使用;
    2. 模型文件比较大 (单纯的 Pytorch 就超过 800M),函数计算的上传代码最多才 100M,所以这个项目无法直接上传;

    所以这里需要借助 Serverless Devs 项目来进行处理:

    参考 https://www.serverless-devs.com/fc/yaml/readme

    完成 s.yaml 的编写:

    edition: 1.0.0
    name: start-ai
    access: "default"
    vars: # 全局变量
      region: cn-hangzhou
      service:
        name: ai
        nasConfig:                  # NAS配置, 配置后function可以访问指定NAS
          userId: 10003             # userID, 默认为10003
          groupId: 10003            # groupID, 默认为10003
          mountPoints:              # 目录配置
            - serverAddr: 0fe764bf9d-kci94.cn-hangzhou.nas.aliyuncs.com # NAS 服务器地址
              nasDir: /python3
              fcDir: /mnt/python3
        vpcConfig:
          vpcId: vpc-bp1rmyncqxoagiyqnbcxk
          securityGroupId: sg-bp1dpxwusntfryekord6
          vswitchIds:
            - vsw-bp1wqgi5lptlmk8nk5yi0
    services:
      image:
        component:  fc
        props: #  组件的属性值
          region: ${vars.region}
          service: ${vars.service}
          function:
            name: image_server
            description: 图片处理服务
            runtime: python3
            codeUri: ./
            ossBucket: temp-code-cn-hangzhou
            handler: index.app
            memorySize: 3072
            timeout: 300
            environmentVariables:
              PYTHONUSERBASE: /mnt/python3/python
          triggers:
            - name: httpTrigger
              type: http
              config:
                authType: anonymous
                methods:
                  - GET
                  - POST
                  - PUT
          customDomains:
            - domainName: avatar.aialbum.net
              protocol: HTTP
              routeConfigs:
                - path: /*
    

    然后进行:

    1、依赖的安装:s build --use-docker
    2、项目的部署:s deploy
    3、在 NAS 中创建目录,上传依赖:

    s nas command mkdir /mnt/python3/python
    s nas upload -r 本地依赖路径 /mnt/python3/python
    

    完成之后可以通过接口对项目进行测试。

    另外,微信小程序需要 https 的后台接口,所以这里还需要配置 https 相关的证书信息,此处不做展开。

    小程序项目

    小程序项目依旧采用 colorUi,整个项目就只有一个页面:
    在这里插入图片描述

    页面相关布局:

    <scroll-view scroll-y class="scrollPage">
      <image src='/images/topbg.jpg' mode='widthFix' class='response'></image>
      <view class="cu-bar bg-white solid-bottom margin-top">
        <view class="action">
          <text class="cuIcon-title text-blue"></text>第一步:选择图片
        </view>
      </view>
      <view class="padding bg-white solid-bottom">
        <view class="flex">
          <view class="flex-sub bg-grey padding-sm margin-xs radius text-center" bindtap="chosePhoto">本地上传图片</view>
          <view class="flex-sub bg-grey padding-sm margin-xs radius text-center" bindtap="getUserAvatar">获取当前头像</view>
        </view>
      </view>
      <view class="padding bg-white" hidden="{{!userChosePhoho}}">
        <view class="images">
          <image src="{{userChosePhoho}}" mode="widthFix" bindtap="previewImage" bindlongpress="editImage" data-image="{{userChosePhoho}}"></image>
        </view>
        <view class="text-right padding-top text-gray">* 点击图片可预览,长按图片可编辑</view>
      </view>
      <view class="cu-bar bg-white solid-bottom margin-top">
        <view class="action">
          <text class="cuIcon-title text-blue"></text>第二步:选择图片处理方案
        </view>
      </view>
      <view class="bg-white">
        <scroll-view scroll-x class="bg-white nav">
          <view class="flex text-center">
            <view class="cu-item flex-sub {{style==currentStyle?'text-orange cur':''}}" wx:for="{{styleList}}"
              wx:for-index="style" bindtap="changeStyle" data-style="{{style}}">
              {{style}}
            </view>
          </view>
        </scroll-view>
      </view>
      <view class="padding-sm bg-white solid-bottom">
        <view class="cu-avatar round xl bg-{{item.color}} margin-xs" wx:for="{{styleList[currentStyle].detailList}}"
          wx:for-index="substyle" bindtap="changeStyle" data-substyle="{{substyle}}" bindlongpress="showModal" data-target="Image"> 
          <view class="cu-tag badge cuIcon-check bg-grey" hidden="{{currentSubStyle == substyle ? false : true}}"></view>
          <text class="avatar-text">{{substyle}}</text>
        </view>
        <view class="text-right padding-top text-gray">* 长按风格圆圈可以预览模板效果</view>
      </view>
      <view class="padding-sm bg-white solid-bottom">
        <button class="cu-btn block bg-blue margin-tb-sm lg" bindtap="getNewPhoto" disabled="{{!userChosePhoho}}"
          type="">{{ userChosePhoho ? (getPhotoStatus ? 'AI将花费较长时间' : '生成图片') : '请先选择图片' }}</button>
      </view>
      <view class="cu-bar bg-white solid-bottom margin-top" hidden="{{!resultPhoto}}">
        <view class="action">
          <text class="cuIcon-title text-blue"></text>生成结果
        </view>
      </view>
      <view class="padding-sm bg-white solid-bottom" hidden="{{!resultPhoto}}">
        <view wx:if="{{resultPhoto == 'error'}}">
          <view class="text-center padding-top">服务暂时不可用,请稍后重试</view>
          <view class="text-center padding-top">或联系开发者微信:<text class="text-blue" data-data="zhihuiyushaiqi" bindtap="copyData">zhihuiyushaiqi</text></view>
        </view>
        <view wx:else>
          <view class="images">
            <image src="{{resultPhoto}}" mode="aspectFit" bindtap="previewImage" bindlongpress="saveImage" data-image="{{resultPhoto}}"></image>
          </view>
          <view class="text-right padding-top text-gray">* 点击图片可预览,长按图片可保存</view>
        </view>
      </view>
      <view class="padding bg-white margin-top margin-bottom">
        <view class="text-center">自豪的采用 Serverless Devs 搭建</view>
        <view class="text-center">Powered By Anycodes <text bindtap="showModal" class="text-cyan" data-target="Modal">{{"<"}}作者的话{{">"}}</text></view>
      </view>
      <view class="cu-modal {{modalName=='Modal'?'show':''}}">
      <view class="cu-dialog">
        <view class="cu-bar bg-white justify-end">
          <view class="content">作者的话</view>
          <view class="action" bindtap="hideModal">
            <text class="cuIcon-close text-red"></text>
          </view>
        </view>
        <view class="padding-xl text-left">
          大家好,我是刘宇,很感谢您可以关注和使用这个小程序,这个小程序是我用业余时间做的一个头像生成小工具,基于“人工智障”技术,反正现在怎么看怎么别扭,但是我会努力让这小程序变得“智能”起来的。如果你有什么好的意见也欢迎联系我<text class="text-blue" data-data="service@52exe.cn" bindtap="copyData">邮箱</text>或者<text class="text-blue" data-data="zhihuiyushaiqi" bindtap="copyData">微信</text>,另外值得一提的是,本项目基于阿里云Serverless架构,通过Serverless Devs开发者工具建设。
        </view>
      </view>
    </view>
    <view class="cu-modal {{modalName=='Image'?'show':''}}">
      <view class="cu-dialog">
        <view class="bg-img" style="background-image: url("{{previewStyle}}");height:200px;">
          <view class="cu-bar justify-end text-white">
            <view class="action" bindtap="hideModal">
              <text class="cuIcon-close "></text>
            </view>
          </view>
        </view>
        <view class="cu-bar bg-white">
          <view class="action margin-0 flex-sub  solid-left" bindtap="hideModal">关闭预览</view>
        </view>
      </view>
    </view>
    </scroll-view>
    页面逻辑也是比较简单的:
    // index.js
    // 获取应用实例
    const app = getApp()
    Page({
      data: {
        styleList: {},
        currentStyle: "动漫风",
        currentSubStyle: "v1模型",
        userChosePhoho: undefined,
        resultPhoto: undefined,
        previewStyle: undefined,
        getPhotoStatus: false
      },
      // 事件处理函数
      bindViewTap() {
        wx.navigateTo({
          url: '../logs/logs'
        })
      },
      onLoad() {
        const that = this
        wx.showLoading({
          title: '加载中',
        })
        app.doRequest(`system/styles`, {}, option = {
          method: "GET"
        }).then(function (result) {
          wx.hideLoading()
          that.setData({
            styleList: result,
            currentStyle: Object.keys(result)[0],
            currentSubStyle: Object.keys(result[Object.keys(result)[0]].detailList)[0],
          })
        })
      },
      changeStyle(attr) {
        this.setData({
          "currentStyle": attr.currentTarget.dataset.style || this.data.currentStyle,
          "currentSubStyle": attr.currentTarget.dataset.substyle || Object.keys(this.data.styleList[attr.currentTarget.dataset.style].detailList)[0]
        })
      },
      chosePhoto() {
        const that = this
        wx.chooseImage({
          count: 1,
          sizeType: ['compressed'],
          sourceType: ['album', 'camera'],
          complete(res) {
            that.setData({
              userChosePhoho: res.tempFilePaths[0],
              resultPhoto: undefined
            })
          }
        })
      },
      headimgHD(imageUrl) {
        imageUrl = imageUrl.split('/'); //把头像的路径切成数组
        //把大小数值为 46 || 64 || 96 || 132 的转换为0
        if (imageUrl[imageUrl.length - 1] && (imageUrl[imageUrl.length - 1] == 46 || imageUrl[imageUrl.length - 1] == 64 || imageUrl[imageUrl.length - 1] == 96 || imageUrl[imageUrl.length - 1] == 132)) {
          imageUrl[imageUrl.length - 1] = 0;
        }
        imageUrl = imageUrl.join('/'); //重新拼接为字符串
        return imageUrl;
      },
      getUserAvatar() {
        const that = this
        wx.getUserProfile({
          desc: "获取您的头像",
          success(res) {
            const newAvatar = that.headimgHD(res.userInfo.avatarUrl)
            wx.getImageInfo({
              src: newAvatar,
              success(res) {
                that.setData({
                        userChosePhoho: res.path,
                        resultPhoto: undefined
                      })
              }
            })
          }
        })
      },
      previewImage(e) {
        wx.previewImage({
          urls: [e.currentTarget.dataset.image]
        })
      },
      editImage() {
        const that = this
        wx.editImage({
          src: this.data.userChosePhoho,
          success(res) {
            that.setData({
              userChosePhoho: res.tempFilePath
            })
          }
        })
      },
      getNewPhoto() {
        const that = this
        wx.showLoading({
          title: '图片生成中',
        })
        this.setData({
          getPhotoStatus: true
        })
        app.doRequest(this.data.styleList[this.data.currentStyle].detailList[this.data.currentSubStyle].uri, {
          style: this.data.styleList[this.data.currentStyle].detailList[this.data.currentSubStyle].name,
          image: wx.getFileSystemManager().readFileSync(this.data.userChosePhoho, "base64")
        }, option = {
          method: "POST"
        }).then(function (result) {
          wx.hideLoading()
          that.setData({
            resultPhoto: result.error ? "error" : result.photo,
            getPhotoStatus: false
          })
        })
      },
      saveImage() {
        wx.saveImageToPhotosAlbum({
          filePath: this.data.resultPhoto,
          success(res) {
            wx.showToast({
              title: "保存成功"
            })
          },
          fail(res) {
            wx.showToast({
              title: "异常,稍后重试"
            })
          }
        })
      },
      onShareAppMessage: function () {
        return {
          title: "头头是道个性头像",
        }
      },
      onShareTimeline() {
        return {
          title: "头头是道个性头像",
        }
      },
      showModal(e) {
        if(e.currentTarget.dataset.target=="Image"){
          const previewSubStyle = e.currentTarget.dataset.substyle
          const previewSubStyleUrl = this.data.styleList[this.data.currentStyle].detailList[previewSubStyle].preview
          if(previewSubStyleUrl){
            this.setData({
              previewStyle: previewSubStyleUrl
            })
          }else{
            wx.showToast({
              title: "暂无模板预览",
              icon: "error"
            })
            return 
          }
        }
        this.setData({
          modalName: e.currentTarget.dataset.target
        })
      },
      hideModal(e) {
        this.setData({
          modalName: null
        })
      },
      copyData(e) {
        wx.setClipboardData({
          data: e.currentTarget.dataset.data,
          success(res) {
            wx.showModal({
              title: '复制完成',
              content: `已将${e.currentTarget.dataset.data}复制到了剪切板`,
            })
          }
        })
      },
    })
    

    因为项目会请求比较多次的后台接口,所以,我将请求方法进行额外的抽象:

    // 统一请求接口
      doRequest: async function (uri, data, option) {
        const that = this
        return new Promise((resolve, reject) => {
          wx.request({
            url: that.url + uri,
            data: data,
            header: {
              "Content-Type": 'application/json',
            },
            method: option && option.method ? option.method : "POST",
            success: function (res) {
              resolve(res.data)
            },
            fail: function (res) {
              reject(null)
            }
          })
        })
      }
    

    完成之后配置一下后台接口,发布审核即可。
    发布云原生技术最新资讯、汇集云原生技术最全内容,定期举办云原生活动、直播,阿里产品及用户最佳实践发布。与你并肩探索云原生技术点滴,分享你需要的云原生内容。

    关注【阿里巴巴云原生】公众号,获取更多云原生实时资讯!

  • 相关阅读:
    植物园偶遇一直喵
    植物园偶遇一直喵
    美食篇
    美食篇
    端午节路过南站
    端午节路过南站
    黄山云海
    黄山云海
    Android (1)
    树和树算法(1)
  • 原文地址:https://www.cnblogs.com/alisystemsoftware/p/16173240.html
Copyright © 2020-2023  润新知