背景
- ATrium という AT Protocol のためのライブラリを自作している
- が、まったくドッグフーディングしていなかった
- ので、Blueskyに詰将棋の問題を放流するBotを作ってみることにした
- gfx氏が作ったBot を参考に
- というわけで、詰将棋の問題の局面を画像で投稿したい
- が、あまり自分好みの画像を生成できるライブラリやWebサービス等がない
- ので、結局それも自分で作ることにした
先行・類似事例
- Shogipic
- https://shogipic.jp/
- かなりキレイな局面図画像が生成できる
- サーバで生成する方式でAPIが公開されているわけではない
- クラウド将棋局面図ジェネレーター
- https://sfenreader2.appspot.com/
- Python on Google App Engineで動いているようだ
- SFEN形式のQueryから画像を生成するAPIがある
- 悪くはないがShogipicの方が好み
- 将棋局面図の画像作成(SVG,PNG)
- https://shogi.zukeran.org/shogi-draw/
- ブラウザ上のJSで生成する形式?
- cshogi
- https://tadaoyamaoka.hatenablog.com/entry/2019/08/17/163308
- Python libraryから画像を生成できる
- 出力はSVG形式
その他、自分でも過去にGoで作成したものがあったが、もう今はメンテしていなくて動かない。画像素材を公開してくださっていたサイトも消滅してしまっている。
自作のメリット
- 自分好みの画像を生成できる
- Rustのプログラムから使えるライブラリとして存在していると嬉しい
- WASM化してWebアプリ化もしやすいかもしれない
Rustで局面画像生成
基本的には、透過PNGの素材を組み合わせて盤上に駒を配置するだけ。
あとは持ち駒の表現が様々な表示方法があるが、歩は最大18枚なので重ねて並べるのは微妙で、素直に個数を数字としてレンダリングした方が分かりやすいと思う。
盤・駒画像の素材
最近では Electron将棋 のKubo, Ryosukeさんが使いやすい形で素材画像を公開してくださっている。
ので、これを使わせていただくことにした。 二文字駒もあると嬉しかったが、一時期追加されていたものの諸事情で削除されてしまったようだ。 今後 誰かがオリジナルで作成してここに追加してくれたりするようになるといいな…。
画像処理
image というライブラリが画像処理によく使われているようだ。 PNG以外にも様々な画像形式に対応していて、拡大縮小や重ね合わせなどの基本的な処理も簡単にできる。
作成時にはPNG画像を合成する操作だけなので、もっと軽いライブラリで同様のものが実現できるならそちらの方が良いかもしれない。とりあえずはimage
で実装してみた。
入出力
入力は局面の情報を持つものとして shogi_core の PartialPosition
を使うことにした。出力はimage
のRgbaImage
。
ライブラリ使用者は必要に応じて例えば shogi_usi_parse を使ってSFEN文字列からPartialPosition
を作り、それを元に生成した結果のRgbaImage
を加工したり好きな形式でファイルに書き出したりすれば良い、という想定。
pub fn pos2img(position: &shogi_core::PartialPosition) -> image::RgbaImage { Generator::default().generate(position) }
Generatorと下準備
局面画像を作成する際の計算負荷が減るよう、Geenrator
という構造体を用意した。
スタイルを指定してnew
した時点で必要な駒などの素材データを読み込んでおき、generate
を呼ばれた際にはそれらを貼り合わせるだけ、にする。
これによって、同じスタイルで複数の局面を生成する場合には、素材の読み込みが一度で済んで高速に生成できるようになる、はず。
盤と駒の画像は事前にサイズを決めておき、それぞれ切り出した上で最適化したPNGファイルとして置いておく。ファイル読み込みはせず、include_bytes!
でメモリ上から読み込んで使う。
Publish
というわけで出来上がったので crates.io に公開した。 ご自由にお使いください。
Web Appで使う
せっかくライブラリとして作ったので、ちょっと加工してWeb Appとしても使えると嬉しいかもしれない、と思って作ってみることにした。
局面を表現するSFEN文字列は lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1
のように表現される。これをURLのPathとして受け取って、対応する局面画像を返すようなものを考えた。
例:
https://example.com/3sks3/9/4S4/9/1+B7/9/9/9/9%20b%20S2rb4g4n4l18p%201
スペースは %20
にエンコードする必要があるのでちょっとカッコ悪いが…。
CDN Edgeで動かす
最近よく聞くようになったEdge Computingをまだ試したことがなかったので、この機会に色々触ってみることにした。
wasm-packでWebAssembly作成
まずは簡単なインタフェースを決めて、WebAssemblyで使えるようにパッケージを用意した。
簡単に扱えるように、入力はSFEN文字列としshogi_usi_parse
でそれをparseし局面情報を取得、出力は生成した画像のPNG形式バイナリデータとした。
use shogi_img::{image, pos2img, shogi_core}; use shogi_usi_parser::FromUsi; use std::io::Cursor; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn sfen2png(sfen: &str) -> Vec<u8> { let pos = shogi_core::PartialPosition::from_usi(sfen).unwrap_or_default(); let mut cursor = Cursor::new(Vec::new()); pos2img(&pos) .write_to(&mut cursor, image::ImageFormat::Png) .ok(); cursor.into_inner() }
あとは wasm-pack
でビルドすればいい感じにwasmファイルが生成される。
Deno Deploy
上記のwasmを使って最も簡単に実現できたのが、Deno Deploy だった。
wasm-pack build --target deno
するとDeno用のwasmファイルが生成され、あとはそれを読み込んで使うだけ。
import { sfen2png } from "./pkg/sfen2png.js"; Deno.serve((req: Request) => { const sfen = `sfen ${decodeURI(new URL(req.url).pathname).slice(1)}`; return new Response(sfen2png(sfen), { headers: { "content-type": "image/png", }, }); });
これだけのコードを書いて、あとはdeployすれば動く。簡単すぎてすごい。
$ curl -I https://sfen2img.deno.dev/ HTTP/2 200 content-type: image/png vary: Accept-Encoding date: Wed, 24 Jan 2024 14:39:32 GMT content-length: 447458 via: http/2 edgeproxy-h server: deno/gcp-asia-northeast1
Vercel Edge Functions
同様にwasmを読み込んで使えるものとして、Vercel Edge Functions を試してみた。
ドキュメントには wasm-pack
を使った例が無いようで、他の人が作っているexample repositoryなどを参考にしてどうにか。
wasm-pack build --target web
で生成し、Next.jsで/src/app/[[...slug]]/route.ts
で使う。
// @ts-ignore import wasm from "@/pkg/sfen2png_bg.wasm?module"; import init, { sfen2png } from "@/pkg/sfen2png.js"; export const runtime = "edge"; export const dynamic = "force-dynamic"; export async function GET(request: Request) { await init(wasm); const sfen = `sfen ${decodeURI(new URL(request.url).pathname).slice(1)}`; return new Response(sfen2png(sfen), { headers: { "content-type": "image/png", }, }); }
とりあえずこれでローカルでは動いた。deployしてみようとしたが
Error: The Edge Function "[[...slug]]" size is 1.62 MB and your plan size limit is 1 MB.
となってしまった。wasmだけで1.3MBくらいあり、Hobbyプランでは1MBまで という制限を超えてしまうようだ。 Pro以上のプランにするか、もっと軽いバイナリになるように工夫が必要になる…。
Cloudflare Workers
よく名前を聞くので是非試してみたかった、Cloudflare Workers。
wrangler
でプロジェクトを作成する際に worker-rust
のテンプレートを使うと、Rustで worker
を使うRustプロジェクトが生成される。
wrangler generate [name] https://github.com/cloudflare/workers-sdk/templates/experimental/worker-rust
そして lib.rs
を以下のように編集するだけ。
use sfen2png_wasm::sfen2png; use urlencoding::decode; use worker::*; #[event(fetch)] async fn main(req: Request, _env: Env, _ctx: Context) -> Result<Response> { let decoded = decode(&req.path()) .map(String::from) .expect("decode failed"); let sfen = format!("sfen {}", decoded.strip_prefix('/').expect("invalid path")); Ok(Response::from_bytes(sfen2png(&sfen))? .with_headers(Headers::from_iter([("content-type", "image/png")]))) }
JavaScriptのdecodeURI
同等のものは標準には無いようなので urlencoding
ライブラリを使っている。
あとは wrangler deploy
するだけ。その中で worker-build
によってCloudflare Workers用に諸々ビルドされるようで、worker-build
が内部でwasm-pack
を実行したりしているようだ。
Rustだけで完結し、JS/TSのコードを書く必要がない。wrangler
のためにnpm
を使うくらい。
Cloudflare WorkersもVercel Edge Functionsと同様に Free planでは 1 MB まで という制限があるが、こちらは size after compression ということで事前にgzip圧縮して送信してくれるおかげで660KB程度になり、制限に引っかかることなくdeploy成功する。
Total Upload: 1414.62 KiB / gzip: 659.91 KiB
$ curl -I https://sfen2img.sugyan.workers.dev/ HTTP/2 200 date: Thu, 25 Jan 2024 06:16:53 GMT content-type: image/png content-length: 447458 report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=cT5XwLyJxYgiWXTJ85RHOGIRSxlpJSnl%2F6iPDoQW097set8BelY7M%2Fc6mRULGktwSqBU2Jlv6UoJ3w6eYFDM4bBjNHmZPGvfrnipB8u4Fxmkc3T%2BjFVUyF80dEFkxsn5X1Lp9OzTRhytCWAf%2BLA%3D"}],"group":"cf-nel","max_age":604800} nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800} server: cloudflare cf-ray: 84ae63d57d908370-KIX alt-svc: h3=":443"; ma=86400
Fastly Compute@Edge
最後に Cloudflare Workersと並んでよく名前を聞く、Fastly Compute@Edge。
$ fastly compute init ... Language: (Find out more about language support at https://developer.fastly.com/learning/compute) [1] Rust [2] JavaScript [3] Go [4] Other ('bring your own' Wasm binary)
このあたりから選べそう。今回はRustで。Cloudflare Workersと同様にRustプロジェクトが生成されるので、同じようにmain.rs
を編集していく。
use fastly::http::header; use fastly::{Error, Request, Response}; use sfen2png_wasm::sfen2png; use urlencoding::decode; #[fastly::main] fn main(req: Request) -> Result<Response, Error> { let decoded = decode(req.get_path())?; let sfen = format!("sfen {}", decoded.strip_prefix('/').expect("invalid path")); Ok(Response::from_body(sfen2png(&sfen)).with_header(header::CONTENT_TYPE, "image/png")) }
Request/Responseなどのインタフェースは worker
とは多少異なるが、おおまかには同じなのでこれくらいの内容であればほぼ同じような感覚で書ける。
あとは fastly compute publish
するだけ。こちらは wasm-pack
なども使わない。
$ curl -I https://sfen2img.edgecompute.app/ HTTP/2 200 content-type: image/png x-served-by: cache-nrt-rjtf7700062-NRT date: Thu, 25 Jan 2024 07:26:11 GMT
その他
Edge Computing系のサービスは他にも多くあるようだが、今回はこれくらいで。 大抵はJSからwasmを呼び出す形でVercel Edge Functionsと同様に動かせる、と予想している。
サイズ制限は厳しい場合が多いので、.wasmが1MBを切る程度には軽量できた方が望ましそうではある…。
まとめ
局面画像生成ライブラリを作ったおかげで、詰将棋画像を投稿するBluesky BotをRustだけで作ることができた。
スクレイピングして得たkifファイルをparseして局面情報を取得する部分も 自作ライブラリ を使っているので、
- kifから
PartialPosition
への変換 PartialPosition
から画像生成- 生成画像を含むPostをBlueskyに投稿
の3つを自作のライブラリを使って実現していることになる。
副産物として、Edgeで動的生成するWeb Appが複数できた。 無料枠でそのまま置いておくので、使える限りはご自由にお使いください。