{作成}LLMのブラウザ操作で課題となっている座標指定について試してみた

# はじめに
最近LLMを使用したブラウザ操作が話題になっており気になってので似たようなことを再現できないか試しました。
PCのスクショを撮影し、pytesseractなどのOCRを利用して、テキスト認識と座標取得とLLMを組み合わせて、PCを操作することは可能ですが、検索ボックスやアイコンなどのテキストがない場合が問題でした。また、LLMが座標を認識し、クリックすることは難しいようでした。そこで、グリッドを使用することでLLMが座標を認識できるか試しました。

# コード
```python

def launch_browser_and_capture():
    WIDTH = 500  # ブラウザの幅
    HEIGHT = 500  # ブラウザの高さ
    
    chrome_options = Options()
    chrome_options.add_argument(f"--window-size={WIDTH},{HEIGHT}")
    chrome_options.add_argument('--log-level=3')
    chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
    chrome_options.add_argument("--disable-infobars")
    chrome_options.add_argument("--disable-extensions")
    
    driver = webdriver.Chrome(options=chrome_options)
    driver.set_window_size(WIDTH, HEIGHT)
    driver.get("https://qiita.com/")
    driver.set_window_position(0, 0)
    
    driver.execute_script("""
        document.body.style.transform = 'scale(0.7)';
        document.body.style.transformOrigin = '0 0';
        document.body.style.width = '143%';
    """)
    
    time.sleep(2)
    return driver, WIDTH, HEIGHT

def capture_and_annotate_screenshot():
    try:
        driver, WIDTH, HEIGHT = launch_browser_and_capture()
        
        # 上部10%を除外したスクリーンショット領域の計算
        top_offset = int(HEIGHT * 0.3)  # 上部10%の高さ
        actual_height = HEIGHT - top_offset  # 実際のスクリーンショット高さ
        
        window_location = driver.get_window_position()
        screenshot = pyautogui.screenshot(region=(
            window_location['x'],
            window_location['y'] + top_offset,  # 上部をスキップ
            WIDTH,
            actual_height  # 調整後の高さ
        ))
        
        draw = ImageDraw.Draw(screenshot)
        EDGE_PADDING = 10
        GRID_SPACING = 40
        
        try:
            font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
        except:
            try:
                font = ImageFont.truetype("arial.ttf", 12)
            except:
                font = ImageFont.load_default()
        
        # 調整後の高さに基づいてグリッドを描画
        counter = 1
        for y in range(EDGE_PADDING, actual_height - EDGE_PADDING, GRID_SPACING):
            for x in range(EDGE_PADDING, WIDTH - EDGE_PADDING, GRID_SPACING):
                draw.point((x, y), fill='red')
                text = str(counter)
                
                text_bbox = draw.textbbox((x+2, y+2), text, font=font)
                padded_bbox = (
                    text_bbox[0] - 2,
                    text_bbox[1] - 2,
                    text_bbox[2] + 2,
                    text_bbox[3] + 2
                )
                draw.rectangle(padded_bbox, fill='white')
                draw.text((x+2, y+2), text, fill='red', font=font)
                counter += 1
        
        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"screenshot_{timestamp}.png"
        save_path = os.path.join(os.getcwd(), filename)
        
        screenshot.save(save_path)
        print(f"スクリーンショットを保存しました: {save_path}")
        
        driver.quit()
        return filename
        
    except Exception as e:
        print(f"エラーが発生しました: {str(e)}")
        if 'driver' in locals():
            driver.quit()
        return None


if __name__ == "__main__":
    try:
        filename = capture_and_annotate_screenshot()
        if filename:
            path = str(filename)
            print(path)

            genai.configure(api_key=APIk)

            def upload_to_gemini(path, mime_type=None):
                file = genai.upload_file(path, mime_type=mime_type)
                print(f"Uploaded file '{file.display_name}' as: {file.uri}")
                return file

            generation_config = {
                "temperature": 1,
                "top_p": 0.95,
                "top_k": 40,
                "max_output_tokens": 8192,
                "response_mime_type": "text/plain",
            }

            model = genai.GenerativeModel(
                model_name="gemini-1.5-flash",
                generation_config=generation_config,
            )

            files = [
                upload_to_gemini(path, mime_type="image/png"),
            ]

            chat_session = model.start_chat(
                history=[
                    {
                        "role": "user",
                        "parts": [
                            files[0],
                            "以下のスクリーンショットでは、画像のxy軸に番号が連番で割り振られている。「検索を行いたい場合にクリックするべき番号を一つ出力してください。」",
                        ],
                    },
                ]
            )

            response = chat_session.send_message("画像を分析して、questionの位置に最も近い番号を教えてください。")
            print(response.text)
            
    except Exception as e:
        print(f"エラーが発生しました: {str(e)}")
        
```
すべてAIでコーディングしています。
historyは不要ですがそのまま実行してしまいました。
# 結果
### questionボタンについて聞いてみた
### 1000x1000の場合
![screenshot_20241225_022047.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/98ebb195-28ec-9fa3-a15a-3295a915ea27.png)

画像内の「question」のすぐ隣にある番号は **76** です。
### 800x800の場合
![screenshot_20241225_022128.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/5347f772-2cda-8e50-a6f3-de5eaca25c43.png)

質問をクリックするには、番号 **215** をクリックする必要があります。

GPT4oの場合
スクリーンショット内で「question」をクリックしたい場合に、クリックすべき番号を確認します。少しお待ちください。

分析が終了しました
python
常に詳細を表示する
画像を開きましたので、「question」がどの位置にあるか確認し、クリックすべき番号を特定します。 ​​
画像を確認した結果、「question」をクリックするための番号は 66 です。

＝＞おしい

### 600x600の場合
![screenshot_20241225_022158.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/079c0abe-c410-5530-0b55-4599842ff186.png)

質問をクリックするには、番号 **52** をクリックする必要があります。

### 500x500の場合
![screenshot_20241225_022008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/9ef59df4-a621-0b7e-3b4b-37c91f82bf57.png)

質問をクリックしたい場合にクリックする必要がある番号は **43** です。

### 600x400
![screenshot_20241225_022831.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/6a9bce52-ce73-9a77-e0f1-005cbcf31367.png)
画像から、"Question" という単語に最も近い番号は **51** です。

### 600x500
![screenshot_20241225_023001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/f160b3bf-9014-1fde-c475-19a03b326740.png)

画像内の「Question」という単語に最も近い番号は **50** です。

## 次にブラウザの文字を70%に変更し、検索方法を質問してみた。
![screenshot_20241225_025010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/1bbc31cf-f27c-cfcb-5956-4a934a7c1514.png)
8です。
スクリーンショットに「Login」ボタンがあり、検索を行うにはログインが必要なため、ログインボタンの番号である8をクリックする必要があります。

番号の点数を増やすと正解しなくなったので70個が限界みたいです。

無理やり倍率をそろえると
![screenshot_20241225_025638.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3965944/24119c66-1c74-e016-95f7-520f99cd2fa8.png)
画像内の「検索」に相当する機能は、画面上部に表示されている虫眼鏡アイコンです。このアイコンは番号「7」のセルにあります。

したがって、答えは 7 です。

# まとめ
合計1000pxを越えなければ認識してくれるようです。ＧＰＴ４oでも結果は似た感じでした。現状だと、番号がかぶっている個所しか操作できないため、「番号と番号の間」といった要素を追加する必要がありそうです。


# 補足
以下のように、番号の位置を出力するように指示したところ精度が落ち、左右上下等の説明もないことから、不可能な可能性が高いです。
```prompt
ブラウザスクリーンショットでは、画像のxy軸に番号が連番で割り振られている。詳細に分析してください。
指示：実行希望内容を確認し条件に従い、画像を見て最適なアクションを一つ提供してください。イラストやマークで提供されている場合もあります。
実行希望内容：サイトで検索を行いたい
条件：
- 押すべき場所に存在する番号と補足情報で答える。
- 指示にあるものが、番号と重なっていない場合、番号より左右上下、番号mと番号nのの間に存在するなどの補足を追加
出力：
番号：
補足情報
```
番号：8
補足情報：Loginボタン


検索マークを探すようにした場合
```
検索に相当するボタンやマークを画像に割り振られた番号で答えてください。また、番号から左右上下ずれている場合はその趣旨をも出力すること
```
The search button or mark is number 8.