Edited at

Raspberry Piで4DXを作る


4DXとは ... ?

近年の映画では3Dが当たり前のように観れるようになりました.

最近では揺れや風, 水しぶきなどの5感を使う映画"4DX"が出現しています.

簡単なものでいいので, この4DXをraspbetty piで作ってみようという試みです.


完成品

完成した様子として2つ動画を再生して見ました.

動画はYoutubeの公式アカウントが公開しているものを利用させていただきました.

正直, 完成度は低めです.

deviceなし, 映像のみ

demo① 「君の名は。」予告

deviceあり, 映像あり

demo② 映画『貞子vs伽椰子』予告編

全体図

全体図

水しぶき機

送風機

振動機

発表の前日に回路が不具合を起こし, 急いで作り直したので半田付けはしていないです.


開発環境

解析側

- MacBook Air (13-inch, Early 2015)

- OS : macOS Mojave バージョン10.14

- プロセッサ : 1.6 GHz Intel Core i5

- メモリ : 8 GB 1600 MHz DDR3

- Python 3.7.1

- opencv-python 3.4.4.19

ハード側

- Raspberry Pi3 Model B

- OS : raspbian

- Python 3 (opencv-python利用時のみpython2.7)

- opencv-python

-iPhone 7

-OS : バージョン12.01

raspbery piはRaspberry Pi3 Model B ボード&ケースセット 3ple Decker対応 (Blue)を購入しました.

また, 映像を投影するためにミニディスプレイが必要です.

ミニディスプレイはQuimat 3.5インチタッチスクリーン HDMIモニタTFT LCDディスプレイ Raspberry Pi 3 2 Model B Rpi B B+ A A+ 映画 アーケードゲーム オーディオ入力 RPi GPIOブレークアウト拡張ボード 保護ケースキット アクリル(透明) QC35Cを購入しました. このミニディスプレイは装着後もgpioのピンを使えるので購入しました.

結局解像度と輝度が足りなかったので, 手持ちのiphone7を使うことにしました.


DCモーターの制御


送風機

送風機は直流モーターにプロペラをつけて作ります.

結構プロペラの種類によって電力が同じでも(体感)風量が変わったのでプロペラ選びは大事かもしれません.


霧吹き機

これをどう実現するかが一番困難でした. 最初はドライアイスをペットボトルの中に入れて, 圧をストローなどで逃す機構を作り, そのストローにサーボモーターを取り付けて圧を制御して, その圧で霧吹きで水しぶきを...と考えていました.


  • ストローをへし折る -> 圧が逃げられなくなり, 高圧となって水が噴射される

  • ストローをへし折らない -> 圧が逃げて高圧とならず水が噴射されない

ですが, 圧を逃がしたり, 逃さなかったりで即座に圧が変わるわけではなく, 時間遅れが出てしまい, 好きなタイミングで制御しづらいことが判明したので諦めました.

この案はこの動画から得ました.

参考 : 【簡単】自動霧吹きの作り方

と言うことで, 制御のことを考えて大人しく電気で制御するしかないと思って, amazonで電動霧吹きを調達しました.

ヨーキ産業 電池式スマートスプレー 単三電池2本使用

乾電池仕様となっています. 中身はただのモーターなので結局先の扇風機とあまりやることは変わらないはずです.


振動機

こちらをamazonで購入しました.

DealMux DC 5V 4500RPMプラスチックハウジングマッサージ機の電気USB振動モータ

こちらはUSBで制御します.


特徴量

制御の基準となる特徴量を決めます.

ここでは音声が解析済み(振幅, ピッチが既知)という前提で話を進めていきます.

とりあえず, 認識したいのは,


  • 悲鳴

  • 衝撃

  • 静寂

の3つです.


  • 悲鳴, 衝撃

悲鳴ですが, 一般にピッチが高いはずです.

ということで「ピッチが高い⇒ 悲鳴」,「振幅が大きい ⇒ 衝撃」ということにしてみました. パラメータはいくつか解析してみて決めようという感じです.


  • 静寂

これに関しては映像の助けを借りようと思います. 薄暗くて静かだと寒気がするようなシーンが多いと思います. 必ずしもそうだとは限りませんが, それは妥協ということで, 「(振幅が小さい ∧ 輝度が小さい) ⇒ 静寂」という案でやってみます.

どちらも雑な感じが否めませんがこれでやってみます.


mp4からwavへの変換

音声解析のために, mp4からwavに変換する必要があります. Macではターミナルで変換することが出来ます. もしinstallされていなければ次のコマンドを打ってinstall出来ます(brewが必要です).


Installの方法

$ brew install ffmpeg


変換は次にようにして行います.


実行方法

$ ffmpeg -y -i file.mp4 -ac 1 -ar 44100 file.wav


-i は入力ファイルの指定です.

-y は常に上書きするという指定です.

-ar 44100 はサンプリング周波数の指定です.

pythonでの音声解析ではwavはモノラル形式でなければなりません. それを -ac 1 で指定しています.

以下, ファイル構造を次のように作ります.


~/4dx/

4dx

├── src # プログラムコードの置き場所
├── movie # mp4ファイルの置き場所
├── wav # wavファイルの置き場所
└── time_arr # 制御データの置き場所


time arrayの作成

制御には, デバイスをONにする時間を格納した"time array"を渡します. デバイスは扇風機とスプレーとバイブレータの3つで, それぞれ "filename_silence.npy", "filename_yell.npy", "filename_impact.npy" というnumpyのarrayファイルを受け取り, それにしたがって制御します.

array([1, 4, 6, 9]) であれば, 1秒, 4秒, 6秒, 9秒でデバイスをONにします. それ以外ではOFFにします.

また, "filename_time.npy"には動画の長さを書き出します. numpyのarrayですが実際は動画の長さが入っただけの, 要素が1つだけのarrayです. array([54]) であればこの動画は54秒です.

実際に使用するのは".npy"のファイルだけですが, 目視で確認できるよう, ".txt"ファイルも作成するようにしました.


pyreaperで音声解析

音声解析については, pyraperというpython用のライブラリを使いました.

この辺りに関しては別途記事にしたのでそちらを参考にしてください.

pyworld, pyreaperで音声解析入門

-debugというoptionでprint文のdebugができます.


python-opencvで動画解析

最初は解析速度的な問題を考えて, PythonではなくC++を使おうと思って書いていたのですが, コンパイルでエラーはでないものの, いざ実行すると微妙に全データを処理してくれないくて(?), 途中でエラーになって解消できませんでした. ということでPythonでやってみました.

方針としては輝度値を使います. 輝度値はグレースケール変換した時の画素値と考えてもらえば大丈夫です. 明度とは似て非なるものです. $RGB$ から輝度値 $Y$ 計算式は

$Y = 0.298912 \times R + 0.586611 \times G + 0.114478 \times B$

となります.

-debugというoptionでprint文のdebugができ, -showで動画の表示ができます.

計算時間のために動画はかなり遅く再生されます. 計算部分を除けば普通に再生できます.


time_arr作成の実装

-debug, -showのoptionを引数に取ることが可能です.


picking.py

import pyreaper

import sys
import numpy as np
import cv2
import matplotlib.pyplot as plt
from scipy.io import wavfile
from os import chdir

def voice_analysis():
# Get command line
FILENAME = sys.argv[1]
WAV_FILE = "../wav/"+FILENAME+".wav"
option = sys.argv[2:]

# Anlysis
fs, row_data = wavfile.read(WAV_FILE)
pm_times, pm, f0_times, f0, corr = pyreaper.reaper(row_data, fs)
_time = pm_times[-1]

# Save time length
SAVE_FILENAME_NPY = "../time_arr/"+FILENAME+"_time.npy"
np.save(SAVE_FILENAME_NPY, np.array([_time]))

SAVE_FILENAME_TXT = "../time_arr/"+FILENAME+"_time.txt"
np.savetxt(SAVE_FILENAME_TXT, np.array([_time]), fmt='%d', delimiter=',')

# -debug option
if "-debug" in option:
fig,axes = plt.subplots(nrows=2,ncols=2,figsize=(14,10))
# row_data graph
axes[0,0].plot(row_data, label="Row_data")
axes[0,0].legend(fontsize=10)

# freq graph
axes[0,1].plot(pm_times, pm, linewidth=3, color="red", label="Pitch mark")
axes[0,1].legend(fontsize=10)

# pirch mark graph
axes[1,0].plot(f0_times, f0, linewidth=3, color="green", label="F0 contour")
axes[1,0].legend(fontsize=10)

# corr graph
axes[1,1].plot(f0_times, corr, linewidth=3, color="blue", label="Correlations")
axes[1,1].legend(fontsize=10)

plt.show();

return row_data, f0, f0_times, _time

def picking_yell(f0, f0_times, threshold_freq=350):
# Get command line
FILENAME = sys.argv[1]
option = sys.argv[2:]

yell_time_arr = np.unique(f0_times[np.where(f0>threshold_freq)[0]].astype(np.int))

# Save time array
SAVE_FILENAME_NPY = "../time_arr/"+FILENAME+"_yell.npy"
np.save(SAVE_FILENAME_NPY, yell_time_arr)

SAVE_FILENAME_TXT = "../time_arr/"+FILENAME+"_yell.txt"
np.savetxt(SAVE_FILENAME_TXT, yell_time_arr, fmt='%d', delimiter=',')

# -debug option
if "-debug" in option:
print("\n", end="")
print("**********")
print("length of yell_time_arr:"+str(len(yell_time_arr)))
print("\n", end="")
print("yell_time_arr")
print(yell_time_arr)
print("**********")

def picking_impact(row_data, _time, threshold_amp=15000):
# Get command line
FILENAME = sys.argv[1]
option = sys.argv[2:]

amp = abs(row_data)
impact_time_arr = np.where(amp>threshold_amp)[0]/len(amp)*_time
impact_time_arr = np.unique(impact_time_arr.astype(np.int))

# Save time array
SAVE_FILENAME_NPY = "../time_arr/"+FILENAME+"_impact.npy"
np.save(SAVE_FILENAME_NPY, impact_time_arr)

SAVE_FILENAME_TXT = "../time_arr/"+FILENAME+"_impact.txt"
np.savetxt(SAVE_FILENAME_TXT, impact_time_arr, fmt='%d', delimiter=',')

# -debug option
if "-debug" in option:
print("\n", end="")
print("**********")
print("length of impact_time_arr:"+str(len(impact_time_arr)))
print("\n", end="")
print("impact_time_arr")
print(impact_time_arr)
print("**********")

def picking_silence(row_data, _time, threshold_amp=500, threshold_intensity=35):
# Get command line
FILENAME = sys.argv[1]
option = sys.argv[2:]
MOVIE_FILE = "../movie/"+FILENAME+".mp4"

cap = cv2.VideoCapture(MOVIE_FILE)

# Get properties & Define variables
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
frame_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = int(cap.get(cv2.CAP_PROP_FPS))
loop_count = 0
intensity_list = []

# Print movie properties
print("\n", end="")
print("**********")
print("width:" + str(width))
print("height:" + str(height))
print("frame num:" + str(frame_num))
print("fps:" + str(fps))
print("movie_time:" + str(_time))
print("**********")
print("\n", end="")

# Processing for each frame
while cap.isOpened():
ret, frame = cap.read()

if ret:
loop_count += 1
intensity = 0
gray_img = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

# Calculate average intensity
for x in range(height):
for y in range(width):
intensity += gray_img[x][y]

intensity /= (width*height)
intensity_list.append(intensity)

# -debug option
if "-debug" in option:
print("intensity:{0:.4f} ".format(intensity), end="")

# -show option
if "-show" in option:
cv2.imshow("Movie", gray_img)

# KeyboardIntterrupt
if cv2.waitKey(25) & 0xFF==ord('q'):
break

# print %
print(str(int(100*loop_count/frame_num)) + "% is written.")

else:
break

cap.release()
cv2.destroyAllWindows()

# Calculate average intensity
avg_intensity_arr = np.array(intensity_list)
length_avg_intensity_arr = len(avg_intensity_arr)
length_avg_intensity_arr -= length_avg_intensity_arr % fps
avg_intensity_arr = avg_intensity_arr[:length_avg_intensity_arr]
avg_intensity_arr = avg_intensity_arr.reshape(-1, fps).mean(axis=1)
dark_time_arr = np.where(avg_intensity_arr<threshold_intensity)[0]
dark_time_arr = np.unique(dark_time_arr.astype(np.int))

quiet_time_arr = np.where(amp<threshold_amp)[0]/len(amp)*_time
quiet_time_arr = np.unique(quiet_time_arr.astype(np.int))

# pick silence, judging from darkness && quietness
silence_time_arr = np.intersect1d(dark_time_arr, quiet_time_arr)

# Save time array
SAVE_FILENAME_NPY = "..time_arr/"+FILENAME+"_silence.npy"
np.save(SAVE_FILENAME_NPY, silence_time_arr)

SAVE_FILENAME_TXT = "../time_arr/"+FILENAME+"_silence.txt"
np.savetxt(SAVE_FILENAME_TXT, silence_time_arr, fmt='%d', delimiter=',')

# -debug option
if "-debug" in option:
print("\n", end="")
print("**********")
print("average_intensity_arr")
print(avg_intensity_arr)
print("\n", end="")
print("length of avg_intensity_arr:"+str(len(avg_intensity_arr)))
print("\n", end="")
print("silence_time_arr")
print(silence_time_arr)
print("**********")

if __name__ == "__main__":
chdir("/Users/YujiNarita")

print("\n", end="")
print("voice anlysis...")
print("\n", end="")
row_data, f0, f0_times, _time = voice_analysis()

print("\n", end="")
print("picking yell...")
picking_yell(f0, f0_times)

print("\n", end="")
print("picking impact...")
picking_impact(row_data, _time)

print("\n", end="")
print("picking silence...")
picking_silence(row_data, _time)



Shell Scriptによる整理

mp4からwavファイルの作成と, time_arrの作成をまとめます.


make_data.sh

#!/bin/sh

ffmpeg -y -i /Users/YujiNarita/4dx/movie/$1.mp4 -ac 1 -ar 44100 /Users/YujiNarita/4dx/wav/$1.wav
/Users/yuji1997/.pyenv/shims/python3 /Users/yuji1997/4dx/src/flip.py $1
/Users/yuji1997/.pyenv/shims/python3 /Users/yuji1997/4dx/src/picking.py $1 $2 $3



GPIOの制御


TA7291Pについて

raspberry piのgpioのピンは1本あたり50mAまでしか流すことは出来ません. それ以上流すと過電流で見事にpiが焼き上がります. この前友人が9Vの電池を繋げて焦がしました(笑).

また, モーターを手で回転させると発電して故障の原因になります.

また, モーターの雑音で他の回路に影響を及ぼすこともあります.

ということで, DCモーターを直接raspberry piから電圧を取るのではなく, モーター制御用ICという素子を使います. 電圧は乾電池から取りましょう.


回路図

Fritzingというソフトを使うことで綺麗な配線図が描けます.

test_audio.png

これらを基盤上で半田付けします.

なお, 基盤は秋葉原の秋月商店で販売している片面ガラス・ユニバーサル基板(ブレッドボード配線パターンを使いました. ユニバーサル基板でも良いですが, ブレッドボードとほぼ同じなのでこちらの方があれこれ考えないで済みました.

また, raspberry piからジャンパーピンで繋ぎ続けるのも格好が悪いので, ピンソケット(メス) 1×5を購入しました. こちらも秋月商店で販売しています. これにより, シェルのようにraspberry piのgpioピンに基盤を差し込むだけでよく, 邪魔な銅線を除くことが出来ます.


RPi.GPIOで制御

raspberry piのgpioピンの制御のライブラリには複数ありますが, 今回は "RPi.GPIO" というライブラリを使います.

今回はdebugモードを組み込みました. debug=True という引数を消す, あるいは debug=False とすればprint文のdebugを非表示にできます.


gpio_control.py

import RPi.GPIO as GPIO

import time
import numpy as np
import sys

class GPIO_CONTROL:
"""
Class for controling DC motor by TA7291 @ GPIO
*** This is based on the BCM standards. ***

Parameters
_____
PIN : GPIO PIN number.
duty : PWM duty from 0 to 100. The defaults are set to duty=100.
Hz : PWM frequency: 50Hz has been set as default.
debug : If you want to debug, set "debug=True". False has been set as default.

Functions
_____
action(time_arr movie_time) : Start up DCmotor control according to time_arr.
terminate() : Finish DCmotor control.
"""

def __init__(self, PIN, duty=100, Hz=50, debug=False):
# Initialize
GPIO.cleanup()
GPIO.setmode(GPIO.BCM)
GPIO.setup(PIN, GPIO.OUT)

# Save member variables
self.duty = duty
self.debug = debug
self.DCmotor = GPIO.PWM(PIN, Hz)

GPIO.setwarnings(False)

self.DCmotor.start(0)

def action(self, time_arr, movie_time):
start = time.time()
elapsed_time_int = int(time.time()-start)

# Control DCmotor according to time_arr
while elapsed_time_int <= movie_time:
elapsed_time_int = int(time.time()-start)

# Turn Off
if elapsed_time_int not in time_arr:
self.DCmotor.ChangeDutyCycle(0)
if self.debug:
print("\n", end="")

# Turn On
elif elapsed_time_int in time_arr:
self.DCmotor.ChangeDutyCycle(self.duty)
if self.debug:
print("***************")
print("\n", end="")

def terminate(self):
if self.debug:
print("Terminate DCmotor control.")
self.DCmotor.ChangeDutyCycle(0)
GPIO.cleanup()

if __name__ == "__main__":
LOAD_NAME_time_arr = "../time_arr/"+sys.argv[1]+"_"+sys.argv[2]+".npy"
time_arr = np.load(LOAD_NAME_time_arr)

LOAD_NAME_movie_time = "../time_arr/"+sys.argv[1]+"_time.npy"
movie_time = np.load(LOAD_NAME_movie_time)

PIN_NUM = int(sys.argv[3])

if len(sys.argv)>=5:
debug = (sys.argv[4]=="-debug")
DCmotor = GPIO_CONTROL(PIN=PIN_NUM, debug=debug)
else:
DCmotor = GPIO_CONTROL(PIN=PIN_NUM)

DCmotor.action(time_arr, movie_time)
DCmotor.terminate()



USBの制御

pythonでshellのコマンドを実行することで, USBの制御をします.

いくつかのライブラリで出来るようですが, 今回は "subprocess" を使っていきます.

参考 : Raspberry PiにUSB扇風機を接続してUSBポートをON/OFF制御する方法


usb_control.py

import sys,os

import numpy as np
import time
import subprocess

# Define shell command
USB_ON = ["hub-ctrl", "-b", "1", "-d", "2", "-P" "2", "-p", "0"]
USB_OFF = ["hub-ctrl", "-b", "1", "-d", "2", "-P" "2", "-p", "1"]

class USB_CONTROL:
"""
Class for controling usb

Parameters
_____
debug : If you want to debug, set "debug=True". The defaults are set to false.

Functions
_____
acton(time_arr, movie_time) : Start up usb control according to time_arr.
terminate() : Finish usb control.
"""

def __init__(self, debug=False):
self.debug = debug

def TurnOn(self):
try:
if self.debug:
print("***************")
print("\n", end="")
res = subprocess.check_call(USB_ON)
except:
print("Fail to Turn On.")

def TurnOff(self):
try:
if self.debug:
print("\n", end="")
res = subprocess.check_call(USB_OFF)
except:
print("Fail to Turn Off.")

def acton(self, time_arr, movie_time):
start = time.time()

while elapsed_time_int <= movie_time:
elapsed_time_int = int(time.time()-start)

if elapsed_time_int in time_arr:
self.TurnOn()

if elapsed_time_int not in time_arr:
self.TurnOff()

self.TurnOff()

if __name__ == "__main__":
LOAD_NAME_time_arr = "../time_arr/"+sys.argv[1]+"_"+sys.argv[2]+".npy"
time_arr = np.load(LOAD_NAME_time_arr)

LOAD_NAME_movie_time = "../time_arr/"+sys.argv[1]+"_time.npy"
movie_time = np.load(LOAD_NAME_movie_time)

if len(sys.argv)>=4:
debug = (sys.argv[3]=="-debug")
FAN = USB_CONTROL(debug=debug)
else:
FAN = USB_CONTROL()

FAN.acton(time_arr, movie_time)



Shell Scriptによる整理(DCモータ)

今回はデバイスの制御を各々異なるpythonファイルをバックグラウンドで実行することで実現させます.

参考 : 初心者向けシェルスクリプトの基本コマンドの紹介

実行方法は ./theater.sh filename (-debug) です. 引数の数に対応するため, 少々shell scriptが長くなってしまいました.


theater.sh

#!/bin/sh

/usr/bin/python3 /home/pi/4dx/src/gpio_control.py $1 yell 18 $2 &
/usr/bin/python3 /home/pi/4dx/src/gpio_control.py $1 silence 12 $2 &
/usr/bin/python3 /home/pi/4dx/src/gpio_control.py $1 impact $2 &



iPhoneとraspberry piの同期

映像は手持ちのiPhone, 制御はraspberry piで行いますが, これらをどのように同期するか考えました.

同じWi-Fiに繋いでいるはずなので上手く実装すれば正確に同期できそうですが, そのためにはiPhoneのApp開発が必要そうなので今回は諦めました.

今回はraspberry piにwebカメラを繋いでiPhoneの画面の明るさを検知することにしました.

①予め動画の先頭に一定時間ピントを合わせるための暗めの動画を繋げる

②ピントを合わせる

③再生したい動画の手前で真っ白の映像を映し出し, それをwebカメラが検知する

④制御開始

少し無理やりな気もしますが...

それと既にUSB振動機を差し込んでいるので制御開始まで振動機が振動します.

本当ならCdSセルなどを用いて明るさを検知すれば良いのですが...半田付けをしてしまって作り直すのが大変だったのと, webカメラが作動しているかどうか振動で確認できるということで, 今回はこれで行きます.

やることは2つです.

・先頭に繋げる動画の作成と連結

・輝度値の取得と制御


sensor.py

import cv2

import time
import subprocess
import sys

def isBright(cap):

ret, img = cap.read()

gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
avg_intensity = 0

for x in range(48):
for y in range(64):
avg_intensity += gray_img[x][y]

avg_intensity /= (64*48)
print avg_intensity

isBright = (avg_intensity>80)

return isBright

def DCmotor_control():
cap = cv2.VideoCapture(1)
cap.set(3, 64)
cap.set(4, 48)

while not isBright(cap):
isBright(cap)

cmd = ["./theater.sh"]+sys.argv[1:]

try:
res = subprocess.check_call(cmd)
except:
print "./theater.sh exec Error."

def test():
cap = cv2.VideoCapture(0)
cap.set(3, 64)
cap.set(4, 48)

while True:
isBright(cap)

if __name__ == "__main__":
DCmotor_control()
#test()



Shell Scriptによる整理(同期)

一度ひと通りのプログラムを実行するとUSB振動機をOFFにした為に, もう一度実行する際にwebカメラがONになりません.


all.sh

#!/bin/sh

sudo hub-ctrl -h 0 -P 2 -p 1
/use/bin/python /home/pi/4dx/src/sensor.py $1 $2



簡易版プロジェクターの作成

こちらに関しては以下を参考にさせていただきました.

参考 : スマートフォンで作る自作プロジェクター

参考 : 自作プロジェクターを作って大きめホログラム映像を見てみた

以下のものをDAISOで購入しました.

・B5の大きさの段ボール

・ブックスタンド

・耐震用の滑り止め

・ルーペ

・カラースプレー

・B5の下敷き

・円形カッター

・スマホ用の三脚(扇風機の台に使います)

詳細な作成方法は省略します. 写真を見れば大体はわかると思います.


動画の作成

動画は先のOpeningを先頭につなげることと, 上下反転が必要です.

これらはmacであればQuickTime Playerを使えば簡単に結合・反転ができます.