はじめに
麻雀初心者を救うアプリ「麻雀サポーター」リリース。
初心者の「雀荘に行けない」以下の悩みを解決する!
- 点数計算ができない
- 打牌選択や多面待ちの待ち探しに時間がかかってしまう
- 誤ロンとかチョンボしそう
本アプリケーションは、
手牌をリアルタイムカメラにかざすだけで、
- 手牌が14枚あれば、点数計算をしてくれる
- テンパイ時に待ち牌一覧とそれぞれの牌で和了したときの期待点数を教えてくれる
- 手牌が14枚、11枚、8枚、5枚、2枚で3シャンテン以下のときに、オススメの打牌を受け入れが広い順に受け入れの牌とともに表示してくれる
開発環境
Androidアプリ
- Flutter (3.13.7)
⇒ フレームワーク。 - Dart (3.1.3)
⇒ 開発言語。悪くない。 - Visual Studio Code
⇒ 開発自体はこちらのエディタを使用。軽くて良い。 - Android Studio
⇒ ビルド周りでたまに使用。ちょい重。
麻雀牌検出器
- Google Colaboratory
⇒ Proバージョンも契約。GPU使用。 - Tensorflow Object Detection API
⇒ 最高。 - Roboflow
⇒ 最高。
開発の目的
何年も前からこういうのあったらいいな、と思ってたから。
思うだけで何もしない冬眠時期を経て、今に至る。
冬眠から覚める直前、さすがに自分が思い描いていたアプリは既に溢れているかなと思って見渡してみると、意外と数が少なかったのと、どのアプリも思い描いているものと少し異なっていたので作ってみた。
ビジョン・ポイント
当初から思い描いていたビジョンとしては、とにかくユーザ体験最高なアプリにしたかった。
以下に開発当時のビジョンや、それをもとに開発をすすめるうえでポイントとなったことを記載する。
-
リアルタイムカメラによる手牌自動検出
これは絶対に必要。
実際に雀荘でのユースケースを考えたとき、ただでさえ打牌やらツモやらで忙しいのに、1つ1つ麻雀牌を選択させたくない。
普通のシャッターカメラではなくリアルタイムカメラにした理由は、ただでさえ忙しいユーザに「手牌にピントを合わせる」のみでなく、「シャッターボタンを押す」という操作を強いたくなかった。
-
手動の牌追加・削除・交換機能も必須
麻雀牌検出、勿論認識率100%が望ましい。
ただそうはいっても、暗い場所だったり新種の麻雀牌の一索だったり(一索のデザインはマジでバラバラ)、どうしても認識できないときはある。
そのときにこのアプリは機能しません、ではいけない。
自動検出に加え、手動での牌操作もできるようにしておく必要がある。
更に削除や並び替えも簡単にできるようにしておきたい。
スワイプ、ドラッグ&ドロップに対応するしかない!
-
結果にすぐアクセスできる
UXを考えるうえで念頭に置いておかなければならないのが、ユーザには様々なニーズの方がいて、正確な点数を知りたいという方もいれば、符まで分かれば後は点数計算できる方、満貫以上なら分かる方等、様々だということだ。
麻雀で正確な点数を算出するときに必要な情報として、ツモ or ロン、自風、場風、ドラ何枚か等々が挙げられるが、これは一部のユーザにとっては副次的な情報でしかない。
勿論、正確な点数算出には必要不可欠なので本アプリでも選択可能とするが、それは満貫かどうかが分かればよいユーザ、何符何翻が分かれば点数計算できるというユーザに結果を表示した後だ。
実際このアプリは、「○翻○符」/「満貫」/「跳満」・・・のような情報には最短2手でアクセスできる。
①アプリを起動する
②カメラに手牌を映す
これだけで右上に結果が表示される。
ボタン操作が多いのは大きな時間ロスになり、ユーザのストレスとなりやすい。
-
ユースケースを考える
これまでの内容にも準ずるが、とにかくユースケースを考えて作りたかった。
雀荘で麻雀を打つ初心者ユーザは、多面待ちでもとにかく間違えずにあがりたい、あがった後はなるべく素早く点数を申告したい。
例えば以下のケースを考えた。
ユーザが多面待ちテンパイをしているとき、本アプリを使って待ち牌を検出してあがり待ちをしていたとする。
何巡目か後にあがり牌を掴み、あがることができた。
さあ、いざ点数計算だが・・・。
ここでもう一度手牌を読み込ませるのか?
いや、それはない。もう既に13牌は同じ牌を読み取り済みだ。
では、あがり牌のみ手動選択で持ってくる?
それでも良いが、もっと良いのは待ち牌一覧画面からツモってきた牌をポチッとすることでダイレクトに点数画面に遷移することだろう。
-
計算速度は1秒以内
最近の良い感じのスマホでは、1秒以内に計算が終わることを目標とした。
ユースケースとして高速性が必要なのもあるが、それとは別にアプリとして速度が遅いとユーザのストレスとなる。
ポイントとなることは、以下の2つである。
①スマホアプリ用の軽量高速オブジェクト検出機を生成する
②手牌の面子や塔子の何通りもの組み合わせ探索について、正確性を担保しながら、かつ高速に終わらせる必要がある
実際本アプリでは、私のXperia 10IVでは①も②も1秒足らずで計算終了してくれた。
以降は、本開発で特に苦労したこと・工夫したことを記載する。
麻雀牌検出器(物体検出)
本開発で使用した物体検出モデルは以下である。
SSD MobileNet V2 FPNLite 640x640
Model ZooにあるCOCO 2017 データセットの学習済みモデルを転移学習して生成した。
Google Colab × Tensorflow Object Detection APIが神がかっており、学習環境の構築には全く苦労しなかった。
勿論改造しまくったが、基本的にこれをベースにして学習ぶんぶん回した。
ColabはPro契約をして、良いGPUを使って学習回しまくった。
(合計で1万ちょいくらい課金したと思う)
とはいえ、良いモデルを生成するのに非常に時間がかかった。
何日経っても良い結果が得られない。
何がダメだったかというと、
① そもそも教師データが足りなかった
② 入力サイズが小さいモデルを使っていた
③ ハイパーパラメータがいまいちだった
④ 推論時の入力画像の前処理が足りなかった
⑤ 「手牌」という性質上、画質がどうしても悪くなる問題があった
では、これらの問題をどう解消したか?
① そもそも教師データが足りなかった
こちらはRoboflowで良い感じにデータを増やしていった。
Roboflowでは教師画像をアップロードして、物体をラベリングすることができる。
全てのラベリングが完了するとデータセットとして出力し、ついでにRoboflowの内部でモデルの生成・評価を行ってくれる。
またデータセット出力の際は、コントラスト調整やサイズ変更などの様々なデータかさましオプションから、データのかさましも行ってくれる。
Roboflowの生成したモデルはダウンロードできないので、主にはデータセットの生成&かさまし、またはRoboflow内のモデルをAPIでコールして推論する用途で使用される。
今回は、データセットの生成&かさましに使用した。
神である。
②入力サイズが小さいモデルを使っていた
最初は SSD MobileNet V2 FPNLite 320x320 でやっていたのだが、どうしても性能が出せなかった。
そこで SSD MobileNet V2 FPNLite 640x640 でやってみると格段に性能が良くなった。
サイズが大きくて大丈夫かと不安だったが、試しにスマホで動かしてみると普通に動いた。
最近のスマホは普通に高スペックなので、多少モデルのサイズ大きくなっても大丈夫なのかも。
③ ハイパーパラメータがいまいちだった
ハイパーパラメータで考慮が必要なのが、「学習率」と「バッチサイズ」である。
SSD MobileNet V2 FPNLite 640x640 では、Colabの良いGPUでもバッチサイズを大きくできない。
バッチサイズが変わると学習率も変える必要があるらしく、良い値を探求していた。
何回もパラメータを変えてトライし、最終的に SSD MobileNet V2 FPNLite 640x640 の私のモデルでは、以下の値が最も良いモデルとなった。
...
# バッチサイズ
batch_size: 16
...
# 学習率に関するパラメータ
cosine_decay_learning_rate {
learning_rate_base: .02
total_steps: 90000
warmup_learning_rate: .0066666
warmup_steps: 2000
}
...
# トレーニングする総ステップ数
num_steps: 80000
...
トレーニングの学習率は、warmup_learning_rate から始まり learning_rate_base まで、warmup_steps ステップを経て直線的に増加する。
その後、learning_rate_base から total_steps の終わりまで、指数関数的減衰を遂げる。
この学習率はバッチサイズに比例するという情報があるのだが、その情報結構正しそう。
またハイパーパラメータとは少し異なるが、学習における設定ファイルにはデータ拡張のオプションもいくつかあり、設定できる。
本モデルでは以下のようにした。
...
data_augmentation_options {
# 90度回転させる
random_rotation90 {
}
}
data_augmentation_options {
# 輝度を変える
random_adjust_brightness {
max_delta: 0.1
}
}
data_augmentation_options {
# コントラストを変える
random_adjust_contrast {
min_delta: 0.9
max_delta: 1.1
}
}
data_augmentation_options {
# サイズを変える
random_image_scale {
min_scale_ratio: 0.5
max_scale_ratio: 2.0
}
}
...
ここは各モデルに適した拡張を行うと良い。
デフォルトではランダムに切り抜きを行うオプションがあったりするので、外した。
ただの1筒を、切り抜かれた2筒として判定されたらたまったもんじゃない。
④ 推論時の入力画像の前処理が足りなかった
全くもって当然のことなのだけれど、学習した入力画像の前処理と、推論時の入力画像の前処理が一致していなければ正しい結果は得られない。
今回、推論時に怠っていた処理が「正規化」である。
リサイズで満足してしまっていた。
Object-Detection APIが設定ファイルの記述のみで、ある程度勝手にやってくれるので頭から抜けてしまっていたのもある。
例えば今回Dartで記載した以下の部分、前処理を記載する部分である。
// 推論前に入力画像を加工する前処理メソッド
TensorImage getProcessedImage(TensorImage inputImage) { // 入力画像
padSize = max(inputImage.height, inputImage.width); // リサイズに関する設定
if (imageProcessor == null) {
imageProcessor = ImageProcessorBuilder()
.add(ResizeWithCropOrPadOp(padSize, padSize)) // リサイズ処理1
.add(ResizeOp(INPUT_SIZE, INPUT_SIZE, ResizeMethod.BILINEAR)) // リサイズ処理2
.add(NormalizeOp(127.5, 127.5)) // ここ忘れてた!!正規化処理
.build();
}
inputImage = imageProcessor!.process(inputImage);
return inputImage; // 入力画像を加工して返す
}
⑤「手牌」という性質上、画質がどうしても悪くなる問題があった
どういうことかというと、手牌は非常に横長の画像であり(MAX14枚)、これを正方形にすると手牌1枚1枚の情報が超貧弱になる。
一般的なモデルの入力サイズは、ほとんどが正方形である。
勿論今回の SSD MobileNet V2 FPNLite 640x640 も入力画像は正方形なので、手牌の画像を正方形にリサイズしてやる必要がある。
しかしただ単にリサイズのみ行ってしまうと縦横比が大幅にゆがみ、検出できなくなってしまうので、以下のような黒埋め処理が必要である。
そして更にこれを 640 × 640 にリサイズし、入力画像とする。
ここだ。ここで手牌1枚の情報がすご~く小さくなる。
最近のカメラでは、横幅1920はごく普通だと思う。
上記の元画像の横幅が1920だとすると、1920 × 1920 ⇒ 640 × 640 に縮小することになる。
3分の1である。3分の1の情報が抜けてしまうことを意味する。
勿論情報落ち自体は今回に限った話ではなく、どの画像であれリサイズするのだから同じである。
ただ今回特例なのは、横長に14枚の牌が敷き詰められたこの状況である。
そもそも検出する物体が大きければ、情報落ちはそんなに気にならない。元々の情報が大きかったのだから。
正方形の画像に縦と横 4 × 4 で牌を配置すれば、1つ1つの牌は大きくなるのだから何も気にしなくていいだろう。
しかし今回はそうはいかない。
横に一斉に14枚を配置しなければならない。
すると1つ1つの牌は小さくなることを強いられるのであり、更にこれを3分の1に縮小すると牌の情報はどんどん小さくなる。
一般的に画像認識・物体検出系は情報が多い(解像度が高い)方が精度が良いとされる。
(勿論、学習時に低画質の画像ばかり使用していたのなら話は別だが・・・)
そのため、スマホ用のこのモデルで麻雀牌識別するのには限界があるかな・・・と思い始めていた。
ところが意外にも、何個か取りこぼしはあるものの前述したいくつかの対策によって、検出自体は多少成功するようになってくれた。
だから「まあこんなもんか・・・」で終わっても良かったのだが、世に出すものとしてやっぱり認識精度の高いものを作りたかったため、以下の工夫を凝らした。
入力画像を推論する前に、上記のような整形を行うことで、リサイズ率を大幅に減らした。
3分割したものを右から順に積み重ねることで、正方形に近づける。
例えば1920の横幅の画像であれば大体3分割されているため、640 × 640 にリサイズする際にほぼ解像度が落ちない。
このようにしたことで1つ1つの牌の情報減衰が非常に小さくなり、実際にこれで性能が爆上がりした。
ただ1つ注意点がある。
分割するうえで重複している範囲があるが、この範囲が必要である。
もし牌の切れ目の部分に分割の区切りが来てしまった場合、その牌はおそらく認識不可となる。
そうならないように1牌分重複させて分割しなければならない。
そしてそうなるとちょうど重複部分に配置された牌が、1枚なのに2枚として検出されるのではないか?という課題が残る。
しかしそれについては、そもそも物体検出で考慮されている重複排除の技術「IoUによる重複排除」を用いてどうにかできる。
以下のような手順である。
①検出結果の各座標を、3分割された入力画像の座標から、横長の元画像の座標に変換する。(すると分割時の重複部分に牌があった場合、その牌2つ分の検出結果の座標の重なりが発生する)
②厳格なIoU閾値で重複判定を行い、重複があれば除去する。
これで横一列に並んだ牌を1枚1枚重複なしで検出できる。
なんかPythonだと10行で済みそうな内容だが、Dartだと結構しんどかった。
そういうライブラリを見つけれなかったのもあり、Imageのリストを走査した私である。
点数計算&待ち牌算出&受入牌算出ロジック
本アプリケーションでは、点数計算~受入算出まで全て自作であり、ライブラリ等は一切使用していない。(というかそんなライブラリないか・・・)
面子探索
まず初めに面子を全探索する。
対子、刻子、刻子、刻子、刻子
↓ ↓ ↓
対子、順子、刻子、刻子、刻子
↓ ↓ ↓
対子、順子、順子、刻子、刻子
↓ ↓ ↓
対子、順子、順子、順子、刻子
↓ ↓ ↓
対子、順子、順子、順子、順子
みたいな形で、更に左の牌から再帰的に探索していく。
例えば上の手牌であれば、1索が対子となるときの各面子の組み合わせケースを探索し、次に2索が対子となるときの各面子の組み合わせ・・・という風に全探索する。
最終的に全ての牌があてはまれば、それは「あがり」であり、次は点数計算だ。
1枚、2枚塔子が余れば、それは「テンパイ」であり、浮いた塔子から待ち牌の算出が可能だ。
待ち牌の期待点数も算出したいので、ここでも点数計算処理を行う必要がある。
どれにも当てはまらなければ、それは「○シャンテン」止まりである。
受け入れが一番広い打牌を算出する必要があり、この時だけ次に「塔子探索」に移行する。
点数計算
まずは役について。
こちらは取りこぼしがあってはいけないのと、スマートなロジックにするために基本的に「消去法」を取り入れた。
まず最初に、本アプリが対象とする役を全てドカッと入れてしまう。
そしてその後に消去法でフィルタリングしていく。
例えば、刻子が3個以下のものは四暗刻、対々和にはなりえない。
刻子が2個以下のものは三暗刻にはなりえない。
のように。
前述した面子探索でどれかのパターンに当てはまっているので、刻子と順子それぞれの数は既知である。
そしてフィルタリング後、残っていて更にチェックが必要な役については適合しているかどうかを精査していく。
こうすることで複合役を逃しにくいのと、シンプルなロジックにできる。
麻雀の手役は一致することを判定するより、一致しないことを判定する方がはるかに簡単な場合が多い。
役が分かれば、あらかじめ役に紐づけておいた翻数(面前時と食い下がり時)について加算していけばよい。
ちなみに符計算は以下のようにすればよい。
- ツモ
七対子以外+2 - ロン
鳴き以外+10 - 刻子
前述の面子全探索で面子クラスの登録をするときに、クラスのメンバとして刻子の場合の符の加算数も入れておく。
そして点数計算時面子を順に探索し、加算。 - あがり方
手牌の一番右端に設定している和了牌を抜き出しておく。
前述の面子全探索で面子クラスの登録をするときに、和了牌でかつ単騎待ちかどうかを判定し、該当していればクラスのメンバとして符の加算数(+2)も入れておく。 - 対子
普通に対子の牌が何か見るだけ。
役牌であれば+2 - 七対子
25符固定
そして最も得点が高い組み合わせを、その手牌の得点として結果に出力する。
塔子探索
○シャンテンのものは、受け入れの広い打牌を算出する必要があるので、塔子探索が必要である。
面子探索した結果余った浮き牌について塔子探索する。
これも全探索である。
例えば上のような牌が余ったときに、以下のような順で再帰探索していく。
左端の牌から、
- 孤立牌となるパターン
- 対子となるパターン
- 両面塔子となるパターン
- カンチャン塔子となるパターン
を走査し、それぞれのパターンで再帰ルートに突入する。
どの牌も上記4つのいずれかのパターンに当てはまるため、この再帰探索は全探索となる。
これが面子探索のパターンの数だけあるので、普通に探索すると探索量が膨大になる。
ビジョンとしていた1秒以内なんて、間に合うわけがない。
ただ全探索しないと取りこぼしが怖く、全探索以外の優れたアルゴリズムが思い浮かばなかった。
そこで以下のような手順で探索量&計算時間削減を行った。
①面子探索完了後、面子のみの暫定シャンテン数を計算する。(シャンテン数は、8 - 2 × 面子数 - 1 × 塔子数
で算出できる)
②これまでの暫定シャンテン数から、どう頑張ってもベストシャンテン数に届かない組み合わせは除外する。
③面子探索が全てのパターンについて終わった後に、暫定シャンテン数が良いものから塔子探索を開始する。
④探索途中で以下のチェックを行う。
・現時点でのシャンテン数が、これまでのベストシャンテン数を上回っていれば、ベストシャンテン数を更新する。
・現時点で確定しているシャンテン数と残りの牌数から、ベストシャンテン数に届かないことが確定している場合、探索を中止する。
良さそうなものから探索していくので、④により後半の探索ががっつり削られる。
再帰探索というのは、深くなればなるほど分岐が増え、計算量が増大するので早期に中断することが非常に効果的である。
面子と塔子が割り出せれば、いくつかのパターンから、捨て牌 + 受け入れ牌を算出できる。
パターン | 捨て牌候補 | 受け入れ牌候補 |
---|---|---|
面子4組(対子なし) | ・塔子 ・孤立牌 |
・孤立牌の対子重なり |
面子と塔子が6組以上(対子あり) | ・塔子 ・孤立牌 |
・塔子 → 面子の変化 |
面子と塔子が5組以上(対子なし) | ・塔子 ・孤立牌 |
・塔子 → 面子の変化 ・孤立牌の対子重なり |
面子と塔子が5組(対子あり) | ・孤立牌 | ・塔子 → 面子の変化 |
面子と塔子が4組(対子なし) | ・孤立牌 | ・孤立牌の対子重なり |
面子と塔子が4組(対子なし)または3組以下 | ・孤立牌 | ・塔子 → 面子の変化 ・孤立牌の対子重なり |
受け入れ含めて最終面子が5組に届かない場合 | ・余剰牌(4枚目の字牌等) | ・余剰牌以外の牌全て |
Flutterによるアプリ開発
デザインについて
デザインセンスは欠片もなく、ダメダメではあるものの、多少分かってきたこともある。
ユーザのアクション関連で大切なのは、
- タップできるボタンが、タップできそうなこと
- スワイプできるアイテムが、スワイプできそうなこと
- アイテムをドロップできるスペースが、ドロップできそうなこと
に違いない。
要は○○できるものが、○○できそうなデザインであることが大切なのだ。
実際に今回デザインするうえで、検索キーワードには「押せそうな」や「スワイプできそうな」を多用していた。
それでもやはりセンスが大きく関わるのでイマイチ感はあるが、多少なりとも伝わると思う。
↑↑↑ 全体を透過させ浮遊感を演出 + タップアイコンを配置することで「タップできる」を演出してみたが、タップできそうに見えるだろうか・・・?
↑↑↑ スワイプできそうな感じを演出してみた。これはそんなに悪くない気がしてる。
Widgetについて
Stateless Widget + Provider で構築した。
Riverpodは開発当初は全然情報なく、間に合わなかった。
現状困ってなかったので、まあいっかと思った。
で、Providerの呼び出し方がたくさんあって非常に困った。
何が何なのか今でもよく分からない。
サイトの情報でも色んな書き方されている。
正解は分からないが、自分なりにこうかな~と思ったことを記載する。
特に再描画については以下のサイトがありがたかった。
-
Provider.of
foo = Provider.of<Foo>(context, listen: true); // Fooインスタンスが監視される foo = Provider.of<Foo>(context, listen: false); // Fooインスタンスを使用できる(監視はしない)
最初こっち使ってた。なんだけど、、ん~~・・・。
liten: true で変更検知するとcontextを引数に取ったWidget配下全てリビルドされたり、特定のメンバの変更検知する select 的な機能がなかったりで、これを使うメリットがよくわからない。
誰か知ってる方教えてください。
-
context.watch, context.read, context.select
foo = context.watch<Foo>(); // Fooインスタンスが更新される foo = context.read<Foo>(); // Fooインスタンスを使用できる(監視はしない) fooValue = context.select((Foo foo) => foo.value); // Fooインスタンスの「value」メンバのみを監視する
クラスのWidgetの再描画を気にしないケースではほとんどこれを使用していた。
selectがあって良い。
-
Consumer, Selector
// Fooインスタンスが監視され、変更時にはConsumer以下のみ再ビルドされる Consumer<Foo>( builder: (context, foo, child) { return Text('Foo Value: ${foo.value}'); }, ); // Fooインスタンスのメンバ「value」が監視され、変更時にはSelector以下のみ再ビルドされる Selector<Foo>( selector: (ctx, foo) => foo.value, shouldRebuild: (old, new) => old != new, builder: (ctx, value, _) { return Text('Foo Value: $value'); }, );
神。
準備(宣言)が結構大変なので、再ビルドの範囲を制限する必要がなかったり、状態を監視する必要がない場合は、context.を使うのが良い。
またFlutterでは、デザインを宣言的に記載できる点がとても良いと思った。
細かい配置やサイズの調整などが、Webアプリより分かりやすいし、簡単。
自由度が低い分、簡単でシンプルなデザインなら最高だと思う。
ただしFlutter自体はわけわからない挙動に何度も出会ったので、溺愛はしてない。
データ構造について
本アプリでは探索回数が多いこともあり、データ構造にList使うかSet使うかを、データのユニーク性関係なく性能という観点で悩んだことが多かった。
Dartに限ったことではないが、改めてこれらの使い分けのポイントを、性能の観点でまとめてみる。
- List が高速なケース
- インデックスを使用したデータの参照
- 末尾への○個のデータの追加
- 末尾の○個のデータの削除
- データのソート
- Set・Map が高速なケース
- 特定のデータの参照
- データの存在有無チェック
- 特定のデータの削除
今後のアップデートについて
本当は実装したくて仕方なかった機能がある。
槓の対応だ。
どう入れこむのが最適か非常に難しく、今回のバージョンでは妥協した。
もし入れ込むのであれば、それが槓をしなかったユーザの通常の手順をも増やしてしまうようなアップデートは避けなければならない。
あくまで特例扱いにすべきだろう。
例えば、4枚手牌が認識されれば、4枚集まったそれぞれの牌の中央に「槓」のボタンが表示され、それを押すことでグループ化される。とかだろうか。
また暗槓の場合は裏返すから、裏2枚に同じ牌2枚が挟まれていた場合は、暗槓扱いにするか。
となると裏の牌を学習しなおさなければならない・・・。
白と区別できるのだろうか・・・?色々心配。
いや、でもユースケースを考えると、槓した牌って絶対手牌にはないはずだ。
手動で追加するのが一番良いのだろうか?
手動で追加するアップデート案は結構すんなりあって、手牌選択画面に新しく「槓」というタブを用意し、そちらを選択してから牌を手牌に移すと、槓のグループが手牌に追加される。
デフォルトはもちろん1つずつの牌の操作なので、通常操作に影響を及ぼすことは全くない。
しかもこの槓のグループは、他の牌と同じく手牌中でドラッグ&ドロップによる並べ替えや、スワイプによる削除も可能なので、槓として特別に新たなボタンを設ける必要もない。
色々不安要素はあるが、やっぱり槓は重要度高いと思う。
ユーザ体験向上に向け、アップグレードに挑戦してみようと思う。
最後に
最後までご覧頂き、ありがとうございます。
物体検出&アプリ開発難しかったですが、出来たときは感動しました。
Webアプリ開発が人気な昨今ですが、ぜひiOSやAndroidアプリ開発にもトライしてみましょう!
また筆者は、Androidアプリ開発も初心者、物体検出も初心者、Flutterも初心者でございます。
間違い等ございましたら、温かくご指摘くださいませ。