"""インタラクティブ 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()