snovaのブログ

主にプログラミングやデジタルコンテンツについて書きます。最近はPython, Flutter, VRに興味があります。

Zennで本を書いてみた

はじめに

以前、Flutter x Firebaseでカウンターアプリを作成する記事を公開しました。

snova301.hatenablog.com

内容は基本的なものばかりでしたが、それでもそこそこのボリュームになりました。

十数日間かけて書いたシリーズなので、記事をもう少し体系的にまとめてみようと思い、zennで本にして公開しました。 無料です。

zenn.dev

作成した感想

  • もともと作っていた記事があったので、執筆自体はそんなに大変ではなかった
  • はてなブログ独自の記法を使用している部分をzennの形式に変更するのが少し面倒だった
  • zennのエディタについては、ダークモードにしたり、エディタとプレビューを同時に表示してくれるなら、使いやすいと感じた
  • デザインは自動でいい感じにしてくれるので、気にしなくていい
  • 有料設定やバッジなどがあるので、励みになる
  • 表紙を作るのは意外と難しい。世の中の表紙絵を書く人はすごいなと思った

結論

今後もシリーズ物を書いたらzennの本にまとめようと思います。

Raspberry Pi x Flutter x Firebaseでホームカメラを製作(実践編)

Raspberry PiとUSBカメラを使ったホームカメラの製作の実践編です。

前回の内容はこちら。

snova301.hatenablog.com

システム概要

Raspberry PiPythonを実行し、USBカメラで画像を撮影します。 撮影された画像をadmin権限でCloud Storage for Firebaseにアップします。 アップされた画像をFlutterアプリで受け取ります。

環境

今回、各ツールのインストールは紹介しません。

項目 内容
Raspberry Pi 3 Model B+
USBカメラ Buffalo BSWHD06MBK
macbook air m1
Python 3.9.1
Flutter 3.0.4

Python opencvでカメラのデータを読み込む

opencvをインポートします。 必要に応じて他のモジュールもインポートしてください。

pip install opencv-python

opencvを使って映像が流れるかテストします。

import cv2

def main():
    # キャプチャの初期化
    cap = cv2.VideoCapture(0)

    while True:
        # frameの読み込み
        ret, frame = cap.read()

        # 表示
        cv2.imshow('frame', frame

        # qボタンを押すと再生停止
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

    # キャプチャ終了
    cap.release()

if __name__ == '__main__':
    main()

参考

www.randpy.tokyo

ちなみに、本当はDockerで実行するつもりでしたが、USBカメラの読み込みがいまいちうまくいかなかったので、今回はローカル環境で実行しました。

Cloud Storage for Firebaseへの画像アップロードテスト

画像とFirebaseの準備が終了したら、Cloud Storageに画像をアップできるかテストします。 Firebase consoleからStorageを始めます。

セキュリティルールは本番ルール、リージョンはasia-northeast2(大阪)を選択。

セキュリティルールを以下のように変更。

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

python側に戻り、アップロード用のコードを記述します。 <BUCKET_NAME>にはFirebase consoleにあるURLを記述しますが、gs://を含めない形にしないと、404エラーになります。

import firebase_admin
from firebase_admin import credentials
from firebase_admin import storage

cred = credentials.Certificate('serviceAccountKey.json')
firebase_admin.initialize_app(cred, {
    'storageBucket': '<BUCKET_NAME>.appspot.com'
})

bucket = storage.bucket()

実行してFirebase consoleで画像があるか確認します。

Cloud Storage for Firebaseの公式ドキュメントにAPIの使い方が記載されています。

firebase.google.com

Cloud Storage for Firebaseのアップロードとダウンロードの操作は、Cloud Storageと同じなのでGoogle Cloudの公式ドキュメントも参考にしました。

cloud.google.com

Firebase Authの設定

Cloud Storageを使用するためにFirebase Authを使用します。

Firebase consoleからAuthenticationを開始し、メールとパスワードを選択

今回はメールアドレスとパスワードを事前に作成しておくため、ユーザーを追加を選択し、適当にアカウントを作成します。

ユーザーが作成されていたら、OKです。

Flutterアプリの製作

Flutterアプリの初期化します。

flutter create home_camera

Firebaseの使用に必要なコマンドラインツールをインストール。

firebase login
dart pub global activate flutterfire_cli

アプリをFirebaseに接続

flutterfire configure

選択内容は以下の通り。

# プロジェクトを選択
? Select a Firebase project to configure your Flutter application with ›
❯ ***

# プラットフォームの選択。全てにチェックが入っているか確認
? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
✔ macos
✔ web

# android/build.gradleの更新を行うかどうか
? The files android/build.gradle & android/app/build.gradle will be updated to apply Firebase configuration and gradle build plugins. Do you want to continue? (y/n) › yes

pubspe.yamlfirebase_corefirebase_storageを追加します。

dependencies:
  firebase_core: ^1.19.1
  firebase_storage: ^10.3.1
  firebase_auth: ^3.4.1

main.dartにFirebaseのパッケージを導入し、初期化します。

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

Authの認証設定です。 今回はFirebase console上ですでに作成したアカウントに自動的にサインインします。

void signIn() async {
  try {
    /// credential にはアカウント情報が記録される
    final credential = await FirebaseAuth.instance.signInWithEmailAndPassword(
      email: id, // メールアドレス
      password: pass, // パスワード
    );
  }

  /// サインインに失敗した場合のエラー処理
  on FirebaseAuthException catch (e) {
    print(e.code);
  }
}

Cloud Storageから画像をダウンロードするコードを実装します。

/// 写真のデータ
Uint8List? imageData;

/// 画像のダウンロード
void downloadPic() async {
  try {
    /// 参照の作成
    String downloadName = 'Jul_07_1657594686';
    final storageRef = FirebaseStorage.instance.ref().child(downloadName);

    /// 画像をメモリに保存し、Uint8Listへ変換
    const oneMegabyte = 2048 * 2048;
    imageData = await storageRef.getData(oneMegabyte);
  } catch (e) {
    print(e);
  }
}

webでは動作しないので、androidまたはiosからテストします。

完成したコード

Raspberry Pi側のpythonコード

import cv2
import time
import datetime

import firebase_admin
from firebase_admin import credentials
from firebase_admin import storage

def main():
    # キャプチャの初期化
    cap = cv2.VideoCapture(0)

    # firebaseとstorage初期化
    cred = credentials.Certificate("serviceAccountKey.json")
    app = firebase_admin.initialize_app(cred, {'storageBucket': '***.appspot.com'} )
    bucket = storage.bucket()

    while True:
        # frameの読み込み
        ret, frame = cap.read()

        # 保存
        nowtime =datetime.datetime.now().strftime('%h_%m_%s')
        strfilename = 'nowtime.jpg'
        cv2.imwrite(strfilename, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90])

        # アップロードの名前を決定しアップロード
        blob=bucket.blob('nowtime.jpg')
        blob.upload_from_filename(strfilename)

        # 停止タイマー
        time.sleep(1)

        # qボタンを押すと再生停止
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

    # キャプチャ終了
    cap.release()

if __name__ == '__main__':
    main()

Flutter側コード

riverpodを使用しています。

import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  /// riverpod
  runApp(const ProviderScope(child: MyApp()));
}

/// 画像表示用Provider
final imageStateProvider = StateProvider<Uint8List?>((ref) => null);

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

/// ページ
class MyHomePage extends ConsumerStatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends ConsumerState<MyHomePage> {
  /// 認証情報
  String id = '***@example.com';
  String pass = '***';

  /// authのサインイン処理
  void signIn() async {
    try {
      /// credential にはアカウント情報が記録される
      final credential = await FirebaseAuth.instance.signInWithEmailAndPassword(
        email: id,
        password: pass,
      );
    }

    /// サインインに失敗した場合のエラー処理
    on FirebaseAuthException catch (e) {
      print(e.code);
    }
  }

  /// 画像のダウンロード
  void downloadPic() async {
    try {
      /// 参照の作成
      String downloadName = 'nowtime.jpg';
      final storageRef = FirebaseStorage.instance.ref().child(downloadName);

      /// 画像をメモリに保存し、Uint8Listへ変換
      const oneMegabyte = 1024 * 1024;

      /// 書込み
      ref.read(imageStateProvider.state).state =
          await storageRef.getData(oneMegabyte);
    } catch (e) {
      print(e);
    }
  }

  /// タイマー関数用
  Timer? timer;

  /// 再生中
  bool isPlaying = false;

  @override
  Widget build(BuildContext context) {
    /// authにサインイン
    signIn();

    var imageData = ref.watch(imageStateProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ホームカメラ'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(10),
        children: <Widget>[
          /// 再生中なら表示変更
          Center(
            child: isPlaying ? const Text('再生中') : const Text('停止'),
          ),

          /// ボタン
          Container(
            padding: const EdgeInsets.all(10),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                /// 再生開始ボタン
                Container(
                  padding: const EdgeInsets.all(10),
                  child: ElevatedButton(
                    style: ButtonStyle(
                      backgroundColor: MaterialStateProperty.all(Colors.green),
                      padding:
                          MaterialStateProperty.all(const EdgeInsets.all(20)),
                    ),
                    onPressed: () {
                      /// 再生中
                      isPlaying = true;

                      /// 以前のタイマーがあれば停止
                      timer != null ? timer!.cancel() : null;

                      /// 1回目のダウンロード
                      downloadPic();

                      /// 2回目以降のダウンロード
                      timer = Timer.periodic(
                        const Duration(seconds: 1),
                        (Timer t) {
                          downloadPic();
                        },
                      );
                    },
                    child: const Tooltip(
                      message: '再生開始',
                      child: Icon(Icons.play_arrow),
                    ),
                  ),
                ),

                /// 再生停止
                Container(
                  padding: const EdgeInsets.all(10),
                  child: ElevatedButton(
                    style: ButtonStyle(
                      backgroundColor: MaterialStateProperty.all(Colors.red),
                      padding:
                          MaterialStateProperty.all(const EdgeInsets.all(20)),
                    ),
                    onPressed: () {
                      /// 再生中OFF
                      isPlaying = false;

                      /// タイマー停止
                      timer!.cancel();
                    },
                    child: const Tooltip(
                      message: '再生停止',
                      child: Icon(Icons.pause),
                    ),
                  ),
                ),
              ],
            ),
          ),

          /// 画像の表示
          Container(
            alignment: Alignment.center,
            padding: const EdgeInsets.all(10),
            child: imageData == null
                ? const Text('No Image')
                : Image.memory(imageData),
          ),
        ],
      ),
    );
  }
}

ビルド&テスト

作成したpythonコードをラズパイへ転送し、実行します。 また、Flutterアプリは自分の所有しているモバイル端末にあわせてビルドします。

実際に動作させてみた結果はこちら。 (いい感じに撮影できなかったので、モザイク入れています)

課題

見切り発車で作ってみたはいいけど、いくつか課題が出てきたので、まとめます。

  • Cloud Storage for Firebaseの無料枠がすぐに無くなる

画像を1秒に約1枚アップロード&ダウンロードするため、1日あたりの通信量制限にすぐに引っかかります。

firebase.google.com

対策として、Firestoreでアップロードの制御を行う、PythonまたはCloud Functionでアップロード枚数の制限をかけるなどがありますが、根本的には解決しません。

Firebaseを使わず、ライブ配信アプリのように別サーバーを立てるのが無難でしょうか。 参考サイトはこちら。

zenn.dev

  • セキュリティ上の問題

PythonからCloud Storageに画像をアップロードしていますが、private keyを使ってadmin権限でアップロードしているため、個人的にはあまりいい方法ではないと考えています。

  • カメラが水平垂直方向に動かず、通話もできない

Amazonで市販品を見ていると、カメラに首振り機能があり、通話もできるようです。

今回の自作ホームカメラには、残念ながら、そのような機能を付けられませんでした。

正直、市販品を購入したい方がいいと思いました。

まとめ

実質数時間程度でホームカメラを製作できました。 まだまだ機能が少ないですが、作っているときは楽しかったので、今回はよしとしましょう。

参考

qiita.com

zenn.dev

Raspberry Pi x Flutter x Firebaseでホームカメラを製作(準備編)

はじめに

子どもの様子を見に行くための部屋の移動が面倒だったので、移動しなくてもいいように、余っていたラズパイとUSBカメラを使ってホームカメラを製作しました。 今回の内容はホームカメラ製作の準備編です。

システム概要

Raspberry PiPythonを実行し、USBカメラで画像を撮影します。 撮影された画像をadmin権限でCloud Storage for Firebaseにアップします。 アップされた画像をFlutterアプリで受け取ります。

環境

今回、各ツールのインストールは紹介しません。

項目 内容
Raspberry Pi 3 Model B+
USBカメラ Buffalo BSWHD06MBK
macbook air m1
Python 3.9.1
Flutter 3.0.4

Raspberry Piの初期設定

OSはRaspberry Pi OSが推奨されています。

www.raspberrypi.com

Raspberry Pi 3 以降は64bitのCPUなので、64bit版のOSを選択します。 なお、Raspberry Pi OSのwith desktopはGUI付のOSイメージ、LiteはCLIのみのOSイメージです。

今回はフォーマットもできる公式imagerを使用してインストールします。

www.raspberrypi.com

imagerをインストールした後、まずは既存のsdカードをフォーマットします。

完了したら、64bit版のRaspberry Pi OS with desktopをインストールします。

完了しました。

問題ないか実機で確認します。

ここでは、ssh接続やリモートデスクトップ接続(VNC Viewer)の導入についての説明は省略します。

USBカメラの動作確認

lsusbコマンドでUSBカメラが認識されているか確認します。

問題なければ、Rasberry Pi OSに標準で同梱されているVLC Playerを使ってカメラデバイスの映像を画面に映します。 使用したUSBカメラはBuffaloのBSWHD06MBKです。

www.buffalo.jp

VLCを起動し、メディア > キャプチャデバイスを開き、デバイス設定します。

映像が出たら確認完了です。

Firebaseの初期設定

今回はFirebaseを使用するので、Firebaseの設定をします。 ここではアカウントの作成がすでに終了している状態から設定開始します。

まず、Firebaseコンソールから新しくプロジェクトを作成します。

console.firebase.google.com

プロジェクトの設定 > サービスアカウントからAdmin SDKの新しい秘密鍵を生成。

ダウンロードした鍵を任意の場所に設置します。 このとき、鍵ファイルは公開リポジトリにアップしてはいけません。

次に、firebase_adminをインストール。

pip install firebase_admin

「構成スニペット」通りにpythonスクリプトに記述します。

import firebase_admin
from firebase_admin import credentials

cred = credentials.Certificate("serviceAccountKey.json")
firebase_admin.initialize_app(cred)

完了後、動作チェックし、エラーが出なければOKです。

続きは実践編にて紹介します。

参考

www.indoorcorgielec.com

Google Play and the Google Play logo are trademarks of Google LLC.