feat: [video] 좌표 검증 루프(Coordinate Verification Loop) 추가
- Gemini 2-pass 자기 검증 메커니즘 구현 - runCoordinateVerification: 검증 오케스트레이터 - createVerificationImage: 색상별 스포트라이트 렌더링 - verifyCoordinates: Gemini에게 좌표 정확도 확인 요청 - applyVerifiedCoordinates: 보정 좌표 적용
This commit is contained in:
@@ -275,6 +275,9 @@ private function analyzeSingleScreen(string $imagePath, int $screenNumber, int $
|
||||
// screen_number 강제 보정
|
||||
$parsed['screen_number'] = $screenNumber;
|
||||
|
||||
// 좌표 검증 루프 (normalizeCoordinates 이전, 0~1000 좌표 상태에서 실행)
|
||||
$parsed = $this->runCoordinateVerification($imagePath, $parsed);
|
||||
|
||||
// 0~1000 좌표 → 0~1 비율로 변환
|
||||
$this->normalizeCoordinates($parsed);
|
||||
|
||||
@@ -458,6 +461,15 @@ private function createGridOverlay(string $imagePath): ?string
|
||||
|
||||
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 비율로 변환 + 타입별 최소 크기 보정 + 패딩
|
||||
*
|
||||
@@ -573,4 +585,357 @@ public function normalizeCoordinates(array &$parsed): void
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user