OpenCVとSpeechRecognitionで雀魂の音声操作をやってみる

# はじめに
雀魂をプレイしてるときにクリックすら億劫になるときがありませんか？私はあります。ふんぞり返って声で操作出来たらいいのにと。なのでそれを実現するためのプログラムを作ってみることにしました。

# 音声認識
## PyAudio
まずは音声を入力するところからです。マイクからの音声データを順次処理したいのでPyAudioのcallback modeというものを使います。

https://people.csail.mit.edu/hubert/pyaudio/docs/

```python
SAMPLERATE = 44100
def main():
    audio = pyaudio.PyAudio() 
    stream = audio.open(
        format = pyaudio.paInt16,
        rate = SAMPLERATE,
        channels = 1, 
        input_device_index = 1,
        input = True, 
        frames_per_buffer = int(SAMPLERATE*1.5),
        stream_callback=callback
    )  
    stream.start_stream()
    while stream.is_active():
        time.sleep(0.01)
        
    stream.stop_stream()
    stream.close()
    audio.terminate()
```

こいつは音声データの準備ができたらcallback関数にそいつを渡してくれます。
`audio.open`で設定をします。色々項目がありますが特に弄ってみたところで`frame_per_buffer`がcallback関数に渡す音声データあたりのサンプル数です。

`frame_per_buffer = SAMPLERATE * 4`(4秒分)
```
['バスガス爆', 'ばす ガス爆', 'バスガス爆発', 'バスがす 爆', 'ばすがす 爆']
```
`frame_per_buffer = SAMPLERATE`
```
['バス', 'ばす', 'バスが', 'ばすが', 'バスガ']
['初', 'はつ', '発']
```
小さくするとcallbackが呼び出されるまでが早いんですがその分文章がブツ切れになります。麻雀の発声は短いのでとりあえず`frame_per_buffer = int(SAMPLERATE * 1.5)`とします。
## recognize_google
実は先ほどすでに使ってましたが音声データを文字に起こすのにはSpeechRecognitionの`reconize_google`を使います。`audio.open`で指定したcallback関数の中に処理を書いてきます。

https://github.com/Uberi/speech_recognition/blob/master/reference/library-reference.rst

```python
def callback(in_data, frame_count, time_info, status):
    try:
        #AudioDataインスタンスを作成、第3引数は量子化byte数
        audiodata = sr.AudioData(in_data,SAMPLERATE,2)
        result = sprec.recognize_google(audiodata, language='ja-JP',show_all=True)
        if "alternative" in result:
            transcript = []
            for n in result["alternative"]:
                transcript.append(n["transcript"])
            #以下のif-else文の繰り返し。
            if any(any(s in t for t in transcript) for s in ["ポン","チー","等"]):
                #認識結果を画像認識の方に渡す。
            elif
            # ...
            # 繰り返し終わり
    except sr.UnknownValueError:
        pass
    except sr.RequestError:
        pass
    return (None, pyaudio.paContinue)
```

返ってきた`result`の中身はこんな感じになっていますので上の処理では`transcript`だけ取り出して使っています。精度は申し分なさそうですがかなりハキハキ発声する必要があります。
```
{'alternative': [{'transcript': 'リーチ', 'confidence': 0.91845304}, {'transcript': 'ビーチ'}, {'transcript': 'びーち'}], 'final': True}
```

if文の条件はごちゃごちゃしてますが例えばリーチの場合を例にとりますと`transcript`の中に一つでも`["リーチ","りーち","ビーチ","ピーチ"]`のいずれかの文字列を部分的に含むものがあれば真になるって感じです。
7m7s7p(ちーまん、ちーそう、ちーぴん)とチーが混ざりそうですがチーをするタイミングと牌を切るタイミングは異なるのでチーの判定を後に行えば大丈夫です。
辞書型での比較も考えましたが部分一致ですので正規表現書くの大変そうですし速度も知覚できるほどは変わらなそうですからやめました。

callback関数の呼び出しから条件分岐一番下まで0.6秒程度でした。そのうちほとんどの時間が
`result = sprec.recognize_google(audiodata, language='ja-JP',show_all=True)`の行の実行に使われているようです。


# 画像認識
## 特徴量マッチング
音声に対応した牌を切ってもらうためにその牌が画面のどこにあるかを特徴量マッチングで調べます。今回は動作が速くて拡大縮小、回転などにも強いといわれるAKAZE特徴量を試してみます。

```python
import cv2

akaze = cv2.AKAZE_create()
bf = cv2.BFMatcher()

img1 = cv2.imread("screenshot.png")
img2 = cv2.imread("image/mj22.png")

kp1,des1 = akaze.detectAndCompute(img1,None)
kp2,des2 = akaze.detectAndCompute(img2,None)

matches = bf.knnMatch(des1, des2, k=2)

good = []
ratio = 0.5
for m, n in matches:
    if m.distance < ratio * n.distance:
        good.append(m)
        
img_matches = cv2.drawMatches(img1,kp1,img2,kp2,good,None,
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS)

cv2.imwrite("img_matches.png", img_matches)
```


![img_matches1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/2993ee34-2dc9-85c3-c5d9-5ce5454fc52f.png)
ratio = 0.5
あえて他の牌と誤認されそうな2sでマッチングしてみましたがこの程度なら大丈夫そうかな。次はブラウザサイズを変更して試してみます。

![img_matches2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/818a94cd-a667-e587-f550-963cde888e09.png)
ratio = 0.8
あれ。ratioが低いと表示されるマッチングがなかったので大きくしてみましたが全然関係ないところがマッチングしてました。

ググってみると先駆者様がいましたが同じく苦しんでいるご様子。

https://qiita.com/xenepic/items/4aa94bece50364002497

う～～ん。~~めんどくさいので~~今回は私の環境で動きさえすればいいので拡大縮小はいったん無視します。

## テンプレートマッチング
拡大縮小を見ないのであればテンプレートマッチングでも目的は達成できそうなので今度はそちらも試してみます。

```python
import cv2,time

img1 = cv2.imread("screenshot.png")
img2 = cv2.imread("image/mj22.png")

match = cv2.matchTemplate(img2,img1,cv2.TM_CCOEFF_NORMED)
_,maxVal,_,maxLoc = cv2.minMaxLoc(match)
if maxVal > 0.65:
    x = maxLoc[0] + int(img2.shape[1]/2)
    y = maxLoc[1] + int(img2.shape[0]/2)
    cv2.circle(img1,(x,y),10,(0,255,0),5)
    cv2.imshow("img",img1)
    cv2.waitKey(0)
    cv2.destroyAllWindows()
```
![img_matches.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/29ff8519-979e-d20b-bb1d-cb42037fdcf0.png)
いい感じです。ちゃんと2sにマッチしてますね。マッチング時間も計測したのですがAKAZEが0.3秒、テンプレートマッチングが0.2秒程度でした。ということで今回はテンプレートマッチングで実装していきます。

## スクリーンショット
テンプレートマッチングを行う上で当然画面のデータが必要になります。まあスクリーンショットとるだけなんですが今回は少しでも速くしようということで`dxcam`というパッケージを使わせていただきました。`pyautogui.screenshot`と比べてそこそこ速くなります。注意点として`camera.grab()`は前回からフレームに変化がないとき`None`を返します。

https://github.com/ra1nty/DXcam

```python
import pyautogui,time,dxcam

camera = dxcam.create()

start = time.time()
for i in range(100):
    pyautogui.screenshot()
print((time.time() -start)/100)

start = time.time()
for i in range(100):
    camera.grab()
print((time.time() - start)/100)
```
```:実行結果
0.0372364616394043
0.00021941661834716796
```





# 実装
一切手を使わずにプレイしたいので打牌、鳴き、リーチ、上がりのすべてを認識させます。またQoLのためにツモ切りと鳴きのオンオフも音声入力でできるようにします。やることは音声入力に対応した牌や鳴きボタンの画像をスクリーンショットにマッチングさせて座標を割り出し`pyautogui`でクリックさせるだけです。

ただしちょっと面倒なことにチーやポンは鳴いた後に鳴く形の選択が必要なことがあります。そこで左から何番目を選ぶかを音声で入力し、座標は文字とキャンセルボタンの位置関係が選択肢の数に応じて変化することを基に決定しています。だいぶごり押しなのでもう少しスマートな方法があればよかった。

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/bde51dbb-f9ef-f639-cbcd-667ab9356d32.png)

![mjChoiceStr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/6d008d3a-8e67-ce38-8397-2b2a6d68a6fd.png)![mjChoiceMark.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/bf33e079-d1e1-163d-38de-cff9776c06f5.png)

ツモ切りはツモ牌が他の手牌から浮いていることを利用します。こんな画像でマッチングを取ればツモ牌がマッチするって寸法です。
![mjPaiTop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/1ca055a0-c53d-ceda-3466-120664309ec7.png)

<br>
出来上がったコードはこんなです。音声認識のところのelifの羅列だけはほんとにごちゃごちゃしてるんで省略しました。

```main.py
import speech_recognition as sr
import pyaudio, time, pyautogui, cv2, dxcam

SAMPLERATE = 44100
sprec = sr.Recognizer()
camera = dxcam.create()

#画像の読み込み
mjchoicestr = cv2.imread("image/mjChoiceStr.png")
mjchoicemark = cv2.imread("image/mjChoiceMark.png")

#m->s->p->char->call->others
imageset = [[] for i in range(6)]
for i in range(6):
    if i < 3:
        for j in range(10):
            imageset[i].append(cv2.imread("image/mj" + str(i+1) + str(j+1) + ".png"))
    elif i < 5:
        for j in range(7):
            imageset[i].append(cv2.imread("image/mj" + str(i+1) + str(j+1) + ".png"))
    else:
        for j in range(3):
            imageset[i].append(cv2.imread("image/mj" + str(i+1) + str(j+1) + ".png"))
#画像の読み込み終わり

def callback(in_data, frame_count, time_info, status):
    try:
        #AudioDataインスタンスを作成、第3引数は量子化byte数
        audiodata = sr.AudioData(in_data,SAMPLERATE,2)
        result = sprec.recognize_google(audiodata, language='ja-JP',show_all=True)
        if "alternative" in result:
            transcript = []
            for n in result["alternative"]:
                transcript.append(n["transcript"])
            print(transcript)
            if any(any(element in t for t in transcript) for element in ["ポン","チー","等"]):
                click(n,m) #n,mは牌種や数字などを区別する
            elif #...
            #繰り返し終わり
    except sr.UnknownValueError:
        pass
    except sr.RequestError:
        pass
    return (None, pyaudio.paContinue)

def click(flag1,flag2):
    global img1
    #鳴き選択の場合
    if flag1 == 6:
        resultStr = cv2.matchTemplate(mjchoicestr,img1,cv2.TM_CCOEFF_NORMED)
        _,maxValStr,_,maxLocStr = cv2.minMaxLoc(resultStr)
        if maxValStr > 0.7:
            resultMark = cv2.matchTemplate(mjchoicemark,img1,cv2.TM_CCOEFF_NORMED)
            _,_,_,maxLocMark = cv2.minMaxLoc(resultMark)
            distance = (maxLocMark[0] + int(mjchoicemark.shape[1]/2))-(maxLocStr[0] + int(mjchoicestr.shape[1]/2))
            centerx = 942
            centery = 747
            pair = 142
            gap = 36
            x = 0
            y = centery
            if distance < 218 + 90/2:
                #2choice
                if flag2 == 0:
                    x = centerx - gap/2 - pair/2
                elif flag2 == 1:
                    x = centerx + gap/2 + pair/2
            elif distance < 307 + 90/2:
                #3choice
                if flag2 == 0:
                    x = centerx - pair/2 - gap - pair/2
                elif flag2 == 1:
                    x = centerx
                elif flag2 == 2:
                    x = centerx + pair/2 + gap + pair/2
            elif distance < 396 + 90/2:
                #4choice
                if flag2 == 0:
                    x = centerx - gap/2 - pair - gap - pair/2
                elif flag2 == 1:
                    x = centerx - gap/2 - pair/2
                elif flag2 == 2:
                    x = centerx + gap/2 + pair/2
                elif flag2 == 3:
                    x = centerx + gap/2 + pair + gap + pair/2
            elif distance < 485 + 98/2:
                #5choice
                if flag2 == 0:
                    x = centerx - pair/2 - gap - pair - gap - pair/2
                elif flag2 == 1:
                    x = centerx - pair/2 - gap - pair/2
                elif flag2 == 2:
                    x = centerx
                elif flag2 == 3:
                    x = centerx + pair/2 + gap + pair/2
                elif flag2 == 4:
                    x = centerx + pair/2 + gap + pair + gap + pair/2
            if x > 0:
                pyautogui.moveTo(x, y)
                pyautogui.click(x, y)
                pyautogui.moveTo(200, 200)
    #それ以外
    else:
        result = cv2.matchTemplate(imageset[flag1][flag2],img1,cv2.TM_CCOEFF_NORMED)
        _,maxVal,_,maxLoc = cv2.minMaxLoc(result)
        if maxVal > 0.65:
            x = maxLoc[0] + int(imageset[flag1][flag2].shape[1]/2)
            y = maxLoc[1] + int(imageset[flag1][flag2].shape[0]/2)
            pyautogui.moveTo(x, y)
            pyautogui.click(x, y)
            pyautogui.moveTo(200, 200)

def main():
    global img1
    audio = pyaudio.PyAudio() 
    stream = audio.open(
        format = pyaudio.paInt16,
        rate = SAMPLERATE,
        channels = 1, 
        input_device_index = 1,
        input = True, 
        frames_per_buffer = int(SAMPLERATE * 1.5),
        stream_callback=callback
    )  
    stream.start_stream()
    temp = None;
    while stream.is_active():
        img1 = camera.grab()
        if img1 is None:
            img1 = temp
        else:
            temp = img1
        time.sleep(0.01)
        
    stream.stop_stream()
    stream.close()
    audio.terminate()

if __name__ == '__main__':
    main()
```


# いざ実行

![2023-12-22-17-52-18.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/c2b83aad-ee5a-8025-d476-064196846d84.gif)

。。。
んーーーーなんか思ってたのと違う。
体感時間ですが発声してからコンソールに認識された文字列が表示されるまで1秒程度かかっています。もっと快適に遊べるようになると思ってたんですが現実は厳しい。

問題点を列挙すると
* テンポが悪い。
* 認識してもらえるように発声するのがかなり疲れる。<--これが一番致命的。
* 上と関係してますが短い語句は認識してもらいにくい。
* 赤を区別するのが難しい。一応赤は丸印がついているので区別できるはずだが結構な割合で間違える。

![mj15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/a0672354-d176-b938-187b-624f65b7d2c3.png)![mj110.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629545/b828cf7e-fefc-046d-a7cd-ad68f3e6830d.png)

などなど

テンポの悪さの原因のほとんどが音声認識周りなのでそこが短縮されればテンポはかなり良くなりそうですが発声が疲れる問題は解決しようがありません。口を動かすのが手を動かすより疲れるなんて当たり前のこともう少し早く気付くべきでした。
こいつはお蔵入りです。悲しいですが記事のネタぐらいにはなったのでよしとします。

# 終わりに
大分長くなってしまいましたがここまで読んでいただきありがとうございました。
音声入力はもっと入力回数が少なくてゆったりしたテンポのゲーム向きなのかなといった印象です。
脳波で入力できる時代が来るのを待ちます。