alexaで視力検査スキルを作る

#はじめに
スマートスピーカーalexaで視力検査スキルを作成し公開しています。視力検査スキルは、回答にキーボードまたはマウスを使わずに、音声で回答できるので、違和感なく検査できるのが特徴です。

2018年9月にEcho Spot専用スキルとして「スポット用の視力検査」を、2018年12月にEcho Show専用スキルとして「ショー用の視力検査」を公開しました。また、2019年8月20日に、Echo Show 5専用スキルとして「ショーファイブ専用の視力検査」を公開しました。

![1418.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269534/00a66afd-f00f-4ce4-5f66-f59af7b9d487.jpeg)


上記3機種は、ディスプレイサイズ・縦横比・解像度がそれぞれ異なるため、プログラムは同じものを使っていますが、画像はそれぞれのディスプレイに合わせて作成しています。3つ公開した現時点で簡単に作成プロセスを紹介します。

#画像の準備
Echo Show 5専用スキルとして「ショーファイブ専用の視力検査」の作成を例にとって説明します。

###ディスプレイサイズ・解像度の確認

ディスプレイは5.5型/960×480ドットです。ディスプレイの対角線が5.5インチで縦横比が2:1なので横124.9515ミリ、縦62.4757ミリと計算できます。実際に実機のディスプレイを定規で測ると、横125ミリ、縦63ミリでした。また、1ドットのサイズは124.9515ミリ÷960ドット＝0.130ミリ/ドットと計算できます。


####ランドルト環とは

ランドルト環の説明は[こちら](https://ja.wikipedia.org/wiki/%E8%A6%96%E5%8A%9B)
「5mの距離で直径7.25mm太さ1.45mm切れ目1.45mmのランドルト環が視認できれば視角1分となり、視力1.0に相当する。」ことを基本に、ディスプレイからの距離をまず決定し、ランドルト環の大きさを計算します。

####視力検査の種類

また視力検査で最も一般的な検査は5m離れて検査する遠見検査です。一方で、VDT（Visual Display Terminal)の健康診断で行われるのは、30センチまたは50センチ離れて検査する近見検査です。老眼の確認も近見検査が必要になります。

####どのような視力検査を行うかを決定

Echo Show 5が置かれている場所、ディスプレイから離れることができる距離などを、考えます。
Echo Show 5の場合、机の上に置かれている場合が多いと想像でき、ディスプレイの大きさから推察すると大きなスペースの中にあるというより小さな部屋に置かれ、近距離から見ているユースケースが多いと予測します。簡単に5メートル離れることはできないと考えます。Echo Show 5用のスキルとしては、50センチの近見視力検査に絞って作ろうと考えました。

####ランドルト環のサイズの計算

「5mの距離で直径7.25mm太さ1.45mm切れ目1.45mmのランドルト環が視認できれば視角1分となり、視力1.0に相当する。」ことを基本に、今回は距離が50センチで行う設定で計算すると、それぞれのランドルト環のサイズは以下の表のとおりとなります。

![1412.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269534/04319ac1-f3d3-6c9f-1109-ba2bcc71ae45.jpeg)


|   視力（距離:50センチ） |   環の直径(mm)|  切れ目の長さ(mm) |
|:-----------------|------------------:|:------------------:|
| 0.1     | 7.25 | 1.45 |
| 0.2     | 3.6525 | 0.725 |
| 0.3     | 2.4166 | 0.4833  |
| 0.4     | 1.8125 | 0.3625  |
| 0.5     | 1.45 | 0.29  |
| 0.7     | 1.0357 | 0.2071 |
| 0.8     | 0.9062 | 0.1812 |
| 1.0     | 0.725 | 0.145 |
| 1.2     | 0.6041  | 0.1208 |
| 1.5     | 0.4833 | 0.09666 |
| 2.0     | 0.3625 | 0.0725 |

このEcho Show5のディスプレイの1ドットのサイズは0.130ミリ/ドットですので、視力1.0のランドルト環の切れ目0.145ミリは表示できますが、視力1.2以上のランドルト環の切れ目は表示できません。そのため、このスキルでは、視力1.0までの検査表とします。

このように、ディスプレイの1ドットの長さとランドルト環の切れ目の長さを考えつつ、視力検査の測定距離も設定していくことになります。


####photoshopでひたすら描く
960×480ドットの画像で、レイヤーを使って、方眼紙などをレイヤーに入れて大きさを確認しながら、ランドルト環を描きます。その後、バックに灰色レイヤー、ランドルト環レイヤーとバックの間に白色レイヤーを入れながら図を、視力数（今回0.1,0.2,0.3,0.4,0.5,0.7,0.8,1.0の8種）×　列数(今回6列）＝ 48パターン作成します。

完成した図は、適切なアクセス権設定を行って、AWSのS3に入れます。


作成方法と作成図例
![1415.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269534/2feca80f-5fa8-37e6-1f40-9e6a0ef45c8a.jpeg)

#alexa developer consoleのコード

alexa developer console内のビルド手順の詳細は今回説明しません。
alexa developer consoleのコードエディタで以下のコードを書きます。

```index.js
const Alexa = require('ask-sdk-core');
const visiondata = require('./visiondata');
const SKILL_NAME = "ショーファイブ専用の視力検査";

const NON_ECHOSPOT_MESSAGE = 'ごめんなさい。このスキルはエコーショー ファイブ専用です。エコーショー ファイブでこのスキルを使ってください。これで終了します。';
const INTRO_MESSAGE = '視力検査を始めます。近見視力を検査します。エコーショー ファイブから50センチ離れてください。はじめに右目の視力を測定します。左目を閉じてください。なお、この検査は、医師による視力検査に代わるものではありません。「うえ向き」<break time="0.3s"/>、「左向き」<break time="0.2s"/>のように答えてくださいね。準備はいいですか？';
const READY_CONFIRMATION = 'はじめますか？';

const ImageURLBase = 'https://xxxxxxxxxxxxxxxxx.s3-ap-northeast-1.amazonaws.com/';

const RightEyeImageURL = ImageURLBase + 'top_right_001.jpg';
const LeftEyeImageURL = ImageURLBase + 'top_left_001.jpg';

const LaunchRequestWithoutDisplayHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest'
        && !supportsDisplay(handlerInput) ;
    },
    handle(handlerInput){
        let speechOutput = NON_ECHOSPOT_MESSAGE;
        return handlerInput.responseBuilder
        .speak(speechOutput)
        .getResponse();
    }
};

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
    },
    handle(handlerInput){
        
        let attributes = handlerInput.attributesManager.getSessionAttributes();
        attributes.side = "R";
        attributes.index = 0;
        attributes.test = getTest(visiondata[0]);
        handlerInput.attributesManager.setSessionAttributes(attributes);

        let template = getImageTemplate("右目の測定", RightEyeImageURL);

        console.log(JSON.stringify(attributes));
        let speechOutput = INTRO_MESSAGE;
        let speechPrompt = READY_CONFIRMATION;
        return handlerInput.responseBuilder
            .addRenderTemplateDirective(template)
            .withShouldEndSession(false)
            .speak(speechOutput)
            .reprompt(speechPrompt)
            .getResponse();
    }
};


const AnswerIntentHandler = {
    canHandle(handlerInput) {
        return (handlerInput.requestEnvelope.request.type === 'IntentRequest'
          && (handlerInput.requestEnvelope.request.intent.name === 'AnswerIntent' 
          || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.YesIntent'));
    },
    handle(handlerInput) {
        let speechOutput = "";
        let attributes = handlerInput.attributesManager.getSessionAttributes();
        console.log(JSON.stringify(attributes));
        // AMAZON.YesIntentの場合
        if(handlerInput.requestEnvelope.request.intent.name === 'AMAZON.YesIntent'){
            const template = getImageTemplate(null, ImageURLBase + attributes.test.image);
            let speechOutput = "これは？";
            return handlerInput.responseBuilder
                .addRenderTemplateDirective(template)
                .speak(speechOutput)
                .reprompt(speechOutput)
                .withShouldEndSession(false)
                .getResponse();           
        }

        // AnswerIntentの場合
        // ユーザーの答えをスロットから取得
        let users_answer = handlerInput.requestEnvelope.request.intent.slots.direction.resolutions.resolutionsPerAuthority[0].values[0].value.id;

        // 出した問題の回答
        let answer = attributes.test.answer;
        console.log("Users Answer:" + users_answer + " Answer:" + answer);
        if(users_answer == answer){
            // 正解の場合
            if(attributes.index === visiondata.length - 1){
                // 最後まで行った場合
                if(attributes.side == "R"){
                    // 右の検査の場合
                    speechOutput = "あなたの右目の視力は" + visiondata[attributes.index].eyesight + "です。続けて左目を測定しますか？";
                    
                    attributes.side = "L";
                    attributes.index = 0;
                    attributes.test = getTest(visiondata[0]);

                    handlerInput.attributesManager.setSessionAttributes(attributes);
                    let template = getImageTemplate("左目の測定", LeftEyeImageURL);

                    return handlerInput.responseBuilder
                        .addRenderTemplateDirective(template)
                        .speak(speechOutput)
                        .reprompt("続けて左目を測定しますか？")
                        .withShouldEndSession(false)
                        .getResponse();     
                }else{ // if(attributes.side == "L"){
                    // 左の検査の場合
                    speechOutput = "あなたの左目の視力は" + visiondata[attributes.index].eyesight + "です。お疲れ様でした。";
                    return handlerInput.responseBuilder
                        .speak(speechOutput)
                        .withShouldEndSession(true)
                        .getResponse();   
                }

            }else{
                // 正解、まだ次がある場合、次の問題をだす
                speechOutput = 'これは？';
                let index = attributes.index + 1;
                attributes.index = index;
                attributes.test = getTest(visiondata[index]);
                
                let template = getImageTemplate(null, ImageURLBase + attributes.test.image);
                
                handlerInput.attributesManager.setSessionAttributes(attributes);
                
                return handlerInput.responseBuilder
                    .addRenderTemplateDirective(template)
                    .speak(speechOutput)
                    .reprompt("切れ目はどちらですか？")
                    .withShouldEndSession(false)
                    .getResponse();
            }
        }else{
            // 不正解の場合一つ前のテストデータの視力を伝える
            let eyesight;
            if(attributes.index == 0)
                eyesight =  visiondata[0].eyesight + "未満";
            else
                eyesight = visiondata[attributes.index - 1].eyesight;
            
            if (attributes.side == "R"){
                // 右の場合左に進むか確認する
                speechOutput= "あなたの右目の視力は" + eyesight + "です。左目も測定しますか？";
                attributes.side = "L";
                attributes.index = 0;
                attributes.test = getTest(visiondata[0]);
                handlerInput.attributesManager.setSessionAttributes(attributes);
                let template = getImageTemplate("左目の測定", LeftEyeImageURL);
                return handlerInput.responseBuilder
                .addRenderTemplateDirective(template)
                .speak(speechOutput)
                .reprompt("続けて左目を測定しますか？")
                .withShouldEndSession(false)
                .getResponse();   
            }
            if(attributes.side == "L"){
                // 左の場合一つ前の視力を言って終了する
                speechOutput= "あなたの左目の視力は" + eyesight + "です。お疲れ様でした";
                return handlerInput.responseBuilder
                .speak(speechOutput)
                .withShouldEndSession(true)
                .getResponse();   
            }
        }
    }
};

function getTest(eyesightlevel){
    return eyesightlevel.options[Math.floor(Math.random() * eyesightlevel.options.length)];
}

const HelpIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
    },
    handle(handlerInput) {
        const speechOutput = 'エコーショー ファイブのディスプレイを使用する視力検査です。これは近見検査です。エコーショー　ファイブから50センチ離れた場所からディスプレイをみてください。まず、右目を測定し、そのあとに左目を測定します。視力0.1から1.0まで検査します。なお、この検査は、医師による視力検査に代わるものではありません。切れ目の向きを、「うえ向き」<break time="0.3s"/>、「左向き」<break time="0.2s"/>のように答えてくださいね。準備はよろしいですか？';
        const reprompt = '始めますか？';
        return handlerInput.responseBuilder
        .withShouldEndSession(false)
        .speak(speechOutput)
        .reprompt(reprompt)
        .getResponse();
    }
};

const CancelAndStopIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent'
                || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'
            || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NoIntent');
    },
    handle(handlerInput) {
        const speechOutput = 'ご利用ありがとうございました。';
        return handlerInput.responseBuilder
            .speak(speechOutput)
            .withShouldEndSession(true)
            .getResponse();
    }
};

const SessionEndedRequestHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
    },
    handle(handlerInput) {
        return handlerInput.responseBuilder.getResponse();
    }
};

const ErrorHandler = {
    canHandle () {
      return true;
    },
    handle (handlerInput, error) {
      console.log(`Error handled: ${error.message}`);
      const message = "すみません、視力検査はなんだかうまく動かないようです。答えてくださった向きを、うまく認識できませんでした。切れ目の向きを、「うえ向き」、「左向き」のように「向き」という言葉をつけて答えてくださいね。もう一度お試しください。";
      return handlerInput.responseBuilder
        .speak(message)
        .withShouldEndSession(true)
        .getResponse();
    }
}

const skillBuilder = Alexa.SkillBuilders.custom();
exports.handler = skillBuilder
  .addRequestHandlers(
    LaunchRequestWithoutDisplayHandler,
    LaunchRequestHandler,
    AnswerIntentHandler,
    HelpIntentHandler,
    CancelAndStopIntentHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();


// スキルが画面付きデバイスで動作しているかどうかを調べるヘルパー関数
// 画面付きの時は true を返す。 
function supportsDisplay(handlerInput)
{
    var hasDisplay =
    handlerInput.requestEnvelope.context &&
    handlerInput.requestEnvelope.context.System &&
    handlerInput.requestEnvelope.context.System.device &&
    handlerInput.requestEnvelope.context.System.device.supportedInterfaces &&
    handlerInput.requestEnvelope.context.System.device.supportedInterfaces.Display;
    return hasDisplay;
}

// スロットの値を取得するヘルパー関数
function getSlot(handlerInput, slotName)
{
    let request = handlerInput.requestEnvelope.request;
    if (
        request &&
        request.intent &&
        request.intent.slots &&
        request.intent.slots[slotName]
    ){
        return handlerInput.requestEnvelope.request.intent.slots[slotName];    
    }
    else return undefined;
}

// BodyTemplate7でタイトルとイメージを表示するテンプレートを返す
function getImageTemplate(title, url){
    const image = new Alexa.ImageHelper()
    .addImageInstance(url)
    .getImage();

    const template ={
        type: 'BodyTemplate7',
        token: null,
        backButton: 'HIDDEN',
        backgroundImage: image,
        title: title,
    };
    return template;
}

```

さらに、index.jsと並列に以下のvisiondata.jsを置く。

```visiondata.js
const visiondata = [
    // 視力0.1
    {"eyesight": "0.1", "next_index": 1,
     "options" : [{"image": "V01_1.jpg", "answer": "U"}, {"image": "V01_2.jpg", "answer": "L"}, {"image": "V01_3.jpg", "answer": "R"},{"image": "V01_4.jpg", "answer": "D"},{"image": "V01_5.jpg", "answer": "U"},{"image": "V01_6.jpg", "answer": "R"}]
    },
    // 視力0.2
    {"eyesight": "0.2", "next_index": 2,
     "options" : [{"image": "V02_1.jpg", "answer": "D"}, {"image": "V02_2.jpg", "answer": "R"}, {"image": "V02_3.jpg", "answer": "R"},{"image": "V02_4.jpg", "answer": "U"},{"image": "V02_5.jpg", "answer": "L"},{"image": "V02_6.jpg", "answer": "U"}]
    },
    // 視力0.3
    {"eyesight": "0.3", "next_index": 3,
     "options" : [{"image": "V03_1.jpg", "answer": "U"}, {"image": "V03_2.jpg", "answer": "R"}, {"image": "V03_3.jpg", "answer": "D"},{"image": "V03_4.jpg", "answer": "L"},{"image": "V03_5.jpg", "answer": "D"},{"image": "V03_6.jpg", "answer": "R"}]
    },
    // 視力0.4
    {"eyesight": "0.4", "next_index": 4,
     "options" : [{"image": "V04_1.jpg", "answer": "D"}, {"image": "V04_2.jpg", "answer": "L"}, {"image": "V04_3.jpg", "answer": "U"},{"image": "V04_4.jpg", "answer": "R"},{"image": "V04_5.jpg", "answer": "R"},{"image": "V04_6.jpg", "answer": "U"}]
    },
    // 視力0.5
    {"eyesight": "0.5", "next_index": 5,
     "options" : [{"image": "V05_1.jpg", "answer": "R"}, {"image": "V05_2.jpg", "answer": "U"}, {"image": "V05_3.jpg", "answer": "L"},{"image": "V05_4.jpg", "answer": "U"},{"image": "V05_5.jpg", "answer": "U"},{"image": "V05_6.jpg", "answer": "D"}]
    },
    // 視力0.7
    {"eyesight": "0.7", "next_index": 6,
     "options" : [{"image": "V07_1.jpg", "answer": "D"}, {"image": "V07_2.jpg", "answer": "R"}, {"image": "V07_3.jpg", "answer": "U"},{"image": "V07_4.jpg", "answer": "L"},{"image": "V07_5.jpg", "answer": "D"},{"image": "V07_6.jpg", "answer": "U"}]
    },
    // 視力0.8
    {"eyesight": "0.8", "next_index": 7,
     "options" : [{"image": "V08_1.jpg", "answer": "U"}, {"image": "V08_2.jpg", "answer": "L"}, {"image": "V08_3.jpg", "answer": "D"},{"image": "V08_4.jpg", "answer": "R"},{"image": "V08_5.jpg", "answer": "U"},{"image": "V08_6.jpg", "answer": "L"}]
    },
    // 視力1.0
    {"eyesight": "1.0", "next_index": 8,
     "options" : [{"image": "V10_1.jpg", "answer": "L"}, {"image": "V10_2.jpg", "answer": "U"}, {"image": "V10_3.jpg", "answer": "L"},{"image": "V10_4.jpg", "answer": "D"},{"image": "V10_5.jpg", "answer": "D"},{"image": "V10_6.jpg", "answer": "R"}]
    },
    ];

module.exports = visiondata;

```

#完成したスキルの動画

完成した「ショーファイブ用の視力検査」の動画は[こちら](https://youtu.be/puDy2yVkz2I)

![1417.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269534/c8784cb3-9213-4772-fcc9-240e215d2e66.jpeg)

エンジニアの皆さん、時々、視力検査を行ってください。自身の眼を大切に！！