K.S
K.S

Reputation: 31

Create a GUI that can turn on / off camera images using Python 3 and tkinter

What I want to do

  1. Create the main window with two buttons "start button" and "stop button"

  2. When "start button" is pressed, images of the connected USB camera are displayed in the main window

  3. Press the "stop button" to erase the image of the USB camera displayed in [2] (leaving the main window)

Trouble

[1] and [2] was done. However, it is impossible to erase the image of the USB camera with [3]. Error message:

Exception in thread Thread-8:
Traceback (most recent call last):
File "C:\Users\usr\Anaconda3\lib\threading.py", line 916, in _bootstrap_inner self.run()
File "C:\Users\usr\Anaconda3\lib\threading.py", line 864, in run
self._target(*self._args, **self._kwargs)
TypeError: destroy() missing 1 required positional argument: 'panel' 

Code

import cv2
from PIL import Image
from PIL import ImageTk
import threading
import tkinter as tk


def button1_clicked():
    thread = threading.Thread(target=videoLoop, args=())
    thread.start()

def button2_clicked():
    thread = threading.Thread(target=destroy, args=())
    thread.start()

def destroy(panel):
    panel.destroy()

def videoLoop(mirror=False):
    No=0
    cap = cv2.VideoCapture(No)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 800)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 600)

    while True:
        ret, to_draw = cap.read()
        if mirror is True:
            to_draw = to_draw[:,::-1]

        image = cv2.cvtColor(to_draw, cv2.COLOR_BGR2RGB)
        image = Image.fromarray(image)
        image = ImageTk.PhotoImage(image)
        panel = tk.Label(image=image)
        panel.image = image
        panel.place(x=50, y=50)

    return panel


root = tk.Tk()
root.geometry("1920x1080+0+0")

button1 = tk.Button(root, text="start", bg="#fff", font=("",50), command=button1_clicked)
button1.place(x=1000, y=100, width=400, height=250)

button2 = tk.Button(root, text="stop", bg="#fff", font=("",50), command=button2_clicked)
button2.place(x=1000, y=360, width=400, height=250)

root.mainloop()

Upvotes: 3

Views: 4290

Answers (2)

patthoyts
patthoyts

Reputation: 33193

There are some fundamental issues with what you are doing here. The basic premise is incorrect in that you should not continually destroy and re-create the Label widget to display the image. Instead just update the image that is attached to the existing widget by calling its configure() method with the new image. This is a performance fix regardless of the threading problems you have here. In general create the widgets once and update them. This avoids a cascade of geometry change events that occur as you remove and add widgets from the UI tree.

The threading design is incorrect here. You should not make Tk calls from worker threads. Tk is tied to a single thread and only events should pass between threads. To show how this might be better constructed I've modified the code to use a queue.Queue() for passing the image frame from the opencv reader thread to the Tk thread. We can post a custom event to notify the UI there is a new frame ready (<<MessageGenerated>>).

A last gotcha is that you should hold a reference to an image that you add to a Tk label otherwise it can get garbage collected when you don't expect it. Hence we update the self.photo member with each new image.

import sys
import cv2
import threading
import tkinter as tk
import tkinter.ttk as ttk
from queue import Queue
from PIL import Image
from PIL import ImageTk


class App(tk.Frame):
    def __init__(self, parent, title):
        tk.Frame.__init__(self, parent)
        self.is_running = False
        self.thread = None
        self.queue = Queue()
        self.photo = ImageTk.PhotoImage(Image.new("RGB", (800, 600), "white"))
        parent.wm_withdraw()
        parent.wm_title(title)
        self.create_ui()
        self.grid(sticky=tk.NSEW)
        self.bind('<<MessageGenerated>>', self.on_next_frame)
        parent.wm_protocol("WM_DELETE_WINDOW", self.on_destroy)
        parent.grid_rowconfigure(0, weight = 1)
        parent.grid_columnconfigure(0, weight = 1)
        parent.wm_deiconify()

    def create_ui(self):
        self.button_frame = ttk.Frame(self)
        self.stop_button = ttk.Button(self.button_frame, text="Stop", command=self.stop)
        self.stop_button.pack(side=tk.RIGHT)
        self.start_button = ttk.Button(self.button_frame, text="Start", command=self.start)
        self.start_button.pack(side=tk.RIGHT)
        self.view = ttk.Label(self, image=self.photo)
        self.view.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
        self.button_frame.pack(side=tk.BOTTOM, fill=tk.X, expand=True)

    def on_destroy(self):
        self.stop()
        self.after(20)
        if self.thread is not None:
            self.thread.join(0.2)
        self.winfo_toplevel().destroy()

    def start(self):
        self.is_running = True
        self.thread = threading.Thread(target=self.videoLoop, args=())
        self.thread.daemon = True
        self.thread.start()

    def stop(self):
        self.is_running = False

    def videoLoop(self, mirror=False):
        No=0
        cap = cv2.VideoCapture(No)
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, 800)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 600)

        while self.is_running:
            ret, to_draw = cap.read()
            if mirror is True:
                to_draw = to_draw[:,::-1]
            image = cv2.cvtColor(to_draw, cv2.COLOR_BGR2RGB)
            self.queue.put(image)
            self.event_generate('<<MessageGenerated>>')

    def on_next_frame(self, eventargs):
        if not self.queue.empty():
            image = self.queue.get()
            image = Image.fromarray(image)
            self.photo = ImageTk.PhotoImage(image)
            self.view.configure(image=self.photo)


def main(args):
    root = tk.Tk()
    app = App(root, "OpenCV Image Viewer")
    root.mainloop()

if __name__ == '__main__':
    sys.exit(main(sys.argv))

I should add that at this point if you want to show a blank image after the stop button has been pressed you can set the viewing label widget to a new blank image as shown in the constructor.

Upvotes: 1

user10325516
user10325516

Reputation:

Error you got actually tells what exactly is wrong with the code. TypeError: destroy() missing 1 required positional argument: 'panel' literally says that you have to pass argument panel to function destroy(). You call the function implicitly with thread.start() in the button2_clicked()'s suite. To fix the problem you should modify thread object creation:

thread = threading.Thread(target=destroy, args=(panel,))

Also you should pass panel to button2_clicked() function. Here another problem arises since panel is returned by the function videoloop(). So panel is never returned because videoloop() contains infinite while loop. To solve this problem you need a way to pass data between operational pecies of your code. For example, you may do it like this (simple but not robust approach):

import cv2
from PIL import Image
from PIL import ImageTk
import threading
import tkinter as tk


def button1_clicked(videoloop_stop):
    threading.Thread(target=videoLoop, args=(videoloop_stop,)).start()


def button2_clicked(videoloop_stop):
    videoloop_stop[0] = True


def videoLoop(mirror=False):
    No = 0
    cap = cv2.VideoCapture(No)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 800)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 600)

    while True:
        ret, to_draw = cap.read()
        if mirror is True:
            to_draw = to_draw[:, ::-1]

        image = cv2.cvtColor(to_draw, cv2.COLOR_BGR2RGB)
        image = Image.fromarray(image)
        image = ImageTk.PhotoImage(image)
        panel = tk.Label(image=image)
        panel.image = image
        panel.place(x=50, y=50)

        # check switcher value
        if videoloop_stop[0]:
            # if switcher tells to stop then we switch it again and stop videoloop
            videoloop_stop[0] = False
            panel.destroy()
            break


# videoloop_stop is a simple switcher between ON and OFF modes
videoloop_stop = [False]

root = tk.Tk()
root.geometry("1920x1080+0+0")

button1 = tk.Button(
    root, text="start", bg="#fff", font=("", 50),
    command=lambda: button1_clicked(videoloop_stop))
button1.place(x=1000, y=100, width=400, height=250)

button2 = tk.Button(
    root, text="stop", bg="#fff", font=("", 50),
    command=lambda: button2_clicked(videoloop_stop))
button2.place(x=1000, y=360, width=400, height=250)

root.mainloop()

I cannot fully test the code. Although the skeleton of the code (starting thread and stopping it by switching) works.

I have no experience with tkinter. So I don't know what panel is and can't tell if this approach is viable. Probably it is better to create panel in the main thread of the code and pass the newly created panel to the function button1_clicked() and then to the function videoLoop(). This will allow to control panel directly from main thread but videoLoop() should be modified significantly (including checking/exception_handling in the case when panel is detroyed by the main thread).

Upvotes: 1

Related Questions