✒️

[Tesseract.js]OCRエンジンを使った「字の下手さを競うゲーム」の製作

2024/06/11に公開1

初手で成果物を貼るスタイル

『AIに読める範囲でもっとも下手な字を書いた奴が勝ちオフライン』というタイトルのゲームを作りました。
https://github.com/qwegat/hateg
👆これがリポジトリです
https://qwegat.github.io/hateg/
👆これがプレイ用のサイトです
(※ランキング機能がありますが、情報共有にLocalStorageを使っているためオンラインで競うことはできません(後述))

👆プレイ画面はこんな感じです

前置き

タイトルについて

最初に謝っておきたいことがあります。本記事のタイトルには「字の下手さを競う」という文言が使われていますが、正直に書くとこれはある種のレトリックを含む書き方で、実際のところ、このゲームで競っているのは字の下手さではなくOCRエンジンによって算出される信頼値(Confidence)の低さです。
ゲームを学校の文化祭で展示した(後述)ところ、とある来場者の方[1]にこの欺瞞を看破されてしまい、たいへん肝を冷やしたので、記事内では先頭に記しておきます。

改めて――背景情報

筆者は本記事の公開時点で高校生で、在籍している学校におけるパソコン部のメンバーでもあります。弊校ではついこのまえ、二日間にわたる文化祭を終えたところでして。パソコン部ではこの文化祭に際して、パソコン室に並べられているPCを使い、個人的に制作した作品を展示する場を設けようという話になっていました。

その事実を、文化祭まであと2日というところで、急に思い出しました。

思い出したうえで、思いました。

\large{\bf ここ一年、なんかゴロゴロしていた以外の記憶がない}
\Large{\bf 個人的な作品など、何も作っていない}
\LARGE{\bf 加速する鼓動}
\huge{\bf 滲む手汗}
\Huge{\bf まずい}

ので、突貫で簡単なゲームを作ることにしました。当然ながら製作期間は2日です[2]
この記事では、今回作ったゲームについていろいろ書いていきます。

制作

検討

機材の発見

今回作るのは展示用のゲームですから、ウェブサイト上で配布するようなゲームと異なり、プレイヤーの機材環境をある程度こちらで規定することができます。というわけで、特定のガジェットを前提とするようなゲームにできたらいいかな、と思いました。
そういうわけで、パソコン室に使えそうなものがないか探していたところ……見つけたわけです。

写真を撮り忘れたのでいらすとやです

なんかペンタブを、2枚。
詳しい型番は調べていない[3]んですが、ワコムと書いてあって液晶がついていないタイプ[4]でちょっと古そうと考えると、たぶんIntuosとかBambooとかその辺の何かかなと思います。
単純に考えて、これらをPCに繋いだうえで展示を行えば、ゲームのプレイヤーがペンタブを使える環境にあることが確定します。普段は作る勇気がわかないような、マウスとペンタブで体験が大きく違うゲーム[5]のアイデアも、今回は実現できるということです。
これは、作るしかないでしょう。

ゲーム性の検討

ペンタブを使ったゲームを作るというのは決まりましたが、果たしてこれで何を描かせるかが問題です。描くといえば第一候補はイラストですが、あまりいいアイデアが出ませんでしたし、筆者が昔のペンタブというものを舐めていたため、「本当に絵を描ける精度なのか」などといったわけのわからない不安もありました[6]

絵が駄目なら……。

ではないか?

そういうことになりました。

方向性

少し考えた結果、字の下手さを競うゲームにすれば面白いのでは、というところに落ち着きました。以下のような感じです。

  1. お題となる漢字を一つ提示し、プレイヤーに手書きさせる
  2. 適当なOCR用のライブラリを持ってきて、プレイヤーが書いた漢字を読み取る
  3. ライブラリが算出した信頼値が低い(≒ほかの字との判別が難しい)ほど、スコアを高くする
    • ただしあまりにも適当に書きすぎると、そもそもお題とは別の漢字として判定されてしまうため、ギリギリを見極める必要がある!
  4. ランキングもあるよ

ミニゲームにおいて重要なジレンマのエッセンスを盛り込みつつ、ランキング機能でプレイヤーの競争を煽りもする……なかなかいいバランスじゃないでしょうか。いやいいバランスじゃなかったとしても時間がないので、もうこれを実装するしかありません。しました。

実装

使用するもの

  • Node.js
    • Pythonで開発するのもいいかなと思ったんですが、Webサイトとしての公開が容易に可能なほうがセットアップの手間が少なく済みそうでしたし、学校で実装をすることを考えるとGitHub Codespacesだけで開発を完結させられるのが魅力でした
  • TypeScript
  • react
    • 今回はNextとかではなく素のReactです。あまり書いたことがないのでコードと最適化がひどいことになってしまいましたが、どうせメンテナンスをする気がないので動けばいいかなと思いました(最悪)。何も考えずcreate-react-appを打っています
  • react-router-dom
    • ルーティングできたほうがやりやすかったので……
  • tesseract.js
    • ググったら出てきたOCRライブラリです。名前からも察せられるようにTesseractというOCRエンジンのラッパーにあたるようです
  • mui
    • UIコンポーネントフレームワーク。一瞬tailwindを使いかけたのですがあまり経験がなかったので取りやめてこっちにしました。ただCSSファイルの作成もThemeのカスタムも拒否った結果、コンポーネントのデザインを微調整したいときにstyleタグ直書き野郎になっていてかなり最悪です。あとレスポンシブもちゃんとやってません[7]
  • fabric
    • Canvas操作用のライブラリで、今回はユーザーが漢字を書きこむインターフェースのために導入しました

ダイジェスト

本記事の要点は実装部分の詳細な説明ではないので、適当に巻きつつかいつまんで紹介します。

ルーティング

react-router-domを導入しているので、機能ごとにページを分割したうえで、各ページのURLを指定するなどできます(ふわっとした理解によるふわっとした説明)。
今回はこんな感じにしました。

https://.../#/ 👈トップページ
https://.../#/Game 👈ゲームプレイ部分(漢字を書く画面)
https://.../#/Result 👈スコアなどを表示するページ
https://.../#/Ranking 👈ランキングページ

遷移図にするとこうです。

プレイ結果の保存

ランキング機能があったほうが人が集まるという確信があったので、プレイ結果を保存できるようにすることに決めていました。DB用の適当なライブラリを導入しかけましたが、今回は展示用PCの内部でだけ情報を共有できればそれでいいということを加味するとLocalStorageでゴリ押すだけでいいことに気づき、そうしました。

export interface PlayResult {
    playerName: string; //プレイヤー名
    base64Uri: string; //プレイヤーが書いた漢字の画像をbase64フォーマットに変換したもの
    score: number; //スコア
    character: string; //お題となる漢字
    date: string; //日付。`new Date()`を`toString()`したものをそのまま突っ込んでいるが、冷静に考えるとnumber型にしてUNIX時間を格納するとかでよかった
}

……という感じのオブジェクトの配列をJSON.stringify()し、直でLocalStorage上のいち変数に書き込んでいます。

漢字を書く機能

fabricisDrawingMode: trueを書くと一発です。

/*...*/
export const DrawCanvas = forwardRef((props: any, ref: any) =>{
    const [fabricCanvas, setFabricCanvas] = useState<Canvas>()
    

    const initialize = () =>{
        const canvas = new fabric.Canvas(canvasId, {
            isDrawingMode: true,
            backgroundColor: "rgba(255,255,255,0)",
        });
        canvas.setWidth(300)
        canvas.setHeight(300)
            
        canvas.freeDrawingBrush.color = "#000000";
        canvas.freeDrawingBrush.width = 7;
        canvas.clear()
        setFabricCanvas(canvas)
    }
/*...*/

採点

/Resultへの遷移は/Gameからしかできないので、遷移操作時にlocation.stateに描かれた文字の情報を突っ込むことでうまいこと受け渡しをします。

/*...*/
interface State {
    char: string;
    base64Uri: string;
}

export const ResultPage = (): JSX.Element => {
    const location = useLocation(); 
    const {char, base64Uri} = location.state as State
/*...*/

    const {char, base64Uri} = location.state as State
    const [script,setScript] = useState<string>("");
    const [waiting,setWaiting] = useState<boolean>(true)
    const [score,setScore] = useState<number>(-1)
    const navigate = useNavigate()

    useEffect(() => {
        (async () => {
            const worker = await createWorker("jpn",OEM.TESSERACT_ONLY)
            //`PSM.SINGLE_CHAR`は`OEM.TESSERACT_ONLY`モードを指定しないと動かないっぽかった
            await worker.setParameters({tessedit_pageseg_mode: PSM.SINGLE_CHAR})
            //`PSM.SINGLE_CHAR`: 入力された画像が全体で一つの文字を表していると解釈するモード
            await worker.setParameters({tessedit_char_whitelist: kanjis})
            const { data } = await worker.recognize(base64Uri);
            console.log(data)
            if (data.symbols.length>0) {
                setScript(data.symbols![0].text)
                setWaiting(false)
                if (data.symbols![0].text === char) {
                    setScore(Math.round((100-data.symbols![0].confidence!)*1000)/1000)
                    //小数点以下第4位を四捨五入
                } else {
                    setScore(0)
                }

            } else {
                
                setScript("判読不能")
                setScore(0)
                setWaiting(false)
            }
            await worker.terminate();
          })();
    }, [base64Uri, char]);

base64形式で渡される「ユーザーが描いた漢字の画像」を読み込み、事前に用意されている常用漢字リストkanjis内のどれか一つとして解釈する処理です。解釈の結果がお題と同じ漢字なら前述のようにConfidenceが低いほど高い下手スコアを与え[9]、違う漢字の場合0点とします。

その他

あとは良い感じにランキングとかを実装して終わりです。

展示

ネーミングについて

先ほども触れましたが、『AIに読める範囲でもっとも下手な字を書いた奴が勝ちオフライン』 というタイトルにはかなりの欺瞞が含まれています。分解してみてみましょう。

  • AIに
    • 欺瞞。 実際Tesseractはニューラルネットワークを利用したOCRエンジンなのでAIという言葉は間違ってはいないのだが、とりあえず「AI」というバズワードに乗っかっておこうという軽薄な姿勢がある
  • 読める範囲でもっとも
    • 欺瞞。 納期の短さゆえにチューニングをほとんどまったくやっていないだけで、本来の"AI"はもっと高精度で漢字を認識できる。いたずらにAIの能力を貶める、不用意な文言
  • 下手な字を書いた奴が勝ち
    • 欺瞞。 前述のとおり、競うのは下手さではなく信頼値の低さ。とにかくわかりやすければいいだろうとディティールを無視して真実から離れた場所を目指す魂胆が透けて見える
  • オフライン
    • 本当。

まあ、そういうものです。

当日

何とか前日の深夜に実装を終え、いざ、文化祭当日です。
1日目はペンタブのセットアップが上手くいかなかったので[10]、こんなこともあろうかと持参しておいた私物の板タブを置いて挑みました。
1時間ごとにお題の漢字が切り替わるようにして展示をしたところ、想像していたよりたくさんの人が遊びに来てくれました。当初の思惑に反して板タブをうまく扱えない人が結構いたので結局みんなマウスで字を書いていたのがちょっと想定外でしたが、まあ展示は展示です。
何度もリトライしてハイスコアの出し方を探っていた方もいてうれしかったです。皆さんのアプローチを見てみたところ、

  • 利き手と逆の手で書いてみる
  • 極端に小さな文字で書いてみる
  • ぐにゃぐにゃの字で書いてみる

など、筆者自身でも想定していなかったようなアプローチが見られて感動しました。

筆例

せっかくなので、いくつか来場者の方が書いた漢字を紹介したいと思います。容量削減の関係で画質が粗いですが、ご了承ください。


『交』36.916点。先述の「極端に小さな文字」アプローチの成功例といえる


『臣』32.063点。『臣』はOCRエンジンにとって判別しやすい漢字のようで全体的に下手スコアが低く出る傾向にあったが、逆に言えば大きく崩しても別の漢字と判定されるリスクが低いことになる


『臣』33.991点。線の曲がり具合はそこまででもないが、全体的な形状のアバウトさで点を稼いでいるテクニカルな一筆


『町』45.456点。それで行けるのか、という衝撃が強い

また、『雪』のお題は個人的に今回の展示で最も面白く、いろいろな角度からの「下手な字」があってランキングを見るだけでとても楽しかったです。


『雪』32.265点。これだけ崩しても25位という事実が『雪』の魔境ぐあいを強調する


『雪』36.330点。雨冠の中の点を簡略化するテクニックは多くの人が使っていた。ちなみに登録プレイヤー名が『帰る』で、背後にあるかなりの試行錯誤をうかがわせる


『雪』45.596点。「極端に小さな文字」アプローチがこれくらい複雑な漢字にも通用するのは予想外だった


『雪』40.821点。うそやろ?


『雪』47.431点。うそやろ?????


『雪』50.630点。『雪』1位。筆者は感涙した

まとめ

楽しかったです。

脚注
  1. 来場者の方あるある:強そう ↩︎

  2. 前述のリポジトリの中にあるコードが大変汚らしく、とりあえずみたいな感じで打ったcreate-react-appの残滓を肉体の隅々に纏っているのも、そういう理由があるからなのですね ↩︎

  3. 面倒くさがっているだけなので、普通にググれば出てくるとは思います ↩︎

  4. 俗にいう「板タブ」 ↩︎

  5. 既存の作品を例として挙げさせていただくとGartic Phoneなど。あれは対戦ゲームなので全員が使用機材をマウス/ペンタブで統一すれば特に弊害は発生しませんが、そういう意味でもプレイ環境を固定できる場面は有効と言えます ↩︎

  6. おそらく、ペンタブレットが(いわゆる)スマートフォンと同じくらいの時期に発明された技術だと思っている。まったく若者の無教養とは恐ろしいものです ↩︎

  7. ペンタブ同様プレイヤーの使うディスプレイについてもパソコン室のもので固定なので、とりあえずそれに対応しておけば問題はないという判断 ↩︎

  8. 容量をケチれるが、透過情報が保存されない ↩︎

  9. 具体的には100からConfidenceを引いた値です ↩︎

  10. 2日目にドライバを入れたらなんとかなった ↩︎

Discussion

NPG418NPG418

パソコン部の出し物で一番の人気でした
彼を讃えよ