Files
sam-manage/app/Services/Video/ScreenAnalysisService.php

1338 lines
54 KiB
PHP
Raw Permalink Normal View History

<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class ScreenAnalysisService
{
private GeminiScriptService $gemini;
public function __construct(GeminiScriptService $gemini)
{
$this->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
당신은 SAM(Smart Automation Management) 시스템의 사용자 매뉴얼 작성 전문가입니다.
스크린샷은 SAM 시스템의 화면입니다. (화면 {$screenNumber}/{$totalScreens})
=== 이미지 레이아웃 안내 ===
- 1920x1080 캔버스에 원본 스크린샷이 **비율 보존**으로 배치되어 있습니다.
- 하단 180px는 캡션 영역(검은 )이므로, 이미지 배치 영역은 1920x900입니다.
- 원본 비율에 따라 좌우 또는 상하에 **검은 여백(레터박스)** 있을 있습니다.
- 좌표는 검은 여백을 제외한 **실제 이미지 콘텐츠 영역** 기준입니다.
- 하단 캡션 영역(검은 ) 좌표 계산에서 **제외**됩니다.
=== 그리드 오버레이 안내 ===
이미지 콘텐츠 영역 위에 **20x12 그리드** 표시되어 있습니다.
- 가로 20, 세로 12
- **5칸마다(가로) / 3칸마다(세로) 주요 그리드선**(굵은 ) 있습니다.
- 주요 교차점에 "X좌표,Y좌표" 형식의 라벨이 표시됩니다.
- 좌측/상단 테두리에 0~1000 좌표 눈금이 표시됩니다.
- 가로 주요 눈금: 0, 250, 500, 750, 1000
- 세로 주요 눈금: 0, 250, 500, 750, 1000
그리드와 좌표 라벨을 참고하여 UI 영역의 정확한 위치를 파악하세요.
화면을 분석하고, 사용자가 따라할 있는 **3~5 단계(steps)** 나누어 튜토리얼을 작성하세요.
단계마다 화면에서 집중해야 UI 영역(focused_element) 지정합니다.
=== 분석 요구사항 ===
1. 화면의 주요 목적/기능을 파악
2. 사용자가 수행할 작업을 3~5단계로 분해
3. 단계마다 집중할 UI 영역의 위치와 크기를 0~1000 정수 좌표로 표시
4. 단계별 나레이션은 독립적인 문장으로 작성
=== 나레이션 작성 규칙 ===
- 친근한 존댓말 사용 (: "~하실 수 있습니다", "~을 클릭하세요")
- TTS로 읽을 것이므로 이모지/특수기호 금지
- 순수 한글 텍스트만 작성
- 문장은 마침표로 끝내기
- 단계 나레이션은 30~80 ( 3~7 분량)
반드시 아래 JSON 형식으로만 응답하세요:
{
"screen_number": {$screenNumber},
"title": "이 화면의 제목 (10자 이내)",
"steps": [
{
"step_number": 1,
"narration": "이 단계에서 할 일을 안내하는 나레이션 (30~80자)",
"focused_element": {
"type": "button|input|table|menu|tab|sidebar|header|form|other",
"label": "UI 영역에 표시된 텍스트 (10자 이내)",
"x": 100,
"y": 300,
"w": 200,
"h": 400
},
// 주의: w, h는 해당 UI 요소의 실제 보이는 영역 전체를 넉넉히 감싸야 합니다.
// 너무 작으면 스포트라이트가 일부만 강조하여 사용자가 혼란스러워합니다.
"duration": 6
}
]
}
=== focused_element 좌표 (0~1000 정수 좌표계) ===
좌표는 **이미지 콘텐츠 영역**(검은 여백/캡션 제외) 좌상단(0,0) ~ 우하단(1000,1000) 범위의 **정수**입니다.
그리드 오버레이의 눈금과 교차점 라벨을 활용하여 정확한 좌표를 지정하세요.
- x: 영역 좌측 가장자리의 가로 위치 (0=왼쪽 , 1000=오른쪽 )
- y: 영역 상단 가장자리의 세로 위치 (0=위쪽 , 1000=아래쪽 )
- w: 영역의 너비 (0~1000)
- h: 영역의 높이 (0~1000)
=== 그리드 참조 가이드 (20x12 그리드) ===
- 가로 주요 눈금: 0, 250, 500, 750, 1000 (5 간격)
- 세로 주요 눈금: 0, 250, 500, 750, 1000 (3 간격)
- 1 = 가로 50, 세로 83 좌표 단위
- ) 좌상단 1/4 영역 x=0, y=0, w=250, h=250
- ) 중앙 절반 영역 x=250, y=250, w=500, h=500
- ) 우하단 1/4 영역 x=750, y=750, w=250, h=250
=== 좌표 정확도 예시 (ERP/관리 시스템 화면 기준) ===
- 왼쪽 사이드바 메뉴: x=0, y=50, w=150, h=950
- 상단 헤더/네비게이션: x=0, y=0, w=1000, h=60
- 메인 콘텐츠의 데이터 테이블: x=180, y=150, w=800, h=600
- 상단 검색/필터 영역: x=180, y=80, w=800, h=80
- 우측 상단 버튼 그룹: x=700, y=80, w=280, h=50
- 메뉴: x=180, y=100, w=600, h=40
- 모달/팝업: x=250, y=200, w=500, h=500
=== UI 요소 타입별 최소 크기 가이드라인 ===
focused_element의 w, h는 해당 UI 요소가 화면에서 차지하는 실제 영역을 충분히 포함해야 합니다.
type별 최소 권장 크기입니다. 실제 요소가 크면 실제 크기에 맞추세요.
- button: 최소 w=100, h=50
- input: 최소 w=250, h=60 (검색창은 아이콘~버튼까지 전체 포함)
- form: 최소 w=400, h=200 (라벨+입력+버튼 전체)
- table: 최소 w=500, h=300 (헤더+본문 포함)
- tab: 최소 w=300, h=50 ( 그룹 전체)
- menu/sidebar: 최소 w=150, h=400
- header: 최소 w=600, h=60
- other: 최소 w=150, h=80
중요:
- "기간을 설정하세요" 시작일~종료일 input 2 + 사이 텍스트 모두 포함
- "검색창을 클릭하세요" 검색 아이콘부터 검색 버튼까지 전체 포함
- 하나의 논리적 UI 그룹은 분리하지 말고 하나의 focused_element로 잡으세요
- 요소 경계에서 10~20 단위의 여유를 두세요
=== duration 규칙 ===
- 단계는 5~8 (나레이션 길이에 비례)
- 전체 steps의 duration 합계가 20~40초가 되도록 조절
PROMPT;
$parts = [
['text' => $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
이미지에는 번호가 매겨진 색상 하이라이트(반투명 사각형) 표시되어 있습니다.
하이라이트가 의도한 UI 요소를 정확히 감싸고 있는지 확인하세요.
=== 이미지 레이아웃 안내 ===
- 1920x1080 캔버스에 원본 스크린샷이 비율 보존으로 배치되어 있습니다.
- 하단 180px는 범례 영역이므로 UI 분석에서 제외하세요.
- 색상 사각형이 표시된 영역이 해당 UI 요소의 실제 위치와 일치하는지 판단하세요.
=== 하이라이트의 의도한 대상 ===
{$stepDescriptions}
=== 좌표 체계 ===
- 0~1000 정수 좌표 (이미지 콘텐츠 영역 기준)
- x: 영역 좌측 가장자리 (0=왼쪽 , 1000=오른쪽 )
- y: 영역 상단 가장자리 (0=위쪽 , 1000=아래쪽 )
- w: 영역 너비, h: 영역 높이
=== 판단 기준 ===
- 하이라이트가 해당 UI 요소를 70% 이상 포함하고, 요소 밖으로 크게 벗어나지 않으면 "accurate": true
- 하이라이트가 완전히 다른 위치에 있거나, 대상 요소의 50% 미만만 포함하면 "accurate": false
단계별로 판단하세요:
- "accurate": true이면 현재 좌표 유지
- "accurate": false이면 corrected_x, corrected_y, corrected_w, corrected_h를 제공
(0~1000 정수 좌표, 실제 UI 요소를 정확히 감싸는 위치)
반드시 아래 JSON 형식으로만 응답하세요:
{
"verifications": [
{
"step_number": 1,
"accurate": true
},
{
"step_number": 2,
"accurate": false,
"corrected_x": 150,
"corrected_y": 200,
"corrected_w": 300,
"corrected_h": 80
}
]
}
PROMPT;
$parts = [
['text' => $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];
}
}
}