men100のアイコン画像
men100 2024年01月30日作成 (2024年03月03日更新) © MIT
製作品 製作品 閲覧数 241
men100 2024年01月30日作成 (2024年03月03日更新) © MIT 製作品 製作品 閲覧数 241

[Spresense] 麻雀カードの点数計算機

[Spresense] 麻雀カードの点数計算機

概要

概要

今回は Spresense を使って麻雀カードの点数計算機を製作しました。
このハードウェアにより、下記を実現します。

  • 麻雀カードを読み取り、どの牌かを認識する
  • 認識された牌によって役ができていれば、役の名前と点数をスピーカーから読み上げる
  • LCD には検出した牌を表示する他、役や点数など各種情報を表示する

どんな動きをするかが分かる動画を用意しましたので、ぜひご覧ください。

ここに動画が表示されます

製作のきっかけ

製作のきっかけ

今回の作品の製作のきっかけは妻の一言からでした。
妻は家族団らんの一つとして、家族で麻雀を打ちたいそうなのです。
まだ、子どもが小さいので難しいですが、ゆくゆくは実現したいのとのこと。

そこで問題になってくるのが点数計算です。
麻雀はルールや役については比較的シンプルなのですが、点数計算がちょっと厄介です(特に私にとっては)
そのためルールや役については分かるけど、点数計算は分からない。
でも友達と打つときは他に計算してくれる人がいるからいいや・・・という人たちがいます。
はい、私がその一人です。

肝心の妻はそもそも麻雀自体やったことない人なので、点数計算も私の役目でしょう(たぶん
そこで、今回その麻雀の点数計算機を作ってみよう!と思い立ちました。

説明書

ここでは麻雀カードの点数計算機の使い方について説明します。

電源投入

まずは電源を入れます。

電源投入

初期化が済むと LCD にカメラ映像とメニューが出てきます。

カードをセット

カードホルダーに点数計算をしたいカードをセットします。
麻雀の基本形である 14 枚をセットします (雀頭2 + 面子3 x 4 = 14)

カードをセット

メニュー画面

起動後の画面について説明します。

メニュー

画面の大部分はカメラ画像になります。
カードを置くべきガイド枠が表示されておりますので、カードホルダーや Spresense カメラボードの位置を調整し、各カードがガイド枠に収まるようにしてください。
今回は14枚目のカードを "上がり牌" (役が完成し、プレイヤーが上がったときの牌) とし、そこの枠は青で表示するようにしています。

下の白い四角群はカードでは判定できないコンテキストを設定するボタンと、推論と計算を実行するボタンになります。
タッチペンでそのボタンを押すことで値が切り替わります。

  • 左から1番目のボタンで自分が ”親" か "子" かを設定
  • 左から2番目のボタンで "ツモ" で上がったか、"ロン" で上がったかを設定
  • 左から3番目のボタンで場風を設定 (東南西北)
  • 左から4番目のボタンで自風を設定 (東南西北)
  • 左から5番目のボタンで何本場かを設定 (最大8本場までとした)
  • 左から6番目のボタンでドラを設定 (最大ドラ8までとした)
  • 左から7番目のボタンで推論と計算の実行に移行します

推論・計算中の画面

推論・計算中の画面について説明します。

推論・計算中の画面

この画面が出ている間はお待ち下さい。
操作できることはありません。
処理が終わると、結果表示画面を描画します。

結果表示画面

結果表示画面について説明します。

結果表示画面

ガイド枠があったところには推論の結果、どの牌と認識したかを画像で表示します。
ボタンがあったところには結果として認識した手牌 (一連のカード)、役、ドラ、翻、符、ランク (満貫以上なら)、点数をテキストで表示します。
点数については "ツモ" 上がりの場合はそれぞれ何点払うべきかも表示します。

エラーの場合は認識した手牌とエラーであることのみを表示します。

ここで画面にタッチすると状態をリセットし、初期画面に戻ります。
認識結果が異なる場合は位置を調整の上、再び判定にかけることも可能です。

読み上げ

結果表示画面になったと同時に、スピーカーから合成音声で役とランク (満貫以上なら)、点数を読み上げます。

エラーの場合はエラーである旨のみ読み上げます。

百聞は一見に如かず、ということで一連の操作動画をご覧ください。
(概要に貼ったものと同じものです)

ここに動画が表示されます

なぜ麻雀カード?

麻雀カード

タイトルを見て、少しでも麻雀に詳しい方なら「なんで牌にしないんだ?」と思われたかもしれません。
それに対する回答は明快で「麻雀カードしかなかったから」です。

今回の麻雀カードは妻の麻雀学習用に買ったもので、それとは別に麻雀牌も一応所持しておりました。
ただ、引っ越しなど経て行方不明になってしまいました・・・(今も見つかっておりません

しかし、製作を通してこの紙の麻雀カード、取り回しはしやすいし絵も大きいし、良いチョイスだったなあと思っています。
麻雀牌を採用していたらもっと苦労していた気がします。

本作品の制限

本作品には一部制限があります。
麻雀を知らない人にはちんぷんかんぷんかもしれませんが、以下には非対応となっています。

  1. 一部の特殊役
    • リーチ、ダブルリーチ、一発、チャンカン、リンシャン、ハイテイ、天和、地和
  2. 副露 (フーロ)
    • チー・ポン・暗槓・明槓
  3. 流し満貫

1 については UI/UX 上、非対応として落としたものです。UI/UX の再構築により、こちらは比較的実現が容易です。
2 は現状の製品の仕様として実現できないものです。課題はありますが、アイデアはありますので、課題のところで述べようと思います。
3 については捨て牌によって成立する役になりますので、今後も対応する予定はありません。

部品について

まずは全体から見た各部品を示します。

全体から見た部品

部品名 説明・補足など
麻雀カード 今回使用したのは、ビバリーから発売されている「マスター麻雀カード」です。
麻雀カードホルダー 麻雀カードを収納するのに使用します。CAD で設計し、3Dプリンターで印刷したものを使用しています。
ディスプレイ(タッチ機能付き) 製品名は MSP2807。ドライバに ILI9341 互換のものを使っているなら他でも OK。タッチスクリーンの機能を持ったものが必要で、こちらはドライバに XPT2046 互換であることが必要です。
タッチペン MSP2807 に付属しているものを使用。抵抗膜方式のタッチスクリーンなので、持っていなければ指でも押せます (細かいところは押しづらいけど)
モバイルバッテリー パナソニック QE-PL201 を使用しています。低電力モードに対応したものを選びましょう。そうでないと Spresense が低消費電力のため、バッテリーが出力を停止してしまいます。
スピーカー 今回使用したのはダイソーで購入したもの。3.5mmステレオミニプラグのものなら他のスピーカーでも OK です。

次に Spresense 周りの各部品を示します。

Spresense 周りの部品

部品名 説明・補足など
Spresense メインボード -
Spresense 拡張ボード -
Spresense カメラボード 麻雀カードの画像をキャプチャするのに使用します。
カメラサポート カメラを Spresense よりも高い位置に固定するのに使用します。CAD で設計し、3Dプリンターで印刷したものを使用しています。
SD カード 東芝製の micro SD カード(4GB)を使用。麻雀カードの認識の学習済みのモデルデータ、読み上げのための音声データ(mp3 フォーマット)、麻雀牌画像(rgb565 フォーマット)を格納するのに使用します。他のメーカーのものでも問題ないと思います。容量も4GBも不要で、128MBくらいでも十分です
基板ケース カメラサポートの固定に使用します。CAD で設計し、3Dプリンターで印刷したものを使用しています。

配線について

Spresense メインボードとモバイルバッテリーとの接続

micro USB オス-USB オスのケーブルで Spresense メインボードとモバイルバッテリーを接続します。
(Spresense 側は micro USB のメスであることに注意)

Spresense 拡張ボードとスピーカーとの接続

Spresense 拡張ボード搭載の 3.5mm ステレオジャックにスピーカーのプラグを接続します。

Spresense 拡張ボードとディスプレイとの接続

Spresense はディスプレイとは SPI 通信によって画面の表示、及びタッチスクリーンの座標を取得します。
ピンの対応としては下記のようになります。

ディスプレイ Spresense 拡張ボード
T_IRQ 接続しない
T_DO D12
T_DIN D11
T_CS D7
T_CLK D13
SDO(MISO) D12
LED 3.3V
SCK D13
SDI(MOSI) D11
DC/RS D9
RESET D8
CS D10
GND GND
VCC 3.3V

ケーブル自体はノンブランドのジャンパーワイヤーケーブルを使用しています。

Spresense メインボードと Spresense カメラボードとの接続

Spresense カメラボードに付属しているフラットケーブルだと長さが足りず、カメラサポートに取り付けることができません。
そのため、今回は長めのフラットケーブルであるモレックスの "0150200213" を使って Spresense メインボードと Spresense カメラボードを接続しています。

製作のポイント

ここでは製作を通して頑張ったところ、苦労したところなどを延べます。

麻雀の点数計算ライブラリ作成

当然(?)ながら Arduino で使える計算ライブラリなどは無かったので、今回作ってみました。
せっかくなので Arduino ライブラリーにも登録してみました。

MJScore

GitHub のアドレスはこちら。
https://github.com/men100/MJScore

ニューラルネットワーク用データの作成

ニューラルネットワーク用の学習データ、検証データは解像度 42x60 のグレースケール Bitmap とすることにしました。
下記のようにカメラのキャプチャ画像をグレースケールに変換したものになります。

入力データ例

データラベルの種類は全部で 35 種類になります。

  • 萬子 (ワンズ) 牌の 1 ~ 9: 9種
  • 筒子 (ピンズ) 牌の 1 ~ 9: 9種
  • 索子 (ソーズ) 牌の 1 ~ 9: 9種
  • 風牌 (東南西北): 4種
  • 役牌 (白・發・中): 3種
  • 何も置いていない状態: 1種

次項でも述べますが、学習当時は正解率が芳しくありませんでした。正解率は 50 % くらいでしょうか。
対策としては「データを増やす」でした。
そのため結果的に 1,600 枚ほど撮影しました。
(学習データ 1,100 枚、検証データ 500 枚)

時間が取られる作業のため、カードホルダーをフル活用して一度に 15枚撮れるようにし、スクリプトを用意しファイルのリネームやデータセット定義ファイル作成は自動で行うようにしました。

麻雀カード認識用学習済モデルの作成

Neural Network Console を使って、麻雀カード認識用学習済モデルの作成にチャレンジしました。
最終的なニューラルネットワークは下記のようになりました。

ニューラルネットワーク

6枚程度のカードを認識するプロトタイプのときは、データが少なくても結果は良好でした。

プロトタイプ

ただ麻雀カードすべてに拡大すると誤認識が増えてしまいました。
対策としてデータ数を増やすことで、結果的に正解率が 98% まで向上しました。

学習曲線と正解率

C ランタイムのデータサイズとしては比較的シンプルな構成のニューラルネットワークですが、入力層が 60x42 で、出力が 35 もあるため、163KB にもなってしまいました。

タッチスクリーンのキャリブレーション

Arduino で使えるタッチスクリーン用のライブラリは沢山あり、手元でもすぐタッチ座標の値を取得することはできました。
ただ、画面の取る値とタッチスクリーンからの値のマッチング(いわゆるキャリブレーション)が必要でした。
これに関してはあまり情報が無い模様で、自己流キャリブレーションしてみました。
そのアルゴリズムは以下になります。

  • 4隅のタッチ座標を取得
  • 2点の平均値を取る
  • ディスプレイの解像度(幅)を "右端のタッチ x 座標" と "左端のタッチ x 座標" の差で割る
    • これが x 軸の scale になる (scaleX)
  • ディスプレイの解像度(高さ)を "下端のタッチ y 座標" と "上端のタッチ y 座標" の差で割る
    • これが y 軸の scale になる (scaleY)
  • "左端のタッチ x 座標" が x 軸の offset になる (offsetX)
  • "右端のタッチ y 座標" の y 軸の offset になる (offsetY)
  • (タッチ x 座標 - offsetX) * scaleX がディスプレイの x 座標となる
  • (タッチ y 座標 - offsetY) * scaleY がディスプレイの y 座標となる

キャリブレーションプログラムも作成し、これで取得したパラメータを本作品でも使っています。

キャリブレーションプログラムはこちら。
https://github.com/men100/arduino/tree/main/Spresense_XPT2046_calibration

役とスコアの読み上げ

せっかくのエンタメ作品なため、役とスコアを読み上げてほしいと思いました。
そこで今回は Open JTalk を利用して、合成音声を作成しました。
Python からサクッと使えたのが良かったです。

合成音声は wav で保存し、それを ffmepg で mp3 に変換しています。
ffmpeg で ab オプションでビットレートを指定しておかないと Spresense 側で正しく再生できないのにはちょっとハマりました。

牌画像の表示

認識結果として表示している牌画像については gif -> bmp -> rgb565 と変換しディスプレイに表示しています。

gif から bmp については画像編集ソフト、bmp から rgb565 は ffmpeg で変換しました。

表示するところなのですが、Adafruit_GFX の drawRGBBitmap 関数を複数読み込むと画面が荒れるという不具合?に遭遇。
1度だけなら呼び出しても問題なかったので、カメラ画像をキャプチャしたら、そのバッファに牌画像も書き込み、
最後に drawRGBBitmap 関数を呼び出すような対応をしました。

ソースコード

最新のものは以下にあります。

https://github.com/men100/arduino/tree/main/MahjongCardScore

mcs_app.ino

/* メモリは 1024KB 確保すること Use Adafruit-GFX-Library 1.5.6 https://github.com/kzhioki/Adafruit-GFX-Library/archive/spresense.zip Use Adafruit-ILI9341 1.5.1 https://github.com/kzhioki/Adafruit_ILI9341/archive/spresense.zip */ #include <queue> #include <stdio.h> #include <string.h> #include <Camera.h> #include <SPI.h> #include <SDHCI.h> #include <Adafruit_ILI9341.h> #include "define.h" #include "util.h" #include "TouchScreen.h" #include "CardRecognizer.h" #include "MJScoreHandler.h" #include "Sound.h" Adafruit_ILI9341 tft = Adafruit_ILI9341(&SPI, TFT_DC_PIN, TFT_CS_PIN, TFT_RST_PIN); TouchScreen ts; CardRecognizer cr; MJScoreHandler mjs; SDClass sd; int mjsIndexes[CARD_NUM]; bool mjsYakuTable[60]; std::queue<String> playQueue; enum State { Menu, Wait_Inference, Result }; int state = State::Menu; bool needUpdate = true; // メニュー領域 const Rect menuArea = { 0, 208, 320, 32 }; // Adafruit_ILI9341 ライブラリのデフォルトフォントの幅、高さ (pixel) #define TEXT_SIZE_BASE_WIDTH 6 #define TEXT_SIZE_BASE_HEIGHT 7 #define BUTTON_NUM 7 #define BUTTON_WIDTH 50 #define BUTTON_HEIGHT 32 const Rect buttonLocations[BUTTON_NUM] = { { 0, 208, 30, 32 }, // 親 or 子 { 35, 208, 40, 32 }, // ツモ or ロン { 80, 208, 52, 32 }, // 場風 { 137, 208, 52, 32 }, // 自風 { 194, 208, 48, 32 }, // n 本場 { 247, 208, 40, 32 }, // ドラ { 292, 208, 28, 32 } // 計算開始 }; enum Button { Is_Oya, Is_Tsumo, Bakaze, Jikaze, N_Honba, Dora, Go }; // MJScore での風を示す番号 #define EAST 31 #define SOUTH 32 #define WEST 33 #define NORTH 34 // N本場のマックス (とりあえず 8) #define N_HONBA_MAX 8 // ドラのマックス (とりあえず 8) #define DORA_MAX 8 // 親 or 子 bool isOya = true; // 場風、自風 int bakaze = EAST; int jikaze = EAST; // ツモ or ロン bool isTsumo = true; // n 本場 int nhonba = 0; // ドラ int dora = 0; int checkTouchedButton() { // 触れたときの座標を取得 Point p = ts.getLastTriggeredPoint(); const Rect* r = buttonLocations; for (int i = 0; i < BUTTON_NUM; i++) { if (r[i].x <= p.x && p.x <= r[i].x + r[i].w - 1 && r[i].y <= p.y && p.y <= r[i].y + r[i].h - 1) { return i; } } // どこのボタンにも触れていない return -1; } void drawStringCenter(const char* str, int x, int y, int w, int h, int color, int scale = 1) { int len = strlen(str); tft.setTextSize(scale); int strWidth = len * TEXT_SIZE_BASE_WIDTH * scale; int strHeight = TEXT_SIZE_BASE_HEIGHT * scale; int sx = (w - strWidth)/2; if (sx < 0) { sx = 0; } int sy = (h - strHeight)/2; if (sy < 0) { sy = 0; } tft.setCursor(x + sx, y + sy); tft.setTextColor(color); tft.print(str); } void drawMenu() { char temp[16]; const Rect& m = menuArea; tft.fillRect(m.x, m.y, m.w, m.h, ILI9341_BLACK); if (state == State::Wait_Inference) { drawStringCenter("Inferring ... please wait", m.x, m.y, m.w, m.h, ILI9341_RED, 2); return; } const Rect* r = buttonLocations; for (int i = 0; i < BUTTON_NUM; i++) { tft.drawRect(r[i].x, r[i].y, r[i].w, r[i].h, ILI9341_WHITE); switch (i) { case Button::Is_Oya: sprintf(temp, "%s", isOya ? "oya" : "ko"); break; case Button::Is_Tsumo: sprintf(temp, "%s", isTsumo ? "tsumo" : "ron"); break; case Button::Bakaze: sprintf(temp, "bakaze %c", getMjsHaiName(bakaze)[0]); break; case Button::Jikaze: sprintf(temp, "jikaze %c", getMjsHaiName(jikaze)[0]); break; case Button::N_Honba: sprintf(temp, "%d honba", nhonba); break; case Button::Dora: sprintf(temp, "dora %d", dora); break; case Button::Go: sprintf(temp, "go"); break; } drawStringCenter(temp, r[i].x, r[i].y, r[i].w, r[i].h, ILI9341_WHITE); } } void caluculateScore() { // 点数計算 mjs.Clear_WithoutRule(); mjs.set(mjsIndexes, bakaze, jikaze, isTsumo, nhonba, dora); mjs.Run(); mjs.Get_Yakutable(mjsYakuTable); } void drawResults() { char temp[32]; const Rect& m = menuArea; tft.fillRect(m.x, m.y, m.w, m.h, ILI9341_BLACK); tft.setTextSize(1); tft.setCursor(m.x, m.y); tft.setTextWrap(true); tft.setTextColor(ILI9341_WHITE); // 認識した牌情報 tft.print("Tehai:"); for (int i = 0; i < CARD_NUM; i++) { int haiIndex = mjsIndexes[i]; sprintf(temp, "%s%c", getMjsHaiName(haiIndex), i == CARD_NUM - 1 ? ' ' : '_'); printf("%s", temp); tft.print(temp); } printf("\n"); int errorCode = mjs.Get_ErrorCode(); // エラー if (errorCode != 0) { sprintf(temp, "error=%d", errorCode); tft.print(temp); printf("%s\n", temp); return; } // 役 tft.print("Yaku:"); bool isYakuman = isMjsYakuman(mjsYakuTable); int firstIndex = isYakuman ? MJScore::YAKU::KOKUSHI : MJScore::YAKU::RIICHI; int lastIndex = getMjsLastYakuIndex(mjsYakuTable); for (int i = firstIndex; i <= lastIndex; i++) { if (mjsYakuTable[i]) { int yakuIndex = i; sprintf(temp, "%s%c", getMjsYakuName(yakuIndex), i == lastIndex ? ' ' : ','); printf(temp); tft.print(temp); } } printf("\n"); // 役満のときにドラ・翻・符の表示は不要 int fan = mjs.Get_Fan(); int fu = mjs.Get_Fu(); if (!isYakuman) { // ドラ sprintf(temp, "Dora:%d ", mjs.Get_Dorasuu()); tft.print(temp); printf("%s\n", temp); // 翻 sprintf(temp, "Fan:%d ", fan); tft.print(temp); printf("%s\n", temp); // 符 sprintf(temp, "Fu:%d ", fu); tft.print(temp); printf("%s\n", temp); } // Rank YakuRank rank = getYakuRank(fan, fu); if (isYakuman || rank > YakuRank::Normal) { sprintf(temp, "%s ", getYakuRankName(rank)); tft.print(temp); printf("%s\n", temp); } // 点数 tft.print("Score:"); int score; if (isOya) { if (isTsumo) { // 親ツモ score = mjs.Get_TokutenOyaTumo(); sprintf(temp, "%d (%d all)", score, score / 3); } else { // 親ロン score = mjs.Get_TokutenOya(); sprintf(temp, "%d", score); } } else { if (isTsumo) { // 子ツモ score = mjs.Get_TokutenKoTumo(); int oya = score / 2; sprintf(temp, "%d (oya: %d, ko: %d)", score, oya, oya / 2); } else { // 子ロン score = mjs.Get_TokutenKo(); sprintf(temp, "%d", score); } } tft.print(temp); printf("%s\n", temp); } void createYakuQueue(int yakuIndex) { char path[32]; // 役の音声は <YakuName>.mp3 の形式で VOICE に抱えている sprintf(path, "VOICE/%s.mp3", getMjsYakuName(yakuIndex)); playQueue.push(path); } void createNumberQueue(int num) { char path[32]; int digit; int divisor = 10000; // 始めの除数を10000の位に設定 while (divisor >= 100) { digit = (num / divisor) % 10; // 特定の位の数を取得 if (digit != 0) { // 100, 1000, 10000 の位の数値音声はその名前で mp3 を VOICE に抱えている // e.g. 300 だったら VOICE/300.mp3 がある sprintf(path, "VOICE/%d.mp3", digit * divisor); playQueue.push(path); printf("%s\n", path); } divisor = divisor / 10; // 次の位に移動 } } void createDoraQueue() { if (dora != 0) { char path[32]; // ドラ音声は dora%d.mp3 の形式で VOICE に抱えている sprintf(path, "VOICE/dora%d.mp3", dora); playQueue.push(path); printf("%s\n", path); } } void createPlayQueue() { int score; // キューを空にする std::queue<String> empty; playQueue.swap(empty); // エラー int errorCode = mjs.Get_ErrorCode(); if (errorCode != 0) { playQueue.push(getReadingVoicePath(ReadingVoice::Error)); return; } // 役 bool isYakuman = isMjsYakuman(mjsYakuTable); int firstIndex = isYakuman ? MJScore::YAKU::KOKUSHI : MJScore::YAKU::RIICHI; int lastIndex = getMjsLastYakuIndex(mjsYakuTable); for (int i = firstIndex; i <= lastIndex; i++) { if (mjsYakuTable[i]) { createYakuQueue(i); } } // ドラ if (!isYakuman) { createDoraQueue(); } // Rank int fan = mjs.Get_Fan(); int fu = mjs.Get_Fu(); YakuRank rank = getYakuRank(fan, fu); if (isYakuman) { playQueue.push(getReadingVoicePath(ReadingVoice::RV_Yakuman)); } else { switch (rank) { case YakuRank::Normal: // do nothing break; case YakuRank::Mangan: playQueue.push(getReadingVoicePath(ReadingVoice::RV_Mangan)); break; case YakuRank::Haneman: playQueue.push(getReadingVoicePath(ReadingVoice::RV_Haneman)); break; case YakuRank::Baiman: playQueue.push(getReadingVoicePath(ReadingVoice::RV_Baiman)); break; case YakuRank::Sanbaiman: playQueue.push(getReadingVoicePath(ReadingVoice::RV_Sanbaiman)); break; case YakuRank::KazoeYakuman: playQueue.push(getReadingVoicePath(ReadingVoice::RV_KazoeYakuman)); break; } } // 点数 if (isOya) { if (isTsumo) { // 親ツモ score = mjs.Get_TokutenOyaTumo(); createNumberQueue(score); int each = score / 3; createNumberQueue(each); playQueue.push(getReadingVoicePath(ReadingVoice::All)); } else { // 親ロン score = mjs.Get_TokutenOya(); createNumberQueue(score); } } else { if (isTsumo) { // 子ツモ score = mjs.Get_TokutenKoTumo(); createNumberQueue(score); playQueue.push(getReadingVoicePath(ReadingVoice::Oya)); int oya = score / 2; createNumberQueue(oya); playQueue.push(getReadingVoicePath(ReadingVoice::Ko)); int ko = oya / 2; createNumberQueue(ko); } else { // 子ロン score = mjs.Get_TokutenKo(); createNumberQueue(score); } } } void setup() { CamErr camErr; Serial.begin(SERIAL_BAUDRATE); while (!Serial) { // wait for serial port to connect. Needed for native USB port only } tft.begin(40000000); tft.setRotation(3); ts.initialize(TOUCH_SCREEN_CS_PIN); while (!sd.begin()) { putStringOnLcd(tft, "Insert SD card", ILI9341_RED); } SoundInitialize(&sd); mjs.initialize(); camErr = cr.initialize(&tft, &sd); if (camErr != CAM_ERR_SUCCESS) { printCameraError(camErr); while (true) { // do nothing } } camErr = cr.start(); if (camErr != CAM_ERR_SUCCESS) { printCameraError(camErr); while (true) { // do nothing } } } void loop() { ts.update(); SoundUpdate(); if (!SoundIsPlaying()) { if (!playQueue.empty()) { SoundPlay(playQueue.front().c_str(), false, false); playQueue.pop(); } } switch (state) { case State::Menu: if (ts.isReleased()) { int button = checkTouchedButton(); printf("button=%d\n", button); switch (button) { // oya or ko case Button::Is_Oya: isOya = !isOya; break; // tsumo or ron case Button::Is_Tsumo: isTsumo = !isTsumo; break; // bakaze case Button::Bakaze: bakaze++; if (bakaze > NORTH) { bakaze = EAST; } break; // jikaze case Button::Jikaze: jikaze++; if (jikaze > NORTH) { jikaze = EAST; } break; // nhonba case Button::N_Honba: nhonba++; if (nhonba > N_HONBA_MAX) { nhonba = 0; } break; // dora case Button::Dora: dora++; if (dora > DORA_MAX) { dora = 0; } break; // go case Button::Go: cr.setTrigger(); state = State::Wait_Inference; } if (button != -1) { needUpdate = true; } } if (needUpdate) { drawMenu(); needUpdate = false; } break; case State::Wait_Inference: if (cr.isInferred()) { printf("finished inference\n"); cr.getMjsIndexes(mjsIndexes); caluculateScore(); state = State::Result; needUpdate = true; if (!playQueue.empty()) { SoundPlay(playQueue.front().c_str(), false, false); playQueue.pop(); } } break; case State::Result: if (needUpdate) { drawResults(); createPlayQueue(); needUpdate = false;; } else if (ts.isReleased()) { state = State::Menu; needUpdate = true; SoundStop(); cr.clearInferredStatus(); } break; } }

define.h

#ifndef __MCS_DEFINE_H__ #define __MCS_DEFINE_H__ // シリアルの Baudrate #define SERIAL_BAUDRATE (115200) // Card 認識用学習済モデルデータ #define DNNRT_MODEL_FILENAME "mjcard_detection.nnb" // スキャンするカードの数 #define CARD_NUM 14 // 一枚あたりのカードの幅・高さ #define CARD_WIDTH 42 #define CARD_HEIGHT 60 // ディスプレイ用 CS, RESET, DC ピン #define TFT_CS_PIN 10 #define TFT_RST_PIN 8 #define TFT_DC_PIN 9 // タッチスクリーン用 CS ピン #define TOUCH_SCREEN_CS_PIN 7 // 描画するカメラ画像の高さ #define CAMERA_DISPLAY_HEIGHT 208 // 牌グラッフィクの幅・高さ #define HAI_GRA_WIDTH 33 #define HAI_GRA_HEIGHT 59 // ポイント座標 typedef struct Point { int x; int y; } Point; // Rect 情報 typedef struct Rect { int x; int y; int w; int h; } Rect; // 再生情報 typedef struct Play { char filePath[64]; } Play; // 翻からの Rank enum YakuRank { Normal, // 満貫未満 Mangan, // 満貫 Haneman, // 跳満 Baiman, // 倍満 Sanbaiman, // 3倍満 KazoeYakuman // 数え役満 }; enum ReadingVoice { RV_Mangan, RV_Haneman, RV_Baiman, RV_Sanbaiman, RV_KazoeYakuman, RV_Yakuman, Oya, Ko, All, Error }; #endif // __MCS_DEFINE_H__

CardRecognizer.h

#ifndef __MCS_CARD_RECOGNIZER_H__ #define __MCS_CARD_RECOGNIZER_H__ #include <Camera.h> #include <SDHCI.h> class CardRecognizer { public: CardRecognizer() : m_Initialized(false) {}; virtual ~CardRecognizer() {} CamErr initialize(Adafruit_ILI9341 *tft, SDClass* sd); CamErr start(); void setTrigger() { s_Trigger = true; } void clearInferredStatus() { s_Inferred = false; } bool isInferred() { return s_Inferred; } void getMjsIndexes(int indexes[]); private: static void cameraCallback(CamImage img); private: static bool s_Trigger; static bool s_Inferred; static int s_DnnIndexes[CARD_NUM]; private: bool m_Initialized; }; #endif // __MCS_CARD_RECOGNIZER_H__

CardRecognizer.cpp

#include <stdio.h> #include <SDHCI.h> #include <DNNRT.h> #include <Adafruit_ILI9341.h> #include "define.h" #include "util.h" #include "CardRecognizer.h" bool CardRecognizer::s_Trigger = false; bool CardRecognizer::s_Inferred = false; int CardRecognizer::s_DnnIndexes[CARD_NUM]; static Adafruit_ILI9341 *s_pTft = NULL; static SDClass *s_pSd = NULL; static DNNRT s_Dnnrt; static DNNVariable s_DnnInput(CARD_WIDTH * CARD_HEIGHT); // 牌画像バッファ uint16_t haiGraBuffer[HAI_GRA_WIDTH * HAI_GRA_HEIGHT]; // 各カードの座標 const Point cardLocation[CARD_NUM] = { // 1行目 { 48, 8 }, { 90, 8 }, { 132, 8 }, { 174, 8 }, { 216, 8 }, // 2行目 { 48, 73 }, { 90, 73 }, { 132, 73 }, { 174, 73 }, { 216, 73 }, // 3行目 { 48, 138 }, { 90, 138 }, { 132, 138 }, { 174, 138 } }; // drawBitmp 系の API を何度も呼び出すと画面が荒れるので、imgBuf に書き込みする // 牌画像は GRA/<HaiName>.raw という形式で格納されている想定 void drawHaiGraphic(uint16_t* imgBuf, int mjsIndex, int cardIndex) { char path[32]; File myFile; sprintf(path, "GRA/%s.raw", getMjsHaiName(mjsIndex)); myFile = s_pSd->open(path, FILE_READ); // Verify file open if (!myFile) { printf("File open error(%s)\n", path); exit(1); } // ファイルのサイズを取得 int fileSize = myFile.size(); // ファイルサイズとバッファのサイズが一致しなかったらエラー if (fileSize != sizeof(haiGraBuffer)) { printf("fileSize doesn't match buffer size!(fileSize=%d)\n", fileSize); exit(1); } int bytesRead = myFile.read(haiGraBuffer, fileSize); // 読み込んだサイズとファイルサイズが一致しなかったらエラー if (bytesRead != fileSize) { printf("bytesRead doesn't match fileSizse!(bytesRead=%d)\n", bytesRead); exit(1); } myFile.close(); int dispWidth = s_pTft->width(); // 枠のサイズと牌画像のサイズがちょっと違うので調整 (x + 4, y + 1) uint16_t* p = imgBuf + dispWidth * (cardLocation[cardIndex].y + 1) + cardLocation[cardIndex].x + 4; for (int y = 0; y < HAI_GRA_HEIGHT; y++) { for (int x = 0; x < HAI_GRA_WIDTH; x++) { *(p + dispWidth * y + x) = haiGraBuffer[HAI_GRA_WIDTH * y + x]; } } } void CardRecognizer::cameraCallback(CamImage img) { if (!img.isAvailable()) { printf("Image is not available. Try again\n"); return; } // 画像の加工と推論処理 if (s_Trigger) { printf("triggerd. start process\n"); s_Trigger = false; for (int i = 0; i < CARD_NUM; i++) { // カメラ画像の切り抜きと縮小 CamImage small; CamErr err = img.clipAndResizeImageByHW(small, cardLocation[i].x, cardLocation[i].y, cardLocation[i].x + CARD_WIDTH - 1, cardLocation[i].y + CARD_HEIGHT - 1, CARD_WIDTH, CARD_HEIGHT); if (!small.isAvailable()){ putStringOnLcd(*s_pTft, "Clip and Reize Error:" + String(err), ILI9341_RED); return; } // 認識用モノクロ画像をDNNVariableに設定 uint16_t* imgbuf = (uint16_t*)small.getImgBuff(); float *dnnbuf = s_DnnInput.data(); for (int n = 0; n < CARD_WIDTH * CARD_HEIGHT; ++n) { // YUV422の輝度成分をモノクロ画像として利用 // 学習済モデルの入力に合わせ0.0-1.0に正規化 dnnbuf[n] = (float)(((imgbuf[n] & 0xf000) >> 8) | ((imgbuf[n] & 0x00f0) >> 4))/255.; } // 推論の実行 s_Dnnrt.inputVariable(s_DnnInput, 0); s_Dnnrt.forward(); DNNVariable output = s_Dnnrt.outputVariable(0); s_DnnIndexes[i] = output.maxIndex(); int mjsIndex = convertDnnIndexToMJSIndex(s_DnnIndexes[i]); printf("i=%d, index=%d, mjsIndex=%d (%s)\n", i, s_DnnIndexes[i], mjsIndex, getMjsHaiName(mjsIndex)); } // 処理結果のディスプレイ表示 img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* imgBuf = (uint16_t*)img.getImgBuff(); for (int i = 0; i < CARD_NUM; i++) { drawHaiGraphic(imgBuf, convertDnnIndexToMJSIndex(s_DnnIndexes[i]), i); } s_pTft->drawRGBBitmap(0, 0, imgBuf, s_pTft->width(), CAMERA_DISPLAY_HEIGHT); s_Inferred = true; } // 処理結果のディスプレイ表示 if (!s_Inferred) { img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* imgBuf = (uint16_t*)img.getImgBuff(); for (int i = 0; i < CARD_NUM; i++) { if (i != CARD_NUM - 1) { drawBox(*s_pTft, imgBuf, cardLocation[i].x, cardLocation[i].y, CARD_WIDTH, CARD_HEIGHT, 1, ILI9341_RED); } else { drawBox(*s_pTft, imgBuf, cardLocation[i].x, cardLocation[i].y, CARD_WIDTH, CARD_HEIGHT, 1, ILI9341_BLUE); } } s_pTft->drawRGBBitmap(0, 0, imgBuf, s_pTft->width(), CAMERA_DISPLAY_HEIGHT); } // おまじない (Touch Screen の影響?で画面上部にゴミが出るが、Gfx の draw API を呼び出すと消える) s_pTft->drawPixel(0, 0, ILI9341_BLACK); } CamErr CardRecognizer::initialize(Adafruit_ILI9341 *tft, SDClass* sd) { CamErr err; s_pSd = sd; if (m_Initialized) { return CAM_ERR_ALREADY_INITIALIZED; } // begin() without parameters means that number of buffers = 1, 30FPS, QVGA, YUV 4:2:2 format printf("Prepare camera\n"); err = theCamera.begin(); if (err != CAM_ERR_SUCCESS) { return err; } printf("Set Auto white balance parameter\n"); err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_FLUORESCENT); if (err != CAM_ERR_SUCCESS) { return err; } // SDカードにある学習済モデルの読み込み // 失敗することもあるよね・・・ケアが必要 File nnbfile = s_pSd->open(DNNRT_MODEL_FILENAME); // 学習済モデルでDNNRTを開始 s_Dnnrt.begin(nnbfile); nnbfile.close(); s_pTft = tft; m_Initialized = true; return CAM_ERR_SUCCESS; } CamErr CardRecognizer::start() { CamErr err; printf("Start streaming\n"); err = theCamera.startStreaming(true, &CardRecognizer::cameraCallback); if (err != CAM_ERR_SUCCESS) { return err; } return CAM_ERR_SUCCESS; } void CardRecognizer::getMjsIndexes(int indexes[]) { for (int i = 0; i < CARD_NUM; i++) { indexes[i] = convertDnnIndexToMJSIndex(s_DnnIndexes[i]); } }

MJScoreHandler.h

#ifndef __MCS_MJSCORE_HANDLER_H__ #define __MCS_MJSCORE_HANDLER_H__ #include <MJScore.h> #include "define.h" #define MJSCORE_HANDLER_TEHAI_NUM 14 class MJScoreHandler : public MJScore { public: MJScoreHandler() : MJScore(), m_Initialized(false) {}; virtual ~MJScoreHandler() {} void initialize(); void set(int indexes[], int bakaze, int jikaze, bool isTsumo, int nhonba, int dora); private: bool m_Initialized; int m_Tehai[MJSCORE_INPUT_TEHAI_NUM]; // 手牌 }; #endif // __MCS_MJSCORE_HANDLER_H__

MJScoreHandler.cpp

#include "MJScoreHandler.h" #include "util.h" void MJScoreHandler::initialize() { if (m_Initialized) { return; } // ルール入力 MJScore::Avail_Akahai(false); //赤牌有り MJScore::Avail_Kuitan(true); //クイタン有り MJScore::Avail_ManganKiriage(false); //満貫切り上げ有り MJScore::Avail_Ba1500(false); //場千五有り MJScore::Avail_DoubleKokushi13(false); //国士13面待ちダブル役満有り MJScore::Avail_DoubleTyuuren9(false); //九連宝燈9面待ちダブル役満有り MJScore::Avail_DoubleDaisuusii(false); //大四喜ダブル役満有り MJScore::Avail_DoubleSuttan(false); //四暗刻単騎ダブル役満有り memset(m_Tehai, 0, sizeof(m_Tehai)); m_Initialized = true; } void MJScoreHandler::set(int indexes[], int bakaze, int jikaze, bool isTsumo, int nhonba, int dora) { memset(m_Tehai, 0, sizeof(m_Tehai)); int agarihai = 0; for (int i = 0; i < MJSCORE_HANDLER_TEHAI_NUM; i++) { int mjsIndex = indexes[i]; m_Tehai[mjsIndex]++; } // ひとまず最後のカードをあがり牌とする agarihai = indexes[MJSCORE_HANDLER_TEHAI_NUM - 1]; //特殊役成立フラグ入力 MJScore::Is_Riichi(false); //リーチ MJScore::Is_Tenhou(false); //天和 MJScore::Is_Tiihou(false); //地和 MJScore::Is_DoubleRiichi(false); //ダブルリーチ MJScore::Is_Ippatu(false); //一発 MJScore::Is_Tyankan(false); //チャンカン MJScore::Is_Rinsyan(false); //リンシャン MJScore::Is_NagashiMangan(false); //流し満貫 MJScore::Is_Haitei(false); //ハイテイ //必須情報入力 MJScore::Set_Tehai(m_Tehai); //手牌 MJScore::Set_Agarihai(agarihai); //あがり牌 MJScore::Set_Bakaze(bakaze); //場風 MJScore::Set_Jikaze(jikaze); //自風 //追加情報入力 MJScore::Set_Tumoagari(isTsumo); //ツモあがりかどうか MJScore::Set_Honba(nhonba); //N本場 MJScore::Set_Dorasuu(dora); //ドラ数入力 }

TouchScreen.h

#ifndef __MCS_TOUCH_SCREEN_H__ #define __MCS_TOUCH_SCREEN_H__ #include "define.h" class XPT2046_Touchscreen; class TouchScreen { public: TouchScreen() : m_Initialized(false), m_IsTriggered(false), m_IsPressed(false), m_IsReleased(false) {} virtual ~TouchScreen() {} void initialize(unsigned int csPin); void finalize(); void update(); bool isTriggered() { return m_IsTriggered; } bool isPressed() { return m_IsPressed; } bool isReleased() { return m_IsReleased; } Point getLastTriggeredPoint() { return m_TriggeredPoint; } Point getLastPressedPoint() { return m_PressedPoint; } Point getLastReleasedPoint() { return m_ReleasedPoint; } private: Point convertRawPointToDisplayPoint(int x, int y); private: XPT2046_Touchscreen *m_pTouchScreen; bool m_Initialized; bool m_IsTriggered; bool m_IsPressed; bool m_IsReleased; Point m_TriggeredPoint; Point m_PressedPoint; Point m_ReleasedPoint; }; #endif // __MCS_TOUCH_SCREEN_H__

TouchScreen.cpp

#include <XPT2046_Touchscreen.h> #include "TouchScreen.h" // タッチスクリーン座標からディスプレイ変換に使用するパラメータ // 下記のプログラムで算出 // https://github.com/men100/arduino/tree/main/Spresense_XPT2046_calibration const float SCALE_X = 0.09; const float SCALE_Y = 0.07; const float OFFSET_X = 346.64; const float OFFSET_Y = 248.35; void TouchScreen::initialize(unsigned int csPin) { if (!m_Initialized) { m_Initialized = true; } m_pTouchScreen = new XPT2046_Touchscreen(csPin); m_pTouchScreen->begin(); m_pTouchScreen->setRotation(1); // tft が 3 のときに 1 で向きが一致する模様 } void TouchScreen::finalize() { if (m_pTouchScreen) { delete(m_pTouchScreen); } m_Initialized = false; } void TouchScreen::update() { if (!m_Initialized) { return; } bool isTouched = m_pTouchScreen->touched(); if (isTouched) { // Triggered if (!m_IsPressed && !m_IsTriggered) { m_IsTriggered = true; m_IsPressed = true; TS_Point rawPoint = m_pTouchScreen->getPoint(); Point displayPoint = convertRawPointToDisplayPoint(rawPoint.x, rawPoint.y); m_TriggeredPoint = displayPoint; m_PressedPoint = displayPoint; } else { m_IsTriggered = false; TS_Point rawPoint = m_pTouchScreen->getPoint(); m_PressedPoint = convertRawPointToDisplayPoint(rawPoint.x, rawPoint.y); } } else { // Released if (m_IsPressed && !m_IsReleased) { m_IsPressed = false; m_IsReleased = true; TS_Point rawPoint = m_pTouchScreen->getPoint(); m_ReleasedPoint = convertRawPointToDisplayPoint(rawPoint.x, rawPoint.y); } else { m_IsReleased = false; } } } Point TouchScreen::convertRawPointToDisplayPoint(int x, int y) { Point displayPoint; // 生の座標を画面解像度に変換 displayPoint.x = (x - OFFSET_X) * SCALE_X; displayPoint.y = (y - OFFSET_Y) * SCALE_Y; return displayPoint; }

Sound.h

#ifndef __MCS_SOUND_H__ #define __MCS_SOUND_H__ #include <Audio.h> #include <SDHCI.h> #define SOUND_BUZZER 0 #define SOUND_TRANSITION 1 void SoundInitialize(SDClass* sd); void SoundPlay(const char* path, bool isLoop, bool isStopImmediately); void SoundUpdate(); bool SoundIsPlaying(); void SoundStop(); void SoundFinalize(); #endif // __MCS_SOUND_H__

Sound.cpp

#include "Sound.h" // Player0 のバッファは 24KB。Player1 のバッファは不要 #define PLAYER_0_BUFFER_SIZE 1024 * 24 #define PLAYER_1_BUFFER_SIZE 0 const int WAIT_TIME_MSEC = 40; const int VOLUME_DB = -10; AudioClass *theAudio = AudioClass::getInstance(); SDClass *pSd = NULL; File myFile; static unsigned long previousMsec = 0; static bool isInitialized = false; static bool ErrEnd = false; typedef struct Parameter { char path[64]; bool isPlaying; bool isLoop; bool isStopImmediately; // あんまり短いファイルだと、再生される前に停まってしまうので注意 } Parameter; static Parameter param = { NULL, false, false, false }; static void audio_attention_callback(const ErrorAttentionParam *atprm) { puts("Attention!"); if (atprm->error_code >= AS_ATTENTION_CODE_WARNING) { ErrEnd = true; } } void SoundInitialize(SDClass* sd) { pSd = sd; theAudio->begin(audio_attention_callback); puts("initialization Audio Library"); // Set clock mode to normal theAudio->setRenderingClockMode(AS_CLKMODE_NORMAL); theAudio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT, PLAYER_0_BUFFER_SIZE, PLAYER_1_BUFFER_SIZE); // Set main player to decode stereo mp3. Stream sample rate is set to "auto detect" // Search for MP3 decoder in "/mnt/sd0/BIN" directory err_t err = theAudio->initPlayer(AudioClass::Player0, AS_CODECTYPE_MP3, "/mnt/sd0/BIN", AS_SAMPLINGRATE_AUTO, AS_CHANNEL_STEREO); // Verify player initialize if (err != AUDIOLIB_ECODE_OK) { printf("Player0 initialize error\n"); exit(1); } isInitialized = true; } void SoundPlay(const char* path, bool isLoop, bool isStopImmediately) { if (!isInitialized) { return; } if (param.isPlaying) { return; } strncpy(param.path, path, strlen(path) + 1); // strlen は NULL 文字含まず param.isLoop = isLoop; param.isStopImmediately = isStopImmediately; // Open file placed on SD card myFile = pSd->open(path); // Verify file open if (!myFile) { printf("File open error(%s)\n", path); exit(1); } // Send first frames to be decoded err_t err = theAudio->writeFrames(AudioClass::Player0, myFile); if ((err != AUDIOLIB_ECODE_OK) && (err != AUDIOLIB_ECODE_FILEEND)) { printf("File Read Error! =%d\n",err); myFile.close(); exit(1); } puts("Play!"); theAudio->setVolume(VOLUME_DB); theAudio->startPlayer(AudioClass::Player0); param.isPlaying = true; } void SoundUpdate() { if (!param.isPlaying) { return; } unsigned long currentMsec = millis(); if (currentMsec - previousMsec <= WAIT_TIME_MSEC) { return; } previousMsec = currentMsec; // Send new frames to decode in a loop until file ends int err = theAudio->writeFrames(AudioClass::Player0, myFile); // Tell when player file ends if (err == AUDIOLIB_ECODE_FILEEND) { printf("Main player File End!\n"); SoundStop(); if (param.isLoop) { SoundPlay(param.path, param.isLoop, param.isStopImmediately); } return; } // Show error code from player and stop if (err) { printf("Main player error code: %d\n", err); goto stop_player; } if (ErrEnd) { printf("Error End\n"); goto stop_player; } // Don't go further and continue play return; stop_player: SoundFinalize(); } bool SoundIsPlaying() { return param.isPlaying; } void SoundStop() { // AS_STOPLAYER_NORMAL で即時停止 theAudio->stopPlayer(AudioClass::Player0, param.isStopImmediately ? AS_STOPPLAYER_NORMAL : AS_STOPPLAYER_ESEND); myFile.close(); param.isPlaying = false; } void SoundFinalize() { // AS_STOPLAYER_NORMAL で即時停止 theAudio->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); myFile.close(); theAudio->setReadyMode(); theAudio->end(); exit(1); }

util.h

#ifndef __MCS_UTIL_H__ #define __MCS_UTIL_H__ #include <Camera.h> #include <Adafruit_ILI9341.h> #include "define.h" // 液晶ディスプレイの下部に文字列を表示する void putStringOnLcd(Adafruit_ILI9341& tft, String str, int color); // 液晶ディスプレイに LINE_THICKNESS の太さの四角形を描画する void drawBox(Adafruit_ILI9341& tft, uint16_t* imgBuf, int offset_x, int offset_y, int width, int height, int thickness, int color); // CamErr を解釈して表示 void printCameraError(enum CamErr err); // index から MJScore で該当する牌の名前を返す const char* getMjsHaiName(int index); // index から MJScore で該当する役の名前を返す const char* getMjsYakuName(int index); // 役の中に役満があるかどうかを返す bool isMjsYakuman(bool yakuTable[]); // 役の中の最後の index を返す int getMjsLastYakuIndex(bool yakuTable[]); // 役の Rank (満貫、跳満など) を返す YakuRank getYakuRank(int fan, int fu); // 役 Rank の文字列を返す const char* getYakuRankName(YakuRank rank); // 読み上げ音声ファイルのパスを返す const char* getReadingVoicePath(ReadingVoice voice); // DnnIndex から MJScore の配牌 index へと変換する int convertDnnIndexToMJSIndex(int dnnIndex); #endif // __MCS_UTIL_H__

util.cpp

#include <MJScore.h> #include "util.h" const char* mjsHaiNames[] = { "undefined", // 1-9: 萬子 "1w", "2w", "3w", "4w", "5w", "6w", "7w", "8w", "9w", "undefined", // 11-19: 筒子 "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "undefined", // 21-29: 索子 "1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", "undefined", // 31-37: 東南西北白發中 "east", "south", "west", "north", "haku", "hatsu", "chun" }; const char* mjsYakuNames[] = { // 通常役 (0-29) "riichi", "double-riichi", "ippatu", "menzen-tsumo", "tanyao", "pinfu", "iipeikou", "bakaze", "jikaze", "haku", "hatu", "chun", "rinsyan", "tyankan", "haitei-tsumo", "haitei-ron", "sansyoku-dojyun", "ittuu", "tyanta", "chiitoitu", "toitoi", "sananko", "honro-toitoi", "sansyoku-doukou", "sankantu", "syousangen", "honitu", "juntyanta", "ryanpeikou", "tinitu", // 天和・地和 (30-31) "tenhou", "tiihou", // 役満 (32-44) "kokushi", "kokushi-13", "tyuuren", "tyuuren-9", "suuanko", "suuanko-tanki", "daisuusii", "syousuusii", "daisangen", "tuuiisou", "tinroutou", "ryuuiisou", "suukantu" }; const char* yakuRankName[] = { "Normal", "Mangan", "Haneman", "Baiman", "Sanbaiman", "Yakuman" }; const char* readingVoicePathes[] = { "VOICE/mangan.mp3", // RV_Mangan "VOICE/haneman.mp3", // RV_Haneman "VOICE/baiman.mp3", // RV_Baiman "VOICE/sanbaiman.mp3", // RV_Sanbaiman "VOICE/kazoe_yakuman.mp3", // RV_KazoeYakuman "VOICE/yakuman.mp3", // RV_Yakuman "VOICE/oya.mp3", // Oya "VOICE/ko.mp3", // Ko "VOICE/all.mp3", // All "VOICE/error.mp3" // Error }; void putStringOnLcd(Adafruit_ILI9341& tft, String str, int color) { int len = str.length(); tft.fillRect(0, 224, 320, 240, ILI9341_BLACK); tft.setTextSize(1); int sx = 160 - len/2*12; if (sx < 0) sx = 0; tft.setCursor(sx, 225); tft.setTextColor(color); tft.println(str); } void drawBox(Adafruit_ILI9341& tft, uint16_t* imgBuf, int offset_x, int offset_y, int width, int height, int thickness, int color) { /* Draw target line */ for (int x = offset_x; x < offset_x+width; ++x) { for (int n = 0; n < thickness; ++n) { *(imgBuf + tft.width()*(offset_y+n) + x) = color; *(imgBuf + tft.width()*(offset_y+height-1-n) + x) = color; } } for (int y = offset_y; y < offset_y+height; ++y) { for (int n = 0; n < thickness; ++n) { *(imgBuf + tft.width()*y + offset_x+n) = color; *(imgBuf + tft.width()*y + offset_x + width-1-n) = color; } } } void printCameraError(enum CamErr err) { Serial.print("Error: "); switch (err) { case CAM_ERR_NO_DEVICE: Serial.println("No Device"); break; case CAM_ERR_ILLEGAL_DEVERR: Serial.println("Illegal device error"); break; case CAM_ERR_ALREADY_INITIALIZED: Serial.println("Already initialized"); break; case CAM_ERR_NOT_INITIALIZED: Serial.println("Not initialized"); break; case CAM_ERR_NOT_STILL_INITIALIZED: Serial.println("Still picture not initialized"); break; case CAM_ERR_CANT_CREATE_THREAD: Serial.println("Failed to create thread"); break; case CAM_ERR_INVALID_PARAM: Serial.println("Invalid parameter"); break; case CAM_ERR_NO_MEMORY: Serial.println("No memory"); break; case CAM_ERR_USR_INUSED: Serial.println("Buffer already in use"); break; case CAM_ERR_NOT_PERMITTED: Serial.println("Operation not permitted"); break; default: break; } } const char* getMjsHaiName(int index) { if (index < 0 || 37 < index) { return NULL; } return mjsHaiNames[index]; } const char* getMjsYakuName(int index) { if (index < 0 || MJScore::YAKU::SUUKANTU < index) { return NULL; } return mjsYakuNames[index]; } bool isMjsYakuman(bool yakuTable[]) { for (int i = MJScore::YAKU::KOKUSHI; i <= MJScore::YAKU::SUUKANTU; i++) { if (yakuTable[i]) { return true; } } return false; } int getMjsLastYakuIndex(bool yakuTable[]) { int lastIndex = 0; for (int i = MJScore::YAKU::RIICHI; i <= MJScore::YAKU::SUUKANTU; i++) { if (yakuTable[i]) { lastIndex = i; } } return lastIndex; } YakuRank getYakuRank(int fan, int fu) { if (0 < fan && fan <= 2) { return YakuRank::Normal; } // 満貫 if ((fan == 3 && fu >= 70) || (fan == 4 && fu >= 40) || fan == 5) { return YakuRank::Mangan; } // 跳満 if (fan == 6 || fan == 7) { return YakuRank::Haneman; } // 倍満 if (fan == 8 || fan == 9 || fan == 10) { return YakuRank::Baiman; } // 3倍満 if (fan == 11 || fan == 12) { return YakuRank::Sanbaiman; } // (数え)役満 if (fan >= 13) { return YakuRank::KazoeYakuman; } return YakuRank::Normal; } const char* getYakuRankName(YakuRank rank) { if (rank > YakuRank::KazoeYakuman) { return NULL; } return yakuRankName[rank]; } const char* getReadingVoicePath(ReadingVoice voice) { if (voice > ReadingVoice::Error) { return NULL; } return readingVoicePathes[voice]; } int convertDnnIndexToMJSIndex(int dnnIndex) { // 萬子 if (0 <= dnnIndex && dnnIndex <= 8) { return dnnIndex + 1; } // 索子 else if (9 <= dnnIndex && dnnIndex <= 17) { return dnnIndex + 2; } // 筒子 else if (18 <= dnnIndex && dnnIndex <= 26) { return dnnIndex + 3; } // 東南西北白發中 else if (27 <= dnnIndex && dnnIndex <= 33) { return dnnIndex + 4; } // undefined return 0; }

今後の課題

今後の課題について延べます。

コードのリファクタリング

締切に追われ、やっつけ的なコードがあちらこちらにあるので、まずはリファクタリングですね。

一部の特殊役への対応

UI/UX の見直しをし、一部の特殊役へも対応したいです。

副露 (フーロ)対応

麻雀では状況によっては対戦相手の牌を自分に取り込むことができます。
それが副露 (フーロ) で、チー・ポン・暗槓・明槓になります。

チー・ポン・暗槓・明槓

ここで大事なのは副露した牌は図にあるように横倒しにして並べなければいけないということです。
この横倒しが問題で、現在のカードホルダーでは横向きに格納することはできません。

そのため、カードホルダーの再設計が必要です。
そして横向きに対応するために入力データ解像度の拡張が必要で、データの取り直しにもなってしまうかもしれません。

一方、チーやポン、カンが行われている事自体は前後の牌情報からコンテキストとして把握できるのではないかと考えています。

スムーズな読み上げ

今回は Open JTalk を使いましたが、特に設定していなかったため、たどたどしい読み上げの箇所が散見されました。
こちら調整し、流暢に読み上げたいです。

また、読み上げは一定単位ごと、読み上げファイルのロードで行っているのですが、
あるファイルの読み上げ終わり、次のファイルの読み上げが始まるまでに一定の間が生じてしまっています。
これがまたたどたどしさを感じさせてしまっています。

次のファイルの読み上げまでの間を改善するには Audio クラスの stopPlayer 関数を AS_STOPPLAYER_NORMAL モードで呼び出せば良いのですが、
再生時間の短いファイルだと再生される前に終了してしまうため、うまくいきません。

Just idea ですが考えていることは現状 player0 しか使っていないため、player1 も活用できないか、ということです。
例えば player0 で行われているファイル再生位置を把握し、終了間際になったら、player1 で次のファイルを開始するという方法があります。

最後に

まだ発展途上なところはありますが、面倒な点数計算をエンタメ化する作品ができました。
認識させたり読み上げさせているだけでも結構楽しいです。

楽しいコンテストの開催、ありがとうございました。次回の開催も期待しております。
前回は IoT 部門で参加、今回はエンタメ部門で参加、ということで次回はロボット部門で頑張りたい、かも。
難しそうだけど、また色々学びがありそうです。

クレジット

認識結果として表示している牌画像については、麻雀王国の麻雀素材を利用させていただきました。

1
ログインしてコメントを投稿する