OpenCVでシンクロビューアを作り間違い探しに活用する

20220530　変更　広範囲を見ているときでもドラッグが素早くできるよう改良

# はじめに
同じサイズの複数の画像を**同期させて**見たいことがよくある。

 - 間違い探し
 - トレパクの検証
 - 撮影時期の異なる[定点撮影](https://qiita.com/mo256man/items/9b451ff246e019437c98)写真を見比べる
 - AEやGANなど生成モデルのAIで元画像と生成された画像を比較
 
など。
単に並べるだけでは駄目だ。大きい画像の注目すべきエリアのみを並べて比較したい、もちろんそのエリアは動かしたいし拡大縮小もしたい。
そんなビューアがあるといいなと以前から思っていたのだが、個人的に本格的なニーズが発生したので満を持して作ってみた。

# 完成品
みんな大好き[サイゼリヤの間違い探し](https://www.saizeriya.co.jp/entertainment/)を今回のアプリでやってみた。
![saizeriya.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/d6f8cac9-8020-df4e-2a6b-37607b61c613.gif)




# 我がGUIアプリの歴史
これまで何度かOpenCVのマウスイベントでGUIアプリを作ってきている。
こうやって見ると、少しずつレベルアップしているのを実感する。

### OpenCVでマウスイベントを取得する　～GUIな集中線ツールを作る～

https://qiita.com/mo256man/items/3a9e67cb5b9fe32839f0

この時点ではコールバック関数に変数を送る方法がよくわからず、グローバル変数頼みとなっていた。
[チュートリアル](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_gui/py_mouse_handling/py_mouse_handling.html)でもグローバル変数を使っているからな。

### 投影変換で画像を変形する　～モニタ画面をハックする～

https://qiita.com/mo256man/items/27d0a44071aee5f06933

ここではコールバック関数のparamに辞書を渡すというテクニックを使っている。

### そして今回
今回はプログラム全体をクラス化することで、ついにグローバル変数を全廃することに成功した。
それがクラスの使い方として正しいかどうかは別問題だけど。

# 全体コード
<details><summary>折りたたみ</summary>

```Python:
import numpy as np
import cv2
import random

class Viewer():
    def __init__(self, dic):
        self.images, self.titles = [], []
        for title, filename in dic.items():
            self.images.append(cv2.imread(filename))
            self.titles.append(title)

        self.winname = "synchro viewer"                     # ウィンドウ名
        self.cnt = len(dic)                                 # 画像の数
        self.imgH, self.imgW = self.images[0].shape[:2]     # 各画像のサイズ（全部同じ前提）
        self.WINH, self.WINW = 300, 300                     # 画像表示窓のサイズ（定数）
        self.roiH, self.roiW = self.WINH, self.WINW         # ROIのサイズの初期値
        self.screen = np.zeros((self.WINH, self.cnt*self.WINW, 3), np.uint8)    # アプリ全体
        self.x0, self.y0 = (self.imgW-self.WINW)//2, (self.imgH-self.WINH)//2   # 左上座標初期値
        self.ix, self.iy = 0, 0                             # テンポラリな変数
        self.BAI = 1.2                                      # 倍率の底（定数）
        self.k = 0                                          # 倍率の指数
        self.font, self.fontScale, self.thickness = cv2.FONT_HERSHEY_DUPLEX, 1, 2
        self.is_dragging = False                            # ドラッグ初期値
        cv2.namedWindow(self.winname)
        cv2.setMouseCallback(self.winname, self.mouse_event)

    def show(self):
        x0, y0 = self.x0, self.y0                           # よく使うので短い変数名とする
        x1, y1 = max(self.x0, 0), max(self.y0,0)            # 画像のあるエリアの左上と右下
        x2, y2 = min(self.x0+self.roiW, self.imgW), min(self.y0+self.roiH, self.imgH)
        color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))

        for i, (title, image) in enumerate(zip(self.titles, self.images)):
            roi = np.zeros((self.roiH, self.roiW, 3), np.uint8)     # ROIの初期値　真っ黒

            # ROI内に一部でも画像があればそれを貼る　完全に画像外ならば何もしない
            if (-self.roiW< x0 < self.imgW) and (-self.roiH < y0 < self.imgH):
                roi[y1-y0:y2-y0, x1-x0:x2-x0] = image[y1:y2, x1:x2]

            # ROIを画像表示サイズに縮小拡大する
            roi = cv2.resize(roi, (self.WINW, self.WINH))

            # 枠およびタイトルを記載する
            cv2.rectangle(roi, (0,0), (self.WINW-1, self.WINH-1), color, 2)
            (w, h), _ = cv2.getTextSize(title, self.font, self.fontScale, self.thickness)
            x, y = self.WINW//2-w//2, h+5
            cv2.putText(roi, title, (x, y), self.font, self.fontScale, color, self.thickness)
            self.screen[0:self.WINH, i*self.WINW:(i+1)*self.WINW] = roi

        cv2.imshow(self.winname, self.screen)

    def mouse_event(self, event, x, y, flags, param):

        if event == cv2.EVENT_LBUTTONDOWN and not self.is_dragging:
            self.is_dragging = True
            self.ix, self.iy = x, y                         # ドラッグ開始時のマウス座標を覚えておく
        
        elif event == cv2.EVENT_MOUSEMOVE and self.is_dragging:
            self.x0 -= int((x - self.ix)/self.BAI**self.k)
            self.y0 -= int((y - self.iy)/self.BAI**self.k)
            self.ix, self.iy = x, y

        elif event == cv2.EVENT_LBUTTONUP:
            self.is_dragging = False
        
        elif event == cv2.EVENT_MOUSEWHEEL:
            self.k = self.k + 1 if flags>0 else self.k - 1  # ホイールの上下で+1もしくは-1する
            self.k = max(self.k, -20)                       # 過大・過小はエラーになるので調整
            self.k = min(self.k, 20)
            self.scaling()

        elif event == cv2.EVENT_RBUTTONDOWN:
            self.k = 0                                      # 等倍に戻す
            self.scaling()

    def scaling(self):
        bai = self.BAI**self.k                              # 実際の倍率

        # 現ROIの中心座標（拡大縮小後も同じになるようにする）
        xc, yc = self.x0 + self.roiH//2, self.y0 + self.roiW//2

        # 新ROIのサイズ　拡大するとROIは小さくなる　縮小するとROIは大きくなる
        self.roiW, self.roiH = int(self.WINW/bai), int(self.WINH/bai)

        # 新ROIの左上座標　さっき計算したxc, ycを使う
        self.x0, self.y0 = xc - self.roiW//2, yc - self.roiH//2


if __name__ == "__main__":
    pics = {"left": "saizeriya_left.png",
            "right": "saizeriya_right.png"}

    viewer = Viewer(pics)
    while True:
        viewer.show()
        key = cv2.waitKey(1) & 0xFF
        if key == 27:                                       # esc key
            break
    cv2.destroyAllWindows()
```
</details>

# 技術トピック

### 拡大縮小
倍率を直接操作すると、縮小しすぎて0になってしまい拡大で元に戻せなくなることがあった。そこで倍率そのものではなくべき乗の指数を変更することにした。
特に指数関数を使う必要があるわけではないこの場所で指数を使う発想力！ これに気づいたときは嬉しかったものだ。

### ドラッグ
拡大縮小を実装するときにドラッグの仕様を変えたので無駄な部分があるかもしれない。

### テキスト描画位置
`cv2.getTextSize()`で描画サイズを取得することでいわゆる中央揃えを実装している。そのかわりと言うのも何だが、フォント情報は`cv2.getTextSize()`と`cv2.putText()`の2箇所で使っているので直書きではなく変数に定義する必要がある。
日本語に対応させたい場合は以下の記事を参照ください（宣伝）。

https://qiita.com/mo256man/items/b6e17b5a66d1ea13b5e3

# 終わりに
面白いプログラムを作るのはもちろん面白いが、役に立つプログラムを作るのは面白くかつ意義のあることだ。