- Gemini 2-pass 자기 검증 메커니즘 구현 - runCoordinateVerification: 검증 오케스트레이터 - createVerificationImage: 색상별 스포트라이트 렌더링 - verifyCoordinates: Gemini에게 좌표 정확도 확인 요청 - applyVerifiedCoordinates: 보정 좌표 적용
942 lines
37 KiB
PHP
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;
|
|
}
|
|
}
|