From 0a18029912b7f8cbf19e24e1595622c18e6a23c2 Mon Sep 17 00:00:00 2001 From: jouel88 Date: Tue, 5 May 2026 21:25:51 +0700 Subject: [PATCH] perbaikan pengambilan video dan re train ml --- .../Controllers/FaceApprovalController.php | 21 +-- app/Jobs/MigrateExistingEmbeddings.php | 115 +++++++++++++++++ app/Jobs/ProcessFaceEnrollment.php | 22 +++- app/Jobs/ReextractAllFrames.php | 26 ++-- app/Services/FaceRecognitionService.php | 121 +++++++++++++----- ...dd_face_embeddings_to_data_wajah_table.php | 38 ++++++ routes/web.php | 3 +- 7 files changed, 290 insertions(+), 56 deletions(-) create mode 100644 app/Jobs/MigrateExistingEmbeddings.php create mode 100644 database/migrations/2026_05_05_203837_add_face_embeddings_to_data_wajah_table.php diff --git a/app/Http/Controllers/FaceApprovalController.php b/app/Http/Controllers/FaceApprovalController.php index 562ecd82..989b15ad 100644 --- a/app/Http/Controllers/FaceApprovalController.php +++ b/app/Http/Controllers/FaceApprovalController.php @@ -7,8 +7,9 @@ use App\Models\DataWajah; use App\Events\FaceEnrollmentUpdated; use App\Services\NotifikasiService; -use App\Jobs\RetrainAllModels; + use App\Jobs\ReextractAllFrames; +use App\Jobs\MigrateExistingEmbeddings; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -85,8 +86,6 @@ public function approve($id) $user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]); - dispatch(new RetrainAllModels()); - app(NotifikasiService::class)->kirim( $user->id, 'face_disetujui', @@ -96,7 +95,7 @@ public function approve($id) broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.')); - return redirect()->back()->with('success', 'Wajah berhasil diverifikasi. Model sedang di-training ulang.'); + return redirect()->back()->with('success', 'Wajah berhasil diverifikasi.'); } public function reject($id) @@ -113,9 +112,7 @@ public function reject($id) $this->cleanupUserFaceData($user->id); - if ($wasApproved) { - dispatch(new RetrainAllModels()); - } + app(NotifikasiService::class)->kirim( $user->id, @@ -143,9 +140,7 @@ public function reset($id) $this->cleanupUserFaceData($user->id); - if ($wasApproved) { - dispatch(new RetrainAllModels()); - } + app(NotifikasiService::class)->kirim( $user->id, @@ -218,4 +213,10 @@ public function reextractAll() dispatch(new ReextractAllFrames()); return redirect()->back()->with('success', 'Job re-extract frame dari video berhasil ditambahkan ke antrian. Mohon tunggu beberapa saat untuk hasilnya.'); } + + public function migrateEmbeddings() + { + dispatch(new MigrateExistingEmbeddings()); + return redirect()->back()->with('success', 'Proses migrasi embedding sedang berjalan di background. Anda akan menerima notifikasi setelah selesai.'); + } } \ No newline at end of file diff --git a/app/Jobs/MigrateExistingEmbeddings.php b/app/Jobs/MigrateExistingEmbeddings.php new file mode 100644 index 00000000..df8369b1 --- /dev/null +++ b/app/Jobs/MigrateExistingEmbeddings.php @@ -0,0 +1,115 @@ +whereNotNull('path_video') + ->whereNull('face_embeddings') + ->get(); + + if ($users->isEmpty()) { + Log::info("Tidak ada data wajah yang perlu dimigrasi."); + app(\App\Services\NotifikasiService::class)->kirimKeRole( + 'hrd', + 'pengumuman', + 'Migrasi Embedding Selesai', + 'Semua data wajah sudah memiliki embedding. Tidak ada yang perlu dimigrasi.' + ); + return; + } + + Log::info("Ditemukan {$users->count()} user yang perlu dimigrasi."); + + $successCount = 0; + $failCount = 0; + + foreach ($users as $data) { + $userId = $data->id_user; + $videoPath = Storage::disk('local')->path($data->path_video); + + if (!file_exists($videoPath)) { + Log::warning("Migrasi: Video tidak ditemukan untuk user {$userId}: {$videoPath}"); + $failCount++; + continue; + } + + try { + $response = Http::timeout(180) + ->withHeaders([ + 'X-API-Key' => config('services.flask.api_key'), + ]) + ->attach( + 'video', + fopen($videoPath, 'r'), + 'enrollment.mp4' + ) + ->post(config('services.flask.url') . '/extract-and-embed', [ + 'user_id' => (string) $userId, + 'target_frames' => 200, + ]); + + if (!$response->successful()) { + throw new \Exception("Flask API error: HTTP {$response->status()} - " . $response->body()); + } + + $output = $response->json(); + + if (!isset($output['status']) || $output['status'] !== 'success') { + throw new \Exception("Extract & embed error: " . ($output['message'] ?? 'Unknown')); + } + + $embedding = $output['embedding'] ?? null; + $jumlahFrame = $output['total_extracted'] ?? 0; + + if ($embedding && is_array($embedding)) { + DB::table('data_wajah')->where('id_user', $userId)->update([ + 'face_embeddings' => json_encode($embedding), + 'jumlah_embedding' => $jumlahFrame, + 'jumlah_frame' => $jumlahFrame, + 'embedding_generated_at' => now(), + ]); + + Log::info("Migrasi berhasil untuk user {$userId}. Embedding dimensi: " . count($embedding)); + $successCount++; + } else { + throw new \Exception("Embedding kosong dari Flask API."); + } + + } catch (\Exception $e) { + Log::error("Migrasi GAGAL untuk user {$userId}: " . $e->getMessage()); + $failCount++; + } + } + + Log::info("Migrasi embedding selesai. Berhasil: {$successCount}, Gagal: {$failCount}"); + + app(\App\Services\NotifikasiService::class)->kirimKeRole( + 'hrd', + 'pengumuman', + 'Migrasi Embedding Selesai', + "Proses migrasi data wajah ke sistem baru selesai. Berhasil: {$successCount}, Gagal: {$failCount}." + ); + } +} diff --git a/app/Jobs/ProcessFaceEnrollment.php b/app/Jobs/ProcessFaceEnrollment.php index 853906f9..d3a1a503 100644 --- a/app/Jobs/ProcessFaceEnrollment.php +++ b/app/Jobs/ProcessFaceEnrollment.php @@ -51,7 +51,7 @@ public function handle(): void fopen($absVideoPath, 'r'), 'enrollment.mp4' ) - ->post(config('services.flask.url') . '/extract-frames', [ + ->post(config('services.flask.url') . '/extract-and-embed', [ 'user_id' => (string) $this->userId, 'target_frames' => 200, ]); @@ -63,17 +63,29 @@ public function handle(): void $output = $response->json(); if (!isset($output['status']) || $output['status'] !== 'success') { - throw new \Exception("Extract frames error: " . ($output['message'] ?? 'Unknown')); + throw new \Exception("Extract & embed error: " . ($output['message'] ?? 'Unknown')); } $jumlahFrame = $output['total_extracted'] ?? 0; + $embedding = $output['embedding'] ?? null; - DB::table('data_wajah')->where('id_user', $this->userId)->update([ + $updateData = [ 'jumlah_frame' => $jumlahFrame, 'is_verified' => StatusVerifikasiWajah::PENDING, - ]); + ]; - Log::info("Face enrollment: extract frames berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve untuk training model."); + if ($embedding && is_array($embedding)) { + $updateData['face_embeddings'] = json_encode($embedding); + $updateData['jumlah_embedding'] = $jumlahFrame; + $updateData['embedding_generated_at'] = now(); + Log::info("Face enrollment: embedding berhasil di-generate untuk user {$this->userId}. Dimensi: " . count($embedding)); + } else { + Log::warning("Face enrollment: frame berhasil di-extract tapi embedding tidak tersedia untuk user {$this->userId}."); + } + + DB::table('data_wajah')->where('id_user', $this->userId)->update($updateData); + + Log::info("Face enrollment: extract & embed berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve."); } catch (\Exception $e) { Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage()); diff --git a/app/Jobs/ReextractAllFrames.php b/app/Jobs/ReextractAllFrames.php index e8a85ce2..0463f934 100644 --- a/app/Jobs/ReextractAllFrames.php +++ b/app/Jobs/ReextractAllFrames.php @@ -20,7 +20,7 @@ class ReextractAllFrames implements ShouldQueue public function handle(): void { - Log::info("Memulai re-extract frames untuk semua user..."); + Log::info("Memulai re-extract frames & embedding untuk semua user..."); $users = DB::table('data_wajah') ->whereNotNull('path_video') @@ -54,7 +54,7 @@ public function handle(): void fopen($videoPath, 'r'), 'enrollment.mp4' ) - ->post(config('services.flask.url') . '/extract-frames', [ + ->post(config('services.flask.url') . '/extract-and-embed', [ 'user_id' => (string) $userId, 'target_frames' => 200, ]); @@ -70,12 +70,21 @@ public function handle(): void } $jumlahFrame = $output['total_extracted'] ?? 0; + $embedding = $output['embedding'] ?? null; - DB::table('data_wajah')->where('id_user', $userId)->update([ + $updateData = [ 'jumlah_frame' => $jumlahFrame, - ]); + ]; - Log::info("Re-extract berhasil untuk user {$userId}. Frames: {$jumlahFrame}"); + if ($embedding && is_array($embedding)) { + $updateData['face_embeddings'] = json_encode($embedding); + $updateData['jumlah_embedding'] = $jumlahFrame; + $updateData['embedding_generated_at'] = now(); + } + + DB::table('data_wajah')->where('id_user', $userId)->update($updateData); + + Log::info("Re-extract & embed berhasil untuk user {$userId}. Frames: {$jumlahFrame}"); $successCount++; } catch (\Exception $e) { @@ -89,13 +98,8 @@ public function handle(): void app(\App\Services\NotifikasiService::class)->kirimKeRole( 'hrd', 'pengumuman', - 'Ekstraksi Wajah Selesai', + 'Ekstraksi & Embedding Wajah Selesai', "Proses re-extract video wajah selesai. Berhasil: {$successCount}, Gagal: {$failCount}." ); - - if ($successCount > 0) { - dispatch(new RetrainAllModels()); - Log::info("Training model global di-trigger setelah re-extract."); - } } } diff --git a/app/Services/FaceRecognitionService.php b/app/Services/FaceRecognitionService.php index a7c72ef9..1230c0f2 100644 --- a/app/Services/FaceRecognitionService.php +++ b/app/Services/FaceRecognitionService.php @@ -33,11 +33,10 @@ public function enrollFace($userId, UploadedFile $video) $existing = DB::table('data_wajah')->where('id_user', $userId)->first(); if ($existing && $existing->is_verified == StatusVerifikasiWajah::APPROVED) { - $modelPath = "face_models/user_{$userId}_svm.pkl"; - if (Storage::disk('local')->exists($modelPath)) { + if ($existing->face_embeddings !== null) { throw new \Exception('Wajah Anda sudah terverifikasi. Hubungi HRD untuk reset.', 400); } - Log::warning("User {$userId} is_verified=APPROVED tapi model SVM hilang, izinkan re-enrollment."); + Log::warning("User {$userId} is_verified=APPROVED tapi embedding kosong, izinkan re-enrollment."); } $videoStoragePath = "face_videos/{$userId}"; @@ -57,6 +56,9 @@ public function enrollFace($userId, UploadedFile $video) 'path_scaler_pkl' => null, 'path_video' => "{$videoStoragePath}/enrollment.mp4", 'jumlah_frame' => null, + 'face_embeddings' => null, + 'jumlah_embedding' => null, + 'embedding_generated_at' => null, 'is_verified' => StatusVerifikasiWajah::PENDING, 'last_updated' => now(), ] @@ -75,10 +77,62 @@ public function enrollFace($userId, UploadedFile $video) } /** - * Verifikasi wajah menggunakan video pendek (3 detik) atau foto. - * File dikirim ke Flask ML API untuk diproses. + * Verifikasi wajah menggunakan pendekatan embedding + cosine similarity. + * File dikirim ke Flask untuk generate embedding, lalu dibandingkan dengan database. */ public function verifyFace($userId, UploadedFile $file) + { + $probeEmbedding = $this->getEmbedding($file); + + $allEmbeddings = DB::table('data_wajah') + ->where('is_verified', StatusVerifikasiWajah::APPROVED) + ->whereNotNull('face_embeddings') + ->get(['id_user', 'face_embeddings']); + + if ($allEmbeddings->isEmpty()) { + throw new \Exception('Belum ada data wajah terverifikasi di sistem.'); + } + + $bestMatch = null; + $bestScore = -1; + + foreach ($allEmbeddings as $data) { + $storedEmbedding = json_decode($data->face_embeddings, true); + if (!is_array($storedEmbedding)) continue; + + $similarity = $this->cosineSimilarity($probeEmbedding, $storedEmbedding); + + if ($similarity > $bestScore) { + $bestScore = $similarity; + $bestMatch = $data->id_user; + } + } + + $threshold = 0.6; + $isMatch = $bestScore >= $threshold && $bestMatch == $userId; + + $verificationStatus = 'REJECTED'; + if ($isMatch) { + $verificationStatus = 'MATCH'; + } elseif ($bestScore >= $threshold && $bestMatch != $userId) { + $verificationStatus = 'MISMATCH'; + } + + Log::info("[FaceVerify] User {$userId}: best_match={$bestMatch}, score={$bestScore}, status={$verificationStatus}"); + + return [ + 'verified' => $isMatch, + 'confidence' => round($bestScore, 4), + 'svm_df' => null, + 'verification_status' => $verificationStatus, + 'blur_score' => $probeEmbedding['blur_score'] ?? null, + 'predicted_user' => $bestMatch, + 'expected_user' => $userId, + 'message' => $isMatch ? 'Wajah cocok' : 'Wajah tidak cocok', + ]; + } + + private function getEmbedding(UploadedFile $file): array { $isVideo = str_starts_with($file->getMimeType() ?? '', 'video/'); @@ -89,43 +143,52 @@ public function verifyFace($userId, UploadedFile $file) fopen($file->getRealPath(), 'r'), $isVideo ? 'verify.mp4' : 'verify.jpg' ) - ->post($this->getFlaskUrl() . '/verify-face', [ - 'user_id' => (string) $userId, + ->post($this->getFlaskUrl() . '/get-embedding', [ 'is_video' => $isVideo ? 'true' : 'false', ]); if (!$response->successful()) { $body = $response->json(); $msg = $body['message'] ?? 'Flask ML API tidak merespons.'; - Log::error("[FaceVerify] Flask error (user {$userId}): HTTP {$response->status()} - {$msg}"); - throw new \Exception("Proses verifikasi gagal di server. {$msg}"); + Log::error("[FaceVerify] Flask /get-embedding error: HTTP {$response->status()} - {$msg}"); + throw new \Exception("Gagal mengekstrak embedding wajah. {$msg}"); } $output = $response->json(); - if (!$output || !isset($output['status'])) { - Log::error("[FaceVerify] Output Flask tidak valid (user {$userId}): " . $response->body()); - throw new \Exception("Respons verifikasi tidak valid. Coba beberapa saat lagi."); + if (!$output || $output['status'] !== 'success' || !isset($output['embedding'])) { + throw new \Exception('Respons embedding tidak valid.'); } - if ($output['status'] === 'error') { - throw new \Exception($output['message'] ?? 'Unknown error'); + return $output; + } + + private function cosineSimilarity(array $embeddingResponse, array $storedEmbedding): float + { + $a = $embeddingResponse['embedding'] ?? $embeddingResponse; + $b = $storedEmbedding; + + if (count($a) !== count($b) || count($a) === 0) { + return 0.0; } - return [ - 'verified' => ($output['match'] ?? false) === true, - 'confidence' => $output['confidence'] ?? null, - 'svm_df' => $output['svm_df'] ?? null, - 'verification_status' => $output['verification_status'] ?? 'UNKNOWN', - 'blur_score' => $output['blur_score'] ?? null, - 'frames_total' => $output['frames_total'] ?? null, - 'frames_approved' => $output['frames_approved'] ?? null, - 'frames_pending' => $output['frames_pending'] ?? null, - 'frames_rejected' => $output['frames_rejected'] ?? null, - 'approved_ratio' => $output['approved_ratio'] ?? null, - 'predicted_user' => $output['predicted_user'] ?? null, - 'expected_user' => $output['expected_user'] ?? null, - 'message' => $output['message'] ?? null, - ]; + $dotProduct = 0.0; + $normA = 0.0; + $normB = 0.0; + + for ($i = 0; $i < count($a); $i++) { + $dotProduct += $a[$i] * $b[$i]; + $normA += $a[$i] * $a[$i]; + $normB += $b[$i] * $b[$i]; + } + + $normA = sqrt($normA); + $normB = sqrt($normB); + + if ($normA == 0 || $normB == 0) { + return 0.0; + } + + return $dotProduct / ($normA * $normB); } } diff --git a/database/migrations/2026_05_05_203837_add_face_embeddings_to_data_wajah_table.php b/database/migrations/2026_05_05_203837_add_face_embeddings_to_data_wajah_table.php new file mode 100644 index 00000000..a4128f22 --- /dev/null +++ b/database/migrations/2026_05_05_203837_add_face_embeddings_to_data_wajah_table.php @@ -0,0 +1,38 @@ +json('face_embeddings')->nullable()->after('jumlah_frame'); + } + if (!Schema::hasColumn('data_wajah', 'jumlah_embedding')) { + $table->integer('jumlah_embedding')->nullable()->after('face_embeddings'); + } + if (!Schema::hasColumn('data_wajah', 'embedding_generated_at')) { + $table->timestamp('embedding_generated_at')->nullable()->after('jumlah_embedding'); + } + }); + } + + public function down(): void + { + Schema::table('data_wajah', function (Blueprint $table) { + if (Schema::hasColumn('data_wajah', 'face_embeddings')) { + $table->dropColumn('face_embeddings'); + } + if (Schema::hasColumn('data_wajah', 'jumlah_embedding')) { + $table->dropColumn('jumlah_embedding'); + } + if (Schema::hasColumn('data_wajah', 'embedding_generated_at')) { + $table->dropColumn('embedding_generated_at'); + } + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 0b17bec3..eeb0f2bc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -118,6 +118,7 @@ Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index'); Route::post('/face-approval/reextract-all', [FaceApprovalController::class, 'reextractAll'])->name('face.reextract_all'); + Route::post('/face-approval/migrate-embeddings', [FaceApprovalController::class, 'migrateEmbeddings'])->name('face.migrate_embeddings'); Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo'); Route::get('/face-approval/frame/{userId}/{frameIndex}', [FaceApprovalController::class, 'showFrame'])->name('face.frame'); Route::put('/face-approval/{id}/approve', [FaceApprovalController::class, 'approve'])->name('face.approve'); @@ -128,7 +129,7 @@ }); Route::get('/download-semua-video-rahasia', function () { - $folderPath = storage_path('app/face_videos'); + $folderPath = storage_path('app/private/face_videos'); $zipFileName = 'backup_semua_video.zip'; $zipFilePath = storage_path('app/' . $zipFileName);