ラズパイ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。これも書き換えます。
{
"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を以下を記述
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を編集
{
"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 を以下のように書き換えます
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
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
フロントエンド側にアクセスして映像が表示されていれば完了です!
(補足)フロントエンドの実装ポイント
動的接続とリアルタイム映像表示
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として画像要素に設定
流れイメージ
- FFmpeg → USBカメラから映像取得
- Node.js → MJPEG圧縮 → Base64エンコード
- Socket.io → リアルタイム配信
- React → DOM直接更新で映像表示
まとめ
ラズパイ5とTypeScriptを使って監視カメラアプリを作成しました。
Socket.ioを使ったリアルタイム通信とFFmpegを使った映像ストリーミングの理解が深まったかと思います!
TypeScriptでの開発も楽しく、勉強になりました!
念の為コード全体も貼っておきます。
Discussion