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:
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user