勉強しないとな~blog

ちゃんと勉強せねば…な電気設計エンジニアです。

OpenCVやってみる - 49. スレッドで並列処理

前回の続き。

今回は、春のパン祭りシール点数集計の処理を組み合わせていく前に、アプリ内処理の並列化をします。

スレッドで並列処理

前回はGUI上でのキー入力でカメラ画像更新をしましたが、実際は操作なしで更新するようにしたいところ。

ということで、処理をいくつかに分けて、それぞれ並列処理で別々に動かすことで、画像の自動更新を実現します。

GUIのクラス化

並列化する前に、少しコードを整理しておきたい。

今まで見てきたTkinter参考では、tkinterのクラスを継承してアプリケーションを作っていることがありました。
そのほうがまとまりがよさそう。

tkinter.Tkを継承するパターン、tkinter.Frameを継承するバターンとありましたが、 OpenCV+tkinterをやっていたサイトではtkinter.Frameを継承していたので、そっちにしておきます。

【Python/tkinter】OpenCVのカメラ動画をCanvasに表示する | イメージングソリューション

その他参考にしたページ。

【Python】Tkinterのテンプレート - Qiita

TkinterでGUI 後書きと補足 | Python学習講座

import tkinter as tk
import cv2
import numpy as np
from PIL import Image, ImageOps, ImageTk
class harupan_gui(tk.Frame):
    TEXT_CONNECT = 'Connect   '
    TEXT_DISCONNECT = 'Disconnect'

    def __init__(self, master=None):
        super().__init__(master)
        self.pack()

        self.cap = cv2.VideoCapture()

        self.master.title('Harupan App')
        self.master.geometry('500x300')

        #### Entries for connection information ####
        self.t_ip = tk.StringVar(value='192.168.1.7')
        self.t_port = tk.StringVar(value='4747')
        self.entry_ip = tk.Entry(self, textvariable=self.t_ip)
        self.entry_port = tk.Entry(self, textvariable=self.t_port)
        self.entry_ip.grid(row=0, column=0)
        self.entry_port.grid(row=1, column=0)

        #### Connect button ####
        self.t_connect = tk.StringVar(value=self.TEXT_CONNECT)
        self.button_connect = tk.Button(self, textvariable=self.t_connect)
        self.button_connect.bind('<Button-1>', self.event_connect)
        self.button_connect.grid(row=0, column=1)

        #### Image canvas ####
        self.canvas_image = tk.Canvas(self, bg='white')
        self.canvas_image.grid(row=2,column=0)
        self.update()
        self.w, self.h = self.canvas_image.winfo_width(), self.canvas_image.winfo_height()
        print(f'Canvas size: {self.w},{self.h}')
        self.disp_img = None

        master.bind('<KeyPress>', self.key_handler)

    def event_connect(self, e):
        if(self.t_connect.get() == self.TEXT_CONNECT):
            ret = self.cap.open(f'http://{self.t_ip.get()}:{self.t_port.get()}/video')
            if ret:
                print('Camera opened')
                self.t_connect.set(self.TEXT_DISCONNECT)
            else:
                print('Camera open failed')
        else:
            self.cap.release()
            print('Camera closed')
            self.t_connect.set(self.TEXT_CONNECT)

    #### Capture and display camera image on canvas ####
    def update_image(self):
        ret, img = self.cap.read()
        if not ret:
            print('Can\'t capture image')
            return
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        img = Image.fromarray(img)
        img = ImageOps.pad(img, (self.w,self.h))
        self.disp_img = ImageTk.PhotoImage(image=img)
        self.canvas_image.create_image(self.w/2,self.h/2,image=self.disp_img)
        # canvas_image.create_image(0,0,image=img, anchor=tkinter.NW)
        print('updated')

    def key_handler(self, e):
        print(e.keysym)
        if e.keysym == 'space':
            self.update_image()
root = tk.Tk()
app = harupan_gui(master=root)
app.mainloop()
Canvas size: 382,269
Camera opened
space
updated
Win_L
Shift_L
Camera closed

キー入力の最後のほうのWin_L, Shift_Lは、スクリーンショットをとったときのキー入力です。

なんとなくコードのまとまりが見えてきたかな。。。

GUIと画像取得部分の分離 (スレッド化)

上で作ったharupan_guiの処理を分割したいと思います。
分割のしかたとしては、

  • GUIの受け付け(接続情報入力、接続・切断ボタン)
  • カメラ接続・切断の実施
  • 定期的な画像取得
  • GUIの画像更新

※後述しますが、2つ目と3つ目はまとめないとうまくいかなかったので、実装は1つにまとめました。

という感じになります。
このように分割して並列処理することで、特にGUI上でのきっかけがなくても勝手に画像が更新されるようになります。

実装は以下のようにしました。

  • マルチスレッドを使用、2つのスレッドを立てる (※GUIのメイン処理Tk.mainloopはメインのスレッドで動かすので、計3つの並列処理になる)
    • cv2.VideoCaptureからの画像取得(および接続・切断)
    • 取得した画像のtkinter.Canvasへの反映
  • スレッド間はQueue.queueでデータをやり取りする
    • 画像データ (画像取得スレッド → 画像反映スレッド)
    • 接続・切断要求 (GUIメイン処理(ボタンクリックイベント) → 画像取得スレッド)
  • どちらのスレッドも、アプリ起動中はずっと回り続ける処理になるが、アプリ終了時に終了できるよう、フラグを見て回るようにしておく
    • フラグ不要のやり方があればよかったが…

上で作ったharupan_guiクラスを継承して、関数をオーバーライドする形で実装します。

参考サイト。

threading --- スレッドベースの並列処理 — Python 3.7.16 ドキュメント

queue --- 同期キュークラス — Python 3.11.2 ドキュメント

Python入門 (3) -マルチスレッド|npaka|note

Pythonのスレッド間の協調作業にはQueueを使う - Qiita

[解決!Python]クラスを継承するには:解決!Python - @IT

その他はまったポイントは以下の通り。

  • Tk.mainloopは別スレッド化できない
  • デバッグ時、立ち上げたスレッドを落としきれないことがあった
    • その場合、Jupyterカーネルの再起動と上のセルの再実行が必要、なかなか面倒だった
    • threading.active_count()threading.enumerate()で状況確認可能
    • 下記コードでは、立ち上げたスレッドを終了できたことが確認できるよう、表示を追加
  • VideoCaptureの接続・切断と画像読み出しは同じスレッドでやらないとうまくいかない
    • 最初は別スレッドにしていましたが、タイミングによっては切断後に読み出しが行われることがあり
    • その場合、例外が発生してスレッドが落ちてしまう
    • 1つのスレッドにして、接続・切断要求がある場合は画像読み出しを行わないように実装した
import queue
import threading
class harupan_gui_pp(harupan_gui):
    def __init__(self, master=None, img_queue_size=3):
        super().__init__(master)
        self.q_connect = queue.Queue(maxsize=0)
        self.q_img = queue.Queue(maxsize=img_queue_size)
        self.run_flag = True
        self.thread1 = threading.Thread(target=self.update_image, name='thread1')
        self.thread2 = threading.Thread(target=self.cap_process, name='thread2')
        self.thread1.start()
        self.thread2.start()
        print(f'Number of threads: {threading.active_count()}')
        for th in threading.enumerate():
            print('  ', th)

    def event_connect(self, e):
        if(self.t_connect.get() == self.TEXT_CONNECT):
            url = f'http://{self.t_ip.get()}:{self.t_port.get()}/video'
            self.q_connect.put(url)
            self.t_connect.set(self.TEXT_DISCONNECT)
        else:
            self.q_connect.put(None)
            self.t_connect.set(self.TEXT_CONNECT)

    def update_image(self):
        while self.run_flag:
            if not self.q_img.empty():
                img = self.q_img.get()
                img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
                img = Image.fromarray(img)
                img = ImageOps.pad(img, (self.w,self.h))
                self.disp_img = ImageTk.PhotoImage(image=img)
                self.canvas_image.create_image(self.w/2,self.h/2,image=self.disp_img)
                # print('updated')

    def cap_process(self):
        while self.run_flag:
            if not self.q_connect.empty():
                print(self.q_connect.qsize())
                url = self.q_connect.get()
                if url == None:
                    self.cap.release()
                    print('Camera closed')
                else:
                    if self.cap.open(url):
                        print('Camera opened')
                    else:
                        print('Camera open failed')
            elif self.cap.isOpened():
                ret, img = self.cap.read()
                if ret and not self.q_img.full():
                    self.q_img.put(img)

    def run_app(self):
        self.master.mainloop()
        self.run_flag = False
        self.thread1.join(timeout=10)
        self.thread2.join(timeout=10)
        
        print(f'Number of threads: {threading.active_count()}')
        for th in threading.enumerate():
            print('  ', th)

    def key_handler(self, e):
        print(e.keysym)

root = tk.Tk()
app = harupan_gui_pp(root)
app.run_app()
Canvas size: 382,269
Number of threads: 7
   <_MainThread(MainThread, started 13660)>
   <Thread(Thread-2, started daemon 8116)>
   <Heartbeat(Thread-3, started daemon 27768)>
   <HistorySavingThread(IPythonHistorySavingThread, started 19020)>
   <ParentPollerWindows(Thread-1, started daemon 25108)>
   <Thread(thread1, started 23272)>
   <Thread(thread2, started 12632)>
F7
1
Camera opened
1
Camera closed
Number of threads: 5
   <_MainThread(MainThread, started 13660)>
   <Thread(Thread-2, started daemon 8116)>
   <Heartbeat(Thread-3, started daemon 27768)>
   <HistorySavingThread(IPythonHistorySavingThread, started 19020)>
   <ParentPollerWindows(Thread-1, started daemon 25108)>

カメラ接続・切断と、画像表示ができました。
画像表示は、自動で更新し続けました。ただ、ちらつきが出てしまっています。 生成したスレッドもきちんと終了できています。

ここまで

今回はここで区切りにしておきます。

今回は少し寄り道になってしまいましたが、次こそ春のパン祭りシール点数集計との組み合わせをしたいと思います。