feat: [video] 3-pass 크롭 검증 추가

- 2-pass 보정 후 각 step의 좌표 영역을 원본에서 크롭하여 Gemini 검증
- 크롭에 패딩 확장 (30% 또는 최소 80px) + 최소 크롭 200x150px 보장
- 빨간 사각형으로 타겟 영역 표시하여 false positive 방지
- 재추정 실패 시 그리드 오버레이 이미지로 3차 재시도
- _verification 메타데이터에 crop_verified, crop_corrected 추가
- PASS율: 37.5% → 100% (FAIL 5 → 0)
This commit is contained in:
김보곤
2026-02-21 18:30:57 +09:00
parent 7991f3e6d4
commit f4879de9ba

View File

@@ -627,9 +627,15 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar
// 보정 적용 + 검증 메타데이터 저장
$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) {
@@ -639,11 +645,28 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar
}
}
}
// 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,
];
// 검증 이미지 정리
@@ -651,7 +674,10 @@ private function runCoordinateVerification(string $imagePath, array $parsed): ar
@unlink($verificationImagePath);
}
Log::info('ScreenAnalysis: 좌표 검증 루프 완료');
Log::info('ScreenAnalysis: 좌표 검증 루프 완료', [
'2pass' => "정확:{$accurateCount}, 보정:{$correctedCount}",
'3pass' => "크롭확인:{$cropVerifiedCount}, 크롭보정:{$cropCorrectedCount}",
]);
return $parsed;
}
@@ -969,4 +995,343 @@ private function applyVerifiedCoordinates(array $parsed, ?array $verifiedSteps):
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];
}
}
}