前言
我一直都想要有一個漫畫版的頭像,奈何手太笨,用了很多軟體 “捏不出來”,所以就在想著,是否可以基於 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 架構進行了部分改良的:
- 例項初始化的時候,進行模型的載入,已經可能的減少頻繁的冷啟動帶來的影響情況;
- 在函式模式下,往往只有/tmp目錄是可寫的,所以圖片會被快取到/tmp目錄下;
- 雖然說函式計算是“無狀態”的,但是實際上也有複用的情況,所有資料在儲存到tmp的時候進行了隨機命名;
- 雖然部分雲廠商支援二進位制的檔案上傳,但是大部分的 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)
可以看到,此時我的做法是,新增了一個函式作為新介面對外暴露,那麼為什麼不在剛剛的專案中,增加這樣的一個介面呢?而是要多維護一個函式呢?
- AI 模型載入速度慢,如果把獲取AI處理列表的介面整合進去,勢必會影響該介面的效能;
- AI 模型所需配置的記憶體會比較多,而獲取 AI 處理列表的介面所需要的記憶體非常少,而記憶體會和計費有一定的關係,所以分開有助於成本的降低;
關於第二個介面(獲取 AI 處理列表的介面),相對來說是比較簡單的,沒什麼問題,但是針對第一個 AI 模型的介面,就有比較頭疼的點:
- 模型所需要的依賴,可能涉及到一些二進位制編譯的過程,所以導致無法直接跨平臺使用;
- 模型檔案比較大 (單純的 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)
}
})
})
}
完成之後配置一下後臺介面,釋出稽核即可。