Lチカで始めるテスト自動化(27)矩形の画像を動画のフレームから切り出してファイルに保存する

## 1. はじめに
矩形の画像を動画のフレームから切り出してファイルに保存する機能を追加し、例としてrec_startコマンドで録画した動画に重畳している時刻情報をexec_ocrコマンドでテキスト化します。
![metadata superimpose](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/b241d570-eeeb-b48c-2898-ef6156026c8b.png)

## 2. テストランナーの改修
### 2.1 コマンド仕様

|コマンド|引数|機能
|--------|----|----
|crop_mov|入力ファイル名<br>切り出し位置（左上）<br>切り出し位置（右下）<br>出力フォルダ名|矩形の画像を動画のフレームから切り出して白黒二値化し出力フォルダに保存する

出力ファイル名は、<入力ファイル名のベースネーム>_<連番(8桁)>.<画像の形式>。

### 2.2 実装
[Python, OpenCVで動画ファイルからフレームを切り出して保存](https://note.nkmk.me/python-opencv-video-to-still-image/)で紹介されている[opencv_video_to_still.py](https://github.com/nkmk/python-snippets/blob/c4d70389733a34250595f1c17d737ef004f2dcbd/notebook/opencv_video_to_still.py#L1-L27) ([MIT License](https://github.com/nkmk/python-snippets/blob/c4d70389733a34250595f1c17d737ef004f2dcbd/LICENSE)、Copyright 2019 nkmk.me) をベースにさせていただきました。

```python:crop_mov()
def crop_mov(filename_in, p0, p1, dirname_out, ext="jpg"):
    """
    This function is based on
    https://github.com/nkmk/python-snippets/blob/c4d70389733a34250595f1c17d737ef004f2dcbd/notebook/opencv_video_to_still.py#L1-L27
    MIT License, Copyright 2019 nkmk.me
    """
    cap = cv2.VideoCapture(filename_in)
    if not cap.isOpened():
        return

    os.makedirs(dirname_out, exist_ok=True)
    base_name = os.path.splitext(os.path.basename(filename_in))[0]
    base_path = os.path.join(dirname_out, base_name)

    #digit = len(str(int(cap.get(cv2.CAP_PROP_FRAME_COUNT))))
    digit = 8

    h0 = int(p0.split(':')[0])
    v0 = int(p0.split(':')[1])
    h1 = int(p1.split(':')[0])
    v1 = int(p1.split(':')[1])

    n = 0
    while True:
        ret, frame = cap.read()
        if ret:
            frame2 = frame[v0:v1, h0:h1]
            frame3 = cv2.resize(frame2, dsize=None, fx=2.0, fy=2.0)
            frame4 = cv2.cvtColor(frame3, cv2.COLOR_BGR2GRAY)
            th, frame5 = cv2.threshold(frame4, 16, 255, cv2.THRESH_OTSU)
            #print(th)
            cv2.imwrite('{}_{}.{}'.format(base_path, str(n).zfill(digit), ext), frame5)
            n += 1
        else:
            return
```

呼び出し側です。
OpenCVが対応している画像フォーマットを5番目の引数に指定するとその形式で出力します（隠しコマンド）。

```python:Testrunner.parser()
        elif cmd[0] == "crop_mov":
            print(len(cmd))
            if len(cmd) == 6:
                crop_mov(cmd[1], cmd[2], cmd[3], cmd[4], cmd[5])
            else:
                crop_mov(cmd[1], cmd[2], cmd[3], cmd[4], "jpg")
```

### 2.3 画像切り出しスクリプト
Webカメラの画像をmov10s.mp4というファイル名で10秒録画し時刻部分の画像を切り出して1_dateフォルダへ保存する例です。

```mov10s.csv
open_cam,1,1280,720
rec_start,1,mov10s.mp4
sleep,10
rec_stop,1
crop_mov,mov10s.mp4,115:5,230:25,1_date
```

```cmd
python testrunner.py runscript mov10s.csv mov10s_res.csv
```

### 2.4 実行例
筆者の環境で実行したところ何フレームか取りこぼしが発生していることがわかりました。
- 環境
  - Webカメラ ロジクール[C270n](https://www.logicool.co.jp/ja-jp/products/webcams/hd-webcam-c270n.html)(720p/30fps)
  - ThinkPad X1 Carbon 5th(Core i7-7500U、メモリ16GB)
  - Windows 10 22H2
  - Python 3.10.0
  - opencv-python 4.5.4.60
- 動画の先頭フレームの時刻：15:45:28.798
![mov10s_00000000.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/06c601aa-58a2-8f11-c5c9-4ea4c4e7f665.jpeg)
- 動画の末尾フレームの時刻：15:45:36.875
![mov10s_00000231.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/2c9ceae2-a861-97a0-b43b-7ce6cc299674.jpeg)
- 切り出した画像は232枚（本来は30fps、8.077秒で242枚）
![mov10s.mov frames](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/971db4e1-126a-23e5-c05a-f55a466978f4.png)

## 3. OCRしてみた
切り出した画像を以下に示すテストスクリプトでOCRし、動画に重畳している時刻の間隔を調べます。きっちり30fps出ていれば間隔は0.033[sec]になります。

```mov10socr.csv
open_ocr
exec_ocr,1_date\mov10s_00000000.jpg
export,mov10s_ocr.txt,val
export,mov10s_ocr.txt,writerow
exec_ocr,1_date\mov10s_00000001.jpg
export,mov10s_ocr.txt,val
export,mov10s_ocr.txt,writerow
・・・
・・・
exec_ocr,1_date\mov10s_00000231.jpg
export,mov10s_ocr.txt,val
export,mov10s_ocr.txt,writerow
```

```cmd
set path=%PATH%;C:\Program Files (x86)\Tesseract-OCR
set TESSDATA_PREFIX=C:\Program Files (x86)\Tesseract-OCR
python testrunner.py runscript mov10socr.csv mov10socr_res.csv
```

OCRの結果を確認して誤検出を適宜修正します。
![mov10s-ocr-check.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/d58f54f6-0a88-71f0-9f2a-022b9ef2d973.png)

動画に重畳している時刻の間隔は概ね0.033[sec]を中心に振れていますが2倍の0.066[sec]を超えるケースも発生していました。
![mov10s.mp4 frame interval](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/5f33b67c-f7a3-bc31-6610-fdd3aba8191c.png)

![mov10s.mp4 frame interval number of occurrences](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/9269e881-3482-333d-207d-66b4c8cfd83a.png)

rec_startコマンドで録画を行っているプログラムを以下に抜粋します[^1]。whileループでループ1回ごとにファイルへの書き込みやGUIの更新、キー入力の監視を行っていて、間隔が伸び縮みしたり画像データの取りこぼしが発生するのはこのためと思います。

1. Webカメラの画像をcv2.VideoCapture.read()で取得する
1. 取得した画像にtext_over_img()で時刻情報を重畳する
1. 画像をcv2.VideoWriter.write()でファイルへ書き込む
1. 画像をcv2.imshow()でGUIに表示する
1. GUIのキー入力をcv2.waitKey()で待つ

```python:camera.py
    def text_over_img(self, img):
        timestamp = (datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S.%f")[:-3])
        videotxt  = self.get_video_txt()
        cv2.rectangle(img, (5,5), (self.w-5,25), (255,255,255), thickness=-1)
        cv2.putText(img, timestamp, ( 10,20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA)
        cv2.putText(img, videotxt,  (235,20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA)

    def video_record(self, filename):
        if self.cam == False:
            print("CAM Not Ready.")
            return False

        fourcc = cv2.VideoWriter_fourcc('m','p','4','v')
        out = cv2.VideoWriter(filename, fourcc, self.f, (self.w, self.h))
        msg = "CAM " + str(self.camera_id) + " : q to quit"

        while True:
            ret, img = self.cam.read()
            if ret == True:
                self.text_over_img(img)
                out.write(img)
                cv2.imshow(msg, img)

            if (cv2.waitKey(1) == ord('q')) or (self.get_video_rec() == False):
                print("### rec stop ###")
                break

        out.release()
        return True
```

## 4. おわりに
- 時刻を表示している箇所の画像を動画のフレームから切り出してファイルに保存しOCRでテキスト化できました。
- OCRしたところ間隔がばらついたり時々取りこぼしていることがわかりました。動画に重畳している時刻情報を利用する場合は時間分解能を0.1秒程度[^2]と考えると良さそうです。

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

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

from time import sleep
import random
import sys
import codecs
import csv
import datetime
import serial
import pyvisa as visa

#【Python】opencvでWebカメラの起動に時間がかかる問題の対処
# https://qiita.com/youichi_io/items/b894b85d790720ea2346
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2

from PIL import Image
import pyocr
import pyocr.builders
import platform
import subprocess
from subprocess import PIPE
import threading
from camera import Camera
from beepsound import BEEPSOUND

UNINITIALIZED = 0xdeadbeef
NUM_OF_SERVO = 6

class Uart:
    def __init__(self):
        self.device = []

    def list(self):
        print(self.device)
        return self.device

    def open(self, port, bps, timeout_val, dsrdtr_val):
        h = self.gethandle(port)
        if h == None:
            try:
                h = serial.Serial(port, int(bps), timeout=timeout_val, dsrdtr=int(dsrdtr_val))
                self.device.append(h)
                return True
            except:
                print("ERROR>>>open fail")
                return False
        else:
            print("WARNING>>>port %s is ready." % port)
            return True

    def send(self, port, txdata):
        h = self.gethandle(port)
        if h == None:
            print("ERROR>>>port %s is not ready." % port)
            return False
        else:
            try:
                txdata = txdata + '\n'
                txdata = str.encode(txdata)
                h.write(txdata)
                return True
            except:
                print("ERROR>>>send fail.")
                return False

    def rcvd(self, port):
        h = self.gethandle(port)
        if h == None:
            print("ERROR>>>port %s is not ready." % port)
            return False
        else:
            try:
                rxdata = h.readline().strip().decode('utf-8')
                print(rxdata)
                return rxdata
            except:
                print("ERROR>>>rcvd fail.")
                return False

    def gethandle(self, port):
        h = None
        for dev in self.device:
            if dev:
                if dev.port == port:
                    h = dev
                    break
        return h

class Export:
    def __init__(self):
        #exportfiles
        #概要
        #  exportコマンドで使用するN行3列の二次元配列
        #説明
        #  以下の3つの要素を1行3桁の配列で構造化し、
        #    1.ファイルオブジェクト(open()の戻り値)
        #    2.Writerオブジェクト(csv.writer()の戻り値)
        #    3.ファイルに出力するデータのバッファ
        #  出力するファイルの数だけこの配列を行方向に追加する
        #構造
        #  exportfiles = [ 
        #                 [ [FileObject],[WriterObject],[Buffer] ], #1つめの出力ファイルの配列
        #                 [ [FileObject],[WriterObject],[Buffer] ], #2つめの出力ファイルの配列
        #                 ...
        #                ]
        #初期値
        #  空の1行3列の2次元配列で初期化する
        self.exportfiles = [\
                            [ [],[],[] ]\
                           ]

    def parser(self, cmd, script_line, timestamp, val):
        #export,<filename.csv>,str,<string>
        #export,<filename.csv>,linenumber
        #export,<filename.csv>,timestamp
        #export,<filename.csv>,val
        #export,<filename.csv>,writerow
        #export,close
        if cmd[0] == "export":
            #1)引数が3以上の場合に処理を行う
            if len(cmd) >= 3:
                #2)操作対象のファイルディスクリプタを決める
                ##2.1)操作対象のファイルディスクリプタを検索する
                exportfile = None
                i = 0 #exportfiles配列の行番号
                #print(exportfiles)
                for f in self.exportfiles:
                    if f[0]:
                        #print(f[0].name)
                        if f[0].name == cmd[1]:
                            exportfile = f
                            break
                        i = i + 1

                #print("index " + str(i))

                ##2.2)操作対象のファイルディスクリプタがない場合は自動でopenする
                if exportfile == None:
                    if i >= 1:
                        self.exportfiles.append([[],[],[]])
                    self.exportfiles[i][0] = codecs.open(cmd[1], 'w', 'utf-8')
                    self.exportfiles[i][1] = csv.writer(self.exportfiles[i][0], delimiter=',', lineterminator='\r\n', quotechar='"')
                    #print(i, exportfiles)

                if cmd[2] == "str":
                    #print("str  " + self.exportfiles[i][0].name)
                    self.exportfiles[i][2].append(cmd[3])

                elif cmd[2] == "linenumber":
                    #print("line " + self.exportfiles[i][0].name)
                    self.exportfiles[i][2].append(str(script_line))

                elif cmd[2] == "timestamp":
                    #print("time " + self.exportfiles[i][0].name)
                    self.exportfiles[i][2].append(timestamp)

                elif cmd[2] == "val":
                    #print("val  " + self.exportfiles[i][0].name)
                    self.exportfiles[i][2].append(str(val))

                elif cmd[2] == "writerow":
                    #print("wrow " + self.exportfiles[i][0].name)
                    #print(self.exportfiles)
                    self.exportfiles[i][1].writerow(self.exportfiles[i][2])
                    self.exportfiles[i][2].clear()

                else:
                    print("export parameter error")
                    return False

            elif cmd[1] == "close":
                for f in self.exportfiles:
                    if f[0]:
                        print("close " + f[0].name)
                        f[0].close()

            else:
                print("export parameter error")
                return False

        return True

class Testrunner:
    def __init__(self):
        self.is_passed = True
        self.val  = str(UNINITIALIZED)
        self.fval = 0.0
        self.uart = Uart()
        self.dso  = UNINITIALIZED
        self.ocr  = UNINITIALIZED
        self.cmds = ""
        self.cam0 = Camera(0)
        self.cam1 = Camera(1)
        self.cam2 = Camera(2)
        self.cam  = [self.cam0, self.cam1, self.cam2]
        self.beepsound = BEEPSOUND()
        self.export = Export()
        self.script_line = 0

    def parser(self, cmd):
        timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")
        self.script_line += 1
        print("##### " + timestamp + " " + str(self.script_line) + " #####")

        cmds = str(self.script_line) + ":"
        for i in range(len(cmd)):
            cmds += str(cmd[i]) + ","
        for i in range(len(self.cam)):
            self.cam[i].set_video_txt(cmds)
        print(cmds)

        if "#" in cmd[0]:
            pass

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

        elif cmd[0] == "rusleep":
            self.fval = random.uniform(float(cmd[1]), float(cmd[2]))
            self.val = str(self.fval)
            cmd.append(self.val)
            cmds += self.val
            for i in range(len(self.cam)):
                self.cam[i].set_video_txt(cmds)
            print(cmds)
            sleep(self.fval)

        elif cmd[0] == "prompt":
            print("Enter y/n")
            self.val = input()
            self.val = self.val.lower()
            cmd.append(self.val)
            cmds += self.val
            for i in range(len(self.cam)):
                self.cam[i].set_video_txt(cmds)
            print(cmds)
            sleep(1)
            if self.val != "y":
                self.is_passed = False

        elif cmd[0] == "list_uart":
             self.val = self.uart.list()
             cmd.append(self.val)

        elif cmd[0] == "open_uart":
            try:
                self.is_passed = self.uart.open(cmd[1], cmd[2], 1.0, cmd[3])
            except:
                print("Open Fail")
                self.is_passed = False

        elif cmd[0] == "send_uart":
            ret = self.uart.send(cmd[1], cmd[2])
            if ret == False:
                self.is_passed = False

        elif cmd[0] == "rcvd_uart":
            try:
                self.val = self.uart.rcvd(cmd[1])
                cmd.append(self.val)
            except:
                self.is_passed = False

        elif cmd[0] == "open_dso":
            try:
                self.dso = open_dso()
                print(self.dso)
                if self.dso == UNINITIALIZED:
                    self.is_passed = False
            except:
                self.is_passed = False

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

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

        elif cmd[0] == "close_cam":
            self.cam[int(cmd[1])].close_cam()

        elif cmd[0] == "close_allcam":
            for i in range(len(self.cam)):
                print("CamID:%d" % i)
                if self.cam[i].get_video_rec() == True:
                    self.cam[i].set_video_rec(False)
                    self.cam[i].thread.join()
                else:
                    pass
                    #print("No Recording.")
                self.cam[i].close_cam()

        elif cmd[0] == "capture_cam":
            ret = self.cam[int(cmd[1])].capture_cam(cmd[2])
            if ret == False:
                self.is_passed = False

        elif cmd[0] == "rec_start":
            if self.cam[int(cmd[1])].get_video_rec() == False:
                self.cam[int(cmd[1])].set_video_rec(True)
                self.cam[int(cmd[1])].thread = threading.Thread(target=self.cam[int(cmd[1])].video_record, args=(cmd[2],))
                self.cam[int(cmd[1])].thread.start()
            else:
                print("Already Recording.")

        elif cmd[0] == "rec_stop":
            if self.cam[int(cmd[1])].get_video_rec() == True:
                self.cam[int(cmd[1])].set_video_rec(False)
                self.cam[int(cmd[1])].thread.join()
            else:
                print("No Recording.")

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

        elif cmd[0] == "crop_mov":
            print(len(cmd))
            if len(cmd) == 6:
                crop_mov(cmd[1], cmd[2], cmd[3], cmd[4], cmd[5])
            else:
                crop_mov(cmd[1], cmd[2], cmd[3], cmd[4], "jpg")

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

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

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

        #set_servo, port, ID, Val
        elif cmd[0] == "set_servo":
            ret = set_servo(self.uart, cmd[1], int(cmd[2]), int(cmd[3]))
            if ret == False:
                self.is_passed = False

        elif cmd[0] == "run":
            ret = subprocess.run(cmd[1], shell=True, stdout=PIPE, stderr=PIPE, universal_newlines=True)
            self.val = ret.stdout.strip()
            print(ret)
            if ret.returncode != 0:
                self.is_passed = False

        elif cmd[0] == "beepsound":
            if cmd[1] == "FurElise":
                self.beepsound.set_tempo(60)
                self.beepsound.FurElise()
            elif cmd[1] == "Menuet":
                self.beepsound.set_tempo(120)
                self.beepsound.Menuet()
            elif cmd[1] == "ToccataUndFugeInDMoll":
                self.beepsound.set_tempo(120)
                self.beepsound.ToccataUndFugeInDMoll()
            else:
                self.is_passed = False

        elif cmd[0] == "export":
            self.export.parser(cmd, self.script_line, timestamp, self.val)

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

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

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

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

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

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

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

        elif cmd[0] == "runscript":
            if len(cmd) == 3:
                self.__runscript(cmd[1],cmd[2])
            else:
                print("ERROR>>>invalid args.")
                self.is_passed = False

        else:
            cmd.append("#")

        return self.is_passed, timestamp, cmd

    def __runscript(self, script_file_name, result_file_name):
        with codecs.open(script_file_name, 'r', 'utf-8') as file_script:
            script = csv.reader(file_script, delimiter=',', lineterminator='\r\n', quotechar='"')

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

                for cmd in script:
                    self.is_passed, timestamp, cmd = self.parser(cmd)
                    if self.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)
                        print("FAIL")
                        return

        if self.is_passed == True:
            print("PASS")
            return

def help():
    helpmsg = [\
        "usage:",\
        "  python testrunner.py <command>",\
        "command:",\
        "  sleep,<sleep[sec]>                                               sleep",\
        "  rusleep,<lower[sec]>,<upper[sec]>                                random sleep",\
        "  prompt                                                           hit y/n to continue",\
        "  open_uart,<port>,<bps>,<dsrdtr(0 or 1)>                          open uart",\
        "  send_uart,<port>,<txdata>                                        send txdata to port",\
        "  rcvd_uart,<port>                                                 receive rxdata from port",\
        "  list_uart                                                        print uart list",\
        "  set_servo,<port>,<servo ID>,<angle>                              servo moving",\
        "  open_dso                                                         open VISA device",\
        "  dso,<txdata>                                                     send txdata to VISA device",\
        "  open_cam,   <Camera ID>,<Width>,<Height>                         open Camera device",\
        "  capture_cam,<Camera ID>,<filename.png>                           capture Camera picture",\
        "  rec_start,  <Camera ID>,<filename.mp4>                           start Video recording",\
        "  rec_stop,   <Camera ID>                                          stop Video recording",\
        "  close_cam,  <Camera ID>                                          close Camera device",\
        "  close_allcam                                                     close all Camera device",\
        "  crop_img,<filename(from)>,<pos1(x:y)>,<pos2(x:y)>,<filename(to)> crop image from file",\
        "  crop_mov,<filename(from)>,<pos1(x:y)>,<pos2(x:y)>,<dirname(to)>  crop image from movie file",\
        "  exec_ocr,<filename>                                              ocr",\
        "  exec_labelimg,<filename>,<label>                                 TensorFlow labelimg",\
        "  export,<filename.csv>,str,<string>",\
        "  export,<filename.csv>,linenumber",\
        "  export,<filename.csv>,timestamp",\
        "  export,<filename.csv>,val",\
        "  export,<filename.csv>,writerow",\
        "  export,close",\
        "  eval_str_eq,<value>",\
        "  eval_int_eq,<value>",\
        "  eval_int_gt,<value>",\
        "  eval_int_lt,<value>",\
        "  eval_dbl_eq,<value>",\
        "  eval_dbl_gt,<value>",\
        "  eval_dbl_lt,<value>",\
        "  beepsound,<FurElise/Menuet/ToccataUndFugeInDMoll>                beep sound",\
        "  run,<command strings>                                            execute command",\
        "  runscript,<scriptfile.csv>,<resultfile.csv>                      run command in script file"
    ]
    for i in helpmsg:
        print(i)

def open_dso():
    dso = UNINITIALIZED
    rm = visa.ResourceManager()
    resources = rm.list_resources()
    #print(resources)
    for resource in resources:
        #print(resource)
        if resource.find("USB0",0,4) == 0: #文字列がUSB0で始まれば0、そうでない場合は-1
            try:
                dso = rm.open_resource(resource)
            except:
                print(resource, "Not Found.")
            else:
                print(resource, "Detected.")
                return dso
        else:
            print("%s is not DSO" % resource)

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

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

def crop_mov(filename_in, p0, p1, dirname_out, ext="jpg"):
    """
    This function is based on
    https://github.com/nkmk/python-snippets/blob/c4d70389733a34250595f1c17d737ef004f2dcbd/notebook/opencv_video_to_still.py#L1-L27
    MIT License, Copyright 2019 nkmk.me
    """
    cap = cv2.VideoCapture(filename_in)
    if not cap.isOpened():
        return

    os.makedirs(dirname_out, exist_ok=True)
    base_name = os.path.splitext(os.path.basename(filename_in))[0]
    base_path = os.path.join(dirname_out, base_name)

    #digit = len(str(int(cap.get(cv2.CAP_PROP_FRAME_COUNT))))
    digit = 8

    h0 = int(p0.split(':')[0])
    v0 = int(p0.split(':')[1])
    h1 = int(p1.split(':')[0])
    v1 = int(p1.split(':')[1])

    n = 0
    while True:
        ret, frame = cap.read()
        if ret:
            frame2 = frame[v0:v1, h0:h1]
            frame3 = cv2.resize(frame2, dsize=None, fx=2.0, fy=2.0)
            frame4 = cv2.cvtColor(frame3, cv2.COLOR_BGR2GRAY)
            th, frame5 = cv2.threshold(frame4, 16, 255, cv2.THRESH_OTSU)
            #print(th)
            cv2.imwrite('{}_{}.{}'.format(base_path, str(n).zfill(digit), ext), frame5)
            n += 1
        else:
            return

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 set_servo(uart, port, servo_id, servo_pos):
    if servo_id < 0 or servo_id >= NUM_OF_SERVO:
        print("Invalid Servo ID")
        return False

    if servo_pos < 0 or servo_pos > 180:
        print("Invalid Servo Position")
        return False

    if uart.gethandle(port) != None:
        uart.send(port, "servoread")

        servo_position = uart.rcvd(port)
        print(servo_position)

        # discard "OK"
        devnull = uart.rcvd(port)

        current_pos = int(servo_position.split(' ')[servo_id])
        #print(servo_id, current_pos)

        if current_pos < servo_pos:
            start = current_pos +1
            stop  = servo_pos +1
            step  = 1
        else:
            start = current_pos -1
            stop  = servo_pos -1
            step  = -1

        for i in range(start, stop, step):
            command = "servo " + str(servo_id) + " " + str(i)
            print(command)
            uart.send(port, command)
            # discard "OK"
            devnull = uart.rcvd(port)
            sleep(0.2) # sec.

        return True
    else:
        print("ERROR>>>port %s is not ready." % port)
        return False

def main():
    #print(sys.argv)
    if len(sys.argv) == 1:
        help()
    else:
        sys.argv.pop(0)  #引数の1番目の要素(=testrunner.py)を削除

        testrunner = Testrunner()
        ret = testrunner.parser(sys.argv)
        #print(ret)

        testrunner.parser(["close_allcam"])
        testrunner.parser(["export","close"])

        if ret[0] == True:
            testrunner.parser(["beepsound","Menuet"])
            print("PASS")
            sys.exit(0)
        else:
            testrunner.parser(["beepsound","FurElise"])
            print("FAIL")
            sys.exit(1)


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

## 付録B. Lチカで始めるテスト自動化・記事一覧
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)
8. [Lチカで始めるテスト自動化(8)HDMIビデオキャプチャデバイスの組込み](https://qiita.com/pbjpkas/items/58f84c347e44fb9d5635)
9. [Lチカで始めるテスト自動化(9)6DoFロボットアームの組込み](https://qiita.com/pbjpkas/items/7a4cb4c59886b1fd2cf9)
10. [Lチカで始めるテスト自動化(10)6DoFロボットアームの制御スクリプトの保守性向上](https://qiita.com/pbjpkas/items/c011d7ad6f98bc1458d5)
11. [Lチカで始めるテスト自動化(11)ロボットアームのコントローラ製作](https://qiita.com/pbjpkas/items/fa6d2956ea3dd18b3584)
12. [Lチカで始めるテスト自動化(12)書籍化の作業メモ](https://qiita.com/pbjpkas/items/e76a763de7f496d5430c)
13. [Lチカで始めるテスト自動化(13)外部プログラムの呼出し](https://qiita.com/pbjpkas/items/717b2aba39798d8cab1d)
14. [Lチカで始めるテスト自動化(14)sleepの時間をランダムに設定する](https://qiita.com/pbjpkas/items/4f023917f31f6096eb6b)
15. [Lチカで始めるテスト自動化(15)Raspberry Pi Zero WHでテストランナーを動かして秋月のIoT学習HATキットに進捗を表示する](https://qiita.com/pbjpkas/items/a2e7551ae10f7dce2505)
16. [Lチカで始めるテスト自動化(16)秋月のIoT学習HATキットにBME280を接続してテスト実行環境の温度・湿度・気圧を取得する](https://qiita.com/pbjpkas/items/0a48ca71a2de0042e188)
17. [Lチカで始めるテスト自動化(17)コマンド制御のBLE Keyboard & MouseをM5Stackで製作しiOSアプリをテストスクリプトで操作する](https://qiita.com/pbjpkas/items/3df58743644128a11b9e)
18. [Lチカで始めるテスト自動化(18)秋月のIoT学習HATキットの圧電ブザーでテスト終了時にpass/failに応じてメロディを流す](https://qiita.com/pbjpkas/items/43a609581cc68b7ea2fd)
19. [Lチカで始めるテスト自動化(19)Webカメラの映像を録画しながらテストスクリプトを実行する](https://qiita.com/pbjpkas/items/589243af5c9ff0dc933c)
20. [Lチカで始めるテスト自動化(20)複数のカメラ映像の同時録画](https://qiita.com/pbjpkas/items/cdfc0238b54bf197e98a)
21. [Lチカで始めるテスト自動化(21)キーボード入力待ちの実装](https://qiita.com/pbjpkas/items/9e3c21c3cd375635568a)
22. [Lチカで始めるテスト自動化(22)DACをコマンドで制御してLチカする](https://qiita.com/pbjpkas/items/35d1450804f3bfbe238e)
23. [Lチカで始めるテスト自動化(23)ブレッドボード互換のユニバーサル基板](https://qiita.com/pbjpkas/items/ca510b483e7f36e97d96)
24. [Lチカで始めるテスト自動化(24)コマンドの実行結果を任意のCSVファイルへ出力する](https://qiita.com/pbjpkas/items/7751a08eaaa0e3d50298)
25. [Lチカで始めるテスト自動化(25)Windowsでもテスト終了時にpass/failに応じてメロディを流す](https://qiita.com/pbjpkas/items/5d8ca74b011d4c8d6e43)
26. [Lチカで始めるテスト自動化(26)テストランナーのモジュール化](https://qiita.com/pbjpkas/items/ce2d032f9ee9d641a229)

電子書籍化したものを[技術書典](https://techbookfest.org/)の[ka'sらぼ](https://techbookfest.org/organization/4777931404476416)で頒布しています。

1. [Lチカで始めるテスト自動化](https://techbookfest.org/product/5661312486998016)
2. [Lチカで始めるテスト自動化 -2- リレー駆動回路の設計](https://techbookfest.org/product/5299462253576192) (※書き下ろし)
3. [Lチカで始めるテスト自動化 -3- Raspberry Pi Zero WHとIoT学習HATキットで作るテストランナー](https://techbookfest.org/product/6323836549070848)

[^1]: camera.py全文は[Lチカで始めるテスト自動化(20)複数のカメラ映像の同時録画 5.camera.py](https://qiita.com/pbjpkas/items/cdfc0238b54bf197e98a#5-camerapy)をご覧ください。
[^2]: あくまでも筆者の環境で10秒弱録画した動画にもとづく値です。