Files
sam-manage/app/Services/Video/ScreenAnalysisService.php
김보곤 b47c27614b feat:스크린샷 분석 좌표 정확도 개선 (그리드 오버레이 + 0-1000 좌표계)
- 10x10 그리드 오버레이를 스크린샷에 그려서 Gemini에 전송
  (가로 0~9, 세로 A~J 라벨로 AI에게 시각적 참조점 제공)
- 좌표계를 0~1 소수 → 0~1000 정수로 변경 (AI 추정 정확도 향상)
- ERP 화면 기준 좌표 예시 추가 (사이드바, 헤더, 테이블 등)
- normalizeCoordinates()로 1000좌표 → 0~1 비율 자동 변환
- 범위 클램핑으로 비정상 좌표 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:53:53 +09:00

368 lines
13 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})
이미지에는 **10x10 그리드 오버레이**가 표시되어 있습니다. 가로축은 0~9, 세로축은 A~J로 표기됩니다.
이 그리드를 참고하여 각 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
},
"duration": 6
}
]
}
=== focused_element 좌표 (0~1000 정수 좌표계) ===
좌표는 이미지의 좌상단(0,0) ~ 우하단(1000,1000) 범위의 **정수**입니다.
그리드 오버레이를 활용하여 정확한 좌표를 지정하세요.
- x: 영역 좌측 가장자리의 가로 위치 (0=왼쪽 끝, 1000=오른쪽 끝)
- y: 영역 상단 가장자리의 세로 위치 (0=위쪽 끝, 1000=아래쪽 끝)
- w: 영역의 너비 (0~1000)
- h: 영역의 높이 (0~1000)
=== 그리드 참조 가이드 ===
- 가로축 0~9: 0=가로 0~100, 1=100~200, ..., 9=900~1000
- 세로축 A~J: A=세로 0~100, B=100~200, ..., J=900~1000
- 예) 그리드 "0A~1C" 영역 → x=0, y=0, w=200, h=300
- 예) 그리드 "2B~8H" 영역 → x=200, y=100, w=600, h=600
=== 좌표 정확도 예시 (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
=== 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;
// 0~1000 좌표 → 0~1 비율로 변환
$this->normalizeCoordinates($parsed);
return $parsed;
}
/**
* 스크린샷에 10x10 그리드 오버레이 생성
*
* AI가 정확한 좌표를 추정할 수 있도록 시각적 참조 격자를 그립니다.
*/
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;
}
$w = imagesx($source);
$h = imagesy($source);
// 반투명 색상
$gridColor = imagecolorallocatealpha($source, 255, 0, 0, 100); // 연한 빨강
$labelBg = imagecolorallocatealpha($source, 0, 0, 0, 80); // 반투명 검정
$labelText = imagecolorallocate($source, 255, 255, 0); // 노란색 텍스트
$cols = 10;
$rows = 10;
$cellW = $w / $cols;
$cellH = $h / $rows;
$rowLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
// 세로선 그리기
for ($i = 1; $i < $cols; $i++) {
$x = (int) ($i * $cellW);
imageline($source, $x, 0, $x, $h, $gridColor);
}
// 가로선 그리기
for ($j = 1; $j < $rows; $j++) {
$y = (int) ($j * $cellH);
imageline($source, 0, $y, $w, $y, $gridColor);
}
// 라벨: 가로축 숫자 (상단)
for ($i = 0; $i < $cols; $i++) {
$lx = (int) ($i * $cellW) + 4;
$ly = 2;
$label = (string) $i;
// 배경
imagefilledrectangle($source, $lx - 2, $ly, $lx + 14, $ly + 16, $labelBg);
imagestring($source, 3, $lx, $ly, $label, $labelText);
}
// 라벨: 세로축 문자 (좌측)
for ($j = 0; $j < $rows; $j++) {
$lx = 4;
$ly = (int) ($j * $cellH) + 4;
$label = $rowLabels[$j];
// 배경
imagefilledrectangle($source, $lx - 2, $ly - 1, $lx + 12, $ly + 15, $labelBg);
imagestring($source, 3, $lx, $ly, $label, $labelText);
}
// 임시 파일에 저장
$gridPath = $imagePath . '_grid.png';
imagepng($source, $gridPath, 6);
imagedestroy($source);
Log::debug("ScreenAnalysis: 그리드 오버레이 생성 완료", ['path' => $gridPath]);
return $gridPath;
} catch (\Exception $e) {
Log::warning("ScreenAnalysis: 그리드 오버레이 생성 실패", ['error' => $e->getMessage()]);
return null;
}
}
/**
* 0~1000 정수 좌표 → 0~1 비율로 변환
*
* Gemini가 반환한 0~1000 좌표를 하류 파이프라인이 사용하는 0~1 비율로 정규화
*/
private function normalizeCoordinates(array &$parsed): void
{
if (empty($parsed['steps'])) {
return;
}
foreach ($parsed['steps'] as &$step) {
if (empty($step['focused_element'])) {
continue;
}
$el = &$step['focused_element'];
// 0~1000 범위인지 확인 (어떤 좌표든 1보다 크면 0~1000 좌표계)
$isMilliCoords = ($el['x'] ?? 0) > 1 || ($el['y'] ?? 0) > 1
|| ($el['w'] ?? 0) > 1 || ($el['h'] ?? 0) > 1;
if ($isMilliCoords) {
$el['x'] = round(($el['x'] ?? 0) / 1000, 4);
$el['y'] = round(($el['y'] ?? 0) / 1000, 4);
$el['w'] = round(($el['w'] ?? 200) / 1000, 4);
$el['h'] = round(($el['h'] ?? 200) / 1000, 4);
}
// 범위 클램핑
$el['x'] = max(0, min(1, $el['x']));
$el['y'] = max(0, min(1, $el['y']));
$el['w'] = max(0.02, min(1 - $el['x'], $el['w']));
$el['h'] = max(0.02, min(1 - $el['y'], $el['h']));
}
unset($step, $el);
}
}