ReactNativeで湯婆婆を実装(OCR)

# はじめに
@Nemesisさんの[Javaで湯婆婆を実装してみる - Qiita](https://qiita.com/Nemesis/items/c7192a7c510788d2cba2)を発端に、様々な言語やフレームワーク、果てはサービスを題材に実装されている湯婆婆。
令和の`Hello World!`なんて呼ばれるぐらいプログラミングの基礎問題として優れているかと思います。

「基礎的」なお題であるせいか、どの言語でも **「コマンドラインでの対話式」** で実装されていることが多いです。
記事に独自性を出すためにも、ここは原作を忠実に再現して **「入力は手書き」** そして **「入力内容を`Cloud Vision API`で読み取らせて例の処理を実行」** することにしました。

どうせなら指でスラスラ入力させたかったので`ReactNative`で実装しています。

# コード

```jsx:Yubaba.jsx
import React, {useRef, useState} from 'react';
import {
  StatusBar,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  TouchableOpacity,
} from 'react-native';
import RNDrawOnScreen from 'react-native-draw-on-screen';
import ViewShot, {captureRef} from 'react-native-view-shot';

// GCPで取得したAPIキー
const GCP_API_KEY = 'xxxxxxxxxxxx';

const Yubaba = () => {
  const RNDraw = useRef(null);
  const RNViewShot = useRef(null);
  const [name, setName] = useState('');
  const newName = name.substr(Math.floor(Math.random() * name.length), 1);

  // クリアボタン押下イベント
  const onPressClear = () => {
    RNDraw?.current?.clear();
    setName('');
  };

  // OKボタン押下イベント
  const onPressOk = () => {
    // react-native-view-shotで描画部分のキャプチャを取得
    captureRef(RNViewShot, {result: 'base64'}).then(async (data) => {
      // リクエストボディ作成
      const body = JSON.stringify({
        requests: [
          {
            features: [
              {
                // テキスト認識
                type: 'TEXT_DETECTION',
                // 取得したい結果数
                maxResults: 1,
              },
            ],
            image: {
              // base64エンコードした画像をそのままセット
              content: data,
            },
          },
        ],
      });

      // CloudVisionAPI実行
      const response = await fetch(
        'https://vision.googleapis.com/v1/images:annotate?key=' + GCP_API_KEY,
        {
          headers: {
            Accept: 'application/json',
            'Content-Type': 'application/json',
          },
          method: 'POST',
          body: body,
        },
      );

      // レスポンス解析
      const resJson = await response.json();
      const description =
        resJson.responses[0]?.textAnnotations[0]?.description || '';

      // 正常にテキストが読み取れていたらsetName実行
      if (description) {
        // 改行コードが含まれていることがあるため除外
        setName(description.replace(/\r?\n/g, ''));
      }
    });
  };

  return (
    <>
      <StatusBar barStyle="dark-content" />
      <SafeAreaView style={styles.container}>
        <View style={styles.contentWrap}>
          <Text>{`契約書だよ。そこに名前を書きな。`}</Text>
        </View>
        <ViewShot style={styles.canves} ref={RNViewShot}>
          <RNDrawOnScreen penColor={'black'} strokeWidth={10} ref={RNDraw} />
        </ViewShot>
        <View style={styles.buttonWrap}>
          <TouchableOpacity
            style={[styles.button, {borderColor: '#007AFF'}]}
            onPress={onPressOk}>
            <Text style={[styles.buttonText, {color: '#007AFF'}]}>{`OK`}</Text>
          </TouchableOpacity>
          <TouchableOpacity
            style={[styles.button, {borderColor: '#FF7A00'}]}
            onPress={onPressClear}>
            <Text
              style={[styles.buttonText, {color: '#FF7A00'}]}>{`クリア`}</Text>
          </TouchableOpacity>
        </View>
        {!!name && (
          <View style={styles.contentWrap}>
            <Text>{`フン。${name}というのかい。贅沢な名だねぇ。`}</Text>
            <Text>{`今からお前の名前は${newName}だ。いいかい、${newName}だよ。\n分かったら返事をするんだ、${newName}!!`}</Text>
          </View>
        )}
      </SafeAreaView>
    </>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  contentWrap: {
    width: '100%',
    margin: '4%',
    marginTop: '8%',
  },
  canves: {
    height: '30%',
    margin: '2%',
    borderWidth: 2,
    borderColor: '#ccc',
    backgroundColor: '#FFF',
  },
  buttonWrap: {
    margin: '2%',
    width: '100%',
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
  },
  button: {
    borderWidth: 1,
    margin: '2%',
    padding: 10,
    borderRadius: 4,
  },
  buttonText: {
    fontSize: 14,
  },
});

export default Yubaba;
```

# コードの解説
基本的な湯婆婆は@dryttさんの[React (JavaScript) で湯婆婆を実装してみる - Qiita](https://qiita.com/drytt/items/250440366c9f72f619dd)のロジックを踏襲しているので、
ここではこの湯婆婆の特色である`CloudVisionAPI`を使った手書き文字の読み取り、について掘り下げます。

## 手書きUI
手書きの`UI`には[react-native-draw-on-screen](https://www.npmjs.com/package/react-native-draw-on-screen)を使用しています。
文字認識を行いやすくするため、文字色は黒で線の太さも固定としています。

さらに手書きした内容をいったん画像として保持したいので、[react-native-view-shot](https://github.com/gre/react-native-view-shot)を使って画像化したい要素をラップしています。

```jsx
import RNDrawOnScreen from 'react-native-draw-on-screen';
import ViewShot, {captureRef} from 'react-native-view-shot';

// ...略

<ViewShot style={styles.canves} ref={RNViewShot}>
  <RNDrawOnScreen penColor={'black'} strokeWidth={10} ref={RNDraw} />
</ViewShot>
```

## キャプチャ
手書きが完了したら`react-native-view-shot`でキャプチャを撮ります。
`CloudVisionAPI`には`base64`で送りつけたいのでオプションで指定しています。
*※指定しなかった場合は端末ストレージ内に配置されたキャプチャのパスが`data`に入ります。*

```js
captureRef(RNViewShot, {result: 'base64'}).then(async (data) => {/** dataにbase64エンコードした内容がそのまま入る */ }
```

## CloudVisionAPIへ送信
- 参考：[Cloud Vision のドキュメント](https://cloud.google.com/vision/docs?hl=ja)

あとは`CloudVisionAPI`の仕様に従ってリクエストを投げるだけです。
先に`GCP`プロジェクトを作成し、課金設定をしておく必要があります。
*※1000リクエスト/月までは無料です。*

```js
 // リクエストボディ作成
const body = JSON.stringify({
  requests: [
    {
      features: [
        {
          // テキスト認識
          type: 'TEXT_DETECTION',
          // 取得したい結果数
          maxResults: 1,
        },
      ],
      image: {
        // base64エンコードした画像をそのままセット
        content: data,
      },
    },
  ],
});

// CloudVisionAPI実行
const response = await fetch(
  'https://vision.googleapis.com/v1/images:annotate?key=' + GCP_API_KEY,
  {
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    method: 'POST',
    body: body,
  },
);
```

# 実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435429/eefbfbd0-e7b8-bd7c-fca2-981c095abe4d.gif" alt="yubaba" width="50%" />

そこは「千」じゃないんかーい！

# おわりに
ここまで読んでいただきありがとうございます。
入門的とは言い難いので、湯婆婆本来の趣旨からは外れてしまったかもしれません。

`Cloud Vision API`を使ったのは初めてですが、思ったよりも遥かに簡単に導入できました。
精度が落ちるかもしれませんが、カメラを起動して撮影した文字から名前を読み取ることもできます。