JX通信社シニア・エンジニアの@shinyorke(しんよーく)です.
最近は色んなエンジニアリングをしつつ, イベントの司会業をしています(詳細は最後の方を見てね).
開発しているサービス・プロダクトの要件で,
- TwitterやLINE, FacebookでシェアするOGP*1コンテンツ(タイトル・本文・画像)が欲しい
- コンテンツはユーザーさんの操作で動的に変わる
- テキストだけじゃなくて, 画像も変えたい←これ
なんて事は非常によくある話だと思います.
私はちょっと前に開発したAIワクチン接種予測でそれがありました.
例えば上記画像のテキスト(地域・年齢・接種可能時期)は予測の結果を動的に画像テンプレートに入れて都度作っています.
上記のOGPを生成するために必要なことはこういう感じだろうなー, と以下の絵の通り整理し,
結果的に, OGPを生成するためのサービスを200行にも満たない小さいAPIサーバーとして実現しました.
このエントリーでは,
- CairoSVGを使った画像生成
- FastAPI + CairoSVG + GCSを使ったAPI構築
- Cloud Runでの運用
で上記のサービスを作った事例を紹介します.
なおこのエントリーは,
- PythonでWebアプリを作ったことがある
- FastAPIおよびJinja2を使える/使ったことがある
- ラスタ画像とベクター画像の意味がわかる
程度の前提知識を読者の皆さまが持っている, と想定して執筆しています(Pythonアプリ開発初級みたいなノリです).
TL;DR
- FastAPIとCairoSVGで画像の動的生成はいい感じにできちゃいます
- テンプレートエンジンは迷ったらJinja2
- フォント指定はハマるので気をつけよう
おしながき
ベクター素材から画像を作る🐍
前述の通り, やりたいことは
- 地域・年齢・接種可能時期といったテキストをAIさんが準備し
- 予め用意したテンプレートのベクター素材にテキストを埋め込み
- pngなどのラスタ形式に変換(いわゆるラスタライズ)
です.
自分で調べたり, 社内で色々聞き回った結果,
- SVGで素材を準備して(デザイナーさんにお願いして)それを使ってサーバーサイドでラスタライズする
- SVGからのラスタライズはPythonのライブラリでできるっぽい
というのがわかったので早速やりました.
CairoSVGで画像を作る
SVGをラスタライズする手段は意外とあっさりで, CairoSVGというライブラリで解決しました.
cairosvg.org
github.com
公式サイトのサンプルを見る限り, やりたいことは出来そうとわかりました.
import cairosvg
cairosvg.svg2pdf(url='sample.svg', write_to='sample.pdf')
cairosvg.svg2png(url='sample.svg', write_to='sample.png')
コードもすごく短いですし, とても良さそうです👍
SVG素材をJinja2テンプレにする
やりたいことはCairoSVGでできそうとわかったのですが, 前述の通り今回やりたいのは
- 地域・年齢・接種可能時期といったテキストをAIさんが準備(できている)
- 予め用意したテンプレートのベクター素材にテキストを埋め込み
- pngなどのラスタ形式に変換(CairoSVGでやれる)
で, 肝心の「テンプレートのベクター素材にテキストを埋め込み」が解決していません.
ちなみにやりたいことをコードに起こすとこんな感じです.
import cairosvg
def convert_ogp(area: str, age: int, period: str):
ogp_context = 'TODO: ここにSVGの中身が入る'
with NamedTemporaryFile('w') as f:
f.write(ogp_context)
f.flush()
image_bytes = svg2png(url=f.name, write_to=None)
return image_bytes
image = convert_ogp('東京都', 40, '10月下旬〜2月上旬')
一番単純な方法はヒアドキュメントとしてSVGテキストを用意して置換することなのでしょうが, デザイン素材を(文字列とはいえ)Pythonコードに書くのは若干気が引けた*2ので,
- SVG素材をJinja2テンプレとして書き直す
- 上記Jinja2テンプレを元に画像生成する
という方法でいい感じにやりました.
import cairosvg
from jinja2 import Environment, FileSystemLoader
def convert_ogp(area: str, age: int, period: str, template_file: str):
env = Environment(loader=FileSystemLoader(os.path.dirname(template_file)))
ogp_template = env.get_template(os.path.basename(template_file))
ogp_context = ogp_template.render(area=area, age=age, period=period)
with NamedTemporaryFile('w') as f:
f.write(ogp_context)
f.flush()
image_bytes = svg2png(url=f.name, write_to=None)
return image_bytes
image = convert_ogp('東京都', 40, '10月下旬〜2月上旬', 'templates/ogp.svg.j2')
テキスト(ベクター)として扱う時はまずJinja2ってくらいよく使ってるのでこれはあっさり思いついてすぐ実現できました.
何かしらのフォーマット(HTMLでもXMLでもYAMLでもJSONでも)で動的にコンテンツを生産したい時はJinja2便利です.
流石Pythonを代表するテンプレートエンジンなだけあります*3.
APIサーバーにする
画像生成の仕組みはこれで目処が付いたので, API化します.
FastAPIサーバーとして用意する
やることは,
- 予測結果を受け取って画像をGoogle Cloud StorageにuploadするAPIを作る
- 予測結果はPOSTパラメーターとして受け取る
です.
APIはFlask, bottle, FastAPIと比較的軽めなFrameworkなら何でも大丈夫*4なのですが今回は(社内でよく使ってる)FastAPIで実現しました.
from fastapi import FastAPI
from pydantic import BaseModel
from sample_image import convert_ogp
class OgpContext(BaseModel):
"""
OGPで使う項目
"""
age: int
area: str
period: str
app = FastAPI(docs_url=None, redoc_url=None)
@app.post('/creage')
def create(form: OgpContext):
image_byte = convert_ogp(form.area, form.age, form.period, 'templates/ogp.svg.j2')
upload(image_byte)
return {'status': 'ok'}
if __name__ == '__main__':
import os
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=os.getenv('PORT', 8080))
画像作ってアップロードするだけなら(上記のコードでは端折ってる画像アップロード関数含めて)ほんの100〜200行で終わります.
Cloud Runでホスティング
実サービスのホスティングはCloud Runで行いました.
cloud.google.com
Dockerコンテナ一つで動く薄い画像生成APIとして作っていたので,
- gcloudコマンド2回でデプロイまでいける
- トラフィックに応じてインスタンスを増やす(減らす)がGUIコンソールをポチポチするだけでいける
という利便性を重視してCloud Runにしました.
ちなみに「コマンド2回でデプロイまで」はこんな感じです
# Docker buildとimage push
gcloud builds submit --tag asia.gcr.io/example-test-prj/ogp-generator
# 上記imageをdeploy
gcloud run deploy --image asia.gcr.io/example-test-prj/ogp-generator --platform managed
たったこれだけで終わるの最高です.
ハマったこと
ちなみに開発中ハマったこととして, 文字化けがありました.
- 開発環境(自分のMac)では文字化けしない
- Docker imageから作った環境だと文字化けする
原因は明確で, 「Macには存在するフォントだけどDocker image(Debian)には存在しないフォント」でやったのでこの差がでました.
FROM python:3.9
# install
COPY poetry.lock pyproject.toml ./
RUN pip install poetry
RUN poetry config virtualenvs.create false \
&& poetry install --no-dev
# app
COPY app.py ./
ADD templates templates
# templates/fonts配下のフォントを然るべき所にコピー
COPY templates/fonts /usr/share/fonts/truetype/dejavu
CMD python app.py
SVGフォーマット上で使ってるフォントを洗い出し, 見つけて追加して無事解決しました.
運用の結果&結び
というわけで, このエントリーではサーバーサイドで動的に画像を作る方法について紹介しました.
AIワクチン接種予測がTVに出たときもこの仕組みが動いていたのですが, 大きなトラブルもなくトラフィックに耐えきったのでやり方として正解だった様に思えます.
なお, このエントリーは(このブログを書く前に)社内のWin Sessionでネタを披露したところ, ウケが良かったので今回ブログとして公開することにしました.
これで似たような課題を抱えている皆さまの手助けになると幸いです.
JX Press Tech Talk でPythonの話やります!
最後にちょこっと宣伝です.
jxpress.connpass.com
好評だった前回に続きまして, JX Press Tech Talk第二弾を6/23(水)にやることになりました!
私は前回に続いて司会をやると同時に「StreamlitとFlaskでPoCから本番運用までやりきったやで📺」的なお話をさせていただきます.
二刀流で頑張りたいと思います, お時間ある方ぜひいらしてください!