COTOHA APIとCloud Vision API で音声合成して絵本を読ませてみた話

#COTOHA APIとは
NTTさんが出している言語解析などに使えるAPI群です。
構文解析などだけではなく、音声認識や音声合成など(有料)もついているので、これがあれば会話ロボットや、発話解析などの大体のことはできます！
今までコツコツ実装していたキーワード抽出や、言いよどみ除去など、かゆいところに手が届くような機能も満載ですし、ディープラーニングでのユーザー応答の一致率とかも取れちゃうので、精度があれば日本語を扱う上では一番最強なのではないでしょうか。

[COTOHA APIのページ](https://api.ce-cotoha.com/contents/index.html)
>構文解析、照応解析、キーワード抽出、音声認識、要約など、様々な自然言語処理・音声処理APIを提供しているサービスです。NTTグループの40年にわたる研究成果である、日本語辞書や単語を3000種以上の意味性分類する技術などを活用し、高度な解析をAPIで手軽に利用できます。

#今回の製作物
絵本を撮影した画像から文字を抽出し、その文章に解析をかけて、演出をつけ、シアターとしてアウトプット出来たらものすごく面白いかもしれない、と思ったので、そのプロトを試しに作ってみた。
コロナウィルスのせいで嫁の実家に帰っている娘と会えない日々を過ごしているので、おさまったら娘と遊びたくて作った。
普通に文字認識→音声合成につなぐと、ひらがな棒読みマンになるので、それはいただけない。
娘の教育にもよくない。
そこで、COTOHA APIの出番である。
COTOHA APIを使って、感情豊かに絵本を読み上げる。

大まかな流れとしては、
1. Cloud Vision OCRで画像からテキストを抽出する
2. google transrateでひらがなを漢字に変換する
3. COTOHA APIの音声認識誤り検知(β)で、変換ミスを補正する
4. COTOHA APIの感情分析で文章の感情を認識する
5. COTOHA APIのユーザ属性推定(β)で登場人物のペルソナを解析する
6. HOYA VoiceText APIで最適な話者と話し方を選定し、音声合成する
の手順である。

各フェーズごとに結果をtxtやjsonのファイルで保存し、次のフェーズで使うように書いている。

#1. Cloud Vision OCRで画像からテキストを抽出する
こちらに関しては今回メインではないので深くは触れない。
詳しく知りたい方は別で書いている[こちら](https://qiita.com/hatt_takumi/items/5f88cafc82f5e31e6375)などを参考にしてほしい。
今回テストに用いた絵本は、ガース・ウィリアムズの"しろいうさぎとくろいうさぎ"である。
![500_Ehon_582.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/467051/da1b5e66-946e-2d86-96ed-fe3441086100.jpeg)

これを選んだ理由は、なんとなく認識しやすそうだったのと、自分自身が初めて買ってもらった絵本で、もう死ぬほど読んでもらったやつだからである。
ソースコードは以下。
基本的に、出現するのは日本語のみであるという仮定の下、英語は除去している。

<details><summary>ソースコード</summary><div>

```python
import copy
from google.cloud import vision
from pathlib import Path
import re

def is_japanese(text):
    if re.search(r'[ぁ-ん]', text):
        return True
    else:
        return False

client = vision.ImageAnnotatorClient()
row_list = []
res_list = []
text_path = "./ehon_text/text.txt"

with open(text_path, 'w') as f:
    for x in range(1, 15):
        p = Path(__file__).parent / "ehon_image/{}.png".format(x)
        with p.open('rb') as image_file:
            content = image_file.read()
        image = vision.types.Image(content=content)
        response = client.text_detection(image=image)
        if len(response.text_annotations) == 0:
            row_list.append("-")
        for lines in response.text_annotations:
            if lines.locale != "ja":
                for text in str(lines.description).split("\n"):
                    if is_japanese(text):
                        print(text)
                        f.write(text + '\n')
            else:
                print(lines.description)
                f.write(lines.description)
            break
        f.write("\n")
```
</div></details>

実行結果は以下のような感じ(一部抜粋)さすがに100%とはいかないが、中々の精度である。
文章のほとんどがひらがなであるし、認識しやすいのかもしれない。
"き"と"さ"や、"ぽ"と"ぼ"などが難しいようで、よく間違える。
今回の絵本が全体的に絵に対して文字が小さいので、解像度の問題も大きい。
試しに文字だけを大きめに撮ると正しく認識した。
幸いにも今回結果のテキストは表示せず、音声合成されるので、仮に"たんぽぽ"が"たんぽぼ"になっていたとしても一瞬そう読まれた気がする程度でそこまで強い違和感はない。
娘にもばれないはず。

```
しばらくすると、くろいうさぎは すわりこみました。
そして、とても かなしそうな かおをしました。
「どうかしたの?」
しろいうきぎが ききました。
「うん、ほく、ちょっと かんがえてたんだ」
くろいうさぎは こたえました。
```

#2. google transrateでひらがなを漢字に変換する
画像からの認識に関してはひらがなの方がありがたいが、これ以降のテキストを用いた操作は漢字かな交じりの文章の方がよい結果が出る(はず)。
日本語というのはメンドクサイ言語で、漢字かな交じりか、ひらがなのみかでプログラムが理解する難易度が大きく変わってくる。音の情報しかないひらがなのみでは、意味を解析することは難しい。
音声合成の際の読み上げのイントネーションも違うし、解析にかける際の精度も漢字が入っているほうがよいはず。

とりあえず今回はgoogle transrateを使った。
ソースコードは以下。

<details><summary>ソースコード</summary><div>

```python
import urllib
import json

kanji_text_path = "./ehon_text/kanji_text.txt"

with open('./ehon_text/text.txt', 'r') as f:
    lines = f.readlines()

url = "http://www.google.com/transliterate?"
kanji_text = ""

with open('./ehon_text/kanji_text.txt', 'w') as f:
    for line in lines:
        if line == "\n":
            f.write(line)
        else:
            param = {'langpair':'ja-Hira|ja','text':line.strip().replace('　','').replace(' ','')}
            paramStr = urllib.parse.urlencode(param)
            readObj = urllib.request.urlopen(url + paramStr)
            response = readObj.read()
            data = json.loads(response)
            for text in data:
                kanji_text += text[1][0]
            print(kanji_text)
            f.write(kanji_text)
            kanji_text = ""

```
</div></details>

実行結果はこんな感じ。
"どうかしたの？"が"同化したの？"になっているのがつらい。。
金鳳花(きんぽうげ)なんかもちゃんと変換されているのだが、これはむしろ音声合成APIがちゃんと読み上げられるか微妙になってくるので、あまりよくないかもしれない。

```
しばらくすると、黒いうさぎは座り込みました。
そして、とても悲しそうな顔をしました。
「同化したの？」白いウサギ歌が聞きました。
「うん、僕、ちょっと考えてたんだ」黒いうさぎは答えました。
それから、二引きは、ヒナギクゃ金鳳花の咲いている野原で、かくれんぼをしました。
```

#3. COTOHA APIの音声認識誤り検知(β)で、変換ミスを補正する
ここで、少し興味があったので、上記の変換ミスの混じった文章を音声認識誤り検知(β)にかけると補正できないかと思い、試してみた。
音声認識でも、発話が短かったりすると構文解析が不十分で誤変換が起こったりする。それを補正するものなので、今回の目的で使用しても、使用方法としてはあっているはず。
ソースコードは以下。
一応信頼度が0.9を超えるものだけ、第一候補の結果と入れ替えるようにした。

<details><summary>ソースコード</summary><div>

```python


import requests
import json

access_token_publish_url = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
api_base_url = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientid = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientsecret = "XXXXXXXXXXXXXXXXXXXXX"

headers = {'Content-Type': 'application/json',}
data = json.dumps({"grantType": "client_credentials","clientId": clientid,"clientSecret": clientsecret})
response = requests.post(access_token_publish_url, headers=headers, data=data)
print(response)
access_token = json.loads(response.text)["access_token"]

api_url = api_base_url + "nlp/beta/detect_misrecognition"
headers = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json;charset=UTF-8"}

with open('./ehon_text/kanji_text.txt', 'r') as f:
    lines = f.readlines()

with open('./ehon_text/kanji_text2.txt', 'w') as f:
    for line in lines:
        print(line)
        data = json.dumps({"sentence": line})
        response = requests.post(api_url, headers=headers, data=data)
        result = json.loads(response.text)
        if result["result"]["score"] > 0.9:
            for candidate in result["result"]["candidates"]:
                if candidate["detect_score"] > 0.9:
                    line = line.replace(candidate["form"], candidate["correction"][0]["form"])
        # print(response)
        # print(json.loads(response.text))
        print(line)
        f.write(line)


```
</div></details>

結果は以下のようになった、google transrateでは"二匹"がすべて"二引き"に変換されていたが、これらの一部(すべてではない)が改善された。
改悪された部分はなかったので、こちらはかけておいて正解だと思う。
(っていうかウサギって匹で数えるんだっけ)

**before**

```
毎朝、二引きは、寝床から跳ね起きて、朝の光の中へ、飛び出していきました。そして、一日中、一緒に楽しく遊びました。
```

**after**

```
毎朝、二匹は、寝床から跳ね起きて、朝の光の中へ、飛び出していきました。そして、一日中、一緒に楽しく遊びました。
```

#4. COTOHA APIの感情分析で文章の感情を認識する
COTOHA APIでは、テキストから感情を表す単語を抽出したり、その文章全体でのネガ・ポジをとることができる。
実は音声合成でも一部感情をパラメータとして与えることができるものが存在するので、この結果を音声合成時のパラメータとして用いることができれば、より感情のこもった音読ができるはず。
また、今回は音声認識をやっていないので使わないが、使いようによってはユーザーの"ありよりのなし"といったような細かい感情もとることができるかもしれない。

感情を扱うものは、単純にネガ・ポジのみを結果として与えるものと、happy, sad, angry, などの複数の感情をパーセンテージで返すもの等が多いが、COTOHA APIでは文章全体に関してが前者、特徴的な単語の単位に対してが後者が近い。

今回、しろいうさぎと、くろいうさぎと、語り手で音声を分けるつもりだったのだが、例えば、

"どうしたの(sad)"としろうさぎは言いました(happy)

みたいに、ひとつの文章内でこの三人の感情の違いがあるとおかしくなる気がしたのと、単純に長いサンプルのほうが結果も出やすいだろうと思って、APIに投げるのは"文章"の単位にしている。

ソースコードは以下。

<details><summary>ソースコード</summary><div>

```python


import requests
import json
import copy

access_token_publish_url = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
api_base_url = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientid = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientsecret = "XXXXXXXXXXXXXXXXXXXXX"

headers = {'Content-Type': 'application/json',}
data = json.dumps({"grantType": "client_credentials","clientId": clientid,"clientSecret": clientsecret})
response = requests.post(access_token_publish_url, headers=headers, data=data)
access_token = json.loads(response.text)["access_token"]

api_url = api_base_url + "nlp/v1/sentiment"
headers = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json;charset=UTF-8"}

with open('./ehon_text/kanji_text2.txt', 'r') as f:
    lines = f.readlines()

story = []
text_list = []
page_sentenses = []
aa = {"sentiment": "", "text": ""}
with open('./ehon_json/ehon.json', 'w') as f:
    for line in lines:
        for text in line.split("。"):
            if text != "\n":
                data = json.dumps({"sentence": text})
                response = requests.post(api_url, headers=headers, data=data)
                result = json.loads(response.text)
                # print(text)
                # print(result["result"]["sentiment"])
                text_list.append({"sentiment": result["result"]["sentiment"], "text": text})
        story.append(copy.deepcopy(text_list))
        text_list = []
    json.dump(story, f, indent=4, ensure_ascii=False)

```
</div></details>


結果(レスポンスの一例)は以下のような感じ。
文章的にNeutralばかりになるかと思ったが、意外と感情の起伏がある。
ネガもポジもちゃんと出てきたので、感情のこもった読み上げに一役買っていると思う。

```
毎朝、二匹は、寝床から跳ね起きて、朝の光の中へ、飛び出していきました
{'result': {'sentiment': 'Neutral', 'score': 0.3747452771403413, 'emotional_phrase': []}, 'status': 0, 'message': 'OK'}
そして、とても悲しそうな顔をしました
{'result': {'sentiment': 'Negative', 'score': 0.6020340536995118, 'emotional_phrase': [{'form': 'とても悲しそうな', 'emotion': 'N'}]}, 'status': 0, 'message': 'OK'}
```

#5. COTOHA APIのユーザ属性推定(β)で登場人物のペルソナを解析する
COTOHA APIには、ユーザ属性推定(β)の機能があり、結構事細かなペルソナが返ってくる。
音声合成のほうも話者数が多いので、この情報から自動で話者を一致させることができないかとかんがえた。
本当はすべてプログラム内で自動でやりたかったが、どの発話が誰のものなのかを決めるロジックが思いつかなず。。今回、ここは手作業になってしまった。
日本語の絵本の場合、セリフはきちんと「」でくくってあることが多いので、
登場人物が何人なのかを最初に入力し、正規表現で「」の中身を抜き出し、ユーザーに発話ごとにidを振ってもらう仕様とした。なお、語り手のidは0に設定される。
ソースコードは以下

<details><summary>ソースコード</summary><div>

```python

import requests
import re
import json

char0 = []
char_num = int(input("Please input number of characters =>"))
for i in range(1, char_num+1):
    exec('char{} = []'.format(i))

with open('./ehon_json/ehon.json', 'r') as f:
    story = json.load(f)

story_list = []
for page in story:
    page_list = []
    for sentense in page:
        # try:
        speech_list = re.split("(?<=」)|(?=「)", sentense["text"])
        for speech in speech_list:
            if speech != "":
                if speech.find("「") > -1:
                    while True:
                        try:
                            print(sentense)
                            print(speech)
                            id = int(input("Please input char ID =>"))
                            if id <= char_num and id > 0:
                                break
                        except:
                            print("once again")
                    exec('char{}.append(speech)'.format(id))
                    page_list.append({"sentiment": sentense["sentiment"], "text": speech, "char": id})
                else:
                    char0.append(speech)
                    page_list.append({"sentiment": sentense["sentiment"], "text": speech, "char": 0})
    story_list.append(copy.deepcopy(page_list))
print(story_list)

access_token_publish_url = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
api_base_url = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientid = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
clientsecret = "XXXXXXXXXXXXXXXXXXXXX"

headers = {'Content-Type': 'application/json',}
data = json.dumps({"grantType": "client_credentials","clientId": clientid,"clientSecret": clientsecret})
response = requests.post(access_token_publish_url, headers=headers, data=data)
access_token = json.loads(response.text)["access_token"]

api_url = api_base_url + "nlp/beta/user_attribute"
headers = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json;charset=UTF-8"}

char_list = []
for i in range(0, char_num+1):
    exec('l = char{}'.format(i))
    data = json.dumps({"document": l})
    response = requests.post(api_url, headers=headers, data=data)
    result = json.loads(response.text)
    char_list.append(result)
    print(result)

with open('./ehon_json/char.json', 'w') as f:
    json.dump(char_list, f, indent=4, ensure_ascii=False)


```
</div></details>

こうやって、語り手、しろいうさぎ、くろいうさぎと、話者ごと発話のリストを作り、APIに投げてみた。
結果はこちら。

・語り手

```
{
        "result": {
            "age": "40-49歳",
            "civilstatus": "既婚",
            "habit": [
                "SMOKING"
            ],
            "hobby": [
                "COLLECTION",
                "COOKING",
                "FORTUNE",
                "GOURMET",
                "INTERNET",
                "SHOPPING",
                "STUDY",
                "TVGAME"
            ],
            "location": "近畿",
            "occupation": "会社員"
        },
        "status": 0,
        "message": "OK"
    }
```
・しろいうさぎ

```
    {
        "result": {
            "age": "40-49歳",
            "civilstatus": "既婚",
            "earnings": "-1M",
            "hobby": [
                "COOKING",
                "GOURMET",
                "INTERNET",
                "TVDRAMA"
            ],
            "location": "関東",
            "occupation": "会社員"
        },
        "status": 0,
        "message": "OK"
    }
```
・くろいうさぎ

```
    {
        "result": {
            "age": "40-49歳",
            "earnings": "-1M",
            "hobby": [
                "INTERNET"
            ],
            "location": "関東",
            "occupation": "会社員"
        },
        "status": 0,
        "message": "OK"
    }
```

上から、語り手、しろいうさぎ、くろいうさぎである。
うーーーーーん？
この結果は少しイマイチだったかもしれない。というか、ドキュメントには"gender"とかが返ってくるとあったのだが、この結果には含まれていなかった。まだベータ版だからだろうか。
でも結婚する話だし、意外と大人だと思うから、もしかしたら案外正しいのかもしれない。
これだけの精度で出そうと思ったら、膨大な会話ログを送らないと無理なんだろうか。

ここの精度が上がってくると、登場人物ごとのキャラ付けなどがある程度テンプレート化出来て、音声認識を使って絵本の中の登場人物と会話する、等の体験も考えられるかもしれない。
とりあえず今回は、この結果を参考に手作業でそれっぽい声を選定した。

#6. HOYA VoiceText APIで最適な話者と話し方を選定し、音声合成する
最後に、これらの情報を複合して音声合成する。
音声合成に関しては、COTOHA APIのものは感情が指定できなかったのと、あと自分が無料プランのみの登録のため、今回はHOYAのVOICE TEXTを使ってみた。
本当はコエステーションで、自分の声で音声合成作って、いつでも父が読んであげるアプリにしたかったんだけど、個人の力では無理でした。

ちなみに、HOYAの合成音声も、二次配布等は禁止のライセンスなので注意

[VoixeText Web API](https://voicetext.jp/products/vt-webapi/)
>無料版で作成した音声データの商用利用、二次利用及び配布する行為は禁止されております。利用規約をご確認の上、本サービスをご利用ください

今回は

```
    語り手:"hikari"
    しろいうさぎ:"haruka"
    くろいうさぎ:"takeru"
```
とした。また、感情は、

```
    "Neutral":""
    "Positive":"happiness"
    "Negative":"sadness"
```
として設定している。
また、普通に合成すると後ろのバッファが足りないのか、声が途切れるので、SSMLタグである`<vt_pause=1000/>`をすべての文言の後ろにつけ、ファイルを長くしている。

<details><summary>ソースコード</summary><div>

```python

from voicetext import VoiceText
import copy
import json

speaker = {
    0:"hikari",
    1:"haruka",
    2:"takeru"
}

emotion = {
    "Neutral":"",
    "Positive":"happiness",
    "Negative":"sadness"
}

play_list = []
vt = VoiceText('XXXXXXXXXXXXXXXXX')
with open('./ehon_json/story.json', 'r') as f:
    story = json.load(f)
    for i, page in enumerate(story):
        play = {"image": "./ehon_image/{}.png".format(i+1), "voice":[]}
        voice_list = []
        for j, speech in enumerate(page):
            print(speech)
            if speech["sentiment"] == "Neutral":
                vt.speaker(speaker[speech["char"]])
            else:
                vt.speaker(speaker[speech["char"]]).emotion(emotion[speech["sentiment"]])
            with open('./ehon_speech/{}_{}.wav'.format(i+1, j+1), 'wb') as f:
                print(speech["text"])
                f.write(vt.to_wave(speech["text"] + '<vt_pause=1000/>'))
            voice_list.append('./ehon_speech/{}_{}.wav'.format(i+1, j+1))
        play["voice"] = copy.deepcopy(voice_list)
        play_list.append(copy.deepcopy(play))
        voice_list = []


with open('./play_json/play.json', 'w') as f:
    json.dump(play_list, f, indent=4, ensure_ascii=False)


```
</div></details>


#最後に
これらの方法で今回生成した音声を、読み込んだ画像と同期させて再生しているのが以下。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/U6IXwKgUYG">pic.twitter.com/U6IXwKgUYG</a></p>&mdash; hatbot (@hatbot3) <a href="https://twitter.com/hatbot3/status/1239898362532544518?ref_src=twsrc%5Etfw">March 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

一応、載せるのは一部に留めておく。
今回作ってみて、結構おもしろかった。
今後の展望として、ラズパイとかで"絵本読み上げカメラ"としてデバイス化するのも面白いし、プロジェークターとつないでシアターにするのもいいと思った。
VisionAPI周りとももっとうまくつなげば、言葉と画像がリンクして面白い体験が作れそう。
自分で絵本を書いてみたり、絵本への落書きがしゃべったりしても面白いかも。
感情も結構細かい単位でとれるので、BGMや効果音ももう少し手を加えれば入れられる。

COTOHA APIは他にもまだまだ遊べそうなので、引き続き実装したら記事にしていきたい。

一応断っておくが、娘にはもちろん自分でも絵本は読んであげるつもりである。
ちなみに娘は今 `生後1.5ヵ月` だ。