```
# clipcrop.py
import asyncio
import os
from logging import getLogger
from typing import Optional

import cv2
import numpy as np
import pytesseract
import torch
from PIL import Image
from ultralytics import YOLO

from configs.path_config import EXAMPLE_IMAGE, YOLO_MODELS_DIR

logger_local = getLogger(__name__)


class ClipCropAsync:
    def __init__(self, image_path: str, yolo_model_path: str = YOLO_MODELS_DIR / "yolov5su.pt"):
        self.image_path = str(image_path)
        self.yolo_model_path = yolo_model_path
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model: Optional[YOLO] = None

    async def _load_model(self) -> None:
        # モデルファイルが存在しない場合は、ultralytics が自動でダウンロード
        if not os.path.exists(self.yolo_model_path):
            logger_local.info(f"YOLO model not found at {self.yolo_model_path}, downloading...")
        self.model = YOLO(self.yolo_model_path)
        # 必要なら GPU に転送
        try:
            self.model.to(self.device)
        except Exception:
            pass

    async def auto_captcha(self, threshold: int = 3, tx: str = "default") -> Image.Image:
        if self.model is None:
            await self._load_model()

        # 画像読み込み
        image = await asyncio.to_thread(cv2.imread, self.image_path)
        if image is None:
            raise FileNotFoundError(f"Image not found or cannot be read: {self.image_path}")

        # グレースケール変換
        gray = await asyncio.to_thread(cv2.cvtColor, image, cv2.COLOR_BGR2GRAY)

        # OCR 前処理
        _, thresh_bin = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
        kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 1))
        close = cv2.erode(thresh_bin, kernel, iterations=2)
        invert = cv2.bitwise_not(close)

        inf = await asyncio.to_thread(pytesseract.image_to_string, invert)
        lines = [x for x in inf.split('\n') if len(x) > 2]
        txt = lines[1] if len(lines) > 1 else tx
        logger_local.info(f"Extracted text: {txt}")

        # 画像シャープ化
        blur = cv2.medianBlur(gray, 3)
        sharpen_kernel = np.array([[-1, -1, -1],
                                   [-1, 9, -1],
                                   [-1, -1, -1]])
        sharpen = cv2.filter2D(blur, -1, sharpen_kernel)

        # 二値化・モルフォロジー
        _, thresh_inv = cv2.threshold(sharpen, 160, 255, cv2.THRESH_BINARY_INV)
        close2 = cv2.morphologyEx(thresh_inv, cv2.MORPH_CLOSE, kernel, iterations=2)

        # 輪郭抽出・描画
        cnts, _ = cv2.findContours(close2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        for c in cnts:
            x, y, w, h = cv2.boundingRect(c)
            if w == h and w > 5:
                cv2.rectangle(image, (x, y), (x + w, y + h), (36, 255, 12), 2)

        # BGR→RGB 変換して PIL Image 化
        rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        return Image.fromarray(rgb)

    async def extract_image(self, text: str, num: int = 3) -> list[dict]:
        if self.model is None:
            await self._load_model()

        # 画像読み込み
        image = await asyncio.to_thread(cv2.imread, self.image_path)
        if image is None:
            raise FileNotFoundError(f"Image not found or cannot be read: {self.image_path}")

        # モデル推論（スレッドプールで実行）
        results = await asyncio.to_thread(self.model, image)
        boxes = results[0].boxes.xyxy.cpu().numpy()

        # 面積でソートして上位 num 件を抽出
        sorted_boxes = sorted(
            boxes,
            key=lambda b: (b[2] - b[0]) * (b[3] - b[1]),
            reverse=True
        )[:num]

        crops: list[dict] = []
        for xmin, ymin, xmax, ymax in sorted_boxes:
            crop_img = image[int(ymin):int(ymax), int(xmin):int(xmax)]
            # BGR→RGB
            pil_img = Image.fromarray(crop_img[:, :, ::-1])
            crops.append({
                "image": pil_img,
                "bbox": (int(xmin), int(ymin), int(xmax), int(ymax))
            })
        return crops

    # crop_to_prompt は extract_image のエイリアスとして残します
    async def crop_to_prompt(self, prompt: str, num: int = 1) -> list[dict]:
        return await self.extract_image(prompt, num=num)


if __name__ == "__main__":
    from logging.config import dictConfig
    from configs.logging_config import LOGGING_CONFIG

    dictConfig(LOGGING_CONFIG)

    async def main():
        # EXAMPLE_IMAGE のテスト
        cc = ClipCropAsync(EXAMPLE_IMAGE)

        # キャプチャ解読テスト
        captcha_result = await cc.auto_captcha()
        captcha_result.save("captcha_result.png")
        logger_local.info("Saved captcha result to captcha_result.png")

        # プロンプトに基づくクロップテスト
        crops = await cc.crop_to_prompt("object", num=3)
        for i, crop in enumerate(crops):
            filename = f"crop_{i}.png"
            crop["image"].save(filename)
            logger_local.info(f"Crop {i}: bbox={crop['bbox']}, saved to {filename}")

    asyncio.run(main())
```

```
C:\Users\faker\Desktop\lora_test\.venv\Scripts\python.exe C:\Users\faker\Desktop\lora_test\src\utils\clipcrop.py 
Traceback (most recent call last):
  File "C:\Users\faker\Desktop\lora_test\.venv\Lib\site-packages\pytesseract\pytesseract.py", line 275, in run_tesseract
    proc = subprocess.Popen(cmd_args, **subprocess_args())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\subprocess.py", line 1026, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\subprocess.py", line 1538, in _execute_child
    hp, ht, pid, tid = _winapi.CreateProcess(executable, args,
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [WinError 2] 指定されたファイルが見つかりません。

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\faker\Desktop\lora_test\src\utils\clipcrop.py", line 140, in <module>
    asyncio.run(main())
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\asyncio\runners.py", line 194, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\asyncio\base_events.py", line 687, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "C:\Users\faker\Desktop\lora_test\src\utils\clipcrop.py", line 129, in main
    captcha_result = await cc.auto_captcha()
                     ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\Desktop\lora_test\src\utils\clipcrop.py", line 55, in auto_captcha
    inf = await asyncio.to_thread(pytesseract.image_to_string, invert)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\asyncio\threads.py", line 25, in to_thread
    return await loop.run_in_executor(None, func_call)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\AppData\Local\Programs\Python\Python312\Lib\concurrent\futures\thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\Desktop\lora_test\.venv\Lib\site-packages\pytesseract\pytesseract.py", line 486, in image_to_string
    return {
           ^
  File "C:\Users\faker\Desktop\lora_test\.venv\Lib\site-packages\pytesseract\pytesseract.py", line 489, in <lambda>
    Output.STRING: lambda: run_and_get_output(*args),
                           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\faker\Desktop\lora_test\.venv\Lib\site-packages\pytesseract\pytesseract.py", line 352, in run_and_get_output
    run_tesseract(**kwargs)
  File "C:\Users\faker\Desktop\lora_test\.venv\Lib\site-packages\pytesseract\pytesseract.py", line 280, in run_tesseract
    raise TesseractNotFoundError()
pytesseract.pytesseract.TesseractNotFoundError: tesseract is not installed or it's not in your PATH. See README file for more information.

Process finished with exit code 1
```