diff --git a/README.md b/README.md index 58a5d63..476e9bc 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Biopass was developed by [@phucvinh57](https://github.com/phucvinh57) and [@thai | **Authentication methods** | Face + Fingerprint | Face only | | **User Interface** | Modern GUI for management | Command-line interface only | | **Configuration** | GUI | Manual | -| **Face Anti-spoofing** | IR camera + Embedded AI model | IR camera only | +| **Face Anti-spoofing** | IR liveness classification + Embedded AI model | IR camera only | ## Installation @@ -62,6 +62,7 @@ Feel free to request new features or report bugs by opening an issue. For contri Models used in this project: - Face Recognition: **[EdgeFace](https://github.com/otroshi/edgeface)** - Face Detection: **[YOLO-Face](https://github.com/akanametov/yolo-face)** +- Face Anti-Spoofing: **[mobilenetv3-antispoof](https://github.com/facenox/face-antispoof-onnx)** (used for both RGB and IR liveness classification) ## Star History diff --git a/app/bun.lock b/app/bun.lock index 34dd7e5..2f0d18b 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -19,9 +19,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-router": "^1.168.18", "@tanstack/react-router-devtools": "^1.166.13", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-fs": "2.4.5", + "@tauri-apps/plugin-opener": "2.5.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", @@ -53,12 +53,6 @@ }, }, }, - "overrides": { - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "react": "19.2.4", - "react-dom": "19.2.4", - }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -436,7 +430,7 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], - "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], @@ -462,9 +456,9 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], - "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.5", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA=="], - "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], @@ -556,7 +550,7 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],s + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1190,6 +1184,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tauri-apps/plugin-fs/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + + "@tauri-apps/plugin-opener/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/app/package.json b/app/package.json index 7e83b1b..6bd45d8 100644 --- a/app/package.json +++ b/app/package.json @@ -27,9 +27,9 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-router": "^1.168.18", "@tanstack/react-router-devtools": "^1.166.13", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-fs": "2.4.5", + "@tauri-apps/plugin-opener": "2.5.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", diff --git a/app/src-tauri/src/config.rs b/app/src-tauri/src/config.rs index af90e35..e01a9f0 100644 --- a/app/src-tauri/src/config.rs +++ b/app/src-tauri/src/config.rs @@ -59,6 +59,10 @@ struct AntiSpoofingConfigRaw { pub threshold: Option, #[serde(default)] pub ir_camera: Option, + #[serde(default = "default_ir_warmup_delay_ms")] + pub ir_warmup_delay_ms: i32, + #[serde(default = "default_ir_min_face_area_ratio")] + pub ir_min_face_area_ratio: f32, } #[derive(Debug, Deserialize)] @@ -160,6 +164,8 @@ pub struct AntiSpoofingConfig { pub enable: bool, pub model: AntiSpoofingModelConfig, pub ir_camera: Option, + pub ir_warmup_delay_ms: i32, + pub ir_min_face_area_ratio: f32, } impl AntiSpoofingConfig { @@ -201,6 +207,8 @@ impl AntiSpoofingConfig { enable: raw.enable, model, ir_camera: raw.ir_camera, + ir_warmup_delay_ms: raw.ir_warmup_delay_ms, + ir_min_face_area_ratio: raw.ir_min_face_area_ratio, } } } @@ -242,6 +250,12 @@ fn default_fingerprint_retries() -> u32 { fn default_fingerprint_timeout() -> u32 { 5000 } +fn default_ir_warmup_delay_ms() -> i32 { + 300 +} +fn default_ir_min_face_area_ratio() -> f32 { + 0.08 +} fn default_ignored_services() -> Vec { vec!["polkit-1".to_string(), "pkexec".to_string()] @@ -282,6 +296,8 @@ fn get_default_config(app: &AppHandle) -> BiopassConfig { threshold: 0.8, }, ir_camera: None, + ir_warmup_delay_ms: default_ir_warmup_delay_ms(), + ir_min_face_area_ratio: default_ir_min_face_area_ratio(), }, }, fingerprint: FingerprintMethodConfig { diff --git a/app/src/app/configuration/-components/face/FaceSetting.tsx b/app/src/app/configuration/-components/face/FaceSetting.tsx index c4f9dd4..6a6eb96 100644 --- a/app/src/app/configuration/-components/face/FaceSetting.tsx +++ b/app/src/app/configuration/-components/face/FaceSetting.tsx @@ -285,7 +285,7 @@ export function FaceSetting() { - {config.anti_spoofing.enable && ( + {(config.anti_spoofing.enable || !!config.anti_spoofing.ir_camera) && (
(); } + + if (anti_spoofing["ir_min_face_area_ratio"]) { + config.methods.face.anti_spoofing.ir_min_face_area_ratio = + anti_spoofing["ir_min_face_area_ratio"].as(); + } } // Backward compatibility with old schema: @@ -258,6 +263,8 @@ bool migrateConfigSchema(const std::string& username, std::string* error) { std::string model_path = "models/mobilenetv3_antispoof.onnx"; float threshold = 0.8f; std::string ir_camera_path; + int ir_warmup_delay_ms = 300; + float ir_min_face_area_ratio = 0.08f; if (anti) { if (anti["enable"]) { @@ -284,6 +291,14 @@ bool migrateConfigSchema(const std::string& username, std::string* error) { if (anti["ir_camera"] && !anti["ir_camera"].IsNull()) { ir_camera_path = anti["ir_camera"].as(); } + + if (anti["ir_warmup_delay_ms"]) { + ir_warmup_delay_ms = anti["ir_warmup_delay_ms"].as(); + } + + if (anti["ir_min_face_area_ratio"]) { + ir_min_face_area_ratio = anti["ir_min_face_area_ratio"].as(); + } } if (ir_camera_path.empty() && face["ir_camera"]) { @@ -311,9 +326,11 @@ bool migrateConfigSchema(const std::string& username, std::string* error) { anti && static_cast(anti["model"]) && anti["model"].IsMap() && static_cast(anti["model"]["path"]) && static_cast(anti["model"]["threshold"]); const bool has_new_ir_key = anti && static_cast(anti["ir_camera"]); + const bool has_new_ir_warmup_key = anti && static_cast(anti["ir_warmup_delay_ms"]); + const bool has_new_ir_area_key = anti && static_cast(anti["ir_min_face_area_ratio"]); const bool needs_migration = has_legacy_face_ir || has_legacy_anti_threshold || has_legacy_anti_model_scalar || !has_new_model_map || - !has_new_ir_key; + !has_new_ir_key || !has_new_ir_warmup_key || !has_new_ir_area_key; if (!needs_migration) return true; @@ -328,6 +345,8 @@ bool migrateConfigSchema(const std::string& username, std::string* error) { } else { anti_new["ir_camera"] = ir_camera_path; } + anti_new["ir_warmup_delay_ms"] = ir_warmup_delay_ms; + anti_new["ir_min_face_area_ratio"] = ir_min_face_area_ratio; face["anti_spoofing"] = anti_new; if (face["ir_camera"]) { diff --git a/auth/core/auth_config.h b/auth/core/auth_config.h index 02c8f2d..6e259d0 100644 --- a/auth/core/auth_config.h +++ b/auth/core/auth_config.h @@ -46,13 +46,12 @@ struct AntiSpoofingConfig { AntiSpoofingModelConfig model; // Linux device path, e.g. "/dev/video2". nullopt means disabled. std::optional ir_camera = std::nullopt; - // Extra delay (ms) inserted after the warmup-frame discard and before the - // actual IR capture. Gives IR LEDs and auto-exposure time to stabilise. + // Extra delay (ms) kept for backwards compatibility with existing configs. // Configurable via anti_spoofing.ir_warmup_delay_ms in config.yaml. - // NOTE: The IR check verifies that a face-shaped bounding box exists in the - // IR frame (presence check). It is NOT a full liveness detector. A printed - // photo that the YOLO model can detect in IR will still pass. int ir_warmup_delay_ms = 300; + // Minimum detected IR face bounding-box area as a fraction of the full frame. + // Smaller crops are treated as insufficient detail for reliable liveness. + float ir_min_face_area_ratio = 0.08f; }; struct FaceMethodConfig { diff --git a/auth/face/antispoofing/antispoof_check.cc b/auth/face/antispoofing/antispoof_check.cc index f8504fa..79a998c 100644 --- a/auth/face/antispoofing/antispoof_check.cc +++ b/auth/face/antispoofing/antispoof_check.cc @@ -1,13 +1,18 @@ #include "antispoof_check.h" +#include + +#include #include #include +#include #include -#include - +#include "camera_capture.h" #include "debug_image_io.h" #include "face_as.h" +#include "face_detection.h" +#include "image_utils.h" #include "ir_camera_as.h" namespace biopass { @@ -19,6 +24,12 @@ struct AntiSpoofTask { std::future future; }; +struct IRFrameStats { + int min_val = 255; + int max_val = 0; + double mean_val = 0.0; +}; + AntiSpoofTask make_task(const std::string& name, std::future future) { AntiSpoofTask task; task.name = name; @@ -26,6 +37,28 @@ AntiSpoofTask make_task(const std::string& name, std::future future) { return task; } +IRFrameStats calculate_ir_frame_stats(const ImageRGB& frame) { + IRFrameStats stats; + const int total_pixels = frame.width * frame.height; + if (frame.empty() || total_pixels <= 0) { + stats.min_val = 0; + return stats; + } + + double sum_val = 0.0; + const uint8_t* ptr = frame.ptr(); + for (int i = 0; i < total_pixels; ++i) { + uint8_t val = ptr[i * 3]; + if (val < stats.min_val) + stats.min_val = val; + if (val > stats.max_val) + stats.max_val = val; + sum_val += val; + } + stats.mean_val = sum_val / total_pixels; + return stats; +} + bool checkAntiSpoofByAIModel(const FaceMethodConfig& faceCfg, const std::string& username, const ImageRGB& face, const AuthConfig& authCfg) { const std::string modelPath = faceCfg.anti_spoofing.model.path; @@ -37,15 +70,19 @@ bool checkAntiSpoofByAIModel(const FaceMethodConfig& faceCfg, const std::string& try { FaceAntiSpoofing face_as(modelPath, 128, faceCfg.anti_spoofing.model.threshold); const SpoofResult result = face_as.inference(face); - if (result.spoof) { - spdlog::warn("FaceAuth: AI anti-spoofing detected spoof, score: {}", result.score); + if (!result.is_real) { + spdlog::warn( + "FaceAuth: AI anti-spoofing failed — real={:.4f}, spoof={:.4f}, threshold={:.3f}", + result.real_score, result.spoof_score, faceCfg.anti_spoofing.model.threshold); if (authCfg.debug) { saveFailedFace(username, face, "spoof"); } return false; } - spdlog::debug("FaceAuth: AI anti-spoofing check passed"); + spdlog::debug( + "FaceAuth: AI anti-spoofing check passed — real={:.4f}, spoof={:.4f}, threshold={:.3f}", + result.real_score, result.spoof_score, faceCfg.anti_spoofing.model.threshold); return true; } catch (const std::exception& e) { spdlog::error("FaceAuth: AI anti-spoofing check failed: {}", e.what()); @@ -56,8 +93,8 @@ bool checkAntiSpoofByAIModel(const FaceMethodConfig& faceCfg, const std::string& } // namespace bool checkAntiSpoof(const FaceMethodConfig& face_config, const std::string& username, - const ImageRGB& face, const AuthConfig& config, - ICameraCaptureSession* ir_camera_session) { + const Detection& rgb_det, int rgb_width, int rgb_height, + const AuthConfig& config, ICameraCaptureSession* ir_camera_session) { const bool ai_enabled = face_config.anti_spoofing.enable; const bool ir_enabled = face_config.anti_spoofing.ir_camera.has_value() && !face_config.anti_spoofing.ir_camera->empty(); @@ -68,41 +105,211 @@ bool checkAntiSpoof(const FaceMethodConfig& face_config, const std::string& user } spdlog::debug("FaceAuth: Anti-spoofing started (ai_enabled={}, ir_enabled={}, ir_camera='{}')", - ai_enabled, ir_enabled, - face_config.anti_spoofing.ir_camera.value_or("")); + ai_enabled, ir_enabled, face_config.anti_spoofing.ir_camera.value_or("")); std::vector tasks; if (ai_enabled) { const auto face_config_copy = face_config; const auto username_copy = username; - const auto face_copy = face; + const auto face_copy = rgb_det.image; const auto config_copy = config; tasks.push_back(make_task( - "AI", std::async(std::launch::async, - [face_config_copy, username_copy, face_copy, config_copy]() { - return checkAntiSpoofByAIModel(face_config_copy, username_copy, face_copy, - config_copy); - }))); + "AI", + std::async(std::launch::async, [face_config_copy, username_copy, face_copy, config_copy]() { + return checkAntiSpoofByAIModel(face_config_copy, username_copy, face_copy, config_copy); + }))); } if (ir_enabled) { - const auto ir_camera_path = *face_config.anti_spoofing.ir_camera; const auto detection_model = face_config.detection.model; - const auto detection_threshold = face_config.detection.threshold; + const auto antispoof_model = face_config.anti_spoofing.model.path; + const auto antispoof_threshold = face_config.anti_spoofing.model.threshold; const auto username_copy = username; const auto debug_enabled = config.debug; const auto warmup_delay_ms = face_config.anti_spoofing.ir_warmup_delay_ms; - auto* ir_camera_session_ptr = ir_camera_session; - tasks.push_back(make_task( - "IR", std::async(std::launch::async, - [ir_camera_path, detection_model, detection_threshold, username_copy, - debug_enabled, warmup_delay_ms, ir_camera_session_ptr]() { - return checkAntispoofByIRCamera(ir_camera_path, detection_model, - detection_threshold, username_copy, - debug_enabled, ir_camera_session_ptr, - warmup_delay_ms); - }))); + const auto min_face_area_ratio = face_config.anti_spoofing.ir_min_face_area_ratio; + + // Check models first! + if (detection_model.empty() || !std::ifstream(detection_model).good()) { + spdlog::error("FaceAuth: IR liveness check — detection model not found: {}", detection_model); + tasks.push_back(make_task("IR", std::async(std::launch::deferred, []() { return false; }))); + } else if (antispoof_model.empty() || !std::ifstream(antispoof_model).good()) { + spdlog::error("FaceAuth: IR liveness check — anti-spoofing model not found: {}", + antispoof_model); + tasks.push_back(make_task("IR", std::async(std::launch::deferred, []() { return false; }))); + } else { + (void)warmup_delay_ms; + (void)rgb_width; + (void)rgb_height; + + const auto ir_camera_path = *face_config.anti_spoofing.ir_camera; + + tasks.push_back(make_task( + "IR", + std::async(std::launch::async, [detection_model, antispoof_model, antispoof_threshold, + username_copy, debug_enabled, ir_camera_path, + ir_camera_session, min_face_area_ratio]() { + // Helper lambda: capture one IR frame from an open session or new session. + auto capture_ir = [&]() -> ImageRGB { + if (ir_camera_session && ir_camera_session->isOpen()) { + return ir_camera_session->capture(); + } + return captureImageByIRCamera(ir_camera_path, /*warmup_frames=*/5, + /*timeout_ms=*/3000, + /*poll_interval_ms=*/10); + }; + + // LED warm-up retry: use cheap brightness stats. Face detection is kept + // for usable-frame selection only, so AI and IR work can overlap sooner. + constexpr int kMaxLedWarmupRetries = 10; + constexpr int kLedWarmupRetryDelayMs = 300; + constexpr double kUsableIrMean = 35.0; + constexpr int kUsableIrMax = 120; + constexpr float kIrFacePresentThreshold = 0.45f; + constexpr int kRequiredUsableIrFrames = 2; + constexpr int kMaxIrFrameSelectionAttempts = 6; + constexpr int kIrFrameSelectionIntervalMs = 80; + + auto process_and_log_ir_frame = [&](const ImageRGB& frame, + int attempt) -> IRFrameStats { + if (frame.empty()) { + spdlog::debug("FaceAuth: IR frame stats (attempt {}) — frame is empty", attempt); + return {}; + } + const IRFrameStats stats = calculate_ir_frame_stats(frame); + + if (debug_enabled) { + saveImage("/tmp/biopass_ir_raw.png", frame); + } + + spdlog::debug( + "FaceAuth: IR frame stats (attempt {}) — shape=({}x{}x3), " + "dtype=uint8, min={}, max={}, mean={:.2f}", + attempt, frame.width, frame.height, stats.min_val, stats.max_val, stats.mean_val); + return stats; + }; + + spdlog::debug( + "FaceAuth: IR liveness check — capturing frame (LED warm-up check " + "enabled)"); + ImageRGB ir_frame = capture_ir(); + process_and_log_ir_frame(ir_frame, 0); + + try { + FaceDetection ir_frame_detector(detection_model, 640, {"face"}, 0.05f); + + IRFrameStats ir_stats = calculate_ir_frame_stats(ir_frame); + for (int attempt = 0; attempt < kMaxLedWarmupRetries && !ir_frame.empty(); + ++attempt) { + if (ir_stats.mean_val >= kUsableIrMean && ir_stats.max_val >= kUsableIrMax) { + if (attempt > 0) { + spdlog::debug( + "FaceAuth: IR liveness check — LED ready after {} " + "recapture(s) (mean={:.2f}, max={})", + attempt, ir_stats.mean_val, ir_stats.max_val); + } + break; + } + + spdlog::debug( + "FaceAuth: IR liveness check — IR frame is still dark " + "(mean={:.2f} < {:.2f} or max={} < {}), LED may not be ready — " + "recapturing in {}ms (attempt {}/{})", + ir_stats.mean_val, kUsableIrMean, ir_stats.max_val, kUsableIrMax, + kLedWarmupRetryDelayMs, attempt + 1, kMaxLedWarmupRetries); + std::this_thread::sleep_for(std::chrono::milliseconds(kLedWarmupRetryDelayMs)); + ir_frame = capture_ir(); + ir_stats = process_and_log_ir_frame(ir_frame, attempt + 1); + } + + std::vector ir_faces; + for (int attempt = 0; attempt < kMaxIrFrameSelectionAttempts && + (int)ir_faces.size() < kRequiredUsableIrFrames; + ++attempt) { + ImageRGB candidate = attempt == 0 ? ir_frame : capture_ir(); + const IRFrameStats stats = process_and_log_ir_frame(candidate, 100 + attempt); + if (candidate.empty()) { + continue; + } + + if (stats.mean_val < kUsableIrMean || stats.max_val < kUsableIrMax) { + spdlog::debug( + "FaceAuth: IR liveness check — skipping low-quality IR frame " + "(mean={:.2f} < {:.2f} or max={} < {})", + stats.mean_val, kUsableIrMean, stats.max_val, kUsableIrMax); + } else { + const std::vector raw = ir_frame_detector.inference(candidate); + float max_conf = 0.0f; + int best_detection_index = -1; + for (size_t i = 0; i < raw.size(); ++i) { + const auto& d = raw[i]; + if (d.conf > max_conf) { + max_conf = d.conf; + best_detection_index = static_cast(i); + } + } + + if (best_detection_index >= 0 && max_conf >= kIrFacePresentThreshold) { + const Detection& best_detection = raw[best_detection_index]; + const int bbox_w = best_detection.box.x2 - best_detection.box.x1; + const int bbox_h = best_detection.box.y2 - best_detection.box.y1; + const float frame_area = + static_cast(candidate.width) * static_cast(candidate.height); + const float bbox_area_ratio = + frame_area > 0.0f + ? (static_cast(bbox_w) * static_cast(bbox_h)) / frame_area + : 0.0f; + + if (bbox_area_ratio < min_face_area_ratio) { + spdlog::debug( + "FaceAuth: IR liveness check — skipping IR face too small for reliable " + "liveness (face_conf={:.4f}, bbox={}x{}, area_ratio={:.4f} < {:.4f})", + max_conf, bbox_w, bbox_h, bbox_area_ratio, min_face_area_ratio); + } else { + ir_faces.push_back(best_detection.image); + if (debug_enabled) { + saveFailedFace(username_copy, best_detection.image, "ir_selected_crop"); + saveFailedFace(username_copy, resizeImage(best_detection.image, 128, 128), + "ir_model_input_128"); + } + + spdlog::debug( + "FaceAuth: IR liveness check — accepted usable IR face {}/{} " + "(face_conf={:.4f}, bbox={}x{}, area_ratio={:.4f}, mean={:.2f}, max={})", + ir_faces.size(), kRequiredUsableIrFrames, max_conf, bbox_w, bbox_h, + bbox_area_ratio, stats.mean_val, stats.max_val); + } + } else { + spdlog::debug( + "FaceAuth: IR liveness check — skipping IR frame with weak face " + "confidence ({:.4f} < {:.4f})", + max_conf, kIrFacePresentThreshold); + } + } + + if ((int)ir_faces.size() < kRequiredUsableIrFrames) { + std::this_thread::sleep_for( + std::chrono::milliseconds(kIrFrameSelectionIntervalMs)); + } + } + + if (ir_faces.size() < 2) { + spdlog::error( + "FaceAuth: IR liveness check — only {} usable IR face(s) captured " + "from '{}' (required >= 2)", + ir_faces.size(), ir_camera_path); + return false; + } + + return checkAntispoofByIRCrops(ir_faces, antispoof_model, antispoof_threshold, + username_copy, debug_enabled); + } catch (const std::exception& e) { + spdlog::warn("FaceAuth: IR liveness check — frame selection error: {}", e.what()); + return false; + } + }))); + } } bool all_passed = true; diff --git a/auth/face/antispoofing/antispoof_check.h b/auth/face/antispoofing/antispoof_check.h index 0c42e3b..81221a2 100644 --- a/auth/face/antispoofing/antispoof_check.h +++ b/auth/face/antispoofing/antispoof_check.h @@ -6,12 +6,14 @@ #include "auth_method.h" #include "image_utils.h" +struct Detection; + namespace biopass { class ICameraCaptureSession; bool checkAntiSpoof(const FaceMethodConfig& face_config, const std::string& username, - const ImageRGB& face, const AuthConfig& config, + const Detection& rgb_det, int rgb_width, int rgb_height, const AuthConfig& config, ICameraCaptureSession* ir_camera_session = nullptr); } // namespace biopass diff --git a/auth/face/antispoofing/face_as.cc b/auth/face/antispoofing/face_as.cc index 151e246..03afd52 100644 --- a/auth/face/antispoofing/face_as.cc +++ b/auth/face/antispoofing/face_as.cc @@ -1,17 +1,5 @@ #include "face_as.h" -int argmax(const float* data, int size) { - int max_index = 0; - float max_val = data[0]; - for (int i = 1; i < size; i++) { - if (data[i] > max_val) { - max_val = data[i]; - max_index = i; - } - } - return max_index; -} - FaceAntiSpoofing::FaceAntiSpoofing(const std::string& ckpt, int imgsz, const float threshold) { this->ckpt = ckpt; this->imgsz = imgsz; @@ -67,9 +55,25 @@ SpoofResult FaceAntiSpoofing::inference(const ImageRGB& image) { this->session->Run(Ort::RunOptions{nullptr}, this->input_names_cstr.data(), &input_tensor, 1, this->output_names_cstr.data(), this->output_names_cstr.size()); + if (output_tensors.empty()) { + throw std::runtime_error("FaceAntiSpoofing: model returned no outputs"); + } + + const auto shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); + if (shape.empty() || shape.back() != 2) { + throw std::runtime_error("FaceAntiSpoofing: unexpected output shape, expected [..., 2]"); + } + + // The model outputs post-softmax probabilities in [0, 1]. + // Index 0 = REAL, index 1 = SPOOF. const float* logits = output_tensors[0].GetTensorData(); + const float real_score = logits[0]; + const float spoof_score = logits[1]; + + // Secure threshold logic: only allow passage if the real score is high-confidence + // (>= threshold) AND strictly greater than the spoof score. + // Otherwise, fail-closed (treat as spoof). + const bool is_real = (real_score >= this->threshold) && (real_score > spoof_score); - int spoof_cls = argmax(logits, 2); - float score = logits[spoof_cls]; - return SpoofResult(score, spoof_cls == 0 && score >= this->threshold); + return SpoofResult(real_score, spoof_score, is_real); } diff --git a/auth/face/antispoofing/face_as.h b/auth/face/antispoofing/face_as.h index f9d4ca1..8a32fba 100644 --- a/auth/face/antispoofing/face_as.h +++ b/auth/face/antispoofing/face_as.h @@ -11,9 +11,11 @@ #include struct SpoofResult { - float score; - bool spoof; - SpoofResult(float score, bool spoof) : score(score), spoof(spoof) {} + float real_score; + float spoof_score; + bool is_real; + SpoofResult(float real_score, float spoof_score, bool is_real) + : real_score(real_score), spoof_score(spoof_score), is_real(is_real) {} }; class FaceAntiSpoofing { diff --git a/auth/face/antispoofing/ir_camera_as.cc b/auth/face/antispoofing/ir_camera_as.cc index 8e7ecf5..1229bb0 100644 --- a/auth/face/antispoofing/ir_camera_as.cc +++ b/auth/face/antispoofing/ir_camera_as.cc @@ -2,101 +2,276 @@ #include -#include -#include #include #include -#include +#include -#include "camera_capture.h" #include "debug_image_io.h" +#include "face_as.h" #include "face_detection.h" namespace biopass { namespace { -constexpr int kIrCaptureWarmupFrames = 5; -constexpr int kIrCaptureTimeoutMs = 3000; -constexpr int kIrCapturePollIntervalMs = 10; +float calculate_iou(float ax1, float ay1, float ax2, float ay2, float bx1, float by1, float bx2, + float by2) { + float inter_x1 = std::max(ax1, bx1); + float inter_y1 = std::max(ay1, by1); + float inter_x2 = std::min(ax2, bx2); + float inter_y2 = std::min(ay2, by2); + + float inter_w = std::max(0.0f, inter_x2 - inter_x1); + float inter_h = std::max(0.0f, inter_y2 - inter_y1); + float inter_area = inter_w * inter_h; + + float area_a = (ax2 - ax1) * (ay2 - ay1); + float area_b = (bx2 - bx1) * (by2 - by1); + float union_area = area_a + area_b - inter_area; + + if (union_area <= 0.0f) + return 0.0f; + return inter_area / union_area; +} } // namespace -bool checkAntispoofByIRCamera(const std::string& device_path, - const std::string& detection_model_path, float detection_threshold, - const std::string& username, bool debug, - ICameraCaptureSession* session, int warmup_delay_ms) { - spdlog::debug("FaceAuth: IR presence check | device='{}' detection_threshold={:.3f} warmup_delay_ms={}", - device_path, detection_threshold, warmup_delay_ms); +bool checkAntispoofByIRCamera(const std::vector& ir_frames, float target_rx1, + float target_ry1, float target_rx2, float target_ry2, + const std::string& detection_model_path, float detection_threshold, + const std::string& antispoof_model_path, float antispoof_threshold, + const std::string& username, bool debug) { + spdlog::debug( + "FaceAuth: IR liveness check | detection_threshold={:.3f} antispoof_threshold={:.3f}", + detection_threshold, antispoof_threshold); - if (device_path.empty()) { - spdlog::error("FaceAuth: IR presence check skipped — device path is empty"); + if (ir_frames.empty()) { + spdlog::error("FaceAuth: IR liveness check — received no frames, aborting"); return false; } if (!std::ifstream(detection_model_path).good()) { - spdlog::error("FaceAuth: IR presence check — detection model not found: {}", detection_model_path); + spdlog::error("FaceAuth: IR liveness check — detection model not found: {}", + detection_model_path); return false; } - // Optional extra delay after warmup frames to let IR LEDs and auto-exposure stabilise. - if (warmup_delay_ms > 0) { - spdlog::debug("FaceAuth: IR presence check — sleeping {}ms for camera stabilisation", warmup_delay_ms); - std::this_thread::sleep_for(std::chrono::milliseconds(warmup_delay_ms)); + if (antispoof_model_path.empty() || !std::ifstream(antispoof_model_path).good()) { + spdlog::error("FaceAuth: IR liveness check — anti-spoofing model not found: {}", + antispoof_model_path); + return false; } - ImageRGB frame; - if (session && session->isOpen()) { - spdlog::debug("FaceAuth: IR presence check — capturing from existing open session"); - frame = session->capture(); - } else { - spdlog::debug("FaceAuth: IR presence check — opening new session on '{}'", device_path); - frame = captureImageByIRCamera(device_path, kIrCaptureWarmupFrames, kIrCaptureTimeoutMs, - kIrCapturePollIntervalMs); + spdlog::debug("FaceAuth: IR liveness check — processing {} frames", ir_frames.size()); + + int passed_frames = 0; + int valid_frames_processed = 0; + int detected_frames_processed = 0; + + try { + FaceDetection detector(detection_model_path, 640, {"face"}, 0.05f); + FaceAntiSpoofing face_as(antispoof_model_path, 128, antispoof_threshold); + + for (size_t frame_idx = 0; frame_idx < ir_frames.size(); ++frame_idx) { + const auto& ir_frame = ir_frames[frame_idx]; + if (ir_frame.empty()) { + continue; + } + valid_frames_processed++; + + std::vector raw_detections = detector.inference(ir_frame); + + float max_conf = 0.0f; + std::vector detections; + for (const auto& d : raw_detections) { + if (d.conf > max_conf) { + max_conf = d.conf; + } + if (d.conf >= detection_threshold) { + detections.push_back(d); + } + } + + if (detections.empty()) { + spdlog::warn( + "FaceAuth: IR liveness check [frame {}/{}] — SKIPPED (no face bounding box detected " + "above threshold, highest conf={:.4f})", + frame_idx + 1, ir_frames.size(), max_conf); + if (debug) { + saveFailedFace(username, ir_frame, "ir_no_face_frame_" + std::to_string(frame_idx)); + } + continue; + } + + detected_frames_processed++; + + // Find the IR detection that matches the target RGB face best (highest IoU, fallback to + // closest center). + int best_idx = 0; + float best_iou = 0.0f; + float min_center_dist = std::numeric_limits::max(); + + float target_cx = (target_rx1 + target_rx2) / 2.0f; + float target_cy = (target_ry1 + target_ry2) / 2.0f; + + for (size_t i = 0; i < detections.size(); ++i) { + float ir_rx1 = (float)detections[i].box.x1 / ir_frame.width; + float ir_ry1 = (float)detections[i].box.y1 / ir_frame.height; + float ir_rx2 = (float)detections[i].box.x2 / ir_frame.width; + float ir_ry2 = (float)detections[i].box.y2 / ir_frame.height; + + float iou = calculate_iou(target_rx1, target_ry1, target_rx2, target_ry2, ir_rx1, ir_ry1, + ir_rx2, ir_ry2); + + float ir_cx = (ir_rx1 + ir_rx2) / 2.0f; + float ir_cy = (ir_ry1 + ir_ry2) / 2.0f; + float dist = std::sqrt((ir_cx - target_cx) * (ir_cx - target_cx) + + (ir_cy - target_cy) * (ir_cy - target_cy)); + + if (iou > best_iou) { + best_iou = iou; + best_idx = i; + } else if (best_iou <= 0.0f && dist < min_center_dist) { + min_center_dist = dist; + best_idx = i; + } + } + + ImageRGB face = detections[best_idx].image; + const SpoofResult result = face_as.inference(face); + + const bool is_frame_real = result.is_real; + if (!is_frame_real) { + spdlog::warn( + "FaceAuth: IR liveness check [frame {}/{}] — FAILED (real={:.4f}, spoof={:.4f}, " + "threshold={:.3f})", + frame_idx + 1, ir_frames.size(), result.real_score, result.spoof_score, + antispoof_threshold); + if (debug) { + saveFailedFace(username, face, "ir_spoof_frame_" + std::to_string(frame_idx)); + } + } else { + spdlog::debug( + "FaceAuth: IR liveness check [frame {}/{}] — PASSED (real={:.4f}, spoof={:.4f}, " + "threshold={:.3f})", + frame_idx + 1, ir_frames.size(), result.real_score, result.spoof_score, + antispoof_threshold); + passed_frames++; + } + } + + if (valid_frames_processed == 0) { + spdlog::error("FaceAuth: IR liveness check — all frames were empty/invalid"); + return false; + } + + constexpr int kMinDetectedFrames = 2; + constexpr int kRequiredRealFrames = 2; + + if (detected_frames_processed < kMinDetectedFrames) { + spdlog::warn( + "FaceAuth: IR liveness check AGGREGATE FAILED — only {}/{} valid frame(s) contained a " + "detectable face (required >= {})", + detected_frames_processed, valid_frames_processed, kMinDetectedFrames); + return false; + } + + int required_passes = std::min(kRequiredRealFrames, detected_frames_processed); + bool passed = (passed_frames >= required_passes); + + if (passed) { + spdlog::debug( + "FaceAuth: IR liveness check AGGREGATE PASSED — {}/{} detected frame(s) passed " + "({} valid frame(s), required >= {})", + passed_frames, detected_frames_processed, valid_frames_processed, required_passes); + } else { + spdlog::warn( + "FaceAuth: IR liveness check AGGREGATE FAILED — {}/{} detected frame(s) passed " + "({} valid frame(s), required >= {})", + passed_frames, detected_frames_processed, valid_frames_processed, required_passes); + } + return passed; + } catch (const std::exception& e) { + spdlog::error("FaceAuth: IR liveness check — exception during inference: {}", e.what()); + return false; + } +} + +bool checkAntispoofByIRCrops(const std::vector& ir_faces, + const std::string& antispoof_model_path, float antispoof_threshold, + const std::string& username, bool debug) { + spdlog::debug("FaceAuth: IR liveness check | preselected_faces={} antispoof_threshold={:.3f}", + ir_faces.size(), antispoof_threshold); + + if (ir_faces.empty()) { + spdlog::error("FaceAuth: IR liveness check — received no preselected face crops, aborting"); + return false; } - if (frame.empty()) { - spdlog::error("FaceAuth: IR presence check — frame capture failed from '{}'", device_path); + if (antispoof_model_path.empty() || !std::ifstream(antispoof_model_path).good()) { + spdlog::error("FaceAuth: IR liveness check — anti-spoofing model not found: {}", + antispoof_model_path); return false; } - spdlog::debug("FaceAuth: IR presence check — frame captured ({}x{})", frame.width, frame.height); + int passed_faces = 0; + int valid_faces_processed = 0; try { - FaceDetection detector(detection_model_path, 640, {"face"}, detection_threshold); - std::vector detections = detector.inference(frame); - - // TODO: This is a face presence check only, NOT a real liveness detector. - // The YOLO model only checks for any face-shaped bounding box in the IR frame. - // An attacker holding a printed photo or displaying a photo on a screen will - // pass this check once the IR camera capture succeeds. A real anti-spoofing - // solution requires a specialized IR liveness model (e.g. texture analysis, - // depth verification, or dedicated IR liveness classification). - // Tracked in upcoming issue. - if (detections.empty()) { - spdlog::error( - "FaceAuth: IR presence check FAILED — no face bounding box detected " - "(threshold={:.3f}, device='{}')", - detection_threshold, device_path); - if (debug) { - saveFailedFace(username, frame, "ir_no_face"); + FaceAntiSpoofing face_as(antispoof_model_path, 128, antispoof_threshold); + + for (size_t face_idx = 0; face_idx < ir_faces.size(); ++face_idx) { + const auto& face = ir_faces[face_idx]; + if (face.empty()) { + continue; + } + valid_faces_processed++; + + const SpoofResult result = face_as.inference(face); + const bool is_face_real = result.is_real; + if (!is_face_real) { + spdlog::warn( + "FaceAuth: IR liveness check [face {}/{}] — FAILED (crop={}x{}, real={:.4f}, " + "spoof={:.4f}, threshold={:.3f})", + face_idx + 1, ir_faces.size(), face.width, face.height, result.real_score, + result.spoof_score, antispoof_threshold); + if (debug) { + saveFailedFace(username, face, "ir_spoof_face_" + std::to_string(face_idx)); + } + } else { + spdlog::debug( + "FaceAuth: IR liveness check [face {}/{}] — PASSED (crop={}x{}, real={:.4f}, " + "spoof={:.4f}, threshold={:.3f})", + face_idx + 1, ir_faces.size(), face.width, face.height, result.real_score, + result.spoof_score, antispoof_threshold); + passed_faces++; } - return false; } - // Log every detection so the caller can see confidence vs threshold. - for (size_t i = 0; i < detections.size(); ++i) { - spdlog::debug("FaceAuth: IR presence check — detection[{}] conf={:.4f} (threshold={:.3f})", - i, detections[i].conf, detection_threshold); + if (valid_faces_processed == 0) { + spdlog::error("FaceAuth: IR liveness check — all preselected face crops were empty/invalid"); + return false; } - spdlog::debug( - "FaceAuth: IR presence check PASSED — {} face(s) detected, best conf={:.4f} " - "(NOTE: presence check only, not liveness)", - detections.size(), detections[0].conf); - return true; + constexpr int kRequiredRealFaces = 2; + const int required_passes = std::min(kRequiredRealFaces, valid_faces_processed); + const bool passed = passed_faces >= required_passes; + + if (passed) { + spdlog::debug( + "FaceAuth: IR liveness check AGGREGATE PASSED — {}/{} preselected face(s) passed " + "(required >= {})", + passed_faces, valid_faces_processed, required_passes); + } else { + spdlog::warn( + "FaceAuth: IR liveness check AGGREGATE FAILED — {}/{} preselected face(s) passed " + "(required >= {})", + passed_faces, valid_faces_processed, required_passes); + } + return passed; } catch (const std::exception& e) { - spdlog::error("FaceAuth: IR presence check — exception during detection: {}", e.what()); + spdlog::error("FaceAuth: IR liveness check — exception during preselected inference: {}", + e.what()); return false; } } diff --git a/auth/face/antispoofing/ir_camera_as.h b/auth/face/antispoofing/ir_camera_as.h index c1788f1..16b6c46 100644 --- a/auth/face/antispoofing/ir_camera_as.h +++ b/auth/face/antispoofing/ir_camera_as.h @@ -2,25 +2,29 @@ #include -namespace biopass { +#include "image_utils.h" -class ICameraCaptureSession; +namespace biopass { -// IR face-presence check. -// Returns true when the YOLO face-detection model finds at least one bounding -// box in the captured IR frame. +// IR face liveness classification. +// Accepts a pre-captured IR frame (captured synchronously by the caller before +// launching the async task) so that no camera session pointer is captured by +// the async closure. This prevents use-after-free if the task lifetime is ever +// decoupled from the session owner in the future. // -// IMPORTANT — this is NOT a liveness detector. It verifies that a face shape -// is visible in the IR stream; a printed photo placed in front of an IR camera -// can still pass this check. Treat it as a "blank-frame guard" rather than -// anti-spoofing in the strict sense. -// -// warmup_delay_ms: extra sleep (ms) inserted after the V4L2 warmup frames and -// before the capture, giving IR LEDs and auto-exposure time to stabilise. -bool checkAntispoofByIRCamera(const std::string& ir_camera_path, - const std::string& detection_model_path, - float detection_threshold, const std::string& username, - bool debug, ICameraCaptureSession* session = nullptr, - int warmup_delay_ms = 300); +// Returns true when the YOLO face-detection model finds at least one face, and +// the MobileNetV3 anti-spoofing model classifies the cropped face as real. +bool checkAntispoofByIRCamera(const std::vector& ir_frames, float target_rx1, + float target_ry1, float target_rx2, float target_ry2, + const std::string& detection_model_path, float detection_threshold, + const std::string& antispoof_model_path, float antispoof_threshold, + const std::string& username, bool debug); + +// IR face liveness classification for faces that have already been detected and +// cropped by the caller. This avoids running the detector twice on the same IR +// frames when the capture/selection stage already proved face presence. +bool checkAntispoofByIRCrops(const std::vector& ir_faces, + const std::string& antispoof_model_path, float antispoof_threshold, + const std::string& username, bool debug); } // namespace biopass diff --git a/auth/face/common/camera_capture.cc b/auth/face/common/camera_capture.cc index 89462e1..927134a 100644 --- a/auth/face/common/camera_capture.cc +++ b/auth/face/common/camera_capture.cc @@ -51,7 +51,7 @@ std::vector enumerate_linux_video_capture_paths() { continue; } - v4l2_capability video_cap {}; + v4l2_capability video_cap{}; if (::ioctl(fd, VIDIOC_QUERYCAP, &video_cap) == 0 && (video_cap.device_caps & V4L2_CAP_VIDEO_CAPTURE) != 0) { paths.emplace_back(device_path); @@ -80,14 +80,14 @@ int xioctl_retry(int fd, unsigned long request, void* arg) { } std::optional find_camera_format_by_fourcc(CapContext ctx, CapDeviceID device_index, - uint32_t fourcc) { + uint32_t fourcc) { const int32_t format_count = Cap_getNumFormats(ctx, device_index); if (format_count <= 0) { return std::nullopt; } for (int32_t format_index = 0; format_index < format_count; ++format_index) { - CapFormatInfo format {}; + CapFormatInfo format{}; if (Cap_getFormatInfo(ctx, device_index, static_cast(format_index), &format) != CAPRESULT_OK) { continue; @@ -102,8 +102,8 @@ std::optional find_camera_format_by_fourcc(CapContext ctx, CapDev bool capture_frame_openpnp(CapContext ctx, CapStream stream, uint8_t* buffer, size_t buffer_size, int capture_timeout_ms, int poll_interval_ms) { - const auto capture_deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(std::max(0, capture_timeout_ms)); + const auto capture_deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(std::max(0, capture_timeout_ms)); const bool has_timeout = capture_timeout_ms > 0; const auto sleep_interval = std::chrono::milliseconds(std::max(1, poll_interval_ms)); @@ -197,7 +197,7 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { return; } - v4l2_format v4l2_format_info {}; + v4l2_format v4l2_format_info{}; v4l2_format_info.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; v4l2_format_info.fmt.pix.width = format.width; v4l2_format_info.fmt.pix.height = format.height; @@ -210,7 +210,7 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { } if (format.fps > 0) { - v4l2_streamparm stream_params {}; + v4l2_streamparm stream_params{}; stream_params.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; stream_params.parm.capture.timeperframe.numerator = 1; stream_params.parm.capture.timeperframe.denominator = format.fps; @@ -222,19 +222,20 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { bytes_per_line_ = std::max(v4l2_format_info.fmt.pix.bytesperline, v4l2_format_info.fmt.pix.width); - v4l2_requestbuffers request_buffers {}; + v4l2_requestbuffers request_buffers{}; request_buffers.count = 4; request_buffers.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; request_buffers.memory = V4L2_MEMORY_MMAP; if (xioctl_retry(fd_, VIDIOC_REQBUFS, &request_buffers) == -1 || request_buffers.count == 0) { - spdlog::error("FaceAuth: VIDIOC_REQBUFS failed for {}: {}", device_path_, std::strerror(errno)); + spdlog::error("FaceAuth: VIDIOC_REQBUFS failed for {}: {}", device_path_, + std::strerror(errno)); close(); return; } buffers_.resize(request_buffers.count); for (uint32_t index = 0; index < request_buffers.count; ++index) { - v4l2_buffer buffer {}; + v4l2_buffer buffer{}; buffer.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buffer.memory = V4L2_MEMORY_MMAP; buffer.index = index; @@ -246,8 +247,8 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { } buffers_[index].length = buffer.length; - buffers_[index].start = ::mmap(nullptr, buffer.length, PROT_READ | PROT_WRITE, MAP_SHARED, - fd_, buffer.m.offset); + buffers_[index].start = + ::mmap(nullptr, buffer.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, buffer.m.offset); if (buffers_[index].start == MAP_FAILED) { spdlog::error("FaceAuth: mmap failed for {}: {}", device_path_, std::strerror(errno)); close(); @@ -256,12 +257,13 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { } for (uint32_t index = 0; index < buffers_.size(); ++index) { - v4l2_buffer buffer {}; + v4l2_buffer buffer{}; buffer.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buffer.memory = V4L2_MEMORY_MMAP; buffer.index = index; if (xioctl_retry(fd_, VIDIOC_QBUF, &buffer) == -1) { - spdlog::error("FaceAuth: VIDIOC_QBUF failed for {}: {}", device_path_, std::strerror(errno)); + spdlog::error("FaceAuth: VIDIOC_QBUF failed for {}: {}", device_path_, + std::strerror(errno)); close(); return; } @@ -293,7 +295,7 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { std::chrono::milliseconds(std::max(0, capture_timeout_ms_)); while (captured_frames < total_frames_needed) { - pollfd poll_info {}; + pollfd poll_info{}; poll_info.fd = fd_; poll_info.events = POLLIN; @@ -308,8 +310,7 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { const auto remaining_ms = std::chrono::duration_cast(capture_deadline - now).count(); - poll_timeout_ms = - std::max(1, std::min(poll_interval_ms_, static_cast(remaining_ms))); + poll_timeout_ms = std::max(1, std::min(poll_interval_ms_, static_cast(remaining_ms))); } const int poll_rc = ::poll(&poll_info, 1, poll_timeout_ms); @@ -325,7 +326,7 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { continue; } - v4l2_buffer buffer {}; + v4l2_buffer buffer{}; buffer.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buffer.memory = V4L2_MEMORY_MMAP; if (xioctl_retry(fd_, VIDIOC_DQBUF, &buffer) == -1) { @@ -357,12 +358,14 @@ class V4L2GreyCameraSession : public ICameraCaptureSession { } if (xioctl_retry(fd_, VIDIOC_QBUF, &buffer) == -1) { - spdlog::error("FaceAuth: VIDIOC_QBUF failed for {}: {}", device_path_, std::strerror(errno)); + spdlog::error("FaceAuth: VIDIOC_QBUF failed for {}: {}", device_path_, + std::strerror(errno)); close(); return {}; } if (!image.empty()) { + warmup_frames_ = 0; return image; } } @@ -466,8 +469,9 @@ std::optional resolveCameraDeviceIdx( } } - spdlog::error("FaceAuth: Camera path '{}' was not found among capture-capable /dev/video* devices", - *linux_video_device_path); + spdlog::error( + "FaceAuth: Camera path '{}' was not found among capture-capable /dev/video* devices", + *linux_video_device_path); return std::nullopt; } @@ -523,11 +527,12 @@ std::unique_ptr openCameraSession( return session; } - CapFormatInfo fmt {}; + CapFormatInfo fmt{}; CapResult fmt_result = Cap_getFormatInfo(ctx, *device_index, 0, &fmt); if (fmt_result != CAPRESULT_OK) { spdlog::error("FaceAuth: Failed to get camera format info for '{}' (index {}, code {})", - device_label(linux_video_device_path), *device_index, static_cast(fmt_result)); + device_label(linux_video_device_path), *device_index, + static_cast(fmt_result)); Cap_releaseContext(ctx); return nullptr; } @@ -546,9 +551,8 @@ std::unique_ptr openCameraSession( return nullptr; } - spdlog::warn( - "FaceAuth: Device '{}' reports GREY format; using V4L2 GREY fallback on '{}'", - device_label(linux_video_device_path), *linux_path); + spdlog::warn("FaceAuth: Device '{}' reports GREY format; using V4L2 GREY fallback on '{}'", + device_label(linux_video_device_path), *linux_path); Cap_releaseContext(ctx); auto session = std::make_unique(*linux_path, fmt, warmup_frames, capture_timeout_ms, poll_interval_ms); @@ -566,9 +570,9 @@ std::unique_ptr openCameraSession( return nullptr; } - auto session = std::make_unique( - ctx, stream, fmt.width, fmt.height, device_label(linux_video_device_path), capture_timeout_ms, - poll_interval_ms); + auto session = std::make_unique(ctx, stream, fmt.width, fmt.height, + device_label(linux_video_device_path), + capture_timeout_ms, poll_interval_ms); if (!session->isOpen()) { return nullptr; } diff --git a/auth/face/detection/face_detection.cc b/auth/face/detection/face_detection.cc index c5338f7..9040eeb 100644 --- a/auth/face/detection/face_detection.cc +++ b/auth/face/detection/face_detection.cc @@ -88,7 +88,12 @@ std::vector FaceDetection::inference(const ImageRGB &image) { results.push_back(det); } - std::sort(results.begin(), results.end(), std::greater()); + std::sort(results.begin(), results.end(), [](const Detection &a, const Detection &b) { + if (a.conf == b.conf) { + return a.area() > b.area(); + } + return a.conf > b.conf; + }); return results; } diff --git a/auth/face/face_auth.cc b/auth/face/face_auth.cc index 009d83b..8ca763e 100644 --- a/auth/face/face_auth.cc +++ b/auth/face/face_auth.cc @@ -128,7 +128,7 @@ AuthResult FaceAuth::authenticate(const std::string& username, const AuthConfig& kIrCaptureWarmupFrames, kIrCaptureTimeoutMs, kIrCapturePollIntervalMs); } - if (!checkAntiSpoof(face_config_, username, face, config, ir_camera_session_.get())) { + if (!checkAntiSpoof(face_config_, username, detectedImages[0], loginFace.width, loginFace.height, config, ir_camera_session_.get())) { spdlog::warn("FaceAuth: Anti-spoofing failed — returning Failure (no retry allowed)"); // Always tear down the IR session so a subsequent call cannot reuse a // partially-warmed camera to bypass the check. diff --git a/auth/face/models/mobilenetv3_antispoof.onnx b/auth/face/models/mobilenetv3_antispoof.onnx index 3b5a422..23371ee 100644 Binary files a/auth/face/models/mobilenetv3_antispoof.onnx and b/auth/face/models/mobilenetv3_antispoof.onnx differ diff --git a/docs/IR camera.md b/docs/IR camera.md index d47e2f9..ca93155 100644 --- a/docs/IR camera.md +++ b/docs/IR camera.md @@ -1,6 +1,8 @@ # IR Camera Guide -Biopass uses infrared (IR) camera for face anti-spoofing, rather than relying only on the RGB anti-spoofing AI model. This is usually configured as a Linux video device path such as `/dev/video2`. If your devices supports IR camera, you can turn on this option by using the configuration UI. +Biopass supports an infrared (IR) camera for face liveness detection. When configured, the IR pipeline waits for a usable IR frame, selects face crops from quality-filtered IR frames, and classifies those crops with the MobileNetV3 anti-spoofing model. The model was originally trained on RGB imagery and is evaluated in grayscale-as-RGB mode by replicating the single IR channel. + +This texture-based check is designed to help reject printed photos or screen replays. It is not an IR-domain fine-tuned model and does not provide hardware-backed depth or TPM-style guarantees. ## Requirements @@ -10,6 +12,22 @@ Biopass uses infrared (IR) camera for face anti-spoofing, rather than relying on Biopass only reads from the configured IR video device. It does not manage the hardware IR emitter for your laptop or webcam. +## How It Works + +The IR anti-spoofing pipeline runs as a layered liveness check: + +1. **LED / exposure warm-up** — the IR camera may initially return a dark frame, so Biopass waits until brightness statistics (`mean` and `max`) show that the frame is usable. +2. **Frame quality filtering** — dark or stale IR frames are skipped before they reach the liveness classifier. +3. **Face crop selection** — a YOLO model (`yolov8n-face.onnx`) locates a face in usable IR frames. The current threshold for IR face presence is intentionally lower than identity recognition because this step only proves that the RGB face area has a usable IR face crop. +4. **Minimum face scale check** — the detected IR face must occupy enough of the frame for reliable texture-based liveness classification. Very small / distant faces are skipped instead of being treated as spoof evidence. The default threshold requires the IR face bounding box to cover at least 8% of the frame and can be tuned with `anti_spoofing.ir_min_face_area_ratio`. +5. **Liveness classification** — a MobileNetV3 model (`mobilenetv3_antispoof.onnx`) classifies each selected crop as **real** or **spoof**. Since the model expects RGB, the single grayscale IR channel is cloned into all 3 color channels. + +For the sudo/PAM path, Biopass currently collects 2 usable IR face crops and requires both to pass liveness. A crop is accepted as real only when the real score meets the configured threshold and is greater than the spoof score. This strict 2/2 policy favors security over convenience; motion blur, poor alignment, or unstable lighting may fail closed. + +If the IR pipeline is enabled alongside the RGB AI model, **both** methods must independently pass before authentication is granted. The RGB AI anti-spoofing task and IR liveness task run in parallel. + +When debug mode is enabled, Biopass saves additional IR diagnostics, including the selected IR face crop and the resized `128x128` image that is passed to the anti-spoofing model. These images can help distinguish between a real model mismatch and insufficient input detail caused by distance, blur, or poor crop scale. + ## 1. Find the IR Camera Device List video devices: @@ -32,7 +50,7 @@ Open the Biopass desktop app and go to the face settings. In the anti-spoofing section: -1. Enable face anti-spoofing if you want to use the AI anti-spoofing model too. +1. Enable face anti-spoofing if you want to use the RGB AI anti-spoofing model too. 2. Set `IR Camera` to the correct `/dev/video*` device. 3. Save your configuration. @@ -51,7 +69,7 @@ wget https://github.com/EmixamPP/linux-enable-ir-emitter/releases/download/$VERS sudo tar -C / --no-same-owner -m -h -vxzf $DIST ``` -Then, configure your IR emitter: +Then, configure your IR emitter: ```bash sudo linux-enable-ir-emitter configure