はじめに
本記事では、カメラ映像からリアルタイムで感情を認識するWebアプリを、
Flask・DeepFace・HTML/JavaScript・Dockerで構築します。
ブラウザでカメラを起動し、画像をFlask APIに送り、
DeepFaceで推定した感情を画面に表示するシンプルな構成です。
本記事ではWindows / macOS両対応です。
ローカル実行用です
この記事の構成はローカル環境(VSCode + Docker)での実行を想定しています。
Webサーバーを外部公開したり、クラウド環境で動かす場合はセキュリティ設定や
CORSなどの追加対応が必要になります。
この記事は以下のような方を対象にしています。
- FlaskでAPI実装に触れてみたい
- DeepFaceで感情認識を試してみたい
- Dockerで環境構築をしてみたい
この記事を書いたきっかけ
大学のゼミでDeepFaceを使った感情認識の課題に取り組もうとしていたところ、
メンバーそれぞれ開発環境が異なり、ライブラリのバージョンや依存関係で上手く動かないことが多く、
教員を含めて苦戦しました。
そこでDockerを使って環境を統一してみたのですが、
Docker内ではローカルカメラを直接扱えない問題がありました。
そのため、Flask + HTML / JavaScriptでブラウザからカメラ映像を取得して、
Flask APIへ送る形にした、というのがこの記事の始まりです。
せっかく整理したので、同じようなことで困ってる人の参考になればと思い、Qiitaにまとめました。
全体構成
最終的なフォルダ構成は以下のようになります。
emotion_demo/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── requirements.txt
├── app.py
├── backend/
│ ├── detector.py
│ └── image_decoder.py
├── templates/
│ └── index.html
└── static/
└── js/
└── script.js
1. Docker環境の準備
ここからは実際に開発環境を整えていきます。
今回、Docker DesktopまたはOrbStack上で動かすことを前提としています。
まだインストールされていない方は、以下の記事を参考にセットアップしておきましょう。
補足
Docker DesktopやOrbStackが起動していないと、
docker psなどのコマンドがエラーになります。
起動中はタスクバーにアイコンが表示されますので確認してみてください。
動作確認
ターミナルを開いて、Dockerが使えるか確認してみましょう。
バージョンが表示されれば準備完了です。
docker --version
表示されない場合は、上のリンク先で再確認してみてください。
作業ディレクトリの作成
次に、作業用フォルダを作ります。場所はどこでも構いません。
例として以下のように進めていきます。
mkdir emotion_demo
cd emotion_demo
VSCodeを使う方は、code .で開くと便利です。
ここからはすべてこのフォルダ内で作業します。
Dockerfileの作成
まずはDockerfileを作成します。
コード内にコメントをたくさん入れているので、あわせて読んでみてください。
# ベースイメージ
FROM python:3.9-slim
# Pythonの軽量化設定
# .pycファイルの生成抑制と標準出力などのバッファリングの無効化
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# コンテナ内の作業ディレクトリ
WORKDIR /app
# 依存関係をコピー
COPY requirements.txt .
# OpenCVで必要なOSライブラリをインストール
# 1. パッケージ一覧の最新化
# 2. OSライブラリをインストール、必要最低限だけ入れる(イメージを軽くするため)
# 3. OpenGLの共有ライブラリ(OpenCVの画像表示やデコード系で必要)
# 4. GLibのインストール(OpenCVのvideoioなどで要求されることがある)
# 5. aptの一時キャッシュを削除(イメージを軽くするため)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Pythonライブラリのインストール
RUN pip install --no-cache-dir -r requirements.txt
# ホスト側のソースコードをコンテナ側にコピー
COPY . .
# コンテナ起動時に実行されるコマンド
CMD ["python", "app.py"]
docker-compose.ymlの作成
次にdocker-compose.ymlを用意します。
複数コンテナを管理するときに使う設定ファイルですが、今回は単体アプリ用です。
services:
app:
# 現在のディレクトリのDockerfileを使ってイメージを作成
build: .
# ホスト側の5050番ポートをコンテナ側の5050番に接続(ポートフォワーディング)
ports:
- "5050:5050"
# ローカルの作業フォルダを/appにマウント(ファイル変更を即反映できる)
volumes:
- .:/app
補足
-
"5050:5050"はポートフォワーディングの設定です
左がホスト(PC)で、右がコンテナ内部のポート番号です。 - MacOSの方は5000番がAirPlayに使われるため、5050番をおすすめします
.dockerignoreの作成
Dockerイメージを軽くするため、不要なファイルを除外します。
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.vscode/
.git
node_modules/
requirements.txtの作成
最後に、Pythonで使うライブラリの一覧を用意します
requirements.txtというファイルを作成して編集していきます。
Dockerfileで使っていますね。
opencv-python
deepface
tf-keras
ここまでの4ファイルが揃っていれば、Dockerの準備は完了です。
おつかれさまでした。:
:
2. Flask APIの実装
ここからは、バックエンドの処理を作っていきます。
Flaskを使ってルーティングとAPIを実装し、ブラウザから送信された画像を処理できるようにします。
実際のコード内にコメントを多く入れているので、あわせて読んでみてください。
app.pyの記述
まずはプロジェクトのルートにapp.pyを作成します。
Flaskアプリの初期化、ルーティング、例外処理、ログ出力の設定をまとめています。
from flask import Flask, render_template, request, jsonify
from backend.image_decoder import decode_base64_image
from backend.detector import detect_emotion
import logging
# Loggerの設定
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger(__name__)
# Flaskアプリの初期化
app = Flask(__name__)
# HTMLを返すルート
@app.route("/")
def index():
return render_template("index.html")
# 感情認識を行うエンドポイント
@app.route("/api/predict", methods=["POST"])
def predict():
try:
# JSONのbodyを辞書型で受け取る
data = request.get_json()
# 辞書からimageを取り出す(Base64の文字列)
img_data = data.get("image")
# デコードを行う
img = decode_base64_image(img_data)
# 感情認識を行う
emotion = detect_emotion(img)
logger.info(f"検出結果: {emotion}")
# 結果を返す
return jsonify({"ok": True, "data": {"emotion": emotion}})
except ValueError as e:
logger.warning(f"入力データエラー: {e}")
return jsonify({"ok": False, "error": {"message": str(e)}}), 400
except Exception as e:
logger.exception("predict処理中のエラー")
return jsonify({"ok": False, "error": {"message": str(e)}}), 500
if __name__ == "__main__":
# 開発段階ではdebug=True
# 実際に運用する際にはdebug=Falseにすること
logger.info("Flaskサーバーを起動します...")
app.run(host="0.0.0.0", port=5050, debug=True)
コードのポイント
-
/: HTMLページを返すルート -
/api/predict: 感情認識を行うAPI(POSTでBase64の画像を受け取る) - `decode_base64_image(): Base64画像をNumPy配列に変換する関数
- detect_emotion(): DeepFaceを使って感情を推定する関数
余談ですが
predictは「予測する」、detectは「検出する」という意味です。
感情認識ではモデルが感情を予測しているので、predictのほうが自然ですね。
3. 画像デコード処理
ここでは、ブラウザから送られてくるBase64形式の画像データを
Pythonで扱える形に変換していきます。
Flaskアプリの中ではこの処理を通して、DeepFaceが読み込めるRGB画像を作ります。
ファイルの作成
backend/ディレクトリを作成し、その中にimage_decoder.pyを作成します。
この関数は「画像を受け取ってNumPy配列に変換する」だけの単機能です。
処理が失敗した場合にはValueErrorを発生させ、app.pyでまとめて扱います。
import base64
import cv2
import numpy as np
def decode_base64_image(img_data: str) -> np.ndarray:
"""
Base64形式の画像文字列をOpenCVで扱えるRGB画像(ndarray)に変換する関数
Args:
img_data (str): "data:image/jpeg;base64,..."形式の文字列
Returns:
np.ndarray: RGB形式の画像データ
Raises:
ValueError: 入力やデコードに失敗した場合
"""
# 入力のチェック
if not img_data or "," not in img_data:
raise ValueError("Invalid image data")
# ヘッダーとBase64データ部分を分割
header, encoded = img_data.split(",", 1)
# Base64文字列をバイト列にデコード
img_bytes = base64.b64decode(encoded)
# バイト列をNumPy配列に変換(uint8配列)
np_arr = np.frombuffer(img_bytes, np.uint8)
if np_arr.size == 0:
raise ValueError("Empty buffer")
# NumPy配列をOpenCV画像(BGR)としてデコード
img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("デコード失敗")
# OpenCV(BGR)をDeepFace用にRGBに変換
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
return img
コードのポイント
- Base64 -> バイト列 -> NumPy配列 -> 画像(RGB)の流れで処理しています
- OpenCVは内部的にBGR形式なので、最後に
cv2.cvtColor()でRGBに変換しています - この関数ではエラー処理をせず、上位で例外をまとめてキャッチする設計となっています
Base64とは、画像などのバイナリデータを
文字列(英数字+記号)で表現するためのエンコード方式です。
後ほど実装するJavaScript側では、canvas.toDataURL()によって
"data:image/jpeg;base64,..."という形式の文字列が生成されます。
この関数では、その...の部分に含まれるBase64データを
Pythonでデコードして画像として扱えるようにしています。
4. 感情認識処理
ここでは、DeepFaceを使って画像から感情を推定します。
前章にて作成したimage_decoder.pyで画像をRGB形式に変換しているので、
そのデータをDeepFaceに渡して感情を取得します。
ファイルの作成
backend/ディレクトリ内にdetector.pyを作成して、
以下のコードを記述します。
import numpy as np
from deepface import DeepFace
def detect_emotion(img: np.ndarray) -> str:
"""
DeepFaceを使って画像から感情を推定する関数
Args:
img (np.ndarray): RGB形式の画像データ
Returns:
str: 検出された感情(例: "happy", "sad", "-"など)
Raises:
ValueError: 結果が空や不正な場合
"""
# 感情認識
result = DeepFace.analyze(img, actions=["emotion"], enforce_detection=False)
# 出力結果のチェック
if not result or not isinstance(result, list) or len(result) == 0:
raise ValueError("感情を検出できませんでした")
# 最も確率の高い感情を取得
emotion = result[0].get("dominant_emotion")
if emotion is None:
raise ValueError("感情データが不正です")
return emotion
コードのポイント
-
DeepFace.analyze()の引数actions=["emotion"]により、感情だけを推定対象としています -
enforce_detection=False=にしておくことで、顔が検出されなくても例外を投げずに処理を続けられます - DeepFaceの出力はリスト形式なので、
result[0]から"dominant_emotion"を取り出しています - 感情が取得できない場合は
ValueErrorを発生させ、上位でハンドリングします
DeepFaceについて
DeepFaceが推定できる感情の種類は以下の通りです。
angry, disgust, fear, happy, sad, surprise, neutral
(参考: DeepFace GitHub)
この関数では、その中で最も確率の高い感情(dominant emotion)を返しています。
これでPython側の処理は完成です。:
:
次は、ブラウザ側からカメラ映像を送信して感情を受け取る
HTML/JavaScriptパートを実装していきます。
5. HTML/JSでカメラ映像を送信
ここからはフロントエンドの実装です。
ブラウザでカメラ映像を取得し、そのフレームをFlask APIへ送信します。
DeepFaceが返した感情をリアルタイムに画面へ表示する流れを作っていきます。
HTMLの作成
Flaskでは、HTMLテンプレートはtemplates/ディレクトリに配置します。
templates/index.htmlを作成して、以下の内容を記述してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>感情認識デモ</title>
<script type="module" src="{{ url_for('static', filename='js/script.js') }}"></script>
</head>
<body>
<h1>カメラで感情認識</h1>
<!-- カメラを表示するための場所 -->
<video id="camera" autoplay playsinline></video>
<!-- 一時的にフレームを保持するcanvas -->
<canvas id="canvas" style="display:none;"></canvas>
<!-- 結果の表示 -->
<p id="result">感情:-</p>
</body>
</html>
HTMLのJSの呼び出しについて
<script type="module" src="{{ url_for('static', filename='js/script.js') }}"></script>はFlask特有の書き方です。
{{ url_for('static', filename='...') }}を使うことで、
static/以下のファイルへのURLを自動的に生成してくれます。
ここではstatic/js/script.jsを読み込んでいます。
JavaScriptの記述
次に、static/js/script.jsを作成します。
カメラの初期化・画像の送信・結果の更新までを行います。
// 要素取得
const video = document.getElementById('camera');
const canvas = document.getElementById('canvas');
const result = document.getElementById('result');
// コンテキスト用意
const ctx = canvas.getContext('2d');
// 感情を保持
let currentEmotion = "neutral";
/**
* カメラ映像を初期化し<video>要素にストリームを表示する
*
* @async
* @function initCamera
* @throws {NotAllowedError} ユーザーがカメラ使用を拒否した場合
* @throws {NotFoundError} カメラデバイスが存在しない場合
* @throws {TypeError} HTTPSやローカルなど保護されたコンテキストでない場合
* @throws {NotReadableError} カメラが他のアプリで使用中の場合
* @see {@link // https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia}
*/
async function initCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true})
video.srcObject = stream;
} catch (e) {
if (e.name === "NotAllowedError") {
alert("カメラの使用が拒否されました。設定を確認してください。");
} else if (e.name === "NotFoundError") {
alert("カメラデバイスが見つかりません。");
} else if (e.name === "TypeError") {
alert("このページはHTTPSなど保護されたコンテキスト内で読み込んでください。");
} else if (e.name === "NotReadableError") {
alert("カメラが他のアプリで使用中です。");
} else {
console.error("予期せぬエラー: ", e);
}
}
}
/**
* 現在のカメラフレームをBase64に変換し、Flask APIへ送信する
*
* @async
* @function sendFrame
* @returns {Promise<void>}
* @see {@link https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/drawImage}
* @see {@link https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toDataURL}
*/
async function sendFrame() {
// フレームを<canvas>に描画
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
// Base64画像に変換
const imageData = canvas.toDataURL('image/jpeg');
// Flask APIに送信
const res = await fetch('/api/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData })
});
// 応答を処理
const json_data = await res.json();
if (!json_data.ok) {
console.error(json_data.error.message);
return;
}
currentEmotion = json_data.data.emotion;
result.textContent = `感情:${currentEmotion}`;
}
/**
* 一定間隔でフレームをサーバーに送信し、感情を更新する
*
* @function startEmotionRecognitionLoop
* @see https://developer.mozilla.org/ja/docs/Web/API/Window/setInterval
*/
function startEmotionRecognitionLoop() {
setInterval(sendFrame, 3000);
}
// 初期化処理
initCamera().then(startEmotionRecognitionLoop);
コードのポイント
-
navigator.mediaDevices.getUserMedia()を使ってカメラを起動しています -
canvas.toDataURL()で現在のフレームをBase64文字列として取得し、FlaskのAPIへPOSTしています -
setInterval()で一定間隔(ここでは3秒)ごとにフレームを送信しています
コメント
この構成では、サーバー側(Flask)は毎回新しいフレームを受け取り、
その度DeepFaceで感情を推定しています。
リアルタイムにより近い挙動を確認するには、
間隔を短くする(例: 1000ms)ことで反応を速くできますが、
その分サーバー負荷も上がる点に注意してください。
Tips
DeepFace.analyze()の実行時間をtimeモジュールで計測したところ、
CPU (Intel Core i7-14700F)環境でおよそ0.2秒前後でした。
CPU/GPUや環境によって差がありますが、
1フレームあたり0.2秒程度なら、3秒ごとの推定ループでも十分リアルタイム性を保てます。
次は、これまで作ったすべてのファイルを組み合わせて
Dockerでアプリを起動し、実際に動作確認を行っていきましょう。![]()
6. 起動と動作確認
ここまでで、アプリのすべての構成要素がそろいました。
まずは、最終的なフォルダ構成を確認しましょう
emotion_demo/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── requirements.txt
├── app.py
├── backend/
│ ├── detector.py
│ └── image_decoder.py
├── templates/
│ └── index.html
└── static/
└── js/
└── script.js
この構成になっていれば、Flaskがtemplatesとstaticを自動で認識してくれます。
次にDockerを使ってアプリを起動してみましょう。
コンテナの起動
プロジェクトのルートで以下を実行します。
docker compose up
初回実行時は自動的にイメージがビルドされます。
ビルド完了後、以下のようなログが表示されれば正常に起動しています。
app-1 | * Running on all addresses (0.0.0.0)
app-1 | * Running on http://127.0.0.1:5050
app-1 | * Running on http://172.24.0.2:5050
動作確認
ブラウザで以下にアクセスしてください。
http://localhost:5050
ページが表示され、カメラアクセスの許可を求められたら「許可」を選択します。
カメラ映像が表示され、数秒ごとに「感情:happy」などが更新されれば成功です。
docker-compose.ymlのports: "5050:5050"はポートフォワーディング設定で、
ホスト(左側) -> コンテナ(右側)に通信を転送しています。
この設定により、ブラウザからlocalhost:5050でアクセスできます。
コンテナの停止
アプリの実行を止めるときは、ターミナルでCtrl + Cを押します。
開発中にはこの方法で十分です。コンテナは停止状態で残ります。
不要になった場合、以下でコンテナとネットワークを削除できます。
docker compose down
downはコンテナやネットワークを削除してクリーンな状態に戻すコマンドです。
一方で、毎回再ビルドが発生するため、開発中はCtrl + C、仕上げ時はdownという使い分けが便利だと思います。
7. おわりに
お疲れ様でした。
![]()
ここまで、Flask・DeepFace・Dockerを使った感情認識Webアプリを構築できたと思います。
技術的なポイントとしては
- ブラウザからカメラ映像を取得して送信するJavaScriptの流れ
- FlaskによるAPI実装と画像デコード処理
- DeepFaceを使った感情推定の基本構成
- Dockerでの環境構築と実行手順
この4点を一通り体験できました。
今回の構成はあくまでローカル開発用の最小例です。
みなさんも、機能を追加したりUIを工夫したりして、
自分なりにアプリを発展させて見ると面白いと思います。
もしこの記事が役に立ったと思った方は、
いいねやコメントで感想をもらえると励みになります。
質問・指摘も歓迎です。
読んでくださってありがとうございました。