a
This commit is contained in:
parent
e2be1ed7ff
commit
0e5d2b0c9e
|
|
@ -1332,9 +1332,7 @@ public function getTarifActeAdherent($idAdherent)
|
|||
$codeSociete = $_SESSION['p_codeSociete'];
|
||||
$codePrestataire = $_SESSION['p_codePrestataire_C'];
|
||||
$idBeneficiaire = $_SESSION['p_idBeneficiaire_C'];
|
||||
$user = $_SESSION['p_login'];
|
||||
|
||||
// $sql = 'call sp_p_get_beneficiaire_tag(?, ?, ?, ?);';
|
||||
$sql = 'call sp_p_checkdemandereconnaissancefaciale(?, ?, ?);';
|
||||
$resultat = $this->executerRequete($sql, array($codeSociete, $codePrestataire, $idBeneficiaire));
|
||||
$ligne = $resultat->fetch(PDO::FETCH_ASSOC);
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
<?php
|
||||
$_SESSION['p_messageFace'] = "";
|
||||
?>
|
||||
|
||||
<div class="modal fade" id="pop_rec_faciale" role="dialog" data-backdrop="static" data-keyboard="false" >
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header">
|
||||
<button id="btn_close_pop_rec_faciale" name="btn_close_pop_rec_faciale" type="button" class="close" data-bs-dismiss="modal" onclick="javascript:fiche_beneficiaire();"> <?= _("Fermer") ?> </button>
|
||||
<h4 class="modal-title"> <?= _("RECONNAISSANCE FACIALE") ?> </h4>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Début ajout 25/09/20225 -->
|
||||
<div class="row">
|
||||
<?php include 'liveness.php'; ?>
|
||||
</div>
|
||||
|
||||
<!-- <div id="div_ebene" hidden> -->
|
||||
<div id="div_ebene">
|
||||
<!-- Fin ajout 25/09/20225 -->
|
||||
|
||||
<table class="table table-responsive table-condensed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="48%">
|
||||
<button id="ebene_take_photo_face" name="ebene_take_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:takephoto();"> <?= _("PRENDRE UNE PHOTO") ?> </button>
|
||||
</td>
|
||||
|
||||
<td > </td>
|
||||
<?php if ($faceRegistered=="1") : ?>
|
||||
<td width="48%">
|
||||
<button disabled id="ebene_confirmer_photo_face" name="ebene_confirmer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:ebene_confirmer_photo_face();"> <?= _("CONFIRMER LA PHOTO") ?> </button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if ($faceRegistered!="1") : ?>
|
||||
<td width="48%">
|
||||
<button disabled id="ebene_enregistrer_photo_face" name="ebene_enregistrer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:ebene_enregistrer_photo_face();"> <?= _("ENREGISTRER LA PHOTO") ?> </button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6" >
|
||||
<legend style="text-align:center" >Webcam</legend>
|
||||
<video id="video_face" name="video_face" autoplay height="250" align="center"></video><br />
|
||||
</div>
|
||||
|
||||
<div class="col-6" >
|
||||
|
||||
<legend style="text-align:center" >Photo</legend>
|
||||
<img id="photo_face" name="photo_face" src="" />
|
||||
<form id="form_face" name="form_face" enctype="multipart/form-data" method="post" action="Fichebeneficiaire/ebeneenregistrerface">
|
||||
<INPUT class="sr-only" TYPE="text" id="compare_face" name="compare_face" value="<?= $faceRegistered ?>">
|
||||
<INPUT class="sr-only" TYPE="text" id="del_face" name="del_face" value="0">
|
||||
<INPUT class="image-tag" TYPE="hidden" id="image_face" name="image_face" >
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" name="canvas" style="display: none;" width="350" height="260"></canvas>
|
||||
|
||||
<?php if ($faceRegistered=="1") : ?>
|
||||
<!--
|
||||
<button disabled id="ebene_supprimer_photo_face" name="ebene_supprimer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-danger" onclick="javascript:ebene_supprimer_photo_face();"> <?= _("SUPPRIMER LA PHOTO") ?> </button>
|
||||
|
||||
<span style='font-size:12pt;font-weight: bold;' ><?= _("Motif Suppression") ?> :</span> <INPUT disabled class="form-control" TYPE="text" id="motif" NAME="motif" style='font-size:12pt;font-weight: bold;' >
|
||||
-->
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="message_face" name="message_face" >
|
||||
<H2 style="background-color:yellow;">
|
||||
<marquee behavior="scroll" direction="left" scrollamount="10"> </marquee>
|
||||
</H2>
|
||||
<INPUT class="sr-only" TYPE="text" id="photo_succes" name="photo_succes" value="0">
|
||||
</div>
|
||||
|
||||
<div id="div_wait_face_ebene">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var video = document.getElementById('video_face');
|
||||
var canvas = document.getElementById('canvas');
|
||||
var photo = document.getElementById('photo_face');
|
||||
var image_face = document.getElementById('image_face');
|
||||
|
||||
|
||||
|
||||
navigator.getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia || navigator.mediaDevices.getUserMedia || navigator.moxGetUserMedia;
|
||||
|
||||
if (navigator.mediaDevices.getUserMedia)
|
||||
{
|
||||
navigator.mediaDevices.getUserMedia({video: true })
|
||||
.then(function (stream) {
|
||||
video.srcObject = stream;
|
||||
})
|
||||
.catch(function (e) { alert(e.name + ": " + e.message); });
|
||||
}
|
||||
else
|
||||
{
|
||||
navigator.getMedia({ video: { mandatory: { maxWidth: 350, maxHeight: 260 } } }, function(stream) {
|
||||
video.src = stream;
|
||||
}, function(e) {
|
||||
alert(e);
|
||||
console.log("Failed!", e);
|
||||
});
|
||||
}
|
||||
|
||||
function takephoto() {
|
||||
|
||||
$('#message_face').html("");
|
||||
$("#div_wait_face_ebene").html('');
|
||||
|
||||
// var ctx = canvas.getContext("2d").drawImage(video, 0, 0, 350, 260, 0, 0, 350, 260);
|
||||
var ctx = canvas.getContext("2d").drawImage(video, 0, 0, 350, 260);
|
||||
var data = canvas.toDataURL('image/jpeg');
|
||||
// var data = canvas.toDataURL('image/jpeg', 0.7);
|
||||
photo.setAttribute('src', data);
|
||||
$("#image_face").val(data);
|
||||
var faceRegistered = $("#faceRegistered").val();
|
||||
$("#ebene_enregistrer_photo_face").enable();
|
||||
if(faceRegistered=="1")
|
||||
{
|
||||
$("#ebene_confirmer_photo_face").enable();
|
||||
$("#ebene_supprimer_photo_face").enable();
|
||||
$("#motif").enable();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button id="close_poprec_faciane" name="close_poprec_faciane" type="button" class="btn btn-default" data-bs-dismiss="modal" onclick="javascript:fiche_beneficiaire();" > <?= _("Fermer") ?> </button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
<?php
|
||||
$_SESSION['p_messageFace'] = "";
|
||||
?>
|
||||
|
||||
<div class="modal fade" id="pop_rec_faciale" role="dialog" data-backdrop="static" data-keyboard="false" >
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header">
|
||||
<button id="btn_close_pop_rec_faciale" name="btn_close_pop_rec_faciale" type="button" class="close" data-bs-dismiss="modal" onclick="javascript:fiche_beneficiaire();"> <?= _("Fermer") ?> </button>
|
||||
<h4 class="modal-title"> <?= _("RECONNAISSANCE FACIALE") ?> </h4>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<table class="table table-responsive table-condensed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="48%">
|
||||
<button id="ebene_take_photo_face" name="ebene_take_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:takephoto();"> <?= _("PRENDRE UNE PHOTO") ?> </button>
|
||||
</td>
|
||||
|
||||
<td > </td>
|
||||
<?php if ($faceRegistered=="1") : ?>
|
||||
<td width="48%">
|
||||
<button disabled id="ebene_confirmer_photo_face" name="ebene_confirmer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:ebene_confirmer_photo_face();"> <?= _("CONFIRMER LA PHOTO") ?> </button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if ($faceRegistered!="1") : ?>
|
||||
<td width="48%">
|
||||
<button disabled id="ebene_enregistrer_photo_face" name="ebene_enregistrer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-primary" onclick="javascript:ebene_enregistrer_photo_face();"> <?= _("ENREGISTRER LA PHOTO") ?> </button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6" >
|
||||
<legend style="text-align:center" >Webcam</legend>
|
||||
<video id="video_face" name="video_face" autoplay 350="450" height="250" align="center"></video><br />
|
||||
</div>
|
||||
|
||||
<div class="col-6" >
|
||||
|
||||
<legend style="text-align:center" >Photo</legend>
|
||||
<img id="photo_face" name="photo_face" src="" />
|
||||
<form id="form_face" name="form_face" enctype="multipart/form-data" method="post" action="Fichebeneficiaire/ebeneenregistrerface">
|
||||
<INPUT class="sr-only" TYPE="text" id="compare_face" name="compare_face" value="<?= $faceRegistered ?>">
|
||||
<INPUT class="sr-only" TYPE="text" id="del_face" name="del_face" value="0">
|
||||
<INPUT class="image-tag" TYPE="hidden" id="image_face" name="image_face" >
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" name="canvas" style="display: none;" width="350" height="260"></canvas>
|
||||
|
||||
<?php if ($faceRegistered=="1") : ?>
|
||||
<!--
|
||||
<button disabled id="ebene_supprimer_photo_face" name="ebene_supprimer_photo_face" style='font-size:15pt;' type="button" class="form-control btn btn-danger" onclick="javascript:ebene_supprimer_photo_face();"> <?= _("SUPPRIMER LA PHOTO") ?> </button>
|
||||
|
||||
<span style='font-size:12pt;font-weight: bold;' ><?= _("Motif Suppression") ?> :</span> <INPUT disabled class="form-control" TYPE="text" id="motif" NAME="motif" style='font-size:12pt;font-weight: bold;' >
|
||||
-->
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="message_face" name="message_face" >
|
||||
<H2 style="background-color:yellow;">
|
||||
<marquee behavior="scroll" direction="left" scrollamount="10"> </marquee>
|
||||
</H2>
|
||||
<INPUT class="sr-only" TYPE="text" id="photo_succes" name="photo_succes" value="0">
|
||||
</div>
|
||||
|
||||
<div id="div_wait_face_ebene">
|
||||
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var video = document.getElementById('video_face');
|
||||
var canvas = document.getElementById('canvas');
|
||||
var photo = document.getElementById('photo_face');
|
||||
var image_face = document.getElementById('image_face');
|
||||
|
||||
|
||||
|
||||
navigator.getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia || navigator.mediaDevices.getUserMedia || navigator.moxGetUserMedia;
|
||||
|
||||
if (navigator.mediaDevices.getUserMedia)
|
||||
{
|
||||
navigator.mediaDevices.getUserMedia({video: true })
|
||||
.then(function (stream) {
|
||||
video.srcObject = stream;
|
||||
})
|
||||
.catch(function (e) { alert(e.name + ": " + e.message); });
|
||||
}
|
||||
else
|
||||
{
|
||||
navigator.getMedia({ video: { mandatory: { maxWidth: 350, maxHeight: 260 } } }, function(stream) {
|
||||
video.src = stream;
|
||||
}, function(e) {
|
||||
alert(e);
|
||||
console.log("Failed!", e);
|
||||
});
|
||||
}
|
||||
|
||||
function takephoto() {
|
||||
|
||||
$('#message_face').html("");
|
||||
$("#div_wait_face_ebene").html('');
|
||||
|
||||
// var ctx = canvas.getContext("2d").drawImage(video, 0, 0, 350, 260, 0, 0, 350, 260);
|
||||
var ctx = canvas.getContext("2d").drawImage(video, 0, 0, 350, 260);
|
||||
var data = canvas.toDataURL('image/jpeg');
|
||||
// var data = canvas.toDataURL('image/jpeg', 0.7);
|
||||
photo.setAttribute('src', data);
|
||||
$("#image_face").val(data);
|
||||
var faceRegistered = $("#faceRegistered").val();
|
||||
$("#ebene_enregistrer_photo_face").enable();
|
||||
if(faceRegistered=="1")
|
||||
{
|
||||
$("#ebene_confirmer_photo_face").enable();
|
||||
$("#ebene_supprimer_photo_face").enable();
|
||||
$("#motif").enable();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button id="close_poprec_faciane" name="close_poprec_faciane" type="button" class="btn btn-default" data-bs-dismiss="modal" onclick="javascript:fiche_beneficiaire();" > <?= _("Fermer") ?> </button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
<style>
|
||||
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
|
||||
/*body { margin: 0; background: #0f172a; color: #e2e8f0; }*/
|
||||
header { padding: 16px 24px; background: #111827; display:flex; align-items:center; gap:12px; }
|
||||
h1 { font-size: 18px; margin: 0; }
|
||||
/*main { display:grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }*/
|
||||
.stage { position: relative; aspect-ratio: 16/9; background: #111827; border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
/* video, canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } */
|
||||
.panel { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 16px; }
|
||||
.row { display:flex; justify-content: space-between; align-items:center; margin: 8px 0; }
|
||||
.pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; border:1px solid #1f2937; font-size:12px; }
|
||||
.ok { color:#10b981; }
|
||||
.warn { color:#f59e0b; }
|
||||
.bad { color:#ef4444; }
|
||||
button { background:#1f2937; color:#e5e7eb; border:1px solid #374151; border-radius:10px; padding:10px 12px; cursor:pointer; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
small { color:#94a3b8; }
|
||||
.meter { height: 8px; border-radius: 999px; background:#0b1220; border:1px solid #1f2937; overflow:hidden; }
|
||||
.meter > div { height: 100%; background: linear-gradient(90deg,#22c55e,#16a34a); width:0%; }
|
||||
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:8px; }
|
||||
.kpi { background:#0b1220; border:1px solid #1f2937; border-radius:10px; padding:10px; }
|
||||
code { background:#0b1220; padding:2px 6px; border-radius:6px; }
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>Détection de vivacité (Liveness)</h1>
|
||||
<div class="pill"><span>🎥</span><span id="camStatus">Caméra : inactif</span></div>
|
||||
<div class="pill"><span>🧠</span><span id="mpStatus">Modèle : non chargé</span></div>
|
||||
</header>
|
||||
|
||||
<div class="col-6" >
|
||||
<section class="stage">
|
||||
<video id="video" playsinline muted height="250"></video>
|
||||
<canvas id="overlay"></canvas>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-6" >
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<button id="btnStart">Démarrer</button>
|
||||
<button class="sr-only" id="btnStop" disabled>Arrêter</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<strong>Statut vivacité</strong>
|
||||
<span id="liveBadge" class="pill bad">Non vérifié</span>
|
||||
</div>
|
||||
<div class="meter" style="margin:8px 0 16px">
|
||||
<div id="liveMeter"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="kpi"><div>Clignements</div><div id="blinkCount" style="font-size:22px">0</div><small>EAR<seuil</small></div>
|
||||
<div class="kpi"><div>Mouvements tête</div><div id="headMoves" style="font-size:22px">0</div><small>yaw/roll Δ</small></div>
|
||||
<div class="kpi"><div>Confiance visage</div><div id="faceScore" style="font-size:22px">0.00</div><small>presence score</small></div>
|
||||
<div class="kpi"><div>FPS</div><div id="fps" style="font-size:22px">0</div><small>approx</small></div>
|
||||
</div>
|
||||
<!--
|
||||
<div id="blinkCount" style="font-size:22px">0</div>
|
||||
<div id="headMoves" style="font-size:22px">0</div>
|
||||
<div id="faceScore" style="font-size:22px">0.00</div>
|
||||
<div id="fps" style="font-size:22px">0</div>
|
||||
-->
|
||||
|
||||
<div class="sr-only">
|
||||
<input id="earThresh" type="number" min="0" max="1" step="0.01" value="0.21">
|
||||
<input id="closedFrames" type="number" min="1" max="15" step="1" value="3">
|
||||
<input id="moveThresh" type="number" min="0" max="30" step="0.5" value="6">
|
||||
<!-- <input id="proofNeeded" type="number" min="1" max="10" step="1" value="3"> -->
|
||||
<input id="proofNeeded" type="number" min="1" max="10" step="1" value="2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- </main> -->
|
||||
|
||||
<!-- MediaPipe Tasks Vision (web) -->
|
||||
<script type="module">
|
||||
// -- Dépendances MediaPipe Tasks Vision
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
DrawingUtils
|
||||
} from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js";
|
||||
|
||||
// URLs modèles (hébergés par Google)
|
||||
const MP_FACE_TASK = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
||||
|
||||
// Références DOM
|
||||
const video = document.getElementById('video');
|
||||
const canvas = document.getElementById('overlay');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const btnStart = document.getElementById('btnStart');
|
||||
const btnStop = document.getElementById('btnStop');
|
||||
|
||||
const camStatus = document.getElementById('camStatus');
|
||||
const mpStatus = document.getElementById('mpStatus');
|
||||
|
||||
const blinkCountEl = document.getElementById('blinkCount');
|
||||
const headMovesEl = document.getElementById('headMoves');
|
||||
const faceScoreEl = document.getElementById('faceScore');
|
||||
const fpsEl = document.getElementById('fps');
|
||||
const liveBadge = document.getElementById('liveBadge');
|
||||
const liveMeter = document.getElementById('liveMeter');
|
||||
|
||||
const earThreshEl = document.getElementById('earThresh');
|
||||
const closedFramesEl= document.getElementById('closedFrames');
|
||||
const moveThreshEl = document.getElementById('moveThresh');
|
||||
const proofNeededEl = document.getElementById('proofNeeded');
|
||||
|
||||
// État
|
||||
let running = false;
|
||||
let faceLandmarker; // modèle
|
||||
let lastVideoTime = -1;
|
||||
let rafId = null;
|
||||
|
||||
// Métriques liveness
|
||||
let blinkCount = 0;
|
||||
let closedConsec = 0;
|
||||
let lastEAR = 1;
|
||||
|
||||
let headMoves = 0;
|
||||
let lastYaw = null, lastRoll = null;
|
||||
|
||||
let facePresenceScore = 0;
|
||||
let livenessScore = 0; // somme pondérée des preuves
|
||||
|
||||
let lastFpsT = performance.now();
|
||||
let frames = 0;
|
||||
|
||||
// Indices MediaPipe FaceMesh pour calcul EAR (6 points par œil)
|
||||
// Schéma: EAR = (||p2-p6|| + ||p3-p5||) / (2*||p1-p4||)
|
||||
const LEFT_EYE = [33,160,158,133,153,144];
|
||||
const RIGHT_EYE = [263,387,385,362,380,373];
|
||||
|
||||
const distance = (a,b)=> Math.hypot(a.x-b.x, a.y-b.y);
|
||||
|
||||
function eyeEAR(landmarks, idxs){
|
||||
const [p1,p2,p3,p4,p5,p6] = idxs.map(i=>landmarks[i]);
|
||||
const vert = distance(p2,p6) + distance(p3,p5);
|
||||
const horiz= distance(p1,p4)*2;
|
||||
return horiz>0 ? (vert/horiz) : 0;
|
||||
}
|
||||
|
||||
function estimateYawRoll(landmarks){
|
||||
// yaw ~ orientation horizontale via ligne yeux, roll ~ inclinaison de la tête
|
||||
const left = avgPoint([33,133].map(i=>landmarks[i]));
|
||||
const right= avgPoint([263,362].map(i=>landmarks[i]));
|
||||
const dx = right.x - left.x;
|
||||
const dy = right.y - left.y;
|
||||
const roll = -rad2deg(Math.atan2(dy, dx));
|
||||
// yaw approximé par asymétrie distance nez-centres yeux
|
||||
const nose = landmarks[1] || landmarks[4];
|
||||
const midEye = {x:(left.x+right.x)/2, y:(left.y+right.y)/2};
|
||||
const yaw = rad2deg(Math.atan2(nose.x - midEye.x, 0.5)); // pseudo-yaw basé sur décalage nez
|
||||
return {yaw, roll};
|
||||
}
|
||||
const rad2deg = r=> r*180/Math.PI;
|
||||
const avgPoint = (pts)=>({x: pts.reduce((s,p)=>s+p.x,0)/pts.length, y: pts.reduce((s,p)=>s+p.y,0)/pts.length});
|
||||
|
||||
function updateLiveUI(){
|
||||
blinkCountEl.textContent = String(blinkCount);
|
||||
headMovesEl.textContent = String(headMoves);
|
||||
faceScoreEl.textContent = facePresenceScore.toFixed(2);
|
||||
|
||||
// Score simple: 1 point par clignement (max 2), 1 point par move tête (max 2), +1 si présence stable (>0.5)
|
||||
const maxBlinkPoints = Math.min(blinkCount, 2);
|
||||
const maxMovePoints = Math.min(headMoves, 2);
|
||||
const presencePoint = facePresenceScore > 0.5 ? 1 : 0;
|
||||
livenessScore = maxBlinkPoints + maxMovePoints + presencePoint;
|
||||
|
||||
const proofNeeded = Number(proofNeededEl.value);
|
||||
const pct = Math.min(100, Math.round(100*livenessScore/Math.max(1,proofNeeded)));
|
||||
liveMeter.style.width = pct + '%';
|
||||
|
||||
const div_ebene = document.getElementById("div_ebene");
|
||||
|
||||
if(livenessScore >= proofNeeded){
|
||||
liveBadge.className = 'pill ok';
|
||||
liveBadge.textContent = 'Vivant confirmé';
|
||||
|
||||
// alert('Vivant confirmé');
|
||||
stop();
|
||||
// $("#div_ebene").fadeIn();
|
||||
// $("#div_ebene").show();
|
||||
|
||||
// div_ebene.style.display = "block";
|
||||
// div_ebene.classList.remove("hidden");
|
||||
|
||||
} else if (livenessScore>0){
|
||||
liveBadge.className = 'pill warn';
|
||||
liveBadge.textContent = 'Indices de vivacité';
|
||||
} else {
|
||||
liveBadge.className = 'pill bad';
|
||||
liveBadge.textContent = 'Non vérifié';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModel(){
|
||||
mpStatus.textContent = 'Modèle : chargement…';
|
||||
const filesetResolver = await FilesetResolver.forVisionTasks(
|
||||
// wasm path (CDN jsDelivr) — laisse MediaPipe gérer les dépendances
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.11/wasm'
|
||||
);
|
||||
faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver,{
|
||||
baseOptions: { modelAssetPath: MP_FACE_TASK },
|
||||
runningMode: 'VIDEO',
|
||||
numFaces: 1,
|
||||
outputFaceBlendshapes: false,
|
||||
outputFacialTransformationMatrixes: true
|
||||
});
|
||||
mpStatus.textContent = 'Modèle : prêt';
|
||||
}
|
||||
|
||||
async function start(){
|
||||
try{
|
||||
btnStart.disabled = true;
|
||||
await loadModel();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: {ideal: 1280}, height:{ideal:720} },
|
||||
audio: false
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
camStatus.textContent = 'Caméra : OK';
|
||||
running = true;
|
||||
canvas.width = video.videoWidth || 1280;
|
||||
canvas.height= video.videoHeight|| 720;
|
||||
btnStop.disabled = false;
|
||||
lastVideoTime = -1;
|
||||
loop();
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
camStatus.textContent = 'Caméra : échec ('+ (err?.name||'Erreur') +')';
|
||||
btnStart.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stop(){
|
||||
running = false;
|
||||
if(rafId) cancelAnimationFrame(rafId);
|
||||
const stream = video.srcObject;
|
||||
if(stream){
|
||||
stream.getTracks().forEach(t=>t.stop());
|
||||
}
|
||||
video.srcObject = null;
|
||||
btnStart.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
camStatus.textContent = 'Caméra : inactif';
|
||||
mpStatus.textContent = faceLandmarker ? 'Modèle : prêt' : 'Modèle : non chargé';
|
||||
}
|
||||
|
||||
function drawLandmarks(landmarks){
|
||||
ctx.clearRect(0,0,canvas.width, canvas.height);
|
||||
const drawUtils = new DrawingUtils(ctx);
|
||||
// Points clés yeux + traits simples
|
||||
const eyePts = [...LEFT_EYE, ...RIGHT_EYE];
|
||||
drawUtils.drawLandmarks(landmarks.filter((_,i)=> eyePts.includes(i)), {lineWidth: 2, color: '#22c55e', radius: 2});
|
||||
}
|
||||
|
||||
function loop(){
|
||||
if(!running) return;
|
||||
const startT = performance.now();
|
||||
|
||||
const nowVideoTime = video.currentTime;
|
||||
if(nowVideoTime !== lastVideoTime){
|
||||
lastVideoTime = nowVideoTime;
|
||||
const res = faceLandmarker.detectForVideo(video, performance.now());
|
||||
const faces = res.faceLandmarks || [];
|
||||
|
||||
if(faces.length){
|
||||
const lm = faces[0];
|
||||
drawLandmarks(lm);
|
||||
|
||||
// EAR pour chaque œil
|
||||
const earL = eyeEAR(lm, LEFT_EYE);
|
||||
const earR = eyeEAR(lm, RIGHT_EYE);
|
||||
const ear = (earL + earR)/2;
|
||||
|
||||
const earThresh = Number(earThreshEl.value);
|
||||
const closedNeeded = Number(closedFramesEl.value);
|
||||
|
||||
if(ear < earThresh){
|
||||
closedConsec++;
|
||||
} else {
|
||||
if(closedConsec >= closedNeeded){
|
||||
blinkCount++;
|
||||
}
|
||||
closedConsec = 0;
|
||||
}
|
||||
|
||||
// Head movement (yaw/roll change)
|
||||
const {yaw, roll} = estimateYawRoll(lm);
|
||||
const moveThresh = Number(moveThreshEl.value);
|
||||
if(lastYaw!==null && lastRoll!==null){
|
||||
const dYaw = Math.abs(yaw - lastYaw);
|
||||
const dRoll= Math.abs(roll - lastRoll);
|
||||
if(dYaw > moveThresh || dRoll > moveThresh){
|
||||
headMoves++;
|
||||
}
|
||||
}
|
||||
lastYaw = yaw; lastRoll = roll;
|
||||
|
||||
// Présence visage (proxy via nb de points vs bruit)
|
||||
facePresenceScore = 0.7; // simplifié: présent
|
||||
|
||||
} else {
|
||||
ctx.clearRect(0,0,canvas.width, canvas.height);
|
||||
facePresenceScore = 0.0;
|
||||
}
|
||||
|
||||
// MAJ UI
|
||||
updateLiveUI();
|
||||
}
|
||||
|
||||
// FPS approx
|
||||
frames++;
|
||||
const t = performance.now();
|
||||
if(t - lastFpsT > 1000){
|
||||
fpsEl.textContent = String(frames);
|
||||
frames = 0; lastFpsT = t;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(loop);
|
||||
const endT = performance.now();
|
||||
// Option: utiliser endT-startT si besoin de profiler
|
||||
}
|
||||
|
||||
btnStart.addEventListener('click', start);
|
||||
btnStop.addEventListener('click', stop);
|
||||
|
||||
|
||||
// Conseil : demander permission caméra immédiatement (optionnel)
|
||||
// navigator.mediaDevices.getUserMedia({video:true}).then(s=>s.getTracks().forEach(t=>t.stop()));
|
||||
</script>
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
<style>
|
||||
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
|
||||
body { margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||
header { padding: 16px 24px; background: #111827; display:flex; align-items:center; gap:12px; }
|
||||
h1 { font-size: 18px; margin: 0; }
|
||||
main { display:grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }
|
||||
.stage { position: relative; aspect-ratio: 16/9; background: #111827; border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
|
||||
video, canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.panel { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 16px; }
|
||||
.row { display:flex; justify-content: space-between; align-items:center; margin: 8px 0; }
|
||||
.pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; border:1px solid #1f2937; font-size:12px; }
|
||||
.ok { color:#10b981; }
|
||||
.warn { color:#f59e0b; }
|
||||
.bad { color:#ef4444; }
|
||||
button { background:#1f2937; color:#e5e7eb; border:1px solid #374151; border-radius:10px; padding:10px 12px; cursor:pointer; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
small { color:#94a3b8; }
|
||||
.meter { height: 8px; border-radius: 999px; background:#0b1220; border:1px solid #1f2937; overflow:hidden; }
|
||||
.meter > div { height: 100%; background: linear-gradient(90deg,#22c55e,#16a34a); width:0%; }
|
||||
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:8px; }
|
||||
.kpi { background:#0b1220; border:1px solid #1f2937; border-radius:10px; padding:10px; }
|
||||
code { background:#0b1220; padding:2px 6px; border-radius:6px; }
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>Détection de vivacité (Liveness)</h1>
|
||||
<div class="pill"><span>🎥</span><span id="camStatus">Caméra : inactif</span></div>
|
||||
<div class="pill"><span>🧠</span><span id="mpStatus">Modèle : non chargé</span></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="stage">
|
||||
<video id="video" playsinline muted></video>
|
||||
<canvas id="overlay"></canvas>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<button id="btnStart">Démarrer</button>
|
||||
<button id="btnStop" disabled>Arrêter</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<strong>Statut vivacité</strong>
|
||||
<span id="liveBadge" class="pill bad">Non vérifié</span>
|
||||
</div>
|
||||
<div class="meter" style="margin:8px 0 16px">
|
||||
<div id="liveMeter"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="kpi"><div>Clignements</div><div id="blinkCount" style="font-size:22px">0</div><small>EAR<seuil</small></div>
|
||||
<div class="kpi"><div>Mouvements tête</div><div id="headMoves" style="font-size:22px">0</div><small>yaw/roll Δ</small></div>
|
||||
<div class="kpi"><div>Confiance visage</div><div id="faceScore" style="font-size:22px">0.00</div><small>presence score</small></div>
|
||||
<div class="kpi"><div>FPS</div><div id="fps" style="font-size:22px">0</div><small>approx</small></div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:#1f2937; margin:16px 0" />
|
||||
|
||||
<input class="sr-only" id="earThresh" type="number" min="0" max="1" step="0.01" value="0.21">
|
||||
<input class="sr-only" id="closedFrames" type="number" min="1" max="15" step="1" value="3">
|
||||
<input class="sr-only" id="moveThresh" type="number" min="0" max="30" step="0.5" value="6">
|
||||
<input class="sr-only" id="proofNeeded" type="number" min="1" max="10" step="1" value="3">
|
||||
|
||||
<hr style="border-color:#1f2937; margin:16px 0" />
|
||||
|
||||
<p><strong>Conseils :</strong> placez-vous face caméra, éclairez le visage, clignez des yeux et tournez légèrement la tête.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- MediaPipe Tasks Vision (web) -->
|
||||
<script type="module">
|
||||
// -- Dépendances MediaPipe Tasks Vision
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
DrawingUtils
|
||||
} from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.js";
|
||||
|
||||
// URLs modèles (hébergés par Google)
|
||||
const MP_FACE_TASK = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
||||
|
||||
// Références DOM
|
||||
const video = document.getElementById('video');
|
||||
const canvas = document.getElementById('overlay');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const btnStart = document.getElementById('btnStart');
|
||||
const btnStop = document.getElementById('btnStop');
|
||||
|
||||
const camStatus = document.getElementById('camStatus');
|
||||
const mpStatus = document.getElementById('mpStatus');
|
||||
|
||||
const blinkCountEl = document.getElementById('blinkCount');
|
||||
const headMovesEl = document.getElementById('headMoves');
|
||||
const faceScoreEl = document.getElementById('faceScore');
|
||||
const fpsEl = document.getElementById('fps');
|
||||
const liveBadge = document.getElementById('liveBadge');
|
||||
const liveMeter = document.getElementById('liveMeter');
|
||||
|
||||
const earThreshEl = document.getElementById('earThresh');
|
||||
const closedFramesEl= document.getElementById('closedFrames');
|
||||
const moveThreshEl = document.getElementById('moveThresh');
|
||||
const proofNeededEl = document.getElementById('proofNeeded');
|
||||
|
||||
// État
|
||||
let running = false;
|
||||
let faceLandmarker; // modèle
|
||||
let lastVideoTime = -1;
|
||||
let rafId = null;
|
||||
|
||||
// Métriques liveness
|
||||
let blinkCount = 0;
|
||||
let closedConsec = 0;
|
||||
let lastEAR = 1;
|
||||
|
||||
let headMoves = 0;
|
||||
let lastYaw = null, lastRoll = null;
|
||||
|
||||
let facePresenceScore = 0;
|
||||
let livenessScore = 0; // somme pondérée des preuves
|
||||
|
||||
let lastFpsT = performance.now();
|
||||
let frames = 0;
|
||||
|
||||
// Indices MediaPipe FaceMesh pour calcul EAR (6 points par œil)
|
||||
// Schéma: EAR = (||p2-p6|| + ||p3-p5||) / (2*||p1-p4||)
|
||||
const LEFT_EYE = [33,160,158,133,153,144];
|
||||
const RIGHT_EYE = [263,387,385,362,380,373];
|
||||
|
||||
const distance = (a,b)=> Math.hypot(a.x-b.x, a.y-b.y);
|
||||
|
||||
function eyeEAR(landmarks, idxs){
|
||||
const [p1,p2,p3,p4,p5,p6] = idxs.map(i=>landmarks[i]);
|
||||
const vert = distance(p2,p6) + distance(p3,p5);
|
||||
const horiz= distance(p1,p4)*2;
|
||||
return horiz>0 ? (vert/horiz) : 0;
|
||||
}
|
||||
|
||||
function estimateYawRoll(landmarks){
|
||||
// yaw ~ orientation horizontale via ligne yeux, roll ~ inclinaison de la tête
|
||||
const left = avgPoint([33,133].map(i=>landmarks[i]));
|
||||
const right= avgPoint([263,362].map(i=>landmarks[i]));
|
||||
const dx = right.x - left.x;
|
||||
const dy = right.y - left.y;
|
||||
const roll = -rad2deg(Math.atan2(dy, dx));
|
||||
// yaw approximé par asymétrie distance nez-centres yeux
|
||||
const nose = landmarks[1] || landmarks[4];
|
||||
const midEye = {x:(left.x+right.x)/2, y:(left.y+right.y)/2};
|
||||
const yaw = rad2deg(Math.atan2(nose.x - midEye.x, 0.5)); // pseudo-yaw basé sur décalage nez
|
||||
return {yaw, roll};
|
||||
}
|
||||
const rad2deg = r=> r*180/Math.PI;
|
||||
const avgPoint = (pts)=>({x: pts.reduce((s,p)=>s+p.x,0)/pts.length, y: pts.reduce((s,p)=>s+p.y,0)/pts.length});
|
||||
|
||||
function updateLiveUI(){
|
||||
blinkCountEl.textContent = String(blinkCount);
|
||||
headMovesEl.textContent = String(headMoves);
|
||||
faceScoreEl.textContent = facePresenceScore.toFixed(2);
|
||||
|
||||
// Score simple: 1 point par clignement (max 2), 1 point par move tête (max 2), +1 si présence stable (>0.5)
|
||||
const maxBlinkPoints = Math.min(blinkCount, 2);
|
||||
const maxMovePoints = Math.min(headMoves, 2);
|
||||
const presencePoint = facePresenceScore > 0.5 ? 1 : 0;
|
||||
livenessScore = maxBlinkPoints + maxMovePoints + presencePoint;
|
||||
|
||||
const proofNeeded = Number(proofNeededEl.value);
|
||||
const pct = Math.min(100, Math.round(100*livenessScore/Math.max(1,proofNeeded)));
|
||||
liveMeter.style.width = pct + '%';
|
||||
|
||||
if(livenessScore >= proofNeeded){
|
||||
liveBadge.className = 'pill ok';
|
||||
liveBadge.textContent = 'Vivant confirmé';
|
||||
} else if (livenessScore>0){
|
||||
liveBadge.className = 'pill warn';
|
||||
liveBadge.textContent = 'Indices de vivacité';
|
||||
} else {
|
||||
liveBadge.className = 'pill bad';
|
||||
liveBadge.textContent = 'Non vérifié';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModel(){
|
||||
mpStatus.textContent = 'Modèle : chargement…';
|
||||
const filesetResolver = await FilesetResolver.forVisionTasks(
|
||||
// wasm path (CDN jsDelivr) — laisse MediaPipe gérer les dépendances
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.11/wasm'
|
||||
);
|
||||
faceLandmarker = await FaceLandmarker.createFromOptions(filesetResolver,{
|
||||
baseOptions: { modelAssetPath: MP_FACE_TASK },
|
||||
runningMode: 'VIDEO',
|
||||
numFaces: 1,
|
||||
outputFaceBlendshapes: false,
|
||||
outputFacialTransformationMatrixes: true
|
||||
});
|
||||
mpStatus.textContent = 'Modèle : prêt';
|
||||
}
|
||||
|
||||
async function start(){
|
||||
try{
|
||||
btnStart.disabled = true;
|
||||
await loadModel();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: {ideal: 1280}, height:{ideal:720} },
|
||||
audio: false
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
camStatus.textContent = 'Caméra : OK';
|
||||
running = true;
|
||||
canvas.width = video.videoWidth || 1280;
|
||||
canvas.height= video.videoHeight|| 720;
|
||||
btnStop.disabled = false;
|
||||
lastVideoTime = -1;
|
||||
loop();
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
camStatus.textContent = 'Caméra : échec ('+ (err?.name||'Erreur') +')';
|
||||
btnStart.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stop(){
|
||||
running = false;
|
||||
if(rafId) cancelAnimationFrame(rafId);
|
||||
const stream = video.srcObject;
|
||||
if(stream){
|
||||
stream.getTracks().forEach(t=>t.stop());
|
||||
}
|
||||
video.srcObject = null;
|
||||
btnStart.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
camStatus.textContent = 'Caméra : inactif';
|
||||
mpStatus.textContent = faceLandmarker ? 'Modèle : prêt' : 'Modèle : non chargé';
|
||||
}
|
||||
|
||||
function drawLandmarks(landmarks){
|
||||
ctx.clearRect(0,0,canvas.width, canvas.height);
|
||||
const drawUtils = new DrawingUtils(ctx);
|
||||
// Points clés yeux + traits simples
|
||||
const eyePts = [...LEFT_EYE, ...RIGHT_EYE];
|
||||
drawUtils.drawLandmarks(landmarks.filter((_,i)=> eyePts.includes(i)), {lineWidth: 2, color: '#22c55e', radius: 2});
|
||||
}
|
||||
|
||||
function loop(){
|
||||
if(!running) return;
|
||||
const startT = performance.now();
|
||||
|
||||
const nowVideoTime = video.currentTime;
|
||||
if(nowVideoTime !== lastVideoTime){
|
||||
lastVideoTime = nowVideoTime;
|
||||
const res = faceLandmarker.detectForVideo(video, performance.now());
|
||||
const faces = res.faceLandmarks || [];
|
||||
|
||||
if(faces.length){
|
||||
const lm = faces[0];
|
||||
drawLandmarks(lm);
|
||||
|
||||
// EAR pour chaque œil
|
||||
const earL = eyeEAR(lm, LEFT_EYE);
|
||||
const earR = eyeEAR(lm, RIGHT_EYE);
|
||||
const ear = (earL + earR)/2;
|
||||
|
||||
const earThresh = Number(earThreshEl.value);
|
||||
const closedNeeded = Number(closedFramesEl.value);
|
||||
|
||||
if(ear < earThresh){
|
||||
closedConsec++;
|
||||
} else {
|
||||
if(closedConsec >= closedNeeded){
|
||||
blinkCount++;
|
||||
}
|
||||
closedConsec = 0;
|
||||
}
|
||||
|
||||
// Head movement (yaw/roll change)
|
||||
const {yaw, roll} = estimateYawRoll(lm);
|
||||
const moveThresh = Number(moveThreshEl.value);
|
||||
if(lastYaw!==null && lastRoll!==null){
|
||||
const dYaw = Math.abs(yaw - lastYaw);
|
||||
const dRoll= Math.abs(roll - lastRoll);
|
||||
if(dYaw > moveThresh || dRoll > moveThresh){
|
||||
headMoves++;
|
||||
}
|
||||
}
|
||||
lastYaw = yaw; lastRoll = roll;
|
||||
|
||||
// Présence visage (proxy via nb de points vs bruit)
|
||||
facePresenceScore = 0.7; // simplifié: présent
|
||||
|
||||
} else {
|
||||
ctx.clearRect(0,0,canvas.width, canvas.height);
|
||||
facePresenceScore = 0.0;
|
||||
}
|
||||
|
||||
// MAJ UI
|
||||
updateLiveUI();
|
||||
}
|
||||
|
||||
// FPS approx
|
||||
frames++;
|
||||
const t = performance.now();
|
||||
if(t - lastFpsT > 1000){
|
||||
fpsEl.textContent = String(frames);
|
||||
frames = 0; lastFpsT = t;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(loop);
|
||||
const endT = performance.now();
|
||||
// Option: utiliser endT-startT si besoin de profiler
|
||||
}
|
||||
|
||||
btnStart.addEventListener('click', start);
|
||||
btnStop.addEventListener('click', stop);
|
||||
|
||||
// Conseil : demander permission caméra immédiatement (optionnel)
|
||||
// navigator.mediaDevices.getUserMedia({video:true}).then(s=>s.getTracks().forEach(t=>t.stop()));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
faceebene/sav1/webcam.min.js
vendored
2
faceebene/sav1/webcam.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user