Sync foto: generate embedding dan simpan ke student_faces, opsi no-embedding, update docs
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Foto wajah siswa dipakai untuk **verifikasi wajah** di aplikasi mobile (absen masuk/pulang). Foto disimpan di backend: `writable/faces/{student_id}.jpg`.
|
Foto wajah siswa dipakai untuk **verifikasi wajah** di aplikasi mobile (absen masuk/pulang). Foto disimpan di backend: `writable/faces/{student_id}.jpg`.
|
||||||
|
|
||||||
|
**Alur yang proper:** Verifikasi wajah memakai **vektor (embedding)** yang disimpan di tabel `student_faces`, bukan file gambar mentah. Jadi setelah foto formal ada (dari Drive atau sumber lain), kita **harus bikin vektor dulu** lewat face-service lalu simpan ke `student_faces`. Script sync di bawah mengerjakan keduanya: copy file **dan** generate embedding.
|
||||||
|
|
||||||
## Sumber foto
|
## Sumber foto
|
||||||
|
|
||||||
Folder contoh: [Foto Siswa Kls XII TP 25-26](https://drive.google.com/drive/folders/16E84NFGYPItaTANQwEyMQW5rkTCt6Asy) — berisi subfolder per kelas (XII-1, XII-2, … XII-12, Susulan).
|
Folder contoh: [Foto Siswa Kls XII TP 25-26](https://drive.google.com/drive/folders/16E84NFGYPItaTANQwEyMQW5rkTCt6Asy) — berisi subfolder per kelas (XII-1, XII-2, … XII-12, Susulan).
|
||||||
@@ -23,11 +25,14 @@ Folder contoh: [Foto Siswa Kls XII TP 25-26](https://drive.google.com/drive/fold
|
|||||||
cd c:\laragon\www\sman1\backend
|
cd c:\laragon\www\sman1\backend
|
||||||
php scripts/sync_face_photos.php --source=C:\temp\foto-siswa-xii
|
php scripts/sync_face_photos.php --source=C:\temp\foto-siswa-xii
|
||||||
```
|
```
|
||||||
|
- Pastikan **face-service** (Python) sudah jalan, karena script akan memanggilnya untuk generate embedding.
|
||||||
- Script akan:
|
- Script akan:
|
||||||
- Scan semua subfolder (XII-1, XII-2, dll.),
|
- Scan semua subfolder (XII-1, XII-2, dll.),
|
||||||
- Untuk setiap file gambar (.jpg, .jpeg, .png), ambil nama file tanpa ekstensi = NISN,
|
- Untuk setiap file gambar (.jpg, .jpeg, .png), ambil nama file tanpa ekstensi = NISN,
|
||||||
- Cari siswa di database berdasarkan NISN,
|
- Cari siswa di database berdasarkan NISN,
|
||||||
- Copy file ke `writable/faces/{student_id}.jpg`.
|
- Copy file ke `writable/faces/{student_id}.jpg`,
|
||||||
|
- **Kirim gambar ke face-service → dapat embedding → simpan ke tabel `student_faces` (source=formal)**.
|
||||||
|
- Opsi `--no-embedding`: hanya copy file + update `face_hash`, tidak generate vektor (misalnya kalau face-service belum jalan).
|
||||||
|
|
||||||
## Cara 2: Upload per siswa lewat dashboard (rencana)
|
## Cara 2: Upload per siswa lewat dashboard (rencana)
|
||||||
|
|
||||||
@@ -44,5 +49,7 @@ Ke depan bisa ditambah halaman di **Pengaturan Academic → Siswa**: per siswa a
|
|||||||
| Yang diatur | Keterangan |
|
| Yang diatur | Keterangan |
|
||||||
|-------------|------------|
|
|-------------|------------|
|
||||||
| Penyimpanan | `writable/faces/{student_id}.jpg` (atau .png) |
|
| Penyimpanan | `writable/faces/{student_id}.jpg` (atau .png) |
|
||||||
|
| **Vektor (embedding)** | Script sync memanggil face-service lalu menyimpan ke `student_faces` (source=formal). Verifikasi wajah memakai data ini. |
|
||||||
| Mapping | Nama file = NISN → cari siswa → simpan dengan student_id |
|
| Mapping | Nama file = NISN → cari siswa → simpan dengan student_id |
|
||||||
| Sumber | Download folder Drive → rename file = NISN → jalankan script sync |
|
| Sumber | Download folder Drive → rename file = NISN → jalankan script sync (dan pastikan face-service jalan) |
|
||||||
|
| Opsi | `--no-embedding` = hanya copy file, tidak generate embedding |
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Sync foto wajah dari folder (hasil download Google Drive) ke writable/faces/{student_id}.jpg
|
* Sync foto wajah dari folder (hasil download Google Drive) ke writable/faces/{student_id}.jpg
|
||||||
* Pemakaian: php sync_face_photos.php --source=C:\path\to\folder
|
* dan generate embedding (vektor) lalu simpan ke student_faces — alur proper untuk verifikasi wajah.
|
||||||
|
*
|
||||||
|
* Pemakaian: php sync_face_photos.php --source=C:\path\to\folder [--no-embedding]
|
||||||
* Konvensi: nama file mengandung NISN (contoh: 1234567890.jpg atau 01. 1234567890.jpg)
|
* Konvensi: nama file mengandung NISN (contoh: 1234567890.jpg atau 01. 1234567890.jpg)
|
||||||
* Lihat: backend/docs/SYNC_FOTO_WAJAH.md
|
* Lihat: backend/docs/SYNC_FOTO_WAJAH.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$options = getopt('', ['source:']);
|
$options = getopt('', ['source:', 'no-embedding']);
|
||||||
$source = $options['source'] ?? null;
|
$source = $options['source'] ?? null;
|
||||||
|
$noEmbedding = isset($options['no-embedding']);
|
||||||
if (empty($source) || ! is_dir($source)) {
|
if (empty($source) || ! is_dir($source)) {
|
||||||
echo "Pemakaian: php sync_face_photos.php --source=/path/to/folder\n";
|
echo "Pemakaian: php sync_face_photos.php --source=/path/to/folder [--no-embedding]\n";
|
||||||
|
echo " --no-embedding Skip generate vektor (hanya copy file + face_hash).\n";
|
||||||
echo "Folder harus berisi subfolder (XII-1, XII-2, dll) dengan file gambar bernama NISN.jpg\n";
|
echo "Folder harus berisi subfolder (XII-1, XII-2, dll) dengan file gambar bernama NISN.jpg\n";
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -62,10 +66,75 @@ if (! is_dir($facesDir)) {
|
|||||||
echo "Folder dibuat: {$facesDir}\n";
|
echo "Folder dibuat: {$facesDir}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$faceServiceUrl = rtrim($env['FACE_SERVICE_URL'] ?? $env['face_service_url'] ?? 'http://localhost:5000', '/');
|
||||||
|
$embeddingDim = (int) ($env['FACE_EMBEDDING_DIM'] ?? $env['face_embedding_dim'] ?? 512);
|
||||||
|
$minFaceSize = (float) ($env['FACE_MIN_SIZE'] ?? $env['face_min_size'] ?? 80);
|
||||||
|
$minBlur = (float) ($env['FACE_MIN_BLUR'] ?? $env['face_min_blur'] ?? 30);
|
||||||
|
$minBrightness = (float) ($env['FACE_MIN_BRIGHTNESS'] ?? $env['face_min_brightness'] ?? 0.2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding dari file gambar lalu simpan ke student_faces (source=formal).
|
||||||
|
* Menggantikan embedding formal yang sudah ada untuk student_id ini.
|
||||||
|
* Return: 'ok' | 'skip' | 'fail'
|
||||||
|
*/
|
||||||
|
$generateAndSaveEmbedding = function (PDO $pdo, string $destPath, int $studentId) use ($faceServiceUrl, $embeddingDim, $minFaceSize, $minBlur, $minBrightness): string {
|
||||||
|
if (! is_file($destPath)) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
$mime = mime_content_type($destPath) ?: 'image/jpeg';
|
||||||
|
$ch = curl_init($faceServiceUrl . '/embed');
|
||||||
|
if ($ch === false) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
$cfile = new CURLFile($destPath, $mime, basename($destPath));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => ['image' => $cfile],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_HTTPHEADER => [],
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $status !== 200) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (! is_array($data) || empty($data['embedding']) || ! is_array($data['embedding'])) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
$embedding = $data['embedding'];
|
||||||
|
if (count($embedding) !== $embeddingDim) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
$facesCount = (int) ($data['faces_count'] ?? 0);
|
||||||
|
$faceSize = (float) ($data['face_size'] ?? 0);
|
||||||
|
$blur = (float) ($data['blur'] ?? 0);
|
||||||
|
$brightness = (float) ($data['brightness'] ?? 0);
|
||||||
|
if ($facesCount !== 1) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
if ($faceSize < $minFaceSize || $blur < $minBlur || $brightness < $minBrightness) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
$qualityScore = isset($data['quality_score']) ? (float) $data['quality_score'] : null;
|
||||||
|
$embeddingJson = json_encode(array_map('floatval', $embedding));
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM student_faces WHERE student_id = ? AND source = ?')->execute([$studentId, 'formal']);
|
||||||
|
$ins = $pdo->prepare('INSERT INTO student_faces (student_id, embedding, source, quality_score, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())');
|
||||||
|
$ins->execute([$studentId, $embeddingJson, 'formal', $qualityScore]);
|
||||||
|
return 'ok';
|
||||||
|
};
|
||||||
|
|
||||||
$extensions = ['jpg', 'jpeg', 'png'];
|
$extensions = ['jpg', 'jpeg', 'png'];
|
||||||
$countOk = 0;
|
$countOk = 0;
|
||||||
$countSkip = 0;
|
$countSkip = 0;
|
||||||
$countFail = 0;
|
$countFail = 0;
|
||||||
|
$countEmbedOk = 0;
|
||||||
|
$countEmbedSkip = 0;
|
||||||
|
$countEmbedFail = 0;
|
||||||
|
|
||||||
$it = new RecursiveIteratorIterator(
|
$it = new RecursiveIteratorIterator(
|
||||||
new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
|
new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
@@ -125,8 +194,15 @@ foreach ($it as $file) {
|
|||||||
$hash = md5_file($dest);
|
$hash = md5_file($dest);
|
||||||
$update = $pdo->prepare('UPDATE students SET face_hash = ? WHERE id = ?');
|
$update = $pdo->prepare('UPDATE students SET face_hash = ? WHERE id = ?');
|
||||||
$update->execute([$hash, $studentId]);
|
$update->execute([$hash, $studentId]);
|
||||||
echo " [ok-copy] {$nisn} -> student_id {$studentId}\n";
|
echo " [ok-copy] {$nisn} -> student_id {$studentId}";
|
||||||
$countOk++;
|
$countOk++;
|
||||||
|
if (! $noEmbedding) {
|
||||||
|
$emb = $generateAndSaveEmbedding($pdo, $dest, $studentId);
|
||||||
|
if ($emb === 'ok') { $countEmbedOk++; echo ' + embedding'; }
|
||||||
|
elseif ($emb === 'skip') { $countEmbedSkip++; echo ' (embed skip)'; }
|
||||||
|
else { $countEmbedFail++; echo ' (embed fail)'; }
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
} else {
|
} else {
|
||||||
echo " [fail] Gagal copy (GD error): {$path} -> {$dest}\n";
|
echo " [fail] Gagal copy (GD error): {$path} -> {$dest}\n";
|
||||||
$countFail++;
|
$countFail++;
|
||||||
@@ -155,8 +231,15 @@ foreach ($it as $file) {
|
|||||||
$hash = md5_file($dest);
|
$hash = md5_file($dest);
|
||||||
$update = $pdo->prepare('UPDATE students SET face_hash = ? WHERE id = ?');
|
$update = $pdo->prepare('UPDATE students SET face_hash = ? WHERE id = ?');
|
||||||
$update->execute([$hash, $studentId]);
|
$update->execute([$hash, $studentId]);
|
||||||
echo " [ok] {$nisn} -> student_id {$studentId}\n";
|
echo " [ok] {$nisn} -> student_id {$studentId}";
|
||||||
$countOk++;
|
$countOk++;
|
||||||
|
if (! $noEmbedding) {
|
||||||
|
$emb = $generateAndSaveEmbedding($pdo, $dest, $studentId);
|
||||||
|
if ($emb === 'ok') { $countEmbedOk++; echo ' + embedding'; }
|
||||||
|
elseif ($emb === 'skip') { $countEmbedSkip++; echo ' (embed skip)'; }
|
||||||
|
else { $countEmbedFail++; echo ' (embed fail)'; }
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
} else {
|
} else {
|
||||||
imagedestroy($srcImage);
|
imagedestroy($srcImage);
|
||||||
imagedestroy($dstImage);
|
imagedestroy($dstImage);
|
||||||
@@ -166,3 +249,6 @@ foreach ($it as $file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "\nSelesai. OK: {$countOk}, Skip (NISN tidak ada): {$countSkip}, Gagal: {$countFail}\n";
|
echo "\nSelesai. OK: {$countOk}, Skip (NISN tidak ada): {$countSkip}, Gagal: {$countFail}\n";
|
||||||
|
if (! $noEmbedding) {
|
||||||
|
echo "Embedding: OK: {$countEmbedOk}, Skip (kualitas/tidak 1 wajah): {$countEmbedSkip}, Gagal: {$countEmbedFail}\n";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user