Lチカで始めるテスト自動化(8)HDMIビデオキャプチャデバイスの組込み

##1. はじめに
Lチカで始めるテスト自動化シリーズ第八弾です。

UVC(USB Video Class)対応のパススルー機能付きHDMIビデオキャプチャデバイスをテストベンチに組込み、Windowsに標準で入っている「メモ帳」をGUIで操作してテストを自動実行します。

1. HDMIビデオキャプチャデバイスを介してノートPCに外部モニタを接続する
2. 外部モニタ上でメモ帳を開く
3. Arduino Leonardoをコマンド制御のUSBキーボードとして利用し、コマンド制御で任意の文字列をメモ帳に打鍵する
4. OpenCVでHDMI映像のキャプチャとトリミングを行う
5. トリミングした画像からPyOCRで文字列を検出する
6. 検出した文字列と期待値の文字列を比較してGo/No-GO判定を行う

![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/fb1b0c35-df55-d8f8-52b4-3d4588422f75.png)

これまでの記事はこちらをご覧ください。

1. [Lチカで始めるテスト自動化](https://qiita.com/pbjpkas/items/fd4bd2689deda2d793aa)
2. [Lチカで始めるテスト自動化(2)テストスクリプトの保守性向上](https://qiita.com/pbjpkas/items/337bce392281ee5f0526)
3. [Lチカで始めるテスト自動化(3)オシロスコープの組込み](https://qiita.com/pbjpkas/items/b80b7e199a5aed4688a4)
4. [Lチカで始めるテスト自動化(4)テストスクリプトの保守性向上(2)](https://qiita.com/pbjpkas/items/a02c4128baf5a3f2a46c)
5. [Lチカで始めるテスト自動化(5)WebカメラおよびOCRの組込み](https://qiita.com/pbjpkas/items/9af9e6441e1e20c5909e)
6. [Lチカで始めるテスト自動化(6)AI(機械学習)を用いたPass/Fail判定](https://qiita.com/pbjpkas/items/405d55f809da1221b867)
7. [Lチカで始めるテスト自動化(7)タイムスタンプの保存](https://qiita.com/pbjpkas/items/8e3c4330f4a81d30bd53)

##2. テストベンチ
###2.1 HDMIビデオキャプチャデバイス
型番がよくわからないのですがUVC対応でパススルー機能を備えているHDMIビデオキャプチャデバイスがアマゾンで二千円～数千円程度で売られていてそれを購入しました。AliExpressで調べると数ドル～数十ドル程度で出ているようです([amazon](https://www.amazon.co.jp/s?k=hdmi+to+usb+video+capture+loop+out&i=computers)、[AliExpress](https://ja.aliexpress.com/wholesale?SearchText=hdmi+to+usb+video+capture+loop+out&ltype=wholesale&SortType=default&page=1&CatId=0))。

- OS標準のドライバで動作します（デバイスマネージャを開くとカメラのところにあります）
- パススルー機能付きの機種を選んだのでほぼほぼ遅延なしに画面を確認できます
- 入力は4Kx2Kまで対応
- キャプチャ画像は最大で1920x1080(1080p)

スクリーンショットとOpenCVでJPEGでキャプチャした画像の比較を以下に示します。外部モニタの解像度とOpenCVの画像キャプチャの解像度はどちらも1920x1080に設定しています。

![image-quality.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/c6ffb020-5af9-04bb-d297-0f756971a859.png)

###2.2 Arduino Leonardo USBキーボード
- 拙著「[Arduino Leonardoで多目的ツールの製作](https://qiita.com/pbjpkas/items/97dbf835b0aab6725e94)」の[スケッチ](https://qiita.com/pbjpkas/items/97dbf835b0aab6725e94#7-スケッチ)を一か所、969行目の通信速度を9600→115200に変更して利用します。
  - コマンド一覧は[4.3コマンドモード](https://qiita.com/pbjpkas/items/97dbf835b0aab6725e94#43-コマンドモード)をご覧ください。
- UART通信で任意の文字データを受信するとコマンド制御モードに遷移します。
- UARTのdsrdtrは0を指定します。

#3. テストランナーの改修
下記3点改修しています。

1. open_cam()の引数にwidthとheightを追加
2. exec_ocr()のエラーメッセージを修正
3. open_cam()の呼び出し側を修正

[第七弾の付録A](https://qiita.com/pbjpkas/items/8e3c4330f4a81d30bd53#a-%E4%BB%98%E9%8C%B2-test-runnerpy%E3%81%AE%E3%82%BD%E3%83%BC%E3%82%B9)に掲載したtest-runner.pyとの差分を以下に示します。

```sh:test-runner.py(第七弾と第八弾のソースの差分)
$ diff test-runner_07.py test-runner_08.py
23,24c23,27
< def open_cam(camera_number):
<     return cv2.VideoCapture(camera_number)
---
> def open_cam(camera_number, width, height):
>     h = cv2.VideoCapture(camera_number)
>     h.set(cv2.CAP_PROP_FRAME_WIDTH, width)
>     h.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
>     return h
66c69
<         print("OCR Not Ready.")
---
>         print("OCR Fail.")
145c148,151
<                     cam = open_cam(int(cmd[1]))
---
>                     if len(cmd) == 2:
>                         cam = open_cam(int(cmd[1]), 640, 480)
>                     else:
>                         cam = open_cam(int(cmd[1]), int(cmd[2]), int(cmd[3]))
```

##4. テスト実行
1章に挙げた手順1～6を実行します。

###4.1 テスト対象の準備
1. HDMIビデオキャプチャデバイスを介してノートPCに外部モニタを接続します（外部モニタの解像度は1920x1080に設定します）。
2. 外部モニタ上でメモ帳を開き、全画面表示で配置し、拡大率を160%にします。

###4.2 テストスクリプト
1. Arduino Leonardo USBキーボードにコマンドを送り、メモ帳に改行および文字列1234を打鍵します。
2. OpenCVでHDMI映像のキャプチャとトリミングを行います。
3. トリミングした画像からPyOCRで文字列を検出します。
4. 検出した文字列と期待値の文字列(1234)を比較してGo/No-GO判定を行います。

####4.2.1 テストスクリプトの補足
- 1台のPCでテスト実行とテスト対象の操作を行うため冒頭で3秒のsleepを入れ、この間にフォアグラウンドのアプリをAnaconda Promptからメモ帳に切替えます。
- ビデオキャプチャに遅延が発生するため2秒のsleepを入れています。
- Arduino Leonardoのコマンド制御用UARTは筆者の環境ではCOM10で認識されています。

```sh:script.csv
#
# switch foreground application from Anaconda Prompt to Notepad in 3-seconds...
sleep,3
#
# open HDMI Video Capture Device resolution:1920x1080
open_cam,1,1920,1080
#
# Arduino Leonardo, dsrdtr=0
open_uart,COM10,0
send,x
send,key ent
send,str 1234
send,key ent
send,key ent
#
# wait 2-seconds for Video Capture Devices's Delay
sleep,2
#
# Video Caputuring and Cropping
capture_cam,hdmi01.jpg
crop_img,hdmi01.jpg,65:100,0:80,hdmi01_cropped.jpg
close_cam
#
# Extract strings by OCR
open_ocr
exec_ocr,hdmi01_cropped.jpg
#
# evaluation
eval_str_eq,1234
# end.
```

###4.3 テスト実行結果
GUI操作、ビデオのキャプチャとトリミング、OCRによる文字列検出、Go/No-Go判定までできました。

```python:result.csv
2020/06/27 18:38:01,#,OK
2020/06/27 18:38:01,# switch foreground application from Anaconda Prompt to Notepad in 3-seconds...,OK
2020/06/27 18:38:01,sleep,3,OK
2020/06/27 18:38:04,#,OK
2020/06/27 18:38:04,# open HDMI Video Capture Device resolution:1920x1080,OK
2020/06/27 18:38:04,open_cam,1,1920,1080,OK
2020/06/27 18:38:12,#,OK
2020/06/27 18:38:12,# Arduino Leonardo, dsrdtr=0,OK
2020/06/27 18:38:12,open_uart,COM10,0,OK
2020/06/27 18:38:12,send,x,OK
2020/06/27 18:38:12,send,key ent,OK
2020/06/27 18:38:12,send,str 1234,OK
2020/06/27 18:38:12,send,key ent,OK
2020/06/27 18:38:12,send,key ent,OK
2020/06/27 18:38:12,#,OK
2020/06/27 18:38:12,# wait 2-seconds for Video Capture Devices's Delay,OK
2020/06/27 18:38:12,sleep,2,OK
2020/06/27 18:38:15,#,OK
2020/06/27 18:38:15,# Video Caputuring and Cropping,OK
2020/06/27 18:38:15,capture_cam,hdmi01.jpg,OK
2020/06/27 18:38:15,crop_img,hdmi01.jpg,65:100,0:80,hdmi01_cropped.jpg,OK
2020/06/27 18:38:15,close_cam,OK
2020/06/27 18:38:15,#,OK
2020/06/27 18:38:15,# Extract strings by OCR,OK
2020/06/27 18:38:15,open_ocr,OK
2020/06/27 18:38:15,exec_ocr,hdmi01_cropped.jpg,1234,OK
2020/06/27 18:38:15,#,OK
2020/06/27 18:38:15,# evaluation,OK
2020/06/27 18:38:15,eval_str_eq,1234,OK
2020/06/27 18:38:15,# end.,OK
```

##5. おわりに
- Windowsに標準で入っている「メモ帳」をGUIで操作してテストの自動実行ができました。
  - HDMI接続のディスプレイにGUIを出力する機器であればPCに限らずラズパイなどのIoTデバイスや組込み機器も同じ仕組みを応用できるものと思います。
  - [コマンド制御のBluetoothキーボード](https://speakerdeck.com/pbjpkas/pin-zhi-ji-shu-wozhi-ru-xue-bu-zuo-ru?slide=28)とスマートフォン用のHDMI変換器を用意することでスマホアプリにも応用が利く期待があります。
- UVC対応ビデオキャプチャデバイスの採用によりWebカメラとテストランナーを共有できました。
  - UVC対応ビデオキャプチャデバイスはOS標準ドライバを使える点でも敷居が低いように思います。

##A. 付録 test-runner.pyのソース
```python:test-runner.py
#!/usr/bin/python3

#
# This software includes the work that is distributed in the Apache License 2.0
#

from time import sleep
import serial
import codecs
import csv
import platform
import datetime
import sys
import subprocess
import visa
import cv2
from PIL import Image
import pyocr
import pyocr.builders

UNINITIALIZED = 0xdeadbeef

def open_cam(camera_number, width, height):
    h = cv2.VideoCapture(camera_number)
    h.set(cv2.CAP_PROP_FRAME_WIDTH, width)
    h.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
    return h

def close_cam(cam):
    if cam != UNINITIALIZED:
        cam.release()

def capture_cam(cam, filename):
    if cam != UNINITIALIZED:
        _, img = cam.read()
        cv2.imwrite(filename, img)
        return True
    else:
        print("CAM Not Ready.")
        return False

def crop_img(filename_in, v, h, filename_out):
    img = cv2.imread(filename_in, cv2.IMREAD_COLOR)
    v0 = int(v.split(':')[0])
    v1 = int(v.split(':')[1])
    h0 = int(h.split(':')[0])
    h1 = int(h.split(':')[1])
    img2 = img[v0:v1, h0:h1]
    cv2.imwrite(filename_out, img2)
    return True

def open_ocr():
    ocr = pyocr.get_available_tools()
    if len(ocr) != 0:
        ocr = ocr[0]
    else:
        ocr = UNINITIALIZED
        print("OCR Not Ready.")
    return ocr

def exec_ocr(ocr, filename):
    try:
        txt = ocr.image_to_string(
            Image.open(filename),
            lang = "eng",
            builder = pyocr.builders.TextBuilder()
        )
    except:
        print("OCR Fail.")
    else:
        return txt

def exec_labelimg(filename, label_string):
    if platform.system() == "Windows" :
        python = "python"
        grep = "findstr"
    else:
        python = "python3"
        grep = "grep"
    
    cmd = python + " label_image.py --graph=c:\\tmp\\output_graph.pb --labels=c:\\tmp\\output_labels.txt \
        --input_layer=Placeholder --output_layer=final_result --image=" + filename + \
        "|" + grep + " " + label_string
    print(cmd)
    log = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
    ret = log.stdout.strip().decode("utf-8").split(" ")[1]
    return ret

def serial_write(h, string):
    if h != UNINITIALIZED:
        string = string + '\n'
        string = str.encode(string)
        h.write(string)
        return True
    else:
        print("UART Not Initialized.")
        return False

def close_uart(h):
    if h != UNINITIALIZED:
        h.close()
    else:
        #print("UART Not Initialized.")
        pass

def open_dso():
    rm = visa.ResourceManager()
    resources = rm.list_resources()
    #print(resources)
    for resource in resources:
        #print(resource)
        try:
            dso = rm.open_resource(resource)
        except:
            print(resource, "Not Found.")
        else:
            print(resource, "Detected.")
            return dso

    #Throw an error to caller if none succeed.
    return dso

def main():
    is_passed = True
    val = str(UNINITIALIZED)
    cam = UNINITIALIZED
    ocr = UNINITIALIZED
    dso = UNINITIALIZED
    uart = UNINITIALIZED

    with codecs.open('script.csv', 'r', 'utf-8') as file:
        script = csv.reader(file, delimiter=',', lineterminator='\r\n', quotechar='"')

        with codecs.open('result.csv', 'w', 'utf-8') as file:
            result = csv.writer(file, delimiter=',', lineterminator='\r\n', quotechar='"')

            for cmd in script:
                timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")
                print(cmd)

                if "#" in cmd[0]:
                    pass

                elif cmd[0] == "sleep":
                    sleep(float(cmd[1]))

                elif cmd[0] == "open_cam":
                    if len(cmd) == 2:
                        cam = open_cam(int(cmd[1]), 640, 480)
                    else:
                        cam = open_cam(int(cmd[1]), int(cmd[2]), int(cmd[3]))

                elif cmd[0] == "close_cam":
                    close_cam(cam)
                    cam = UNINITIALIZED

                elif cmd[0] == "capture_cam":
                    ret = capture_cam(cam, cmd[1])
                    if ret == False:
                        is_passed = False

                elif cmd[0] == "crop_img":
                    crop_img(cmd[1], cmd[2], cmd[3], cmd[4])

                elif cmd[0] == "open_ocr":
                    ocr = open_ocr()
                    if ocr == UNINITIALIZED:
                        is_passed = False

                elif cmd[0] == "exec_ocr":
                    try:
                        val = exec_ocr(ocr, cmd[1])
                    except:
                        is_passed = False
                    else:
                        cmd.append(str(val))

                elif cmd[0] == "exec_labelimg":
                    try:
                        val = exec_labelimg(cmd[1], cmd[2])
                    except:
                        is_passed = False
                    else:
                        cmd.append(str(val))

                elif cmd[0] == "open_dso":
                    try:
                        dso = open_dso()
                    except:
                        is_passed = False

                elif cmd[0] == "dso":
                    try:
                        if "?" in cmd[1]:
                            val = dso.query(cmd[1]).rstrip().replace(",", "-")
                            cmd.append(val)
                        else:
                            dso.write(cmd[1])
                    except:
                        is_passed = False

                elif cmd[0] == "open_uart":
                    if len(cmd) == 2:
                        dsrdtr_val = 1
                    else:
                        dsrdtr_val = int(cmd[2])
                    try:
                        uart = serial.Serial(cmd[1], 115200, timeout=1.0, dsrdtr=dsrdtr_val)
                    except:
                        is_passed = False

                elif cmd[0] == "send":
                    ret = serial_write(uart, cmd[1])
                    if ret == False:
                        is_passed = False

                elif cmd[0] == "rcvd":
                    try:
                        val = uart.readline().strip().decode('utf-8')
                        cmd.append(val)
                    except:
                        is_passed = False

                elif cmd[0] == "eval_str_eq":
                    if str(val) != str(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_eq":
                    if int(val) != int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_gt":
                    if int(val) < int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_lt":
                    if int(val) > int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_eq":
                    if float(val) != float(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_gt":
                    if float(val) < float(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_lt":
                    if float(val) > float(cmd[1]):
                        is_passed = False

                else:
                    cmd.append("#")

                if is_passed == True:
                    cmd.append("OK")
                    cmd.insert(0,timestamp)
                    print(cmd)
                    result.writerow(cmd)
                else:
                    cmd.append("NG")
                    cmd.insert(0,timestamp)
                    print(cmd)
                    result.writerow(cmd)
                    close_uart(uart)
                    close_cam(cam)
                    print("FAIL")
                    sys.exit(1)

    if is_passed == True:
        close_uart(uart)
        close_cam(cam)
        print("PASS")
        sys.exit(0)

main()
```