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というライブラリで解決しました.
公式サイトのサンプルを見る限り, やりたいことは出来そうとわかりました.
# pip install cairosvg で普通に入ります import cairosvg # SVGからPDF cairosvg.svg2pdf(url='sample.svg', write_to='sample.pdf') # SVGからpng(これが本命でやりたかったこと) 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): # 何かしらの方法でSVGフォーマットなテキストを入手 ogp_context = 'TODO: ここにSVGの中身が入る' # TemporaryFileとして書き出して後byteでもらう 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) # TemporaryFileとして書き出して後byteでもらう with NamedTemporaryFile('w') as f: f.write(ogp_context) f.flush() image_bytes = svg2png(url=f.name, write_to=None) return image_bytes # area, age, periodという変数を持ったjinja2テンプレを事前に準備 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で実現しました.
# 実際のAPIはもっとちゃんと作ってます(イメージを掴むためのサンプルです) from fastapi import FastAPI from pydantic import BaseModel # さっきのOGP生成関数 from sample_image import convert_ogp class OgpContext(BaseModel): """ OGPで使う項目 """ age: int area: str period: str # docは公開する必要ない(しちゃ🙅)なので使いません 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') # 取得したimageを何かしらの方法でアップロード 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で行いました.
Dockerコンテナ一つで動く薄い画像生成APIとして作っていたので,
- gcloudコマンド2回でデプロイまでいける
- Cloud Buildでimage作ってレジストリ(GCR)にpush
- Cloud Runにデプロイ
- トラフィックに応じてインスタンスを増やす(減らす)が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の話やります!
最後にちょこっと宣伝です.
好評だった前回に続きまして, JX Press Tech Talk第二弾を6/23(水)にやることになりました!
私は前回に続いて司会をやると同時に「StreamlitとFlaskでPoCから本番運用までやりきったやで📺」的なお話をさせていただきます.
二刀流で頑張りたいと思います, お時間ある方ぜひいらしてください!
*1:Open Graph Protocolのことで, かんたんに言うとSNSシェアする時に出てくる文章や画像を定義するための仕様・お決まりのことです. ちなみにTwitterはTwitter Cardという独自のお決まりがあります(やろうとしてることはOGPと一緒)
*2:「明日にもリリースしなきゃ」という状況だったらこの方法は全然アリです. 今回は(タイトなスケジュールだったとはいえ)そこまでじゃなかったのでテンプレとコードはちゃんと分離しました.
*3:Flaskのデフォルトテンプレートエンジンだったりしますし, Djangoでも使ったりします. また, AnsibleのplaybookはJinja2がベースだったりします.
*4:もちろんDjango(厳密にはDjango REST framework)も候補になると思います. が今回はホントに薄いラッパーなので軽量FWを中心に考えました.