🐙

ラズパイ5とTypeScriptで作る監視カメラアプリ

に公開

ラズパイ5 + TypeScript で監視カメラアプリを作ってみた

はじめに

どうもこんにちは。
今回はラズパイ5とTypeScriptで定番の監視カメラアプリを作成しました。

なぜPythonで作成しないのかって?もちろん半分ネタです。

今回はTypeScriptのお勉強も兼ねてます。
私は最近TypeScriptのお勉強を始めたので
本記事を読まれる方はその辺を念頭に入れておいてください。

もしかすると情報不足であったり、間違いがあるかもしれないのでお気軽にコメントをいただけると嬉しいです!

完成品イメージ

こんな感じです

使用機器

機器はこんな感じです

今回はMacからラズパイ5にssh接続をして作業を進めていきます。

前提条件

  • ラズパイ5セットアップ済み(nodeとかは後で入れていきます)
  • ssh接続設定済み

ラズパイで作業する時は以下のような感じでMacからssh接続

ssh your_name_XXX@192.168.XX.XXX
# ユーザー名とIPアドレスを設定してください
# 私の場合は
# ssh tanaka@192.168.10.111 みたいな感じで接続しました
# 接続する時にはパスワードを求められるので入力してください

動作環境

Mac

sw_vers
# 以下が出力
# ProductName:      macOS
# ProductVersion:   15.4.1

ラズパイ5

cat /etc/os-release
# PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"

USBカメラ

  • ELP

バージョン情報

Node.js と npm

node -v
# v18.19.0
npm -v
# 9.2.0

今回はssh接続とVSCodeの拡張機能である「Remote - SSH」で操作しています。

余談
ラズパイ5の動作が軽快で快適です。VSCodeも普通に動作します(さすがメモリ8GB!)

使用技術スタック

バックエンド

  • Node.js + TypeScript: サーバーサイドの基盤
  • Express: Webサーバーフレームワーク
  • Socket.io: リアルタイム通信
  • FFmpeg: 映像ストリーミング処理

フロントエンド

  • React + TypeScript: UIフレームワーク
  • Vite: 高速ビルドツール
  • Socket.io Client: リアルタイム通信クライアント

参考
Socket.io Clientの詳細は こちら

プロジェクト構成

camera-app/
├── backend/
│   ├── src/
│   │   └── server.ts          # メインサーバー
│   ├── package.json
│   └── tsconfig.json
├── frontend/
│   ├── src/
│   │   ├── App.tsx            # メインアプリ
│   │   └── components/
│   │       └── CameraStream.tsx # カメラコンポーネント
│   ├── package.json
│   └── vite.config.ts
└── README.md

監視カメラアプリ作り開始

前置きが長くなりましたが作業をしていきます。

1. 全体的な設定

ssh tanaka@192.168.10.111 # IPとユーザー名は例です

アップデートとかを先にしておきます

sudo apt update
sudo apt upgrade

Node.js と npm をインストール

sudo apt install nodejs npm

⚠️ 注意
aptからnodeを入れるとバージョンが古いものが入るという情報も見たので気になる方は別の方法でお試しください

USBカメラを認識しているか確認

lsusb
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 003 Device 002: ID 1ea7:0066 SHARKOON Technologies GmbH [Mediatrack Edge Mini Keyboard]
Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 001 Device 005: ID 32e4:9530 HD USB Camera HD USB Camera # <- これっぽい

(自分はよく指し忘れます…笑)

FFmpeg も入れます

sudo apt install ffmpeg

カメラとか良い感じに制御してくれると思います。
📖 FFmpeg公式サイト

2. プロジェクト作成

mkdir camera-app
cd camera-app
mkdir backend frontend

バックエンド開発

1. 初期設定

cd backend
npm init -y

backend直下にpackage.jsonができたと思うので、それを早速書き換えます
(余計なもの入っていたらすみません)

{
  "name": "camera-backend",
  "version": "1.0.0",
  "scripts": {
    "dev": "ts-node-dev --respawn src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "socket.io": "^4.7.4",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.0",
    "@types/cors": "^2.8.17",
    "typescript": "^5.3.2",
    "ts-node-dev": "^2.0.0"
  }
}

package.jsonを書き換えたのでインストールしていきます

npm i

node_modules や package-lock.json ができればインストール完了です。

2. TypeScript設定

tsc --init

tsconfig.json ができればOK。これも書き換えます。

backend/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

3. サーバープログラム作成

backendディレクトリで以下を実行

mkdir src && cd src
touch server.ts

server.tsを以下を記述

bacnend/src/server.ts
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import cors from "cors";
import { spawn } from "child_process";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: ["http://localhost:5173", "http://192.168.10.114:5173"],
    methods: ["GET", "POST"],
    credentials: true,
  },
});

app.use(
  cors({
    origin: ["http://localhost:5173", "http://192.168.10.114:5173"],
    credentials: true,
  })
);
app.use(express.json());

// カメラストリーミング用のFFmpegプロセス
let ffmpegProcess: any = null;

// カメラストリーミング開始
const startCameraStream = () => {
  if (ffmpegProcess) {
    ffmpegProcess.kill();
  }

  // ELP USBカメラからのストリーミング設定
  ffmpegProcess = spawn("ffmpeg", [
    "-f",
    "v4l2",
    "-i",
    "/dev/video0", // USBカメラデバイス
    "-vf",
    "scale=640:480", // 解像度調整
    "-r",
    "15", // フレームレート
    "-f",
    "mjpeg",
    "-q:v",
    "5", // 品質設定
    "pipe:1",
  ]);

  ffmpegProcess.stdout.on("data", (data: Buffer) => {
    // 映像データをクライアントに送信
    io.emit("frame", data.toString("base64"));
  });

  ffmpegProcess.stderr.on("data", (data: Buffer) => {
    console.log("FFmpeg stderr:", data.toString());
  });

  ffmpegProcess.on("close", (code: number) => {
    console.log(`FFmpeg process closed with code ${code}`);
  });
};

// Socket.io接続処理
io.on("connection", (socket) => {
  console.log("Client connected:", socket.id);

  // クライアント接続時にストリーミング開始
  if (!ffmpegProcess) {
    startCameraStream();
  }

  socket.on("disconnect", () => {
    console.log("Client disconnected:", socket.id);

    // 全クライアントが切断された場合はストリーミング停止
    if (io.engine.clientsCount === 0 && ffmpegProcess) {
      ffmpegProcess.kill();
      ffmpegProcess = null;
    }
  });
});

// ヘルスチェック用エンドポイント
app.get("/health", (req, res) => {
  res.json({ status: "ok", camera: ffmpegProcess ? "running" : "stopped" });
});

const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

// プロセス終了時のクリーンアップ
process.on("SIGINT", () => {
  if (ffmpegProcess) {
    ffmpegProcess.kill();
  }
  process.exit();
});

4. サーバー動作確認

一旦サーバーを動かしてみましょう!

npm run build
npm start

以下のようなログが出れば一旦OKです。

> camera-backend@1.0.0 start
> node dist/server.js

Server running on port 3001

5. (補足)バックエンド実装のポイント

FFmpegによる映像処理

const startCameraStream = () => {
  ffmpegProcess = spawn("ffmpeg", [
    "-f", "v4l2",
    "-i", "/dev/video0",           // USBカメラを指定
    "-vf", "scale=640:480",       // 解像度調整
    "-r", "15",                   // フレームレート
    "-f", "mjpeg",
    "pipe:1"
  ]);
  
  ffmpegProcess.stdout.on("data", (data: Buffer) => {
    io.emit("frame", data.toString("base64"));  // Base64でクライアントに送信
  });
};

ポイント

  • FFmpegでUSBカメラ(/dev/video0)から映像を取得
  • MJPEG形式で圧縮してリアルタイム配信に最適化
  • Socket.ioで全クライアントに映像フレームをブロードキャスト

最初は映像がカックカクで非常に苦労しました…笑

Socket.io接続管理

io.on("connection", (socket) => {
  if (!ffmpegProcess) {
    startCameraStream();  // 初回接続時のみストリーミング開始
  }
  
  socket.on("disconnect", () => {
    if (io.engine.clientsCount === 0 && ffmpegProcess) {
      ffmpegProcess.kill();  // 全クライアント切断時に停止
    }
  });
});

効率化のポイント

  • クライアント数に応じた自動開始/停止
  • リソースの無駄遣いを防ぐ

フロントエンド開発

1. プロジェクト移動・初期設定

さっきのnpm startを実行したターミナルは開いたままにしてください。
backend の src ディレクトリにいると思うので frontend まで移動。

cd ../..
cd frontend

以下のコマンドで React を設定します。

npm create vite@latest

設定項目

  • プロジェクト名: 「.」を入力
  • フレームワーク: React を選択
  • variant: TypeScript

以下のログを参考ください

tanaka@raspberrypi:~/dev/cam-app-zenn/frontend $ npm create vite@latest
│
◇  Project name:
│  .
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Scaffolding project in /home/tanaka/dev/cam-app-zenn/frontend...
│
└  Done. Now run:

  npm install
  npm run dev

ログにあるように以下を実行して起動するか確認してみてください。
(リモート接続元からはアクセスできません。後で設定します)

Vite が動いていたら以下のようなログが出ます

  VITE v6.3.5  ready in 366 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

2. リモート接続対応

リモート接続元から接続できるようにしていきます。デフォルトでは Vite はローカルからの接続をリッスンするようにしているので以下のようにします。

⚠️ セキュリティ注意
公共のWi-Fiなどを利用している場合は0.0.0.0にしないように…

// frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: '0.0.0.0',
    port: 5173
  }
})

ラズパイのファイアウォールも開放します

sudo ufw allow 5173/tcp

リモート接続元のブラウザから「192.168.10.111:5173」とアクセスしてみてください。
IPは自分の端末のIPに書き換えるのをお忘れなく!

いつものVite画面が表示されればOKです。

3. 必要パッケージのインストール

今度はfrontendディレクトリのpackage.jsonを編集

frontend/package.json
{
  "name": "camera-frontend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "socket.io-client": "^4.8.1"
  },
  "devDependencies": {
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "@vitejs/plugin-react": "^4.2.1",
    "typescript": "^5.2.2",
    "vite": "^5.0.8"
  }
}

package.jsonに書いてあるものをインストールします。

npm i

4. メインアプリ作成

src直下にある App.tsx を以下のように書き換えます

src/App.tsx
import React from 'react';
import CameraStream from './components/CameraStream';

const App: React.FC = () => {
  return (
    <div style={{ 
      padding: '20px', 
      fontFamily: 'Arial, sans-serif',
      backgroundColor: '#f0f0f0',
      minHeight: '100vh'
    }}>
      <h1 style={{ 
        textAlign: 'center', 
        color: '#333',
        marginBottom: '30px'
      }}>
        ラズパイ5 監視カメラ
      </h1>
      <CameraStream />
    </div>
  );
};

export default App;

5. カメラコンポーネント作成

cd src
mkdir components && cd components
touch CameraStream.tsx
src/components/CameraStream.tsx
import React, { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";

const CameraStream: React.FC = () => {
  const imgRef = useRef<HTMLImageElement>(null);
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [error, setError] = useState<string>("");

  useEffect(() => {
    // 現在のホストのIPアドレスを取得してバックエンドのURLを動的に生成
    const hostname = window.location.hostname;
    const backendUrl = `http://${hostname}:3001`;

    console.log("Connecting to:", backendUrl);

    // サーバーに接続
    const newSocket = io(backendUrl, {
      transports: ["polling", "websocket"],
    });
    setSocket(newSocket);

    newSocket.on("connect", () => {
      console.log("Connected to server");
      setIsConnected(true);
      setError("");
    });

    newSocket.on("disconnect", () => {
      console.log("Disconnected from server");
      setIsConnected(false);
    });

    newSocket.on("frame", (frameData: string) => {
      if (imgRef.current) {
        imgRef.current.src = `data:image/jpeg;base64,${frameData}`;
      }
    });

    newSocket.on("connect_error", (err) => {
      console.error("Connection error:", err);
      setError("サーバーに接続できません");
      setIsConnected(false);
    });

    return () => {
      newSocket.close();
    };
  }, []);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: "20px",
      }}
    >
      <div
        style={{
          padding: "10px",
          borderRadius: "5px",
          backgroundColor: isConnected ? "#d4edda" : "#f8d7da",
          color: isConnected ? "#155724" : "#721c24",
          border: `1px solid ${isConnected ? "#c3e6cb" : "#f5c6cb"}`,
        }}
      >
        状態: {isConnected ? "接続中" : "切断中"}
        {error && ` - ${error}`}
      </div>

      <div
        style={{
          border: "2px solid #333",
          borderRadius: "10px",
          padding: "10px",
          backgroundColor: "#000",
          boxShadow: "0 4px 8px rgba(0,0,0,0.3)",
        }}
      >
        <img
          ref={imgRef}
          alt="カメラ映像"
          style={{
            width: "640px",
            height: "480px",
            display: "block",
            backgroundColor: "#000",
          }}
          onError={() => setError("映像の表示に失敗しました")}
        />
      </div>

      <div
        style={{
          fontSize: "14px",
          color: "#666",
          textAlign: "center",
        }}
      >
        ELP USBカメラからのライブ映像
      </div>
    </div>
  );
};

export default CameraStream;

ではfrontendディレクトリに戻って

npm run dev

フロントエンド側にアクセスして映像が表示されていれば完了です!

(補足)フロントエンドの実装ポイント

動的接続とリアルタイム映像表示

CameraStream.tsx
useEffect(() => {
  const backendUrl = `http://${window.location.hostname}:3001`;  // 動的URL生成
  const newSocket = io(backendUrl);
  
  newSocket.on('frame', (frameData: string) => {
    if (imgRef.current) {
      imgRef.current.src = `data:image/jpeg;base64,${frameData}`;  // 映像更新
    }
  });
  
  return () => newSocket.close();  // クリーンアップ
}, []);

実装ポイント

  • 「window.location.hostname」でIPアドレスのハードコーディングを回避
    • ラズパイのIPを固定すればハードコーディングでも可能っちゃ可能
  • 「useRef」でDOM直接操作によるリアルタイム映像更新
  • Base64データをData URLとして画像要素に設定

流れイメージ

  1. FFmpeg → USBカメラから映像取得
  2. Node.js → MJPEG圧縮 → Base64エンコード
  3. Socket.io → リアルタイム配信
  4. React → DOM直接更新で映像表示

まとめ

ラズパイ5とTypeScriptを使って監視カメラアプリを作成しました。
Socket.ioを使ったリアルタイム通信とFFmpegを使った映像ストリーミングの理解が深まったかと思います!

TypeScriptでの開発も楽しく、勉強になりました!

念の為コード全体も貼っておきます。

https://github.com/kassa697/raspi5-cam-app/tree/main

Discussion