Use RNN-based machine learning to simulate candlelight in this electronics project. Learn how to extract light data from videos using OpenCV and Python.
相模原市で IoT 設計を受託しているファームロジックスです。
夏休み工作シリーズといった記事は書いたことがありますが、今回は年末年始向けの工作をしてみましょう。名付けて、
AI(ニューラルネット)でバーチャルキャンドルを作ろう!
です。
年末までに完成すると良いのですが、他の仕事もしなくてはならないので、ちょっと難しいかも知れません。これを一つのまとまった記事にしようとすると、何も投稿できずに終わってしまうかも知れないので、何回かの記事に分けたいと思います。
電子キャンドルを作りませんか?
ネットで次のように検索すると、Arduino マイコンなどを使ってバーチャルキャンドルを作るといった記事が何件も見つかります。
- Arduino LED キャンドル 作り方
- Arduino electronic candle DIY
しかし、私がざっと探したところでは、AI、特に機械学習(machine learning)を使ってバーチャルキャンドルを作る、といった記事はあまりないようです。
そこで今回は AI バーチャルキャンドルに挑戦したいと思います。機械学習の技術の中でも、RNN(Recurrent Neural Network: 回帰型ニューラルネットワーク)というものを使いたいと思います。果たしてうまくいくのでしょうか。(お楽しみに…)
RNN とはなにか?
やや専門的な話になります。
RNN は、時系列データから未来の値を予測するための技術です。これに似た古典的な手法に「線形予測(Linear Prediction)」があります。線形予測は、過去のデータを基に予測に必要な係数を求め、固定された数の過去のデータから未来の値を計算します。
一方、RNN は過去のデータを基に「内部状態」を更新し続け、必要に応じて情報を保持しながら未来の値を予測します。内部状態を持つことで、原理的には無限の過去の影響を反映できます。また、RNN は非線形なデータの変化も学習できるため、音声認識や文章生成のような複雑なタスクも実現できます。
今回はこの RNN を使い、ニューラルネットワークにロウソクの炎の光り方を学習させることができないか、検討してみようと思います。
リアルロウソク(?)の明滅データを取得するには
今回の記事の主題はここです。RNN を学習させる(訓練する)には、本当のロウソク(リアルロウソクと呼びましょう)の光り方のデータを入手する必要があります。その手順だけでも興味深いテーマとなりそうですので、今回はその点に集中します。
この場合、最も理にかなった方法は、実際にロウソクを用意してウチワか何かで風を当てながら、その明るさデータを取得するというものです。明るさの取得には、CdS や フォトダイオードを使っても良いでしょうし、最近のツールであれば、たとえばスマートフォンのカメラで動画を撮影し、OpenCV などで明るさを取得しても良いでしょう。
もう少し簡単な方法としては、ネット上で、ロウソクが瞬くビデオを探すというものです。検索エンジンで探すと、そのような動画はたくさん見つかります。例えば、こんなものがあるでしょう。
※ なお、このようなネット上のリソースを利用する際には、ライセンスに十分配慮しましょう。
動画から明滅データを CSV に変換しよう
今回は、Python と OpenCV を使って明滅データを取得することにしましょう。ロウソクの炎の「色」にも興味があるので、RGB データを得ることにします。
具体的なやり方ですが、OpenCV を使い、動画データからフレーム単位の静止画を抜き出していきます。次に、画面全体を見てもしょうがないので、ロウソクの炎周辺の画像 cropped
だけを抜き出します。次のようなイメージ(擬似コード)になるでしょうか。
import cv2 cap = cv2.VideoCapture(input_file) while cap.isOpened() and frame_count < max_frames: ret, frame = cap.read() if not ret: break cropped = frame[top : top + height, left : left + width]
次は、この cropped
を使って、各フレームにおけるロウソクの明るさと色データを取得しようと思います。
アプローチ 1(単純平均)
私が最初に考えたのは、上記 cropped
に含まれる画素全てに対して、R, G, B それぞれの平均値を求める、という方法です。なあんだ、簡単だ!
上記ループの中で、次のように処理します。
# Calculate average BGR mean = cropped.mean(axis=(0, 1)) # Normalize to 0-1 avg_b, avg_g, avg_r = (mean / 255.0).tolist()
結果は CSV ファイルに保存します。各行が動画の 1フレームに該当し、左から R, G, B の値となります。(0〜1に正規化済み。以下同様)
0.67,0.57,0.48 0.67,0.57,0.48 0.66,0.55,0.47 0.65,0.55,0.47 0.65,0.55,0.47 0.66,0.56,0.48 (略)
以下に、その時間軸プロットを示します。
グラフはもっともらしい形をしています。しかしこの方法はうまく行きませんでした。得られたデータで仮想 LED アプリを作って明滅させてみると、色が「土色」とでも呼ぶべき色(泥のような色?)となってしまい、色の時間変化もほとんどなく、とてもロウソクの炎には見えなかったのです。
そこで、ビデオから静止画を切り出した後、Gimp という画像編集ツールを使って元画像のあちこちのピクセルを覗いていたところ、原因が分かりました。それは、ロウソクの炎に対応するピクセルのほとんどが、R = 255, G = 255, B = 255(255 は明るさの最大値)にクリップしているのです。結果として、そのような色と、ロウソクの背景(今回は黒)の単純平均をとっても、ロウソクの炎の色には見えない、ということのようです。
アプローチ 2(彩度の高いピクセルのみ平均)
次に考えたのは、cropped
に含まれる画素を全て HSV(Hue, Saturation, Value)に変換し、S(彩度)の高いピクセル(たとえば上位 1%)だけを使って色(H, S)の平均を求め、それとは別に cropped
内の「全画素」の V(明度)を使って、そのフレームの RGB を求めたら、というものです。
ループの中で、次のように処理します。
# Calculate average brightness from grayscale gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY) brightness = gray.mean() # Convert to HSV and sort by saturation hsv = cv2.cvtColor(cropped, cv2.COLOR_BGR2HSV) hsv_flat = hsv.reshape(-1, 3) hsv_sorted = hsv_flat[hsv_flat[:, 1].argsort()][ -int(0.01 * len(hsv_flat)) : ] # Average H and S from top 1% saturation avg_h = hsv_sorted[:, 0].mean() avg_s = hsv_sorted[:, 1].mean() # Convert back to BGR (values in range 0-255) final_hsv = np.uint8([[[avg_h, avg_s, brightness]]]) final_bgr = cv2.cvtColor(final_hsv, cv2.COLOR_HSV2BGR)[0, 0] # Correctly unpack in RGB order avg_b, avg_g, avg_r = (final_bgr / 255.0).tolist()
これが結果です。
残念ながら、この方法でもうまくいきませんでした。仮想 LED を使って明滅させてみると、かなりの割合で緑色に近い光り方をして、あまりロウソクの炎らしく見えないのです。
アプローチ 3(パラメタによるフィルタリング)
最後に試したのは、cropped
に含まれるピクセルをフィルタリング(マスキング)するという方法です。具体的には、S > 0.2 で、かつ、V < 0.98 のピクセルだけを抜き出し、それらピクセルだけを使って H, S の平均値を求めます。最後に、cropped
全体の V の平均を求め(アプローチ 2 と同様)、その H, S, V を RGB 値に変換します。これは Gimp で画像を眺め回して試行錯誤で決めたやり方なのですが、思惑としては、ある程度の彩度があって、しかし R, G, B が飽和していないピクセルだけを使って色を求めよう、というものです。)
# Calculate average brightness from grayscale gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY) brightness = gray.mean() # Convert to HSV and filter by S and V thresholds hsv = cv2.cvtColor(cropped, cv2.COLOR_BGR2HSV) mask = (hsv[:, :, 1] > 0.2 * 255) & (hsv[:, :, 2] < 0.98 * 255) selected_pixels = hsv[mask] if len(selected_pixels) > 0: avg_h = selected_pixels[:, 0].mean() avg_s = selected_pixels[:, 1].mean() else: avg_h, avg_s = 0, 0 # Convert back to BGR (values in range 0-255) final_hsv = np.uint8([[[avg_h, avg_s, brightness]]]) final_bgr = cv2.cvtColor(final_hsv, cv2.COLOR_HSV2BGR)[0, 0] # Correctly unpack in RGB order avg_b, avg_g, avg_r = (final_bgr / 255.0).tolist()
次のような RGB グラフが得られました。
これを使って仮想 LED を明滅させてみたところ、割と本物のロウソクの炎のように見えるようになりました。まだまだ完全とは言えませんが、とりあえずこれで、RNN の訓練作業に移れそうです。
それでは、次回をお楽しみに…。(今年中に間に合うかな?)