init backend presensi
This commit is contained in:
236
app/Modules/Face/Controllers/FaceController.php
Normal file
236
app/Modules/Face/Controllers/FaceController.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Modules/Face/Models/StudentFaceModel.php
Normal file
32
app/Modules/Face/Models/StudentFaceModel.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Face\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class StudentFaceModel extends Model
|
||||
{
|
||||
protected $table = 'student_faces';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'student_id',
|
||||
'embedding',
|
||||
'source',
|
||||
'quality_score',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
protected $validationRules = [
|
||||
'student_id' => 'required|integer|is_not_unique[students.id]',
|
||||
'embedding' => 'required',
|
||||
'source' => 'required|in_list[formal,live,live_avg]',
|
||||
'quality_score' => 'permit_empty|numeric',
|
||||
];
|
||||
}
|
||||
|
||||
15
app/Modules/Face/Routes.php
Normal file
15
app/Modules/Face/Routes.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Face Module Routes
|
||||
*
|
||||
* @var \CodeIgniter\Router\RouteCollection $routes
|
||||
*/
|
||||
|
||||
$routes->group('api/face', [
|
||||
'namespace' => 'App\Modules\Face\Controllers',
|
||||
], function ($routes) {
|
||||
$routes->post('import-formal', 'FaceController::importFormal', ['filter' => 'admin_only']);
|
||||
$routes->post('enroll-live', 'FaceController::enrollLive');
|
||||
});
|
||||
|
||||
144
app/Modules/Face/Services/FaceService.php
Normal file
144
app/Modules/Face/Services/FaceService.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Face\Services;
|
||||
|
||||
use CodeIgniter\HTTP\CURLRequest;
|
||||
|
||||
/**
|
||||
* FaceService
|
||||
*
|
||||
* Bertugas berkomunikasi dengan engine embedding wajah eksternal (Python/ONNX),
|
||||
* serta menyediakan helper cosine similarity.
|
||||
*
|
||||
* Konfigurasi ENV:
|
||||
* - FACE_SERVICE_URL (contoh: http://localhost:5000)
|
||||
* - FACE_EMBEDDING_DIM (default: 512)
|
||||
* - FACE_SIM_THRESHOLD (default: 0.85)
|
||||
*/
|
||||
class FaceService
|
||||
{
|
||||
protected CURLRequest $http;
|
||||
protected string $serviceUrl;
|
||||
protected int $embeddingDim;
|
||||
protected float $defaultThreshold;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
/** @var CURLRequest $http */
|
||||
$http = service('curlrequest');
|
||||
$this->http = $http;
|
||||
$this->serviceUrl = rtrim((string) (env('FACE_SERVICE_URL') ?? 'http://localhost:5000'), '/');
|
||||
$this->embeddingDim = (int) (env('FACE_EMBEDDING_DIM') ?? 512);
|
||||
$this->defaultThreshold = (float) (env('FACE_SIM_THRESHOLD') ?? 0.85);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim gambar ke service eksternal untuk mendapatkan embedding + metrik kualitas.
|
||||
* Expected response JSON:
|
||||
* {
|
||||
* "embedding": [float...],
|
||||
* "quality_score": float,
|
||||
* "faces_count": int,
|
||||
* "face_size": float,
|
||||
* "blur": float,
|
||||
* "brightness": float
|
||||
* }
|
||||
*
|
||||
* @throws \RuntimeException jika gagal atau quality gate tidak lolos
|
||||
*/
|
||||
public function extractEmbeddingWithQuality(string $imagePath): array
|
||||
{
|
||||
if (! is_file($imagePath)) {
|
||||
throw new \RuntimeException('File gambar tidak ditemukan: ' . $imagePath);
|
||||
}
|
||||
|
||||
$url = $this->serviceUrl . '/embed';
|
||||
|
||||
$response = $this->http->post($url, [
|
||||
'multipart' => [
|
||||
[
|
||||
'name' => 'image',
|
||||
'contents' => fopen($imagePath, 'rb'),
|
||||
'filename' => basename($imagePath),
|
||||
],
|
||||
],
|
||||
'timeout' => 15,
|
||||
]);
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
if ($status !== 200) {
|
||||
throw new \RuntimeException('Face service HTTP error: ' . $status);
|
||||
}
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
if (! is_array($data) || empty($data['embedding']) || ! is_array($data['embedding'])) {
|
||||
throw new \RuntimeException('Face service response invalid');
|
||||
}
|
||||
|
||||
$embedding = array_map('floatval', $data['embedding']);
|
||||
if (count($embedding) !== $this->embeddingDim) {
|
||||
throw new \RuntimeException('Embedding dimensi tidak sesuai: ' . count($embedding));
|
||||
}
|
||||
|
||||
$facesCount = (int) ($data['faces_count'] ?? 0);
|
||||
$faceSize = (float) ($data['face_size'] ?? 0);
|
||||
$blur = (float) ($data['blur'] ?? 0);
|
||||
$brightness = (float) ($data['brightness'] ?? 0);
|
||||
|
||||
// Quality gates (bisa di-tune lewat ENV nanti)
|
||||
if ($facesCount !== 1) {
|
||||
throw new \RuntimeException('Foto harus mengandung tepat 1 wajah (faces_count=' . $facesCount . ')');
|
||||
}
|
||||
if ($faceSize < (float) (env('FACE_MIN_SIZE', 80))) {
|
||||
throw new \RuntimeException('Wajah terlalu kecil untuk verifikasi');
|
||||
}
|
||||
if ($blur < (float) (env('FACE_MIN_BLUR', 30))) {
|
||||
throw new \RuntimeException('Foto terlalu blur, silakan ulangi');
|
||||
}
|
||||
if ($brightness < (float) (env('FACE_MIN_BRIGHTNESS', 0.2))) {
|
||||
throw new \RuntimeException('Foto terlalu gelap, silakan cari cahaya lebih terang');
|
||||
}
|
||||
|
||||
return [
|
||||
'embedding' => $embedding,
|
||||
'quality_score' => (float) ($data['quality_score'] ?? 1.0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity antara dua embedding.
|
||||
*
|
||||
* @param float[] $a
|
||||
* @param float[] $b
|
||||
*/
|
||||
public function cosineSimilarity(array $a, array $b): float
|
||||
{
|
||||
if (count($a) !== count($b) || count($a) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$dot = 0.0;
|
||||
$na = 0.0;
|
||||
$nb = 0.0;
|
||||
$n = count($a);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$va = (float) $a[$i];
|
||||
$vb = (float) $b[$i];
|
||||
$dot += $va * $vb;
|
||||
$na += $va * $va;
|
||||
$nb += $vb * $vb;
|
||||
}
|
||||
|
||||
if ($na <= 0.0 || $nb <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $dot / (sqrt($na) * sqrt($nb));
|
||||
}
|
||||
|
||||
public function getDefaultThreshold(): float
|
||||
{
|
||||
return $this->defaultThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user