画像処理利用して環境に強いスクレイピング

つづき
pythonでできるだけ精度の良いキャプチャーソフトを作ってみる（２）
https://qiita.com/akaiteto/items/56bfd8d764d42b9ff508
pythonでできるだけ精度の良いキャプチャーソフトを作ってみる（１）
https://qiita.com/akaiteto/items/b2119260d732bb189c87

#はじめに
画面・音声キャプチャに飽きたので別のパーツをつくります。
下記工程が現在想定している処理の大まかな過程です。

　　　１．指定した時刻でコマンド実行
　->　２．事前にセットしたURLでブラウザ起動
　　　３．ブラウザのスクリーンショット・音声をキャプチャ
　　　４．ブラウザのスクリーンショット・音声をマージして動画出力

今回は２の部分を作ってみます。
webサイトがどんな構造でも柔軟に対応したいので
HTMLの解析等はせずにopencvの画像検出でブラウザの操作を行うことを目標にします。

・・・と、後付けの理由をつけましたが、
画面をスクショするのでせっかくなので画像処理したいだけです。


#仕様

```
１．URL先に遷移
２．再生ボタンを押す
３．天気図の領域を検出
４．検出した領域を録画
```

どこかしらの天気図を動画で配信するサイトを想定します。
全体の処理は上記のような想定です。今回はおそらく１～３までになります。


#実行環境
OS : windows10
ver: python3.7
web: chrome


#１．URL先に遷移
##Selenium

ブラウザをコマンドから操作できるライブラリ、Seleniumを使ってみます。
https://qiita.com/hanzawak/items/2ab4d2a333d6be6ac760
https://rabbitfoot.xyz/selenium-chrome-profile/

余談ですが、、私はChrome + pycharmを使っているので、
ウェブドライバのインストールの際には仮想環境のpythonにインストールします。

```
#仮想環境へのインストール
cd D:～中略～\venv\Scripts
bat activate.bat
pip install chromedriver-binary==（使用してるChromeのバージョン）

```

##URLを開く
天気図のサイトを表示します。
のちの工程のために、開いたchromeのキャプチャ画像も保存しておきます。
下記ソースを使う場合は、（あなたのユーザー名）の箇所を埋めてください。
プロファイルのパスの詳細は　https://rabbitfoot.xyz/selenium-chrome-profile/

```python

from selenium import webdriver
import chromedriver_binary  #大事
from selenium.webdriver.chrome.options import Options
import win32gui
import win32ui
import win32con
import numpy as np
import cv2

#画面のフルサイズ取得
hnd = win32gui.GetDesktopWindow()
x0, y0, x1, y1 = win32gui.GetWindowRect(hnd)
fullscreen_width = x1 - x0
fullscreen_height = y1 - y0

#ブラウザのサイズ
browse_width=300
browse_height=fullscreen_height

#ブラウザ起動
options = Options()
options.add_argument(r"--user-data-dir=C:\Users\(あなたのユーザー名)\AppData\Local\Google\Chrome\User Data")
driver = webdriver.Chrome(chrome_options=options)
driver.get("url")
driver.set_window_size(browse_width,browse_height)
driver.set_window_position(0,0)

#ブラウザのスクリーンショット
windc = win32gui.GetWindowDC(hnd)
srcdc = win32ui.CreateDCFromHandle(windc)
memdc = srcdc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(srcdc, browse_width, browse_height)
memdc.SelectObject(bmp)
memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
bmp.SaveBitmapFile(memdc, 'PointDetect.bmp')


# driver.close()
```

ログインが必要なサイトを開く場合について。
自分のパスワードを生のままコード書くのは生理的に嫌なのでやりません。

**ログインの必要なサイトの場合は、事前にchromeからログインしておきましょう**

```
user data directory is already in use, please specify a unique value for --user-data-dir argument, or don't use --user-data-dir
```

ちなみに、chromeを起動した状態で実行すると上記エラーが発生します。
対策はできますが、今回はchromeを複数起動する必要性はないのでやりません。



#２．再生ボタンを押す
ボタンを画像処理で検知し、クリックするようにしてみましょう。

処理の概要としては、
再生ボタンの座標を取得してpyautoguiのライブラリで座標をクリックすることです。
問題は再生ボタンの座標の取得です。検討案としては以下の２つです。

```
１．図形を検出して座標を取得する
２．再生ボタンの画像と同じ箇所を探す
```


##１．図形を検出して座標を取得する

```
１．図形を検出して座標を取得する
```

opencvの関数で簡単にやってみます。


```DetectTriangle.py
import cv2
import numpy as np

def DetectTriangle(img, inputNm, outputNm):
    image_obj = cv2.imread(inputNm)
    img = cv2.adaptiveThreshold(img, 255, 1, 1, 11, 2)
    cv2.imwrite("PointDetect_threshold" + outputNm, img)

    contours, hierarchy = cv2.findContours(img,cv2.RETR_CCOMP,cv2.CHAIN_APPROX_NONE)

    for cnt in contours:
        approx = cv2.approxPolyDP(cnt, 0.1 * cv2.arcLength(cnt, True), True)
        # approx = cv2.approxPolyDP(cnt, 0.07 * cv2.arcLength(cnt, True), True)  #パラメータ：精度に影響
        # approx = cv2.approxPolyDP(cnt, .03 * cv2.arcLength(cnt, True), True)   #パラメータ：精度に影響
        # approx = cv2.approxPolyDP(cnt, .009 * cv2.arcLength(cnt, True), True)  #パラメータ：精度に影響

        if len(approx) == 3:
            print("triangle")
            cv2.drawContours(image_obj, [cnt], 0, (0, 0, 255), -1)
        elif len(approx) == 4:
            print("square")
            cv2.drawContours(image_obj, [cnt], 0, (0, 255, 0), -1)
        elif len(approx) == 8:
            print("circle")
            area = cv2.contourArea(cnt)
            (cx, cy), radius = cv2.minEnclosingCircle(cnt)
            circleArea = radius * radius * np.pi
            if circleArea == area:
                cv2.drawContours(image_obj, [cnt], 0, (255, 0, 0), -1)

    cv2.imwrite(outputNm, image_obj)

inputNm = 'PointDetect2.bmp'
srcImage = cv2.imread(inputNm)

gray = cv2.cvtColor(srcImage, cv2.COLOR_BGR2GRAY)
cv2.imwrite("PointDetect_gray.png", gray)

kernel = np.ones((4, 4), np.uint8)
dilation = cv2.dilate(gray, kernel, iterations=1)
cv2.imwrite("PointDetect_dilation.png", dilation)

blur = cv2.GaussianBlur(dilation, (5, 5), 0)
cv2.imwrite("PointDetect_blur.png", dilation)

DetectTriangle(blur,inputNm,"result_blur.png")         #ぼかし
DetectTriangle(gray,inputNm,"result_gray.png")         #グレースケール
DetectTriangle(dilation,inputNm,"result_dilation.png") #膨張（再生ボタン小さいから）

```

やりたい処理としてはざっくり以下の通り。

```
１．前処理
２．しきい値処理
３．輪郭抽出
```

googleの画像検索のスクショでやってみます。
![PointDetect2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/38dc2dc7-5f94-c4b9-8530-ba4cb7b70946.jpeg)


前処理としてグレースケール化↓（ソースでは他にもいろいろやってます）
![PointDetect_gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/310ef44a-e125-fedc-c580-671b0bfa42ef.png)


しきい値処理↓
![PointDetect_thresholdresult_dilation.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/57002fd1-6077-b3b7-4a61-a660d5c4c18a.png)


輪郭抽出↓
![result_gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/ccdfbdde-fd9b-eb2a-4b2b-37914ec02218.png)


赤いところが三角形、緑は四角形、青は円形となります。
ここで検出した図形の輪郭線の座標から、
三角形の形である再生ボタンの位置を特定しようという算段です。


・・・
・・
・

ということで、お天気サイトにやってみました。
結果は・・・・ダメでした。
ぼかし、拡張、パラメータ調整などいくつか試しましたがダメでした。

再生ボタンも検出できていますが、誤検知が多すぎます。
そもそも、三角形の図形がたくさんあるサイトでは不向きですし、
それぞれのサイトに対して適したパラメータの調整が必要になりそうなところも難点です。

このお天気サイトに特化した検知は可能でしょうが、
様々なサイトで行える形式にしたいので１の案は却下です。


##２．再生ボタンの画像と同じ箇所を探す

```
２．再生ボタンの画像と同じ箇所を探す
```

テンプレートマッチングで検出してみます。
ある小さな画像と同じものが別の画像の中に含まれているかを検出する処理です。
テンプレートマッチングを行うには正解となる再生ボタンの画像を教えてやる必要があります。


![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/11e16797-d671-8982-7cdf-834c647ed629.png)
ということで、上記フローのもと、
「再生ボタンの画像取得（手動）」というフェーズを追加します。
全て自動で行いたかったですが仕方ない。

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/58f92b99-7e01-1810-671a-7cecf0c150fa.png)

参考：https://shizenkarasuzon.hatenablog.com/entry/2020/03/23/005440

イメージ的には、
上記のような小窓が立ち上がり、ユーザーはクリックしたいボタンを四角の選択窓(水色)で選択する、
・・・というイメージでテンプレートマッチング用の画像を保存します。
手動操作は初回だけの想定です。

###テンプレートマッチング

ということでテンプレートマッチングやってみます。

http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_template_matching/py_template_matching.html

openCVのサンプルソース通りに実行したらあっさりうまくいけました。
2の方針でいってみましょう

#３．天気図の領域を検出
opencvのabsdiffでごちゃごちゃやります（雑）
再生ボタンを押す前後の画像に対して動体検知を実行し、
変化している箇所の領域を検出します。

#まとめ
ここまでのソースをまとめます。

前提として、クリックしたい箇所の画像、
ここでいう再生ボタンの画像が既にあるとします。
↓下のような切り取った画像
<img width="38" alt="PointDetect_patch.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/2e0a6f3c-9e0d-a35f-37f8-fb47b7bb706f.jpeg">


実行する前に、下記ライブラリをインストールします。

```
pip install PyAutoGUI
```

私の環境では下記エラーが起きてインストールに失敗しました。

```
SyntaxError: (unicode error) 'utf-8' codec can't decode byte 0x93 in position 0: invalid start byte (sitecustomize.py, line 7)
```

https://qiita.com/hisakichi95/items/41002333efa8f6371d40
を参考にして、PyMsgBoxの古いバージョンをインストールしました。

ということで、以下ソース。整理してないのはご愛敬

```Detect.py
from selenium import webdriver
import chromedriver_binary  #大事
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
import win32gui
import win32ui
import win32con
import numpy as np
import cv2

def DetectMotion(ImgNm1,ImgNm2):
    img1 = cv2.imread(ImgNm1, 0)
    img2 = cv2.imread(ImgNm2, 0)

    img1 = img1.copy().astype("float")
    cv2.accumulateWeighted(img2, img1, 0.6)

    cv2.accumulateWeighted(img2, img1, 0.6)
    frameDelta = cv2.absdiff(img2, cv2.convertScaleAbs(img1))

    thresh = cv2.threshold(frameDelta, 3, 255, cv2.THRESH_BINARY)[1]
    contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    img2 = cv2.imread(TEMP_AFTER_SCREENSHOT)
    top_left_X = 9999999
    top_left_Y = 9999999
    bum_right_X = 0
    bum_right_Y = 0

    for i in range(0, len(contours)):
        if len(contours[i]) > 0:

            # remove small objects
            if cv2.contourArea(contours[i]) < 500:
                continue

            rect = contours[i]
            x, y, w, h = cv2.boundingRect(rect)
            pos_top = (x, y)
            pos_bum = (x + w, y + h)

            print(x, y, x + w, y + h)
            top_left_X = pos_top[0] if top_left_X > pos_top[0] else top_left_X
            top_left_Y = pos_top[1] if top_left_Y > pos_top[1] else top_left_Y
            bum_right_X = pos_bum[0] if bum_right_X < pos_bum[0] else bum_right_X
            bum_right_Y = pos_bum[1] if bum_right_Y < pos_bum[1] else bum_right_Y

    return (top_left_X, top_left_Y), (bum_right_X, bum_right_Y)

def DiffImage(img1,img2):
    im_diff = img1.astype(int) - img2.astype(int)
    im_diff_abs = np.abs(im_diff)
    return im_diff_abs.max()

def DetectBtn(bmp,memdc,CapFIleNm,PatchNm,scrollY):
    memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
    bmp.SaveBitmapFile(memdc, CapFIleNm)
    # ボタンの座標取得
    img = cv2.imread(CapFIleNm, 0)

    img2 = img.copy()
    template = cv2.imread(PatchNm, 0)
    w, h = template.shape[::-1]

    meth = 'cv2.TM_CCOEFF_NORMED'
    img = img2.copy()
    method = eval(meth)

    res = cv2.matchTemplate(img, template, method)
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)

    if method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]:
        top_left = min_loc
    else:
        top_left = max_loc
    bottom_right = (top_left[0] + w, top_left[1] + h)

    cv2.rectangle(img, top_left, bottom_right, 255, 2)
    range = ((bottom_right[0] - top_left[0]) / 2, (bottom_right[1] - top_left[1]) / 2)
    btn_center = (int(top_left[0] + range[0]), int(top_left[1] + range[1]))
    print("ボタン左上座標", top_left)
    print("ボタン右上座標", bottom_right)
    print("ボタン中央座標", btn_center)

    #検出した箇所を切り取る
    img1 = img[top_left[1]: bottom_right[1], top_left[0]: bottom_right[0]]

    if DiffImage(template,img1) > 180:
        # ボタン画像と違いすぎる　-> False
        cv2.imwrite("Detect_Fail" + str(scrollY) + ".jpg", img1)
        print("btn not exist")
        return False,(0,0)
    else:
        # 成功　-> True
        cv2.imwrite("Detect_Success" + str(scrollY) + ".jpg", img1)
        print("btn exist")
        return True,btn_center

TEMP_BEFORE_SCREENSHOT = 'PointDetect_before.bmp'
TEMP_AFTER_SCREENSHOT = 'PointDetect_after.bmp'
TEMP_PATCH = 'PointDetect_patch.jpg'

#画面のフルサイズ取得
hnd = win32gui.GetDesktopWindow()
x0, y0, x1, y1 = win32gui.GetWindowRect(hnd)
fullscreen_width = x1 - x0
fullscreen_height = y1 - y0

#ブラウザのサイズ
# browse_width=fullscreen_width
# browse_height=fullscreen_height
browse_width=1920
browse_height=1080

#ブラウザ起動
options = Options()
# options.add_argument(r"--user-data-dir=C:\Users\あなたのユーザー名\AppData\Local\Google\Chrome\User Data")
driver = webdriver.Chrome(chrome_options=options)
driver.get("https://")
driver.set_window_size(browse_width,browse_height)
driver.set_window_position(0,0)

#　ブラウザの挙動待ち
import time
time.sleep(3)

#スクショ準備
windc = win32gui.GetWindowDC(hnd)
srcdc = win32ui.CreateDCFromHandle(windc)
memdc = srcdc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(srcdc, browse_width, browse_height)
memdc.SelectObject(bmp)

# ボタンを発見するまでスクロール
Detectflg=False
isScrolButton=False
scrollY = 0
while Detectflg==False:
    scrollY += int(fullscreen_height/4)
    #スクロール前後のキャプチャ取得
    memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
    bmp.SaveBitmapFile(memdc, TEMP_BEFORE_SCREENSHOT)
    driver.execute_script("window.scrollTo(0, "+ str(scrollY) +")")
    time.sleep(5)
    memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
    bmp.SaveBitmapFile(memdc, TEMP_AFTER_SCREENSHOT)

    img1 = cv2.imread(TEMP_BEFORE_SCREENSHOT, 0)
    img2 = cv2.imread(TEMP_AFTER_SCREENSHOT, 0)

    diff = DiffImage(img1,img2)
    if diff < 100:
        # スクロールしても画面に変化ない　->　スクロール最下段まできたので失敗
        print("scrollbutton")
        flg=True,btn_pos
        isScrolButton=True

    flg,btn_pos = DetectBtn(bmp,memdc,TEMP_AFTER_SCREENSHOT,TEMP_PATCH,scrollY)
    Detectflg = flg

#ボタンの座標をクリック
if isScrolButton:
    print("ボタンが見つかりませんでした")
    exit()

#再生
import pyautogui
pyautogui.click(btn_pos[0],btn_pos[1])

#変化前保存
memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
bmp.SaveBitmapFile(memdc, TEMP_BEFORE_SCREENSHOT)

#画面変化するまで待機
time.sleep(5)

#変化後保存
memdc.BitBlt((0, 0), (browse_width, browse_height), srcdc, (0, 0), win32con.SRCCOPY)
bmp.SaveBitmapFile(memdc, TEMP_AFTER_SCREENSHOT)

#動体検知
top_left,bottom_right = DetectMotion(TEMP_BEFORE_SCREENSHOT,TEMP_AFTER_SCREENSHOT)
img = cv2.imread(TEMP_AFTER_SCREENSHOT)
img1 = img[top_left[1]: bottom_right[1], top_left[0]: bottom_right[0]]
cv2.imwrite("MotionArea.jpg", img1)

# driver.close()
```

ボタンを探すスクロール機能と、
ボタンを検出した時の誤検出用に画像の差分をとる処理などさらっと追加しました。

次回は前回作った録画機能と合体させたいですね。
それでは