既存の顔認証システムに、なりすまし対策を後付けする【C++ / C# 実装】
顔認証はもう動いている。入退室でも、本人確認でも、勤怠打刻でも構いません。そこに「なりすまし対策」だけを足したい——というのが本記事の想定です。
顔認証をゼロから作る話ではありません。OpenCV で顔認証を実装する方法は別記事で扱っています。ここではすでに動いているパイプラインを壊さずに、どこへ何を挟むかだけを扱います。
結論から書くと、既存の顔照合コードは 1 行も変えません。判定を1つのクラスに閉じ込めて、照合の直前に挟むだけです。実装は C++ で 38 行 / C#(.NET 8)で 35 行でした。
1. なぜ顔認証だけでは足りないのか
顔認証は「誰の顔か」を判定する技術で、「生きている人間の顔か」は判定しません。この 2 つは別問題です。
実際に OpenCV(YuNet + SFace)で顔認証を組み、本人の顔を印刷した紙をカメラに向けると、そのまま本人として通ります。詳細は印刷した写真であっさり突破されたで実験結果を出しています。
これは実装のバグではなく、顔照合という技術の定義どおりの動作です。照合器は「与えられた画像が紙か肌か」を見ていないので当然そうなります。
したがって対策は「顔認証の精度を上げる」ではなく、別の判定を1つ足すという形になります。
2. 後付けの選択肢は3つ
| 方式 | 実装コスト | 破られ方 | 向いているケース |
|---|---|---|---|
| ① 自前実装(まばたき要求・首振り要求など) | 低〜中 | 動画再生で通ることがある。動作要求が UX を損なう | 社内ツールなど、脅威が低く UX の妥協が効く場合 |
| ② OSS モデル(公開されている学習済みライブネスモデル) | 中 | 配布済みモデルをそのまま使うと、自分の環境の照明・カメラ・攻撃手口に合わないことがある | 検証・PoC。自前で学習データを用意し、継続的に更新できる体制がある場合 |
| ③ 商用SDK | 低(組み込みは数十行) | 製品ごとに異なる。精度は測定条件つきで確認が必要 | 本番運用。攻撃手口の更新をベンダー側に任せたい場合 |
① の限界は独立した記事にしています。まばたき要求は動画再生で、首振り要求も録画で通り得るという構造的な問題があります → 簡易ライブネス3方式の限界
② は「動かす」まではすぐです。問題はその先で、ライブネス判定の実力はモデルの構造よりも学習データに強く依存します。自分の設置環境(カメラの画角・照明・想定する攻撃手口)に合った状態を維持するには、データ収集と再学習を回し続ける必要があります。ここを見積もりに入れるかどうかで、②と③の比較結果は変わります。
以下では ③ を選んだ場合の具体的な組み込み方を書きます。当社のなりすまし判定SDKを例にしますが、「判定を1クラスに閉じ込めて照合の前に挟む」という構造は方式によらず共通です。①や②を選ぶ場合も、この形にしておけば後から差し替えられます。
3. 組み込みは3ステップ
ステップ1: 配布物を置く(コードは書かない)
判定ライブラリ本体(DLL / .so / framework)、依存する OpenCV ランタイム、モデルファイル一式、ライセンストークン——これらを所定の場所に置きます。ここまでコードは 0 行です。
⚠️ Windows で C# から使う場合、VC++ 2015-2022 再頒布可能パッケージ (x64) が別途必要です。入れ忘れると起動時に
DllNotFoundException(0x8007007E)になります。エラーは「SDK が見つからない」ように見えますが、原因は依存 DLL 側です。
ステップ2: 判定を1つのクラスに閉じ込める
既存コードに SDK の API を直接ばら撒かないことが、後付けを安全にやる唯一のコツです。
- 初期化は起動時に 1 回だけ
- 呼び出し側に見せるのは「生体 / 偽物 / 判定不能」の 3 値だけ
- SDK の型・関数はクラスの中に閉じる
こうしておくと、後から方式(②の OSS や別ベンダー)へ差し替えるときに、書き換えるのはこのクラスだけで済みます。
ステップ3: 照合の直前に挟む
既存の「顔検出 → 顔照合」の間に、judge() を 1 回呼ぶだけです。
4. C++ の実装(全コード)
既存の顔認証(YuNet + SFace)に、判定ゲートを後付けした状態の全コードです。// ★ 追加 が今回足した部分で、それ以外は既存のままです。
#include <opencv2/opencv.hpp>
#include <opencv2/objdetect/face.hpp>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include "LivenessCheck.hpp" // ★ 追加: なりすまし判定SDK
// 型は sw::livenesscheck 名前空間、生成/破棄関数は extern "C" でグローバルに置かれている
using namespace sw::livenesscheck;
// ★ 追加: 既存の顔認証に差し込む「なりすまし判定ゲート」
// open() は起動時に1回、judge() を認証のたびに呼ぶ。
class LivenessGate
{
public:
enum class Verdict { Live, Fake, Unknown };
bool open(const std::string &modelDir, const std::string &tokenPath)
{
std::ifstream ifs(tokenPath);
std::string token;
std::getline(ifs, token);
auto *ac = newActivationChallenge();
const bool activated =
ac->checkToken(token.c_str()) && ac->validToken(token.c_str());
releaseActivationChallenge(ac);
if (!activated) return false;
ExternalFilePath filePath;
filePath.faceDetectModel = modelDir + "/faceDetect.onnx";
filePath.fakeModelH = modelDir + "/livenessH.onnx";
filePath.fakeModelV = modelDir + "/livenessV.onnx";
Params params{}; // {} でゼロ初期化してから必要な分だけ設定
params.minFaceWidth = 80;
params.maxFaceWidth = 1000;
params.faceAreaMinRatio = 0.05f;
params.faceAreaMaxRatio = 0.65f;
params.edgePosErrMode = true;
params.isFakeMode = true;
params.fakeJudgeTh = 0.15f; // 判定しきい値(下げるほど厳しい)
liveness_ = newLivenessCheck();
bio_ = std::make_shared<BioData>();
if (!liveness_->init(filePath, params)) return false;
return liveness_->registerBioData(bio_);
}
Verdict judge(const cv::Mat &frame)
{
bio_->clear();
liveness_->process(frame);
if (!bio_->isValidFace) return Verdict::Unknown; // 顔が小さい・切れている等
return bio_->isFakeFace ? Verdict::Fake : Verdict::Live;
}
~LivenessGate()
{
if (liveness_) releaseLivenessCheck(liveness_);
}
private:
LivenessCheck *liveness_ = nullptr;
std::shared_ptr<BioData> bio_;
};
// 既存の顔認証側(変更しない): YuNet で顔検出 → SFace で特徴抽出
static cv::Mat extractFeature(const cv::Ptr<cv::FaceDetectorYN> &detector,
const cv::Ptr<cv::FaceRecognizerSF> &recognizer,
const cv::Mat &img)
{
detector->setInputSize(img.size());
cv::Mat faces;
detector->detect(img, faces);
if (faces.rows == 0) return cv::Mat();
cv::Mat aligned, feature;
recognizer->alignCrop(img, faces.row(0), aligned);
recognizer->feature(aligned, feature);
return feature.clone(); // feature() の戻りは内部バッファの参照なので clone 必須
}
int main()
{
// ---- ★ 追加: ゲートの初期化(起動時に1回) ----
LivenessGate gate;
if (!gate.open("models", "token.txt")) {
std::cerr << "liveness gate init failed\n";
return 1;
}
// ---- 既存: 顔認証パイプラインの準備 ----
auto detector = cv::FaceDetectorYN::create(
"face_detection_yunet_2023mar.onnx", "", cv::Size(320, 320), 0.9f, 0.3f, 5000);
auto recognizer = cv::FaceRecognizerSF::create(
"face_recognition_sface_2021dec.onnx", "");
cv::Mat enrolled = cv::imread("enrolled.jpg", cv::IMREAD_COLOR); // 登録済みの本人画像
cv::Mat frame = cv::imread("input.png", cv::IMREAD_COLOR); // 認証時のフレーム
if (enrolled.empty() || frame.empty()) return 1;
// ---- ★ 追加: 照合の前に「生体か」を確認する ----
switch (gate.judge(frame)) {
case LivenessGate::Verdict::Fake:
std::cout << "なりすましの疑いを検出しました。認証を中断します\n";
return 0;
case LivenessGate::Verdict::Unknown:
std::cout << "顔が正しく写っていません。もう一度お試しください\n";
return 0;
case LivenessGate::Verdict::Live:
break; // 生体 → 既存の照合へ進む
}
// ---- 既存: 顔照合(1行も変えていない) ----
const cv::Mat featEnrolled = extractFeature(detector, recognizer, enrolled);
const cv::Mat featFrame = extractFeature(detector, recognizer, frame);
if (featEnrolled.empty() || featFrame.empty()) {
std::cout << "顔が検出できませんでした\n";
return 0;
}
const double score = recognizer->match(
featEnrolled, featFrame, cv::FaceRecognizerSF::DisType::FR_COSINE);
std::cout << "cosine similarity = " << score << "\n";
std::cout << (score >= 0.363 ? "同一人物 → 認証OK\n" : "別人 → 認証NG\n");
return 0;
}
ポイントは 3 つです。
① 生成・破棄関数は名前空間の外にある。 型(LivenessCheck / BioData / Params)は sw::livenesscheck 名前空間にありますが、newLivenessCheck() などの生成・破棄関数は extern "C" でグローバルに置かれています。sw::livenesscheck::newLivenessCheck() と書くとコンパイルが通りません。
② Params params{}; の {} は必須。 既定値を持たない素の構造体なので、{} を付けないとメンバが未初期化のまま渡ります。症状は「顔が検出されない」「結果が毎回違う」で、原因が分かりにくいタイプの不具合です。
③ 判定不能を「偽物」と同じ扱いにしない。 顔が小さい・見切れている・暗すぎる場合、SDK は「判定できない」を返します。これを Fake と一緒にすると、ただ立ち位置が悪いだけのユーザーに「なりすまし」と表示することになります。3 値で受けるのはこのためです。
5. C#(.NET 8)の実装
同じ構造を C# で書いたものです。SDK 同梱の P/Invoke ラッパーを使うので、DllImport を自分で書く必要はありません。
using System;
using System.IO;
using System.Runtime.InteropServices;
using OpenCvSharp;
using SwallowIncubate.LivenessCheck; // ★ 追加: SDK 同梱の P/Invoke ラッパー
/// <summary>既存の顔認証に差し込む「なりすまし判定ゲート」。</summary>
sealed class LivenessGate : IDisposable
{
public enum Verdict { Live, Fake, Unknown }
private readonly ActivationChallenge _ac = new();
private readonly LivenessCheck _liveness = new();
private readonly BioData _bio = new();
public bool Open(string modelDir, string tokenPath)
{
string token = File.ReadAllText(tokenPath).Trim();
if (!_ac.CheckToken(token) || !_ac.ValidToken(token)) return false;
var filePath = new ExternalFilePath
{
FaceDetectModel = Path.Combine(modelDir, "faceDetect.onnx"),
FakeModelH = Path.Combine(modelDir, "livenessH.onnx"),
FakeModelV = Path.Combine(modelDir, "livenessV.onnx"),
};
var p = new Params
{
MinFaceWidth = 80, MaxFaceWidth = 1000,
FaceAreaMinRatio = 0.05f, FaceAreaMaxRatio = 0.65f,
EdgePosErrMode = true,
IsFakeMode = true, FakeJudgeTh = 0.15f,
// 既定値は true。モデルパスを渡さない機能は明示的に false にする
IsFaceDirMode = false, IsGlassesMode = false,
IsMaskMode = false, IsEyeMode = false, IsEyeDirMode = false,
};
if (!_liveness.Init(filePath, p)) return false;
return _liveness.RegisterBioData(_bio);
}
public Verdict Judge(Mat frame)
{
_bio.Clear();
// ネイティブ側へは BGR のバイト配列で渡す。連続領域でない Mat は先に詰め直す
Mat cont = frame.IsContinuous() ? frame : frame.Clone();
byte[] pixels = new byte[cont.Total() * cont.ElemSize()];
Marshal.Copy(cont.Data, pixels, 0, pixels.Length);
int w = cont.Cols, h = cont.Rows;
if (!ReferenceEquals(cont, frame)) cont.Dispose();
_liveness.Process(pixels, w, h);
if (!_bio.IsValidFace) return Verdict.Unknown;
return _bio.IsFakeFace ? Verdict.Fake : Verdict.Live;
}
public void Dispose()
{
_bio.Dispose();
_liveness.Dispose();
_ac.Dispose();
}
}
class Program
{
static int Main()
{
// ---- ★ 追加: ゲートの初期化(起動時に1回) ----
using var gate = new LivenessGate();
if (!gate.Open("models", "token.txt"))
{
Console.Error.WriteLine("liveness gate init failed");
return 1;
}
using var frame = Cv2.ImRead("input.png", ImreadModes.Color);
if (frame.Empty()) return 1;
// ---- ★ 追加: 既存の顔照合を呼ぶ前に通す ----
switch (gate.Judge(frame))
{
case LivenessGate.Verdict.Fake:
Console.WriteLine("なりすましの疑いを検出しました。認証を中断します");
return 0;
case LivenessGate.Verdict.Unknown:
Console.WriteLine("顔が正しく写っていません。もう一度お試しください");
return 0;
}
// ---- 既存の顔照合処理(変更なし) ----
Console.WriteLine("生体と判定 → 既存の顔照合へ");
return 0;
}
}
C# 固有の注意が 2 つあります。
① Is***Mode の初期値が C++ と逆。 C# 側の Params はプロパティに既定値を持っていて、各機能フラグが true から始まります。使わない機能を明示的に false にしないと、モデルパスが空のまま初期化されて失敗します。C++ 側は {} でゼロ初期化=全部 false なので、両言語で初期状態が反転している点に注意してください。
② 画像はバイト配列で渡す。 ネイティブ側に Mat をそのまま渡せないため、BGR のバイト配列にコピーします。カメラや動画から取った Mat は連続領域でないことがあるので、IsContinuous() を確認してから詰めます。
なお C# だけで顔照合まで完結させるのは、現状では回り道になります。 OpenCvSharp は顔検出(FaceDetectorYN)はラップしていますが、顔照合(FaceRecognizerSF)は 4.13 時点で未ラップです。詳細と選択肢はOpenCVだけで顔認証を実装するにまとめてあります。
6. 判定結果をどう扱うか
閾値は「どちらの間違いが痛いか」で決める
判定しきい値を下げると攻撃の見逃しは減り、本人の拒否が増えます。上げるとその逆です。片方だけを良くすることはできません。
この 2 種類の誤りには名前が付いていて、攻撃を通してしまう率が APCER、本人を弾いてしまう率が BPCER です。顔照合の FAR / FRR とは別物なので、混同すると評価がずれます → FAR/FRR と APCER/BPCER の違い
決め方は用途で変わります。
| 用途 | 重視するもの | 閾値の方向 |
|---|---|---|
| 口座開設・高額決済 | 見逃しを減らす(APCER 優先) | 厳しめ。本人拒否はリトライで吸収する |
| 勤怠打刻・入退室 | 通行を止めない(BPCER 優先) | 標準。弾かれ続けると運用が回らない |
| 無人受付 | 中間。有人フォローの有無で決まる | 標準〜厳しめ |
⚠️ 公称値をそのまま自分の閾値にしないでください。 精度値はベンダーの測定条件(カメラ・照明・距離・攻撃サンプル)での数字です。実際の設置環境で、本人の画像と攻撃サンプルを両方通して確認してから決めます。
NG のときに何を表示するか
判定結果をそのままユーザーに出すと、運用が壊れます。
| 判定 | ❌ やりがちな表示 | ✅ 望ましい扱い |
|---|---|---|
| Fake | 「なりすましを検出しました」 | 理由を明かさず「認証できませんでした」。連続失敗は有人対応・別手段へ誘導 |
| Unknown(判定不能) | 「なりすまし」と同じ扱い | 「顔全体が映るように近づいてください」など具体的な撮り直し案内 |
| Live | — | 既存の顔照合へ進む |
Fake の理由を画面に出すのは避けます。攻撃者に対しては「どの条件で弾かれたか」のフィードバックになり、正規ユーザーに対しては不快な誤解を生みます。ログには残し、画面には出さないのが基本形です。
また Unknown の割合はモニタリング対象です。ここが跳ね上がっているときは、なりすましではなくカメラの設置位置や照明の問題であることがほとんどです。
7. 既存フローのどこに差すか
差し込み位置の候補は 4 つあります。
① カメラ取得直後 ② 顔検出の後 ③ 顔照合の前 ④ 顔照合の後
↓ ↓ ↓ ↓
[フレーム取得] → [顔検出] → [なりすまし判定] → [顔照合] → 認証OK
| 位置 | 評価 |
|---|---|
| ① カメラ取得直後 | ⚠️ 顔が写っていないフレームでも毎回判定が走り、無駄が多い |
| ② 顔検出の後 | ✅ 妥当。既存の検出結果を再利用できるなら効率がよい |
| ③ 顔照合の前 | ✅ 推奨。偽物なら照合を実行せずに済み、顔特徴量を作る処理も走らない |
| ④ 顔照合の後 | ❌ 照合が通ってから弾く形。本人の顔特徴量を偽物の画像から作ってしまうため、ログ・監査の観点でも避けたい |
本記事のコードは ③ を採っています。「本人と一致したが偽物だった」という状態をシステム内に一度も作らないのが理由です。
負荷面では、なりすまし判定を足すと 1 認証あたりの処理時間が増えます。低スペック CPU での実測値はCortex-A76(Raspberry Pi 5)でのベンチマークにまとめました。入退室・本人確認のような「1 回の認証を 1 秒以内に返す」用途なら、エッジ端末でも十分に成立します。
組み込みに実際どれくらいコードを書くのかはなりすまし判定SDKの組み込みに何行必要かで行数を実測しています。設計全体の抜け漏れチェックはなりすまし対策 設計チェックリスト12項目をどうぞ。
8. まとめ
- 顔認証は「誰か」を判定するが「生体か」は判定しない。対策は精度向上ではなく、判定を1つ足すこと
- 後付けの選択肢は自前実装・OSS・商用SDKの3つ。②と③の比較は「学習データを維持し続けられるか」で決まる
- 実装は判定を1クラスに閉じ込め、顔照合の直前に挟む。既存の照合コードは1行も変えない(C++ 38行 / C# 35行)
- 結果は Live / Fake / Unknown の3値で受ける。判定不能を Fake と同じ扱いにすると、立ち位置が悪いだけのユーザーを「なりすまし」扱いしてしまう
- 閾値は APCER と BPCER のどちらが痛いかで決め、自分の設置環境で測ってから確定する
- Fake の理由は画面に出さずログへ。Unknown 率のモニタリングは運用トラブルの早期発見に効く
なりすまし対策の追加は、顔認証の作り直しではありません。挟む場所と、NG のときの見せ方を決めれば、既存システムのまま入ります。
🛡 既存の顔認証に後付けしたい方へ
本ブログを運営するスワローインキュベートのなりすまし判定SDKは、単一フレーム・パッシブ判定(動作要求なし)、CPU推論・オフライン動作で、既存の顔認証パイプラインに後付けできます。C++ / C#(.NET 8)のサンプルアプリ付き。試用のご相談を承っています。

