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; // デフォルト高信頼度 } }