migrant-web/app/Services/GoogleVisionOcrService.php

324 lines
12 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GoogleVisionOcrService
{
/**
* Call Google Cloud Vision API to detect text in the uploaded image.
*/
public static function extractPassportData(UploadedFile $file): array
{
$apiKey = config('services.google.vision_api_key');
if (empty($apiKey)) {
Log::warning('Google Vision API key is not configured.');
return [
'success' => false,
'message' => 'Google Vision API key is not configured.',
'data' => self::emptyData(),
];
}
try {
$base64Image = base64_encode($file->get());
$url = "https://vision.googleapis.com/v1/images:annotate?key={$apiKey}";
$payload = [
'requests' => [
[
'image' => [
'content' => $base64Image,
],
'features' => [
[
'type' => 'TEXT_DETECTION',
],
],
],
],
];
$response = Http::withoutVerifying()->timeout(30)->post($url, $payload);
if (!$response->successful()) {
Log::error('Google Vision API error response: ' . $response->body());
return [
'success' => false,
'message' => 'Google Vision API request failed.',
'data' => self::emptyData(),
];
}
$json = $response->json();
$fullText = $json['responses'][0]['fullTextAnnotation']['text'] ?? '';
if (empty($fullText)) {
Log::warning('Google Vision API did not detect any text.');
return [
'success' => true,
'data' => self::emptyData(),
];
}
$parsedData = self::parsePassportText($fullText);
return [
'success' => true,
'data' => $parsedData,
];
} catch (\Exception $e) {
Log::error('Google Vision OCR exception: ' . $e->getMessage());
return [
'success' => false,
'message' => 'An error occurred during OCR processing.',
'data' => self::emptyData(),
];
}
}
private static function emptyData(): array
{
return [
'date_of_birth' => null,
'date_of_expiry' => null,
'date_of_issue' => null,
'given_names' => null,
'issuing_country' => null,
'nationality' => null,
'passport_number' => null,
'place_of_birth' => null,
];
}
/**
* Parse raw OCR text to extract passport fields.
*/
public static function parsePassportText(string $text): array
{
$data = self::emptyData();
// Split text into lines
$lines = explode("\n", $text);
$cleanLines = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '') {
$cleanLines[] = $line;
}
}
// Try to parse MRZ first
$mrzLines = self::findMrzLines($cleanLines);
if ($mrzLines) {
$mrzData = self::parseMrz($mrzLines[0], $mrzLines[1]);
foreach ($mrzData as $key => $val) {
if ($val !== null) {
$data[$key] = $val;
}
}
}
// ── Passport Number fallback ─────────────────────────────────────────
if (empty($data['passport_number'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:passport|pass\s*no|doc\s*no|document|number|no\.?)\s*[:\-\s]\s*([A-Z0-9]{7,9})/i', $line, $matches)) {
$data['passport_number'] = strtoupper($matches[1]);
break;
}
}
if (empty($data['passport_number'])) {
foreach ($cleanLines as $line) {
if (preg_match('/\b([A-Z][0-9]{7,8}|[A-Z0-9]{8,9})\b/', $line, $matches)) {
$data['passport_number'] = strtoupper($matches[1]);
break;
}
}
}
}
// ── Given Names fallback ─────────────────────────────────────────────
if (empty($data['given_names'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:given\s*names?|first\s*names?|name|nom)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) {
$data['given_names'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Place of Birth fallback ──────────────────────────────────────────
if (empty($data['place_of_birth'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:place\s*of\s*birth|lieu\s*de\s*naissance|birthplace)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) {
$data['place_of_birth'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Dates fallback ───────────────────────────────────────────────────
if (empty($data['date_of_birth'])) {
$data['date_of_birth'] = self::findDateByKeyword($cleanLines, ['birth', 'dob', 'naissance']);
}
if (empty($data['date_of_expiry'])) {
$data['date_of_expiry'] = self::findDateByKeyword($cleanLines, ['expiry', 'expiration', 'exp', 'valable']);
}
if (empty($data['date_of_issue'])) {
$data['date_of_issue'] = self::findDateByKeyword($cleanLines, ['issue', 'delivrance', 'issued']);
}
// ── Nationality fallback ─────────────────────────────────────────────
if (empty($data['nationality'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:nationality|nationalite)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) {
$data['nationality'] = trim(strtoupper($matches[1]));
break;
}
}
}
// ── Issuing Country fallback ─────────────────────────────────────────
if (empty($data['issuing_country'])) {
foreach ($cleanLines as $line) {
if (preg_match('/(?:issuing\s*state|issuing\s*country|country)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) {
$data['issuing_country'] = trim(strtoupper($matches[1]));
break;
}
}
}
return $data;
}
/**
* Look for MRZ lines in clean lines.
*/
private static function findMrzLines(array $lines): ?array
{
$candidateLines = [];
foreach ($lines as $line) {
$cleaned = str_replace(' ', '', $line);
$cleaned = str_replace('«', '<', $cleaned);
if (strlen($cleaned) >= 35 && strlen($cleaned) <= 50) {
$candidateLines[] = [
'original' => $line,
'cleaned' => $cleaned
];
}
}
for ($i = 0; $i < count($candidateLines); $i++) {
$c1 = $candidateLines[$i]['cleaned'];
if (strpos($c1, 'P') === 0) {
for ($j = 0; $j < count($candidateLines); $j++) {
if ($i === $j) continue;
$c2 = $candidateLines[$j]['cleaned'];
if (preg_match('/^[A-Z0-9<]{35,50}$/', $c2)) {
if (preg_match('/\d{6}/', $c2)) {
return [$candidateLines[$i]['cleaned'], $candidateLines[$j]['cleaned']];
}
}
}
}
}
return null;
}
/**
* Parse standard 44-character passport MRZ lines.
*/
private static function parseMrz(string $line1, string $line2): array
{
$data = self::emptyData();
$line1 = str_pad(substr($line1, 0, 44), 44, '<');
$line2 = str_pad(substr($line2, 0, 44), 44, '<');
$data['issuing_country'] = str_replace('<', '', substr($line1, 2, 3));
$namePart = substr($line1, 5);
$parts = explode('<<', $namePart);
$surname = isset($parts[0]) ? str_replace('<', ' ', $parts[0]) : '';
$givenNames = isset($parts[1]) ? str_replace('<', ' ', $parts[1]) : '';
$data['given_names'] = trim(strtoupper($givenNames));
if (empty($data['given_names'])) {
$data['given_names'] = trim(strtoupper($surname));
}
$data['passport_number'] = str_replace('<', '', substr($line2, 0, 9));
$data['nationality'] = str_replace('<', '', substr($line2, 10, 3));
$rawDob = substr($line2, 13, 6);
if (ctype_digit($rawDob)) {
$data['date_of_birth'] = self::formatMrzDate($rawDob, true);
}
$rawExpiry = substr($line2, 21, 6);
if (ctype_digit($rawExpiry)) {
$data['date_of_expiry'] = self::formatMrzDate($rawExpiry, false);
}
return $data;
}
private static function formatMrzDate(string $yymmdd, bool $isBirth): string
{
$yy = intval(substr($yymmdd, 0, 2));
$mm = substr($yymmdd, 2, 2);
$dd = substr($yymmdd, 4, 2);
$currentYear = intval(date('Y'));
$currentYearShort = $currentYear % 100;
if ($isBirth) {
$year = ($yy > $currentYearShort) ? (1900 + $yy) : (2000 + $yy);
} else {
$year = ($yy > ($currentYearShort + 20)) ? (1900 + $yy) : (2000 + $yy);
}
return "{$dd}/{$mm}/{$year}";
}
private static function findDateByKeyword(array $lines, array $keywords): ?string
{
foreach ($lines as $i => $line) {
$matched = false;
foreach ($keywords as $kw) {
if (stripos($line, $kw) !== false) {
$matched = true;
break;
}
}
if ($matched) {
$textToSearch = $line;
if (isset($lines[$i + 1])) {
$textToSearch .= ' ' . $lines[$i + 1];
}
if (preg_match('/(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})/', $textToSearch, $matches)) {
$day = str_pad($matches[1], 2, '0', STR_PAD_LEFT);
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$year = $matches[3];
return "{$day}/{$month}/{$year}";
}
if (preg_match('/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/', $textToSearch, $matches)) {
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
return "{$day}/{$month}/{$year}";
}
}
}
return null;
}
}