OpenCVでOBS用に手元俯瞰画面を切り出す（改良版）

# 前回作ったもの

https://qiita.com/ucosarv/items/aa0c576b782a398b4a2a

# 動機
- 手元を真上から撮ってOBSに取り込みたい（１回数分程度の録画）
- カメラの物理的な位置合わせ、切り抜きの微調整が面倒
- （今回）露出補正・フォーカス調整のUIが欲しい

# 作ったもの／改良したところ
- カメラ内の四点をクリックすると切り抜き、変形、回転を一発で変換して別ウインドウに出すもの
  - クリック順で回転の仕方を変えられる
  - 変換後に左上、右上、左下、右下になる位置を順にクリックする
  - （今回）露出補正・フォーカス調整用のスライダーを追加
  - （今回）起動／終了時に設定値の復元／保存を行う


# コード

- Windows 11、Python 3.13.2、opencv-python 4.11.0.86

```python
import sys
import time
import json
from pathlib import Path

import cv2
import numpy as np

UI_SCALE = 0.8
DST_CAM_WIDTH = 800
DST_CAM_HEIGHT = 800
DST_SIZE = (DST_CAM_WIDTH, DST_CAM_HEIGHT)

GUI_WINDOW_NAME = 'Camera'
DST_WINDOW_NAME = 'WARPED'

SRC_PTS = np.float32([
    [0, 0], 
    [DST_CAM_HEIGHT, 0], 
    [0, DST_CAM_WIDTH], 
    [DST_CAM_HEIGHT, DST_CAM_WIDTH]
])

SETTINGS_FILE = "settings.json"

def load_settings():
    if Path(SETTINGS_FILE).exists():
        with open(SETTINGS_FILE, "r") as file:
            return json.load(file)
    return {}

def save_settings(settings):
    with open(SETTINGS_FILE, "w") as file:
        json.dump(settings, file)
    print("スライダーの設定値を保存しました")

class PerspectiveTransformer:
    def __init__(self):
        self.clicked_points = []
        self.target_points = None
        self.transformation_matrix = np.eye(3)

    def clear_points(self):
        self.clicked_points.clear()
        self.transformation_matrix = np.eye(3)
        print("クリックした点をクリア")

    def add_point(self, x, y):
        if len(self.clicked_points) >= 4:
            return
        self.clicked_points.append((x, y))
        print(f'Mouse L x:{x}, y:{y}')
        if len(self.clicked_points) == 4:
            self.target_points = np.float32([(x / UI_SCALE, y / UI_SCALE) for x, y in self.clicked_points])
            self.transformation_matrix = cv2.getPerspectiveTransform(self.target_points, SRC_PTS)
            print(f'変換：{self.target_points}')
        else:
            self.target_points = None

    def get_transformation_matrix(self):
        return self.transformation_matrix

def print_camera_properties(cap):
    fps = cap.get(cv2.CAP_PROP_FPS)
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
    exposure = cap.get(cv2.CAP_PROP_EXPOSURE)
    focus = cap.get(cv2.CAP_PROP_FOCUS)
    print(f"camera: {width}x{height} {fps}fps 露出{exposure} フォーカス{focus}")

def setup_camera():
    cap = cv2.VideoCapture(0, cv2.CAP_MSMF)
    cap.set(cv2.CAP_PROP_FPS, 60)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 960)
    if not cap.isOpened():
        print('カメラを開けません')
        cap.release()
        cv2.destroyAllWindows()
        sys.exit(1)
    return cap

def setup_gui(transformer, cap):
    settings = load_settings()
    exposure = settings.get("Exposure", 0)
    autofocus = settings.get("AF", 1)
    focus = settings.get("Focus", 0)
    zoom = settings.get("Zoom", 0)

    cv2.namedWindow(GUI_WINDOW_NAME)
    cv2.namedWindow(DST_WINDOW_NAME)
    cv2.setMouseCallback(GUI_WINDOW_NAME, lambda event, x, y, flags, param: mouse_callback(event, x, y, transformer))
    cv2.createTrackbar('Exposure', GUI_WINDOW_NAME, exposure, 20, lambda val: set_exposure(cap, val,
        generate_mapper(input_min=0, input_max=20, output_min=-10, output_max=0)))
    cv2.createTrackbar('AF', GUI_WINDOW_NAME, autofocus, 1, lambda val: set_autofocus(cap, val))
    cv2.createTrackbar('Focus', GUI_WINDOW_NAME, focus, 1000, lambda val: set_focus(cap, val))
    cv2.createTrackbar('Zoom', GUI_WINDOW_NAME, zoom, 60, lambda val: set_zoom(cap, val))

def mouse_callback(event, x, y, transformer):
    if event == cv2.EVENT_RBUTTONDOWN:
        transformer.clear_points()
    elif event == cv2.EVENT_LBUTTONDOWN:
        transformer.add_point(x, y)

def generate_mapper(input_min, input_max, output_min, output_max):
    # 線形変換
    return lambda input_value : output_min + (input_value - input_min) * ((output_max - output_min) / (input_max - input_min))

def set_exposure(cap, val, mapper):
    real_value = mapper(val)
    cap.set(cv2.CAP_PROP_EXPOSURE, real_value)
    print(f"Set Exposure: {real_value}  (slider: {val})")

def set_autofocus(cap, val):
    cap.set(cv2.CAP_PROP_AUTOFOCUS, val)
    print(f"Set AF: {val}")

def set_focus(cap, val):
    cap.set(cv2.CAP_PROP_FOCUS, val)
    print(f"Set Focus: {val}")

def set_zoom(cap, val):
    cap.set(cv2.CAP_PROP_ZOOM, val)
    print(f"Set Zoom: {val}")

def main_loop(cap, transformer):
    while True:
        ret, frame = cap.read()
        if not ret:
            break

        frame_ui = cv2.resize(frame, dsize=None, fx=UI_SCALE, fy=UI_SCALE, interpolation=cv2.INTER_AREA)
        msg_ui1 = "Exit=Q Reset=Click(R) CropPoint=Click(L)"
        msg_ui2 = f"Points: {len(transformer.clicked_points)} {[f'({x},{y}) ' for x, y in transformer.clicked_points]}"
        cv2.putText(frame_ui, msg_ui1, (20, 20), cv2.FONT_HERSHEY_PLAIN, 1.0, (0, 255, 0))
        cv2.putText(frame_ui, msg_ui2, (20, 40), cv2.FONT_HERSHEY_PLAIN, 1.0, (0, 255, 0))
        for pt in transformer.clicked_points:
            cv2.drawMarker(frame_ui, pt, (0, 255, 0), cv2.MARKER_CROSS)

        cv2.imshow(GUI_WINDOW_NAME, frame_ui)

        dst = cv2.warpPerspective(frame, transformer.get_transformation_matrix(), DST_SIZE)
        cv2.imshow(DST_WINDOW_NAME, dst)

        if cv2.waitKey(1) & 0xff == ord('q'):
            break

def main():
    print("左上、右上、左下、右下の順に左クリック（Z字の順）")
    print("右クリックでリセット、Qで終了")
    transformer = PerspectiveTransformer()
    cap = setup_camera()
    print_camera_properties(cap)
    setup_gui(transformer, cap)
    time.sleep(0.1)
    try:
        main_loop(cap, transformer)
    finally:
        settings = {
            "Exposure": cv2.getTrackbarPos('Exposure', GUI_WINDOW_NAME),
            "AF": cv2.getTrackbarPos('AF', GUI_WINDOW_NAME),
            "Focus": cv2.getTrackbarPos('Focus', GUI_WINDOW_NAME),
            "Zoom": cv2.getTrackbarPos('Zoom', GUI_WINDOW_NAME)
        }
        save_settings(settings)
        cap.release()
        cv2.destroyAllWindows()

if __name__ == "__main__":
    main()

```


## 動作画面

### 入力・切り抜きポイント指定
  - 90度回転させたいので『左下、左上、右下、右上』の順にクリックする（正対した時に左上、右上、左下、右下になる位置）
  - 見えにくいが緑の十字がクリックした場所
  - 重いブームマイクスタンドで真俯瞰気味の場所にWebカメラを設置
  - （今回）スライダーを追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282700/3c8b1604-0ccc-4009-a028-4c9cd24dfb59.png)

### 出力（切り出し・変形・回転）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282700/1ddbfe31-ecbe-460d-9fdb-c549cfe2e29b.png)

### OBS取り込みイメージ（Windowの名前でキャプチャ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282700/5d4203c9-7c7a-4f10-9721-1843ff45015d.png)



# 自分が使っている俯瞰撮影用の機材についてのメモ

  - （ウェブカメラ）ELECOM UCAM-CX20ABBK
    - 60FPS(FullHD MJPG)、LED内蔵、近接8cm(AF)、5000円台
      - 手元の動きの記録として60FPS、露出の組み合わせを増やすための内蔵LED
    - そこそこ熱を持つ、時々パキッという音がするのがすこし不安
  - （ブームスタンド）K&M 25960B + 18872 延長ロッド（セール品） + 変換ネジ（3/8→1/4）
    - 重さ重視、円形ベース、保守部品が安価、信頼性、合計約1万円
      - 円形ベースのゴムとフローリングの長期的な相性が気になる（跡がつく？）
    - 支点のねじをきちんと締めないと少しずつお辞儀してくることがある
  - 円形ベースのブームスタンドにした理由
    - 床置き＆重さで机の振動を拾いにくい（撮影対象が机上で叩くものなので重要）
    - 円形ベースは設置面積が小さい、重りを載せやすい
      - 三脚タイプは足を引っかけそう
    - ブームスタンドはアームに比べて単純で扱いやすい印象がある