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