アナログ時計をマイコンで読む

# 背景

やれIoTだEdge computingだと言われていますが、話題にされるのはRaspberry PiだったりJetson Nanoだったりで、電源が常時供給されているのが大前提なことがほとんどです。M5StickVなどのK210のボードたちもあまり低消費電力な気がしない。

でも本当は乾電池で1年ぐらいもつようなのでやりたいなぁ。

そんなときにMassimoの"tiny machine learning with Arduino"を見ました。

<iframe width="560" height="315" src="https://www.youtube.com/embed/y7S_n1AT5sM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

マイコン(Arm Cortex M7, M4)でもTensorflow Liteが動かせるらしい。そしてマイコンでもカメラから画像をとれるらしい。
そういえば以前カメラ付きのマイコンボード[OpenMV H7](https://openmv.io/products/openmv-cam-h7)を買ったまま積んでたのがある。

マイコンで写真を撮って推論までできるなら、間欠動作で乾電池で1年持たせるのも行けるのかも！？
ということでマイコンで画像関係の深層学習の推論がどれぐらいできるのか試してみました。

## お題

アナログ時計を撮影し、その時刻をマイコンに認識させます。
![I20210505082249.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/170828da-1d2c-7753-85f2-f8dcc05abccb.jpeg)
こんな感じの画像から、8時22分だと読み取らせたい。

今回は後述する諸々の都合で、撮影画像は48x48ピクセルのグレイスケール、長針だけに注目して0~4分は"00", 5~9分は"01"といった12個のクラスに分けられるかに挑戦します。

## そもそもアナログ時計を読み取る例ってある？

電気メータやガスメータの値を、画像から読み取りたい需要は結構あるはず。

でもパッと出るのはデジタル数字を読み取るパターン。
https://www.novatec-gmbh.de/en/blog/exploring-automated-meter-reading-using-computer-vision/
https://www.mkompf.com/cplus/emeocv.html
https://betterprogramming.pub/yolov4-for-water-meter-reading-5d27191bc053
少しアナログメーターの例もある？
https://medium.com/eliiza-ai/utility-meter-reading-on-mobile-devices-dfb376c83465
https://nanonets.com/blog/sub-meter-reading-using-deep-learning/
https://arxiv.org/pdf/2005.03106.pdf

Kaggleとかであったりするのかな？
例などご存じの方ぜひコメントください。

# 利用した環境

## マイコン OpenMV H7

とりあえず手元にあったOpenMV H7で試しました。
新しい [OpenMV H7 Plus](https://openmv.io/collections/products/products/openmv-cam-h7-plus)はTensorFlow Liteをより快適に動かすためにRAMとFLASHメモリをもりもりにしている。実際後述する[Edge Impulseのチュートリアル](https://docs.edgeimpulse.com/docs/openmv-cam-h7-plus)もOpenMV H7 Plusを前提に書かれている。でも小さいモデルならOpenMV H7でも動くらしいので試してみました。

|  | OpenMV Cam H7 | OpenMV Cam H7 Plus |
|:-:|:-:|:-:|
| Processor | RM® 32-bit Cortex®-M7 CPU 480 MHz (1027 DMIPS) | RM® 32-bit Cortex®-M7 CPU 480 MHz (1027 DMIPS) |
| RAM Layout | 256KB .DATA/.BSS/Heap/Stack 512KB Frame Buffer/Stack 256KB DMA Buffers | 256KB .DATA/.BSS/Heap/Stack __32MB Frame Buffer/Stack__ 256KB DMA Buffers |
| Flash Layout | 128KB Bootloader 128KB Embedded Flash Drive 1792KB Firmware | 128KB Bootloader __16MB Embedded Flash Drive__ 1792KB Firmware |

## モデル構築と学習は Edge Impulseにおまかせ

学習モデルの構築や学習、そしてTensorFlow Liteへの変換まで。すべてEdge Impulseにおまかせすることにしました。最初はGoogleのColabでポチポチやろうと思っていたのですが、深層学習自体あまり経験ないし、TensorFlowのバージョンを合わせたりが大変だと聞いて尻込みました。

何よりEdge Impulseのチュートリアル見てたらもうこれでいいじゃんってなりました。

<iframe width="560" height="315" src="https://www.youtube.com/embed/2wvbgsjpt2U" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


サインアップしてプロジェクトをつくり、学習させるデータをアップロードし、学習モデルをブロックで選択するだけ。転移学習ブロックも選べるし、深層学習ブロックもある。層数とかもいじれるけど、とりあえずデフォルトの値でそれなりに動くのが嬉しい。

![Screen Shot 2021-05-05 at 20.39.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/b684859a-cbff-552a-eea1-6d54155f7a9f.png)

さらに学習後は自動的に量子化した上でTensorFlow Liteに変換し、ArduinoやC++、WebAssemblyなどからすぐに使えるコードまで出力してくれる。なんて素晴らしい！ありがたく使わせていただきます。

# 作業

## 学習データ取得

OpenMVで学習用の画像を撮影します。OpenMVはMicroPythonで動かすことができるので楽しいです。教師用のラベル付けを自動化するために、リアルタイムクロックの時刻情報をファイル名にします。OpenMV内臓のリアルタイムクロックはバックアップバッテリーを取り付けることができないので電源が切れるごとにリセットされてしまいます。仕方ないので[boochowさんの記事](https://blog.boochow.com/article/459684626.html)を参考にDS3231を外付けしました。ドライバコードもほぼコピーさせていただきました。

主要な部分コメント

```python
sensor.reset() # Initialize the camera sensor.
sensor.set_pixformat(sensor.GRAYSCALE)
sensor.set_framesize(sensor.QQVGA) # or sensor.QQVGA (or others)
sensor.set_windowing((62,0,96,96))       # Set 96x96 window.
sensor.skip_frames(time = 2000) # Let new settings take affect.
```

カメラを初期化。Edge Impulseで後で転移学習させるときに96x96 pixelをおすすめされるので、pixformatとして近いQQVGAを指定した上でset_windowingで96x96分の領域だけを取り出すことにする。暗くても撮影できるようにOpenMV上のLEDを点灯するのですが、カメラボディの真横にあるため画像の一部に影ができます。それを避けるために画角の右上部分の96x96を使う。

```python
from pyb import I2C

i2c = I2C(4)
i2c.init(I2C.MASTER)

c = DS3231(i2c)
#c.write([2021, 5, 2, 15, 31, 0, 0]) # 2021/5/2 15:31:00 Sun
```

DS3231はI2C Bus4に接続しました。初回に一度だけ時刻を設定してあげます。撮影するアナログ時計と同時刻になるように慎重に狙って。次からは時刻設定のラインはコメントアウト。

```python
while True:
    dateTime = c.read()
    year = str(dateTime[0])
    month = '%02d' % dateTime[1]
    day = '%02d' % dateTime[2]
    hour = '%02d' % dateTime[3]
    minute = '%02d' % dateTime[4]
    second = '%02d' % dateTime[5]
    newName='I'+year+month+day+hour+minute+second+'.jpg' # Image file name based on RTC

    pyb.LED(BLUE_LED_PIN).on()
    pyb.LED(RED_LED_PIN).on()
    pyb.LED(GREEN_LED_PIN).on()

    img = sensor.snapshot().scale(0.5, 0.5)
    img.save('images/' + newName, quality=80)

    pyb.LED(BLUE_LED_PIN).off()
    pyb.LED(RED_LED_PIN).off()
    pyb.LED(GREEN_LED_PIN).off()
    utime.sleep(5)
```

あとは時刻を読みファイル名決定、LED点灯、撮影し48x48のサイズに変換して保存、LED消灯のサイクルを5秒ごとに繰り返します。

main.pyの全体はこちら。撮影枚数が多いのでuSDに保存します。

```python:main.py
import sensor, image, pyb, os, utime

RED_LED_PIN = 1
GREEN_LED_PIN = 2
BLUE_LED_PIN = 3

def decimal2num(b):
    return ((b & 0xf) + 10 * (b >> 4))

def num2decimal(d):
    return (d % 10) | (d // 10) << 4

def hour_value(b):
    result = decimal2num(b & 0x1f)
    #if (b & 0x20):
    #    result += 12
    #if (b & 0x40) == 0:
    #    result += 8
    return result

class DS3231:
    # Taken from https://blog.boochow.com/article/459684626.html
    def __init__(self, i2c, addr=0x68):
        self.i2c = i2c
        self.addr = addr

    def read_reg(self, reg, num=1):
        #self.i2c.writeto(self.addr, bytearray([reg]), False)
        #return self.i2c.readfrom(self.addr, num)
        return self.i2c.mem_read(num, self.addr, reg)

    def write_reg(self, reg, value):
        #self.i2c.writeto(self.addr, bytearray([reg]), False)
        #self.i2c.writeto(self.addr, value)
        self.i2c.mem_write(value, self.addr, reg)

    def read(self):
        date = self.read_reg(0, 7)
        #print(ubinascii.hexlify(date))
        result = [2000 + decimal2num(date[6])]     # year
        result.append(decimal2num(date[5] & 0x7f)) # month
        result.append(decimal2num(date[4]))        # day
        result.append(hour_value(date[2]))         # hour
        result.append(decimal2num(date[1]))        # min
        result.append(decimal2num(date[0]))        # sec
        result.append(decimal2num(date[3]))        # day of week
        return result

    def write(self, date):
        data = [num2decimal(date[5])]
        data.append(num2decimal(date[4]))
        data.append(num2decimal(date[3]))
        data.append(date[6] & 7)
        data.append(num2decimal(date[2]))
        data.append(num2decimal(date[1]))
        data.append(num2decimal(date[0] - 2000))
        print(data)
        self.write_reg(0, bytearray(data))

    def temperature(self):
        t = self.read_reg(0x11, 2)
        return 0.25 * (t[1] >> 6) + t[0]


from pyb import I2C

i2c = I2C(4)
i2c.init(I2C.MASTER)

c = DS3231(i2c)
#c.write([2021, 5, 2, 15, 31, 0, 0]) # 2021/5/2 15:31:00 Sun

if not "images" in os.listdir():
    print("no images folder")
    os.mkdir("images") # Make a temp directory
else:
    print("found images folder")

sensor.reset() # Initialize the camera sensor.
sensor.set_pixformat(sensor.GRAYSCALE)
sensor.set_framesize(sensor.QQVGA) # or sensor.QQVGA (or others)
sensor.set_windowing((62,0,96,96))       # Set 96x96 window.
sensor.skip_frames(time = 2000) # Let new settings take affect.

while True:
    dateTime = c.read()
    print(dateTime)
    year = str(dateTime[0])
    month = '%02d' % dateTime[1]
    day = '%02d' % dateTime[2]
    hour = '%02d' % dateTime[3]
    minute = '%02d' % dateTime[4]
    second = '%02d' % dateTime[5]

    newName='I'+year+month+day+hour+minute+second+'.jpg' # Image file name based on RTC
    print(newName)

    pyb.LED(BLUE_LED_PIN).on()
    pyb.LED(RED_LED_PIN).on()
    pyb.LED(GREEN_LED_PIN).on()

    img = sensor.snapshot().scale(0.5, 0.5)
    img.save('images/' + newName, quality=80)
    pyb.LED(BLUE_LED_PIN).off()
    pyb.LED(RED_LED_PIN).off()
    pyb.LED(GREEN_LED_PIN).off()
    print("Done.")
    utime.sleep(5)
```


![Screen Shot 2021-05-05 at 20.48.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/97b66f71-a4cb-946d-38d5-c7fb7b3b48d1.png)

## 学習データのアップロード

20時間ぐらいの撮影で9千枚ぐらいの画像ができました。uSDカードからローカルPCにコピーし、ラベル付けした上でEdge Impulseにアップロードしていきます。

ファイル名が正解時刻のラベルになっています。今回は画像サイズが小さく自分が目で見ても時刻が読みづらい。。。とりあえず長針だけに注目し、細かな1分単位は諦めて5分ごとにクラス分けすることにします。0~4分は"00", 5~9分は"01"といったように。

```python
import os
import glob
import re
import shutil

file_path = "images/20210504/"
target_path = "images/minutes20210504"

flist = glob.glob(file_path + "*.jpg")
pattern = r"I(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2}).jpg"

for minute in range(12):
    destination = os.path.join(target_path, "{:02d}".format(minute))
    if not os.path.exists(destination):
        os.mkdir(destination)

for fname in flist:
    year, month, day, hour, minute, second = re.match(pattern, os.path.basename(fname)).groups()
    myi = int(minute)
    if (0 <= myi) and (myi < 5):
        myt = "00"
    elif (5 <= myi) and (myi < 10):
        myt = "01"
    elif (10 <= myi) and (myi < 15):
        myt = "02"
    elif (15 <= myi) and (myi < 20):
        myt = "03"
    elif (20 <= myi) and (myi < 25):
        myt = "04"
    elif (25 <= myi) and (myi < 30):
        myt = "05"
    elif (30 <= myi) and (myi < 35):
        myt = "06"
    elif (35 <= myi) and (myi < 40):
        myt = "07"
    elif (40 <= myi) and (myi < 45):
        myt = "08"
    elif (45 <= myi) and (myi < 50):
        myt = "09"
    elif (50 <= myi) and (myi < 55):
        myt = "10"
    elif (55 <= myi):
        myt = "11"
    destination = os.path.join(target_path, myt)
    shutil.copy(fname, destination)
```

target_pathの下に12個のクラスにあたるフォルダを作り、ファイル名をparseしてminuteの数字にしたがってクラスを決定し、対応するフォルダにコピーします。

次に各クラス（フォルダ）ごとに[Edge Impulse CLI](https://docs.edgeimpulse.com/docs/cli-installation)を呼び出してアップロードします。

```python
import os
import subprocess

# Get API key from Edge Impulse project page
# this is unique for each project
api_key = 'ei_XXXX'

target_path = "images/minutes20210504/"
folders = os.listdir(target_path)

for label in folders:
    cmd = "edge-impulse-uploader --api-key {} --category split --label {} {}{}/*.jpg".format(api_key, label, target_path, label)
    process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, shell=True)
    print(cmd)
    print(process.stdout)
    print(process.stderr)
```

API keyはプロジェクトごとに紐付けられているので、特にプロジェクト名を指定しなくても狙ったプロジェクトにアップされるようです。
subprocess.runを呼び出す際には、ワイルドカードが含まれているから(?) shell=Trueを指定する必要がありました。そしてコマンドの引数はリスト形式ではなく、スペース区切りの文字列にしたらうまくいきました。

Edge Impulseのプロジェクトページできちんとデータがアップロードされたのを確認します。

![Screen Shot 2021-05-05 at 23.42.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/65b73e3c-f02b-ff1c-6892-238f228bb65c.png)
![Screen Shot 2021-05-05 at 23.43.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/fc959ba3-ab87-04bf-c0e6-2a848e5cafa3.png)

ちゃんとTraining dataと Test data に適当に振り分けられてますね。
12 Labelの分布も円グラフで表示されほぼ均等であるのが確認できます。

## 学習モデルの定義

ここからはEdge Impulseのサイト上で操作していきます。
Create Impulseでprocessing blockとlearning blockを指定します。画像データだとprocessing blockとしてはサイズとカラーかグレイスケールかの選択肢しかないのが素晴らしい。Learning blockとしては転移学習かニューラルネットワークを選べます。

![Screen Shot 2021-05-05 at 20.39.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/a8ab0b98-757c-f88c-b093-667df650633e.png)

今回はニューラルネットワークを選び、デフォルトの 2D conv 2層の構成で進めます。
![Screen Shot 2021-05-05 at 23.50.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/8483d94f-e5ca-a8e6-8c63-17c7832808e8.png)
あらかじめそれなりに動くデフォルト値がセットされているのがいいですね。

あまり考えずに"Start training"を押したら学習が始まりました。10分ぐらいでもう終了。
![Screen Shot 2021-05-05 at 23.50.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/f2fea41d-a2e9-d6c2-3ccf-a8738bdf7d2c.png)

終了と同時に自動的に量子化もしてくれて、int8のモデルで91%の精度だそうです。しかもOn-device performanceまで表示してくれてる。スゴー。ちなみに推論に576msかかりそうで遅いですが、これはnRF53840での予想です。OpenMV H7用のマイコンを選べなかったのですが、Cortex-M7 216MHzを選ぶと118msでした。480MHzのOpenMV H7なら53msぐらいでしょうか？

## 実機にディプロイ

Edge Impulseがさらに嬉しいことに、TensorFlow Liteモデルを作ってくれるだけでなく、それを各種マイコンなどからすぐに使えるコードまで出力してくれるんです。

![Screen Shot 2021-05-05 at 20.46.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138680/d5177c6b-57bc-c36b-b29c-2ca1bbfa1689.png)

今回はOpenMVを選びBuildしてみたところ、TensorFlow Liteのモデルファイル(trained.tflite)とラベルのファイル(labels.txt)とともに、OpenMVで走らせるei_image_classification.py がダウンロードできました。
ei_image_classification.pyを学習時の条件に近づけるように少し修正してました。

```python:ei_image_classification.py
# Edge Impulse - OpenMV Image Classification Example

import sensor, image, time, os, tf, pyb

sensor.reset()                         # Reset and initialize the sensor.
sensor.set_pixformat(sensor.GRAYSCALE)    # Set pixel format to RGB565 (or GRAYSCALE)
sensor.set_framesize(sensor.QQVGA) # or sensor.QQVGA (or others)
sensor.set_windowing((62,0,96,96))       # Set 240x240 window.
sensor.skip_frames(time=2000)          # Let the camera adjust.

net = "trained.tflite"
labels = [line.rstrip('\n') for line in open("labels.txt")]

RED_LED_PIN = 1
GREEN_LED_PIN = 2
BLUE_LED_PIN = 3

pyb.LED(BLUE_LED_PIN).on()
pyb.LED(RED_LED_PIN).on()
pyb.LED(GREEN_LED_PIN).on()

clock = time.clock()
while(True):
    clock.tick()

    img = sensor.snapshot().scale(0.5, 0.5)

    # default settings just do one detection... change them to search the image...
    for obj in tf.classify(net, img, min_scale=1.0, scale_mul=0.8, x_overlap=0.5, y_overlap=0.5):
        #print("**********\nPredictions at [x=%d,y=%d,w=%d,h=%d]" % obj.rect())
        img.draw_rectangle(obj.rect())
        output = obj.output()
        # This combines the labels and confidence values into a list of tuples
        predictions_list = list(zip(labels, output))
        sorted_list = [i[0] for i in sorted(enumerate(output), key=lambda x:x[1], reverse=True)]
        #print(sorted_list)
        for i in sorted_list:
            print("{}: {:.2f}".format(labels[i], output[i]*100), end=' ')
        print("")
        #for i in range(len(predictions_list)):
        #    print("%s = %f" % (predictions_list[i][0], predictions_list[i][1]))

    #print(clock.fps(), "fps")
```

たったこれだけのコードで自分が取得したデータで学習させたモデルでの推論がマイコン上で走るなんて感動！19FPSぐらいでてました。画像取得＋推論で50msぐらい。スゴイなぁ。

# うまくいかなかったこと

転移学習は精度が上がらなかったうえに、モデルサイズが大きくなりOpenMV H7ではメモリエラーで動きませんでした。
ニューラルネットワークも画像サイズが96x96の小ささでもメモリエラーで動かず、48x48という極小サイズの画像でやっと動きました。
やっぱりH7 PlusみたいにRAMがたっぷりないと厳しいなぁ。

そして実際に推論させると結構精度悪い。。。秒針に惑わされるのはまだ分かるけど、どの針もないようなところを高いconfidenceで出されると困る。。。光の当たり具合とか学習データの多様性が足りなかったのかも。

# これから

今回はCNN2層で学習モデルを作りましたが、もっと純粋にMachine Visionの処理や機械学習の組み合わせでもできる気がします。

画角いっぱいに時計を捉えるために、学習時と推論時に同じ位置になるように固定の治具が必要でした。手で持って適当に時計が画角のどこかに写っていれば読み取れるように、Object detectionと組み合わせたい。

本当は[ARDUINO TINY MACHINE LEARNING KIT](https://store.arduino.cc/usa/tiny-machine-learning-kit)で試してみたい。Arduino Nano BLE 33 BLE Sense (nRF52840)とOV7675カメラがセットになってる。