グラブル編成スクショ切り取りの新実装とかの話

これの自動切り取り機能の実装周りのお話です。

なんで作り直したの?

グラブルの画面ってマジで曲者で、

とかでホンマに自動で切り取るのが難しいんですよね。

旧版では俺が気合いで見つけた、どの解像度でも変わりづらい場所の色を探す泥臭い仕様でした。

ただ、新しく追加された編成画面(ヴェルサシアとか)で背景色が変わったり、特定の属性で誤検知したり問題がちょくちょく発生していました。

改善策として矩形検知モデルとかテンプレートマッチングとか発想自体は合ったけど、検証とか実装にかかる時間を考えるとなかなか手を付けることが出来ず…😞

しかし!先月からお仕事でCalude Codeを使わせてもらっていて、今ならあまり時間をかけずにこの辺りの解決に乗り出せそうだなということで今回作り直しを決行しました。

ボツ案1: 矩形検知 + 整列パターン

二値化して矩形を拾い、その並びから画面種別を当てる案。

グラブルの編成画面を見た時に一番軽く実装できそうかなってので試してみました。

結果、ありえない量の矩形誤検知と候補数が出現…そもそも正解の矩形自体を定量的に二値化するのが難しすぎました。

これ用に検知モデルをトレーニングするか…とかも考えましたが、筋が悪いなというとことでボツ。

ボツ案2: SSRバッジをテンプレートマッチング

武器とか石の右上に必ず付いているコレ!N,R,SR,SSRの4種類をテンプレートマッチングして探す手法です。

バッジが3x3で並んでいたら武器、2x2で並んでいたら石、みたいな感じで振り分けられそうじゃないですか!

これなら、武器でも石でも同じものを探すので速そうじゃないですか?

実際それなりにうまくいきましたが

  1. Emptyがある場合、整列パターンが崩れる
  2. 思ったよりエッジが薄い
  3. メイン武器とかメイン石だけちょっと違う

などの理由からスケールを安定して特定できない問題に直面…

惜しくはあるもののこちらの案もボツになりました。

採用案: 装飾プレートをテンプレートマッチング

日本語版でも英語版でも変わらず存在していて、どの種類の編成画面でも変わらない部分がありました。

それがこいつらです。

あとは「Additional Weapons」とか「DUAL BLADE」とかをテンプレートマッチングで探索。

これでゲーム画面の正確な座標とスケールを特定するというものです。

座標とスケールさえわかってしまえば、あとは画面のバリエーションごとに決まった範囲を切り出すだけです。(スケール×定数+座標offset)

高速化とか

ここについては発想や並列化などの指示は出しましたが、Claude Codeくんが頑張ってくれました。

詳しくは今回の流れをClaude Codeくんがまとめてくれているのでそちらを見ていただければと思います。

Claude Codeの説明

# gbf-image の画面検出

グラブルの編成スクショから必要な部分を切り取るツール。その検出まわりの仕様と、そこに至るまでの検証の記録。

## 作り直した理由

旧版は「決め打ちの座標の画素が何色か」で画面を判定していた。属性やUIの更新で壊れ続け、手元のサンプル13枚のうち**3枚で破綻**していた(基準点の検出に失敗し、幅19501px の矩形を出す)。

色と座標を捨て、**画面に必ずある固定の意匠をテンプレートマッチングで探す**方式に変えた。

## 方針が決まるまで

### 却下1: 矩形検知 + 整列パターン

二値化して矩形を拾い、その並びから画面種別を当てる案。最初に試した。

駄目だったのは**スケールが自分で決まらない**から。グラブルのUIは入れ子で自己相似(枠の中に枠、カードの中にカード)なので、「同じ比率の矩形が同じ間隔で並ぶ」構造がどの倍率でも成立してしまう。基準になる長さが取れない。

### 却下2: カード右上のバッジをスケール源にする

バッジ(レアリティの意匠)は1画面に何十個もあるので、拾えば格子が組めると考えた。スケールに対するスコアの鋭さを実測したら、**ピークが平坦なうえ偽ピークが出る**。summon-001 で破綻。

### 採用: 装飾プレート

「Main Weapon」等の見出しに付いている装飾プレート。1画面に1つしかない代わりに、**ピークが鋭く単一**| | スコアのピーク | スケール源として |
|---|---|---|
| バッジ | 平坦・偽ピークあり ||
| プレート | 鋭い・単一 ||

プレートを見つけた時点で**位置・スケール・画面種別**が同時に確定する。バッジは「本当にそのスケールでカードが並んでいるか」の裏取りに降格した。

## パイプライン

```
1. プレート探索
     MAIN_WEAPON / MAIN_SUMMON を先に、外れたら DUAL_BLADE / CHARA_MAIN
     → 位置・スケール・画面種別・二刀流が確定
2. スケールの追い込み(局所走査 + 放物線補間)
3. ADDITIONAL プレートを確定スケールで1回照合 → アディショナル武器の有無
4. 編成切り替えなら CHARA_SUB も探し、Main との位置関係で row / stacked を判別
5. カード右上コーナーで裏取り
6. プレート位置を原点にした定数から切り取り矩形を算出
```

プレートが見つからなければ作業解像度を **512 → 1024 → 2048** と上げて再試行する。ゲームがスクショ幅の25%程度しか占めない場合に必要になる。

テンプレートは9ファイル41KB(`public/gbf-image/tpl/`)。実際に使っている6枚。

![探すテンプレート](./gbf-image-plates.png)

プレート3種は 62x62 の正方形に切り出してある。こうすると画面上で同じ大きさになり、スケールの走査範囲を3種で共有できる(図で文字が切れているのはそのため)。残る3枚はカード右上のコーナーで、裏取りにだけ使う(画質 low / mid / normal の3種)。

## 使っているもの

**検出まわりの外部ライブラリはゼロ。** OpenCV.js も TensorFlow.js も入れていない。`core/``worker/` の import は自分のファイルだけで、npm パッケージを一つも参照していない。

当初は「軽い物体検知モデルを載せる」つもりだったが、探すものが**固定の意匠1種類**だと分かった時点で学習モデルは要らなくなった。テンプレートマッチングで足りる。

使っているのは標準のブラウザAPIだけ。

| | |
|---|---|
| `Float32Array` / `Float64Array` / `Int32Array` | 画素・積分画像・オフセット表。数値計算はすべて型付き配列 |
| `OffscreenCanvas` + `getImageData` | 画素の取り出しとテンプレートの縮小 |
| `createImageBitmap` | 画像のデコード(ワーカーへも渡せる) |
| `Web Worker` | 走査の並列化。`navigator.hardwareConcurrency` で本数を決める |

追加のJSペイロードは無し。増えるのはテンプレート画像 **41KB**(PNG 9枚)だけ。

`OffscreenCanvas` だけは対応環境の制約があり(iOS Safari 16.4 以降)、起動時に見て非対応なら明示的に案内する。

プロトタイプ(Node)だけは別で、画像の入出力に **sharp**、パリティ検証用のバンドルに **esbuild** を使っている。どちらも検出処理そのものには関与しない。

## マッチャ

マスク付き正規化相互相関(NCC)。マスクはテンプレートのアルファから作る(プレートの外形が矩形ではないため)。

### 事前棄却

勾配エネルギーを積分画像に載せ、O(1) の箱和でテンプレートとかけ離れた位置を先に捨てる。ただしゲーム画面は全体が高精細なので**2割程度しか落ちない**。効果は限定的。

### 2段ピラミッド

半分の解像度で粗探索し、原寸で候補周辺だけ精査する。

単純にストライドを飛ばす方式は使えない。NCC は 1px ずれるとスコアが急落するので真のピークを飛ばす。閾値を下げて補うと候補が爆発して元より遅くなる。**縮小するとスコア曲面が平滑化される**ので、粗探索でもピークを見逃さない。

### 閾値

真の一致のマージンは **0.30〜0.45**、誤検出は **0.04** 程度。間が大きく空くので閾値設定は楽。

ただしテンプレートが小さいほど偶然の一致が起きやすい。NCC の偶然一致は画素数の平方根に反比例するので、閾値をマスク画素数に連動させている。

```
閾値 = 0.45 + 1.8 / √n     (n = マスク画素数)
```

### スケールの追い込み

走査は 24〜62px を 1.09 倍刻みで **12段**。NCC のスケール許容幅が ±7% 程度なので、これより細かくする必要がある。

刻みのままだと最大 4% の誤差が残る。切り取り範囲はプレート幅の約10倍あるので、それが数十pxのずれになる。ピーク周辺3点に放物線を当て、**対数空間で内挿**してサブステップまで詰めている。

## 切り取り

矩形はプレートの検出位置を原点、**プレート幅を単位**とした定数で表す。スケールが確定していれば掛けるだけで出る。

2モードある。

| | |
|---|---|
| `padded` | セクションのパネル枠まで含める。装飾枠が切れないので見栄えがよい |
| `tight` | 中身だけ。必要な情報が入る最小の範囲 |

定数は実測。`padded` は境界を可視化しながらパネル枠に合わせ、`tight`**正解の切り取り画像から逆算**した(専用のスクリプトを書いた)。

## 高速化

効いた順。合計 **10.5倍**(2813ms → 267ms)。いずれも検出結果・切り取り結果が完全に不変であることを確認済み。

| | 効果 | |
|---|---|---|
| 候補上限を 3000 → 150 | 3.2倍 | 精査(候補 × 25位置 × 500サンプル)が支配的だった |
| 粗探索のテンプレート画素を 100 に | 1.5倍 | 候補上限を先に絞らないと粗探索が甘くなって逆効果。**順序が重要** |
| 確信できる一致で残りを打ち切り | 1.2倍 | 逐次版のみ |
| ワーカー8並列 | 2.3倍 | 618ms → 267ms |

並列版は打ち切りをやめ、常に4プレート × 12段を全部走らせる。総CPU作業量は約2倍になるが、**実時間が画面種別によらず一定**になり、最悪ケース(編成切り替え 1239ms)が大きく縮む。スケール追い込みの5段も並列に投げている。

| ワーカー数 | 時間 | 逐次比 |
|---|---|---|
| 逐次 | 618ms | 1.00 |
| 2 | 551ms | 1.13 |
| 4 | 367ms | 1.68 |
| 6 | 344ms | 1.82 |
| 8 | 267ms | 2.31 |

4を超えると鈍る。ブラウザでは `navigator.hardwareConcurrency` を見て 4〜8 に決めるのが妥当。

## 効かなかったもの

- **単純なストライド間引き** — 上述のとおり真のピークを飛ばす
- **色による事前棄却** — 検出率が落ちて速度も改善せず
- **ピラミッドの3層以上** — 走査は1.9倍速くなるが、粗い層で決まる位置が1pxずれ、そこでのスケールのピークも変わる。最終的なスケール推定が1〜3%ずれて切り取りサイズが合わない。追い込みの窓を広げても回復しない(位置がずれているため)
- **内側ループのラン(連続区間)化** — JSでは約14%遅い。V8は型付き配列の間接参照を十分速く扱うので、ラン管理の分だけ損。ただしSIMD化するなら gather 命令が無いので必須になる

## 測って、成り立たなかった仮説

**「解像度を落とすと NCC のスケール許容幅が広がる」は誤り。**
作業幅512(プレート49px)で ±6〜8%、作業幅256(プレート25px)でも ±8% とほぼ同じ。幾何的なズレは相対量なので、ぼかしても縮まらない。低解像度にしてもスケールの走査段数は減らせない。

**「画面全体の色相で探索するプレートを事前に絞れる」は誤り。**
召喚石だけは分離できる(赤系が47%以上)が、武器と編成切り替えが分離できない。武器でもシアン帯が56%になる個体があり、編成切り替えの範囲に完全に重なる。合成画像では背景色に引きずられて判定不能。旧版が壊れ続けた色ベース判定と同じ失敗。

## ブラウザ移植

プロトタイプは Node(`worker_threads` + sharp)、本番は Web Worker + OffscreenCanvas。別実装だが、パリティ検証スクリプトで**完全一致**を確認した。

> 10サンプルで スケール差 0.00 / 位置差 0.0 / 切り取り矩形差 0px

画像は1枚につき1回だけワーカーへ送り、半分解像度と積分画像は各ワーカーが自分で作る(転送量削減)。

## 手動調整のグリッド検出

検出できなかったときや微調整のために、切り取り枠を手で動かす画面を用意した。辺を画面内の区切り線に吸着させたい。

最初は**軸ごとに明るさの差を平均**して山を探したが、拾えない。カード1枚ぶんの短い縁が、残りの領域に薄められて閾値に届かないため。画面いっぱいに伸びた線しか出なかった。

**一定の長さ続いている段差を探し、それを端まで伸ばす**方式に変えた。

1. 隣接画素の明るさの差を求め、画像ごとに平均+標準偏差で「段差あり」の閾値を決める
2. 列ごとに、段差が縦に連続している最長区間を測る(3pxまでの途切れは継続扱い。アイコンや文字で切れる縁を拾うため)
3. その長さが縦の5%以上なら線として採用
4. 長い順に取り、3px以内の重複は統合。1軸240本まで

検出済みの正解矩形と突き合わせた結果。

```
weapon-additional-001  628x768   縦30本 横89本
  rect(97,136,403,311)   左+2 右+5 上+1 下-4
  rect(97,461,403,104)   左+2 右+5 上+1 下-5
summon-001             503x555   縦42本 横80本
  rect(57,74,393,300)    左+1 右+1 上+0 下+3
  rect(57,397,393,93)    左+1 右+1 上+5 下+0
```

各辺の 0〜5px 以内に線がある。吸着距離は画面上7pxなので、全体表示でも届き、拡大するほど細かく合わせられる。

## 残っている課題

- **WebAssembly + SIMD 化** — さらに約2倍の見込み。内側ループをラン方式に書き換える必要があり、それ単体ではJSで14%遅くなる。Rust ツールチェーンが前提。並列化で目標に届いたので優先度は低い
- **編成切り替えの `padded` モード** — 正解画像がもう1組あれば逆算で確定できる。いまは `tight` と同値

感想など

今回はコードを一切書いていません。

  1. 仮説を立てサンプルを集めて検証を指示&改善
  2. 採用案の高速化手法を壁打ち&検証を指示
  3. ブラウザ環境に移植&アプリの機能設計を伝えて実装を指示

大まかにこんな流れで作りました。

全べてOpus 5のEffort(Max)で走り抜けました。

時間としては5,6時間といったところでしょうか。

全て自分で書いていたらおそらく1週間はかかっていたでしょうね。

とんでもない時代になりましたね。

コーディングとかの技術はほぼ不要になり、必要なのは「実現したいことを詳細に言語化する力」って感じになってますね。

今までの開発で培ってきた機能設計とか、何が実現可能だとか、解決策や方向性が正しいかの直感的な判断だとか、そういうところが響いてくるなって感じはあります。(これもそのうち不要になるだろうけど)

今までも完成物や処理の内容を思い描くところまでは、さほど時間はかかっていませんでした。

ただ「いや…この実装は重いから…やめとくか」みたいな、出力時(コーディング)の負荷を考えて断念することが結構ありました。

でも今はコード側の設計なども含めて丸投げできます。しかも一瞬で終わると来た。

こうなったらもう俺は止まりません。無限ラリーです。

AIから成果物を受け取り、それについて脳から溢れてくる発想や処理内容をひたすら打ち返す…するとどうでしょう。

ありえないスピードで今まで諦めていた機能などまで付いた「俺の考えた最強のアプリケーション」ができるわけです。

面白いですね。俺はもう…止まれない…🕺

たぶん今が一番面白い時期なんだと思います。

完全に人間が不要になるその日まで…俺はこのラリーを楽しもうと思います。

ありがとうAI、さようならコーディング。

好きだったけど嫌いだったよ。今までありがとう。