OpenCVでOBS用に手元俯瞰画面を切り出す

# 動機

- 手元を真上から撮ってOBSに取り込みたい（１回数分程度の録画）
- カメラの物理的な位置合わせ、切り抜きの微調整が面倒

# 作ったもの

- カメラ内の四点をクリックすると切り抜き、変形、回転を一発で変換して別ウインドウに出すもの
  - クリック順で回転の仕方を変えられる
    - 変換後に左上、右上、左下、右下になる位置を順にクリックする


# コード

- Windows 11、Python 3.13.2、opencv-python 4.11.0.86

```python
import cv2
import numpy as np


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


print("左上、右上、左下、右下の順に左クリック（Z字の順）")
print("右クリックでリセット、Qで終了")

SRC_PTS = np.float32([ 
    [0,0],              # 左上
    [DST_CAM_HEIGHT,0],     # 右上
    [0,DST_CAM_WIDTH],      # 左下
    [DST_CAM_HEIGHT,DST_CAM_WIDTH] ]) # 右下
TGT_PTS = None
CLICKED_PTS = []


def print_camera_prop(cap):
    fps = cap.get(cv2.CAP_PROP_FPS)
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
    print(f"camera: {width}x{height} ({fps}fps)")

def pts_clicked_clear():
    CLICKED_PTS.clear()
    print("クリックした点をクリア")

def mouseCB(event,x,y,flags,param):
    global TGT_PTS
    if event == cv2.EVENT_RBUTTONDOWN:
        pts_clicked_clear()
    if event != cv2.EVENT_LBUTTONDOWN:
        return
    if len(CLICKED_PTS) >=4:
        return
    item = (x,y)
    CLICKED_PTS.append(item)
    print(f'Mouse L x:{x}, y:{y}')
    if len(CLICKED_PTS) >=4:
        TGT_PTS = np.float32(CLICKED_PTS)
        print(f'変換：{TGT_PTS}')
    else:
        TGT_PTS = None


def main():
    cap = cv2.VideoCapture(0, cv2.CAP_MSMF) # cv2.CAP_MSMF CAP_DSHOW CAP_WINRT 
    cap.set(cv2.CAP_PROP_FPS, 60)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH,  1280) # (4:3)1280x960 (16:9)1280x720 1920x1080 
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT,  960)
    # cap.set(cv2.CAP_PROP_SETTINGS, 1) # CAP_DSHOW
    print_camera_prop(cap)

    ret = True
    while(ret):
        ret, frame = cap.read()
        frame_ui = frame.copy()
        msg_ui1 = f"Exit=Q Reset=Click(R) CropPoint=Click(L)"
        msg_ui2 = f"Points: {len(CLICKED_PTS)} {[f'({x},{y}) ' for x,y in CLICKED_PTS]}"
        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 CLICKED_PTS:
            cv2.drawMarker(frame_ui, pt, (0, 255, 0), cv2.MARKER_CROSS)

        cv2.imshow("Camera", frame_ui)
        cv2.setMouseCallback('Camera', mouseCB)

        if TGT_PTS is not None:
            M = cv2.getPerspectiveTransform(TGT_PTS, SRC_PTS)
            dst = cv2.warpPerspective(frame, M, DST_SIZE)
        else:
            dst = cv2.resize(frame, DST_SIZE, fx=0,fy=0,interpolation=cv2.INTER_LINEAR)

        cv2.imshow("WARPED", dst)

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

    cap.release()
    cv2.destroyAllWindows() 


if __name__ == "__main__":
    main()

```
## 動作の様子

### コマンドプロンプトの出力

> 左上、右上、左下、右下の順に左クリック（Z字の順）
右クリックでリセット、Qで終了
camera: 1280.0x960.0 (60.0fps)
Mouse L x:321, y:849
Mouse L x:304, y:100
Mouse L x:1056, y:826
Mouse L x:1039, y:100
変換：[[ 321.  849.]
 [ 304.  100.]
 [1056.  826.]
 [1039.  100.]]

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

### 出力（切り出し・変形・回転）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282700/b4cf192e-17c7-4983-9ec4-1bb79fd82123.png)

### OBS取り込みイメージ（Windowの名前でキャプチャ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282700/ffc61a41-21c1-4b22-abb6-9d7e1f64727f.png)



# 誤算だったこと

- モニタ上や卓上三脚から角度をつけてカメラをセットすると大幅に画質が落ちる
  - 撮像範囲に対し切り出した範囲が狭い
    - 小さく切り出したものを大きく変形するのでどうしても汚くなる
  - 被写界深度の関係で端がボケる
- 真俯瞰撮影できる位置にカメラを設置するスタンドが結局必要
  - 大きく写す＆被写体の面とセンサーの面を平行にする＝真上