はじめに
Flutterで「牌をカメラで撮影するだけで点数計算してくれるアプリ」を思い付きましたのでclaudeにお願いするだけで作れるか試してみました。
(リサーチの結果、巷には同じようなアプリは多数存在するようです)
実装はClaude Code(AIコーディングエージェント)と一緒に進めていて、この記事ではどういう設計・実装にしたか、そして実装後にシミュレータ/実機で動作確認してもらった際に何が起きたか(Claudeからの質問と私の回答も含めて)の両方をまとめます。単なる実装ログではなく、AIとのやり取りをそのまま載せることで「AIに設計〜実装〜実機確認まで任せるとどんな感じになるのか」が伝わればと思います。
結論から言いますと
- image_pickerでカメラとギャラリー参照
- TFLiteとYOLOモデル(写真から麻雀牌を検出)で麻雀牌の判定
- その他役判定や点数計算などはFlutterで基本的に実装
という形になりました。
開発順番
claude デスクトップでまずは設計
claude デスクトップで下記設計書を作成してもらいました
プロンプト:
麻雀牌の上がり手牌をカメラで撮って付点計算するアプリをflutterで作成したいです
プロンプト:
この設計をマークダウンで書き出してください
claude code で実装
設計書をペーストして、
プロンプト:
この設計書を元に、麻雀牌の上がり手牌をカメラで撮って付点計算するアプリをflutterで作成してください
とお願いしました。
アプリの概要
麻雀の手牌をカメラで撮影 → 検出結果を確認/修正 → リーチや親子などの付随情報を入力 → 役・翻・符・点数を自動計算、という一連の流れを提供するアプリです。実装は大きく3ステップに分けて進めました。Step1は点数計算エンジン(lib/scoring/)で、手牌の形から役・翻・符・点数を計算する純粋なロジックです。Step2は牌認識(lib/recognition/)で、撮影した写真から牌を検出するYOLOモデル(TFLite)を扱います。Step3+4はUI(lib/ui/)で、上記2つを実際に使えるアプリとして繋ぐ撮影〜結果表示の画面フローです。
lib/
scoring/
tile.dart # 牌1枚の表現(スート・数字・赤ドラ)とパース
meld.dart # 面子(順子・刻子・槓子・対子)
hand_state.dart # 採点対象の手牌状態(門前/副露、リーチ、風、ドラ等)
hand_parser.dart # 14枚の牌列を「上がり形」に分解する
yaku_checker.dart # 分解結果から成立する役を判定
fu_calculator.dart # 符計算
score_table.dart # 翻・符 → 点数のテーブル
scoring_engine.dart # 上記すべてを束ねるエントリーポイント
recognition/
tile_detector.dart # TFLiteモデルの前処理・推論・後処理(NMS含む)
tile_labels.dart # モデルのクラスIDと牌notationの対応表
models/
hand_draft.dart # 画面間で持ち回す「編集中の手牌」状態
ui/
home_screen.dart # 撮影/ギャラリー選択
confirm_screen.dart # 検出結果の一覧・修正・面子タグ付け・上がり牌指定
info_screen.dart # リーチ/ツモロン/親子/場風自風/ドラ表示牌の入力
result_screen.dart # 役・翻・符・点数の表示
tile_picker.dart # 牌選択パレット(共通コンポーネント)
tile_widget.dart # 牌1枚の見た目
設計フェーズ:まずドキュメントを書く
Step3+4に着手する前に、Claudeにまず設計ドキュメントを書いてもらいました(docs/superpowers/specs/配下)。ポイントは、lib/scoring/とlib/recognition/には一切手を加えない(既存のロジックを再利用するだけ)こと、画面間で持ち回す「まだ確定していない手牌」を表すHandDraftという中間モデルを新設し、HandParser.decomposeが使えるHandStateへの変換はHandDraft.toHandState()に閉じ込めること、カメラ撮影はcameraパッケージを使わずimage_pickerでネイティブのカメラ/ギャラリーUIをそのまま使うこと(実装コストを下げるため)の3点でした。
このドキュメント駆動のおかげで、後述するUI実装がブレずに1回のセッションでほぼ通しで書けました。
Step1: 点数計算エンジンの実装
牌の表現とパース
牌はSuit(萬子/筒子/索子/字牌)とnumberのペアで表現し、赤ドラはisRedフラグで別管理しています。標準表記("123456789m11p22s"のような文字列や"0p"で赤5筒など)のパーサーも用意し、テストが書きやすい形にしています。
factory Tile.parse(String notation) { ... }
List<Tile> parseHand(String notation) { ... }
手牌分解(HandParser)
一番のコアは「14枚の牌をどう4面子1雀頭(または七対子・国士無双)に分解できるか」を全パターン探索するHandParser.decomposeです。1つの手牌が複数の分解パターンを持ちうる(例えば同じ牌列が一盃口的に2通りに読める、など)ため、すべての分解パターンを列挙してから、それぞれで役を判定し、一番点数が高いものを採用するという設計にしています。
static WinResult score(HandState state) {
final decompositions = HandParser.decompose(state);
...
WinResult? best;
for (final d in decompositions) {
final yaku = YakuChecker.check(d, state);
if (yaku.isEmpty) continue;
...
if (best == null || scoreResult.total > best.score.total) {
best = WinResult(...);
}
}
...
}
decomposeはバックトラック探索で「対子を1つ確定 → 残りから順子/刻子を再帰的に取り出す」形で全組み合わせを列挙し、七対子・国士無双はそれぞれ専用の判定関数(_tryChiitoitsu / _tryKokushi)で別枠に足しています。待ちの種類(両面・嵌張・辺張・シャンポン・単騎)も、上がり牌がどの面子/雀頭を完成させたかから逆算して判定します(_classifyWait)。
役判定(YakuChecker)
対応している役は、立直・門前清自摸和・断幺九・平和・一盃口・役牌(三元牌/場風/自風)・三色同順・一気通貫・対々和・混全帯幺九・混一色・清一色・七対子・国士無双、そしてドラ/赤ドラ/裏ドラです。HandDecomposition(分解結果)とHandState(リーチ・風・ドラ表示牌などの付随情報)を受け取って、成立した役のリストを返すだけのシンプルな関数です。役がゼロ件なら「上がれない手」として扱われます。
符計算・点数テーブル
符計算(FuCalculator)は面子の種類(順子/刻子/槓子、開/暗)・待ちの形・雀頭の種類から素点を積み上げ、七対子は固定25符、ピンフツモは20符固定(切り上げしない)など、実際のルールの細かい例外もテストで固定しています。点数テーブル(ScoreTable)は翻・符・親子・ツモロンから最終的な点数(満貫/跳満/倍満/三倍満/役満の判定含む)を算出します。
Step2: 牌認識(YOLO + TFLite)
牌検出は、Roboflow上で公開されている麻雀牌検出データセット(38クラス:0m〜9z表記、赤ドラ含む)を使い、ultralyticsのYOLOv8nをファインチューニングしています。
Roboflowからのエクスポート手順(無料アカウント登録は必要でした)
- Riichi Mahjong Detection (https://universe.roboflow.com/riichimahjongdetection/riichi-mahjong-detection)
を開く(0m〜9s表記の38クラス版。ラベル一覧が本アプリのtile.dartの記法と一致するのでこちらを推奨) - 無料アカウントでサインイン(未登録なら要サインアップ)
- データセットページ右上あたりの 「Download Dataset」(または画像一覧の「Export」)をクリック
- フォーマットは YOLOv8(または YOLOv11。ディレクトリ構成はほぼ同じなのでどちらでもOK)を選択
- 「Download zip to computer」を選択("Show download code"はAPIキーが必要になるので不要)
- ダウンロードされたzipを展開し、中身(train/, valid/, test/, data.yaml など)をまるごと
model = YOLO("yolov8n.pt")
model.train(
data=str(DATA_YAML),
epochs=100,
imgsz=640,
batch=16,
patience=20,
device="mps", # Apple Silicon GPU
...
)
学習済みモデルはTFLite形式にエクスポートし、assets/models/tile_detector.tfliteとしてアプリに同梱しています。
Flutter側のTileDetectorは、tflite_flutterで読み込んだモデルに対して、画像を640×640にストレッチリサイズ(Roboflow学習時と同じ、レターボックスなし)してRGB 0-1に正規化し、[1, 4+38, 8400]の生出力(バウンディングボックス4値 + クラスごとのsigmoidスコア、objectness別枠なし)をデコードした上で、クラスごとのNMS(IoU閾値0.45)で重複ボックスを間引く、という前処理・後処理を行い、List<TileDetection>(ラベル・信頼度・元画像座標でのボックス)を返します。後処理部分(decodeOutput)は推論と分離してあるので、実機推論なしで合成データによる単体テストが書けるようになっています。
Step3+4: UI実装
HandDraft — 「編集中の手牌」を表す中間モデル
確認画面〜情報入力画面の間で持ち回す状態は、まだHandStateとして確定していないHandDraftというクラスにまとめています。
class HandDraft {
List<Tile> looseTiles; // 面子タグ付けされていない牌(門前部分)
List<DraftMeld> melds; // ポン/チー/明槓/暗槓としてグループ化された面子
Tile? winningTile;
bool riichi;
WinType winType;
bool isDealer;
Wind roundWind;
Wind seatWind;
List<Tile> doraIndicators;
List<Tile> uraDoraIndicators;
HandState? toHandState() { ... }
}
toHandState()はwinningTileが未選択ならnullを返すだけで、「本当に上がり形として成立するか」のチェックは一切行いません。それは既存のHandParser.decomposeに任せる設計です(Step1のロジックを再実装しない、という設計方針をそのまま反映しています)。
面子タグ付け(ポン/チー/明槓/暗槓)のバリデーションだけはHandDraft側の責務にしていて、validateMeldSelectionが「同じ牌が3/4枚揃っているか」「同色の連続3種か」を見て、Flutter widgetに依存しない純粋関数としてユニットテストしています。
画面遷移
HomeScreen
└─(撮影 or ギャラリー選択; image_picker)─→ 画像取得
└─(TileDetector.detect)─→ ConfirmScreen(初期検出結果)
└─(「次へ」)─→ InfoScreen(HandDraftの続き)
└─(「計算する」)─→ ResultScreen(WinResult) または エラー表示
ConfirmScreenでは、検出結果を元画像からクロップしたサムネイルとして一覧表示し、タップで牌種を差し替え・削除・追加ができます。選択モードをONにすると複数牌をチェックして「ポン/チー/明槓/暗槓」ボタンで面子化でき、validateMeldSelectionが失敗すればSnackBarでエラーを出して状態を変えません。「次へ」を押す前にはHandDraftを仮組みしてHandParser.decomposeを呼び、結果が空なら「牌の組み合わせが正しくありません」と表示して遷移をブロックします。
TileDetector.load()が失敗した場合(アセット破損など)や検出0件の場合でも、空の一覧から手動追加できる形にフォールバックするようにしてあり、認識精度に依存せず最低限アプリとして成立するようにしています。
テスト方針
lib/scoring/・lib/recognition/・lib/models/hand_draft.dartの変換/バリデーションロジックは、Flutter widgetに依存しない純粋ロジックとして切り出し、flutter testでユニットテストしています。ConfirmScreen/InfoScreenの主要操作(タップで差し替え、選択→面子化、エラー表示)はflutter_testのウィジェットテストで検証し、TileDetectorの実推論はテスト対象外としてList<TileDetection>を直接注入する形にしました。実機/シミュレータでの実推論・カメラ連携は自動テスト対象外とし、実装後に実際にアプリを起動して確認する方針を設計ドキュメントに明記しています。
最終的にこの方針で書いたテストは103件、flutter analyzeもクリーンな状態になりました。
$ flutter test
...
00:02 +103: All tests passed!
$ flutter analyze
No issues found! (ran in 2.8s)
ここまでが実装フェーズです。
まとめ
YOLOモデルは一見便利そうでしたが、学習済みの麻雀牌と同じか、かなり似たようなデザインでないと上手く判定してくれない場合も多々ありました(今回はうまく行ったケースのみ掲載していますので、、、)
学習に時間をかければより良いものができるのではないかと思いました!
ちなみにシュミレータを自動的に操作する機能を使用した成果、10分ほどで5時間待つリクエスト上限を超えてしまった場合がありました。
ギャラリーから和了り画像を選んで判定!
夢の国士無双も!
claudeに作成してもらった設計書
麻雀牌カメラ認識・点数計算アプリ 設計ドキュメント
概要
麻雀の上がり手牌をカメラで撮影し、牌を自動認識した上で役・符・点数を計算するFlutterアプリの設計。
- 牌認識: TFLiteによるオンデバイス物体検出(自作モデル)
- ルールセット: 一般的なリーチ麻雀(標準ルール)
牌認識と点数計算は互いに独立したモジュールとして設計し、疎結合を保つ。
---
全体アーキテクチャ
lib/
camera/
capture_screen.dart # camera パッケージで撮影 or ギャラリー選択
recognition/
tile_detector.dart # tflite_flutter で推論実行
tile_labels.dart # 34種+赤ドラのラベル定義
scoring/
tile.dart # 牌モデル(種類・数字・赤ドラフラグ)
hand_parser.dart # 手牌14枚→面子分解の全パターン列挙
yaku_checker.dart # 役判定(標準ルール一式)
fu_calculator.dart # 符計算
score_table.dart # 翻数・符→点数の早見表変換
ui/
confirm_screen.dart # 認識結果の一覧+タップで修正
result_screen.dart # 役・翻・符・点数の表示
models/
hand_state.dart # 立直/ツモロン/親子/ドラ表示牌などの付随情報
---
Step 1: 点数計算エンジン(牌認識と切り離して先に実装)
Flutter/カメラに依存しない純粋なDartロジック。単体テストで固めることを最優先とする。
牌モデル
- 萬子・筒子・索子(1〜9)+字牌(東南西北白發中)
- 赤ドラフラグ
面子分解
- 14枚(または13枚+上がり牌)から「順子3つ+刻子系+雀頭」の全組み合わせを列挙するアルゴリズム
- 七対子・国士無双は別ロジックとして分岐
役判定(標準ルール範囲)
- リーチ・ツモ
- タンヤオ・平和・一盃口
- 役牌(白發中・場風・自風)
- 三色同順・一気通貫・チャンタ
- トイトイ・七対子
- 混一色/清一色
- 国士無双
- ドラ
符計算
- 面子構成(明刻/暗刻/明槓/暗槓)
- 待ちの形(単騎/嵌張/辺張)
- 雀頭の種類
点数早見
- 子/親、ツモ/ロンで点数テーブルを分岐
> このモジュールが完成すれば、認識精度がゼロでも「手動で牌をタップして入力→点数が出る」というMVPが動く状態になる。
牌認識より先にここを固めるのが効率的。
---
Step 2: 牌認識モデルの準備
データセット確保
- 麻雀牌画像のデータセット(Kaggle等で "mahjong tile detection" を検索すると公開データが存在)
- 無い場合は手持ちの牌セットを様々な角度・照明で撮影し、LabelImg等でアノテーション
モデル選定
- 軽量な物体検出モデル(YOLOv5n/YOLOv8n、またはSSD MobileNet)をベースに転移学習
- TFLiteに変換
推論
- tflite_flutter + image パッケージで撮影画像を前処理→推論
- バウンディングボックスごとに牌種類を出力
---
Step 3: 認識結果の確認・修正UI
物体検出は誤検出が起きる前提で設計する。
- 認識結果を一覧表示し、タップで牌を差し替えられるUI
- 牌画像を横一列に並べ、タップで牌選択パレットを表示する形式を想定
- このステップを挟まないと実用に耐えない
---
Step 4: 結合・付随情報の入力
牌認識だけでは判定できない以下の情報は、別途トグル/選択UIで入力してもらい、点数計算エンジンに渡す。
- リーチの有無
- ツモ/ロン
- 親/子
- ドラ表示牌
---
開発の進め方(推奨順序)
1. 点数計算エンジン(Step 1)を単体で完成させ、手動入力ベースのMVPを動かす
2. 牌認識用データセットの確保・アノテーション(Step 2)
3. モデル学習・TFLite変換・推論実装(Step 2)
4. 確認・修正UI(Step 3)
5. 付随情報入力と全体結合(Step 4)
