セサミmini、Web Camera でセンサー対応オートロックを作ってみる (PyTorch)

スマートロックの セサミ mini を便利に使っています。(前回の記事) 物理的に鍵を使う必要がなく完全にワイヤレスで済みますし、施錠操作もオートロックのおかげで不要となりました。

非常に気に入っているのですが、セサミ mini のオートロックはドアの開閉と連動しているわけではなく時限式となっています。もたもたしているとドアを閉じる前に鍵が締まってしまうこともあります。時間を長めに設定すると、今度はロックが掛かるまで待つ場合に待ち時間が長くなります。

そこで、セサミの API を使ってドアの開け閉めと連動してロックが掛かる仕組みを作ってみました。本来なら、マグネット式のセンサーを使うのが最も簡単で確実だと思います。今回は手持ちの機材だけで何とかしようと思い、折角なので Web Camera で識別させてみました。

● Sesame API

CANDY HOUSE Developer Reference

さまざまなサイトに解説がありますので参考にさせていただきました。

Sesame API Version 3 のチュートリアル
APIキー取得方法とセサミIDの確認方法

API Key を取得したら Web 呼び出しで操作することができます。さらに鍵自体の Lock / Unlock 操作には、SmartLock 本体を識別するための Device ID が必要です。Device ID は API を通じて一覧から取得することもできますし、上の解説記事を見ると Web のダッシュボードで確認することもできるようです。

以下 Python 3.x を使っています。Device 一覧の取得です。

import requests
import json

API_URL= 'https://api.candyhouse.co/public/'
API_KEY= '<API_KEY>'

result= requests.get( API_URL+'sesames/', headers={ 'Authorization': API_KEY } )
device_list= json.loads( result.text )

for device in device_list:
    print( device['device_id'] )

毎回 Device ID を取得すると時間がかかるので file にキャッシュしておきます。これでサーバーに問い合わせるのは最初の一度だけになります。

import os
import requests
import json

API_KEY= '<API_KEY>'

class SesameAPI:
    CACHE_FILE= 'cache_file.txt'
    API_URL= 'https://api.candyhouse.co/public/'

    def __init__( self ):
        self.load_cache()

    def load_cache( self ):
        if os.path.exists( self.CACHE_FILE ):
            with open( self.CACHE_FILE, 'r' ) as fi:
                self.dev_list= json.loads( fi.read() )
        else:
            result= requests.get( self.API_URL+'sesames/', headers={ 'Authorization': API_KEY } )
            self.dev_list= json.loads( result.text )
            with open( self.CACHE_FILE, 'w' ) as fo:
                fo.write( json.dumps( self.dev_list ) )

    def get_device_id( self ):
        return  self.dev_list[0][ 'device_id' ]


print( SesameAPI().get_device_id() )

Device ID が判明したので、あとは状態の確認や Lock/Unlock コマンドの送信ができます。

class SesameAPI:
    ~

    def get_status( self ):
        result= requests.get( self.API_URL+'sesame/'+self.get_device_id(), headers={ 'Authorization': API_KEY } )
        status= json.loads( result.text )
        return  status['locked']

    def send_command( self, command ):
        requests.post( self.API_URL+'sesame/'+self.get_device_id(),
            headers={ 'Authorization': API_KEY, 'Content-type': 'application/json' },
            data=json.dumps( { 'command': command } ) )


sesame= SesameAPI()

# 状態の取得
print( sesame.get_status() )

# 施錠、解錠
sesame.send_command( 'lock' )
# sesame.send_command( 'unlock' )

Lock/Unlock コマンドの実行は時間がかかるので、結果を確認したい場合はあらためて問い合わせる必要があります。問い合わせには Command 実行時に得られる Task ID を使います。

↓成功か失敗を確実に返す (待つ) 場合。

    def get_task_result( self, task_id ):
        while True:
            time.sleep( 5.0 )
            result= requests.get( self.API_URL+'action-result?task_id='+task_id, headers={ 'Authorization': API_KEY } )
            status= json.loads( result.text )
            if status['status'] == 'terminated':
                if 'successful' in status:
                    return  status['successful']
                return  False

    def send_command( self, command ):
        result= requests.post( self.API_URL+'sesame/'+self.get_device_id(),
            headers={ 'Authorization': API_KEY, 'Content-type': 'application/json' },
            data=json.dumps( { 'command': command } ) )
        task= json.loads( result.text )
        task_id= task[ 'task_id' ]
        return  self.get_task_result( task_id )


sesame= SesameAPI()
print( sesame.send_command( 'lock' ) )

●画像による判定

機械学習 (Deep Learning) を使っています。モデルは非常に簡単な CNN で PyTorch を使いました。

USB の Web Camera を使ってドアの画像を撮影します。かなり少ないですが 640×480 で 1500枚ほど。様々な時間帯で人が写った出入り時の画像も含めます。先に完全に閉じている状態とそれ以外で分類しておきます。

Web Camera の撮影画像は少々暗く、夕方以降は見えないくらい真っ暗な写真が撮れることがあります。ドアを開けた方が明るいので、暗すぎる場合は閉まっているとみなしています。性能が良い最近のスマホの方が明るく写るので、スマホを Web Camera の代わりに使うともっと精度が上がるかもしれません。

学習は 128×128 dot にしましたがもっと小さくても良いと思います。Windows の GeForce GTX1070 でおよそ 15分くらいです。(BatchSize 32, Epoch 1000 の場合)

class Model_Sesame( nn.Module ):

    def __init__( self ):
        super().__init__()
        self.c0= nn.Conv2d( 3, 32, 5 )
        self.c1= nn.Conv2d( 32, 32, 5 )
        self.fc0= nn.Linear( 32*6*6, 128 )
        self.fc1= nn.Linear( 128, 64 )
        self.fc2= nn.Linear( 64, 2 )
        self.drop0= nn.Dropout( 0.25 )
        self.drop1= nn.Dropout( 0.25 )
        self.drop2= nn.Dropout( 0.5 )
        self.drop3= nn.Dropout( 0.5 )

    def forward( self, x ):
        x= F.relu( self.c0( x ) )
        x= F.max_pool2d( x, (4, 4) )
        x= self.drop0( x )
        x= F.relu( self.c1( x ) )
        x= F.max_pool2d( x, (4, 4) )
        x= self.drop1( x )
        x= x.view( x.size(0), -1 )
        x= F.relu( self.fc0( x ) )
        x= self.drop2( x )
        x= F.relu( self.fc1( x ) )
        x= self.drop3( x )
        x= self.fc2( x )
        return  x

開け閉めの判定部分では、OpenCV でキャプチャした画像をおよそ 0.5秒に一度推論に通しています。結果は完全に閉まっているかどうかの二択です。

    def main_loop( self ):
        cap= cv2.VideoCapture( 0 )
        PREDICT_TIME= 0.5
        SLEEP_TIME= 0.2
        STEP_PREDICT= int(PREDICT_TIME / SLEEP_TIME)
        counter= 0
        while( True ):
            ret,image= cap.read()
            cv2.imshow( 'preview', image )
            counter+= 1
            if counter > STEP_PREDICT:
                self.predict( image ) # 判定
                counter= 0
            time.sleep( SLEEP_TIME )
            key= cv2.waitKey(1) & 0xff
            if key == ord('q') or key == ord('\x1b'):
                break
        cap.release()
        cv2.destroyAllWindows()

データが少なく変化も乏しいので過学習している可能性はありますが、開閉のタイミングはそれっぽく取れているようです。あとは状態が変化したタイミングを捉えて、完全に閉まったときだけ lock command を送ります。

長時間走らせていると稀に誤判定が混ざることがあります。施錠されている状態をさらに施錠し直すだけなので、誤判定が紛れ込んでも特に問題はないです。ただそのたびに API が作動したという通知が来てしまうので少々気になります。

そこで確率の差が小さいあやふやな判定はある程度取り除くことにしました。さらに閉じたときは 4回以上連続、開けたときは 2回以上連続で同じ判定が続いたときだけ状態を反映させるようにしています。

    def predict( self, image ):
        size= IMAGE_SIZE # 64
        y,x,ch= image.shape
        ns= min(x,y)
        bx= (x - ns)//2
        by= (y - ns)//2
        crop_image= image[bx:bx+ns,by:by+ns]
        resize_image= cv2.resize( crop_image, (size,size) )
        ndata= np.zeros( (ch,size,size), dtype=np.float32 )
        for iy in (range(size)):
            for ix in (range(size)):
                for ic in range(ch):
                    ndata[ic,iy,ix]= resize_image[iy,ix,ic]
        fdata= ndata * (1.0/255.0)
        fdata= fdata.reshape( (1,ch,size,size) )
        x_data= torch.tensor( fdata, dtype=torch.float32, device=self.device )
        outputs_c= self.model( x_data ).to( 'cpu' ).detach().numpy() # 推論
        oy= outputs_c[0]
        self.sum= (self.sum + oy) * 0.5
        result= np.argmax(self.sum) # 0=Open, 1=Close

	open_state= self.open_state
        if result == self.prev_result:
            self.lock_count+= 1
            if result == 0:
                if self.lock_count >= 4:
                    self.lock_count= 0
                    open_state= 0
            elif result == 1:
                if self.lock_count >= 2:
                    self.lock_count= 0
                    open_state= 1
        else:
            self.lock_count= 0
            self.capture( image ) # 状態変化時の画像の保存
        self.prev_result= result

        if open_state != self.open_state:
            if open_state == 0:
                self.send_command( 'lock' ) # コマンドの送信
        self.open_state= open_state

Web Camera を設置して main_loop() を回します。これでドアを閉じたときに連動してセサミで施錠出来るようになりました。誤判定もなくなり明るい時間帯は安定して動いています。

まだまだデータの蓄積や改良が必要なので、判定結果が変化した時の画像を次の学習のために保存しています。最適化したら Raspberry Pi や Jetson Nano あたりで動かしたいと思っています。

●まとめ

API を利用して セサミ mini でもドアの動きに連動したオートロックを実現することができました。API が使えるのは非常に大きなメリットです。無い機能も簡単に作れますし、他にも様々な応用ができそうです。

最初にも書いたのですが、実用性を考えるなら普通にマグネットスイッチなどのセンサーを使った方が良いと思います。Web Camera を使う利点としては防犯カメラを兼用出来ることくらいでしょうか。もともと学習用データ収集のつもりだったのですが、ドアの開閉に連動して出入りした人の写真がきれいに残るようになりました。

関連エントリ
セサミmini、スマートロックを使って1年
Android UserLAnd で PyTorch を使う。C++ API
RADEON (ROCm) で PyTorch を使う。C++ API
Jetson Nano で TensorFlow の C 言語 API を使う