白目まで透けて泣いた僕が、GoでLINEスタンプ制作を全自動化した話
LINEスタンプを作るとき、絵を描くのは楽しい。でもそのあとが地獄だった。背景を透過して、文字を入れて、サイズを揃えて、規定に合ってるか確認して、ZIPにまとめる。24枚+メイン+タブを手作業でやると、絵を描いた時間より加工の時間のほうが長くなる。耐えられなくなって、この単純作業を全部やってくれるCLIツールをGoで書いた。`stamp-tool` という名前にした。背景透過の「目が透ける問題」とか、文字のはみ出しとか、ハマりどころが多かったので記録しておく。
とりあえず4つのサブコマンドに分けた
最初から大きく作る気はなくて、自分の作業手順をそのままコマンドに割っただけ。フォルダを切る `init`、画像を加工する `process`、規定チェックの `validate`、申請用ZIPを吐く `zip`。普段やってる流れを写しただけだから迷わなかった。
stamp-tool init myset # フォルダと config.json を作る
stamp-tool process myset # raw画像 → LINE仕様の透過PNGへ
stamp-tool validate myset # 偶数サイズ・サイズ上限などを検査
stamp-tool zip myset # set_01.zip を書き出す
入力は画像生成AIで作った白背景のキャラ素材。これを `raw/` に置いて `process` を回すと、透過・文字入れ・サイズ調整まで終わった状態で `out/` に並ぶ。LINEの仕様を毎回手で確認しなくていいのが、思っていた以上に効いた。
「白を透明にする」だけだと目が消える
最初に書いた透過処理がひどかった。白いピクセルを片っ端から透明にしたら、キャラの白目も、白い服も、歯も、全部透けた。トーク画面で見ると目玉に穴が空いてて完全にホラー。LINEの審査でも背景の抜き残しや変な透過はリジェクト対象になるので、これは直さないとどうにもならなかった。
外側からBFSで「地続きの白」だけ抜く
解決のヒントは単純で、背景の白は必ず画像の端に接しているということ。逆に言うと、キャラの内側にある白は端と繋がっていない。だから画像の四隅・外周から探索を始めて、隣り合う白ピクセルだけを辿っていけば、背景だけを抜ける。いわゆるFlood Fillで、幅優先探索(BFS)で外周から内側へ「白の地続き」を広げていくイメージ。
しきい値はRGBがそれぞれ240/255以上なら白とみなす形にした。Goの `image` だと色は16bitで返ってくるので、240/255に当たる `61680` を境にしている。`visited` フラグで一度見たピクセルを管理して、キューで繋がりを辿るだけ。
// 外周をキューに積んで、地続きの白だけ透明化する
const threshold = 61680 // 240/255 を16bitにした値
for !queue.empty() {
p := queue.pop()
if visited[p] { continue }
visited[p] = true
r, g, b, _ := src.At(p.X, p.Y).RGBA()
if r < threshold || g < threshold || b < threshold {
continue // 白じゃない=キャラの輪郭。ここで止まる
}
dst.Set(p.X, p.Y, color.Transparent)
queue.pushNeighbors(p) // 上下左右へ伝播
}
これでキャラの内側の白目は端と繋がっていないので残る。輪郭線で囲まれている絵柄なら、線がストッパーになってキレイに背景だけ抜ける。逆に輪郭が途切れてると白がキャラ内部までダダ漏れするので、素材側の線はちゃんと閉じておいたほうがいい、というのを身をもって学んだ。
長いセリフが枠からはみ出す
次に詰まったのが文字入れ。スタンプによってセリフの長さがバラバラで、「OK」みたいな短いやつはいいけど、「了解しました!」みたいに長いと横にはみ出る。LINEのスタンプ画像は最大で横370px・縦320pxと決まっているので、ここを超えるわけにいかない。
描画には `github.com/fogleman/gg` を使った。ありがたいことに、描く前に文字のピクセル幅を測れる。なので一回ダミーで測って、370pxに収まらなければフォントを縮める比率を計算して当てる、という形にした。
w, _ := dc.MeasureString(text) // 描画前に幅を測る
if w > maxWidth {
fontSize *= maxWidth / w // はみ出したぶんだけ縮小
dc.LoadFontFace(fontPath, fontSize)
}
さらに、文字が長いときはキャンバスの幅自体も広げるようにした。キャラの幅と文字の幅、大きいほうを基準にキャンバスを決める。ここで一個落とし穴があって、LINEのスタンプは自動で縮小される都合上、画像サイズが偶数じゃないとダメ。奇数で吐くと地味に弾かれる。なので最後に必ず偶数へ丸める処理を挟んでいる。
どんな背景でも読めるように縁取りを二重にした
LINEのトーク画面って、白背景の人もいればダークモードの人もいるし、パステルの壁紙にしてる人もいる。白文字だけだと明るい背景で消えるし、黒文字だけだと暗い背景で沈む。最終的に「白文字+黒の内フチ+黄色の外フチ」の二重縁取りに落ち着いた。これだと大抵の背景で文字が浮き上がる。
やってることは力技で、文字を少しずつ全方向にずらして描いて縁を作る。`gg` の `DrawStringAnchored` で、角度を変えながら16方向くらいに置いていく。先に外フチ(黄)を太めに、その上に内フチ(黒)、最後に本体(白)を重ねる順番が大事だった。フチ幅はフォントサイズに対する比率で出していて、内フチが約8%、外フチが約15%くらいにしている。
感情を足す漫画エフェクトの合成
焦りの汗とか、怒りマークとか、漫画的なエフェクトを乗せたくなる。これは `config.json` にエフェクト素材のどこを使うか座標とサイズで書いておいて、`image.Rectangle` と `SubImage` で必要なパーツだけ切り出して合成する形にした。キャラの大きさに合わせてアスペクト比を保ったまま拡縮して、右上あたりに置く。設定で位置を変えられるようにしたおかげで、同じ汗素材を使い回せて楽だった。
最後は機械にダメ出しさせる
人間の目視チェックが一番信用できないので、`validate` で機械的に弾くようにした。チェックしてるのはこのあたり。
| 項目 | 規定 |
|---|---|
| スタンプ画像の最大サイズ | 横370 × 縦320px 以内 |
| 画像サイズ | 偶数ピクセル |
| ファイルサイズ | 1枚あたり1MB以下 |
| 形式・透過 | PNG・背景透過 |
メイン画像は240×240、タブ(タグ)画像は96×74と別サイズなので、ここも `process` で一緒に生成して `validate` にかける。全部パスしたら `zip` が申請用のパッケージを吐いて終わり。検証で落ちた項目はログに出るので、何を直せばいいか一目でわかる。手作業で1枚ずつプロパティを見ていた頃と比べると、別世界だった。
まとめ
- 背景透過は「白を全部消す」じゃなく、外周からのBFS(Flood Fill)で地続きの白だけ抜くと白目が守れる
- 文字のはみ出しは `MeasureString` で事前測定→フォント縮小&キャンバス幅拡張で対応。サイズは必ず偶数に丸める
- 二重縁取り(白+黒+黄)にすると、ダークモードでもパステル背景でも文字が読める
- `validate` で最大370×320・偶数・1MB以下・透過を機械チェックしてからZIP化すると、審査前のミスが激減した