gemini = $gemini; } /** * 스크린샷 배열을 Gemini Vision으로 분석 * * @param array $imagePaths 스크린샷 파일 경로 배열 * @return array 분석 결과 배열 */ public function analyzeScreenshots(array $imagePaths): array { $results = []; foreach ($imagePaths as $index => $imagePath) { $screenNumber = $index + 1; Log::info("ScreenAnalysis: 스크린샷 {$screenNumber}/".count($imagePaths).' 분석 시작', [ 'path' => $imagePath, ]); $result = $this->analyzeSingleScreen($imagePath, $screenNumber, count($imagePaths)); if ($result) { $result = $this->ensureStepsFormat($result); $results[] = $result; } else { Log::warning("ScreenAnalysis: 스크린샷 {$screenNumber} 분석 실패, 기본값 사용"); $results[] = [ 'screen_number' => $screenNumber, 'title' => "화면 {$screenNumber}", 'steps' => [ [ 'step_number' => 1, 'narration' => '이 화면에서는 주요 기능을 확인할 수 있습니다.', 'focused_element' => null, 'duration' => 6, ], ], ]; } } return $results; } /** * 기존 형식(narration/ui_elements) → steps[] 형식으로 변환 (하위 호환) * Job에서 DB 캐시된 기존 형식 변환에도 사용 */ public function ensureStepsFormatPublic(array $screen): array { return $this->ensureStepsFormat($screen); } /** * 기존 형식(narration/ui_elements) → steps[] 형식으로 변환 (하위 호환) */ private function ensureStepsFormat(array $screen): array { if (! empty($screen['steps'])) { return $screen; } $narration = $screen['narration'] ?? '이 화면의 기능을 확인할 수 있습니다.'; $uiElements = $screen['ui_elements'] ?? []; $duration = $screen['duration'] ?? 8; if (empty($uiElements)) { $screen['steps'] = [ [ 'step_number' => 1, 'narration' => $narration, 'focused_element' => null, 'duration' => $duration, ], ]; } else { $steps = []; $sentences = preg_split('/(?<=[.。])\s*/u', $narration, -1, PREG_SPLIT_NO_EMPTY); foreach ($uiElements as $i => $el) { $steps[] = [ 'step_number' => $i + 1, 'narration' => $sentences[$i] ?? $narration, 'focused_element' => [ 'type' => $el['type'] ?? 'other', 'label' => $el['label'] ?? '', 'x' => $el['x'] ?? 0.5, 'y' => $el['y'] ?? 0.5, 'w' => 0.2, 'h' => 0.15, ], 'duration' => max(5, (int) ($duration / count($uiElements))), ]; } $screen['steps'] = $steps; } unset($screen['narration'], $screen['ui_elements'], $screen['duration']); return $screen; } /** * 단일 스크린샷 분석 */ private function analyzeSingleScreen(string $imagePath, int $screenNumber, int $totalScreens): ?array { if (! file_exists($imagePath)) { Log::error("ScreenAnalysis: 파일 없음 - {$imagePath}"); return null; } // 그리드 오버레이가 있는 이미지 생성 $gridImagePath = $this->createGridOverlay($imagePath); $analyzeImagePath = $gridImagePath ?: $imagePath; $imageData = base64_encode(file_get_contents($analyzeImagePath)); $mimeType = mime_content_type($analyzeImagePath) ?: 'image/png'; $prompt = << $prompt], [ 'inlineData' => [ 'mimeType' => $mimeType, 'data' => $imageData, ], ], ]; $result = $this->gemini->callGeminiWithParts($parts, 0.3, 2048); // 그리드 이미지 정리 if ($gridImagePath && file_exists($gridImagePath)) { @unlink($gridImagePath); } if (! $result) { return null; } $parsed = $this->gemini->parseJson($result); if (! $parsed || ! isset($parsed['screen_number'])) { Log::warning('ScreenAnalysis: JSON 파싱 실패 또는 형식 불일치', [ 'result' => substr($result, 0, 300), ]); return null; } // screen_number 강제 보정 $parsed['screen_number'] = $screenNumber; // 좌표 검증 루프 (normalizeCoordinates 이전, 0~1000 좌표 상태에서 실행) $parsed = $this->runCoordinateVerification($imagePath, $parsed); // 0~1000 좌표 → 0~1 비율로 변환 $this->normalizeCoordinates($parsed); return $parsed; } private const ANALYSIS_WIDTH = 1920; private const ANALYSIS_HEIGHT = 1080; private const CAPTION_HEIGHT = 180; // SlideAnnotationService::CAPTION_HEIGHT와 동일 /** * 스크린샷에 그리드 오버레이 생성 (annotateSlideWithSpotlight와 동일한 레이아웃) * * Phase 1: 비율 보존 레터박스 배치 (종횡비 일치) * Phase 2: 20x12 고밀도 그리드 + 주요 그리드선 + 좌표 라벨 */ private function createGridOverlay(string $imagePath): ?string { try { $info = getimagesize($imagePath); if (! $info) { return null; } $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 null; } $srcW = imagesx($source); $srcH = imagesy($source); // Phase 1: annotateSlideWithSpotlight()와 동일한 레이아웃 // 1920x1080 캔버스, 하단 180px 캡션 제외 → 이미지 배치 영역: 1920x900 $canvas = imagecreatetruecolor(self::ANALYSIS_WIDTH, self::ANALYSIS_HEIGHT); $black = imagecolorallocate($canvas, 0, 0, 0); imagefill($canvas, 0, 0, $black); $availH = self::ANALYSIS_HEIGHT - self::CAPTION_HEIGHT; $scale = min(self::ANALYSIS_WIDTH / $srcW, $availH / $srcH); $newW = (int) ($srcW * $scale); $newH = (int) ($srcH * $scale); $offsetX = (int) ((self::ANALYSIS_WIDTH - $newW) / 2); $offsetY = (int) (($availH - $newH) / 2); imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH); imagedestroy($source); Log::debug('ScreenAnalysis: 이미지 정규화 (비율 보존 레터박스)', [ 'original' => "{$srcW}x{$srcH}", 'scaled' => "{$newW}x{$newH}", 'offset' => "({$offsetX}, {$offsetY})", 'availArea' => self::ANALYSIS_WIDTH.'x'.$availH, ]); // Phase 2: 20x12 고밀도 그리드 (이미지 콘텐츠 영역에만) $gridColor = imagecolorallocatealpha($canvas, 255, 0, 0, 110); $majorGridColor = imagecolorallocatealpha($canvas, 255, 0, 0, 90); $labelBg = imagecolorallocatealpha($canvas, 0, 0, 0, 60); $labelText = imagecolorallocate($canvas, 255, 255, 0); $tickColor = imagecolorallocate($canvas, 255, 80, 80); $captionColor = imagecolorallocate($canvas, 200, 200, 200); $captionBg = imagecolorallocatealpha($canvas, 0, 0, 0, 50); $cols = 20; $rows = 12; $cellW = $newW / $cols; $cellH = $newH / $rows; $majorColInterval = 5; // 가로 5칸마다 주요선 → 좌표 0,250,500,750,1000 $majorRowInterval = 3; // 세로 3칸마다 주요선 → 좌표 0,250,500,750,1000 // 세로선 (이미지 콘텐츠 영역 내) for ($i = 1; $i < $cols; $i++) { $x = $offsetX + (int) ($i * $cellW); $isMajor = ($i % $majorColInterval === 0); $color = $isMajor ? $majorGridColor : $gridColor; imageline($canvas, $x, $offsetY, $x, $offsetY + $newH, $color); if ($isMajor) { imageline($canvas, $x + 1, $offsetY, $x + 1, $offsetY + $newH, $color); } } // 가로선 (이미지 콘텐츠 영역 내) for ($j = 1; $j < $rows; $j++) { $y = $offsetY + (int) ($j * $cellH); $isMajor = ($j % $majorRowInterval === 0); $color = $isMajor ? $majorGridColor : $gridColor; imageline($canvas, $offsetX, $y, $offsetX + $newW, $y, $color); if ($isMajor) { imageline($canvas, $offsetX, $y + 1, $offsetX + $newW, $y + 1, $color); } } // 상단 라벨: 가로 좌표 눈금 (0~1000 좌표계) for ($i = 0; $i <= $cols; $i += $majorColInterval) { $x = $offsetX + (int) ($i * $cellW); $coordVal = (int) round($i * 1000 / $cols); $label = (string) $coordVal; $lx = max($offsetX, $x - 2); imagefilledrectangle($canvas, $lx, $offsetY, $lx + strlen($label) * 7 + 4, $offsetY + 14, $labelBg); imagestring($canvas, 2, $lx + 2, $offsetY + 1, $label, $labelText); } // 좌측 라벨: 세로 좌표 눈금 (0~1000 좌표계) for ($j = 0; $j <= $rows; $j += $majorRowInterval) { $y = $offsetY + (int) ($j * $cellH); $coordVal = (int) round($j * 1000 / $rows); $label = (string) $coordVal; $ly = max($offsetY, $y - 1); imagefilledrectangle($canvas, $offsetX, $ly, $offsetX + strlen($label) * 7 + 4, $ly + 14, $labelBg); imagestring($canvas, 2, $offsetX + 2, $ly + 1, $label, $labelText); } // 교차점 마커 (축소: 일반 2px, 주요 4px) for ($i = 1; $i < $cols; $i++) { for ($j = 1; $j < $rows; $j++) { $cx = $offsetX + (int) ($i * $cellW); $cy = $offsetY + (int) ($j * $cellH); $isMajor = ($i % $majorColInterval === 0) && ($j % $majorRowInterval === 0); $sz = $isMajor ? 4 : 2; imageline($canvas, $cx - $sz, $cy, $cx + $sz, $cy, $tickColor); imageline($canvas, $cx, $cy - $sz, $cx, $cy + $sz, $tickColor); } } // 주요 교차점에 좌표값 라벨 표시 for ($i = $majorColInterval; $i < $cols; $i += $majorColInterval) { for ($j = $majorRowInterval; $j < $rows; $j += $majorRowInterval) { $cx = $offsetX + (int) ($i * $cellW); $cy = $offsetY + (int) ($j * $cellH); $coordX = (int) round($i * 1000 / $cols); $coordY = (int) round($j * 1000 / $rows); $label = "{$coordX},{$coordY}"; $lw = strlen($label) * 6 + 6; imagefilledrectangle($canvas, $cx + 3, $cy - 12, $cx + 3 + $lw, $cy + 2, $labelBg); imagestring($canvas, 1, $cx + 5, $cy - 10, $label, $labelText); } } // 하단 캡션 영역에 좌표 기준 안내 텍스트 $captionY = self::ANALYSIS_HEIGHT - self::CAPTION_HEIGHT + 10; $guideText = 'Coords: image content area (0,0)~(1000,1000) | Grid: 20x12 | Caption area excluded'; imagefilledrectangle($canvas, 8, $captionY, strlen($guideText) * 7 + 16, $captionY + 16, $captionBg); imagestring($canvas, 2, 12, $captionY + 2, $guideText, $captionColor); $gridPath = $imagePath.'_grid.png'; imagepng($canvas, $gridPath, 6); imagedestroy($canvas); Log::debug('ScreenAnalysis: 그리드 오버레이 생성 완료', ['path' => $gridPath]); return $gridPath; } catch (\Exception $e) { Log::warning('ScreenAnalysis: 그리드 오버레이 생성 실패', ['error' => $e->getMessage()]); return null; } } // UI 요소 타입별 최소 크기 (0~1 비율) private const MIN_ELEMENT_SIZES = [ 'button' => ['w' => 0.06, 'h' => 0.04], 'input' => ['w' => 0.15, 'h' => 0.05], 'form' => ['w' => 0.25, 'h' => 0.15], 'table' => ['w' => 0.30, 'h' => 0.20], 'tab' => ['w' => 0.20, 'h' => 0.05], 'menu' => ['w' => 0.10, 'h' => 0.25], 'sidebar' => ['w' => 0.10, 'h' => 0.40], 'header' => ['w' => 0.40, 'h' => 0.06], 'other' => ['w' => 0.08, 'h' => 0.06], ]; private const SPOTLIGHT_PADDING = 0.015; // 전체 크기 대비 1.5% 패딩 // 좌표 검증 루프용 색상 (step별) private const VERIFICATION_COLORS = [ 1 => [255, 60, 60], // 빨강 2 => [60, 200, 60], // 초록 3 => [60, 100, 255], // 파랑 4 => [255, 160, 0], // 주황 5 => [180, 60, 255], // 보라 ]; /** * 0~1000 정수 좌표 → 0~1 비율로 변환 + 타입별 최소 크기 보정 + 패딩 * * Gemini가 반환한 0~1000 좌표를 하류 파이프라인이 사용하는 0~1 비율로 정규화 */ public function normalizeCoordinates(array &$parsed): void { if (empty($parsed['steps'])) { return; } foreach ($parsed['steps'] as $stepIdx => &$step) { if (empty($step['focused_element'])) { continue; } $el = &$step['focused_element']; $stepNum = $step['step_number'] ?? ($stepIdx + 1); // Step 1: 0~1000 → 0~1 비율 변환 $isMilliCoords = ($el['x'] ?? 0) > 1 || ($el['y'] ?? 0) > 1 || ($el['w'] ?? 0) > 1 || ($el['h'] ?? 0) > 1; $rawX = $el['x'] ?? 0; $rawY = $el['y'] ?? 0; $rawW = $el['w'] ?? 200; $rawH = $el['h'] ?? 200; if ($isMilliCoords) { $el['x'] = round($rawX / 1000, 4); $el['y'] = round($rawY / 1000, 4); $el['w'] = round($rawW / 1000, 4); $el['h'] = round($rawH / 1000, 4); } Log::debug("ScreenAnalysis: 좌표 변환 Step {$stepNum} [1/4 비율변환]", [ 'raw' => "x={$rawX}, y={$rawY}, w={$rawW}, h={$rawH}", 'normalized' => "x={$el['x']}, y={$el['y']}, w={$el['w']}, h={$el['h']}", 'label' => $el['label'] ?? '', ]); // Step 2: 타입별 최소 크기 보정 (중심점 기준 확장) $type = $el['type'] ?? 'other'; $minSize = self::MIN_ELEMENT_SIZES[$type] ?? self::MIN_ELEMENT_SIZES['other']; if ($el['w'] < $minSize['w']) { $centerX = $el['x'] + $el['w'] / 2; $el['w'] = $minSize['w']; $el['x'] = $centerX - $el['w'] / 2; } if ($el['h'] < $minSize['h']) { $centerY = $el['y'] + $el['h'] / 2; $el['h'] = $minSize['h']; $el['y'] = $centerY - $el['h'] / 2; } Log::debug("ScreenAnalysis: 좌표 변환 Step {$stepNum} [2/4 최소크기]", [ 'type' => $type, 'after' => "x={$el['x']}, y={$el['y']}, w={$el['w']}, h={$el['h']}", ]); // Step 3: 패딩 추가 (경계 인접 시 편향 패딩) $pad = self::SPOTLIGHT_PADDING; $spaceLeft = max(0, $el['x']); $spaceRight = max(0, 1 - $el['x'] - $el['w']); $spaceTop = max(0, $el['y']); $spaceBottom = max(0, 1 - $el['y'] - $el['h']); $leftPad = min($pad, $spaceLeft); $rightPad = min($pad, $spaceRight); $topPad = min($pad, $spaceTop); $bottomPad = min($pad, $spaceBottom); // 부족분을 반대 방향으로 보상 if ($leftPad < $pad) { $rightPad = min($spaceRight, $rightPad + ($pad - $leftPad)); } if ($rightPad < $pad) { $leftPad = min($spaceLeft, $leftPad + ($pad - $rightPad)); } if ($topPad < $pad) { $bottomPad = min($spaceBottom, $bottomPad + ($pad - $topPad)); } if ($bottomPad < $pad) { $topPad = min($spaceTop, $topPad + ($pad - $bottomPad)); } $el['x'] -= $leftPad; $el['y'] -= $topPad; $el['w'] += $leftPad + $rightPad; $el['h'] += $topPad + $bottomPad; Log::debug("ScreenAnalysis: 좌표 변환 Step {$stepNum} [3/4 패딩]", [ 'padding' => "L={$leftPad}, R={$rightPad}, T={$topPad}, B={$bottomPad}", 'after' => "x={$el['x']}, y={$el['y']}, w={$el['w']}, h={$el['h']}", ]); // Step 4: 경계 클램핑 (0~1 범위 내로 수렴) $el['x'] = max(0, $el['x']); $el['y'] = max(0, $el['y']); if ($el['x'] + $el['w'] > 1) { $el['w'] = 1 - $el['x']; } if ($el['y'] + $el['h'] > 1) { $el['h'] = 1 - $el['y']; } $el['w'] = max($minSize['w'], $el['w']); $el['h'] = max($minSize['h'], $el['h']); Log::debug("ScreenAnalysis: 좌표 변환 Step {$stepNum} [4/4 최종]", [ 'final' => "x={$el['x']}, y={$el['y']}, w={$el['w']}, h={$el['h']}", ]); } unset($step, $el); } /** * 좌표 검증 루프 오케스트레이터 * * 1-pass로 추정한 좌표를 원본 이미지 위에 렌더링한 뒤, * Gemini에게 2-pass로 위치 정확도를 확인/보정하게 한다. */ private function runCoordinateVerification(string $imagePath, array $parsed): array { // focused_element가 있는 step만 추출 $stepsWithElement = []; foreach ($parsed['steps'] ?? [] as $step) { if (! empty($step['focused_element'])) { $stepsWithElement[] = $step; } } if (empty($stepsWithElement)) { Log::debug('ScreenAnalysis: 좌표 검증 건너뜀 - focused_element 없음'); return $parsed; } Log::info('ScreenAnalysis: 좌표 검증 루프 시작', [ 'screen' => $parsed['screen_number'] ?? '?', 'steps_count' => count($stepsWithElement), ]); // 검증 이미지 생성 $verificationImagePath = $this->createVerificationImage($imagePath, $stepsWithElement); if (! $verificationImagePath) { Log::warning('ScreenAnalysis: 검증 이미지 생성 실패, 원본 좌표 유지'); return $parsed; } // Gemini 2-pass 검증 호출 $verifiedSteps = $this->verifyCoordinates($verificationImagePath, $stepsWithElement); // 보정 적용 + 검증 메타데이터 저장 $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) { $accurateCount++; } else { $correctedCount++; } } } // 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, ]; // 검증 이미지 정리 if (file_exists($verificationImagePath)) { @unlink($verificationImagePath); } Log::info('ScreenAnalysis: 좌표 검증 루프 완료', [ '2pass' => "정확:{$accurateCount}, 보정:{$correctedCount}", '3pass' => "크롭확인:{$cropVerifiedCount}, 크롭보정:{$cropCorrectedCount}", ]); return $parsed; } /** * 검증용 이미지 생성 * * 원본 이미지 위에 각 step의 focused_element를 색상별 반투명 사각형 + 번호 원으로 표시. * 그리드 없이 깨끗한 원본 위에 렌더링하여 AI가 UI 요소를 명확히 볼 수 있도록 한다. */ private function createVerificationImage(string $imagePath, array $steps): ?string { try { $info = getimagesize($imagePath); if (! $info) { return null; } $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 null; } $srcW = imagesx($source); $srcH = imagesy($source); // annotateSlideWithSpotlight()와 동일한 레터박스 레이아웃 $canvas = imagecreatetruecolor(self::ANALYSIS_WIDTH, self::ANALYSIS_HEIGHT); $black = imagecolorallocate($canvas, 0, 0, 0); imagefill($canvas, 0, 0, $black); $availH = self::ANALYSIS_HEIGHT - self::CAPTION_HEIGHT; $scale = min(self::ANALYSIS_WIDTH / $srcW, $availH / $srcH); $newW = (int) ($srcW * $scale); $newH = (int) ($srcH * $scale); $offsetX = (int) ((self::ANALYSIS_WIDTH - $newW) / 2); $offsetY = (int) (($availH - $newH) / 2); imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH); imagedestroy($source); // 각 step의 focused_element를 색상별 반투명 사각형 + 번호 원으로 표시 $colorNames = [1 => 'Red', 2 => 'Green', 3 => 'Blue', 4 => 'Orange', 5 => 'Purple']; foreach ($steps as $idx => $step) { $stepNum = $step['step_number'] ?? ($idx + 1); $colorIdx = min($stepNum, 5); [$r, $g, $b] = self::VERIFICATION_COLORS[$colorIdx]; $el = $step['focused_element']; $x = $el['x'] ?? 0; $y = $el['y'] ?? 0; $w = $el['w'] ?? 200; $h = $el['h'] ?? 200; // 0~1000 좌표를 캔버스 좌표로 변환 $cx1 = (int) ($offsetX + ($x / 1000) * $newW); $cy1 = (int) ($offsetY + ($y / 1000) * $newH); $cx2 = (int) ($offsetX + (($x + $w) / 1000) * $newW); $cy2 = (int) ($offsetY + (($y + $h) / 1000) * $newH); // 반투명 사각형 (테두리 + 내부 채움) $fillColor = imagecolorallocatealpha($canvas, $r, $g, $b, 100); $borderColor = imagecolorallocate($canvas, $r, $g, $b); imagefilledrectangle($canvas, $cx1, $cy1, $cx2, $cy2, $fillColor); // 테두리 3px for ($t = 0; $t < 3; $t++) { imagerectangle($canvas, $cx1 + $t, $cy1 + $t, $cx2 - $t, $cy2 - $t, $borderColor); } // 번호 원 (좌상단에 배치) $circleX = $cx1 + 12; $circleY = $cy1 + 12; $circleR = 12; $white = imagecolorallocate($canvas, 255, 255, 255); imagefilledellipse($canvas, $circleX, $circleY, $circleR * 2, $circleR * 2, $borderColor); imagestring($canvas, 5, $circleX - 4, $circleY - 7, (string) $stepNum, $white); } // 하단 캡션에 범례 표시 $captionY = self::ANALYSIS_HEIGHT - self::CAPTION_HEIGHT + 10; $legendX = 12; $captionTextColor = imagecolorallocate($canvas, 220, 220, 220); imagestring($canvas, 3, $legendX, $captionY, 'Verification Overlay - Step Legend:', $captionTextColor); $legendX = 12; $captionY += 20; foreach ($steps as $idx => $step) { $stepNum = $step['step_number'] ?? ($idx + 1); $colorIdx = min($stepNum, 5); [$r, $g, $b] = self::VERIFICATION_COLORS[$colorIdx]; $label = $step['focused_element']['label'] ?? '?'; $colorName = $colorNames[$colorIdx] ?? '?'; $swatchColor = imagecolorallocate($canvas, $r, $g, $b); imagefilledrectangle($canvas, $legendX, $captionY, $legendX + 14, $captionY + 14, $swatchColor); $legendText = "#{$stepNum} ({$colorName}): {$label}"; imagestring($canvas, 2, $legendX + 18, $captionY + 1, $legendText, $captionTextColor); $legendX += strlen($legendText) * 7 + 30; // 줄바꿈 (너비 초과 시) if ($legendX > self::ANALYSIS_WIDTH - 200) { $legendX = 12; $captionY += 18; } } $outputPath = $imagePath.'_verify.png'; imagepng($canvas, $outputPath, 6); imagedestroy($canvas); Log::debug('ScreenAnalysis: 검증 이미지 생성 완료', ['path' => $outputPath]); return $outputPath; } catch (\Exception $e) { Log::warning('ScreenAnalysis: 검증 이미지 생성 실패', ['error' => $e->getMessage()]); return null; } } /** * Gemini 2-pass 검증 호출 * * 검증 이미지를 Gemini에 보내 각 하이라이트의 위치 정확도를 확인한다. */ private function verifyCoordinates(string $verificationImagePath, array $steps): ?array { try { $imageData = base64_encode(file_get_contents($verificationImagePath)); $mimeType = 'image/png'; $colorNames = [1 => 'Red', 2 => 'Green', 3 => 'Blue', 4 => 'Orange', 5 => 'Purple']; // 각 step 정보를 텍스트로 나열 $stepDescriptions = ''; foreach ($steps as $idx => $step) { $stepNum = $step['step_number'] ?? ($idx + 1); $colorIdx = min($stepNum, 5); $colorName = $colorNames[$colorIdx] ?? '?'; $el = $step['focused_element']; $label = $el['label'] ?? '?'; $x = $el['x'] ?? 0; $y = $el['y'] ?? 0; $w = $el['w'] ?? 0; $h = $el['h'] ?? 0; $stepDescriptions .= "- Step {$stepNum} ({$colorName}): \"{$label}\" → x={$x}, y={$y}, w={$w}, h={$h}\n"; } $prompt = << $prompt], [ 'inlineData' => [ 'mimeType' => $mimeType, 'data' => $imageData, ], ], ]; $result = $this->gemini->callGeminiWithParts($parts, 0.1, 1024); if (! $result) { Log::warning('ScreenAnalysis: 좌표 검증 API 호출 실패'); return null; } $verified = $this->gemini->parseJson($result); if (! $verified || ! isset($verified['verifications'])) { Log::warning('ScreenAnalysis: 좌표 검증 JSON 파싱 실패', [ 'result' => substr($result, 0, 300), ]); return null; } // 검증 결과 로그 $accurateCount = 0; $correctedCount = 0; foreach ($verified['verifications'] as $v) { if ($v['accurate'] ?? true) { $accurateCount++; } else { $correctedCount++; } } Log::info("ScreenAnalysis: 좌표 검증 결과 - 정확: {$accurateCount}, 보정: {$correctedCount}"); return $verified['verifications']; } catch (\Exception $e) { Log::warning('ScreenAnalysis: 좌표 검증 예외 발생', ['error' => $e->getMessage()]); return null; } } /** * 검증 결과를 원본 $parsed에 적용 * * accurate: true → 좌표 유지, accurate: false → corrected 좌표로 교체 */ private function applyVerifiedCoordinates(array $parsed, ?array $verifiedSteps): array { if (! $verifiedSteps) { Log::debug('ScreenAnalysis: 검증 결과 없음, 원본 좌표 유지 (fallback)'); return $parsed; } // step_number를 키로 하는 검증 맵 생성 $verificationMap = []; foreach ($verifiedSteps as $v) { $verificationMap[$v['step_number']] = $v; } foreach ($parsed['steps'] as &$step) { if (empty($step['focused_element'])) { continue; } $stepNum = $step['step_number'] ?? 0; $verification = $verificationMap[$stepNum] ?? null; if (! $verification) { continue; } $isAccurate = $verification['accurate'] ?? true; if (! $isAccurate) { $oldX = $step['focused_element']['x']; $oldY = $step['focused_element']['y']; $oldW = $step['focused_element']['w']; $oldH = $step['focused_element']['h']; $step['focused_element']['x'] = $verification['corrected_x'] ?? $oldX; $step['focused_element']['y'] = $verification['corrected_y'] ?? $oldY; $step['focused_element']['w'] = $verification['corrected_w'] ?? $oldW; $step['focused_element']['h'] = $verification['corrected_h'] ?? $oldH; // 보정 전 좌표를 메타데이터로 저장 $step['focused_element']['_verification'] = [ 'accurate' => false, 'original' => ['x' => $oldX, 'y' => $oldY, 'w' => $oldW, 'h' => $oldH], ]; Log::info("ScreenAnalysis: 좌표 보정 Step {$stepNum}", [ 'label' => $step['focused_element']['label'] ?? '?', 'before' => "x={$oldX}, y={$oldY}, w={$oldW}, h={$oldH}", 'after' => "x={$step['focused_element']['x']}, y={$step['focused_element']['y']}, w={$step['focused_element']['w']}, h={$step['focused_element']['h']}", ]); } else { $step['focused_element']['_verification'] = [ 'accurate' => true, ]; } } unset($step); 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]; } } }