#はじめに
※この記事は、SAP Advent Calendar 2020 の22日目の記事として投稿しています。
気が付けば年の暮れですね。
今年はほぼ1年間コロナウイルスとの戦いでした。
特に飲食業界では客足が減ったり細心の注意を払って接客したりと、かなり大変なようです。
そこで今回はお客さんとの接触を出来るだけ避けての営業を可能とする、わんこそば自動お代わり機をご紹介いたします。
概要
まずは動画をご覧ください。
#SAPアドベントカレンダー 12月22日分のQiita記事「わんこそば自動お代わり機でコロナ禍の飲食業界を救いたい」のデモ動画です。#わんこそば #ロボットアーム #Python #コロナにまけるな pic.twitter.com/551ofyFXTn
— サイコパスのりお (@norio_psycho) December 21, 2020
アプリを起動すると杯数カウント画面が立ち上がり、わんこそばをお代わりできる状態となります。
この状態でお代わりを頼む(=感圧センサーにお椀を乗せる)とロボットアームがお代わりを装い、また回転皿が動いてロボットアームにお代わりを補充します。
連動して画面には現在の杯数が表示されるようになっており、開始/おかわり/終了時に音声も出力されるようになっています。
アプリを終了するには水色のボタンを押す必要がありますが、このボタンはお代わりを頼んでいる状態(=感圧センサーにお椀を乗せている状態)でのみ反応します。ゆっくりしていると0.5秒後には次のお代わりが装われるため、すぐにボタンを押下する必要があります。
終了に成功したら金額が計算され、帳票が出力されます。
またその際SAP S/4HANAにアクセスして購買依頼伝票を作成します。
アーキテクチャ
Jetson Nano上ではわんこそばお代わり機能を構成するPythonアプリケーションと、帳票作成機能であるNode.jsアプリケーションの2種類を実装しています。
#必要なもの
製品 | 補足 |
---|---|
Jetson Nano | Raspberry Pi 4Bでも可 |
6軸ロボットアーム | |
360度回転サーボモータ | |
感圧センサー | |
プラスチック用コンパスカッター | |
プラスチック皿 x2 | |
LEDボタン | |
お代わり機の台座の各種部品 | 木材とかを組み合わせて作ってください |
SAP S/4 HANA 1909 | オンプレミスver |
#パッケージ構成
.
├── display_window
│ ├── templates
│ │ └── index.html
│ ├── source
│ │ └── 各種画像の素材
│ └── flaskapp.py
├── create_check
│ ├── node_modules
│ ├── index.js
│ └── package.json
├── sound
│ └── 各種音声ファイル
├── button.py
├── imgedit.py
├── main.py
├── okawari.py
├── pressure_sensor.py
├── purchase_create.py
└── sound.py
#実装手順
1. 感圧センサーの設定
2. お代わり機の作成
3. 杯数カウントの画面作成
4. 音声出力機能の追加
5. 終了ボタンの作成
6. 帳票作成
7. SAP S/4 HANA上での購買依頼伝票の作成
##1. 感圧センサーの設定
お代わりのトリガーには感圧センサーを用います。感圧センサーからはアナログ信号が出力されるため、A/Dコンバーターを用いてデジタル信号に変換するようにしてください。配線の方法とソースコードは以下の記事をそっくりそのまま使わせてもらいました。
Raspberry Piで感圧センサー(ALPHA-MF02-N-221-A01 )の情報取得するところまで
import wiringpi as pi
import time
class MCP3002:
def __init__( self, ss, speed, vref ):
self.ss = ss
self.speed = speed
self.vref = vref
pi.wiringPiSPISetup( self.ss, self.speed )
def get_value( self, ch ):
# mcp3002 データシート Table 5-1やFigure 6-1を参考
# ch0 0x6800(0b01101000), ch1 0x7800(0b01111000)
senddata = 0x6800 | ( 0x1800 * ch )
buffer = senddata.to_bytes( 2, byteorder='big' )
pi.wiringPiSPIDataRW( self.ss , buffer )
# buffer[0]に上位2bitが入っているのでシフトする
# buffer[1]に下位の数字が入っているので足す
# 0x3ff(0b1111111111)なのでANDをとって1023以上は切り捨てられる
value = (( buffer[0] << 8 ) + buffer[1] ) & 0x3ff
return value
def get_volt( self, value ):
return value * self.vref / float( 1023 )
##2. お代わり機の作成
お代わりが補充される仕組みですが、6箇所の穴を開けた皿(皿①)と1箇所だけ穴を開けた皿(皿②)の2枚を上下に重ね合わせ、上方の皿をサーボモータで回転させることによりお代わりが下に落ちるようにします。
皿及びホルダーとなるプラスチック製の容器の穴は、プラスチック用コンパスカッターで力技でくり抜きます。(皿が硬いと生半可なコンパスカッターでは切れません。今回は1000円程度のコンパスカッターを使用しましたが、1穴開けるのに30分弱かかっていました。)
またサーボモータは360度以上回転するタイプのものと180度までのものの2種類がありますが、今回は必ず360度以上回転するものを選ぶようにしましょう。
次に、実際にお代わりを装うロボットアームについて説明します。ロボットアームは合計6つのサーボモーターからなり、直接Jetson Nanoから全てのサーボモーターを制御することは出来ないので、間にサーボモータードライバーを噛ませる必要があります。
先程の回転皿のサーボモータも一緒にサーボモータードライバーに繋げてやりましょう。またロボットアームとサーボモータードライバーは外部電源に接続して電力を供給しています。
またお代わりを装うためのお椀を用意し穴を空け、ロボットアームのエンドエフェクター(ハンドの部分)に装着します。
上記の回転皿とロボットアームを連動させることでお代わりを実現します。今回は各オブジェクトの位置関係を固定値で入れているため、少しでも配置がズレると想定通りに動いてくれません。そこで、適当に台座を工作してロボットアームと回転皿の位置を固定しましょう。
ロボットアームと回転皿を動かすためのロジックは以下の通りです。
import sys
import time
# Import the PCA9685 module.
sys.path.append('/home/user/Adafruit_Python_PCA9685/')
import Adafruit_PCA9685
class Arm:
def __init__(self):
#初期設定
self.pwm = Adafruit_PCA9685.PCA9685()
# Set frequency to 60hz, good for servos.
self.pwm.set_pwm_freq(60)
self.pwm.set_pwm(8, 0, 202)
time.sleep(0.5)
self.pwm.set_pwm(3, 0, 200)
time.sleep(0.5)
self.pwm.set_pwm(1, 0, 400)
time.sleep(0.5)
self.pwm.set_pwm(2, 0, 200)
time.sleep(0.5)
self.pwm.set_pwm(4, 0, 400)
time.sleep(0.5)
self.pwm.set_pwm(5, 0, 400)
time.sleep(0.5)
self.pwm.set_pwm(0, 0, 600)
time.sleep(0.5)
def okawari(self, counter):
#おわんをひっくり返す
self.pwm.set_pwm(0, 0, 150)
time.sleep(1)
self.pwm.set_pwm(0, 0, 600)
time.sleep(1)
#回転皿の下ロボットアームを移動(一気に目標地点まで動かすと勢い余って柱に衝突するため、目標に近づくと少しずつ動かす)
self.pwm.set_pwm(5, 0, 250)
time.sleep(0.2)
self.pwm.set_pwm(5, 0, 240)
time.sleep(0.2)
self.pwm.set_pwm(5, 0, 230)
time.sleep(0.2)
self.pwm.set_pwm(5, 0, 220)
time.sleep(0.2)
self.pwm.set_pwm(5, 0, 210)
time.sleep(0.2)
#回転皿からそばを落とす
self.pwm.set_pwm(8, 0, 202 + (40 * (counter-1)))
time.sleep(2)
#元の位置にアームを戻す
self.pwm.set_pwm(5, 0, 400)
time.sleep(2)
##3. 杯数カウントの画面作成
画面作成にかけられる時間が少なかったため、「サーバを立ち上げてURLにアクセスすると、杯数を表示する」という至ってシンプルな作りの画面を採用しました。サーバの立ち上げにはflaskを使用しています。
まずは画面に表示するための画像を作成します。以下のような画像をフレームとして用意しておきます。
上記フレームと数字の画像を組み合わせて、その時の杯数に応じた画面を作成するためのコードを実装します。
import time
from PIL import Image, ImageDraw, ImageFilter, ImageTk
import os.path
class ImageEditor:
def __init__(self):
#画像ファイルを読み出し
self.image_wanko = Image.open('/home/user/ドキュメント/自動わんこそば/display_window/source/image_wanko/wankosoba_frame.png')
self.images = [''] * 10
for i in range(10):
self.images[i] = Image.open('/home/user/ドキュメント/自動わんこそば/display_window/source/image_wanko/%d.png' % i)
def edit(self, counter):
backup_image = ''
backup_image = self.image_wanko.copy()
if counter <= 9:
backup_image.paste(self.images[counter], (270, 50))
elif 10 <= counter <= 99:
counter_10th = str(counter)[0]
counter_1th = str(counter)[1]
backup_image.paste(self.images[int(counter_10th)], (210, 50))
backup_image.paste(self.images[int(counter_1th)], (270, 50))
elif 99 <= counter <= 999:
counter_100th = str(counter)[0]
counter_10th = str(counter)[1]
counter_1th = str(counter)[2]
backup_image.paste(self.images[int(counter_100th)], (170, 50))
backup_image.paste(self.images[int(counter_10th)], (225, 50))
backup_image.paste(self.images[int(counter_1th)], (280, 50))
else:
print("食べ過ぎです")
backup_image.save('/home/user/ドキュメント/自動わんこそば/display_window/static/image_tmp.png', quality=95)
画面に表示する画像の準備が整ったので、次にflaskを用いてサーバを立ち上げるためのコードを書きます。わざわざネットワーク通信する必要もないので、localhost指定で良いと思います。
# server.py
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.after_request
def add_header(r):
"""
Add headers to both force latest IE rendering engine or Chrome Frame,
and also to cache the rendered page for 10 minutes.
"""
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
r.headers['Cache-Control'] = 'public, max-age=0'
return r
def execute():
app.run(host='localhost', port=8080)
if __name__ == '__main__':
app.debug = True
app.run(host='localhost', port=8080)
URLにアクセスした場合に呼び出されるindex.htmlファイルを実装します。↑で作成した杯数画像をimgタグで指定しています。「?php ...?」の部分についてですが、これが無いと画像内の杯数が更新されても、読み込むファイル名は同じであるためキャッシュから古い画像を取ってきてしまい、画面上での画像が更新されない状態となります。これを防ぐために「?php ...?」でファイルにタイムスタンプを押し、読み込み画像が最新となるようにしています。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>WankosobaApp</title>
</head>
<body>
<br>
<img id="image_place" src="/static/image_tmp.png?<?php echo date('YmdHis'); ?>" />
</body>
</html>
##4. 音声出力機能の追加
今回はmp3形式の音声ファイルを出力するので以下のように実装しました。wav形式の場合は少し書き方が異なるので注意してください。
import pygame.mixer
import time
from mutagen.mp3 import MP3 as mp3
class Sound:
def play(self, filename):
# mixerモジュールの初期化
pygame.mixer.init()
# 音楽ファイルの読み込み
pygame.mixer.music.load(filename)
#音源の長さ取得
mp3_length = mp3(filename).info.length
# 再生
pygame.mixer.music.play(1)
#再生開始後、音源の長さだけ待つ(0.25待つのは誤差解消)
time.sleep(mp3_length + 0.25)
# 再生の終了
pygame.mixer.music.stop()
##5. 終了ボタンの作成
見覚えのあるボタンを終了ボタンとして使用します。配線は「たらい落としでSAP S/4 HANA上に購買依頼伝票を作成してみた」を参考にしてください。
ロジックは以下の通りです。
import time
import subprocess
import RPi.GPIO as GPIO
class Button:
def __init__(self):
GPIO.setmode(GPIO.BCM)
GPIO.setup(24, GPIO.IN)
def isPressed(self):
print(GPIO.input(24))
#LEDボタンが押下されている場合
if GPIO.input(24) == GPIO.HIGH:
return True
else:
return False
##6. 帳票作成
お会計の帳票作成はSCPの帳票作成サービスであるSAP Cloud Platform Forms by Adobeを使用します。私の師匠である鬼瓦先生の記事「SAP Forms by Adobe使ってみた」を参考に実装しました。index.jsを呼び出せばPDF形式で以下のような帳票が出力されるようになっています。
##7. SAP S/4 HANA上での購買依頼伝票の作成
そばが品切れになると困るので、いつも通りS/4HANA上で購買依頼伝票を作成します。
APIは前回の記事「コロナで危険度の高い人を見つけたらすぐ治療しようとするアプリ作ってみた」の「3. SAP Cloud SDK for JavaScriptを用いたAPI実装」で作成したものをそのまま呼び出しています。
import urllib.request
import urllib.error
import lxml
import base64
import json
import re
import ssl
from bs4 import BeautifulSoup
from collections import defaultdict
class HttpClient :
#POSTメソッドによる購買依頼伝票の作成
def createPurchaseReq():
url = <<APIのエンドポイント>>
#ヘッダ情報を設定
headers = {"Content-Type" : "application/json"}
#ボディ情報を設定
json_obj = {
"matnr": "300002",
"menge": 1,
"meins": "PC"
}
# POSTリクエスト送信
json_body = json.dumps(json_obj).encode("utf-8")
req = urllib.request.Request(url, data=json_body, headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as response:
#レスポンスを取得する
response_body = response.read().decode("utf-8")
print()
# JSONとして読み込む
json_obj = json.loads(response_body)
# 値の取り出し
pr_number = json_obj['matnr']
print('購買依頼伝票:' + pr_number + 'を作成しました。')
#例外処理
except urllib.error.HTTPError as err:
soup = BeautifulSoup(err, "lxml")
print(soup)
最後に実行用のファイルを用意します。
from __future__ import division
from pressure_sensor import MCP3002
from purchase_create import HttpClient
from okawari import Arm
from imgedit import ImageEditor
from button import Button
from sound import Sound
import time
from selenium import webdriver
import subprocess
#import sys
#sys.path.append('/home/user/ドキュメント/自動わんこそば/create_check/')
#感圧センサーをインスタンス化
adc = MCP3002(0, 1000000, 3.3)
#ロボットアームをインスタンス化
arm = Arm()
#ボタンをインスタンス化
button = Button()
okawari_flag = False
counter = 1
#音声を出力する「いらっしゃいませ」
Sound().play('./sound/door.mp3')
Sound().play('./sound/welcome.mp3')
#ブラウザに表示する画面の初期化
img_editor = ImageEditor()
img_editor.edit(counter)
#ブラウザ上で表示
driver = webdriver.Chrome()
driver.get("http://localhost:8080/")
while True:
#感圧センサーの値を取得
value = adc.get_value(0)
print(value)
time.sleep(0.5)
#そばがない状態でおわんがセンサー上に置かれた場合
if (500 <= value):
if okawari_flag == False:
okawari_flag = True
counter += 1
#終了ボタンが押されていない場合はおかわりを装う
if button.isPressed() == False:
#画面更新処理
img_editor.edit(counter)
driver.refresh()
#音声を出力する「はい、どうぞ」
Sound().play('./sound/haidouzo.mp3')
#アームを動かしておかわり
arm.okawari(counter)
#終了ボタンが押されている場合は終了処理を行う
else:
time.sleep(1)
#音声を出力する「ありがとうございました」
Sound().play('./sound/thanks.mp3')
Sound().play('./sound/thanksossan.mp3')
#お会計の伝票を出力する
print('帳票を出力します')
with open('/home/user/ドキュメント/自動わんこそば/create_check/number.txt', mode='w') as f:
f.write(str(counter))
subprocess.check_call('sudo node /home/user/ドキュメント/自動わんこそば/create_check/index.js', shell=True)
print('帳票を出力しました')
time.sleep(1)
#購買依頼伝票を作成
print('購買依頼伝票を作成します')
HttpClient.createPurchaseReq()
break
else:
okawari_flag = False
完成
これで冒頭のような動きのわんこそば自動お代わり機が完成します。Flaskサーバを立てた上でmain.pyを実行しましょう。
残課題
動画では素晴らしい出来栄えのわんこそば自動お代わり機ですが、残念ながら現時点で以下のような課題が残っており、現場で使用するには改修が必要です。
①ロボットアームの動きがガクガクしている
今回の実装だとアームの速度が0か1の二択となってしまいガクガクしてしまいます。アームの速度がシグモイド曲線を描く(=動き始めと終わりはゆっくりで、途中は早く動く)ように出来ればもっとカッコ良く動くはずで、ROSを使えば実現できそうなので今後検証を進めていきたいです。
②お代わり用のそばが回転皿の隙間に詰まるバグがある
回転皿を構成する2枚の皿の間には少し隙間が空いており、隙間にそばが詰まって停止することがあります。100均のお皿の底を削って無理やり使用していますが、3Dプリンタを使用して隙間が無くなるような作りにすれば解決すると思います。
③500回に1回くらいの割合で回転皿が暴走する
稀に回転皿が暴走して回り続けることがあり、原因不明の状態です。電気信号が変に残っていたりするせいなのかもしれませんが、再現性が無いため半ば諦めています。
#BGM/音声のリンク
動画で使用させていただいたフリーのBGMや音声の取得元を以下に記載いたします。
H/MIX GALLERY
http://www.hmix.net/music_gallery/image/asian.htm
あみたろの声素材工房
https://www14.big.or.jp/~amiami/happy/voice_01.html
効果音ラボ
https://soundeffect-lab.info/sound/voice/line-girl1.html
Wingless Seraph
https://wingless-seraph.net/material-voice.html