Raspberry Pi でタイムラプス動画を撮り YouTube に自動アップロードする

Linux,プログラム,画像処理ffmpeg,OpenCV,Python,Raspberry Pi,YouTube,アップロード,シェルスクリプト,タイムラプス,動画

2022/06/01 更新
upload_video.sh と make_movies.sh の日付の扱いに問題があり、月末の動画がアップロードできないバグを修正。

毎日自動でタイムラプス動画を作成し、自動で YouTube にアップロードするスクリプトを作ったので紹介します。

こんな感じの動画を自動で毎日あげています。

必要なものは USB カメラを接続したコンピュータボード Raspberry Pi のみです(Linux マシンなら何でもいいかもしれません)。

  • Python で OpenCV を用いてカメラ操作と動画作成
  • FFmpeg と SoX を使って BGM 音声の処理と結合
  • シェルスクリプトで YouTube API を操作する Python を呼び出して動画アップロード

それぞれを cron 経由で動かし、毎日動作させています。

タイムラプス用画像作成

Python スクリプトでカメラの撮影と動画の作成を行います。流れとしては以下のとおりです。

  • カメラデバイスの取得と撮影解像度などの設定
  • 無限ループで撮影ループ開始
    • 画像を撮影し、撮影日付時刻の文字列を(左上に)オーバレイして保存
  • 日付時刻を確認し、指定した時刻(23:59)になったら撮影ループ終了
  • 保存した画像を mp4 動画ファイルに結合

スクリプト time_lapse_cam.py は以下のとおりです。

#!/usr/bin/python3
# 日毎に 23:59 ごろまで capture_interval 秒ときに保存し,
# タイムラプス動画を作るスクリプト
import cv2
import glob
import os
import shutil
import time
from datetime import datetime, timedelta

# ビデオデバイスの指定
cam_dev = "/dev/video0"

### 保存先ディレクトリの作成
date_time = datetime.now()
date = date_time.strftime("%Y%m%d")
if not os.path.exists(date):
    os.mkdir(date)   # 画像保存用のフォルダ作製

# 撮影開始出力
print(date, " start shoot")

capture_interval = 3 # 画像取得間隔(秒)

##################
# 撮影する関数
def capture():
    cap = cv2.VideoCapture(cam_dev) # カメラデバイスの取得
    # カメラの解像度を設定. ここでは 1920x1080 の画像
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)

    counter = 0 # 撮影回数のカウンタ
    counter_max = 9 # 撮影テスト用のシャッター数

    while True: # 設定した時間 (limit_time) まで撮影を無限ループ

        ret, frame = cap.read() # カメラからキャプチャされた画像をframeとして読み込む
        cv2.imshow("camera", frame) # frameを画面に表示
        # カレントディレクトリ内にある「img」フォルダに「(date).jpg」というファイル名でファイルを保存
        raw_datetime = datetime.now() # 現在時刻を取得
        shoot_date_time = raw_datetime.strftime("%Y%m%d%H%M%S") # 日付からファイル名を生成
        path = "./{0}/".format(date) + shoot_date_time + ".jpg"
        cv2.imwrite(path, frame) # 画像をフォルダへ保存

        # 画像オーバーレイ用のタイムスタンプ文字列設定
        timestamp = raw_datetime.strftime("%Y.%m.%d - %H:%M:%S")
        cap_img = cv2.imread(path)
        # フォントスタイルと文字の線サイズ指定
        fontstyle = cv2.FONT_HERSHEY_SIMPLEX
        linestyle = cv2.LINE_AA

        # オーバーレイの描画. 白文字にグレーで影をつけるため, 2 種の文字列をずらして描画
        cv2.putText(cap_img, timestamp, (10, 65), fontstyle, 2, (64, 64, 64), 3, linestyle)
        cv2.putText(cap_img, timestamp, (5, 60), fontstyle, 2, (255, 255, 255), 3, linestyle)
        cv2.imwrite(path, cap_img)

        # デバッグ用の撮影回数カウンタ出力
        if counter % 500 == 0:
            print("shoot count = ", counter)
        # if counter > counter_max: # 撮影テスト時はコメントアウトを外し, 指定回数の撮影で break
        #     break
        counter += 1

        # 時刻がその日の 23:59:50 を回っていたらループをストップ
        limit_time = datetime(date_time.year, date_time.month, date_time.day, 23, 59, 50)
        time_delta = limit_time - raw_datetime # 現在時刻との差分を取得
        # print(raw_datetime)
        # print(time_delta.seconds)
        # 現在時刻と limit_time との差分が capture_interval + 1 秒より短ければ break
        # 撮影処理に時間がかかるかもしれないため, 念の為 1 秒の余裕を持たせている
        if time_delta.seconds < (capture_interval + 1):
            break

        # インターバル時間待つ
        time.sleep(capture_interval)

    # ループを抜け, 撮影終了後はカメラリソースの解放
    cap.release()
    cv2.destroyAllWindows()

##################
# 画像のタイムラプス結合
def make_movie():
    images = sorted(glob.glob('{0}/*.jpg'.format(date))) # 撮影した画像の読み込み。
    print("Whole images {0}".format(len(images))) # 画像枚数の表示

    frame_rate = 60
    width = 1920
    height = 1080
    # 動画コーデックの指定. ここでは mp4 動画
    fourcc = cv2.VideoWriter_fourcc('m','p','4','v')
    # 作成する動画の情報を指定(ファイル名、拡張子、FPS、動画サイズ)。
    video = cv2.VideoWriter('result_video/video_{0}.mp4'.format(date), fourcc, frame_rate, (width, height))

    print("build movie...")

    for i in range(len(images)):
        # 画像を読み込む
        img = cv2.imread(images[i])
        # 画像のサイズを合わせる。
        img = cv2.resize(img,(width,height))
        # デバッグのための処理枚数表示
        if i % 1000 == 0:
            print(i, " th image to video")
        video.write(img)

    video.release()

if __name__ == '__main__':

    # 画像撮影. 撮影処理はエラーがでることがあるので例外処理にしておく
    try:
        capture()
    except Exception as e:
        print("Error occured!!!")
        print(e)

    # 動画作成
    make_movie()

撮影する capture() 関数はカメラの取得などでエラーが出やすいため、例外処理を噛ませています。もし例外が投げられても撮影された画像の分だけ動画が生成されるように、make_movie() 関数は外にだしています。

以下では要所について解説します。

カメラの初期化と無限ループ撮影

capture() 関数の最初でカメラの初期化をしています。その後、while true による無限ループで撮影を始めます。

cap = cv2.VideoCapture(cam_dev) # カメラデバイスの取得
# カメラの解像度を設定. ここでは 1920x1080 の画像
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)

OpenCV の cv2.VideoCapture によってカメラを取得します。そのあと cap.set を使って撮影の解像度を指定しています。

続いて、無限ループを開始し、cap.read() を使ってカメラ画像をキャプチャします。

while True: # 設定した時間 (limit_time) まで撮影を無限ループ
    ret, frame = cap.read() # カメラからキャプチャされた画像をframeとして読み込む
    cv2.imshow("camera", frame) # frameを画面に表示
    raw_datetime = datetime.now() # 現在時刻を取得
    shoot_date_time = raw_datetime.strftime("%Y%m%d%H%M%S") # 日付からファイル名を生成
    path = "./{0}/".format(date) + shoot_date_time + ".jpg"
    cv2.imwrite(path, frame) # 画像をフォルダへ保存

ここで datetime.new() を使って日付時刻を取得し、ファイル名をつけて保存しています。

画像へ日付時刻の書き込み

先ほど取得した時刻を strftime メソッドを使って整形したあと、cv2.putText をつかって画像に書き込んでいます[1]一度書き込んだ画像を再読み込みし、字を書き込んで上書きするという無駄なことをしている気がします。。putText を 2 回使っているのは文字色と場所を少し変えて重ね書きすることで、文字に影効果を作り、読みやすくする工夫です。

# 画像オーバーレイ用のタイムスタンプ文字列設定
timestamp = raw_datetime.strftime("%Y.%m.%d - %H:%M:%S")
cap_img = cv2.imread(path)
# フォントスタイルと文字の線サイズ指定
fontstyle = cv2.FONT_HERSHEY_SIMPLEX
linestyle = cv2.LINE_AA

# オーバーレイの描画. 白文字にグレーで影をつけるため, 2 種の文字列をずらして描画
cv2.putText(cap_img, timestamp, (10, 65), fontstyle, 2, (64, 64, 64), 3, linestyle)
cv2.putText(cap_img, timestamp, (5, 60), fontstyle, 2, (255, 255, 255), 3, linestyle)
cv2.imwrite(path, cap_img)

putText の引数は次のとおりです。

cv2.putText(書き込む先の画像, 書き込む文字列, 書き込む (x 座標, y 座標), フォント, フォントサイズ, 色, 太さ, 線種)

どんな文字種やサイズがいいのかは色々確かめてみるしかないと思います。

指定時刻における無限ループ撮影の停止

まず停止したい時刻を指定して取得します。その後、time_delta として現在時刻と指定した時刻の差分を得ます。

# 時刻がその日の 23:59:50 を回っていたらループをストップ
limit_time = datetime(date_time.year, date_time.month, date_time.day, 23, 59, 50)
time_delta = limit_time - raw_datetime # 現在時刻との差分を取得

この差分を元にして、撮影インターバル + 1 秒(capture_inverval + 1)よりも小さくなれば無限ループを抜けます

if time_delta.seconds < (capture_interval + 1):
    break

ここで「撮影インターバル + 1 秒」のマージンをとっているのは、撮影インターバルより長い時間で撮影を止めるためです。あまり深い意味はありません。

タイムラプス動画作成

make_movie() 関数で撮影した画像を結合し、動画を作成します。

まずは作成する動画のフォーマットを設定します。フレームレートやサイズを指定します。

frame_rate = 60
width = 1920
height = 1080
# 動画コーデックの指定. ここでは mp4 動画
fourcc = cv2.VideoWriter_fourcc('m','p','4','v')
# 作成する動画の情報を指定(ファイル名、拡張子、FPS、動画サイズ)。
video = cv2.VideoWriter('result_video/video_{0}.mp4'.format(date), fourcc, frame_rate, (width, height))

つづいて、画像ファイルのパスのリストを作成して、画像を 1 枚ずつ読み込み動画にしていきます。

images = sorted(glob.glob('{0}/*.jpg'.format(date))) # 撮影した画像の読み込み。

for i in range(len(images)):
    # 画像を読み込む
    img = cv2.imread(images[i])
    # 画像のサイズを合わせる。
    img = cv2.resize(img,(width,height))
    video.write(img)

これで動画の完成です。

タイムラプス動画にBGM をくっつける

単に動画だけだと寂しいので BGM をくっつけます。シェルスクリプトで動作し、手順は以下のとおりです。

  • 動画の長さを取得
  • 動画の長さに合わせて音声をカットし、指定した秒数フェードアウト
  • 動画と音声を結合
  • (YouTube へのアップロードスクリプトの実行)

音声の処理には SoX (Sound eXchange) を利用します。タイムラプス動画の結合が終わった翌日に実行することを想定しているため、冒頭で前日の日付情報を取得しています。

#!/bin/sh
# 毎日できる動画ファイルに対して指定した音声ファイルを
# いい感じの長さにしてフェードアウトさせた音声を結合するスクリプト
# 最後に YouTube へのアップロードスクリプトを回す

# 実際に処理する日付は前日のもの
yesterday=`date +%Y%m%d --date '1 day ago'`
# yesterday=`date +%Y%m%d` # 当日データを使ったテスト用

# 動画と音声の一時ファイル名
temp_video_name="./result_video/tempVideo.mp4"
temp_audio_name="tempAudio.mp3"
source_video_name="./result_video/video_${yesterday}.mp4"

durationTime=3 # フェードアウトさせたい時間の設定

# 動画ファイルの長さを取得
dur=$(ffprobe -loglevel error -show_entries format=duration -of default=nk=1:nw=1 "${source_video_name}")

# フェードアウトした音声一時ファイルを作成
sox ./source.mp3 ${temp_audio_name} fade 0 $dur $durationTime

# 動画と音声を結合
ffmpeg -i ${source_video_name} -i ${temp_audio_name} -c:v copy -c:a aac -map 0:v:0 -map 1:a:0 ${temp_video_name}

# 音声を結合した動画で上書き
mv -f ${temp_video_name} ${source_video_name}
rm ${temp_audio_name}

# アップロード処理
/usr/bin/zsh ./upload_video.sh

動画の長さ取得と音声ファイル操作

BGM をいい感じでフェードアウトさせたいので、音声を加工します。

そのためにまず ffprobe コマンドを利用して結合したい先の動画の長さを取得してシェル変数 dur に格納します。

# 動画ファイルの長さを取得
dur=$(ffprobe -loglevel error -show_entries format=duration -of default=nk=1:nw=1 "${source_video_name}")

取得した動画の長さを元に、あらかじめ durationTime で指定した秒数だけフェードアウトさせた音声を作ります。

# フェードアウトした音声一時ファイルを作成
sox ./source.mp3 ${temp_audio_name} fade 0 $dur $durationTime

音量を fade オプションで 0 までフェードアウトさせることを指定しています。ここでは音声ファイルを任意の時間でカットしているため、少なくとも結合したい動画時間以上の音声ファイルを用意しておく必要があります。

YouTube にアップロードする

YouTube へのアップロードスクリプトを呼び出します。この部分は「YouTubeAPIを利用して動画をアップロードする – Qiita」を参考にしています。

YouTube で用いる API の利用のための GCP アプリケーション登録などは上記ページを参考にしてください。pip で google-api-python-client をインストールし、upload_video.py を用意しておきます。それをシェルスクリプトで呼び出します。

#!/bin/sh
# 前日の動画を YouTube にアップロードするスクリプト
# アップロードした後の動画は削除する

# 実際に処理する日付は前日のもの
yesterday=`date +%Y%m%d --date '1 day ago'`
# yesterday=`date +%Y%m%d` # 当日データを使ったテスト用

# 出力ファイルの指定
filename="./result_video/video_${yesterday}.mp4"

# 動画タイトルの指定
title_date=$(date +%Y/%m/%d --date '1 day ago')
title_name="[${title_date}] タイムラプス"
echo $title_name

# アップロードスクリプトを呼び出す
/usr/bin/python3 ./youtube_upload.py \
    --file="${filename}" \
    --title="${title_name}" \
    --description="概要コメント概要コメント概要コメント概要コメント" \
    --category="22" \
    --privacyStatus="public"

# アップロードが終わった動画を削除する
rm ${filename}

このスクリプトで実際にアップロードしているのは以下の部分です。

/usr/bin/python3 ./youtube_upload.py \
    --file="${filename}" \
    --title="${title_name}" \
    --description="概要コメント概要コメント概要コメント概要コメント" \
    --category="22" \
    --privacyStatus="public"

それぞれオプションは以下のとおりです。

  • –file=アップロードするファイルのパス
  • –title=動画タイトル
  • –description=概要コメント
  • –category=動画カテゴリ ID
  • –privacyStatus=動画の公開ステータス
    (public : 公開 / private : 非公開 / unlisted : 限定公開)

動画のカテゴリと ID の対応は以下のようになっています。(参考資料 : dgp/youtube api video category id list – GitHub Gist

1映画とアニメ
2自動車と乗り物
10音楽
15ペットと動物
17スポーツ
19旅行とイベント
20ゲーム
22ブログ
23コメディ
24エンターテイメント
25ニュース政治
26ハウツーとスタイル
27教育
28科学と技術
29非営利団体と社会活動

このスクリプトでは容量節約のため、最後に動画ファイルを削除しています。

また初回アップロード時には GUI を介した操作が必要です。

主要な時刻のスナップショットを保存

タイムラプスに必須ではありませんが、あとで再利用するために適当な時刻の画像を保存しています。

  • 指定した時刻のファイル名を取り出し
  • 指定したディレクトリに格納する

という操作をしています。いかにコードを載せます。

#!/bin/sh
# 前日のタイムラプス撮影画像の中から適当な時刻の画像を
# 抜き出して保存するスクリプト
# 最後に前日のタイムラプス画像を削除する

yesterday=`date +%Y%m%d --date '1 day ago'`

hour4=`ls -1 ./${yesterday}/${yesterday}04????.jpg | sort | head -n 1`
hour5=`ls -1 ./${yesterday}/${yesterday}05????.jpg | sort | head -n 1`
hour6=`ls -1 ./${yesterday}/${yesterday}06????.jpg | sort | head -n 1`
morning=`ls -1 ./${yesterday}/${yesterday}07????.jpg | sort | head -n 1`
noon=`ls -1 ./${yesterday}/${yesterday}12????.jpg | sort | head -n 1`
evening=`ls -1 ./${yesterday}/${yesterday}18????.jpg | sort | head -n 1`

echo "${hour4} ${hour5} ${hour5} $morning"
echo "$noon"
echo "$evening"

cp -f ${hour4} ./snapshot/hour4/
cp -f ${hour5} ./snapshot/hour5/
cp -f ${hour6} ./snapshot/hour6/
cp -f ${morning} ./snapshot/morning/
cp -f ${noon} ./snapshot/noon/
cp -f ${evening} ./snapshot/evening/

rm -rf ./${yesterday}

まず date コマンドを利用して前日の日付を取得しています。

yesterday=`date +%Y%m%d --date '1 day ago'`

–date '1 day ago’ をオプションにつけると、前日の日付が指定できます。

続いて指定した日付のファイルを取得します。

morning=`ls -1 ./${yesterday}/${yesterday}07????.jpg | sort | head -n 1`

撮影したファイル名は「日付 8 桁 時刻 6 桁」の形で表現されているので、ls -1 で時刻 6 桁のうち分と秒の桁を ? のグロブを使って任意のファイルを指定して取得します。さらにそれを sort コマンドにパイプして head -n 1 で先頭行だけ取り出すことで、指定した時刻のうち、最も数字が小さい(この場合 07:00 に最も近い)時刻の画像を指定しています。

Cron で毎日実行

Crontab を設定し、毎日 0:00 から撮影スクリプト time_lapse_cam.py を起動します。最初に環境変数で DISPLAY=:0 を設定しているのは OpenCV による画面表示を抑制するためです。表示させるようにしておくと GUI 画面を確認しないといけなくなるため、自動で実行するのが難しくなります。

0 0 * * * export DISPLAY=:0 && cd /home/pi/camera/usb/ && /usr/bin/python3 /home/pi/camera/usb/time_lapse_cam.py >> /home/pi/camera/usb/camera.log 2>&1
28 6 * * * cd /home/pi/camera/usb/ && /usr/bin/zsh /home/pi/camera/usb/make_movies.sh >> /home/pi/camera/usb/ffmpeg.log 2>&1
28 22 * * * cd /home/pi/camera/usb/ && /usr/bin/zsh /home/pi/camera/usb/save_snapshot.sh >> /home/pi/camera/usb/snapshot.log 2>&1

また最後にリダイレクトを入れることで、標準出力の結果をデバッグのためにログに残しています。

save_snapshot.sh を夜遅くに実行しているのは、もしアップロードなどが失敗した時に、夜までにチェックができるように遅めにしています。

YouTube Live も配信する

同じようにして Raspberry Pi を使用して USB カメラから YouTube Live を配信するやり方も紹介しています。記事「FFmpeg を使って YouTube Live にカメラ配信する方法(Raspberry Pi)」も参照してください。

すでにある動画や音声などを配信する方法も紹介しています。記事「FFmpeg で動画や静止画 + 音声を YouTube Live 配信する」を参照してください。

参考資料

脚注

脚注
1一度書き込んだ画像を再読み込みし、字を書き込んで上書きするという無駄なことをしている気がします。