Files
sam-manage/app/Services/Video/ScreenAnalysisService.php
김보곤 cf6525c8f3 feat: [video] 좌표 검증 루프(Coordinate Verification Loop) 추가
- Gemini 2-pass 자기 검증 메커니즘 구현
- runCoordinateVerification: 검증 오케스트레이터
- createVerificationImage: 색상별 스포트라이트 렌더링
- verifyCoordinates: Gemini에게 좌표 정확도 확인 요청
- applyVerifiedCoordinates: 보정 좌표 적용
2026-02-21 16:02:10 +09:00

942 lines
37 KiB
PHP

<?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);
// 검증 이미지 정리
if (file_exists($verificationImagePath)) {
@unlink($verificationImagePath);
}
Log::info('ScreenAnalysis: 좌표 검증 루프 완료');
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;
}
if (! ($verification['accurate'] ?? true)) {
$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;
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']}",
]);
}
}
unset($step);
return $parsed;
}
}