簡易「顔認証」Webアプリを作ってみた

現場や受付、入退室の簡易確認などでは、「あらかじめ登録したこの人物かどうか」をカメラ映像からその場で判定したい場面があります。そこで今回も、「エレクトロニクスを簡単に」をテーマに対象人物の顔写真を1枚アップロードして登録し、その後はUSBカメラ映像を見ながら、同一人物なら○、別人なら×を表示するWebアプリを、できるだけ短いプログラムで試作してみました。

一般に「顔認識」と呼ばれますが、今回の構成は大規模な人物検索ではなく、登録済み1人との1:1顔照合 (face verification)に絞っています。この割り切りによって、PoC として扱いやすく、コードも短く保ちやすくなります。

今回使うのは FlaskOpenCVです。顔検出にはYuNet、顔の特徴比較にはSFaceを使います。OpenCV 4.5.0 以降は Apache 2.0 ライセンスで提供され、OpenCV Zoo は Apache 2.0、さらにSFace ディレクトリは Apache 2.0、YuNet ディレクトリは MIT License で公開されています。OpenCV Zoo側も、モデルごとにライセンス確認が必要であることを明記しています。
OpenCV License
OpenCV Zoo
SFace
YuNet

目次

はじめに

今回のアプリは、最初に対象人物の顔写真を1枚アップロードして登録し、その後はUSBカメラのライブ映像をWeb画面に表示しながら、映っている顔がその人物なら、違えば×を重ねて表示する仕組みです。

構成としてはとてもシンプルで、

  • 登録用の顔写真をアップロードする

  • USBカメラ映像を読み込む

  • 映像中の顔を検出する

  • 登録顔とカメラ映像の顔を照合する

  • Web画面上に○/×を表示する

という流れだけに絞っています。

YuNet SFace を選んだ理由

顔検出には YuNet、顔照合にはSFaceを使います。OpenCV ZooではYuNet は軽量で高速な顔検出モデル、SFace は顔認識用モデルとして公開されています。SFace のページには、MobileFaceNet 系のモデルをSFace loss で学習したONNX モデルであることも記載されています。WebアプリのPoCとしては、軽量な顔検出+顔特徴比較の組み合わせが扱いやすく、依存関係も増やしすぎずに済みます。
YuNet
SFace

また OpenCV には、顔検出用の FaceDetectorYN と顔照合用の FaceRecognizerSF が用意されており、detect()alignCrop()feature()match() という流れで比較的素直に実装できます。
OpenCV FaceDetectorYN
OpenCV FaceRecognizerSF

Webアプリの基本構成

今回は Flask ベースのWebアプリとして実装し、ブラウザにはMJPEG ストリームとしてUSBカメラ映像を表示します。Flask では generator を使ったストリーミングレスポンスのパターンが公式ドキュメントで説明されており、継続的にデータを返す構成を作れます。
Flask Streaming

カメラ入力は OpenCVの VideoCapture を使います。OpenCVのドキュメントでも、VideoCaptureカメラ・動画ファイル・画像列から映像を取得するためのクラスとして説明されており、カメラデバイスを開いてフレームを読み出す使い方が示されています。
OpenCV Documentation

今回の処理フローは以下の通りです。

  1. 登録用の顔写真をアップロード

  2. その画像から顔特徴量を抽出してメモリ上に保持

  3. USBカメラを起動

  4. カメラ映像の各フレームから顔を検出

  5. 顔を整列して特徴量を抽出

  6. 登録済み特徴量と比較

  7. 一致なら、不一致なら×を映像上に表示

ディレクトリ構成

text

face-ox-camera-app/
├── app.py
├── requirements.txt
└── models/
    ├── face_dinaetection_yunet_2023mar.onnx
    └── face_recognition_sface_2021dec.onnx

サンプルコード

今回はできるだけ短くするために、HTML も Python 側へ埋め込んだ1ファイル構成にしています。 最初に登録画像をアップロードすると、その後のUSBカメラ映像に○/×が重ねて表示されます。
※本サンプルコードは生成AIを用いて生成、実装しています。

main.py

mimport threading
import traceback
import cv2
import numpy as np
from flask import Flask, Response, render_template_string, request
from werkzeug.exceptions import RequestEntityTooLarge

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024  # 5MB

OPENCV_MAJOR = int(cv2.__version__.split(".")[0])
if OPENCV_MAJOR >= 5:
    DETECTOR_MODEL = "models/face_detection_yunet_2026may.onnx"
else:
    DETECTOR_MODEL = "models/face_detection_yunet_2023mar.onnx"

RECOGNIZER_MODEL = "models/face_recognition_sface_2021dec.onnx"
THRESHOLD = 0.363

camera = None
camera_lock = threading.Lock()
registered_feature = None
feature_lock = threading.Lock()

HTML = """
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>USBカメラ顔照合アプリ</title>
<style>
body { font-family: system-ui, sans-serif; background: #f6f7fb; margin:0; padding:24px; color:#222; }
.wrap { max-width: 900px; margin:auto; background:#fff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
h1 { margin-top:0; }
.card { margin:16px 0; padding: 16px; border:1px solid #e5e7eb; border-radius: 12px; background:#fafafa; }
input[type=file] { display:block; margin-top:8px; }
button { background: #2563eb; color:#fff; border:0; border-radius: 10px; padding:12px 18px; font-size:1rem; cursor:pointer; }
img { width:100%; max-width: 720px; border-radius: 12px; border:1px solid #ddd; display:block; }
.msg { font-weight:700; margin-top:8px; }
.err { color:#dc2626; }
.ok { color:#16a34a; }
</style>
</head>
<body>
<div class="wrap">
<h1>USBカメラ顔照合アプリ</h1>
<p>OpenCV version: {{ version }}</p>
<form method="post" enctype="multipart/form-data" class="card">
<label>登録用の顔写真
<input type="file" name="target" accept="image/*" required>
</label>
<br>
<button type="submit">この人物を登録する</button>
</form>
<p class="msg {{ 'err' if is_error else 'ok' }}">{{ message }}</p>
<div class="card">
<h3>USBカメラ映像</h3>
<img src="/video_feed" alt="camera stream">
</div>
</div>
</body>
</html>
"""

def create_detector():
    return cv2.FaceDetectorYN.create(
        DETECTOR_MODEL, "", (320, 320), 0.6, 0.3, 5000
    )

def create_recognizer():
    return cv2.FaceRecognizerSF.create(RECOGNIZER_MODEL, "")

def get_camera():
    global camera
    with camera_lock:
        if camera is None or not camera.isOpened():
            camera = cv2.VideoCapture(0)
            camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
            camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
    return camera

def decode_image(file_storage):
    data = np.frombuffer(file_storage.read(), np.uint8)
    if data.size == 0:
        return None
    return cv2.imdecode(data, cv2.IMREAD_COLOR)

def detect_largest_face(image, detector):
    h, w = image.shape[:2]
    detector.setInputSize((w, h))
    faces = detector.detect(image)
    if faces is None or len(faces) == 0:
        return None
    return max(faces[1], key=lambda f: float(f[2] * f[3]))

def extract_feature(image, detector, recognizer):
    face = detect_largest_face(image, detector)
    if face is None:
        return None, None
    aligned = recognizer.alignCrop(image, face)
    feature = recognizer.feature(aligned)
    return feature, face

def draw_header(frame, text, color):
    cv2.putText(
        frame, text, (20, 50),
        cv2.FONT_HERSHEY_SIMPLEX, 1.2, color, 3, cv2.LINE_AA
    )

def gen_frames():
    detector = create_detector()
    recognizer = create_recognizer()
    cap = get_camera()
    while True:
        ok, frame = cap.read()
        if not ok or frame is None:
            continue
        try:
            with feature_lock:
                reg = None if registered_feature is None else registered_feature.copy()
            
            feature, face = extract_feature(frame, detector, recognizer)
            
            if reg is None:
                draw_header(frame, "NO TARGET", (128, 128, 128))
            elif feature is None:
                draw_header(frame, "NO FACE", (0, 165, 255))
            else:
                score = float(
                    recognizer.match(reg, feature, cv2.FaceRecognizerSF_FR_COSINE)
                )
                same = score > THRESHOLD
                x, y, w, h = map(int, face[:4])
                color = (0, 200, 0) if same else (0, 0, 255)
                big = "O" if same else "X"
                label = f"{big} score={score:.3f}"
                cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2)
                cv2.putText(
                    frame, label, (x, max(30, y - 10)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2, cv2.LINE_AA
                )
                draw_header(frame, big, color)
        except Exception:
            traceback.print_exc()
            draw_header(frame, "ERROR", (0, 0, 255))
            
        ret, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
        if not ret:
            continue
        yield (
            b"--frame\r\n"
            b"Content-Type: image/jpeg\r\n\r\n" +
            buffer.tobytes() +
            b"\r\n"
        )

@app.errorhandler(RequestEntityTooLarge)
def handle_large_file(_e):
    return render_template_string(
        HTML,
        message="画像サイズが大きすぎます。5MB以下にしてください。",
        is_error=True,
        version=cv2.__version__
    ), 413

@app.route("/", methods=["GET", "POST"])
def index():
    global registered_feature
    message = "まだ人物は登録されていません"
    is_error = False
    if request.method == "POST":
        try:
            file = request.files.get("target")
            if not file or file.filename == "":
                message = "画像を選択してください"
                is_error = True
            else:
                img = decode_image(file)
                if img is None:
                    message = "画像の読み込みに失敗しました"
                    is_error = True
                else:
                    detector = create_detector()
                    recognizer = create_recognizer()
                    feature, _ = extract_feature(img, detector, recognizer)
                    if feature is None:
                        message = "登録画像から顔を検出できませんでした"
                        is_error = True
                    else:
                        with feature_lock:
                            registered_feature = feature.copy()
                        message = "人物を登録しました。USBカメラ映像で判定を開始します"
        except Exception:
            traceback.print_exc()
            message = "内部エラーが発生しました。ターミナルのログを確認してください。"
            is_error = True
            
    return render_template_string(
        HTML,
        message=message,
        is_error=is_error,
        version=cv2.__version__
    )

@app.route("/video_feed")
def video_feed():
    return Response(
        gen_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame"
    )

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, threaded=True, debug=False)
requirements.txt

Flask>=3.1
numpy>=2.0
opencv-python-headless>=4.10

セットアップ手順

まずは仮想環境を作り、依存ライブラリを入れます。その後、OpenCV Zoo から YuNet と SFace の ONNX モデルを取得します。
OpenCV Zoo
YuNet
SFace

bash

mkdir face-ox-camera-app
cd face-ox-camera-app
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip setuptools wheel
pip install -r requirements.txt

mkdir -p models
curl -Lo models/face_detection_yunet_2023mar.onnx \
  https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx
curl -Lo models/face_recognition_sface_2021dec.onnx \
  https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx

app.pyを保存したら、以下で起動します。

bash

python app.py

起動後、ブラウザで以下へアクセスします。

http://127.0.0.1:5000

使い方

操作は以下の流れです。

  1. Web画面を開く

  2. 登録したい人物の顔写真を1枚アップロードする

  3. 「この人物を登録する」を押す

  4. USBカメラ映像に対して、自動で○/×判定が始まる

  5. 同一人物と判定されれば○、そうでなければ×が表示される

このサンプルでは、フレーム内で最も大きい顔1つだけを判定対象にしています。複数人対応や顔一覧検索は、あえて外して短いコードにしています。

今回はフリー画像のモデルの方の顔を登録してみました。

同じモデルの方の別の写真を写すと、正しく認識されました。

別のモデルの方を写すと、別人として検出されます。

人の顔が映っていない場合はNO FACEとして検出されます。

実装のポイント

今回の実装は、顔照合の流れをなるべくストレートに書いています。まず FaceDetectorYN でフレームから顔を検出し、FaceRecognizerSFalignCrop() で顔を整列、feature() で特徴量を抽出し、match() で登録済み人物との類似度を計算します。OpenCVのAPIがこの一連の処理を比較的きれいに分けてくれているため、短いPoCに向いています。
OpenCV FaceDetectorYN
OpenCV FaceRecognizerSF

また、ライブ映像のブラウザ表示は Flask のストリーミングレスポンスで実現しています。Flask 公式ドキュメントでは generator で逐次データを返す方法が説明されており、この方式を使うとMJPEGのような単純なライブ表示を作りやすいです。
Flask Streaming

カメラ入力には OpenCVの VideoCapture を使っています。これはカメラデバイスを開いてフレームを読むための標準的なクラスで、USBカメラをPoCで扱うには十分シンプルです。
OpenCV Documentation

注意点

このサンプルは「1人を登録して、その人かどうかを見る」という最小構成です。そのため、実運用を考えるといくつか注意点があります。

まず、顔照合の精度は撮影条件に影響されます。逆光、横顔、マスク、ブレ、解像度不足、フレーム内の顔が小さすぎるケースでは、同一人物でもスコアが下がることがあります。そのため、THRESHOLD は固定値で決め打ちせず、実際の利用環境で調整したほうが安全です。

また、USBカメラ映像を扱う以上、処理速度やカメラ解像度とのバランスも重要です。PoCの段階では十分でも、複数人対応や高解像度化を進めると負荷が増えます。

さらに、ライセンス上使いやすいモデルを採用していても、顔情報を扱う運用面の配慮は別途必要です。本人同意、保存期間、誰がアクセスできるか、登録顔写真の削除方法などは、業務導入時に整理しておきたいところです。

今後の改善案

今回のサンプルは最小構成なので、今後は以下のような拡張が考えられます。

  • 登録画像を1枚ではなく複数枚にする
    1人につき複数の顔画像を使うと、角度や表情差に強くしやすくなります。

  • 複数人の登録に対応する
    1人だけでなく、名前付きで複数人物を保持して最も近い人物名を出す構成にも広げられます。

  • ○/×だけでなく「未判定」も明示する
    顔が見つからない、スコアが境界付近、画質が悪い、といったケースを分けると運用しやすくなります。

  • 顔領域の切り出しやログ保存を追加する
    判定の根拠確認や誤判定分析をしやすくできます。

  • Raspberry Pi 5への展開
    USBカメラ+ローカルWebアプリの構成は、エッジ端末上のPoCとしても相性が良いです。

  • Renesasデバイスへの展開
    OpenCVを活用した顔認識やAI処理への対応として、RenesasはRZシリーズを展開しています。今回の作例では顔認識やAI機能の実装までは行っていませんが、今後、顔認識やAIを活用したさまざまなアプリケーションへの展開を検討する際には、RZシリーズの活用も有効な選択肢になると感じました。
    RZファミリ 32ビットおよび64ビット・マイクロプロセッサ (MPU)

参考リンク

OpenCV License
OpenCV Zoo
YuNet model page
SFace model page
OpenCV FaceDetectorYN
OpenCV FaceRecognizerSF
OpenCV VideoCapture
Flask: Streaming Contents
RZファミリ 32ビットおよび64ビット・マイクロプロセッサ (MPU)