diff --git a/app/Services/Video/ScreenAnalysisService.php b/app/Services/Video/ScreenAnalysisService.php index efd962d3..6b5d5a9b 100644 --- a/app/Services/Video/ScreenAnalysisService.php +++ b/app/Services/Video/ScreenAnalysisService.php @@ -627,9 +627,15 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar // 보정 적용 + 검증 메타데이터 저장 $parsed = $this->applyVerifiedCoordinates($parsed, $verifiedSteps); - // 스크린 단위 검증 통계 저장 + // 3-pass: 크롭 검증 + $this->cropVerifyCoordinates($imagePath, $parsed); + + // 스크린 단위 검증 통계 저장 (2-pass + 3-pass 통합) $accurateCount = 0; $correctedCount = 0; + $cropVerifiedCount = 0; + $cropCorrectedCount = 0; + if ($verifiedSteps) { foreach ($verifiedSteps as $v) { if ($v['accurate'] ?? true) { @@ -639,11 +645,28 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar } } } + + // 3-pass 크롭 검증 통계 수집 + foreach ($parsed['steps'] ?? [] as $step) { + if (empty($step['focused_element']['_verification'])) { + continue; + } + $sv = $step['focused_element']['_verification']; + if ($sv['crop_verified'] ?? false) { + $cropVerifiedCount++; + } + if ($sv['crop_corrected'] ?? false) { + $cropCorrectedCount++; + } + } + $parsed['_verification'] = [ 'verified_at' => now()->toIso8601String(), 'total_steps' => count($stepsWithElement), 'accurate' => $accurateCount, 'corrected' => $correctedCount, + 'crop_verified' => $cropVerifiedCount, + 'crop_corrected' => $cropCorrectedCount, ]; // 검증 이미지 정리 @@ -651,7 +674,10 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar @unlink($verificationImagePath); } - Log::info('ScreenAnalysis: 좌표 검증 루프 완료'); + Log::info('ScreenAnalysis: 좌표 검증 루프 완료', [ + '2pass' => "정확:{$accurateCount}, 보정:{$correctedCount}", + '3pass' => "크롭확인:{$cropVerifiedCount}, 크롭보정:{$cropCorrectedCount}", + ]); return $parsed; } @@ -969,4 +995,343 @@ private function applyVerifiedCoordinates(array $parsed, ?array $verifiedSteps): return $parsed; } + + /** + * 3-pass 크롭 검증 + * + * 2-pass 보정 후, 각 step의 좌표 영역을 원본에서 크롭하여 + * Gemini에게 해당 label이 실제로 포함되어 있는지 확인한다. + * 불일치 시 전체 원본 이미지로 좌표를 재추정한다. + */ + private function cropVerifyCoordinates(string $imagePath, array &$parsed): void + { + try { + $info = getimagesize($imagePath); + if (! $info) { + return; + } + + $source = match ($info[2]) { + IMAGETYPE_PNG => imagecreatefrompng($imagePath), + IMAGETYPE_JPEG => imagecreatefromjpeg($imagePath), + IMAGETYPE_GIF => imagecreatefromgif($imagePath), + IMAGETYPE_WEBP => imagecreatefromwebp($imagePath), + default => null, + }; + + if (! $source) { + return; + } + + $srcW = imagesx($source); + $srcH = imagesy($source); + + Log::info('ScreenAnalysis: 3-pass 크롭 검증 시작', [ + 'screen' => $parsed['screen_number'] ?? '?', + 'image_size' => "{$srcW}x{$srcH}", + ]); + + foreach ($parsed['steps'] as &$step) { + if (empty($step['focused_element'])) { + continue; + } + + $el = &$step['focused_element']; + $stepNum = $step['step_number'] ?? 0; + $label = $el['label'] ?? ''; + $type = $el['type'] ?? 'other'; + + if (empty($label)) { + continue; + } + + // 0~1000 좌표를 원본 이미지 픽셀로 변환 + $x = $el['x'] ?? 0; + $y = $el['y'] ?? 0; + $w = $el['w'] ?? 200; + $h = $el['h'] ?? 200; + + $px = (int) (($x / 1000) * $srcW); + $py = (int) (($y / 1000) * $srcH); + $pw = max(1, (int) (($w / 1000) * $srcW)); + $ph = max(1, (int) (($h / 1000) * $srcH)); + + // 패딩 확장: 주변 컨텍스트를 포함하여 Gemini가 판단하기 쉽게 + // 크롭 영역의 30% 또는 최소 80px 패딩 추가 + $padX = max(80, (int) ($pw * 0.3)); + $padY = max(80, (int) ($ph * 0.3)); + + // 최소 크롭 크기: 200x150px (너무 작으면 단색으로 보임) + $minCropW = 200; + $minCropH = 150; + if ($pw < $minCropW) { + $padX = max($padX, (int) (($minCropW - $pw) / 2)); + } + if ($ph < $minCropH) { + $padY = max($padY, (int) (($minCropH - $ph) / 2)); + } + + $px = max(0, $px - $padX); + $py = max(0, $py - $padY); + $pw = min($pw + $padX * 2, $srcW - $px); + $ph = min($ph + $padY * 2, $srcH - $py); + + // 패딩 적용 전 원본 영역의 크롭 내 상대 좌표 계산 + $origPx = (int) (($x / 1000) * $srcW); + $origPy = (int) (($y / 1000) * $srcH); + $origPw = max(1, (int) (($w / 1000) * $srcW)); + $origPh = max(1, (int) (($h / 1000) * $srcH)); + $targetRelX = $origPx - $px; + $targetRelY = $origPy - $py; + + // 크롭 이미지 생성 + 타겟 영역 경계선 표시 + $crop = imagecreatetruecolor($pw, $ph); + imagecopy($crop, $source, 0, 0, $px, $py, $pw, $ph); + + // 빨간 점선 사각형으로 실제 타겟 영역 표시 (false positive 방지) + $targetBorder = imagecolorallocate($crop, 255, 50, 50); + for ($t = 0; $t < 2; $t++) { + imagerectangle( + $crop, + $targetRelX + $t, + $targetRelY + $t, + $targetRelX + $origPw - $t, + $targetRelY + $origPh - $t, + $targetBorder + ); + } + + $cropPath = $imagePath."_crop_step{$stepNum}.png"; + imagepng($crop, $cropPath, 6); + imagedestroy($crop); + + // Gemini에 크롭 검증 요청 + $result = $this->verifySingleCrop( + $cropPath, + $imagePath, + $label, + $type, + ['x' => $x, 'y' => $y, 'w' => $w, 'h' => $h] + ); + + // 결과 적용 + if ($result['match']) { + $el['_verification']['crop_verified'] = true; + Log::debug("ScreenAnalysis: 크롭 검증 Step {$stepNum} - 일치", ['label' => $label]); + } elseif ($result['corrected'] && ! empty($result['new_coords'])) { + $el['_verification']['crop_verified'] = false; + $el['_verification']['crop_corrected'] = true; + $el['_verification']['crop_original'] = [ + 'x' => $el['x'], + 'y' => $el['y'], + 'w' => $el['w'], + 'h' => $el['h'], + ]; + + $el['x'] = $result['new_coords']['x']; + $el['y'] = $result['new_coords']['y']; + $el['w'] = $result['new_coords']['w']; + $el['h'] = $result['new_coords']['h']; + + Log::info("ScreenAnalysis: 크롭 보정 Step {$stepNum}", [ + 'label' => $label, + 'before' => $el['_verification']['crop_original'], + 'after' => $result['new_coords'], + ]); + } else { + $el['_verification']['crop_verified'] = false; + Log::warning("ScreenAnalysis: 크롭 불일치 Step {$stepNum} 보정 실패", ['label' => $label]); + } + + // 임시 크롭 파일 정리 + if (file_exists($cropPath)) { + @unlink($cropPath); + } + } + unset($step, $el); + + imagedestroy($source); + + Log::info('ScreenAnalysis: 3-pass 크롭 검증 완료'); + } catch (\Exception $e) { + Log::warning('ScreenAnalysis: 크롭 검증 예외 발생', ['error' => $e->getMessage()]); + } + } + + /** + * 단일 크롭 이미지를 Gemini에 보내 label 포함 여부 판정 + * + * 1차: 크롭 이미지만 전송 → match 판정 + * 2차 (match=false 시): 전체 원본 이미지 전송 → 좌표 재추정 + */ + private function verifySingleCrop(string $cropImagePath, string $fullImagePath, string $label, string $type, array $currentCoords): array + { + try { + // 1차 요청: 크롭 이미지로 label 포함 여부 확인 + $cropData = base64_encode(file_get_contents($cropImagePath)); + + $prompt1 = "이 이미지는 UI 화면에서 특정 영역과 그 주변을 잘라낸 것입니다.\n" + ."빨간색 사각형 테두리가 실제 타겟 영역을 표시합니다.\n" + ."빨간 테두리 안쪽에 '{$label}' ({$type}) UI 요소가 포함되어 있습니까?\n" + ."(빨간 테두리 바깥의 주변 영역은 컨텍스트일 뿐이므로 판단에서 제외하세요)\n" + ."반드시 아래 JSON 형식으로만 응답하세요:\n" + ."{\"match\": true, \"actual_content\": \"빨간 테두리 안의 실제 내용 요약\"}\n" + ."또는\n" + .'{"match": false, "actual_content": "빨간 테두리 안의 실제 내용 요약"}'; + + $parts1 = [ + ['text' => $prompt1], + [ + 'inlineData' => [ + 'mimeType' => 'image/png', + 'data' => $cropData, + ], + ], + ]; + + $result1 = $this->gemini->callGeminiWithParts($parts1, 0.1, 512); + + if (! $result1) { + return ['match' => true, 'corrected' => false, 'new_coords' => null]; + } + + $parsed1 = $this->gemini->parseJson($result1); + + if (! $parsed1) { + return ['match' => true, 'corrected' => false, 'new_coords' => null]; + } + + $isMatch = $parsed1['match'] ?? true; + + Log::debug('ScreenAnalysis: 크롭 1차 판정', [ + 'label' => $label, + 'match' => $isMatch, + 'actual' => $parsed1['actual_content'] ?? '?', + ]); + + if ($isMatch) { + return ['match' => true, 'corrected' => false, 'new_coords' => null]; + } + + // 2차 요청: 전체 원본 이미지로 정확한 좌표 재추정 + $fullData = base64_encode(file_get_contents($fullImagePath)); + $fullMime = mime_content_type($fullImagePath) ?: 'image/png'; + + $x = $currentCoords['x']; + $y = $currentCoords['y']; + $w = $currentCoords['w']; + $h = $currentCoords['h']; + + $prompt2 = "이 이미지에서 '{$label}' ({$type}) UI 요소의 정확한 위치를 찾아주세요.\n" + ."이전 추정 좌표 (x={$x}, y={$y}, w={$w}, h={$h})는 부정확했습니다.\n" + ."해당 영역을 잘라냈을 때 '{$label}'이 포함되지 않았습니다.\n\n" + ."0~1000 정수 좌표로 응답해주세요.\n" + ."- x: 영역 좌측 가장자리 (0=이미지 왼쪽 끝, 1000=오른쪽 끝)\n" + ."- y: 영역 상단 가장자리 (0=이미지 위쪽 끝, 1000=아래쪽 끝)\n" + ."- w: 영역 너비, h: 영역 높이\n\n" + ."반드시 아래 JSON 형식으로만 응답하세요:\n" + ."{\"found\": true, \"x\": 100, \"y\": 200, \"w\": 150, \"h\": 50}\n" + ."또는 찾을 수 없는 경우:\n" + .'{"found": false}'; + + $parts2 = [ + ['text' => $prompt2], + [ + 'inlineData' => [ + 'mimeType' => $fullMime, + 'data' => $fullData, + ], + ], + ]; + + $result2 = $this->gemini->callGeminiWithParts($parts2, 0.2, 512); + + if (! $result2) { + return ['match' => false, 'corrected' => false, 'new_coords' => null]; + } + + $parsed2 = $this->gemini->parseJson($result2); + + if (! $parsed2 || ! ($parsed2['found'] ?? false)) { + Log::debug('ScreenAnalysis: 크롭 2차 재추정 실패, 그리드 이미지로 재시도', ['label' => $label]); + + // 3차 재시도: 그리드 오버레이 이미지로 정확한 좌표 추정 + $gridImagePath = $this->createGridOverlay($fullImagePath); + if ($gridImagePath && file_exists($gridImagePath)) { + $gridData = base64_encode(file_get_contents($gridImagePath)); + + $prompt3 = "이 이미지에는 20x12 그리드 오버레이가 표시되어 있습니다.\n" + ."그리드의 좌표 눈금(0, 250, 500, 750, 1000)을 참고하여\n" + ."'{$label}' ({$type}) UI 요소의 정확한 위치를 찾아주세요.\n\n" + ."이전 추정 좌표 (x={$x}, y={$y}, w={$w}, h={$h})는 부정확했습니다.\n\n" + ."0~1000 정수 좌표로 응답해주세요.\n" + ."- x: 영역 좌측 가장자리 (0=왼쪽 끝, 1000=오른쪽 끝)\n" + ."- y: 영역 상단 가장자리 (0=위쪽 끝, 1000=아래쪽 끝)\n" + ."- w: 영역 너비, h: 영역 높이\n\n" + ."반드시 아래 JSON 형식으로만 응답하세요:\n" + ."{\"found\": true, \"x\": 100, \"y\": 200, \"w\": 150, \"h\": 50}\n" + ."또는 찾을 수 없는 경우:\n" + .'{"found": false}'; + + $parts3 = [ + ['text' => $prompt3], + [ + 'inlineData' => [ + 'mimeType' => 'image/png', + 'data' => $gridData, + ], + ], + ]; + + $result3 = $this->gemini->callGeminiWithParts($parts3, 0.2, 512); + @unlink($gridImagePath); + + if ($result3) { + $parsed3 = $this->gemini->parseJson($result3); + if ($parsed3 && ($parsed3['found'] ?? false)) { + Log::debug('ScreenAnalysis: 크롭 3차 그리드 재추정 성공', [ + 'label' => $label, + 'new_coords' => "x={$parsed3['x']}, y={$parsed3['y']}, w={$parsed3['w']}, h={$parsed3['h']}", + ]); + + return [ + 'match' => false, + 'corrected' => true, + 'new_coords' => [ + 'x' => (int) ($parsed3['x'] ?? $x), + 'y' => (int) ($parsed3['y'] ?? $y), + 'w' => (int) ($parsed3['w'] ?? $w), + 'h' => (int) ($parsed3['h'] ?? $h), + ], + ]; + } + } + } + + Log::debug('ScreenAnalysis: 크롭 재추정 최종 실패', ['label' => $label]); + + return ['match' => false, 'corrected' => false, 'new_coords' => null]; + } + + Log::debug('ScreenAnalysis: 크롭 2차 재추정 성공', [ + 'label' => $label, + 'new_coords' => "x={$parsed2['x']}, y={$parsed2['y']}, w={$parsed2['w']}, h={$parsed2['h']}", + ]); + + return [ + 'match' => false, + 'corrected' => true, + 'new_coords' => [ + 'x' => (int) ($parsed2['x'] ?? $x), + 'y' => (int) ($parsed2['y'] ?? $y), + 'w' => (int) ($parsed2['w'] ?? $w), + 'h' => (int) ($parsed2['h'] ?? $h), + ], + ]; + } catch (\Exception $e) { + Log::warning('ScreenAnalysis: 크롭 검증 예외', ['error' => $e->getMessage(), 'label' => $label]); + + return ['match' => true, 'corrected' => false, 'new_coords' => null]; + } + } }