Préambule
Lors du développement d'une application web mobile nécesssitant une authentification biométrique, nous avons fait le choix d'implémenter une solution de reconnaissance faciale côté client. Cette approche évite la complexité des applications natives tout en offrant une expérience utilisateur fluide. Cette documentation présente une implémentation complète utilisant l'écosystème Vue 3 avec TypeScript.
Installation et configuration des dépendances
Commencez par installer la bibliothèque principale via npm :
npm install face-api.js
Les modèles de reconnaissance doivent être téléchargés séparément depuis le dépôt GitHub :
https://github.com/justadudewhohacks/face-api.js/tree/master/weights
Procurez-vous l'ensemble des fichiers du répertoire weights et archivez-les dans un dossier models placé à la racine du dossier public de votre projet. Cette disposition assure une exposition directe via le serveur de développement.
Déclarations TypeScript pour face-api.js
Créez un fichier de définition de types pour bénéficier de l'autocomplétion et de la vérification statique :
// src/types/face-api.d.ts
declare module "face-api.js" {
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface FacialDetection {
confidence: number;
box: BoundingBox;
}
export interface LandmarkSet {
keypoints: Array<{ x: number; y: number }>;
}
export class TinyFaceDetectorOptions {
constructor(params?: { inputSize?: number; confidenceThreshold?: number });
}
export const neuralNets: {
tinyFaceDetector: {
load: (path?: string) => Promise<void>;
loadFromUri: (uri: string) => Promise<void>;
loadFromDisk: (path: string) => Promise<void>;
};
faceLandmark68Net: {
load: (path?: string) => Promise<void>;
loadFromUri: (uri: string) => Promise<void>;
loadFromDisk: (path: string) => Promise<void>;
};
};
export function detectSingleFace(
mediaElement: HTMLVideoElement | HTMLImageElement | HTMLCanvasElement,
options: TinyFaceDetectorOptions
): Promise<
| {
withFaceLandmarks: () => Promise<{
detection: FacialDetection;
landmarks: LandmarkSet;
} | null>;
}
| undefined
>;
export function detectAllFaces(
mediaElement: HTMLVideoElement | HTMLImageElement | HTMLCanvasElement,
options: TinyFaceDetectorOptions
): Promise<FacialDetection[]>;
}
Composable Vue pour la capture faciale
Implémentez un composable réutilisable gérant l'ensemble du flux de détection :
// src/composables/useFacialRecognition.ts
import { ref, onBeforeUnmount, computed } from "vue";
import * as faceapi from "face-api.js";
interface RecognitionConfig {
frameWidth?: number;
frameHeight?: number;
stabilityThreshold?: number;
}
type CaptureCallback = (imageData: string | null) => void;
export function useFacialRecognition(
config: RecognitionConfig = {},
onCapture?: CaptureCallback
) {
const cameraFeed = ref<HTMLVideoElement | null>(null);
const captureState = ref<
"idle" | "searching" | "stabilizing" | "snapshotting" | "completed"
>("idle");
const capturedImage = ref<string | null>(null);
let mediaStream: MediaStream | null = null;
let isProcessing = false;
let consecutiveValidFrames = 0;
const FRAME_WIDTH = config.frameWidth ?? 320;
const FRAME_HEIGHT = config.frameHeight ?? 320;
const MIN_CONSECUTIVE_FRAMES = config.stabilityThreshold ?? 12;
const EYE_TILT_THRESHOLD = 18;
const NOSE_CENTER_OFFSET_LIMIT = 55;
const MOUTH_ANGLE_TOLERANCE = 18;
const MIN_FACE_HEIGHT_PERCENTAGE = 0.22;
const MIN_FACE_WIDTH_PERCENTAGE = 0.16;
const MIN_KEYPOINTS_REQUIRED = 32;
const stateMessage = computed(() => {
const messages = {
idle: "Initialisation en cours",
searching: "Recherche d'un visage",
stabilizing: "Visage détecté, stabilisation...",
snapshotting: "Capture en cours",
completed: "Acquisition terminée"
};
return messages[captureState.value];
});
async function initializeModels() {
const context = getRuntimeContext();
if (context === "browser") {
await faceapi.neuralNets.tinyFaceDetector.loadFromUri("/models");
await faceapi.neuralNets.faceLandmark68Net.loadFromUri("/models");
console.log("Modèles IA chargés avec succès");
} else {
throw new Error("Environnement d'exécution non supporté");
}
}
function getRuntimeContext() {
return typeof window !== "undefined" ? "browser" : "unknown";
}
async function startDetection() {
console.log("Démarrage du système de détection");
captureState.value = "searching";
consecutiveValidFrames = 0;
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: FRAME_WIDTH },
height: { ideal: FRAME_HEIGHT },
facingMode: "user"
},
audio: false
});
if (cameraFeed.value) {
cameraFeed.value.srcObject = mediaStream;
await cameraFeed.value.play();
}
} catch (err) {
console.error("Erreur d'accès caméra:", err);
captureState.value = "idle";
return;
}
await initializeModels();
isProcessing = true;
// Compensation du délai iOS
setTimeout(() => processVideoStream(), 1500);
}
async function processVideoStream() {
if (!cameraFeed.value || !isProcessing) return;
if (captureState.value === "completed") return;
const detectionOptions = new faceapi.TinyFaceDetectorOptions();
try {
const result = await faceapi.detectSingleFace(
cameraFeed.value,
detectionOptions
)?.withFaceLandmarks();
if (result) {
const keypoints = result.landmarks.keypoints;
if (keypoints.length >= MIN_KEYPOINTS_REQUIRED) {
const faceBox = result.detection.box;
const heightRatio = faceBox.height / cameraFeed.value.videoHeight;
const widthRatio = faceBox.width / cameraFeed.value.videoWidth;
if (
heightRatio < MIN_FACE_HEIGHT_PERCENTAGE ||
widthRatio < MIN_FACE_WIDTH_PERCENTAGE
) {
consecutiveValidFrames = 0;
captureState.value = "searching";
} else if (validateFacialPose(keypoints)) {
consecutiveValidFrames++;
if (consecutiveValidFrames >= MIN_CONSECUTIVE_FRAMES) {
captureState.value = "stabilizing";
setTimeout(() => {
if (captureState.value === "stabilizing") {
captureState.value = "snapshotting";
takeSnapshot();
}
}, 800);
}
} else {
consecutiveValidFrames = 0;
if (captureState.value !== "stabilizing") {
captureState.value = "searching";
}
}
} else {
consecutiveValidFrames = 0;
captureState.value = "searching";
}
} else {
consecutiveValidFrames = 0;
captureState.value = "searching";
}
} catch (error) {
console.error("Erreur de traitement:", error);
}
requestAnimationFrame(processVideoStream);
}
function validateFacialPose(keypoints: Array<{ x: number; y: number }>): boolean {
const leftEye = calculateCenter(keypoints.slice(36, 42));
const rightEye = calculateCenter(keypoints.slice(42, 48));
const noseTip = keypoints[30];
const mouthLeft = keypoints[48];
const mouthRight = keypoints[54];
const eyeAngle = Math.atan2(rightEye.y - leftEye.y, rightEye.x - leftEye.x) * (180 / Math.PI);
const videoCenter = (cameraFeed.value?.videoWidth ?? FRAME_WIDTH) / 2;
const noseOffset = Math.abs(noseTip.x - videoCenter);
const mouthAngle = Math.atan2(mouthRight.y - mouthLeft.y, mouthRight.x - mouthLeft.x) * (180 / Math.PI);
return (
Math.abs(eyeAngle) < EYE_TILT_THRESHOLD &&
noseOffset < NOSE_CENTER_OFFSET_LIMIT &&
Math.abs(mouthAngle) < MOUTH_ANGLE_TOLERANCE
);
}
function calculateCenter(points: Array<{ x: number; y: number }>): { x: number; y: number } {
return {
x: points.reduce((sum, p) => sum + p.x, 0) / points.length,
y: points.reduce((sum, p) => sum + p.y, 0) / points.length
};
}
function takeSnapshot() {
const video = cameraFeed.value;
if (!video) return;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context) return;
context.drawImage(video, 0, 0);
capturedImage.value = canvas.toDataURL("image/jpeg", 0.95);
captureState.value = "completed";
stopDetection();
onCapture?.(capturedImage.value);
}
function stopDetection() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (cameraFeed.value) cameraFeed.value.srcObject = null;
isProcessing = false;
consecutiveValidFrames = 0;
}
async function restartDetection() {
stopDetection();
capturedImage.value = null;
captureState.value = "idle";
await startDetection();
}
function resetSystem() {
stopDetection();
capturedImage.value = null;
captureState.value = "idle";
}
onBeforeUnmount(() => stopDetection());
return {
cameraFeed,
captureState,
capturedImage,
stateMessage,
startDetection,
restartDetection,
stopDetection,
resetSystem
};
}
Composant d'interface utilisateur
Créez un composant modal pour l'interaction avec l'utilisateur :
// src/components/FaceScanModal.vue
<template>
<div class="scanner-container">
<h3 class="scanner-title">Vérification biométrique</h3>
<p class="scanner-instruction">Positionnez votre visage dans le cadre</p>
<video autoplay="" class="camera-preview" muted="" playsinline="" ref="cameraFeed" webkit-playsinline=""></video>
<div class="status-indicator">
<van-loading size="20px" v-if="captureState !== 'completed'" vertical="">
{{ stateMessage }}
</van-loading>
<div class="success-badge" v-else="">✓ Capture réussie</div>
</div>
<div class="timer-display" v-if="captureState !== 'completed'">
Temps restant: {{ countdown }}s
</div>
</div>
</template>
<script lang="ts" setup="">
import { ref, onMounted, watch } from "vue";
import { useFacialRecognition } from "@/composables/useFacialRecognition";
import { showToast } from "vant";
const emit = defineEmits<{ (e: "scan-complete", data: string): void; (e: "scan-cancelled"): void }>();
const countdown = ref(10);
const countdownTimer = ref<ReturnType<typeof setInterval> | null>(null);
const handleCapture = (imageData: string | null) => {
if (imageData) {
emit("scan-complete", imageData);
} else {
emit("scan-cancelled");
}
};
const {
cameraFeed,
captureState,
startDetection,
stopDetection,
stateMessage
} = useFacialRecognition(
{
frameWidth: 280,
frameHeight: 280,
stabilityThreshold: 10
},
handleCapture
);
const startCountdown = () => {
countdownTimer.value = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
stopDetection();
emit("scan-cancelled");
}
}, 1000);
};
onMounted(async () => {
await startDetection();
startCountdown();
});
watch(captureState, (newState) => {
if (newState === "completed" && countdownTimer.value) {
clearInterval(countdownTimer.value);
}
});
</script>
Intégration dans une page applicative
Utilisez le composant modal dans votre vue parente :
// src/views/IdentityVerification.vue
<template>
<div class="verification-page">
<van-button block="" type="primary" v-if="!isNativeApp">
Démarrer la reconnaissance faciale
</van-button>
<van-popup :style="{ width: '100%', height: '100%' }" position="full" v-model:show="scannerVisible">
<facescanmodal></facescanmodal>
</van-popup>
</div>
</template>
<script lang="ts" setup="">
import { ref } from "vue";
import FaceScanModal from "@/components/FaceScanModal.vue";
const scannerVisible = ref(false);
const isNativeApp = ref(typeof plus !== "undefined");
function launchScanner() {
scannerVisible.value = true;
}
async function onScanSuccess(imageBase64: string) {
scannerVisible.value = false;
// Appel API pour vérification backend
try {
const verificationResult = await verifyFace({
imageData: imageBase64.split("base64,")[1],
userId: "currentUserId"
});
console.log("Vérification réussie:", verificationResult);
} catch (error) {
console.error("Échec de vérification:", error);
}
}
function onScanCancelled() {
scannerVisible.value = false;
console.log("Scan annulé par l'utilisateur");
}
</script>
Considérations techniques importantes
Gestion des permissions
Que ce soit en environnement web, Electron ou WebView Android, l'accès à la caméra nécessite une autorisation explicite de l'utilisateur. Pour les applications Android packagées avec HBuilderX, les permissions suivantes doivent être déclarées dans le manifeste :
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
Configuration navigateur pour HTTP
Pour les tests en environnement local non sécurisé (HTTP), Chrome nécessite une configuration spéciale :
- Ouvrez
chrome://flags/#unsafely-treat-insecure-origin-as-secure - Ajoutez l'URL de votre serveur de développement
- Activez l'option
- Redémarrez le navigateur
Compatibilité iOS
Les appareils iOS exhibent un comportement particuleir nécessitent un délai avant l'initialisation du flux vidéo. Le composable intègre déjà une temporisation de 1500ms via setTimeout pour contourner cette limitation.
Serveur de modèles alternatif
Si les modèles ne se chargent pas correctement en production (notamment dans des WebViews natives), exposez-les via un serveur HTTP dédié. Une configuration Nginx minimale suffit :
location /models/ {
alias /chemin/vers/vos/modeles/;
add_header Access-Control-Allow-Origin *;
}
Modifiez ensuite l'appel à loadFromUri pour pointer vers cette URL distante.