332 lines
11 KiB
PHP
332 lines
11 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use Exception;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
/**
|
||
* Google Cloud Vision API サービス
|
||
* SHJ-1本人確認自動処理でOCR処理に使用
|
||
*/
|
||
class GoogleVisionService
|
||
{
|
||
protected $apiKey;
|
||
protected $projectId;
|
||
protected $baseUrl = 'https://vision.googleapis.com/v1';
|
||
|
||
public function __construct()
|
||
{
|
||
$this->apiKey = config('shj1.apis.google_vision.api_key');
|
||
$this->projectId = config('shj1.apis.google_vision.project_id');
|
||
}
|
||
|
||
/**
|
||
* 画像からテキストを抽出(OCR処理)
|
||
*
|
||
* @param string $imageFilename 画像ファイル名
|
||
* @return string 抽出されたテキスト
|
||
* @throws Exception
|
||
*/
|
||
public function extractTextFromImage(string $imageFilename): string
|
||
{
|
||
try {
|
||
Log::info('GoogleVision OCR処理開始', [
|
||
'filename' => $imageFilename
|
||
]);
|
||
|
||
// 画像ファイルを取得
|
||
$imageData = $this->getImageData($imageFilename);
|
||
|
||
// Vision API リクエスト実行
|
||
$detectedText = $this->callVisionApi($imageData);
|
||
|
||
Log::info('GoogleVision OCR処理完了', [
|
||
'filename' => $imageFilename,
|
||
'text_length' => strlen($detectedText),
|
||
'text_preview' => substr($detectedText, 0, 100) . '...',
|
||
'text_full' => $detectedText, // ← 完整のOCR結果を追加
|
||
'debug_marker' => 'NEW_CODE_EXECUTED_' . time() // ← デバッグマーカー追加
|
||
]);
|
||
|
||
return $detectedText;
|
||
|
||
} catch (Exception $e) {
|
||
Log::error('GoogleVision OCR処理エラー', [
|
||
'filename' => $imageFilename,
|
||
'error' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString()
|
||
]);
|
||
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 画像データを取得(Laravel Storage使用)
|
||
*/
|
||
private function getImageData(string $filename): string
|
||
{
|
||
$disk = config('shj1.storage.photo_disk');
|
||
|
||
Log::info('GoogleVision 画像ファイル読取開始', [
|
||
'filename' => $filename,
|
||
'disk' => $disk
|
||
]);
|
||
|
||
if (!Storage::disk($disk)->exists($filename)) {
|
||
Log::error('GoogleVision 画像ファイルが見つかりません', [
|
||
'filename' => $filename,
|
||
'disk' => $disk
|
||
]);
|
||
throw new Exception("画像ファイルが見つかりません: {$filename}");
|
||
}
|
||
|
||
$imageContent = Storage::disk($disk)->get($filename);
|
||
|
||
if (empty($imageContent)) {
|
||
Log::error('GoogleVision 画像ファイルが空です', [
|
||
'filename' => $filename
|
||
]);
|
||
throw new Exception("画像ファイルが空です: {$filename}");
|
||
}
|
||
|
||
Log::info('GoogleVision 画像ファイル読取成功', [
|
||
'filename' => $filename,
|
||
'size' => strlen($imageContent)
|
||
]);
|
||
|
||
return base64_encode($imageContent);
|
||
}
|
||
|
||
/**
|
||
* Google Cloud Vision API呼び出し
|
||
*/
|
||
private function callVisionApi(string $base64Image): string
|
||
{
|
||
if (!$this->isApiKeyConfigured()) {
|
||
throw new Exception("Google Vision API キーが設定されていません");
|
||
}
|
||
|
||
$url = "{$this->baseUrl}/images:annotate";
|
||
|
||
$requestBody = [
|
||
'requests' => [
|
||
[
|
||
'image' => [
|
||
'content' => $base64Image
|
||
],
|
||
'features' => [
|
||
[
|
||
'type' => 'TEXT_DETECTION',
|
||
'maxResults' => 1
|
||
]
|
||
],
|
||
'imageContext' => [
|
||
'languageHints' => ['ja'] // 日本語優先
|
||
]
|
||
]
|
||
]
|
||
];
|
||
|
||
$response = Http::timeout(30)
|
||
->withHeaders([
|
||
'Content-Type' => 'application/json',
|
||
])
|
||
->post($url . '?key=' . $this->apiKey, $requestBody);
|
||
|
||
if (!$response->successful()) {
|
||
throw new Exception("Google Vision API request failed: " . $response->status());
|
||
}
|
||
|
||
$data = $response->json();
|
||
|
||
// APIエラーチェック
|
||
if (isset($data['responses'][0]['error'])) {
|
||
$error = $data['responses'][0]['error'];
|
||
throw new Exception("Google Vision API error: " . $error['message']);
|
||
}
|
||
|
||
// テキスト抽出結果を取得
|
||
$textAnnotations = $data['responses'][0]['textAnnotations'] ?? [];
|
||
|
||
if (empty($textAnnotations)) {
|
||
return ''; // テキストが検出されなかった場合
|
||
}
|
||
|
||
// 最初のアノテーションが全体のテキスト
|
||
return $textAnnotations[0]['description'] ?? '';
|
||
}
|
||
|
||
/**
|
||
* 文字列の類似度を計算
|
||
*
|
||
* @param string $expected 期待する文字列
|
||
* @param string $detected 検出された文字列
|
||
* @return float 類似度(0-100)
|
||
*/
|
||
public function calculateSimilarity(string $expected, string $detected): float
|
||
{
|
||
// 前処理:空白文字削除、大文字小文字統一
|
||
$expected = $this->normalizeText($expected);
|
||
$detected = $this->normalizeText($detected);
|
||
|
||
if (empty($expected) || empty($detected)) {
|
||
return 0.0;
|
||
}
|
||
|
||
// 複数の計算方法を試し、最高値を返す
|
||
$similarities = [];
|
||
|
||
// 1. 標準的な類似度計算
|
||
similar_text($expected, $detected, $percent);
|
||
$similarities[] = $percent;
|
||
|
||
// 2. 文字含有率計算(OCRの分離文字対応)
|
||
$containsScore = $this->calculateContainsScore($expected, $detected);
|
||
$similarities[] = $containsScore;
|
||
|
||
// 3. 部分文字列マッチング
|
||
$substringScore = $this->calculateSubstringScore($expected, $detected);
|
||
$similarities[] = $substringScore;
|
||
|
||
// 最高スコアを返す
|
||
return max($similarities);
|
||
}
|
||
|
||
/**
|
||
* 文字含有率スコア計算(姓名の分離文字対応)
|
||
*/
|
||
private function calculateContainsScore(string $expected, string $detected): float
|
||
{
|
||
if (empty($expected)) return 0.0;
|
||
|
||
// 期待文字列の各文字が検出文字列に含まれるかチェック
|
||
$expectedChars = mb_str_split($expected, 1, 'UTF-8');
|
||
$containedCount = 0;
|
||
|
||
foreach ($expectedChars as $char) {
|
||
if (mb_strpos($detected, $char, 0, 'UTF-8') !== false) {
|
||
$containedCount++;
|
||
}
|
||
}
|
||
|
||
return ($containedCount / count($expectedChars)) * 100;
|
||
}
|
||
|
||
/**
|
||
* 部分文字列マッチングスコア計算
|
||
*/
|
||
private function calculateSubstringScore(string $expected, string $detected): float
|
||
{
|
||
if (empty($expected)) return 0.0;
|
||
|
||
// 期待文字列を2文字以上のチャンクに分割してマッチング
|
||
$chunks = [];
|
||
$expectedLength = mb_strlen($expected, 'UTF-8');
|
||
|
||
// 2文字チャンク
|
||
for ($i = 0; $i <= $expectedLength - 2; $i++) {
|
||
$chunks[] = mb_substr($expected, $i, 2, 'UTF-8');
|
||
}
|
||
|
||
// 3文字チャンク
|
||
for ($i = 0; $i <= $expectedLength - 3; $i++) {
|
||
$chunks[] = mb_substr($expected, $i, 3, 'UTF-8');
|
||
}
|
||
|
||
if (empty($chunks)) return 0.0;
|
||
|
||
$matchedChunks = 0;
|
||
foreach ($chunks as $chunk) {
|
||
if (mb_strpos($detected, $chunk, 0, 'UTF-8') !== false) {
|
||
$matchedChunks++;
|
||
}
|
||
}
|
||
|
||
return ($matchedChunks / count($chunks)) * 100;
|
||
}
|
||
|
||
/**
|
||
* テキスト正規化(OCR認識結果の形式差異に対応)
|
||
*/
|
||
private function normalizeText(string $text): string
|
||
{
|
||
// 空白文字除去、改行除去
|
||
$text = preg_replace('/\s+/', '', $text);
|
||
|
||
// 全角英数字・記号を半角に変換
|
||
$text = mb_convert_kana($text, 'rnask', 'UTF-8');
|
||
|
||
// 住所の同義語統一
|
||
$text = str_replace(['東京市', '東京府'], '東京都', $text);
|
||
$text = str_replace(['大阪市', '大阪府'], '大阪', $text);
|
||
|
||
// 数字の統一(全角→半角は上でやっているが、明示的に処理)
|
||
$text = str_replace(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
|
||
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], $text);
|
||
|
||
// 記号の統一
|
||
$text = str_replace(['ー', '−', '―'], '-', $text);
|
||
$text = str_replace(['の'], '', $text); // 「1番地の1」→「1番地1」
|
||
|
||
return $text;
|
||
}
|
||
|
||
/**
|
||
* 部分マッチング検索
|
||
*
|
||
* @param string $needle 検索する文字列
|
||
* @param string $haystack 検索対象の文字列
|
||
* @return bool
|
||
*/
|
||
public function containsText(string $needle, string $haystack): bool
|
||
{
|
||
$needle = $this->normalizeText($needle);
|
||
$haystack = $this->normalizeText($haystack);
|
||
|
||
return mb_strpos($haystack, $needle) !== false;
|
||
}
|
||
|
||
/**
|
||
* 画像形式の検証
|
||
*/
|
||
public function isValidImageFormat(string $filename): bool
|
||
{
|
||
$allowedExtensions = config('shj1.storage.allowed_extensions');
|
||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||
|
||
return in_array($extension, $allowedExtensions);
|
||
}
|
||
|
||
/**
|
||
* APIキーが設定されているかチェック
|
||
*/
|
||
public function isApiKeyConfigured(): bool
|
||
{
|
||
$dummyKey = env('GOOGLE_VISION_API_KEY') === 'dummy_google_vision_api_key_replace_with_real_one';
|
||
return !empty($this->apiKey) && !$dummyKey;
|
||
}
|
||
|
||
/**
|
||
* Vision API レスポンスから信頼度を取得
|
||
*/
|
||
public function getConfidenceScore(array $textAnnotation): float
|
||
{
|
||
if (!isset($textAnnotation['boundingPoly']['vertices'])) {
|
||
return 0.0;
|
||
}
|
||
|
||
// 検出領域のサイズから信頼度を推定
|
||
$vertices = $textAnnotation['boundingPoly']['vertices'];
|
||
if (count($vertices) < 4) {
|
||
return 0.0;
|
||
}
|
||
|
||
// 単純な信頼度計算(実際の実装では、より詳細な分析が必要)
|
||
return 0.95; // デフォルト高信頼度
|
||
}
|
||
}
|