Newer
Older
SmTIAS-Evaluation / scripts / select_roi.py
"""インタラクティブ ROI 選択スクリプト.

OpenCV の selectROI を使って画像上で矩形を選択し,
座標を config/roi_config.json に保存する.

使い方:
    python scripts/select_roi.py \\
        --image data/minitias/whiteboard/MiniTIAS_20260408_140317.png \\
        --key minitias.whiteboard

操作:
    マウスでドラッグして矩形を描き,Space または Enter で確定,
    c キーでキャンセルする.
"""

import argparse
import json
import sys
from pathlib import Path

import cv2
import numpy

# プロジェクトルートを sys.path に追加(スクリプトを任意の場所から実行できるよう)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

from src.io.loader import load_image  # noqa: E402

# 表示用にリサイズする最大幅・高さ(画面に収まるよう)
DISPLAY_MAX_WIDTH = 1280
DISPLAY_MAX_HEIGHT = 720


def calc_display_scale(img_w: int, img_h: int) -> float:
    """表示用スケール係数を計算する.

    画像が DISPLAY_MAX_WIDTH x DISPLAY_MAX_HEIGHT に収まるよう
    縦横比を保ったスケール係数を返す.

    Args:
        img_w: 元画像の幅(ピクセル).
        img_h: 元画像の高さ(ピクセル).

    Returns:
        スケール係数(1.0 以下).
    """
    scale_w = DISPLAY_MAX_WIDTH / img_w if img_w > DISPLAY_MAX_WIDTH else 1.0
    scale_h = DISPLAY_MAX_HEIGHT / img_h if img_h > DISPLAY_MAX_HEIGHT else 1.0
    return min(scale_w, scale_h)


def load_or_create_config(config_path: Path) -> dict:
    """既存の設定ファイルを読み込む,なければ空の辞書を返す.

    Args:
        config_path: 設定ファイルのパス.

    Returns:
        設定辞書.
    """
    if config_path.exists():
        with config_path.open(encoding="utf-8") as f:
            return json.load(f)
    return {}


def set_nested(config: dict, key: str, value: object) -> None:
    """ドット区切りキーで辞書にネストして値をセットする(既存キーはマージ).

    Args:
        config: 対象の辞書(インプレース更新).
        key: ドット区切りキー(例: "minitias.whiteboard").
        value: セットする値.
    """
    keys = key.split(".")
    node = config
    for k in keys[:-1]:
        if k not in node or not isinstance(node[k], dict):
            node[k] = {}
        node = node[k]
    node[keys[-1]] = value


def save_config(config: dict, config_path: Path) -> None:
    """設定辞書を JSON ファイルに保存する.

    Args:
        config: 保存する設定辞書.
        config_path: 保存先ファイルパス.
    """
    config_path.parent.mkdir(parents=True, exist_ok=True)
    with config_path.open("w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=2)


def select_roi_interactive(
    image_rgb: numpy.ndarray,
) -> tuple[int, int, int, int] | None:
    """selectROI で ROI をインタラクティブに選択する.

    画像が大きい場合は表示用にリサイズし,選択後に元解像度へスケーリングする.

    Args:
        image_rgb: RGB 画像の NumPy 配列(H x W x 3, uint8).

    Returns:
        (x, y, width, height) のタプル(元解像度基準).
        キャンセルされた場合は None.
    """
    img_h, img_w = image_rgb.shape[:2]
    scale = calc_display_scale(img_w, img_h)

    # BGR に変換(OpenCV の表示用)
    image_bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)

    if scale < 1.0:
        display_w = int(img_w * scale)
        display_h = int(img_h * scale)
        display_img = cv2.resize(
            image_bgr, (display_w, display_h), interpolation=cv2.INTER_AREA
        )
        print(
            f"画像を表示用にリサイズしました: {img_w}x{img_h} → {display_w}x{display_h}"
            f"(スケール: {scale:.3f})"
        )
    else:
        display_img = image_bgr
        scale = 1.0

    print("ROI を選択してください(Space または Enter で確定,c でキャンセル)")
    roi = cv2.selectROI("Select ROI", display_img, fromCenter=False, showCrosshair=True)
    cv2.destroyAllWindows()

    rx, ry, rw, rh = roi

    # キャンセル(幅または高さが 0)
    if rw == 0 or rh == 0:
        return None

    # 表示サイズから元解像度へスケーリング
    orig_x = int(rx / scale)
    orig_y = int(ry / scale)
    orig_w = int(rw / scale)
    orig_h = int(rh / scale)

    return orig_x, orig_y, orig_w, orig_h


def main() -> None:
    """エントリーポイント."""
    parser = argparse.ArgumentParser(
        description="画像上で ROI をインタラクティブに選択し,設定ファイルに保存する."
    )
    parser.add_argument("--image", required=True, help="ROI を選択する画像のパス")
    parser.add_argument(
        "--key",
        required=True,
        help="ドット区切りの保存キー(例: minitias.whiteboard)",
    )
    parser.add_argument(
        "--config",
        default=str(PROJECT_ROOT / "config" / "roi_config.json"),
        help="設定ファイルのパス(デフォルト: config/roi_config.json)",
    )
    args = parser.parse_args()

    config_path = Path(args.config)

    # 画像読み込み
    print(f"画像を読み込みます: {args.image}")
    image = load_image(args.image)
    print(f"画像サイズ: {image.shape[1]} x {image.shape[0]} px")

    # ROI 選択
    result = select_roi_interactive(image)

    if result is None:
        print("ROI の選択がキャンセルされました.設定ファイルは更新しません.")
        return

    x, y, w, h = result
    roi_dict = {"x": x, "y": y, "width": w, "height": h}

    print(f"選択した ROI: x={x}, y={y}, width={w}, height={h}")

    # 設定ファイルへ保存(既存設定とマージ)
    config = load_or_create_config(config_path)
    set_nested(config, args.key, roi_dict)
    save_config(config, config_path)

    print(f"ROI を保存しました: {config_path}  (キー: {args.key})")


if __name__ == "__main__":
    main()