perbaikan pengambilan video dan re train ml
This commit is contained in:
parent
f5366f7f72
commit
0a18029912
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MigrateExistingEmbeddings implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 3600;
|
||||
public $uniqueFor = 300;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Log::info("Memulai migrasi embedding untuk data wajah yang sudah ada...");
|
||||
|
||||
$users = DB::table('data_wajah')
|
||||
->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}."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
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;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('data_wajah', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('data_wajah', 'face_embeddings')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue