Mon 21 Aug 2023
ChatGPTによるハードウェア制御のサンプル。
「AIテック&実験サイト」は、最新のAI技術に関するテック記事、実験記録、その他何か面白そうなこと諸々の乱雑なコンテンツを掲載する、どちらかと言えばブログ的なサイトです。
出来上がりイメージ

みなさんこんにちは。株式会社令和AIシニアエンジニア・兼・CEOの坂本俊之です。
「AIテック&実験サイト」のページでは、わりと緩い感じでテック記事を書いていこうと思っているのですが、ここではその第一弾として、プロンプトによるハードウェア制御の入門向けの技術記事を書こうと思います。
あくまでプロンプトによるハードウェア制御の入門として、監視カメラの角度をプロンプトから調整できるプログラムを作成してみます。
ここでは、プログラムのAI部分としてChatGPTのAPIを、ハードウェア制御の部分には、電子工作入門に定番のRaspberry Piを使います。
周囲の情報を予め用意する
AIによるハードウェア制御において、最初に直面する困難点が、AIに外界の情報をどうやって認知させるか、という点です。
※ここで言う「認知」とは、画像認識等によって「認識」した外界の情報を、AIが想定しているフレーム内の情報として落とし込む、という意味です
この記事は、プロンプトによるハードウェア制御という概念を紹介するためのものなので、ここでは単純に、カメラを設置する場所の情報を予め用意して、プロンプト中に埋め込むことにします。
用意しておく情報は、カメラの角度と、その角度を向いたときにカメラに写る背景のリストとなります。
もし実際の製品などに応用する場合は、カメラの初期化時にユーザーに入力させたり、画像認識を行ったりすると良いですね。

ここでは、本格的な製品開発ではなく、プロンプトによるハードウェア制御の概念実証のために作成する、夏休みの工作レベルのPoCを行います。
そのため、使用するハードウェアは、Raspberry Piに接続したサーボモーターに、小型のカメラを直結したものを想定します。
つまり、サーボモーターの向き=カメラの向きとなり、モーターを90度に設定すれば、カメラも90度の方向を向く、ということです。
この制御コードは、Raspberry Pi公式のサーボモーター制御の解説を元にしているので、ハードウェア構成などはそちらを参照してください。
サーボモーターの制御コード
GPIO.setmode(GPIO.BOARD)
GPIO.setwarnings(False)
pwm_gpio = 12
frequence = 50
GPIO.setup(pwm_gpio, GPIO.OUT)
pwm = GPIO.PWM(pwm_gpio, frequence)
class CameraServo:
def move(angle):
start = 4
end = 12.5
ratio = (end - start)/180
pwm.start(angle * ratio)
camera = CameraServo()
サンプルなので、パッケージのインポートや値の範囲チェックなどは省略しています。このように「camera」インスタンスを作っておくと、
camera.move(カメラを向かせたい角度)
としてカメラの向きを変更することが出来ます。
ここで作成する機能にはおいてAIは、外界の情報を認知する点について、ごく限られた情報しか扱う必要がありません。
ここではAIに与える外界の情報として、固定の背景位置と共に、現時点でカメラに写っている画像を与えます。
つまり、このAIにとって外界とは、「カメラの角度→写る物体」データのみであり、そのデータ構造がAIのフレームとなります。
この程度の複雑性であれば、複数のLLMレイヤーをChainする必要は無く、単純に一つのプロンプトに全ての外界の情報とAIへの指示を同時に詰め込むことが出来ます。
Few-shot学習による固定プロンプト
以下が、この記事のために作成したプロンプトの固定部分です。前提条件をそのまま説明しただけで、特にチューニングなどは行っていません。出力するコードの例をいくつかFew-shot学習データとして与えた、ごく単純なプロンプトとなります。
※HTMLの都合でインデントが全角スペースになっています。コピペ注意!
スニペット(中間コード)によるハードウェア制御

このプロンプトでは、直接カメラの向きを出力させるのではなく、カメラの向きを制御するPythonのプログラムコードを出力させています。
これは、「1分ごとに全部の範囲をチェックする」のように、連続して動作する制御を行うために必要なものです。少なくとも、サーボモーターを動かす度にChatGPTを呼び出しているのでは、APIの呼び出し頻度が多くなりすぎます。(ChatGPT APIの呼び出しには、OpenAIに料金を支払う必要があります!)
ここで紹介しているような用途では、ChatGPTを呼び出す必要がある制御アルゴリズムの更新は、月に一回か多くて日に何回あるかという頻度である一方で、カメラの向きを変更するロジックは1分あるいはそれ以下の間隔で実行される必要があります。さらに、サーボモーターの制御信号は50Hzなので、Raspberry PiのGPIOモジュールが行うDSP制御処理は、遙かに高い周波数で動作しています。
そのため、それぞれのステップ毎にプログラムの実行レイヤーを構築し、AIの実行と制御ロジックの実行を切り分けているのです。
Hint:フリーAPI版のSnippetCheckerには含まれませんが、エンタープライズ版に含まれるSnippetRunnerを使うと、このような無限ループを含んだコードであっても、ループの実行回数や実行タイミングを制御しながらコードを実行することが出来ます。
次に、ChatGPTのAPIを使って、「作成したプロンプト+カメラの向きへの指示」に、どのようなコードが返されるかをチェックしてみましょう。
まずは次のように、ChatGPTのAPIにアクセスして、その中からマークダウンタグ「```」で囲まれたコード部分を返す関数です。
def get_snippet(query):
# ChatGPT APIの呼び出し
response = openai.ChatCompletion.create(
model='gpt-4',
messages=[{'role': 'user', 'content': prompt_fixpart+query+'\n'}],
temperature=0.0,
)
# 回答からマークダウンタグ「```」で囲まれた部分を見つける
answer = response['choices'][0]['message']['content']
lines, in_code = [], False
for line in answer.split('\n'): # 行毎に分割
if in_code==False and line.strip().startswith('```'):
in_code = True
elif in_code:
if line.strip().startswith('```'): # タグが閉じられた
break
lines.append(line)
return '\n'.join(lines) # タグ内の行を繋げて返す
カメラの撮影する対象を指定する
監視カメラへの指示として、もっともそうな文章を入力してみます。
get_snippet('視界が良い部分を繰り返し撮影してください')上の指示の結果として、ChatGPTから返されたコード部分は、以下のようになります。(ChatGPTの生成する文章は乱数が関係するので、必ずしも同一の結果になるとは限りません)
while True:
# 橋を撮影
camera.move(0)
time.sleep(15) # 15秒間撮影
# 車道を撮影
camera.move(45)
time.sleep(15) # 15秒間撮影
# 玄関を撮影
camera.move(90)
time.sleep(15) # 15秒間撮影
# 桜の木を撮影
camera.move(135)
time.sleep(15) # 15秒間撮影
プロンプト内にある、180度では隣家の壁が視線を遮っているという情報を踏まえて、それ以外の箇所を連続撮影するコードが生成されました。
このほかにも、プロンプト内にある情報を踏まえて、玄関や道路など特定の場所を撮影したりスキップしたりという指示に対して、きちんとそれを実現するコードを生成してくれます。
OpenCVの機能を使う処理を含める
他にも、もう少し高度な処理を必要とする指示を与えてみましょう。
以下の指示は、カメラに写っている画像を認識して、人物の写っている場所を探すという行為を含む指示です。ChatGPTに与えるプロンプトは、上にあるものそのままなので、特別なプログラミング上の指示は与えていません。
get_snippet('全周を監視しつつ、道路の上に人が居たらそこを集中的に撮影してください。')上の指示の結果、生成されたコードは、以下のようになります。コメントも含めてChatGPTが出力したそのままです。
import cv2
# Haar Cascadesを使用して顔を検出する
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
while True:
for angle in [0, 45, 90, 135, 180]:
camera.move(angle)
# 画像を取得
img = cv2.imread('image.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 顔の検出
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
# 道路を監視している角度(0度または45度)で人の姿が検出された場合、
# カメラをその角度に固定して集中的に撮影する
if angle in [0, 45] and len(faces) > 0:
time.sleep(60) # 1分間その角度で撮影
break # その後、再び全周監視ルーチンに戻る
time.sleep(10) # 他の角度では10秒間撮影
プロンプトで与えたのは、カメラに写っている画像を保存するファイル名と、OpenCVが利用できるという情報だけですが、そこから見事に解法を導き出しました。このコードはOpenCVの顔認識機能を利用して、カメラに人物が写っているかどうかをチェックしています。
道路の上というやや曖昧な指示にも、橋にある車道と家の前の市道の両方に対応するのだと、きちんと判断してくれています。
このくらいOpenCVを扱えるのならば、画像の加工などもっと色々な機能を含めて実現してくれそうですね。

ChatGPTが出力したコードを見ると、そのままexec()で実行すれば、すぐにでもハードウェア制御のために利用できると思えるかもしれません。
しかし、実際はそうは行きません。
ChatGPTを初めとするAIが生成したコードは「本質的に安全ではない」ため、AIが生成したコードを自動的に実行するパイプラインを構築するためには、セキュリティサンドボックスが絶対に必要となります。
AIが生成したコードを利用するためには、以下の3つが必須となります。
3つの要素のうち、1つでも欠けると、AIの生成したコードを安全に実行することは出来ません。
この3つの要素を実現する環境スイート製品が、当社の「SnippetBox」です。詳しくは製品ページをご覧ください。
エンタープライズ版の「SnippetBox」にはプロンプトの事前チェックや、安全なコード実行のためのライブラリが含まれています。
しかし、エンタープライズ版の機能を利用しなくても、利用シーンに応じてPythonの標準機能を組み合わせることで、ある程度は機能するサンドボックス環境を構築することが出来ます。
ここでは、完璧なものでは無いことを承知の上で、あくまで概念実証に使える、限定的なサンドボックスを作成します。
正規表現によるプロンプトのチェック
ここでは、悪意のあるプロンプトによるジェイルブレイク(脱獄)さえ防げれば良い、と割り切って、ごくシンプルな正規表現によるプロンプトのチェックを行います。
入力されたプロンプトが、きちんとカメラの方向を指示するものであるかどうかのチェックは無いので、一般に公開するサービスや実際の製品には不足ですが、概念実証のための最小機能としては成立します。
query_re = "[ぁ-んァ-ヶー一-龯々、]{6,36}"
query_susfix = "((見張)((りし)|(って?))|(撮っ)|((((撮影)|(監視)|(観察))し)))((て(ください)?)|ろ|(なさい))。?"
if re.fullmatch(query_re+query_susfix, query):
generated_code = get_snippet(query)
この正規表現は不完全ながらも、悪意のあるプロンプトによるジェイルブレイク対策のフィルターとしては機能します。
ChatGPTに与えるプロンプトの末尾は必ず「撮影しなさい」等の指示になりますし、その前の部分は36文字まで、かつ、改行や英数文字は含まれない日本語のみ、そして「。」は最後に1個までです。
つまり、ユーザー入力の自由度は1行の文のさらに1部分のみとなるので、この条件下で、Few-shot学習データとして与えられるサンプルコードから、大きく変わるコードを出力させるというのは、かなり難易度が高くなります。
AI生成コードの事前チェック
AIが生成するコードが、Few-shot学習データとして与えたサンプルコードと、全体的に大きく変わりはしない、という前提を導入できると、コードの事前チェックで安全性をチェックするステップが楽になります。
ここでは、フリーAPI版のSnippetCheckerを使い、監視カメラの角度を制御するためのコードが、実行しても良いものなのかをチェックします。
ただし、どのようなコードであれば実行しても良いのかは、プログラマーが予めセキュリティ要件を理解し、正しくコーディングしておく必要があります。
まずは、生成されたコードが、正しくサンプルコードを参照しているか調べるために、ループの入れ子構造を調べます。
SnippetCheckerの「find_loop_in_snippet」関数は、for angle in [0, 45, 90, 135, 180]:
のような必ず固定回数回るループは「CONSTANT_LOOP」と判定するので、一番外側のループのみ「HAS_LOOP」で、それ以下は全て「CONSTANT_LOOP」となれば、Few-shot学習データとおおむね同じ形のコードが出力されています。
# フリーAPI版のSnippetCheckerを使用する
c = SnippetChecker()
# ループの入れ子構造を判定する
loop_def = c.find_loop_in_snippet(generated_code)
assert len(c.errormsg) == 0 # 実行エラーチェック
def check_loop(r, depth=0): # 再帰的にループ構造を見て行く
if type(r) is LoopType:
return depth == 0 or r == LoopType.CONSTANT_LOOP
elif type(r) is LoopStructure:
return check_loop(r.loop,depth) and check_loop(r.body,depth+1)
elif type(r) is list:
return sum([check_loop(v,depth) for v in r]) == len(r)
if len(loop_def) == 1 and check_loop(loop_def):
# 最外側はwhileループが一つで中身は固定長のループのみ
そして、「check_formal_snippet」関数を使用してコードの実行前チェックを行います。
「check_formal_snippet」関数には、使用を許可するパッケージや関数の名前を指定し、それ以外の関数呼び出しが行われていないかチェックします。
# 呼び出しを許可する関数の名前
allow_function = ['len','range', 'camera.move', 'tile.sleep', 'random.random',
'cv2.CascadeClassifier', '*.detectMultiScale']
# 使用を許可するオブジェクトの名前
allow_valiable = ['camera']
# 使用を許可するパッケージの名前
allow_module = ['time', 'random', 'cv2']
# コードの実行前チェック
it_can_run = c.check_formal_snippet(generated_code,
safe_function=allow_function,
safe_objects=allow_valiable,
safe_modules=allow_module)
assert len(c.errormsg) == 0 # 実行エラーチェック
if it_can_run:
# 実行前チェックOK
必要な__builtins__のみ含む実行環境
コードの実行前チェックは、実行時の実際のインスタンスを検証するものではないので、危険な関数をスコープ内から排除する実行環境は、どうしても必要になります。
ここでの例は、エンタープライズ版に含まれるSnippetRunnerは使わず、Pythonの標準ライブラリのみ使用します。呼び出しを許可する関数やパッケージのみ含まれる名前空間を用意して、その名前空間内でコードを実行することで、サンドボックスの代用とします(もちろん、それだけではなく、全体がDocker等のコンテナ内で実行される設計が望ましいです)。
フリーAPI版のSnippetCheckerには、そのためのサポート関数が含まれており、「make_cleaned_builtins」関数は利用するグローバル関数とパッケージのみを持った組み込みモジュールを作成してくれます。それを、グローバル名前空間の「__builtins__」に設定すると、ある程度は安全にexec()関数を使う事が出来ます。
# 使用するパッケージは直接__builtins__に入れるので、生成したコードからimport文を削除する
generated_code_noimport = '\n'.join([line for line in generated_code.split('\n')
if not (line.lstrip().startswith('import ') or line.lstrip().startswith('from '))])
# グローバル名前空間にある__builtins__を、最小限の構成で作る
global_builtins = make_cleaned_builtins(allow_global_functions=['len','range'],
allow_import_modules=allow_module)
# ローカル名前空間には「camera」変数を渡す
local_variables = {'camera':camera}
# Python組み込み関数のexec()でコードを実行する
exec(generated_code_noimport,
{'__builtins__':global_builtins},
local_variables)
ここでは、3つの要素毎に異なるライブラリを組み合わせて使用しているので、きちんとセキュリティが確保されるか、抜け穴が生まれていないか、常に気を配りながらプログラミングする必要があります。
例えば、呼び出しを許可する関数のリストには、「cv2.write」のようなファイルに書き込みを行う関数を含めてはいけません。どうしても使用したい場合は、何らかの方法で関数をラップし、想定外のファイルを上書きしてしまわないように工夫する必要があります。
ここで紹介している手法は、あくまで概念実証用の不完全なサンドボックスです。緩い正規表現による事前のコードチェックのみでは、ジェイルブレイク(脱獄)の可能性を完全には排除できませんし、exec()を使った実行環境は、リソースの食い潰し等のエラーに対処することが出来ません。一般に公開するサービスや実際の製品への応用には、コンサルティングをお問い合わせ頂くか、エンタープライズ版のSnippetBoxソリューションをご利用ください。