基於 Serverless 架構的頭像漫畫風處理小程式

Serverless發表於2022-04-08

前言

我一直都想要有一個漫畫版的頭像,奈何手太笨,用了很多軟體 “捏不出來”,所以就在想著,是否可以基於 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.c... 完成 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)
        }
      })
    })
  }

完成之後配置一下後臺介面,釋出稽核即可。

相關文章