Rustで将棋の局面画像生成、そしてCDN Edgeで動的生成

背景

先行・類似事例

その他、自分でも過去にGoで作成したものがあったが、もう今はメンテしていなくて動かない。画像素材を公開してくださっていたサイトも消滅してしまっている。

自作のメリット

  • 自分好みの画像を生成できる
    • 形式もまずはPNGで、SVGも後で追加するなど可能かも
  • Rustのプログラムから使えるライブラリとして存在していると嬉しい
    • WASM化してWebアプリ化もしやすいかもしれない

Rustで局面画像生成

基本的には、透過PNGの素材を組み合わせて盤上に駒を配置するだけ。

あとは持ち駒の表現が様々な表示方法があるが、歩は最大18枚なので重ねて並べるのは微妙で、素直に個数を数字としてレンダリングした方が分かりやすいと思う。

盤・駒画像の素材

最近では Electron将棋 のKubo, Ryosukeさんが使いやすい形で素材画像を公開してくださっている。

sunfish-shogi.github.io

ので、これを使わせていただくことにした。 二文字駒もあると嬉しかったが、一時期追加されていたものの諸事情で削除されてしまったようだ。 今後 誰かがオリジナルで作成してここに追加してくれたりするようになるといいな…。

画像処理

image というライブラリが画像処理によく使われているようだ。 PNG以外にも様々な画像形式に対応していて、拡大縮小や重ね合わせなどの基本的な処理も簡単にできる。

作成時にはPNG画像を合成する操作だけなので、もっと軽いライブラリで同様のものが実現できるならそちらの方が良いかもしれない。とりあえずはimageで実装してみた。

入出力

入力は局面の情報を持つものとして shogi_corePartialPosition を使うことにした。出力はimageRgbaImage

ライブラリ使用者は必要に応じて例えば 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")])))
}

JavaScriptdecodeURI同等のものは標準には無いようなので 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 というCLIツールを使う。

$ 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だけで作ることができた。

bsky.app

スクレイピングして得たkifファイルをparseして局面情報を取得する部分も 自作ライブラリ を使っているので、

  • kifから PartialPosition への変換
  • PartialPosition から画像生成
  • 生成画像を含むPostをBlueskyに投稿

の3つを自作のライブラリを使って実現していることになる。

副産物として、Edgeで動的生成するWeb Appが複数できた。 無料枠でそのまま置いておくので、使える限りはご自由にお使いください。

Repository