237 lines
7.4 KiB
PHP
237 lines
7.4 KiB
PHP
<?php
|
||
|
||
namespace App\Modules\Face\Controllers;
|
||
|
||
use App\Core\BaseApiController;
|
||
use App\Modules\Academic\Models\StudentModel;
|
||
use App\Modules\Face\Models\StudentFaceModel;
|
||
use App\Modules\Face\Services\FaceService;
|
||
use CodeIgniter\HTTP\ResponseInterface;
|
||
|
||
class FaceController extends BaseApiController
|
||
{
|
||
/**
|
||
* POST /api/face/import-formal
|
||
* Body: {
|
||
* "items": [
|
||
* { "student_id": 123, "url": "https://..." },
|
||
* ...
|
||
* ]
|
||
* }
|
||
*
|
||
* Admin-only (protected by filter admin_only).
|
||
*/
|
||
public function importFormal(): ResponseInterface
|
||
{
|
||
$payload = $this->request->getJSON(true) ?? [];
|
||
$items = $payload['items'] ?? [];
|
||
|
||
if (! is_array($items) || $items === []) {
|
||
return $this->errorResponse('items wajib berupa array', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
|
||
}
|
||
|
||
$studentModel = new StudentModel();
|
||
$faceModel = new StudentFaceModel();
|
||
$faceService = new FaceService();
|
||
|
||
$success = 0;
|
||
$fail = 0;
|
||
$errors = [];
|
||
|
||
foreach ($items as $item) {
|
||
$studentId = (int) ($item['student_id'] ?? 0);
|
||
$url = trim((string) ($item['url'] ?? ''));
|
||
|
||
if ($studentId < 1 || $url === '') {
|
||
$fail++;
|
||
$errors[] = [
|
||
'student_id' => $studentId,
|
||
'url' => $url,
|
||
'reason' => 'student_id dan url wajib diisi',
|
||
];
|
||
continue;
|
||
}
|
||
|
||
$student = $studentModel->find($studentId);
|
||
if (! $student) {
|
||
$fail++;
|
||
$errors[] = [
|
||
'student_id' => $studentId,
|
||
'url' => $url,
|
||
'reason' => 'Siswa tidak ditemukan',
|
||
];
|
||
continue;
|
||
}
|
||
|
||
$tmpFile = tempnam(sys_get_temp_dir(), 'face_');
|
||
try {
|
||
$imgData = @file_get_contents($url);
|
||
if ($imgData === false) {
|
||
throw new \RuntimeException('Gagal mengunduh gambar dari URL');
|
||
}
|
||
file_put_contents($tmpFile, $imgData);
|
||
|
||
$result = $faceService->extractEmbeddingWithQuality($tmpFile);
|
||
|
||
$faceModel->insert([
|
||
'student_id' => $studentId,
|
||
'embedding' => json_encode($result['embedding']),
|
||
'source' => 'formal',
|
||
'quality_score' => $result['quality_score'],
|
||
]);
|
||
|
||
// Simpan URL formal di tabel students
|
||
$studentModel->update($studentId, ['photo_formal_url' => $url]);
|
||
|
||
$success++;
|
||
} catch (\Throwable $e) {
|
||
$fail++;
|
||
$errors[] = [
|
||
'student_id' => $studentId,
|
||
'url' => $url,
|
||
'reason' => $e->getMessage(),
|
||
];
|
||
} finally {
|
||
if (is_file($tmpFile)) {
|
||
@unlink($tmpFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $this->successResponse([
|
||
'success_count' => $success,
|
||
'fail_count' => $fail,
|
||
'errors' => $errors,
|
||
], 'Import foto formal selesai');
|
||
}
|
||
|
||
/**
|
||
* POST /api/face/enroll-live
|
||
* Body: {
|
||
* "student_id": 123,
|
||
* "images": ["data:image/jpeg;base64,...", "..."]
|
||
* }
|
||
*
|
||
* Dipanggil saat aktivasi dari device (3–5 frame).
|
||
*/
|
||
public function enrollLive(): ResponseInterface
|
||
{
|
||
$payload = $this->request->getJSON(true) ?? [];
|
||
$studentId = (int) ($payload['student_id'] ?? 0);
|
||
$images = $payload['images'] ?? [];
|
||
|
||
if ($studentId < 1) {
|
||
return $this->errorResponse('student_id wajib diisi', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
|
||
}
|
||
|
||
if (! is_array($images) || count($images) < 1) {
|
||
return $this->errorResponse('images wajib berisi minimal 1 item', null, null, ResponseInterface::HTTP_UNPROCESSABLE_ENTITY);
|
||
}
|
||
|
||
$studentModel = new StudentModel();
|
||
$student = $studentModel->find($studentId);
|
||
if (! $student) {
|
||
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
|
||
}
|
||
|
||
$faceModel = new StudentFaceModel();
|
||
$faceService = new FaceService();
|
||
|
||
$embeddings = [];
|
||
$saved = 0;
|
||
$errors = [];
|
||
|
||
foreach ($images as $idx => $imgData) {
|
||
$tmpFile = tempnam(sys_get_temp_dir(), 'live_');
|
||
try {
|
||
$raw = $this->decodeBase64Image($imgData);
|
||
if ($raw === null) {
|
||
throw new \RuntimeException('Format gambar tidak valid (harus base64)');
|
||
}
|
||
file_put_contents($tmpFile, $raw);
|
||
|
||
$result = $faceService->extractEmbeddingWithQuality($tmpFile);
|
||
$embeddings[] = $result['embedding'];
|
||
|
||
$faceModel->insert([
|
||
'student_id' => $studentId,
|
||
'embedding' => json_encode($result['embedding']),
|
||
'source' => 'live',
|
||
'quality_score' => $result['quality_score'],
|
||
]);
|
||
|
||
$saved++;
|
||
} catch (\Throwable $e) {
|
||
$errors[] = [
|
||
'index' => $idx,
|
||
'reason' => $e->getMessage(),
|
||
];
|
||
} finally {
|
||
if (is_file($tmpFile)) {
|
||
@unlink($tmpFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Optional: simpan embedding rata-rata sebagai live_avg jika ada cukup sample
|
||
if (count($embeddings) >= 2) {
|
||
$avg = $this->averageEmbedding($embeddings);
|
||
$faceModel->insert([
|
||
'student_id' => $studentId,
|
||
'embedding' => json_encode($avg),
|
||
'source' => 'live_avg',
|
||
'quality_score' => null,
|
||
]);
|
||
}
|
||
|
||
return $this->successResponse([
|
||
'student_id' => $studentId,
|
||
'saved' => $saved,
|
||
'errors' => $errors,
|
||
], 'Enrollment live selesai');
|
||
}
|
||
|
||
/**
|
||
* Hitung rata-rata beberapa embedding (elemen per elemen).
|
||
*
|
||
* @param array<int, array<int,float>> $vectors
|
||
* @return float[]
|
||
*/
|
||
protected function averageEmbedding(array $vectors): array
|
||
{
|
||
$count = count($vectors);
|
||
if ($count === 0) {
|
||
return [];
|
||
}
|
||
$dim = count($vectors[0]);
|
||
$sum = array_fill(0, $dim, 0.0);
|
||
foreach ($vectors as $vec) {
|
||
for ($i = 0; $i < $dim; $i++) {
|
||
$sum[$i] += (float) $vec[$i];
|
||
}
|
||
}
|
||
for ($i = 0; $i < $dim; $i++) {
|
||
$sum[$i] /= $count;
|
||
}
|
||
return $sum;
|
||
}
|
||
|
||
/**
|
||
* Decode string base64 (data URL atau plain base64) ke binary.
|
||
*/
|
||
protected function decodeBase64Image(string $input): ?string
|
||
{
|
||
$input = trim($input);
|
||
if ($input === '') {
|
||
return null;
|
||
}
|
||
if (strpos($input, 'base64,') !== false) {
|
||
$parts = explode('base64,', $input, 2);
|
||
$input = $parts[1];
|
||
}
|
||
$data = base64_decode($input, true);
|
||
return $data === false ? null : $data;
|
||
}
|
||
}
|
||
|