imshow を同じ位置・サイズで復活させる

python の cv2.imshow で使ったウィンドウを閉じた後、同じ位置・サイズで復活させたいだけなのになかなかできなかった。できた。

OS は Windows 11、 python は 3.10.12、opencv は 4.7.0。

# 現在の描画領域サイズの取得

そもそも、 cv2.resizeWindow で指定したサイズを知るのが難しい。
WINDOW_NORMAL オプションで開くと、ウィンドウ側でウィンドウサイズを変えられるので、
ウィンドウを閉じるときには、描画領域サイズがいくつになったかを知りたい。
しかし、単に cv2.getWindowImageRect しても、それは分からない。
それは cv2.getWindowImageRect で出てくる width, height の縦横比は、
その時描画している画像の縦横比になっているから。
ということで、次のような関数を作って調べる。
（なお、コードは適宜 Claude3 Opus を使っている）


```

import cv2
import numpy as np

def get_imshow_window_size(window_name):
    # サイズ 50 x 1 の画像を作成
    img_horizontal = np.zeros((1, 50, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_horizontal)
    cv2.waitKey(1)
    rect_horizontal = cv2.getWindowImageRect(window_name)
    width = rect_horizontal[2]

    # サイズ 1 x 50 の画像を作成
    img_vertical = np.zeros((50, 1, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_vertical)
    cv2.waitKey(1)
    rect_vertical = cv2.getWindowImageRect(window_name)
    height = rect_vertical[3]

    img_full = np.zeros((height, width, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_full)
    cv2.waitKey(1)
    rect = cv2.getWindowImageRect(window_name)

    return rect

def main():
    window_name = "Test Window"

    cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
    cv2.resizeWindow(window_name, 800, 600)
    cv2.moveWindow(window_name, 200, 100)
    cv2.waitKey(0)
    rect = get_imshow_window_size(window_name)
    print(f"Window position: ({rect[0]}, {rect[1]})\nWindow size: {rect[2]} x {rect[3]}")

    cv2.destroyAllWindows()

main()


```

縦横比が極端な画像を表示して、幅と高さをそれぞれ取得している。
なお、50 x 1 というのは、なんでもいいわけではなかった。
10000 x 1 とかにすると高さが 0 になってしまいうまくいかない。
100000 x 1000 みたいな大きい画像でもなにか問題があった（理由は忘れた）。

実行するとこういうウィンドウが出てくる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3747683/534aecee-e852-69ac-d613-45c140c32a92.png)

なにかキーを押すと位置とサイズが出力される。

```
Window position: (201, 165)
Window size: 800 x 546
```

もう一度キーを押すと終了する（PRTSC用）。

resizeWindow では 800 x 600 を指定しているが、戻ってくる height は違う。
上下にいろいろ付いているからのようだ。
ちなみにこれは qt 版らしい。
anaconda をインストールしてopencv を conda install したら入ってた。
qt版でないものも見かけたことがあるが詳しいことは知らない。qt版でなくてもこの height は食い違うのではないか。

なので、ここで得られたサイズでウィンドウを復活させても元には戻らない。

位置も元の(200,100)とは違うのでこの位置で復活させても元には戻らない。

# 復活のための位置とサイズを返すモジュール

ということで、同じ位置とサイズで復活するための位置とサイズを返すモジュールを作った。

```cv2_window_rect.py
'''ウィンドウクローズ時の位置とサイズを次回復元するパラメータを計算する
'''
import cv2
import numpy as np

class Cv2WindowRect:
    def __init__(self):
        self.window_name: str
        self.given_rect: list[int, int, int, int]
        self.fetched_rect: list[int, int, int, int]

    def create_window(
        self, window_name, x, y, width, height, hook_function = lambda x: x
    ):
        self.window_name = window_name
        self.given_rect = [x, y, width, height]
        cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
        cv2.moveWindow(window_name, x, y)
        cv2.resizeWindow(window_name, width, height)
        hook_function(window_name)
        cv2.waitKey(1)
        self.fetched_rect = list(self.get_window_image_rect())

    def close_window(self):
        cv2.destroyWindow(self.window_name)

    def get_window_rect(self):
        rect = list(self.get_window_image_rect())
        rect = [r + g - f for r, g, f in zip(rect, self.given_rect, self.fetched_rect)]
        return tuple(rect)

    def get_window_image_rect(self):
        # サイズ 1 x 50 の画像を作成
        img_vertical = np.zeros((50, 1, 3), dtype=np.uint8)
        cv2.imshow(self.window_name, img_vertical)
        cv2.waitKey(1)
        rect_vertical = cv2.getWindowImageRect(self.window_name)
        y = rect_vertical[1]
        height = rect_vertical[3]

        # サイズ 50 x 1 の画像を作成
        img_horizontal = np.zeros((1, 50, 3), dtype=np.uint8)
        cv2.imshow(self.window_name, img_horizontal)
        cv2.waitKey(1)
        rect_horizontal = cv2.getWindowImageRect(self.window_name)
        x = rect_horizontal[0]
        width = rect_horizontal[2]

        # resizeWindow すると表示画像の縦横比は維持されるが、
        # 以下のコードで描画領域いっぱいの画像を描くと以降枠内いっぱいに描画されるモードに
        # 変わることがあるようだ。
        # img_full = np.zeros((height, width, 3), dtype=np.uint8)
        # cv2.imshow(self.window_name, img_full)
        # cv2.waitKey(1)
        # rect = cv2.getWindowImageRect(self.window_name)
        rect = (x, y, width, height)
        return rect

def main():
    def create_trackbar(window_name):
        cv2.createTrackbar(
            "Var",
            window_name,
            0,
            1,
            lambda x: x,
        )
    window_name = "Test Window"
    rect = (200, 100, 800, 600)
    window = Cv2WindowRect()
    while True:
        window.create_window(window_name, *rect, create_trackbar)
        print(window.get_window_rect())
        key = cv2.waitKey()
        if key == ord("q"):
            break
        rect = window.get_window_rect()
        print(rect)
        window.close_window()

    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()
```
使用例は main() にあるが、
クラス Cv2WindowRect の create_window を使ってウィンドウを生成する。
もしトラックバーなどを加える場合にはフック関数を用意して引数に与える。
メソッド内ではウィンドウやトラックバーを生成した後に、
生成時のパラメータとそのときの得られる描画領域のパラメータを保存しておく。

ウィンドウを閉じたいときには get_window_rect を呼び出す。
この中では描画領域のパラメータを取得した後、
保存しておいたパラメータの差分だけ補正して返す。

次回はそのパラメータを与えれば同じ位置・サイズで復活する。


以上。

# 補足1: 描画領域の位置

最初の調査コードには続きがあって
描画領域のサイズを取得した後、そのサイズの画像を作って再描画し、
改めて getWindowImageRect をして描画領域の位置を取得している。
表示して PRTSC する。



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3747683/2376872e-f79b-dac0-edee-a9c91da05372.png)

`Window position: (201, 165)`という結果とぴったりあっているようだ（いまのところはな）。

で、ディスプレイの拡大/縮小が100%でない時どうなるか。

普段3枚ディスプレイを使っていて、メインのだけ 150% で使っている。

で、それで実行するとこうなる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3747683/ca23886d-40c3-5e9e-666d-0d1db4358c43.png)

(405, 317) ?????

(200, 100) を150%でどうしてそんな数字に…。笑かしてくれはる。

# 補足2: ディスプレイの dpi を調べる

なにやら dpi が関係あるのかと Claude3 Opus におんぶにだっこで、ディスプレイの dpi を調べるツールを作った。


``` window_utils.py
import ctypes
from ctypes import wintypes
import win32gui

# DPI認識を有効にする
ctypes.windll.shcore.SetProcessDpiAwareness(2)

GetDpiForMonitor = ctypes.windll.shcore.GetDpiForMonitor
GetDpiForMonitor.restype = wintypes.UINT
GetDpiForMonitor.argtypes = [wintypes.HMONITOR, wintypes.UINT, ctypes.POINTER(wintypes.UINT), ctypes.c_void_p]

# MONITORINFO構造体を定義
class MONITORINFO(ctypes.Structure):
    _fields_ = [
        ("cbSize", wintypes.DWORD),
        ("rcMonitor", wintypes.RECT),
        ("rcWork", wintypes.RECT),
        ("dwFlags", wintypes.DWORD),
    ]

def _get_window_dpi(hwnd):
    left, top, right, bottom = win32gui.GetWindowRect(hwnd)
    x = (left + right) // 2
    y = (top + bottom) // 2
    monitor = ctypes.windll.user32.MonitorFromPoint(wintypes.POINT(x, y), 0)
    dpi_x = wintypes.UINT()
    dpi_y = wintypes.UINT()
    GetDpiForMonitor(monitor, 0, ctypes.byref(dpi_x), ctypes.byref(dpi_y))
    
    # モニターのワークエリアの左上座標を取得
    monitor_info = MONITORINFO()
    monitor_info.cbSize = ctypes.sizeof(MONITORINFO)
    ctypes.windll.user32.GetMonitorInfoW(monitor, ctypes.byref(monitor_info))
    monitor_left, monitor_top = monitor_info.rcWork.left, monitor_info.rcWork.top

    return dpi_x.value, dpi_y.value, monitor_left, monitor_top

def get_window_dpi(window_name):
    hwnd = win32gui.FindWindow(None, window_name)
    dpi_x, dpi_y, _monitor_left, _monitor_top = _get_window_dpi(hwnd)
    return dpi_x, dpi_y

def get_window_rect(window_name):
    hwnd = win32gui.FindWindow(None, window_name)
    
    # クライアント領域の相対座標を取得
    left, top, right, bottom = win32gui.GetClientRect(hwnd)
    
    # 相対座標をスクリーン座標に変換
    left, top = win32gui.ClientToScreen(hwnd, (left, top))
    right, bottom = win32gui.ClientToScreen(hwnd, (right, bottom))
    print(f"rect: {window_name} {left} {top} {right} {bottom}")

    width = right - left
    height = bottom - top
    
    dpi_x, dpi_y, monitor_left, monitor_top = _get_window_dpi(hwnd)
    
    left -= monitor_left
    top -= monitor_top
    
    width = int(width * 96 / dpi_x)
    height = int(height * 96 / dpi_y)
    
    left += monitor_left
    top += monitor_top
    
    return left, top, width, height
```

中には、dpi を返す get_window_dpi 以外に、
仮想のウィンドウの位置やサイズを返そうとする get_window_rect のあるのだけど、
これはうまくいかなかった。
上で書いたように、位置指定のパラメータと実際に出てくる位置の関係が良く分からないからだ。

この get_window_dpi の呼び出しを加えたテストスクリプトの全体がこちら

```
import cv2
import numpy as np

from window_utils import get_window_dpi

# DPI認識を有効にする
# get_window_dpi で 0が返ってくるときには、次の2行を実行する。
# import ctypes
# ctypes.windll.shcore.SetProcessDpiAwareness(2)


def get_imshow_image_rect(window_name):
    # サイズ 50 x 1 の画像を作成
    img_horizontal = np.zeros((1, 50, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_horizontal)
    cv2.waitKey(1)
    rect_horizontal = cv2.getWindowImageRect(window_name)
    width = rect_horizontal[2]

    # サイズ 1 x 50 の画像を作成
    img_vertical = np.zeros((50, 1, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_vertical)
    cv2.waitKey(1)
    rect_vertical = cv2.getWindowImageRect(window_name)
    height = rect_vertical[3]

    img_full = np.zeros((height, width, 3), dtype=np.uint8)
    cv2.imshow(window_name, img_full)
    cv2.waitKey(1)
    rect = cv2.getWindowImageRect(window_name)

    return rect

def main():
    window_name = "Test Window"

    cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
    cv2.moveWindow(window_name, 200, 100)
    cv2.resizeWindow(window_name, 800, 600)
    # ウィンドウの位置・サイズを変えるならここで
    cv2.waitKey(0)
    rect = get_imshow_image_rect(window_name)
    print(f"Window position: ({rect[0]}, {rect[1]})\nWindow size: {rect[2]} x {rect[3]}")
    dpi_x, dpi_y = get_window_dpi(window_name)
    print(f"dpi_x, dpi_y: {dpi_x}, {dpi_y}")
    cv2.waitKey(0)
    cv2.destroyAllWindows()

main()

```

これを実行した結果はこう。

```
Window position: (201, 157)
Window size: 800 x 546
dpi_x, dpi_y: 144, 144
```

ちなみにテストプログラムの冒頭の部分、
```
# DPI認識を有効にする
# get_window_dpi で 0が返ってくるときには、次の2行を実行する。
# import ctypes
# ctypes.windll.shcore.SetProcessDpiAwareness(2)
```
は、このサンプルではコメントのままで動いた。
同じことが window_utils.py に書いてあるのでインポートすれば実行される。

が、
私が本来この機能を入れたいプログラムでは main() のあるファイルでの実行が必要だった。
条件は分からない。
tkも使っているせいかもしれない。
調べる気はない。

# 補足3：window_utils.py の get_window_rect について。

繰り返しになるのだけど、今回残った謎について。

もともと cv2 の getWindowImageRect がわけわかだったので、
cv2 とは無関係にウィンドウの状態を得るスクリプトを作った。
その返り値から復元のためのパラメータを作れるだろうと考えたから。
でもなんだか全然うまくいかないので、
このページを作って調べなおし始めた。
そしたら、150%拡大では、位置を(200,100)に指定したら、
(405, 317)に出るというちょっと意味のわからない現象が起こっていた。
ほんの少し調べたけどまるで分からない。

目的は位置とサイズの復元だけなので、
この問題には立ち入らなかった。