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> $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; } }